Splits challenge mismatch out of the terminal failure path. A challenge mismatch is now a benign, retryable refresh outcome (a fresh 403 + challenge), not a JwtInvalidException that revokes the session and logs the user out.
Why it's safe: refresh() reaches the challenge comparison only after the JWT signature has already verified against the device-bound key. A validly-signed JWT proves possession of the device private key, so a mismatch at that point can only be a benign race — an idle session, a concurrent refresh, a lost 403 — never a forged or stolen-cookie signal. Treating it as terminal only ever punished the loser of a race (usually the legitimate user); a bad signature still dies earlier as a terminal JwtInvalidException, unchanged. This also closes a replay-driven logout: a spent-but-signed JWT replayed after rotation used to force-log-out the real user, and is now benign.
New RetryableRefreshException marker. MissingChallengeException, ChallengeExpiredException, and the new ChallengeMismatchException all implement it — catch the marker once instead of enumerating the classes:
try {
emit($dbsc->refresh($jwt, $ctx));
} catch (RetryableRefreshException) {
emit($dbsc->issueRefreshChallenge($ctx)); // benign: 403, browser retries
} catch (DbscException $e) {
emit($dbsc->revoke($ctx, enforcementTerminated: true)); // terminal: stolen-cookie signal
}Distinct audit event for benign retries. All three retryable paths now log DbscServer::EVENT_REFRESH_RETRYABLE (dbscRefreshRetryable); EVENT_REFRESH_FAILED (dbscRefreshFailed) is now reserved for terminal failures (bad signature, unknown session). Ordinary concurrency no longer inflates the signal an integrator alerts on.
⚠️ Upgrading from 0.1.x — read this
Nothing breaks silently: the common broad-catch pattern stays compile-safe and keeps today's fail-safe (terminal) behaviour on mismatch until you opt in, and the two patterns that do break fail loudly and immediately (at compile time or on the first refresh), not weeks later in production. Three things to check:
-
Mismatch now throws
ChallengeMismatchException, notJwtInvalidException. If you catchJwtInvalidExceptionspecifically to handle a challenge mismatch, it no longer lands there. Both stillextends DbscException, so a broadcatch (DbscException) { revoke(...) }keeps working — but it also keeps the old behaviour (a benign mismatch still logs the user out) until you add acatch (RetryableRefreshException)ahead of it. That extra catch is the whole point of the release; without it the upgrade is a no-op. -
The
dbscRefreshRetryableaudit event is new, andMissingChallengeException/ChallengeExpiredExceptionmoved onto it (they loggeddbscRefreshFailedin 0.1.x). If yourAuditLoggerInterfaceimplementation does an exhaustivematch/switchover theEVENT_*constants and throws on an unknown value, add an arm forEVENT_REFRESH_RETRYABLEor it will throw on the first benign refresh. A logger that records the event string as-is needs no change. -
Monitoring: any alert keyed on
dbscRefreshFailedvolume will see it drop (benign missing/expired/mismatch no longer count toward it) and a newdbscRefreshRetryablestream appear. This is the intended split — re-point dashboards accordingly, and keep alerting ondbscRefreshFailedsince it now means a genuine terminal failure.
No other API removals. ChallengeMismatchException, RetryableRefreshException, and EVENT_REFRESH_RETRYABLE are additive; records written by 0.1.x decode unchanged.