[BUG] verifyKeycloakJwt rejects valid tokens — Keycloak default access token aud=account, not westside-admin #26
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Type
Bug
Lineage
Discovered during the same end-to-end SSO validation that surfaced #24 (cookie-too-big). After PR #25 fixed the cookie size (3151 B vs 4096 B cap, verified live), the SSO round-trip STILL produces an infinite redirect loop. Root cause is independent of #24 and unmasked only after #24 was fixed: the per-request JWT verification fails because the access token's
audclaim isaccount(Keycloak default), notwestside-admin.Repo
forgejo_admin/westside-adminWhat Broke
Same surface symptom as #24 (login → callback succeeds → next request to
/redirects to/auth/login→ infinite loop), but a different root cause that was masked by the cookie-size bug. After the cookie-size fix:verifyKeycloakJwt(access_token)throwsJWTClaimValidationFailedbecauseaud === 'account'butexpectedAudience() === 'westside-admin'.catch {}block treats verify failure as anonymous → 302 to/auth/login.Decoded the live access token claims (Lucas's session, post-#25 deploy at SHA
785a1ca3):Why this is a Keycloak default, not a misconfiguration
Per Keycloak documentation and OIDC convention: the access token's
audclaim is populated from resource servers / audience mappers, not the requesting client. By default a client has zero protocol mappers and the token'saudbecomes whatever scope-defined audiences exist (typicallyaccountfrom the standardaccountclient scope). The requesting client's identity goes in theazp(authorized party) claim instead.This is a well-known Keycloak gotcha. There are two industry-standard fixes:
A) Configure an Audience protocol mapper on the Keycloak
westside-adminclient that addswestside-admintoaud. Keeps the static-analysis-friendlyaudience: expectedAudience()invariant injwtVerify. Requires a Keycloak admin-console click (no code change). Should be added tosop-keycloak-client-creationstep 5 or 6 if we go this route.B) Update
verifyKeycloakJwtto acceptazp === clientIdin addition to (or instead of)aud === clientId. OIDC Core §2 definesazpas exactly "the party to which the ID Token was issued"; using it for client identity is RFC-compliant. Requires a code change insrc/lib/server/keycloak.ts. No SOP change needed (works for any future Keycloak client without per-client mapper config).Proposed Fix: B (code change)
Soften
verifyKeycloakJwtto also acceptazpmatching the expected client_id. Concretely:Rationale for accepting both:
azpcarries client_id,auddoes not).audincludes the client_id).The unit test for
verifyKeycloakJwtrejecting wrong-audience tokens (perkeycloak.test.tsfrom PR #18) needs to be updated — its current test asserts rejection onaudmismatch alone, which would now incorrectly pass ifazpmatches. New cases:aud=account, azp=westside-admin→ ACCEPT (this is the Keycloak default, the bug we're fixing).aud=other, azp=other→ REJECT (truly wrong client).aud=westside-admin, azp=other→ ACCEPT (audience mapper configured, also valid).aud=other, azp=westside-admin→ ACCEPT (Keycloak default, also valid).aud=account, azp=other→ REJECT (no match).Repro Steps
adminrole athttps://westside-admin.tail5b443a.ts.net(post-PR-#25 image).ERR_TOO_MANY_REDIRECTS(same surface symptom as #24).westside_admin_sessioncookie viaCOOKIE_SIGNING_KEY(kubectl -n westside-admin get secret westside-admin-secrets -o jsonpath='{.data.COOKIE_SIGNING_KEY}' | base64 -d), decode the embeddedaccess_token's middle segment as JWT claims, observeaud = "account"andazp = "westside-admin".verifyKeycloakJwtcall (line ~283 inkeycloak.tsat SHA785a1ca3): theaudience: expectedAudience()arg tojwtVerifycausesJWTClaimValidationFailedbecause"account" !== "westside-admin".Expected Behavior
/returns 200 with the admin app rendered,event.locals.user.name === 'draneylucas@gmail.com',event.locals.user.rolesincludes'admin'.(unauthorized)403 page with sign-out button.Environment
westside-adminnamespace785a1ca311c1c964e26319c94373688f9ed1dfb1(PR #25 merge — has the cookie-size fix; reproduces this aud bug consistently because the cookie now actually reaches the hook).westside-adminclient UUID:c5749fa6-4d1e-4b07-bdc0-e371bf65e1e5[web-origins, acr, profile, roles, basic, email]— none of which include an audience mapper forwestside-admin.Acceptance Criteria
/(no cookie) → 302 chain → eventual GET/returns 200 with admin app rendered./returns 200 after the callback's Set-Cookie./withevent.locals.user.name === 'draneylucas@gmail.com'.keycloak.test.tsJWT-verify cases still pass (no regression on truly-wrong-audience rejection).npm testexits 0.feedback_funnel_requires_authexplicitly addressing why acceptingazpis OIDC-spec-conformant and not a security regression.Related
project-westside-adminforgejo_admin/westside-admin#14— original keycloak.ts (PR #18, the lib whose verification is too strict)forgejo_admin/westside-admin#15— hook (PR #20, downstream consumer of the verify failure)forgejo_admin/westside-admin#22— open follow-up touching the same lib (refresh_exp); coordinate via PR description, no semantic conflictforgejo_admin/westside-admin#24— sibling bug in the same validation pass (cookie too big — fixed in PR #25)feedback_validate_before_done— drove this discoveryfeedback_funnel_requires_auth— without this fix, the funnel-auth gate is non-functional even though every individual component is "approved"sop-keycloak-client-creation— should consider documenting either the audience-mapper option (path A) or the azp-acceptance pattern (path B) so future clients don't repeat thisScope Review: APPROVED
Review note:
review-1141-2026-05-03Bug template fully populated. Live JWT claims + OIDC-convention rationale + accept/reject test matrix all in body. File targets verified at deployed SHA
785a1ca3:src/lib/server/keycloak.ts(verifyKeycloakJwt at line 283, offending audience check at line 293) andsrc/lib/server/keycloak.test.ts(existing aud-rejection test at line 212). Story labeladmin-row-crudverified on project page.arch:westside-adminbacked by 3 project-specific arch notes per established convention.arch:keycloakgap waived per 10+ prior precedents. No sibling fanout (only westside-admin uses this jwtVerify pattern). 5-minute rule passes. No decomposition needed.Two non-blocking
[BODY]refinement notes for the dev:signJwttest helper at line 74 doesn't currently support anazpparameter — dev will need to extendJwtOptsor use rawSignJWTfor the new matrix cases.requireEnv('KEYCLOAK_CLIENT_ID')directly; existingexpectedAudience()helper at line 126 does the same thing (cosmetic preference).Two
[SCOPE]follow-ups (NOT blocking on #26):arch-keycloaknote still missing in pal-e-docs (recurring platform gap; separate doc ticket).sop-keycloak-client-creationshould document either audience-mapper (path A) or azp-acceptance (path B) so future clients don't repeat this gotcha.Ticket is scope-ready for
todo. Dev agent can ship as-written.