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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -42,6 +63,16 @@ tags.
`PkAuthConfig.Jwt` (Dropwizard), and `PkAuthConfiguration.Jwt` (Micronaut)
each gain a `ttlsByAudience: Map<String, Duration>` 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)

Expand Down
117 changes: 117 additions & 0 deletions docs/adr/0015-stateful-access-tokens.md
Original file line number Diff line number Diff line change
@@ -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<String> 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.
139 changes: 139 additions & 0 deletions docs/adr/0016-user-deletion-fan-out.md
Original file line number Diff line number Diff line change
@@ -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<UserDeletionListener>`,
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<UserDeletionListener>`.

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.
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading