Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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,
Expand Down
22 changes: 21 additions & 1 deletion clients/passkeys-browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RefreshResult> {
return this.refreshClient.refresh(refreshToken);
}
}
98 changes: 98 additions & 0 deletions clients/passkeys-browser/src/refresh.ts
Original file line number Diff line number Diff line change
@@ -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<RefreshResult> {
try {
const response = await request<RawRefreshResponse>(
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;
}
}
}
84 changes: 84 additions & 0 deletions clients/passkeys-browser/test/refresh.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
Loading