fix: verify nonce against original after refresh instead of overwriting it#2211
fix: verify nonce against original after refresh instead of overwriting it#2211FabianGosebrink wants to merge 2 commits into
Conversation
…ng it Silent renew was overwriting the stored nonce with the placeholder string '--RefreshToken--' before each refresh. When the IdP (notably Keycloak) returned the original nonce in the refreshed id_token, validation then compared that real nonce against the placeholder and failed, kicking the user out. The only workaround was setting `ignoreNonceAfterRefresh: true`, which disables the check entirely. Stop overwriting the nonce. Keep the original in storage and pass an explicit `isRefreshTokenFlow` flag into `validateIdTokenNonce`. Per the OIDC spec (section 12.2) the id_token from a refresh-token exchange SHOULD NOT contain a nonce claim, but some IdPs include the original one anyway. New refresh-flow logic: - no nonce in id_token -> accept (spec-compliant IdP) - nonce in id_token == original -> accept (Keycloak case) - nonce mismatch + escape hatch -> accept (ignoreNonceAfterRefresh) - nonce mismatch otherwise -> reject (real anomaly) This is stricter than `ignoreNonceAfterRefresh: true`: Keycloak users now get nonce *verification* against the original instead of nonce *skipping*, so tampering with the returned nonce is still caught. The static `TokenValidationService.refreshTokenNoncePlaceholder` is kept and marked @deprecated to avoid breaking any external consumer that imported it; it is no longer written to storage. Adds tests for spec-compliant IdP, Keycloak-style nonce echo, tampered-nonce rejection, and the preserved escape hatch. Existing 984-test suite stays green. Fixes #1947 Fixes #2068
| * Will be removed in a future major release. | ||
| */ | ||
| static refreshTokenNoncePlaceholder = '--RefreshToken--'; | ||
|
|
There was a problem hiding this comment.
Why is this not stored on the storage, what happens after a refresh?
Not saying this is bad, just want to understand this better. Also wondering if this is a behavior change
There was a problem hiding this comment.
All Good. It is good that you double check this.
In the old code the RefreshSessionCallbackHandlerService overwrote authNonce in storage with '--RefreshToken--' before each refresh. validateIdTokenNonce then detected refresh flow by checking if localNonce === refreshTokenNoncePlaceholder. So the storage was being used as a flag and the real initial nonce was getting overwritten.
Now it is not stored in the storage anymore. authNonce in storage now holds the original nonce from the initial authentication, for the whole session. It's never overwritten during refresh. state-validation.service.ts already computes isInRefreshTokenFlow at line 85 from the callback context (isRenewProcess && !!refreshToken), and that boolean is now passed directly into validateIdTokenNonce instead of being used via storage.
It is a change, yes.
IdP returns in the refreshed id_token |
Old behavior | New behavior |
|---|---|---|
No nonce (spec-compliant) |
✅ accepted | ✅ accepted |
Original nonce (Keycloak) |
❌ rejected (placeholder ≠ returned nonce) | ✅ accepted (returned nonce == original in storage) |
Some other nonce |
❌ rejected | ❌ rejected |
ignoreNonceAfterRefresh: true |
✅ accepted | ✅ accepted (unchanged) |
So yes — this is a behavior change, and it's intentional. Two improvements:
- Keycloak (and other IdPs that echo the nonce on refresh) now work with the default
ignoreNonceAfterRefresh: false. Previously they required users to flip the flag, which weakens the check. - Tampering is now caught. If a man-in-the-middle returns a nonce that isn't the original, validation fails. The old code couldn't tell the difference between "spec-compliant IdP" and "tampered refresh" — both produced a mismatch with the placeholder.
Net security posture is stricter, not looser. ignoreNonceAfterRefresh: true still exists as an escape hatch for IdPs that return arbitrary nonces.
On the deprecated static
The refreshTokenNoncePlaceholder constant is still exported (just unused internally) so any external consumer that imported it still compiles. Plan is to drop it in the next major. Happy to remove it now instead if you'd prefer a cleaner break — it's only useful as a transition aid.
Summary
'--RefreshToken--'over the storedauthNonceduring silent renew.isRefreshTokenFlowflag intoTokenValidationService.validateIdTokenNonce.Fixes #1947
Fixes #2068
Background
Per OIDC core spec section 12.2, the id_token from a refresh-token exchange SHOULD NOT contain a
nonceclaim. Some IdPs (notably Keycloak, also reported with other providers) include the original nonce anyway. Before this PR, the library:authNoncein storage with the literal'--RefreshToken--'before each refresh (refresh-session-callback-handler.service.ts).The only escape was
ignoreNonceAfterRefresh: true, which disables the nonce check entirely post-refresh. Several users on #1947 / #2068 explicitly rejected that workaround for security reasons.New validation matrix (refresh-token flow)
id_token.nonceignoreNonceAfterRefreshundefinedtruefalseNormal (non-refresh) auth-flow behavior is unchanged.
This is stricter than
ignoreNonceAfterRefresh: true: Keycloak users now get nonce verification against the original instead of nonce skipping, so tampering with the returned nonce is still caught.API change
TokenValidationService.validateIdTokenNoncegained an optional 5th parameter:```ts
validateIdTokenNonce(
dataIdToken,
localNonce,
ignoreNonceAfterRefresh,
configuration,
isRefreshTokenFlow = false, // NEW, defaults to false
): boolean
```
Default is
false, so existing 4-arg callers behave identically. The library's own call site instate-validation.service.ts:138passes theisInRefreshTokenFlowvalue already computed at line 85.The static
TokenValidationService.refreshTokenNoncePlaceholderis kept and marked@deprecated— no longer written to storage, but the symbol stays exported so any external consumer that imported it still compiles. Plan to remove in the next major.Migration note
Users mid-session at upgrade time who still have
'--RefreshToken--'in localStorage from a previous version will see one more cycle of the bug on their next refresh (because the stored nonce isn't the real one). After the next successful login the real nonce is written and the issue clears. No user action needed.Test plan
validateIdTokenNoncetests + 1 new tampered-nonce test — all passrefresh-session-callback-handlertest assertssetNonceis not called when a refresh token existsignoreNonceAfterRefresh: false— recommend a reviewer with a Keycloak setup confirmsFiles changed
isInRefreshTokenFlowat the call sitesetNoncecall🤖 Generated with Claude Code