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
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ jobs:
- name: Set up Gradle
uses: gradle/actions/setup-gradle@5e2ebd065dc2488b7a6ad670704656cbbe1e8f60 # v6

# The persistence integration suites self-skip when PKAUTH_SKIP_TESTCONTAINERS=1. The CI
# gate must exercise them against real Postgres/DynamoDB Local, so fail loudly if that
# escape hatch leaks into the gating pipeline (otherwise the build can go green with zero
# persistence and concurrency coverage).
- name: Guard against skipped integration tests
run: |
if [ "${PKAUTH_SKIP_TESTCONTAINERS:-}" = "1" ]; then
echo "PKAUTH_SKIP_TESTCONTAINERS=1 must not be set in the CI gate — it would silently skip all persistence integration tests." >&2
exit 1
fi

- name: Build and test
run: ./gradlew clean build test --stacktrace

Expand Down Expand Up @@ -65,6 +76,8 @@ jobs:
working-directory: clients/passkeys-browser

- name: SonarQube scan
# JavaScript/browser-SDK static analysis only. SonarQube was dropped for the JVM modules
# (ADR 0018, native JaCoCo gates) but retained here for the TypeScript SDK.
# SONAR_TOKEN is unavailable to PRs from forks; skip there to avoid a hard failure.
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
uses: sonarsource/sonarqube-scan-action@713881670b6b3676cda39549040e2d88c70d582e # v8.2.0
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,15 @@ Feature modules (`pk-auth-backup-codes`, `pk-auth-magic-link`, `pk-auth-otp`, `p
- **Sealed result sums, not exceptions.** Ceremony and admin operations return sealed interfaces — `AdminResult<T>` (`Success | NotFound | Forbidden | ValidationFailed | Conflict | RateLimited`), `RegistrationResult`, `AssertionResult`, `RotateResult`, `JwtVerificationResult`. Adapters map these to HTTP; never throw across that boundary. When you add a variant, every adapter's `*ResultMapper` must handle it.
- **Wire bytes are base64url, no padding** (RFC 4648 §5). Jackson 3 adapters get this from `PkAuthObjectMappers.pkAuthModule()`; the Dropwizard adapter is still on Jackson 2 and uses the `PkAuthJacksonBridge`.
- **`finish` endpoints are not idempotent** — challenges are single-use via `ChallengeStore.takeOnce`. There is **no shared transaction across SPIs**: `takeOnce` is consumed before `CredentialRepository.save`; a failed save forces a ceremony restart. This is intentional — see [`docs/transactional-semantics.md`](./docs/transactional-semantics.md).
- **Dropwizard mounts paths one segment shorter** (`/auth/registration/start` vs `/auth/passkeys/registration/start`); the TS SDK handles this via a per-client path override.
- **All three adapters mount identical `/auth/**` paths** (`/auth/passkeys/**`, `/auth/refresh`, `/auth/admin/**`) — Dropwizard's Jersey resources use the same `@Path("/auth/passkeys")` etc. as Spring/Micronaut, so the TS SDK targets one path scheme everywhere (no per-client path override).
- **DI annotations differ by adapter on purpose** (`CONTRIBUTING.md` §9): Spring and Micronaut auto-detect the single constructor — do **not** add `@Autowired`/`@Inject`. Dropwizard's Dagger 2 wiring **requires** `@Inject` on the injected constructor. Match the module you're in.
- **Atomic-claim operations return `boolean`** so the caller can detect a race-lost claim — JDBI uses conditional `UPDATE ... WHERE consumed_at IS NULL`; DynamoDB uses `ConditionExpression` (failed condition → `ConditionalCheckFailedException` mapped to `Conflict`/`Expired`).
- **DynamoDB is single-table** (`DynamoDbTable<T>` per item type; refresh tokens write 3 items per token for the jti/user/family indexes). **JDBI uses Flyway** migrations at `pk-auth-persistence-jdbi/src/main/resources/db/migration/` — bump `PkAuthJdbiSchema.CURRENT_SCHEMA_VERSION` when adding one.

## Conventions (enforced — `./gradlew check` fails otherwise)

- **SPDX header** `// SPDX-License-Identifier: MIT` on every Java file (Spotless adds/checks it).
- **`@since X.Y.Z`** Javadoc on every new/modified public API element across the library modules (current in-flight target `@since 1.3.0`-line; check `gradle.properties` for the active version). Full policy in `CONTRIBUTING.md` §7.
- **`@since X.Y.Z`** Javadoc on every new/modified public API element across the library modules (the in-flight target is the active version in `gradle.properties` with the `-SNAPSHOT` suffix dropped — always check `gradle.properties` rather than trusting a hardcoded number here). Full policy in `CONTRIBUTING.md` §7.
- **JSpecify null discipline** — `@NonNull`/`@Nullable` on every public param and return; Error Prone enforces it. Compilation runs `-Xlint:all` with strict Error Prone.
- **Records for DTOs/configs/result variants; sealed interfaces for closed sums.** Public API sealed via `module-info.java` exports.
- **Conventional commits** (`feat(core):`, `fix(jdbi):`, `docs(adr):`, `build:`, `ci:`). Small, atomic.
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,4 @@ JDK 21 required (Gradle toolchain will fetch one if needed).

## Running locally

Module-level READMEs (added in each phase) document the "5-minute integration" snippet and per-module specifics.
See [`GETTING_STARTED.md`](./GETTING_STARTED.md) for the "5-minute integration" snippet and per-module specifics.
73 changes: 43 additions & 30 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,17 @@ The whole project is structured around three concentric rings:
WebAuthn, JWT, and the wire contract. Has no dependencies on
Spring, Dropwizard, Micronaut, JDBC, DynamoDB, HTTP, or any
servlet API. Exposes services and SPIs.
2. **SPIs (ports).** Interfaces the host implements:
`CredentialRepository`, `UserLookup`, `ChallengeStore`,
`BackupCodeRepository`, `OtpRepository`, `EmailSender`, `SmsSender`,
`AttestationTrustPolicy`, `OriginValidator`, `ClockProvider`.
2. **SPIs (ports).** Interfaces the host implements. The required and
core-resident ones live in `pk-auth-core/spi` (`CredentialRepository`,
`UserLookup`, `ChallengeStore`, plus the optional `AttestationTrustPolicy`,
`OriginValidator`, `ClockProvider`, `ConsumedJtiStore`,
`CeremonyRateLimiter`); `UserDeletionListener` is in `pk-auth-core/lifecycle`.
The remaining ports ship with the feature modules that own them —
`BackupCodeRepository` (`pk-auth-backup-codes`), `OtpRepository` /
`SmsSender` (`pk-auth-otp`), `EmailSender` (`pk-auth-magic-link`),
`AccessTokenStore` / `TokenTtlPolicy` / `RevocationCheck` (`pk-auth-jwt`),
and `RefreshTokenRepository` (`pk-auth-refresh-tokens`). The §6 table below
lists the owning module for each.
3. **Adapters.** Three of them — Spring Boot, Dropwizard, Micronaut.
Each adapter mounts the same JSON contract under `/auth/**` and
delegates to the core.
Expand All @@ -111,7 +118,7 @@ modules implement SPIs declared in core and are wired in by the host.
| `pk-auth-jwt` | HS256 JWT mint (`PkAuthJwtIssuer`) + validate (`PkAuthJwtValidator`). Nimbus JOSE+JWT under the hood. Hosts the `TokenTtlPolicy` SPI for per-audience access-token TTL dispatch ([ADR 0014](./docs/adr/0014-per-audience-ttl-policy.md)) and the `AccessTokenStore` SPI for stateful (server-revocable) access tokens ([ADR 0015](./docs/adr/0015-stateful-access-tokens.md)). |
| `pk-auth-backup-codes` | Alt flow: generate, hash (Argon2id), and atomically claim view-once backup codes. |
| `pk-auth-magic-link` | Alt flow: random-token magic links over the host's email dispatcher. |
| `pk-auth-otp` | Alt flow: 6-digit OTPs over the host's SMS dispatcher; Argon2id-hashed and atomic-claim. |
| `pk-auth-otp` | Alt flow: 6-digit OTPs over the host's SMS dispatcher; hashed with HMAC-SHA256 (server-side pepper) and atomic-claim. |
| `pk-auth-refresh-tokens` | Rotating refresh tokens with family-based replay defense. `RefreshTokenService` + `RefreshTokenRepository` SPI; `RefreshHandler` is the framework-neutral `POST /auth/refresh` composer ([ADR 0013](./docs/adr/0013-refresh-tokens-family-rotation.md)). |
| `pk-auth-admin-api` | `AdminService` exposes account/credential/backup-code/email/phone operations. Result-typed (`AdminResult<T>` sealed sum). |
| `pk-auth-persistence-jdbi` | SPI impls on JDBI + Postgres + Flyway. Migrations at `src/main/resources/db/migration/`. |
Expand Down Expand Up @@ -140,10 +147,11 @@ framework-specific on the wire.
| `POST` | `/auth/passkeys/authentication/finish` | Mints a JWT; returns `{token}` |
| `POST` | `/auth/refresh` | Rotates a refresh token; returns `{refresh, access}` on success, `401 {detail}` on any failure. Only mounted when `pk-auth-refresh-tokens` is on the classpath and a `RefreshTokenRepository` SPI is bound. |

> The Dropwizard adapter mounts these one segment shorter
> (`/auth/registration/start`, etc.) because Dropwizard's bundle root
> path convention differs. The TypeScript SDK handles this via a
> per-client path override; see `clients/passkeys-browser/README.md`.
> All three adapters mount these paths identically — the Dropwizard
> Jersey resources use the same `@Path("/auth/passkeys")` (and
> `/auth/refresh`, `/auth/admin`) roots as the Spring and Micronaut
> controllers, so the TypeScript SDK targets one path scheme everywhere
> with no per-client path override.

### Admin endpoints (require `Authorization: Bearer <jwt>`)

Expand Down Expand Up @@ -207,23 +215,23 @@ Worth knowing for any non-trivial integration:
If you adopt pk-auth, the SPIs are your only mandatory contact
surface. They are intentionally narrow.

| SPI | Required? | Notes |
|---|---|---|
| `UserLookup` | **Yes** | Maps `(username/email) ↔ UserHandle`. Atomic find-or-create on first registration. |
| `CredentialRepository` | **Yes** | Insert / list-by-user / update / delete / find-by-id. |
| `ChallengeStore` | **Yes** | `create(...)`, `takeOnce(challengeId)` — atomic single-use. |
| `BackupCodeRepository` | Only if backup codes are enabled | Hashed-storage CRUD + atomic claim. |
| `OtpRepository` | Only if phone OTP is enabled | Same shape as backup codes. |
| `EmailSender` | Only if magic-link is enabled | `send(to, subject, body)`. |
| `SmsSender` | Only if phone OTP is enabled | `send(phoneE164, body)`. |
| `AccessTokenStore` | Optional (paved road for revocability) | Stateful access tokens. Issuer calls `record` on issue, validator calls `exists` on every validate. Default `AccessTokenStore.noop()` preserves stateless behaviour. JDBI + DynamoDB implementations ship in-tree. See [ADR 0015](./docs/adr/0015-stateful-access-tokens.md). |
| `TokenTtlPolicy` | Optional | Per-audience access-token TTL dispatch. Static factories `TokenTtlPolicy.single(ttl)` and `TokenTtlPolicy.fixed(default, overrides)` cover the common cases. See [ADR 0014](./docs/adr/0014-per-audience-ttl-policy.md). |
| `RefreshTokenRepository` | Only if `pk-auth-refresh-tokens` is wired | Storage SPI for the rotating refresh-token primitive. Load-bearing `rotateAtomically` atomically marks the parent used and inserts the successor. JDBI, DynamoDB, and in-memory impls ship; the contract is enforced by a parity test suite that includes the 8-thread concurrent-rotation race test. See [ADR 0013](./docs/adr/0013-refresh-tokens-family-rotation.md). |
| `UserDeletionListener` | Optional (extension point) | Hook for the `UserDeletionService` fan-out. The library auto-registers listeners for credentials, backup codes, OTPs, access tokens, and refresh tokens; hosts add their own to clean up host-owned tables on user delete. See [ADR 0016](./docs/adr/0016-user-deletion-fan-out.md). |
| `RevocationCheck` | Optional | In-process deny-list for hosts that want fast invalidation of a small set of JTIs without persisting every issued token. Orthogonal to `AccessTokenStore`. |
| `AttestationTrustPolicy` | Optional | Default policy is `none`. Override to enforce MDS3 / specific AAGUID lists. |
| `OriginValidator` | Optional | Default is config-driven exact-match. Override for tenancy-aware origins. |
| `ClockProvider` | Optional | Default is `Clock.systemUTC()`. Override in tests. |
| SPI | Module | Required? | Notes |
|---|---|---|---|
| `UserLookup` | `pk-auth-core` | **Yes** | Maps `(username/email) ↔ UserHandle`. Atomic find-or-create on first registration. |
| `CredentialRepository` | `pk-auth-core` | **Yes** | Insert / list-by-user / update / delete / find-by-id. |
| `ChallengeStore` | `pk-auth-core` | **Yes** | `create(...)`, `takeOnce(challengeId)` — atomic single-use. |
| `BackupCodeRepository` | `pk-auth-backup-codes` | Only if backup codes are enabled | Hashed-storage CRUD + atomic claim. |
| `OtpRepository` | `pk-auth-otp` | Only if phone OTP is enabled | Same shape as backup codes. |
| `EmailSender` | `pk-auth-magic-link` | Only if magic-link is enabled | `send(to, subject, body)`. |
| `SmsSender` | `pk-auth-otp` | Only if phone OTP is enabled | `send(phoneE164, body)`. |
| `AccessTokenStore` | `pk-auth-jwt` | Optional (paved road for revocability) | Stateful access tokens. Issuer calls `record` on issue, validator calls `exists` on every validate. Default `AccessTokenStore.noop()` preserves stateless behaviour. JDBI + DynamoDB implementations ship in-tree. See [ADR 0015](./docs/adr/0015-stateful-access-tokens.md). |
| `TokenTtlPolicy` | `pk-auth-jwt` | Optional | Per-audience access-token TTL dispatch. Static factories `TokenTtlPolicy.single(ttl)` and `TokenTtlPolicy.fixed(default, overrides)` cover the common cases. See [ADR 0014](./docs/adr/0014-per-audience-ttl-policy.md). |
| `RevocationCheck` | `pk-auth-jwt` | Optional | In-process deny-list for hosts that want fast invalidation of a small set of JTIs without persisting every issued token. Orthogonal to `AccessTokenStore`. |
| `RefreshTokenRepository` | `pk-auth-refresh-tokens` (`…refresh.spi`) | Only if `pk-auth-refresh-tokens` is wired | Storage SPI for the rotating refresh-token primitive. Load-bearing `rotateAtomically` atomically marks the parent used and inserts the successor. JDBI, DynamoDB, and in-memory impls ship; the contract is enforced by a parity test suite that includes the 8-thread concurrent-rotation race test. See [ADR 0013](./docs/adr/0013-refresh-tokens-family-rotation.md). |
| `UserDeletionListener` | `pk-auth-core` (`…lifecycle`, not `spi`) | Optional (extension point) | Hook for the `UserDeletionService` fan-out. The library auto-registers listeners for credentials, backup codes, OTPs, access tokens, and refresh tokens; hosts add their own to clean up host-owned tables on user delete. See [ADR 0016](./docs/adr/0016-user-deletion-fan-out.md). |
| `AttestationTrustPolicy` | `pk-auth-core` | Optional | Default policy is `none`. Override to enforce MDS3 / specific AAGUID lists. |
| `OriginValidator` | `pk-auth-core` | Optional | Default is config-driven exact-match. Override for tenancy-aware origins. |
| `ClockProvider` | `pk-auth-core` | Optional | Default is `Clock.systemUTC()`. Override in tests. |

For a fresh project, the testkit's in-memory implementations let
you boot end-to-end without writing any SPI. For Phase-12-style
Expand Down Expand Up @@ -303,8 +311,10 @@ SPIs.
(V1–V5, no `pkauth_` prefix), plus the append-only `pkauth_audit_events`
table from V6. `V8__create_access_tokens.sql` and
`V9__create_refresh_tokens.sql` add the stateful-access-token and
refresh-token tables for the 1.1.0 SPIs.
`PkAuthJdbiSchema.CURRENT_SCHEMA_VERSION` is `"9"`. Magic-link tokens are
refresh-token tables for the 1.1.0 SPIs; `V10__refresh_tokens_amr.sql`
adds the `amr` (RFC 8176 authentication-method-reference) column to
`refresh_tokens`. `PkAuthJdbiSchema.CURRENT_SCHEMA_VERSION` is `"10"`.
Magic-link tokens are
not persisted — the JWT itself is the credential; consumed JTIs live in a
`ConsumedJtiStore` (in-memory by default, swap in a shared backend for
multi-replica deployments).
Expand Down Expand Up @@ -431,8 +441,11 @@ Highlights:
for sites where synced (counter-0) passkeys dominate, at the cost
of weakening the clone-detection signal.
- **Challenges are single-use** and TTL-bounded (5 min default).
- **Backup codes and OTPs are Argon2id-hashed** server-side. Plaintext
is returned only at regeneration time (view-once).
- **Backup codes are Argon2id-hashed** server-side; **OTPs are hashed
with HMAC-SHA256 using a server-side pepper** (the 10^6 search space
makes a CPU-heavy hash pointless — the per-attempt cap and rate
limiter are the brute-force defence). Backup-code plaintext is
returned only at regeneration time (view-once).
- **Last-credential guard**: `DELETE /credentials/{id}` returns 409
if it would leave the user with zero passkeys. Backup codes are the
intended recovery path; encourage users to add a second passkey
Expand Down
2 changes: 1 addition & 1 deletion GETTING_STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ Skip them and the feature isn't exposed.
|---|---|---|
| `pk-auth-backup-codes` | View-once Argon2id-hashed backup codes for account recovery. | Users will lose their phones; backup codes are the documented recovery path before "talk to support." |
| `pk-auth-magic-link` | Single-use email magic-link tokens. JWTs on the wire; consumed-JTI tracking via a swappable SPI. | Email verification, or a passwordless login alternative for users without a passkey-capable device. |
| `pk-auth-otp` | 6-digit SMS OTPs with attempt caps and Argon2id-hashed storage. | Phone verification. |
| `pk-auth-otp` | 6-digit SMS OTPs with attempt caps; codes are hashed with HMAC-SHA256 using a server-side pepper. | Phone verification. |
| `pk-auth-refresh-tokens` | Rotating refresh tokens with family-based replay defense. Adds `POST /auth/refresh`; on success returns a new refresh token + a fresh access JWT, on replay scorches the entire token family. Requires a `RefreshTokenRepository` SPI (JDBI / DynamoDB impls ship). See [ADR 0013](./docs/adr/0013-refresh-tokens-family-rotation.md). | When you need sessions longer than the access-token TTL without re-running a WebAuthn ceremony. |
| `pk-auth-admin-api` | Adds the `/auth/admin/**` endpoints (rename / delete passkeys, regenerate backup codes, account summary, email & phone verification). | Almost always — without it the UI can't manage credentials. |

Expand Down
16 changes: 9 additions & 7 deletions clients/passkeys-browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,18 +49,20 @@ await pk.ceremonies.authenticate({ conditional: true });
Wires `mediation: "conditional"` into the underlying `navigator.credentials.get` call,
so the browser can offer passkeys via autofill UI before the user clicks "Sign in."

### Adapter path differences
### Overriding ceremony paths

The default ceremony paths target Spring Boot / Micronaut (`/auth/passkeys/...`). The
Dropwizard adapter omits the `/passkeys/` segment; pass `paths` to override:
All three adapters (Spring Boot, Dropwizard, Micronaut) mount the ceremony endpoints at the
same `/auth/passkeys/...` paths, which are the SDK defaults — no per-adapter override is
needed. The `paths` option remains an escape hatch for hosts that remount the endpoints under
a custom prefix:

```ts
new PkAuthCeremonyClient(options, {
paths: {
startReg: "/auth/registration/start",
finishReg: "/auth/registration/finish",
startAuth: "/auth/authentication/start",
finishAuth: "/auth/authentication/finish",
startReg: "/api/auth/passkeys/registration/start",
finishReg: "/api/auth/passkeys/registration/finish",
startAuth: "/api/auth/passkeys/authentication/start",
finishAuth: "/api/auth/passkeys/authentication/finish",
},
});
```
Expand Down
Loading
Loading