Skip to content

fix: verify nonce against original after refresh instead of overwriting it#2211

Open
FabianGosebrink wants to merge 2 commits into
mainfrom
fix/silent-renew-nonce-placeholder
Open

fix: verify nonce against original after refresh instead of overwriting it#2211
FabianGosebrink wants to merge 2 commits into
mainfrom
fix/silent-renew-nonce-placeholder

Conversation

@FabianGosebrink

Copy link
Copy Markdown
Collaborator

Summary

  • Stop writing the placeholder string '--RefreshToken--' over the stored authNonce during silent renew.
  • Keep the original nonce in storage and pass an explicit isRefreshTokenFlow flag into TokenValidationService.validateIdTokenNonce.
  • New refresh-flow branch verifies that any returned nonce equals the original — so the Keycloak case works without needing to disable the check.

Fixes #1947
Fixes #2068

Background

Per OIDC core spec section 12.2, the id_token from a refresh-token exchange SHOULD NOT contain a nonce claim. Some IdPs (notably Keycloak, also reported with other providers) include the original nonce anyway. Before this PR, the library:

  1. Overwrote authNonce in storage with the literal '--RefreshToken--' before each refresh (refresh-session-callback-handler.service.ts).
  2. Then compared the IdP's returned nonce against that placeholder.
  3. → mismatch → silent renew fails → user logged out.

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.nonce Matches original? ignoreNonceAfterRefresh Result
undefined ✅ accept (spec-compliant IdP)
defined yes ✅ accept (Keycloak — verified)
defined no true ✅ accept (preserved escape hatch)
defined no false ❌ reject (real anomaly — was previously hidden by the placeholder)

Normal (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.validateIdTokenNonce gained 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 in state-validation.service.ts:138 passes the isInRefreshTokenFlow value already computed at line 85.

The static TokenValidationService.refreshTokenNoncePlaceholder is 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

  • 4 rewritten validateIdTokenNonce tests + 1 new tampered-nonce test — all pass
  • New refresh-session-callback-handler test asserts setNonce is not called when a refresh token exists
  • Full library test suite: 984 / 984 SUCCESS
  • `npm run lint-lib` clean
  • Manually verify against a real Keycloak with default ignoreNonceAfterRefresh: false — recommend a reviewer with a Keycloak setup confirms

Files changed

  • `validation/token-validation.service.ts` — signature + new logic + @deprecated tag
  • `validation/token-validation.service.spec.ts` — rewrote 4 refresh-flow tests, added tamper-rejection
  • `validation/state-validation.service.ts` — pass isInRefreshTokenFlow at the call site
  • `flows/callback-handling/refresh-session-callback-handler.service.ts` — drop placeholder overwrite
  • `flows/callback-handling/refresh-session-callback-handler.service.spec.ts` — assert no setNonce call

🤖 Generated with Claude Code

…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--';

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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.
  2. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants