Skip to content

Commit d80dc0d

Browse files
authored
Merge pull request #14 from codeheadsystems/feature/refresh-tokens
feat(refresh-tokens): rotating refresh tokens with family-based replay defense
2 parents c6b86fa + 9350462 commit d80dc0d

68 files changed

Lines changed: 3938 additions & 32 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,39 @@ tags.
3030
calls `exists` on every validate. The default `AccessTokenStore.noop()` keeps
3131
stateless behaviour; hosts wire a real store (JDBI, DynamoDB) to opt in. See
3232
ADR 0015.
33+
- `pk-auth-refresh-tokens` (new module): `RefreshTokenService` with `issue`,
34+
`rotate`, `revokeFamily`, `revokeAllForUser`, `listForUser`. Sealed
35+
`RotateResult` (Success / Replayed / Expired / Unknown / Revoked). Wire
36+
format `{refreshId}.{secret}` (both halves base64url); SHA-256
37+
hash-at-rest; hash-before-mark-used invariant. Family-based replay
38+
defense — re-presenting a used token scorches the entire family. See
39+
ADR 0013.
40+
- `RefreshTokenRepository` SPI with the load-bearing `rotateAtomically`
41+
primitive: parent mark-used and successor insert commit atomically on
42+
every backend (JDBI transaction, DynamoDB `TransactWriteItems`,
43+
in-memory `ConcurrentHashMap.compute`).
44+
- `pk-auth-persistence-jdbi`: `JdbiRefreshTokenRepository` + Flyway V9
45+
migration. PkAuthJdbiSchema.CURRENT_SCHEMA_VERSION → "9".
46+
- `pk-auth-persistence-dynamodb`: `DynamoDbRefreshTokenRepository` with
47+
three-item layout (primary jti / user-index / family-index) and
48+
DynamoDB-native TTL.
49+
- `pk-auth-testkit`: `InMemoryRefreshTokenRepository` +
50+
`RefreshTokenScenarios` parity test class — nine scenarios including
51+
the non-negotiable `concurrentRotationExactlyOneSucceedsFamilyRevoked`
52+
race test, driven by 8 threads + `CountDownLatch`, passing against
53+
in-memory, real Postgres, and DynamoDB Local.
54+
- `RefreshHandler` (framework-neutral HTTP composer) + `PkAuthRefreshController`
55+
/ `PkAuthRefreshResource` in Spring, Dropwizard, Micronaut adapters.
56+
`POST /auth/refresh` returns the new refresh + access JWT on success,
57+
401 with a typed `detail` on any failure.
58+
- `AuthMethod.REFRESH` for access tokens minted from a refresh rotation.
59+
- `JwtClaims.forRefresh(userHandle, audience, amr)` factory.
60+
- Browser SDK: `PkAuthClient.refresh(wireToken)` returning a typed
61+
`RefreshResult` sum (`RefreshSuccess | RefreshFailure`) — never throws
62+
on 401.
63+
- `RefreshTokenServiceDeletionListener` plugged into `UserDeletionService`
64+
so user-delete revokes every active refresh family alongside access
65+
tokens / credentials / backup codes / OTPs.
3366
- `pk-auth-persistence-jdbi`: `JdbiAccessTokenStore` backed by the new
3467
`access_tokens` table (Flyway V8).
3568
- `pk-auth-persistence-dynamodb`: `DynamoDbAccessTokenStore` using two items

