Skip to content

feat(auth): sign in from guard's identity (COR-1082) - #4

Open
malpou wants to merge 1 commit into
masterfrom
malthe/cor-1082-umami-fork-login-through-guard-identity
Open

malpou wants to merge 1 commit into
masterfrom
malthe/cor-1082-umami-fork-login-through-guard-identity

Conversation

@malpou

@malpou malpou commented Oct 30, 2026

Copy link
Copy Markdown
Owner

What and why

COR-1082 (project "Central auth in guard"). Guard becomes the only OIDC relying party for insights.metsa.app. It owns the sessions and their revocation, and it sends a short-lived ES256 identity JWT in x-bastion-identity with each request it lets through. This PR makes the fork use that JWT for sign-in, behind one env flag, GUARD_IDENTITY=1.

  • The server-side auth check, not a token. The dashboard SPA calls /api/* with a Bearer token it keeps in localStorage. Behind guard there is no such token. Instead, checkAuth (src/lib/auth.ts, which parseRequest uses) verifies the identity header with @metsaapp/edgeauth on every API request and turns it into the Umami user.
  • User mapping is unchanged, so existing users keep their data. The Zitadel sub is looked up through user.oidc_issuer / user.oidc_subject, under GUARD_USER_ISSUER, which must equal the old OIDC_ISSUER. A user is created on first sight and is suffixed if the username is taken. Opt-in auto-link happens only on a verified email. An existing user's username is never changed, because the identity has no preferred_username.
  • Role. It is re-derived from the identity's role keys on every request (GUARD_ROLE_ADMIN=umami-admin → admin). An identity with no mapped role gets 401 (fail closed).
  • Errors.
    • An invalid identity token is logged and answered with 401.
    • If guard's keys can't be reached, the API answers 503.
    • The server refuses to start when GUARD_IDENTITY is set but PLATFORM_KIT_EDGEAUTH_AUDIENCES, GUARD_USER_ISSUER or a role mapping is missing.
  • Login and logout. /login offers one button that goes to /.well-known/bastion/login?return_to=/. It never redirects by itself, so a signed-in user without a role can't get stuck in a redirect loop. /logout goes to /.well-known/bastion/logout.

Removed

  • The fork's OIDC client: src/app/api/auth/oidc/{authorize,callback,logout} (routes and OpenAPI contracts), /sso-complete and its handoff cookie, the discovery, PKCE and login-state code in src/lib/oidc.ts, and the openid-client dependency.
  • The OIDC_* env vars and the umami-oidc client id and secret. The OIDC strings in en-US messages and the regenerated api-client types are also gone.
  • Local login behind guard. /api/auth/login answers 403 and Umami session tokens are not accepted. Guard gates every dashboard path, so a password login could only be reached by someone who already holds umami-admin. It would add a second credential and no availability, because when guard or Zitadel is down the host is unreachable anyway.
    • Break-glass is still possible: unset GUARD_IDENTITY and port-forward to the pod. Without the flag the fork falls back to upstream's password login, so a local account with a password (the seeded admin) still works.

Kept

  • Share tokens still work in share context.
  • APP_SECRET and 2FA are kept.
  • NATS KV stays: the lookup cache still lives in UMAMI_STATE. Only the auth.* keys stop being written behind guard.
  • src/lib/oidc.ts is renamed to src/lib/guard-identity.ts and keeps only the mapping code.

ENG-1068 / COR-1068 (Umami on NATS KV): it has already landed (Done). Had this PR landed first, ENG-1068 would have shrunk to nothing for sessions, since revocation now lives in guard. The cache still uses the bucket, so that work stays useful.

Docs: the README's OIDC section is replaced by "Sign-in through guard".

Tests

  • pnpm test (vitest), src/lib/guard-identity.test.ts and src/lib/auth.guard.test.ts use ES256 identity JWTs minted locally (node:crypto key and a JWKS served on 127.0.0.1, helper in src/test/edgeauth.ts) and verified by the real verifier. They cover:
    • an admin is signed in and mapped to its user
    • no role → 401
    • an identity for another host, an expired token or a forged token is refused
    • a valid Umami session token is ignored behind guard
    • a share token works only in share context
    • unreachable keys → 503
    • the mapping and linking rules (create, re-sync role and display name, suffix, verified-email-only auto-link, races)
    • password login → 403 (login/route.test.ts)
    • the startup config check (instrumentation.test.ts)
  • Full suite: 927/932 pass. The 5 failures also fail on master here:
    • src/app/mcp/route.test.ts needs the built @umami/mcp and api-client packages.
    • src/lib/two-factor/backup-codes.test.ts hits bcrypt timeouts under full parallel load and passes when run alone.
  • biome lint .: the same 6 errors as master, all in untouched files, and one warning fewer.
  • pnpm openapi:check and pnpm check:api:client are clean.
  • pnpm build:packages && next build --turbo succeeds. @metsaapp/edgeauth resolves through its compiled dist/index.js in the Node server runtime with no transpilePackages or serverExternalPackages change.
  • Not checked here: a real login through guard against Zitadel, because there's no cluster in this environment.

Pins platform-kit at the COR-1076 branch commit; bump to v0.3.0 once MetsaApp/platform-kit#6 is released, before merging.

Depends on: guard COR-1074 (login, sessions), the deploy-repo guard config for this host.

Cutover (deploy and infra are applied by the stack, not this PR)

  1. Guard gets the umami client for insights.metsa.app. Rules:
    • /(script\.js|recorder\.js|api/(send|batch|record|heartbeat)|[pq]/[^/]+) → auth: none
    • everything else → auth: required, requireRoles: [umami-admin], unauthenticated: auto
    • Public share links are not kept open by that config: their data calls hit the same /api paths as the dashboard.
  2. On the Zitadel client umami:
    • add redirect URI https://insights.metsa.app/.well-known/bastion/callback and post-logout URI https://insights.metsa.app/
    • move secretRef to guard-production/umami-oidc
  3. Deploy the new image with these env changes:
    • set GUARD_IDENTITY=1, PLATFORM_KIT_EDGEAUTH_AUDIENCES=insights.metsa.app, GUARD_USER_ISSUER=https://auth.metsa.app, GUARD_ROLE_ADMIN=umami-admin
    • remove OIDC_* and the umami-oidc secretEnv
  4. Everyone is signed out once (old localStorage tokens are ignored). After that, drop the old callback and post-logout URIs.
  5. Separately, the infra docs need fixing: grants.yaml (and the umami project comment) say an Envoy SecurityPolicy gates Umami on umami-admin, but none exists. The corrected wording is in the stack's deploy notes.

Generated by Claude Code

With GUARD_IDENTITY=1 the fork no longer runs its own OIDC client: guard
is the relying party and owns the sessions, and checkAuth verifies the
x-bastion-identity JWT with @metsaapp/edgeauth on every API request,
mapping the Zitadel subject to the Umami user linked through
oidc_issuer/oidc_subject (GUARD_USER_ISSUER keeps existing users). The
role comes from the identity's role keys (GUARD_ROLE_*); no mapped role
is 401, unreachable guard keys are 503. Password login and Umami's own
session tokens are refused behind guard; share tokens keep working.
Login and logout go to /.well-known/bastion/.

Removes the OIDC authorize/callback/logout routes, /sso-complete, the
OIDC_* settings and openid-client. The server refuses to start with
GUARD_IDENTITY set but incomplete.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01MwB9uUtkK8BFT7ZGoXwX9u
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant