fix(auth): accept azp claim for client identity (closes #26) #27
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "26-jwt-aud-azp-fix"
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?
Summary
verifyKeycloakJwtwas rejecting every live access token because Keycloak's default access tokens carryaud=account, azp=westside-admin(no audience mapper configured on the client). This PR softens the client-identity check to acceptaudcontaining the client_id ORazpequal to the client_id, per OIDC Core §2. SSO completes end-to-end against the existing Keycloak deployment with no admin-console changes required.Closes #26.
Changes
src/lib/server/keycloak.ts—verifyKeycloakJwt: dropaudience: expectedAudience()from the josejwtVerifyoptions (jose no longer auto-rejects on aud). After verify, manually checkaud === clientId || aud.includes(clientId) || azp === clientId. ThrowJwtVerificationError(claim aud/azp invalid (aud=…, azp=…))when both fail. Pass-through our own typed error so the manual rejection isn't double-wrapped by the jose-error-mapper.src/lib/server/keycloak.test.ts— extendJwtOptswith optionalazp(passed as a JWT body claim); broadenaudtostring | string[]so we can build the array case; rename the existingrejects a token with the wrong audiencecase torejects a token with the wrong audience AND wrong azp (truly wrong client)and set both claims to non-matching values (otherwise it becomes a false-pass under the new logic); add the 6-row accept/reject matrix from #26.scripts/verify-aud-azp.mjs— standalone harness that imports the real exportedverifyKeycloakJwt, stubs the JWKS endpoint via__setFetchForTests, and exercises every matrix case end-to-end. Mirrors themeasure-cookie-size.mjspattern from #25. Run withnpx tsx scripts/verify-aud-azp.mjs.Live JWT-claims Evidence (from #26)
Decoded the live access token claims for Lucas's session at SHA
785a1ca3(post-#25 deploy):Pre-fix:
audience: expectedAudience()injwtVerifycausesJWTClaimValidationFailedonaud === 'account'. Hook'scatch {}falls through to anonymous → 302 → infinite redirect.Post-fix: aud-mismatch is no longer fatal when
azp === client_id, so the Keycloak default shape verifies cleanly.Accept/Reject Matrix (new test coverage)
audazpaccountwestside-adminwestside-adminwestside-adminwestside-adminotheraccountother[westside-admin, account]westside-adminotherotherValidation Script Output
Per
feedback_validate_before_done— running the standalone matrix harness against the actual exportedverifyKeycloakJwt:This is the live aud=account shape that broke prod — exit code 0, all 6 cases match expected outcomes against the real exported function.
Funnel-Auth Review (per
feedback_funnel_requires_auth)The westside-admin app sits behind a Tailscale funnel + Keycloak OIDC. This PR softens the JWT identity check; below is the security argument for why it's not a regression.
azpis OIDC-spec-conformant for client identity. OIDC Core §2 definesazpas "the party to which the ID Token was issued" (commonly the client_id). Major IdPs (Keycloak, Auth0, Okta) populate it with the requesting client_id by default. Accepting it satisfies "did this token originate from a request my client made?" — exactly the question we're asking.aud === client_id. Post-fix logic accepts:aud === client_idORaud.includes(client_id)ORazp === client_id. We reject any token where ALL THREE are false. The signature, issuer, and exp checks are unchanged — every other invariant from #14's threat model is intact.issandkidfilter to our realm's JWKS). Addingazpto the accept set does not lower that bar — the attacker still needs the private key to sign a token bearingiss=our-realmANDazp=westside-admin. Compromised signing key = game-over regardless of which claim we read for client identity.westside-public) would haveazp=westside-public, aud=…. Neither matcheswestside-admin, so we reject. Verified by theaud=other, azp=othercase in the matrix.aud=accounttoken in the wild. Theaccountaudience is Keycloak's built-in account-management client scope, present on every default-scoped client in every realm. By itself it does not grant admin access — the role check downstream ofverifyKeycloakJwt(in the hook) gates admin actions onrealm_access.rolescontainingadmin, which only Lucas's user has. The fix unblocks that check from firing; it does not weaken it.Conclusion: accepting
azpis OIDC-spec-conformant and reduces no security invariant. The funnel-auth gate becomes operational rather than non-functional.Test Plan
npm test— 31 cases pass (was 25, added 6 matrix cases + replaced 1)npm run check— 0 errors, 0 warningsnpx prettier --check src/lib/server/keycloak.ts src/lib/server/keycloak.test.ts scripts/verify-aud-azp.mjs— cleannpx eslint src/lib/server/keycloak.ts src/lib/server/keycloak.test.ts— no errorsnpm run build— succeeds (adapter-node, server bundle 127.66 kB)npx tsx scripts/verify-aud-azp.mjs— exit 0, all 6 matrix cases PASS/returns 200 after callback Set-Cookie (ACs 1, 2 from #26)/→event.locals.user.namepopulated (AC 3 from #26)(unauthorized)403 pageCoordination with #22
#22(open —refresh_expires_inexposure onKeycloakTokens) also touchessrc/lib/server/keycloak.ts. Different function (refreshTokensIfNeeded+ theKeycloakTokensinterface), no semantic conflict withverifyKeycloakJwt. Whichever lands second rebases — likely a trivial three-way merge since the line ranges are non-overlapping. No code changes needed in this PR for #22 awareness.Out of Scope (per ticket)
src/routes/auth/*— unchanged; they don't callverifyKeycloakJwtdirectly.src/hooks.server.ts— unchanged; gets the new behavior through the unchangedverifyKeycloakJwtsignature.pal-e-deployments/*,(unauthorized)/*— unchanged.Review Checklist
src/lib/server/keycloak.tsandsrc/lib/server/keycloak.test.tsplus the newscripts/verify-aud-azp.mjsvalidation harness — no edits tosrc/routes/auth/*,src/hooks.server.ts, deployments, or unrelated fileskeycloak.tssource-hygiene unit test still passes)rejects-on-wrong-audcase was UPDATED, not deleted — now sets bothaudandazpto non-matching values so it isn't a false-pass under the new logicazpis OIDC-spec-conformant and not a security regression, perfeedback_funnel_requires_authfeedback_validate_before_doneazpJwtVerificationErrorthrown manually is NOT re-wrapped by the catch block (theif (err instanceof JwtVerificationError) throw err;guard at the top of catch handles this)Related Notes
feedback_funnel_requires_auth— funnel-auth gate is non-functional without this fix even though every individual component was "approved"feedback_validate_before_done— drove the standalone harness output in this PR body; gate that didn't catch this last time catches it this timesop-keycloak-client-creation— no SOP change required by Path B (works for any client; Path A would have needed a per-client audience mapper step)Related
forgejo_admin/westside-admin#26forgejo_admin/westside-admin#22(open —refresh_exp; overlapping lib file, different function)forgejo_admin/westside-admin#24/ PR#25(closed — cookie size fix; SAME bug surface, different root cause; this PR completes the loop unmasked by #25)PR #27 Review
DOMAIN REVIEW
Stack: SvelteKit (adapter-node) + TypeScript + jose 5.x + Vitest. Server-side OIDC/JWT primitives. Funnel-fronted via Tailscale + Keycloak —
feedback_funnel_requires_authapplies.Cryptographic correctness — OIDC Core §2 conformance.
The fix replaces jose's built-in
audience: expectedAudience()enforcement with a manual post-verify check:aud === clientId || aud.includes(clientId) || azp === clientId. Per OIDC Core §2 (azp= "the party to which the ID Token was issued"), acceptingazp === clientIdas a client-identity assertion is spec-conformant. Major IdPs (Keycloak, Auth0, Okta) populateazpwith the requestingclient_idby default. The funnel-auth review in the PR body correctly establishes:azp=other-client, aud=...→ both branches false → REJECT. Verified by matrix row 6 (aud=other, azp=other).realm_access.rolesadmin gate inhooks.server.tsis untouched —aud=accounttokens do NOT bypass admin authz, they merely pass JWT-shape validation so the role gate can fire.No security regression.
azpis the right OIDC claim for "did this token originate from a request my client made?"Test matrix completeness.
All 6 cases from #26's body are present in
keycloak.test.ts(lines 232–331):accountwestside-adminwestside-adminwestside-adminwestside-adminotheraccountother[westside-admin, account]westside-adminotherotherNo-regression on the original wrong-aud test.
The existing
rejects a token with the wrong audiencecase (pre-PR line 212–222) was correctly UPDATED, not deleted. Bothaudandazpnow set to'some-other-client'. Without this update the test would have become a false-pass under the new logic (a wrong-aud-only token would fail-but-not-because-of-the-claim-the-test-named). The rename to "wrong audience AND wrong azp (truly wrong client)" makes the intent explicit. Correctly done.Standalone validation script (
scripts/verify-aud-azp.mjs).Imports the REAL
verifyKeycloakJwtfrom../src/lib/server/keycloak.ts(not a re-implementation), stubs JWKS via__setFetchForTests, signs JWTs with the same jose primitives prod uses, runs all 6 matrix cases, exits non-zero on any fail. Production code path is exercised end-to-end. PR body shows all 6 cases PASS against the liveaud=accountshape that broke prod.Error message hygiene.
The new rejection error reads
claim aud/azp invalid (aud=..., azp=...). The REJECT matrix test (line 264) assertsreason: expect.stringContaining('aud/azp')— confirms the new tag distinguishes from the legacy jose-mappedauderror so the existing wrong-iss test (which assertsstringContaining('iss')) still works alongside it. Token bytes are not embedded in the error — only claim values. The existing source-hygiene test (line 595) still passes per PR body.BLOCKERS
None.
NITS
keycloak.tsrendered asBinary files differin the Forgejo diff. Almost certainly a Forgejo display quirk (non-ASCII char in a hunk?) rather than an actual binary. The PR body describes the source change clearly and the tests + script + matrix output are all consistent with that change. Recommend reviewers spot-check viagit show 26-jwt-aud-azp-fix:src/lib/server/keycloak.tsto eyeball the manual aud/azp branch and theif (err instanceof JwtVerificationError) throw err;guard at the top of the catch block. Not blocking — the test surface and standalone script provide independent evidence.Manual check ordering. For an array
aud,aud === clientIdis always false (string vs array), soaud.includes(clientId)carries the array case. Fine. A one-line comment in source explaining the short-circuit order would aid future maintainers — defer to source spot-check.Process gap covered by #19. This is the third PR (#23, #25, #27) closing a bug surfaced after the "approved" lib hit the live Keycloak default token.
verify-aud-azp.mjsis exactly the kind of harness that should run in CI to catch this class pre-merge. Recommend a one-line follow-up comment on #19 to wirenpx tsx scripts/verify-aud-azp.mjsinto the Woodpecker pipeline alongsidenpm test. Not blocking this PR.SOP COMPLIANCE
26-jwt-aud-azp-fixfollows{issue-number}-{kebab-case-purpose}.Closes #26linkage present.feedback_funnel_requires_auth— funnel-fronted ingress).feedback_validate_before_done).#22collision note present and accurate (different function, no semantic conflict).#25sibling reference present (cookie-size fix that masked this bug).src/lib/server/keycloak.ts,src/lib/server/keycloak.test.ts,scripts/verify-aud-azp.mjs. No edits tosrc/hooks.server.ts,src/routes/auth/*,src/routes/(unauthorized)/*, orpal-e-deployments/*.npm test31 pass,npm run check0/0, prettier + eslint clean,npm run buildsucceeds,npx tsx scripts/verify-aud-azp.mjsexits 0 with all 6 matrix PASS.feedback_validate_before_donethese tick post-deploy, not at QA gate.PROCESS OBSERVATIONS
sop-keycloak-client-creationsimple).npx tsx scripts/verify-aud-azp.mjsto the Woodpecker pipeline so this class of bug — unit tests pass but live-IdP shape unmasks an integration gap — gets caught pre-merge next time. Three iterations (#23, #25, #27) on a single auth surface suggests the harness gap is the systemic issue, not any one PR's correctness.VERDICT: APPROVED