README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ All modules share the same version and `com.codeheadsystems` group id.
1919
| `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. |
2020
| `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. |
2121
| `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. |
22+
| `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)* |
2223
| `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. |
2324
| `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). |
2425
| `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:
3132
- WebAuthn registration and assertion ceremonies — including multi-passkey
3233
enrolment and conditional UI — backed by WebAuthn4J.
3334
- A stateless JWT mint at the end of authentication (HS256, configurable
34-
TTL).
35+
TTL) — or, with the 1.1.0 `AccessTokenStore` SPI, a stateful access
36+
token that's revocable before its `exp`.
37+
- Per-audience JWT TTL dispatch via `TokenTtlPolicy` so web / cli /
38+
mobile clients can carry different access-token lifetimes from a single
39+
issuer.
40+
- **Rotating refresh tokens with family-based replay defense** *(1.1.0)*
41+
`POST /auth/refresh` is one ceremony / one row per rotation, with
42+
motif-style atomic mark-and-insert and family scorch on detected
43+
replay. The browser SDK's `PkAuthClient.refresh()` returns a typed
44+
result sum, never throws on 401. See
45+
[ADR 0013](./docs/adr/0013-refresh-tokens-family-rotation.md).
3546
- Account admin: list / rename / delete passkeys, regenerate
3647
view-once backup codes, account summary.
3748
- Alternative-flow modules: backup codes, magic-link email verification,

clients/passkeys-browser/src/index.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,40 @@ export type {
1818
StartRegistrationParams,
1919
} from "./ceremonies";
2020
export { PkAuthAdminClient } from "./admin";
21+
export { PkAuthRefreshClient, isRefreshSuccess, isRefreshFailure } from "./refresh";
22+
export type {
23+
RefreshResult,
24+
RefreshSuccess,
25+
RefreshFailure,
26+
RefreshFailureReason,
27+
} from "./refresh";
2128
export type * from "./types";
2229

2330
import { PkAuthAdminClient } from "./admin";
2431
import { PkAuthCeremonyClient } from "./ceremonies";
2532
import type { CeremonyOptions } from "./ceremonies";
33+
import { PkAuthRefreshClient, type RefreshResult } from "./refresh";
2634
import type { ClientOptions } from "./types";
2735

28-
/** Convenience facade: a single object exposing both ceremony and admin clients. */
36+
/** Convenience facade: a single object exposing ceremony, admin, and refresh clients. */
2937
export class PkAuthClient {
3038
readonly ceremonies: PkAuthCeremonyClient;
3139
readonly admin: PkAuthAdminClient;
40+
readonly refreshClient: PkAuthRefreshClient;
3241

3342
constructor(options: ClientOptions, ceremonyOptions: CeremonyOptions = {}) {
3443
this.ceremonies = new PkAuthCeremonyClient(options, ceremonyOptions);
3544
this.admin = new PkAuthAdminClient(options);
45+
this.refreshClient = new PkAuthRefreshClient(options);
46+
}
47+
48+
/**
49+
* Convenience shortcut to {@link PkAuthRefreshClient#refresh}. Returns a typed
50+
* {@link RefreshResult} sum — does not throw on 401.
51+
*
52+
* @since 1.1.0
53+
*/
54+
refresh(refreshToken: string): Promise<RefreshResult> {
55+
return this.refreshClient.refresh(refreshToken);
3656
}
3757
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// SPDX-License-Identifier: MIT
2+
3+
import { PkAuthHttpError, request } from "./http";
4+
import type { ClientOptions } from "./types";
5+
6+
/** Typed success: a fresh refresh + access token pair to swap in. */
7+
export interface RefreshSuccess {
8+
readonly kind: "success";
9+
readonly accessToken: string;
10+
readonly refreshToken: string;
11+
readonly expiresAt: string;
12+
}
13+
14+
/** Typed failure mirroring the {@code detail} field on the server's 401 body. */
15+
export type RefreshFailureReason = "expired" | "unknown" | "replayed" | "revoked";
16+
17+
export interface RefreshFailure {
18+
readonly kind: "failure";
19+
readonly reason: RefreshFailureReason;
20+
/** Categorical revoke reason when `reason === "revoked"`. */
21+
readonly revokeReason?: string;
22+
}
23+
24+
/** Sealed sum of refresh outcomes. */
25+
export type RefreshResult = RefreshSuccess | RefreshFailure;
26+
27+
/** Type guard for {@link RefreshSuccess}. */
28+
export function isRefreshSuccess(r: RefreshResult): r is RefreshSuccess {
29+
return r.kind === "success";
30+
}
31+
32+
/** Type guard for {@link RefreshFailure}. */
33+
export function isRefreshFailure(r: RefreshResult): r is RefreshFailure {
34+
return r.kind === "failure";
35+
}
36+
37+
interface RawRefreshResponse {
38+
refreshToken: string;
39+
accessToken: string;
40+
expiresAt: string;
41+
}
42+
43+
interface RawErrorResponse {
44+
detail?: string;
45+
reason?: string;
46+
}
47+
48+
/**
49+
* Client for the {@code POST /auth/refresh} endpoint shipped by every pk-auth adapter. Returns
50+
* a typed {@link RefreshResult} rather than throwing on 401 — the bearer's expected response to
51+
* an Unknown / Expired / Replayed / Revoked outcome is to redirect to login, not to handle an
52+
* exception.
53+
*
54+
* @since 1.1.0
55+
*/
56+
export class PkAuthRefreshClient {
57+
private readonly options: ClientOptions;
58+
private readonly path: string;
59+
60+
constructor(options: ClientOptions, path: string = "/auth/refresh") {
61+
this.options = options;
62+
this.path = path;
63+
}
64+
65+
async refresh(refreshToken: string): Promise<RefreshResult> {
66+
try {
67+
const response = await request<RawRefreshResponse>(
68+
this.options,
69+
"POST",
70+
this.path,
71+
{ refreshToken },
72+
false,
73+
);
74+
return {
75+
kind: "success",
76+
accessToken: response.accessToken,
77+
refreshToken: response.refreshToken,
78+
expiresAt: response.expiresAt,
79+
};
80+
} catch (e: unknown) {
81+
if (e instanceof PkAuthHttpError && e.status === 401) {
82+
const data = e.data as RawErrorResponse | undefined;
83+
const detail = data?.detail;
84+
if (
85+
detail === "expired" ||
86+
detail === "unknown" ||
87+
detail === "replayed" ||
88+
detail === "revoked"
89+
) {
90+
return { kind: "failure", reason: detail, revokeReason: data?.reason };
91+
}
92+
// Server returned a 401 we don't recognise — surface it as the most conservative failure.
93+
return { kind: "failure", reason: "unknown" };
94+
}
95+
throw e;
96+
}
97+
}
98+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// SPDX-License-Identifier: MIT
2+
import { describe, expect, it, vi } from "vitest";
3+
import { isRefreshFailure, isRefreshSuccess, PkAuthRefreshClient } from "../src/refresh";
4+
5+
function stubFetch(handler: (init: RequestInit) => { status: number; body: string }) {
6+
return vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => {
7+
const { status, body } = handler(init ?? {});
8+
return new Response(body, { status });
9+
});
10+
}
11+
12+
describe("PkAuthRefreshClient", () => {
13+
it("returns RefreshSuccess on 200", async () => {
14+
const fetchImpl = stubFetch((init) => {
15+
expect(init.method).toBe("POST");
16+
const body = JSON.parse(String(init.body));
17+
expect(body).toEqual({ refreshToken: "old.token" });
18+
return {
19+
status: 200,
20+
body: JSON.stringify({
21+
refreshToken: "new.token",
22+
accessToken: "jwt-blob",
23+
expiresAt: "2026-06-01T00:00:00Z",
24+
}),
25+
};
26+
});
27+
28+
const client = new PkAuthRefreshClient({ apiBase: "https://x", fetch: fetchImpl } as never);
29+
const result = await client.refresh("old.token");
30+
31+
expect(isRefreshSuccess(result)).toBe(true);
32+
if (isRefreshSuccess(result)) {
33+
expect(result.accessToken).toBe("jwt-blob");
34+
expect(result.refreshToken).toBe("new.token");
35+
expect(result.expiresAt).toBe("2026-06-01T00:00:00Z");
36+
}
37+
});
38+
39+
it.each(["expired", "unknown", "replayed", "revoked"] as const)(
40+
"maps 401 with detail=%s to a typed RefreshFailure",
41+
async (detail) => {
42+
const fetchImpl = stubFetch(() => ({
43+
status: 401,
44+
body: JSON.stringify({ detail }),
45+
}));
46+
const client = new PkAuthRefreshClient({ apiBase: "https://x", fetch: fetchImpl } as never);
47+
const result = await client.refresh("old.token");
48+
expect(isRefreshFailure(result)).toBe(true);
49+
if (isRefreshFailure(result)) {
50+
expect(result.reason).toBe(detail);
51+
}
52+
},
53+
);
54+
55+
it("surfaces revokeReason when present", async () => {
56+
const fetchImpl = stubFetch(() => ({
57+
status: 401,
58+
body: JSON.stringify({ detail: "revoked", reason: "USER_DELETED" }),
59+
}));
60+
const client = new PkAuthRefreshClient({ apiBase: "https://x", fetch: fetchImpl } as never);
61+
const result = await client.refresh("old.token");
62+
expect(isRefreshFailure(result)).toBe(true);
63+
if (isRefreshFailure(result)) {
64+
expect(result.reason).toBe("revoked");
65+
expect(result.revokeReason).toBe("USER_DELETED");
66+
}
67+
});
68+
69+
it("falls back to 'unknown' on 401 with no/unfamiliar detail", async () => {
70+
const fetchImpl = stubFetch(() => ({ status: 401, body: "{}" }));
71+
const client = new PkAuthRefreshClient({ apiBase: "https://x", fetch: fetchImpl } as never);
72+
const result = await client.refresh("old.token");
73+
expect(isRefreshFailure(result)).toBe(true);
74+
if (isRefreshFailure(result)) {
75+
expect(result.reason).toBe("unknown");
76+
}
77+
});
78+
79+
it("rethrows non-401 HTTP errors", async () => {
80+
const fetchImpl = stubFetch(() => ({ status: 500, body: "boom" }));
81+
const client = new PkAuthRefreshClient({ apiBase: "https://x", fetch: fetchImpl } as never);
82+
await expect(client.refresh("old.token")).rejects.toThrow(/HTTP 500/);
83+
});
84+
});

0 commit comments

Comments
 (0)