feat(refresh-tokens): rotating refresh tokens with family-based replay defense#14
Merged
Conversation
…y defense
The third and largest 1.1.0 feature: a new pk-auth-refresh-tokens module
with RefreshTokenService (issue / rotate / revokeFamily /
revokeAllForUser). Wire format {refreshId}.{secret} (base64url),
SHA-256 hash-at-rest, hash-before-mark-used invariant, family-based
replay scorch.
The load-bearing primitive on the SPI is rotateAtomically(parent, now,
successor) — marks the parent used AND inserts the successor as a
single atomic operation. Implementations:
- JDBI: jdbi.inTransaction(...) wrapping a conditional UPDATE on the
parent and an INSERT for the successor.
- DynamoDB: TransactWriteItems with a conditional update on the parent
primary item and conditional puts for the successor's primary +
user-index + family-index items.
- In-memory: ConcurrentHashMap.compute(parentId, ...) block.
Shared RefreshTokenScenarios drives nine parity scenarios across all
three backends, including the non-negotiable concurrent rotation race
(8 threads + CountDownLatch + ExecutorService) — exactly one wins, the
rest see Replayed, the entire family ends up revoked. Passes against
Postgres Testcontainers and DynamoDB Local on every CI run.
Adapter wiring:
- Spring Boot: refresh beans + PkAuthRefreshController behind
@ConditionalOnBean(RefreshTokenRepository.class). End-to-end
integration test asserts the rotated access JWT carries
AuthMethod.REFRESH.
- Dropwizard: Optional<RefreshHandler> threaded through the Dagger
graph (slim + full components both expose it); bundle.run()
registers PkAuthRefreshResource only when the host wired a
RefreshTokenRepository.
- Micronaut: @requires(beans = RefreshTokenRepository.class) on the
service / handler / controller; refresh integration test alongside
the existing ceremony test.
Browser SDK: PkAuthClient.refresh(wireToken) returns a typed
RefreshResult sum (success | failure with typed reason), never throws
on 401.
Cross-cutting:
- AuthMethod.REFRESH for access tokens minted from a refresh rotation.
- RefreshTokenServiceDeletionListener auto-registered into
UserDeletionService so user-delete revokes refresh families.
- ADR 0013 documents the design + invariants.
- Operator guide gains a Token-table cleanup section; threat-model
gains a Refresh-token replay defense section.
- Flyway V9 ships refresh_tokens; PkAuthJdbiSchema.CURRENT_SCHEMA_VERSION
→ "9".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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
The third 1.1.0 feature — new
pk-auth-refresh-tokensmodule shipping the rotating refresh-token primitive motif (and most multi-client deployments) currently roll by hand. See ADR 0013.Service surface.
RefreshTokenService.issue / rotate / revokeFamily / revokeAllForUser / listForUser.rotate(presentedWireToken)returns a sealedRotateResultsum:Success(pair, claimsForAccessIssue) | Replayed(familyId, userHandle) | Expired | Unknown | Revoked(reason). The service does NOT callPkAuthJwtIssueritself — it returns the data the consumer needs to mint an access JWT, keeping the two primitives composable.Wire format.
{refreshId}.{secret}— both halves base64url, no padding.refreshIdis 16 random bytes (22 chars),secretis 32 random bytes (43 chars). OnlySHA-256(secret)is persisted; the wire token never gets logged. Hash-before-mark-used invariant enforced: a presented refresh-id with the wrong secret returnsUnknown, never burns the legitimate row'sused_at.Load-bearing atomic primitive on the SPI.
rotateAtomically(parentRefreshId, now, successor)marks the parent used AND inserts the successor as a single atomic operation:jdbi.inTransaction(...)wrapping a conditionalUPDATEon the parent and anINSERTfor the successor.TransactWriteItemswith conditional update on the parent primary item + conditional puts for the successor's primary + user-index + family-index items.ConcurrentHashMap.compute(parentId, ...)block.Family-based replay defense. When
rotateAtomicallyreturns false (race lost OR parent flipped used/revoked between read and write), the service callsrevokeFamily(familyId, ROTATION_REPLAY)outside the failed-rotation scope so the scorch always commits. Both attacker and legitimate client lose the session — the legit user sees their next refresh fail and re-authenticates. A row'srevokedReason = ROTATION_REPLAYmaps toRotateResult.Replayedso race losers and replay-after-the-fact callers see a consistent outcome.Non-negotiable concurrent rotation race test.
concurrentRotationExactlyOneSucceedsFamilyRevokedlaunches 8 threads viaCountDownLatch+ExecutorService. Exactly one returnsSuccess; the other 7 returnReplayed; the entire family (root + winner's successor) ends up revoked. Passes against:computepath)TransactWriteItemspath)Adapter wiring — all three adapters wire the service, the deletion listener, and a
POST /auth/refreshHTTP endpoint:@ConditionalOnBean(RefreshTokenRepository.class)gates the service / handler / controller;PkAuthRefreshIntegrationTestasserts the rotated access JWT carriesAuthMethod.REFRESH.Optional<RefreshHandler>threaded through the Dagger graph (both slim + full components);bundle.run()registersPkAuthRefreshResourceonly when the host wired a repository.@Requires(beans = RefreshTokenRepository.class)on the service / handler / controller; newPkAuthRefreshControllerTestcovers happy-path + replay→401.Browser SDK.
PkAuthClient.refresh(wireToken)returns a typedRefreshResultsum ({ kind: 'success', ... } | { kind: 'failure', reason: 'expired'|'unknown'|'replayed'|'revoked' }) — never throws on 401. Vitest covers all five outcomes plus revoke-reason surfacing.Cross-cutting.
AuthMethod.REFRESHfor access tokens minted from a rotation;JwtClaims.forRefresh(...)factory;RefreshTokenServiceDeletionListenerauto-registered intoUserDeletionServiceso user-delete revokes refresh families alongside access tokens / credentials / backup codes / OTPs.Docs. ADR 0013 (full design rationale); operator-guide gains a Token-table cleanup section; threat-model gains a Refresh-token replay defense section; README gains a 1.1.0 features bullet pointing at the new module.
Test plan
./gradlew :pk-auth-testkit:test --tests "InMemoryRefreshTokenRepositoryTest"— 9 scenarios pass (including the concurrent race)../gradlew :pk-auth-persistence-jdbi:test --tests "JdbiRefreshTokenRepositoryIntegrationTest"— 9 scenarios pass against real Postgres (Testcontainers); concurrent race serialised by Postgres row-locking../gradlew :pk-auth-persistence-dynamodb:test --tests "DynamoDbRefreshTokenRepositoryIntegrationTest"— 9 scenarios pass against DynamoDB Local; concurrent race serialised byTransactWriteItems../gradlew :pk-auth-spring-boot-starter:test --tests "PkAuthRefreshIntegrationTest"— happy path mints a valid access JWT, replay returns 401 detail="replayed", unknown wire token returns 401 detail="unknown"../gradlew :pk-auth-micronaut:test --tests "PkAuthRefreshControllerTest"— happy path + replay→401 against the Netty HTTP layer.cd clients/passkeys-browser && pnpm test— 43 tests pass (8 new refresh tests)../gradlew check— full build green across all 15 modules + 3 example demos. JaCoCo coverage thresholds met (pk-auth-micronaut needed the new refresh integration test to recover from a 62% dip).Schema migrations
refresh_tokens (refresh_id, token_hash, user_handle, audience, device_id, family_id, parent_refresh_id, issued_at, expires_at, used_at, revoked_at, revoked_reason)plus indexes on user_handle, family_id, expires_at.PkAuthJdbiSchema.CURRENT_SCHEMA_VERSION→"9".PkAuthCore(RT#,RTU#,RTF#) with nativettlset toexpiresAtepoch second.🤖 Generated with Claude Code