Repository navigation
Enforce token type separation between access and refresh tokens - #259
Merged
Merged
Conversation
…rver:0.55.3, e2e-auth-tests:0.12.7 - enforce access/refresh token type separation
A platform token is a JWE whose payload carries an inner signed JWT. signJWT
wrote the token's `type` ("access"/"refresh") into that signature, but
verifyJWTSignature never checked it and the outer JWE carried no `type` at
all. For the auth server's own audience, access and refresh tokens share
`aud`, `iss`, keyset and subject, so nothing distinguished them: a
third-party app's refresh token worked as a first-party bearer access token
(privilege escalation, admin routes included), and an auth-server access
token could be redeemed at the refresh grant for a two-week refresh token.
Fix (no token-format break, no token invalidation):
- verify_signature.ts: the signature's `type` claim must equal the type
being verified for (root cause).
- generate.ts / payload_data.ts: mint `type` into the outer JWE payload too
(optional in the schema so tokens minted before this stay decodable until
they expire).
- decode.ts: refuse an outer-type mismatch early.
Regression tests at the jwt layer, the server SDK route-guard layer, and the
E2E api_contract suite cover both confusion directions plus legacy tokens
with no outer type claim.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149W2dafrKCf1ZtCVfV6p1k
…rver:0.55.4 - drop the outer-JWE type claim, keep signature-only enforcement The first commit also minted a `type` claim into the outer JWE payload as defence in depth. That changed the token wire format, and the example_resource_server E2E suite went red: every browser PKCE hand-off timed out before reaching the resource server's /account, while the auth server's own suites (api_contract included) stayed green. The auth server's own flows were unaffected because they mint and verify with the same build; the resource-server boundary is where the extra claim did not belong. Revert the outer-payload claim entirely (generate.ts, payload_data.ts, decode.ts) and keep only the signature-level enforcement in verify_signature.ts — which is the finding's recommended fix and needs no token-format change. The signature's `type` claim has always been present, so this closes both confusion directions (refresh token used as an access token; access token redeemed as a refresh token) for every token in circulation, and tokens go back to byte-identical shape. Verified: jwt / auth-server-sdk / auth-server unit suites green, and a live auth server refuses a refresh token sent as `Authorization: Bearer` (401 on user, org and admin routes) and an access token sent as grant_type=refresh_token (invalid_grant), with genuine tokens still working. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0149W2dafrKCf1ZtCVfV6p1k
…kens Documents that the token-type fix is graceful: it adds no claim to the outer JWE payload, so a token on the wire is unchanged from before the fix (and from every token still in circulation). The new test decodes such a legacy-shaped token for its own type, asserts the outer payload carries no `type` field, and confirms the same token is still refused when presented as the other type (enforcement comes from the inner signature's `type`, minted since well beyond the longest token lifetime). Test-only: jwt's build strips *.test.* from the published dist, so the published artifact is unchanged and no downstream cascade bump is needed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0149W2dafrKCf1ZtCVfV6p1k
…-app refresh token as a bearer
The token-type separation fix exposed that the client SDK's own whoami call
relied on the confusion it closes. For a client whose refresh token is not
an HTTP-only cookie (inline delivery — every app but the auth server's own),
checkIfAuthenticatedWithServer authenticates GET /api/auth/whoami/{client_app_id}
with `Authorization: Bearer <refresh token>`. whoami decoded every bearer
credential as an access token, so once a refresh token could no longer pose
as an access token the call returned 401, the SDK concluded the user was
signed out, and the browser never reached the resource server's /account —
the example_resource_server E2E suite's only failure.
Accept the client app's refresh token as a bearer credential on whoami,
decoded as a refresh token (never an access token) and only when its `app`
claim is the path's client_app_id. This is the same credential whoami
already takes as the refresh_token_<client_app_id> cookie, just in the
header. Only whoami lists the new scheme, so a refresh token presented as a
bearer still unlocks nothing elsewhere: every other session-guarded
operation accepts only the access-token bearer scheme, which rejects a
refresh token. whoami returns the caller's own user data, so the bearer
refresh path grants no more than the cookie one already did.
Verified against a running auth server: whoami accepts the session refresh
token as a bearer (200), the same token is still refused as a bearer on
/api/user/profile, /api/me/organizations and /api/admin/users/list (401),
whoami refuses a refresh token issued to another client app (401), and the
refresh cookie path is unchanged. E2E assertions added to the api_contract
TokenTypeSeparation spec.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149W2dafrKCf1ZtCVfV6p1k
A redis-server started in the repo root for local verification writes a dump.rdb there, which otherwise shows up as an untracked file. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0149W2dafrKCf1ZtCVfV6p1k
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This change adds cryptographic enforcement of token type separation between access and refresh tokens. Access and refresh tokens minted for the auth server's own audience share the same
aud,iss, keyset, and subject, making them otherwise indistinguishable. Without type enforcement, a refresh token could be presented as an access token (gaining first-party API access) or an access token could be redeemed as a refresh token (gaining a longer lifetime).Key Changes
JWT signature verification (
verify_signature.ts): Added validation that thetypeclaim in the signature payload matches the expected token type. This is the primary enforcement mechanism.JWT decoding (
decode.ts): Added early validation of the outer payload'stypeclaim (when present) before signature verification. This provides fast rejection of mis-presented tokens and supports legacy tokens minted before the outer claim existed.JWT generation (
generate.ts): Now includes thetypeclaim in the outer JWE payload, mirroring the inner signature's type for early validation.JWT payload schema (
payload_data.ts): Updated to include the optionaltypefield in the outer payload schema with documentation explaining its purpose and backward compatibility.Route guard factory (
route-guard-factory.test.ts): Added comprehensive tests verifying that:Unit tests (
token-type-separation.test.ts): Added 302 lines of unit tests covering:E2E tests (
TokenTypeSeparation.cy.ts): Added 173 lines of Cypress tests verifying the API never confuses token types:invalid_grantVersion bumps: Incremented patch versions across affected packages:
@schemavaults/jwt: 0.14.2 → 0.14.3@schemavaults/auth-server-sdk: 0.32.5 → 0.32.6@schemavaults/trpc-backend-init: 0.9.76 → 0.9.77@schemavaults/auth-server: 0.55.2 → 0.55.3@schemavaults/e2e-auth-tests: 0.12.6 → 0.12.7Implementation Details
typeclaim is the authority; the outer payload'stype(added in this version) provides early rejection before cryptographic verification.typefield) fall through to signature verification alone.https://claude.ai/code/session_0149W2dafrKCf1ZtCVfV6p1k