Skip to content

v0.2.0 — challenge mismatch is a retryable, non-terminal refresh outcome

Latest

Choose a tag to compare

@ScottHelme ScottHelme released this 20 Jul 10:52
4aad944

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 403never 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:

  1. Mismatch now throws ChallengeMismatchException, not JwtInvalidException. If you catch JwtInvalidException specifically to handle a challenge mismatch, it no longer lands there. Both still extends DbscException, so a broad catch (DbscException) { revoke(...) } keeps working — but it also keeps the old behaviour (a benign mismatch still logs the user out) until you add a catch (RetryableRefreshException) ahead of it. That extra catch is the whole point of the release; without it the upgrade is a no-op.

  2. The dbscRefreshRetryable audit event is new, and MissingChallengeException / ChallengeExpiredException moved onto it (they logged dbscRefreshFailed in 0.1.x). If your AuditLoggerInterface implementation does an exhaustive match/switch over the EVENT_* constants and throws on an unknown value, add an arm for EVENT_REFRESH_RETRYABLE or it will throw on the first benign refresh. A logger that records the event string as-is needs no change.

  3. Monitoring: any alert keyed on dbscRefreshFailed volume will see it drop (benign missing/expired/mismatch no longer count toward it) and a new dbscRefreshRetryable stream appear. This is the intended split — re-point dashboards accordingly, and keep alerting on dbscRefreshFailed since 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.