From 1eec095b445422c3a5722d012d0977c0c076a993 Mon Sep 17 00:00:00 2001 From: Ned Wolpert Date: Thu, 11 Jun 2026 20:53:01 -0700 Subject: [PATCH 01/10] docs: fix version/convention drift across top-level docs - README Status and docs/stability.md were frozen at 1.1.0-SNAPSHOT; the live line is 1.3.1-SNAPSHOT (gradle.properties is now cited as authoritative) and the 1.1 breaking-change caveat is reframed as historical. - CONTRIBUTING @since target now references gradle.properties instead of a hardcoded 1.1.0, so the convention can't drift again. - Fix Maven group io.codeheadsystems -> com.codeheadsystems in the DESIGN.md wiring snippet (the only occurrence). - Reconcile the exported-package claim with module-info.java (9 packages, not 3) in DESIGN.md and CLAUDE.md. - Drop the hardcoded "16 ADRs" count (18 exist) in DESIGN.md and GETTING_STARTED.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- CONTRIBUTING.md | 2 +- DESIGN.md | 16 +++++++++------- GETTING_STARTED.md | 2 +- README.md | 7 +++++-- docs/stability.md | 10 ++++++---- 6 files changed, 23 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7a3a2e3..95d6cd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,7 @@ JDK 21 is required (Gradle's toolchain fetches one if absent). Node ≥ 20 + npm Dependency arrows always point **inward**. Adapters depend on core; core depends on no adapter, no framework, no servlet/HTTP API, no JDBC/DynamoDB. -1. **`pk-auth-core`** — framework- and persistence-neutral. Knows WebAuthn (WebAuthn4J), the wire contract, and declares the SPIs. `PasskeyAuthenticationService` is the ceremony entry point. Only `api`, `spi`, and `config` packages are exported (enforced via `module-info.java`). +1. **`pk-auth-core`** — framework- and persistence-neutral. Knows WebAuthn (WebAuthn4J), the wire contract, and declares the SPIs. `PasskeyAuthenticationService` is the ceremony entry point. The exported packages are `api`, `ceremony`, `config`, `credential`, `error`, `json`, `lifecycle`, `metrics`, and `spi` (enforced via `module-info.java`); everything else is module-internal. 2. **SPIs (ports)** — narrow interfaces the host implements: `UserLookup`, `CredentialRepository`, `ChallengeStore` (required); `BackupCodeRepository`, `OtpRepository`, `EmailSender`, `SmsSender`, `RefreshTokenRepository`, `AccessTokenStore`, `TokenTtlPolicy`, `UserDeletionListener`, `AttestationTrustPolicy`, `OriginValidator`, `ClockProvider` (optional / feature-gated). See `DESIGN.md` §6 for the required-vs-optional table. 3. **Adapters** — `pk-auth-spring-boot-starter` (Spring Boot 4 / Security 7 autoconfigure), `pk-auth-dropwizard` (Dropwizard 5 `ConfiguredBundle` + Dagger 2), `pk-auth-micronaut` (Micronaut 4 `@Factory` + `@Filter`, deliberately **not** Micronaut Security). Each mounts the same `/auth/**` JSON contract and pattern-matches the core's sealed result sums into HTTP status codes. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6947aa8..6eca713 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ These mirror `pk-auth-build-brief.md` §12 — read that section for the full ra 4. **Dependencies.** New libraries go through `gradle/libs.versions.toml` and are justified in the commit message or an ADR. 5. **No `TODO` in main** unless paired with a GitHub issue link. 6. **SPDX header** on every Java source file: `// SPDX-License-Identifier: MIT`. Spotless enforces this. -7. **`@since` tags on public API.** Every new or modified public element (class, record, interface, method, field, or sealed-variant) gets an `@since X.Y.Z` Javadoc tag carrying the version it first ships in. The current target is `@since 1.1.0` for surfaces introduced in the in-flight 1.1 line; pre-1.1 surfaces keep the version they shipped with. When renaming a method or changing its signature in a MAJOR bump, update the `@since` on the new shape. The policy applies across `pk-auth-core`, `pk-auth-admin-api`, `pk-auth-jwt`, `pk-auth-otp`, `pk-auth-magic-link`, `pk-auth-backup-codes`, `pk-auth-refresh-tokens`, and the three adapter modules. +7. **`@since` tags on public API.** Every new or modified public element (class, record, interface, method, field, or sealed-variant) gets an `@since X.Y.Z` Javadoc tag carrying the version it first ships in. The current target is the in-flight version in `gradle.properties` (the `version` property, minus the `-SNAPSHOT` suffix) for newly introduced surfaces; existing surfaces keep the version they shipped with. When renaming a method or changing its signature in a MAJOR bump, update the `@since` on the new shape. The policy applies across `pk-auth-core`, `pk-auth-admin-api`, `pk-auth-jwt`, `pk-auth-otp`, `pk-auth-magic-link`, `pk-auth-backup-codes`, `pk-auth-refresh-tokens`, and the three adapter modules. 8. **Don't optimize prematurely.** Correct → tested → fast. 9. **Constructor-injection annotations.** Spring and Micronaut detect a single non-default constructor automatically; do not add `@Autowired` / `@Inject` in those adapters. Dropwizard's adapter is wired through Dagger 2, which requires `@Inject` on the injected constructor — keep the annotation. The asymmetry is by DI framework, not by style preference; new files in each adapter follow the conventions already present in that module. diff --git a/DESIGN.md b/DESIGN.md index bc4862c..17d35f4 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -8,8 +8,8 @@ exposes, and the conventions the codebase follows. For specific topics: - **What it does + how to run a demo** — [`README.md`](./README.md). -- **Per-decision rationale** — [`docs/adr/`](./docs/adr/) (16 ADRs, - numbered). +- **Per-decision rationale** — [`docs/adr/`](./docs/adr/) (numbered, + sequential). - **Running it in production** — [`docs/operator-guide.md`](./docs/operator-guide.md). - **Security posture** — [`docs/threat-model.md`](./docs/threat-model.md). - **The original brief and phase plan** — @@ -107,7 +107,7 @@ modules implement SPIs declared in core and are wired in by the host. | Module | Purpose | |---|---| -| `pk-auth-core` | Framework-neutral ceremony engine. `PasskeyAuthenticationService` is the entry point; `api/`, `spi/`, `config/`, and `lifecycle/` are exported. Hosts the `UserDeletionService` fan-out and `UserDeletionListener` SPI ([ADR 0016](./docs/adr/0016-user-deletion-fan-out.md)). | +| `pk-auth-core` | Framework-neutral ceremony engine. `PasskeyAuthenticationService` is the entry point; `api/`, `ceremony/`, `config/`, `credential/`, `error/`, `json/`, `lifecycle/`, `metrics/`, and `spi/` are exported (see `module-info.java`). Hosts the `UserDeletionService` fan-out and `UserDeletionListener` SPI ([ADR 0016](./docs/adr/0016-user-deletion-fan-out.md)). | | `pk-auth-jwt` | HS256 JWT mint (`PkAuthJwtIssuer`) + validate (`PkAuthJwtValidator`). Nimbus JOSE+JWT under the hood. Hosts the `TokenTtlPolicy` SPI for per-audience access-token TTL dispatch ([ADR 0014](./docs/adr/0014-per-audience-ttl-policy.md)) and the `AccessTokenStore` SPI for stateful (server-revocable) access tokens ([ADR 0015](./docs/adr/0015-stateful-access-tokens.md)). | | `pk-auth-backup-codes` | Alt flow: generate, hash (Argon2id), and atomically claim view-once backup codes. | | `pk-auth-magic-link` | Alt flow: random-token magic links over the host's email dispatcher. | @@ -237,8 +237,8 @@ storage SPIs against a real backend. ```java // build.gradle.kts -implementation("io.codeheadsystems:pk-auth-spring-boot-starter:") -implementation("io.codeheadsystems:pk-auth-persistence-jdbi:") // optional +implementation("com.codeheadsystems:pk-auth-spring-boot-starter:") +implementation("com.codeheadsystems:pk-auth-persistence-jdbi:") // optional // application.yml pkauth: @@ -475,8 +475,10 @@ Worth knowing if you're contributing: - **Null discipline**: `@org.jspecify.annotations.NonNull` / `@Nullable` on every public method parameter and return type. JSpecify is loaded; Error Prone catches violations. -- **Public API is sealed** via `module-info.java` exports. Only `api`, - `spi`, and `config` packages are exported per module. +- **Public API is sealed** via `module-info.java` exports. In + `pk-auth-core` the exported packages are `api`, `ceremony`, `config`, + `credential`, `error`, `json`, `lifecycle`, `metrics`, and `spi`; + everything else is module-internal. - **No reflection in hot paths.** The only reflection is Jackson's, confined to (de)serialization boundaries. - **Conventional commits** in commit messages; rationale for any diff --git a/GETTING_STARTED.md b/GETTING_STARTED.md index 7a9a33b..72ea055 100644 --- a/GETTING_STARTED.md +++ b/GETTING_STARTED.md @@ -261,6 +261,6 @@ project, and the three demos are the proof. - **The wire and class details** — [`DESIGN.md`](./DESIGN.md). - **Running it in production** — [`docs/operator-guide.md`](./docs/operator-guide.md). - **Security stance** — [`docs/threat-model.md`](./docs/threat-model.md). -- **Why a thing is the way it is** — [`docs/adr/`](./docs/adr/) (16 ADRs). +- **Why a thing is the way it is** — [`docs/adr/`](./docs/adr/). - **SPI stability + versioning** — [`docs/stability.md`](./docs/stability.md). - **Transactional behavior across SPIs** — [`docs/transactional-semantics.md`](./docs/transactional-semantics.md). diff --git a/README.md b/README.md index 0cb06a3..3807430 100644 --- a/README.md +++ b/README.md @@ -151,10 +151,13 @@ tests and Playwright end-to-end suites: ## Status 1.0.0 cut the stable baseline; the current development line is -**1.1.0-SNAPSHOT**, which adds per-audience JWT TTLs, the +**1.3.1-SNAPSHOT** (see `gradle.properties` for the authoritative +version). The 1.1 line added per-audience JWT TTLs, the `AccessTokenStore` (stateful access tokens), the `UserDeletionService` fan-out, and the `pk-auth-refresh-tokens` -module (rotating refresh tokens with family-based replay defense). +module (rotating refresh tokens with family-based replay defense); +1.2–1.3 followed with per-framework admin toggles and supply-chain +hardening. See [`CHANGELOG.md`](./CHANGELOG.md) for the full delta and [`docs/stability.md`](./docs/stability.md) for the versioning policy and the list of SPI surfaces. The full build and end-to-end suites diff --git a/docs/stability.md b/docs/stability.md index 9900250..d2532d3 100644 --- a/docs/stability.md +++ b/docs/stability.md @@ -3,7 +3,9 @@ ## Current status: 1.x pk-auth is **post-1.0** as of the `v1.0.0` tag. The current development line is -**1.1.0-SNAPSHOT**; see [`CHANGELOG.md`](../CHANGELOG.md) for the in-flight delta. +**1.3.1-SNAPSHOT** (`gradle.properties` is authoritative); the 1.1, 1.2, and +1.3 releases have shipped. See [`CHANGELOG.md`](../CHANGELOG.md) for the +per-release delta. The 1.x line follows [Semantic Versioning](https://semver.org/): @@ -16,12 +18,12 @@ The 1.x line follows [Semantic Versioning](https://semver.org/): Configuration key renames or removals are treated as breaking changes and require a MAJOR bump. -> **1.1 caveat.** The 1.1.0 line is still in development and carries a small +> **1.1 migration note (historical).** The 1.1.0 release carried a small > number of *intentional* breaking SPI changes against 1.0 — the > `CredentialRepository.deleteByUserHandle` / `OtpRepository.deleteByUserHandle` > additions, the `JwtConfig.tokenTtl` → `JwtConfig.ttlPolicy` swap, and the -> `JwtConfig.audience` → `JwtConfig.defaultAudience` rename. These will land -> together at 1.1.0 final; see `CHANGELOG.md` for the migration notes. From +> `JwtConfig.audience` → `JwtConfig.defaultAudience` rename. These landed +> together at 1.1.0; see `CHANGELOG.md` for the migration notes. From > 1.1.0 onward the SemVer contract above is binding. Pin to an exact version in your build file and review the changelog before From 876efb4c8c970f5de6eb9b8da0e33f243d267e69 Mon Sep 17 00:00:00 2001 From: Ned Wolpert Date: Thu, 11 Jun 2026 20:54:34 -0700 Subject: [PATCH 02/10] test(core): pin all CeremonyWireMapper refusal variants + exhaustiveness guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wire mapper is the single surface all three adapters delegate through, but six refusal variants were unverified — a wrong status (e.g. attestation-reject -> 200) would ship silently with no test to catch it. Add status+outcome assertions for: - RegistrationResult.AttestationRejected (400), InvalidPayload (400), RateLimited (429) - AssertionResult.InvalidChallenge (400), OriginMismatch (400), RateLimited (429) - the start-ceremony rateLimited() refusal (429) Add two exhaustiveness tripwires that enumerate the sealed permitted subclasses, so adding a future variant fails the test until it is both wired and covered. (RateLimited carries no Retry-After field, so none is asserted.) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../pkauth/api/CeremonyWireMapperTest.java | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/api/CeremonyWireMapperTest.java b/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/api/CeremonyWireMapperTest.java index b7bb535..a113c96 100644 --- a/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/api/CeremonyWireMapperTest.java +++ b/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/api/CeremonyWireMapperTest.java @@ -84,6 +84,35 @@ void registrationOriginMismatchIs400WithExpectedAndActual() { .containsEntry("actual", "https://attacker"); } + @Test + void registrationAttestationRejectedIs400WithReason() { + CeremonyResponse r = + CeremonyWireMapper.forRegistration( + new RegistrationResult.AttestationRejected("untrusted root")); + assertThat(r.status()).isEqualTo(400); + assertThat(r.body()) + .containsEntry("outcome", "attestation_rejected") + .containsEntry("reason", "untrusted root"); + } + + @Test + void registrationInvalidPayloadIs400WithDetail() { + CeremonyResponse r = + CeremonyWireMapper.forRegistration(new RegistrationResult.InvalidPayload("bad CBOR")); + assertThat(r.status()).isEqualTo(400); + assertThat(r.body()) + .containsEntry("outcome", "invalid_payload") + .containsEntry("detail", "bad CBOR"); + } + + @Test + void registrationRateLimitedIs429() { + CeremonyResponse r = + CeremonyWireMapper.forRegistration(new RegistrationResult.RateLimited("register")); + assertThat(r.status()).isEqualTo(429); + assertThat(r.body()).containsEntry("outcome", "rate_limited"); + } + @Test void assertionSuccessIncludesToken() { AssertionResult.Success success = @@ -128,6 +157,43 @@ void assertionCounterRegressionIs409WithStoredAndReceived() { .containsEntry("received", 5L); } + @Test + void assertionInvalidChallengeIs400WithDetail() { + CeremonyResponse r = + CeremonyWireMapper.forAssertionError(new AssertionResult.InvalidChallenge("expired")); + assertThat(r.status()).isEqualTo(400); + assertThat(r.body()) + .containsEntry("outcome", "invalid_challenge") + .containsEntry("detail", "expired"); + } + + @Test + void assertionOriginMismatchIs400WithExpectedAndActual() { + CeremonyResponse r = + CeremonyWireMapper.forAssertionError( + new AssertionResult.OriginMismatch("https://expected", "https://attacker")); + assertThat(r.status()).isEqualTo(400); + assertThat(r.body()) + .containsEntry("outcome", "origin_mismatch") + .containsEntry("expected", "https://expected") + .containsEntry("actual", "https://attacker"); + } + + @Test + void assertionRateLimitedIs429() { + CeremonyResponse r = + CeremonyWireMapper.forAssertionError(new AssertionResult.RateLimited("authenticate")); + assertThat(r.status()).isEqualTo(429); + assertThat(r.body()).containsEntry("outcome", "rate_limited"); + } + + @Test + void startCeremonyRateLimitedIs429() { + CeremonyResponse r = CeremonyWireMapper.rateLimited(); + assertThat(r.status()).isEqualTo(429); + assertThat(r.body()).containsEntry("outcome", "rate_limited"); + } + @Test void assertionUserVerificationRequiredIs401() { CeremonyResponse r = @@ -159,4 +225,50 @@ void responseBodyIsImmutable() { assertThatThrownBy(() -> r.body().put("hacked", "yes")) .isInstanceOf(UnsupportedOperationException.class); } + + /** + * Tripwire: if a new {@link RegistrationResult} variant is added, this fails until the variant is + * both wired in {@link CeremonyWireMapper#forRegistration} and covered by a test above. The + * {@code switch} in the mapper is already compile-time exhaustive; this guards the *test* + * coverage. + */ + @Test + void everyRegistrationVariantIsCoveredByATest() { + assertThat(simpleNamesOfPermittedSubclasses(RegistrationResult.class)) + .containsExactlyInAnyOrder( + "Success", + "InvalidChallenge", + "OriginMismatch", + "AttestationRejected", + "DuplicateCredential", + "InvalidPayload", + "RateLimited"); + } + + /** + * Tripwire: if a new {@link AssertionResult} variant is added, this fails until the variant is + * both wired in {@link CeremonyWireMapper#forAssertionError} (or {@code forAssertionSuccess}) and + * covered by a test above. + */ + @Test + void everyAssertionVariantIsCoveredByATest() { + assertThat(simpleNamesOfPermittedSubclasses(AssertionResult.class)) + .containsExactlyInAnyOrder( + "Success", + "UnknownCredential", + "InvalidChallenge", + "OriginMismatch", + "CounterRegression", + "UserVerificationRequired", + "InvalidSignature", + "RateLimited"); + } + + private static Set simpleNamesOfPermittedSubclasses(Class sealed) { + Class[] permitted = sealed.getPermittedSubclasses(); + assertThat(permitted).as("expected a sealed type").isNotNull(); + return java.util.Arrays.stream(permitted) + .map(Class::getSimpleName) + .collect(java.util.stream.Collectors.toSet()); + } } From 748a0c715ee35b1be7ebb426663899a423c3e973 Mon Sep 17 00:00:00 2001 From: Ned Wolpert Date: Thu, 11 Jun 2026 21:00:38 -0700 Subject: [PATCH 03/10] refactor(persistence): one wrap() exception helper per module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The JdbiException/SdkException -> PkAuthPersistenceException wrapper was copy-pasted verbatim into all 7 JDBI repos and all 7 DynamoDB repos (14 identical bodies). Any change to the wrapping contract (e.g. scrubbing a leaked token hash, preserving SQLSTATE) would have to touch 14 files. Promote one package-private helper per module — JdbiSupport.wrap and DynamoDbSupport.wrap — and route every call site through it. Behaviour is unchanged: the catch order (already-wrapped passthrough, then backend exception) and the op-labelled wrapping are identical. All JDBI and DynamoDB Testcontainers integration tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dynamodb/DynamoDbAccessTokenStore.java | 23 +++-------- .../DynamoDbBackupCodeRepository.java | 27 ++----------- .../dynamodb/DynamoDbChallengeStore.java | 23 +---------- .../DynamoDbCredentialRepository.java | 34 ++++------------- .../dynamodb/DynamoDbOtpRepository.java | 31 +++------------ .../DynamoDbRefreshTokenRepository.java | 29 ++++---------- .../persistence/dynamodb/DynamoDbSupport.java | 38 +++++++++++++++++++ .../dynamodb/DynamoDbUserLookup.java | 27 ++----------- .../jdbi/JdbiAccessTokenStore.java | 23 +++-------- .../jdbi/JdbiBackupCodeRepository.java | 30 +++------------ .../persistence/jdbi/JdbiChallengeStore.java | 21 +--------- .../jdbi/JdbiCredentialRepository.java | 33 ++++------------ .../persistence/jdbi/JdbiOtpRepository.java | 30 +++------------ .../jdbi/JdbiRefreshTokenRepository.java | 29 ++++---------- .../pkauth/persistence/jdbi/JdbiSupport.java | 36 ++++++++++++++++++ .../persistence/jdbi/JdbiUserLookup.java | 25 ++---------- 16 files changed, 148 insertions(+), 311 deletions(-) create mode 100644 pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbSupport.java create mode 100644 pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiSupport.java 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 index 1548326..e7eae4b 100644 --- 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 @@ -4,12 +4,9 @@ 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; @@ -55,7 +52,7 @@ public void record( Objects.requireNonNull(deviceId, "deviceId"); Objects.requireNonNull(issuedAt, "issuedAt"); Objects.requireNonNull(expiresAt, "expiresAt"); - wrap( + DynamoDbSupport.wrap( "access_tokens.record", () -> { String userB64 = Base64Url.encode(userHandle.value()); @@ -93,7 +90,7 @@ public boolean exists(String jti) { if (jti == null) { return false; } - return wrap( + return DynamoDbSupport.wrap( "access_tokens.exists", () -> table.getItem(Key.builder().partitionValue("AT#" + jti).sortValue("AT#" + jti).build()) @@ -105,7 +102,7 @@ public boolean delete(UserHandle userHandle, String jti) { if (jti == null) { return false; } - return wrap( + return DynamoDbSupport.wrap( "access_tokens.delete", () -> { AccessTokenItem primary = @@ -136,7 +133,7 @@ public boolean delete(UserHandle userHandle, String jti) { @Override public int deleteAllForUser(UserHandle userHandle) { - return wrap( + return DynamoDbSupport.wrap( "access_tokens.deleteAllForUser", () -> { String userB64 = Base64Url.encode(userHandle.value()); @@ -166,7 +163,7 @@ public int deleteAllForUser(UserHandle userHandle) { 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( + return DynamoDbSupport.wrap( "access_tokens.deleteExpiredBefore", () -> { long beforeEpoch = before.getEpochSecond(); @@ -215,14 +212,4 @@ private static AccessTokenItem buildItem( 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/DynamoDbBackupCodeRepository.java b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbBackupCodeRepository.java index 76c8ccb..1a88004 100644 --- a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbBackupCodeRepository.java +++ b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbBackupCodeRepository.java @@ -4,15 +4,12 @@ import com.codeheadsystems.pkauth.api.UserHandle; import com.codeheadsystems.pkauth.json.Base64Url; import com.codeheadsystems.pkauth.spi.BackupCodeRepository; -import com.codeheadsystems.pkauth.spi.PkAuthPersistenceException; import java.time.Instant; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; -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; @@ -45,7 +42,7 @@ public DynamoDbBackupCodeRepository( @Override public void save(StoredBackupCode code) { - wrap( + DynamoDbSupport.wrap( "backupCodes.save", () -> { table.putItem(BackupCodeItem.fromRecord(code)); @@ -55,7 +52,7 @@ public void save(StoredBackupCode code) { @Override public List findByUserHandle(UserHandle userHandle) { - return wrap( + return DynamoDbSupport.wrap( "backupCodes.findByUserHandle", () -> { String userB64 = Base64Url.encode(userHandle.value()); @@ -72,7 +69,7 @@ public List findByUserHandle(UserHandle userHandle) { @Override public boolean consume(UserHandle userHandle, String codeId, Instant consumedAt) { - return wrap( + return DynamoDbSupport.wrap( "backupCodes.consume", () -> { // Single conditional UpdateItem against the exact (pk, sk). No read, no scan. The @@ -110,7 +107,7 @@ public boolean consume(UserHandle userHandle, String codeId, Instant consumedAt) @Override public void deleteByUserHandle(UserHandle userHandle) { - wrap( + DynamoDbSupport.wrap( "backupCodes.deleteByUserHandle", () -> { String userB64 = Base64Url.encode(userHandle.value()); @@ -131,22 +128,6 @@ public void deleteByUserHandle(UserHandle userHandle) { }); } - /** - * Runs {@code body} and wraps any {@link SdkException} in a {@link PkAuthPersistenceException} so - * adapter exception mappers can produce a uniform 503. Documented {@link - * ConditionalCheckFailedException} control-flow branches handled inside {@code body} never reach - * here. - */ - 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); - } - } - /** * Atomically replaces all backup codes for a user using a single {@code TransactWriteItems} call. * Existing rows are deleted and the new rows are inserted in one transaction; if the combined diff --git a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbChallengeStore.java b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbChallengeStore.java index 338c9ac..7d8b9c7 100644 --- a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbChallengeStore.java +++ b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbChallengeStore.java @@ -4,15 +4,12 @@ import com.codeheadsystems.pkauth.api.ChallengeId; import com.codeheadsystems.pkauth.spi.ChallengeRecord; import com.codeheadsystems.pkauth.spi.ChallengeStore; -import com.codeheadsystems.pkauth.spi.PkAuthPersistenceException; import java.time.Duration; import java.time.Instant; import java.util.HashMap; import java.util.Map; import java.util.Objects; import java.util.Optional; -import java.util.function.Supplier; -import software.amazon.awssdk.core.exception.SdkException; import software.amazon.awssdk.services.dynamodb.DynamoDbClient; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import software.amazon.awssdk.services.dynamodb.model.ConditionalCheckFailedException; @@ -42,7 +39,7 @@ public void put(ChallengeId id, ChallengeRecord record, Duration ttl) { if (ttl.isZero() || ttl.isNegative()) { throw new IllegalArgumentException("ttl must be strictly positive, got " + ttl); } - wrap( + DynamoDbSupport.wrap( "challenges.put", () -> { ChallengeItem item = ChallengeItem.build(id, record); @@ -64,7 +61,7 @@ public void put(ChallengeId id, ChallengeRecord record, Duration ttl) { @Override public Optional takeOnce(ChallengeId id) { - return wrap( + return DynamoDbSupport.wrap( "challenges.takeOnce", () -> { Map key = new HashMap<>(); @@ -113,20 +110,4 @@ public Optional takeOnce(ChallengeId id) { return Optional.of(item.toRecord()); }); } - - /** - * Runs {@code body} and wraps any {@link SdkException} in a {@link PkAuthPersistenceException} so - * adapter exception mappers can produce a uniform 503. Documented {@link - * ConditionalCheckFailedException} control-flow branches handled inside {@code body} never reach - * here. - */ - 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 15c5684..2fc2368 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 @@ -7,15 +7,12 @@ import com.codeheadsystems.pkauth.json.Base64Url; import com.codeheadsystems.pkauth.spi.CredentialRepository; import com.codeheadsystems.pkauth.spi.DuplicateCredentialException; -import com.codeheadsystems.pkauth.spi.PkAuthPersistenceException; import java.time.Instant; import java.util.HashMap; import java.util.List; import java.util.Map; 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.DynamoDbIndex; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; @@ -44,7 +41,7 @@ public DynamoDbCredentialRepository(DynamoDbEnhancedClient enhanced, PkAuthDynam @Override public void save(CredentialRecord record) { - wrap( + DynamoDbSupport.wrap( "credentials.save", () -> { try { @@ -65,7 +62,7 @@ public void save(CredentialRecord record) { @Override public Optional findByCredentialId(CredentialId credentialId) { - return wrap( + return DynamoDbSupport.wrap( "credentials.findByCredentialId", () -> { String credIdB64 = credentialId.b64url(); @@ -82,7 +79,7 @@ public Optional findByCredentialId(CredentialId credentialId) @Override public List findByUserHandle(UserHandle userHandle) { - return wrap( + return DynamoDbSupport.wrap( "credentials.findByUserHandle", () -> { String userB64 = Base64Url.encode(userHandle.value()); @@ -99,7 +96,7 @@ public List findByUserHandle(UserHandle userHandle) { @Override public void updateSignCount(CredentialId credentialId, long newCount, Instant lastUsedAt) { - wrap( + DynamoDbSupport.wrap( "credentials.updateSignCount", () -> { // Retry loop: @DynamoDbVersionAttribute provides optimistic concurrency — if a concurrent @@ -151,7 +148,7 @@ public void updateSignCount(CredentialId credentialId, long newCount, Instant la @Override public void updateLabel(UserHandle userHandle, CredentialId credentialId, String label) { - wrap( + DynamoDbSupport.wrap( "credentials.updateLabel", () -> { // Retry loop: guards against concurrent field updates clobbering each other via the @@ -183,7 +180,7 @@ public void updateLabel(UserHandle userHandle, CredentialId credentialId, String @Override public void delete(UserHandle userHandle, CredentialId credentialId) { - wrap( + DynamoDbSupport.wrap( "credentials.delete", () -> { lookupItem(credentialId) @@ -201,7 +198,7 @@ public void delete(UserHandle userHandle, CredentialId credentialId) { @Override public int deleteByUserHandle(UserHandle userHandle) { - return wrap( + return DynamoDbSupport.wrap( "credentials.deleteByUserHandle", () -> { String userB64 = Base64Url.encode(userHandle.value()); @@ -240,21 +237,4 @@ Map asRawKey(String pk, String sk) { key.put("sk", AttributeValue.fromS(sk)); return key; } - - /** - * Runs {@code body} and wraps any {@link SdkException} in a {@link PkAuthPersistenceException} so - * adapter exception mappers can produce a uniform 503. {@link PkAuthPersistenceException} - * subclasses (including {@link DuplicateCredentialException}) are re-thrown unchanged. Documented - * {@link ConditionalCheckFailedException} control-flow branches handled inside {@code body} never - * reach here. - */ - 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/DynamoDbOtpRepository.java b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbOtpRepository.java index d761bc9..9fbec54 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 @@ -4,15 +4,12 @@ import com.codeheadsystems.pkauth.api.UserHandle; import com.codeheadsystems.pkauth.json.Base64Url; import com.codeheadsystems.pkauth.spi.OtpRepository; -import com.codeheadsystems.pkauth.spi.PkAuthPersistenceException; import java.time.Instant; import java.util.Comparator; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.OptionalInt; -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; @@ -43,7 +40,7 @@ public DynamoDbOtpRepository( @Override public void save(StoredOtp otp) { - wrap( + DynamoDbSupport.wrap( "otp.save", () -> { table.putItem(OtpItem.fromRecord(otp)); @@ -53,7 +50,7 @@ public void save(StoredOtp otp) { @Override public Optional findLatestActive(UserHandle userHandle, String phoneE164) { - return wrap( + return DynamoDbSupport.wrap( "otp.findLatestActive", () -> { String userB64 = Base64Url.encode(userHandle.value()); @@ -72,7 +69,7 @@ public Optional findLatestActive(UserHandle userHandle, String phoneE @Override public OptionalInt incrementAttempts(UserHandle userHandle, String otpId) { - return wrap( + return DynamoDbSupport.wrap( "otp.incrementAttempts", () -> { // Atomic counter increment on the exact (pk, sk). No read, no scan. Two concurrent @@ -110,7 +107,7 @@ public OptionalInt incrementAttempts(UserHandle userHandle, String otpId) { @Override public boolean consume(UserHandle userHandle, String otpId) { - return wrap( + return DynamoDbSupport.wrap( "otp.consume", () -> { // Like backup-code consume, two concurrent verifies must NOT both succeed. The @@ -143,7 +140,7 @@ public boolean consume(UserHandle userHandle, String otpId) { @Override public int deleteByUserHandle(UserHandle userHandle) { - return wrap( + return DynamoDbSupport.wrap( "otp.deleteByUserHandle", () -> { String userB64 = Base64Url.encode(userHandle.value()); @@ -166,7 +163,7 @@ public int deleteByUserHandle(UserHandle userHandle) { @Override public int countSince(UserHandle userHandle, String phoneE164, Instant since) { - return wrap( + return DynamoDbSupport.wrap( "otp.countSince", () -> { String userB64 = Base64Url.encode(userHandle.value()); @@ -185,20 +182,4 @@ public int countSince(UserHandle userHandle, String phoneE164, Instant since) { .count(); }); } - - /** - * Runs {@code body} and wraps any {@link SdkException} in a {@link PkAuthPersistenceException} so - * adapter exception mappers can produce a uniform 503. Documented {@link - * ConditionalCheckFailedException} control-flow branches handled inside {@code body} never reach - * here. - */ - 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/DynamoDbRefreshTokenRepository.java b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbRefreshTokenRepository.java index cca9ead..ed5d413 100644 --- a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbRefreshTokenRepository.java +++ b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbRefreshTokenRepository.java @@ -7,7 +7,6 @@ import com.codeheadsystems.pkauth.refresh.RefreshTokenRecord; import com.codeheadsystems.pkauth.refresh.RevokeReason; import com.codeheadsystems.pkauth.refresh.spi.RefreshTokenRepository; -import com.codeheadsystems.pkauth.spi.PkAuthPersistenceException; import java.time.Duration; import java.time.Instant; import java.util.HashMap; @@ -16,8 +15,6 @@ import java.util.Map; 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.Expression; @@ -87,7 +84,7 @@ public DynamoDbRefreshTokenRepository( @Override public void create(RefreshTokenRecord record) { Objects.requireNonNull(record, "record"); - wrap( + DynamoDbSupport.wrap( "refresh_tokens.create", () -> { putAllItems(record, /*requirePrimaryAbsent*/ true); @@ -97,7 +94,7 @@ public void create(RefreshTokenRecord record) { @Override public Optional findByRefreshId(String refreshId) { - return wrap( + return DynamoDbSupport.wrap( "refresh_tokens.findByRefreshId", () -> { RefreshTokenItem item = @@ -116,7 +113,7 @@ public boolean rotateAtomically( Objects.requireNonNull(parentRefreshId, "parentRefreshId"); Objects.requireNonNull(now, "now"); Objects.requireNonNull(successor, "successor"); - return wrap( + return DynamoDbSupport.wrap( "refresh_tokens.rotateAtomically", () -> { // Mark the parent's primary item used via a conditional UpdateItem that touches ONLY @@ -194,7 +191,7 @@ public boolean rotateAtomically( @Override public int revokeFamily(String familyId, Instant now, RevokeReason reason) { - return wrap( + return DynamoDbSupport.wrap( "refresh_tokens.revokeFamily", () -> { // Query the family-index for every member, then mutate the primary item of each (the @@ -245,7 +242,7 @@ public int revokeFamily(String familyId, Instant now, RevokeReason reason) { @Override public int revokeAllForUser(UserHandle userHandle, Instant now, RevokeReason reason) { - return wrap( + return DynamoDbSupport.wrap( "refresh_tokens.revokeAllForUser", () -> { String userB64 = Base64Url.encode(userHandle.value()); @@ -293,7 +290,7 @@ public int revokeAllForUser(UserHandle userHandle, Instant now, RevokeReason rea @Override public List findByUserHandle(UserHandle userHandle) { - return wrap( + return DynamoDbSupport.wrap( "refresh_tokens.findByUserHandle", () -> { String userB64 = Base64Url.encode(userHandle.value()); @@ -327,7 +324,7 @@ public List findByUserHandle(UserHandle userHandle) { @Override public List findByFamilyId(String familyId) { - return wrap( + return DynamoDbSupport.wrap( "refresh_tokens.findByFamilyId", () -> { Map byId = new LinkedHashMap<>(); @@ -358,7 +355,7 @@ public List findByFamilyId(String familyId) { @Override public int deleteExpiredBefore(Instant cutoff) { - return wrap( + return DynamoDbSupport.wrap( "refresh_tokens.deleteExpiredBefore", () -> { long cutoffEpoch = cutoff.getEpochSecond(); @@ -491,16 +488,6 @@ private static List splitAmr(String stored) { return List.of(stored.split(",")); } - 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); - } - } - // Defensive: silence unused-import warnings on classes pulled in for the TransactWrite path. @SuppressWarnings("unused") private static final ConditionCheck UNUSED_CONDITION = null; diff --git a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbSupport.java b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbSupport.java new file mode 100644 index 0000000..acc48b4 --- /dev/null +++ b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbSupport.java @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.persistence.dynamodb; + +import com.codeheadsystems.pkauth.spi.PkAuthPersistenceException; +import java.util.function.Supplier; +import software.amazon.awssdk.core.exception.SdkException; + +/** + * Internal helpers shared across the DynamoDB repositories. Package-private: this is not part of + * the module's public surface. + */ +final class DynamoDbSupport { + + private DynamoDbSupport() {} + + /** + * Runs {@code body} and wraps any {@link SdkException} in a {@link PkAuthPersistenceException} so + * adapter exception mappers can produce a uniform 503. An already-wrapped {@link + * PkAuthPersistenceException} (e.g. thrown by a nested call) propagates unchanged so its original + * {@code op} context is preserved. Atomic-claim operations catch {@code + * ConditionalCheckFailedException} inside {@code body} and translate it to a race/expiry result + * before it can reach this wrapper. + * + * @param op short operation label carried on the wrapped exception. + * @param body the persistence operation to run. + * @param the operation's return type. + * @return whatever {@code body} returns. + */ + 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/DynamoDbUserLookup.java b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbUserLookup.java index 5ec0b03..2411c05 100644 --- a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbUserLookup.java +++ b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbUserLookup.java @@ -3,13 +3,10 @@ import com.codeheadsystems.pkauth.api.UserHandle; import com.codeheadsystems.pkauth.json.Base64Url; -import com.codeheadsystems.pkauth.spi.PkAuthPersistenceException; import com.codeheadsystems.pkauth.spi.UserLookup; import java.util.Locale; 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.DynamoDbIndex; import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; @@ -34,7 +31,7 @@ public DynamoDbUserLookup(DynamoDbEnhancedClient enhanced, PkAuthDynamoTables ta @Override public Optional findHandleByUsername(String username) { - return wrap( + return DynamoDbSupport.wrap( "users.findHandleByUsername", () -> lookupByUsername(username) @@ -43,7 +40,7 @@ public Optional findHandleByUsername(String username) { @Override public Optional findViewByHandle(UserHandle handle) { - return wrap( + return DynamoDbSupport.wrap( "users.findViewByHandle", () -> { String h = Base64Url.encode(handle.value()); @@ -55,7 +52,7 @@ public Optional findViewByHandle(UserHandle handle) { @Override public UserHandle getOrCreateHandle(String username) { - return wrap( + return DynamoDbSupport.wrap( "users.getOrCreateHandle", () -> { Optional existing = lookupByUsername(username); @@ -82,7 +79,7 @@ public UserHandle getOrCreateHandle(String username) { /** Pre-registers a user (test fixture support). */ public UserHandle register(String username, String displayName) { UserHandle handle = UserHandle.random(); - wrap( + DynamoDbSupport.wrap( "users.register", () -> { table.putItem(UserItem.build(handle, username, displayName)); @@ -101,20 +98,4 @@ private Optional lookupByUsername(String username) { .flatMap(page -> page.items().stream()) .findFirst(); } - - /** - * Runs {@code body} and wraps any {@link SdkException} in a {@link PkAuthPersistenceException} so - * adapter exception mappers can produce a uniform 503. Documented {@link - * ConditionalCheckFailedException} control-flow branches handled inside {@code body} never reach - * here. - */ - 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-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 index a905307..7a07f29 100644 --- 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 @@ -3,15 +3,12 @@ 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, @@ -41,7 +38,7 @@ public void record( Objects.requireNonNull(deviceId, "deviceId"); Objects.requireNonNull(issuedAt, "issuedAt"); Objects.requireNonNull(expiresAt, "expiresAt"); - wrap( + JdbiSupport.wrap( "access_tokens.record", () -> { // ON CONFLICT DO UPDATE so re-recording the same jti (e.g. an issuer retry after a @@ -76,7 +73,7 @@ public boolean exists(String jti) { if (jti == null) { return false; } - return wrap( + return JdbiSupport.wrap( "access_tokens.exists", () -> jdbi.withHandle( @@ -93,7 +90,7 @@ public boolean delete(UserHandle userHandle, String jti) { if (jti == null) { return false; } - return wrap( + return JdbiSupport.wrap( "access_tokens.delete", () -> jdbi.withHandle( @@ -109,7 +106,7 @@ public boolean delete(UserHandle userHandle, String jti) { @Override public int deleteAllForUser(UserHandle userHandle) { - return wrap( + return JdbiSupport.wrap( "access_tokens.deleteAllForUser", () -> jdbi.withHandle( @@ -121,7 +118,7 @@ public int deleteAllForUser(UserHandle userHandle) { @Override public int deleteExpiredBefore(Instant before) { - return wrap( + return JdbiSupport.wrap( "access_tokens.deleteExpiredBefore", () -> jdbi.withHandle( @@ -130,14 +127,4 @@ public int deleteExpiredBefore(Instant 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/JdbiBackupCodeRepository.java b/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiBackupCodeRepository.java index 4130303..c9634b6 100644 --- a/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiBackupCodeRepository.java +++ b/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiBackupCodeRepository.java @@ -3,7 +3,6 @@ import com.codeheadsystems.pkauth.api.UserHandle; import com.codeheadsystems.pkauth.spi.BackupCodeRepository; -import com.codeheadsystems.pkauth.spi.PkAuthPersistenceException; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Types; @@ -12,10 +11,8 @@ import java.time.ZoneOffset; import java.util.List; import java.util.Objects; -import java.util.function.Supplier; import org.jdbi.v3.core.Handle; import org.jdbi.v3.core.Jdbi; -import org.jdbi.v3.core.JdbiException; import org.jdbi.v3.core.mapper.RowMapper; import org.jdbi.v3.core.statement.Update; import org.jspecify.annotations.Nullable; @@ -37,7 +34,7 @@ public JdbiBackupCodeRepository(Jdbi jdbi) { @Override public void save(StoredBackupCode code) { - wrap( + JdbiSupport.wrap( "backupCodes.save", () -> { jdbi.useHandle(h -> insertBackupCode(h, code)); @@ -81,7 +78,7 @@ private static void bindNullable( /** Returns only active (not yet revoked) codes for the given user handle. */ @Override public List findByUserHandle(UserHandle userHandle) { - return wrap( + return JdbiSupport.wrap( "backupCodes.findByUserHandle", () -> jdbi.withHandle( @@ -105,7 +102,7 @@ public List findByUserHandle(UserHandle userHandle) { */ @Override public boolean consume(UserHandle userHandle, String codeId, Instant consumedAt) { - return wrap( + return JdbiSupport.wrap( "backupCodes.consume", () -> jdbi.withHandle( @@ -131,7 +128,7 @@ public boolean consume(UserHandle userHandle, String codeId, Instant consumedAt) * @param userHandle the user whose code was attempted, or {@code null} if unknown */ public void recordVerifyFailure(String codeId, UserHandle userHandle) { - wrap( + JdbiSupport.wrap( "backupCodes.recordVerifyFailure", () -> { insertAuditEvent( @@ -149,7 +146,7 @@ public void recordVerifyFailure(String codeId, UserHandle userHandle) { */ @Override public void deleteByUserHandle(UserHandle userHandle) { - wrap( + JdbiSupport.wrap( "backupCodes.deleteByUserHandle", () -> { jdbi.useHandle( @@ -173,7 +170,7 @@ public void deleteByUserHandle(UserHandle userHandle) { */ @Override public void replaceAll(UserHandle userHandle, List records) { - wrap( + JdbiSupport.wrap( "backupCodes.replaceAll", () -> { jdbi.useTransaction( @@ -196,21 +193,6 @@ public void replaceAll(UserHandle userHandle, List records) { // Internal helpers // ------------------------------------------------------------------------- - /** - * Runs {@code body} and wraps any {@link JdbiException} in a {@link PkAuthPersistenceException} - * so adapter exception mappers can produce a uniform 503. Existing {@link - * PkAuthPersistenceException} instances are re-thrown unchanged. - */ - 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); - } - } - private void insertAuditEvent( String eventType, byte[] userHandle, String subjectId, String detail) { jdbi.useHandle( diff --git a/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiChallengeStore.java b/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiChallengeStore.java index b8f032d..de48919 100644 --- a/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiChallengeStore.java +++ b/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiChallengeStore.java @@ -5,7 +5,6 @@ import com.codeheadsystems.pkauth.api.UserHandle; import com.codeheadsystems.pkauth.spi.ChallengeRecord; import com.codeheadsystems.pkauth.spi.ChallengeStore; -import com.codeheadsystems.pkauth.spi.PkAuthPersistenceException; import java.sql.ResultSet; import java.sql.SQLException; import java.time.Duration; @@ -13,9 +12,7 @@ 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; import org.jdbi.v3.core.mapper.RowMapper; /** @@ -38,7 +35,7 @@ public void put(ChallengeId id, ChallengeRecord record, Duration ttl) { if (ttl.isZero() || ttl.isNegative()) { throw new IllegalArgumentException("ttl must be strictly positive, got " + ttl); } - wrap( + JdbiSupport.wrap( "challenges.put", () -> { jdbi.useHandle( @@ -63,7 +60,7 @@ public void put(ChallengeId id, ChallengeRecord record, Duration ttl) { @Override public Optional takeOnce(ChallengeId id) { - return wrap( + return JdbiSupport.wrap( "challenges.takeOnce", () -> jdbi.withHandle( @@ -76,20 +73,6 @@ public Optional takeOnce(ChallengeId id) { .findFirst())); } - /** - * Runs {@code body} and wraps any {@link JdbiException} in a {@link PkAuthPersistenceException} - * so adapter exception mappers can produce a uniform 503. - */ - 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); - } - } - private static final RowMapper MAPPER = (rs, ctx) -> readRow(rs); private static ChallengeRecord readRow(ResultSet rs) throws SQLException { 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 41684cd..2fc4151 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 @@ -7,7 +7,6 @@ import com.codeheadsystems.pkauth.credential.CredentialRecord; import com.codeheadsystems.pkauth.spi.CredentialRepository; import com.codeheadsystems.pkauth.spi.DuplicateCredentialException; -import com.codeheadsystems.pkauth.spi.PkAuthPersistenceException; import java.sql.Array; import java.sql.ResultSet; import java.sql.SQLException; @@ -22,9 +21,7 @@ import java.util.Optional; import java.util.Set; import java.util.UUID; -import java.util.function.Supplier; import org.jdbi.v3.core.Jdbi; -import org.jdbi.v3.core.JdbiException; import org.jdbi.v3.core.mapper.RowMapper; import org.jdbi.v3.core.statement.Update; import org.jspecify.annotations.Nullable; @@ -55,7 +52,7 @@ public void save(CredentialRecord record) { transportWire[i++] = t.wireName(); } int inserted = - wrap( + JdbiSupport.wrap( "credentials.save", () -> jdbi.withHandle( @@ -101,7 +98,7 @@ public void save(CredentialRecord record) { @Override public Optional findByCredentialId(CredentialId credentialId) { - return wrap( + return JdbiSupport.wrap( "credentials.findByCredentialId", () -> jdbi.withHandle( @@ -114,7 +111,7 @@ public Optional findByCredentialId(CredentialId credentialId) @Override public List findByUserHandle(UserHandle userHandle) { - return wrap( + return JdbiSupport.wrap( "credentials.findByUserHandle", () -> jdbi.withHandle( @@ -133,7 +130,7 @@ public void updateSignCount(CredentialId credentialId, long newCount, Instant la // Guard against concurrent racing assertions overwriting a higher stored counter with a // lower one — that would silently defeat WebAuthn's clone-detection invariant. Only advance // the counter when the new value strictly exceeds the stored one. - wrap( + JdbiSupport.wrap( "credentials.updateSignCount", () -> { jdbi.useHandle( @@ -151,7 +148,7 @@ public void updateSignCount(CredentialId credentialId, long newCount, Instant la @Override public void updateLabel(UserHandle userHandle, CredentialId credentialId, String label) { - wrap( + JdbiSupport.wrap( "credentials.updateLabel", () -> { jdbi.useHandle( @@ -175,7 +172,7 @@ public void updateLabel(UserHandle userHandle, CredentialId credentialId, String @Override public void delete(UserHandle userHandle, CredentialId credentialId) { byte[] credIdBytes = credentialId.value(); - wrap( + JdbiSupport.wrap( "credentials.delete", () -> { jdbi.useHandle( @@ -191,7 +188,7 @@ public void delete(UserHandle userHandle, CredentialId credentialId) { @Override public int deleteByUserHandle(UserHandle userHandle) { - return wrap( + return JdbiSupport.wrap( "credentials.deleteByUserHandle", () -> jdbi.withHandle( @@ -220,22 +217,6 @@ private static void bindNullable( } } - /** - * Runs {@code body} and wraps any {@link JdbiException} (or other unchecked JDBC exception) in a - * {@link PkAuthPersistenceException} so adapter exception mappers can produce a uniform 503. - * {@link PkAuthPersistenceException} (including {@link DuplicateCredentialException}) is - * re-thrown unchanged so the duplicate-credential branch reaches the caller intact. - */ - 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); - } - } - private static final RowMapper MAPPER = (rs, ctx) -> readRow(rs); private static CredentialRecord readRow(ResultSet rs) throws SQLException { 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 8173898..10797a7 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 @@ -3,7 +3,6 @@ import com.codeheadsystems.pkauth.api.UserHandle; import com.codeheadsystems.pkauth.spi.OtpRepository; -import com.codeheadsystems.pkauth.spi.PkAuthPersistenceException; import java.sql.ResultSet; import java.sql.SQLException; import java.time.Instant; @@ -12,9 +11,7 @@ import java.util.Objects; import java.util.Optional; import java.util.OptionalInt; -import java.util.function.Supplier; import org.jdbi.v3.core.Jdbi; -import org.jdbi.v3.core.JdbiException; import org.jdbi.v3.core.mapper.RowMapper; /** {@link OtpRepository} backed by the {@code otp_codes} table (Flyway V4). */ @@ -28,7 +25,7 @@ public JdbiOtpRepository(Jdbi jdbi) { @Override public void save(StoredOtp otp) { - wrap( + JdbiSupport.wrap( "otp.save", () -> { jdbi.useHandle( @@ -54,7 +51,7 @@ public void save(StoredOtp otp) { @Override public Optional findLatestActive(UserHandle userHandle, String phoneE164) { - return wrap( + return JdbiSupport.wrap( "otp.findLatestActive", () -> jdbi.withHandle( @@ -71,7 +68,7 @@ public Optional findLatestActive(UserHandle userHandle, String phoneE @Override public OptionalInt incrementAttempts(UserHandle userHandle, String otpId) { - return wrap( + return JdbiSupport.wrap( "otp.incrementAttempts", () -> jdbi.withHandle( @@ -102,7 +99,7 @@ public OptionalInt incrementAttempts(UserHandle userHandle, String otpId) { @Override public boolean consume(UserHandle userHandle, String otpId) { - return wrap( + return JdbiSupport.wrap( "otp.consume", () -> jdbi.withHandle( @@ -119,7 +116,7 @@ public boolean consume(UserHandle userHandle, String otpId) { @Override public int countSince(UserHandle userHandle, String phoneE164, Instant since) { - return wrap( + return JdbiSupport.wrap( "otp.countSince", () -> jdbi.withHandle( @@ -136,7 +133,7 @@ public int countSince(UserHandle userHandle, String phoneE164, Instant since) { @Override public int deleteByUserHandle(UserHandle userHandle) { - return wrap( + return JdbiSupport.wrap( "otp.deleteByUserHandle", () -> jdbi.withHandle( @@ -146,21 +143,6 @@ public int deleteByUserHandle(UserHandle userHandle) { .execute())); } - /** - * Runs {@code body} and wraps any {@link JdbiException} in a {@link PkAuthPersistenceException} - * so adapter exception mappers can produce a uniform 503. Existing {@link - * PkAuthPersistenceException} instances are re-thrown unchanged. - */ - 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); - } - } - private static final RowMapper MAPPER = (rs, ctx) -> readRow(rs); private static StoredOtp readRow(ResultSet rs) throws SQLException { diff --git a/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiRefreshTokenRepository.java b/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiRefreshTokenRepository.java index 000cb18..fe008e1 100644 --- a/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiRefreshTokenRepository.java +++ b/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiRefreshTokenRepository.java @@ -5,7 +5,6 @@ import com.codeheadsystems.pkauth.refresh.RefreshTokenRecord; import com.codeheadsystems.pkauth.refresh.RevokeReason; import com.codeheadsystems.pkauth.refresh.spi.RefreshTokenRepository; -import com.codeheadsystems.pkauth.spi.PkAuthPersistenceException; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Types; @@ -15,10 +14,8 @@ import java.util.List; import java.util.Objects; import java.util.Optional; -import java.util.function.Supplier; import org.jdbi.v3.core.Handle; import org.jdbi.v3.core.Jdbi; -import org.jdbi.v3.core.JdbiException; import org.jdbi.v3.core.mapper.RowMapper; import org.jdbi.v3.core.statement.Update; import org.jspecify.annotations.Nullable; @@ -41,7 +38,7 @@ public JdbiRefreshTokenRepository(Jdbi jdbi) { @Override public void create(RefreshTokenRecord record) { - wrap( + JdbiSupport.wrap( "refresh_tokens.create", () -> { jdbi.useHandle(h -> insert(h, record)); @@ -51,7 +48,7 @@ public void create(RefreshTokenRecord record) { @Override public Optional findByRefreshId(String refreshId) { - return wrap( + return JdbiSupport.wrap( "refresh_tokens.findByRefreshId", () -> jdbi.withHandle( @@ -68,7 +65,7 @@ public boolean rotateAtomically( Objects.requireNonNull(parentRefreshId, "parentRefreshId"); Objects.requireNonNull(now, "now"); Objects.requireNonNull(successor, "successor"); - return wrap( + return JdbiSupport.wrap( "refresh_tokens.rotateAtomically", () -> jdbi.inTransaction( @@ -96,7 +93,7 @@ public boolean rotateAtomically( @Override public int revokeFamily(String familyId, Instant now, RevokeReason reason) { - return wrap( + return JdbiSupport.wrap( "refresh_tokens.revokeFamily", () -> jdbi.withHandle( @@ -113,7 +110,7 @@ public int revokeFamily(String familyId, Instant now, RevokeReason reason) { @Override public int revokeAllForUser(UserHandle userHandle, Instant now, RevokeReason reason) { - return wrap( + return JdbiSupport.wrap( "refresh_tokens.revokeAllForUser", () -> jdbi.withHandle( @@ -130,7 +127,7 @@ public int revokeAllForUser(UserHandle userHandle, Instant now, RevokeReason rea @Override public List findByUserHandle(UserHandle userHandle) { - return wrap( + return JdbiSupport.wrap( "refresh_tokens.findByUserHandle", () -> jdbi.withHandle( @@ -145,7 +142,7 @@ public List findByUserHandle(UserHandle userHandle) { @Override public List findByFamilyId(String familyId) { - return wrap( + return JdbiSupport.wrap( "refresh_tokens.findByFamilyId", () -> jdbi.withHandle( @@ -160,7 +157,7 @@ public List findByFamilyId(String familyId) { @Override public int deleteExpiredBefore(Instant cutoff) { - return wrap( + return JdbiSupport.wrap( "refresh_tokens.deleteExpiredBefore", () -> jdbi.withHandle( @@ -222,16 +219,6 @@ private static void bindNullable( } } - 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); - } - } - private static final RowMapper MAPPER = (rs, ctx) -> readRow(rs); private static RefreshTokenRecord readRow(ResultSet rs) throws SQLException { diff --git a/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiSupport.java b/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiSupport.java new file mode 100644 index 0000000..e35639a --- /dev/null +++ b/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiSupport.java @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.persistence.jdbi; + +import com.codeheadsystems.pkauth.spi.PkAuthPersistenceException; +import java.util.function.Supplier; +import org.jdbi.v3.core.JdbiException; + +/** + * Internal helpers shared across the JDBI repositories. Package-private: this is not part of the + * module's public surface. + */ +final class JdbiSupport { + + private JdbiSupport() {} + + /** + * Runs {@code body} and wraps any {@link JdbiException} in a {@link PkAuthPersistenceException} + * so adapter exception mappers can produce a uniform 503. An already-wrapped {@link + * PkAuthPersistenceException} (e.g. thrown by a nested call) propagates unchanged so its original + * {@code op} context is preserved. + * + * @param op short operation label carried on the wrapped exception (never the raw SQL). + * @param body the persistence operation to run. + * @param the operation's return type. + * @return whatever {@code body} returns. + */ + 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/JdbiUserLookup.java b/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiUserLookup.java index 5d5596f..30f95a8 100644 --- a/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiUserLookup.java +++ b/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiUserLookup.java @@ -2,15 +2,12 @@ package com.codeheadsystems.pkauth.persistence.jdbi; import com.codeheadsystems.pkauth.api.UserHandle; -import com.codeheadsystems.pkauth.spi.PkAuthPersistenceException; import com.codeheadsystems.pkauth.spi.UserLookup; import java.sql.ResultSet; import java.sql.SQLException; 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; import org.jdbi.v3.core.mapper.RowMapper; /** @@ -28,7 +25,7 @@ public JdbiUserLookup(Jdbi jdbi) { @Override public Optional findHandleByUsername(String username) { - return wrap( + return JdbiSupport.wrap( "users.findHandleByUsername", () -> jdbi.withHandle( @@ -42,7 +39,7 @@ public Optional findHandleByUsername(String username) { @Override public Optional findViewByHandle(UserHandle handle) { - return wrap( + return JdbiSupport.wrap( "users.findViewByHandle", () -> jdbi.withHandle( @@ -57,7 +54,7 @@ public Optional findViewByHandle(UserHandle handle) { public UserHandle getOrCreateHandle(String username) { Objects.requireNonNull(username, "username"); UserHandle candidate = UserHandle.random(); - return wrap( + return JdbiSupport.wrap( "users.getOrCreateHandle", () -> jdbi.withHandle( @@ -81,7 +78,7 @@ public UserHandle getOrCreateHandle(String username) { /** Pre-registers a user with the supplied display name (test fixture support). */ public UserHandle register(String username, String displayName) { UserHandle handle = UserHandle.random(); - wrap( + JdbiSupport.wrap( "users.register", () -> { jdbi.useHandle( @@ -98,20 +95,6 @@ public UserHandle register(String username, String displayName) { return handle; } - /** - * Runs {@code body} and wraps any {@link JdbiException} in a {@link PkAuthPersistenceException} - * so adapter exception mappers can produce a uniform 503. - */ - 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); - } - } - private static final RowMapper VIEW_MAPPER = (rs, ctx) -> readView(rs); private static UserView readView(ResultSet rs) throws SQLException { From e73f7d439a04506b5721bf24cbb005ceb680a9cc Mon Sep 17 00:00:00 2001 From: Ned Wolpert Date: Thu, 11 Jun 2026 21:02:58 -0700 Subject: [PATCH 04/10] refactor(refresh-tokens): centralize amr storage codec joinAmr/splitAmr (RFC 8176 amr <-> comma-separated storage form) was duplicated across the JDBI and DynamoDB refresh-token repositories, with the DynamoDB save path additionally inlining String.join. The two copies could silently diverge on the ["user"] legacy fallback or the separator. Introduce a single Amr.encode/decode in pk-auth-refresh-tokens (refresh.spi, alongside RefreshTokenRepository) and route both adapters through it. The comma separator stays safe because RefreshTokenRecord already rejects amr entries containing ',' at construction. Adds a unit test covering round-trip and the null/blank legacy fallback; all refresh-token persistence integration tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../DynamoDbRefreshTokenRepository.java | 16 ++---- .../jdbi/JdbiRefreshTokenRepository.java | 21 ++------ .../pkauth/refresh/spi/Amr.java | 51 +++++++++++++++++++ .../pkauth/refresh/spi/AmrTest.java | 43 ++++++++++++++++ 4 files changed, 100 insertions(+), 31 deletions(-) create mode 100644 pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/spi/Amr.java create mode 100644 pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/spi/AmrTest.java diff --git a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbRefreshTokenRepository.java b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbRefreshTokenRepository.java index ed5d413..d760cff 100644 --- a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbRefreshTokenRepository.java +++ b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbRefreshTokenRepository.java @@ -6,6 +6,7 @@ import com.codeheadsystems.pkauth.refresh.RefreshTokenConfig; import com.codeheadsystems.pkauth.refresh.RefreshTokenRecord; import com.codeheadsystems.pkauth.refresh.RevokeReason; +import com.codeheadsystems.pkauth.refresh.spi.Amr; import com.codeheadsystems.pkauth.refresh.spi.RefreshTokenRepository; import java.time.Duration; import java.time.Instant; @@ -452,7 +453,7 @@ private RefreshTokenItem toItem(RefreshTokenRecord r, String pk, String sk) { item.setUsedAtIso(r.usedAt().map(Instant::toString).orElse(null)); item.setRevokedAtIso(r.revokedAt().map(Instant::toString).orElse(null)); item.setRevokedReason(r.revokedReason().map(Enum::name).orElse(null)); - item.setAmr(String.join(",", r.amr())); + item.setAmr(Amr.encode(r.amr())); item.setExpiresAtEpoch(r.expiresAt().getEpochSecond()); item.setTtl(r.expiresAt().plus(cleanupRetention).getEpochSecond()); return item; @@ -474,18 +475,7 @@ private static RefreshTokenRecord toRecord(RefreshTokenItem item) { Optional.ofNullable(item.getUsedAtIso()).map(Instant::parse), Optional.ofNullable(item.getRevokedAtIso()).map(Instant::parse), Optional.ofNullable(item.getRevokedReason()).map(RevokeReason::valueOf), - splitAmr(item.getAmr())); - } - - /** - * Parses the stored comma-separated {@code amr} attribute back into a list. A null/blank value - * (items written before the attribute existed) maps to the generic {@code ["user"]}. - */ - private static List splitAmr(String stored) { - if (stored == null || stored.isBlank()) { - return List.of("user"); - } - return List.of(stored.split(",")); + Amr.decode(item.getAmr())); } // Defensive: silence unused-import warnings on classes pulled in for the TransactWrite path. diff --git a/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiRefreshTokenRepository.java b/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiRefreshTokenRepository.java index fe008e1..688dfe9 100644 --- a/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiRefreshTokenRepository.java +++ b/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiRefreshTokenRepository.java @@ -4,6 +4,7 @@ import com.codeheadsystems.pkauth.api.UserHandle; import com.codeheadsystems.pkauth.refresh.RefreshTokenRecord; import com.codeheadsystems.pkauth.refresh.RevokeReason; +import com.codeheadsystems.pkauth.refresh.spi.Amr; import com.codeheadsystems.pkauth.refresh.spi.RefreshTokenRepository; import java.sql.ResultSet; import java.sql.SQLException; @@ -192,7 +193,7 @@ private static void insert(Handle h, RefreshTokenRecord r) { .bind("iat", OffsetDateTime.ofInstant(r.issuedAt(), ZoneOffset.UTC)) .bind("exp", OffsetDateTime.ofInstant(r.expiresAt(), ZoneOffset.UTC)) .bind("reason", r.revokedReason().map(Enum::name).orElse(null)) - .bind("amr", joinAmr(r.amr()))) { + .bind("amr", Amr.encode(r.amr()))) { // used_at and revoked_at are TIMESTAMPTZ; JDBI's untyped-null default (Types.VARCHAR) is // rejected by Postgres against a TIMESTAMPTZ column. Force Types.TIMESTAMP_WITH_TIMEZONE on // the null branch. @@ -240,22 +241,6 @@ private static RefreshTokenRecord readRow(ResultSet rs) throws SQLException { Optional.ofNullable(usedAt).map(OffsetDateTime::toInstant), Optional.ofNullable(revokedAt).map(OffsetDateTime::toInstant), Optional.ofNullable(revokedReasonStr).map(RevokeReason::valueOf), - splitAmr(rs.getString("amr"))); - } - - /** Serializes the RFC 8176 {@code amr} references as a comma-separated string for storage. */ - private static String joinAmr(List amr) { - return String.join(",", amr); - } - - /** - * Parses the stored comma-separated {@code amr} string back into a list. A null/blank value (rows - * written before the V10 column existed) maps to the generic {@code ["user"]}. - */ - private static List splitAmr(String stored) { - if (stored == null || stored.isBlank()) { - return List.of("user"); - } - return List.of(stored.split(",")); + Amr.decode(rs.getString("amr"))); } } diff --git a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/spi/Amr.java b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/spi/Amr.java new file mode 100644 index 0000000..8985e2b --- /dev/null +++ b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/spi/Amr.java @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.refresh.spi; + +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * Storage codec for the RFC 8176 {@code amr} (authentication method references) list carried on a + * {@link com.codeheadsystems.pkauth.refresh.RefreshTokenRecord}. Persistence adapters store the + * list as a single comma-separated column/attribute; this codec is the one place that encoding + * lives so the JDBI and DynamoDB implementations cannot silently diverge. + * + *

The comma separator is safe because {@code RefreshTokenRecord} rejects any {@code amr} entry + * containing a {@code ','} at construction time, so a round-trip is lossless. + * + * @since 1.3.1 + */ +public final class Amr { + + /** The generic fallback returned for rows persisted before an {@code amr} column/attribute. */ + private static final List DEFAULT = List.of("user"); + + private Amr() {} + + /** + * Encodes the {@code amr} references for storage as a single comma-separated string. + * + * @param amr the non-empty, comma-free references from a {@code RefreshTokenRecord}. + * @return the comma-joined storage form. + * @since 1.3.1 + */ + public static String encode(List amr) { + return String.join(",", amr); + } + + /** + * Decodes the stored comma-separated {@code amr} string back into a list. A {@code null} or blank + * value — a row written before the {@code amr} column/attribute existed — maps to the generic + * {@code ["user"]} so older tokens still satisfy the record's non-empty contract. + * + * @param stored the stored comma-separated value, possibly {@code null} or blank. + * @return the decoded references, never empty. + * @since 1.3.1 + */ + public static List decode(@Nullable String stored) { + if (stored == null || stored.isBlank()) { + return DEFAULT; + } + return List.of(stored.split(",")); + } +} diff --git a/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/spi/AmrTest.java b/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/spi/AmrTest.java new file mode 100644 index 0000000..177dda5 --- /dev/null +++ b/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/spi/AmrTest.java @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.refresh.spi; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; + +/** Locks the storage encoding for the {@code amr} list shared by every persistence adapter. */ +class AmrTest { + + @Test + void encodeJoinsWithComma() { + assertThat(Amr.encode(List.of("pwd", "otp", "hwk"))).isEqualTo("pwd,otp,hwk"); + } + + @Test + void encodeSingleHasNoSeparator() { + assertThat(Amr.encode(List.of("user"))).isEqualTo("user"); + } + + @Test + void decodeSplitsOnComma() { + assertThat(Amr.decode("pwd,otp,hwk")).containsExactly("pwd", "otp", "hwk"); + } + + @Test + void roundTripIsLossless() { + List amr = List.of("swk", "user", "mfa"); + assertThat(Amr.decode(Amr.encode(amr))).isEqualTo(amr); + } + + @Test + void decodeNullFallsBackToGenericUser() { + assertThat(Amr.decode(null)).containsExactly("user"); + } + + @Test + void decodeBlankFallsBackToGenericUser() { + assertThat(Amr.decode("")).containsExactly("user"); + assertThat(Amr.decode(" ")).containsExactly("user"); + } +} From 57524a08b2882589d9c9eeaf85cb848f6f66915a Mon Sep 17 00:00:00 2001 From: Ned Wolpert Date: Thu, 11 Jun 2026 21:08:17 -0700 Subject: [PATCH 05/10] refactor(dynamodb): centralize single-table key prefixes and instant codec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-table key prefixes (USER#, AT#, CRED#, OTP#, BACKUP#, CHAL#, RT#, RTF#, USERNAME#) lived as free-floating string literals across 12 files. A writer/reader prefix mismatch would not throw — it would silently fail to find the row — so any drift was invisible. - Add package-private DynamoKeys holding every prefix once; replace all loose literals with the constants. DynamoDbRefreshTokenRepository's local PREFIX constants now delegate to DynamoKeys, with a comment documenting that PRIMARY_PK and INDEX_SK intentionally share RT#. - Add an instant storage codec to DynamoDbSupport (encodeInstant / encodeInstantOrNull / parseInstant / parseInstantOrNull) so the ISO encoding has one definition. encodeInstant is deliberately Instant.toString() so existing rows stay readable (ordering still uses epoch seconds, per RefreshTokenItem's note). Behaviour is byte-for-byte identical; the full DynamoDB Testcontainers suite (which round-trips every item type) passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../persistence/dynamodb/BackupCodeItem.java | 13 +++-- .../persistence/dynamodb/ChallengeItem.java | 2 +- .../persistence/dynamodb/CredentialItem.java | 15 +++--- .../dynamodb/DynamoDbAccessTokenStore.java | 53 +++++++++++++------ .../DynamoDbBackupCodeRepository.java | 21 +++++--- .../dynamodb/DynamoDbChallengeStore.java | 2 +- .../DynamoDbCredentialRepository.java | 22 ++++++-- .../dynamodb/DynamoDbOtpRepository.java | 24 +++++---- .../DynamoDbRefreshTokenRepository.java | 30 ++++++----- .../persistence/dynamodb/DynamoDbSupport.java | 35 ++++++++++++ .../dynamodb/DynamoDbUserLookup.java | 5 +- .../persistence/dynamodb/DynamoKeys.java | 43 +++++++++++++++ .../pkauth/persistence/dynamodb/OtpItem.java | 13 +++-- .../pkauth/persistence/dynamodb/UserItem.java | 4 +- 14 files changed, 203 insertions(+), 79 deletions(-) create mode 100644 pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoKeys.java diff --git a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/BackupCodeItem.java b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/BackupCodeItem.java index fb0f8dc..6e43f05 100644 --- a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/BackupCodeItem.java +++ b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/BackupCodeItem.java @@ -4,7 +4,6 @@ import com.codeheadsystems.pkauth.api.UserHandle; import com.codeheadsystems.pkauth.json.Base64Url; import com.codeheadsystems.pkauth.spi.BackupCodeRepository.StoredBackupCode; -import java.time.Instant; 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; @@ -101,15 +100,15 @@ public void setCreatedAt(String createdAt) { public static BackupCodeItem fromRecord(StoredBackupCode c) { String userB64 = Base64Url.encode(c.userHandle().value()); BackupCodeItem item = new BackupCodeItem(); - item.setPk("USER#" + userB64); - item.setSk("BACKUP#" + c.codeId()); + item.setPk(DynamoKeys.USER + userB64); + item.setSk(DynamoKeys.BACKUP + c.codeId()); item.setEntityType("BackupCode"); item.setCodeId(c.codeId()); item.setUserHandle(userB64); item.setHashedCode(c.hashedCode()); item.setConsumed(c.consumed()); - item.setConsumedAt(c.consumedAt() == null ? null : c.consumedAt().toString()); - item.setCreatedAt(c.createdAt().toString()); + item.setConsumedAt(DynamoDbSupport.encodeInstantOrNull(c.consumedAt())); + item.setCreatedAt(DynamoDbSupport.encodeInstant(c.createdAt())); return item; } @@ -120,7 +119,7 @@ public StoredBackupCode toRecord() { UserHandle.of(Base64Url.decode(userHandle)), hashedCode, consumed, - Instant.parse(createdAt), - consumedAt == null ? null : Instant.parse(consumedAt)); + DynamoDbSupport.parseInstant(createdAt), + DynamoDbSupport.parseInstantOrNull(consumedAt)); } } diff --git a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/ChallengeItem.java b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/ChallengeItem.java index 2209b75..04c61bd 100644 --- a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/ChallengeItem.java +++ b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/ChallengeItem.java @@ -92,7 +92,7 @@ public void setExpiresAt(Long expiresAt) { /** Constructs a DynamoDB row from a ChallengeId + record + computed expiry epoch second. */ public static ChallengeItem build(ChallengeId id, ChallengeRecord record) { ChallengeItem item = new ChallengeItem(); - item.setPk("CHAL#" + id.value()); + item.setPk(DynamoKeys.CHAL + id.value()); item.setSk("META"); item.setEntityType("Challenge"); item.setTtl(record.expiresAt().getEpochSecond()); diff --git a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/CredentialItem.java b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/CredentialItem.java index ed302da..48fb732 100644 --- a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/CredentialItem.java +++ b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/CredentialItem.java @@ -6,7 +6,6 @@ import com.codeheadsystems.pkauth.api.UserHandle; import com.codeheadsystems.pkauth.credential.CredentialRecord; import com.codeheadsystems.pkauth.json.Base64Url; -import java.time.Instant; import java.util.EnumSet; import java.util.LinkedHashSet; import java.util.Set; @@ -197,10 +196,10 @@ public static CredentialItem fromRecord(CredentialRecord r) { String credIdB64 = r.credentialId().b64url(); String userB64 = Base64Url.encode(r.userHandle().value()); CredentialItem item = new CredentialItem(); - item.setPk("USER#" + userB64); - item.setSk("CRED#" + credIdB64); + item.setPk(DynamoKeys.USER + userB64); + item.setSk(DynamoKeys.CRED + credIdB64); item.setEntityType("Credential"); - item.setGsi1pk("CRED#" + credIdB64); + item.setGsi1pk(DynamoKeys.CRED + credIdB64); item.setGsi1sk("META"); item.setCredentialId(credIdB64); item.setUserHandle(userB64); @@ -219,8 +218,8 @@ public static CredentialItem fromRecord(CredentialRecord r) { } item.setBackupEligible(r.backupEligible()); item.setBackupState(r.backupState()); - item.setCreatedAt(r.createdAt().toString()); - item.setLastUsedAt(r.lastUsedAt() == null ? null : r.lastUsedAt().toString()); + item.setCreatedAt(DynamoDbSupport.encodeInstant(r.createdAt())); + item.setLastUsedAt(DynamoDbSupport.encodeInstantOrNull(r.lastUsedAt())); return item; } @@ -242,7 +241,7 @@ public CredentialRecord toRecord() { tx, backupEligible, backupState, - Instant.parse(createdAt), - lastUsedAt == null ? null : Instant.parse(lastUsedAt)); + DynamoDbSupport.parseInstant(createdAt), + DynamoDbSupport.parseInstantOrNull(lastUsedAt)); } } 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 index e7eae4b..71694e0 100644 --- 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 @@ -60,8 +60,8 @@ public void record( // Primary item (jti-keyed) — the load-bearing one for exists/validate. table.putItem( buildItem( - "AT#" + jti, - "AT#" + jti, + DynamoKeys.AT + jti, + DynamoKeys.AT + jti, jti, userB64, audience, @@ -72,8 +72,8 @@ public void record( // User-index item — for deleteAllForUser fan-out. table.putItem( buildItem( - "USER#" + userB64, - "AT#" + jti, + DynamoKeys.USER + userB64, + DynamoKeys.AT + jti, jti, userB64, audience, @@ -93,7 +93,11 @@ public boolean exists(String jti) { return DynamoDbSupport.wrap( "access_tokens.exists", () -> - table.getItem(Key.builder().partitionValue("AT#" + jti).sortValue("AT#" + jti).build()) + table.getItem( + Key.builder() + .partitionValue(DynamoKeys.AT + jti) + .sortValue(DynamoKeys.AT + jti) + .build()) != null); } @@ -107,7 +111,10 @@ public boolean delete(UserHandle userHandle, String jti) { () -> { AccessTokenItem primary = table.getItem( - Key.builder().partitionValue("AT#" + jti).sortValue("AT#" + jti).build()); + Key.builder() + .partitionValue(DynamoKeys.AT + jti) + .sortValue(DynamoKeys.AT + jti) + .build()); if (primary == null) { return false; } @@ -119,13 +126,16 @@ public boolean delete(UserHandle userHandle, String jti) { } // Delete primary first — the load-bearing row for validation. table.deleteItem( - Key.builder().partitionValue("AT#" + jti).sortValue("AT#" + jti).build()); + Key.builder() + .partitionValue(DynamoKeys.AT + jti) + .sortValue(DynamoKeys.AT + jti) + .build()); // Best-effort: delete the user-index pointer too. If this fails, native TTL or a // later deleteExpiredBefore will eventually clear it. table.deleteItem( Key.builder() - .partitionValue("USER#" + primary.getUserHandleB64u()) - .sortValue("AT#" + jti) + .partitionValue(DynamoKeys.USER + primary.getUserHandleB64u()) + .sortValue(DynamoKeys.AT + jti) .build()); return true; }); @@ -141,7 +151,10 @@ public int deleteAllForUser(UserHandle userHandle) { table .query( QueryConditional.sortBeginsWith( - Key.builder().partitionValue("USER#" + userB64).sortValue("AT#").build())) + Key.builder() + .partitionValue(DynamoKeys.USER + userB64) + .sortValue(DynamoKeys.AT) + .build())) .stream() .flatMap(page -> page.items().stream()) .forEach( @@ -149,7 +162,10 @@ public int deleteAllForUser(UserHandle userHandle) { 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()); + Key.builder() + .partitionValue(DynamoKeys.AT + jti) + .sortValue(DynamoKeys.AT + jti) + .build()); // Then the user-index pointer we found. table.deleteItem( Key.builder().partitionValue(item.getPk()).sortValue(item.getSk()).build()); @@ -169,19 +185,22 @@ public int deleteExpiredBefore(Instant before) { long beforeEpoch = before.getEpochSecond(); int[] removed = {0}; table.scan().items().stream() - .filter(item -> "AT#".regionMatches(0, item.getPk(), 0, 3)) + .filter(item -> DynamoKeys.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()); + Key.builder() + .partitionValue(DynamoKeys.AT + jti) + .sortValue(DynamoKeys.AT + jti) + .build()); if (item.getUserHandleB64u() != null) { table.deleteItem( Key.builder() - .partitionValue("USER#" + item.getUserHandleB64u()) - .sortValue("AT#" + jti) + .partitionValue(DynamoKeys.USER + item.getUserHandleB64u()) + .sortValue(DynamoKeys.AT + jti) .build()); } removed[0]++; @@ -207,8 +226,8 @@ private static AccessTokenItem buildItem( item.setUserHandleB64u(userHandleB64u); item.setAudience(audience); item.setDeviceId(deviceId); - item.setIssuedAtIso(issuedAt.toString()); - item.setExpiresAtIso(expiresAt.toString()); + item.setIssuedAtIso(DynamoDbSupport.encodeInstant(issuedAt)); + item.setExpiresAtIso(DynamoDbSupport.encodeInstant(expiresAt)); item.setTtl(ttl); return item; } diff --git a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbBackupCodeRepository.java b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbBackupCodeRepository.java index 1a88004..84d07ae 100644 --- a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbBackupCodeRepository.java +++ b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbBackupCodeRepository.java @@ -59,7 +59,10 @@ public List findByUserHandle(UserHandle userHandle) { return table .query( QueryConditional.sortBeginsWith( - Key.builder().partitionValue("USER#" + userB64).sortValue("BACKUP#").build())) + Key.builder() + .partitionValue(DynamoKeys.USER + userB64) + .sortValue(DynamoKeys.BACKUP) + .build())) .stream() .flatMap(page -> page.items().stream()) .map(BackupCodeItem::toRecord) @@ -84,8 +87,8 @@ public boolean consume(UserHandle userHandle, String codeId, Instant consumedAt) .tableName(tableName) .key( Map.of( - "pk", AttributeValue.fromS("USER#" + userB64), - "sk", AttributeValue.fromS("BACKUP#" + codeId))) + "pk", AttributeValue.fromS(DynamoKeys.USER + userB64), + "sk", AttributeValue.fromS(DynamoKeys.BACKUP + codeId))) .updateExpression("SET #c = :true, consumedAt = :consumedAt") .conditionExpression("attribute_exists(pk) AND #c = :false") .expressionAttributeNames(Map.of("#c", "consumed")) @@ -93,7 +96,8 @@ public boolean consume(UserHandle userHandle, String codeId, Instant consumedAt) Map.of( ":true", AttributeValue.fromBool(true), ":false", AttributeValue.fromBool(false), - ":consumedAt", AttributeValue.fromS(consumedAt.toString()))) + ":consumedAt", + AttributeValue.fromS(DynamoDbSupport.encodeInstant(consumedAt)))) .build()); return true; } catch (ConditionalCheckFailedException ignored) { @@ -114,7 +118,10 @@ public void deleteByUserHandle(UserHandle userHandle) { table .query( QueryConditional.sortBeginsWith( - Key.builder().partitionValue("USER#" + userB64).sortValue("BACKUP#").build())) + Key.builder() + .partitionValue(DynamoKeys.USER + userB64) + .sortValue(DynamoKeys.BACKUP) + .build())) .stream() .flatMap(page -> page.items().stream()) .forEach( @@ -141,13 +148,13 @@ public void replaceAll(UserHandle userHandle, List records) { Objects.requireNonNull(userHandle, "userHandle"); Objects.requireNonNull(records, "records"); String userB64 = Base64Url.encode(userHandle.value()); - String pk = "USER#" + userB64; + String pk = DynamoKeys.USER + userB64; List existing = table .query( QueryConditional.sortBeginsWith( - Key.builder().partitionValue(pk).sortValue("BACKUP#").build())) + Key.builder().partitionValue(pk).sortValue(DynamoKeys.BACKUP).build())) .stream() .flatMap(page -> page.items().stream()) .toList(); diff --git a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbChallengeStore.java b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbChallengeStore.java index 7d8b9c7..8c685af 100644 --- a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbChallengeStore.java +++ b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbChallengeStore.java @@ -65,7 +65,7 @@ public Optional takeOnce(ChallengeId id) { "challenges.takeOnce", () -> { Map key = new HashMap<>(); - key.put("pk", AttributeValue.fromS("CHAL#" + id.value())); + key.put("pk", AttributeValue.fromS(DynamoKeys.CHAL + id.value())); key.put("sk", AttributeValue.fromS("META")); // Server-side expiry: refuse to delete a row that's already past its expiresAt timestamp. // expiresAt is stored as a numeric epoch-millis attribute so DynamoDB's numeric 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 2fc2368..1c289f1 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 @@ -69,7 +69,10 @@ public Optional findByCredentialId(CredentialId credentialId) return credentialByIdIndex .query( QueryConditional.keyEqualTo( - Key.builder().partitionValue("CRED#" + credIdB64).sortValue("META").build())) + Key.builder() + .partitionValue(DynamoKeys.CRED + credIdB64) + .sortValue("META") + .build())) .stream() .flatMap(page -> page.items().stream()) .findFirst() @@ -86,7 +89,10 @@ public List findByUserHandle(UserHandle userHandle) { return table .query( QueryConditional.sortBeginsWith( - Key.builder().partitionValue("USER#" + userB64).sortValue("CRED#").build())) + Key.builder() + .partitionValue(DynamoKeys.USER + userB64) + .sortValue(DynamoKeys.CRED) + .build())) .stream() .flatMap(page -> page.items().stream()) .map(CredentialItem::toRecord) @@ -110,7 +116,7 @@ public void updateSignCount(CredentialId credentialId, long newCount, Instant la } CredentialItem item = existing.get(); item.setSignCount(newCount); - item.setLastUsedAt(lastUsedAt.toString()); + item.setLastUsedAt(DynamoDbSupport.encodeInstant(lastUsedAt)); // Guard against the lost-update race: two concurrent assertions (e.g. a clone vs. the // real authenticator) would otherwise overwrite a higher stored counter with a lower // one, silently defeating clone detection. The conditional rejects the write unless @@ -206,7 +212,10 @@ public int deleteByUserHandle(UserHandle userHandle) { table .query( QueryConditional.sortBeginsWith( - Key.builder().partitionValue("USER#" + userB64).sortValue("CRED#").build())) + Key.builder() + .partitionValue(DynamoKeys.USER + userB64) + .sortValue(DynamoKeys.CRED) + .build())) .stream() .flatMap(page -> page.items().stream()) .forEach( @@ -224,7 +233,10 @@ private Optional lookupItem(CredentialId credentialId) { return credentialByIdIndex .query( QueryConditional.keyEqualTo( - Key.builder().partitionValue("CRED#" + credIdB64).sortValue("META").build())) + Key.builder() + .partitionValue(DynamoKeys.CRED + credIdB64) + .sortValue("META") + .build())) .stream() .flatMap(page -> page.items().stream()) .findFirst(); 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 9fbec54..dd62270 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 @@ -57,7 +57,10 @@ public Optional findLatestActive(UserHandle userHandle, String phoneE return table .query( QueryConditional.sortBeginsWith( - Key.builder().partitionValue("USER#" + userB64).sortValue("OTP#").build())) + Key.builder() + .partitionValue(DynamoKeys.USER + userB64) + .sortValue(DynamoKeys.OTP) + .build())) .stream() .flatMap(page -> page.items().stream()) .filter(i -> phoneE164.equals(i.getPhoneE164())) @@ -83,8 +86,8 @@ public OptionalInt incrementAttempts(UserHandle userHandle, String otpId) { .tableName(tableName) .key( Map.of( - "pk", AttributeValue.fromS("USER#" + userB64), - "sk", AttributeValue.fromS("OTP#" + otpId))) + "pk", AttributeValue.fromS(DynamoKeys.USER + userB64), + "sk", AttributeValue.fromS(DynamoKeys.OTP + otpId))) .updateExpression("SET #a = if_not_exists(#a, :zero) + :one") .conditionExpression("attribute_exists(pk)") .expressionAttributeNames(Map.of("#a", "attempts")) @@ -120,8 +123,8 @@ public boolean consume(UserHandle userHandle, String otpId) { .tableName(tableName) .key( Map.of( - "pk", AttributeValue.fromS("USER#" + userB64), - "sk", AttributeValue.fromS("OTP#" + otpId))) + "pk", AttributeValue.fromS(DynamoKeys.USER + userB64), + "sk", AttributeValue.fromS(DynamoKeys.OTP + otpId))) .updateExpression("SET #c = :true") .conditionExpression("attribute_exists(pk) AND #c = :false") .expressionAttributeNames(Map.of("#c", "consumed")) @@ -148,7 +151,10 @@ public int deleteByUserHandle(UserHandle userHandle) { table .query( QueryConditional.sortBeginsWith( - Key.builder().partitionValue("USER#" + userB64).sortValue("OTP#").build())) + Key.builder() + .partitionValue(DynamoKeys.USER + userB64) + .sortValue(DynamoKeys.OTP) + .build())) .stream() .flatMap(page -> page.items().stream()) .forEach( @@ -172,13 +178,13 @@ public int countSince(UserHandle userHandle, String phoneE164, Instant since) { .query( QueryConditional.sortBeginsWith( Key.builder() - .partitionValue("USER#" + userB64) - .sortValue("OTP#") + .partitionValue(DynamoKeys.USER + userB64) + .sortValue(DynamoKeys.OTP) .build())) .stream() .flatMap(page -> page.items().stream()) .filter(i -> phoneE164.equals(i.getPhoneE164())) - .filter(i -> !Instant.parse(i.getCreatedAt()).isBefore(since)) + .filter(i -> !DynamoDbSupport.parseInstant(i.getCreatedAt()).isBefore(since)) .count(); }); } diff --git a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbRefreshTokenRepository.java b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbRefreshTokenRepository.java index d760cff..badc3e2 100644 --- a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbRefreshTokenRepository.java +++ b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbRefreshTokenRepository.java @@ -50,10 +50,13 @@ */ public final class DynamoDbRefreshTokenRepository implements RefreshTokenRepository { - private static final String FAMILY_PK_PREFIX = "RTF#"; - private static final String USER_PK_PREFIX = "USER#"; - private static final String PRIMARY_PK_PREFIX = "RT#"; - private static final String INDEX_SK_PREFIX = "RT#"; + private static final String FAMILY_PK_PREFIX = DynamoKeys.RTF; + private static final String USER_PK_PREFIX = DynamoKeys.USER; + // PRIMARY_PK_PREFIX and INDEX_SK_PREFIX intentionally share the same DynamoKeys.RT value: the + // primary item's partition key and the jti-index's sort key both namespace on "RT#". They are + // named separately because they play distinct roles in the single-table layout. + private static final String PRIMARY_PK_PREFIX = DynamoKeys.RT; + private static final String INDEX_SK_PREFIX = DynamoKeys.RT; private final DynamoDbEnhancedClient enhanced; private final DynamoDbTable table; @@ -134,7 +137,7 @@ public boolean rotateAtomically( RefreshTokenItem parentMark = new RefreshTokenItem(); parentMark.setPk(PRIMARY_PK_PREFIX + parentRefreshId); parentMark.setSk(PRIMARY_PK_PREFIX + parentRefreshId); - parentMark.setUsedAtIso(now.toString()); + parentMark.setUsedAtIso(DynamoDbSupport.encodeInstant(now)); Expression freshness = Expression.builder() @@ -198,7 +201,7 @@ public int revokeFamily(String familyId, Instant now, RevokeReason reason) { // Query the family-index for every member, then mutate the primary item of each (the // primary is the authority on revoked_at). int[] revoked = {0}; - String nowIso = now.toString(); + String nowIso = DynamoDbSupport.encodeInstant(now); table .query( QueryConditional.sortBeginsWith( @@ -248,7 +251,7 @@ public int revokeAllForUser(UserHandle userHandle, Instant now, RevokeReason rea () -> { String userB64 = Base64Url.encode(userHandle.value()); int[] revoked = {0}; - String nowIso = now.toString(); + String nowIso = DynamoDbSupport.encodeInstant(now); table .query( QueryConditional.sortBeginsWith( @@ -373,9 +376,10 @@ public int deleteExpiredBefore(Instant cutoff) { .filter( item -> (item.getUsedAtIso() != null - && Instant.parse(item.getUsedAtIso()).isBefore(cutoff)) + && DynamoDbSupport.parseInstant(item.getUsedAtIso()).isBefore(cutoff)) || (item.getRevokedAtIso() != null - && Instant.parse(item.getRevokedAtIso()).isBefore(cutoff))) + && DynamoDbSupport.parseInstant(item.getRevokedAtIso()) + .isBefore(cutoff))) .forEach( item -> { deleteAllItems(item); @@ -448,8 +452,8 @@ private RefreshTokenItem toItem(RefreshTokenRecord r, String pk, String sk) { item.setDeviceId(r.deviceId().orElse(null)); item.setFamilyId(r.familyId()); item.setParentRefreshId(r.parentRefreshId().orElse(null)); - item.setIssuedAtIso(r.issuedAt().toString()); - item.setExpiresAtIso(r.expiresAt().toString()); + item.setIssuedAtIso(DynamoDbSupport.encodeInstant(r.issuedAt())); + item.setExpiresAtIso(DynamoDbSupport.encodeInstant(r.expiresAt())); item.setUsedAtIso(r.usedAt().map(Instant::toString).orElse(null)); item.setRevokedAtIso(r.revokedAt().map(Instant::toString).orElse(null)); item.setRevokedReason(r.revokedReason().map(Enum::name).orElse(null)); @@ -470,8 +474,8 @@ private static RefreshTokenRecord toRecord(RefreshTokenItem item) { Optional.ofNullable(item.getDeviceId()), item.getFamilyId(), Optional.ofNullable(item.getParentRefreshId()), - Instant.parse(item.getIssuedAtIso()), - Instant.parse(item.getExpiresAtIso()), + DynamoDbSupport.parseInstant(item.getIssuedAtIso()), + DynamoDbSupport.parseInstant(item.getExpiresAtIso()), Optional.ofNullable(item.getUsedAtIso()).map(Instant::parse), Optional.ofNullable(item.getRevokedAtIso()).map(Instant::parse), Optional.ofNullable(item.getRevokedReason()).map(RevokeReason::valueOf), diff --git a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbSupport.java b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbSupport.java index acc48b4..d3d47db 100644 --- a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbSupport.java +++ b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbSupport.java @@ -2,7 +2,9 @@ package com.codeheadsystems.pkauth.persistence.dynamodb; import com.codeheadsystems.pkauth.spi.PkAuthPersistenceException; +import java.time.Instant; import java.util.function.Supplier; +import org.jspecify.annotations.Nullable; import software.amazon.awssdk.core.exception.SdkException; /** @@ -35,4 +37,37 @@ static T wrap(String op, Supplier body) { throw new PkAuthPersistenceException(op, e.getMessage(), e); } } + + /** + * Encodes an {@link Instant} to its ISO-8601 storage form. This is the single definition of the + * table's instant encoding; it is deliberately {@link Instant#toString()} so existing rows remain + * readable (see {@code RefreshTokenItem} for why ordering still uses epoch seconds, not this). + * + * @param instant the instant to encode. + * @return the ISO-8601 string stored on the item. + */ + static String encodeInstant(Instant instant) { + return instant.toString(); + } + + /** Nullable variant of {@link #encodeInstant(Instant)}; {@code null} encodes to {@code null}. */ + static @Nullable String encodeInstantOrNull(@Nullable Instant instant) { + return instant == null ? null : instant.toString(); + } + + /** + * Parses an ISO-8601 storage string back into an {@link Instant}. The inverse of {@link + * #encodeInstant(Instant)}. + * + * @param iso the stored ISO-8601 string. + * @return the decoded instant. + */ + static Instant parseInstant(String iso) { + return Instant.parse(iso); + } + + /** Nullable variant of {@link #parseInstant(String)}; {@code null} decodes to {@code null}. */ + static @Nullable Instant parseInstantOrNull(@Nullable String iso) { + return iso == null ? null : Instant.parse(iso); + } } diff --git a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbUserLookup.java b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbUserLookup.java index 2411c05..7cfc22b 100644 --- a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbUserLookup.java +++ b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbUserLookup.java @@ -45,7 +45,8 @@ public Optional findViewByHandle(UserHandle handle) { () -> { String h = Base64Url.encode(handle.value()); UserItem item = - table.getItem(Key.builder().partitionValue("USER#" + h).sortValue("META").build()); + table.getItem( + Key.builder().partitionValue(DynamoKeys.USER + h).sortValue("META").build()); return Optional.ofNullable(item).map(UserItem::toView); }); } @@ -89,7 +90,7 @@ public UserHandle register(String username, String displayName) { } private Optional lookupByUsername(String username) { - String key = "USERNAME#" + username.toLowerCase(Locale.ROOT); + String key = DynamoKeys.USERNAME + username.toLowerCase(Locale.ROOT); return byUsername .query( QueryConditional.keyEqualTo( diff --git a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoKeys.java b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoKeys.java new file mode 100644 index 0000000..6750fac --- /dev/null +++ b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoKeys.java @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.persistence.dynamodb; + +/** + * Single-table key prefixes for the pk-auth DynamoDB schema. Every partition/sort key in the table + * is a {@code } string; centralizing the prefixes here is what stops a writer and a + * reader from silently disagreeing on a prefix — a mismatch would not throw, it would just fail to + * find the row. Each constant includes its trailing {@code '#'} separator. + * + *

Package-private: these are an internal detail of the DynamoDB adapter, not part of any public + * contract. + */ +final class DynamoKeys { + + /** User partition prefix (also the GSI hash for user-owned items). */ + static final String USER = "USER#"; + + /** Username lookup prefix. */ + static final String USERNAME = "USERNAME#"; + + /** Credential item prefix. */ + static final String CRED = "CRED#"; + + /** Access-token (JTI) item prefix. */ + static final String AT = "AT#"; + + /** One-time-passcode item prefix. */ + static final String OTP = "OTP#"; + + /** Backup-code item prefix. */ + static final String BACKUP = "BACKUP#"; + + /** Ceremony-challenge item prefix. */ + static final String CHAL = "CHAL#"; + + /** Refresh-token item prefix (primary item and jti-index sort key). */ + static final String RT = "RT#"; + + /** Refresh-token family prefix. */ + static final String RTF = "RTF#"; + + private DynamoKeys() {} +} diff --git a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/OtpItem.java b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/OtpItem.java index 9e964cf..12a0ca2 100644 --- a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/OtpItem.java +++ b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/OtpItem.java @@ -4,7 +4,6 @@ import com.codeheadsystems.pkauth.api.UserHandle; import com.codeheadsystems.pkauth.json.Base64Url; import com.codeheadsystems.pkauth.spi.OtpRepository.StoredOtp; -import java.time.Instant; 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; @@ -137,8 +136,8 @@ public void setExpiresAt(String expiresAt) { public static OtpItem fromRecord(StoredOtp o) { String userB64 = Base64Url.encode(o.userHandle().value()); OtpItem item = new OtpItem(); - item.setPk("USER#" + userB64); - item.setSk("OTP#" + o.otpId()); + item.setPk(DynamoKeys.USER + userB64); + item.setSk(DynamoKeys.OTP + o.otpId()); item.setEntityType("OtpCode"); item.setTtl(o.expiresAt().getEpochSecond()); item.setOtpId(o.otpId()); @@ -148,8 +147,8 @@ public static OtpItem fromRecord(StoredOtp o) { item.setAttempts(o.attempts()); item.setMaxAttempts(o.maxAttempts()); item.setConsumed(o.consumed()); - item.setCreatedAt(o.createdAt().toString()); - item.setExpiresAt(o.expiresAt().toString()); + item.setCreatedAt(DynamoDbSupport.encodeInstant(o.createdAt())); + item.setExpiresAt(DynamoDbSupport.encodeInstant(o.expiresAt())); return item; } @@ -163,7 +162,7 @@ public StoredOtp toRecord() { attempts, maxAttempts, consumed, - Instant.parse(createdAt), - Instant.parse(expiresAt)); + DynamoDbSupport.parseInstant(createdAt), + DynamoDbSupport.parseInstant(expiresAt)); } } diff --git a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/UserItem.java b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/UserItem.java index ccfc958..9f48841 100644 --- a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/UserItem.java +++ b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/UserItem.java @@ -104,14 +104,14 @@ public void setGsi1sk(String gsi1sk) { public static UserItem build(UserHandle handle, String username, String displayName) { String h = Base64Url.encode(handle.value()); UserItem item = new UserItem(); - item.setPk("USER#" + h); + item.setPk(DynamoKeys.USER + h); item.setSk("META"); item.setUserHandle(h); item.setUsername(username); item.setDisplayName(displayName); item.setEmailVerified(false); item.setPhoneVerified(false); - item.setGsi1pk("USERNAME#" + username.toLowerCase(java.util.Locale.ROOT)); + item.setGsi1pk(DynamoKeys.USERNAME + username.toLowerCase(java.util.Locale.ROOT)); item.setGsi1sk("META"); return item; } From dfb170d6dd7896018703b3ca5a6f4bdf4227beea Mon Sep 17 00:00:00 2001 From: Ned Wolpert Date: Thu, 11 Jun 2026 21:18:03 -0700 Subject: [PATCH 06/10] feat(admin,spring): let passkey-only hosts boot without alt-flow SPIs A passkey-only Spring host previously had to either provide backup-code/OTP/email SPIs or flip pkauth.dev-mode=true (which wires the plaintext-logging Logging{Email,Sms}Sender) just to start, because the alt-flow service beans and two user-deletion listeners unconditionally required those SPIs, and the admin service hard-required all three alt-flow services. - admin-api: DefaultAdminService now treats backupCodeService / magicLinkService / otpService as optional. Operations for an absent feature return AdminResult.ValidationFailed("... is not configured"); authorization is still checked first so an unauthorized actor cannot probe which features exist. The deleteCredential anti-lockout guard stays FAIL-CLOSED: an absent backup-code service counts as zero remaining codes, so deleting a last credential is still blocked unless the host opts in via AdminSafetyConfig.allowDeleteWithoutBackupCodes(). Dependencies.passkeyOnly(credentialRepository, userLookup) added. - spring: gate pkAuthBackupCodeService / pkAuthMagicLinkService / pkAuthOtpService and the backup-code/OTP deletion listeners with @ConditionalOnBean on their backing SPI (mirroring the refresh-token treatment); inject the alt-flow services into the admin service via ObjectProvider. This only ever NARROWS what is wired and removes the incentive to enable dev-mode in production. Adds admin-api PasskeyOnly tests (incl. the fail-closed delete and the auth-precedes-disclosure check) and a Spring ApplicationContextRunner test asserting a passkey-only context boots with the alt-flow services absent. All admin-api / spring / dropwizard / micronaut suites pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../pkauth/admin/DefaultAdminService.java | 70 +++++++++--- .../pkauth/admin/DefaultAdminServiceTest.java | 108 ++++++++++++++---- .../PkAuthAdminAutoConfiguration.java | 19 ++- .../PkAuthAutoConfiguration.java | 16 ++- .../pkauth/spring/PkAuthDevModeGuardTest.java | 30 +++++ 5 files changed, 198 insertions(+), 45 deletions(-) diff --git a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/DefaultAdminService.java b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/DefaultAdminService.java index ddf5c5d..1459319 100644 --- a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/DefaultAdminService.java +++ b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/DefaultAdminService.java @@ -16,6 +16,7 @@ import java.util.List; import java.util.Objects; import java.util.Optional; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -26,9 +27,9 @@ public final class DefaultAdminService implements AdminService { private final CredentialRepository credentialRepository; private final UserLookup userLookup; - private final BackupCodeService backupCodeService; - private final MagicLinkService magicLinkService; - private final OtpService otpService; + private final @Nullable BackupCodeService backupCodeService; + private final @Nullable MagicLinkService magicLinkService; + private final @Nullable OtpService otpService; private final AdminAuthorizer authorizer; private final AdminSafetyConfig safetyConfig; @@ -72,7 +73,7 @@ public AdminResult getAccount(UserHandle actor, UserHandle targe Optional view = userLookup.findViewByHandle(target); if (view.isEmpty()) return new AdminResult.NotFound<>(); int credentialCount = credentialRepository.findByUserHandle(target).size(); - int remaining = backupCodeService.remainingCount(target); + int remaining = remainingBackupCodeCount(target); UserLookup.UserView v = view.get(); return new AdminResult.Success<>( new AccountSummary( @@ -122,7 +123,10 @@ public AdminResult deleteCredential( if (!safetyConfig.allowDeleteWithoutBackupCodes()) { List all = credentialRepository.findByUserHandle(target); boolean lastOne = all.size() == 1 && all.get(0).credentialId().equals(credentialId); - if (lastOne && backupCodeService.remainingCount(target) == 0) { + // remainingBackupCodeCount() returns 0 when the backup-code feature is not configured, so the + // anti-lockout guard stays fail-closed: deleting the last credential is still blocked unless + // the host opts in via AdminSafetyConfig.allowDeleteWithoutBackupCodes(). + if (lastOne && remainingBackupCodeCount(target) == 0) { return new AdminResult.Conflict<>( "Cannot delete the last credential while no backup codes remain."); } @@ -143,6 +147,7 @@ public AdminResult deleteCredential( public AdminResult regenerateBackupCodes( UserHandle actor, UserHandle target) { if (!authorize(actor, target)) return new AdminResult.Forbidden<>(); + if (backupCodeService == null) return notConfigured("backup codes"); List plaintext = backupCodeService.regenerateBackupCodes(target); return new AdminResult.Success<>(new BackupCodesGenerated(plaintext)); } @@ -150,6 +155,7 @@ public AdminResult regenerateBackupCodes( @Override public AdminResult remainingBackupCodes(UserHandle actor, UserHandle target) { if (!authorize(actor, target)) return new AdminResult.Forbidden<>(); + if (backupCodeService == null) return notConfigured("backup codes"); return new AdminResult.Success<>(backupCodeService.remainingCount(target)); } @@ -162,6 +168,7 @@ public AdminResult startEmailVerification( if (email == null || email.isBlank()) { return new AdminResult.ValidationFailed<>("email must be non-blank"); } + if (magicLinkService == null) return notConfigured("email verification"); SendResult send = magicLinkService.startEmailVerification(target, email); if (send instanceof SendResult.RateLimited) { return new AdminResult.RateLimited<>(Duration.ofHours(1)); @@ -179,6 +186,7 @@ public AdminResult finishEmailVerification(String token) { if (token == null || token.isBlank()) { return new AdminResult.ValidationFailed<>("token must be non-blank"); } + if (magicLinkService == null) return notConfigured("email verification"); ConsumeResult result = magicLinkService.finishVerification(token); if (result instanceof ConsumeResult.Success success) { // Host apps own the users table; we report success and let the adapter persist the @@ -201,6 +209,7 @@ public AdminResult startPhoneVerification( if (phoneE164 == null || !phoneE164.startsWith("+")) { return new AdminResult.ValidationFailed<>("phone must be E.164 format"); } + if (otpService == null) return notConfigured("phone verification"); OtpService.SendResult send = otpService.startVerification(target, phoneE164); if (send instanceof OtpService.SendResult.RateLimited) { return new AdminResult.RateLimited<>(Duration.ofMinutes(15)); @@ -216,6 +225,7 @@ public AdminResult finishPhoneVerification( if (phoneE164 == null || code == null) { return new AdminResult.ValidationFailed<>("phone and code are required"); } + if (otpService == null) return notConfigured("phone verification"); OtpService.VerifyResult result = otpService.finishVerification(target, phoneE164, code); return new AdminResult.Success<>( switch (result) { @@ -238,25 +248,53 @@ private boolean authorize(UserHandle actor, UserHandle target) { } /** - * Canonical holder of the five required collaborators for {@link DefaultAdminService}. + * Backup-code count for {@code target}, or {@code 0} when the backup-code feature is not + * configured. Returning 0 (rather than throwing) keeps the {@code deleteCredential} anti-lockout + * guard fail-closed for passkey-only hosts. + */ + private int remainingBackupCodeCount(UserHandle target) { + return backupCodeService == null ? 0 : backupCodeService.remainingCount(target); + } + + private static AdminResult notConfigured(String feature) { + return new AdminResult.ValidationFailed<>(feature + " is not configured on this deployment"); + } + + /** + * Holder of the {@link DefaultAdminService} collaborators. {@code credentialRepository} and + * {@code userLookup} are always required; the three alt-flow services ({@code backupCodeService}, + * {@code magicLinkService}, {@code otpService}) are optional — pass {@code null} for any + * feature a passkey-only host does not run. Admin operations for an absent feature return {@link + * AdminResult.ValidationFailed} ("… is not configured"), and the {@code deleteCredential} + * anti-lockout guard treats an absent backup-code service as zero remaining codes (fail-closed). * - *

Pass an instance to {@link #create(Dependencies)} (or the overloads that accept an optional - * {@link AdminAuthorizer} / {@link AdminSafetyConfig}) to construct a service. Using a record - * keeps construction sites concise and self-documenting through Java's named component syntax. + *

Pass an instance to {@link #create(Dependencies)} (or the {@link #create(Dependencies, + * Config)} overload) to construct a service. Using a record keeps construction sites concise and + * self-documenting through Java's named component syntax. + * + * @since 1.3.1 */ public record Dependencies( CredentialRepository credentialRepository, UserLookup userLookup, - BackupCodeService backupCodeService, - MagicLinkService magicLinkService, - OtpService otpService) { - /** Compact constructor — enforces non-null on all required collaborators. */ + @Nullable BackupCodeService backupCodeService, + @Nullable MagicLinkService magicLinkService, + @Nullable OtpService otpService) { + /** Compact constructor — enforces non-null on the two always-required collaborators. */ public Dependencies { Objects.requireNonNull(credentialRepository, "credentialRepository"); Objects.requireNonNull(userLookup, "userLookup"); - Objects.requireNonNull(backupCodeService, "backupCodeService"); - Objects.requireNonNull(magicLinkService, "magicLinkService"); - Objects.requireNonNull(otpService, "otpService"); + } + + /** + * Convenience for a passkey-only deployment: credentials and user lookup only, with every + * alt-flow service absent. + * + * @since 1.3.1 + */ + public static Dependencies passkeyOnly( + CredentialRepository credentialRepository, UserLookup userLookup) { + return new Dependencies(credentialRepository, userLookup, null, null, null); } } diff --git a/pk-auth-admin-api/src/test/java/com/codeheadsystems/pkauth/admin/DefaultAdminServiceTest.java b/pk-auth-admin-api/src/test/java/com/codeheadsystems/pkauth/admin/DefaultAdminServiceTest.java index f05050f..1700621 100644 --- a/pk-auth-admin-api/src/test/java/com/codeheadsystems/pkauth/admin/DefaultAdminServiceTest.java +++ b/pk-auth-admin-api/src/test/java/com/codeheadsystems/pkauth/admin/DefaultAdminServiceTest.java @@ -318,7 +318,7 @@ void supportStaffAuthorizerOverride() { // -- Dependencies record -- @Test - void dependenciesRejectsNullInputs() { + void dependenciesRejectsNullRequiredCollaborators() { org.junit.jupiter.api.Assertions.assertAll( () -> org.junit.jupiter.api.Assertions.assertThrows( @@ -331,25 +331,93 @@ void dependenciesRejectsNullInputs() { NullPointerException.class, () -> new DefaultAdminService.Dependencies( - credentials, null, backupCodeService, magicLink, otpService)), - () -> - org.junit.jupiter.api.Assertions.assertThrows( - NullPointerException.class, - () -> - new DefaultAdminService.Dependencies( - credentials, users, null, magicLink, otpService)), - () -> - org.junit.jupiter.api.Assertions.assertThrows( - NullPointerException.class, - () -> - new DefaultAdminService.Dependencies( - credentials, users, backupCodeService, null, otpService)), - () -> - org.junit.jupiter.api.Assertions.assertThrows( - NullPointerException.class, - () -> - new DefaultAdminService.Dependencies( - credentials, users, backupCodeService, magicLink, null))); + credentials, null, backupCodeService, magicLink, otpService))); + } + + @Test + void dependenciesAllowNullAltFlowServices() { + // The three alt-flow services are optional (passkey-only hosts pass null); the record must + // accept that without throwing. + org.junit.jupiter.api.Assertions.assertDoesNotThrow( + () -> new DefaultAdminService.Dependencies(credentials, users, null, null, null)); + org.junit.jupiter.api.Assertions.assertDoesNotThrow( + () -> DefaultAdminService.Dependencies.passkeyOnly(credentials, users)); + } + + // -- Passkey-only deployment (no alt-flow services wired) -- + + @org.junit.jupiter.api.Nested + class PasskeyOnly { + + private DefaultAdminService passkeyOnly; + + @BeforeEach + void setUp() { + passkeyOnly = + DefaultAdminService.create( + DefaultAdminService.Dependencies.passkeyOnly(credentials, users)); + } + + @Test + void getAccountWorksAndReportsZeroBackupCodes() { + saveCredential(alice, new byte[] {1}); + AdminResult result = passkeyOnly.getAccount(alice, alice); + assertThat(result) + .isInstanceOfSatisfying( + AdminResult.Success.class, + s -> { + AccountSummary summary = (AccountSummary) s.value(); + assertThat(summary.credentialCount()).isEqualTo(1); + assertThat(summary.remainingBackupCodes()).isZero(); + }); + } + + @Test + void credentialManagementStillWorks() { + saveCredential(alice, new byte[] {1}); + assertThat(passkeyOnly.listCredentials(alice, alice)).isInstanceOf(AdminResult.Success.class); + } + + @Test + void deletingLastCredentialIsBlockedFailClosed() { + saveCredential(alice, new byte[] {1}); + // No backup-code service configured -> remaining treated as 0 -> last-credential delete + // blocked. + assertThat(passkeyOnly.deleteCredential(alice, alice, CredentialId.of(new byte[] {1}))) + .isInstanceOf(AdminResult.Conflict.class); + } + + @Test + void backupCodeOperationsReportNotConfigured() { + assertThat(passkeyOnly.regenerateBackupCodes(alice, alice)) + .isInstanceOf(AdminResult.ValidationFailed.class); + assertThat(passkeyOnly.remainingBackupCodes(alice, alice)) + .isInstanceOf(AdminResult.ValidationFailed.class); + } + + @Test + void emailVerificationReportsNotConfigured() { + assertThat(passkeyOnly.startEmailVerification(alice, alice, "alice@example.com")) + .isInstanceOf(AdminResult.ValidationFailed.class); + assertThat(passkeyOnly.finishEmailVerification("any-token")) + .isInstanceOf(AdminResult.ValidationFailed.class); + } + + @Test + void phoneVerificationReportsNotConfigured() { + assertThat(passkeyOnly.startPhoneVerification(alice, alice, "+15551234567")) + .isInstanceOf(AdminResult.ValidationFailed.class); + assertThat(passkeyOnly.finishPhoneVerification(alice, alice, "+15551234567", "123456")) + .isInstanceOf(AdminResult.ValidationFailed.class); + } + + @Test + void authorizationStillPrecedesNotConfigured() { + UserHandle bob = users.register("bob", "Bob"); + // A forbidden actor must get Forbidden, not a "not configured" disclosure. + assertThat(passkeyOnly.regenerateBackupCodes(alice, bob)) + .isInstanceOf(AdminResult.Forbidden.class); + } } // -- helpers -- diff --git a/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/autoconfigure/PkAuthAdminAutoConfiguration.java b/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/autoconfigure/PkAuthAdminAutoConfiguration.java index f87cbe7..1c3bdb1 100644 --- a/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/autoconfigure/PkAuthAdminAutoConfiguration.java +++ b/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/autoconfigure/PkAuthAdminAutoConfiguration.java @@ -11,6 +11,7 @@ import com.codeheadsystems.pkauth.spi.CredentialRepository; import com.codeheadsystems.pkauth.spi.UserLookup; import com.codeheadsystems.pkauth.spring.admin.PkAuthAdminController; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; @@ -35,18 +36,28 @@ public AdminAuthorizer pkAuthAdminAuthorizer() { return AdminAuthorizer.subjectScoped(); } + /** + * Wires the admin service. The three alt-flow services are injected via {@link ObjectProvider} so + * a passkey-only host — one that wired no backup-code / OTP / magic-link feature — still gets a + * working admin service for credential management; the absent flows surface as {@code + * ValidationFailed("… is not configured")} (see {@link DefaultAdminService.Dependencies}). + */ @Bean @ConditionalOnMissingBean public AdminService pkAuthAdminService( CredentialRepository credentialRepository, UserLookup userLookup, - BackupCodeService backupCodeService, - MagicLinkService magicLinkService, - OtpService otpService, + ObjectProvider backupCodeService, + ObjectProvider magicLinkService, + ObjectProvider otpService, AdminAuthorizer authorizer) { return DefaultAdminService.create( new DefaultAdminService.Dependencies( - credentialRepository, userLookup, backupCodeService, magicLinkService, otpService), + credentialRepository, + userLookup, + backupCodeService.getIfAvailable(), + magicLinkService.getIfAvailable(), + otpService.getIfAvailable()), new DefaultAdminService.Config(authorizer, AdminSafetyConfig.defaults())); } 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 3ea3244..7170f31 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 @@ -298,15 +298,17 @@ public UserDeletionListener pkAuthCredentialRepositoryDeletionListener( return new CredentialRepositoryDeletionListener(repository); } - /** Listener: deletes every backup code owned by the user. */ + /** Listener: deletes every backup code owned by the user — only when a repository is wired. */ @Bean + @ConditionalOnBean(BackupCodeRepository.class) public UserDeletionListener pkAuthBackupCodeRepositoryDeletionListener( BackupCodeRepository repository) { return new BackupCodeRepositoryDeletionListener(repository); } - /** Listener: deletes every OTP row owned by the user. */ + /** Listener: deletes every OTP row owned by the user — only when a repository is wired. */ @Bean + @ConditionalOnBean(OtpRepository.class) public UserDeletionListener pkAuthOtpRepositoryDeletionListener(OtpRepository repository) { return new OtpRepositoryDeletionListener(repository); } @@ -396,9 +398,10 @@ public RefreshHandler pkAuthRefreshHandler( * magic-link token or OTP code — to the application log. Gating them behind {@code * pkauth.dev-mode=true} prevents an accidental production deploy from silently leaking single-use * credentials to log aggregation systems. A host without a real {@code EmailSender} / {@code - * SmsSender} bean and without {@code dev-mode=true} fails to start (no bean for the downstream - * {@code MagicLinkService} / {@code OtpService} factory parameters), which is the intended - * fail-fast behaviour. + * SmsSender} bean (and without {@code dev-mode=true}) simply does not get the downstream {@code + * MagicLinkService} / {@code OtpService} beans — those are {@link ConditionalOnBean} on their + * sender/repository — so a passkey-only host boots cleanly. The admin service then reports those + * flows as "not configured" rather than the application failing to start. */ @Bean @ConditionalOnMissingBean @@ -422,6 +425,7 @@ public SmsSender pkAuthSmsSender() { @Bean @ConditionalOnMissingBean + @ConditionalOnBean(BackupCodeRepository.class) public BackupCodeService pkAuthBackupCodeService( BackupCodeRepository repo, ClockProvider clockProvider) { return BackupCodeService.create(BackupCodeService.Dependencies.of(repo, clockProvider)); @@ -429,6 +433,7 @@ public BackupCodeService pkAuthBackupCodeService( @Bean @ConditionalOnMissingBean + @ConditionalOnBean(EmailSender.class) public MagicLinkService pkAuthMagicLinkService( PkAuthJwtIssuer issuer, PkAuthJwtValidator validator, @@ -446,6 +451,7 @@ public MagicLinkService pkAuthMagicLinkService( @Bean @ConditionalOnMissingBean + @ConditionalOnBean({OtpRepository.class, SmsSender.class}) public OtpService pkAuthOtpService( OtpRepository repo, SmsSender smsSender, diff --git a/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthDevModeGuardTest.java b/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthDevModeGuardTest.java index 4dae587..6484c03 100644 --- a/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthDevModeGuardTest.java +++ b/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthDevModeGuardTest.java @@ -3,12 +3,19 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.codeheadsystems.pkauth.backupcodes.BackupCodeService; +import com.codeheadsystems.pkauth.ceremony.PasskeyAuthenticationService; +import com.codeheadsystems.pkauth.magiclink.MagicLinkService; +import com.codeheadsystems.pkauth.otp.OtpService; import com.codeheadsystems.pkauth.spi.BackupCodeRepository; import com.codeheadsystems.pkauth.spi.ChallengeStore; import com.codeheadsystems.pkauth.spi.CredentialRepository; import com.codeheadsystems.pkauth.spi.OtpRepository; import com.codeheadsystems.pkauth.spi.UserLookup; import com.codeheadsystems.pkauth.spring.autoconfigure.PkAuthAutoConfiguration; +import com.codeheadsystems.pkauth.testkit.InMemoryChallengeStore; +import com.codeheadsystems.pkauth.testkit.InMemoryCredentialRepository; +import com.codeheadsystems.pkauth.testkit.InMemoryUserLookup; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.UnsatisfiedDependencyException; import org.springframework.boot.autoconfigure.AutoConfigurations; @@ -74,4 +81,27 @@ void inMemorySpiBeansActivateWhenDevModeTrue() { .hasSingleBean(BackupCodeRepository.class) .hasSingleBean(OtpRepository.class)); } + + /** + * Passkey-only host: the three required core SPIs are provided but no backup-code / OTP / email + * SPI is wired and dev-mode is off. The context must start (the alt-flow services are {@link + * org.springframework.boot.autoconfigure.condition.ConditionalOnBean} on their backing SPI) + * rather than forcing the host to wire those SPIs or flip dev-mode (which would log plaintext + * credentials). + */ + @Test + void passkeyOnlyHostBootsWithoutAltFlowSpisOrDevMode() { + runner + .withBean(CredentialRepository.class, InMemoryCredentialRepository::new) + .withBean(UserLookup.class, InMemoryUserLookup::new) + .withBean(ChallengeStore.class, InMemoryChallengeStore::new) + .run( + ctx -> + assertThat(ctx) + .hasNotFailed() + .hasSingleBean(PasskeyAuthenticationService.class) + .doesNotHaveBean(BackupCodeService.class) + .doesNotHaveBean(OtpService.class) + .doesNotHaveBean(MagicLinkService.class)); + } } From 283e286ad5bdc9edfe6839817732b80f29f0273b Mon Sep 17 00:00:00 2001 From: Ned Wolpert Date: Thu, 11 Jun 2026 21:24:19 -0700 Subject: [PATCH 07/10] refactor: centralize host->domain config translation in factories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-vs-fixed TTL-policy dispatch and the JwtConfig / RefreshTokenConfig assembly were duplicated across all three adapters (Spring, Micronaut, Dropwizard) — byte-identical for JWT, near-identical for refresh. Because these govern token TTLs and skew windows, a missed adapter would silently issue tokens with different lifetimes. Add inward factories so the translation has one definition: - TokenTtlPolicy.from(defaultTtl, overrides) / RefreshTtlPolicy.from(...) encapsulate the null/empty -> single, else fixed dispatch. - JwtConfig.from(issuer, audience, defaultTtl?, ttlsByAudience?) applies DEFAULT_TOKEN_TTL and the standard nbf/clock skews. - RefreshTokenConfig.from(defaultTtl?, ttlsByAudience?, cleanupRetention?) applies the entropy and retention defaults. All three adapters now call these factories instead of re-deriving the assembly. Behaviour is preserved (the factories encode the exact defaults each adapter used). Adds unit tests for every new factory; jwt, refresh-tokens, and all three adapter suites pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dropwizard/config/PkAuthConfig.java | 17 +-------- .../dropwizard/dagger/PkAuthModule.java | 16 +------- .../codeheadsystems/pkauth/jwt/JwtConfig.java | 33 ++++++++++++++++ .../pkauth/jwt/TokenTtlPolicy.java | 18 +++++++++ .../pkauth/jwt/JwtConfigTest.java | 38 +++++++++++++++++++ .../pkauth/jwt/TokenTtlPolicyTest.java | 17 +++++++++ .../pkauth/micronaut/PkAuthFactory.java | 34 ++--------------- .../pkauth/refresh/RefreshTokenConfig.java | 32 ++++++++++++++++ .../pkauth/refresh/RefreshTtlPolicy.java | 18 +++++++++ .../refresh/RefreshTokenConfigTest.java | 22 +++++++++++ .../pkauth/refresh/RefreshTtlPolicyTest.java | 16 ++++++++ .../PkAuthAutoConfiguration.java | 37 ++---------------- 12 files changed, 202 insertions(+), 96 deletions(-) create mode 100644 pk-auth-jwt/src/test/java/com/codeheadsystems/pkauth/jwt/JwtConfigTest.java diff --git a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/config/PkAuthConfig.java b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/config/PkAuthConfig.java index bdfd5c3..0e62f6c 100644 --- a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/config/PkAuthConfig.java +++ b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/config/PkAuthConfig.java @@ -2,7 +2,6 @@ package com.codeheadsystems.pkauth.dropwizard.config; import com.codeheadsystems.pkauth.refresh.RefreshTokenConfig; -import com.codeheadsystems.pkauth.refresh.RefreshTtlPolicy; import java.time.Duration; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -235,21 +234,7 @@ public Refresh() { /** Builds a {@link RefreshTokenConfig} from this block. */ public RefreshTokenConfig toRefreshTokenConfig() { - Duration ttl = defaultTtl == null ? RefreshTokenConfig.DEFAULT_REFRESH_TTL : defaultTtl; - Map overrides = ttlsByAudience; - RefreshTtlPolicy policy = - overrides == null || overrides.isEmpty() - ? RefreshTtlPolicy.single(ttl) - : RefreshTtlPolicy.fixed(ttl, overrides); - Duration retention = - cleanupRetention == null - ? RefreshTokenConfig.DEFAULT_CLEANUP_RETENTION - : cleanupRetention; - return new RefreshTokenConfig( - policy, - RefreshTokenConfig.DEFAULT_SECRET_BYTES, - RefreshTokenConfig.DEFAULT_REFRESH_ID_BYTES, - retention); + return RefreshTokenConfig.from(defaultTtl, ttlsByAudience, cleanupRetention); } } } 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 e5b99fd..c8ddba4 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 @@ -16,7 +16,6 @@ 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; @@ -34,8 +33,6 @@ import dagger.Provides; import dagger.multibindings.IntoSet; import jakarta.inject.Singleton; -import java.time.Duration; -import java.util.Map; import java.util.Optional; import java.util.Set; @@ -150,18 +147,7 @@ PasskeyAuthenticationService providePasskeyAuthenticationService( @Singleton JwtConfig provideJwtConfig(PkAuthConfig cfg) { PkAuthConfig.Jwt jwt = cfg.jwt(); - Duration defaultTtl = jwt.defaultTtl() == null ? JwtConfig.DEFAULT_TOKEN_TTL : jwt.defaultTtl(); - Map overrides = jwt.ttlsByAudience(); - TokenTtlPolicy ttlPolicy = - overrides == null || overrides.isEmpty() - ? TokenTtlPolicy.single(defaultTtl) - : TokenTtlPolicy.fixed(defaultTtl, overrides); - return new JwtConfig( - jwt.issuer(), - jwt.audience(), - ttlPolicy, - JwtConfig.DEFAULT_NBF_SKEW, - JwtConfig.DEFAULT_CLOCK_SKEW); + return JwtConfig.from(jwt.issuer(), jwt.audience(), jwt.defaultTtl(), jwt.ttlsByAudience()); } @Provides diff --git a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtConfig.java b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtConfig.java index 170bd55..896e2be 100644 --- a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtConfig.java +++ b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtConfig.java @@ -3,8 +3,10 @@ import java.time.Duration; import java.util.HashSet; +import java.util.Map; import java.util.Objects; import java.util.Set; +import org.jspecify.annotations.Nullable; /** * Configuration for pk-auth's JWT issuance and validation. @@ -94,4 +96,35 @@ public static JwtConfig defaults(String issuer, String audience) { DEFAULT_NBF_SKEW, DEFAULT_CLOCK_SKEW); } + + /** + * Builds a {@link JwtConfig} from host configuration with the documented skew defaults ({@link + * #DEFAULT_NBF_SKEW} / {@link #DEFAULT_CLOCK_SKEW}). A {@code null} {@code defaultTtl} falls back + * to {@link #DEFAULT_TOKEN_TTL}; {@code ttlsByAudience} that is {@code null} or empty yields a + * single-TTL policy, otherwise a per-audience policy (see {@link TokenTtlPolicy#from}). + * + *

This is the host-config-to-domain-config translation every framework adapter performs. + * Centralizing it ensures all adapters issue tokens with identical skew windows — a divergence + * here would be a silent, security-relevant inconsistency. + * + * @param issuer the {@code iss} claim value. + * @param audience the default audience. + * @param defaultTtl the access-token TTL, or {@code null} for {@link #DEFAULT_TOKEN_TTL}. + * @param ttlsByAudience per-audience TTL overrides, or {@code null}/empty for a uniform TTL. + * @return the assembled config. + * @since 1.3.1 + */ + public static JwtConfig from( + String issuer, + String audience, + @Nullable Duration defaultTtl, + @Nullable Map ttlsByAudience) { + Duration ttl = defaultTtl == null ? DEFAULT_TOKEN_TTL : defaultTtl; + return new JwtConfig( + issuer, + audience, + TokenTtlPolicy.from(ttl, ttlsByAudience), + DEFAULT_NBF_SKEW, + DEFAULT_CLOCK_SKEW); + } } diff --git a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/TokenTtlPolicy.java b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/TokenTtlPolicy.java index ca1ef7b..e452875 100644 --- a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/TokenTtlPolicy.java +++ b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/TokenTtlPolicy.java @@ -6,6 +6,7 @@ import java.util.Map; import java.util.Objects; import java.util.Set; +import org.jspecify.annotations.Nullable; /** * Per-audience TTL lookup used by {@link PkAuthJwtIssuer} when minting access tokens. Hosts that @@ -84,6 +85,23 @@ public String toString() { }; } + /** + * Builds a policy from optional host configuration: {@link #single(Duration)} when {@code + * overrides} is {@code null} or empty, otherwise {@link #fixed(Duration, Map)}. This is the + * single-vs-fixed dispatch every adapter performs when translating its per-audience TTL config; + * centralizing it keeps that decision identical across adapters. + * + * @param defaultTtl the fallback TTL for any audience not in {@code overrides}. + * @param overrides per-audience TTL overrides, or {@code null}/empty for a uniform TTL. + * @return the resolved policy. + * @since 1.3.1 + */ + static TokenTtlPolicy from(Duration defaultTtl, @Nullable Map overrides) { + return overrides == null || overrides.isEmpty() + ? single(defaultTtl) + : fixed(defaultTtl, overrides); + } + /** Returns a policy that uses the same TTL for every audience. */ static TokenTtlPolicy single(Duration ttl) { Objects.requireNonNull(ttl, "ttl"); diff --git a/pk-auth-jwt/src/test/java/com/codeheadsystems/pkauth/jwt/JwtConfigTest.java b/pk-auth-jwt/src/test/java/com/codeheadsystems/pkauth/jwt/JwtConfigTest.java new file mode 100644 index 0000000..927af31 --- /dev/null +++ b/pk-auth-jwt/src/test/java/com/codeheadsystems/pkauth/jwt/JwtConfigTest.java @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.jwt; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** Covers the host-config-to-domain-config {@link JwtConfig#from} translation. */ +class JwtConfigTest { + + @Test + void fromAppliesDocumentedDefaultsWhenTtlNull() { + JwtConfig config = JwtConfig.from("iss", "aud", null, null); + assertThat(config.issuer()).isEqualTo("iss"); + assertThat(config.defaultAudience()).isEqualTo("aud"); + assertThat(config.notBeforeSkew()).isEqualTo(JwtConfig.DEFAULT_NBF_SKEW); + assertThat(config.clockSkew()).isEqualTo(JwtConfig.DEFAULT_CLOCK_SKEW); + assertThat(config.ttlPolicy().accessTtl("aud")).isEqualTo(JwtConfig.DEFAULT_TOKEN_TTL); + assertThat(config.ttlPolicy().knownAudiences()).isEmpty(); + } + + @Test + void fromHonorsExplicitDefaultTtl() { + JwtConfig config = JwtConfig.from("iss", "aud", Duration.ofMinutes(5), null); + assertThat(config.ttlPolicy().accessTtl("aud")).isEqualTo(Duration.ofMinutes(5)); + } + + @Test + void fromBuildsPerAudiencePolicyFromOverrides() { + JwtConfig config = + JwtConfig.from("iss", "aud", Duration.ofHours(1), Map.of("cli", Duration.ofHours(8))); + assertThat(config.ttlPolicy().accessTtl("cli")).isEqualTo(Duration.ofHours(8)); + assertThat(config.ttlPolicy().accessTtl("aud")).isEqualTo(Duration.ofHours(1)); + assertThat(config.allowedAudiences()).contains("aud", "cli"); + } +} diff --git a/pk-auth-jwt/src/test/java/com/codeheadsystems/pkauth/jwt/TokenTtlPolicyTest.java b/pk-auth-jwt/src/test/java/com/codeheadsystems/pkauth/jwt/TokenTtlPolicyTest.java index f6bc79b..86b0621 100644 --- a/pk-auth-jwt/src/test/java/com/codeheadsystems/pkauth/jwt/TokenTtlPolicyTest.java +++ b/pk-auth-jwt/src/test/java/com/codeheadsystems/pkauth/jwt/TokenTtlPolicyTest.java @@ -64,4 +64,21 @@ void fixedRejectsNullKeyOrValue() { assertThatThrownBy(() -> TokenTtlPolicy.fixed(Duration.ofHours(1), nullValue)) .isInstanceOf(NullPointerException.class); } + + @Test + void fromNullOrEmptyOverridesYieldsSinglePolicy() { + assertThat(TokenTtlPolicy.from(Duration.ofMinutes(10), null).knownAudiences()).isEmpty(); + assertThat(TokenTtlPolicy.from(Duration.ofMinutes(10), Map.of()).knownAudiences()).isEmpty(); + assertThat(TokenTtlPolicy.from(Duration.ofMinutes(10), null).accessTtl("web")) + .isEqualTo(Duration.ofMinutes(10)); + } + + @Test + void fromNonEmptyOverridesYieldsFixedPolicy() { + TokenTtlPolicy policy = + TokenTtlPolicy.from(Duration.ofHours(1), Map.of("web", Duration.ofMinutes(15))); + assertThat(policy.knownAudiences()).containsExactly("web"); + assertThat(policy.accessTtl("web")).isEqualTo(Duration.ofMinutes(15)); + assertThat(policy.accessTtl("other")).isEqualTo(Duration.ofHours(1)); + } } 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 89de7a5..8730ab3 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 @@ -14,7 +14,6 @@ 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; @@ -30,7 +29,6 @@ import com.codeheadsystems.pkauth.refresh.RefreshTokenConfig; import com.codeheadsystems.pkauth.refresh.RefreshTokenService; import com.codeheadsystems.pkauth.refresh.RefreshTokenServiceDeletionListener; -import com.codeheadsystems.pkauth.refresh.RefreshTtlPolicy; import com.codeheadsystems.pkauth.refresh.spi.RefreshTokenRepository; import com.codeheadsystems.pkauth.refresh.web.RefreshHandler; import com.codeheadsystems.pkauth.spi.BackupCodeRepository; @@ -43,11 +41,9 @@ import io.micronaut.context.annotation.Factory; 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; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -108,15 +104,7 @@ JwtConfig jwtConfig(PkAuthConfiguration config) { "pkauth.jwt.{issuer,audience} are required. Set them explicitly in configuration —" + " there are no defaults."); } - Duration defaultTtl = - jwt.getDefaultTtl() == null ? JwtConfig.DEFAULT_TOKEN_TTL : jwt.getDefaultTtl(); - Map overrides = jwt.getTtlsByAudience(); - TokenTtlPolicy ttlPolicy = - overrides == null || overrides.isEmpty() - ? TokenTtlPolicy.single(defaultTtl) - : TokenTtlPolicy.fixed(defaultTtl, overrides); - return new JwtConfig( - issuer, audience, ttlPolicy, JwtConfig.DEFAULT_NBF_SKEW, JwtConfig.DEFAULT_CLOCK_SKEW); + return JwtConfig.from(issuer, audience, jwt.getDefaultTtl(), jwt.getTtlsByAudience()); } @Singleton @@ -194,24 +182,8 @@ UserDeletionService userDeletionService(Collection listene @Singleton RefreshTokenConfig refreshTokenConfig(PkAuthConfiguration config) { PkAuthConfiguration.Refresh refresh = config.getRefresh(); - Duration defaultTtl = - refresh.getDefaultTtl() == null - ? RefreshTokenConfig.DEFAULT_REFRESH_TTL - : refresh.getDefaultTtl(); - Map overrides = refresh.getTtlsByAudience(); - RefreshTtlPolicy policy = - overrides == null || overrides.isEmpty() - ? RefreshTtlPolicy.single(defaultTtl) - : RefreshTtlPolicy.fixed(defaultTtl, overrides); - Duration retention = - refresh.getCleanupRetention() == null - ? RefreshTokenConfig.DEFAULT_CLEANUP_RETENTION - : refresh.getCleanupRetention(); - return new RefreshTokenConfig( - policy, - RefreshTokenConfig.DEFAULT_SECRET_BYTES, - RefreshTokenConfig.DEFAULT_REFRESH_ID_BYTES, - retention); + return RefreshTokenConfig.from( + refresh.getDefaultTtl(), refresh.getTtlsByAudience(), refresh.getCleanupRetention()); } @Singleton diff --git a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenConfig.java b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenConfig.java index bf0ec27..14c1039 100644 --- a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenConfig.java +++ b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenConfig.java @@ -2,7 +2,9 @@ package com.codeheadsystems.pkauth.refresh; import java.time.Duration; +import java.util.Map; import java.util.Objects; +import org.jspecify.annotations.Nullable; /** * Configuration for {@link RefreshTokenService}. @@ -56,4 +58,34 @@ public static RefreshTokenConfig defaults() { DEFAULT_REFRESH_ID_BYTES, DEFAULT_CLEANUP_RETENTION); } + + /** + * Builds a {@link RefreshTokenConfig} from host configuration with the documented entropy + * defaults ({@link #DEFAULT_SECRET_BYTES} / {@link #DEFAULT_REFRESH_ID_BYTES}). A {@code null} + * {@code defaultTtl} falls back to {@link #DEFAULT_REFRESH_TTL}; {@code ttlsByAudience} that is + * {@code null} or empty yields a single-TTL policy (see {@link RefreshTtlPolicy#from}); a {@code + * null} {@code cleanupRetention} falls back to {@link #DEFAULT_CLEANUP_RETENTION}. + * + *

This is the host-config-to-domain-config translation each framework adapter performs; + * centralizing it keeps the entropy and retention defaults identical across adapters. + * + * @param defaultTtl the refresh-token TTL, or {@code null} for {@link #DEFAULT_REFRESH_TTL}. + * @param ttlsByAudience per-audience TTL overrides, or {@code null}/empty for a uniform TTL. + * @param cleanupRetention forensic retention window, or {@code null} for {@link + * #DEFAULT_CLEANUP_RETENTION}. + * @return the assembled config. + * @since 1.3.1 + */ + public static RefreshTokenConfig from( + @Nullable Duration defaultTtl, + @Nullable Map ttlsByAudience, + @Nullable Duration cleanupRetention) { + Duration ttl = defaultTtl == null ? DEFAULT_REFRESH_TTL : defaultTtl; + Duration retention = cleanupRetention == null ? DEFAULT_CLEANUP_RETENTION : cleanupRetention; + return new RefreshTokenConfig( + RefreshTtlPolicy.from(ttl, ttlsByAudience), + DEFAULT_SECRET_BYTES, + DEFAULT_REFRESH_ID_BYTES, + retention); + } } diff --git a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTtlPolicy.java b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTtlPolicy.java index d3b7c4c..1cfd2c4 100644 --- a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTtlPolicy.java +++ b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTtlPolicy.java @@ -6,6 +6,7 @@ import java.util.Map; import java.util.Objects; import java.util.Set; +import org.jspecify.annotations.Nullable; /** * Per-audience TTL lookup used by {@link RefreshTokenService} when issuing refresh tokens. @@ -38,6 +39,23 @@ default Set knownAudiences() { return Set.of(); } + /** + * Builds a policy from optional host configuration: {@link #single(Duration)} when {@code + * overrides} is {@code null} or empty, otherwise {@link #fixed(Duration, Map)}. This is the + * single-vs-fixed dispatch every adapter performs; centralizing it keeps the JDBI/DynamoDB-backed + * hosts identical on the refresh-TTL decision. + * + * @param defaultTtl the fallback TTL for any audience not in {@code overrides}. + * @param overrides per-audience TTL overrides, or {@code null}/empty for a uniform TTL. + * @return the resolved policy. + * @since 1.3.1 + */ + static RefreshTtlPolicy from(Duration defaultTtl, @Nullable Map overrides) { + return overrides == null || overrides.isEmpty() + ? single(defaultTtl) + : fixed(defaultTtl, overrides); + } + /** Returns a policy dispatching by audience with a default-TTL fallback. */ static RefreshTtlPolicy fixed(Duration defaultTtl, Map overrides) { Objects.requireNonNull(defaultTtl, "defaultTtl"); diff --git a/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshTokenConfigTest.java b/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshTokenConfigTest.java index ec0c857..fa76b61 100644 --- a/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshTokenConfigTest.java +++ b/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshTokenConfigTest.java @@ -5,6 +5,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.time.Duration; +import java.util.Map; import org.junit.jupiter.api.Test; /** Validates {@link RefreshTokenConfig} compact-constructor guards and the {@code defaults()}. */ @@ -58,4 +59,25 @@ void acceptsMinimumBoundaryValues() { assertThat(config.refreshIdBytes()).isEqualTo(8); assertThat(config.cleanupRetention()).isEqualTo(Duration.ZERO); } + + @Test + void fromAppliesAllDocumentedDefaultsWhenNull() { + RefreshTokenConfig config = RefreshTokenConfig.from(null, null, null); + assertThat(config.secretBytes()).isEqualTo(RefreshTokenConfig.DEFAULT_SECRET_BYTES); + assertThat(config.refreshIdBytes()).isEqualTo(RefreshTokenConfig.DEFAULT_REFRESH_ID_BYTES); + assertThat(config.cleanupRetention()).isEqualTo(RefreshTokenConfig.DEFAULT_CLEANUP_RETENTION); + assertThat(config.ttlPolicy().refreshTtl("web")) + .isEqualTo(RefreshTokenConfig.DEFAULT_REFRESH_TTL); + assertThat(config.ttlPolicy().knownAudiences()).isEmpty(); + } + + @Test + void fromHonorsExplicitValuesAndPerAudienceOverrides() { + RefreshTokenConfig config = + RefreshTokenConfig.from( + Duration.ofDays(7), Map.of("cli", Duration.ofDays(90)), Duration.ofDays(60)); + assertThat(config.cleanupRetention()).isEqualTo(Duration.ofDays(60)); + assertThat(config.ttlPolicy().refreshTtl("cli")).isEqualTo(Duration.ofDays(90)); + assertThat(config.ttlPolicy().refreshTtl("web")).isEqualTo(Duration.ofDays(7)); + } } diff --git a/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshTtlPolicyTest.java b/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshTtlPolicyTest.java index 7909ced..58840e1 100644 --- a/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshTtlPolicyTest.java +++ b/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshTtlPolicyTest.java @@ -78,4 +78,20 @@ void fixedRejectsNullKeyOrValueInOverrides() { assertThatThrownBy(() -> RefreshTtlPolicy.fixed(Duration.ofDays(1), nullKey)) .isInstanceOf(NullPointerException.class); } + + @Test + void fromNullOrEmptyOverridesYieldsSinglePolicy() { + assertThat(RefreshTtlPolicy.from(Duration.ofDays(14), null).knownAudiences()).isEmpty(); + assertThat(RefreshTtlPolicy.from(Duration.ofDays(14), Map.of()).refreshTtl("web")) + .isEqualTo(Duration.ofDays(14)); + } + + @Test + void fromNonEmptyOverridesYieldsFixedPolicy() { + RefreshTtlPolicy policy = + RefreshTtlPolicy.from(Duration.ofDays(14), Map.of("cli", Duration.ofDays(90))); + assertThat(policy.knownAudiences()).containsExactly("cli"); + assertThat(policy.refreshTtl("cli")).isEqualTo(Duration.ofDays(90)); + assertThat(policy.refreshTtl("web")).isEqualTo(Duration.ofDays(14)); + } } 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 7170f31..2181e8a 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 @@ -19,7 +19,6 @@ 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; @@ -35,7 +34,6 @@ import com.codeheadsystems.pkauth.refresh.RefreshTokenConfig; import com.codeheadsystems.pkauth.refresh.RefreshTokenService; import com.codeheadsystems.pkauth.refresh.RefreshTokenServiceDeletionListener; -import com.codeheadsystems.pkauth.refresh.RefreshTtlPolicy; import com.codeheadsystems.pkauth.refresh.spi.RefreshTokenRepository; import com.codeheadsystems.pkauth.refresh.web.RefreshHandler; import com.codeheadsystems.pkauth.spi.BackupCodeRepository; @@ -51,9 +49,7 @@ import com.codeheadsystems.pkauth.testkit.InMemoryCredentialRepository; 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; import org.springframework.boot.autoconfigure.AutoConfiguration; @@ -216,18 +212,7 @@ public PasskeyAuthenticationService pkAuthService( @ConditionalOnMissingBean public JwtConfig pkAuthJwtConfig(PkAuthProperties props) { PkAuthProperties.Jwt jwt = requireJwt(props); - Duration defaultTtl = jwt.defaultTtl() == null ? JwtConfig.DEFAULT_TOKEN_TTL : jwt.defaultTtl(); - Map overrides = jwt.ttlsByAudience(); - TokenTtlPolicy ttlPolicy = - overrides == null || overrides.isEmpty() - ? TokenTtlPolicy.single(defaultTtl) - : TokenTtlPolicy.fixed(defaultTtl, overrides); - return new JwtConfig( - jwt.issuer(), - jwt.audience(), - ttlPolicy, - JwtConfig.DEFAULT_NBF_SKEW, - JwtConfig.DEFAULT_CLOCK_SKEW); + return JwtConfig.from(jwt.issuer(), jwt.audience(), jwt.defaultTtl(), jwt.ttlsByAudience()); } @Bean @@ -345,24 +330,8 @@ public UserDeletionService pkAuthUserDeletionService(List @ConditionalOnMissingBean public RefreshTokenConfig pkAuthRefreshTokenConfig(PkAuthProperties props) { PkAuthProperties.Refresh refresh = props.refresh(); - Duration defaultTtl = - refresh.defaultTtl() == null - ? RefreshTokenConfig.DEFAULT_REFRESH_TTL - : refresh.defaultTtl(); - Map overrides = refresh.ttlsByAudience(); - RefreshTtlPolicy policy = - overrides == null || overrides.isEmpty() - ? RefreshTtlPolicy.single(defaultTtl) - : RefreshTtlPolicy.fixed(defaultTtl, overrides); - Duration retention = - refresh.cleanupRetention() == null - ? RefreshTokenConfig.DEFAULT_CLEANUP_RETENTION - : refresh.cleanupRetention(); - return new RefreshTokenConfig( - policy, - RefreshTokenConfig.DEFAULT_SECRET_BYTES, - RefreshTokenConfig.DEFAULT_REFRESH_ID_BYTES, - retention); + return RefreshTokenConfig.from( + refresh.defaultTtl(), refresh.ttlsByAudience(), refresh.cleanupRetention()); } /** {@link RefreshTokenService} bean — only when a {@link RefreshTokenRepository} is wired. */ From ec62e8518a55aaae981f49dff07dfdac32fb5473 Mon Sep 17 00:00:00 2001 From: Ned Wolpert Date: Thu, 11 Jun 2026 21:28:54 -0700 Subject: [PATCH 08/10] fix(spring): default ceremony UV to REQUIRED and surface the knobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Spring starter hardcoded the ceremony policy with userVerification=PREFERRED, silently weaker than the framework-neutral CeremonyConfig.defaults() (REQUIRED) that the Micronaut and Dropwizard adapters already use. A Spring host therefore got UV=PREFERRED — WebAuthn4J would not enforce flagUV on assertions — unless it shadowed the whole CeremonyConfig bean. - pkAuthCeremonyConfig now builds every knob from CeremonyConfig.defaults() as the fallback, so the Spring default is UV=REQUIRED / counter=REJECT, matching core and the other adapters. This STRENGTHENS the default; a host that needs PREFERRED (e.g. UV-less security keys) must now opt in explicitly via pkauth.ceremony.user-verification=preferred. - Expose user-verification, resident-key, attestation, and counter-regression as optional pkauth.ceremony.* properties (each defaulting to the secure core value) so hardening or deliberate relaxing is a first-class, reviewable config choice rather than a bean override. Adds context tests asserting the REQUIRED default and an override. Micronaut and Dropwizard already defaulted to REQUIRED via CeremonyConfig.defaults() and did not carry the bug; surfacing their knobs is a follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../PkAuthAutoConfiguration.java | 18 ++++++------- .../spring/config/PkAuthProperties.java | 27 ++++++++++++++++--- .../pkauth/spring/PkAuthDevModeGuardTest.java | 27 +++++++++++++++++++ 3 files changed, 59 insertions(+), 13 deletions(-) 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 2181e8a..b6009ad 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 @@ -1,15 +1,11 @@ // SPDX-License-Identifier: MIT package com.codeheadsystems.pkauth.spring.autoconfigure; -import com.codeheadsystems.pkauth.api.AttestationConveyance; -import com.codeheadsystems.pkauth.api.ResidentKeyRequirement; -import com.codeheadsystems.pkauth.api.UserVerificationRequirement; import com.codeheadsystems.pkauth.backupcodes.BackupCodeService; import com.codeheadsystems.pkauth.ceremony.InMemoryCeremonyRateLimiter; import com.codeheadsystems.pkauth.ceremony.PasskeyAuthenticationService; import com.codeheadsystems.pkauth.composition.PkAuthComposition; 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; @@ -117,12 +113,16 @@ public RelyingPartyConfig pkAuthRelyingPartyConfig(PkAuthProperties props) { @Bean @ConditionalOnMissingBean public CeremonyConfig pkAuthCeremonyConfig(PkAuthProperties props) { + PkAuthProperties.Ceremony c = props.ceremony(); + CeremonyConfig defaults = CeremonyConfig.defaults(); + // Every knob falls back to the framework-neutral core defaults (UV=REQUIRED, counter=REJECT), + // not weaker adapter-local values — a host must opt in explicitly to relax any of them. return new CeremonyConfig( - props.ceremony().challengeTtl(), - UserVerificationRequirement.PREFERRED, - ResidentKeyRequirement.PREFERRED, - AttestationConveyance.NONE, - CounterRegressionPolicy.REJECT); + c.challengeTtl() == null ? defaults.challengeTtl() : c.challengeTtl(), + c.userVerification() == null ? defaults.userVerification() : c.userVerification(), + c.residentKey() == null ? defaults.residentKey() : c.residentKey(), + c.attestation() == null ? defaults.attestationConveyance() : c.attestation(), + c.counterRegression() == null ? defaults.counterRegression() : c.counterRegression()); } /** diff --git a/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/config/PkAuthProperties.java b/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/config/PkAuthProperties.java index 63d2c37..d2b70f5 100644 --- a/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/config/PkAuthProperties.java +++ b/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/config/PkAuthProperties.java @@ -1,6 +1,10 @@ // SPDX-License-Identifier: MIT package com.codeheadsystems.pkauth.spring.config; +import com.codeheadsystems.pkauth.api.AttestationConveyance; +import com.codeheadsystems.pkauth.api.ResidentKeyRequirement; +import com.codeheadsystems.pkauth.api.UserVerificationRequirement; +import com.codeheadsystems.pkauth.config.CounterRegressionPolicy; import java.time.Duration; import java.util.Map; import java.util.Set; @@ -86,14 +90,29 @@ public record Jwt( @Nullable Map ttlsByAudience) {} /** - * Ceremony tunables forwarded to {@code CeremonyConfig}. + * Ceremony tunables forwarded to {@code CeremonyConfig}. Every field is optional; any left unset + * falls back to {@link com.codeheadsystems.pkauth.config.CeremonyConfig#defaults()} — notably + * {@code userVerification} defaults to {@code REQUIRED} and {@code counterRegression} to {@code + * REJECT}, matching the framework-neutral core defaults (and the Micronaut/Dropwizard adapters). + * These are security-load-bearing; relaxing them (e.g. {@code user-verification: preferred} for + * UV-less security keys) must be an explicit, deliberate host choice. * - * @param challengeTtl how long an issued challenge remains valid + * @param challengeTtl how long an issued challenge remains valid (default 5 minutes) + * @param userVerification WebAuthn UV requirement (default {@code REQUIRED}) + * @param residentKey discoverable-credential requirement (default {@code PREFERRED}) + * @param attestation attestation conveyance preference (default {@code NONE}) + * @param counterRegression signature-counter regression policy (default {@code REJECT}) + * @since 1.3.1 */ - public record Ceremony(Duration challengeTtl) { + public record Ceremony( + @Nullable Duration challengeTtl, + @Nullable UserVerificationRequirement userVerification, + @Nullable ResidentKeyRequirement residentKey, + @Nullable AttestationConveyance attestation, + @Nullable CounterRegressionPolicy counterRegression) { public static Ceremony defaults() { - return new Ceremony(Duration.ofMinutes(5)); + return new Ceremony(Duration.ofMinutes(5), null, null, null, null); } } diff --git a/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthDevModeGuardTest.java b/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthDevModeGuardTest.java index 6484c03..629d986 100644 --- a/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthDevModeGuardTest.java +++ b/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthDevModeGuardTest.java @@ -3,8 +3,10 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.codeheadsystems.pkauth.api.UserVerificationRequirement; import com.codeheadsystems.pkauth.backupcodes.BackupCodeService; import com.codeheadsystems.pkauth.ceremony.PasskeyAuthenticationService; +import com.codeheadsystems.pkauth.config.CeremonyConfig; import com.codeheadsystems.pkauth.magiclink.MagicLinkService; import com.codeheadsystems.pkauth.otp.OtpService; import com.codeheadsystems.pkauth.spi.BackupCodeRepository; @@ -82,6 +84,31 @@ void inMemorySpiBeansActivateWhenDevModeTrue() { .hasSingleBean(OtpRepository.class)); } + /** + * Security default: with no ceremony overrides, the CeremonyConfig bean must default to {@code + * userVerification=REQUIRED} (the framework-neutral core default), not the weaker PREFERRED the + * Spring adapter previously hardcoded. + */ + @Test + void ceremonyConfigDefaultsToRequiredUserVerification() { + runner + .withPropertyValues("pkauth.dev-mode=true") + .run( + ctx -> + assertThat(ctx.getBean(CeremonyConfig.class).userVerification()) + .isEqualTo(UserVerificationRequirement.REQUIRED)); + } + + @Test + void ceremonyConfigHonorsUserVerificationOverride() { + runner + .withPropertyValues("pkauth.dev-mode=true", "pkauth.ceremony.user-verification=preferred") + .run( + ctx -> + assertThat(ctx.getBean(CeremonyConfig.class).userVerification()) + .isEqualTo(UserVerificationRequirement.PREFERRED)); + } + /** * Passkey-only host: the three required core SPIs are provided but no backup-code / OTP / email * SPI is wired and dev-mode is off. The context must start (the alt-flow services are {@link From 33b7e52dc3249a52269211728c2aa2ba73f1ebd8 Mon Sep 17 00:00:00 2001 From: Ned Wolpert Date: Thu, 11 Jun 2026 21:36:11 -0700 Subject: [PATCH 09/10] test+fix: end-to-end forged/replayed ceremony coverage and CBOR robustness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The anti-forgery and anti-replay guarantees were only proven via injected WebAuthn4J exceptions and the takeOnce SPI seam — never through real crypto + persistence. Extend CeremonyScenarios with three scenarios driven through real WebAuthn4J verification: - tamperedAssertionSignatureIsRejected: a flipped signature byte -> AssertionResult.InvalidSignature, and the stored sign-count is unchanged. - tamperedRegistrationPayloadIsRejected: a corrupted attestation object -> RegistrationResult.InvalidPayload, and no credential is persisted. - replayedAssertionChallengeIsRejected: replaying a consumed assertion -> AssertionResult.InvalidChallenge (proves single-use takeOnce end-to-end). Wire all three into the in-memory FullCeremonyTest and the JDBI/DynamoDB ceremony integration tests, so they run against real Postgres and DynamoDB. The registration scenario surfaced a robustness bug: a structurally broken attestation CBOR makes WebAuthn4J throw a raw Jackson exception that the service did not catch — an attacker-controlled malformed payload escaped as an uncaught 500 across the sealed-result boundary instead of a clean 400. finishRegistration now catches JacksonException and maps it to InvalidPayload, matching the documented "sealed results, never throw across that boundary" contract. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../DefaultPasskeyAuthenticationService.java | 11 +++ .../DynamoDbCeremonyIntegrationTest.java | 18 ++++ .../jdbi/JdbiCeremonyIntegrationTest.java | 18 ++++ .../pkauth/testkit/CeremonyScenarios.java | 95 +++++++++++++++++++ .../pkauth/testkit/FullCeremonyTest.java | 29 ++++++ 5 files changed, 171 insertions(+) diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/DefaultPasskeyAuthenticationService.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/DefaultPasskeyAuthenticationService.java index a2f463b..f63589e 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/DefaultPasskeyAuthenticationService.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/DefaultPasskeyAuthenticationService.java @@ -69,6 +69,7 @@ import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import tools.jackson.core.JacksonException; /** * Default {@link PasskeyAuthenticationService} backed by WebAuthn4J's {@link WebAuthnManager}. @@ -301,6 +302,16 @@ public RegistrationResult finishRegistration( ChallengeValidator.Ceremony.REGISTRATION, new RegistrationResult.InvalidPayload(messageOf(ex)), start); + } catch (JacksonException ex) { + // WebAuthn4J does not always wrap a malformed attestation-object CBOR payload in + // DataConversionException — a structurally-broken object can surface a raw Jackson + // (de)serialization error. Map it to InvalidPayload so attacker-controlled garbage yields a + // clean 400, never an uncaught 500 across the sealed-result boundary. + LOG.debug("registration.finish malformed attestation CBOR", ex); + return outcome( + ChallengeValidator.Ceremony.REGISTRATION, + new RegistrationResult.InvalidPayload("malformed attestation object"), + start); } catch (VerificationException ex) { return outcome( ChallengeValidator.Ceremony.REGISTRATION, diff --git a/pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbCeremonyIntegrationTest.java b/pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbCeremonyIntegrationTest.java index 63ea762..3746f17 100644 --- a/pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbCeremonyIntegrationTest.java +++ b/pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbCeremonyIntegrationTest.java @@ -87,6 +87,24 @@ void usernamelessFlowAgainstDynamoDb() { .usernamelessFlowSucceedsWithSingleCredential(); } + @Test + void tamperedAssertionRejectedAgainstDynamoDb() { + new CeremonyScenarios(service, authenticator, credentialRepository, userLookup) + .tamperedAssertionSignatureIsRejected(); + } + + @Test + void tamperedRegistrationRejectedAgainstDynamoDb() { + new CeremonyScenarios(service, authenticator, credentialRepository, userLookup) + .tamperedRegistrationPayloadIsRejected(); + } + + @Test + void replayedAssertionRejectedAgainstDynamoDb() { + new CeremonyScenarios(service, authenticator, credentialRepository, userLookup) + .replayedAssertionChallengeIsRejected(); + } + @Test void challengeTakeOnceIsSingleUse() { ChallengeId id = new ChallengeId("ch-" + UUID.randomUUID()); diff --git a/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiCeremonyIntegrationTest.java b/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiCeremonyIntegrationTest.java index fd317b5..171e1c6 100644 --- a/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiCeremonyIntegrationTest.java +++ b/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiCeremonyIntegrationTest.java @@ -75,6 +75,24 @@ void usernamelessFlowAgainstPostgres() { .usernamelessFlowSucceedsWithSingleCredential(); } + @Test + void tamperedAssertionRejectedAgainstPostgres() { + new CeremonyScenarios(service, authenticator, credentialRepository, userLookup) + .tamperedAssertionSignatureIsRejected(); + } + + @Test + void tamperedRegistrationRejectedAgainstPostgres() { + new CeremonyScenarios(service, authenticator, credentialRepository, userLookup) + .tamperedRegistrationPayloadIsRejected(); + } + + @Test + void replayedAssertionRejectedAgainstPostgres() { + new CeremonyScenarios(service, authenticator, credentialRepository, userLookup) + .replayedAssertionChallengeIsRejected(); + } + @Test void challengeStoreTakeOnceConsumesAtomically() { challengeStore.put( diff --git a/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/CeremonyScenarios.java b/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/CeremonyScenarios.java index e6f7bbb..cd2aa9c 100644 --- a/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/CeremonyScenarios.java +++ b/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/CeremonyScenarios.java @@ -112,6 +112,101 @@ public UserHandle register() { return ((RegistrationResult.Success) result).credential().userHandle(); } + /** + * Anti-forgery: a single tampered byte in the assertion signature must be rejected as {@link + * AssertionResult.InvalidSignature} — driven end-to-end through real WebAuthn4J verification, not + * an injected exception. The credential's stored sign-count must be unchanged afterwards (a + * failed assertion does not advance the counter). + */ + public void tamperedAssertionSignatureIsRejected() { + UserHandle handle = register(); + long before = singleCredentialFor(handle).orElseThrow().signCount(); + + StartAuthenticationResponse start = + service.startAuthentication(new StartAuthenticationRequest(USERNAME, null)); + AuthenticationResponseJson resp = authenticator.createAssertionResponse(start, handle); + AssertionResult result = + service.finishAuthentication( + new FinishAuthenticationRequest(start.challengeId(), tamperSignature(resp))); + + assertThat(result).isInstanceOf(AssertionResult.InvalidSignature.class); + assertThat(singleCredentialFor(handle).orElseThrow().signCount()).isEqualTo(before); + } + + /** + * Anti-tamper: a single corrupted byte in the registration attestation object must be rejected as + * {@link RegistrationResult.InvalidPayload} (the parse/verify path), and no credential is + * persisted. + */ + public void tamperedRegistrationPayloadIsRejected() { + StartRegistrationResponse start = + service.startRegistration(new StartRegistrationRequest(USERNAME, DISPLAY_NAME, null, null)); + RegistrationResponseJson resp = authenticator.createRegistrationResponse(start); + RegistrationResult result = + service.finishRegistration( + new FinishRegistrationRequest( + start.challengeId(), USERNAME, LABEL, tamperAttestation(resp))); + + assertThat(result).isInstanceOf(RegistrationResult.InvalidPayload.class); + UserHandle handle = users.getOrCreateHandle(USERNAME); + assertThat(credentials.findByUserHandle(handle)).isEmpty(); + } + + /** + * Anti-replay: replaying an already-consumed assertion (the same challengeId + response submitted + * a second time) must be rejected as {@link AssertionResult.InvalidChallenge}. Challenges are + * single-use via {@code ChallengeStore.takeOnce}; this proves that contract end-to-end rather + * than at the SPI seam. + */ + public void replayedAssertionChallengeIsRejected() { + UserHandle handle = register(); + + StartAuthenticationResponse start = + service.startAuthentication(new StartAuthenticationRequest(USERNAME, null)); + AuthenticationResponseJson resp = authenticator.createAssertionResponse(start, handle); + + AssertionResult first = + service.finishAuthentication(new FinishAuthenticationRequest(start.challengeId(), resp)); + assertThat(first).isInstanceOf(AssertionResult.Success.class); + + AssertionResult replay = + service.finishAuthentication(new FinishAuthenticationRequest(start.challengeId(), resp)); + assertThat(replay).isInstanceOf(AssertionResult.InvalidChallenge.class); + } + + private static AuthenticationResponseJson tamperSignature(AuthenticationResponseJson resp) { + AuthenticationResponseJson.AuthenticatorAssertionResponseJson r = resp.response(); + byte[] signature = r.signature(); + signature[signature.length - 1] ^= 0x01; + return new AuthenticationResponseJson( + resp.id(), + resp.rawId(), + new AuthenticationResponseJson.AuthenticatorAssertionResponseJson( + r.clientDataJSON(), r.authenticatorData(), signature, r.userHandle()), + resp.authenticatorAttachment(), + resp.clientExtensionResults(), + resp.type()); + } + + private static RegistrationResponseJson tamperAttestation(RegistrationResponseJson resp) { + RegistrationResponseJson.AuthenticatorAttestationResponseJson r = resp.response(); + byte[] attestation = r.attestationObject(); + attestation[0] ^= 0x01; + return new RegistrationResponseJson( + resp.id(), + resp.rawId(), + new RegistrationResponseJson.AuthenticatorAttestationResponseJson( + r.clientDataJSON(), + attestation, + r.transports(), + r.authenticatorData(), + r.publicKey(), + r.publicKeyAlgorithm()), + resp.authenticatorAttachment(), + resp.clientExtensionResults(), + resp.type()); + } + /** Runs a single assertion for the registered username. */ public AssertionResult assertOnce(UserHandle handle) { StartAuthenticationResponse start = diff --git a/pk-auth-testkit/src/test/java/com/codeheadsystems/pkauth/testkit/FullCeremonyTest.java b/pk-auth-testkit/src/test/java/com/codeheadsystems/pkauth/testkit/FullCeremonyTest.java index bf4b926..d093613 100644 --- a/pk-auth-testkit/src/test/java/com/codeheadsystems/pkauth/testkit/FullCeremonyTest.java +++ b/pk-auth-testkit/src/test/java/com/codeheadsystems/pkauth/testkit/FullCeremonyTest.java @@ -33,6 +33,35 @@ void usernamelessFlowSucceedsWhenSingleCredentialRegistered() { scenarios.usernamelessFlowSucceedsWithSingleCredential(); } + @Test + void tamperedAssertionSignatureIsRejected() { + InMemoryEverything env = InMemoryEverything.defaults(); + CeremonyScenarios scenarios = + new CeremonyScenarios(env.service, env.authenticator, env.credentials, env.users); + + scenarios.tamperedAssertionSignatureIsRejected(); + + assertThat(env.challenges.size()).isZero(); // challenge still consumed on rejection + } + + @Test + void tamperedRegistrationPayloadIsRejected() { + InMemoryEverything env = InMemoryEverything.defaults(); + CeremonyScenarios scenarios = + new CeremonyScenarios(env.service, env.authenticator, env.credentials, env.users); + + scenarios.tamperedRegistrationPayloadIsRejected(); + } + + @Test + void replayedAssertionChallengeIsRejected() { + InMemoryEverything env = InMemoryEverything.defaults(); + CeremonyScenarios scenarios = + new CeremonyScenarios(env.service, env.authenticator, env.credentials, env.users); + + scenarios.replayedAssertionChallengeIsRejected(); + } + @Test void duplicateRegistrationIsRejectedOnSecondAttempt() { InMemoryEverything env = InMemoryEverything.defaults(); From cd45e5b95228f8542d8a9f57e5fceee20959b8b3 Mon Sep 17 00:00:00 2001 From: Ned Wolpert Date: Thu, 11 Jun 2026 21:41:23 -0700 Subject: [PATCH 10/10] docs+test: backfill @since on public API and add a mechanical gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONTRIBUTING.md §7 mandates an @since tag on every public API element, but ~half of pk-auth-core's exported types and most of pk-auth-admin-api lacked a class-level tag — the convention read as aspirational on the most load-bearing, stability-promising types. - Backfill @since on 52 public top-level types (43 in pk-auth-core's exported packages, 9 in pk-auth-admin-api). The version was determined per file from the earliest release tag containing it (git --follow --diff-filter=A); every one traces to v0.9.0, so all are @since 0.9.0. - Add PublicApiSinceTagTest to each module: a source-scanning gate that fails the build if any public top-level type in an exported package lacks a class-level @since tag, so the convention can no longer rot. Verified the gate trips when a tag is removed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../pkauth/admin/AccountSummary.java | 6 +- .../pkauth/admin/AdminAuthorizer.java | 2 + .../pkauth/admin/AdminResult.java | 1 + .../pkauth/admin/AdminSafetyConfig.java | 1 + .../pkauth/admin/AdminService.java | 2 + .../pkauth/admin/BackupCodesGenerated.java | 2 + .../pkauth/admin/DefaultAdminService.java | 6 +- .../pkauth/admin/OtpDispatchResult.java | 6 +- .../pkauth/admin/PhoneVerificationResult.java | 6 +- .../pkauth/admin/PublicApiSinceTagTest.java | 79 +++++++++++++ .../pkauth/api/AssertionResult.java | 2 + .../pkauth/api/AttestationConveyance.java | 6 +- .../api/AuthenticationResponseJson.java | 2 + .../pkauth/api/AuthenticatorAttachment.java | 6 +- .../pkauth/api/CeremonyWireMapper.java | 2 + .../pkauth/api/ChallengeId.java | 6 +- .../pkauth/api/CredentialId.java | 2 + .../api/FinishAuthenticationRequest.java | 6 +- .../pkauth/api/FinishRegistrationRequest.java | 2 + .../pkauth/api/RegistrationResponseJson.java | 2 + .../pkauth/api/RegistrationResult.java | 2 + .../pkauth/api/ResidentKeyRequirement.java | 6 +- .../api/StartAuthenticationRequest.java | 2 + .../api/StartAuthenticationResponse.java | 2 + .../pkauth/api/StartRegistrationRequest.java | 6 +- .../pkauth/api/StartRegistrationResponse.java | 2 + .../codeheadsystems/pkauth/api/Transport.java | 2 + .../pkauth/api/UserHandle.java | 2 + .../api/UserVerificationRequirement.java | 6 +- .../PasskeyAuthenticationService.java | 2 + .../PasskeyAuthenticationServices.java | 2 + .../pkauth/config/CeremonyConfig.java | 6 +- .../config/CounterRegressionPolicy.java | 2 + .../pkauth/config/RelyingPartyConfig.java | 1 + .../pkauth/credential/AuthenticatorData.java | 1 + .../pkauth/credential/CredentialMetadata.java | 6 +- .../pkauth/credential/CredentialRecord.java | 1 + .../pkauth/error/ConfigurationException.java | 6 +- .../error/IllegalPkAuthStateException.java | 6 +- .../pkauth/error/PkAuthErrorCode.java | 2 + .../pkauth/error/PkAuthException.java | 2 + .../pkauth/json/Base64Url.java | 2 + .../pkauth/json/PkAuthObjectMappers.java | 2 + .../pkauth/metrics/Metrics.java | 2 + .../pkauth/spi/AttestationTrustPolicy.java | 2 + .../pkauth/spi/BackupCodeRepository.java | 6 +- .../pkauth/spi/ChallengeRecord.java | 1 + .../pkauth/spi/ChallengeStore.java | 2 + .../pkauth/spi/ClockProvider.java | 2 + .../pkauth/spi/CredentialRepository.java | 2 + .../pkauth/spi/OriginValidator.java | 2 + .../pkauth/spi/OtpRepository.java | 6 +- .../pkauth/spi/UserLookup.java | 2 + .../pkauth/PublicApiSinceTagTest.java | 105 ++++++++++++++++++ 54 files changed, 333 insertions(+), 17 deletions(-) create mode 100644 pk-auth-admin-api/src/test/java/com/codeheadsystems/pkauth/admin/PublicApiSinceTagTest.java create mode 100644 pk-auth-core/src/test/java/com/codeheadsystems/pkauth/PublicApiSinceTagTest.java diff --git a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/AccountSummary.java b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/AccountSummary.java index aace7c4..cd985d3 100644 --- a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/AccountSummary.java +++ b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/AccountSummary.java @@ -4,7 +4,11 @@ import com.codeheadsystems.pkauth.api.UserHandle; import java.util.Objects; -/** Wire-shape returned by {@code GET /auth/admin/account}. */ +/** + * Wire-shape returned by {@code GET /auth/admin/account}. + * + * @since 0.9.0 + */ public record AccountSummary( UserHandle userHandle, String username, diff --git a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/AdminAuthorizer.java b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/AdminAuthorizer.java index 5eea5b8..d9a1fb3 100644 --- a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/AdminAuthorizer.java +++ b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/AdminAuthorizer.java @@ -7,6 +7,8 @@ * Decides whether the JWT-authenticated {@code actor} is allowed to act on {@code target}'s * account. Default implementation is subject-scoped — a user only acts on themselves. Adapter * modules can plug in support-staff-impersonation flows by overriding this SPI. + * + * @since 0.9.0 */ @FunctionalInterface public interface AdminAuthorizer { diff --git a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/AdminResult.java b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/AdminResult.java index 82f5d0a..bff8cd6 100644 --- a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/AdminResult.java +++ b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/AdminResult.java @@ -10,6 +10,7 @@ * AdminResultMapper}. * * @param the payload type for {@link Success}. + * @since 0.9.0 */ public sealed interface AdminResult { diff --git a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/AdminSafetyConfig.java b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/AdminSafetyConfig.java index 640ccd3..9a5cb99 100644 --- a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/AdminSafetyConfig.java +++ b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/AdminSafetyConfig.java @@ -8,6 +8,7 @@ * * @param allowDeleteWithoutBackupCodes when true, callers can delete the last credential even when * no backup codes remain. The brief recommends keeping this {@code false}. + * @since 0.9.0 */ public record AdminSafetyConfig(boolean allowDeleteWithoutBackupCodes) { diff --git a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/AdminService.java b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/AdminService.java index c8f371e..de29814 100644 --- a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/AdminService.java +++ b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/AdminService.java @@ -9,6 +9,8 @@ * Framework-neutral admin operations on a user's pk-auth account (brief §6.9). All authenticated * methods take an {@code actor} which the adapter resolves from the JWT subject and apply the * configured {@link AdminAuthorizer} before touching the underlying SPIs. + * + * @since 0.9.0 */ public interface AdminService { diff --git a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/BackupCodesGenerated.java b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/BackupCodesGenerated.java index a722619..c0e5455 100644 --- a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/BackupCodesGenerated.java +++ b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/BackupCodesGenerated.java @@ -7,6 +7,8 @@ /** * One-time-view payload from {@code POST /auth/admin/backup-codes/regenerate}. The plaintext codes * are returned to the caller exactly once; pk-auth stores only Argon2id hashes server-side. + * + * @since 0.9.0 */ public record BackupCodesGenerated(List codes) { public BackupCodesGenerated { diff --git a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/DefaultAdminService.java b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/DefaultAdminService.java index 1459319..776ec2a 100644 --- a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/DefaultAdminService.java +++ b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/DefaultAdminService.java @@ -20,7 +20,11 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -/** Default {@link AdminService} composition over the Phase 5/6 SPIs and services. */ +/** + * Default {@link AdminService} composition over the Phase 5/6 SPIs and services. + * + * @since 0.9.0 + */ public final class DefaultAdminService implements AdminService { private static final Logger LOG = LoggerFactory.getLogger(DefaultAdminService.class); diff --git a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/OtpDispatchResult.java b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/OtpDispatchResult.java index 85bfeb4..ded887e 100644 --- a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/OtpDispatchResult.java +++ b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/OtpDispatchResult.java @@ -3,7 +3,11 @@ import java.util.Objects; -/** Returned from {@code POST /auth/admin/phone/start-verification}. */ +/** + * Returned from {@code POST /auth/admin/phone/start-verification}. + * + * @since 0.9.0 + */ public record OtpDispatchResult(String otpId) { public OtpDispatchResult { Objects.requireNonNull(otpId, "otpId"); diff --git a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/PhoneVerificationResult.java b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/PhoneVerificationResult.java index da75815..f91c858 100644 --- a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/PhoneVerificationResult.java +++ b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/PhoneVerificationResult.java @@ -1,7 +1,11 @@ // SPDX-License-Identifier: MIT package com.codeheadsystems.pkauth.admin; -/** Returned from {@code POST /auth/admin/phone/complete-verification}. */ +/** + * Returned from {@code POST /auth/admin/phone/complete-verification}. + * + * @since 0.9.0 + */ public sealed interface PhoneVerificationResult { /** Code matched and the user's {@code phoneVerified} flag is now true. */ diff --git a/pk-auth-admin-api/src/test/java/com/codeheadsystems/pkauth/admin/PublicApiSinceTagTest.java b/pk-auth-admin-api/src/test/java/com/codeheadsystems/pkauth/admin/PublicApiSinceTagTest.java new file mode 100644 index 0000000..8cfcc6d --- /dev/null +++ b/pk-auth-admin-api/src/test/java/com/codeheadsystems/pkauth/admin/PublicApiSinceTagTest.java @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth.admin; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; + +/** + * Mechanical {@code @since} gate (CONTRIBUTING.md §7): every public top-level type in + * pk-auth-admin-api must carry a class-level {@code @since} Javadoc tag. When this fails, add + * {@code @since } to the type's class Javadoc. + */ +class PublicApiSinceTagTest { + + private static final Path SOURCE_ROOT = Path.of("src/main/java/com/codeheadsystems/pkauth/admin"); + + private static final Pattern TYPE_DECL = + Pattern.compile( + "^(public\\s+(?:final\\s+|abstract\\s+|sealed\\s+|non-sealed\\s+)*)" + + "(class|interface|record|enum)\\s+(\\w+)", + Pattern.MULTILINE); + + @Test + void everyPublicTypeHasSinceTag() throws IOException { + assertThat(SOURCE_ROOT).as("source root must exist").exists(); + List violations = new ArrayList<>(); + try (Stream files = Files.walk(SOURCE_ROOT)) { + files + .filter(p -> p.toString().endsWith(".java")) + .filter(p -> !p.getFileName().toString().equals("package-info.java")) + .filter(p -> !p.getFileName().toString().equals("module-info.java")) + .forEach(p -> checkFile(p, violations)); + } + assertThat(violations).as("public types missing a class-level @since tag").isEmpty(); + } + + private static void checkFile(Path file, List violations) { + String src; + try { + src = Files.readString(file); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + Matcher m = TYPE_DECL.matcher(src); + if (!m.find()) { + return; + } + String javadoc = precedingJavadoc(src, m.start()); + if (javadoc == null || !javadoc.contains("@since")) { + violations.add(file.getFileName() + " (" + m.group(3) + ")"); + } + } + + private static String precedingJavadoc(String src, int declStart) { + String head = src.substring(0, declStart); + int close = head.lastIndexOf("*/"); + if (close < 0) { + return null; + } + int open = head.lastIndexOf("/**", close); + if (open < 0) { + return null; + } + String between = head.substring(close + 2); + if (between.matches("(?s).*\\b(class|interface|record|enum)\\b.*")) { + return null; + } + return head.substring(open, close + 2); + } +} diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/AssertionResult.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/AssertionResult.java index 299eac6..374407e 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/AssertionResult.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/AssertionResult.java @@ -6,6 +6,8 @@ /** * Closed sum of outcomes from {@code PasskeyAuthenticationService.finishAuthentication}. Mapped to * HTTP responses by adapter modules. + * + * @since 0.9.0 */ public sealed interface AssertionResult { diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/AttestationConveyance.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/AttestationConveyance.java index 6b746c0..dcfc251 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/AttestationConveyance.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/AttestationConveyance.java @@ -3,7 +3,11 @@ import com.fasterxml.jackson.annotation.JsonProperty; -/** WebAuthn {@code AttestationConveyancePreference} enumeration (Level 3 §5.4.7). */ +/** + * WebAuthn {@code AttestationConveyancePreference} enumeration (Level 3 §5.4.7). + * + * @since 0.9.0 + */ public enum AttestationConveyance { @JsonProperty("none") NONE, diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/AuthenticationResponseJson.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/AuthenticationResponseJson.java index 99a8eca..7d0a5e8 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/AuthenticationResponseJson.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/AuthenticationResponseJson.java @@ -9,6 +9,8 @@ /** * Browser-returned payload for an authentication ceremony. Mirrors WebAuthn JSON Spec §5.1.4 * "AuthenticationResponseJSON". + * + * @since 0.9.0 */ @JsonInclude(JsonInclude.Include.NON_NULL) public record AuthenticationResponseJson( diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/AuthenticatorAttachment.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/AuthenticatorAttachment.java index 564c3ce..a8578fa 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/AuthenticatorAttachment.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/AuthenticatorAttachment.java @@ -3,7 +3,11 @@ import com.fasterxml.jackson.annotation.JsonProperty; -/** WebAuthn {@code AuthenticatorAttachment} enumeration (Level 3 §5.4.5). */ +/** + * WebAuthn {@code AuthenticatorAttachment} enumeration (Level 3 §5.4.5). + * + * @since 0.9.0 + */ public enum AuthenticatorAttachment { @JsonProperty("platform") PLATFORM, diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/CeremonyWireMapper.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/CeremonyWireMapper.java index ccb1075..8c56637 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/CeremonyWireMapper.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/CeremonyWireMapper.java @@ -29,6 +29,8 @@ *

  • Start ceremony rate-limit refusal (a {@code CeremonyRateLimitedException} escaping the * service) → 429; adapters call {@link #rateLimited()} to shape the response. * + * + * @since 0.9.0 */ public final class CeremonyWireMapper { diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/ChallengeId.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/ChallengeId.java index 07d29e8..84adda1 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/ChallengeId.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/ChallengeId.java @@ -4,7 +4,11 @@ import java.util.Objects; import java.util.UUID; -/** Opaque identifier for an in-flight WebAuthn challenge in the {@code ChallengeStore}. */ +/** + * Opaque identifier for an in-flight WebAuthn challenge in the {@code ChallengeStore}. + * + * @since 0.9.0 + */ public record ChallengeId(String value) { public ChallengeId { diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/CredentialId.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/CredentialId.java index dde4441..7dc406a 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/CredentialId.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/CredentialId.java @@ -13,6 +13,8 @@ * mutate the stored value. {@code equals} and {@code hashCode} are content-based, which the default * record implementation does not provide for array-valued components. This makes {@code * CredentialId} safe to use as a {@link java.util.Map} or {@link java.util.Set} key. + * + * @since 0.9.0 */ public record CredentialId(byte[] value) { diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/FinishAuthenticationRequest.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/FinishAuthenticationRequest.java index c4890dc..0a22633 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/FinishAuthenticationRequest.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/FinishAuthenticationRequest.java @@ -4,7 +4,11 @@ import com.fasterxml.jackson.annotation.JsonInclude; import java.util.Objects; -/** Host-app input for finishing an authentication ceremony. */ +/** + * Host-app input for finishing an authentication ceremony. + * + * @since 0.9.0 + */ @JsonInclude(JsonInclude.Include.NON_NULL) public record FinishAuthenticationRequest( ChallengeId challengeId, AuthenticationResponseJson response) { diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/FinishRegistrationRequest.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/FinishRegistrationRequest.java index 269b202..4554f44 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/FinishRegistrationRequest.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/FinishRegistrationRequest.java @@ -9,6 +9,8 @@ * Host-app input for finishing a registration ceremony. The {@code challengeId} ties this request * back to the {@link PublicKeyCredentialCreationOptionsJson} previously issued by {@code * startRegistration}. + * + * @since 0.9.0 */ @JsonInclude(JsonInclude.Include.NON_NULL) public record FinishRegistrationRequest( diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/RegistrationResponseJson.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/RegistrationResponseJson.java index 955b342..f9f0dbe 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/RegistrationResponseJson.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/RegistrationResponseJson.java @@ -10,6 +10,8 @@ /** * Browser-returned payload for a registration ceremony. Mirrors WebAuthn JSON Spec §5.1.3 * "RegistrationResponseJSON". + * + * @since 0.9.0 */ @JsonInclude(JsonInclude.Include.NON_NULL) public record RegistrationResponseJson( diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/RegistrationResult.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/RegistrationResult.java index fcab2f6..40f0dfb 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/RegistrationResult.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/RegistrationResult.java @@ -8,6 +8,8 @@ /** * Closed sum of outcomes from {@code PasskeyAuthenticationService.finishRegistration}. Mapped to * HTTP responses by adapter modules. + * + * @since 0.9.0 */ public sealed interface RegistrationResult { diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/ResidentKeyRequirement.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/ResidentKeyRequirement.java index 513dfcc..96950a4 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/ResidentKeyRequirement.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/ResidentKeyRequirement.java @@ -3,7 +3,11 @@ import com.fasterxml.jackson.annotation.JsonProperty; -/** WebAuthn {@code ResidentKeyRequirement} enumeration (Level 3 §5.4.6). */ +/** + * WebAuthn {@code ResidentKeyRequirement} enumeration (Level 3 §5.4.6). + * + * @since 0.9.0 + */ public enum ResidentKeyRequirement { @JsonProperty("required") REQUIRED, diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/StartAuthenticationRequest.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/StartAuthenticationRequest.java index ae60a15..c40e0e0 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/StartAuthenticationRequest.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/StartAuthenticationRequest.java @@ -7,6 +7,8 @@ /** * Host-app input for starting an authentication ceremony. A null {@code username} allows * usernameless / discoverable-credential flows. + * + * @since 0.9.0 */ @JsonInclude(JsonInclude.Include.NON_NULL) public record StartAuthenticationRequest( diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/StartAuthenticationResponse.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/StartAuthenticationResponse.java index ec93a1e..92f2d0b 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/StartAuthenticationResponse.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/StartAuthenticationResponse.java @@ -10,6 +10,8 @@ *

    The browser only consumes {@code publicKey} (which the JS SDK passes to {@code * navigator.credentials.get({ publicKey })}). pk-auth's TS SDK additionally remembers the sibling * {@code challengeId} and includes it in the {@code FinishAuthenticationRequest}. + * + * @since 0.9.0 */ @JsonInclude(JsonInclude.Include.NON_NULL) public record StartAuthenticationResponse( diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/StartRegistrationRequest.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/StartRegistrationRequest.java index 4f18079..cf200be 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/StartRegistrationRequest.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/StartRegistrationRequest.java @@ -5,7 +5,11 @@ import java.util.Objects; import org.jspecify.annotations.Nullable; -/** Host-app input for starting a registration ceremony. */ +/** + * Host-app input for starting a registration ceremony. + * + * @since 0.9.0 + */ @JsonInclude(JsonInclude.Include.NON_NULL) public record StartRegistrationRequest( String username, diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/StartRegistrationResponse.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/StartRegistrationResponse.java index 0369ad2..672ce48 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/StartRegistrationResponse.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/StartRegistrationResponse.java @@ -10,6 +10,8 @@ *

    The browser only consumes {@code publicKey} (which the JS SDK passes to {@code * navigator.credentials.create({ publicKey })}). pk-auth's TS SDK additionally remembers the * sibling {@code challengeId} and includes it in the {@code FinishRegistrationRequest}. + * + * @since 0.9.0 */ @JsonInclude(JsonInclude.Include.NON_NULL) public record StartRegistrationResponse( diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/Transport.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/Transport.java index 2b75605..c721e06 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/Transport.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/Transport.java @@ -9,6 +9,8 @@ * WebAuthn {@code AuthenticatorTransport} values, per the Level 3 spec §5.8.4. Each constant knows * its wire-string form via {@link #wireName()}; parsing from the wire is done with {@link * #fromWire(String)}. + * + * @since 0.9.0 */ public enum Transport { USB("usb"), diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/UserHandle.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/UserHandle.java index 1969f93..08dc895 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/UserHandle.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/UserHandle.java @@ -12,6 +12,8 @@ *

    The byte array is defensively copied on construction and on {@link #value()} so callers cannot * mutate the stored value. {@code equals} and {@code hashCode} are content-based, which the default * record implementation does not provide for array-valued components. + * + * @since 0.9.0 */ public record UserHandle(byte[] value) { diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/UserVerificationRequirement.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/UserVerificationRequirement.java index c1917c2..c7bf95e 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/UserVerificationRequirement.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/UserVerificationRequirement.java @@ -3,7 +3,11 @@ import com.fasterxml.jackson.annotation.JsonProperty; -/** WebAuthn {@code UserVerificationRequirement} enumeration (Level 3 §5.10.6). */ +/** + * WebAuthn {@code UserVerificationRequirement} enumeration (Level 3 §5.10.6). + * + * @since 0.9.0 + */ public enum UserVerificationRequirement { @JsonProperty("required") REQUIRED, diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/ceremony/PasskeyAuthenticationService.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/ceremony/PasskeyAuthenticationService.java index 1c47dd0..4baa21c 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/ceremony/PasskeyAuthenticationService.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/ceremony/PasskeyAuthenticationService.java @@ -19,6 +19,8 @@ *

    No exceptions cross this boundary for ceremony-flow failures — every failure mode is a variant * of the relevant {@code *Result} sealed interface. Methods may still throw on programmer errors * (null inputs, unconfigured RP, etc.). + * + * @since 0.9.0 */ public interface PasskeyAuthenticationService { diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/ceremony/PasskeyAuthenticationServices.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/ceremony/PasskeyAuthenticationServices.java index f759c28..a27eb6b 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/ceremony/PasskeyAuthenticationServices.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/ceremony/PasskeyAuthenticationServices.java @@ -23,6 +23,8 @@ /** * Public entry point for constructing a {@link PasskeyAuthenticationService}. Hides the internal * implementation class while still letting callers wire each SPI explicitly. + * + * @since 0.9.0 */ public final class PasskeyAuthenticationServices { diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/config/CeremonyConfig.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/config/CeremonyConfig.java index 761b4e9..4530f48 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/config/CeremonyConfig.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/config/CeremonyConfig.java @@ -7,7 +7,11 @@ import java.time.Duration; import java.util.Objects; -/** Ceremony-level policy knobs. Brief §7 documents the security-relevant defaults. */ +/** + * Ceremony-level policy knobs. Brief §7 documents the security-relevant defaults. + * + * @since 0.9.0 + */ public record CeremonyConfig( Duration challengeTtl, UserVerificationRequirement userVerification, diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/config/CounterRegressionPolicy.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/config/CounterRegressionPolicy.java index 89d26cb..a6d01da 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/config/CounterRegressionPolicy.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/config/CounterRegressionPolicy.java @@ -6,6 +6,8 @@ * *

    Synced passkeys (iCloud Keychain, Google Password Manager, …) typically report a counter of * zero across all devices, so counter regression is normal for those credentials. + * + * @since 0.9.0 */ public enum CounterRegressionPolicy { /** Reject the assertion (cloning risk). Default for hardware-token-heavy deployments. */ diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/config/RelyingPartyConfig.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/config/RelyingPartyConfig.java index eb40738..58e20e9 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/config/RelyingPartyConfig.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/config/RelyingPartyConfig.java @@ -10,6 +10,7 @@ * @param id the RP ID (eTLD+1, e.g. {@code "example.com"}) * @param name human-readable RP name shown to the user during ceremonies * @param origins the set of acceptable client-reported origins ({@code https://example.com}, …) + * @since 0.9.0 */ public record RelyingPartyConfig(String id, String name, Set origins) { diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/credential/AuthenticatorData.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/credential/AuthenticatorData.java index c7c70c2..72a1a39 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/credential/AuthenticatorData.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/credential/AuthenticatorData.java @@ -17,6 +17,7 @@ * @param attestedCredentialDataIncluded {@code AT} flag * @param extensionDataIncluded {@code ED} flag * @param signCount the authenticator's signature counter + * @since 0.9.0 */ public record AuthenticatorData( byte[] raw, diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/credential/CredentialMetadata.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/credential/CredentialMetadata.java index 2de5f05..70df8b5 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/credential/CredentialMetadata.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/credential/CredentialMetadata.java @@ -10,7 +10,11 @@ import java.util.UUID; import org.jspecify.annotations.Nullable; -/** Read-only credential projection without the COSE public key. Suitable for listing endpoints. */ +/** + * Read-only credential projection without the COSE public key. Suitable for listing endpoints. + * + * @since 0.9.0 + */ public record CredentialMetadata( CredentialId credentialId, String label, diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/credential/CredentialRecord.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/credential/CredentialRecord.java index 2bc19cf..5057302 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/credential/CredentialRecord.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/credential/CredentialRecord.java @@ -28,6 +28,7 @@ * @param backupState authenticator-reported BS flag * @param createdAt when the credential was registered * @param lastUsedAt when the credential was last used in a successful assertion; null if never + * @since 0.9.0 */ public record CredentialRecord( CredentialId credentialId, diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/error/ConfigurationException.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/error/ConfigurationException.java index 53e93bb..c09ede0 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/error/ConfigurationException.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/error/ConfigurationException.java @@ -3,7 +3,11 @@ import org.jspecify.annotations.Nullable; -/** Thrown when pk-auth is misconfigured (missing RP id, no origins, etc.). */ +/** + * Thrown when pk-auth is misconfigured (missing RP id, no origins, etc.). + * + * @since 0.9.0 + */ public class ConfigurationException extends PkAuthException { private static final long serialVersionUID = 1L; diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/error/IllegalPkAuthStateException.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/error/IllegalPkAuthStateException.java index 45d1be4..e062674 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/error/IllegalPkAuthStateException.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/error/IllegalPkAuthStateException.java @@ -3,7 +3,11 @@ import org.jspecify.annotations.Nullable; -/** Thrown when pk-auth state is used in a way that should be impossible — i.e. programmer error. */ +/** + * Thrown when pk-auth state is used in a way that should be impossible — i.e. programmer error. + * + * @since 0.9.0 + */ public class IllegalPkAuthStateException extends PkAuthException { private static final long serialVersionUID = 1L; diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/error/PkAuthErrorCode.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/error/PkAuthErrorCode.java index 7643005..c9c6e5c 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/error/PkAuthErrorCode.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/error/PkAuthErrorCode.java @@ -12,6 +12,8 @@ * {@link #INVALID_SIGNATURE}) are intentionally reserved as part of the wire contract — the library * may begin emitting them in a future release. Consumers should treat the full enum as the source * of truth, not the subset currently observable in production traffic. + * + * @since 0.9.0 */ public enum PkAuthErrorCode { /** Internal misconfiguration detected at startup or during a ceremony. */ diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/error/PkAuthException.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/error/PkAuthException.java index f84882b..8b802db 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/error/PkAuthException.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/error/PkAuthException.java @@ -7,6 +7,8 @@ /** * Base unchecked exception for pk-auth programmer errors. Ceremony-flow failures are returned as * variants of {@code *Result} sealed interfaces, not thrown. + * + * @since 0.9.0 */ public abstract class PkAuthException extends RuntimeException { diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/json/Base64Url.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/json/Base64Url.java index f6df2dc..2f2dbdc 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/json/Base64Url.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/json/Base64Url.java @@ -8,6 +8,8 @@ * RFC 4648 §5 base64url codec with padding stripped. WebAuthn JSON uses the no-padding form for * every binary field; pk-auth uses this codec uniformly for both serialization and any * application-level base64url conversions. + * + * @since 0.9.0 */ public final class Base64Url { diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/json/PkAuthObjectMappers.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/json/PkAuthObjectMappers.java index e94e347..38afcd9 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/json/PkAuthObjectMappers.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/json/PkAuthObjectMappers.java @@ -34,6 +34,8 @@ * *

    Jackson 3 mappers are immutable; each call returns a fresh builder so adapter modules may * apply additional configuration before {@code build()}. + * + * @since 0.9.0 */ public final class PkAuthObjectMappers { diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/metrics/Metrics.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/metrics/Metrics.java index 8319d9b..e5b29a0 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/metrics/Metrics.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/metrics/Metrics.java @@ -8,6 +8,8 @@ * Tiny metrics facade with two implementations: a no-op default and a Micrometer-backed factory * activated only when {@code io.micrometer:micrometer-core} is on the runtime classpath. Phase 1 * defines the shape; Phase 2 wires concrete counters in the ceremony service. + * + * @since 0.9.0 */ public interface Metrics { diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/AttestationTrustPolicy.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/AttestationTrustPolicy.java index a100526..a4ad804 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/AttestationTrustPolicy.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/AttestationTrustPolicy.java @@ -20,6 +20,8 @@ * verification) MUST request {@link AttestationConveyance#DIRECT} (or stricter) via {@code * RelyingPartyConfig} so the underlying WebAuthn manager runs the format-specific verifier. Until * that happens, treat {@code format} as untrusted metadata. + * + * @since 0.9.0 */ public interface AttestationTrustPolicy { diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/BackupCodeRepository.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/BackupCodeRepository.java index b4a869c..3286d1b 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/BackupCodeRepository.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/BackupCodeRepository.java @@ -7,7 +7,11 @@ import java.util.Objects; import org.jspecify.annotations.Nullable; -/** Persistent storage for backup-code hashes (brief §6.3). One row per issued code. */ +/** + * Persistent storage for backup-code hashes (brief §6.3). One row per issued code. + * + * @since 0.9.0 + */ public interface BackupCodeRepository { /** Stored representation of a single backup code. The plaintext is never persisted. */ diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/ChallengeRecord.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/ChallengeRecord.java index 714e560..089199f 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/ChallengeRecord.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/ChallengeRecord.java @@ -15,6 +15,7 @@ * @param userHandle the user this challenge is bound to; nullable for usernameless flows where the * user is only known at finish * @param expiresAt absolute expiration; consumers should treat past-due records as missing + * @since 0.9.0 */ public record ChallengeRecord( byte[] challenge, Purpose purpose, @Nullable UserHandle userHandle, Instant expiresAt) { diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/ChallengeStore.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/ChallengeStore.java index 1467393..b2b648d 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/ChallengeStore.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/ChallengeStore.java @@ -9,6 +9,8 @@ * Single-use challenge store. Implementations must enforce atomic single-use semantics in {@link * #takeOnce} — reading a challenge MUST remove it from the store in the same operation to prevent * replay. + * + * @since 0.9.0 */ public interface ChallengeStore { diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/ClockProvider.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/ClockProvider.java index 54bdcdf..abdefe2 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/ClockProvider.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/ClockProvider.java @@ -7,6 +7,8 @@ /** * Indirection over {@link Clock} so ceremony logic stays testable. Adapters wire either the default * system clock or a controllable test clock. + * + * @since 0.9.0 */ @FunctionalInterface public interface ClockProvider { 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 19dada4..be8e116 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 @@ -11,6 +11,8 @@ /** * Persistent storage for passkey credentials. Implemented by the JDBI and DynamoDB persistence * modules (and by {@code InMemoryEverything} in the testkit). + * + * @since 0.9.0 */ public interface CredentialRepository { diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/OriginValidator.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/OriginValidator.java index 3afdd4c..9734fd8 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/OriginValidator.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/OriginValidator.java @@ -8,6 +8,8 @@ * Predicate over the client-reported origin. The default implementation is a strict allow-list * match against {@link RelyingPartyConfig#origins()}. Custom implementations may accept additional * origins (e.g., embedded webviews) — at their own risk. + * + * @since 0.9.0 */ @FunctionalInterface public interface OriginValidator { 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 013a70b..d474ecd 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 @@ -7,7 +7,11 @@ import java.util.Optional; import java.util.OptionalInt; -/** Persistent storage for SMS-OTP records (brief §6.5). */ +/** + * Persistent storage for SMS-OTP records (brief §6.5). + * + * @since 0.9.0 + */ public interface OtpRepository { /** diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/UserLookup.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/UserLookup.java index 7457868..6337577 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/UserLookup.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/UserLookup.java @@ -8,6 +8,8 @@ /** * Host-app integration point that maps usernames to pk-auth user handles. pk-auth does not own the * users table; the host application is the source of truth. + * + * @since 0.9.0 */ public interface UserLookup { diff --git a/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/PublicApiSinceTagTest.java b/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/PublicApiSinceTagTest.java new file mode 100644 index 0000000..404c225 --- /dev/null +++ b/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/PublicApiSinceTagTest.java @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: MIT +package com.codeheadsystems.pkauth; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; + +/** + * Mechanical {@code @since} gate (CONTRIBUTING.md §7): every public top-level type in an exported + * package must carry a class-level {@code @since} Javadoc tag. This stops the convention from + * silently rotting on the most load-bearing types — when it fails, add {@code @since } to + * the type's class Javadoc (the in-flight version from {@code gradle.properties}, minus {@code + * -SNAPSHOT}). + */ +class PublicApiSinceTagTest { + + /** Packages exported by {@code module-info.java}. Keep in sync with the module declaration. */ + private static final Set EXPORTED_PACKAGES = + Set.of( + "api", + "ceremony", + "config", + "credential", + "error", + "json", + "lifecycle", + "metrics", + "spi"); + + private static final Path SOURCE_ROOT = Path.of("src/main/java/com/codeheadsystems/pkauth"); + + private static final Pattern TYPE_DECL = + Pattern.compile( + "^(public\\s+(?:final\\s+|abstract\\s+|sealed\\s+|non-sealed\\s+)*)" + + "(class|interface|record|enum)\\s+(\\w+)", + Pattern.MULTILINE); + + @Test + void everyExportedPublicTypeHasSinceTag() throws IOException { + assertThat(SOURCE_ROOT).as("source root must exist").exists(); + List violations = new ArrayList<>(); + try (Stream files = Files.walk(SOURCE_ROOT)) { + files + .filter(p -> p.toString().endsWith(".java")) + .filter(p -> !p.getFileName().toString().equals("package-info.java")) + .filter(p -> !p.getFileName().toString().equals("module-info.java")) + .filter(PublicApiSinceTagTest::inExportedPackage) + .forEach(p -> checkFile(p, violations)); + } + assertThat(violations) + .as("public types in exported packages missing a class-level @since tag") + .isEmpty(); + } + + private static boolean inExportedPackage(Path file) { + Path rel = SOURCE_ROOT.relativize(file); + return rel.getNameCount() >= 2 && EXPORTED_PACKAGES.contains(rel.getName(0).toString()); + } + + private static void checkFile(Path file, List violations) { + String src; + try { + src = Files.readString(file); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + Matcher m = TYPE_DECL.matcher(src); + if (!m.find()) { + return; // no public top-level type (e.g. an internal-only helper) + } + String javadoc = precedingJavadoc(src, m.start()); + if (javadoc == null || !javadoc.contains("@since")) { + violations.add(file.getFileName() + " (" + m.group(3) + ")"); + } + } + + /** Returns the Javadoc block immediately preceding {@code declStart}, or {@code null}. */ + private static String precedingJavadoc(String src, int declStart) { + String head = src.substring(0, declStart); + int close = head.lastIndexOf("*/"); + if (close < 0) { + return null; + } + int open = head.lastIndexOf("/**", close); + if (open < 0) { + return null; + } + // Nothing but annotations / whitespace may sit between the Javadoc and the declaration. + String between = head.substring(close + 2); + if (between.matches("(?s).*\\b(class|interface|record|enum)\\b.*")) { + return null; + } + return head.substring(open, close + 2); + } +}