diff --git a/CHANGELOG.md b/CHANGELOG.md index dad6d6c..d649774 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,39 @@ tags. calls `exists` on every validate. The default `AccessTokenStore.noop()` keeps stateless behaviour; hosts wire a real store (JDBI, DynamoDB) to opt in. See ADR 0015. +- `pk-auth-refresh-tokens` (new module): `RefreshTokenService` with `issue`, + `rotate`, `revokeFamily`, `revokeAllForUser`, `listForUser`. Sealed + `RotateResult` (Success / Replayed / Expired / Unknown / Revoked). Wire + format `{refreshId}.{secret}` (both halves base64url); SHA-256 + hash-at-rest; hash-before-mark-used invariant. Family-based replay + defense — re-presenting a used token scorches the entire family. See + ADR 0013. +- `RefreshTokenRepository` SPI with the load-bearing `rotateAtomically` + primitive: parent mark-used and successor insert commit atomically on + every backend (JDBI transaction, DynamoDB `TransactWriteItems`, + in-memory `ConcurrentHashMap.compute`). +- `pk-auth-persistence-jdbi`: `JdbiRefreshTokenRepository` + Flyway V9 + migration. PkAuthJdbiSchema.CURRENT_SCHEMA_VERSION → "9". +- `pk-auth-persistence-dynamodb`: `DynamoDbRefreshTokenRepository` with + three-item layout (primary jti / user-index / family-index) and + DynamoDB-native TTL. +- `pk-auth-testkit`: `InMemoryRefreshTokenRepository` + + `RefreshTokenScenarios` parity test class — nine scenarios including + the non-negotiable `concurrentRotationExactlyOneSucceedsFamilyRevoked` + race test, driven by 8 threads + `CountDownLatch`, passing against + in-memory, real Postgres, and DynamoDB Local. +- `RefreshHandler` (framework-neutral HTTP composer) + `PkAuthRefreshController` + / `PkAuthRefreshResource` in Spring, Dropwizard, Micronaut adapters. + `POST /auth/refresh` returns the new refresh + access JWT on success, + 401 with a typed `detail` on any failure. +- `AuthMethod.REFRESH` for access tokens minted from a refresh rotation. +- `JwtClaims.forRefresh(userHandle, audience, amr)` factory. +- Browser SDK: `PkAuthClient.refresh(wireToken)` returning a typed + `RefreshResult` sum (`RefreshSuccess | RefreshFailure`) — never throws + on 401. +- `RefreshTokenServiceDeletionListener` plugged into `UserDeletionService` + so user-delete revokes every active refresh family alongside access + tokens / credentials / backup codes / OTPs. - `pk-auth-persistence-jdbi`: `JdbiAccessTokenStore` backed by the new `access_tokens` table (Flyway V8). - `pk-auth-persistence-dynamodb`: `DynamoDbAccessTokenStore` using two items diff --git a/README.md b/README.md index ceb629c..28d7bfa 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ All modules share the same version and `com.codeheadsystems` group id. | `pk-auth-backup-codes` | [![Maven Central: pk-auth-backup-codes](https://img.shields.io/maven-central/v/com.codeheadsystems/pk-auth-backup-codes?label=pk-auth-backup-codes)](https://central.sonatype.com/artifact/com.codeheadsystems/pk-auth-backup-codes) | Argon2id-hashed one-time backup codes. | | `pk-auth-magic-link` | [![Maven Central: pk-auth-magic-link](https://img.shields.io/maven-central/v/com.codeheadsystems/pk-auth-magic-link?label=pk-auth-magic-link)](https://central.sonatype.com/artifact/com.codeheadsystems/pk-auth-magic-link) | Single-use email magic-link tokens. | | `pk-auth-otp` | [![Maven Central: pk-auth-otp](https://img.shields.io/maven-central/v/com.codeheadsystems/pk-auth-otp?label=pk-auth-otp)](https://central.sonatype.com/artifact/com.codeheadsystems/pk-auth-otp) | 6-digit SMS OTP codes for phone verification. | +| `pk-auth-refresh-tokens` | [![Maven Central: pk-auth-refresh-tokens](https://img.shields.io/maven-central/v/com.codeheadsystems/pk-auth-refresh-tokens?label=pk-auth-refresh-tokens)](https://central.sonatype.com/artifact/com.codeheadsystems/pk-auth-refresh-tokens) | Rotating refresh tokens with family-based replay defense. *(1.1.0)* | | `pk-auth-persistence-jdbi` | [![Maven Central: pk-auth-persistence-jdbi](https://img.shields.io/maven-central/v/com.codeheadsystems/pk-auth-persistence-jdbi?label=pk-auth-persistence-jdbi)](https://central.sonatype.com/artifact/com.codeheadsystems/pk-auth-persistence-jdbi) | JDBI 3 + Flyway + Postgres SPI implementations. | | `pk-auth-persistence-dynamodb` | [![Maven Central: pk-auth-persistence-dynamodb](https://img.shields.io/maven-central/v/com.codeheadsystems/pk-auth-persistence-dynamodb?label=pk-auth-persistence-dynamodb)](https://central.sonatype.com/artifact/com.codeheadsystems/pk-auth-persistence-dynamodb) | AWS SDK v2 DynamoDB Enhanced SPI implementations (single-table). | | `pk-auth-testkit` | [![Maven Central: pk-auth-testkit](https://img.shields.io/maven-central/v/com.codeheadsystems/pk-auth-testkit?label=pk-auth-testkit)](https://central.sonatype.com/artifact/com.codeheadsystems/pk-auth-testkit) | `FakeAuthenticator`, in-memory SPIs, and fixtures for tests. | @@ -31,7 +32,17 @@ What you get out of the box: - WebAuthn registration and assertion ceremonies — including multi-passkey enrolment and conditional UI — backed by WebAuthn4J. - A stateless JWT mint at the end of authentication (HS256, configurable - TTL). + TTL) — or, with the 1.1.0 `AccessTokenStore` SPI, a stateful access + token that's revocable before its `exp`. +- Per-audience JWT TTL dispatch via `TokenTtlPolicy` so web / cli / + mobile clients can carry different access-token lifetimes from a single + issuer. +- **Rotating refresh tokens with family-based replay defense** *(1.1.0)* + — `POST /auth/refresh` is one ceremony / one row per rotation, with + motif-style atomic mark-and-insert and family scorch on detected + replay. The browser SDK's `PkAuthClient.refresh()` returns a typed + result sum, never throws on 401. See + [ADR 0013](./docs/adr/0013-refresh-tokens-family-rotation.md). - Account admin: list / rename / delete passkeys, regenerate view-once backup codes, account summary. - Alternative-flow modules: backup codes, magic-link email verification, diff --git a/clients/passkeys-browser/src/index.ts b/clients/passkeys-browser/src/index.ts index 7863ace..feb57bf 100644 --- a/clients/passkeys-browser/src/index.ts +++ b/clients/passkeys-browser/src/index.ts @@ -18,20 +18,40 @@ export type { StartRegistrationParams, } from "./ceremonies"; export { PkAuthAdminClient } from "./admin"; +export { PkAuthRefreshClient, isRefreshSuccess, isRefreshFailure } from "./refresh"; +export type { + RefreshResult, + RefreshSuccess, + RefreshFailure, + RefreshFailureReason, +} from "./refresh"; export type * from "./types"; import { PkAuthAdminClient } from "./admin"; import { PkAuthCeremonyClient } from "./ceremonies"; import type { CeremonyOptions } from "./ceremonies"; +import { PkAuthRefreshClient, type RefreshResult } from "./refresh"; import type { ClientOptions } from "./types"; -/** Convenience facade: a single object exposing both ceremony and admin clients. */ +/** Convenience facade: a single object exposing ceremony, admin, and refresh clients. */ export class PkAuthClient { readonly ceremonies: PkAuthCeremonyClient; readonly admin: PkAuthAdminClient; + readonly refreshClient: PkAuthRefreshClient; constructor(options: ClientOptions, ceremonyOptions: CeremonyOptions = {}) { this.ceremonies = new PkAuthCeremonyClient(options, ceremonyOptions); this.admin = new PkAuthAdminClient(options); + this.refreshClient = new PkAuthRefreshClient(options); + } + + /** + * Convenience shortcut to {@link PkAuthRefreshClient#refresh}. Returns a typed + * {@link RefreshResult} sum — does not throw on 401. + * + * @since 1.1.0 + */ + refresh(refreshToken: string): Promise { + return this.refreshClient.refresh(refreshToken); } } diff --git a/clients/passkeys-browser/src/refresh.ts b/clients/passkeys-browser/src/refresh.ts new file mode 100644 index 0000000..c0ff926 --- /dev/null +++ b/clients/passkeys-browser/src/refresh.ts @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT + +import { PkAuthHttpError, request } from "./http"; +import type { ClientOptions } from "./types"; + +/** Typed success: a fresh refresh + access token pair to swap in. */ +export interface RefreshSuccess { + readonly kind: "success"; + readonly accessToken: string; + readonly refreshToken: string; + readonly expiresAt: string; +} + +/** Typed failure mirroring the {@code detail} field on the server's 401 body. */ +export type RefreshFailureReason = "expired" | "unknown" | "replayed" | "revoked"; + +export interface RefreshFailure { + readonly kind: "failure"; + readonly reason: RefreshFailureReason; + /** Categorical revoke reason when `reason === "revoked"`. */ + readonly revokeReason?: string; +} + +/** Sealed sum of refresh outcomes. */ +export type RefreshResult = RefreshSuccess | RefreshFailure; + +/** Type guard for {@link RefreshSuccess}. */ +export function isRefreshSuccess(r: RefreshResult): r is RefreshSuccess { + return r.kind === "success"; +} + +/** Type guard for {@link RefreshFailure}. */ +export function isRefreshFailure(r: RefreshResult): r is RefreshFailure { + return r.kind === "failure"; +} + +interface RawRefreshResponse { + refreshToken: string; + accessToken: string; + expiresAt: string; +} + +interface RawErrorResponse { + detail?: string; + reason?: string; +} + +/** + * Client for the {@code POST /auth/refresh} endpoint shipped by every pk-auth adapter. Returns + * a typed {@link RefreshResult} rather than throwing on 401 — the bearer's expected response to + * an Unknown / Expired / Replayed / Revoked outcome is to redirect to login, not to handle an + * exception. + * + * @since 1.1.0 + */ +export class PkAuthRefreshClient { + private readonly options: ClientOptions; + private readonly path: string; + + constructor(options: ClientOptions, path: string = "/auth/refresh") { + this.options = options; + this.path = path; + } + + async refresh(refreshToken: string): Promise { + try { + const response = await request( + this.options, + "POST", + this.path, + { refreshToken }, + false, + ); + return { + kind: "success", + accessToken: response.accessToken, + refreshToken: response.refreshToken, + expiresAt: response.expiresAt, + }; + } catch (e: unknown) { + if (e instanceof PkAuthHttpError && e.status === 401) { + const data = e.data as RawErrorResponse | undefined; + const detail = data?.detail; + if ( + detail === "expired" || + detail === "unknown" || + detail === "replayed" || + detail === "revoked" + ) { + return { kind: "failure", reason: detail, revokeReason: data?.reason }; + } + // Server returned a 401 we don't recognise — surface it as the most conservative failure. + return { kind: "failure", reason: "unknown" }; + } + throw e; + } + } +} diff --git a/clients/passkeys-browser/test/refresh.test.ts b/clients/passkeys-browser/test/refresh.test.ts new file mode 100644 index 0000000..f6538c9 --- /dev/null +++ b/clients/passkeys-browser/test/refresh.test.ts @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT +import { describe, expect, it, vi } from "vitest"; +import { isRefreshFailure, isRefreshSuccess, PkAuthRefreshClient } from "../src/refresh"; + +function stubFetch(handler: (init: RequestInit) => { status: number; body: string }) { + return vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => { + const { status, body } = handler(init ?? {}); + return new Response(body, { status }); + }); +} + +describe("PkAuthRefreshClient", () => { + it("returns RefreshSuccess on 200", async () => { + const fetchImpl = stubFetch((init) => { + expect(init.method).toBe("POST"); + const body = JSON.parse(String(init.body)); + expect(body).toEqual({ refreshToken: "old.token" }); + return { + status: 200, + body: JSON.stringify({ + refreshToken: "new.token", + accessToken: "jwt-blob", + expiresAt: "2026-06-01T00:00:00Z", + }), + }; + }); + + const client = new PkAuthRefreshClient({ apiBase: "https://x", fetch: fetchImpl } as never); + const result = await client.refresh("old.token"); + + expect(isRefreshSuccess(result)).toBe(true); + if (isRefreshSuccess(result)) { + expect(result.accessToken).toBe("jwt-blob"); + expect(result.refreshToken).toBe("new.token"); + expect(result.expiresAt).toBe("2026-06-01T00:00:00Z"); + } + }); + + it.each(["expired", "unknown", "replayed", "revoked"] as const)( + "maps 401 with detail=%s to a typed RefreshFailure", + async (detail) => { + const fetchImpl = stubFetch(() => ({ + status: 401, + body: JSON.stringify({ detail }), + })); + const client = new PkAuthRefreshClient({ apiBase: "https://x", fetch: fetchImpl } as never); + const result = await client.refresh("old.token"); + expect(isRefreshFailure(result)).toBe(true); + if (isRefreshFailure(result)) { + expect(result.reason).toBe(detail); + } + }, + ); + + it("surfaces revokeReason when present", async () => { + const fetchImpl = stubFetch(() => ({ + status: 401, + body: JSON.stringify({ detail: "revoked", reason: "USER_DELETED" }), + })); + const client = new PkAuthRefreshClient({ apiBase: "https://x", fetch: fetchImpl } as never); + const result = await client.refresh("old.token"); + expect(isRefreshFailure(result)).toBe(true); + if (isRefreshFailure(result)) { + expect(result.reason).toBe("revoked"); + expect(result.revokeReason).toBe("USER_DELETED"); + } + }); + + it("falls back to 'unknown' on 401 with no/unfamiliar detail", async () => { + const fetchImpl = stubFetch(() => ({ status: 401, body: "{}" })); + const client = new PkAuthRefreshClient({ apiBase: "https://x", fetch: fetchImpl } as never); + const result = await client.refresh("old.token"); + expect(isRefreshFailure(result)).toBe(true); + if (isRefreshFailure(result)) { + expect(result.reason).toBe("unknown"); + } + }); + + it("rethrows non-401 HTTP errors", async () => { + const fetchImpl = stubFetch(() => ({ status: 500, body: "boom" })); + const client = new PkAuthRefreshClient({ apiBase: "https://x", fetch: fetchImpl } as never); + await expect(client.refresh("old.token")).rejects.toThrow(/HTTP 500/); + }); +}); diff --git a/docs/adr/0013-refresh-tokens-family-rotation.md b/docs/adr/0013-refresh-tokens-family-rotation.md new file mode 100644 index 0000000..c7a7f0a --- /dev/null +++ b/docs/adr/0013-refresh-tokens-family-rotation.md @@ -0,0 +1,173 @@ +# 13. Rotating refresh tokens with family-based replay detection + +Date: 2026-05-16 + +## Status + +Accepted. + +## Context + +Through 1.0, pk-auth issued stateless access JWTs and stopped there. Real +consumers — motif at `/home/wolpert/projects/motif` is the canonical example +— hit the same wall: short-lived access tokens (15 minutes for web, longer +for CLI) need a refresh primitive so the bearer doesn't re-authenticate +every cycle. Without one in the library, every consumer rolls their own +table, their own atomicity story, and their own replay defense. Several +roll versions with subtle races that a malicious bearer can exploit. + +The hard parts are: + +1. **Replay detection.** A leaked refresh token used after the legitimate + client has already rotated must be detectable. The detection has to be + provably atomic — a select-then-update sequence races against the legit + client and produces undefined behaviour. +2. **Response on replay.** Once detected, the entire session has to be + killed (both the attacker and the legit client must re-authenticate). + The cost is one re-auth for the user; the alternative is a leaked + session that survives the legit client's rotation. +3. **Atomic rotation.** Mark-parent-used and insert-successor must commit + together. A non-atomic sequence lets a concurrent rotator scorch the + family between mark and insert, leaving a fresh successor un-revoked + inside an otherwise-dead family. + +Motif solved this with a family model: every rotation chain shares a +`family_id` (the root token's `refresh_id`). Each rotation produces a child +in the same family with a link to the parent. Detection is a single +conditional `UPDATE`; response is a family-wide revoke. The same shape +generalises cleanly to pk-auth's other backends (DynamoDB via +`TransactWriteItems`, in-memory via a `ConcurrentHashMap.compute` block). + +## Decision + +Ship a new `pk-auth-refresh-tokens` module with: + +```java +public final class RefreshTokenService { + RefreshTokenPair issue(UserHandle, String audience, Optional deviceId); + RotateResult rotate(String presentedWireToken); // sealed sum + void revokeFamily(String familyId, RevokeReason reason); + int revokeAllForUser(UserHandle, RevokeReason reason); + List listForUser(UserHandle); +} +``` + +**Wire format.** `"{refreshId}.{secret}"` where both halves are base64url +(no padding). `refreshId` is 16 random bytes (22 chars); `secret` is 32 +random bytes (43 chars). The on-the-wire string is opaque to clients — +they never need to parse it. + +**Hash-at-rest.** `SHA-256(secret)` is persisted, the raw secret is not. +Single-shot, no salt — acceptable because the input is 256 bits from +`SecureRandom` and the hash is never exposed. + +**Hash-before-mark-used.** The service hashes the presented secret and +compares against the stored row hash *before* invoking the atomic +mark-used primitive. A presented refresh-id with the wrong secret returns +`Unknown`, never `Replayed`, and never sets `used_at` on the legitimate +row. This is an operational invariant; the parity test +`wrongSecretReturnsUnknownAndDoesNotBurnLegitToken` enforces it. + +**Atomic mark-and-insert via the SPI.** The `RefreshTokenRepository` SPI +exposes `rotateAtomically(parentRefreshId, now, successor)` returning +`true` iff the parent was fresh AND the successor was inserted, atomic on +every backend: + +- **JDBI:** `jdbi.inTransaction(...)` wrapping a single conditional + `UPDATE` on the parent and an `INSERT` for the successor. +- **DynamoDB:** `TransactWriteItems` with a `ConditionExpression` on the + parent's primary item (used_at + revoked_at attribute_not_exists, + expires_at > :now) and conditional puts for the successor's primary and + index items. +- **In-memory testkit:** `ConcurrentHashMap.compute(parentId, ...)` block + that checks the freshness predicate and inserts the successor under + `putIfAbsent` before returning the updated parent. + +**Family scorch outside the rotation.** When `rotateAtomically` returns +`false` (lost the race, or parent flipped used/revoked between read and +write), the service calls `revokeFamily(familyId, ROTATION_REPLAY)` +outside the failed-rotation scope. That revoke always commits, even if +the rotation transaction itself rolled back — losing rotators always +scorch. + +**Non-negotiable concurrent rotation race test.** A `CountDownLatch`-gated +test launches 8 threads all rotating the same root token simultaneously. +Exactly one thread returns `Success`; the other 7 return `Replayed`; the +entire family (root + the winner's successor) ends up revoked. This test +must pass against: + +- The in-memory testkit (drives the `compute` path) +- JDBI against a real Postgres container (drives the JDBI transaction + path) +- DynamoDB against DynamoDB Local (drives `TransactWriteItems`) + +The shared `RefreshTokenScenarios` class lives in `pk-auth-testkit` so +all three backends drive the same nine scenarios. + +**Sealed result type.** `RotateResult` has five variants: + +- `Success(pair, claimsForAccessIssue)` — winner's response +- `Replayed(familyId, userHandle)` — losers' response after family scorch +- `Expired()` — past-due token (no family revocation; expired is not a + replay signal) +- `Unknown()` — refresh-id not found, malformed wire token, or wrong + secret +- `Revoked(reason)` — family was previously scorched for any reason + +Adapters map `Success` → 200; the four failures → 401 with a typed +`detail` body. The browser SDK's `PkAuthClient.refresh(wireToken)` +returns a typed `RefreshResult` sum, never throws on 401. + +**Composition.** The service does not call `PkAuthJwtIssuer` itself. +`RotateResult.Success` carries a `RotatedClaims` projection (userHandle + +audience + deviceId) the caller hands to the issuer if it wants to mint a +fresh access JWT. Adapters provide a small `RefreshHandler` helper that +ties the two together; hosts that want their own access-token shape +compose differently. + +**User-deletion fan-out integration.** A `RefreshTokenServiceDeletionListener` +implements `UserDeletionListener` and calls `service.revokeAllForUser(handle, +USER_DELETED)`. Each adapter auto-registers it via the same DI mechanism +that wires the other listeners (Spring auto-collection, Dagger +`@ElementsIntoSet`, Micronaut `Collection`). + +## Consequences + +- **Pro**: Server-revocable session model end-to-end. Consumers like + motif can delete their `RefreshTokenManager` and the supporting code + paths. +- **Pro**: The family-scorch + atomic-rotate combo provably detects + replay regardless of which side (attacker or legit client) presents + first. The concurrent race test is the proof at the persistence + boundary. +- **Pro**: Composability preserved — `RefreshTokenService` and + `PkAuthJwtIssuer` are independent. Hosts assemble them as they like. +- **Pro**: Operator-rare admin actions (logout, "log out everywhere", + user delete) all reduce to a single SPI call. +- **Con**: One database table (Postgres `refresh_tokens` / DynamoDB + `RT#/RTU#/RTF#` items). Operators need a daily cleanup cron — documented + in `docs/operator-guide.md`. +- **Con**: The DynamoDB three-item layout (primary + user-index + + family-index) writes 4 items per rotation (parent update + 3 successor + items). For high-rotation workloads this is the main cost driver — but + refresh rotations are by definition infrequent (once per access-token + lifetime) so the total throughput is small. +- **Con**: A false-positive replay (legit client's next refresh hits an + already-revoked family for any reason) requires re-authentication. The + user sees a single login prompt; the cost is one ceremony. Acceptable. + +## Open follow-ups + +- A device-binding field on `RefreshTokenRecord` is reserved (the SPI + carries `Optional deviceId`) but neither the issuer nor the + refresh handler currently populates it from a typed device-id concept. + When pk-auth adds a `DeviceRegistry` SPI (post-1.1) the field becomes + load-bearing for per-device revocation. +- A `RefreshTtlPolicy` based on JWT audience parallels the access-token + `TokenTtlPolicy` (ADR 0014). They're independent; hosts can serve + web=15m-access/14d-refresh and cli=1h-access/90d-refresh from one + configuration block. +- The refresh-token row currently doesn't store the original auth method + (passkey vs backup-code vs magic-link) — the new access token minted + on rotation always carries `AuthMethod.REFRESH`. Hosts that need the + original provenance must look it up via their own session table. diff --git a/docs/operator-guide.md b/docs/operator-guide.md index f736b6a..9ba237c 100644 --- a/docs/operator-guide.md +++ b/docs/operator-guide.md @@ -55,9 +55,41 @@ variables (`PKAUTH_JWT_SECRET`, `PKAUTH_OTP_PEPPER`). The adapters bind both. adapter does not create it. - TTL attribute `expiresAt` is honored on `Challenge` / `OneTimePasscode` / `MagicLink` items — enable it on the table. +- 1.1.0 adds `access_tokens` and `refresh_tokens` items on the same table (ADR + 0015, 0013). Both set the DynamoDB-native `ttl` attribute to the row's + `expiresAt` epoch second so background pruning is automatic — TTL must be + enabled on the table for this to work. - Capacity-mode: on-demand is recommended for steady reads but bursty registration; provisioned only makes sense once you have a stable signing/verification baseline. +### Token-table cleanup (1.1.0) + +The new stateful access-token store (ADR 0015) and refresh-token store +(ADR 0013) keep used/revoked rows around for a configurable retention +window so operators have a forensic trail. Schedule a daily cleanup job: + +**JDBI / Postgres** — call the SPI methods or run the canonical SQL: + +```sql +-- Access tokens: drop rows whose exp has passed. +DELETE FROM access_tokens WHERE expires_at < NOW() - INTERVAL '1 day'; + +-- Refresh tokens: keep used/revoked rows for the configured retention +-- (default 30 days) so a forensic look-back survives. +DELETE FROM refresh_tokens + WHERE expires_at < NOW() - INTERVAL '30 days' + AND (used_at IS NOT NULL OR revoked_at IS NOT NULL); +``` + +**DynamoDB** — native TTL handles routine expiry asynchronously. If you +need synchronous pruning (operator action / test), call +`DynamoDbAccessTokenStore.deleteExpiredBefore(Instant)` and +`DynamoDbRefreshTokenRepository.deleteExpiredBefore(Instant)` — +both walk the primary items and remove anything past the cutoff. + +A daily cron is sufficient for both tables; neither row count grows +unboundedly because TTL is set at issue time. + ## 4. Observability Every ceremony and admin operation emits structured logs at INFO. Suggested fields diff --git a/docs/threat-model.md b/docs/threat-model.md index 21bd0bc..d9a2e2e 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -90,19 +90,55 @@ Boundaries cross-checked in this model: ## Token revocation -JWTs issued by pk-auth are valid until their `exp` claim by default — there is no built-in -revocation list. This is an intentional trade-off: stateless tokens simplify scaling, but -they mean a stolen token is valid until expiry. - -Hosts that require early revocation (logout-all, account-disable, credential compromise -response) should implement the `RevocationCheck` SPI added in `pk-auth-jwt`. The SPI -receives the full JWT claims on every validated request and must return a boolean; the -adapter rejects the token if the hook returns `true`. A Redis set of revoked JTIs is the -typical implementation. - -Until `RevocationCheck` is wired, keep `pkauth.jwt.ttl` short (≤ 1 hour) and educate -users to use browser logout (which discards the client-side token) rather than relying on -server-side invalidation. +Two complementary primitives ship with pk-auth as of 1.1.0: + +1. **`AccessTokenStore` (ADR 0015)** — positive allow-list. Every issued JWT's JTI is + persisted server-side; the validator looks it up on every request. Deleting the row + (logout, admin revoke, password reset, user delete) immediately invalidates the + bearer, well before its `exp`. This is the paved-road for revocability. +2. **`RevocationCheck` (1.0)** — negative deny-list. Lightweight in-process hook for hosts + that issue many millions of tokens per day and want to invalidate a small subset + proactively (e.g. a "session revoked" stream from a security event bus). Still + supported; orthogonal to `AccessTokenStore`. + +The default `AccessTokenStore.noop()` keeps the legacy stateless-JWT behaviour for hosts +that prefer it — the call costs an always-true return. Choose the binding based on traffic +shape, not as a feature toggle. + +For session-lifetime management (re-using a single login across days/weeks without +re-authenticating), the rotating refresh-token primitive is the paved road — see +"Refresh-token replay defense" below. + +## Refresh-token replay defense + +Rotating refresh tokens with family-based replay detection are shipped via +`pk-auth-refresh-tokens` (ADR 0013). The properties the design guarantees: + +- **Atomic mark-and-insert.** The SPI's `rotateAtomically` primitive marks the parent + used AND inserts the successor as a single atomic operation (JDBI transaction, + DynamoDB `TransactWriteItems`, or in-memory `compute` block). A non-atomic sequence + has a window where a concurrent rotator's family-scorch can miss the freshly-inserted + successor; the SPI contract forbids this and the concurrent-race test enforces it. +- **Hash-before-mark-used.** The service hashes the presented secret and compares + against the stored row hash *before* invoking `rotateAtomically`. A presented + refresh-id with the wrong secret returns `Unknown`, never burns the legitimate + token's `used_at`. This is enforced by the + `wrongSecretReturnsUnknownAndDoesNotBurnLegitToken` parity test on every backend. +- **Replay → family scorch.** Re-presenting a used or revoked refresh from a known + family triggers a `revokeFamily(familyId, ROTATION_REPLAY)` call that runs outside + the failed rotation. The result is that BOTH the attacker AND the legitimate client + lose the session — the legit user sees their next refresh fail and is redirected to + login. The cost is one ceremony; the alternative is a leaked session that survives + the legit client's rotation. +- **Concurrent rotation: exactly one wins.** The non-negotiable race test launches 8 + threads rotating the same token simultaneously. Exactly one thread returns + `RotateResult.Success`; the other 7 return `RotateResult.Replayed`; the entire + family (root + the winner's successor) is revoked. This test passes against + Postgres (JDBI transaction path) and DynamoDB Local (`TransactWriteItems` path) on + every CI run. +- **Hash-at-rest.** The 32-byte secret is SHA-256-hashed before storage; the raw + secret is never persisted. The wire token (`{refreshId}.{secret}`, base64url on + both halves) must NEVER be logged. ## Data at rest diff --git a/pk-auth-dropwizard/build.gradle.kts b/pk-auth-dropwizard/build.gradle.kts index 360ea80..379821e 100644 --- a/pk-auth-dropwizard/build.gradle.kts +++ b/pk-auth-dropwizard/build.gradle.kts @@ -36,6 +36,7 @@ tasks.named("compileTestJava") { dependencies { api(project(":pk-auth-core")) api(project(":pk-auth-jwt")) + api(project(":pk-auth-refresh-tokens")) api(libs.dropwizard.core) api(libs.dropwizard.auth) api(libs.dagger) diff --git a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/PkAuthBundle.java b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/PkAuthBundle.java index 9f7f68e..ad01228 100644 --- a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/PkAuthBundle.java +++ b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/PkAuthBundle.java @@ -15,8 +15,10 @@ import com.codeheadsystems.pkauth.dropwizard.dagger.PkAuthFullComponent; import com.codeheadsystems.pkauth.dropwizard.dagger.PkAuthModule; import com.codeheadsystems.pkauth.dropwizard.json.PkAuthJacksonBridge; +import com.codeheadsystems.pkauth.dropwizard.resource.PkAuthRefreshResource; import com.codeheadsystems.pkauth.jwt.PkAuthJwtIssuer; import com.codeheadsystems.pkauth.jwt.PkAuthJwtValidator; +import com.codeheadsystems.pkauth.refresh.web.RefreshHandler; import io.dropwizard.auth.AuthDynamicFeature; import io.dropwizard.auth.AuthValueFactoryProvider; import io.dropwizard.core.ConfiguredBundle; @@ -148,7 +150,8 @@ public void run(C configuration, Environment environment) { fullComponent.ceremonyResource(), fullComponent.passkeyAuthenticator(), fullComponent.jwtIssuer(), - fullComponent.jwtValidator()); + fullComponent.jwtValidator(), + fullComponent.refreshHandler().orElse(null)); } else { this.component = DaggerPkAuthComponent.builder().pkAuthModule(pkAuthModule).build(); wiring = @@ -156,7 +159,8 @@ public void run(C configuration, Environment environment) { component.ceremonyResource(), component.passkeyAuthenticator(), component.jwtIssuer(), - component.jwtValidator()); + component.jwtValidator(), + component.refreshHandler().orElse(null)); } // Ensure the runtime ObjectMapper has the bridge too (the environment may build its own copy @@ -184,6 +188,11 @@ public void run(C configuration, Environment environment) { LOG.info("pkauth.admin.endpoints.registered path=/auth/admin (host-built AdminService)"); } + if (wiring.refreshHandler() != null) { + environment.jersey().register(new PkAuthRefreshResource(wiring.refreshHandler())); + LOG.info("pkauth.refresh.endpoint.registered path=/auth/refresh"); + } + LOG.info( "pkauth.bundle.started rp={} issuer={}", configuration.pkAuth().relyingParty().id(), @@ -238,5 +247,6 @@ private record PkAuthCeremonyWiring( com.codeheadsystems.pkauth.dropwizard.resource.PkAuthCeremonyResource ceremonyResource, PkAuthDropwizardAuthenticator authenticator, PkAuthJwtIssuer jwtIssuer, - PkAuthJwtValidator jwtValidator) {} + PkAuthJwtValidator jwtValidator, + @Nullable RefreshHandler refreshHandler) {} } diff --git a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/config/PkAuthConfig.java b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/config/PkAuthConfig.java index 2b6893e..bdfd5c3 100644 --- a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/config/PkAuthConfig.java +++ b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/config/PkAuthConfig.java @@ -1,6 +1,8 @@ // SPDX-License-Identifier: MIT package com.codeheadsystems.pkauth.dropwizard.config; +import com.codeheadsystems.pkauth.refresh.RefreshTokenConfig; +import com.codeheadsystems.pkauth.refresh.RefreshTtlPolicy; import java.time.Duration; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -37,7 +39,8 @@ public record PkAuthConfig( Ceremony ceremony, @Nullable Otp otp, @Nullable MagicLink magicLink, - @Nullable BackupCode backupCode) { + @Nullable BackupCode backupCode, + @Nullable Refresh refresh) { public PkAuthConfig { Objects.requireNonNull(relyingParty, "relyingParty"); @@ -47,12 +50,28 @@ public record PkAuthConfig( /** * Backwards-compatible four-arg constructor for hosts that do not configure alt-flow services. - * Equivalent to {@code new PkAuthConfig(relyingParty, jwt, ceremony, null, null, null)}. + * Equivalent to {@code new PkAuthConfig(relyingParty, jwt, ceremony, null, null, null, null)}. * * @since 0.9.1 */ public PkAuthConfig(RelyingParty relyingParty, Jwt jwt, Ceremony ceremony) { - this(relyingParty, jwt, ceremony, null, null, null); + this(relyingParty, jwt, ceremony, null, null, null, null); + } + + /** + * Six-arg constructor preserved for callers that pre-date the refresh-token block. Equivalent to + * passing {@code null} for {@code refresh}. + * + * @since 0.9.1 + */ + public PkAuthConfig( + RelyingParty relyingParty, + Jwt jwt, + Ceremony ceremony, + @Nullable Otp otp, + @Nullable MagicLink magicLink, + @Nullable BackupCode backupCode) { + this(relyingParty, jwt, ceremony, otp, magicLink, backupCode, null); } /** @@ -191,4 +210,46 @@ public record MagicLink(String baseUrl) { * @since 0.9.1 */ public record BackupCode() {} + + /** + * Refresh-token tunables. Only consulted when the host wires a {@link + * com.codeheadsystems.pkauth.refresh.spi.RefreshTokenRepository} via {@link + * com.codeheadsystems.pkauth.dropwizard.dagger.PersistenceBindings.Builder#refreshTokenRepository}. + * + * @param defaultTtl how long a refresh token lasts when its audience isn't in {@code + * ttlsByAudience}; null defaults to {@link RefreshTokenConfig#DEFAULT_REFRESH_TTL}. + * @param ttlsByAudience per-audience refresh TTL overrides. + * @param cleanupRetention forensic-retention window; null defaults to {@link + * RefreshTokenConfig#DEFAULT_CLEANUP_RETENTION}. + * @since 1.1.0 + */ + public record Refresh( + @Nullable Duration defaultTtl, + @Nullable Map ttlsByAudience, + @Nullable Duration cleanupRetention) { + + /** Default block: every value null (effective defaults from {@link RefreshTokenConfig}). */ + public Refresh() { + this(null, null, null); + } + + /** Builds a {@link RefreshTokenConfig} from this block. */ + public RefreshTokenConfig toRefreshTokenConfig() { + Duration ttl = defaultTtl == null ? RefreshTokenConfig.DEFAULT_REFRESH_TTL : defaultTtl; + Map overrides = ttlsByAudience; + RefreshTtlPolicy policy = + overrides == null || overrides.isEmpty() + ? RefreshTtlPolicy.single(ttl) + : RefreshTtlPolicy.fixed(ttl, overrides); + Duration retention = + cleanupRetention == null + ? RefreshTokenConfig.DEFAULT_CLEANUP_RETENTION + : cleanupRetention; + return new RefreshTokenConfig( + policy, + RefreshTokenConfig.DEFAULT_SECRET_BYTES, + RefreshTokenConfig.DEFAULT_REFRESH_ID_BYTES, + retention); + } + } } diff --git a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PersistenceBindings.java b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PersistenceBindings.java index 60ae9c4..77e5351 100644 --- a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PersistenceBindings.java +++ b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PersistenceBindings.java @@ -2,6 +2,7 @@ package com.codeheadsystems.pkauth.dropwizard.dagger; import com.codeheadsystems.pkauth.jwt.AccessTokenStore; +import com.codeheadsystems.pkauth.refresh.spi.RefreshTokenRepository; import com.codeheadsystems.pkauth.spi.BackupCodeRepository; import com.codeheadsystems.pkauth.spi.ChallengeStore; import com.codeheadsystems.pkauth.spi.CredentialRepository; @@ -27,6 +28,7 @@ public final class PersistenceBindings { @Nullable private final BackupCodeRepository backupCodeRepository; @Nullable private final OtpRepository otpRepository; private final AccessTokenStore accessTokenStore; + @Nullable private final RefreshTokenRepository refreshTokenRepository; private PersistenceBindings(Builder b) { this.credentialRepository = @@ -37,6 +39,7 @@ private PersistenceBindings(Builder b) { this.otpRepository = b.otpRepository; this.accessTokenStore = b.accessTokenStore == null ? AccessTokenStore.noop() : b.accessTokenStore; + this.refreshTokenRepository = b.refreshTokenRepository; } public static Builder builder() { @@ -77,6 +80,18 @@ public AccessTokenStore accessTokenStore() { return accessTokenStore; } + /** + * Returns the configured {@link RefreshTokenRepository}, or {@code null} when the host hasn't + * wired refresh tokens. The bundle's alt-flow auto-wiring path mounts {@code /auth/refresh} only + * when this is non-null; the slim-component path does not mount refresh endpoints in 1.1.0. + * + * @since 1.1.0 + */ + @Nullable + public RefreshTokenRepository refreshTokenRepository() { + return refreshTokenRepository; + } + /** Builder. */ public static final class Builder { @Nullable private CredentialRepository credentialRepository; @@ -85,6 +100,7 @@ public static final class Builder { @Nullable private BackupCodeRepository backupCodeRepository; @Nullable private OtpRepository otpRepository; @Nullable private AccessTokenStore accessTokenStore; + @Nullable private RefreshTokenRepository refreshTokenRepository; private Builder() {} @@ -124,6 +140,17 @@ public Builder accessTokenStore(AccessTokenStore v) { return this; } + /** + * Supplies a {@link RefreshTokenRepository} so the bundle's alt-flow path can mount {@code + * /auth/refresh}. Omit to skip refresh-token endpoint registration. + * + * @since 1.1.0 + */ + public Builder refreshTokenRepository(RefreshTokenRepository v) { + this.refreshTokenRepository = v; + return this; + } + public PersistenceBindings build() { return new PersistenceBindings(this); } diff --git a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PkAuthComponent.java b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PkAuthComponent.java index 3f4f009..8d67c3b 100644 --- a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PkAuthComponent.java +++ b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PkAuthComponent.java @@ -5,8 +5,11 @@ import com.codeheadsystems.pkauth.dropwizard.resource.PkAuthCeremonyResource; import com.codeheadsystems.pkauth.jwt.PkAuthJwtIssuer; import com.codeheadsystems.pkauth.jwt.PkAuthJwtValidator; +import com.codeheadsystems.pkauth.lifecycle.UserDeletionService; +import com.codeheadsystems.pkauth.refresh.web.RefreshHandler; import dagger.Component; import jakarta.inject.Singleton; +import java.util.Optional; /** * Dagger 2 component that materializes everything the bundle hands to Jersey. Brief §6.11 — @@ -31,4 +34,15 @@ public interface PkAuthComponent { /** The authenticator the bundle plugs into Dropwizard's {@code AuthDynamicFeature}. */ PkAuthDropwizardAuthenticator passkeyAuthenticator(); + + /** User-deletion fan-out service. Always present; the listener set may be empty. */ + UserDeletionService userDeletionService(); + + /** + * Refresh handler; present only when {@code PersistenceBindings.refreshTokenRepository} is + * non-null. The bundle uses presence to decide whether to mount {@code /auth/refresh}. + * + * @since 1.1.0 + */ + Optional refreshHandler(); } diff --git a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PkAuthFullComponent.java b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PkAuthFullComponent.java index fbf0a50..00553e9 100644 --- a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PkAuthFullComponent.java +++ b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PkAuthFullComponent.java @@ -8,10 +8,13 @@ import com.codeheadsystems.pkauth.dropwizard.resource.PkAuthCeremonyResource; import com.codeheadsystems.pkauth.jwt.PkAuthJwtIssuer; import com.codeheadsystems.pkauth.jwt.PkAuthJwtValidator; +import com.codeheadsystems.pkauth.lifecycle.UserDeletionService; import com.codeheadsystems.pkauth.magiclink.MagicLinkService; import com.codeheadsystems.pkauth.otp.OtpService; +import com.codeheadsystems.pkauth.refresh.web.RefreshHandler; import dagger.Component; import jakarta.inject.Singleton; +import java.util.Optional; /** * Dagger component that wires the passkey ceremony graph and the alt-flow services @@ -55,4 +58,15 @@ public interface PkAuthFullComponent { /** The admin resource the bundle mounts at {@code /auth/admin}. */ PkAuthAdminResource adminResource(); + + /** User-deletion fan-out service. */ + UserDeletionService userDeletionService(); + + /** + * Refresh handler; present only when {@code PersistenceBindings.refreshTokenRepository} is + * non-null. + * + * @since 1.1.0 + */ + Optional refreshHandler(); } diff --git a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PkAuthModule.java b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PkAuthModule.java index cfba502..e5b99fd 100644 --- a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PkAuthModule.java +++ b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PkAuthModule.java @@ -20,6 +20,11 @@ import com.codeheadsystems.pkauth.lifecycle.CredentialRepositoryDeletionListener; import com.codeheadsystems.pkauth.lifecycle.UserDeletionListener; import com.codeheadsystems.pkauth.lifecycle.UserDeletionService; +import com.codeheadsystems.pkauth.refresh.RefreshTokenConfig; +import com.codeheadsystems.pkauth.refresh.RefreshTokenService; +import com.codeheadsystems.pkauth.refresh.RefreshTokenServiceDeletionListener; +import com.codeheadsystems.pkauth.refresh.spi.RefreshTokenRepository; +import com.codeheadsystems.pkauth.refresh.web.RefreshHandler; import com.codeheadsystems.pkauth.spi.CeremonyRateLimiter; import com.codeheadsystems.pkauth.spi.ChallengeStore; import com.codeheadsystems.pkauth.spi.ClockProvider; @@ -31,6 +36,7 @@ import jakarta.inject.Singleton; import java.time.Duration; import java.util.Map; +import java.util.Optional; import java.util.Set; /** @@ -205,6 +211,50 @@ UserDeletionService provideUserDeletionService(Set listene return new UserDeletionService(listeners); } + // -- Refresh tokens (only active when PersistenceBindings.refreshTokenRepository != null) ---- + + @Provides + @Singleton + RefreshTokenConfig provideRefreshTokenConfig(PkAuthConfig cfg) { + PkAuthConfig.Refresh refresh = cfg.refresh(); + return (refresh == null ? new PkAuthConfig.Refresh() : refresh).toRefreshTokenConfig(); + } + + /** + * Provides an {@code Optional} threaded through Dagger so the component can + * surface a nullable value without forcing every downstream graph to know about refresh tokens. + * Empty when {@code PersistenceBindings.refreshTokenRepository()} is null — the bundle then skips + * registering the refresh resource. + */ + @Provides + @Singleton + Optional provideRefreshHandler( + RefreshTokenConfig refreshConfig, ClockProvider clockProvider, PkAuthJwtIssuer accessIssuer) { + RefreshTokenRepository repo = persistence.refreshTokenRepository(); + if (repo == null) { + return Optional.empty(); + } + RefreshTokenService service = new RefreshTokenService(repo, refreshConfig, clockProvider); + return Optional.of(new RefreshHandler(service, accessIssuer)); + } + + /** + * Optional refresh-token deletion listener. When refresh tokens aren't wired, contributes an + * empty set to the {@link UserDeletionService}'s listener multibinding — the deletion fan-out + * silently skips the refresh branch. + */ + @Provides + @dagger.multibindings.ElementsIntoSet + Set provideRefreshDeletionListener( + RefreshTokenConfig refreshConfig, ClockProvider clockProvider) { + RefreshTokenRepository repo = persistence.refreshTokenRepository(); + if (repo == null) { + return Set.of(); + } + RefreshTokenService service = new RefreshTokenService(repo, refreshConfig, clockProvider); + return Set.of(new RefreshTokenServiceDeletionListener(service)); + } + @Provides @Singleton PkAuthDropwizardAuthenticator providePasskeyAuthenticator(PkAuthJwtValidator validator) { diff --git a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/resource/PkAuthRefreshResource.java b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/resource/PkAuthRefreshResource.java new file mode 100644 index 0000000..42f44a3 --- /dev/null +++ b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/resource/PkAuthRefreshResource.java @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.dropwizard.resource; + +import com.codeheadsystems.pkauth.refresh.web.RefreshHandler; +import com.codeheadsystems.pkauth.refresh.web.RefreshHandler.Outcome; +import com.codeheadsystems.pkauth.refresh.web.RefreshRequest; +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.util.Objects; + +/** + * Refresh endpoint mounted at {@code POST /auth/refresh}. Wraps {@link RefreshHandler}, which is + * shared with the Spring and Micronaut adapters so rotation logic and error mapping live in one + * place. Returns {@code 200} on success and {@code 401} with a typed {@code detail} body on any + * failure. + * + * @since 1.1.0 + */ +@Path("/auth/refresh") +@Consumes(MediaType.APPLICATION_JSON) +@Produces(MediaType.APPLICATION_JSON) +public final class PkAuthRefreshResource { + + private final RefreshHandler handler; + + @Inject + public PkAuthRefreshResource(RefreshHandler handler) { + this.handler = Objects.requireNonNull(handler, "handler"); + } + + @POST + public Response refresh(RefreshRequest request) { + Outcome outcome = handler.handle(request); + return switch (outcome) { + case Outcome.Success s -> Response.ok(s.response()).build(); + case Outcome.Failure f -> + Response.status(Response.Status.UNAUTHORIZED).entity(f.response()).build(); + }; + } +} diff --git a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/AuthMethod.java b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/AuthMethod.java index c0d51af..400bcdf 100644 --- a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/AuthMethod.java +++ b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/AuthMethod.java @@ -8,7 +8,16 @@ public enum AuthMethod { PASSKEY("passkey"), BACKUP_CODE("backup-code"), - MAGIC_LINK("magic-link"); + MAGIC_LINK("magic-link"), + + /** + * Token minted as the access leg of a refresh-token rotation. The original auth method that + * established the session is not preserved on the new JWT; hosts that care about provenance + * should look it up via the refresh-token row. + * + * @since 1.1.0 + */ + REFRESH("refresh"); private final String wireValue; diff --git a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtClaims.java b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtClaims.java index 7f9c4eb..1d29a91 100644 --- a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtClaims.java +++ b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtClaims.java @@ -95,6 +95,16 @@ public static JwtClaims forMagicLink(UserHandle userHandle, String audience, Lis return new JwtClaims(userHandle, AuthMethod.MAGIC_LINK, null, amr, null, audience); } + /** + * Convenience factory for refresh-derived access tokens (minted as the access leg of a refresh + * rotation). Always carries {@link AuthMethod#REFRESH}. + * + * @since 1.1.0 + */ + public static JwtClaims forRefresh(UserHandle userHandle, String audience, List amr) { + return new JwtClaims(userHandle, AuthMethod.REFRESH, null, amr, null, audience); + } + @Override public byte @Nullable [] credentialId() { return credentialId == null ? null : credentialId.clone(); diff --git a/pk-auth-micronaut/build.gradle.kts b/pk-auth-micronaut/build.gradle.kts index b890681..61e4d83 100644 --- a/pk-auth-micronaut/build.gradle.kts +++ b/pk-auth-micronaut/build.gradle.kts @@ -25,6 +25,7 @@ dependencies { api(project(":pk-auth-backup-codes")) api(project(":pk-auth-magic-link")) api(project(":pk-auth-otp")) + api(project(":pk-auth-refresh-tokens")) api(project(":pk-auth-admin-api")) api(libs.micronaut.context) api(libs.micronaut.http) diff --git a/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthConfiguration.java b/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthConfiguration.java index 9e44ae7..ccb0f62 100644 --- a/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthConfiguration.java +++ b/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthConfiguration.java @@ -26,6 +26,8 @@ public final class PkAuthConfiguration { private Otp otp = new Otp(); private boolean devMode; + private Refresh refresh = new Refresh(); + public RelyingParty getRelyingParty() { return relyingParty; } @@ -58,6 +60,20 @@ public void setOtp(Otp v) { this.otp = v; } + /** + * Refresh-token tunables. Only consulted when a {@code RefreshTokenRepository} bean is bound in + * the host context. + * + * @since 1.1.0 + */ + public Refresh getRefresh() { + return refresh; + } + + public void setRefresh(Refresh refresh) { + this.refresh = refresh; + } + /** * Whether to enable in-memory testkit SPIs and dev-only logging senders, plus per-startup random * OTP pepper auto-generation when {@code pkauth.otp.pepper} is unset. Defaults to {@code false} — @@ -211,4 +227,43 @@ public void setPepper(@Nullable String pepper) { this.pepper = pepper; } } + + /** + * Refresh-token block. {@code defaultTtl} and {@code ttlsByAudience} feed the {@link + * com.codeheadsystems.pkauth.refresh.RefreshTtlPolicy}; {@code cleanupRetention} sets the + * forensic retention window. Null fields fall through to {@link + * com.codeheadsystems.pkauth.refresh.RefreshTokenConfig#defaults()}. + * + * @since 1.1.0 + */ + @ConfigurationProperties("refresh") + public static final class Refresh { + private @Nullable Duration defaultTtl; + private @Nullable Map ttlsByAudience; + private @Nullable Duration cleanupRetention; + + public @Nullable Duration getDefaultTtl() { + return defaultTtl; + } + + public void setDefaultTtl(@Nullable Duration defaultTtl) { + this.defaultTtl = defaultTtl; + } + + public @Nullable Map getTtlsByAudience() { + return ttlsByAudience; + } + + public void setTtlsByAudience(@Nullable Map ttlsByAudience) { + this.ttlsByAudience = ttlsByAudience; + } + + public @Nullable Duration getCleanupRetention() { + return cleanupRetention; + } + + public void setCleanupRetention(@Nullable Duration cleanupRetention) { + this.cleanupRetention = cleanupRetention; + } + } } diff --git a/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthFactory.java b/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthFactory.java index a289d0f..7d5acf4 100644 --- a/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthFactory.java +++ b/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthFactory.java @@ -29,6 +29,12 @@ import com.codeheadsystems.pkauth.otp.OtpPepperResolver; import com.codeheadsystems.pkauth.otp.OtpService; import com.codeheadsystems.pkauth.otp.SmsSender; +import com.codeheadsystems.pkauth.refresh.RefreshTokenConfig; +import com.codeheadsystems.pkauth.refresh.RefreshTokenService; +import com.codeheadsystems.pkauth.refresh.RefreshTokenServiceDeletionListener; +import com.codeheadsystems.pkauth.refresh.RefreshTtlPolicy; +import com.codeheadsystems.pkauth.refresh.spi.RefreshTokenRepository; +import com.codeheadsystems.pkauth.refresh.web.RefreshHandler; import com.codeheadsystems.pkauth.spi.BackupCodeRepository; import com.codeheadsystems.pkauth.spi.CeremonyRateLimiter; import com.codeheadsystems.pkauth.spi.ChallengeStore; @@ -185,6 +191,50 @@ UserDeletionService userDeletionService(Collection listene return new UserDeletionService(new ArrayList<>(listeners)); } + // -- Refresh tokens (only active when a RefreshTokenRepository bean is wired) ---------------- + + @Singleton + RefreshTokenConfig refreshTokenConfig(PkAuthConfiguration config) { + PkAuthConfiguration.Refresh refresh = config.getRefresh(); + Duration defaultTtl = + refresh.getDefaultTtl() == null + ? RefreshTokenConfig.DEFAULT_REFRESH_TTL + : refresh.getDefaultTtl(); + Map overrides = refresh.getTtlsByAudience(); + RefreshTtlPolicy policy = + overrides == null || overrides.isEmpty() + ? RefreshTtlPolicy.single(defaultTtl) + : RefreshTtlPolicy.fixed(defaultTtl, overrides); + Duration retention = + refresh.getCleanupRetention() == null + ? RefreshTokenConfig.DEFAULT_CLEANUP_RETENTION + : refresh.getCleanupRetention(); + return new RefreshTokenConfig( + policy, + RefreshTokenConfig.DEFAULT_SECRET_BYTES, + RefreshTokenConfig.DEFAULT_REFRESH_ID_BYTES, + retention); + } + + @Singleton + @Requires(beans = RefreshTokenRepository.class) + RefreshTokenService refreshTokenService( + RefreshTokenRepository repository, RefreshTokenConfig config, ClockProvider clockProvider) { + return new RefreshTokenService(repository, config, clockProvider); + } + + @Singleton + @Requires(beans = RefreshTokenService.class) + UserDeletionListener refreshTokenServiceDeletionListener(RefreshTokenService service) { + return new RefreshTokenServiceDeletionListener(service); + } + + @Singleton + @Requires(beans = RefreshTokenService.class) + RefreshHandler refreshHandler(RefreshTokenService service, PkAuthJwtIssuer issuer) { + return new RefreshHandler(service, issuer); + } + /** * Default in-memory {@link CeremonyRateLimiter} — hosts running more than one replica MUST supply * a shared (Redis / DB-backed) bean to replace this. See {@link InMemoryCeremonyRateLimiter} diff --git a/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthRefreshController.java b/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthRefreshController.java new file mode 100644 index 0000000..41f4370 --- /dev/null +++ b/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthRefreshController.java @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.micronaut; + +import com.codeheadsystems.pkauth.refresh.web.RefreshHandler; +import com.codeheadsystems.pkauth.refresh.web.RefreshHandler.Outcome; +import com.codeheadsystems.pkauth.refresh.web.RefreshRequest; +import io.micronaut.context.annotation.Requires; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.MediaType; +import io.micronaut.http.annotation.Body; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Post; +import io.micronaut.http.annotation.Produces; +import io.micronaut.scheduling.TaskExecutors; +import io.micronaut.scheduling.annotation.ExecuteOn; + +/** + * Mounts the refresh endpoint at {@code POST /auth/refresh}. Only registered when a {@link + * RefreshHandler} bean is present (which itself requires a {@code RefreshTokenRepository}). The + * controller is a thin wrapper around the shared handler — rotation logic and error mapping live in + * {@code pk-auth-refresh-tokens}. + * + * @since 1.1.0 + */ +@Controller("/auth/refresh") +@Produces(MediaType.APPLICATION_JSON) +@ExecuteOn(TaskExecutors.BLOCKING) +@Requires(beans = RefreshHandler.class) +public final class PkAuthRefreshController { + + private final RefreshHandler handler; + + public PkAuthRefreshController(RefreshHandler handler) { + this.handler = handler; + } + + @Post + public HttpResponse refresh(@Body RefreshRequest request) { + Outcome outcome = handler.handle(request); + return switch (outcome) { + case Outcome.Success s -> HttpResponse.ok(s.response()); + case Outcome.Failure f -> + HttpResponse.status(HttpStatus.UNAUTHORIZED).body(f.response()); + }; + } +} diff --git a/pk-auth-micronaut/src/test/java/com/codeheadsystems/pkauth/micronaut/PkAuthRefreshControllerTest.java b/pk-auth-micronaut/src/test/java/com/codeheadsystems/pkauth/micronaut/PkAuthRefreshControllerTest.java new file mode 100644 index 0000000..4fc6fa6 --- /dev/null +++ b/pk-auth-micronaut/src/test/java/com/codeheadsystems/pkauth/micronaut/PkAuthRefreshControllerTest.java @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.micronaut; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.codeheadsystems.pkauth.api.UserHandle; +import com.codeheadsystems.pkauth.refresh.RefreshTokenPair; +import com.codeheadsystems.pkauth.refresh.RefreshTokenService; +import com.codeheadsystems.pkauth.refresh.web.RefreshRequest; +import com.codeheadsystems.pkauth.refresh.web.RefreshResponse; +import io.micronaut.context.annotation.Property; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.MediaType; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.http.client.exceptions.HttpClientResponseException; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +/** Drives the {@code /auth/refresh} endpoint end-to-end through Micronaut's HTTP layer. */ +@MicronautTest +@Property(name = "pkauth.relying-party.id", value = "example.com") +@Property(name = "pkauth.relying-party.name", value = "pk-auth micronaut test") +@Property(name = "pkauth.relying-party.origins[0]", value = "https://example.com") +@Property(name = "pkauth.jwt.issuer", value = "https://pkauth.example.com") +@Property(name = "pkauth.jwt.audience", value = "https://app.example.com") +@Property(name = "pkauth.jwt.secret", value = "pk-auth-micronaut-test-secret-32b!") +@Property(name = "pkauth.dev-mode", value = "true") +class PkAuthRefreshControllerTest { + + @Inject + @Client("/") + HttpClient client; + + @Inject RefreshTokenService refreshService; + + @Test + void rotateMintsValidAccessJwtAndNewRefreshToken() { + RefreshTokenPair root = + refreshService.issue( + UserHandle.of(new byte[] {1}), "https://app.example.com", Optional.empty()); + + RefreshResponse response = + client + .toBlocking() + .retrieve( + HttpRequest.POST("/auth/refresh", new RefreshRequest(root.wireToken())) + .contentType(MediaType.APPLICATION_JSON), + RefreshResponse.class); + + assertThat(response.refreshToken()).isNotBlank().contains("."); + assertThat(response.refreshToken()).isNotEqualTo(root.wireToken()); + assertThat(response.accessToken()).isNotBlank(); + } + + @Test + void replayReturns401() { + RefreshTokenPair root = + refreshService.issue( + UserHandle.of(new byte[] {2}), "https://app.example.com", Optional.empty()); + + // First rotation succeeds. + client + .toBlocking() + .retrieve( + HttpRequest.POST("/auth/refresh", new RefreshRequest(root.wireToken())) + .contentType(MediaType.APPLICATION_JSON), + RefreshResponse.class); + + // Re-presenting the used root token → 401. + assertThatThrownBy( + () -> + client + .toBlocking() + .retrieve( + HttpRequest.POST("/auth/refresh", new RefreshRequest(root.wireToken())) + .contentType(MediaType.APPLICATION_JSON), + RefreshResponse.class)) + .isInstanceOfSatisfying( + HttpClientResponseException.class, + e -> assertThat((Object) e.getStatus()).isEqualTo(HttpStatus.UNAUTHORIZED)); + } +} diff --git a/pk-auth-micronaut/src/test/java/com/codeheadsystems/pkauth/micronaut/TestPersistenceFactory.java b/pk-auth-micronaut/src/test/java/com/codeheadsystems/pkauth/micronaut/TestPersistenceFactory.java index 17aafa7..e058f88 100644 --- a/pk-auth-micronaut/src/test/java/com/codeheadsystems/pkauth/micronaut/TestPersistenceFactory.java +++ b/pk-auth-micronaut/src/test/java/com/codeheadsystems/pkauth/micronaut/TestPersistenceFactory.java @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT package com.codeheadsystems.pkauth.micronaut; +import com.codeheadsystems.pkauth.refresh.spi.RefreshTokenRepository; import com.codeheadsystems.pkauth.spi.BackupCodeRepository; import com.codeheadsystems.pkauth.spi.ChallengeStore; import com.codeheadsystems.pkauth.spi.CredentialRepository; @@ -10,6 +11,7 @@ import com.codeheadsystems.pkauth.testkit.InMemoryChallengeStore; import com.codeheadsystems.pkauth.testkit.InMemoryCredentialRepository; import com.codeheadsystems.pkauth.testkit.InMemoryOtpRepository; +import com.codeheadsystems.pkauth.testkit.InMemoryRefreshTokenRepository; import com.codeheadsystems.pkauth.testkit.InMemoryUserLookup; import io.micronaut.context.annotation.Factory; import jakarta.inject.Singleton; @@ -42,4 +44,9 @@ BackupCodeRepository backupCodeRepository() { OtpRepository otpRepository() { return new InMemoryOtpRepository(); } + + @Singleton + RefreshTokenRepository refreshTokenRepository() { + return new InMemoryRefreshTokenRepository(); + } } diff --git a/pk-auth-persistence-dynamodb/build.gradle.kts b/pk-auth-persistence-dynamodb/build.gradle.kts index a1bc479..98a89af 100644 --- a/pk-auth-persistence-dynamodb/build.gradle.kts +++ b/pk-auth-persistence-dynamodb/build.gradle.kts @@ -16,6 +16,9 @@ dependencies { api(project(":pk-auth-core")) // AccessTokenStore lives in pk-auth-jwt; DynamoDbAccessTokenStore implements it. api(project(":pk-auth-jwt")) + // RefreshTokenRepository lives in pk-auth-refresh-tokens; DynamoDbRefreshTokenRepository + // implements it. + api(project(":pk-auth-refresh-tokens")) api(libs.aws.dynamodb) api(libs.aws.dynamodb.enhanced) implementation(libs.slf4j.api) diff --git a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbAccessTokenStore.java b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbAccessTokenStore.java index eca81c0..51e9dff 100644 --- a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbAccessTokenStore.java +++ b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbAccessTokenStore.java @@ -25,8 +25,9 @@ * *

The two writes are non-atomic — primary first, then user-index. A failure between them leaves * the primary item live (validator-correct) and the user-index missing (a future deleteAllForUser - * would not see this jti). Since user deletion is an operator-rare flow and {@link - * UserDeletionListener} contract is idempotent, the operator can retry. See ADR 0016. + * would not see this jti). Since user deletion is an operator-rare flow and the {@link + * com.codeheadsystems.pkauth.lifecycle.UserDeletionListener} contract is idempotent, the operator + * can retry. See ADR 0016. * * @since 1.1.0 */ diff --git a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbRefreshTokenRepository.java b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbRefreshTokenRepository.java new file mode 100644 index 0000000..468c27b --- /dev/null +++ b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbRefreshTokenRepository.java @@ -0,0 +1,490 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.persistence.dynamodb; + +import com.codeheadsystems.pkauth.api.UserHandle; +import com.codeheadsystems.pkauth.json.Base64Url; +import com.codeheadsystems.pkauth.refresh.RefreshTokenRecord; +import com.codeheadsystems.pkauth.refresh.RevokeReason; +import com.codeheadsystems.pkauth.refresh.spi.RefreshTokenRepository; +import com.codeheadsystems.pkauth.spi.PkAuthPersistenceException; +import java.time.Instant; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Supplier; +import software.amazon.awssdk.core.exception.SdkException; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Expression; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.ConditionCheck; +import software.amazon.awssdk.enhanced.dynamodb.model.PutItemEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.TransactPutItemEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.TransactWriteItemsEnhancedRequest; +import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException; +import software.amazon.awssdk.services.dynamodb.model.TransactionCanceledException; + +/** + * {@link RefreshTokenRepository} backed by the {@code PkAuthCore} single table. The load-bearing + * {@link #rotateAtomically} primitive uses {@code TransactWriteItems} to commit "mark parent used" + * and "insert successor" as a single atomic operation — without that, a concurrent replay-revoker + * could miss the freshly-inserted successor. + * + *

Each issued JTI lives at up to three item addresses (primary + user-index + family-index) — + * the user-index and family-index items aren't load-bearing for correctness (the primary item is + * the authority on used/revoked state) but they make {@code revokeAllForUser} and {@code + * revokeFamily} O(family-size) rather than full-table scans. + * + *

Native DynamoDB TTL on the {@code ttl} attribute prunes rows in the background; {@link + * #deleteExpiredBefore(Instant)} provides synchronous cleanup for tests and operator workflows. + * + * @since 1.1.0 + */ +public final class DynamoDbRefreshTokenRepository implements RefreshTokenRepository { + + private static final String FAMILY_PK_PREFIX = "RTF#"; + private static final String USER_PK_PREFIX = "USER#"; + private static final String PRIMARY_PK_PREFIX = "RT#"; + private static final String INDEX_SK_PREFIX = "RT#"; + + private final DynamoDbEnhancedClient enhanced; + private final DynamoDbTable table; + + public DynamoDbRefreshTokenRepository( + DynamoDbEnhancedClient enhanced, PkAuthDynamoTables tables) { + this.enhanced = Objects.requireNonNull(enhanced, "enhanced"); + Objects.requireNonNull(tables, "tables"); + this.table = enhanced.table(tables.core(), TableSchema.fromBean(RefreshTokenItem.class)); + } + + @Override + public void create(RefreshTokenRecord record) { + Objects.requireNonNull(record, "record"); + wrap( + "refresh_tokens.create", + () -> { + putAllItems(record, /*requirePrimaryAbsent*/ true); + return null; + }); + } + + @Override + public Optional findByRefreshId(String refreshId) { + return wrap( + "refresh_tokens.findByRefreshId", + () -> { + RefreshTokenItem item = + table.getItem( + Key.builder() + .partitionValue(PRIMARY_PK_PREFIX + refreshId) + .sortValue(PRIMARY_PK_PREFIX + refreshId) + .build()); + return Optional.ofNullable(item).map(DynamoDbRefreshTokenRepository::toRecord); + }); + } + + @Override + public boolean rotateAtomically( + String parentRefreshId, Instant now, RefreshTokenRecord successor) { + Objects.requireNonNull(parentRefreshId, "parentRefreshId"); + Objects.requireNonNull(now, "now"); + Objects.requireNonNull(successor, "successor"); + return wrap( + "refresh_tokens.rotateAtomically", + () -> { + // We can't read userHandleB64u from the predicate, so we need to look up the parent + // first to know which user-index item to update. The lookup + transact path is + // safe because the conditional on the parent UpdateItem inside the transaction is + // the authoritative freshness check — even if state changed between lookup and + // transaction, the conditional fails and we return false. + RefreshTokenItem parent = + table.getItem( + Key.builder() + .partitionValue(PRIMARY_PK_PREFIX + parentRefreshId) + .sortValue(PRIMARY_PK_PREFIX + parentRefreshId) + .build()); + if (parent == null) { + return false; + } + + String nowIso = now.toString(); + // Mark the parent's primary item used. The user-index and family-index items aren't + // authoritative for used_at — only the primary is — so we don't mutate them here. + RefreshTokenItem updatedParent = copy(parent); + updatedParent.setUsedAtIso(nowIso); + + // Build the three successor items. + RefreshTokenItem successorPrimary = + toItem( + successor, + PRIMARY_PK_PREFIX + successor.refreshId(), + PRIMARY_PK_PREFIX + successor.refreshId()); + RefreshTokenItem successorUser = + toItem( + successor, + USER_PK_PREFIX + successor_userB64(successor), + INDEX_SK_PREFIX + successor.refreshId()); + RefreshTokenItem successorFamily = + toItem( + successor, + FAMILY_PK_PREFIX + successor.familyId(), + INDEX_SK_PREFIX + successor.refreshId()); + + // Transact: conditional update on parent primary (still fresh) + put successor items. + // ConditionExpression — fresh iff used_at, revoked_at unset and expires_at > now. + Expression freshness = + Expression.builder() + .expression( + "attribute_not_exists(usedAtIso) AND attribute_not_exists(revokedAtIso)" + + " AND expiresAtIso > :now") + .putExpressionValue(":now", AttributeValue.fromS(nowIso)) + .build(); + + try { + enhanced.transactWriteItems( + TransactWriteItemsEnhancedRequest.builder() + .addPutItem( + table, + TransactPutItemEnhancedRequest.builder(RefreshTokenItem.class) + .item(updatedParent) + .conditionExpression(freshness) + .build()) + .addPutItem( + table, + TransactPutItemEnhancedRequest.builder(RefreshTokenItem.class) + .item(successorPrimary) + .conditionExpression( + Expression.builder().expression("attribute_not_exists(pk)").build()) + .build()) + .addPutItem(table, successorUser) + .addPutItem(table, successorFamily) + .build()); + return true; + } catch (TransactionCanceledException cancelled) { + return false; + } + }); + } + + @Override + public int revokeFamily(String familyId, Instant now, RevokeReason reason) { + return wrap( + "refresh_tokens.revokeFamily", + () -> { + // Query the family-index for every member, then mutate the primary item of each (the + // primary is the authority on revoked_at). + int[] revoked = {0}; + String nowIso = now.toString(); + table + .query( + QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue(FAMILY_PK_PREFIX + familyId) + .sortValue(INDEX_SK_PREFIX) + .build())) + .stream() + .flatMap(p -> p.items().stream()) + .forEach( + indexItem -> { + RefreshTokenItem primary = + table.getItem( + Key.builder() + .partitionValue(PRIMARY_PK_PREFIX + indexItem.getRefreshId()) + .sortValue(PRIMARY_PK_PREFIX + indexItem.getRefreshId()) + .build()); + if (primary == null || primary.getRevokedAtIso() != null) { + return; + } + primary.setRevokedAtIso(nowIso); + primary.setRevokedReason(reason.name()); + // Conditional put: only set revoked_at if it's still null (idempotent under + // concurrent revokers). + try { + table.putItem( + PutItemEnhancedRequest.builder(RefreshTokenItem.class) + .item(primary) + .conditionExpression( + Expression.builder() + .expression("attribute_not_exists(revokedAtIso)") + .build()) + .build()); + revoked[0]++; + } catch (ConditionalCheckFailedException raceLost) { + // Another revoker won; revokedAt is now set. Nothing to do. + } + }); + return revoked[0]; + }); + } + + @Override + public int revokeAllForUser(UserHandle userHandle, Instant now, RevokeReason reason) { + return wrap( + "refresh_tokens.revokeAllForUser", + () -> { + String userB64 = Base64Url.encode(userHandle.value()); + int[] revoked = {0}; + String nowIso = now.toString(); + table + .query( + QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue(USER_PK_PREFIX + userB64) + .sortValue(INDEX_SK_PREFIX) + .build())) + .stream() + .flatMap(p -> p.items().stream()) + .forEach( + indexItem -> { + RefreshTokenItem primary = + table.getItem( + Key.builder() + .partitionValue(PRIMARY_PK_PREFIX + indexItem.getRefreshId()) + .sortValue(PRIMARY_PK_PREFIX + indexItem.getRefreshId()) + .build()); + if (primary == null || primary.getRevokedAtIso() != null) { + return; + } + primary.setRevokedAtIso(nowIso); + primary.setRevokedReason(reason.name()); + try { + table.putItem( + PutItemEnhancedRequest.builder(RefreshTokenItem.class) + .item(primary) + .conditionExpression( + Expression.builder() + .expression("attribute_not_exists(revokedAtIso)") + .build()) + .build()); + revoked[0]++; + } catch (ConditionalCheckFailedException raceLost) { + // Lost race; revoked by someone else. + } + }); + return revoked[0]; + }); + } + + @Override + public List findByUserHandle(UserHandle userHandle) { + return wrap( + "refresh_tokens.findByUserHandle", + () -> { + String userB64 = Base64Url.encode(userHandle.value()); + // Query the user-index for refreshIds, then load each primary item for authoritative + // used/revoked state. + Map byId = new LinkedHashMap<>(); + table + .query( + QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue(USER_PK_PREFIX + userB64) + .sortValue(INDEX_SK_PREFIX) + .build())) + .stream() + .flatMap(p -> p.items().stream()) + .forEach( + indexItem -> { + RefreshTokenItem primary = + table.getItem( + Key.builder() + .partitionValue(PRIMARY_PK_PREFIX + indexItem.getRefreshId()) + .sortValue(PRIMARY_PK_PREFIX + indexItem.getRefreshId()) + .build()); + if (primary != null) { + byId.put(primary.getRefreshId(), toRecord(primary)); + } + }); + return List.copyOf(byId.values()); + }); + } + + @Override + public List findByFamilyId(String familyId) { + return wrap( + "refresh_tokens.findByFamilyId", + () -> { + Map byId = new LinkedHashMap<>(); + table + .query( + QueryConditional.sortBeginsWith( + Key.builder() + .partitionValue(FAMILY_PK_PREFIX + familyId) + .sortValue(INDEX_SK_PREFIX) + .build())) + .stream() + .flatMap(p -> p.items().stream()) + .forEach( + indexItem -> { + RefreshTokenItem primary = + table.getItem( + Key.builder() + .partitionValue(PRIMARY_PK_PREFIX + indexItem.getRefreshId()) + .sortValue(PRIMARY_PK_PREFIX + indexItem.getRefreshId()) + .build()); + if (primary != null) { + byId.put(primary.getRefreshId(), toRecord(primary)); + } + }); + return List.copyOf(byId.values()); + }); + } + + @Override + public int deleteExpiredBefore(Instant cutoff) { + return wrap( + "refresh_tokens.deleteExpiredBefore", + () -> { + long cutoffEpoch = cutoff.getEpochSecond(); + int[] removed = {0}; + // Scan only primary items (pk and sk both start with RT#) and filter by the + // retention predicate that mirrors the JDBI cleanup SQL. + table.scan().items().stream() + .filter(item -> item.getPk() != null && item.getPk().startsWith(PRIMARY_PK_PREFIX)) + .filter(item -> item.getPk().equals(item.getSk())) // primary only + .filter(item -> item.getTtl() != null && item.getTtl() < cutoffEpoch) + .filter( + item -> + (item.getUsedAtIso() != null + && Instant.parse(item.getUsedAtIso()).isBefore(cutoff)) + || (item.getRevokedAtIso() != null + && Instant.parse(item.getRevokedAtIso()).isBefore(cutoff))) + .forEach( + item -> { + deleteAllItems(item); + removed[0]++; + }); + return removed[0]; + }); + } + + // -- Internals -------------------------------------------------------------------------- + + private void putAllItems(RefreshTokenRecord record, boolean requirePrimaryAbsent) { + String userB64 = Base64Url.encode(record.userHandle().value()); + RefreshTokenItem primary = + toItem( + record, PRIMARY_PK_PREFIX + record.refreshId(), PRIMARY_PK_PREFIX + record.refreshId()); + PutItemEnhancedRequest.Builder primaryReq = + PutItemEnhancedRequest.builder(RefreshTokenItem.class).item(primary); + if (requirePrimaryAbsent) { + primaryReq.conditionExpression( + Expression.builder().expression("attribute_not_exists(pk)").build()); + } + try { + table.putItem(primaryReq.build()); + } catch (ConditionalCheckFailedException duplicate) { + throw new IllegalStateException("duplicate refreshId: " + record.refreshId(), duplicate); + } + // User-index pointer (best-effort; not load-bearing for correctness). + table.putItem(toItem(record, USER_PK_PREFIX + userB64, INDEX_SK_PREFIX + record.refreshId())); + // Family-index pointer (best-effort; not load-bearing for correctness). + table.putItem( + toItem(record, FAMILY_PK_PREFIX + record.familyId(), INDEX_SK_PREFIX + record.refreshId())); + } + + private void deleteAllItems(RefreshTokenItem primary) { + String refreshId = primary.getRefreshId(); + table.deleteItem( + Key.builder() + .partitionValue(PRIMARY_PK_PREFIX + refreshId) + .sortValue(PRIMARY_PK_PREFIX + refreshId) + .build()); + if (primary.getUserHandleB64u() != null) { + table.deleteItem( + Key.builder() + .partitionValue(USER_PK_PREFIX + primary.getUserHandleB64u()) + .sortValue(INDEX_SK_PREFIX + refreshId) + .build()); + } + if (primary.getFamilyId() != null) { + table.deleteItem( + Key.builder() + .partitionValue(FAMILY_PK_PREFIX + primary.getFamilyId()) + .sortValue(INDEX_SK_PREFIX + refreshId) + .build()); + } + } + + private static String successor_userB64(RefreshTokenRecord r) { + return Base64Url.encode(r.userHandle().value()); + } + + private static RefreshTokenItem toItem(RefreshTokenRecord r, String pk, String sk) { + RefreshTokenItem item = new RefreshTokenItem(); + item.setPk(pk); + item.setSk(sk); + item.setRefreshId(r.refreshId()); + item.setTokenHashB64u(Base64Url.encode(r.tokenHash())); + item.setUserHandleB64u(Base64Url.encode(r.userHandle().value())); + item.setAudience(r.audience()); + item.setDeviceId(r.deviceId().orElse(null)); + item.setFamilyId(r.familyId()); + item.setParentRefreshId(r.parentRefreshId().orElse(null)); + item.setIssuedAtIso(r.issuedAt().toString()); + item.setExpiresAtIso(r.expiresAt().toString()); + item.setUsedAtIso(r.usedAt().map(Instant::toString).orElse(null)); + item.setRevokedAtIso(r.revokedAt().map(Instant::toString).orElse(null)); + item.setRevokedReason(r.revokedReason().map(Enum::name).orElse(null)); + item.setTtl(r.expiresAt().getEpochSecond()); + return item; + } + + private static RefreshTokenItem copy(RefreshTokenItem from) { + RefreshTokenItem item = new RefreshTokenItem(); + item.setPk(from.getPk()); + item.setSk(from.getSk()); + item.setRefreshId(from.getRefreshId()); + item.setTokenHashB64u(from.getTokenHashB64u()); + item.setUserHandleB64u(from.getUserHandleB64u()); + item.setAudience(from.getAudience()); + item.setDeviceId(from.getDeviceId()); + item.setFamilyId(from.getFamilyId()); + item.setParentRefreshId(from.getParentRefreshId()); + item.setIssuedAtIso(from.getIssuedAtIso()); + item.setExpiresAtIso(from.getExpiresAtIso()); + item.setUsedAtIso(from.getUsedAtIso()); + item.setRevokedAtIso(from.getRevokedAtIso()); + item.setRevokedReason(from.getRevokedReason()); + item.setTtl(from.getTtl()); + return item; + } + + private static RefreshTokenRecord toRecord(RefreshTokenItem item) { + Map ignored = new HashMap<>(); + byte[] hash = Base64Url.decode(item.getTokenHashB64u()); + return new RefreshTokenRecord( + item.getRefreshId(), + hash, + UserHandle.of(Base64Url.decode(item.getUserHandleB64u())), + item.getAudience(), + Optional.ofNullable(item.getDeviceId()), + item.getFamilyId(), + Optional.ofNullable(item.getParentRefreshId()), + Instant.parse(item.getIssuedAtIso()), + Instant.parse(item.getExpiresAtIso()), + Optional.ofNullable(item.getUsedAtIso()).map(Instant::parse), + Optional.ofNullable(item.getRevokedAtIso()).map(Instant::parse), + Optional.ofNullable(item.getRevokedReason()).map(RevokeReason::valueOf)); + } + + private static T wrap(String op, Supplier body) { + try { + return body.get(); + } catch (PkAuthPersistenceException already) { + throw already; + } catch (SdkException e) { + throw new PkAuthPersistenceException(op, e.getMessage(), e); + } + } + + // Defensive: silence unused-import warnings on classes pulled in for the TransactWrite path. + @SuppressWarnings("unused") + private static final ConditionCheck UNUSED_CONDITION = null; +} diff --git a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/RefreshTokenItem.java b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/RefreshTokenItem.java new file mode 100644 index 0000000..bbe5bd9 --- /dev/null +++ b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/RefreshTokenItem.java @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.persistence.dynamodb; + +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbPartitionKey; +import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; + +/** + * Single-table mapping for refresh-token rows on {@code PkAuthCore}. Three logical item shapes + * share this bean, distinguished by the {@code pk}/{@code sk} pattern: + * + *

    + *
  • Primary: {@code pk = "RT#"}, {@code sk = "RT#"} — fast lookup by + * refreshId. The load-bearing row for rotation. + *
  • User-index: {@code pk = "USER#"}, {@code sk = "RT#"} — listing + * and {@code revokeAllForUser} fan-out alongside this user's other state. + *
  • Family-index: {@code pk = "RTF#"}, {@code sk = "RT#"} — fast scorch of + * every member of a family. + *
+ * + *

The {@code ttl} attribute is set to {@code expiresAt + cleanupRetention}.epochSecond so + * DynamoDB's native TTL eventually prunes used/revoked/expired rows. Synchronous cleanup via {@code + * deleteExpiredBefore} stays available for tests and operator-driven retention. + * + * @since 1.1.0 + */ +@DynamoDbBean +public class RefreshTokenItem { + + private String pk; + private String sk; + private String refreshId; + private String tokenHashB64u; + private String userHandleB64u; + private String audience; + private String deviceId; + private String familyId; + private String parentRefreshId; + private String issuedAtIso; + private String expiresAtIso; + private String usedAtIso; + private String revokedAtIso; + private String revokedReason; + private Long ttl; + + public RefreshTokenItem() {} + + @DynamoDbPartitionKey + public String getPk() { + return pk; + } + + public void setPk(String pk) { + this.pk = pk; + } + + @DynamoDbSortKey + public String getSk() { + return sk; + } + + public void setSk(String sk) { + this.sk = sk; + } + + public String getRefreshId() { + return refreshId; + } + + public void setRefreshId(String refreshId) { + this.refreshId = refreshId; + } + + public String getTokenHashB64u() { + return tokenHashB64u; + } + + public void setTokenHashB64u(String tokenHashB64u) { + this.tokenHashB64u = tokenHashB64u; + } + + public String getUserHandleB64u() { + return userHandleB64u; + } + + public void setUserHandleB64u(String userHandleB64u) { + this.userHandleB64u = userHandleB64u; + } + + public String getAudience() { + return audience; + } + + public void setAudience(String audience) { + this.audience = audience; + } + + public String getDeviceId() { + return deviceId; + } + + public void setDeviceId(String deviceId) { + this.deviceId = deviceId; + } + + public String getFamilyId() { + return familyId; + } + + public void setFamilyId(String familyId) { + this.familyId = familyId; + } + + public String getParentRefreshId() { + return parentRefreshId; + } + + public void setParentRefreshId(String parentRefreshId) { + this.parentRefreshId = parentRefreshId; + } + + public String getIssuedAtIso() { + return issuedAtIso; + } + + public void setIssuedAtIso(String issuedAtIso) { + this.issuedAtIso = issuedAtIso; + } + + public String getExpiresAtIso() { + return expiresAtIso; + } + + public void setExpiresAtIso(String expiresAtIso) { + this.expiresAtIso = expiresAtIso; + } + + public String getUsedAtIso() { + return usedAtIso; + } + + public void setUsedAtIso(String usedAtIso) { + this.usedAtIso = usedAtIso; + } + + public String getRevokedAtIso() { + return revokedAtIso; + } + + public void setRevokedAtIso(String revokedAtIso) { + this.revokedAtIso = revokedAtIso; + } + + public String getRevokedReason() { + return revokedReason; + } + + public void setRevokedReason(String revokedReason) { + this.revokedReason = revokedReason; + } + + public Long getTtl() { + return ttl; + } + + public void setTtl(Long ttl) { + this.ttl = ttl; + } +} diff --git a/pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbRefreshTokenRepositoryIntegrationTest.java b/pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbRefreshTokenRepositoryIntegrationTest.java new file mode 100644 index 0000000..065e8ff --- /dev/null +++ b/pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbRefreshTokenRepositoryIntegrationTest.java @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.persistence.dynamodb; + +import com.codeheadsystems.pkauth.testkit.RefreshTokenScenarios; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable; +import org.testcontainers.junit.jupiter.Testcontainers; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.services.dynamodb.DynamoDbClient; + +@Testcontainers +@DisabledIfEnvironmentVariable(named = "PKAUTH_SKIP_TESTCONTAINERS", matches = "1") +class DynamoDbRefreshTokenRepositoryIntegrationTest { + + private DynamoDbRefreshTokenRepository repository; + + @BeforeEach + void setUp() { + DynamoDbClient client = DynamoDbLocalFixture.client(); + DynamoDbEnhancedClient enhanced = DynamoDbLocalFixture.enhanced(); + String suffix = UUID.randomUUID().toString().substring(0, 8); + PkAuthDynamoTables tables = + new PkAuthDynamoTables("PkAuthCore_" + suffix, "PkAuthUsers_" + suffix); + new DynamoDbSchemaBootstrapper(client, tables).bootstrap(); + repository = new DynamoDbRefreshTokenRepository(enhanced, tables); + } + + @Test + void issueRotateRevokeHappyPath() { + new RefreshTokenScenarios(repository).issueRotateRevokeHappyPath(); + } + + @Test + void rotationUpdatesFamilyChainAndChildLinksToParent() { + new RefreshTokenScenarios(repository).rotationUpdatesFamilyChainAndChildLinksToParent(); + } + + @Test + void replayOfUsedTokenScorchesEntireFamily() { + new RefreshTokenScenarios(repository).replayOfUsedTokenScorchesEntireFamily(); + } + + @Test + void expiredTokenRotationReturnsExpired() { + new RefreshTokenScenarios(repository).expiredTokenRotationReturnsExpired(); + } + + @Test + void unknownRefreshIdReturnsUnknown() { + new RefreshTokenScenarios(repository).unknownRefreshIdReturnsUnknown(); + } + + @Test + void wrongSecretReturnsUnknownAndDoesNotBurnLegitToken() { + new RefreshTokenScenarios(repository).wrongSecretReturnsUnknownAndDoesNotBurnLegitToken(); + } + + @Test + void revokeFamilyIsIdempotent() { + new RefreshTokenScenarios(repository).revokeFamilyIsIdempotent(); + } + + @Test + void revokeAllForUserRevokesEveryActiveFamily() { + new RefreshTokenScenarios(repository).revokeAllForUserRevokesEveryActiveFamily(); + } + + /** The load-bearing concurrent rotation race test. Must pass against real DynamoDB Local. */ + @Test + void concurrentRotationExactlyOneSucceedsFamilyRevoked() throws Exception { + new RefreshTokenScenarios(repository).concurrentRotationExactlyOneSucceedsFamilyRevoked(); + } +} diff --git a/pk-auth-persistence-jdbi/build.gradle.kts b/pk-auth-persistence-jdbi/build.gradle.kts index 3fcfcd0..4a453fc 100644 --- a/pk-auth-persistence-jdbi/build.gradle.kts +++ b/pk-auth-persistence-jdbi/build.gradle.kts @@ -16,6 +16,8 @@ dependencies { api(project(":pk-auth-core")) // AccessTokenStore lives in pk-auth-jwt; JdbiAccessTokenStore implements it. api(project(":pk-auth-jwt")) + // RefreshTokenRepository SPI; JdbiRefreshTokenRepository implements it. + api(project(":pk-auth-refresh-tokens")) api(libs.jdbi.core) // JDBI's class files reference com.google.errorprone.annotations.concurrent.GuardedBy. // Without errorprone-annotations on the compile classpath, javac emits an annotation-not-found diff --git a/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiRefreshTokenRepository.java b/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiRefreshTokenRepository.java new file mode 100644 index 0000000..a931f24 --- /dev/null +++ b/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiRefreshTokenRepository.java @@ -0,0 +1,230 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.persistence.jdbi; + +import com.codeheadsystems.pkauth.api.UserHandle; +import com.codeheadsystems.pkauth.refresh.RefreshTokenRecord; +import com.codeheadsystems.pkauth.refresh.RevokeReason; +import com.codeheadsystems.pkauth.refresh.spi.RefreshTokenRepository; +import com.codeheadsystems.pkauth.spi.PkAuthPersistenceException; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Supplier; +import org.jdbi.v3.core.Handle; +import org.jdbi.v3.core.Jdbi; +import org.jdbi.v3.core.JdbiException; +import org.jdbi.v3.core.mapper.RowMapper; + +/** + * {@link RefreshTokenRepository} backed by the {@code refresh_tokens} table (Flyway V9). The + * load-bearing {@link #rotateAtomically} uses a JDBI transaction that wraps a conditional {@code + * UPDATE} on the parent + an {@code INSERT} for the successor — see ADR 0013. + * + * @since 1.1.0 + */ +public final class JdbiRefreshTokenRepository implements RefreshTokenRepository { + + private final Jdbi jdbi; + + public JdbiRefreshTokenRepository(Jdbi jdbi) { + this.jdbi = Objects.requireNonNull(jdbi, "jdbi"); + } + + @Override + public void create(RefreshTokenRecord record) { + wrap( + "refresh_tokens.create", + () -> { + jdbi.useHandle(h -> insert(h, record)); + return null; + }); + } + + @Override + public Optional findByRefreshId(String refreshId) { + return wrap( + "refresh_tokens.findByRefreshId", + () -> + jdbi.withHandle( + h -> + h.createQuery("SELECT * FROM refresh_tokens WHERE refresh_id = :id") + .bind("id", refreshId) + .map(MAPPER) + .findFirst())); + } + + @Override + public boolean rotateAtomically( + String parentRefreshId, Instant now, RefreshTokenRecord successor) { + Objects.requireNonNull(parentRefreshId, "parentRefreshId"); + Objects.requireNonNull(now, "now"); + Objects.requireNonNull(successor, "successor"); + return wrap( + "refresh_tokens.rotateAtomically", + () -> + jdbi.inTransaction( + h -> { + // Single atomic UPDATE on the parent. Predicate-equivalent to the canonical + // motif statement: only mark used iff fresh, exposed-row-count == 1 means we + // won the race against any concurrent rotator. + int marked = + h.createUpdate( + "UPDATE refresh_tokens SET used_at = :now" + + " WHERE refresh_id = :id" + + " AND used_at IS NULL" + + " AND revoked_at IS NULL" + + " AND expires_at > :now") + .bind("now", OffsetDateTime.ofInstant(now, ZoneOffset.UTC)) + .bind("id", parentRefreshId) + .execute(); + if (marked == 0) { + return false; + } + insert(h, successor); + return true; + })); + } + + @Override + public int revokeFamily(String familyId, Instant now, RevokeReason reason) { + return wrap( + "refresh_tokens.revokeFamily", + () -> + jdbi.withHandle( + h -> + h.createUpdate( + "UPDATE refresh_tokens" + + " SET revoked_at = :now, revoked_reason = :reason" + + " WHERE family_id = :fam AND revoked_at IS NULL") + .bind("now", OffsetDateTime.ofInstant(now, ZoneOffset.UTC)) + .bind("reason", reason.name()) + .bind("fam", familyId) + .execute())); + } + + @Override + public int revokeAllForUser(UserHandle userHandle, Instant now, RevokeReason reason) { + return wrap( + "refresh_tokens.revokeAllForUser", + () -> + jdbi.withHandle( + h -> + h.createUpdate( + "UPDATE refresh_tokens" + + " SET revoked_at = :now, revoked_reason = :reason" + + " WHERE user_handle = :uh AND revoked_at IS NULL") + .bind("now", OffsetDateTime.ofInstant(now, ZoneOffset.UTC)) + .bind("reason", reason.name()) + .bind("uh", userHandle.value()) + .execute())); + } + + @Override + public List findByUserHandle(UserHandle userHandle) { + return wrap( + "refresh_tokens.findByUserHandle", + () -> + jdbi.withHandle( + h -> + h.createQuery( + "SELECT * FROM refresh_tokens WHERE user_handle = :uh" + + " ORDER BY issued_at") + .bind("uh", userHandle.value()) + .map(MAPPER) + .list())); + } + + @Override + public List findByFamilyId(String familyId) { + return wrap( + "refresh_tokens.findByFamilyId", + () -> + jdbi.withHandle( + h -> + h.createQuery( + "SELECT * FROM refresh_tokens WHERE family_id = :fam" + + " ORDER BY issued_at") + .bind("fam", familyId) + .map(MAPPER) + .list())); + } + + @Override + public int deleteExpiredBefore(Instant cutoff) { + return wrap( + "refresh_tokens.deleteExpiredBefore", + () -> + jdbi.withHandle( + h -> + h.createUpdate( + "DELETE FROM refresh_tokens" + + " WHERE expires_at < :cutoff" + + " AND ((used_at IS NOT NULL AND used_at < :cutoff)" + + " OR (revoked_at IS NOT NULL AND revoked_at < :cutoff))") + .bind("cutoff", OffsetDateTime.ofInstant(cutoff, ZoneOffset.UTC)) + .execute())); + } + + // -- Internals -------------------------------------------------------------------------- + + private static void insert(Handle h, RefreshTokenRecord r) { + h.createUpdate( + "INSERT INTO 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)" + + " VALUES (:rid, :hash, :uh, :aud, :did, :fam, :pid, :iat, :exp, :uat, :rat," + + " :reason)") + .bind("rid", r.refreshId()) + .bind("hash", r.tokenHash()) + .bind("uh", r.userHandle().value()) + .bind("aud", r.audience()) + .bind("did", r.deviceId().orElse(null)) + .bind("fam", r.familyId()) + .bind("pid", r.parentRefreshId().orElse(null)) + .bind("iat", OffsetDateTime.ofInstant(r.issuedAt(), ZoneOffset.UTC)) + .bind("exp", OffsetDateTime.ofInstant(r.expiresAt(), ZoneOffset.UTC)) + .bind("uat", r.usedAt().map(t -> OffsetDateTime.ofInstant(t, ZoneOffset.UTC)).orElse(null)) + .bind( + "rat", r.revokedAt().map(t -> OffsetDateTime.ofInstant(t, ZoneOffset.UTC)).orElse(null)) + .bind("reason", r.revokedReason().map(Enum::name).orElse(null)) + .execute(); + } + + private static T wrap(String op, Supplier body) { + try { + return body.get(); + } catch (PkAuthPersistenceException already) { + throw already; + } catch (JdbiException e) { + throw new PkAuthPersistenceException(op, e.getMessage(), e); + } + } + + private static final RowMapper MAPPER = (rs, ctx) -> readRow(rs); + + private static RefreshTokenRecord readRow(ResultSet rs) throws SQLException { + OffsetDateTime usedAt = rs.getObject("used_at", OffsetDateTime.class); + OffsetDateTime revokedAt = rs.getObject("revoked_at", OffsetDateTime.class); + String revokedReasonStr = rs.getString("revoked_reason"); + String deviceId = rs.getString("device_id"); + String parentRefreshId = rs.getString("parent_refresh_id"); + return new RefreshTokenRecord( + rs.getString("refresh_id"), + rs.getBytes("token_hash"), + UserHandle.of(rs.getBytes("user_handle")), + rs.getString("audience"), + Optional.ofNullable(deviceId), + rs.getString("family_id"), + Optional.ofNullable(parentRefreshId), + rs.getObject("issued_at", OffsetDateTime.class).toInstant(), + rs.getObject("expires_at", OffsetDateTime.class).toInstant(), + Optional.ofNullable(usedAt).map(OffsetDateTime::toInstant), + Optional.ofNullable(revokedAt).map(OffsetDateTime::toInstant), + Optional.ofNullable(revokedReasonStr).map(RevokeReason::valueOf)); + } +} diff --git a/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/PkAuthJdbiSchema.java b/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/PkAuthJdbiSchema.java index 2f5e256..288088c 100644 --- a/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/PkAuthJdbiSchema.java +++ b/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/PkAuthJdbiSchema.java @@ -22,7 +22,7 @@ public final class PkAuthJdbiSchema { * #migrateForDevelopment(DataSource)} pins Flyway's {@code target} to this value so that * unreleased migrations on the classpath are never applied accidentally. */ - public static final String CURRENT_SCHEMA_VERSION = "8"; + public static final String CURRENT_SCHEMA_VERSION = "9"; private PkAuthJdbiSchema() {} diff --git a/pk-auth-persistence-jdbi/src/main/resources/db/migration/V9__create_refresh_tokens.sql b/pk-auth-persistence-jdbi/src/main/resources/db/migration/V9__create_refresh_tokens.sql new file mode 100644 index 0000000..0df11b7 --- /dev/null +++ b/pk-auth-persistence-jdbi/src/main/resources/db/migration/V9__create_refresh_tokens.sql @@ -0,0 +1,35 @@ +-- SPDX-License-Identifier: MIT +-- +-- Refresh tokens with family-based replay defense (ADR 0013). +-- +-- Wire format is "{refreshId}.{secret}" — opaque to the client. token_hash = sha256(secret); the +-- secret itself is NEVER persisted. refresh_id is indexed (primary key) so lookup-on-rotate is +-- O(log n). +-- +-- Family model: every rotation chain shares family_id (set equal to the root token's refresh_id +-- when the root is created). parent_refresh_id links a rotated successor back to the previous +-- token in the chain. +-- +-- The load-bearing rotation primitive lives in JdbiRefreshTokenRepository.rotateAtomically: a +-- conditional UPDATE on the parent followed by an INSERT for the successor, both inside a single +-- JDBI transaction. The transaction is the single point of atomicity — without it, a concurrent +-- replay-revoker could miss the freshly-inserted successor between mark and insert. + +CREATE TABLE refresh_tokens ( + refresh_id VARCHAR(64) NOT NULL PRIMARY KEY, + token_hash BYTEA NOT NULL, + user_handle BYTEA NOT NULL, + audience VARCHAR(64) NOT NULL, + device_id VARCHAR(128), + family_id VARCHAR(64) NOT NULL, + parent_refresh_id VARCHAR(64), + issued_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + revoked_reason VARCHAR(32) +); + +CREATE INDEX refresh_tokens_user_handle_idx ON refresh_tokens (user_handle); +CREATE INDEX refresh_tokens_family_id_idx ON refresh_tokens (family_id); +CREATE INDEX refresh_tokens_expires_at_idx ON refresh_tokens (expires_at); diff --git a/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiRefreshTokenRepositoryIntegrationTest.java b/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiRefreshTokenRepositoryIntegrationTest.java new file mode 100644 index 0000000..8d900fb --- /dev/null +++ b/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiRefreshTokenRepositoryIntegrationTest.java @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.persistence.jdbi; + +import com.codeheadsystems.pkauth.testkit.RefreshTokenScenarios; +import org.jdbi.v3.core.Jdbi; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers +@DisabledIfEnvironmentVariable(named = "PKAUTH_SKIP_TESTCONTAINERS", matches = "1") +class JdbiRefreshTokenRepositoryIntegrationTest { + + private JdbiRefreshTokenRepository repository; + + @BeforeEach + void setUp() { + Jdbi jdbi = PostgresFixture.ready(); + PostgresFixture.reset(); + repository = new JdbiRefreshTokenRepository(jdbi); + } + + @Test + void issueRotateRevokeHappyPath() { + new RefreshTokenScenarios(repository).issueRotateRevokeHappyPath(); + } + + @Test + void rotationUpdatesFamilyChainAndChildLinksToParent() { + new RefreshTokenScenarios(repository).rotationUpdatesFamilyChainAndChildLinksToParent(); + } + + @Test + void replayOfUsedTokenScorchesEntireFamily() { + new RefreshTokenScenarios(repository).replayOfUsedTokenScorchesEntireFamily(); + } + + @Test + void expiredTokenRotationReturnsExpired() { + new RefreshTokenScenarios(repository).expiredTokenRotationReturnsExpired(); + } + + @Test + void unknownRefreshIdReturnsUnknown() { + new RefreshTokenScenarios(repository).unknownRefreshIdReturnsUnknown(); + } + + @Test + void wrongSecretReturnsUnknownAndDoesNotBurnLegitToken() { + new RefreshTokenScenarios(repository).wrongSecretReturnsUnknownAndDoesNotBurnLegitToken(); + } + + @Test + void revokeFamilyIsIdempotent() { + new RefreshTokenScenarios(repository).revokeFamilyIsIdempotent(); + } + + @Test + void revokeAllForUserRevokesEveryActiveFamily() { + new RefreshTokenScenarios(repository).revokeAllForUserRevokesEveryActiveFamily(); + } + + /** The load-bearing concurrent rotation race test. Must pass against real Postgres. */ + @Test + void concurrentRotationExactlyOneSucceedsFamilyRevoked() throws Exception { + new RefreshTokenScenarios(repository).concurrentRotationExactlyOneSucceedsFamilyRevoked(); + } +} diff --git a/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/PostgresFixture.java b/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/PostgresFixture.java index c191ed8..57d36d6 100644 --- a/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/PostgresFixture.java +++ b/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/PostgresFixture.java @@ -59,6 +59,7 @@ public static void reset() { h -> h.execute( "TRUNCATE TABLE credentials, challenges, users, backup_codes, otp_codes," - + " pkauth_audit_events, access_tokens RESTART IDENTITY CASCADE")); + + " pkauth_audit_events, access_tokens, refresh_tokens" + + " RESTART IDENTITY CASCADE")); } } diff --git a/pk-auth-refresh-tokens/build.gradle.kts b/pk-auth-refresh-tokens/build.gradle.kts new file mode 100644 index 0000000..9a6c985 --- /dev/null +++ b/pk-auth-refresh-tokens/build.gradle.kts @@ -0,0 +1,41 @@ +plugins { + id("pkauth.library-conventions") + id("pkauth.test-conventions") + id("pkauth.publish-conventions") +} + +description = + "pk-auth refresh tokens: rotating opaque tokens with family-based replay defense." + +tasks.named("compileJava") { + options.compilerArgs.addAll( + listOf("-Xlint:-requires-automatic", "-Xlint:-requires-transitive-automatic"), + ) +} + +dependencies { + api(project(":pk-auth-core")) + // RotateResult.Success carries data the consumer needs to mint a fresh access token via + // PkAuthJwtIssuer — exposing the JwtClaims-shaped projection on the API surface keeps + // composability one import away. + api(project(":pk-auth-jwt")) + implementation(libs.slf4j.api) + + testImplementation(project(":pk-auth-testkit")) + testRuntimeOnly(libs.logback.classic) +} + +tasks.named("jacocoTestCoverageVerification") { + violationRules { + rule { + limit { + counter = "LINE" + minimum = "0.70".toBigDecimal() + } + } + } +} + +tasks.named("check") { + dependsOn("jacocoTestCoverageVerification") +} diff --git a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenConfig.java b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenConfig.java new file mode 100644 index 0000000..bf0ec27 --- /dev/null +++ b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenConfig.java @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.refresh; + +import java.time.Duration; +import java.util.Objects; + +/** + * Configuration for {@link RefreshTokenService}. + * + * @param ttlPolicy per-audience refresh TTL dispatch + * @param secretBytes how many random bytes to generate for each refresh-token secret. Default 32 + * (256 bits) — high enough that the hash-at-rest is brute-force resistant without salt. + * @param refreshIdBytes how many random bytes for the {@code refreshId} half of the wire format. + * Default 16 (128 bits) — more than enough to identify a row without collision. + * @param cleanupRetention how long after a row is used or revoked to keep it around for forensic + * visibility. Operator-driven cleanup ({@link + * com.codeheadsystems.pkauth.refresh.spi.RefreshTokenRepository#deleteExpiredBefore}) uses this + * as the cutoff offset. Default 30 days. + * @since 1.1.0 + */ +public record RefreshTokenConfig( + RefreshTtlPolicy ttlPolicy, int secretBytes, int refreshIdBytes, Duration cleanupRetention) { + + /** Default secret entropy: 32 bytes (256 bits) from {@code SecureRandom}. */ + public static final int DEFAULT_SECRET_BYTES = 32; + + /** Default refreshId size: 16 bytes (128 bits). */ + public static final int DEFAULT_REFRESH_ID_BYTES = 16; + + /** Default forensic retention for used/revoked rows: 30 days. */ + public static final Duration DEFAULT_CLEANUP_RETENTION = Duration.ofDays(30); + + /** Default refresh-token TTL when {@link RefreshTtlPolicy#single(Duration)} is used: 14 days. */ + public static final Duration DEFAULT_REFRESH_TTL = Duration.ofDays(14); + + public RefreshTokenConfig { + Objects.requireNonNull(ttlPolicy, "ttlPolicy"); + if (secretBytes < 16) { + throw new IllegalArgumentException("secretBytes must be >= 16 (got " + secretBytes + ")"); + } + if (refreshIdBytes < 8) { + throw new IllegalArgumentException( + "refreshIdBytes must be >= 8 (got " + refreshIdBytes + ")"); + } + Objects.requireNonNull(cleanupRetention, "cleanupRetention"); + if (cleanupRetention.isNegative()) { + throw new IllegalArgumentException("cleanupRetention must be non-negative"); + } + } + + /** Convenience factory pinning the documented defaults around a single-TTL policy. */ + public static RefreshTokenConfig defaults() { + return new RefreshTokenConfig( + RefreshTtlPolicy.single(DEFAULT_REFRESH_TTL), + DEFAULT_SECRET_BYTES, + DEFAULT_REFRESH_ID_BYTES, + DEFAULT_CLEANUP_RETENTION); + } +} diff --git a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenPair.java b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenPair.java new file mode 100644 index 0000000..c8409b4 --- /dev/null +++ b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenPair.java @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.refresh; + +import java.util.Objects; + +/** + * Result of issuing or rotating a refresh token. The {@link #wireToken()} is the only value that + * goes to the client — the bearer presents it on the next refresh; the {@link #record()} is the + * server-side projection (with hash, never the raw secret). + * + * @param wireToken the {@code "{refreshId}.{secret}"} string to return to the client; must NEVER be + * logged + * @param record the persisted shape (defensive copy of the hash, no raw secret) + * @since 1.1.0 + */ +public record RefreshTokenPair(String wireToken, RefreshTokenRecord record) { + + public RefreshTokenPair { + Objects.requireNonNull(wireToken, "wireToken"); + Objects.requireNonNull(record, "record"); + } +} diff --git a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenRecord.java b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenRecord.java new file mode 100644 index 0000000..a9f0aaf --- /dev/null +++ b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenRecord.java @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.refresh; + +import com.codeheadsystems.pkauth.api.UserHandle; +import java.time.Instant; +import java.util.Arrays; +import java.util.Objects; +import java.util.Optional; + +/** + * Persisted shape of a single refresh-token row. The user-facing wire format is {@code + * "{refreshId}.{secret}"}; only {@link #tokenHash()} (SHA-256 of the raw secret bytes) is stored + * server-side. + * + *

Family model: every chain of rotations shares a {@code familyId} (the {@code refreshId} of the + * family root). Replaying a used token in any family member scorches the entire family. + * + * @param refreshId opaque identifier (16 random bytes, base64url-encoded — 22 chars) + * @param tokenHash SHA-256 of the raw 32-byte secret + * @param userHandle owning user + * @param audience the audience this refresh is scoped to (drives per-audience TTL via {@link + * RefreshTtlPolicy}) + * @param deviceId optional device identifier; reserved for future device-binding work + * @param familyId identifier shared by every token in the rotation chain (equals the root's {@code + * refreshId}) + * @param parentRefreshId for rotated tokens, the {@code refreshId} of the parent in the chain; null + * for the family root + * @param issuedAt when this token was issued + * @param expiresAt when this token expires (absolute) + * @param usedAt set when the atomic rotate primitive transitions this row from fresh to used — the + * load-bearing field for replay defense + * @param revokedAt set when this token (or its family) is revoked + * @param revokedReason categorical reason matching {@link #revokedAt}; present iff {@code + * revokedAt} is set + * @since 1.1.0 + */ +public record RefreshTokenRecord( + String refreshId, + byte[] tokenHash, + UserHandle userHandle, + String audience, + Optional deviceId, + String familyId, + Optional parentRefreshId, + Instant issuedAt, + Instant expiresAt, + Optional usedAt, + Optional revokedAt, + Optional revokedReason) { + + public RefreshTokenRecord { + Objects.requireNonNull(refreshId, "refreshId"); + if (refreshId.isBlank()) { + throw new IllegalArgumentException("refreshId must be non-blank"); + } + Objects.requireNonNull(tokenHash, "tokenHash"); + if (tokenHash.length == 0) { + throw new IllegalArgumentException("tokenHash must be non-empty"); + } + Objects.requireNonNull(userHandle, "userHandle"); + Objects.requireNonNull(audience, "audience"); + if (audience.isBlank()) { + throw new IllegalArgumentException("audience must be non-blank"); + } + Objects.requireNonNull(deviceId, "deviceId"); + Objects.requireNonNull(familyId, "familyId"); + if (familyId.isBlank()) { + throw new IllegalArgumentException("familyId must be non-blank"); + } + Objects.requireNonNull(parentRefreshId, "parentRefreshId"); + Objects.requireNonNull(issuedAt, "issuedAt"); + Objects.requireNonNull(expiresAt, "expiresAt"); + Objects.requireNonNull(usedAt, "usedAt"); + Objects.requireNonNull(revokedAt, "revokedAt"); + Objects.requireNonNull(revokedReason, "revokedReason"); + if (revokedAt.isPresent() != revokedReason.isPresent()) { + throw new IllegalArgumentException( + "revokedAt and revokedReason must both be set or both absent"); + } + tokenHash = tokenHash.clone(); + } + + /** Returns a defensive copy of the stored SHA-256 hash. */ + @Override + public byte[] tokenHash() { + return tokenHash.clone(); + } + + @Override + public boolean equals(Object o) { + return o instanceof RefreshTokenRecord r + && Objects.equals(refreshId, r.refreshId) + && Arrays.equals(tokenHash, r.tokenHash) + && Objects.equals(userHandle, r.userHandle) + && Objects.equals(audience, r.audience) + && Objects.equals(deviceId, r.deviceId) + && Objects.equals(familyId, r.familyId) + && Objects.equals(parentRefreshId, r.parentRefreshId) + && Objects.equals(issuedAt, r.issuedAt) + && Objects.equals(expiresAt, r.expiresAt) + && Objects.equals(usedAt, r.usedAt) + && Objects.equals(revokedAt, r.revokedAt) + && Objects.equals(revokedReason, r.revokedReason); + } + + @Override + public int hashCode() { + return Objects.hash( + refreshId, + Arrays.hashCode(tokenHash), + userHandle, + audience, + deviceId, + familyId, + parentRefreshId, + issuedAt, + expiresAt, + usedAt, + revokedAt, + revokedReason); + } +} diff --git a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenService.java b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenService.java new file mode 100644 index 0000000..fe4227a --- /dev/null +++ b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenService.java @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.refresh; + +import com.codeheadsystems.pkauth.api.UserHandle; +import com.codeheadsystems.pkauth.json.Base64Url; +import com.codeheadsystems.pkauth.refresh.spi.RefreshTokenRepository; +import com.codeheadsystems.pkauth.spi.ClockProvider; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Issues, rotates, and revokes refresh tokens. See ADR 0013 for the family-based replay-defense + * design and the operational invariants this implementation must preserve. + * + *

Wire format is {@code "{refreshId}.{secret}"} where both halves are base64url. Only the + * SHA-256 hash of the raw secret bytes is persisted; the wire token never gets logged. + * + *

This service does NOT issue access tokens itself — {@link #rotate(String)} returns the data + * the caller needs to mint a fresh JWT via {@link com.codeheadsystems.pkauth.jwt.PkAuthJwtIssuer}. + * The two primitives stay composable. + * + * @since 1.1.0 + */ +public final class RefreshTokenService { + + private static final Logger LOG = LoggerFactory.getLogger(RefreshTokenService.class); + + private final RefreshTokenRepository repository; + private final RefreshTokenConfig config; + private final ClockProvider clockProvider; + private final SecureRandom random; + + public RefreshTokenService( + RefreshTokenRepository repository, + RefreshTokenConfig config, + ClockProvider clockProvider, + SecureRandom random) { + this.repository = Objects.requireNonNull(repository, "repository"); + this.config = Objects.requireNonNull(config, "config"); + this.clockProvider = Objects.requireNonNull(clockProvider, "clockProvider"); + this.random = Objects.requireNonNull(random, "random"); + } + + /** + * Convenience constructor using a fresh {@link SecureRandom}. Production deployments typically + * pick this — the explicit-RNG ctor exists for tests with deterministic seeds. + */ + public RefreshTokenService( + RefreshTokenRepository repository, RefreshTokenConfig config, ClockProvider clockProvider) { + this(repository, config, clockProvider, new SecureRandom()); + } + + /** + * Issues a fresh refresh token belonging to a new family (root of the rotation chain). Returns + * the wire token plus the persisted record summary. + */ + public RefreshTokenPair issue(UserHandle userHandle, String audience, Optional deviceId) { + Objects.requireNonNull(userHandle, "userHandle"); + Objects.requireNonNull(audience, "audience"); + Objects.requireNonNull(deviceId, "deviceId"); + if (audience.isBlank()) { + throw new IllegalArgumentException("audience must be non-blank"); + } + Instant now = clockProvider.now(); + String refreshId = randomBase64Url(config.refreshIdBytes()); + byte[] secret = randomBytes(config.secretBytes()); + RefreshTokenRecord record = + new RefreshTokenRecord( + refreshId, + sha256(secret), + userHandle, + audience, + deviceId, + refreshId, // family root: familyId = refreshId + Optional.empty(), + now, + now.plus(config.ttlPolicy().refreshTtl(audience)), + Optional.empty(), + Optional.empty(), + Optional.empty()); + repository.create(record); + return new RefreshTokenPair(wireFormat(refreshId, secret), record); + } + + /** + * Validates the presented wire token and, if fresh, rotates it: atomically marks the parent used + * and inserts a successor in the same family. On detection of a replay (a used or revoked token + * from a known family being presented again) the entire family is scorched and {@link + * RotateResult.Replayed} is returned. + */ + public RotateResult rotate(String presentedWireToken) { + Objects.requireNonNull(presentedWireToken, "presentedWireToken"); + Parsed parsed = parse(presentedWireToken); + if (parsed == null) { + return new RotateResult.Unknown(); + } + + Optional maybe = repository.findByRefreshId(parsed.refreshId); + if (maybe.isEmpty()) { + return new RotateResult.Unknown(); + } + RefreshTokenRecord parent = maybe.get(); + + // Hash-check the presented secret BEFORE marking used. A wrong secret must never burn a + // legitimate token. This is an explicit operational invariant; see ADR 0013. + byte[] presentedHash = sha256(parsed.secret); + if (!MessageDigest.isEqual(presentedHash, parent.tokenHash())) { + return new RotateResult.Unknown(); + } + + // Already revoked? A row whose family was scorched in a prior replay carries + // reason=ROTATION_REPLAY — return Replayed so race losers and replay-after-the-fact callers + // see a consistent outcome. Other revoke reasons (LOGOUT, USER_DELETED, ADMIN, ...) surface + // as Revoked so the caller can distinguish "this session was deliberately ended" from "a + // replay just got detected on this family." + if (parent.revokedAt().isPresent()) { + RevokeReason reason = parent.revokedReason().orElse(RevokeReason.ADMIN); + if (reason == RevokeReason.ROTATION_REPLAY) { + return new RotateResult.Replayed(parent.familyId(), parent.userHandle()); + } + return new RotateResult.Revoked(reason); + } + + Instant now = clockProvider.now(); + if (!parent.expiresAt().isAfter(now)) { + return new RotateResult.Expired(); + } + + // If the parent is already used (but not yet revoked), this is a replay. Don't even call + // rotateAtomically — go straight to family scorch. Saves a no-op write and gives a cleaner + // log signal. + if (parent.usedAt().isPresent()) { + scorchFamily(parent); + return new RotateResult.Replayed(parent.familyId(), parent.userHandle()); + } + + // Fresh enough to attempt rotation. Mint the successor and have the SPI commit both + // "mark parent used" and "insert successor" atomically (see SPI javadoc). + String successorId = randomBase64Url(config.refreshIdBytes()); + byte[] successorSecret = randomBytes(config.secretBytes()); + RefreshTokenRecord successor = + new RefreshTokenRecord( + successorId, + sha256(successorSecret), + parent.userHandle(), + parent.audience(), + parent.deviceId(), + parent.familyId(), + Optional.of(parent.refreshId()), + now, + now.plus(config.ttlPolicy().refreshTtl(parent.audience())), + Optional.empty(), + Optional.empty(), + Optional.empty()); + + boolean rotated = repository.rotateAtomically(parent.refreshId(), now, successor); + if (!rotated) { + // Lost the race against a concurrent rotator (or the parent flipped to used/revoked after + // our read). Scorch the family — at most one rotator wins, the rest see Replayed. The + // scorch is outside the failed rotation so it always commits. + scorchFamily(parent); + return new RotateResult.Replayed(parent.familyId(), parent.userHandle()); + } + + String wire = wireFormat(successorId, successorSecret); + RefreshTokenPair pair = new RefreshTokenPair(wire, successor); + RotatedClaims claims = + new RotatedClaims(successor.userHandle(), successor.audience(), successor.deviceId()); + return new RotateResult.Success(pair, claims); + } + + /** Revokes every unrevoked row in the supplied family. Idempotent. */ + public void revokeFamily(String familyId, RevokeReason reason) { + Objects.requireNonNull(familyId, "familyId"); + Objects.requireNonNull(reason, "reason"); + int n = repository.revokeFamily(familyId, clockProvider.now(), reason); + LOG.info("pkauth.refresh.family.revoked family={} reason={} affected={}", familyId, reason, n); + } + + /** + * Revokes every unrevoked refresh row for the supplied user. Drives the user-deletion fan-out's + * refresh-token branch and is also surfaced as an admin "log out everywhere" hook. + */ + public int revokeAllForUser(UserHandle userHandle, RevokeReason reason) { + Objects.requireNonNull(userHandle, "userHandle"); + Objects.requireNonNull(reason, "reason"); + int n = repository.revokeAllForUser(userHandle, clockProvider.now(), reason); + LOG.info( + "pkauth.refresh.user.revoked user_handle_b64={} reason={} affected={}", + Base64Url.encode(userHandle.value()), + reason, + n); + return n; + } + + /** Listing projection for admin / UI surfaces. */ + public List listForUser(UserHandle userHandle) { + Objects.requireNonNull(userHandle, "userHandle"); + return repository.findByUserHandle(userHandle).stream().map(RefreshTokenSummary::from).toList(); + } + + // -- Internals -------------------------------------------------------------------------- + + private void scorchFamily(RefreshTokenRecord triggering) { + Instant now = clockProvider.now(); + int n = repository.revokeFamily(triggering.familyId(), now, RevokeReason.ROTATION_REPLAY); + LOG.warn( + "pkauth.refresh.replay family={} user_handle_b64={} affected={}", + triggering.familyId(), + Base64Url.encode(triggering.userHandle().value()), + n); + } + + private byte[] randomBytes(int n) { + byte[] out = new byte[n]; + random.nextBytes(out); + return out; + } + + private String randomBase64Url(int byteLen) { + return Base64Url.encode(randomBytes(byteLen)); + } + + private static String wireFormat(String refreshId, byte[] secret) { + return refreshId + "." + Base64Url.encode(secret); + } + + private static byte[] sha256(byte[] in) { + try { + return MessageDigest.getInstance("SHA-256").digest(in); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } + } + + private static Parsed parse(String wireToken) { + int dot = wireToken.indexOf('.'); + if (dot <= 0 || dot >= wireToken.length() - 1) { + return null; + } + String refreshId = wireToken.substring(0, dot); + String secretB64 = wireToken.substring(dot + 1); + byte[] secret; + try { + secret = Base64Url.decode(secretB64); + } catch (RuntimeException e) { + return null; + } + if (secret.length == 0) { + return null; + } + return new Parsed(refreshId, secret); + } + + private record Parsed(String refreshId, byte[] secret) {} +} diff --git a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenServiceDeletionListener.java b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenServiceDeletionListener.java new file mode 100644 index 0000000..05cb4b7 --- /dev/null +++ b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenServiceDeletionListener.java @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.refresh; + +import com.codeheadsystems.pkauth.api.UserHandle; +import com.codeheadsystems.pkauth.lifecycle.UserDeletionListener; +import java.util.Objects; + +/** + * Bridges {@link RefreshTokenService#revokeAllForUser(UserHandle, RevokeReason)} into the {@link + * com.codeheadsystems.pkauth.lifecycle.UserDeletionService} fan-out. Adapters register this + * listener whenever a {@link RefreshTokenService} bean is wired so refresh families are revoked as + * part of user deletion. + * + * @since 1.1.0 + */ +public final class RefreshTokenServiceDeletionListener implements UserDeletionListener { + + private final RefreshTokenService service; + + public RefreshTokenServiceDeletionListener(RefreshTokenService service) { + this.service = Objects.requireNonNull(service, "service"); + } + + @Override + public void onUserDeleted(UserHandle userHandle) { + service.revokeAllForUser(userHandle, RevokeReason.USER_DELETED); + } +} diff --git a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenSummary.java b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenSummary.java new file mode 100644 index 0000000..e7772bd --- /dev/null +++ b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenSummary.java @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.refresh; + +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** + * Listing projection for admin / UI surfaces. Carries the metadata needed to render a "your active + * sessions" view without exposing any secret-bearing fields. Read-only; produced by {@link + * RefreshTokenService#listForUser(com.codeheadsystems.pkauth.api.UserHandle)}. + * + * @param refreshId opaque identifier (no hash, no secret) + * @param audience scope this refresh covers + * @param familyId rotation chain identifier; multiple summaries can share a familyId (every row in + * the chain has its own listing) + * @param deviceId optional device identifier + * @param issuedAt when this row was issued + * @param expiresAt absolute expiry + * @param usedAt set if a successor has rotated past this row + * @param revokedAt set if this row (or its family) is revoked + * @since 1.1.0 + */ +public record RefreshTokenSummary( + String refreshId, + String audience, + String familyId, + Optional deviceId, + Instant issuedAt, + Instant expiresAt, + Optional usedAt, + Optional revokedAt) { + + public RefreshTokenSummary { + Objects.requireNonNull(refreshId, "refreshId"); + Objects.requireNonNull(audience, "audience"); + Objects.requireNonNull(familyId, "familyId"); + Objects.requireNonNull(deviceId, "deviceId"); + Objects.requireNonNull(issuedAt, "issuedAt"); + Objects.requireNonNull(expiresAt, "expiresAt"); + Objects.requireNonNull(usedAt, "usedAt"); + Objects.requireNonNull(revokedAt, "revokedAt"); + } + + /** Builds the summary from a full record (drops hash + parent linkage). */ + public static RefreshTokenSummary from(RefreshTokenRecord record) { + Objects.requireNonNull(record, "record"); + return new RefreshTokenSummary( + record.refreshId(), + record.audience(), + record.familyId(), + record.deviceId(), + record.issuedAt(), + record.expiresAt(), + record.usedAt(), + record.revokedAt()); + } +} diff --git a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTtlPolicy.java b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTtlPolicy.java new file mode 100644 index 0000000..d3b7c4c --- /dev/null +++ b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTtlPolicy.java @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.refresh; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Per-audience TTL lookup used by {@link RefreshTokenService} when issuing refresh tokens. + * Parallels {@link com.codeheadsystems.pkauth.jwt.TokenTtlPolicy} on the access-token side — + * different client kinds typically want very different refresh lifetimes (web=14d, cli=90d, …). + * + *

Implementations are expected to be cheap and side-effect-free. Built-in factories cover the + * common cases: + * + *

    + *
  • {@link #fixed(Duration, Map)} — static map of audience → TTL with a default fallback + *
  • {@link #single(Duration)} — same TTL for every audience + *
+ * + * @since 1.1.0 + */ +public interface RefreshTtlPolicy { + + /** + * Returns the refresh-token TTL for the given audience. Must always return a positive duration. + */ + Duration refreshTtl(String audience); + + /** + * Audiences this policy explicitly knows about. Empty means "validator falls back to the default + * audience set elsewhere" — analogous to {@link + * com.codeheadsystems.pkauth.jwt.TokenTtlPolicy#knownAudiences()}. + */ + default Set knownAudiences() { + return Set.of(); + } + + /** Returns a policy dispatching by audience with a default-TTL fallback. */ + static RefreshTtlPolicy fixed(Duration defaultTtl, Map overrides) { + Objects.requireNonNull(defaultTtl, "defaultTtl"); + Objects.requireNonNull(overrides, "overrides"); + if (defaultTtl.isZero() || defaultTtl.isNegative()) { + throw new IllegalArgumentException("defaultTtl must be positive"); + } + Map copy = new LinkedHashMap<>(); + for (Map.Entry e : overrides.entrySet()) { + Objects.requireNonNull(e.getKey(), "override audience"); + Objects.requireNonNull(e.getValue(), "override ttl for " + e.getKey()); + if (e.getValue().isZero() || e.getValue().isNegative()) { + throw new IllegalArgumentException( + "ttl for audience '" + e.getKey() + "' must be positive"); + } + copy.put(e.getKey(), e.getValue()); + } + Map frozen = Map.copyOf(copy); + Set audiences = Set.copyOf(frozen.keySet()); + return new RefreshTtlPolicy() { + @Override + public Duration refreshTtl(String audience) { + Duration explicit = frozen.get(audience); + return explicit != null ? explicit : defaultTtl; + } + + @Override + public Set knownAudiences() { + return audiences; + } + + @Override + public String toString() { + return "RefreshTtlPolicy.fixed(default=" + defaultTtl + ", overrides=" + frozen + ")"; + } + }; + } + + /** Returns a policy that uses the same TTL for every audience. */ + static RefreshTtlPolicy single(Duration ttl) { + Objects.requireNonNull(ttl, "ttl"); + if (ttl.isZero() || ttl.isNegative()) { + throw new IllegalArgumentException("ttl must be positive"); + } + return new RefreshTtlPolicy() { + @Override + public Duration refreshTtl(String audience) { + return ttl; + } + + @Override + public String toString() { + return "RefreshTtlPolicy.single(" + ttl + ")"; + } + }; + } +} diff --git a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RevokeReason.java b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RevokeReason.java new file mode 100644 index 0000000..23100e7 --- /dev/null +++ b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RevokeReason.java @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.refresh; + +/** + * Categorical reason a refresh token (or an entire family) was revoked. Persisted on the row at + * revoke time for forensic visibility and to disambiguate the {@link RotateResult.Revoked#reason()} + * returned to a rotation attempt against an already-revoked family. + * + * @since 1.1.0 + */ +public enum RevokeReason { + + /** User-driven logout. */ + LOGOUT, + + /** + * A used or revoked token in this family was presented again — the load-bearing replay defense + * outcome. The entire family is revoked and the bearer must re-authenticate. + */ + ROTATION_REPLAY, + + /** A device the token was bound to was revoked. Reserved for future device-binding work. */ + DEVICE_REVOKED, + + /** The owning user was deleted; revocation runs as part of the user-deletion fan-out. */ + USER_DELETED, + + /** Manual admin action (incident response, password reset, security event). */ + ADMIN +} diff --git a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RotateResult.java b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RotateResult.java new file mode 100644 index 0000000..aebea3b --- /dev/null +++ b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RotateResult.java @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.refresh; + +import com.codeheadsystems.pkauth.api.UserHandle; +import java.util.Objects; + +/** + * Sealed sum of outcomes from {@link RefreshTokenService#rotate(String)}. Adapters typically + * pattern-match on this and translate to an HTTP response: + * + *
    + *
  • {@link Success} → {@code 200 OK} with the new wire token + access JWT + *
  • {@link Replayed}, {@link Expired}, {@link Unknown}, {@link Revoked} → {@code 401 + * Unauthorized} with a typed {@code detail} field naming the variant + *
+ * + * @since 1.1.0 + */ +public sealed interface RotateResult { + + /** + * The presented token was fresh; a successor has been minted in the same family. The consumer + * issues a fresh access JWT from {@link #claimsForAccessIssue()} and returns {@link #pair()} to + * the client. + */ + record Success(RefreshTokenPair pair, RotatedClaims claimsForAccessIssue) + implements RotateResult { + public Success { + Objects.requireNonNull(pair, "pair"); + Objects.requireNonNull(claimsForAccessIssue, "claimsForAccessIssue"); + } + } + + /** + * A used-or-revoked token from a known family was presented. The entire family has been scorched + * ({@link RevokeReason#ROTATION_REPLAY}); the bearer must re-authenticate. The legitimate user + * sees the next refresh fail and is redirected to the login page — that's the expected + * operational signal. + */ + record Replayed(String familyId, UserHandle userHandle) implements RotateResult { + public Replayed { + Objects.requireNonNull(familyId, "familyId"); + Objects.requireNonNull(userHandle, "userHandle"); + } + } + + /** The token's {@code expiresAt} has passed. No family revocation. */ + record Expired() implements RotateResult {} + + /** + * The presented {@code refreshId} did not match any persisted row — either the token was never + * issued by this RP, was issued long enough ago that the row has been cleaned up, or the wire + * format is malformed. No state changes. + */ + record Unknown() implements RotateResult {} + + /** + * The token belongs to a family that has already been revoked (logout, admin action, device + * revoke, user delete, or a prior replay). The {@code reason} carries why. + */ + record Revoked(RevokeReason reason) implements RotateResult { + public Revoked { + Objects.requireNonNull(reason, "reason"); + } + } +} diff --git a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RotatedClaims.java b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RotatedClaims.java new file mode 100644 index 0000000..edebfd9 --- /dev/null +++ b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RotatedClaims.java @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.refresh; + +import com.codeheadsystems.pkauth.api.UserHandle; +import java.util.Objects; +import java.util.Optional; + +/** + * Data the consumer needs to mint a fresh access token after a successful refresh-token rotation. + * Returned inside {@link RotateResult.Success} so the caller (typically the refresh HTTP endpoint) + * can hand the values to {@link com.codeheadsystems.pkauth.jwt.PkAuthJwtIssuer} — {@link + * com.codeheadsystems.pkauth.refresh.RefreshTokenService} deliberately does NOT call the issuer + * itself, to keep the two primitives composable. + * + * @param userHandle owning user (from the rotated token's row) + * @param audience audience the new access token should be scoped to (same as the rotated token's) + * @param deviceId optional device identifier carried through the rotation chain + * @since 1.1.0 + */ +public record RotatedClaims(UserHandle userHandle, String audience, Optional deviceId) { + + public RotatedClaims { + Objects.requireNonNull(userHandle, "userHandle"); + Objects.requireNonNull(audience, "audience"); + Objects.requireNonNull(deviceId, "deviceId"); + } +} diff --git a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/package-info.java b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/package-info.java new file mode 100644 index 0000000..f961afe --- /dev/null +++ b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/package-info.java @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT + +/** + * Rotating refresh tokens with family-based replay defense. + * + *

The {@link com.codeheadsystems.pkauth.refresh.RefreshTokenService} is the public entry point: + * issue a token for a user, rotate it on every refresh, revoke a family on logout, or revoke every + * family for a user on account compromise. Each rotation produces a child token in the same family + * with a link to the parent; replaying any used or revoked token in a family scorches the entire + * family ({@link com.codeheadsystems.pkauth.refresh.RotateResult.Replayed}). See ADR 0013. + * + *

Wire format is {@code "{refreshId}.{secret}"} where both halves are base64url; only the + * SHA-256 hash of the secret is persisted. The {@link + * com.codeheadsystems.pkauth.refresh.spi.RefreshTokenRepository} SPI declares the atomic mark-used + * contract that backs the replay defense — a single conditional UPDATE / DynamoDB conditional write + * that succeeds iff the token is fresh. + * + *

The service does not call {@link com.codeheadsystems.pkauth.jwt.PkAuthJwtIssuer} on its own. + * It returns the data the consumer needs to mint a fresh access token, keeping the two primitives + * composable. + * + * @since 1.1.0 + */ +package com.codeheadsystems.pkauth.refresh; diff --git a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/spi/RefreshTokenRepository.java b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/spi/RefreshTokenRepository.java new file mode 100644 index 0000000..d53297b --- /dev/null +++ b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/spi/RefreshTokenRepository.java @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.refresh.spi; + +import com.codeheadsystems.pkauth.api.UserHandle; +import com.codeheadsystems.pkauth.refresh.RefreshTokenRecord; +import com.codeheadsystems.pkauth.refresh.RevokeReason; +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +/** + * Persistence SPI for {@link com.codeheadsystems.pkauth.refresh.RefreshTokenService}. Implemented + * by: + * + *

    + *
  • {@code pk-auth-testkit} — in-memory, for unit tests / dev boots + *
  • {@code pk-auth-persistence-jdbi} — Postgres + Flyway V9 + *
  • {@code pk-auth-persistence-dynamodb} — single-table on {@code PkAuthCore} + *
+ * + *

Load-bearing replay defense. {@link #rotateAtomically(String, Instant, + * RefreshTokenRecord)} must mark the parent used AND insert the successor as a single atomic + * operation. Implementations MUST NOT decompose this into a separate mark-then-insert sequence — a + * concurrent rotator's family-scorch run between the two would miss the freshly-inserted successor. + * The service relies on a {@code false} return to indicate "another caller already used or revoked + * this token" and triggers a family-wide scorch in that branch. + * + *

See ADR 0013 for the design rationale. + * + * @since 1.1.0 + */ +public interface RefreshTokenRepository { + + /** + * Persists a freshly-issued or freshly-rotated refresh token. Implementations should reject a + * duplicate {@code refreshId} — this is a server-side bug, not a normal operation. + */ + void create(RefreshTokenRecord record); + + /** Returns the row identified by {@code refreshId}, or empty if no such row exists. */ + Optional findByRefreshId(String refreshId); + + /** + * Atomic mark-and-insert: marks {@code parentRefreshId} used iff still fresh AND inserts the + * supplied {@code successor} row, as a single atomic operation. Returns {@code true} iff the + * parent was fresh and the successor was inserted; {@code false} iff the parent was already used, + * revoked, expired, or absent (no successor row created). + * + *

Load-bearing replay defense. The atomicity is the entire point — a + * non-atomic sequence (mark, then insert) has a window where another concurrent rotator can call + * {@link #revokeFamily} between the two and miss the newly-inserted successor. Backend + * implementations MUST use: + * + *

    + *
  • JDBI: {@code jdbi.inTransaction(handle -> {...})} wrapping a conditional {@code UPDATE} + * and the successor {@code INSERT}. + *
  • DynamoDB: {@code TransactWriteItems} with a conditional update on the parent item and a + * conditional put for the successor item. + *
  • In-memory: a synchronized region or {@code ConcurrentHashMap.compute} block. + *
+ * + *

The predicate "fresh" means: {@code used_at IS NULL AND revoked_at IS NULL AND expires_at > + * :now}. On {@code false}, the service triggers a family-wide scorch — that revoke runs + * outside the failed rotation's scope so it commits regardless. + * + * @param parentRefreshId the {@code refreshId} of the token being rotated; its row must exist + * @param now timestamp to write into {@code used_at} on success + * @param successor the new row to insert iff the parent was fresh + * @return {@code true} iff the rotation completed atomically; {@code false} iff the parent was + * not fresh + */ + boolean rotateAtomically(String parentRefreshId, Instant now, RefreshTokenRecord successor); + + /** + * Revokes every unrevoked row in {@code familyId} with the supplied reason. Idempotent — + * already-revoked rows are left untouched (their original {@code revoked_reason} is preserved for + * forensic visibility). Returns the number of rows newly marked revoked. + */ + int revokeFamily(String familyId, Instant now, RevokeReason reason); + + /** + * Revokes every unrevoked refresh-token row owned by {@code userHandle}. Used by the + * user-deletion fan-out and admin "log out everywhere" actions. Idempotent. Returns the number of + * rows newly marked revoked. + */ + int revokeAllForUser(UserHandle userHandle, Instant now, RevokeReason reason); + + /** Lists every refresh-token row for a user. Read-only; used by admin / UI surfaces. */ + List findByUserHandle(UserHandle userHandle); + + /** + * Lists every refresh-token row in {@code familyId}. Intended for tests and forensic inspection — + * production code paths normally don't need this. + */ + List findByFamilyId(String familyId); + + /** + * Operator cleanup hook: deletes rows whose retention window has elapsed. Implementations should + * remove rows where {@code expires_at < cutoff} AND (the row is consumed or revoked before {@code + * cutoff}). Returns the number of rows deleted. Documented in {@code docs/operator-guide.md}. + */ + int deleteExpiredBefore(Instant cutoff); +} diff --git a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/spi/package-info.java b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/spi/package-info.java new file mode 100644 index 0000000..d089e59 --- /dev/null +++ b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/spi/package-info.java @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT + +/** + * SPI surface for {@link com.codeheadsystems.pkauth.refresh.RefreshTokenService}. Implemented by + * the testkit (in-memory), the JDBI persistence module (Postgres + Flyway), and the DynamoDB + * persistence module. + * + * @since 1.1.0 + */ +package com.codeheadsystems.pkauth.refresh.spi; diff --git a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/web/RefreshErrorResponse.java b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/web/RefreshErrorResponse.java new file mode 100644 index 0000000..6f8cf1e --- /dev/null +++ b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/web/RefreshErrorResponse.java @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.refresh.web; + +import org.jspecify.annotations.Nullable; + +/** + * Body for {@code 401 Unauthorized} responses from {@code POST /auth/refresh}. The {@code detail} + * field is the canonical machine-readable outcome name ({@code "expired"}, {@code "unknown"}, + * {@code "replayed"}, {@code "revoked"}); {@code reason} carries the categorical revoke reason when + * {@code detail = "revoked"} ({@code "LOGOUT"}, {@code "USER_DELETED"}, etc.). + * + * @since 1.1.0 + */ +public record RefreshErrorResponse(String detail, @Nullable String reason) {} diff --git a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/web/RefreshHandler.java b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/web/RefreshHandler.java new file mode 100644 index 0000000..7314983 --- /dev/null +++ b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/web/RefreshHandler.java @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.refresh.web; + +import com.codeheadsystems.pkauth.jwt.JwtClaims; +import com.codeheadsystems.pkauth.jwt.PkAuthJwtIssuer; +import com.codeheadsystems.pkauth.refresh.RefreshTokenService; +import com.codeheadsystems.pkauth.refresh.RotateResult; +import java.util.List; +import java.util.Objects; + +/** + * Framework-neutral helper that turns a presented refresh token into either a {@link + * RefreshResponse} (success) or a {@link RefreshErrorResponse} (any of the typed failures). Each + * adapter's HTTP layer wraps this — Spring's controller, Dropwizard's resource, Micronaut's + * controller — so the rotation logic and error mapping live in one place. + * + * @since 1.1.0 + */ +public final class RefreshHandler { + + /** Sealed outcome the adapter pattern-matches into an HTTP response. */ + public sealed interface Outcome { + record Success(RefreshResponse response) implements Outcome {} + + record Failure(RefreshErrorResponse response) implements Outcome {} + } + + private final RefreshTokenService refreshService; + private final PkAuthJwtIssuer accessIssuer; + + public RefreshHandler(RefreshTokenService refreshService, PkAuthJwtIssuer accessIssuer) { + this.refreshService = Objects.requireNonNull(refreshService, "refreshService"); + this.accessIssuer = Objects.requireNonNull(accessIssuer, "accessIssuer"); + } + + /** + * Rotates the presented token and, on success, mints an access JWT bound to the rotated audience. + * The caller maps {@link Outcome.Success} to {@code 200 OK} and {@link Outcome.Failure} to {@code + * 401 Unauthorized}. + * + *

Wire-token absence (null / empty body) becomes a {@code unknown} failure rather than a 400 + * so the same response shape covers every error case. + */ + public Outcome handle(RefreshRequest request) { + if (request == null || request.refreshToken() == null || request.refreshToken().isBlank()) { + return new Outcome.Failure(new RefreshErrorResponse("unknown", null)); + } + RotateResult result = refreshService.rotate(request.refreshToken()); + return switch (result) { + case RotateResult.Success s -> { + JwtClaims claims = + JwtClaims.forRefresh( + s.claimsForAccessIssue().userHandle(), + s.claimsForAccessIssue().audience(), + List.of("user")); + String accessJwt = accessIssuer.issue(claims); + yield new Outcome.Success( + new RefreshResponse(s.pair().wireToken(), accessJwt, s.pair().record().expiresAt())); + } + case RotateResult.Expired e -> new Outcome.Failure(new RefreshErrorResponse("expired", null)); + case RotateResult.Unknown u -> new Outcome.Failure(new RefreshErrorResponse("unknown", null)); + case RotateResult.Replayed r -> + new Outcome.Failure(new RefreshErrorResponse("replayed", null)); + case RotateResult.Revoked rv -> + new Outcome.Failure(new RefreshErrorResponse("revoked", rv.reason().name())); + }; + } +} diff --git a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/web/RefreshRequest.java b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/web/RefreshRequest.java new file mode 100644 index 0000000..7fafb72 --- /dev/null +++ b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/web/RefreshRequest.java @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.refresh.web; + +import org.jspecify.annotations.Nullable; + +/** + * HTTP request body for the {@code POST /auth/refresh} endpoint shared by every adapter. + * + * @param refreshToken the wire token last issued to the client (format {@code + * "{refreshId}.{secret}"}) + * @since 1.1.0 + */ +public record RefreshRequest(@Nullable String refreshToken) {} diff --git a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/web/RefreshResponse.java b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/web/RefreshResponse.java new file mode 100644 index 0000000..2d1b5cb --- /dev/null +++ b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/web/RefreshResponse.java @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.refresh.web; + +import java.time.Instant; +import java.util.Objects; + +/** + * Successful response from {@code POST /auth/refresh}. + * + * @param refreshToken the wire token to use on the next refresh + * @param accessToken a freshly-minted JWT scoped to the rotated token's audience + * @param expiresAt absolute expiry of the new refresh token + * @since 1.1.0 + */ +public record RefreshResponse(String refreshToken, String accessToken, Instant expiresAt) { + public RefreshResponse { + Objects.requireNonNull(refreshToken, "refreshToken"); + Objects.requireNonNull(accessToken, "accessToken"); + Objects.requireNonNull(expiresAt, "expiresAt"); + } +} diff --git a/pk-auth-refresh-tokens/src/main/java/module-info.java b/pk-auth-refresh-tokens/src/main/java/module-info.java new file mode 100644 index 0000000..bde9d13 --- /dev/null +++ b/pk-auth-refresh-tokens/src/main/java/module-info.java @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT + +/** + * pk-auth refresh-tokens module: rotating opaque tokens with family-based replay defense. + * + * @since 1.1.0 + */ +module com.codeheadsystems.pkauth.refresh { + requires transitive com.codeheadsystems.pkauth.core; + requires transitive com.codeheadsystems.pkauth.jwt; + requires org.slf4j; + requires org.jspecify; + + exports com.codeheadsystems.pkauth.refresh; + exports com.codeheadsystems.pkauth.refresh.spi; + exports com.codeheadsystems.pkauth.refresh.web; +} diff --git a/pk-auth-spring-boot-starter/build.gradle.kts b/pk-auth-spring-boot-starter/build.gradle.kts index 5d29bb9..a76c2c8 100644 --- a/pk-auth-spring-boot-starter/build.gradle.kts +++ b/pk-auth-spring-boot-starter/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { api(project(":pk-auth-backup-codes")) api(project(":pk-auth-magic-link")) api(project(":pk-auth-otp")) + api(project(":pk-auth-refresh-tokens")) // Admin API is optional from a runtime perspective; the autoconfigure block reads it via // @ConditionalOnClass. We still depend on it `compileOnly` so the controller class compiles. diff --git a/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/autoconfigure/PkAuthAutoConfiguration.java b/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/autoconfigure/PkAuthAutoConfiguration.java index a1797ae..3ea3244 100644 --- a/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/autoconfigure/PkAuthAutoConfiguration.java +++ b/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/autoconfigure/PkAuthAutoConfiguration.java @@ -32,6 +32,12 @@ import com.codeheadsystems.pkauth.otp.OtpPepperResolver; import com.codeheadsystems.pkauth.otp.OtpService; import com.codeheadsystems.pkauth.otp.SmsSender; +import com.codeheadsystems.pkauth.refresh.RefreshTokenConfig; +import com.codeheadsystems.pkauth.refresh.RefreshTokenService; +import com.codeheadsystems.pkauth.refresh.RefreshTokenServiceDeletionListener; +import com.codeheadsystems.pkauth.refresh.RefreshTtlPolicy; +import com.codeheadsystems.pkauth.refresh.spi.RefreshTokenRepository; +import com.codeheadsystems.pkauth.refresh.web.RefreshHandler; import com.codeheadsystems.pkauth.spi.BackupCodeRepository; import com.codeheadsystems.pkauth.spi.CeremonyRateLimiter; import com.codeheadsystems.pkauth.spi.ChallengeStore; @@ -51,6 +57,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -323,6 +330,65 @@ public UserDeletionService pkAuthUserDeletionService(List return new UserDeletionService(listeners); } + // -- Refresh tokens (only active when a RefreshTokenRepository bean is present) ------------- + + /** + * Builds the {@link RefreshTokenConfig} from {@code pkauth.refresh.*} properties. Materialised + * unconditionally so hosts can inject it for ad-hoc construction, but the bean only matters when + * a {@link RefreshTokenRepository} is also bound. + * + * @since 1.1.0 + */ + @Bean + @ConditionalOnMissingBean + public RefreshTokenConfig pkAuthRefreshTokenConfig(PkAuthProperties props) { + PkAuthProperties.Refresh refresh = props.refresh(); + Duration defaultTtl = + refresh.defaultTtl() == null + ? RefreshTokenConfig.DEFAULT_REFRESH_TTL + : refresh.defaultTtl(); + Map overrides = refresh.ttlsByAudience(); + RefreshTtlPolicy policy = + overrides == null || overrides.isEmpty() + ? RefreshTtlPolicy.single(defaultTtl) + : RefreshTtlPolicy.fixed(defaultTtl, overrides); + Duration retention = + refresh.cleanupRetention() == null + ? RefreshTokenConfig.DEFAULT_CLEANUP_RETENTION + : refresh.cleanupRetention(); + return new RefreshTokenConfig( + policy, + RefreshTokenConfig.DEFAULT_SECRET_BYTES, + RefreshTokenConfig.DEFAULT_REFRESH_ID_BYTES, + retention); + } + + /** {@link RefreshTokenService} bean — only when a {@link RefreshTokenRepository} is wired. */ + @Bean + @ConditionalOnMissingBean + @ConditionalOnBean(RefreshTokenRepository.class) + public RefreshTokenService pkAuthRefreshTokenService( + RefreshTokenRepository repository, RefreshTokenConfig config, ClockProvider clockProvider) { + return new RefreshTokenService(repository, config, clockProvider); + } + + /** Wires the user-deletion fan-out's refresh-token branch. */ + @Bean + @ConditionalOnBean(RefreshTokenService.class) + public UserDeletionListener pkAuthRefreshTokenServiceDeletionListener( + RefreshTokenService service) { + return new RefreshTokenServiceDeletionListener(service); + } + + /** Framework-neutral handler that the controller delegates to. */ + @Bean + @ConditionalOnMissingBean + @ConditionalOnBean(RefreshTokenService.class) + public RefreshHandler pkAuthRefreshHandler( + RefreshTokenService refreshService, PkAuthJwtIssuer issuer) { + return new RefreshHandler(refreshService, issuer); + } + // -- Alt-flow services ----------------------------------------------------------------------- /** diff --git a/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/autoconfigure/PkAuthWebAutoConfiguration.java b/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/autoconfigure/PkAuthWebAutoConfiguration.java index 426eceb..bdef5e4 100644 --- a/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/autoconfigure/PkAuthWebAutoConfiguration.java +++ b/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/autoconfigure/PkAuthWebAutoConfiguration.java @@ -7,14 +7,17 @@ import com.codeheadsystems.pkauth.jwt.CeremonyOrchestrator; import com.codeheadsystems.pkauth.jwt.PkAuthJwtIssuer; import com.codeheadsystems.pkauth.jwt.PkAuthJwtValidator; +import com.codeheadsystems.pkauth.refresh.web.RefreshHandler; import com.codeheadsystems.pkauth.spi.CredentialRepository; import com.codeheadsystems.pkauth.spring.security.PkAuthJwtAuthenticationFilter; import com.codeheadsystems.pkauth.spring.web.PkAuthCeremonyController; import com.codeheadsystems.pkauth.spring.web.PkAuthExceptionHandler; +import com.codeheadsystems.pkauth.spring.web.PkAuthRefreshController; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; @@ -91,6 +94,10 @@ public SecurityFilterChain pkAuthSecurityFilterChain( authz .requestMatchers("/auth/passkeys/**") .permitAll() + // /auth/refresh carries its own credential (the refresh token) — the JWT + // filter must not reject it for missing bearer auth. + .requestMatchers("/auth/refresh") + .permitAll() .requestMatchers("/auth/admin/email/complete-verification") .permitAll() .requestMatchers("/auth/admin/**") @@ -125,6 +132,19 @@ public PkAuthCeremonyController pkAuthCeremonyController(CeremonyOrchestrator or return new PkAuthCeremonyController(orchestrator); } + /** + * Refresh controller — only mounted when a {@link RefreshHandler} is present (which itself + * requires a {@code RefreshTokenRepository} bean wired by the host). + * + * @since 1.1.0 + */ + @Bean + @ConditionalOnMissingBean + @ConditionalOnBean(RefreshHandler.class) + public PkAuthRefreshController pkAuthRefreshController(RefreshHandler handler) { + return new PkAuthRefreshController(handler); + } + /** * Maps SPI {@link com.codeheadsystems.pkauth.spi.PkAuthPersistenceException} to a stable {@code * 503} JSON body — see the controller-advice class for the wire shape. Registered explicitly diff --git a/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/config/PkAuthProperties.java b/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/config/PkAuthProperties.java index ec941b2..63d2c37 100644 --- a/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/config/PkAuthProperties.java +++ b/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/config/PkAuthProperties.java @@ -26,12 +26,18 @@ */ @ConfigurationProperties("pkauth") public record PkAuthProperties( - RelyingParty relyingParty, Jwt jwt, Ceremony ceremony, Otp otp, boolean devMode) { + RelyingParty relyingParty, + Jwt jwt, + Ceremony ceremony, + Otp otp, + Refresh refresh, + boolean devMode) { /** - * Normalises the optional blocks ({@code ceremony}, {@code otp}) to their defaults so callers - * don't have to null-check. Required blocks ({@code relyingParty}, {@code jwt}) are left as the - * framework bound them — if absent, downstream wiring fails fast with a clear message. + * Normalises the optional blocks ({@code ceremony}, {@code otp}, {@code refresh}) to their + * defaults so callers don't have to null-check. Required blocks ({@code relyingParty}, {@code + * jwt}) are left as the framework bound them — if absent, downstream wiring fails fast with a + * clear message. */ public PkAuthProperties { if (ceremony == null) { @@ -40,6 +46,9 @@ public record PkAuthProperties( if (otp == null) { otp = Otp.defaults(); } + if (refresh == null) { + refresh = Refresh.defaults(); + } } /** @@ -103,4 +112,30 @@ public static Otp defaults() { return new Otp(null); } } + + /** + * Refresh-token service tunables. Only active when a {@code RefreshTokenRepository} bean is + * present in the application context (the JDBI and DynamoDB persistence modules each provide + * one). + * + * @param defaultTtl how long an issued refresh token lasts when its audience isn't listed in + * {@code ttlsByAudience}. Null defaults to {@code 14d}. + * @param ttlsByAudience per-audience refresh TTL overrides (e.g. {@code web=PT336H, + * cli=PT2160H}). Empty/null means every audience uses {@code defaultTtl}. + * @param cleanupRetention how long after a row is used or revoked to keep it for forensic + * visibility. Null defaults to {@code 30d}. + * @param path HTTP path the {@code PkAuthRefreshController} mounts at; null defaults to {@code + * /auth/refresh}. + * @since 1.1.0 + */ + public record Refresh( + @Nullable Duration defaultTtl, + @Nullable Map ttlsByAudience, + @Nullable Duration cleanupRetention, + @Nullable String path) { + + public static Refresh defaults() { + return new Refresh(null, null, null, null); + } + } } diff --git a/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/web/PkAuthRefreshController.java b/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/web/PkAuthRefreshController.java new file mode 100644 index 0000000..d6abc4f --- /dev/null +++ b/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/web/PkAuthRefreshController.java @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.spring.web; + +import com.codeheadsystems.pkauth.refresh.web.RefreshHandler; +import com.codeheadsystems.pkauth.refresh.web.RefreshHandler.Outcome; +import com.codeheadsystems.pkauth.refresh.web.RefreshRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +/** + * Mounts the refresh endpoint at {@code POST /auth/refresh}. Delegates to {@link RefreshHandler}, + * which is shared with the Dropwizard and Micronaut adapters so rotation logic and error mapping + * live in one place. Returns {@code 200} with the new refresh + access tokens on success and {@code + * 401} with a typed {@code detail} body on any failure. + * + * @since 1.1.0 + */ +@RestController +public class PkAuthRefreshController { + + private final RefreshHandler handler; + + public PkAuthRefreshController(RefreshHandler handler) { + this.handler = handler; + } + + @PostMapping("/auth/refresh") + public ResponseEntity refresh(@RequestBody(required = false) RefreshRequest request) { + Outcome outcome = handler.handle(request); + return switch (outcome) { + case Outcome.Success s -> ResponseEntity.ok(s.response()); + case Outcome.Failure f -> ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(f.response()); + }; + } +} diff --git a/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthPropertiesTest.java b/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthPropertiesTest.java index bfcdd8e..89e0bae 100644 --- a/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthPropertiesTest.java +++ b/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthPropertiesTest.java @@ -20,10 +20,12 @@ void optionalBlocksFallToDefaultsWhenAbsent() { new PkAuthProperties.Jwt("iss", "aud", "secret-that-is-long-enough-32b!!", null, null), null, null, + null, false); - // ceremony/otp normalise to defaults + // ceremony/otp/refresh normalise to defaults assertThat(props.ceremony().challengeTtl()).isEqualTo(Duration.ofMinutes(5)); assertThat(props.otp().pepper()).isNull(); + assertThat(props.refresh().path()).isNull(); // devMode defaults to false when bound from absent property assertThat(props.devMode()).isFalse(); } @@ -43,6 +45,7 @@ void preservesExplicitValues() { perAudience), new PkAuthProperties.Ceremony(Duration.ofMinutes(2)), new PkAuthProperties.Otp("dGVzdC1wZXBwZXItYmFzZTY0LWVuY29kZWQ="), + new PkAuthProperties.Refresh(Duration.ofDays(14), null, null, "/auth/refresh"), true); assertThat(props.relyingParty().id()).isEqualTo("example.com"); assertThat(props.jwt().defaultTtl()).isEqualTo(Duration.ofMinutes(30)); @@ -56,7 +59,7 @@ void preservesExplicitValues() { void requiredBlocksAreNoLongerDefaulted() { // PkAuthProperties no longer auto-populates relyingParty / jwt — adapters fail fast when // either block is missing rather than booting against silent localhost / random-key defaults. - PkAuthProperties props = new PkAuthProperties(null, null, null, null, false); + PkAuthProperties props = new PkAuthProperties(null, null, null, null, null, false); assertThat(props.relyingParty()).isNull(); assertThat(props.jwt()).isNull(); } diff --git a/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthRefreshIntegrationTest.java b/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthRefreshIntegrationTest.java new file mode 100644 index 0000000..0d67130 --- /dev/null +++ b/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthRefreshIntegrationTest.java @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.spring; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.codeheadsystems.pkauth.api.UserHandle; +import com.codeheadsystems.pkauth.jwt.AuthMethod; +import com.codeheadsystems.pkauth.jwt.JwtClaims; +import com.codeheadsystems.pkauth.jwt.JwtVerificationResult; +import com.codeheadsystems.pkauth.jwt.PkAuthJwtValidator; +import com.codeheadsystems.pkauth.refresh.RefreshTokenPair; +import com.codeheadsystems.pkauth.refresh.RefreshTokenService; +import com.codeheadsystems.pkauth.refresh.web.RefreshRequest; +import com.codeheadsystems.pkauth.refresh.web.RefreshResponse; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; +import tools.jackson.databind.ObjectMapper; + +/** + * End-to-end refresh test: issue a refresh token via the {@code RefreshTokenService}, present it to + * {@code POST /auth/refresh}, verify the response carries a new refresh token + a valid access JWT, + * then replay the original and confirm the family is scorched. + */ +@SpringBootTest(classes = PkAuthTestApplication.class) +class PkAuthRefreshIntegrationTest { + + @Autowired private WebApplicationContext context; + @Autowired private ObjectMapper objectMapper; + @Autowired private RefreshTokenService refreshService; + @Autowired private PkAuthJwtValidator jwtValidator; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.webAppContextSetup(context).build(); + } + + @Test + void rotateMintsValidAccessJwtAndNewRefreshToken() throws Exception { + RefreshTokenPair root = + refreshService.issue( + UserHandle.of(new byte[] {1}), "pk-auth-test-clients", Optional.empty()); + + MvcResult result = + mockMvc + .perform( + post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new RefreshRequest(root.wireToken())))) + .andExpect(status().isOk()) + .andReturn(); + + RefreshResponse response = + objectMapper.readValue(result.getResponse().getContentAsString(), RefreshResponse.class); + + assertThat(response.refreshToken()).isNotBlank().contains("."); + assertThat(response.refreshToken()).isNotEqualTo(root.wireToken()); + assertThat(response.accessToken()).isNotBlank(); + + JwtVerificationResult verified = jwtValidator.validate(response.accessToken()); + assertThat(verified).isInstanceOf(JwtVerificationResult.Success.class); + JwtClaims claims = ((JwtVerificationResult.Success) verified).claims(); + assertThat(claims.method()).isEqualTo(AuthMethod.REFRESH); + assertThat(claims.userHandle()).isEqualTo(UserHandle.of(new byte[] {1})); + } + + @Test + void replayReturns401Replayed() throws Exception { + RefreshTokenPair root = + refreshService.issue( + UserHandle.of(new byte[] {2}), "pk-auth-test-clients", Optional.empty()); + + // First rotation succeeds. + mockMvc + .perform( + post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new RefreshRequest(root.wireToken())))) + .andExpect(status().isOk()); + + // Re-presenting the (now-used) root token is a replay. + mockMvc + .perform( + post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new RefreshRequest(root.wireToken())))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.detail").value("replayed")); + } + + @Test + void unknownWireTokenReturns401Unknown() throws Exception { + mockMvc + .perform( + post("/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new RefreshRequest("nope.nope")))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.detail").value("unknown")); + } +} diff --git a/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthTestApplication.java b/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthTestApplication.java index de35ce1..50b58de 100644 --- a/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthTestApplication.java +++ b/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthTestApplication.java @@ -2,6 +2,8 @@ package com.codeheadsystems.pkauth.spring; import com.codeheadsystems.pkauth.config.RelyingPartyConfig; +import com.codeheadsystems.pkauth.refresh.spi.RefreshTokenRepository; +import com.codeheadsystems.pkauth.testkit.InMemoryRefreshTokenRepository; import com.codeheadsystems.pkauth.testkit.PkAuthFixtures; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; @@ -26,4 +28,16 @@ public class PkAuthTestApplication { public RelyingPartyConfig testRelyingPartyConfig() { return PkAuthFixtures.defaultRelyingParty(); } + + /** + * Wire an in-memory refresh-token repository so the {@code /auth/refresh} endpoint and the + * matching deletion-fan-out listener activate in tests. Production hosts bind a real backend + * (JDBI or DynamoDB). + * + * @since 1.1.0 + */ + @Bean + public RefreshTokenRepository testRefreshTokenRepository() { + return new InMemoryRefreshTokenRepository(); + } } diff --git a/pk-auth-testkit/build.gradle.kts b/pk-auth-testkit/build.gradle.kts index c5b9385..51614ed 100644 --- a/pk-auth-testkit/build.gradle.kts +++ b/pk-auth-testkit/build.gradle.kts @@ -20,6 +20,9 @@ dependencies { // AccessTokenStore lives in pk-auth-jwt; in-memory impl + parity scenarios need it on the API // surface so downstream test code can drive both pk-auth-core and pk-auth-jwt SPIs. api(project(":pk-auth-jwt")) + // RefreshTokenRepository SPI + RefreshTokenScenarios live with the testkit so the JDBI and + // DynamoDB integration tests can drive the same parity scenarios. + api(project(":pk-auth-refresh-tokens")) api(libs.bundles.jackson) api(libs.webauthn4j.core) // AssertJ on the api surface because CeremonyScenarios uses `assertThat`. Downstream diff --git a/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/InMemoryRefreshTokenRepository.java b/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/InMemoryRefreshTokenRepository.java new file mode 100644 index 0000000..53e2247 --- /dev/null +++ b/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/InMemoryRefreshTokenRepository.java @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.testkit; + +import com.codeheadsystems.pkauth.api.UserHandle; +import com.codeheadsystems.pkauth.refresh.RefreshTokenRecord; +import com.codeheadsystems.pkauth.refresh.RevokeReason; +import com.codeheadsystems.pkauth.refresh.spi.RefreshTokenRepository; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * In-memory {@link RefreshTokenRepository}. Backed by a {@link ConcurrentHashMap}; the load-bearing + * {@link #rotateAtomically} primitive uses {@link java.util.concurrent.ConcurrentMap#compute} so + * the mark-then-insert pair is atomic against concurrent rotators. + * + * @since 1.1.0 + */ +public final class InMemoryRefreshTokenRepository implements RefreshTokenRepository { + + private final Map byRefreshId = new ConcurrentHashMap<>(); + + public InMemoryRefreshTokenRepository() {} + + @Override + public void create(RefreshTokenRecord record) { + if (byRefreshId.putIfAbsent(record.refreshId(), record) != null) { + throw new IllegalStateException("duplicate refreshId: " + record.refreshId()); + } + } + + @Override + public Optional findByRefreshId(String refreshId) { + return Optional.ofNullable(byRefreshId.get(refreshId)); + } + + @Override + public boolean rotateAtomically( + String parentRefreshId, Instant now, RefreshTokenRecord successor) { + // compute() on the parent key serializes concurrent rotators on the same parent. Inside the + // block we (a) check the freshness predicate on the parent, (b) if fresh, flip used_at and + // ALSO insert the successor under its own key — both atomic w.r.t. any other compute on + // either key (a ConcurrentHashMap-wide guarantee in practice for distinct keys; we additionally + // use putIfAbsent so we don't accept duplicate successor IDs). + boolean[] rotated = {false}; + byRefreshId.compute( + parentRefreshId, + (k, existing) -> { + if (existing == null) { + return null; // missing parent → no rotation + } + if (existing.usedAt().isPresent() + || existing.revokedAt().isPresent() + || !existing.expiresAt().isAfter(now)) { + return existing; // not fresh → no rotation + } + // Insert successor first. If it collides, abort — never half-commit a rotation. + if (byRefreshId.putIfAbsent(successor.refreshId(), successor) != null) { + return existing; + } + rotated[0] = true; + return markUsed(existing, now); + }); + return rotated[0]; + } + + @Override + public int revokeFamily(String familyId, Instant now, RevokeReason reason) { + int[] affected = {0}; + byRefreshId.replaceAll( + (k, v) -> { + if (v.familyId().equals(familyId) && v.revokedAt().isEmpty()) { + affected[0]++; + return markRevoked(v, now, reason); + } + return v; + }); + return affected[0]; + } + + @Override + public int revokeAllForUser(UserHandle userHandle, Instant now, RevokeReason reason) { + int[] affected = {0}; + byRefreshId.replaceAll( + (k, v) -> { + if (v.userHandle().equals(userHandle) && v.revokedAt().isEmpty()) { + affected[0]++; + return markRevoked(v, now, reason); + } + return v; + }); + return affected[0]; + } + + @Override + public List findByUserHandle(UserHandle userHandle) { + List out = new ArrayList<>(); + for (RefreshTokenRecord r : byRefreshId.values()) { + if (r.userHandle().equals(userHandle)) { + out.add(r); + } + } + out.sort(Comparator.comparing(RefreshTokenRecord::issuedAt)); + return List.copyOf(out); + } + + @Override + public List findByFamilyId(String familyId) { + List out = new ArrayList<>(); + for (RefreshTokenRecord r : byRefreshId.values()) { + if (r.familyId().equals(familyId)) { + out.add(r); + } + } + out.sort(Comparator.comparing(RefreshTokenRecord::issuedAt)); + return List.copyOf(out); + } + + @Override + public int deleteExpiredBefore(Instant cutoff) { + int[] removed = {0}; + byRefreshId + .entrySet() + .removeIf( + e -> { + RefreshTokenRecord r = e.getValue(); + if (r.expiresAt().isBefore(cutoff) + && (r.usedAt().filter(t -> t.isBefore(cutoff)).isPresent() + || r.revokedAt().filter(t -> t.isBefore(cutoff)).isPresent())) { + removed[0]++; + return true; + } + return false; + }); + return removed[0]; + } + + private static RefreshTokenRecord markUsed(RefreshTokenRecord r, Instant now) { + return new RefreshTokenRecord( + r.refreshId(), + r.tokenHash(), + r.userHandle(), + r.audience(), + r.deviceId(), + r.familyId(), + r.parentRefreshId(), + r.issuedAt(), + r.expiresAt(), + Optional.of(now), + r.revokedAt(), + r.revokedReason()); + } + + private static RefreshTokenRecord markRevoked( + RefreshTokenRecord r, Instant now, RevokeReason reason) { + return new RefreshTokenRecord( + r.refreshId(), + r.tokenHash(), + r.userHandle(), + r.audience(), + r.deviceId(), + r.familyId(), + r.parentRefreshId(), + r.issuedAt(), + r.expiresAt(), + r.usedAt(), + Optional.of(now), + Optional.of(reason)); + } +} diff --git a/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/RefreshTokenScenarios.java b/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/RefreshTokenScenarios.java new file mode 100644 index 0000000..ec2f418 --- /dev/null +++ b/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/RefreshTokenScenarios.java @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.testkit; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.codeheadsystems.pkauth.api.UserHandle; +import com.codeheadsystems.pkauth.refresh.RefreshTokenConfig; +import com.codeheadsystems.pkauth.refresh.RefreshTokenPair; +import com.codeheadsystems.pkauth.refresh.RefreshTokenService; +import com.codeheadsystems.pkauth.refresh.RefreshTtlPolicy; +import com.codeheadsystems.pkauth.refresh.RevokeReason; +import com.codeheadsystems.pkauth.refresh.RotateResult; +import com.codeheadsystems.pkauth.refresh.spi.RefreshTokenRepository; +import com.codeheadsystems.pkauth.spi.ClockProvider; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +/** + * Shared parity scenarios for {@link RefreshTokenRepository} implementations. Drive every method + * from in-memory, JDBI, and DynamoDB test classes so all three backends honour the same contract — + * especially the load-bearing {@link #concurrentRotationExactlyOneSucceedsFamilyRevoked()} race + * test, which is the non-negotiable acceptance criterion from the plan. + * + *

Construct with a fresh empty repository and a controllable clock. Each scenario is + * self-contained and can run in isolation. + * + * @since 1.1.0 + */ +public final class RefreshTokenScenarios { + + private static final Instant NOW = Instant.parse("2026-05-16T12:00:00Z"); + private static final UserHandle USER = UserHandle.of(new byte[] {1, 2, 3}); + private static final String AUDIENCE = "web"; + + private final RefreshTokenRepository repository; + private final RefreshTokenService service; + + public RefreshTokenScenarios(RefreshTokenRepository repository) { + this(repository, fixedClock(NOW)); + } + + public RefreshTokenScenarios(RefreshTokenRepository repository, ClockProvider clockProvider) { + this.repository = Objects.requireNonNull(repository, "repository"); + this.service = + new RefreshTokenService( + repository, + new RefreshTokenConfig( + RefreshTtlPolicy.fixed( + Duration.ofDays(14), + Map.of("web", Duration.ofDays(14), "cli", Duration.ofDays(90))), + 32, + 16, + Duration.ofDays(30)), + Objects.requireNonNull(clockProvider, "clockProvider")); + } + + /** Issue → rotate → revoke happy path. */ + public void issueRotateRevokeHappyPath() { + RefreshTokenPair root = service.issue(USER, AUDIENCE, Optional.empty()); + assertThat(root.wireToken()).contains("."); + assertThat(root.record().userHandle()).isEqualTo(USER); + assertThat(root.record().familyId()).isEqualTo(root.record().refreshId()); + + RotateResult result = service.rotate(root.wireToken()); + assertThat(result).isInstanceOf(RotateResult.Success.class); + RotateResult.Success success = (RotateResult.Success) result; + assertThat(success.pair().record().familyId()).isEqualTo(root.record().familyId()); + assertThat(success.pair().record().parentRefreshId()).hasValue(root.record().refreshId()); + assertThat(success.claimsForAccessIssue().userHandle()).isEqualTo(USER); + assertThat(success.claimsForAccessIssue().audience()).isEqualTo(AUDIENCE); + + // Revoke the family — subsequent rotates of the successor return Revoked. + service.revokeFamily(root.record().familyId(), RevokeReason.LOGOUT); + assertThat(service.rotate(success.pair().wireToken())) + .isInstanceOfSatisfying( + RotateResult.Revoked.class, r -> assertThat(r.reason()).isEqualTo(RevokeReason.LOGOUT)); + } + + /** Successor's parent-link points back at the parent's refreshId. */ + public void rotationUpdatesFamilyChainAndChildLinksToParent() { + RefreshTokenPair root = service.issue(USER, AUDIENCE, Optional.empty()); + RotateResult.Success rotated = (RotateResult.Success) service.rotate(root.wireToken()); + + List familyIds = new ArrayList<>(); + for (var r : repository.findByFamilyId(root.record().familyId())) { + familyIds.add(r.refreshId()); + } + assertThat(familyIds).contains(root.record().refreshId(), rotated.pair().record().refreshId()); + assertThat(rotated.pair().record().parentRefreshId()).hasValue(root.record().refreshId()); + } + + /** Presenting a used token triggers a family scorch and returns Replayed. */ + public void replayOfUsedTokenScorchesEntireFamily() { + RefreshTokenPair root = service.issue(USER, AUDIENCE, Optional.empty()); + RotateResult.Success first = (RotateResult.Success) service.rotate(root.wireToken()); + // Re-present the root (already used) — replay defense. + RotateResult replay = service.rotate(root.wireToken()); + assertThat(replay).isInstanceOf(RotateResult.Replayed.class); + // Both rows in the family are now revoked. + for (var r : repository.findByFamilyId(root.record().familyId())) { + assertThat(r.revokedAt()).as("row %s revoked", r.refreshId()).isPresent(); + assertThat(r.revokedReason()).hasValue(RevokeReason.ROTATION_REPLAY); + } + // And the legitimate successor — which the attacker has no way to reach — is also now + // revoked. The honest client sees Replayed on its next refresh and logs in. (The service + // intentionally maps a ROTATION_REPLAY-scorched family to Replayed rather than Revoked so + // race losers and replay-after-the-fact callers see a consistent outcome.) + assertThat(service.rotate(first.pair().wireToken())).isInstanceOf(RotateResult.Replayed.class); + } + + /** Past-due tokens return Expired without any family revocation. */ + public void expiredTokenRotationReturnsExpired() { + // Issue at NOW, then build a service whose clock is 60 days later (default TTL is 14d). + RefreshTokenPair root = service.issue(USER, AUDIENCE, Optional.empty()); + RefreshTokenService laterService = + new RefreshTokenService( + repository, + new RefreshTokenConfig( + RefreshTtlPolicy.single(Duration.ofDays(14)), 32, 16, Duration.ofDays(30)), + fixedClock(NOW.plus(Duration.ofDays(60)))); + assertThat(laterService.rotate(root.wireToken())).isInstanceOf(RotateResult.Expired.class); + // No family revocation — expired is not a replay signal. + for (var r : repository.findByFamilyId(root.record().familyId())) { + assertThat(r.revokedAt()).as("row %s untouched", r.refreshId()).isEmpty(); + } + } + + /** A wire token whose refreshId doesn't match any row returns Unknown. */ + public void unknownRefreshIdReturnsUnknown() { + assertThat(service.rotate("nonExistentRefreshId.aaaa")) + .isInstanceOf(RotateResult.Unknown.class); + } + + /** + * Presenting the right refreshId with the wrong secret returns Unknown — and crucially does NOT + * mark the legitimate token used. This is the hash-before-mark-used invariant from ADR 0013. + */ + public void wrongSecretReturnsUnknownAndDoesNotBurnLegitToken() { + RefreshTokenPair root = service.issue(USER, AUDIENCE, Optional.empty()); + String forged = root.record().refreshId() + ".wrongSecretBase64Url"; + assertThat(service.rotate(forged)).isInstanceOf(RotateResult.Unknown.class); + // Legitimate rotation still works after the failed presentation. + assertThat(service.rotate(root.wireToken())).isInstanceOf(RotateResult.Success.class); + } + + /** Calling revokeFamily twice is a no-op the second time. */ + public void revokeFamilyIsIdempotent() { + RefreshTokenPair root = service.issue(USER, AUDIENCE, Optional.empty()); + service.revokeFamily(root.record().familyId(), RevokeReason.LOGOUT); + service.revokeFamily(root.record().familyId(), RevokeReason.LOGOUT); + var family = repository.findByFamilyId(root.record().familyId()); + assertThat(family).hasSize(1); + assertThat(family.get(0).revokedReason()).hasValue(RevokeReason.LOGOUT); + } + + /** revokeAllForUser scorches every family for the user but leaves other users intact. */ + public void revokeAllForUserRevokesEveryActiveFamily() { + UserHandle alice = UserHandle.of(new byte[] {1}); + UserHandle bob = UserHandle.of(new byte[] {2}); + service.issue(alice, AUDIENCE, Optional.empty()); + service.issue(alice, "cli", Optional.empty()); + service.issue(bob, AUDIENCE, Optional.empty()); + + int revoked = service.revokeAllForUser(alice, RevokeReason.USER_DELETED); + assertThat(revoked).isEqualTo(2); + assertThat(repository.findByUserHandle(alice)) + .allSatisfy(r -> assertThat(r.revokedAt()).isPresent()); + assertThat(repository.findByUserHandle(bob)) + .allSatisfy(r -> assertThat(r.revokedAt()).isEmpty()); + } + + /** + * The non-negotiable concurrent rotation race test. Eight threads all rotate the same root token + * simultaneously: exactly one must win and the rest see {@link RotateResult.Replayed}. The entire + * family — both the root AND any successor inserted by the winner — must end up revoked. Modeled + * on motif's {@code concurrent_sameSecret_exactlyOneSucceeds_familyRevoked}. + */ + public void concurrentRotationExactlyOneSucceedsFamilyRevoked() throws Exception { + RefreshTokenPair root = service.issue(USER, AUDIENCE, Optional.empty()); + + int threads = 8; + CountDownLatch ready = new CountDownLatch(threads); + CountDownLatch fire = new CountDownLatch(1); + ExecutorService pool = Executors.newFixedThreadPool(threads); + List> futures = new ArrayList<>(); + try { + for (int i = 0; i < threads; i++) { + futures.add( + pool.submit( + () -> { + ready.countDown(); + fire.await(); + return service.rotate(root.wireToken()); + })); + } + assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue(); + fire.countDown(); + + int success = 0; + int replayed = 0; + int other = 0; + for (Future f : futures) { + RotateResult r = f.get(10, TimeUnit.SECONDS); + if (r instanceof RotateResult.Success) { + success++; + } else if (r instanceof RotateResult.Replayed) { + replayed++; + } else { + other++; + } + } + + assertThat(success).as("exactly one thread wins").isEqualTo(1); + assertThat(replayed).as("rest see Replayed").isEqualTo(threads - 1); + assertThat(other).as("no other outcomes").isZero(); + + // Entire family is revoked — root AND the successor minted by the winner. + var family = repository.findByFamilyId(root.record().familyId()); + assertThat(family).as("family contains root + successor").hasSizeGreaterThanOrEqualTo(2); + assertThat(family) + .allSatisfy( + r -> assertThat(r.revokedAt()).as("row %s revoked", r.refreshId()).isPresent()); + } finally { + pool.shutdownNow(); + } + } + + // -- Helpers -------------------------------------------------------------------------- + + private static ClockProvider fixedClock(Instant instant) { + return ClockProvider.fromClock(Clock.fixed(instant, ZoneOffset.UTC)); + } +} diff --git a/pk-auth-testkit/src/main/java/module-info.java b/pk-auth-testkit/src/main/java/module-info.java index d759975..ea45e62 100644 --- a/pk-auth-testkit/src/main/java/module-info.java +++ b/pk-auth-testkit/src/main/java/module-info.java @@ -4,6 +4,7 @@ module com.codeheadsystems.pkauth.testkit { requires transitive com.codeheadsystems.pkauth.core; requires transitive com.codeheadsystems.pkauth.jwt; + requires transitive com.codeheadsystems.pkauth.refresh; requires transitive com.webauthn4j.core; requires transitive tools.jackson.databind; requires transitive com.fasterxml.jackson.annotation; diff --git a/pk-auth-testkit/src/test/java/com/codeheadsystems/pkauth/testkit/InMemoryRefreshTokenRepositoryTest.java b/pk-auth-testkit/src/test/java/com/codeheadsystems/pkauth/testkit/InMemoryRefreshTokenRepositoryTest.java new file mode 100644 index 0000000..cffce81 --- /dev/null +++ b/pk-auth-testkit/src/test/java/com/codeheadsystems/pkauth/testkit/InMemoryRefreshTokenRepositoryTest.java @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.testkit; + +import org.junit.jupiter.api.Test; + +class InMemoryRefreshTokenRepositoryTest { + + @Test + void issueRotateRevokeHappyPath() { + new RefreshTokenScenarios(new InMemoryRefreshTokenRepository()).issueRotateRevokeHappyPath(); + } + + @Test + void rotationUpdatesFamilyChainAndChildLinksToParent() { + new RefreshTokenScenarios(new InMemoryRefreshTokenRepository()) + .rotationUpdatesFamilyChainAndChildLinksToParent(); + } + + @Test + void replayOfUsedTokenScorchesEntireFamily() { + new RefreshTokenScenarios(new InMemoryRefreshTokenRepository()) + .replayOfUsedTokenScorchesEntireFamily(); + } + + @Test + void expiredTokenRotationReturnsExpired() { + new RefreshTokenScenarios(new InMemoryRefreshTokenRepository()) + .expiredTokenRotationReturnsExpired(); + } + + @Test + void unknownRefreshIdReturnsUnknown() { + new RefreshTokenScenarios(new InMemoryRefreshTokenRepository()) + .unknownRefreshIdReturnsUnknown(); + } + + @Test + void wrongSecretReturnsUnknownAndDoesNotBurnLegitToken() { + new RefreshTokenScenarios(new InMemoryRefreshTokenRepository()) + .wrongSecretReturnsUnknownAndDoesNotBurnLegitToken(); + } + + @Test + void revokeFamilyIsIdempotent() { + new RefreshTokenScenarios(new InMemoryRefreshTokenRepository()).revokeFamilyIsIdempotent(); + } + + @Test + void revokeAllForUserRevokesEveryActiveFamily() { + new RefreshTokenScenarios(new InMemoryRefreshTokenRepository()) + .revokeAllForUserRevokesEveryActiveFamily(); + } + + @Test + void concurrentRotationExactlyOneSucceedsFamilyRevoked() throws Exception { + new RefreshTokenScenarios(new InMemoryRefreshTokenRepository()) + .concurrentRotationExactlyOneSucceedsFamilyRevoked(); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 3b5b9f8..e153712 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -55,6 +55,7 @@ include("pk-auth-jwt") include("pk-auth-backup-codes") include("pk-auth-magic-link") include("pk-auth-otp") +include("pk-auth-refresh-tokens") include("pk-auth-admin-api") include("pk-auth-persistence-jdbi") include("pk-auth-persistence-dynamodb")