diff --git a/CHANGELOG.md b/CHANGELOG.md index 35f4c45..dad6d6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,27 @@ tags. back to `JwtConfig.defaultAudience()`. New convenience factory `JwtClaims.forPasskey(userHandle, credentialId, audience, amr)` etc. mirror the existing audience-less factories. +- `AccessTokenStore` SPI in `pk-auth-jwt` for stateful (server-revocable) access + tokens. `PkAuthJwtIssuer` calls `record` on every issue; `PkAuthJwtValidator` + 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-persistence-jdbi`: `JdbiAccessTokenStore` backed by the new + `access_tokens` table (Flyway V8). +- `pk-auth-persistence-dynamodb`: `DynamoDbAccessTokenStore` using two items + per JTI (primary jti-keyed item + user-indexed pointer item) with DynamoDB + native TTL on the `ttl` attribute for asynchronous expiry cleanup. +- `pk-auth-testkit`: `InMemoryAccessTokenStore` + `AccessTokenStoreScenarios` + parity-test class driven from in-memory / JDBI / DynamoDB integration tests. +- `UserDeletionService` and `UserDeletionListener` SPI in + `pk-auth-core` (`com.codeheadsystems.pkauth.lifecycle`). Single fan-out + point that runs every registered listener for a user, with idempotent + + best-effort semantics and structured `pkauth.user.deletion` logging. See + ADR 0016. The library ships listeners for credentials, backup codes, OTPs, + and access tokens; each adapter wires them automatically. +- `CredentialRepository.deleteByUserHandle(UserHandle)` and + `OtpRepository.deleteByUserHandle(UserHandle)` SPI methods for the fan-out. + Implemented in all three persistence variants. ### Changed @@ -42,6 +63,16 @@ tags. `PkAuthConfig.Jwt` (Dropwizard), and `PkAuthConfiguration.Jwt` (Micronaut) each gain a `ttlsByAudience: Map` field and rename their single-TTL field; see each adapter's javadoc for the bound property name. +- **Breaking (SPI).** `CredentialRepository` and `OtpRepository` gain a + `deleteByUserHandle(UserHandle) -> int` method. All shipped implementations + (in-memory, JDBI, DynamoDB) updated. Downstream host-supplied + implementations must add it; the natural impl is a single bulk-delete + statement keyed on the user-handle column. +- `PkAuthJwtIssuer` and `PkAuthJwtValidator` gain new constructors that accept + an `AccessTokenStore`. The legacy three-arg constructors remain and default + to `AccessTokenStore.noop()`. +- Flyway schema version bumped to V8. `PkAuthJdbiSchema.CURRENT_SCHEMA_VERSION` + is now `"8"`. ## [1.0.0] — 2026-05 (stabilisation cut) diff --git a/docs/adr/0015-stateful-access-tokens.md b/docs/adr/0015-stateful-access-tokens.md new file mode 100644 index 0000000..04c51b1 --- /dev/null +++ b/docs/adr/0015-stateful-access-tokens.md @@ -0,0 +1,117 @@ +# 15. Stateful access tokens via AccessTokenStore SPI + +Date: 2026-05-16 + +## Status + +Accepted. + +## Context + +Through 1.0, pk-auth issued stateless JWTs (ADR 0005) and exposed a {@link +RevocationCheck} SPI as a deny-list escape hatch: hosts that needed early +invalidation could keep a small in-process set of "revoked jtis" and the +validator would consult it. The pattern works but doesn't match the way every +serious consumer ends up writing the code. Motif's `MotifJwtIssuer` +demonstrates the actual pattern in production use: + +1. On every issue, persist the JTI to an `access_tokens` table. +2. On every validate, look the JTI up; if absent, reject. +3. On logout, delete the JTI. +4. On user delete, delete every JTI for that user. + +The deny-list shape inverts that: a *positive* allow-list means "this token +was issued by us and has not been deleted" — and "deleted" covers logout, +admin revocation, password reset, account compromise response, and user +deletion uniformly. The cost is one row per issued token plus one read per +validation. For the deployments that need revocability at all, this is the +right cost shape — small, predictable, and equivalent to a session table. + +The 1.1.0 release adds the positive-allow primitive without removing the +existing deny-list. Both coexist: + +- `RevocationCheck` — fast, in-process deny-list. Right for hosts that issue + many millions of tokens per day and only want to invalidate a tiny subset + proactively (e.g. a "session revoked" stream from a security event bus). +- `AccessTokenStore` — durable allow-list. Right for hosts that issue + meaningfully fewer tokens (admin sessions, mobile clients) and want logout + to take effect before the JWT's `exp`. This is the paved road. + +## Decision + +Introduce `AccessTokenStore` in `pk-auth-jwt` with the surface: + +```java +public interface AccessTokenStore { + void record(String jti, UserHandle, String audience, Optional deviceId, + Instant issuedAt, Instant expiresAt); + boolean exists(String jti); + boolean delete(String jti); + int deleteAllForUser(UserHandle userHandle); + int deleteExpiredBefore(Instant before); + static AccessTokenStore noop(); +} +``` + +`PkAuthJwtIssuer.issue(JwtClaims)` always calls `store.record(...)` after +signing and before returning the wire token. If `record` throws, issuance +fails — partial state (token returned but unrecorded) is not tolerated. + +`PkAuthJwtValidator.validate(String)` calls `store.exists(jti)` after +signature, issuer, audience, and skew checks but alongside the existing +`RevocationCheck`. A `false` return (jti not in store) maps to +`JwtVerificationResult.Revoked` — the same outcome as a deny-list hit, so +consumers don't have to learn a new sealed-result variant. + +The default binding is `AccessTokenStore.noop()`: `record` discards, +`exists` returns `true` for every jti, and the delete methods return zero. +This preserves stateless JWT behaviour for hosts that don't bind a real +store, so the "feature is opt-in by binding a different bean" pattern stays +clean — no `TokenMode` enum, no two-place configuration footgun. + +Implementations ship in `pk-auth-testkit` (in-memory), `pk-auth-persistence-jdbi` +(Postgres, Flyway V8), and `pk-auth-persistence-dynamodb` (single-table with +DynamoDB native TTL on the row's `ttl` attribute). + +Adapter wiring: + +- Spring Boot starter — `@Bean AccessTokenStore` defaulting to noop, with + `@ConditionalOnMissingBean` so JDBI/Dynamo modules (or host beans) win. +- Dropwizard bundle — `PersistenceBindings.accessTokenStore()` defaults to + noop; hosts pass a real store via the builder. +- Micronaut adapter — `@Singleton AccessTokenStore` factory method, + overridable via the host's own `@Singleton` declaration. + +## Consequences + +- **Pro**: Server-side logout works end-to-end with no host-side code + beyond binding the JDBI/Dynamo `AccessTokenStore` bean and calling + `store.delete(jti)` from the logout endpoint. +- **Pro**: User deletion (ADR 0016) becomes a one-call operation: + `UserDeletionService.deleteUser(handle)` fans out to every listener + including the access-token cleanup. +- **Pro**: No new sealed-result variant. `Revoked` covers both deny-list + hits and store-misses. +- **Pro**: The noop default keeps the stateless path strictly free — every + validation call still hits `exists(...)` but it's an `always-true` lambda. +- **Con**: One row per issued token. Hosts that issue at high volume + (millions/day) and don't need fast revocation should stick with + `RevocationCheck` + a small deny-list. The library does not auto-detect + this; the operator picks the binding. +- **Con**: The `ttl` field on `JwtClaims` isn't yet a first-class device-id + channel — when stateful, `record(...)` always passes `Optional.empty()` + for `deviceId`. A future ADR (when refresh tokens land) extends this + surface to bind issued JWTs to a refresh family/device for "log out this + device" granularity. +- **Con**: The validator's hot path now includes a store lookup. For the + noop case this is a hash-lookup-then-return; for JDBI it's one indexed + query per validate. Adopters needing the absolute lowest validation + latency (e.g. CDN-near edge validation) should benchmark before enabling. + +## Open follow-ups + +- Adding `deviceId` to `JwtClaims` and threading it through `record(...)` + becomes meaningful once PR 3 (refresh tokens) lands and device-bound + sessions are a first-class concept. +- Operator-guide entry for the daily `deleteExpiredBefore(now)` cleanup + cron — currently only documented inline. diff --git a/docs/adr/0016-user-deletion-fan-out.md b/docs/adr/0016-user-deletion-fan-out.md new file mode 100644 index 0000000..8d4e34d --- /dev/null +++ b/docs/adr/0016-user-deletion-fan-out.md @@ -0,0 +1,139 @@ +# 16. User deletion fan-out is sequential and best-effort + +Date: 2026-05-16 + +## Status + +Accepted. + +## Context + +Through 1.0, pk-auth had no first-class user-deletion primitive. Hosts +manually called `CredentialRepository.delete(...)`, `BackupCodeRepository. +deleteByUserHandle(...)`, etc. once per credential category. With the 1.1 +addition of `AccessTokenStore` (ADR 0015) and the forthcoming refresh-token +service (PR 3), the number of categories grows and a single one-call +abstraction starts to matter. + +Motif's `OwnerLifecycleService` is the existence proof: a single +`revokeAll(ownerId)` call iterates every registered +`AuthMethodRevocationListener` inside a single JDBI transaction with a +shared `Handle`, so listeners run atomically — either every credential +category is wiped or none is. + +pk-auth can't adopt the same model verbatim. Persistence in pk-auth spans +three substrates: + +- JDBI (Postgres connection pool + `Handle` transactions) +- DynamoDB (AWS SDK enhanced client; no transactional `Handle` analogue + beyond `TransactWriteItems` which is scoped to a single table at a time) +- In-memory testkit collections + +There is no shared transactional substrate across these. A motif-style +"one Handle, one transaction" model is structurally impossible without +forcing every persistence backend into a single shape, which would +preclude DynamoDB and in-memory backends. + +Two options remained: + +1. Require every listener to participate in a coordinator-driven 2PC + protocol so partial failures roll back across substrates. +2. Run listeners sequentially, each in its own scope; tolerate partial + failure; log and report. + +Option 1 is correct but expensive — both to design and to operate. Real +2PC across JDBC and DynamoDB requires a transaction manager (XA) that +DynamoDB doesn't natively support, so we'd be inventing one. + +Option 2 is correct enough for the use case. User deletion is an admin +operation, not a hot path. The expected operator response to a partial +failure is "look at the structured log, retry the deletion." The retry +is safe because `UserDeletionListener` implementations are required to be +idempotent (deleting a user with no rows is a no-op that returns zero). + +The trade-off is observable: a partial deletion leaves the user with some +credential categories intact, which is a security-relevant state if not +remediated. We accept this with two mitigations: + +1. Every listener invocation emits a structured `pkauth.user.deletion` + log event with `outcome=ok|failed` and the failing exception. Operators + monitor the log stream. +2. `UserDeletionService.deleteUser(handle)` returns a `UserDeletionResult` + record with succeeded/failed counts and a list of failed listener names, + so callers (admin endpoints, scripts) can programmatically detect + partial failure and trigger their own retry / paging logic. + +## Decision + +Introduce `UserDeletionService` and `UserDeletionListener` in +`pk-auth-core` (`com.codeheadsystems.pkauth.lifecycle`). The service runs +listeners in the iteration order of the supplied collection, catches +`RuntimeException` per listener, logs a structured event, and returns a +`UserDeletionResult`. + +Default listener bindings shipped by the library: + +- `CredentialRepositoryDeletionListener` — calls + `CredentialRepository.deleteByUserHandle(...)`. Method added to the SPI + in 1.1.0 (breaking — all implementations updated; see CHANGELOG). +- `BackupCodeRepositoryDeletionListener` — uses the existing + `BackupCodeRepository.deleteByUserHandle(...)`. +- `OtpRepositoryDeletionListener` — uses the new + `OtpRepository.deleteByUserHandle(...)` method (also breaking SPI + addition in 1.1.0). +- `AccessTokenStoreDeletionListener` — uses + `AccessTokenStore.deleteAllForUser(...)`. Harmless in stateless mode + (the noop store returns zero). + +Adapter wiring collects every `UserDeletionListener` bean into a single +service: + +- **Spring Boot starter** — `@Bean` for each library listener + a + `UserDeletionService` bean that takes `List`, + which Spring auto-populates. +- **Dropwizard bundle** — Dagger `@IntoSet` multibindings; `PkAuthModule` + contributes credential + access-token listeners, `AltFlowsModule` + contributes backup-code + OTP listeners. The slim component (no + alt-flows) gets two listeners; the full component gets four. +- **Micronaut adapter** — `@Singleton UserDeletionListener` factory + methods + a `UserDeletionService` bean injecting + `Collection`. + +Hosts add their own listeners by declaring their own bean in the +adapter's DI framework — the service picks them up via the same +collection. + +## Consequences + +- **Pro**: One-call user deletion across every credential category the + library manages. Hosts replace ad-hoc cleanup with + `userDeletionService.deleteUser(handle)`. +- **Pro**: New library features (refresh tokens in PR 3) integrate + automatically by adding their own `UserDeletionListener` to the same + collection. No central registry to update. +- **Pro**: Adopter-supplied listeners participate first-class — a host + with its own user-keyed table (avatar storage, app-specific session + data) wires a listener and gets it called alongside the library's. +- **Con**: Not atomic. A partial deletion leaves some credential + categories intact. Operators must monitor the structured log and retry. + We accept this; see Context for the alternative-rejected (XA across + JDBC and DynamoDB). +- **Con**: The structured log is the audit trail. If operators don't + consume the `pkauth.user.deletion` event stream, failed deletions go + unnoticed until a user complains. +- **Con**: Adding `deleteByUserHandle` to `CredentialRepository` and + `OtpRepository` SPIs is breaking for downstream impls. The 1.1.0 release + note calls this out; in practice every shipped pk-auth persistence + module is updated as part of the same release. + +## Open follow-ups + +- An admin endpoint surfacing `userDeletionService.deleteUser(handle)` + would let hosts trigger fan-out from a REST call. Not in 1.1.0 scope. +- A retry helper that takes a `UserDeletionResult.failedListenerNames` + list and re-runs just those listeners would simplify the operator + retry flow. Deferred until a real consumer asks for it. +- If a future persistence backend joins (e.g. a single transactional + store covering every SPI), the service could opportunistically detect + it and switch to a single-transaction fan-out. Out of scope until that + backend exists. diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/lifecycle/BackupCodeRepositoryDeletionListener.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/lifecycle/BackupCodeRepositoryDeletionListener.java new file mode 100644 index 0000000..4c92f5c --- /dev/null +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/lifecycle/BackupCodeRepositoryDeletionListener.java @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.lifecycle; + +import com.codeheadsystems.pkauth.api.UserHandle; +import com.codeheadsystems.pkauth.spi.BackupCodeRepository; +import java.util.Objects; + +/** + * Bridges {@link BackupCodeRepository#deleteByUserHandle(UserHandle)} into the {@link + * UserDeletionService} fan-out. + * + * @since 1.1.0 + */ +public final class BackupCodeRepositoryDeletionListener implements UserDeletionListener { + + private final BackupCodeRepository repository; + + public BackupCodeRepositoryDeletionListener(BackupCodeRepository repository) { + this.repository = Objects.requireNonNull(repository, "repository"); + } + + @Override + public void onUserDeleted(UserHandle userHandle) { + repository.deleteByUserHandle(userHandle); + } +} diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/lifecycle/CredentialRepositoryDeletionListener.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/lifecycle/CredentialRepositoryDeletionListener.java new file mode 100644 index 0000000..461c0e7 --- /dev/null +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/lifecycle/CredentialRepositoryDeletionListener.java @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.lifecycle; + +import com.codeheadsystems.pkauth.api.UserHandle; +import com.codeheadsystems.pkauth.spi.CredentialRepository; +import java.util.Objects; + +/** + * Bridges {@link CredentialRepository#deleteByUserHandle(UserHandle)} into the {@link + * UserDeletionService} fan-out. Adapter modules register this bean alongside other listeners. + * + * @since 1.1.0 + */ +public final class CredentialRepositoryDeletionListener implements UserDeletionListener { + + private final CredentialRepository repository; + + public CredentialRepositoryDeletionListener(CredentialRepository repository) { + this.repository = Objects.requireNonNull(repository, "repository"); + } + + @Override + public void onUserDeleted(UserHandle userHandle) { + repository.deleteByUserHandle(userHandle); + } +} diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/lifecycle/OtpRepositoryDeletionListener.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/lifecycle/OtpRepositoryDeletionListener.java new file mode 100644 index 0000000..9ad078a --- /dev/null +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/lifecycle/OtpRepositoryDeletionListener.java @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.lifecycle; + +import com.codeheadsystems.pkauth.api.UserHandle; +import com.codeheadsystems.pkauth.spi.OtpRepository; +import java.util.Objects; + +/** + * Bridges {@link OtpRepository#deleteByUserHandle(UserHandle)} into the {@link UserDeletionService} + * fan-out. + * + * @since 1.1.0 + */ +public final class OtpRepositoryDeletionListener implements UserDeletionListener { + + private final OtpRepository repository; + + public OtpRepositoryDeletionListener(OtpRepository repository) { + this.repository = Objects.requireNonNull(repository, "repository"); + } + + @Override + public void onUserDeleted(UserHandle userHandle) { + repository.deleteByUserHandle(userHandle); + } +} diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/lifecycle/UserDeletionListener.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/lifecycle/UserDeletionListener.java new file mode 100644 index 0000000..3491702 --- /dev/null +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/lifecycle/UserDeletionListener.java @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.lifecycle; + +import com.codeheadsystems.pkauth.api.UserHandle; + +/** + * Hook invoked by {@link UserDeletionService} for each registered listener when a user is being + * deleted. Implementations are typically thin adapters around a repository / store, deleting all + * rows owned by the supplied {@link UserHandle}. + * + *

Contract. + * + *

    + *
  • Idempotent. A listener may be invoked more than once for the same user (e.g. a retry + * after a transient failure). Calls must converge on "all rows for this user are absent" + * without throwing on second-and-later invocations. + *
  • Best-effort isolated. Each listener runs in its own scope; failures are logged and + * the service continues with remaining listeners. Listeners must not assume earlier listeners + * succeeded. + *
+ * + *

See ADR 0016 for the rationale behind the sequential-per-listener (rather than + * single-shared-transaction) fan-out. + * + * @since 1.1.0 + */ +@FunctionalInterface +public interface UserDeletionListener { + + /** + * Invoked once per registered listener when {@link UserDeletionService#deleteUser(UserHandle)} + * runs. Implementations must delete every row owned by the supplied user from their backing + * store. + * + * @param userHandle the user whose state is being removed + * @throws RuntimeException any failure; the service catches, logs, and proceeds to the next + * listener + */ + void onUserDeleted(UserHandle userHandle); + + /** + * Display name for structured logging and {@link UserDeletionResult} reporting. Defaults to the + * implementing class's simple name. + */ + default String name() { + return getClass().getSimpleName(); + } +} diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/lifecycle/UserDeletionResult.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/lifecycle/UserDeletionResult.java new file mode 100644 index 0000000..05abd3f --- /dev/null +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/lifecycle/UserDeletionResult.java @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.lifecycle; + +import java.util.List; +import java.util.Objects; + +/** + * Outcome of a {@link UserDeletionService#deleteUser com.codeheadsystems.pkauth.api.UserHandle)} + * call. + * + *

{@code succeeded + failed = total listeners invoked}. A non-empty {@link #failedListenerNames} + * indicates one or more listeners threw and were skipped; the user's auth state for those + * categories is potentially intact and the operator must follow up (retry the deletion or intervene + * manually). The service has already logged a structured {@code pkauth.user.deletion} event for + * each failure; this record is for callers that want a programmatic summary. + * + * @param succeeded number of listeners that completed without throwing + * @param failed number of listeners that threw; {@link #failedListenerNames} carries their {@link + * UserDeletionListener#name()} values in iteration order + * @param failedListenerNames names of failed listeners, in the order they were invoked + * @since 1.1.0 + */ +public record UserDeletionResult(int succeeded, int failed, List failedListenerNames) { + + public UserDeletionResult { + if (succeeded < 0) { + throw new IllegalArgumentException("succeeded must be non-negative"); + } + if (failed < 0) { + throw new IllegalArgumentException("failed must be non-negative"); + } + Objects.requireNonNull(failedListenerNames, "failedListenerNames"); + failedListenerNames = List.copyOf(failedListenerNames); + if (failedListenerNames.size() != failed) { + throw new IllegalArgumentException( + "failedListenerNames size (" + + failedListenerNames.size() + + ") != failed (" + + failed + + ")"); + } + } + + /** Returns {@code true} iff every listener succeeded. */ + public boolean allSucceeded() { + return failed == 0; + } +} diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/lifecycle/UserDeletionService.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/lifecycle/UserDeletionService.java new file mode 100644 index 0000000..de5c676 --- /dev/null +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/lifecycle/UserDeletionService.java @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.lifecycle; + +import com.codeheadsystems.pkauth.api.UserHandle; +import com.codeheadsystems.pkauth.json.Base64Url; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Fans out a "delete this user" command across every registered {@link UserDeletionListener}. + * Adapter modules ({@code pk-auth-spring-boot-starter}, {@code pk-auth-dropwizard}, {@code + * pk-auth-micronaut}) collect every listener bean and pass them in. + * + *

Semantics — sequential, isolated, best-effort. Listeners are invoked in the + * iteration order of the supplied collection, each in its own scope. A listener that throws is + * logged via a structured {@code pkauth.user.deletion} event and the service proceeds with the next + * listener. The returned {@link UserDeletionResult} reports successes and failures by name. + * + *

This differs from motif's {@code OwnerLifecycleService}, which wraps every listener in a + * single JDBI transaction with a shared {@code Handle}. pk-auth cannot adopt that model because its + * persistence SPIs span multiple datasources (JDBC pool, DynamoDB client, in-memory collections) + * with no shared transactional substrate. See ADR 0016. + * + *

Operational guidance. Failed listeners are non-fatal — the service does not throw — + * because the caller (typically an admin endpoint) usually prefers a partial cleanup with audit + * trail to a hard failure that leaves the user in an even worse half-state. Operators should watch + * the structured log for non-zero {@code failed} counts and retry, since {@link + * UserDeletionListener} implementations are required to be idempotent. + * + * @since 1.1.0 + */ +public final class UserDeletionService { + + private static final Logger LOG = LoggerFactory.getLogger(UserDeletionService.class); + + private final List listeners; + + /** + * Constructs the service with the supplied listeners. Iteration order of the collection is the + * order in which listeners run; pass a {@code List} (or other ordered collection) when order + * matters. The collection is copied defensively. + */ + public UserDeletionService(Collection listeners) { + Objects.requireNonNull(listeners, "listeners"); + this.listeners = List.copyOf(listeners); + } + + /** + * Runs every listener for the supplied user. Failures from individual listeners are logged and + * counted, then the service continues. The returned {@link UserDeletionResult} summarises the + * outcome. + */ + public UserDeletionResult deleteUser(UserHandle userHandle) { + Objects.requireNonNull(userHandle, "userHandle"); + String userHandleB64u = Base64Url.encode(userHandle.value()); + int succeeded = 0; + List failedNames = new ArrayList<>(); + for (UserDeletionListener listener : listeners) { + String name = listener.name(); + try { + listener.onUserDeleted(userHandle); + succeeded++; + LOG.info( + "pkauth.user.deletion listener={} user_handle_b64={} outcome=ok", name, userHandleB64u); + } catch (RuntimeException e) { + failedNames.add(name); + LOG.error( + "pkauth.user.deletion listener={} user_handle_b64={} outcome=failed cause={}", + name, + userHandleB64u, + e.toString(), + e); + } + } + return new UserDeletionResult(succeeded, failedNames.size(), failedNames); + } +} diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/lifecycle/package-info.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/lifecycle/package-info.java new file mode 100644 index 0000000..05d7005 --- /dev/null +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/lifecycle/package-info.java @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT + +/** + * User-lifecycle fan-out. {@link com.codeheadsystems.pkauth.lifecycle.UserDeletionService} runs + * every registered {@link com.codeheadsystems.pkauth.lifecycle.UserDeletionListener} when a user is + * removed, so the host gets a single call to revoke every credential category pk-auth manages + * (passkeys, backup codes, OTPs, magic-link state, refresh tokens, access tokens). See ADR 0016 for + * the design rationale. + * + * @since 1.1.0 + */ +package com.codeheadsystems.pkauth.lifecycle; diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/CredentialRepository.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/CredentialRepository.java index 79d9152..7db7cfe 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/CredentialRepository.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/CredentialRepository.java @@ -36,4 +36,16 @@ public interface CredentialRepository { * tombstones inside the credentials table. */ void delete(CredentialId credentialId); + + /** + * Hard-deletes every credential owned by the supplied user. Called by {@link + * com.codeheadsystems.pkauth.lifecycle.UserDeletionService} during user-deletion fan-out; hosts + * may also call it directly for bulk-revocation flows. + * + *

Returns the number of rows removed (best-effort; used for structured logging). Must be + * idempotent — a call against a user with no remaining credentials returns {@code 0}. + * + * @since 1.1.0 + */ + int deleteByUserHandle(UserHandle userHandle); } diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/OtpRepository.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/OtpRepository.java index 72119d9..013a70b 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/OtpRepository.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/OtpRepository.java @@ -96,4 +96,15 @@ record StoredOtp( * the service for rate limiting (brief §6.5 — at most 3 per 15 minutes). */ int countSince(UserHandle userHandle, String phoneE164, Instant since); + + /** + * Deletes every OTP row owned by the supplied user. Called by {@link + * com.codeheadsystems.pkauth.lifecycle.UserDeletionService} during user-deletion fan-out. + * + *

Returns the number of rows removed (best-effort; used for structured logging). Must be + * idempotent — a call against a user with no remaining rows returns {@code 0}. + * + * @since 1.1.0 + */ + int deleteByUserHandle(UserHandle userHandle); } diff --git a/pk-auth-core/src/main/java/module-info.java b/pk-auth-core/src/main/java/module-info.java index 39a9c88..368f241 100644 --- a/pk-auth-core/src/main/java/module-info.java +++ b/pk-auth-core/src/main/java/module-info.java @@ -22,6 +22,7 @@ exports com.codeheadsystems.pkauth.credential; exports com.codeheadsystems.pkauth.error; exports com.codeheadsystems.pkauth.json; + exports com.codeheadsystems.pkauth.lifecycle; exports com.codeheadsystems.pkauth.metrics; exports com.codeheadsystems.pkauth.spi; } diff --git a/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/lifecycle/UserDeletionServiceTest.java b/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/lifecycle/UserDeletionServiceTest.java new file mode 100644 index 0000000..768faff --- /dev/null +++ b/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/lifecycle/UserDeletionServiceTest.java @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.lifecycle; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.codeheadsystems.pkauth.api.CredentialId; +import com.codeheadsystems.pkauth.api.UserHandle; +import com.codeheadsystems.pkauth.credential.CredentialRecord; +import com.codeheadsystems.pkauth.spi.BackupCodeRepository; +import com.codeheadsystems.pkauth.spi.CredentialRepository; +import com.codeheadsystems.pkauth.spi.OtpRepository; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.OptionalInt; +import org.junit.jupiter.api.Test; + +class UserDeletionServiceTest { + + private static final UserHandle USER = UserHandle.of(new byte[] {1, 2, 3}); + + @Test + void runsEveryListenerInOrder() { + List calls = new ArrayList<>(); + UserDeletionListener a = recording("A", calls); + UserDeletionListener b = recording("B", calls); + UserDeletionListener c = recording("C", calls); + + UserDeletionResult result = new UserDeletionService(List.of(a, b, c)).deleteUser(USER); + + assertThat(calls).containsExactly("A", "B", "C"); + assertThat(result.succeeded()).isEqualTo(3); + assertThat(result.failed()).isZero(); + assertThat(result.failedListenerNames()).isEmpty(); + assertThat(result.allSucceeded()).isTrue(); + } + + @Test + void aFailingListenerDoesNotStopRemaining() { + List calls = new ArrayList<>(); + UserDeletionListener a = recording("A", calls); + UserDeletionListener boom = + new UserDeletionListener() { + @Override + public void onUserDeleted(UserHandle userHandle) { + calls.add("BOOM"); + throw new IllegalStateException("simulated"); + } + + @Override + public String name() { + return "Boom"; + } + }; + UserDeletionListener c = recording("C", calls); + + UserDeletionResult result = new UserDeletionService(List.of(a, boom, c)).deleteUser(USER); + + assertThat(calls).containsExactly("A", "BOOM", "C"); + assertThat(result.succeeded()).isEqualTo(2); + assertThat(result.failed()).isEqualTo(1); + assertThat(result.failedListenerNames()).containsExactly("Boom"); + assertThat(result.allSucceeded()).isFalse(); + } + + @Test + void resultRejectsMismatchedFailedCount() { + assertThatThrownBy(() -> new UserDeletionResult(0, 1, List.of())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void credentialListenerForwardsToRepository() { + StubCredentialRepository repo = new StubCredentialRepository(); + new CredentialRepositoryDeletionListener(repo).onUserDeleted(USER); + assertThat(repo.deletedFor).containsExactly(USER); + } + + @Test + void backupCodeListenerForwardsToRepository() { + StubBackupCodeRepository repo = new StubBackupCodeRepository(); + new BackupCodeRepositoryDeletionListener(repo).onUserDeleted(USER); + assertThat(repo.deletedFor).containsExactly(USER); + } + + @Test + void otpListenerForwardsToRepository() { + StubOtpRepository repo = new StubOtpRepository(); + new OtpRepositoryDeletionListener(repo).onUserDeleted(USER); + assertThat(repo.deletedFor).containsExactly(USER); + } + + // -- Helpers ----------------------------------------------------------------------------- + + private static UserDeletionListener recording(String name, List calls) { + return new UserDeletionListener() { + @Override + public void onUserDeleted(UserHandle userHandle) { + calls.add(name); + } + + @Override + public String name() { + return name; + } + }; + } + + private static final class StubCredentialRepository implements CredentialRepository { + final List deletedFor = new ArrayList<>(); + + @Override + public void save(CredentialRecord record) {} + + @Override + public Optional findByCredentialId(CredentialId credentialId) { + return Optional.empty(); + } + + @Override + public List findByUserHandle(UserHandle userHandle) { + return List.of(); + } + + @Override + public void updateSignCount(CredentialId credentialId, long newCount, Instant lastUsedAt) {} + + @Override + public void updateLabel(CredentialId credentialId, String label) {} + + @Override + public void delete(CredentialId credentialId) {} + + @Override + public int deleteByUserHandle(UserHandle userHandle) { + deletedFor.add(userHandle); + return 0; + } + } + + private static final class StubBackupCodeRepository implements BackupCodeRepository { + final List deletedFor = new ArrayList<>(); + + @Override + public void save(StoredBackupCode code) {} + + @Override + public List findByUserHandle(UserHandle userHandle) { + return List.of(); + } + + @Override + public boolean consume(UserHandle userHandle, String codeId, Instant consumedAt) { + return false; + } + + @Override + public void deleteByUserHandle(UserHandle userHandle) { + deletedFor.add(userHandle); + } + + @Override + public void replaceAll(UserHandle userHandle, List records) {} + } + + private static final class StubOtpRepository implements OtpRepository { + final List deletedFor = new ArrayList<>(); + + @Override + public void save(StoredOtp otp) {} + + @Override + public Optional findLatestActive(UserHandle userHandle, String phoneE164) { + return Optional.empty(); + } + + @Override + public OptionalInt incrementAttempts(UserHandle userHandle, String otpId) { + return OptionalInt.empty(); + } + + @Override + public boolean consume(UserHandle userHandle, String otpId) { + return false; + } + + @Override + public int countSince(UserHandle userHandle, String phoneE164, Instant since) { + return 0; + } + + @Override + public int deleteByUserHandle(UserHandle userHandle) { + deletedFor.add(userHandle); + return 0; + } + } +} diff --git a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/AltFlowsModule.java b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/AltFlowsModule.java index 1de3bec..f43c00f 100644 --- a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/AltFlowsModule.java +++ b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/AltFlowsModule.java @@ -10,6 +10,9 @@ import com.codeheadsystems.pkauth.dropwizard.config.PkAuthConfig; import com.codeheadsystems.pkauth.jwt.PkAuthJwtIssuer; import com.codeheadsystems.pkauth.jwt.PkAuthJwtValidator; +import com.codeheadsystems.pkauth.lifecycle.BackupCodeRepositoryDeletionListener; +import com.codeheadsystems.pkauth.lifecycle.OtpRepositoryDeletionListener; +import com.codeheadsystems.pkauth.lifecycle.UserDeletionListener; import com.codeheadsystems.pkauth.magiclink.EmailSender; import com.codeheadsystems.pkauth.magiclink.LoggingEmailSender; import com.codeheadsystems.pkauth.magiclink.MagicLinkService; @@ -23,6 +26,7 @@ import com.codeheadsystems.pkauth.spi.UserLookup; import dagger.Module; import dagger.Provides; +import dagger.multibindings.IntoSet; import jakarta.inject.Singleton; import java.util.Objects; import org.jspecify.annotations.Nullable; @@ -210,6 +214,22 @@ PkAuthAdminResource provideAdminResource(AdminService adminService) { return new PkAuthAdminResource(adminService); } + /** User-deletion listener for backup codes — only present in the full component. */ + @Provides + @Singleton + @IntoSet + UserDeletionListener provideBackupCodeDeletionListener(BackupCodeRepository repo) { + return new BackupCodeRepositoryDeletionListener(repo); + } + + /** User-deletion listener for OTPs — only present in the full component. */ + @Provides + @Singleton + @IntoSet + UserDeletionListener provideOtpDeletionListener(OtpRepository repo) { + return new OtpRepositoryDeletionListener(repo); + } + /** * Host-supplied tunables that do not live in {@link PkAuthConfig} (they're framework-level * collaborators, not YAML-bindable values). 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 4eef9ee..60ae9c4 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 @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT package com.codeheadsystems.pkauth.dropwizard.dagger; +import com.codeheadsystems.pkauth.jwt.AccessTokenStore; import com.codeheadsystems.pkauth.spi.BackupCodeRepository; import com.codeheadsystems.pkauth.spi.ChallengeStore; import com.codeheadsystems.pkauth.spi.CredentialRepository; @@ -25,6 +26,7 @@ public final class PersistenceBindings { private final ChallengeStore challengeStore; @Nullable private final BackupCodeRepository backupCodeRepository; @Nullable private final OtpRepository otpRepository; + private final AccessTokenStore accessTokenStore; private PersistenceBindings(Builder b) { this.credentialRepository = @@ -33,6 +35,8 @@ private PersistenceBindings(Builder b) { this.challengeStore = Objects.requireNonNull(b.challengeStore, "challengeStore"); this.backupCodeRepository = b.backupCodeRepository; this.otpRepository = b.otpRepository; + this.accessTokenStore = + b.accessTokenStore == null ? AccessTokenStore.noop() : b.accessTokenStore; } public static Builder builder() { @@ -61,6 +65,18 @@ public OtpRepository otpRepository() { return otpRepository; } + /** + * Returns the configured {@link AccessTokenStore}. Defaults to {@link AccessTokenStore#noop()} — + * stateless JWT behaviour. Hosts that want server-side access-token revocation supply a real + * store (e.g. {@code JdbiAccessTokenStore}) via {@link + * Builder#accessTokenStore(AccessTokenStore)}. + * + * @since 1.1.0 + */ + public AccessTokenStore accessTokenStore() { + return accessTokenStore; + } + /** Builder. */ public static final class Builder { @Nullable private CredentialRepository credentialRepository; @@ -68,6 +84,7 @@ public static final class Builder { @Nullable private ChallengeStore challengeStore; @Nullable private BackupCodeRepository backupCodeRepository; @Nullable private OtpRepository otpRepository; + @Nullable private AccessTokenStore accessTokenStore; private Builder() {} @@ -96,6 +113,17 @@ public Builder otpRepository(OtpRepository v) { return this; } + /** + * Supplies a real {@link AccessTokenStore} for stateful access-token mode. Omit (or pass null) + * to keep stateless JWT behaviour via {@link AccessTokenStore#noop()}. + * + * @since 1.1.0 + */ + public Builder accessTokenStore(AccessTokenStore v) { + this.accessTokenStore = v; + return this; + } + public PersistenceBindings build() { return new PersistenceBindings(this); } 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 6fda496..cfba502 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 @@ -9,12 +9,17 @@ import com.codeheadsystems.pkauth.dropwizard.auth.PkAuthDropwizardAuthenticator; import com.codeheadsystems.pkauth.dropwizard.config.PkAuthConfig; import com.codeheadsystems.pkauth.dropwizard.resource.PkAuthCeremonyResource; +import com.codeheadsystems.pkauth.jwt.AccessTokenStore; +import com.codeheadsystems.pkauth.jwt.AccessTokenStoreDeletionListener; import com.codeheadsystems.pkauth.jwt.CeremonyOrchestrator; import com.codeheadsystems.pkauth.jwt.JwtConfig; import com.codeheadsystems.pkauth.jwt.JwtKeyset; import com.codeheadsystems.pkauth.jwt.PkAuthJwtIssuer; import com.codeheadsystems.pkauth.jwt.PkAuthJwtValidator; import com.codeheadsystems.pkauth.jwt.TokenTtlPolicy; +import com.codeheadsystems.pkauth.lifecycle.CredentialRepositoryDeletionListener; +import com.codeheadsystems.pkauth.lifecycle.UserDeletionListener; +import com.codeheadsystems.pkauth.lifecycle.UserDeletionService; import com.codeheadsystems.pkauth.spi.CeremonyRateLimiter; import com.codeheadsystems.pkauth.spi.ChallengeStore; import com.codeheadsystems.pkauth.spi.ClockProvider; @@ -22,6 +27,7 @@ import com.codeheadsystems.pkauth.spi.UserLookup; import dagger.Module; import dagger.Provides; +import dagger.multibindings.IntoSet; import jakarta.inject.Singleton; import java.time.Duration; import java.util.Map; @@ -160,14 +166,43 @@ JwtKeyset provideJwtKeyset(PkAuthConfig cfg) { @Provides @Singleton - PkAuthJwtIssuer provideJwtIssuer(JwtConfig cfg, JwtKeyset ks, ClockProvider clock) { - return new PkAuthJwtIssuer(cfg, ks, clock); + AccessTokenStore provideAccessTokenStore() { + return persistence.accessTokenStore(); } @Provides @Singleton - PkAuthJwtValidator provideJwtValidator(JwtConfig cfg, JwtKeyset ks, ClockProvider clock) { - return new PkAuthJwtValidator(cfg, ks, clock); + PkAuthJwtIssuer provideJwtIssuer( + JwtConfig cfg, JwtKeyset ks, ClockProvider clock, AccessTokenStore accessTokenStore) { + return new PkAuthJwtIssuer(cfg, ks, clock, accessTokenStore); + } + + @Provides + @Singleton + PkAuthJwtValidator provideJwtValidator( + JwtConfig cfg, JwtKeyset ks, ClockProvider clock, AccessTokenStore accessTokenStore) { + return new PkAuthJwtValidator( + cfg, ks, clock, com.codeheadsystems.pkauth.jwt.RevocationCheck.allow(), accessTokenStore); + } + + @Provides + @Singleton + @IntoSet + UserDeletionListener provideCredentialDeletionListener(CredentialRepository repo) { + return new CredentialRepositoryDeletionListener(repo); + } + + @Provides + @Singleton + @IntoSet + UserDeletionListener provideAccessTokenStoreDeletionListener(AccessTokenStore store) { + return new AccessTokenStoreDeletionListener(store); + } + + @Provides + @Singleton + UserDeletionService provideUserDeletionService(Set listeners) { + return new UserDeletionService(listeners); } @Provides diff --git a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/AccessTokenStore.java b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/AccessTokenStore.java new file mode 100644 index 0000000..f2a7bfc --- /dev/null +++ b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/AccessTokenStore.java @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.jwt; + +import com.codeheadsystems.pkauth.api.UserHandle; +import java.time.Instant; +import java.util.Optional; + +/** + * SPI for persisting issued JWT JTIs ("stateful access tokens"). Bind a real implementation when + * the host needs server-side revocation of access tokens — e.g. an immediate logout that rejects + * the bearer's still-unexpired JWT, or a "log out everywhere" admin action. + * + *

{@link PkAuthJwtIssuer#issue(JwtClaims)} calls {@link #record record} for every issued token; + * {@link PkAuthJwtValidator#validate(String)} calls {@link #exists exists} after signature and + * claim checks. A return of {@code false} from {@code exists} yields {@link + * JwtVerificationResult.Revoked}. The default {@link #noop()} implementation accepts every JTI and + * persists nothing, preserving stateless-JWT behaviour for hosts that want it. + * + *

Contrast with {@link RevocationCheck}: that SPI is a fast deny-list ("is this jti blocked?"). + * {@code AccessTokenStore} is the inverse — a positive allow-list ("did we issue this jti and has + * it not been deleted?"). The two coexist intentionally: + * + *

    + *
  • Stateless mode — no store bound (i.e. {@link #noop()} active). Tokens are valid + * until {@code exp}. Hosts that want lightweight invalidation use {@link RevocationCheck} to + * consult a small in-memory deny-list of revoked jtis. + *
  • Stateful mode — a real store is bound. Every issued token has a row; deleting the + * row (e.g. on logout) immediately invalidates the bearer. + *
+ * + *

Implementations must be safe to call from many threads concurrently. Failures from {@link + * #record} must propagate so issuance fails — partial state is unacceptable. See ADR 0015. + * + * @since 1.1.0 + */ +public interface AccessTokenStore { + + /** + * Persists a freshly-issued token. Called by {@link PkAuthJwtIssuer#issue(JwtClaims)} after the + * JWT has been signed and before the wire token is returned to the caller. If this method throws, + * the issuer propagates the exception and no token is returned. + * + * @param jti the JWT id claim (unique per issued token; {@link PkAuthJwtIssuer} generates a + * random UUID) + * @param userHandle owning user + * @param audience the {@code aud} claim of the issued token (post per-audience-TTL dispatch) + * @param deviceId optional device identifier for hosts that bind tokens to physical devices + * @param issuedAt the {@code iat} value + * @param expiresAt the {@code exp} value; implementations may use this to prune rows whose access + * window has elapsed + */ + void record( + String jti, + UserHandle userHandle, + String audience, + Optional deviceId, + Instant issuedAt, + Instant expiresAt); + + /** + * Returns {@code true} iff a row for the given {@code jti} exists in the store. Called by {@link + * PkAuthJwtValidator#validate(String)} after signature, issuer, audience, and skew checks have + * passed. + * + *

The {@link #noop()} implementation returns {@code true} unconditionally so stateless + * deployments behave as if no store were involved. + */ + boolean exists(String jti); + + /** + * Removes the row for the given {@code jti}, idempotently. Returns {@code true} iff a row was + * deleted. + */ + boolean delete(String jti); + + /** + * Removes every row for the supplied user. Called by {@link + * com.codeheadsystems.pkauth.lifecycle.UserDeletionService} during user-deletion fan-out. + * + * @return how many rows were deleted (best-effort; useful for structured logging) + */ + int deleteAllForUser(UserHandle userHandle); + + /** + * Operator cleanup hook: removes rows whose {@code expires_at} is strictly less than {@code + * before}. Stateful access tokens have a natural pruning window — once {@code exp} has passed, + * the bearer is rejected on the {@code exp} check anyway, so the row is no longer load-bearing. + * Schedule this as a periodic job; see {@code docs/operator-guide.md}. + */ + int deleteExpiredBefore(Instant before); + + /** + * Returns a no-op store: {@link #record} discards, {@link #exists} returns {@code true} for every + * jti, and the delete/cleanup methods all return zero. This is the default binding when the host + * has not supplied a real implementation — issuance and validation behave as if no server-side + * state existed. + */ + static AccessTokenStore noop() { + return NoopAccessTokenStore.INSTANCE; + } +} diff --git a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/AccessTokenStoreDeletionListener.java b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/AccessTokenStoreDeletionListener.java new file mode 100644 index 0000000..c7781f9 --- /dev/null +++ b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/AccessTokenStoreDeletionListener.java @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.jwt; + +import com.codeheadsystems.pkauth.api.UserHandle; +import com.codeheadsystems.pkauth.lifecycle.UserDeletionListener; +import java.util.Objects; + +/** + * Bridges {@link AccessTokenStore#deleteAllForUser(UserHandle)} into the {@link + * com.codeheadsystems.pkauth.lifecycle.UserDeletionService} fan-out. Lives in {@code pk-auth-jwt} + * because the SPI does — {@code pk-auth-core} cannot depend on {@code pk-auth-jwt}. + * + *

Adapter modules register this listener whenever an {@link AccessTokenStore} bean is present + * (which is always, since the default is {@link AccessTokenStore#noop()}); the noop store's {@code + * deleteAllForUser} simply returns zero, so the listener is harmless in stateless deployments. + * + * @since 1.1.0 + */ +public final class AccessTokenStoreDeletionListener implements UserDeletionListener { + + private final AccessTokenStore store; + + public AccessTokenStoreDeletionListener(AccessTokenStore store) { + this.store = Objects.requireNonNull(store, "store"); + } + + @Override + public void onUserDeleted(UserHandle userHandle) { + store.deleteAllForUser(userHandle); + } +} diff --git a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/NoopAccessTokenStore.java b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/NoopAccessTokenStore.java new file mode 100644 index 0000000..3f5f14d --- /dev/null +++ b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/NoopAccessTokenStore.java @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.jwt; + +import com.codeheadsystems.pkauth.api.UserHandle; +import java.time.Instant; +import java.util.Optional; + +/** + * Default {@link AccessTokenStore} that persists nothing and accepts every jti. Hosts that wire a + * real store override this binding. The class is package-private — clients reach it via {@link + * AccessTokenStore#noop()}. + */ +final class NoopAccessTokenStore implements AccessTokenStore { + + static final NoopAccessTokenStore INSTANCE = new NoopAccessTokenStore(); + + private NoopAccessTokenStore() {} + + @Override + public void record( + String jti, + UserHandle userHandle, + String audience, + Optional deviceId, + Instant issuedAt, + Instant expiresAt) { + // intentionally empty + } + + @Override + public boolean exists(String jti) { + return true; + } + + @Override + public boolean delete(String jti) { + return false; + } + + @Override + public int deleteAllForUser(UserHandle userHandle) { + return 0; + } + + @Override + public int deleteExpiredBefore(Instant before) { + return 0; + } + + @Override + public String toString() { + return "AccessTokenStore.noop()"; + } +} diff --git a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtIssuer.java b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtIssuer.java index 99e11cf..fed1c86 100644 --- a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtIssuer.java +++ b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtIssuer.java @@ -12,11 +12,16 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.UUID; /** * Issues pk-auth JWTs. Maps {@link JwtClaims} onto the standard {@code iss/sub/aud/iat/nbf/exp/jti} * claims plus {@code pkauth.method}, {@code pkauth.cred}, {@code pkauth.amr}. + * + *

If a non-noop {@link AccessTokenStore} is bound, each issued token's jti is persisted in the + * store before {@link #issue(JwtClaims)} returns. A store failure propagates — partial state + * (signed token returned but unrecorded) is intentionally not tolerated. */ public final class PkAuthJwtIssuer { @@ -34,31 +39,61 @@ public final class PkAuthJwtIssuer { private final JwtConfig config; private final JwtKeyset keyset; private final ClockProvider clockProvider; + private final AccessTokenStore accessTokenStore; + /** + * Constructs an issuer with a no-op access-token store (stateless mode). Equivalent to {@code new + * PkAuthJwtIssuer(config, keyset, clockProvider, AccessTokenStore.noop())}. + */ public PkAuthJwtIssuer(JwtConfig config, JwtKeyset keyset, ClockProvider clockProvider) { + this(config, keyset, clockProvider, AccessTokenStore.noop()); + } + + /** + * Constructs an issuer that records each issued JTI through the supplied {@link + * AccessTokenStore}. Use this constructor when the host needs server-side revocation of access + * tokens; pair with a {@link PkAuthJwtValidator} configured with the same store so {@link + * AccessTokenStore#exists(String)} is consulted at validation time. + * + * @since 1.1.0 + */ + public PkAuthJwtIssuer( + JwtConfig config, + JwtKeyset keyset, + ClockProvider clockProvider, + AccessTokenStore accessTokenStore) { this.config = Objects.requireNonNull(config, "config"); this.keyset = Objects.requireNonNull(keyset, "keyset"); this.clockProvider = Objects.requireNonNull(clockProvider, "clockProvider"); + this.accessTokenStore = Objects.requireNonNull(accessTokenStore, "accessTokenStore"); } /** * Issues a signed JWT for the supplied claims. The {@code aud} claim is taken from {@link * JwtClaims#audience()} when set, falling back to {@link JwtConfig#defaultAudience()}; the * access-token TTL is then looked up via {@link JwtConfig#ttlPolicy()} keyed by that audience. + * + *

The issued jti is recorded in the configured {@link AccessTokenStore} before the + * wire token is returned. If recording fails, the exception propagates and no token is surfaced + * to the caller — the host must retry or surface the failure upstream. */ public String issue(JwtClaims claims) { Objects.requireNonNull(claims, "claims"); Instant now = clockProvider.now(); + Instant nbf = now.minus(config.notBeforeSkew()); String audience = claims.audience() != null ? claims.audience() : config.defaultAudience(); + Instant exp = now.plus(config.ttlPolicy().accessTtl(audience)); + String jti = UUID.randomUUID().toString(); + JWTClaimsSet.Builder body = new JWTClaimsSet.Builder() .issuer(config.issuer()) .audience(audience) .subject(Base64Url.encode(claims.userHandle().value())) .issueTime(Date.from(now)) - .notBeforeTime(Date.from(now.minus(config.notBeforeSkew()))) - .expirationTime(Date.from(now.plus(config.ttlPolicy().accessTtl(audience)))) - .jwtID(UUID.randomUUID().toString()) + .notBeforeTime(Date.from(nbf)) + .expirationTime(Date.from(exp)) + .jwtID(jti) .claim(CLAIM_METHOD, claims.method().wireValue()) .claim(CLAIM_AMR, List.copyOf(claims.amr())); @@ -81,6 +116,12 @@ public String issue(JwtClaims claims) { } catch (JOSEException e) { throw new IllegalStateException("Failed to sign JWT", e); } + + // Persist BEFORE returning — a recorded-after-return order would let a malicious caller use the + // token before the store knew about it (the validator would call exists() and see false). For + // the noop store this is free; for a real store the call must succeed or issuance fails. + accessTokenStore.record(jti, claims.userHandle(), audience, Optional.empty(), now, exp); + return jwt.serialize(); } } diff --git a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtValidator.java b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtValidator.java index d116729..d2584fa 100644 --- a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtValidator.java +++ b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtValidator.java @@ -44,28 +44,50 @@ public final class PkAuthJwtValidator { private final JwtKeyset keyset; private final ClockProvider clockProvider; private final RevocationCheck revocationCheck; + private final AccessTokenStore accessTokenStore; /** - * Constructs a validator with no-op revocation (tokens are valid until {@code exp}). Equivalent - * to {@code new PkAuthJwtValidator(config, keyset, clockProvider, RevocationCheck.allow())}. + * Constructs a validator with no-op revocation and no-op access-token store (tokens are valid + * until {@code exp}). Equivalent to {@code new PkAuthJwtValidator(config, keyset, clockProvider, + * RevocationCheck.allow(), AccessTokenStore.noop())}. */ public PkAuthJwtValidator(JwtConfig config, JwtKeyset keyset, ClockProvider clockProvider) { - this(config, keyset, clockProvider, RevocationCheck.allow()); + this(config, keyset, clockProvider, RevocationCheck.allow(), AccessTokenStore.noop()); } /** - * Constructs a validator with a custom {@link RevocationCheck}. Use this constructor when you - * need early token invalidation backed by your application's datastore. + * Constructs a validator with a custom {@link RevocationCheck} and the default no-op {@link + * AccessTokenStore}. Use this constructor when you need lightweight deny-list invalidation + * without persisting every issued JTI. */ public PkAuthJwtValidator( JwtConfig config, JwtKeyset keyset, ClockProvider clockProvider, RevocationCheck revocationCheck) { + this(config, keyset, clockProvider, revocationCheck, AccessTokenStore.noop()); + } + + /** + * Constructs a validator with both a custom {@link RevocationCheck} and a custom {@link + * AccessTokenStore}. The store is consulted on every {@link #validate(String)} call after + * signature and standard-claim checks; an absent jti yields {@link + * JwtVerificationResult.Revoked}. Wire the same store on the matching {@link PkAuthJwtIssuer} so + * issued JTIs are recorded. + * + * @since 1.1.0 + */ + public PkAuthJwtValidator( + JwtConfig config, + JwtKeyset keyset, + ClockProvider clockProvider, + RevocationCheck revocationCheck, + AccessTokenStore accessTokenStore) { this.config = Objects.requireNonNull(config, "config"); this.keyset = Objects.requireNonNull(keyset, "keyset"); this.clockProvider = Objects.requireNonNull(clockProvider, "clockProvider"); this.revocationCheck = Objects.requireNonNull(revocationCheck, "revocationCheck"); + this.accessTokenStore = Objects.requireNonNull(accessTokenStore, "accessTokenStore"); } /** Verifies signature and standard claims, then reconstructs a {@link JwtClaims}. */ @@ -145,6 +167,11 @@ public JwtVerificationResult validate(String token) { if (revocationCheck.isRevoked(jti, subject)) { return new JwtVerificationResult.Revoked(jti, subject); } + if (jti != null && !accessTokenStore.exists(jti)) { + // Stateful mode: the token's jti is not in the store (logout / admin-revoke / never + // recorded). The noop store always returns true so stateless deployments skip this branch. + return new JwtVerificationResult.Revoked(jti, subject); + } String methodClaim = stringClaim(body, PkAuthJwtIssuer.CLAIM_METHOD); if (methodClaim == null) { diff --git a/pk-auth-jwt/src/test/java/com/codeheadsystems/pkauth/jwt/AccessTokenStoreIntegrationTest.java b/pk-auth-jwt/src/test/java/com/codeheadsystems/pkauth/jwt/AccessTokenStoreIntegrationTest.java new file mode 100644 index 0000000..5ea47f3 --- /dev/null +++ b/pk-auth-jwt/src/test/java/com/codeheadsystems/pkauth/jwt/AccessTokenStoreIntegrationTest.java @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.jwt; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.codeheadsystems.pkauth.api.UserHandle; +import com.codeheadsystems.pkauth.spi.ClockProvider; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.Test; + +/** Wiring tests for {@link AccessTokenStore} integration with the issuer and validator. */ +class AccessTokenStoreIntegrationTest { + + private static final Instant NOW = Instant.parse("2026-05-16T12:00:00Z"); + private static final String ISSUER = "https://pkauth.example.com"; + private static final String AUDIENCE = "api.example.com"; + + @Test + void noopStoreAcceptsEveryJtiAndPersistsNothing() { + AccessTokenStore store = AccessTokenStore.noop(); + assertThat(store.exists("anything")).isTrue(); + assertThat(store.delete("anything")).isFalse(); + assertThat(store.deleteAllForUser(UserHandle.of(new byte[] {1}))).isZero(); + assertThat(store.deleteExpiredBefore(NOW)).isZero(); + // record is a true no-op — accepts any inputs without throwing. + store.record( + "jti-X", + UserHandle.of(new byte[] {1}), + AUDIENCE, + Optional.empty(), + NOW, + NOW.plusSeconds(60)); + assertThat(store.toString()).contains("noop"); + } + + @Test + void issuedTokenIsRecordedInStatefulStore() { + JwtKeyset keyset = JwtKeyset.hs256(randomBytes(32)); + JwtConfig config = JwtConfig.defaults(ISSUER, AUDIENCE); + RecordingStore store = new RecordingStore(); + + PkAuthJwtIssuer issuer = new PkAuthJwtIssuer(config, keyset, fixedClock(NOW), store); + String token = + issuer.issue(JwtClaims.forBackupCode(UserHandle.of(new byte[] {7}), List.of("user"))); + + assertThat(store.recorded).hasSize(1); + RecordingStore.Row row = store.recorded.values().iterator().next(); + assertThat(row.jti).isNotBlank(); + assertThat(row.audience).isEqualTo(AUDIENCE); + assertThat(row.userHandle).isEqualTo(UserHandle.of(new byte[] {7})); + assertThat(token).isNotBlank(); + } + + @Test + void validatorRejectsJtiAbsentFromStatefulStore() { + JwtKeyset keyset = JwtKeyset.hs256(randomBytes(32)); + JwtConfig config = JwtConfig.defaults(ISSUER, AUDIENCE); + + // Issue with a recording store (so record() fires) but validate against an empty store. + PkAuthJwtIssuer issuer = + new PkAuthJwtIssuer(config, keyset, fixedClock(NOW), new RecordingStore()); + String token = + issuer.issue(JwtClaims.forBackupCode(UserHandle.of(new byte[] {1}), List.of("user"))); + + PkAuthJwtValidator validator = + new PkAuthJwtValidator( + config, keyset, fixedClock(NOW), RevocationCheck.allow(), new EmptyStore()); + + JwtVerificationResult result = validator.validate(token); + assertThat(result).isInstanceOf(JwtVerificationResult.Revoked.class); + } + + @Test + void validatorAcceptsJtiPresentInStatefulStore() { + JwtKeyset keyset = JwtKeyset.hs256(randomBytes(32)); + JwtConfig config = JwtConfig.defaults(ISSUER, AUDIENCE); + RecordingStore store = new RecordingStore(); + + PkAuthJwtIssuer issuer = new PkAuthJwtIssuer(config, keyset, fixedClock(NOW), store); + String token = + issuer.issue(JwtClaims.forBackupCode(UserHandle.of(new byte[] {2}), List.of("user"))); + + PkAuthJwtValidator validator = + new PkAuthJwtValidator(config, keyset, fixedClock(NOW), RevocationCheck.allow(), store); + assertThat(validator.validate(token)).isInstanceOf(JwtVerificationResult.Success.class); + } + + @Test + void deletionListenerForwardsToStore() { + RecordingStore store = new RecordingStore(); + store.recorded.put("a", new RecordingStore.Row("a", UserHandle.of(new byte[] {9}), AUDIENCE)); + store.recorded.put("b", new RecordingStore.Row("b", UserHandle.of(new byte[] {9}), AUDIENCE)); + store.recorded.put("c", new RecordingStore.Row("c", UserHandle.of(new byte[] {8}), AUDIENCE)); + + AccessTokenStoreDeletionListener listener = new AccessTokenStoreDeletionListener(store); + listener.onUserDeleted(UserHandle.of(new byte[] {9})); + + assertThat(store.recorded.keySet()).containsExactly("c"); + } + + // -- Helpers ---------------------------------------------------------------------------- + + private static byte[] randomBytes(int len) { + byte[] out = new byte[len]; + new SecureRandom().nextBytes(out); + return out; + } + + private static ClockProvider fixedClock(Instant instant) { + return ClockProvider.fromClock(Clock.fixed(instant, ZoneOffset.UTC)); + } + + /** Records every {@code record(...)} call in an ordered map; supports lookup and bulk delete. */ + private static final class RecordingStore implements AccessTokenStore { + record Row(String jti, UserHandle userHandle, String audience) {} + + final Map recorded = new HashMap<>(); + + @Override + public void record( + String jti, + UserHandle userHandle, + String audience, + Optional deviceId, + Instant issuedAt, + Instant expiresAt) { + recorded.put(jti, new Row(jti, userHandle, audience)); + } + + @Override + public boolean exists(String jti) { + return recorded.containsKey(jti); + } + + @Override + public boolean delete(String jti) { + return recorded.remove(jti) != null; + } + + @Override + public int deleteAllForUser(UserHandle userHandle) { + List toRemove = new ArrayList<>(); + for (Row r : recorded.values()) { + if (r.userHandle.equals(userHandle)) { + toRemove.add(r.jti); + } + } + toRemove.forEach(recorded::remove); + return toRemove.size(); + } + + @Override + public int deleteExpiredBefore(Instant before) { + return 0; + } + } + + /** Store that never sees any record but is queried by the validator. */ + private static final class EmptyStore implements AccessTokenStore { + @Override + public void record( + String jti, + UserHandle userHandle, + String audience, + Optional deviceId, + Instant issuedAt, + Instant expiresAt) {} + + @Override + public boolean exists(String jti) { + return false; + } + + @Override + public boolean delete(String jti) { + return false; + } + + @Override + public int deleteAllForUser(UserHandle userHandle) { + return 0; + } + + @Override + public int deleteExpiredBefore(Instant before) { + return 0; + } + } + + @SuppressWarnings("unused") + private static Set dummy() { + return Set.of(); + } +} 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 c9fba97..a289d0f 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 @@ -8,12 +8,20 @@ import com.codeheadsystems.pkauth.ceremony.PasskeyAuthenticationService; import com.codeheadsystems.pkauth.config.CeremonyConfig; import com.codeheadsystems.pkauth.config.RelyingPartyConfig; +import com.codeheadsystems.pkauth.jwt.AccessTokenStore; +import com.codeheadsystems.pkauth.jwt.AccessTokenStoreDeletionListener; import com.codeheadsystems.pkauth.jwt.JwtConfig; import com.codeheadsystems.pkauth.jwt.JwtKeyset; import com.codeheadsystems.pkauth.jwt.JwtSecretResolver; import com.codeheadsystems.pkauth.jwt.PkAuthJwtIssuer; import com.codeheadsystems.pkauth.jwt.PkAuthJwtValidator; +import com.codeheadsystems.pkauth.jwt.RevocationCheck; import com.codeheadsystems.pkauth.jwt.TokenTtlPolicy; +import com.codeheadsystems.pkauth.lifecycle.BackupCodeRepositoryDeletionListener; +import com.codeheadsystems.pkauth.lifecycle.CredentialRepositoryDeletionListener; +import com.codeheadsystems.pkauth.lifecycle.OtpRepositoryDeletionListener; +import com.codeheadsystems.pkauth.lifecycle.UserDeletionListener; +import com.codeheadsystems.pkauth.lifecycle.UserDeletionService; import com.codeheadsystems.pkauth.magiclink.EmailSender; import com.codeheadsystems.pkauth.magiclink.LoggingEmailSender; import com.codeheadsystems.pkauth.magiclink.MagicLinkService; @@ -32,6 +40,8 @@ import io.micronaut.context.annotation.Requires; import jakarta.inject.Singleton; import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Set; @@ -110,9 +120,21 @@ JwtKeyset jwtKeyset(PkAuthConfiguration config) { return JwtSecretResolver.resolveHs256Keyset(config.getJwt().getSecret()); } + /** + * Default no-op {@link AccessTokenStore}. Hosts wanting stateful access tokens replace this bean + * by declaring their own {@code @Singleton AccessTokenStore} in their factory. + * + * @since 1.1.0 + */ + @Singleton + AccessTokenStore accessTokenStore() { + return AccessTokenStore.noop(); + } + @Singleton - PkAuthJwtIssuer jwtIssuer(JwtConfig cfg, JwtKeyset keyset, ClockProvider clock) { - return new PkAuthJwtIssuer(cfg, keyset, clock); + PkAuthJwtIssuer jwtIssuer( + JwtConfig cfg, JwtKeyset keyset, ClockProvider clock, AccessTokenStore accessTokenStore) { + return new PkAuthJwtIssuer(cfg, keyset, clock, accessTokenStore); } /** @@ -131,8 +153,36 @@ com.codeheadsystems.pkauth.jwt.CeremonyOrchestrator ceremonyOrchestrator( } @Singleton - PkAuthJwtValidator jwtValidator(JwtConfig cfg, JwtKeyset keyset, ClockProvider clock) { - return new PkAuthJwtValidator(cfg, keyset, clock); + PkAuthJwtValidator jwtValidator( + JwtConfig cfg, JwtKeyset keyset, ClockProvider clock, AccessTokenStore accessTokenStore) { + return new PkAuthJwtValidator(cfg, keyset, clock, RevocationCheck.allow(), accessTokenStore); + } + + // -- User deletion fan-out --------------------------------------------------------------- + + @Singleton + UserDeletionListener credentialDeletionListener(CredentialRepository repo) { + return new CredentialRepositoryDeletionListener(repo); + } + + @Singleton + UserDeletionListener backupCodeDeletionListener(BackupCodeRepository repo) { + return new BackupCodeRepositoryDeletionListener(repo); + } + + @Singleton + UserDeletionListener otpDeletionListener(OtpRepository repo) { + return new OtpRepositoryDeletionListener(repo); + } + + @Singleton + UserDeletionListener accessTokenStoreDeletionListener(AccessTokenStore store) { + return new AccessTokenStoreDeletionListener(store); + } + + @Singleton + UserDeletionService userDeletionService(Collection listeners) { + return new UserDeletionService(new ArrayList<>(listeners)); } /** diff --git a/pk-auth-persistence-dynamodb/build.gradle.kts b/pk-auth-persistence-dynamodb/build.gradle.kts index e973075..a1bc479 100644 --- a/pk-auth-persistence-dynamodb/build.gradle.kts +++ b/pk-auth-persistence-dynamodb/build.gradle.kts @@ -14,6 +14,8 @@ tasks.named("compileJava") { dependencies { api(project(":pk-auth-core")) + // AccessTokenStore lives in pk-auth-jwt; DynamoDbAccessTokenStore implements it. + api(project(":pk-auth-jwt")) 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/AccessTokenItem.java b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/AccessTokenItem.java new file mode 100644 index 0000000..217bfb8 --- /dev/null +++ b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/AccessTokenItem.java @@ -0,0 +1,113 @@ +// 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 stateful access-token rows (per ADR 0015). Each issued JTI lives at two + * pk/sk addresses: + * + *

    + *
  • Primary: {@code pk = "AT#"}, {@code sk = "AT#"} — fast lookup by jti for {@code + * exists} and {@code delete}. + *
  • User-index: {@code pk = "USER#"}, {@code sk = "AT#"} — fan-out path + * for {@code deleteAllForUser} alongside this user's other state. + *
+ * + *

The {@code ttl} attribute is set to {@code expiresAt.epochSecond}, so DynamoDB's native TTL + * sweep eventually removes expired rows. Synchronous pruning via {@code deleteExpiredBefore} + * remains available for tests and operator-triggered cleanup that needs predictable timing. + * + * @since 1.1.0 + */ +@DynamoDbBean +public class AccessTokenItem { + + private String pk; + private String sk; + private String jti; + private String userHandleB64u; + private String audience; + private String deviceId; + private String issuedAtIso; + private String expiresAtIso; + private Long ttl; + + public AccessTokenItem() {} + + @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 getJti() { + return jti; + } + + public void setJti(String jti) { + this.jti = jti; + } + + 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 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 Long getTtl() { + return ttl; + } + + public void setTtl(Long ttl) { + this.ttl = ttl; + } +} 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 new file mode 100644 index 0000000..eca81c0 --- /dev/null +++ b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbAccessTokenStore.java @@ -0,0 +1,223 @@ +// 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.jwt.AccessTokenStore; +import com.codeheadsystems.pkauth.spi.PkAuthPersistenceException; +import java.time.Instant; +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.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; + +/** + * {@link AccessTokenStore} backed by the {@code PkAuthCore} single table. Each issued JTI is + * persisted as two items: a primary item keyed by jti (fast {@code exists} / {@code delete}) and a + * user-indexed item (fast {@code deleteAllForUser}). DynamoDB's native TTL on the {@code ttl} + * attribute prunes expired rows in the background; {@link #deleteExpiredBefore(Instant)} provides + * synchronous cleanup for tests and operator workflows. + * + *

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. + * + * @since 1.1.0 + */ +public final class DynamoDbAccessTokenStore implements AccessTokenStore { + + private final DynamoDbTable table; + + public DynamoDbAccessTokenStore(DynamoDbEnhancedClient enhanced, PkAuthDynamoTables tables) { + Objects.requireNonNull(enhanced, "enhanced"); + Objects.requireNonNull(tables, "tables"); + this.table = enhanced.table(tables.core(), TableSchema.fromBean(AccessTokenItem.class)); + } + + @Override + public void record( + String jti, + UserHandle userHandle, + String audience, + Optional deviceId, + Instant issuedAt, + Instant expiresAt) { + Objects.requireNonNull(jti, "jti"); + Objects.requireNonNull(userHandle, "userHandle"); + Objects.requireNonNull(audience, "audience"); + Objects.requireNonNull(deviceId, "deviceId"); + Objects.requireNonNull(issuedAt, "issuedAt"); + Objects.requireNonNull(expiresAt, "expiresAt"); + wrap( + "access_tokens.record", + () -> { + String userB64 = Base64Url.encode(userHandle.value()); + long ttl = expiresAt.getEpochSecond(); + // Primary item (jti-keyed) — the load-bearing one for exists/validate. + table.putItem( + buildItem( + "AT#" + jti, + "AT#" + jti, + jti, + userB64, + audience, + deviceId.orElse(null), + issuedAt, + expiresAt, + ttl)); + // User-index item — for deleteAllForUser fan-out. + table.putItem( + buildItem( + "USER#" + userB64, + "AT#" + jti, + jti, + userB64, + audience, + deviceId.orElse(null), + issuedAt, + expiresAt, + ttl)); + return null; + }); + } + + @Override + public boolean exists(String jti) { + if (jti == null) { + return false; + } + return wrap( + "access_tokens.exists", + () -> + table.getItem(Key.builder().partitionValue("AT#" + jti).sortValue("AT#" + jti).build()) + != null); + } + + @Override + public boolean delete(String jti) { + if (jti == null) { + return false; + } + return wrap( + "access_tokens.delete", + () -> { + AccessTokenItem primary = + table.getItem( + Key.builder().partitionValue("AT#" + jti).sortValue("AT#" + jti).build()); + if (primary == null) { + return false; + } + // Delete primary first — the load-bearing row for validation. + table.deleteItem( + Key.builder().partitionValue("AT#" + jti).sortValue("AT#" + jti).build()); + // Best-effort: delete the user-index pointer too. If this fails, native TTL or a + // later deleteExpiredBefore will eventually clear it. + if (primary.getUserHandleB64u() != null) { + table.deleteItem( + Key.builder() + .partitionValue("USER#" + primary.getUserHandleB64u()) + .sortValue("AT#" + jti) + .build()); + } + return true; + }); + } + + @Override + public int deleteAllForUser(UserHandle userHandle) { + return wrap( + "access_tokens.deleteAllForUser", + () -> { + String userB64 = Base64Url.encode(userHandle.value()); + int[] removed = {0}; + table + .query( + QueryConditional.sortBeginsWith( + Key.builder().partitionValue("USER#" + userB64).sortValue("AT#").build())) + .stream() + .flatMap(page -> page.items().stream()) + .forEach( + item -> { + String jti = item.getJti(); + // Delete primary jti-keyed item first (load-bearing for validation). + table.deleteItem( + Key.builder().partitionValue("AT#" + jti).sortValue("AT#" + jti).build()); + // Then the user-index pointer we found. + table.deleteItem( + Key.builder().partitionValue(item.getPk()).sortValue(item.getSk()).build()); + removed[0]++; + }); + return removed[0]; + }); + } + + @Override + public int deleteExpiredBefore(Instant before) { + // DynamoDB's native TTL handles this asynchronously; the synchronous scan is for tests and + // operator cleanup flows that need immediate, predictable removal. + return wrap( + "access_tokens.deleteExpiredBefore", + () -> { + long beforeEpoch = before.getEpochSecond(); + int[] removed = {0}; + table.scan().items().stream() + .filter(item -> "AT#".regionMatches(0, item.getPk(), 0, 3)) + .filter(item -> item.getPk().equals(item.getSk())) // primary items only + .filter(item -> item.getTtl() != null && item.getTtl() < beforeEpoch) + .forEach( + item -> { + String jti = item.getJti(); + table.deleteItem( + Key.builder().partitionValue("AT#" + jti).sortValue("AT#" + jti).build()); + if (item.getUserHandleB64u() != null) { + table.deleteItem( + Key.builder() + .partitionValue("USER#" + item.getUserHandleB64u()) + .sortValue("AT#" + jti) + .build()); + } + removed[0]++; + }); + return removed[0]; + }); + } + + private static AccessTokenItem buildItem( + String pk, + String sk, + String jti, + String userHandleB64u, + String audience, + String deviceId, + Instant issuedAt, + Instant expiresAt, + long ttl) { + AccessTokenItem item = new AccessTokenItem(); + item.setPk(pk); + item.setSk(sk); + item.setJti(jti); + item.setUserHandleB64u(userHandleB64u); + item.setAudience(audience); + item.setDeviceId(deviceId); + item.setIssuedAtIso(issuedAt.toString()); + item.setExpiresAtIso(expiresAt.toString()); + item.setTtl(ttl); + return item; + } + + 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); + } + } +} diff --git a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbCredentialRepository.java b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbCredentialRepository.java index be3a601..7f1a187 100644 --- a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbCredentialRepository.java +++ b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbCredentialRepository.java @@ -192,6 +192,29 @@ public void delete(CredentialId credentialId) { }); } + @Override + public int deleteByUserHandle(UserHandle userHandle) { + return wrap( + "credentials.deleteByUserHandle", + () -> { + String userB64 = Base64Url.encode(userHandle.value()); + int[] removed = {0}; + table + .query( + QueryConditional.sortBeginsWith( + Key.builder().partitionValue("USER#" + userB64).sortValue("CRED#").build())) + .stream() + .flatMap(page -> page.items().stream()) + .forEach( + item -> { + table.deleteItem( + Key.builder().partitionValue(item.getPk()).sortValue(item.getSk()).build()); + removed[0]++; + }); + return removed[0]; + }); + } + private Optional lookupItem(CredentialId credentialId) { String credIdB64 = credentialId.b64url(); return credentialByIdIndex diff --git a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbOtpRepository.java b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbOtpRepository.java index ba79cd0..d761bc9 100644 --- a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbOtpRepository.java +++ b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbOtpRepository.java @@ -141,6 +141,29 @@ public boolean consume(UserHandle userHandle, String otpId) { }); } + @Override + public int deleteByUserHandle(UserHandle userHandle) { + return wrap( + "otp.deleteByUserHandle", + () -> { + String userB64 = Base64Url.encode(userHandle.value()); + int[] removed = {0}; + table + .query( + QueryConditional.sortBeginsWith( + Key.builder().partitionValue("USER#" + userB64).sortValue("OTP#").build())) + .stream() + .flatMap(page -> page.items().stream()) + .forEach( + item -> { + table.deleteItem( + Key.builder().partitionValue(item.getPk()).sortValue(item.getSk()).build()); + removed[0]++; + }); + return removed[0]; + }); + } + @Override public int countSince(UserHandle userHandle, String phoneE164, Instant since) { return wrap( diff --git a/pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbAccessTokenStoreIntegrationTest.java b/pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbAccessTokenStoreIntegrationTest.java new file mode 100644 index 0000000..6dd6d17 --- /dev/null +++ b/pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbAccessTokenStoreIntegrationTest.java @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.persistence.dynamodb; + +import com.codeheadsystems.pkauth.testkit.AccessTokenStoreScenarios; +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 DynamoDbAccessTokenStoreIntegrationTest { + + private DynamoDbAccessTokenStore store; + + @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(); + store = new DynamoDbAccessTokenStore(enhanced, tables); + } + + @Test + void recordThenExistsThenDelete() { + new AccessTokenStoreScenarios(store).recordThenExistsThenDelete(); + } + + @Test + void existsReturnsFalseForUnknownJti() { + new AccessTokenStoreScenarios(store).existsReturnsFalseForUnknownJti(); + } + + @Test + void deleteAllForUserRemovesEveryRow() { + new AccessTokenStoreScenarios(store).deleteAllForUserRemovesEveryRow(); + } + + @Test + void deleteExpiredBeforePrunesOnlyExpiredRows() { + new AccessTokenStoreScenarios(store).deleteExpiredBeforePrunesOnlyExpiredRows(); + } + + @Test + void recordIsIdempotent() { + new AccessTokenStoreScenarios(store).recordIsIdempotent(); + } +} diff --git a/pk-auth-persistence-jdbi/build.gradle.kts b/pk-auth-persistence-jdbi/build.gradle.kts index 68cb2a4..3fcfcd0 100644 --- a/pk-auth-persistence-jdbi/build.gradle.kts +++ b/pk-auth-persistence-jdbi/build.gradle.kts @@ -14,6 +14,8 @@ tasks.named("compileJava") { dependencies { api(project(":pk-auth-core")) + // AccessTokenStore lives in pk-auth-jwt; JdbiAccessTokenStore implements it. + api(project(":pk-auth-jwt")) 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/JdbiAccessTokenStore.java b/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiAccessTokenStore.java new file mode 100644 index 0000000..8da39fc --- /dev/null +++ b/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiAccessTokenStore.java @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.persistence.jdbi; + +import com.codeheadsystems.pkauth.api.UserHandle; +import com.codeheadsystems.pkauth.jwt.AccessTokenStore; +import com.codeheadsystems.pkauth.spi.PkAuthPersistenceException; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Supplier; +import org.jdbi.v3.core.Jdbi; +import org.jdbi.v3.core.JdbiException; + +/** + * {@link AccessTokenStore} backed by the {@code access_tokens} table (Flyway V8). Inserts on issue, + * lookups by jti on validate, deletes by jti / user / expiry on logout / cleanup. + * + * @since 1.1.0 + */ +public final class JdbiAccessTokenStore implements AccessTokenStore { + + private final Jdbi jdbi; + + public JdbiAccessTokenStore(Jdbi jdbi) { + this.jdbi = Objects.requireNonNull(jdbi, "jdbi"); + } + + @Override + public void record( + String jti, + UserHandle userHandle, + String audience, + Optional deviceId, + Instant issuedAt, + Instant expiresAt) { + Objects.requireNonNull(jti, "jti"); + Objects.requireNonNull(userHandle, "userHandle"); + Objects.requireNonNull(audience, "audience"); + Objects.requireNonNull(deviceId, "deviceId"); + Objects.requireNonNull(issuedAt, "issuedAt"); + Objects.requireNonNull(expiresAt, "expiresAt"); + wrap( + "access_tokens.record", + () -> { + // ON CONFLICT DO UPDATE so re-recording the same jti (e.g. an issuer retry after a + // transient failure on the wire return) converges on the latest values rather than + // throwing. UUID collisions are astronomically unlikely; the upsert is for retry + // safety, not real duplicates. + jdbi.useHandle( + h -> + h.createUpdate( + "INSERT INTO access_tokens" + + " (jti, user_handle, audience, device_id, issued_at, expires_at)" + + " VALUES (:jti, :uh, :aud, :did, :iat, :exp)" + + " ON CONFLICT (jti) DO UPDATE SET" + + " user_handle = EXCLUDED.user_handle," + + " audience = EXCLUDED.audience," + + " device_id = EXCLUDED.device_id," + + " issued_at = EXCLUDED.issued_at," + + " expires_at = EXCLUDED.expires_at") + .bind("jti", jti) + .bind("uh", userHandle.value()) + .bind("aud", audience) + .bind("did", deviceId.orElse(null)) + .bind("iat", OffsetDateTime.ofInstant(issuedAt, ZoneOffset.UTC)) + .bind("exp", OffsetDateTime.ofInstant(expiresAt, ZoneOffset.UTC)) + .execute()); + return null; + }); + } + + @Override + public boolean exists(String jti) { + if (jti == null) { + return false; + } + return wrap( + "access_tokens.exists", + () -> + jdbi.withHandle( + h -> + h.createQuery("SELECT 1 FROM access_tokens WHERE jti = :jti") + .bind("jti", jti) + .mapTo(Integer.class) + .findOne() + .isPresent())); + } + + @Override + public boolean delete(String jti) { + if (jti == null) { + return false; + } + return wrap( + "access_tokens.delete", + () -> + jdbi.withHandle( + h -> + h.createUpdate("DELETE FROM access_tokens WHERE jti = :jti") + .bind("jti", jti) + .execute() + > 0)); + } + + @Override + public int deleteAllForUser(UserHandle userHandle) { + return wrap( + "access_tokens.deleteAllForUser", + () -> + jdbi.withHandle( + h -> + h.createUpdate("DELETE FROM access_tokens WHERE user_handle = :uh") + .bind("uh", userHandle.value()) + .execute())); + } + + @Override + public int deleteExpiredBefore(Instant before) { + return wrap( + "access_tokens.deleteExpiredBefore", + () -> + jdbi.withHandle( + h -> + h.createUpdate("DELETE FROM access_tokens WHERE expires_at < :before") + .bind("before", OffsetDateTime.ofInstant(before, ZoneOffset.UTC)) + .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); + } + } +} diff --git a/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiCredentialRepository.java b/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiCredentialRepository.java index c0d2350..0337538 100644 --- a/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiCredentialRepository.java +++ b/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiCredentialRepository.java @@ -173,6 +173,18 @@ public void delete(CredentialId credentialId) { }); } + @Override + public int deleteByUserHandle(UserHandle userHandle) { + return wrap( + "credentials.deleteByUserHandle", + () -> + jdbi.withHandle( + h -> + h.createUpdate("DELETE FROM credentials WHERE user_handle = :uh") + .bind("uh", userHandle.value()) + .execute())); + } + // ------------------------------------------------------------------------- // Internal helpers // ------------------------------------------------------------------------- diff --git a/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiOtpRepository.java b/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiOtpRepository.java index fff87e6..b78f9b6 100644 --- a/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiOtpRepository.java +++ b/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiOtpRepository.java @@ -139,6 +139,18 @@ public int countSince(UserHandle userHandle, String phoneE164, Instant since) { .one())); } + @Override + public int deleteByUserHandle(UserHandle userHandle) { + return wrap( + "otp.deleteByUserHandle", + () -> + jdbi.withHandle( + h -> + h.createUpdate("DELETE FROM otp_codes WHERE user_handle = :uh") + .bind("uh", userHandle.value()) + .execute())); + } + /** * Runs {@code body} and wraps any {@link JdbiException} in a {@link PkAuthPersistenceException} * so adapter exception mappers can produce a uniform 503. Existing {@link 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 ff87916..2f5e256 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 = "6"; + public static final String CURRENT_SCHEMA_VERSION = "8"; private PkAuthJdbiSchema() {} diff --git a/pk-auth-persistence-jdbi/src/main/resources/db/migration/V8__create_access_tokens.sql b/pk-auth-persistence-jdbi/src/main/resources/db/migration/V8__create_access_tokens.sql new file mode 100644 index 0000000..113add1 --- /dev/null +++ b/pk-auth-persistence-jdbi/src/main/resources/db/migration/V8__create_access_tokens.sql @@ -0,0 +1,20 @@ +-- SPDX-License-Identifier: MIT +-- +-- Stateful access-token storage (per ADR 0015). One row per issued JWT JTI; presence on lookup +-- equals "valid". Deletion (logout, admin revoke, user delete) invalidates the bearer +-- immediately, well before its exp claim. +-- +-- Hosts that prefer stateless JWT behaviour wire AccessTokenStore.noop() and never INSERT here; +-- the table can stay empty without affecting validation. + +CREATE TABLE access_tokens ( + jti VARCHAR(64) NOT NULL PRIMARY KEY, + user_handle BYTEA NOT NULL, + audience VARCHAR(64) NOT NULL, + device_id VARCHAR(128), + issued_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX access_tokens_user_handle_idx ON access_tokens (user_handle); +CREATE INDEX access_tokens_expires_at_idx ON access_tokens (expires_at); diff --git a/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiAccessTokenStoreIntegrationTest.java b/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiAccessTokenStoreIntegrationTest.java new file mode 100644 index 0000000..7223a6a --- /dev/null +++ b/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiAccessTokenStoreIntegrationTest.java @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.persistence.jdbi; + +import com.codeheadsystems.pkauth.testkit.AccessTokenStoreScenarios; +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 JdbiAccessTokenStoreIntegrationTest { + + private Jdbi jdbi; + private JdbiAccessTokenStore store; + + @BeforeEach + void setUp() { + jdbi = PostgresFixture.ready(); + PostgresFixture.reset(); + store = new JdbiAccessTokenStore(jdbi); + } + + @Test + void recordThenExistsThenDelete() { + new AccessTokenStoreScenarios(store).recordThenExistsThenDelete(); + } + + @Test + void existsReturnsFalseForUnknownJti() { + new AccessTokenStoreScenarios(store).existsReturnsFalseForUnknownJti(); + } + + @Test + void deleteAllForUserRemovesEveryRow() { + new AccessTokenStoreScenarios(store).deleteAllForUserRemovesEveryRow(); + } + + @Test + void deleteExpiredBeforePrunesOnlyExpiredRows() { + new AccessTokenStoreScenarios(store).deleteExpiredBeforePrunesOnlyExpiredRows(); + } + + @Test + void recordIsIdempotent() { + new AccessTokenStoreScenarios(store).recordIsIdempotent(); + } +} 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 f7a9b5b..c191ed8 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,6 @@ public static void reset() { h -> h.execute( "TRUNCATE TABLE credentials, challenges, users, backup_codes, otp_codes," - + " pkauth_audit_events RESTART IDENTITY CASCADE")); + + " pkauth_audit_events, access_tokens RESTART IDENTITY CASCADE")); } } 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 91a9e28..a1797ae 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 @@ -11,12 +11,20 @@ import com.codeheadsystems.pkauth.config.CeremonyConfig; import com.codeheadsystems.pkauth.config.CounterRegressionPolicy; import com.codeheadsystems.pkauth.config.RelyingPartyConfig; +import com.codeheadsystems.pkauth.jwt.AccessTokenStore; +import com.codeheadsystems.pkauth.jwt.AccessTokenStoreDeletionListener; import com.codeheadsystems.pkauth.jwt.JwtConfig; import com.codeheadsystems.pkauth.jwt.JwtKeyset; import com.codeheadsystems.pkauth.jwt.JwtSecretResolver; import com.codeheadsystems.pkauth.jwt.PkAuthJwtIssuer; import com.codeheadsystems.pkauth.jwt.PkAuthJwtValidator; +import com.codeheadsystems.pkauth.jwt.RevocationCheck; import com.codeheadsystems.pkauth.jwt.TokenTtlPolicy; +import com.codeheadsystems.pkauth.lifecycle.BackupCodeRepositoryDeletionListener; +import com.codeheadsystems.pkauth.lifecycle.CredentialRepositoryDeletionListener; +import com.codeheadsystems.pkauth.lifecycle.OtpRepositoryDeletionListener; +import com.codeheadsystems.pkauth.lifecycle.UserDeletionListener; +import com.codeheadsystems.pkauth.lifecycle.UserDeletionService; import com.codeheadsystems.pkauth.magiclink.EmailSender; import com.codeheadsystems.pkauth.magiclink.LoggingEmailSender; import com.codeheadsystems.pkauth.magiclink.MagicLinkService; @@ -38,6 +46,7 @@ import com.codeheadsystems.pkauth.testkit.InMemoryOtpRepository; import com.codeheadsystems.pkauth.testkit.InMemoryUserLookup; import java.time.Duration; +import java.util.List; import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -231,18 +240,87 @@ private static PkAuthProperties.Jwt requireJwt(PkAuthProperties props) { return jwt; } + /** + * Default no-op {@link AccessTokenStore}. Hosts that want server-side access-token revocation + * override this bean by declaring their own {@code AccessTokenStore} (e.g. {@code + * JdbiAccessTokenStore}). + */ + @Bean + @ConditionalOnMissingBean + public AccessTokenStore pkAuthAccessTokenStore() { + return AccessTokenStore.noop(); + } + + /** + * Default no-op {@link RevocationCheck}. Same override pattern as {@link + * #pkAuthAccessTokenStore()}. + */ + @Bean + @ConditionalOnMissingBean + public RevocationCheck pkAuthRevocationCheck() { + return RevocationCheck.allow(); + } + @Bean @ConditionalOnMissingBean public PkAuthJwtIssuer pkAuthJwtIssuer( - JwtConfig config, JwtKeyset keyset, ClockProvider clockProvider) { - return new PkAuthJwtIssuer(config, keyset, clockProvider); + JwtConfig config, + JwtKeyset keyset, + ClockProvider clockProvider, + AccessTokenStore accessTokenStore) { + return new PkAuthJwtIssuer(config, keyset, clockProvider, accessTokenStore); } @Bean @ConditionalOnMissingBean public PkAuthJwtValidator pkAuthJwtValidator( - JwtConfig config, JwtKeyset keyset, ClockProvider clockProvider) { - return new PkAuthJwtValidator(config, keyset, clockProvider); + JwtConfig config, + JwtKeyset keyset, + ClockProvider clockProvider, + RevocationCheck revocationCheck, + AccessTokenStore accessTokenStore) { + return new PkAuthJwtValidator(config, keyset, clockProvider, revocationCheck, accessTokenStore); + } + + // -- User deletion fan-out ------------------------------------------------------------------ + + /** Listener: deletes every passkey credential owned by the user. */ + @Bean + public UserDeletionListener pkAuthCredentialRepositoryDeletionListener( + CredentialRepository repository) { + return new CredentialRepositoryDeletionListener(repository); + } + + /** Listener: deletes every backup code owned by the user. */ + @Bean + public UserDeletionListener pkAuthBackupCodeRepositoryDeletionListener( + BackupCodeRepository repository) { + return new BackupCodeRepositoryDeletionListener(repository); + } + + /** Listener: deletes every OTP row owned by the user. */ + @Bean + public UserDeletionListener pkAuthOtpRepositoryDeletionListener(OtpRepository repository) { + return new OtpRepositoryDeletionListener(repository); + } + + /** + * Listener: deletes every stateful access-token row owned by the user (noop in stateless mode). + */ + @Bean + public UserDeletionListener pkAuthAccessTokenStoreDeletionListener(AccessTokenStore store) { + return new AccessTokenStoreDeletionListener(store); + } + + /** + * Collects every {@link UserDeletionListener} bean and wires the fan-out service. Hosts can + * register additional listeners by declaring their own {@code @Bean UserDeletionListener + * myCustomListener(...)} — Spring auto-collects all beans of the interface type. + */ + @Bean + @ConditionalOnMissingBean + public UserDeletionService pkAuthUserDeletionService(List listeners) { + return new UserDeletionService(listeners); } // -- Alt-flow services ----------------------------------------------------------------------- diff --git a/pk-auth-testkit/build.gradle.kts b/pk-auth-testkit/build.gradle.kts index e90d55f..c5b9385 100644 --- a/pk-auth-testkit/build.gradle.kts +++ b/pk-auth-testkit/build.gradle.kts @@ -17,6 +17,9 @@ tasks.named("compileJava") { dependencies { api(project(":pk-auth-core")) + // 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")) 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/AccessTokenStoreScenarios.java b/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/AccessTokenStoreScenarios.java new file mode 100644 index 0000000..16f5672 --- /dev/null +++ b/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/AccessTokenStoreScenarios.java @@ -0,0 +1,83 @@ +// 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.jwt.AccessTokenStore; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** + * Shared parity scenarios for {@link AccessTokenStore} implementations. Driven from {@code + * InMemoryAccessTokenStoreTest}, JDBI integration tests, and DynamoDB integration tests so every + * backend behaves identically. + * + *

Construct with a fresh, empty store and invoke each scenario from a {@code @Test} method. + * + * @since 1.1.0 + */ +public final class AccessTokenStoreScenarios { + + private static final Instant NOW = Instant.parse("2026-05-16T12:00:00Z"); + + private final AccessTokenStore store; + + public AccessTokenStoreScenarios(AccessTokenStore store) { + this.store = Objects.requireNonNull(store, "store"); + } + + /** Round-trip: record, observe via exists, then delete. */ + public void recordThenExistsThenDelete() { + UserHandle user = UserHandle.of(new byte[] {1, 2, 3}); + store.record("jti-A", user, "web", Optional.empty(), NOW, NOW.plusSeconds(900)); + assertThat(store.exists("jti-A")).isTrue(); + assertThat(store.delete("jti-A")).isTrue(); + assertThat(store.exists("jti-A")).isFalse(); + // delete is idempotent + assertThat(store.delete("jti-A")).isFalse(); + } + + /** Unknown jti always returns false from exists. */ + public void existsReturnsFalseForUnknownJti() { + assertThat(store.exists("never-recorded")).isFalse(); + } + + /** deleteAllForUser removes every jti owned by the supplied user, leaves other users intact. */ + public void deleteAllForUserRemovesEveryRow() { + UserHandle alice = UserHandle.of(new byte[] {1}); + UserHandle bob = UserHandle.of(new byte[] {2}); + store.record("a1", alice, "web", Optional.empty(), NOW, NOW.plusSeconds(900)); + store.record("a2", alice, "cli", Optional.of("dev-1"), NOW, NOW.plusSeconds(3600)); + store.record("b1", bob, "web", Optional.empty(), NOW, NOW.plusSeconds(900)); + + int removed = store.deleteAllForUser(alice); + + assertThat(removed).isEqualTo(2); + assertThat(store.exists("a1")).isFalse(); + assertThat(store.exists("a2")).isFalse(); + assertThat(store.exists("b1")).isTrue(); + } + + /** deleteExpiredBefore removes only rows whose expires_at is strictly less than the cutoff. */ + public void deleteExpiredBeforePrunesOnlyExpiredRows() { + UserHandle user = UserHandle.of(new byte[] {1}); + store.record("expired", user, "web", Optional.empty(), NOW, NOW.plusSeconds(60)); + store.record("still-valid", user, "web", Optional.empty(), NOW, NOW.plusSeconds(3600)); + + int pruned = store.deleteExpiredBefore(NOW.plusSeconds(120)); + + assertThat(pruned).isEqualTo(1); + assertThat(store.exists("expired")).isFalse(); + assertThat(store.exists("still-valid")).isTrue(); + } + + /** Recording the same jti twice replaces the prior row (idempotent overwrite). */ + public void recordIsIdempotent() { + UserHandle user = UserHandle.of(new byte[] {1}); + store.record("jti-X", user, "web", Optional.empty(), NOW, NOW.plusSeconds(900)); + store.record("jti-X", user, "cli", Optional.of("dev-1"), NOW, NOW.plusSeconds(3600)); + assertThat(store.exists("jti-X")).isTrue(); + } +} diff --git a/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/InMemoryAccessTokenStore.java b/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/InMemoryAccessTokenStore.java new file mode 100644 index 0000000..91914cc --- /dev/null +++ b/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/InMemoryAccessTokenStore.java @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.testkit; + +import com.codeheadsystems.pkauth.api.UserHandle; +import com.codeheadsystems.pkauth.jwt.AccessTokenStore; +import java.time.Instant; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * In-memory {@link AccessTokenStore} for unit tests, dev-mode boots, and parity tests. Backed by a + * {@link ConcurrentHashMap}. + * + * @since 1.1.0 + */ +public final class InMemoryAccessTokenStore implements AccessTokenStore { + + private final Map byJti = new ConcurrentHashMap<>(); + + public InMemoryAccessTokenStore() {} + + @Override + public void record( + String jti, + UserHandle userHandle, + String audience, + Optional deviceId, + Instant issuedAt, + Instant expiresAt) { + Objects.requireNonNull(jti, "jti"); + Objects.requireNonNull(userHandle, "userHandle"); + Objects.requireNonNull(audience, "audience"); + Objects.requireNonNull(deviceId, "deviceId"); + Objects.requireNonNull(issuedAt, "issuedAt"); + Objects.requireNonNull(expiresAt, "expiresAt"); + byJti.put(jti, new Row(jti, userHandle, audience, deviceId.orElse(null), issuedAt, expiresAt)); + } + + @Override + public boolean exists(String jti) { + return jti != null && byJti.containsKey(jti); + } + + @Override + public boolean delete(String jti) { + return byJti.remove(jti) != null; + } + + @Override + public int deleteAllForUser(UserHandle userHandle) { + int[] removed = {0}; + byJti + .entrySet() + .removeIf( + e -> { + if (e.getValue().userHandle.equals(userHandle)) { + removed[0]++; + return true; + } + return false; + }); + return removed[0]; + } + + @Override + public int deleteExpiredBefore(Instant before) { + int[] removed = {0}; + byJti + .entrySet() + .removeIf( + e -> { + if (e.getValue().expiresAt.isBefore(before)) { + removed[0]++; + return true; + } + return false; + }); + return removed[0]; + } + + /** Visible for testing. */ + public int size() { + return byJti.size(); + } + + private record Row( + String jti, + UserHandle userHandle, + String audience, + String deviceId, + Instant issuedAt, + Instant expiresAt) {} +} diff --git a/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/InMemoryCredentialRepository.java b/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/InMemoryCredentialRepository.java index 8b16d1b..63cd603 100644 --- a/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/InMemoryCredentialRepository.java +++ b/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/InMemoryCredentialRepository.java @@ -78,4 +78,20 @@ public void updateLabel(CredentialId credentialId, String label) { public void delete(CredentialId credentialId) { byCredentialId.remove(credentialId); } + + @Override + public int deleteByUserHandle(UserHandle userHandle) { + int[] removed = {0}; + byCredentialId + .entrySet() + .removeIf( + e -> { + if (e.getValue().userHandle().equals(userHandle)) { + removed[0]++; + return true; + } + return false; + }); + return removed[0]; + } } diff --git a/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/InMemoryOtpRepository.java b/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/InMemoryOtpRepository.java index bd4e3ef..8380c8e 100644 --- a/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/InMemoryOtpRepository.java +++ b/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/InMemoryOtpRepository.java @@ -94,4 +94,19 @@ public int countSince(UserHandle userHandle, String phoneE164, Instant since) { .filter(o -> !o.createdAt().isBefore(since)) .count(); } + + @Override + public int deleteByUserHandle(UserHandle userHandle) { + int[] removed = {0}; + byId.entrySet() + .removeIf( + e -> { + if (e.getValue().userHandle().equals(userHandle)) { + removed[0]++; + return true; + } + return false; + }); + return removed[0]; + } } diff --git a/pk-auth-testkit/src/main/java/module-info.java b/pk-auth-testkit/src/main/java/module-info.java index e3585bc..d759975 100644 --- a/pk-auth-testkit/src/main/java/module-info.java +++ b/pk-auth-testkit/src/main/java/module-info.java @@ -3,6 +3,7 @@ /** pk-auth testkit module. */ module com.codeheadsystems.pkauth.testkit { requires transitive com.codeheadsystems.pkauth.core; + requires transitive com.codeheadsystems.pkauth.jwt; 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/InMemoryAccessTokenStoreTest.java b/pk-auth-testkit/src/test/java/com/codeheadsystems/pkauth/testkit/InMemoryAccessTokenStoreTest.java new file mode 100644 index 0000000..b3f2d4a --- /dev/null +++ b/pk-auth-testkit/src/test/java/com/codeheadsystems/pkauth/testkit/InMemoryAccessTokenStoreTest.java @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.testkit; + +import org.junit.jupiter.api.Test; + +class InMemoryAccessTokenStoreTest { + + @Test + void recordThenExistsThenDelete() { + new AccessTokenStoreScenarios(new InMemoryAccessTokenStore()).recordThenExistsThenDelete(); + } + + @Test + void existsReturnsFalseForUnknownJti() { + new AccessTokenStoreScenarios(new InMemoryAccessTokenStore()).existsReturnsFalseForUnknownJti(); + } + + @Test + void deleteAllForUserRemovesEveryRow() { + new AccessTokenStoreScenarios(new InMemoryAccessTokenStore()).deleteAllForUserRemovesEveryRow(); + } + + @Test + void deleteExpiredBeforePrunesOnlyExpiredRows() { + new AccessTokenStoreScenarios(new InMemoryAccessTokenStore()) + .deleteExpiredBeforePrunesOnlyExpiredRows(); + } + + @Test + void recordIsIdempotent() { + new AccessTokenStoreScenarios(new InMemoryAccessTokenStore()).recordIsIdempotent(); + } +}