From c41e3c4f405cd1a9ef5228808f3c58a8e4b18524 Mon Sep 17 00:00:00 2001 From: Ned Wolpert Date: Wed, 7 Oct 2026 07:44:43 -0700 Subject: [PATCH] docs: add style guide and bring docs, Javadoc and site into conformance - Add docs/style.md (register, emphasis, headings, justification, punctuation and mechanics) and point CLAUDE.md at it. - Root Markdown (README, DESIGN, CONTRIBUTING, GETTING_STARTED, RELEASE, SECURITY, CHANGELOG, CLAUDE.md), docs/ (operator guide, stability, threat model, transactional semantics, ADRs), example and SDK READMEs, and site/index.html: third-person present tense, no stress emphasis, noun-phrase headings, no em dashes, serial comma, British spelling, 100-column wrap. Cross-references to renamed anchors updated. - Javadoc across all modules and TSDoc in the browser SDK: same rules, prose only; code, identifiers and tags unchanged. - Factual fixes found along the way: ChallengeId format and SPI ownership in DESIGN.md, demo persistence flags in GETTING_STARTED and the Dropwizard demo, migrations are host-run (site), missing [2.2.0] CHANGELOG link reference. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 280 ++++----- CLAUDE.md | 152 +++-- CONTRIBUTING.md | 46 +- DESIGN.md | 560 ++++++++---------- GETTING_STARTED.md | 256 ++++---- README.md | 173 +++--- RELEASE.md | 199 +++---- SECURITY.md | 89 ++- clients/passkeys-browser/README.md | 41 +- clients/passkeys-browser/src/base64url.ts | 10 +- clients/passkeys-browser/src/ceremonies.ts | 12 +- clients/passkeys-browser/src/index.ts | 2 +- clients/passkeys-browser/src/refresh.ts | 8 +- clients/passkeys-browser/src/types.ts | 14 +- .../adr/0001-record-architecture-decisions.md | 38 +- docs/adr/0002-webauthn4j-over-yubico.md | 42 +- docs/adr/0003-jdbi-over-jpa.md | 43 +- docs/adr/0004-dagger-for-dropwizard.md | 127 ++-- docs/adr/0005-stateless-jwt-default.md | 92 ++- docs/adr/0006-userlookup-spi-not-owned.md | 70 +-- docs/adr/0007-dynamodb-local-vs-localstack.md | 40 +- docs/adr/0008-dynamodb-single-table-design.md | 45 +- docs/adr/0009-jackson-3-over-jackson-2.md | 67 ++- docs/adr/0010-dropwizard-track-latest.md | 75 ++- docs/adr/0011-spring-boot-4.md | 81 ++- docs/adr/0012-micronaut-4.md | 62 +- .../0013-refresh-tokens-family-rotation.md | 262 ++++---- docs/adr/0014-per-audience-ttl-policy.md | 148 +++-- docs/adr/0015-stateful-access-tokens.md | 131 ++-- docs/adr/0016-user-deletion-fan-out.md | 191 +++--- .../0017-sonarqube-cloud-static-analysis.md | 68 ++- docs/adr/0018-remove-sonarqube-cloud.md | 68 +-- ...9-crypto-agility-post-quantum-readiness.md | 104 ++-- docs/adr/0020-browser-sdk-testing-strategy.md | 128 ++-- docs/operator-guide.md | 348 ++++++----- docs/stability.md | 57 +- docs/style.md | 83 +++ docs/threat-model.md | 482 ++++++++------- docs/transactional-semantics.md | 90 ++- examples/dropwizard-demo/README.md | 62 +- .../demo/dropwizard/DemoApplication.java | 19 +- .../demo/dropwizard/DemoConfiguration.java | 6 +- .../pkauth/demo/dropwizard/DemoSmokeTest.java | 4 +- examples/micronaut-demo/README.md | 53 +- .../micronaut/StaticAssetsController.java | 7 +- examples/spring-boot-demo/README.md | 89 ++- .../spring/SpringBootDemoApplication.java | 2 +- .../spring/SpringBootDemoApplicationTest.java | 4 +- .../pkauth/admin/AdminAuthorizer.java | 2 +- .../pkauth/admin/AdminRequests.java | 2 +- .../pkauth/admin/AdminResponseMapper.java | 2 +- .../pkauth/admin/AdminResult.java | 4 +- .../pkauth/admin/AdminSafetyConfig.java | 4 +- .../pkauth/admin/AdminService.java | 6 +- .../admin/BackupCodesCountResponse.java | 2 +- .../pkauth/admin/CredentialSummary.java | 2 +- .../pkauth/admin/DefaultAdminService.java | 10 +- .../pkauth/admin/EmailVerificationResult.java | 8 +- .../pkauth/admin/AdminResponseShapesTest.java | 2 +- .../pkauth/admin/DefaultAdminServiceTest.java | 2 +- .../pkauth/backupcodes/BackupCodeService.java | 47 +- .../pkauth/api/AssertionResult.java | 27 +- .../pkauth/api/CeremonyWireMapper.java | 14 +- ...ublicKeyCredentialCreationOptionsJson.java | 10 +- ...PublicKeyCredentialRequestOptionsJson.java | 10 +- .../api/StartAuthenticationRequest.java | 9 +- .../pkauth/api/StartAuthenticationResult.java | 8 +- .../pkauth/api/StartRegistrationRequest.java | 2 +- .../pkauth/api/StartRegistrationResult.java | 8 +- .../codeheadsystems/pkauth/api/Transport.java | 2 +- .../pkauth/api/package-info.java | 2 +- .../ceremony/InMemoryCeremonyRateLimiter.java | 20 +- .../PasskeyAuthenticationService.java | 22 +- .../PasskeyAuthenticationServices.java | 8 +- .../pkauth/config/CeremonyConfig.java | 40 +- .../pkauth/config/CoseAlgorithm.java | 14 +- .../config/CounterRegressionPolicy.java | 4 +- .../pkauth/config/RelyingPartyConfig.java | 7 +- .../credential/CredentialAlgorithms.java | 8 +- .../pkauth/credential/CredentialRecord.java | 6 +- .../error/IllegalPkAuthStateException.java | 3 +- .../pkauth/error/PkAuthErrorCode.java | 16 +- .../pkauth/internal/ChallengeGenerator.java | 10 +- .../pkauth/internal/ChallengeValidation.java | 2 +- .../pkauth/internal/ChallengeValidator.java | 12 +- .../pkauth/internal/ClientDataJsonParser.java | 8 +- .../DefaultPasskeyAuthenticationService.java | 41 +- .../pkauth/internal/WebAuthn4JConverters.java | 14 +- .../pkauth/internal/package-info.java | 2 +- .../pkauth/json/Base64Url.java | 2 +- .../pkauth/json/PkAuthObjectMappers.java | 8 +- .../lifecycle/UserDeletionListener.java | 12 +- .../pkauth/lifecycle/UserDeletionService.java | 26 +- .../pkauth/metrics/Metrics.java | 4 +- .../ratelimit/InMemoryWindowCounter.java | 8 +- .../pkauth/spi/AttestationTrustPolicy.java | 20 +- .../pkauth/spi/BackupCodeRepository.java | 19 +- .../pkauth/spi/CeremonyRateLimiter.java | 31 +- .../pkauth/spi/ChallengeRecord.java | 4 +- .../pkauth/spi/ChallengeStore.java | 24 +- .../pkauth/spi/ConsumedJtiStore.java | 12 +- .../pkauth/spi/CredentialRepository.java | 22 +- .../pkauth/spi/MessageFormatter.java | 8 +- .../pkauth/spi/OriginValidator.java | 2 +- .../pkauth/spi/OtpRepository.java | 12 +- .../spi/PkAuthPersistenceException.java | 22 +- .../pkauth/spi/PkAuthPersistenceResponse.java | 4 +- .../pkauth/spi/UserLookup.java | 8 +- .../pkauth/spi/package-info.java | 12 +- pk-auth-core/src/main/java/module-info.java | 3 +- .../pkauth/PublicApiSinceTagTest.java | 8 +- .../pkauth/api/CeremonyWireMapperTest.java | 3 +- ...keyAuthenticationServiceAlgorithmTest.java | 4 +- ...AuthenticationServiceRegistrationTest.java | 2 +- .../ratelimit/InMemoryWindowCounterTest.java | 2 +- .../pkauth/dropwizard/PkAuthBundle.java | 16 +- .../auth/PkAuthDropwizardAuthFilter.java | 10 +- .../auth/PkAuthDropwizardAuthenticator.java | 4 +- .../dropwizard/config/PkAuthConfig.java | 18 +- .../dropwizard/dagger/AltFlowsModule.java | 10 +- .../dagger/PersistenceBindings.java | 12 +- .../dagger/PkAuthCeremonyGraph.java | 2 +- .../dropwizard/dagger/PkAuthComponent.java | 10 +- .../dagger/PkAuthFullComponent.java | 8 +- .../dropwizard/dagger/PkAuthModule.java | 10 +- .../dropwizard/json/PkAuthJacksonBridge.java | 10 +- .../resource/PkAuthCeremonyResource.java | 4 +- .../PkAuthPersistenceExceptionMapper.java | 4 +- .../PkAuthBundleAltFlowsIntegrationTest.java | 2 +- .../admin/PkAuthAdminResourceTest.java | 2 +- .../pkauth/composition/PkAuthComposition.java | 13 +- .../pkauth/jwt/AccessTokenStore.java | 46 +- .../jwt/AccessTokenStoreDeletionListener.java | 2 +- .../pkauth/jwt/AuthMethod.java | 2 +- .../pkauth/jwt/CeremonyOrchestrator.java | 11 +- .../codeheadsystems/pkauth/jwt/JwtConfig.java | 25 +- .../codeheadsystems/pkauth/jwt/JwtKeyset.java | 16 +- .../pkauth/jwt/JwtSecretResolver.java | 9 +- .../pkauth/jwt/NoopAccessTokenStore.java | 2 +- .../pkauth/jwt/PkAuthCeremonyJwt.java | 17 +- .../pkauth/jwt/PkAuthJwtIssuer.java | 22 +- .../pkauth/jwt/PkAuthJwtValidator.java | 28 +- .../pkauth/jwt/RevocationCheck.java | 18 +- .../pkauth/jwt/TokenTtlPolicy.java | 10 +- .../pkauth/jwt/package-info.java | 35 +- .../jwt/PkAuthJwtValidatorEdgeCasesTest.java | 2 +- .../magiclink/DefaultMagicLinkFormatter.java | 2 +- .../pkauth/magiclink/EmailSender.java | 2 +- .../magiclink/InMemoryConsumedJtiStore.java | 18 +- .../pkauth/magiclink/JavaMailEmailSender.java | 14 +- .../pkauth/magiclink/MagicLinkContext.java | 6 +- .../pkauth/magiclink/MagicLinkMessage.java | 2 +- .../pkauth/magiclink/MagicLinkService.java | 152 +++-- .../MagicLinkServiceEdgeCasesTest.java | 4 +- .../magiclink/MagicLinkServiceTest.java | 2 +- .../micronaut/PkAuthAdminController.java | 11 +- .../pkauth/micronaut/PkAuthAdminFactory.java | 2 +- .../micronaut/PkAuthCeremonyController.java | 10 +- .../pkauth/micronaut/PkAuthConfiguration.java | 16 +- .../pkauth/micronaut/PkAuthFactory.java | 4 +- .../micronaut/PkAuthIntrospections.java | 6 +- .../PkAuthJwtAuthenticationFilter.java | 14 +- .../PkAuthPersistenceExceptionHandler.java | 2 +- .../micronaut/PkAuthRefreshController.java | 2 +- .../PkAuthAdminEndpointsCoverageTest.java | 2 +- .../PkAuthIntrospectionsCoverageTest.java | 8 +- .../pkauth/otp/DefaultOtpFormatter.java | 2 +- .../pkauth/otp/OtpContext.java | 4 +- .../pkauth/otp/OtpMessage.java | 2 +- .../pkauth/otp/OtpPepperResolver.java | 20 +- .../pkauth/otp/OtpService.java | 27 +- .../codeheadsystems/pkauth/otp/SmsSender.java | 6 +- .../pkauth/otp/TwilioSmsSender.java | 13 +- .../pkauth/otp/OtpServiceTest.java | 2 +- .../persistence/dynamodb/AccessTokenItem.java | 6 +- .../persistence/dynamodb/BackupCodeItem.java | 2 +- .../persistence/dynamodb/ChallengeItem.java | 2 +- .../persistence/dynamodb/CredentialItem.java | 2 +- .../dynamodb/DynamoDbAccessTokenStore.java | 2 +- .../DynamoDbRefreshTokenRepository.java | 30 +- .../persistence/dynamodb/DynamoDbSupport.java | 4 +- .../persistence/dynamodb/DynamoKeys.java | 6 +- .../pkauth/persistence/dynamodb/OtpItem.java | 2 +- .../dynamodb/PkAuthDynamoTables.java | 4 +- .../dynamodb/RefreshTokenItem.java | 8 +- .../pkauth/persistence/dynamodb/UserItem.java | 10 +- ...DbCredentialRepositoryIntegrationTest.java | 2 +- .../DynamoDbUserLookupIntegrationTest.java | 4 +- .../jdbi/JdbiCredentialRepository.java | 4 +- .../jdbi/JdbiRefreshTokenRepository.java | 2 +- .../persistence/jdbi/PkAuthJdbiSchema.java | 14 +- ...biCredentialRepositoryIntegrationTest.java | 2 +- .../JdbiRepositoryExceptionWrappingTest.java | 8 +- .../jdbi/JdbiUserLookupIntegrationTest.java | 4 +- .../pkauth/refresh/RefreshTokenConfig.java | 6 +- .../pkauth/refresh/RefreshTokenPair.java | 4 +- .../pkauth/refresh/RefreshTokenRecord.java | 6 +- .../pkauth/refresh/RefreshTokenService.java | 16 +- .../pkauth/refresh/RefreshTtlPolicy.java | 10 +- .../pkauth/refresh/RevokeReason.java | 4 +- .../pkauth/refresh/RotateResult.java | 4 +- .../pkauth/refresh/RotatedClaims.java | 6 +- .../pkauth/refresh/package-info.java | 6 +- .../pkauth/refresh/spi/Amr.java | 2 +- .../refresh/spi/RefreshTokenRepository.java | 41 +- .../pkauth/refresh/web/RefreshHandler.java | 4 +- .../src/main/java/module-info.java | 2 +- .../refresh/RefreshTokenServiceTest.java | 4 +- .../PkAuthAdminAutoConfiguration.java | 4 +- .../PkAuthAutoConfiguration.java | 34 +- ...hDynamoDbPersistenceAutoConfiguration.java | 6 +- ...kAuthJdbiPersistenceAutoConfiguration.java | 12 +- .../PkAuthWebAutoConfiguration.java | 42 +- .../spring/config/PkAuthProperties.java | 22 +- .../PkAuthJwtAuthenticationFilter.java | 40 +- .../PkAuthJwtAuthenticationToken.java | 5 +- .../spring/web/PkAuthExceptionHandler.java | 2 +- .../pkauth/spring/PkAuthDevModeGuardTest.java | 6 +- ...amoDbPersistenceAutoConfigurationTest.java | 8 +- ...hJdbiPersistenceAutoConfigurationTest.java | 5 +- .../pkauth/spring/PkAuthTestApplication.java | 6 +- .../testkit/AccessTokenStoreScenarios.java | 7 +- .../BackupCodeRepositoryScenarios.java | 8 +- .../pkauth/testkit/CeremonyScenarios.java | 13 +- .../testkit/ChallengeStoreScenarios.java | 12 +- .../pkauth/testkit/FakeAuthenticator.java | 4 +- .../pkauth/testkit/InMemoryEverything.java | 4 +- .../InMemoryRefreshTokenRepository.java | 2 +- .../testkit/OtpRepositoryScenarios.java | 8 +- .../pkauth/testkit/RefreshTokenScenarios.java | 22 +- .../pkauth/testkit/FullCeremonyTest.java | 4 +- site/fonts/README.md | 9 +- site/index.html | 245 ++++---- site/og-image.svg | 6 +- 234 files changed, 3663 insertions(+), 3575 deletions(-) create mode 100644 docs/style.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 12a250c0..63cf36e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,124 +4,125 @@ All notable changes to pk-auth are recorded here. The format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -The 0.x line is treated as a single pre-stable development series — see +The 0.x line is treated as a single pre-stable development series; see `docs/stability.md` and the early ADRs for context. This changelog starts at the -1.0.0 stabilisation cut; for 0.x history consult `git log` against the relevant -tags. +1.0.0 stabilisation cut; for 0.x history, `git log` against the relevant tags is +the source. ## [Unreleased] A security release driven by a full-project audit. Every entry below closes a finding from that review. -**JDBI hosts pick up one new Flyway migration (V12)** which changes username -uniqueness semantics — read the entry below before upgrading. +JDBI hosts pick up one new Flyway migration (V12), which changes username +uniqueness semantics. The entry on JDBI username uniqueness below carries the +upgrade note. ### Security -- **Magic-link `SendResult.Sent` no longer carries the login token.** The record - component was named `tokenJti` but held the complete signed magic-link JWT — a +- Magic-link `SendResult.Sent` no longer carries the login token. The record + component was named `tokenJti` but held the complete signed magic-link JWT, a bearer credential that authenticates as the user without inbox access. A host logging or echoing what its name advertised as an opaque correlation id was publishing a working login credential. `Sent` now carries the real `jti`; the - token reaches only the configured `EmailSender`. **Breaking:** the component is - renamed `tokenJti` → `jti` *and* its value changes, so hosts that recovered the + token reaches only the configured `EmailSender`. Breaking: the component is + renamed `tokenJti` → `jti` and its value changes, so hosts that recovered the token from the result (e.g. to render their own email) must instead capture it in a `MessageFormatter` / `EmailSender`. `startLogin`'s enumeration-resistant short-circuit paths continue to return `Sent("")`. -- **Usernames are bounded and the in-memory rate-limiter caches can no longer grow - without limit.** Nothing capped username length (the JDBI column is Postgres +- Usernames are bounded and the in-memory rate-limiter caches can no longer grow + without limit. Nothing capped username length (the JDBI column is Postgres `TEXT`), and both Caffeine caches used `expireAfterWrite` with no - `maximumSize` — so on the `permitAll` start endpoints, a caller varying its + `maximumSize`. On the `permitAll` start endpoints, a caller varying its username grew the maps with its own key variety rather than with the number of real users, and every distinct key was retained for the full window. `StartRegistrationRequest.MAX_USERNAME_LENGTH` (256, enough for an email address) now bounds both start requests, and `InMemoryCeremonyRateLimiter` / `InMemoryWindowCounter` cap their maps at `DEFAULT_MAX_TRACKED_KEYS` (100 000). An over-long username is refused as a - clean **400** — covered by an adapter-level test asserting the refusal reaches - the client as a 400 and not a 500. `StartAuthenticationRequest` still accepts a + clean `400`, covered by an adapter-level test asserting the refusal reaches + the client as a `400` and not a `500`. `StartAuthenticationRequest` still accepts a `null` username (the usernameless / discoverable-credential flow). -- **The per-username ceremony rate limit can no longer be bypassed by varying - case.** `UserLookup` implementations resolve usernames case-insensitively +- The per-username ceremony rate limit can no longer be bypassed by varying + case. `UserLookup` implementations resolve usernames case-insensitively (`DynamoDbUserLookup` lower-cases the identity key), but the ceremony service - passed the raw request string to `CeremonyRateLimiter.tryAcquireForUsername` — + passed the raw request string to `CeremonyRateLimiter.tryAcquireForUsername`, so `alice`, `Alice`, and `ALICE` drew on three independent budgets against one account, and an 8-character username yielded 256 of them. The per-IP bucket did not compensate: the per-username bucket exists for the distributed case, which is exactly the case the split defeated. The service now case-folds - (`toLowerCase(Locale.ROOT)`) before consulting the limiter, so **every** - `CeremonyRateLimiter` — including a host's shared Redis implementation — - inherits the fix. The SPI documents that the argument arrives folded and MUST + (`toLowerCase(Locale.ROOT)`) before consulting the limiter, so every + `CeremonyRateLimiter`, including a host's shared Redis implementation, + inherits the fix. The SPI documents that the argument arrives folded and must be used as given. -- **JDBI username uniqueness is now case-insensitive, matching DynamoDB.** +- JDBI username uniqueness is now case-insensitive, matching DynamoDB. `DynamoDbUserLookup` keys identity on `lower(username)`, so `Admin` and `admin` are one account there; `JdbiUserLookup` matched exactly against a plain `UNIQUE` constraint, so on Postgres they were two. Same library, same SPI, two - identity models — a host assuming the DynamoDB semantics, or migrating between - backends, could end up with look-alike accounts. **Flyway migration V12** + identity models: a host assuming the DynamoDB semantics, or migrating between + backends, could end up with look-alike accounts. Flyway migration V12 (`PkAuthJdbiSchema.CURRENT_SCHEMA_VERSION` → `12`) adds a unique index on `lower(username)` and the lookup queries fold case to match. The stored username keeps its original casing, so display values round-trip unchanged. - **Upgrade note:** V12 runs a pre-flight check and **refuses to migrate**, naming + Upgrade note: V12 runs a pre-flight check and refuses to migrate, naming the conflicting rows, if the database already contains usernames differing only by case. Choosing which row is authoritative and what becomes of the other's credentials is a business decision, not one a schema change should make - silently. Resolve the listed conflicts and re-run. -- **Credential labels are bounded and no longer rendered as HTML by the demos.** + silently. The listed conflicts are resolved and the migration is re-run. +- Credential labels are bounded and no longer rendered as HTML by the demos. The label is the one free-text field an unauthenticated caller controls (set at `register/finish`, changed via `PATCH /auth/admin/credentials/{id}`), it was validated only for blankness, and all three demos interpolated it straight into - `innerHTML` when rendering the credential list — so markup stored in a label + `innerHTML` when rendering the credential list, so markup stored in a label executed on render, and the demos keep the access token in `localStorage`. The demos now build the row from DOM nodes and assign the label via `textContent`, and the library caps the label at `CredentialRecord.MAX_LABEL_LENGTH` (64 - chars), returning `RegistrationResult.InvalidPayload` / `AdminResult - .ValidationFailed` — a clean 400, never a thrown exception across the sealed - result boundary. The bound is checked *before* the challenge preflight, so a + chars), returning `RegistrationResult.InvalidPayload` / + `AdminResult.ValidationFailed`, a clean `400`, never a thrown exception across the sealed + result boundary. The bound is checked before the challenge preflight, so a rejected label does not burn the single-use challenge. -## [2.2.0] — 2026-06-27 +## [2.2.0] - 2026-06-27 A security-and-correctness release driven by two rounds of adversarial review. The headline is a set of fixes to the magic-link, OTP, and JWT alt-flows plus server-side enforcement of per-request user verification. Passkey-core hosts -upgrade by bumping the version; **hosts that implement the `OtpRepository` SPI -directly must add the new `Instant now` parameter** to `findLatestActive` (see +upgrade by bumping the version; hosts that implement the `OtpRepository` SPI +directly must add the new `Instant now` parameter to `findLatestActive` (see Changed), and JDBI hosts pick up one new Flyway migration (V11). ### Security -- **Magic-link tokens can no longer be replayed or used as bearer tokens.** Links +- Magic-link tokens can no longer be replayed or used as bearer tokens. Links are issued with a dedicated short TTL (15 min, owned by `MagicLinkService` rather than inherited from the 1-hour access-token TTL), the consumed-JTI retention is enforced to be ≥ the token TTL at construction, and tokens carry a dedicated audience (`MagicLinkService.DEFAULT_AUDIENCE`) so the resource-server - validator rejects them — a magic-link token can no longer stand in for an API + validator rejects them. A magic-link token can no longer stand in for an API access token. -- **Magic-link login no longer delivers to attacker-supplied addresses.** +- Magic-link login no longer delivers to attacker-supplied addresses. `MagicLinkService.startLogin` sends only to the address bound to the resolved user via `UserLookup#emailFor`, never the caller-supplied address (account-takeover fix). -- **Per-request user verification is enforced server-side.** A per-ceremony +- Per-request user verification is enforced server-side. A per-ceremony `userVerification=REQUIRED` (e.g. step-up) is persisted on the challenge and enforced at finish as the stricter of {global config, per-request}, so it can no longer be silently downgraded by a non-cooperative client. -- **JWT claim-override hardening.** `JwtClaims` rejects caller-supplied - `additionalClaims` that collide with reserved claims (`iss/sub/aud/exp/jti/ - pkauth.*`), closing an impersonation / TTL-bypass vector. -- **OTP brute-force off-by-one fixed** so exactly `maxAttempts` guesses are - compared, and admin OTP `AttemptsExceeded` now surfaces as HTTP 429 rather than - a 200 with the failure buried in the body. -- **No secret leakage:** `JwtKeyset.es256` puts only public JWKs in +- JWT claim-override hardening. `JwtClaims` rejects caller-supplied + `additionalClaims` that collide with reserved claims (`iss/sub/aud/exp/jti/pkauth.*`), closing + an impersonation / TTL-bypass vector. +- OTP brute-force off-by-one fixed so exactly `maxAttempts` guesses are + compared, and admin OTP `AttemptsExceeded` now surfaces as HTTP `429` rather than + a `200` with the failure buried in the body. +- No secret leakage: `JwtKeyset.es256` puts only public JWKs in `verificationSource()` (a host publishing a JWKS endpoint can't leak the signing key), and the `TwilioSmsSender` skeleton no longer echoes its account SID / sender number into exception messages. ### Added -- `Retry-After: 60` on rate-limited (429) ceremony endpoints +- `Retry-After: 60` on rate-limited (`429`) ceremony endpoints (registration/authentication start + finish), via a new `headers` component on the exported `CeremonyResponse` wire record (back-compatible two-arg constructor); all three adapters copy it onto the HTTP response. @@ -129,12 +130,12 @@ Changed), and JDBI hosts pick up one new Flyway migration (V11). `MagicLinkService.DEFAULT_AUDIENCE` for the dedicated-audience wiring above; all three adapters wire the magic-link service through it. - `ChallengeRecord` carries the resolved `userVerification` requirement (with a - back-compatible four-arg constructor); JDBI persists it via **Flyway migration - V11** (`challenges.user_verification`, schema version → `11`). + back-compatible four-arg constructor); JDBI persists it via Flyway migration + V11 (`challenges.user_verification`, schema version → `11`). ### Changed -- **`OtpRepository.findLatestActive` now takes an `Instant now` parameter** +- `OtpRepository.findLatestActive` now takes an `Instant now` parameter (breaking for direct SPI implementers) so each backend filters expired codes against the host `ClockProvider` rather than the database wall clock. - Magic-link tokens are no longer recorded in the `AccessTokenStore` (the @@ -160,24 +161,24 @@ Changed), and JDBI hosts pick up one new Flyway migration (V11). - `AAGUID` is null-guarded in `persistRegistration` so a null AAGUID returns a sealed `RegistrationResult` instead of throwing across the result boundary. -## [2.1.0] — 2026-06-23 +## [2.1.0] - 2026-06-23 -A backward-compatible minor release. The headline is **crypto-agility and -post-quantum readiness for passkey signature algorithms** (ADR 0019): the +A backward-compatible minor release. The headline is crypto-agility and +post-quantum readiness for passkey signature algorithms (ADR 0019): the two divergent hardcoded COSE algorithm lists are replaced by a single operator-configurable source of truth, and there is now a way to see which algorithm each stored credential uses. No public API is removed and the HTTP `/auth/**` wire contract is unchanged, so hosts upgrade by bumping the version. -To be precise about scope: **no post-quantum signature algorithm is added** — -PQC for passkeys is gated end-to-end by authenticator hardware, CTAP2/FIDO2, -the WebAuthn/COSE registry, and WebAuthn4J's verifier, none of which yet -standardize one. The goal here is crypto-agility (clean removal of the -migration obstacles) and honest documentation, not new algorithms. +Scope: no post-quantum signature algorithm is added. PQC for passkeys is gated +end-to-end by authenticator hardware, CTAP2/FIDO2, the WebAuthn/COSE registry, +and WebAuthn4J's verifier, none of which yet standardise one. The goal of the +release is crypto-agility (clean removal of the migration obstacles) and +accurate documentation, not new algorithms. ### Added -- **Crypto-agility for passkey signature algorithms** (ADR 0019). A new +- Crypto-agility for passkey signature algorithms (ADR 0019). A new framework-neutral `CoseAlgorithm` enum, and `CeremonyConfig` now carries two ordered lists: `offeredAlgorithms` (advertised in registration create-options) and `acceptedAlgorithms` (enforced on the WebAuthn4J verify path). @@ -185,50 +186,50 @@ migration obstacles) and honest documentation, not new algorithms. Both the create-options ceremony and the verify path derive their lists from this single config, replacing the two previously-divergent hardcoded lists, so operators can narrow either without code changes. A new 5-arg `CeremonyConfig` - convenience constructor applies backward-compatible defaults — accepted = + convenience constructor applies backward-compatible defaults: accepted = `ES256, EdDSA, RS256, ES384, RS384` (the union of everything previously accepted, so no already-registered credential can fail verification); offered = - `ES256, EdDSA, RS256` (the historical create-options subset) — so every + `ES256, EdDSA, RS256` (the historical create-options subset). Every existing call site compiles and behaves identically. -- **Per-credential algorithm visibility.** +- Per-credential algorithm visibility. `CredentialAlgorithms.coseAlgorithm(record)` decodes the COSE algorithm already - embedded in a stored public key (**no schema change**), and + embedded in a stored public key (no schema change), and `AdminService.listCredentialsByAlgorithm(actor, target, coseAlgorithm)` reports - which stored credentials use a given algorithm — the read side a future - "re-enroll off algorithm X" campaign drives off. -- **Offered/accepted algorithm configuration wired through every adapter.** The + which stored credentials use a given algorithm, the read side a future + "re-enrol off algorithm X" campaign drives off. +- Offered/accepted algorithm configuration wired through every adapter. The Spring Boot starter, Dropwizard, and Micronaut adapters (and the matching demo `application.yml` files) expose the two algorithm lists as host config, so the create-options and verify lists can be tuned without touching code. ### Changed -- **Honest JWT crypto framing** (ADR 0019, documentation only — no behavior - change). The HS256-vs-ES256 choice is re-documented as a trust-topology - decision rather than a dev-vs-prod one: HMAC-SHA256 is *not* broken by Shor and +- JWT crypto framing (ADR 0019, documentation only; no behaviour change). The HS256-vs-ES256 + choice is re-documented as a trust-topology + decision rather than a dev-vs-prod one: HMAC-SHA256 is not broken by Shor and retains ~128-bit security under Grover with the enforced ≥ 256-bit key, making it the quantum-conservative default for a single-issuer/single-verifier deployment; ES256 JWTs exist for untrusted third-party verification and carry Shor exposure bounded by the short token TTL. See `docs/threat-model.md` and `docs/operator-guide.md`. -- **Build / CI.** The Playwright end-to-end suites under `examples/` are now +- Build / CI. The Playwright end-to-end suites under `examples/` are now wired into each demo's `check` via an opt-in `e2eTest` task (gated on `PK_RUN_E2E=1` / `-PrunE2e`, so the default gate stays fast and Chrome-free), and CI runs all three demos in a dedicated `e2e` matrix job. -## [2.0.0] — 2026-06-13 +## [2.0.0] - 2026-06-13 The first 2.x release. The bulk of the work is correctness, hardening, and -maintainability; the major-version bump is driven by **one breaking change to -the embedded Java API** — the `start*` ceremony methods (described under +maintainability; the major-version bump is driven by one breaking change to +the embedded Java API: the `start*` ceremony methods (described under Changed). The HTTP `/auth/**` wire contract (request/response bodies and status codes) is unchanged, so deployments that integrate through the Spring / Dropwizard / Micronaut adapters and the JSON API need no code changes. ### Changed -- **Breaking (embedded API only): `start*` ceremonies now return sealed result - sums.** `PasskeyAuthenticationService.startRegistration` / +- Breaking (embedded API only): `start*` ceremonies now return sealed result + sums. `PasskeyAuthenticationService.startRegistration` / `startAuthentication` return `StartRegistrationResult` / `StartAuthenticationResult` (`Started | RateLimited`) instead of the bare response envelope, mirroring the `finish*` ceremonies, and the @@ -238,16 +239,16 @@ Dropwizard / Micronaut adapters and the JSON API need no code changes. Dropwizard / Micronaut adapters are unaffected; only code calling `PasskeyAuthenticationService` directly must switch on the result. The `200` / `429` wire responses are identical to before. -- **Host→domain config translation is centralized.** `RelyingPartyConfig.from(…)` +- Host→domain config translation is centralised. `RelyingPartyConfig.from(…)` and `CeremonyConfig.from(…)` carry the shared validation and conservative default-coalescing (UV `REQUIRED`, counter-regression `REJECT`) the three adapter factories previously duplicated; an unset knob can only resolve to the secure default. -- **The persistence-failure response envelope is centralized** in +- The persistence-failure response envelope is centralised in `PkAuthPersistenceResponse` (HTTP `503`, body `{"error":"persistence_failure","operation":…}`), so all three adapters emit a - byte-identical body and a backend outage cannot leak a framework-default 500. -- **Build / CI.** SonarQube Cloud was dropped for the JVM modules in favour of + byte-identical body and a backend outage cannot leak a framework-default `500`. +- Build / CI. SonarQube Cloud was dropped for the JVM modules in favour of native JaCoCo line + branch coverage gates (ADR 0018); it is retained for the TypeScript browser SDK. CI now fails if `PKAUTH_SKIP_TESTCONTAINERS=1` leaks into the gate, adds a `workflow_dispatch` trigger for on-demand runs, and skips @@ -255,59 +256,59 @@ Dropwizard / Micronaut adapters and the JSON API need no code changes. ### Added -- **Passkey-only hosts can boot without the alt-flow SPIs** (backup-codes / OTP / +- Passkey-only hosts can boot without the alt-flow SPIs (backup-codes / OTP / magic-link), in the admin API and Spring starter. -- **Config range validation** on the OTP, backup-code, and magic-link `Config` - records — non-positive TTLs, attempt caps, and rate limits are rejected at +- Config range validation on the OTP, backup-code, and magic-link `Config` + records: non-positive TTLs, attempt caps, and rate limits are rejected at construction instead of producing never-verifiable or instantly-expired codes. -- **Concurrent consume-once acceptance scenarios** for OTP and backup codes in +- Concurrent consume-once acceptance scenarios for OTP and backup codes in the testkit (`OtpRepositoryScenarios`, `BackupCodeRepositoryScenarios`), run - against real Postgres and DynamoDB Local — the atomic single-use guarantee is - now proven for those flows, not just refresh tokens. + against real Postgres and DynamoDB Local; the atomic single-use guarantee is + now exercised for those flows as well as for refresh tokens. ### Fixed -- **DynamoDB refresh-token rotation no longer scorches a token family on a - transient error.** A `TransactionCanceledException` is treated as a +- DynamoDB refresh-token rotation no longer scorches a token family on a + transient error. A `TransactionCanceledException` is treated as a replay/race (revoking the family) only when the parent's freshness condition actually failed (`ConditionalCheckFailed`); throughput / transaction-conflict / - validation cancellations are rethrown as a retryable 5xx, and an + validation cancellations are rethrown as a retryable `5xx`, and an indeterminable reason fails closed. -- **Ceremony user-verification now defaults to `REQUIRED`,** and the UV / +- Ceremony user-verification now defaults to `REQUIRED`, and the UV / resident-key / attestation / counter-regression knobs are surfaced through the Spring properties. -- **DynamoDB maintenance scans** (`deleteExpiredBefore`) are constrained with a +- DynamoDB maintenance scans (`deleteExpiredBefore`) are constrained with a server-side `begins_with(pk, …)` filter instead of reading the whole shared single table. -- **OTP no-active-OTP branch** drops a misleading constant-time self-compare; the - throwaway HMAC that equalizes response timing is kept. -- **Admin phone-verification** maps the `SendResult` sum with an exhaustive +- OTP no-active-OTP branch drops a misleading constant-time self-compare; the + throwaway HMAC that equalises response timing is kept. +- Admin phone-verification maps the `SendResult` sum with an exhaustive switch instead of an unchecked downcast. - Hardened CBOR / converter robustness and added end-to-end forged / replayed ceremony coverage. ### Security -- **esbuild forced to ≥ 0.28.1** to clear GHSA-gv7w-rqvm-qjhr (HIGH). It is a +- esbuild forced to ≥ 0.28.1 to clear GHSA-gv7w-rqvm-qjhr (HIGH). It is a build-time, dev-only transitive dependency of the browser SDK (never shipped in the published bundle), and the advisory targets esbuild's Deno module, so the - practical exposure was low — but the bump clears the advisory at the source. -- **Browser SDK base64url** no longer uses a ReDoS-shaped regex (SonarQube + practical exposure was low, but the bump clears the advisory at the source. +- Browser SDK base64url no longer uses a ReDoS-shaped regex (SonarQube S5852 / CWE-1333); `encode` / `decode` use `replaceAll` with string literals, byte-identical output. -- **Documentation corrections to security-relevant behaviour.** OTP codes are +- Documentation corrections to security-relevant behaviour. OTP codes are hashed with HMAC-SHA256 + a server-side pepper (not Argon2id); the `userVerification = REQUIRED` default and the ≥ 16-byte OTP pepper requirement are now documented in the threat model and operator guide. -## [1.3.1] — 2026-06-06 +## [1.3.1] - 2026-06-06 Patch release: a browser-SDK build fix, a Micronaut admin toggle, and supply-chain hardening. No wire-format or API-breaking changes. ### Added -- **Micronaut: the admin endpoints can now be disabled.** A new +- Micronaut: the admin endpoints can now be disabled. A new configuration toggle lets a Micronaut host opt out of mounting `PkAuthAdminController` (passkey list/rename/delete, etc.) while keeping the core auth endpoints, mirroring the disable switch the other framework @@ -315,7 +316,7 @@ supply-chain hardening. No wire-format or API-breaking changes. ### Fixed -- **Browser SDK builds under TypeScript 6.0.** The dev-dependency bump to +- Browser SDK builds under TypeScript 6.0. The dev-dependency bump to TypeScript 6.0 broke the `tsup` `.d.ts` pipeline with `TS5101` (the injected `baseUrl` is now a deprecation error). Adding `ignoreDeprecations: "6.0"` to the SDK `tsconfig.json` restores the build; @@ -324,9 +325,9 @@ supply-chain hardening. No wire-format or API-breaking changes. ### Security -- **Dependency and CI/CD supply-chain hardening.** +- Dependency and CI/CD supply-chain hardening. - Every GitHub Actions `uses:` is pinned to a full commit SHA (version in a - trailing comment) instead of a mutable `@vN` tag — most importantly the + trailing comment) instead of a mutable `@vN` tag. The pinned actions include the third-party `softprops/action-gh-release` in the privileged release job. - The Gradle distribution is pinned via `distributionSha256Sum` in the wrapper, with a `wrapper-validation` step in CI verifying @@ -343,13 +344,13 @@ supply-chain hardening. No wire-format or API-breaking changes. ### Changed -- **Gradle dependency verification (`verification-metadata.xml`) was not - retained.** It was introduced during the supply-chain work and then +- Gradle dependency verification (`verification-metadata.xml`) was not + retained. It was introduced during the supply-chain work and then removed: with Dependabot auto-merging Gradle bumps, the checksum file would have to be regenerated unattended from whatever was just downloaded - (notarizing rather than vetting), and it broke the build on every bump + (notarising rather than vetting), and it broke the build on every bump because a version-catalog bump cannot update the checksums. Protection - against malicious dependency *releases* is provided by + against malicious dependency releases is provided by `dependency-review-action`'s advisory database instead. The SHA-pinned Actions, pinned Gradle distribution, and dependency-review gate above are retained. @@ -359,23 +360,23 @@ supply-chain hardening. No wire-format or API-breaking changes. - Routine Dependabot bumps across the dev/runtime dependency groups (Gradle and npm) and GitHub Actions. -## [1.3.0] — 2026-06-03 +## [1.3.0] - 2026-06-03 Security-review follow-ups (hardening; no known exploit in the items below). ### Added -- **Refresh tokens carry the original `amr` through rotation.** +- Refresh tokens carry the original `amr` through rotation. `RefreshTokenService.issue(UserHandle, String, Optional, List)` records the RFC 8176 authentication method references on the refresh family, and every rotation propagates them verbatim, so an access token minted from `POST /auth/refresh` reflects how the session was first established (e.g. `["pkauth","webauthn"]`) instead of a generic `["user"]`. `RefreshTokenRecord` and `RotatedClaims` gain an `amr` - field; persisted as the `amr` column (JDBI, Flyway **V10**) and the + field; persisted as the `amr` column (JDBI, Flyway V10) and the `amr` attribute (DynamoDB). The previous three-arg - `issue(UserHandle, String, Optional)` is **deprecated** (defaults `amr` - to `["user"]`, preserving prior behavior). + `issue(UserHandle, String, Optional)` is deprecated (defaults `amr` + to `["user"]`, preserving prior behaviour). - `ChallengeStoreScenarios` in `pk-auth-testkit`: a shared SPI compliance suite asserting `ChallengeStore.takeOnce` is single-use, including a concurrent "exactly one winner" race test, now driven against the @@ -383,8 +384,8 @@ Security-review follow-ups (hardening; no known exploit in the items below). ### Changed -- **`ChallengeId` is now an opaque random handle, decoupled from the - challenge bytes.** Previously the store key was +- `ChallengeId` is now an opaque random handle, decoupled from the + challenge bytes. Previously the store key was `base64url(challenge)`; it is now a random UUID (`ChallengeId.random()`). Finish-time validation still enforces the cryptographic binding by byte-comparing the stored challenge against @@ -393,16 +394,16 @@ Security-review follow-ups (hardening; no known exploit in the items below). challenge. No wire-format change; the browser SDK already round-trips the id. `ChallengeGenerator.idOf(byte[])` and the internal `ChallengeValidation.IdMismatch` variant are removed. -- **`PkAuthJwtValidator` (stateful mode) rejects `jti`-less tokens.** When +- `PkAuthJwtValidator` (stateful mode) rejects `jti`-less tokens. When a non-noop `AccessTokenStore` is bound, a validly-signed token with no `jti` now returns `MissingClaim("jti")` instead of bypassing the revocation gate. (Carried over from 1.2.0's fix; the validator now detects stateful mode up front.) -- **DynamoDB refresh rotation uses a conditional `UpdateItem`.** +- DynamoDB refresh rotation uses a conditional `UpdateItem`. `rotateAtomically` now marks the parent used via a partial conditional update (`ignoreNullsMode(SCALAR_ONLY)`) instead of a full-item PUT from - a prior read — it no longer reads the parent first and can't clobber a - concurrently-written parent attribute. Behavior is unchanged; the + a prior read; it no longer reads the parent first and can't clobber a + concurrently-written parent attribute. Behaviour is unchanged; the freshness condition (incl. the numeric-`ttl` expiry compare from 1.2.0) is preserved. @@ -416,31 +417,31 @@ Security-review follow-ups (hardening; no known exploit in the items below). ### Distribution -- **The browser SDK is now published to npm as +- The browser SDK is now published to npm as [`@pk-auth/passkeys-browser`](https://www.npmjs.com/package/@pk-auth/passkeys-browser) - at `1.3.0`** — its first npm release. The SDK version tracks the pk-auth + at `1.3.0`, its first npm release. The SDK version tracks the pk-auth server release it speaks to, so `1.3.0` matches this release's Maven Central artifacts. Install with `npm install @pk-auth/passkeys-browser`. See [`RELEASE.md`](./RELEASE.md) for the publish steps. (The example apps still consume the SDK via a relative `dist/` import built by Gradle, not the published package.) -## [1.2.0] — 2026-06-02 +## [1.2.0] - 2026-06-02 ### Security -- **DynamoDB refresh-token rotation now compares expiry numerically, not - lexically.** `DynamoDbRefreshTokenRepository.rotateAtomically` gated the +- DynamoDB refresh-token rotation now compares expiry numerically, not + lexically. `DynamoDbRefreshTokenRepository.rotateAtomically` gated the atomic-rotate `ConditionExpression` on `expiresAtIso > :now`, comparing two `Instant.toString()` strings. That output is variable-precision (the fractional-seconds field is dropped when zero), so a bytewise `>` sorts - `…:00Z` *after* `…:00.000001Z` and could treat a just-expired token as still + `…:00Z` after `…:00.000001Z` and could treat a just-expired token as still fresh. The condition now compares the numeric epoch-second `ttl` attribute (`#ttl > :nowEpoch`). The authoritative expiry check in `RefreshTokenService` was already correct, so the exposure was limited to a sub-second fail-open in the database-level backstop for the rotate TOCTOU window. -- **Stateful JWT validation rejects `jti`-less tokens instead of skipping - revocation.** When a non-noop `AccessTokenStore` is bound, +- Stateful JWT validation rejects `jti`-less tokens instead of skipping + revocation. When a non-noop `AccessTokenStore` is bound, `PkAuthJwtValidator` previously guarded the store lookup with `jti != null`, so a validly-signed, in-issuer, in-audience, unexpired token carrying no `jti` bypassed the `exists` revocation gate entirely. The validator now @@ -448,15 +449,15 @@ Security-review follow-ups (hardening; no known exploit in the items below). minted by `PkAuthJwtIssuer` always carry a `jti`, so this never affected the library's own tokens; it closes the gap for any other token signed with the same keyset. -- **Corrected the `MagicLinkService.startLogin` timing-side-channel - contract (documentation).** The javadoc claimed the method defeats timing +- Corrected the `MagicLinkService.startLogin` timing-side-channel + contract (documentation). The javadoc claimed the method defeats timing side-channels, but the not-found path returns before JWT issuance and email dispatch, so response latency still distinguishes known from unknown usernames. The contract now documents the result-shape guarantee only and points hosts at uniform-latency / rate-limiting mitigations. No behavioural change. -## [1.1.0] — 2026-05-18 +## [1.1.0] - 2026-05-18 ### Added @@ -482,31 +483,31 @@ Security-review follow-ups (hardening; no known exploit in the items below). `RotateResult` (Success / Replayed / Expired / Unknown / Revoked). Wire format `{refreshId}.{secret}` (both halves base64url); SHA-256 hash-at-rest; hash-before-mark-used invariant. Family-based replay - defense — re-presenting a used token scorches the entire family. See + defence: re-presenting a used token scorches the entire family. See ADR 0013. - `RefreshTokenRepository` SPI with the load-bearing `rotateAtomically` primitive: parent mark-used and successor insert commit atomically on every backend (JDBI transaction, DynamoDB `TransactWriteItems`, in-memory `ConcurrentHashMap.compute`). - `pk-auth-persistence-jdbi`: `JdbiRefreshTokenRepository` + Flyway V9 - migration. PkAuthJdbiSchema.CURRENT_SCHEMA_VERSION → "9". + migration. `PkAuthJdbiSchema.CURRENT_SCHEMA_VERSION` → `"9"`. - `pk-auth-persistence-dynamodb`: `DynamoDbRefreshTokenRepository` with three-item layout (primary jti / user-index / family-index) and DynamoDB-native TTL. - `pk-auth-testkit`: `InMemoryRefreshTokenRepository` + - `RefreshTokenScenarios` parity test class — nine scenarios including - the non-negotiable `concurrentRotationExactlyOneSucceedsFamilyRevoked` + `RefreshTokenScenarios` parity test class: nine scenarios including + the required `concurrentRotationExactlyOneSucceedsFamilyRevoked` race test, driven by 8 threads + `CountDownLatch`, passing against in-memory, real Postgres, and DynamoDB Local. - `RefreshHandler` (framework-neutral HTTP composer) + `PkAuthRefreshController` / `PkAuthRefreshResource` in Spring, Dropwizard, Micronaut adapters. `POST /auth/refresh` returns the new refresh + access JWT on success, - 401 with a typed `detail` on any failure. + `401` with a typed `detail` on any failure. - `AuthMethod.REFRESH` for access tokens minted from a refresh rotation. - `JwtClaims.forRefresh(userHandle, audience, amr)` factory. - Browser SDK: `PkAuthClient.refresh(wireToken)` returning a typed - `RefreshResult` sum (`RefreshSuccess | RefreshFailure`) — never throws - on 401. + `RefreshResult` sum (`RefreshSuccess | RefreshFailure`) that never throws + on `401`. - `RefreshTokenServiceDeletionListener` plugged into `UserDeletionService` so user-delete revokes every active refresh family alongside access tokens / credentials / backup codes / OTPs. @@ -529,21 +530,21 @@ Security-review follow-ups (hardening; no known exploit in the items below). ### Changed -- **Breaking.** `JwtConfig.tokenTtl: Duration` replaced by +- Breaking. `JwtConfig.tokenTtl: Duration` replaced by `JwtConfig.ttlPolicy: TokenTtlPolicy`. Pre-1.1 code that constructed `JwtConfig` directly must wrap the existing TTL in `TokenTtlPolicy.single(ttl)`. The `JwtConfig.defaults(issuer, audience)` factory still works as before and produces a single-TTL policy. -- **Breaking.** `JwtConfig.audience` renamed to `JwtConfig.defaultAudience`. +- Breaking. `JwtConfig.audience` renamed to `JwtConfig.defaultAudience`. The accessor moves from `config.audience()` to `config.defaultAudience()`. Semantic note: the validator now accepts any audience listed in `defaultAudience ∪ ttlPolicy.knownAudiences()`, which matters when running multiple client audiences through a single validator. -- **Breaking (adapters).** `PkAuthProperties.Jwt` (Spring), +- Breaking (adapters). `PkAuthProperties.Jwt` (Spring), `PkAuthConfig.Jwt` (Dropwizard), and `PkAuthConfiguration.Jwt` (Micronaut) each gain a `ttlsByAudience: Map` field and rename their single-TTL field; see each adapter's javadoc for the bound property name. -- **Breaking (SPI).** `CredentialRepository` and `OtpRepository` gain a +- Breaking (SPI). `CredentialRepository` and `OtpRepository` gain a `deleteByUserHandle(UserHandle) -> int` method. All shipped implementations (in-memory, JDBI, DynamoDB) updated. Downstream host-supplied implementations must add it; the natural impl is a single bulk-delete @@ -554,12 +555,13 @@ Security-review follow-ups (hardening; no known exploit in the items below). - Flyway schema version bumped to V9 (V8 access tokens + V9 refresh tokens). `PkAuthJdbiSchema.CURRENT_SCHEMA_VERSION` is now `"9"`. -## [1.0.0] — 2026-05 (stabilisation cut) +## [1.0.0] - 2026-05 (stabilisation cut) First stable release. Captures the surface produced by the 0.x development series; see `git log` for the full history. -[Unreleased]: https://github.com/codeheadsystems/pk-auth/compare/v2.1.0...HEAD +[Unreleased]: https://github.com/codeheadsystems/pk-auth/compare/v2.2.0...HEAD +[2.2.0]: https://github.com/codeheadsystems/pk-auth/compare/v2.1.0...v2.2.0 [2.1.0]: https://github.com/codeheadsystems/pk-auth/compare/v2.0.0...v2.1.0 [2.0.0]: https://github.com/codeheadsystems/pk-auth/compare/v1.3.1...v2.0.0 [1.3.1]: https://github.com/codeheadsystems/pk-auth/compare/v1.3.0...v1.3.1 diff --git a/CLAUDE.md b/CLAUDE.md index 2ca6b800..60abc0bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,16 +1,20 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## Project summary -## What this is +pk-auth is a passkeys-first authentication library set for the JVM, published to Maven Central +under `com.codeheadsystems`. All modules share one version (the `version` in `gradle.properties` is +authoritative). It is not an identity provider: it owns passkeys and credentials, never users, and +the host maps users via the `UserLookup` SPI. -pk-auth is a **passkeys-first authentication library set for the JVM**, published to Maven Central under `com.codeheadsystems`. All modules share one version (the `version` in `gradle.properties` is authoritative). It is *not* an identity provider: it owns passkeys/credentials, never users — the host maps users via the `UserLookup` SPI. +The canonical architecture reference is [`DESIGN.md`](./DESIGN.md); per-decision rationale lives in +[`docs/adr/`](./docs/adr/) (Nygard format, numbered). The relevant ADR must be read before +cross-module behaviour changes. -The canonical architecture reference is [`DESIGN.md`](./DESIGN.md); per-decision rationale lives in [`docs/adr/`](./docs/adr/) (Nygard format, numbered). Read the relevant ADR before changing cross-module behavior. +## Build and test -## Build & test - -JDK 21 is required (Gradle's toolchain fetches one if absent). Node ≥ 22.22.2 + npm are needed for the browser SDK, which Gradle drives automatically. +JDK 21 is required (Gradle's toolchain fetches one if absent). Node ≥ 22.22.2 and npm are needed +for the browser SDK, which Gradle drives automatically. ```sh ./gradlew check # full gate: spotlessCheck + test + jacoco coverage verify @@ -22,42 +26,110 @@ JDK 21 is required (Gradle's toolchain fetches one if absent). Node ≥ 22.22.2 ./gradlew :examples:spring-boot-demo:run # runnable demo at http://localhost:8080 (also dropwizard-demo / micronaut-demo — one at a time, all bind 8080) ``` -- **Integration tests are ordinary `test` tasks** — the JDBI/DynamoDB persistence modules use Testcontainers (Postgres / DynamoDB Local), so `./gradlew :pk-auth-persistence-jdbi:test` needs **Docker** running. There is no separate `integrationTest` task or JUnit tag. -- Playwright end-to-end suites live under the `examples/` demos and need **Chrome** (drives a CDP virtual WebAuthn authenticator). They are wired into each demo's `check` via the `e2eTest` task (`pkauth.e2e-conventions`) but are **opt-in** — they only run when `PK_RUN_E2E=1` (or `-PrunE2e`) is set, so a default `./gradlew check` and the CI `build` job stay fast and Chrome-free. Run one with `PK_RUN_E2E=1 ./gradlew :examples:spring-boot-demo:e2eTest`; the task itself does `npm ci` + `npx playwright install chrome` and boots the demo via its Gradle `run` task. CI runs all three in the `e2e` matrix job (`.github/workflows/ci.yml`, with `PW_INSTALL_DEPS=1` so Chrome's OS deps are installed too). -- The Gradle **build cache is disabled** on purpose (Spotless 8.x classloader bug) — see the comment in `gradle.properties`. Don't re-enable it. +- Integration tests are ordinary `test` tasks. The JDBI and DynamoDB persistence modules use + Testcontainers (Postgres / DynamoDB Local), so `./gradlew :pk-auth-persistence-jdbi:test` needs + Docker running. There is no separate `integrationTest` task or JUnit tag. +- Playwright end-to-end suites live under the `examples/` demos and need Chrome (drives a CDP + virtual WebAuthn authenticator). They are wired into each demo's `check` via the `e2eTest` task + (`pkauth.e2e-conventions`) but are opt-in: they run only when `PK_RUN_E2E=1` (or `-PrunE2e`) is + set, so a default `./gradlew check` and the CI `build` job stay fast and Chrome-free. One suite + runs with `PK_RUN_E2E=1 ./gradlew :examples:spring-boot-demo:e2eTest`; the task itself does + `npm ci` + `npx playwright install chrome` and boots the demo via its Gradle `run` task. CI runs + all three in the `e2e` matrix job (`.github/workflows/ci.yml`, with `PW_INSTALL_DEPS=1` so + Chrome's OS deps are installed too). +- The Gradle build cache is disabled because of a Spotless 8.x classloader bug; see the comment in + `gradle.properties`. It must not be re-enabled. ## Architecture: three concentric rings -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. The exported packages are `api`, `ceremony`, `config`, `credential`, `error`, `json`, `lifecycle`, `metrics`, `ratelimit`, 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`, `RevocationCheck`, `UserDeletionListener`, `AttestationTrustPolicy`, `OriginValidator`, `ClockProvider`, `ConsumedJtiStore`, `CeremonyRateLimiter`, `MessageFormatter` (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. - -Feature modules (`pk-auth-backup-codes`, `pk-auth-magic-link`, `pk-auth-otp`, `pk-auth-refresh-tokens`, `pk-auth-admin-api`) and persistence modules (`pk-auth-persistence-jdbi`, `pk-auth-persistence-dynamodb`, in-memory `pk-auth-testkit`) implement core-declared SPIs and are wired in by the host. (`pk-auth-admin-api` hosts `AdminService` / `AdminResult` and the account/credential/backup-code/email/phone admin operations mounted by all three adapters at `/auth/admin/**`.) - -### Things that bite if you don't know them - -- **Sealed result sums, not exceptions.** Ceremony and admin operations return sealed interfaces — `AdminResult` (`Success | NotFound | Forbidden | ValidationFailed | Conflict | RateLimited`, declared in `pk-auth-admin-api`), `RegistrationResult`, `AssertionResult` (core), `RotateResult` (`pk-auth-refresh-tokens`), `JwtVerificationResult` (`pk-auth-jwt`). Adapters map these to HTTP; never throw across that boundary. When you add a variant, every adapter's `*ResultMapper` must handle it. -- **Wire bytes are base64url, no padding** (RFC 4648 §5). Jackson 3 adapters (Spring) get this from `PkAuthObjectMappers.pkAuthModule()`; Dropwizard and Micronaut are still on Jackson 2 and use `PkAuthJacksonBridge` (Dropwizard) and the `PkAuthJacksonModule` bean (Micronaut). -- **`finish` endpoints are not idempotent** — challenges are single-use via `ChallengeStore.takeOnce`. There is **no shared transaction across SPIs**: `takeOnce` is consumed before `CredentialRepository.save`; a failed save forces a ceremony restart. This is intentional — see [`docs/transactional-semantics.md`](./docs/transactional-semantics.md). -- **All three adapters mount identical `/auth/**` paths** (`/auth/passkeys/**`, `/auth/refresh`, `/auth/admin/**`) — Dropwizard's Jersey resources use the same `@Path("/auth/passkeys")` etc. as Spring/Micronaut, so the TS SDK targets one path scheme everywhere (no per-client path override). -- **DI annotations differ by adapter on purpose** (`CONTRIBUTING.md` §9): Spring and Micronaut auto-detect the single constructor — do **not** add `@Autowired`/`@Inject`. Dropwizard's Dagger 2 wiring **requires** `@Inject` on the injected constructor. Match the module you're in. -- **Atomic-claim operations return `boolean`** so the caller can detect a race-lost claim — JDBI uses conditional `UPDATE ... WHERE consumed_at IS NULL`; DynamoDB uses `ConditionExpression` (failed condition → `ConditionalCheckFailedException` mapped to `Conflict`/`Expired`). -- **DynamoDB is single-table** (`DynamoDbTable` per item type; refresh tokens write 3 items per token for the jti/user/family indexes). **JDBI uses Flyway** migrations at `pk-auth-persistence-jdbi/src/main/resources/db/migration/` — bump `PkAuthJdbiSchema.CURRENT_SCHEMA_VERSION` when adding one. - -## Conventions (enforced — `./gradlew check` fails otherwise) - -- **SPDX header** `// SPDX-License-Identifier: MIT` on every Java file (Spotless adds/checks it). -- **`@since X.Y.Z`** Javadoc on every new/modified public API element across the library modules (the in-flight target is the active version in `gradle.properties` with the `-SNAPSHOT` suffix dropped — always check `gradle.properties` rather than trusting a hardcoded number here). Full policy in `CONTRIBUTING.md` §7. -- **JSpecify null discipline** — `@NonNull`/`@Nullable` on every public param and return; Error Prone enforces it. Compilation runs `-Xlint:all` with strict Error Prone. -- **Records for DTOs/configs/result variants; sealed interfaces for closed sums.** Public API sealed via `module-info.java` exports. -- **Conventional commits** (`feat(core):`, `fix(jdbi):`, `docs(adr):`, `build:`, `ci:`). Small, atomic. -- **New dependencies go through `gradle/libs.versions.toml`** (the version catalog) and are justified in the commit/ADR. Build conventions live in `build-logic/` (`pkauth.java-conventions`, `library-conventions`, `test-conventions`, `publish-conventions`), applied per-module. -- **No `TODO` in main** without a linked GitHub issue. -- **Non-trivial cross-module decisions get an ADR** under `docs/adr/`, numbered sequentially. -- JaCoCo gate: `pkauth.test-conventions` sets a LINE ≥ 70% / BRANCH ≥ 55% floor on every library module (including the adapters). Modules raise it in their own `build.gradle.kts`: LINE ≥ 80% on `pk-auth-core`, `pk-auth-admin-api`, `pk-auth-jwt`; BRANCH ≥ 85% on `pk-auth-jwt`, `pk-auth-backup-codes`; BRANCH ≥ 80% on `pk-auth-otp`, `pk-auth-refresh-tokens`. +Dependency arrows point inward. Adapters depend on core; core depends on no adapter, no framework, +no servlet/HTTP API, and no JDBC/DynamoDB. + +1. `pk-auth-core` is framework- and persistence-neutral. It 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`, `ratelimit`, and `spi` (enforced via `module-info.java`); everything else is + module-internal. +2. SPIs (ports) are narrow interfaces the host implements. `UserLookup`, `CredentialRepository`, + and `ChallengeStore` are required. `BackupCodeRepository`, `OtpRepository`, `EmailSender`, + `SmsSender`, `RefreshTokenRepository`, `AccessTokenStore`, `TokenTtlPolicy`, `RevocationCheck`, + `UserDeletionListener`, `AttestationTrustPolicy`, `OriginValidator`, `ClockProvider`, + `ConsumedJtiStore`, `CeremonyRateLimiter`, and `MessageFormatter` are optional or + feature-gated. `DESIGN.md` §6 holds the required-vs-optional table. +3. Adapters are `pk-auth-spring-boot-starter` (Spring Boot 4 / Security 7 autoconfigure), + `pk-auth-dropwizard` (Dropwizard 5 `ConfiguredBundle` + Dagger 2), and `pk-auth-micronaut` + (Micronaut 4 `@Factory` + `@Filter`; no Micronaut Security). Each mounts the same `/auth/**` + JSON contract and pattern-matches the core's sealed result sums into HTTP status codes. + +Feature modules (`pk-auth-backup-codes`, `pk-auth-magic-link`, `pk-auth-otp`, +`pk-auth-refresh-tokens`, `pk-auth-admin-api`) and persistence modules (`pk-auth-persistence-jdbi`, +`pk-auth-persistence-dynamodb`, in-memory `pk-auth-testkit`) implement core-declared SPIs and are +wired in by the host. `pk-auth-admin-api` hosts `AdminService` / `AdminResult` and the +account/credential/backup-code/email/phone admin operations mounted by all three adapters at +`/auth/admin/**`. + +### Known pitfalls + +- Ceremony and admin operations return sealed result sums, not exceptions: `AdminResult` + (`Success | NotFound | Forbidden | ValidationFailed | Conflict | RateLimited`, declared in + `pk-auth-admin-api`), `RegistrationResult`, `AssertionResult` (core), `RotateResult` + (`pk-auth-refresh-tokens`), and `JwtVerificationResult` (`pk-auth-jwt`). Adapters map these to + HTTP, and exceptions must not be thrown across that boundary. A new variant must be handled by + every adapter's `*ResultMapper`. +- Wire bytes are base64url with no padding (RFC 4648 §5). Jackson 3 adapters (Spring) get this from + `PkAuthObjectMappers.pkAuthModule()`. Dropwizard and Micronaut are still on Jackson 2 and use + `PkAuthJacksonBridge` (Dropwizard) and the `PkAuthJacksonModule` bean (Micronaut). +- `finish` endpoints are not idempotent: challenges are single-use via `ChallengeStore.takeOnce`. + There is no shared transaction across SPIs. `takeOnce` is consumed before + `CredentialRepository.save`, and a failed save forces a ceremony restart. This is the specified + behaviour; see [`docs/transactional-semantics.md`](./docs/transactional-semantics.md). +- All three adapters mount identical `/auth/**` paths (`/auth/passkeys/**`, `/auth/refresh`, + `/auth/admin/**`). Dropwizard's Jersey resources use the same `@Path("/auth/passkeys")` etc. as + Spring and Micronaut, so the TS SDK targets one path scheme everywhere, with no per-client path + override. +- DI annotations differ by adapter (`CONTRIBUTING.md` §9). Spring and Micronaut auto-detect the + single constructor, so `@Autowired` and `@Inject` must not be added there. Dropwizard's Dagger 2 + wiring requires `@Inject` on the injected constructor. New code matches the module it lives in. +- Atomic-claim operations return `boolean` so that the caller can detect a race-lost claim. JDBI + uses conditional `UPDATE ... WHERE consumed_at IS NULL`. DynamoDB uses `ConditionExpression` (a + failed condition raises `ConditionalCheckFailedException`, mapped to `Conflict` / `Expired`). +- DynamoDB is single-table (`DynamoDbTable` per item type; refresh tokens write 3 items per + token for the jti/user/family indexes). JDBI uses Flyway migrations at + `pk-auth-persistence-jdbi/src/main/resources/db/migration/`, and adding one requires bumping + `PkAuthJdbiSchema.CURRENT_SCHEMA_VERSION`. + +## Enforced conventions + +`./gradlew check` fails when any of the following is violated. + +- SPDX header `// SPDX-License-Identifier: MIT` on every Java file (Spotless adds and checks it). +- `@since X.Y.Z` Javadoc on every new or modified public API element across the library modules. + The in-flight target is the active version in `gradle.properties` with the `-SNAPSHOT` suffix + dropped. `gradle.properties` is always read at the time of the change, and no hardcoded number in + a document is trusted. Full policy in `CONTRIBUTING.md` §7. +- JSpecify null discipline: `@NonNull` / `@Nullable` on every public parameter and return. Error + Prone enforces it. Compilation runs `-Xlint:all` with strict Error Prone. +- Records for DTOs, configs, and result variants; sealed interfaces for closed sums. Public API is + sealed via `module-info.java` exports. +- Conventional commits (`feat(core):`, `fix(jdbi):`, `docs(adr):`, `build:`, `ci:`), small and + atomic. +- New dependencies go through `gradle/libs.versions.toml` (the version catalog) and are justified + in the commit or ADR. Build conventions live in `build-logic/` (`pkauth.java-conventions`, + `library-conventions`, `test-conventions`, `publish-conventions`), applied per module. +- No `TODO` in main without a linked GitHub issue. +- Non-trivial cross-module decisions get an ADR under `docs/adr/`, numbered sequentially. +- Documentation follows [`docs/style.md`](./docs/style.md): register, emphasis, headings, + punctuation (no em dashes, serial comma, British spelling, 100-column wrap) and terminology. It + applies to every Markdown file, Javadoc, and the `site/`, and it must be read before any of them + is written or edited. +- JaCoCo gate: `pkauth.test-conventions` sets a LINE ≥ 70% / BRANCH ≥ 55% floor on every library + module (including the adapters). Modules raise it in their own `build.gradle.kts`: LINE ≥ 80% on + `pk-auth-core`, `pk-auth-admin-api`, `pk-auth-jwt`; BRANCH ≥ 85% on `pk-auth-jwt`, + `pk-auth-backup-codes`; BRANCH ≥ 80% on `pk-auth-otp`, `pk-auth-refresh-tokens`. ## Browser SDK -`clients/passkeys-browser/` is a zero-dep TypeScript SDK (`@pk-auth/passkeys-browser`, ESM + CJS, vitest-tested). Its `dist/` is **gitignored** — Gradle's `:buildPasskeysBrowserSdk` runs `npm ci && npm run build` (tsup) and the demos' `processResources` copy the output in. The Gradle build is the source of truth; don't commit built bundles. +`clients/passkeys-browser/` is a zero-dependency TypeScript SDK (`@pk-auth/passkeys-browser`, ESM + +CJS, vitest-tested). Its `dist/` is gitignored. Gradle's `:buildPasskeysBrowserSdk` runs +`npm ci && npm run build` (tsup), and the demos' `processResources` copy the output in. The Gradle +build is the source of truth, and built bundles must not be committed. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9c26c2e2..d19f0065 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,17 +2,36 @@ ## Working agreements -The architecture reference is [`DESIGN.md`](./DESIGN.md), with per-decision rationale in [`docs/adr/`](./docs/adr/). (The original bootstrap brief is archived at [`docs/history/pk-auth-build-brief.md`](./docs/history/pk-auth-build-brief.md) for context only.) - -1. **Keep `main` releasable.** Work lands as small pull requests against `main`. Run `./gradlew check` before pushing (CI runs the same gate), and record user-visible changes under `[Unreleased]` in `CHANGELOG.md`. -2. **Conventional commits.** Examples: `feat(core): ...`, `test(jdbi): ...`, `docs(adr): ...`, `build: ...`, `ci: ...`. Keep commits small and atomic. -3. **ADRs.** Non-trivial cross-module decisions get an ADR under `docs/adr/`, Nygard format. Number sequentially. -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 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. +The architecture reference is [`DESIGN.md`](./DESIGN.md), with per-decision rationale in +[`docs/adr/`](./docs/adr/). The original bootstrap brief is archived at +[`docs/history/pk-auth-build-brief.md`](./docs/history/pk-auth-build-brief.md) for context only. + +1. `main` stays releasable. Work lands as small pull requests against `main`. `./gradlew check` + runs before pushing (CI runs the same gate), and user-visible changes are recorded under + `[Unreleased]` in `CHANGELOG.md`. +2. Commits follow the conventional-commits format. Examples: `feat(core): ...`, `test(jdbi): ...`, + `docs(adr): ...`, `build: ...`, `ci: ...`. Commits are small and atomic. +3. Non-trivial cross-module decisions get an ADR under `docs/adr/`, in Nygard format, numbered + sequentially. +4. New libraries go through `gradle/libs.versions.toml` and are justified in the commit message or + an ADR. +5. No `TODO` appears in main unless paired with a GitHub issue link. +6. Every Java source file carries the SPDX header `// SPDX-License-Identifier: MIT`. Spotless + enforces it. +7. Public API carries `@since` tags. 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. For newly introduced surfaces, the current target is the in-flight + version in `gradle.properties` (the `version` property, minus the `-SNAPSHOT` suffix). Existing + surfaces keep the version they shipped with. A rename or signature change in a MAJOR bump + updates 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. Optimisation follows correctness and tests: correct, then tested, then fast. +9. Constructor-injection annotations differ by adapter. Spring and Micronaut detect a single + non-default constructor automatically, so `@Autowired` and `@Inject` are not added in those + adapters. The Dropwizard adapter is wired through Dagger 2, which requires `@Inject` on the + injected constructor, so the annotation stays. The asymmetry follows the DI framework. New files + in each adapter follow the conventions already present in that module. ## Build @@ -20,8 +39,9 @@ The architecture reference is [`DESIGN.md`](./DESIGN.md), with per-decision rati ./gradlew check ``` -JDK 21 required (Gradle toolchain will fetch one if needed). +JDK 21 is required. Gradle's toolchain fetches one if needed. ## Running locally -See [`GETTING_STARTED.md`](./GETTING_STARTED.md) for the "5-minute integration" snippet and per-module specifics. +[`GETTING_STARTED.md`](./GETTING_STARTED.md) holds the minimal adoption walkthrough and per-module +specifics. diff --git a/DESIGN.md b/DESIGN.md index b9603089..e7997e21 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,37 +1,30 @@ # pk-auth Design -This document is for a developer adopting pk-auth — or extending it. -It explains the architecture, the SPIs you implement to plug in your -own storage and dispatchers, the wire contract every adapter -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/) (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 bootstrap brief and phase plan (historical)**: - [`docs/history/pk-auth-build-brief.md`](./docs/history/pk-auth-build-brief.md). - Superseded by this document and the ADRs; kept for context only. +pk-auth is a passkeys-first authentication library set for the JVM, split into a framework-neutral +core, host-implemented SPIs, and three framework adapters. + +Related documents: + +- [`README.md`](./README.md): features and demo instructions. +- [`docs/adr/`](./docs/adr/): per-decision rationale (numbered, sequential). +- [`docs/operator-guide.md`](./docs/operator-guide.md): running in production. +- [`docs/threat-model.md`](./docs/threat-model.md): security posture. +- [`docs/history/pk-auth-build-brief.md`](./docs/history/pk-auth-build-brief.md): the original + bootstrap brief and phase plan (historical), superseded by this document and the ADRs. ## 1. Mission -Build a passkey-first authentication template that's drop-in for any of -the three mainstream JVM web frameworks and is *intentionally boring* -about everything else: stateless JWT, configurable persistence, no -proprietary protocol on the wire, no required external SaaS. +pk-auth is a passkey-first authentication template that drops into any of the three mainstream JVM +web frameworks. Everything else is conventional: stateless JWT, configurable persistence, no +proprietary protocol on the wire, and no required external SaaS. -Two non-goals worth surfacing up front: +pk-auth has two non-goals: -- **pk-auth is not an identity provider.** It is the credential layer - of *your* identity story. It does not own users — it stores - passkeys and looks users up through your `UserLookup` SPI. See - [ADR 0006](./docs/adr/0006-userlookup-spi-not-owned.md). -- **pk-auth does not implement attestation policy.** A pluggable - hook (`AttestationTrustPolicy`) exists, but no MDS3 fetcher is - bundled. Sites with FIDO-attestation requirements implement their +- pk-auth is not an identity provider. It is the credential layer of the host's identity story. It + does not own users; it stores passkeys and looks users up through the host's `UserLookup` SPI. + See [ADR 0006](./docs/adr/0006-userlookup-spi-not-owned.md). +- pk-auth does not implement attestation policy. A pluggable hook (`AttestationTrustPolicy`) + exists, but no MDS3 fetcher is bundled. Sites with FIDO-attestation requirements implement their own. ## 2. Architecture overview @@ -86,41 +79,37 @@ flowchart TB CredRepo & UserLookup & Challenge & BackupRepo & OtpRepo --- Dynamo ``` -The whole project is structured around three concentric rings: - -1. **Core (framework-neutral, persistence-neutral).** Knows about - WebAuthn, JWT, and the wire contract. Has no dependencies on - Spring, Dropwizard, Micronaut, JDBC, DynamoDB, HTTP, or any - servlet API. Exposes services and SPIs. -2. **SPIs (ports).** Interfaces the host implements. The required and - core-resident ones live in `pk-auth-core/spi` (`CredentialRepository`, - `UserLookup`, `ChallengeStore`, plus the optional `AttestationTrustPolicy`, - `OriginValidator`, `ClockProvider`, `ConsumedJtiStore`, - `CeremonyRateLimiter`); `UserDeletionListener` is in `pk-auth-core/lifecycle`. - The remaining ports ship with the feature modules that own them — - `BackupCodeRepository` (`pk-auth-backup-codes`), `OtpRepository` / - `SmsSender` (`pk-auth-otp`), `EmailSender` (`pk-auth-magic-link`), - `AccessTokenStore` / `TokenTtlPolicy` / `RevocationCheck` (`pk-auth-jwt`), - and `RefreshTokenRepository` (`pk-auth-refresh-tokens`). The §6 table below - lists the owning module for each. -3. **Adapters.** Three of them — Spring Boot, Dropwizard, Micronaut. - Each adapter mounts the same JSON contract under `/auth/**` and - delegates to the core. - -The dependency arrows always point *inward*: adapters depend on core, -core depends on no adapter. The persistence modules and alt-flow -modules implement SPIs declared in core and are wired in by the host. +The project is structured as three concentric rings: + +1. Core (framework-neutral, persistence-neutral). Core knows about WebAuthn, JWT, and the wire + contract. It has no dependencies on Spring, Dropwizard, Micronaut, JDBC, DynamoDB, HTTP, or any + servlet API. It exposes services and SPIs. +2. SPIs (ports). These are the interfaces the host implements. The core-resident ones live in + `pk-auth-core/spi` (the required `CredentialRepository`, `UserLookup`, and `ChallengeStore`, + plus `BackupCodeRepository`, `OtpRepository`, and the optional `AttestationTrustPolicy`, + `OriginValidator`, `ClockProvider`, `ConsumedJtiStore`, `CeremonyRateLimiter`); + `UserDeletionListener` is in `pk-auth-core/lifecycle`. The remaining ports ship with the feature + modules that own them: `SmsSender` (`pk-auth-otp`), `EmailSender` (`pk-auth-magic-link`), + `AccessTokenStore` / `TokenTtlPolicy` / `RevocationCheck` (`pk-auth-jwt`), and + `RefreshTokenRepository` (`pk-auth-refresh-tokens`). The §6 table below lists the owning module + for each. +3. Adapters. Three adapters exist: Spring Boot, Dropwizard, and Micronaut. Each adapter mounts the + same JSON contract under `/auth/**` and delegates to the core. + +Dependency arrows point inward: adapters depend on core, and core depends on no adapter. The +persistence modules and alt-flow modules implement SPIs declared in core and are wired in by the +host. ## 3. Module layout | Module | Purpose | |---|---| -| `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-core` | Framework-neutral ceremony engine. `PasskeyAuthenticationService` is the entry point; `api/`, `ceremony/`, `config/`, `credential/`, `error/`, `json/`, `lifecycle/`, `metrics/`, `ratelimit/`, 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. | | `pk-auth-otp` | Alt flow: 6-digit OTPs over the host's SMS dispatcher; hashed with HMAC-SHA256 (server-side pepper) and atomic-claim. | -| `pk-auth-refresh-tokens` | Rotating refresh tokens with family-based replay defense. `RefreshTokenService` + `RefreshTokenRepository` SPI; `RefreshHandler` is the framework-neutral `POST /auth/refresh` composer ([ADR 0013](./docs/adr/0013-refresh-tokens-family-rotation.md)). | +| `pk-auth-refresh-tokens` | Rotating refresh tokens with family-based replay defence. `RefreshTokenService` + `RefreshTokenRepository` SPI; `RefreshHandler` is the framework-neutral `POST /auth/refresh` composer ([ADR 0013](./docs/adr/0013-refresh-tokens-family-rotation.md)). | | `pk-auth-admin-api` | `AdminService` exposes account/credential/backup-code/email/phone operations. Result-typed (`AdminResult` sealed sum). | | `pk-auth-persistence-jdbi` | SPI impls on JDBI + Postgres + Flyway. Migrations at `src/main/resources/db/migration/`. | | `pk-auth-persistence-dynamodb` | SPI impls on AWS SDK v2 DynamoDB Enhanced. Single table, schema per item-type ([ADR 0008](./docs/adr/0008-dynamodb-single-table-design.md)). | @@ -133,10 +122,9 @@ modules implement SPIs declared in core and are wired in by the host. ## 4. The wire contract -Every adapter mounts the same paths and consumes/produces the same -JSON shapes. The TypeScript SDK targets this contract; clients in -other languages can target it too — there's nothing -framework-specific on the wire. +Every adapter mounts the same paths and consumes and produces the same JSON shapes. The TypeScript +SDK targets this contract, and clients in other languages can target it too; nothing on the wire is +framework-specific. ### Ceremony endpoints (unauthenticated) @@ -148,83 +136,75 @@ framework-specific on the wire. | `POST` | `/auth/passkeys/authentication/finish` | Mints a JWT; returns `{token}` | | `POST` | `/auth/refresh` | Rotates a refresh token; returns `{refresh, access}` on success, `401 {detail}` on any failure. Only mounted when `pk-auth-refresh-tokens` is on the classpath and a `RefreshTokenRepository` SPI is bound. | -> All three adapters mount these paths identically — the Dropwizard -> Jersey resources use the same `@Path("/auth/passkeys")` (and -> `/auth/refresh`, `/auth/admin`) roots as the Spring and Micronaut -> controllers, so the TypeScript SDK targets one path scheme everywhere -> with no per-client path override. +All three adapters mount these paths identically. The Dropwizard Jersey resources use the same +`@Path("/auth/passkeys")` (and `/auth/refresh`, `/auth/admin`) roots as the Spring and Micronaut +controllers, so the TypeScript SDK targets one path scheme everywhere with no per-client path +override. + +### Admin endpoints -### Admin endpoints (require `Authorization: Bearer `) +Admin endpoints require `Authorization: Bearer `, with the exception noted in the table for +email completion. | Method | Path | Notes | |---|---|---| | `GET` | `/auth/admin/account` | Current user summary | | `GET` | `/auth/admin/credentials` | List passkeys | | `PATCH` | `/auth/admin/credentials/{id}` | Rename a passkey | -| `DELETE` | `/auth/admin/credentials/{id}` | Delete (enforces last-credential guard → 409) | +| `DELETE` | `/auth/admin/credentials/{id}` | Delete (enforces last-credential guard → `409`) | | `POST` | `/auth/admin/backup-codes/regenerate` | View-once plaintext batch | | `GET` | `/auth/admin/backup-codes/count` | Remaining count | | `POST` | `/auth/admin/email/start-verification` | Dispatch magic link | -| `POST` | `/auth/admin/email/complete-verification` | Consume token (no auth — recipient redeems via emailed link) | +| `POST` | `/auth/admin/email/complete-verification` | Consume token (no auth; the recipient redeems via the emailed link) | | `POST` | `/auth/admin/phone/start-verification` | Dispatch OTP | | `POST` | `/auth/admin/phone/complete-verification` | Verify OTP | ### Wire conventions -- **Bytes**: every byte field on the wire is **base64url, no padding** - (RFC 4648 §5). The core's `PkAuthObjectMappers.pkAuthModule()` - registers serializers/deserializers for `byte[]`, `UserHandle`, and - `ChallengeId` so adapters using Jackson 3 get the right shape - automatically. The Dropwizard and Micronaut adapters still ride - Jackson 2 and register equivalent Jackson 2 modules for the same - effect (`PkAuthJacksonBridge` and the `PkAuthJacksonModule` bean, +- Bytes: every byte field on the wire is base64url with no padding (RFC 4648 §5). The core's + `PkAuthObjectMappers.pkAuthModule()` registers serialisers and deserialisers for `byte[]`, + `UserHandle`, and `ChallengeId`, so adapters using Jackson 3 get the right shape automatically. + The Dropwizard and Micronaut adapters still use Jackson 2 and register equivalent Jackson 2 + modules for the same effect (`PkAuthJacksonBridge` and the `PkAuthJacksonModule` bean, respectively). -- **Errors**: `4xx` with a JSON body `{ "outcome": "", "error": - "", "detail": "..." }`. `outcome` and `error` both carry the - same machine-readable tag so clients keyed off either field keep - working; `detail` is present only when the result variant carried - one. Common kinds: `validation_failed`, `origin_mismatch`, - `counter_regression`, `challenge_expired`, `conflict`, `forbidden`, - `not_found`, `rate_limited`. `rate_limited` is paired with a - `Retry-After` response header. `AdminResponseMapper` in - `pk-auth-admin-api` is the source of truth for the admin status codes - and error envelope; each adapter wraps it in a thin native-HTTP - mapper. -- **Idempotence**: `finish` endpoints are *not* idempotent — challenges - are single-use (`ChallengeStore.takeOnce`). +- Errors: `4xx` with a JSON body `{ "outcome": "", "error": "", "detail": "..." }`. + `outcome` and `error` both carry the same machine-readable tag, so clients keyed off either field + keep working; `detail` is present only when the result variant carried one. Common kinds: + `validation_failed`, `origin_mismatch`, `counter_regression`, `challenge_expired`, `conflict`, + `forbidden`, `not_found`, `rate_limited`. `rate_limited` is paired with a `Retry-After` response + header. `AdminResponseMapper` in `pk-auth-admin-api` is the source of truth for the admin status + codes and error envelope; each adapter wraps it in a thin native-HTTP mapper. +- Idempotence: `finish` endpoints are not idempotent. Challenges are single-use + (`ChallengeStore.takeOnce`). ## 5. Core types -Worth knowing for any non-trivial integration: +The following core types matter for any non-trivial integration. -- **`UserHandle`** (`pk-auth-core/api`) — an opaque, stable byte - identifier for a user. Generated on first-passkey registration; once - bound to a passkey, must remain stable for that user across all - future calls. Your `UserLookup` is responsible for the +- `UserHandle` (`pk-auth-core/api`) is an opaque, stable byte identifier for a user. It is + generated on first-passkey registration and, once bound to a passkey, must remain stable for that + user across all future calls. The host's `UserLookup` is responsible for the `(username, email) ↔ UserHandle` mapping. See [ADR 0006](./docs/adr/0006-userlookup-spi-not-owned.md). -- **`ChallengeId`** — a base64url-no-padding 32-byte token issued by - `ChallengeStore.create` and atomically consumed by - `ChallengeStore.takeOnce`. The atomicity is the only thing - preventing challenge replay. -- **`AdminResult` sealed sum** — every admin operation returns one - of `Success | NotFound | Forbidden | ValidationFailed | Conflict | - RateLimited`. Adapters pattern-match this into HTTP status codes. -- **`RegistrationResult` / `AssertionResult`** — sealed sums returned - by the core ceremony service. The adapter wraps these in - `Response.ok(...)` on success or maps the explicit failure variants - to the right HTTP code. - -## 6. SPIs (what you implement) - -If you adopt pk-auth, the SPIs are your only mandatory contact -surface. They are intentionally narrow. +- `ChallengeId` is an opaque random identifier (a UUID string) issued by `ChallengeStore.create` + and atomically consumed by `ChallengeStore.takeOnce`. The atomicity is the only thing preventing + challenge replay. +- `AdminResult` is a sealed sum. Every admin operation returns one of + `Success | NotFound | Forbidden | ValidationFailed | Conflict | RateLimited`. Adapters + pattern-match it into HTTP status codes. +- `RegistrationResult` and `AssertionResult` are sealed sums returned by the core ceremony service. + The adapter wraps them in `Response.ok(...)` on success or maps the explicit failure variants to + the right HTTP code. + +## 6. SPIs + +The SPIs are the host's only mandatory contact surface with pk-auth, and they are narrow. | SPI | Module | Required? | Notes | |---|---|---|---| -| `UserLookup` | `pk-auth-core` | **Yes** | Maps `(username/email) ↔ UserHandle`. Atomic find-or-create on first registration. | -| `CredentialRepository` | `pk-auth-core` | **Yes** | Insert / list-by-user / update / delete / find-by-id. | -| `ChallengeStore` | `pk-auth-core` | **Yes** | `create(...)`, `takeOnce(challengeId)` — atomic single-use. | +| `UserLookup` | `pk-auth-core` | Yes | Maps `(username/email) ↔ UserHandle`. Atomic find-or-create on first registration. | +| `CredentialRepository` | `pk-auth-core` | Yes | Insert / list-by-user / update / delete / find-by-id. | +| `ChallengeStore` | `pk-auth-core` | Yes | `create(...)`, `takeOnce(challengeId)`; atomic single-use. | | `BackupCodeRepository` | `pk-auth-core` | Only if backup codes are enabled | Hashed-storage CRUD + atomic claim. | | `OtpRepository` | `pk-auth-core` | Only if phone OTP is enabled | Same shape as backup codes. | | `EmailSender` | `pk-auth-magic-link` | Only if magic-link is enabled | `send(to, subject, body)`. | @@ -238,13 +218,11 @@ surface. They are intentionally narrow. | `OriginValidator` | `pk-auth-core` | Optional | Default is config-driven exact-match. Override for tenancy-aware origins. | | `ClockProvider` | `pk-auth-core` | Optional | Default is `Clock.systemUTC()`. Override in tests. | -For a fresh project, the testkit's in-memory implementations let -you boot end-to-end without writing any SPI. For -production, the `pk-auth-persistence-jdbi` or -`pk-auth-persistence-dynamodb` modules already implement the -storage SPIs against a real backend. +In a fresh project, the testkit's in-memory implementations boot the stack end to end without any +SPI implementation. For production, the `pk-auth-persistence-jdbi` and +`pk-auth-persistence-dynamodb` modules already implement the storage SPIs against a real backend. -## 7. Wiring it up (per framework) +## 7. Framework wiring ### Spring Boot 4 @@ -263,10 +241,9 @@ pkauth: secret: "${PKAUTH_JWT_SECRET}" # ≥ 32 bytes ``` -The starter autoconfigures everything if all required SPIs resolve -(host or persistence module). Implement `UserLookup` as a Spring -`@Bean` against your user table — that's the only Spring-specific -thing. +The starter autoconfigures everything if all required SPIs resolve (host or persistence module). +`UserLookup` is implemented as a Spring `@Bean` against the host's user table; that is the only +Spring-specific requirement. ### Dropwizard 5 @@ -291,14 +268,12 @@ public class MyApp extends Application { } ``` -Wiring is Dagger 2 (compile-time DI; see -[ADR 0004](./docs/adr/0004-dagger-for-dropwizard.md)). The bundle -expects your config to implement `HasPkAuthConfig`, and a -`PersistenceBindings` (a class with a builder) describing which SPIs -to plug in. The `AltFlowOptions` constructor shown above is the -recommended one: it builds the backup-code, magic-link, OTP, and admin -services from config and mounts `/auth/admin/**`. A one-argument -`PkAuthBundle(persistence)` mounts only the ceremony endpoints. +Wiring is Dagger 2 (compile-time DI; see [ADR 0004](./docs/adr/0004-dagger-for-dropwizard.md)). The +bundle expects the host config to implement `HasPkAuthConfig`, and a `PersistenceBindings` (a class +with a builder) describing which SPIs to plug in. The constructor shown above, taking +`AltFlowOptions`, is the recommended one: it builds the backup-code, magic-link, OTP, and admin +services from config and mounts `/auth/admin/**`. A one-argument `PkAuthBundle(persistence)` mounts +only the ceremony endpoints. ### Micronaut 4 @@ -312,86 +287,73 @@ public class PersistenceFactory { } ``` -The micronaut adapter intentionally does **not** use Micronaut Security -— a plain `@Filter` extracts and validates the JWT. The -generics-heavy `SecurityRule>` surface didn't -pay for itself. +The Micronaut adapter does not use Micronaut Security. A plain `@Filter` extracts and validates the +JWT, because the generics-heavy `SecurityRule>` surface did not justify +its cost. ## 8. Persistence -Two real-backend modules ship in-tree, both implementing the same -SPIs. +Two real-backend modules ship in-tree, both implementing the same SPIs. ### JDBI + Postgres ([ADR 0003](./docs/adr/0003-jdbi-over-jpa.md)) -- Migrations under `pk-auth-persistence-jdbi/src/main/resources/db/migration/` - (Flyway). The schema is hand-tuned for the SPI access patterns; - no JPA / Hibernate. -- Tables: `users`, `credentials`, `challenges`, `backup_codes`, `otp_codes` - (V1–V5, no `pkauth_` prefix), plus the append-only `pkauth_audit_events` - table from V6. `V8__create_access_tokens.sql` and - `V9__create_refresh_tokens.sql` add the stateful-access-token and - refresh-token tables for the 1.1.0 SPIs; `V10__refresh_tokens_amr.sql` - adds the `amr` (RFC 8176 authentication-method-reference) column to - `refresh_tokens`; `V11__challenges_user_verification.sql` persists the - resolved per-ceremony user-verification requirement; and - `V12__users_username_case_insensitive.sql` makes username uniqueness - case-insensitive (unique index on `lower(username)`) so the JDBI and DynamoDB - backends share one identity model — it refuses to run, naming the offending - rows, if the database already holds usernames differing only by case. - `PkAuthJdbiSchema.CURRENT_SCHEMA_VERSION` is `"12"`. - Magic-link tokens are - not persisted — the JWT itself is the credential; consumed JTIs live in a - `ConsumedJtiStore` (in-memory by default, swap in a shared backend for - multi-replica deployments). -- Atomic-claim operations (`takeOnce`, `BackupCodeRepository.consume`, - `OtpRepository.consume`) use conditional `UPDATE ... WHERE consumed_at IS NULL` - / `consumed = FALSE` and return `boolean` so the caller can detect a - race-lost claim. Credential delete is a hard delete (V7 dropped - `revoked_at` / `revoked_reason`); audit history lives in the - `pkauth.credential.deleted` structured log event. +- Migrations live under `pk-auth-persistence-jdbi/src/main/resources/db/migration/` (Flyway). The + schema is hand-tuned for the SPI access patterns; there is no JPA / Hibernate. +- Tables: `users`, `credentials`, `challenges`, `backup_codes`, `otp_codes` (V1–V5, no `pkauth_` + prefix), plus the append-only `pkauth_audit_events` table from V6. +- `V8__create_access_tokens.sql` and `V9__create_refresh_tokens.sql` add the stateful-access-token + and refresh-token tables for the 1.1.0 SPIs. `V10__refresh_tokens_amr.sql` adds the `amr` (RFC + 8176 authentication-method-reference) column to `refresh_tokens`. + `V11__challenges_user_verification.sql` persists the resolved per-ceremony user-verification + requirement. `V12__users_username_case_insensitive.sql` makes username uniqueness + case-insensitive (unique index on `lower(username)`) so the JDBI and DynamoDB backends share one + identity model. It refuses to run, naming the offending rows, if the database already holds + usernames differing only by case. `PkAuthJdbiSchema.CURRENT_SCHEMA_VERSION` is `"12"`. +- Magic-link tokens are not persisted: the JWT itself is the credential. Consumed JTIs live in a + `ConsumedJtiStore` (in-memory by default; a shared backend replaces it for multi-replica + deployments). +- Atomic-claim operations (`takeOnce`, `BackupCodeRepository.consume`, `OtpRepository.consume`) use + conditional `UPDATE ... WHERE consumed_at IS NULL` / `consumed = FALSE` and return `boolean` so + the caller can detect a race-lost claim. Credential delete is a hard delete (V7 dropped + `revoked_at` / `revoked_reason`); audit history lives in the `pkauth.credential.deleted` + structured log event. ### DynamoDB single-table ([ADR 0008](./docs/adr/0008-dynamodb-single-table-design.md)) -- One physical table, schema per item type via `DynamoDbTable` on - the AWS SDK v2 Enhanced client. -- DynamoDB-native TTL runs on the `ttl` attribute (epoch seconds) of - the core table; `DynamoDbSchemaBootstrapper` enables it there. It is - set on challenge, OTP, and access-token items from the row's expiry, - and on refresh-token items from expiry plus the cleanup retention. - Magic links are not persisted (see above), so they have no items. - Production tables provisioned via IaC must enable TTL on `ttl`. - Host user records live in a separate `users` table +- One physical table, with a schema per item type via `DynamoDbTable` on the AWS SDK v2 Enhanced + client. +- DynamoDB-native TTL runs on the `ttl` attribute (epoch seconds) of the core table; + `DynamoDbSchemaBootstrapper` enables it there. It is set on challenge, OTP, and access-token + items from the row's expiry, and on refresh-token items from expiry plus the cleanup retention. + Magic links are not persisted (see above), so they have no items. Production tables provisioned + via IaC must enable TTL on `ttl`. Host user records live in a separate `users` table (`PkAuthDynamoTables.users()`). -- The refresh-token layout writes three items per token (primary - jti / user-index / family-index) so listings, family-scorch, and - user-fan-out delete can all be served by the same physical table. -- Atomic-claim uses conditional-write `ConditionExpression`s; failed - conditions surface as `ConditionalCheckFailedException` and are - mapped to `AdminResult.Conflict` / `Challenge.Expired`. +- The refresh-token layout writes three items per token (primary jti / user-index / family-index), + so listings, family-scorch, and user-fan-out delete can all be served by the same physical table. +- Atomic-claim uses conditional-write `ConditionExpression`s; failed conditions surface as + `ConditionalCheckFailedException` and are mapped to `AdminResult.Conflict` / + `Challenge.Expired`. ### Testkit (in-memory) -- `pk-auth-testkit` ships `InMemoryX` for every SPI. The example apps - default to these so a fresh clone runs without external services. -- Backed by `ConcurrentHashMap`. Not durable across restarts. Not for - production. +- `pk-auth-testkit` ships `InMemoryX` for every SPI. The example apps default to these, so a fresh + clone runs without external services. +- The implementations are backed by `ConcurrentHashMap`. They are not durable across restarts and + are not for production. ## 9. The TS SDK -`clients/passkeys-browser/` is a zero-dep TypeScript SDK that ships -both ESM and CJS bundles, published on npm as +`clients/passkeys-browser/` is a zero-dependency TypeScript SDK that ships both ESM and CJS +bundles, published on npm as [`@pk-auth/passkeys-browser`](https://www.npmjs.com/package/@pk-auth/passkeys-browser) -(`npm install @pk-auth/passkeys-browser`; its version tracks the pk-auth -server release it speaks to). Two clients: +(`npm install @pk-auth/passkeys-browser`; its version tracks the pk-auth server release it speaks +to). It exposes two clients: -- **`PkAuthCeremonyClient`** — full `register()` and `authenticate()` - flows that wrap `navigator.credentials.{create,get}`, handling all - the byte-array / base64url conversions for you. -- **`PkAuthAdminClient`** — admin operations against `/auth/admin/**` - with bearer-token auth. Takes a `getToken: () => string | null` - callback at construction so token storage stays in the consumer's - hands. +- `PkAuthCeremonyClient` provides full `register()` and `authenticate()` flows that wrap + `navigator.credentials.{create,get}` and handle all the byte-array and base64url conversions. +- `PkAuthAdminClient` provides admin operations against `/auth/admin/**` with bearer-token auth. It + takes a `getToken: () => string | null` callback at construction, so token storage stays in the + consumer's hands. ```ts const pk = new PkAuthClient({ @@ -403,149 +365,121 @@ const { token } = await pk.ceremonies.authenticate({ username: "alice" }); localStorage.setItem("pk-jwt", token); ``` -The SDK's `dist/` is **not** committed — Gradle's -`:buildPasskeysBrowserSdk` task runs `npm ci && npm run build` to -produce the bundle before each demo's `processResources` copies it -into the demo's static resources. The vitest suite covers the -serializers and HTTP layer; ceremony / admin flows are covered -end-to-end by the demos' Playwright suites. +The SDK's `dist/` is not committed. Gradle's `:buildPasskeysBrowserSdk` task runs +`npm ci && npm run build` to produce the bundle before each demo's `processResources` copies it +into the demo's static resources. The vitest suite covers the serialisers and HTTP layer; +ceremony and admin flows are covered end to end by the demos' Playwright suites. ## 10. JWTs -The default mint is **HS256** with a configurable secret -(`pkauth.jwt.secret`, ≥ 32 bytes). One-hour TTL by default. Claims: +The default mint is HS256 with a configurable secret (`pkauth.jwt.secret`, ≥ 32 bytes). The default +TTL is one hour. Claims: - `sub`: base64url-encoded `UserHandle` -- `iss` / `aud`: configurable per host; `aud` falls back to - `JwtConfig.defaultAudience()` when the caller's `JwtClaims.audience` - is null -- `iat` / `nbf` / `exp`: epoch seconds (`nbf` is `iat` minus - `JwtConfig.notBeforeSkew()`) +- `iss` / `aud`: configurable per host; `aud` falls back to `JwtConfig.defaultAudience()` when the + caller's `JwtClaims.audience` is null +- `iat` / `nbf` / `exp`: epoch seconds (`nbf` is `iat` minus `JwtConfig.notBeforeSkew()`) - `jti`: random UUID, always set -- `pkauth.method`: the `AuthMethod` wire value (`passkey`, - `backup-code`, `magic-link`, or `refresh`; there is no OTP value) +- `pkauth.method`: the `AuthMethod` wire value (`passkey`, `backup-code`, `magic-link`, or + `refresh`; there is no OTP value) - `pkauth.amr`: array of RFC 8176 authentication-method references - `pkauth.cred`: base64url credential id (passkey tokens only) -- any host-supplied `JwtClaims.additionalClaims` (which may not - override the names above) - -JWT verification (in adapter filters / Micronaut filter / Dropwizard -authenticator) returns a `JwtVerificationResult` sealed sum: -`Success(JwtClaims)` or one of the failure variants -`InvalidSignature`, `Expired`, `NotYetValid`, `WrongIssuer`, -`WrongAudience`, `Malformed`, `MissingClaim`, `Revoked`. - -**Per-audience TTLs (1.1.0).** `JwtConfig.ttlPolicy: TokenTtlPolicy` -replaces the single `tokenTtl: Duration`. The default policy returns -the same TTL for every audience (`TokenTtlPolicy.single(ttl)`); -multi-client deployments wire a `fixed(default, overrides)` policy so -web / cli / mobile audiences can carry different access-token -lifetimes from a single issuer. The validator accepts any audience in -`defaultAudience ∪ ttlPolicy.knownAudiences()`. See +- any host-supplied `JwtClaims.additionalClaims` (which may not override the names above) + +JWT verification (in adapter filters / Micronaut filter / Dropwizard authenticator) returns a +`JwtVerificationResult` sealed sum: `Success(JwtClaims)` or one of the failure variants +`InvalidSignature`, `Expired`, `NotYetValid`, `WrongIssuer`, `WrongAudience`, `Malformed`, +`MissingClaim`, `Revoked`. + +### Per-audience TTLs + +Since 1.1.0, `JwtConfig.ttlPolicy: TokenTtlPolicy` replaces the single `tokenTtl: Duration`. The +default policy returns the same TTL for every audience (`TokenTtlPolicy.single(ttl)`). +Multi-client deployments wire a `fixed(default, overrides)` policy so that web, cli, and mobile +audiences can carry different access-token lifetimes from a single issuer. The validator accepts +any audience in `defaultAudience ∪ ttlPolicy.knownAudiences()`. See [ADR 0014](./docs/adr/0014-per-audience-ttl-policy.md). -**Stateful access tokens (1.1.0).** The original 0.x stance was -stateless-by-default with short TTLs as the mitigation -([ADR 0005](./docs/adr/0005-stateless-jwt-default.md)). 1.1.0 keeps -that default but adds the `AccessTokenStore` SPI: when wired, the -issuer records every JTI and the validator checks `exists` on every -request, so logout / admin revoke / password reset / user delete -invalidate the bearer well before `exp`. The default -`AccessTokenStore.noop()` keeps the legacy behaviour for hosts that -prefer it. The `RevocationCheck` SPI remains supported as a -lighter-weight deny-list orthogonal to the store. See +### Stateful access tokens + +The default is stateless access tokens, with short TTLs as the mitigation +([ADR 0005](./docs/adr/0005-stateless-jwt-default.md)). The `AccessTokenStore` SPI (1.1.0) adds an +opt-in stateful mode. When a store is wired, the issuer records every JTI and the validator checks +`exists` on every request, so logout, admin revoke, password reset, and user delete invalidate the +bearer well before `exp`. The default `AccessTokenStore.noop()` keeps the stateless behaviour. The +`RevocationCheck` SPI remains supported as a lighter-weight deny-list orthogonal to the store. See [ADR 0015](./docs/adr/0015-stateful-access-tokens.md). -**Refresh tokens (1.1.0).** The paved road for "session length -beyond one hour" is the rotating-refresh-token primitive shipped in -`pk-auth-refresh-tokens`. The wire token is `{refreshId}.{secret}` -(both halves base64url); the secret is SHA-256 hashed at rest. Every -`POST /auth/refresh` call is a single ceremony / one row per -rotation, with atomic mark-and-insert at the repository level and -family scorch on detected replay. The `RotateResult` sealed sum -(`Success | Replayed | Expired | Unknown | Revoked`) drives the -adapter response. See +### Refresh tokens + +The paved road for "session length beyond one hour" is the rotating-refresh-token primitive shipped +in `pk-auth-refresh-tokens` (1.1.0). The wire token is `{refreshId}.{secret}` (both halves +base64url); the secret is SHA-256 hashed at rest. Every `POST /auth/refresh` call is a single +ceremony and one row per rotation, with atomic mark-and-insert at the repository level and family +scorch on detected replay. The `RotateResult` sealed sum +(`Success | Replayed | Expired | Unknown | Revoked`) drives the adapter response. See [ADR 0013](./docs/adr/0013-refresh-tokens-family-rotation.md). ## 11. Security stance -The full STRIDE pass lives in [`docs/threat-model.md`](./docs/threat-model.md). -Highlights: - -- **Origin validation is strict** by default with a config-driven - allow-list. Mismatches reject with `origin_mismatch`. -- **Counter regression rejects** by default. Configurable to `warn` - for sites where synced (counter-0) passkeys dominate, at the cost - of weakening the clone-detection signal. -- **Challenges are single-use** and TTL-bounded (5 min default). -- **Backup codes are Argon2id-hashed** server-side; **OTPs are hashed - with HMAC-SHA256 using a server-side pepper** (the 10^6 search space - makes a CPU-heavy hash pointless — the per-attempt cap and rate - limiter are the brute-force defence). Backup-code plaintext is - returned only at regeneration time (view-once). -- **Last-credential guard**: `DELETE /credentials/{id}` returns 409 - if it would leave the user with zero passkeys. Backup codes are the - intended recovery path; encourage users to add a second passkey - before removing the first. -- **No PII is owned by pk-auth.** The `UserLookup` SPI is the only - channel to user data; pk-auth never stores names, emails, or - display names of its own. +The full STRIDE pass lives in [`docs/threat-model.md`](./docs/threat-model.md). Highlights: + +- Origin validation is strict by default, with a config-driven allow-list. Mismatches reject with + `origin_mismatch`. +- Counter regression rejects by default. It is configurable to `warn` for sites where synced + (counter-0) passkeys dominate, at the cost of weakening the clone-detection signal. +- Challenges are single-use and TTL-bounded (5 min default). +- Backup codes are Argon2id-hashed server-side. OTPs are hashed with HMAC-SHA256 using a + server-side pepper (a CPU-heavy hash gives no protection against a 10^6 search space; the + per-attempt cap and rate limiter are the brute-force defence). Backup-code plaintext is returned + only at regeneration time (view-once). +- Last-credential guard: `DELETE /credentials/{id}` returns `409` if it would leave the user with + zero passkeys. Backup codes are the intended recovery path, and a second passkey should be added + before the first is removed. +- No PII is owned by pk-auth. The `UserLookup` SPI is the only channel to user data; pk-auth never + stores names, emails, or display names of its own. ## 12. Build system -Single Gradle multi-project build with conventions in -`build-logic/`: - -- `pkauth.java-conventions`: JDK 21 toolchain, Error Prone, JSpecify, - `-Xlint:all`, Spotless / google-java-format with the SPDX header. - Also applied by the example apps. -- `pkauth.library-conventions`: `java-library`, `-Werror` (plus the - automatic-module lint relaxations), Javadoc and sources jars, jar - manifest attributes. `module-info.java` is per-module, not enforced - by the convention. -- `pkauth.test-conventions`: JUnit Jupiter, AssertJ, Mockito, JaCoCo - report and coverage gate. Testcontainers is a per-module test - dependency of the persistence modules, not convention wiring. -- `pkauth.publish-conventions`: Maven Central publishing. v1.0.0 - shipped through this path; see [`RELEASE.md`](./RELEASE.md) for the - full release workflow. - -JaCoCo enforces a LINE ≥ 70% / BRANCH ≥ 55% floor on every library -module; individual modules (core, jwt, admin-api, backup-codes, otp, -refresh-tokens) raise it in their own `build.gradle.kts`. - -The version catalog is `gradle/libs.versions.toml`; Dependabot -proposes bumps and is configured (`.github/dependabot.yml`) to ignore -specific traps (Micronaut 5.x needs JVM 25, Spotless 8.x has a -classloader bug, etc.). The Spring Boot 4 / Dropwizard 5 majors are -*not* pinned — they're treated as deliberate framework refreshes; -see commits `7b59f01` and `803ea5b` for the precedent. +A single Gradle multi-project build holds the conventions in `build-logic/`: + +- `pkauth.java-conventions`: JDK 21 toolchain, Error Prone, JSpecify, `-Xlint:all`, Spotless / + google-java-format with the SPDX header. Also applied by the example apps. +- `pkauth.library-conventions`: `java-library`, `-Werror` (plus the automatic-module lint + relaxations), Javadoc and sources jars, jar manifest attributes. `module-info.java` is + per-module, not enforced by the convention. +- `pkauth.test-conventions`: JUnit Jupiter, AssertJ, Mockito, JaCoCo report and coverage gate. + Testcontainers is a per-module test dependency of the persistence modules, not convention + wiring. +- `pkauth.publish-conventions`: Maven Central publishing. v1.0.0 shipped through this path; see + [`RELEASE.md`](./RELEASE.md) for the full release workflow. + +JaCoCo enforces a LINE ≥ 70% / BRANCH ≥ 55% floor on every library module; individual modules +(core, jwt, admin-api, backup-codes, otp, refresh-tokens) raise it in their own +`build.gradle.kts`. + +The version catalog is `gradle/libs.versions.toml`. Dependabot proposes bumps and is configured +(`.github/dependabot.yml`) to ignore specific known-problem versions (Micronaut 5.x needs JVM 25, +Spotless 8.x has a classloader bug, etc.). The Spring Boot 4 and Dropwizard 5 majors are not +pinned; major bumps are treated as framework refreshes, as in commits `7b59f01` and `803ea5b`. ## 13. Conventions -Worth knowing if you're contributing: - -- **Records over classes** for DTOs, configs, and result variants. - Sealed interfaces for closed sums (`AdminResult`, - `JwtVerificationResult`, `RegistrationResult`). -- **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. In - `pk-auth-core` the exported packages are `api`, `ceremony`, `config`, - `credential`, `error`, `json`, `lifecycle`, `metrics`, `ratelimit`, 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 - non-trivial decision in either the commit body or an ADR. - -## 14. Where to look next - -- A specific algorithm or class? Start at the `api` / `spi` package - of the relevant module — they're the contract surface. -- A specific operational concern? `docs/operator-guide.md`. -- A specific *why*? `docs/adr/` — every non-obvious decision has one. -- The original bootstrap brief is archived at - `docs/history/pk-auth-build-brief.md` for historical context only; - this document and the ADRs supersede it. +- Records are used over classes for DTOs, configs, and result variants. Sealed interfaces model + closed sums (`AdminResult`, `JwtVerificationResult`, `RegistrationResult`). +- Null discipline: `@org.jspecify.annotations.NonNull` / `@Nullable` on every public method + parameter and return type. JSpecify is loaded, and Error Prone catches violations. +- 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`, `ratelimit`, + and `spi`; everything else is module-internal. +- No reflection runs in hot paths. The only reflection is Jackson's, confined to + (de)serialisation boundaries. +- Commit messages follow conventional commits, with the rationale for any non-trivial decision in + either the commit body or an ADR. + +## 14. Further reading + +- Algorithms and classes: the `api` / `spi` package of the relevant module, which is the contract + surface. +- Operational concerns: [`docs/operator-guide.md`](./docs/operator-guide.md). +- Rationale: [`docs/adr/`](./docs/adr/). Every non-obvious decision has an ADR. diff --git a/GETTING_STARTED.md b/GETTING_STARTED.md index 713dc064..76a1f7d1 100644 --- a/GETTING_STARTED.md +++ b/GETTING_STARTED.md @@ -1,37 +1,33 @@ # Getting Started with pk-auth -A guide for developers who are about to drop pk-auth into a JVM project for -the first time. Start at the top, jump to whatever section answers your -current question. +pk-auth adds passkey authentication to a Spring Boot, Dropwizard, or Micronaut application, and the +host's own user table stays in place. -## 1. The five-year-old version +## 1. Concepts -Your app needs to know **who is logging in**. The old way was to store a -password — the user types it, you hash it, you compare it. That has problems: -users reuse passwords, phishing sites steal them, your database gets dumped -and now every password is on the internet. +An application needs to know who is logging in. The old way was to store a password: the user types +it, the application hashes it and compares. That has problems. Users reuse passwords, phishing +sites steal them, and a dumped database puts every password on the internet. -The new way is a **passkey**. The user's device (phone, laptop, hardware key) -holds a tiny secret that *never leaves the device*. When the user logs in, -the device proves it owns that secret without revealing it. There is nothing -for an attacker to steal from your server, and nothing for the user to type +The new way is a **passkey**. The user's device (phone, laptop, hardware key) holds a tiny secret +that never leaves the device. At login, the device proves it owns that secret without revealing it. +Nothing is left for an attacker to steal from the server, and nothing is left for the user to type into a fake site. -pk-auth is **the part of your app that does the passkey dance**. It speaks -the WebAuthn protocol with the user's browser, remembers which passkey -belongs to which user, and gives you back a short-lived **JWT token** at the -end that you can use to identify the user on later requests. +pk-auth is the part of the application that performs the passkey exchange. It speaks the WebAuthn +protocol with the user's browser, records which passkey belongs to which user, and returns a +short-lived **JWT** at the end, which identifies the user on later requests. -What pk-auth is **not**: +The scope of pk-auth has three limits: -- It's **not your user database.** It doesn't store names, emails, or - permissions. You bring your existing `users` table; pk-auth just stores - the passkeys (public keys + counters) that point at user IDs you give it. -- It's **not a SaaS.** It's a library — code you compile into your own - application. Nothing leaves your servers. -- It's **not Spring-only.** It works with Spring Boot 4, Dropwizard 5, or - Micronaut 4. Same wire contract on all three, same admin endpoints, same - TypeScript SDK for the browser. +- pk-auth is not the user database. It does not store names, emails, or permissions. The host keeps + its existing `users` table, and pk-auth stores only the passkeys (public keys + counters) that + point at user IDs the host supplies. +- pk-auth is not a SaaS. It is a library, code compiled into the host application. Nothing leaves + the host's servers. +- pk-auth is not Spring-only. It works with Spring Boot 4, Dropwizard 5, or Micronaut 4, with the + same wire contract on all three, the same admin endpoints, and the same TypeScript SDK for the + browser. ### The mental model @@ -50,110 +46,111 @@ What pk-auth is **not**: └────────────┘ ``` -pk-auth sits *between* the browser and your app. It handles the cryptographic -parts. You handle "who is this user, and what are they allowed to do?" +pk-auth sits between the browser and the application. It handles the cryptographic parts. The host +handles who a user is and what the user is allowed to do. -### The simplest possible adoption +### Minimal Spring Boot adoption -If your app is Spring Boot 4 today, adoption is roughly: +For a Spring Boot 4 application, adoption is roughly: -1. Add `pk-auth-spring-boot-starter` to your build. -2. Implement **one** Spring bean — `UserLookup` — that knows how to find a - user in *your* user table by username, by email, or by an opaque byte ID - that pk-auth gives you. -3. Set three required config values: `pkauth.relying-party.id` (your - domain), `pkauth.relying-party.origins` (your HTTPS URLs), and - `pkauth.jwt.secret` (a 32+ byte secret for signing JWTs). -4. Start your app. The endpoints under `/auth/passkeys/**` and `/auth/admin/**` - now exist. Wire the browser SDK (`@pk-auth/passkeys-browser`) into your - UI, and login is done. +1. Add `pk-auth-spring-boot-starter` to the build. +2. Implement one Spring bean, `UserLookup`, that finds a user in the application's user table by + username, by email, or by an opaque byte ID that pk-auth supplies. +3. Set three required config values: `pkauth.relying-party.id` (the domain), + `pkauth.relying-party.origins` (the HTTPS URLs), and `pkauth.jwt.secret` (a 32+ byte secret for + signing JWTs). +4. Start the application. The endpoints under `/auth/passkeys/**` and `/auth/admin/**` now exist. + Wire the browser SDK (`@pk-auth/passkeys-browser`) into the UI, and login is done. -You'll be using in-memory storage for the passkeys themselves until you -plug in a real persistence module — totally fine for the first run. +Passkeys are held in in-memory storage until a real persistence module is plugged in, which is +sufficient for a first run. -## 2. What's in the box +## 2. Modules -The project ships as a set of related JARs. You pick the ones you need: +The project ships as a set of related JARs, and the host picks the ones it needs. -### Always required +### Required modules -| Module | What it does | When you need it | +| Module | What it does | When it is needed | |---|---|---| -| `pk-auth-core` | The framework-neutral ceremony engine. Has the WebAuthn logic, the JWT contract, all the SPI interfaces you might implement, and the result types you'll pattern-match. | Always — every other module depends on this. | -| `pk-auth-jwt` | Issues and validates HS256 JWTs. Houses the `TokenTtlPolicy` SPI (per-audience access-token TTLs) and the `AccessTokenStore` SPI (stateful, server-revocable access tokens — paved road for "logout everywhere"); the lighter-weight `RevocationCheck` SPI is also still here. | Always — pk-auth's authentication output is a JWT. | +| `pk-auth-core` | The framework-neutral ceremony engine. Has the WebAuthn logic, the JWT contract, all the SPI interfaces the host might implement, and the result types the host pattern-matches. | Always; every other module depends on it. | +| `pk-auth-jwt` | Issues and validates HS256 JWTs. Houses the `TokenTtlPolicy` SPI (per-audience access-token TTLs) and the `AccessTokenStore` SPI (stateful, server-revocable access tokens, the paved road for "logout everywhere"); the lighter-weight `RevocationCheck` SPI also lives here. | Always; pk-auth's authentication output is a JWT. | -### Pick one adapter (this is your framework binding) +### Adapter modules -| Module | What it does | When you need it | +One adapter is chosen as the framework binding. + +| Module | What it does | When it is needed | |---|---|---| -| `pk-auth-spring-boot-starter` | Auto-configures controllers, the JWT filter, and bean defaults for Spring Boot 4 / Spring Security 7. | If your app is Spring Boot. | -| `pk-auth-dropwizard` | Ships a `ConfiguredBundle` you register with your Dropwizard `Application`. Uses Dagger 2 for DI (see [ADR 0004](./docs/adr/0004-dagger-for-dropwizard.md)). | If your app is Dropwizard 5. | -| `pk-auth-micronaut` | Provides `@Factory` beans + controllers + a plain `@Filter` JWT validator (deliberately not Micronaut Security; see [DESIGN.md §7](./DESIGN.md#micronaut-4)). | If your app is Micronaut 4. | +| `pk-auth-spring-boot-starter` | Auto-configures controllers, the JWT filter, and bean defaults for Spring Boot 4 / Spring Security 7. | When the application is Spring Boot. | +| `pk-auth-dropwizard` | Ships a `ConfiguredBundle` that the host registers with its Dropwizard `Application`. Uses Dagger 2 for DI (see [ADR 0004](./docs/adr/0004-dagger-for-dropwizard.md)). | When the application is Dropwizard 5. | +| `pk-auth-micronaut` | Provides `@Factory` beans + controllers + a plain `@Filter` JWT validator (no Micronaut Security; see [DESIGN.md §7](./DESIGN.md#micronaut-4)). | When the application is Micronaut 4. | + +### Persistence modules -### Pick a persistence backend (or run on the testkit) +A persistence backend is chosen, or the host runs on the testkit. -| Module | What it does | When you need it | +| Module | What it does | When it is needed | |---|---|---| -| `pk-auth-testkit` | In-memory implementations of every SPI plus a `FakeAuthenticator` you can drive WebAuthn ceremonies with from a unit test. | Always on the test classpath. Also fine for a five-minute demo on the main classpath. | -| `pk-auth-persistence-jdbi` | SPI implementations on JDBI 3 + Postgres + Flyway. Your app runs the shipped migrations with its own Flyway setup (see `PkAuthJdbiSchema` and the [operator guide](./docs/operator-guide.md#3-persistence-migrations)); no adapter runs them for you. | When you want real storage and you already have Postgres. | -| `pk-auth-persistence-dynamodb` | SPI implementations on AWS SDK v2 DynamoDB Enhanced. One physical table, schema per item type (see [ADR 0008](./docs/adr/0008-dynamodb-single-table-design.md)). | When you want real storage on AWS. | +| `pk-auth-testkit` | In-memory implementations of every SPI plus a `FakeAuthenticator` that drives WebAuthn ceremonies from a unit test. | Always on the test classpath. Also suitable for a five-minute demo on the main classpath. | +| `pk-auth-persistence-jdbi` | SPI implementations on JDBI 3 + Postgres + Flyway. The host application runs the shipped migrations with its own Flyway setup (see `PkAuthJdbiSchema` and the [operator guide](./docs/operator-guide.md#3-persistence-migrations)); no adapter runs them. | When real storage is wanted and Postgres already exists. | +| `pk-auth-persistence-dynamodb` | SPI implementations on AWS SDK v2 DynamoDB Enhanced. One physical table, schema per item type (see [ADR 0008](./docs/adr/0008-dynamodb-single-table-design.md)). | When real storage on AWS is wanted. | -### Optional "alt-flow" modules +### Optional alt-flow modules -You only need these if you want pk-auth to handle the corresponding feature. -Skip them and the feature isn't exposed. +These modules are needed only when pk-auth handles the corresponding feature. Without a module, the +feature is not exposed. -| Module | What it does | Why you'd add it | +| Module | What it does | Reason to add it | |---|---|---| -| `pk-auth-backup-codes` | View-once Argon2id-hashed backup codes for account recovery. | Users will lose their phones; backup codes are the documented recovery path before "talk to support." | +| `pk-auth-backup-codes` | View-once Argon2id-hashed backup codes for account recovery. | Users lose their phones; backup codes are the documented recovery path before contacting support. | | `pk-auth-magic-link` | Single-use email magic-link tokens. JWTs on the wire; consumed-JTI tracking via a swappable SPI. | Email verification, or a passwordless login alternative for users without a passkey-capable device. | | `pk-auth-otp` | 6-digit SMS OTPs with attempt caps; codes are hashed with HMAC-SHA256 using a server-side pepper. | Phone verification. | -| `pk-auth-refresh-tokens` | Rotating refresh tokens with family-based replay defense. Adds `POST /auth/refresh`; on success returns a new refresh token + a fresh access JWT, on replay scorches the entire token family. Requires a `RefreshTokenRepository` SPI (JDBI / DynamoDB impls ship). See [ADR 0013](./docs/adr/0013-refresh-tokens-family-rotation.md). | When you need sessions longer than the access-token TTL without re-running a WebAuthn ceremony. | -| `pk-auth-admin-api` | Adds the `/auth/admin/**` endpoints (rename / delete passkeys, regenerate backup codes, account summary, email & phone verification). | Almost always — without it the UI can't manage credentials. | +| `pk-auth-refresh-tokens` | Rotating refresh tokens with family-based replay defence. Adds `POST /auth/refresh`; on success returns a new refresh token + a fresh access JWT, on replay scorches the entire token family. Requires a `RefreshTokenRepository` SPI (JDBI / DynamoDB impls ship). See [ADR 0013](./docs/adr/0013-refresh-tokens-family-rotation.md). | Sessions that outlast the access-token TTL without re-running a WebAuthn ceremony. | +| `pk-auth-admin-api` | Adds the `/auth/admin/**` endpoints (rename / delete passkeys, regenerate backup codes, account summary, email & phone verification). | Almost always; without it the UI cannot manage credentials. | ### The browser SDK -| Module | What it does | When you need it | +| Module | What it does | When it is needed | |---|---|---| -| `clients/passkeys-browser` (`@pk-auth/passkeys-browser`) | Zero-dependency TypeScript SDK. `PkAuthCeremonyClient` wraps `navigator.credentials.{create,get}` and handles all the base64url ↔ ArrayBuffer conversions. `PkAuthAdminClient` calls the admin endpoints with a bearer token. ESM + CJS bundles. | If your frontend talks to pk-auth from a browser. Skip if you're calling the JSON endpoints from a non-browser client (mobile, server-to-server). | +| `clients/passkeys-browser` (`@pk-auth/passkeys-browser`) | Zero-dependency TypeScript SDK. `PkAuthCeremonyClient` wraps `navigator.credentials.{create,get}` and handles all the base64url ↔ ArrayBuffer conversions. `PkAuthAdminClient` calls the admin endpoints with a bearer token. ESM + CJS bundles. | When the frontend talks to pk-auth from a browser. Not needed for non-browser clients (mobile, server-to-server) that call the JSON endpoints. | -The SDK is on npm — its version tracks the pk-auth release it speaks to: +The SDK is on npm, and its version tracks the pk-auth release it speaks to: ```sh npm install @pk-auth/passkeys-browser ``` -## 3. What you have to do (the SPI surface) +## 3. SPI surface -pk-auth's design assumes **your app already has a user table**. It doesn't -want to own user data. The price you pay for that is implementing a small -SPI surface — the interfaces below — so pk-auth can reach into your world. +pk-auth assumes that the host application already has a user table, and it does not own user data. +The cost of that assumption is a small SPI surface, the interfaces below, through which pk-auth +reaches the host's data. | SPI | Required? | What it does | |---|---|---| -| `UserLookup` | **Yes** | Maps `(username, email) ↔ UserHandle`. `UserHandle` is an opaque byte ID pk-auth uses internally. Your implementation typically stores it as a `BYTEA` column on your existing `users` table. | -| `CredentialRepository` | **Yes** | Stores passkeys (the public key + counter + label per credential). | -| `ChallengeStore` | **Yes** | Issues short-lived ceremony challenges and atomically consumes them. | -| `BackupCodeRepository` | If you use backup codes | Stores hashed backup codes; supports atomic single-use claim. | -| `OtpRepository` | If you use OTP | Same shape as backup codes for SMS codes. | -| `EmailSender` | If you use magic links | `send(to, subject, body)` — wire your SMTP / SendGrid / Mailgun here. | -| `SmsSender` | If you use OTP | `send(phoneE164, body)` — wire Twilio / SNS / etc. here. | -| `AttestationTrustPolicy` | Optional | Default is "accept any attestation." Override only if you need FIDO MDS3 verification. | -| `OriginValidator` | Optional | Default reads from config (`pkauth.relying-party.origins`). Override for multi-tenant origin rules. | -| `ClockProvider` | Optional | Default is `Clock.systemUTC()`. Override in tests. | -| `ConsumedJtiStore` | Optional (multi-replica only) | In-memory Caffeine cache by default. Replace with a shared store (Redis, DynamoDB) once you run more than one replica with magic-link enabled. | -| `CeremonyRateLimiter` | Optional (multi-replica only) | In-memory per-IP / per-username throttle by default. Same multi-replica caveat as above. | +| `UserLookup` | Yes | Maps `(username, email) ↔ UserHandle`. `UserHandle` is an opaque byte ID pk-auth uses internally. An implementation typically stores it as a `BYTEA` column on the existing `users` table. | +| `CredentialRepository` | Yes | Stores passkeys (the public key + counter + label per credential). | +| `ChallengeStore` | Yes | Issues short-lived ceremony challenges and atomically consumes them. | +| `BackupCodeRepository` | When backup codes are used | Stores hashed backup codes; supports atomic single-use claim. | +| `OtpRepository` | When OTP is used | Same shape as backup codes for SMS codes. | +| `EmailSender` | When magic links are used | `send(to, subject, body)`; the host wires SMTP / SendGrid / Mailgun behind it. | +| `SmsSender` | When OTP is used | `send(phoneE164, body)`; the host wires Twilio / SNS / etc. behind it. | +| `AttestationTrustPolicy` | Optional | Default is "accept any attestation." Overridden only for FIDO MDS3 verification. | +| `OriginValidator` | Optional | Default reads from config (`pkauth.relying-party.origins`). Overridden for multi-tenant origin rules. | +| `ClockProvider` | Optional | Default is `Clock.systemUTC()`. Overridden in tests. | +| `ConsumedJtiStore` | Optional (multi-replica only) | In-memory Caffeine cache by default. Replaced with a shared store (Redis, DynamoDB) once more than one replica runs with magic-link enabled. | +| `CeremonyRateLimiter` | Optional (multi-replica only) | In-memory per-IP / per-username throttle by default. The multi-replica caveat of `ConsumedJtiStore` applies. | | `AccessTokenStore` | Optional (1.1.0) | Stateful access tokens. When wired, every issued JWT's JTI is persisted; the validator looks it up on every request, so deleting the row immediately invalidates the bearer. The shipped JDBI / DynamoDB implementations are the paved road; `AccessTokenStore.noop()` is the legacy default. | | `RevocationCheck` | Optional | In-process deny-list for hosts that want to invalidate a small subset of tokens without persisting every issue. Orthogonal to `AccessTokenStore`. | -| `TokenTtlPolicy` | Optional (1.1.0) | Per-audience access-token TTL. Default is `TokenTtlPolicy.single(ttl)` — one TTL for every audience. Implement when web / cli / mobile clients need different token lifetimes from a single issuer. | -| `RefreshTokenRepository` | If `pk-auth-refresh-tokens` is enabled (1.1.0) | Storage for the rotating refresh-token primitive. The load-bearing `rotateAtomically` method must mark-used + insert-successor atomically; JDBI / DynamoDB / in-memory impls ship. | -| `UserDeletionListener` | Optional (1.1.0) | Hook for the `UserDeletionService` fan-out. Listeners for credentials, backup codes, OTPs, access tokens, and refresh tokens are auto-registered; add your own to clean up host-owned tables when a user is deleted. | +| `TokenTtlPolicy` | Optional (1.1.0) | Per-audience access-token TTL. Default is `TokenTtlPolicy.single(ttl)`, one TTL for every audience. Implemented when web / cli / mobile clients need different token lifetimes from a single issuer. | +| `RefreshTokenRepository` | When `pk-auth-refresh-tokens` is enabled (1.1.0) | Storage for the rotating refresh-token primitive. The load-bearing `rotateAtomically` method must mark-used + insert-successor atomically; JDBI / DynamoDB / in-memory impls ship. | +| `UserDeletionListener` | Optional (1.1.0) | Hook for the `UserDeletionService` fan-out. Listeners for credentials, backup codes, OTPs, access tokens, and refresh tokens are auto-registered; hosts add their own to clean up host-owned tables when a user is deleted. | -**Good news**: every required SPI has a working `InMemoryX` implementation in -`pk-auth-testkit`, so you can boot the whole stack with zero database work -to start. Swap in real implementations one at a time as you go. +Every required SPI has a working `InMemoryX` implementation in `pk-auth-testkit`, so the whole +stack boots with zero database work. Real implementations swap in one at a time. -## 4. What a real wire-up looks like (Spring example) +## 4. Spring wiring example ```kotlin // build.gradle.kts @@ -199,40 +196,41 @@ class UserLookupBean implements UserLookup { } ``` -That's the minimum. Start the app, hit `/auth/passkeys/registration/start` -from the browser SDK, and you have passkey login. +This is the minimum wiring. With it and a started application, the browser SDK reaches +`/auth/passkeys/registration/start`, and passkey login works. -## 5. What you get on the wire +## 5. Wire contract summary Every adapter exposes the same JSON contract. The full table is in -[DESIGN.md §4](./DESIGN.md#4-the-wire-contract); the short version: +[DESIGN.md §4](./DESIGN.md#4-the-wire-contract); in short: -- **Ceremony endpoints** (unauthenticated): +- Ceremony endpoints (unauthenticated): - `POST /auth/passkeys/registration/start` → returns WebAuthn `create()` options - `POST /auth/passkeys/registration/finish` → persists the new credential - `POST /auth/passkeys/authentication/start` → returns WebAuthn `get()` options - `POST /auth/passkeys/authentication/finish` → returns `{token: ""}` - - `POST /auth/refresh` → rotates a refresh token; returns `{refresh, access}` on success, `401 {detail}` on any failure. Only mounted when `pk-auth-refresh-tokens` is wired. + - `POST /auth/refresh` → rotates a refresh token; returns `{refresh, access}` on success, + `401 {detail}` on any failure. Only mounted when `pk-auth-refresh-tokens` is wired. -- **Admin endpoints** (require `Authorization: Bearer `): +- Admin endpoints (require `Authorization: Bearer `): - `GET /auth/admin/account` - `GET /auth/admin/credentials` - `PATCH /auth/admin/credentials/{id}` - - `DELETE /auth/admin/credentials/{id}` (returns 409 if it would leave the user with zero passkeys) + - `DELETE /auth/admin/credentials/{id}` (returns `409` if it would leave the user with zero + passkeys) - `POST /auth/admin/backup-codes/regenerate` - `GET /auth/admin/backup-codes/count` - `POST /auth/admin/email/{start,complete}-verification` - `POST /auth/admin/phone/{start,complete}-verification` -Bytes on the wire are base64url with no padding. Errors are JSON -`{outcome, error, detail}` with a `Retry-After` header on `rate_limited`. +Bytes on the wire are base64url with no padding. Errors are JSON `{outcome, error, detail}` with a +`Retry-After` header on `rate_limited`. -## 6. Look at the example apps +## 6. Example applications -The fastest way to understand any of this is to run a demo. Each example -exercises every flow — registration (single + multiple passkeys), login, -list/rename/delete, backup-code regeneration, email magic links, SMS OTP — -on a single static-HTML page. +Each demo exercises every flow (registration with single and multiple passkeys, login, +list/rename/delete, backup-code regeneration, email magic links, SMS OTP) on a single static-HTML +page, so running one is the fastest way to see the system work. | Demo | Run command | Source | |---|---|---| @@ -240,27 +238,27 @@ on a single static-HTML page. | Dropwizard 5 | `./gradlew :examples:dropwizard-demo:run` | [`examples/dropwizard-demo/`](./examples/dropwizard-demo/) | | Micronaut 4 | `./gradlew :examples:micronaut-demo:run` | [`examples/micronaut-demo/`](./examples/micronaut-demo/) | -Open in any passkey-capable browser. The demos -default to the **testkit's in-memory adapters** so they need no Postgres, -no DynamoDB, no Twilio, no SMTP — magic-link tokens and SMS OTPs are -printed to the server console (`LoggingEmailSender` / `LoggingSmsSender`), -and you copy them back into the UI to complete the verification flows. - -Each demo's `README.md` documents flags for switching to a real persistence -backend (`docker compose up -d` for Postgres or DynamoDB Local, then -`--demo.persistence=jdbi` or `--demo.persistence=dynamodb`). - -For a contrast, **read all three demos' wiring code**. The framework -plumbing differs — Spring `@Configuration` beans vs. Dropwizard's Dagger -module vs. Micronaut's `@Factory` — but the **same SPIs get wired into -the same core** in every case. That's the load-bearing claim of this -project, and the three demos are the proof. - -## 7. Where to go next - -- **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/). -- **SPI stability + versioning** — [`docs/stability.md`](./docs/stability.md). -- **Transactional behavior across SPIs** — [`docs/transactional-semantics.md`](./docs/transactional-semantics.md). +Each demo serves at , in any passkey-capable browser. The demos default to +the testkit's in-memory adapters, so they need no Postgres, no DynamoDB, no Twilio, and no SMTP. +Magic-link tokens and SMS OTPs are printed to the server console (`LoggingEmailSender` / +`LoggingSmsSender`), and they are copied back into the UI to complete the verification flows. + +Each demo's `README.md` documents how to switch to a real persistence backend, and +`docker compose up -d` starts Postgres or DynamoDB Local. The selector differs per demo. The Spring +Boot demo takes `--demo.persistence=jdbi` or `--demo.persistence=dynamodb`, and the Dropwizard demo +reads the `PKAUTH_PERSISTENCE` environment variable. The Micronaut demo replaces +`InMemoryPersistenceFactory` with a factory that surfaces the real repositories. + +The framework plumbing in the three demos differs: Spring `@Configuration` beans, Dropwizard's +Dagger module, and Micronaut's `@Factory`. In every case the same SPIs are wired into the same +core, and the three demos together show it. + +## 7. Further reading + +- Wire and class details: [`DESIGN.md`](./DESIGN.md). +- Running in production: [`docs/operator-guide.md`](./docs/operator-guide.md). +- Security stance: [`docs/threat-model.md`](./docs/threat-model.md). +- Decision rationale: [`docs/adr/`](./docs/adr/). +- SPI stability and versioning: [`docs/stability.md`](./docs/stability.md). +- Transactional behaviour across SPIs: + [`docs/transactional-semantics.md`](./docs/transactional-semantics.md). diff --git a/README.md b/README.md index 7fdf47c9..3e457c34 100644 --- a/README.md +++ b/README.md @@ -3,14 +3,13 @@ ![pk-auth CI](https://github.com/codeheadsystems/pk-auth/actions/workflows/ci.yml/badge.svg) [![passkeys-browser mutation score](https://img.shields.io/endpoint?style=flat&label=mutation%20(passkeys-browser)&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fcodeheadsystems%2Fpk-auth%2Fmain%3Fmodule%3Dpasskeys-browser)](https://dashboard.stryker-mutator.io/reports/github.com/codeheadsystems/pk-auth/main?module=passkeys-browser) -A production-grade, **passkeys-first** authentication template for the JVM. -pk-auth ships as a reusable library set that can be dropped into a Spring -Boot, Dropwizard, or Micronaut application; the core is framework-neutral -and the host's user/credential storage is a plug-in SPI. +pk-auth is a production-grade, passkeys-first authentication template for the JVM. It ships as a +reusable library set that drops into a Spring Boot, Dropwizard, or Micronaut application. The core +is framework-neutral, and the host's user and credential storage is a plug-in SPI. ## Maven Central -All modules share the same version and `com.codeheadsystems` group id. +All modules share one version and the `com.codeheadsystems` group id. | Artifact ID | Version | Description | |---|---|---| @@ -20,7 +19,7 @@ All modules share the same version and `com.codeheadsystems` group id. | `pk-auth-backup-codes` | [![Maven Central: pk-auth-backup-codes](https://img.shields.io/maven-central/v/com.codeheadsystems/pk-auth-backup-codes?label=pk-auth-backup-codes)](https://central.sonatype.com/artifact/com.codeheadsystems/pk-auth-backup-codes) | Argon2id-hashed one-time backup codes. | | `pk-auth-magic-link` | [![Maven Central: pk-auth-magic-link](https://img.shields.io/maven-central/v/com.codeheadsystems/pk-auth-magic-link?label=pk-auth-magic-link)](https://central.sonatype.com/artifact/com.codeheadsystems/pk-auth-magic-link) | Single-use email magic-link tokens. | | `pk-auth-otp` | [![Maven Central: pk-auth-otp](https://img.shields.io/maven-central/v/com.codeheadsystems/pk-auth-otp?label=pk-auth-otp)](https://central.sonatype.com/artifact/com.codeheadsystems/pk-auth-otp) | 6-digit SMS OTP codes for phone verification. | -| `pk-auth-refresh-tokens` | [![Maven Central: pk-auth-refresh-tokens](https://img.shields.io/maven-central/v/com.codeheadsystems/pk-auth-refresh-tokens?label=pk-auth-refresh-tokens)](https://central.sonatype.com/artifact/com.codeheadsystems/pk-auth-refresh-tokens) | Rotating refresh tokens with family-based replay defense. *(1.1.0)* | +| `pk-auth-refresh-tokens` | [![Maven Central: pk-auth-refresh-tokens](https://img.shields.io/maven-central/v/com.codeheadsystems/pk-auth-refresh-tokens?label=pk-auth-refresh-tokens)](https://central.sonatype.com/artifact/com.codeheadsystems/pk-auth-refresh-tokens) | Rotating refresh tokens with family-based replay defence. (1.1.0) | | `pk-auth-persistence-jdbi` | [![Maven Central: pk-auth-persistence-jdbi](https://img.shields.io/maven-central/v/com.codeheadsystems/pk-auth-persistence-jdbi?label=pk-auth-persistence-jdbi)](https://central.sonatype.com/artifact/com.codeheadsystems/pk-auth-persistence-jdbi) | JDBI 3 + Flyway + Postgres SPI implementations. | | `pk-auth-persistence-dynamodb` | [![Maven Central: pk-auth-persistence-dynamodb](https://img.shields.io/maven-central/v/com.codeheadsystems/pk-auth-persistence-dynamodb?label=pk-auth-persistence-dynamodb)](https://central.sonatype.com/artifact/com.codeheadsystems/pk-auth-persistence-dynamodb) | AWS SDK v2 DynamoDB Enhanced SPI implementations (single-table core + separate users table). | | `pk-auth-testkit` | [![Maven Central: pk-auth-testkit](https://img.shields.io/maven-central/v/com.codeheadsystems/pk-auth-testkit?label=pk-auth-testkit)](https://central.sonatype.com/artifact/com.codeheadsystems/pk-auth-testkit) | `FakeAuthenticator`, in-memory SPIs, and fixtures for tests. | @@ -28,33 +27,28 @@ All modules share the same version and `com.codeheadsystems` group id. | `pk-auth-dropwizard` | [![Maven Central: pk-auth-dropwizard](https://img.shields.io/maven-central/v/com.codeheadsystems/pk-auth-dropwizard?label=pk-auth-dropwizard)](https://central.sonatype.com/artifact/com.codeheadsystems/pk-auth-dropwizard) | Dropwizard 5 `ConfiguredBundle` + Dagger 2 wiring. | | `pk-auth-micronaut` | [![Maven Central: pk-auth-micronaut](https://img.shields.io/maven-central/v/com.codeheadsystems/pk-auth-micronaut?label=pk-auth-micronaut)](https://central.sonatype.com/artifact/com.codeheadsystems/pk-auth-micronaut) | Micronaut 4 `@Factory` + controllers + JWT filter. | -What you get out of the box: - -- WebAuthn registration and assertion ceremonies — including multi-passkey - enrolment and conditional UI — backed by WebAuthn4J. -- A stateless JWT mint at the end of authentication (HS256, configurable - TTL) — or, with the 1.1.0 `AccessTokenStore` SPI, a stateful access - token that's revocable before its `exp`. -- Per-audience JWT TTL dispatch via `TokenTtlPolicy` so web / cli / - mobile clients can carry different access-token lifetimes from a single - issuer. -- **Rotating refresh tokens with family-based replay defense** *(1.1.0)* - — `POST /auth/refresh` is one ceremony / one row per rotation, with - motif-style atomic mark-and-insert and family scorch on detected - replay. The browser SDK's `PkAuthClient.refresh()` returns a typed - result sum, never throws on 401. See - [ADR 0013](./docs/adr/0013-refresh-tokens-family-rotation.md). -- Account admin: list / rename / delete passkeys, regenerate - view-once backup codes, account summary. -- Alternative-flow modules: backup codes, magic-link email verification, - phone OTP verification — each behind a clean, host-implementable SPI. -- A framework-neutral admin service that every adapter mounts at the same - paths so the same TypeScript SDK drives all three. -- Persistence options: in-memory (testkit), JDBI + Postgres with Flyway - migrations, or DynamoDB single-table. -- A zero-dependency browser SDK (`@pk-auth/passkeys-browser`) covering - both ceremony and admin operations, published on npm - (`npm install @pk-auth/passkeys-browser`). Its version tracks the +## Features + +- WebAuthn registration and assertion ceremonies, including multi-passkey enrolment and conditional + UI, backed by WebAuthn4J. +- A stateless JWT mint at the end of authentication (HS256, configurable TTL). With the 1.1.0 + `AccessTokenStore` SPI, the mint is a stateful access token that is revocable before its `exp`. +- Per-audience JWT TTL dispatch via `TokenTtlPolicy`, so web, cli, and mobile clients can carry + different access-token lifetimes from a single issuer. +- Rotating refresh tokens with family-based replay defence (1.1.0). `POST /auth/refresh` is one + ceremony and one row per rotation, with motif-style atomic mark-and-insert and family scorch on + detected replay. The browser SDK's `PkAuthClient.refresh()` returns a typed result sum and never + throws on `401`. See [ADR 0013](./docs/adr/0013-refresh-tokens-family-rotation.md). +- Account admin: list, rename, and delete passkeys; regenerate view-once backup codes; account + summary. +- Alternative-flow modules: backup codes, magic-link email verification, and phone OTP + verification, each behind a host-implementable SPI. +- A framework-neutral admin service that every adapter mounts at the same paths, so the same + TypeScript SDK drives all three. +- Persistence options: in-memory (testkit), JDBI + Postgres with Flyway migrations, or DynamoDB + single-table. +- A zero-dependency browser SDK (`@pk-auth/passkeys-browser`) covering both ceremony and admin + operations, published on npm (`npm install @pk-auth/passkeys-browser`). Its version tracks the pk-auth server release it speaks to. ```mermaid @@ -65,48 +59,46 @@ flowchart LR Core --> Alt["backup-codes / magic-link / otp"] ``` -For the full architecture, see [`DESIGN.md`](./DESIGN.md). For decision -records, see [`docs/adr/`](./docs/adr/). For production operations -guidance, see [`docs/operator-guide.md`](./docs/operator-guide.md) and -[`docs/threat-model.md`](./docs/threat-model.md). For SPI versioning and -stability guarantees, see [`docs/stability.md`](./docs/stability.md). -For transactional behavior across SPIs, see -[`docs/transactional-semantics.md`](./docs/transactional-semantics.md). +Related documents: -## Try it +- Architecture: [`DESIGN.md`](./DESIGN.md). +- Decision records: [`docs/adr/`](./docs/adr/). +- Production operations: [`docs/operator-guide.md`](./docs/operator-guide.md) and + [`docs/threat-model.md`](./docs/threat-model.md). +- SPI versioning and stability guarantees: [`docs/stability.md`](./docs/stability.md). +- Transactional behaviour across SPIs: + [`docs/transactional-semantics.md`](./docs/transactional-semantics.md). -The fastest path to a working demo: +## Demo + +The Spring Boot demo is the fastest path to a working server: ```sh ./gradlew :examples:spring-boot-demo:run ``` -Then open **http://localhost:8080** in a passkey-capable browser -(Chrome, Edge, Safari, Firefox 130+). The single-page UI exercises every -flow: +The demo serves at `http://localhost:8080`, to be opened in a passkey-capable browser (Chrome, Edge, +Safari, Firefox 130+). The single-page UI exercises every flow: -1. **Register** an account — your platform authenticator (Touch ID, - Windows Hello, security key) handles the ceremony. -2. **Sign in** to get a JWT; the page decodes its claims at the bottom. -3. **List / rename / delete passkeys** — the demo enforces the - last-credential guard. -4. **Regenerate backup codes** (view-once), check remaining count. -5. **Verify email via magic link** and **phone via OTP**. +1. Registration of an account; the platform authenticator (Touch ID, Windows Hello, security key) + handles the ceremony. +2. Sign-in, which returns a JWT; the page decodes its claims at the bottom. +3. Listing, renaming, and deleting passkeys; the demo enforces the last-credential guard. +4. Regeneration of backup codes (view-once) and a remaining-count check. +5. Email verification via magic link and phone verification via OTP. -Magic-link tokens and OTP codes are written to the **server console** -(the testkit ships `LoggingEmailSender` / `LoggingSmsSender`); copy them -from the gradle log back into the form to complete the verification -flows. +Magic-link tokens and OTP codes are written to the server console (the testkit ships +`LoggingEmailSender` and `LoggingSmsSender`). Copying them from the Gradle log back into the form +completes the verification flows. -Two other demos exist for the other adapters — same UI, different -framework underneath: +Two other demos exist for the other adapters, with the same UI over a different framework: ```sh ./gradlew :examples:dropwizard-demo:run # Jersey + Dropwizard 5 ./gradlew :examples:micronaut-demo:run # Netty + Micronaut 4 ``` -(Run one at a time — all three bind to port 8080.) +Only one demo can run at a time, because all three bind to port 8080. ## Layout @@ -138,46 +130,43 @@ docs/ # ADRs, operator guide, threat model Requirements: -- **JDK 21** — Gradle's toolchain will fetch one if not present. -- **Node ≥ 22.22.2** + **npm** — for the browser SDK build, invoked - automatically by Gradle (`./gradlew :buildPasskeysBrowserSdk`). +- JDK 21. Gradle's toolchain fetches one if none is present. +- Node 22.22.2 or later, and npm, for the browser SDK build, which Gradle invokes automatically + (`./gradlew :buildPasskeysBrowserSdk`). -Optional, only needed for the JDBI / DynamoDB persistence integration -tests and Playwright end-to-end suites: +Optional, needed only for the JDBI and DynamoDB persistence integration tests and the Playwright +end-to-end suites: -- **Docker** — Testcontainers spins Postgres and DynamoDB Local. -- **Chrome** — Playwright drives a CDP virtual WebAuthn authenticator. +- Docker. Testcontainers starts Postgres and DynamoDB Local. +- Chrome. Playwright drives a CDP virtual WebAuthn authenticator. ## Status -1.0.0 cut the stable baseline; the current development line is -the `2.x` `-SNAPSHOT` named by `version` in `gradle.properties` (the -authoritative source). 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); -1.2–1.3 followed with per-framework admin toggles and supply-chain +The 1.0.0 release cut the stable baseline. The current development line is the `2.x` `-SNAPSHOT` +named by `version` in `gradle.properties`, which is the authoritative source. 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 +defence). Releases 1.2 and 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 -are green; the test classpath includes the testkit's -`FakeAuthenticator`, so registration + assertion ceremonies exercise -the real WebAuthn4J verifier without a browser. - -**Known property — transactional boundaries:** pk-auth does not require -host SPIs to share a transactional context. At ceremony finish, -`ChallengeStore.takeOnce` is consumed before `CredentialRepository.save`. -If the save fails, the user must restart the ceremony from the beginning -(a new challenge is issued). This is intentional — challenges are -short-lived (5-minute default TTL), so a forced restart is acceptable and -avoids distributed-transaction complexity across heterogeneous SPI -implementations. See [`docs/transactional-semantics.md`](./docs/transactional-semantics.md) -for full details. - -## License - -MIT — see [`LICENSE`](./LICENSE). +[`docs/stability.md`](./docs/stability.md) for the versioning policy and the list of SPI surfaces. +The full build and end-to-end suites pass. The test classpath includes the testkit's +`FakeAuthenticator`, so registration and assertion ceremonies exercise the real WebAuthn4J verifier +without a browser. + +### Transactional boundaries + +pk-auth does not require host SPIs to share a transactional context. At ceremony finish, +`ChallengeStore.takeOnce` is consumed before `CredentialRepository.save`. If the save fails, the +user restarts the ceremony from the beginning, and a new challenge is issued. Challenges are +short-lived (5-minute default TTL), so a forced restart is acceptable, and it avoids +distributed-transaction complexity across heterogeneous SPI implementations. See +[`docs/transactional-semantics.md`](./docs/transactional-semantics.md) for full details. + +## Licence + +MIT. See [`LICENSE`](./LICENSE). diff --git a/RELEASE.md b/RELEASE.md index 27223189..82ad1352 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,12 +1,12 @@ # Releasing pk-auth -This document explains how to publish the pk-auth libraries to Maven Central -and the browser SDK to npm. +The pk-auth libraries publish to Maven Central through GitHub Actions workflows, and the browser +SDK publishes to npm. ## What gets published -Every release publishes the same set of 13 artifacts, all under -`com.codeheadsystems` and sharing one version: +Every release publishes the same set of 13 artifacts, all under `com.codeheadsystems` and sharing +one version: - `pk-auth-core`, `pk-auth-jwt`, `pk-auth-admin-api` - `pk-auth-backup-codes`, `pk-auth-magic-link`, `pk-auth-otp`, @@ -15,19 +15,19 @@ Every release publishes the same set of 13 artifacts, all under - `pk-auth-testkit` - `pk-auth-spring-boot-starter`, `pk-auth-dropwizard`, `pk-auth-micronaut` -The demo applications under `examples/` are intentionally **not** published. +The demo applications under `examples/` are not published. -Separately, the browser SDK `clients/passkeys-browser/` is published to **npm** -as `@pk-auth/passkeys-browser`. Its version tracks the server release it speaks -to (the SDK and the JVM artifacts share a version), so it is published as part of -the same release — see [Publishing the browser SDK to npm](#publishing-the-browser-sdk-to-npm) -below. There is no CI workflow for the npm publish yet; it is a manual step. +The browser SDK `clients/passkeys-browser/` is published separately to npm as +`@pk-auth/passkeys-browser`. Its version tracks the server release it speaks to (the SDK and the +JVM artifacts share a version), so it is published as part of the same release. See +[Publishing the browser SDK to npm](#publishing-the-browser-sdk-to-npm) below. There is no CI +workflow for the npm publish yet; it is a manual step. ## Prerequisites (one-time) -These repository secrets must exist on the GitHub repo (they are shared with -`hofmann-elimination` under the same `codeheadsystems` org, so on a fresh fork -you only need to set them once at the org level): +The following secrets must exist on the GitHub repository. They are shared with +`hofmann-elimination` under the same `codeheadsystems` org, so a fresh fork needs them set once at +the org level. | Secret | Purpose | | ------------------------- | -------------------------------------------------------------------- | @@ -37,24 +37,24 @@ you only need to set them once at the org level): | `GPG_PASSPHRASE` | Passphrase for the GPG key | | `GPG_KEY_ID` | Long key id (or fingerprint) of the signing key | -To generate the base64 form of the private key locally: +The base64 form of the private key is generated locally with: ```sh gpg --armor --export-secret-keys YOUR_KEY_ID | base64 -w 0 ``` -The signing key must be published to a public keyserver (e.g. -`keys.openpgp.org`) so Central Portal can verify signatures. +The signing key must be published to a public keyserver (for example `keys.openpgp.org`) so that +Central Portal can verify signatures. ## Release modes -Two GitHub Actions workflows are configured: +Two GitHub Actions workflows are configured. -### A. Tagged release (preferred for planned releases) +### A. Tagged release -Push a semver tag matching `vMAJOR.MINOR.PATCH` (or -`vMAJOR.MINOR.PATCH-suffix` for pre-releases) and the -`Tag Release to Maven Central` workflow takes over. +The tagged release is the route for planned releases. A semver tag matching `vMAJOR.MINOR.PATCH` +(or `vMAJOR.MINOR.PATCH-suffix` for pre-releases) starts the `Tag Release to Maven Central` +workflow. ```sh git switch main @@ -63,60 +63,56 @@ git tag -a v0.1.0 -m "v0.1.0" git push origin v0.1.0 ``` -`settings.gradle.kts` reads the exact tag at build time and uses it as the -project version, so you do **not** need to edit `gradle.properties` first. +`settings.gradle.kts` reads the exact tag at build time and uses it as the project version, so +`gradle.properties` needs no edit beforehand. -A pre-release suffix (e.g. `v0.2.0-rc.1`) automatically marks the GitHub -release as a pre-release. +A pre-release suffix (for example `v0.2.0-rc.1`) automatically marks the GitHub release as a +pre-release. -### B. Manual release (one-click patch bump) +### B. Manual release -For a quick patch release with no special version planning, go to **Actions → -Manual Build Release to Maven Central → Run workflow**. The workflow: +The manual release is a one-click patch bump for a quick patch release with no special version +planning. It runs from Actions → Manual Build Release to Maven Central → Run workflow. The workflow: 1. Reads the latest `vX.Y.Z` tag and increments the patch component. 2. Creates and pushes the new tag. -3. Runs the same publish + GitHub-release flow as the tagged path. +3. Runs the same publish and GitHub-release flow as the tagged path. -Use the tagged path (mode A) when the release should be a minor or major bump, -when you want a pre-release suffix, or when you want the version pinned in the -commit history before the workflow runs. +The tagged path (mode A) fits a minor or major bump, a pre-release suffix, or a version pinned in +the commit history before the workflow runs. ## What the workflow does Both workflows perform the same steps: -1. Checkout with full history (`fetch-depth: 0`) so `git describe` can see the - tag. -2. Validate the tag matches semver. -3. Set up JDK 21 + Gradle. -4. Confirm Gradle's resolved version equals the tag's version. -5. `./gradlew clean build test --stacktrace` — full build and test gate; a - failure here aborts the release. -6. Import the GPG key and configure non-interactive signing. -7. `./gradlew publishAggregationToCentralPortal` — builds, signs, and uploads - every module in a single bundle to the Central Portal. -8. Build per-module `-sources.jar` and `-javadoc.jar` archives. -9. Create a GitHub release with all 13 module jars attached and a copy-paste +1. Checkout with full history (`fetch-depth: 0`) so that `git describe` can see the tag. +2. Validation that the tag matches semver. +3. Setup of JDK 21 and Gradle. +4. Confirmation that Gradle's resolved version equals the tag's version. +5. `./gradlew clean build test --stacktrace`, the full build and test gate. A failure here aborts + the release. +6. Import of the GPG key and configuration of non-interactive signing. +7. `./gradlew publishAggregationToCentralPortal`, which builds, signs, and uploads every module in + a single bundle to the Central Portal. +8. Build of per-module `-sources.jar` and `-javadoc.jar` archives. +9. Creation of a GitHub release with all 13 module jars attached and a copy-paste `implementation(...)` snippet in the body. ## Verifying the release After the workflow succeeds: -1. **GitHub release** appears at - with all jars attached. -2. **Maven Central** can take up to ~2 hours to index. Check status at - (Central Portal - dashboard). The deployment moves through `VALIDATING → VALIDATED → PUBLISHING - → PUBLISHED`. -3. **README badges** (driven by `img.shields.io/maven-central/v/...`) will - refresh on their own once Central serves the new version — usually within - an hour of publish completing. +1. The GitHub release appears at with all + jars attached. +2. Maven Central can take up to ~2 hours to index. The Central Portal dashboard at + shows the status. The deployment moves + through `VALIDATING → VALIDATED → PUBLISHING → PUBLISHED`. +3. The README badges (driven by `img.shields.io/maven-central/v/...`) refresh on their own once + Central serves the new version, usually within an hour of publish completing. ## Local dry run -To sanity-check a release locally before tagging: +A release can be checked locally before tagging: ```sh ./gradlew clean build test # the gate the workflow uses @@ -124,50 +120,46 @@ To sanity-check a release locally before tagging: ./gradlew tasks --group publishing # confirm the aggregation task is present ``` -Local publish to Central Portal requires `~/.gradle/gradle.properties` to -contain `centralPortalUsername` / `centralPortalPassword` and a working -`signing.gnupg.keyName`. Most contributors do not need this — push a tag and -let CI handle it. +Local publish to Central Portal requires `~/.gradle/gradle.properties` to contain +`centralPortalUsername` / `centralPortalPassword` and a working `signing.gnupg.keyName`. Most +contributors do not need this; a pushed tag lets CI handle publishing. ## Troubleshooting -- **Tag/version mismatch.** The `Verify version matches tag` step fails when - `gradle.properties` version doesn't match the tag. Because - `settings.gradle.kts` overrides version from the tag, this normally only - fires when the tag itself is malformed. -- **GPG import fails.** Verify `GPG_PRIVATE_KEY` is base64 of the **private** - key (`gpg --export-secret-keys`, not `--export`), encoded with `base64 -w 0` - (single line, no wrap). -- **Central Portal rejects the bundle.** Check the dashboard's deployment - detail page — the most common cause is the signing key not being on a public - keyserver. Re-upload to `keys.openpgp.org` and retry. -- **Artifact not visible after 2 hours.** Re-check the Central Portal - deployment status; if it shows `PUBLISHED`, the indexer is just slow — - artifacts are already downloadable via direct URL. -- **`publishAggregationToCentralPortal` task missing.** Means the - `com.gradleup.nmcp.settings` plugin in `settings.gradle.kts` did not load. - Confirm the plugin id and version, then re-run with `--refresh-dependencies`. +- Tag/version mismatch. The `Verify version matches tag` step fails when the `gradle.properties` + version does not match the tag. Because `settings.gradle.kts` overrides the version from the tag, + this normally fires only when the tag itself is malformed. +- GPG import fails. `GPG_PRIVATE_KEY` must be the base64 of the private key + (`gpg --export-secret-keys`, not `--export`), encoded with `base64 -w 0` (single line, no wrap). +- Central Portal rejects the bundle. The dashboard's deployment detail page shows the cause. The + most common cause is the signing key missing from a public keyserver; re-uploading to + `keys.openpgp.org` and retrying resolves it. +- Artifact not visible after 2 hours. If the Central Portal deployment status shows `PUBLISHED`, + the indexer is slow, and the artifacts are already downloadable via direct URL. +- `publishAggregationToCentralPortal` task missing. The `com.gradleup.nmcp.settings` plugin in + `settings.gradle.kts` did not load. Confirming the plugin id and version, then re-running with + `--refresh-dependencies`, resolves it. ## Publishing the browser SDK to npm -The browser SDK (`clients/passkeys-browser/`, package -`@pk-auth/passkeys-browser`) is published to npm manually after the Maven Central -release for the same version has gone out. Its version must match the pk-auth -release version (the SDK speaks the same wire contract as that server release). +The browser SDK (`clients/passkeys-browser/`, package `@pk-auth/passkeys-browser`) is published to +npm manually after the Maven Central release for the same version has gone out. Its version must +match the pk-auth release version, because the SDK speaks the same wire contract as that server +release. ### Prerequisites (one-time) -- **Node ≥ 22.22.2 + npm** (same toolchain the Gradle SDK build uses). -- An **npm account** that is a member of the `@pk-auth` org/scope with publish - rights. The package is public scoped (`publishConfig.access = "public"` is set - in `package.json`, so no `--access public` flag is needed). -- Authenticate locally once with `npm login` (or set `NPM_TOKEN` / - `~/.npmrc`). Confirm with `npm whoami`. +- Node 22.22.2 or later, and npm (the same toolchain the Gradle SDK build uses). +- An npm account that is a member of the `@pk-auth` org/scope with publish rights. The package is + public scoped (`publishConfig.access = "public"` is set in `package.json`, so no + `--access public` flag is needed). +- Local authentication, done once with `npm login` (or by setting `NPM_TOKEN` / `~/.npmrc`). + `npm whoami` confirms it. ### Steps -Run these from `clients/passkeys-browser/`. Replace `X.Y.Z` with the release -version you just tagged for Maven Central (without the leading `v`). +The steps run from `clients/passkeys-browser/`, with `X.Y.Z` replaced by the release version just +tagged for Maven Central (without the leading `v`). ```sh cd clients/passkeys-browser @@ -189,16 +181,16 @@ npm publish --dry-run npm publish ``` -`--no-git-tag-version` is important: the version bump must **not** create its own -git tag, because the release is already tagged (`vX.Y.Z`) for Maven Central. Pin -the SDK version to that same number so the two stay in lockstep. +`--no-git-tag-version` keeps the version bump from creating its own git tag, because the release is +already tagged (`vX.Y.Z`) for Maven Central. The SDK version is pinned to that same number so that +the two stay in lockstep. -> The committed `version` in `package.json` is the in-development line -> (e.g. `2.1.0-SNAPSHOT`), mirroring `gradle.properties`. `SNAPSHOT` is a valid -> semver prerelease identifier, so it will not be published by accident — step 1 -> always pins it to the concrete release version first. After publishing, you may -> leave `package.json` at the published version or restore the `-SNAPSHOT` line on -> the development branch; either way, the next release re-pins it in step 1. +> The committed `version` in `package.json` is the in-development line (for example +> `2.1.0-SNAPSHOT`), mirroring `gradle.properties`. `SNAPSHOT` is a valid semver prerelease +> identifier, so it is not published by accident, and step 1 always pins it to the concrete +> release version first. After publishing, `package.json` can stay at the published version or +> return to the `-SNAPSHOT` line on the development branch; either way, the next release re-pins it +> in step 1. ### Verifying the npm release @@ -207,8 +199,8 @@ npm view @pk-auth/passkeys-browser version # should report X.Y.Z npm view @pk-auth/passkeys-browser dist-tags # latest -> X.Y.Z ``` -For a **pre-release** (e.g. `X.Y.Z-rc.1`), publish under a dist-tag so it does -not become `latest`: +A pre-release (for example `X.Y.Z-rc.1`) is published under a dist-tag so that it does not become +`latest`: ```sh npm version X.Y.Z-rc.1 --no-git-tag-version --allow-same-version @@ -217,13 +209,12 @@ npm publish --tag next ### Rolling back an npm release -Like Maven Central, npm publishes are effectively immutable (unpublish is -restricted and discouraged). To recover from a bad SDK release, publish a new -patch version with the fix rather than unpublishing. +Like Maven Central, npm publishes are effectively immutable (unpublish is restricted and +discouraged). Recovery from a bad SDK release is a new patch version with the fix, not an +unpublish. ## Rolling back a release -Maven Central releases are immutable — a published version cannot be -overwritten or deleted. To recover from a bad release, cut a new patch (or -minor) release with the fix and update consumers. Do not attempt to re-publish -the same version. +Maven Central releases are immutable: a published version cannot be overwritten or deleted. +Recovery from a bad release is a new patch (or minor) release with the fix, followed by a consumer +update. Re-publishing the same version is not possible. diff --git a/SECURITY.md b/SECURITY.md index 55b8e18b..d27cbed6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,71 +1,66 @@ # Security Policy -pk-auth is an authentication library, so security reports are taken seriously -and handled with priority. Thank you for helping keep it and its users safe. +pk-auth is an authentication library, so security reports are handled with priority. ## Supported versions -Fixes land on the latest published `1.x` line. Maven Central and npm releases -are immutable, so a security fix ships as a new patch (or minor) release rather -than a re-publish of an affected version — upgrade to the latest release to pick -it up. +Fixes land on the latest published `1.x` line. Maven Central and npm releases are immutable, so a +security fix ships as a new patch (or minor) release rather than a re-publish of an affected +version. Upgrading to the latest release picks up the fix. -| Version | Supported | -| ------------------ | ------------------ | -| Latest `1.x` release | :white_check_mark: | +| Version | Supported | +| -------------------- | ----------------------------- | +| Latest `1.x` release | :white_check_mark: | | Older `1.x` releases | Fix only via the next release | -| `0.x` (pre-stable) | :x: | +| `0.x` (pre-stable) | :x: | -The browser SDK (`@pk-auth/passkeys-browser`) shares the same version line as -the JVM artifacts and follows the same support window. +The browser SDK (`@pk-auth/passkeys-browser`) shares the same version line as the JVM artifacts and +follows the same support window. ## Reporting a vulnerability -**Please do not open a public issue, pull request, or discussion for a -suspected vulnerability.** Public disclosure before a fix is available puts -downstream users at risk. +Suspected vulnerabilities are not reported through public issues, pull requests, or discussions. +Public disclosure before a fix is available puts downstream users at risk. -Use one of these private channels instead: +Two private channels accept reports: -1. **GitHub private vulnerability reporting (preferred).** On the repository, - go to the **Security** tab → **Report a vulnerability**, or open - . This - creates a private advisory thread visible only to you and the maintainers. -2. **Email.** Send details to **ned.wolpert@gmail.com** with `pk-auth security` - in the subject line. +1. GitHub private vulnerability reporting (preferred). On the repository, the Security tab offers + Report a vulnerability, and + opens the same form. The + report creates a private advisory thread visible only to the reporter and the maintainers. +2. Email to ned.wolpert@gmail.com with `pk-auth security` in the subject line. -Please include, as far as you can: +A report includes, as far as possible: -- The affected module(s) and version (e.g. `pk-auth-core 2.1.0`). +- The affected module or modules and the version (for example `pk-auth-core 2.1.0`). - A description of the issue and its impact (what an attacker can do). -- Steps to reproduce — a minimal proof of concept, failing test, or request - sequence is ideal. +- Steps to reproduce, ideally a minimal proof of concept, failing test, or request sequence. - Any relevant configuration (adapter, persistence backend, SPI implementations). -## What to expect +## Response timeline -- **Acknowledgement** within **3 business days**. -- An initial assessment (severity, affected versions, whether it reproduces) - within **10 business days**. -- We aim to ship a fix within **90 days** of triage and will keep you updated on - progress. Complex issues may take longer; we will say so. -- We follow **coordinated disclosure**: we ask that you keep the report private - until a fix is released and a GitHub Security Advisory is published. We are - happy to credit you in the advisory unless you prefer to remain anonymous. +- Acknowledgement within 3 business days. +- An initial assessment (severity, affected versions, whether it reproduces) within 10 business + days. +- A fix within 90 days of triage, as the target, with progress updates to the reporter. Complex + issues may take longer, and the maintainers say so. +- Coordinated disclosure. The report stays private until a fix is released and a GitHub Security + Advisory is published. The reporter is credited in the advisory unless they prefer to remain + anonymous. ## Scope -This policy covers the pk-auth library, its three host adapters -(`pk-auth-spring-boot-starter`, `pk-auth-dropwizard`, `pk-auth-micronaut`), the -wire contract, and the browser SDK in this repository. +This policy covers the pk-auth library, its three host adapters (`pk-auth-spring-boot-starter`, +`pk-auth-dropwizard`, `pk-auth-micronaut`), the wire contract, and the browser SDK in this +repository. -It does **not** cover the responsibilities a host application retains — TLS, -network ingress, secrets management, and the authorization layer above -authentication. See [`docs/threat-model.md`](docs/threat-model.md) for the -trust boundaries and STRIDE analysis, and [`docs/stability.md`](docs/stability.md) -for the SPI versioning and stability guarantees. +It does not cover the responsibilities a host application retains: TLS, network ingress, secrets +management, and the authorisation layer above authentication. See +[`docs/threat-model.md`](docs/threat-model.md) for the trust boundaries and STRIDE analysis, and +[`docs/stability.md`](docs/stability.md) for the SPI versioning and stability guarantees. -> **Maturity note.** As stated in the repository README, this project's code is -> AI-generated and has not undergone a formal third-party security review unless -> a note in the repository says otherwise. Evaluate it accordingly before -> production use. +## Maturity + +The project's code is AI-generated and has not undergone a formal third-party security review +unless a note in the repository says otherwise, as stated in the repository README. A production +deployment should be evaluated against that status. diff --git a/clients/passkeys-browser/README.md b/clients/passkeys-browser/README.md index c1c97aec..8b4e1018 100644 --- a/clients/passkeys-browser/README.md +++ b/clients/passkeys-browser/README.md @@ -1,10 +1,10 @@ # `@pk-auth/passkeys-browser` -Zero-dependency TypeScript SDK for the pk-auth wire contract. -Published to npm as [`@pk-auth/passkeys-browser`](https://www.npmjs.com/package/@pk-auth/passkeys-browser); -its version tracks the pk-auth server release it speaks to. The example apps in -this repo consume it via a relative `dist/` import (built by Gradle) rather than -the published package. See [`RELEASE.md`](../../RELEASE.md) for the publish steps. +Zero-dependency TypeScript SDK for the pk-auth wire contract. It is published to npm as +[`@pk-auth/passkeys-browser`](https://www.npmjs.com/package/@pk-auth/passkeys-browser), and its +version tracks the pk-auth server release it speaks to. The example apps in this repository consume +it through a relative `dist/` import (built by Gradle) instead of the published package. The +publish steps are in [`RELEASE.md`](../../RELEASE.md). ```sh npm install @pk-auth/passkeys-browser @@ -27,18 +27,19 @@ await pk.ceremonies.register({ username: "alice", label: "MacBook" }); const { token } = await pk.ceremonies.authenticate({ username: "alice" }); localStorage.setItem("pk-jwt", token); -// Admin (require a token) +// Admin (requires a token) await pk.admin.listCredentials(); await pk.admin.regenerateBackupCodes(); ``` -Two clients are exposed independently if a host only needs one half: +The clients are also exported individually, for hosts that need only part of the surface: -- `PkAuthCeremonyClient` — `startRegistration`, `register`, `startAuthentication`, `authenticate`. -- `PkAuthAdminClient` — `listCredentials`, `renameCredential`, `removeCredential`, +- `PkAuthCeremonyClient`: `startRegistration`, `register`, `startAuthentication`, `authenticate`. +- `PkAuthAdminClient`: `listCredentials`, `renameCredential`, `removeCredential`, `regenerateBackupCodes`, `remainingBackupCodes`, `startEmailVerification`, `completeEmailVerification`, `startPhoneVerification`, `completePhoneVerification`, `getAccount`. +- `PkAuthRefreshClient`: `refresh`. ### Conditional UI @@ -46,15 +47,15 @@ Two clients are exposed independently if a host only needs one half: await pk.ceremonies.authenticate({ conditional: true }); ``` -Wires `mediation: "conditional"` into the underlying `navigator.credentials.get` call, -so the browser can offer passkeys via autofill UI before the user clicks "Sign in." +This passes `mediation: "conditional"` to the underlying `navigator.credentials.get` call, so the +browser can offer passkeys through the autofill UI before the user selects "Sign in". -### Overriding ceremony paths +### Ceremony path overrides All three adapters (Spring Boot, Dropwizard, Micronaut) mount the ceremony endpoints at the -same `/auth/passkeys/...` paths, which are the SDK defaults — no per-adapter override is -needed. The `paths` option remains an escape hatch for hosts that remount the endpoints under -a custom prefix: +same `/auth/passkeys/...` paths, which are the SDK defaults, so no per-adapter override is +needed. The `paths` option is an escape hatch for hosts that remount the endpoints under a custom +prefix: ```ts new PkAuthCeremonyClient(options, { @@ -67,7 +68,7 @@ new PkAuthCeremonyClient(options, { }); ``` -## Build / test +## Build and test ```sh npm install @@ -75,7 +76,7 @@ npm test # vitest, jsdom env npm run build # tsup → dist/index.{js,cjs,d.ts} ``` -`dist/` is gitignored — Gradle's `:buildPasskeysBrowserSdk` task (defined in the root -`build.gradle.kts`) runs `npm ci && npm run build` before each demo's `processResources`, -so the bundle is regenerated from source on every fresh clone. Run the npm commands above -directly when iterating on the SDK in isolation. +`dist/` is gitignored. Gradle's `:buildPasskeysBrowserSdk` task (defined in the root +`build.gradle.kts`) runs `npm ci && npm run build` before each demo's `processResources`, so the +bundle is regenerated from source on every fresh clone. Iterating on the SDK in isolation uses the +npm commands above directly. diff --git a/clients/passkeys-browser/src/base64url.ts b/clients/passkeys-browser/src/base64url.ts index b0635183..98ed733e 100644 --- a/clients/passkeys-browser/src/base64url.ts +++ b/clients/passkeys-browser/src/base64url.ts @@ -1,9 +1,9 @@ // SPDX-License-Identifier: MIT /** - * Base64url (RFC 4648 §5) without padding — matches pk-auth's wire format. + * Base64url (RFC 4648 §5) without padding, matching pk-auth's wire format. * The server's Jackson module emits unpadded base64url; browsers can mix - * Uint8Array, ArrayBuffer, or already-base64url strings, so encode handles + * Uint8Array, ArrayBuffer, or already-base64url strings, so `encode` handles * all three. */ @@ -16,7 +16,7 @@ export function encode(input: ArrayBuffer | Uint8Array | ArrayBufferView): Base6 s += String.fromCharCode(bytes[i]!); } // base64url per RFC 4648 §5: map +/ to -_ and strip padding. Using replaceAll with - // string literals (no regexes) keeps this linear — it avoids the ReDoS shape + // string literals (no regexes) keeps this linear; it avoids the ReDoS shape // SonarQube flags on `/=+$/`. `=` only ever appears as trailing base64 padding, so // removing all of it is equivalent to stripping the trailing run. return btoa(s).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); @@ -43,9 +43,9 @@ export function decodeToArrayBuffer(input: Base64Url): ArrayBuffer { function toUint8Array(input: ArrayBuffer | Uint8Array | ArrayBufferView): Uint8Array { if (input instanceof Uint8Array) return input; - // Use toString-based check to handle cross-realm ArrayBuffers (e.g. from TextEncoder in jsdom) + // A toString-based check handles cross-realm ArrayBuffers (e.g. from TextEncoder in jsdom). // jsdom's TextEncoder creates an ArrayBuffer that is different from NodeJs ArrayBuffer so "instanceof" - // will not work. The string tag for the type will work. + // does not work. The string tag for the type does work. if (input instanceof ArrayBuffer || Object.prototype.toString.call(input) === "[object ArrayBuffer]") { return new Uint8Array(input as ArrayBuffer); } diff --git a/clients/passkeys-browser/src/ceremonies.ts b/clients/passkeys-browser/src/ceremonies.ts index 94394f13..e4349ecf 100644 --- a/clients/passkeys-browser/src/ceremonies.ts +++ b/clients/passkeys-browser/src/ceremonies.ts @@ -25,14 +25,14 @@ const PATHS = { finishAuth: "/auth/passkeys/authentication/finish", } as const; -// All three adapters (Spring, Dropwizard, Micronaut) now mount ceremony endpoints at -// /auth/passkeys//. The `paths` override remains an escape hatch for hosts that -// remap the routes but is no longer needed for any pk-auth-provided adapter. +// All three adapters (Spring, Dropwizard, Micronaut) mount ceremony endpoints at +// /auth/passkeys//. The `paths` override is an escape hatch for hosts that +// remap the routes; no pk-auth-provided adapter needs it. export interface CeremonyOptions { - /** Path overrides — kept as an escape hatch for hosts that remap pk-auth's default routes. */ + /** Path overrides, an escape hatch for hosts that remap pk-auth's default routes. */ paths?: Partial; - /** Optional override for navigator.credentials (tests / non-browser callers). */ + /** Replacement for `navigator.credentials`, for tests and non-browser callers. */ credentials?: CredentialsContainer; } @@ -86,7 +86,7 @@ export class PkAuthCeremonyClient { /** * Runs the full sign-in ceremony: server-start → `navigator.credentials.get` → - * server-finish. Returns the JWT (consumer is responsible for storage). + * server-finish. Returns the JWT; the caller stores it. */ async authenticate(params: StartAuthenticationParams = {}): Promise { const start = await this.startAuthentication({ diff --git a/clients/passkeys-browser/src/index.ts b/clients/passkeys-browser/src/index.ts index feb57bfa..8ff810dd 100644 --- a/clients/passkeys-browser/src/index.ts +++ b/clients/passkeys-browser/src/index.ts @@ -47,7 +47,7 @@ export class PkAuthClient { /** * Convenience shortcut to {@link PkAuthRefreshClient#refresh}. Returns a typed - * {@link RefreshResult} sum — does not throw on 401. + * {@link RefreshResult} sum and does not throw on 401. * * @since 1.1.0 */ diff --git a/clients/passkeys-browser/src/refresh.ts b/clients/passkeys-browser/src/refresh.ts index c0ff9265..98153694 100644 --- a/clients/passkeys-browser/src/refresh.ts +++ b/clients/passkeys-browser/src/refresh.ts @@ -3,7 +3,7 @@ import { PkAuthHttpError, request } from "./http"; import type { ClientOptions } from "./types"; -/** Typed success: a fresh refresh + access token pair to swap in. */ +/** Typed success: a fresh refresh and access token pair. */ export interface RefreshSuccess { readonly kind: "success"; readonly accessToken: string; @@ -47,8 +47,8 @@ interface RawErrorResponse { /** * Client for the {@code POST /auth/refresh} endpoint shipped by every pk-auth adapter. Returns - * a typed {@link RefreshResult} rather than throwing on 401 — the bearer's expected response to - * an Unknown / Expired / Replayed / Revoked outcome is to redirect to login, not to handle an + * a typed {@link RefreshResult} rather than throwing on 401. The bearer's expected response to + * an unknown, expired, replayed, or revoked outcome is to redirect to login, not to handle an * exception. * * @since 1.1.0 @@ -89,7 +89,7 @@ export class PkAuthRefreshClient { ) { return { kind: "failure", reason: detail, revokeReason: data?.reason }; } - // Server returned a 401 we don't recognise — surface it as the most conservative failure. + // A 401 the client does not recognise surfaces as the most conservative failure. return { kind: "failure", reason: "unknown" }; } throw e; diff --git a/clients/passkeys-browser/src/types.ts b/clients/passkeys-browser/src/types.ts index 4d256ad8..fb992e9a 100644 --- a/clients/passkeys-browser/src/types.ts +++ b/clients/passkeys-browser/src/types.ts @@ -2,7 +2,7 @@ import type { Base64Url } from "./base64url"; -/** Caller-supplied bearer-token accessor. Returns null when there's no session yet. */ +/** Caller-supplied bearer-token accessor. Returns null when no session exists. */ export type TokenAccessor = () => string | null; /** Construction options shared by the ceremony and admin clients. */ @@ -14,11 +14,11 @@ export interface ClientOptions { * ceremony operations are anonymous and will not call it. */ getToken?: TokenAccessor; - /** Override the global fetch (handy for tests). */ + /** Replacement for the global `fetch`, used in tests. */ fetch?: typeof fetch; } -// -- Ceremony wire shapes ---------------------------------------------------- +// Ceremony wire shapes export interface RpEntity { id: string; @@ -118,7 +118,7 @@ export interface FinishAuthenticationRequest { response: AuthenticationResponseJson; } -/** Server returns the wrapped RegistrationResult; relevant fields are surfaced here. */ +/** The server returns the wrapped RegistrationResult; the relevant fields are surfaced here. */ export interface FinishRegistrationResponse { credential: { credentialId: Base64Url; @@ -137,7 +137,7 @@ export interface FinishAuthenticationResponse { token: string; } -// -- Admin wire shapes -------------------------------------------------------- +// Admin wire shapes export interface CredentialSummary { credentialId: Base64Url; @@ -171,13 +171,13 @@ export interface BackupCodeCount { export interface EmailDispatchResult { /** Implementation-defined token / link reference returned by the dispatcher. */ dispatchId?: string; - /** Magic-link token (testkit / dev only — real dispatchers send it out of band). */ + /** Magic-link token (testkit and development only; real dispatchers send it out of band). */ token?: string; } export interface PhoneDispatchResult { dispatchId?: string; - /** OTP code (testkit / dev only — real dispatchers send it via SMS). */ + /** OTP code (testkit and development only; real dispatchers send it via SMS). */ code?: string; } diff --git a/docs/adr/0001-record-architecture-decisions.md b/docs/adr/0001-record-architecture-decisions.md index 2be0bd77..30653cc7 100644 --- a/docs/adr/0001-record-architecture-decisions.md +++ b/docs/adr/0001-record-architecture-decisions.md @@ -8,23 +8,37 @@ Accepted. ## Context -pk-auth is a multi-module library project (per `docs/history/pk-auth-build-brief.md`) targeting Spring Boot, Dropwizard, and Micronaut. Decisions made in early phases — choice of WebAuthn library, persistence stack, DI strategy per framework, stateless JWT default, single-table DynamoDB design, and others — will be hard to reverse without breaking downstream consumers. We need a lightweight, durable record of these decisions, the context in which they were made, and the consequences they imply. +pk-auth is a multi-module library project (per `docs/history/pk-auth-build-brief.md`) targeting +Spring Boot, Dropwizard, and Micronaut. Decisions made in early phases (choice of WebAuthn library, +persistence stack, DI strategy per framework, stateless JWT default, single-table DynamoDB design, +and others) are hard to reverse without breaking downstream consumers. The project needs a +lightweight, durable record of these decisions, the context in which they were made, and the +consequences they imply. ## Decision -Use Markdown Architecture Decision Records (ADRs) under `docs/adr/`, numbered sequentially starting from `0001`, in the Michael Nygard format: +Use Markdown Architecture Decision Records (ADRs) under `docs/adr/`, numbered sequentially starting +from `0001`, in the Michael Nygard format: -- **Title** — short, imperative. -- **Date** — ISO date. -- **Status** — `Proposed` | `Accepted` | `Superseded by NNNN` | `Deprecated`. -- **Context** — the forces at play. -- **Decision** — what we are doing. -- **Consequences** — what follows from the decision, including downsides. +- **Title**: short, imperative. +- **Date**: ISO date. +- **Status**: `Proposed` | `Accepted` | `Superseded by NNNN` | `Deprecated`. +- **Context**: the forces at play. +- **Decision**: what the project does. +- **Consequences**: what follows from the decision, including downsides. -The placeholder ADRs already enumerated in the build brief (§5: `0002-webauthn4j-over-yubico`, `0003-jdbi-over-jpa`, `0004-dagger-for-dropwizard`, `0005-stateless-jwt-default`, `0006-userlookup-spi-not-owned`, `0007-dynamodb-local-vs-localstack`, `0008-dynamodb-single-table-design`) are reserved slots and will be written when the corresponding phase lands. +The placeholder ADRs already enumerated in the build brief (§5: `0002-webauthn4j-over-yubico`, +`0003-jdbi-over-jpa`, `0004-dagger-for-dropwizard`, `0005-stateless-jwt-default`, +`0006-userlookup-spi-not-owned`, `0007-dynamodb-local-vs-localstack`, +`0008-dynamodb-single-table-design`) are reserved slots, written when the corresponding phase lands. ## Consequences -- Every non-trivial cross-module or cross-cutting decision lands as an ADR before or alongside the change that introduces it. Per `CONTRIBUTING.md`, new dependencies are justified in either a commit message or an ADR. -- ADRs are append-only: when a decision is reversed, a new ADR is added with status `Accepted` and the prior ADR is updated to `Superseded by NNNN`. -- Downside: this is process overhead. We accept the overhead for decisions that change public API, persistence schema, security posture, or cross-module contracts (per build brief §12.2). Method names, internal package layout, and similar reversible choices do not need an ADR. +- Every non-trivial cross-module or cross-cutting decision lands as an ADR before or alongside the + change that introduces it. Per `CONTRIBUTING.md`, new dependencies are justified in either a + commit message or an ADR. +- ADRs are append-only: when a decision is reversed, a new ADR is added with status `Accepted` and + the prior ADR is updated to `Superseded by NNNN`. +- Downside: the process adds overhead. The overhead applies to decisions that change public API, + persistence schema, security posture, or cross-module contracts (per build brief §12.2). Method + names, internal package layout, and similar reversible choices do not need an ADR. diff --git a/docs/adr/0002-webauthn4j-over-yubico.md b/docs/adr/0002-webauthn4j-over-yubico.md index dec1ebea..e06bdc9a 100644 --- a/docs/adr/0002-webauthn4j-over-yubico.md +++ b/docs/adr/0002-webauthn4j-over-yubico.md @@ -8,29 +8,47 @@ Accepted. ## Context -pk-auth needs a JVM WebAuthn implementation that handles attestation and assertion validation, COSE key parsing, and authenticator-data flag interpretation. Two mature options exist: +pk-auth needs a JVM WebAuthn implementation that handles attestation and assertion validation, COSE +key parsing, and authenticator-data flag interpretation. Two mature options exist: -- **WebAuthn4J** (`com.webauthn4j:webauthn4j-core`) — actively maintained, JSON-first API, tracks the WebAuthn Level 3 spec including modern extensions (PRF, largeBlob), Java 17+ baseline. Used by Spring Security's webauthn module under the hood. Permissive license (Apache 2.0). -- **Yubico's java-webauthn-server** (`com.yubico:webauthn-server-core`) — older, stable, but its `CredentialRecord` and `CredentialRepository` abstractions are tightly coupled to specific persistence patterns. Last meaningful release lag exceeds WebAuthn4J's. Also Apache 2.0. +- WebAuthn4J (`com.webauthn4j:webauthn4j-core`) is actively maintained, has a JSON-first API, tracks + the WebAuthn Level 3 spec including modern extensions (PRF, largeBlob), and has a Java 17+ + baseline. Spring Security's webauthn module uses it internally. Its licence is permissive (Apache + 2.0). +- Yubico's java-webauthn-server (`com.yubico:webauthn-server-core`) is older and stable, but its + `CredentialRecord` and `CredentialRepository` abstractions are tightly coupled to specific + persistence patterns. The lag since its last meaningful release exceeds WebAuthn4J's. Its licence + is also Apache 2.0. -The brief (§3) names WebAuthn4J explicitly and forbids both `webauthn4j-spring-security` and Yubico's library. +The brief (§3) names WebAuthn4J explicitly and forbids both `webauthn4j-spring-security` and +Yubico's library. ## Decision -Depend directly on `com.webauthn4j:webauthn4j-core`. pk-auth wraps WebAuthn4J's `WebAuthnManager` in our own framework-neutral `PasskeyAuthenticationService` (Phase 2). We do **not** expose WebAuthn4J types in our public API surface — `pk-auth-core` defines its own DTOs and result types so that: +Depend directly on `com.webauthn4j:webauthn4j-core`. pk-auth wraps WebAuthn4J's `WebAuthnManager` in +its own framework-neutral `PasskeyAuthenticationService` (Phase 2). WebAuthn4J types are absent from +the public API surface: `pk-auth-core` defines its own DTOs and result types so that: -1. Adapter modules and downstream consumers can be pinned to our wire contract independent of WebAuthn4J release cadence. +1. Adapter modules and downstream consumers can be pinned to the pk-auth wire contract independent + of the WebAuthn4J release cadence. 2. A future swap (if WebAuthn4J ever becomes unmaintained) does not break adopters. -3. Spring users who prefer Spring Security's own webauthn module retain the option to opt out — the brief §4.2 documents this. +3. Spring users who prefer Spring Security's own webauthn module retain the option to opt out (the + brief §4.2 documents this). ## Consequences -- **Positive:** Latest WebAuthn coverage, including extensions our example apps will eventually demonstrate. Active project, responsive maintainers. -- **Positive:** Single source of truth for ceremony validation; no need to maintain our own CBOR / attestation-format code. -- **Negative:** WebAuthn4J 0.31.x cuts over to Jackson 3 internally. This drove ADR 0009 — we standardize the whole core on Jackson 3 to avoid two Jackson lineages on the runtime classpath. -- **Negative:** WebAuthn4J's `CredentialRecord` and `Authenticator` types are not in pk-auth's public API; we translate at the service boundary. Small mapping cost; pays for itself in API stability. +- Positive: the library covers the latest WebAuthn features, including extensions the example apps + will eventually demonstrate. The project is active and its maintainers are responsive. +- Positive: ceremony validation has a single source of truth; pk-auth maintains no CBOR or + attestation-format code of its own. +- Negative: WebAuthn4J 0.31.x moves to Jackson 3 internally. This drove ADR 0009: the whole core + standardises on Jackson 3 to avoid two Jackson lineages on the runtime classpath. +- Negative: WebAuthn4J's `CredentialRecord` and `Authenticator` types are not in pk-auth's public + API, so pk-auth translates at the service boundary. The mapping cost is small and buys API + stability. ## Open follow-ups - Phase 2 will surface concrete WebAuthn4J failure-to-`*Result` mappings. -- A future ADR may revisit attestation metadata (MDS3) integration — WebAuthn4J supplies the hook but pk-auth defers the implementation per brief §7. +- A future ADR may revisit attestation metadata (MDS3) integration. WebAuthn4J supplies the hook, + and pk-auth defers the implementation per brief §7. diff --git a/docs/adr/0003-jdbi-over-jpa.md b/docs/adr/0003-jdbi-over-jpa.md index bc2620eb..8dddc809 100644 --- a/docs/adr/0003-jdbi-over-jpa.md +++ b/docs/adr/0003-jdbi-over-jpa.md @@ -10,25 +10,42 @@ Accepted. pk-auth needs a SQL persistence option for sites running Postgres. The mainstream JVM choices are: -- **JDBI 3** — thin SQL-builder-and-mapper layer over JDBC. No proxying, no lazy loading, no entity manager. Resource use is predictable; SQL is visible at the call site. -- **JPA (Hibernate / EclipseLink / OpenJPA)** — full ORM with managed entities, transactions, lazy associations, schema-from-annotations. Powerful, but introduces a vocabulary and a runtime cost pk-auth doesn't need. -- **Spring Data JPA / Spring Data JDBC** — repository abstractions on top of JPA / JDBC. Coupled to Spring; we want adapter-neutral SPIs. -- **MyBatis** — middle ground; XML/annotation-driven SQL with mapping. Less idiomatic than JDBI in modern Java. - -The build brief is explicit (§3): "JDBI 3 + Flyway against PostgreSQL. **No Hibernate, no JPA, no Spring Data JPA, no Micronaut Data JPA.**" §6.6 reinforces. +- JDBI 3 is a thin SQL-builder-and-mapper layer over JDBC. It has no proxying, no lazy loading, and + no entity manager. Resource use is predictable, and SQL is visible at the call site. +- JPA (Hibernate, EclipseLink, OpenJPA) is a full ORM with managed entities, transactions, lazy + associations, and schema-from-annotations. It is powerful, but introduces a vocabulary and a + runtime cost pk-auth does not need. +- Spring Data JPA and Spring Data JDBC are repository abstractions on top of JPA and JDBC. They are + coupled to Spring, while pk-auth needs adapter-neutral SPIs. +- MyBatis is a middle ground, with XML- or annotation-driven SQL and mapping. It is less idiomatic + than JDBI in modern Java. + +The build brief is explicit (§3): "JDBI 3 + Flyway against PostgreSQL. No Hibernate, no JPA, no +Spring Data JPA, no Micronaut Data JPA." §6.6 repeats the constraint. ## Decision -`pk-auth-persistence-jdbi` uses JDBI 3 with Flyway for migrations. Repository implementations write raw, parameterized SQL against the Phase 5 schema and translate rows to `CredentialRecord` / `ChallengeRecord` via hand-written `RowMapper`s. No annotations on the records; the persistence concern stays in the persistence module. +`pk-auth-persistence-jdbi` uses JDBI 3 with Flyway for migrations. Repository implementations write +raw, parameterised SQL against the Phase 5 schema and translate rows to `CredentialRecord` / +`ChallengeRecord` via hand-written `RowMapper`s. No annotations on the records; the persistence +concern stays in the persistence module. ## Consequences -- **Positive — predictability.** Every query is visible. No magic SQL emitted by an ORM. The audit-friendliest stance for an auth-layer module. -- **Positive — minimal runtime surface.** pk-auth-persistence-jdbi adds only JDBI 3, the Postgres JDBC driver, HikariCP (connection pool), and slf4j-api as runtime dependencies; Flyway is implementation-scoped and used at startup. -- **Positive — no JPA-style entity-manager threading caveats.** Repository methods are stateless; the brief's "no reflection in hot paths" stance (§11) holds. -- **Negative — no automatic schema generation.** Every change to a `CredentialRecord` / `ChallengeRecord` field needs a new Flyway migration. We accept this — auth-data migrations should be deliberate. -- **Negative — no entity-graph fetch.** pk-auth's data is shallow (no relationships across repositories), so this is fine for now. If a future feature needs a join, we write it. +- Positive, predictability: every query is visible, and no ORM emits hidden SQL. This suits an + auth-layer module that is subject to audit. +- Positive, minimal runtime surface: `pk-auth-persistence-jdbi` adds only JDBI 3, the Postgres JDBC + driver, HikariCP (connection pool), and slf4j-api as runtime dependencies. Flyway is + implementation-scoped and runs at startup. +- Positive, no JPA-style entity-manager threading caveats: repository methods are stateless, and the + brief's "no reflection in hot paths" stance (§11) holds. +- Negative, no automatic schema generation: every change to a `CredentialRecord` or + `ChallengeRecord` field needs a new Flyway migration. Each auth-data migration is an explicit + step. +- Negative, no entity-graph fetch: pk-auth's data is shallow (no relationships across repositories), + so the gap has no current effect. A future feature that needs a join writes it by hand. ## Open follow-ups -- A `pk-auth-persistence-jpa` module is **explicitly excluded** for v0.x (brief §13). If demand surfaces post-1.0, a separate ADR will weigh adding it. +- A `pk-auth-persistence-jpa` module is excluded for v0.x (brief §13). If demand surfaces post-1.0, + a separate ADR will weigh adding it. diff --git a/docs/adr/0004-dagger-for-dropwizard.md b/docs/adr/0004-dagger-for-dropwizard.md index 414d250e..ac681b84 100644 --- a/docs/adr/0004-dagger-for-dropwizard.md +++ b/docs/adr/0004-dagger-for-dropwizard.md @@ -9,26 +9,24 @@ Accepted. ## Context The Dropwizard adapter (`pk-auth-dropwizard`, Phase 9) must wire pk-auth's framework-neutral -services — `PasskeyAuthenticationService`, the JWT issuer/validator, the optional `AdminService`, -the pk-auth services and JWT types — into Jersey resources and a Dropwizard `ConfiguredBundle`. Dropwizard -itself does not ship an opinionated DI container. The realistic options were: - -1. **Hand-rolled wiring.** A `PkAuthBundle` constructor that news everything up directly. -2. **Guice + dropwizard-guice.** Familiar to many Dropwizard users, runtime reflection, - `@Inject` everywhere. -3. **HK2.** Jersey's own DI container. Reflection-based, surfaces in error messages in confusing - ways, magical scoping. -4. **Dagger 2.** Compile-time annotation-processed DI; the generated component is a plain Java - class you can read. - -The brief (`docs/history/pk-auth-build-brief.md` §3) calls out Dagger explicitly: - -> Spring Boot → Spring DI. Micronaut → Micronaut DI. **Dropwizard → Dagger 2 (compile-time, -> annotation-processed).** Do not use Guice or HK2 except where Jersey itself requires HK2 wiring +services (`PasskeyAuthenticationService`, the JWT issuer and validator, and the optional +`AdminService`) into Jersey resources and a Dropwizard `ConfiguredBundle`. Dropwizard itself does +not ship an opinionated DI container. The realistic options were: + +1. Hand-rolled wiring: a `PkAuthBundle` constructor that news everything up directly. +2. Guice with dropwizard-guice: familiar to many Dropwizard users, uses runtime reflection, and + needs `@Inject` everywhere. +3. HK2: Jersey's own DI container. It is reflection-based, surfaces in error messages in confusing + ways, and uses magical scoping. +4. Dagger 2: compile-time, annotation-processed DI. The generated component is a plain Java class + that can be read directly. + +The brief (`docs/history/pk-auth-build-brief.md` §3) names Dagger explicitly: + +> Spring Boot → Spring DI. Micronaut → Micronaut DI. Dropwizard → Dagger 2 (compile-time, +> annotation-processed). Do not use Guice or HK2 except where Jersey itself requires HK2 wiring > under the hood. -This ADR records the rationale. - ## Decision Use Dagger 2 (`com.google.dagger:dagger:2.59.x` with `dagger-compiler` on the @@ -38,74 +36,75 @@ object and an optional `AdminService`, and the bundle internally builds a `PkAut larger `PkAuthFullComponent` when the alt-flow modules are auto-wired) whose provision methods Jersey resources consume. -The Dagger module structure is intentionally small: +The Dagger module structure is small: -- `PkAuthModule` — provides `PasskeyAuthenticationService`, `JwtConfig` / `JwtKeyset` / - `PkAuthJwtIssuer` / `PkAuthJwtValidator`, `PkAuthDropwizardAuthenticator`, and the - `PkAuthCeremonyResource`. Bound to the runtime `PkAuthConfig` block via constructor injection +- `PkAuthModule` provides `PasskeyAuthenticationService`, `JwtConfig`, `JwtKeyset`, + `PkAuthJwtIssuer`, `PkAuthJwtValidator`, `PkAuthDropwizardAuthenticator`, and the + `PkAuthCeremonyResource`. It binds to the runtime `PkAuthConfig` block via constructor injection on the module itself. -- `PkAuthComponent` — the `@Component` whose provision methods expose what the bundle hands to +- `PkAuthComponent` is the `@Component` whose provision methods expose what the bundle hands to Jersey: `ceremonyResource()`, `passkeyAuthenticator()`, `jwtIssuer()`, `jwtValidator()`, `userDeletionService()`, and `refreshHandler()`. -- The optional admin path (when `pk-auth-admin-api` is on the classpath): when the alt-flow modules - are auto-wired the bundle builds `PkAuthFullComponent` (`PkAuthModule` + `AltFlowsModule`), which - adds `adminResource()`; when a host supplies its own `AdminService` the bundle instantiates - `PkAuthAdminResource` directly. Either way the admin module's compile-time dependency stays - optional. +- The optional admin path applies when `pk-auth-admin-api` is on the classpath. When the alt-flow + modules are auto-wired, the bundle builds `PkAuthFullComponent` (`PkAuthModule` plus + `AltFlowsModule`), which adds `adminResource()`. When a host supplies its own `AdminService`, the + bundle instantiates `PkAuthAdminResource` directly. In both cases the admin module's compile-time + dependency stays optional. -Generated classes live in `com.codeheadsystems.pkauth.dropwizard.dagger` and are excluded from -the JaCoCo coverage report (they're auto-generated boilerplate that should not skew the -adapter-tier ≥70% gate). +Generated classes live in `com.codeheadsystems.pkauth.dropwizard.dagger` and are excluded from the +JaCoCo coverage report, because auto-generated boilerplate would skew the adapter-tier 70% gate. ## Consequences ### Positive -- **Compile-time validation.** Missing bindings, cycles, and duplicate providers are caught by - the annotation processor and surface as ordinary Java compile errors. Compare with Guice - (`CreationException` thrown at injector boot) and HK2 (silent fallback to no-op providers). -- **No runtime reflection.** Dagger generates a plain `DaggerPkAuthComponent` class that - `new`s the dependency graph in straight-line code. Easy to read; easy to step through in a - debugger; zero classpath scanning overhead at startup. -- **Smaller runtime footprint.** No Guice / Spring / HK2 jar on the consumer's classpath beyond - the ~50 KB `dagger` runtime. -- **No HK2 magic in our code.** Dropwizard still wires Jersey's HK2 internally — we can't avoid - that — but pk-auth itself does not register anything via HK2. The seam is contained. -- **Clean public API.** Host applications interact with `PkAuthBundle`, `PersistenceBindings`, - and the four public records. They never see a `Component` or `@Module` annotation in their - call sites. +- Validation happens at compile time. The annotation processor catches missing bindings, cycles, + and duplicate providers, and reports them as ordinary Java compile errors. Guice throws a + `CreationException` at injector boot, and HK2 falls back silently to no-op providers. +- Dagger uses no runtime reflection. It generates a plain `DaggerPkAuthComponent` class that + `new`s the dependency graph in straight-line code. The code is easy to read and to step through + in a debugger, and startup has no classpath-scanning overhead. +- The runtime footprint is small. No Guice, Spring, or HK2 jar reaches the consumer's classpath + beyond the roughly 50 KB `dagger` runtime. +- pk-auth's own code contains no HK2 wiring. Dropwizard still wires Jersey's HK2 internally, but + pk-auth does not register anything via HK2, so the seam is contained. +- The public API is clean. Host applications interact with `PkAuthBundle`, `PersistenceBindings`, + and the four public records. A `Component` or `@Module` annotation never appears in their call + sites. ### Negative -- **Annotation processor in the build.** Spotless / Error Prone interact with the - Dagger-generated sources. We mitigate by excluding `Dagger*` and `*_Factory*` patterns from - both the JaCoCo report and the spotless target. -- **No runtime override.** Tests cannot just swap a binding the way Guice's `Modules.override` - allows; they need a separate Dagger module variant. For pk-auth this is fine because - `PersistenceBindings` already centralizes the SPI bag, so the in-memory testkit wiring works - through the same component. -- **Static graph.** Dynamic features (e.g. swapping the JWT keyset at runtime) need a layer of - indirection (a `Supplier` provider). The same pattern works in Guice but is more - obvious there. +- The build gains an annotation processor. Spotless and Error Prone interact with the + Dagger-generated sources. The build mitigates this by excluding `Dagger*` and `*_Factory*` + patterns from both the JaCoCo report and the Spotless target. +- Bindings cannot be overridden at runtime. Tests cannot swap a binding the way Guice's + `Modules.override` allows; they need a separate Dagger module variant. For pk-auth the effect is + small because `PersistenceBindings` already centralises the SPI bag, so the in-memory testkit + wiring works through the same component. +- The graph is static. Dynamic features (for example swapping the JWT keyset at runtime) need a + layer of indirection, such as a `Supplier` provider. The same pattern works in Guice, + where it is more obvious. ### Neutral -- **Dagger 2 vs Dagger Hilt vs Anvil.** Hilt is Android-specific; Anvil targets Kotlin. Plain - Dagger 2 is the right tool for a JVM library. -- **Future Java module system.** Dagger generates code that uses `dagger.internal.Provider` etc.; - if pk-auth-dropwizard ever ships a `module-info.java`, those packages need to be reachable. - Not a blocker for v0.x — neither the JDBI nor the admin-api modules ship a module-info either. +- Dagger Hilt is Android-specific and Anvil targets Kotlin, so plain Dagger 2 is the fit for a JVM + library. +- Dagger generates code that uses `dagger.internal.Provider` and similar types. If + `pk-auth-dropwizard` ever ships a `module-info.java`, those packages need to be reachable. This + does not block v0.x, because neither the JDBI module nor the admin-api module ships a + `module-info.java` either. ## Alternatives considered -| Option | Why we said no | +| Option | Reason rejected | |---|---| -| Hand-rolled `new`s in the bundle | Works for the current ~10-binding graph but becomes brittle as the admin / persistence wirings expand. Dagger gives us the same code-shape with compile-time checking for free. | -| Guice | Runtime reflection, surfaces injection errors at boot instead of compile, drags in a heavier runtime, conflicts with HK2's class loader expectations in some Jersey setups. | -| HK2 directly | Jersey's own DI container, but its error messages are notoriously opaque and Dropwizard explicitly recommends against using it as the host-application DI. | +| Hand-rolled `new`s in the bundle | Works for the current graph of about 10 bindings but becomes brittle as the admin and persistence wirings expand. Dagger gives the same code shape with compile-time checking. | +| Guice | Uses runtime reflection, surfaces injection errors at boot instead of compile time, drags in a heavier runtime, and conflicts with the HK2 class loader expectations in some Jersey setups. | +| HK2 directly | Jersey's own DI container, but its error messages are opaque and Dropwizard recommends against using it as the host-application DI. | ## References - Brief §3 (non-negotiable tech choices) and §6.11 (Dropwizard module brief). - [Dagger 2 documentation](https://dagger.dev/dev-guide/). -- Phase 9 implementation lives in `pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/`. +- The Phase 9 implementation lives in + `pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/`. diff --git a/docs/adr/0005-stateless-jwt-default.md b/docs/adr/0005-stateless-jwt-default.md index 8072cbd3..28bc44c5 100644 --- a/docs/adr/0005-stateless-jwt-default.md +++ b/docs/adr/0005-stateless-jwt-default.md @@ -8,27 +8,25 @@ Accepted. ## Context -A successful WebAuthn ceremony (registration or assertion) needs to leave the caller -with something it can present on the next HTTP request. The two mainstream JVM choices are: +A successful WebAuthn ceremony (registration or assertion) must leave the caller with something +it can present on the next HTTP request. The two mainstream JVM choices are: -- **A framework-native HTTP session** (Spring's `HttpSession`, Dropwizard's optional Jetty - session, Micronaut's session module). Server-side state; the cookie carries an opaque - session id. -- **A signed, short-TTL JWT** issued by `pk-auth-jwt` (HS256 by default, ES256 when key - rotation matters). Stateless; the bearer carries the claims. +- A framework-native HTTP session (Spring's `HttpSession`, Dropwizard's optional Jetty session, + Micronaut's session module). State lives on the server, and the cookie carries an opaque session + id. +- A signed, short-TTL JWT issued by `pk-auth-jwt` (HS256 by default, ES256 when key rotation + matters). The mechanism is stateless, and the bearer carries the claims. The build brief is explicit (§4.4): "A successful passkey ceremony issues a signed JWT via `pk-auth-jwt`. No HTTP session is required. Adapters MAY bridge to framework-native -sessions for users who want that, but the default is stateless JWT." This ADR records -the why. +sessions for users who want that, but the default is stateless JWT." -pk-auth's deployment surface — three framework adapters (Spring Boot, Dropwizard, -Micronaut), two persistence backends (Postgres, DynamoDB), and a single TypeScript SDK -that talks to all of them — makes a session-affinity story expensive: a session store -would need its own adapter per persistence backend (`SessionStore` SPI, `JdbiSessionStore`, -`DynamoDbSessionStore`), its own TTL machinery, its own clustering story for the -adapter-specific session managers, and a uniform extraction path that every demo app -re-wires. A JWT defers all of that to the validator. +pk-auth's deployment surface (three framework adapters: Spring Boot, Dropwizard, and Micronaut; two +persistence backends: Postgres and DynamoDB; and a single TypeScript SDK that talks to all of them) +makes a session-affinity story expensive. A session store would need its own adapter per persistence +backend (`SessionStore` SPI, `JdbiSessionStore`, `DynamoDbSessionStore`), its own TTL machinery, its +own clustering story for the adapter-specific session managers, and a uniform extraction path that +every demo app re-wires. A JWT defers all of that to the validator. ## Decision @@ -40,49 +38,45 @@ method in `pkauth.method`. The Spring Boot starter: response body. - Registers a JWT validation filter that produces a `JwtAuthenticationToken` for any request bearing a valid `Authorization: Bearer `. -- Does **not** create an `HttpSession` and does not configure `SecurityContextRepository` +- Does not create an `HttpSession` and does not configure `SecurityContextRepository` to persist authentication between requests. The Dropwizard and Micronaut adapters mirror this when they land in Phase 9 and Phase 10. -Adapters MAY expose an opt-in `sessionBridge` flag (the brief allows it). The default in -every shipped demo, however, is stateless JWT. +Adapters may expose an opt-in `sessionBridge` flag (the brief allows it). The default in every +shipped demo is stateless JWT. ## Consequences -- **Positive — horizontal scaling is free.** Two app instances behind a load balancer - validate the same JWT identically; no sticky sessions, no session-replication module. -- **Positive — uniform token across SDKs.** The TypeScript SDK in `clients/passkeys-browser` - treats the post-ceremony token as a string regardless of which framework adapter served - it. No framework-specific session cookie semantics leak into the browser layer. -- **Positive — adapter parity stays cheap.** Dropwizard and Micronaut implement the same - "mint a token, register a validator filter" pattern. No need for three different session - configurations. -- **Negative — revocation is harder.** A short TTL (default 1 hour) bounds the blast radius - but doesn't eliminate it. A future denylist SPI on the `ChallengeStore` (or a sibling - store) can plug this if we need true logout; the brief calls it out as a non-goal for v0.x. - *(Shipped in 1.1.0 as the opt-in `AccessTokenStore` SPI — stateful, revocable-before-`exp` - access tokens; the default stays the stateless no-op. See ADR 0015.)* -- **Negative — refresh-token machinery is not provided.** Hosts that need long-lived - sessions must either accept re-authentication on TTL expiry or layer their own refresh - flow on top. pk-auth's scope is the credential layer, not session management. - *(Shipped in 1.1.0 as the `pk-auth-refresh-tokens` module — rotating refresh tokens with - family-based replay defense. See ADR 0013.)* -- **Neutral — key rotation is a separate concern.** `JwtKeyset` already supports a current - signing key plus a list of retired verification keys. Rotating the signing key invalidates - no outstanding tokens; rotating it AND removing the retired key invalidates everything - issued before the rotation. +- Positive, horizontal scaling: two app instances behind a load balancer validate the same JWT + identically, with no sticky sessions and no session-replication module. +- Positive, a uniform token across SDKs: the TypeScript SDK in `clients/passkeys-browser` treats + the post-ceremony token as a string regardless of which framework adapter served it. No + framework-specific session cookie semantics leak into the browser layer. +- Positive, cheap adapter parity: Dropwizard and Micronaut implement the same "mint a token, + register a validator filter" pattern, so three different session configurations are unnecessary. +- Negative, harder revocation: a short TTL (default 1 hour) bounds the blast radius but does not + eliminate it. A future denylist SPI on the `ChallengeStore` (or a sibling store) can provide true + logout if it becomes necessary; the brief lists it as a non-goal for v0.x. Shipped in 1.1.0 as + the opt-in `AccessTokenStore` SPI (stateful access tokens, revocable before `exp`); the default + remains the stateless no-op. See ADR 0015. +- Negative, no refresh-token machinery: hosts that need long-lived sessions either accept + re-authentication on TTL expiry or layer their own refresh flow on top. pk-auth's scope is the + credential layer, not session management. Shipped in 1.1.0 as the `pk-auth-refresh-tokens` module + (rotating refresh tokens with family-based replay defence). See ADR 0013. +- Neutral, key rotation as a separate concern: `JwtKeyset` supports a current signing key plus a + list of retired verification keys. Rotating the signing key invalidates no outstanding tokens; + rotating it and also removing the retired key invalidates everything issued before the rotation. ## Open follow-ups -> **Resolved in 1.1.0.** Both deferred items below have since shipped. The stateless-JWT -> default in this ADR still stands — the new capabilities are opt-in and leave the default -> behavior unchanged. +> Both deferred items below shipped in 1.1.0. The stateless-JWT default remains in force; the new +> capabilities are opt-in and leave the default behaviour unchanged. -- ~~A token-revocation SPI is deferred.~~ Shipped as `AccessTokenStore` (ADR 0015) — an - allow-list (`record` / `exists` / `delete` / `deleteAllForUser` / `deleteExpiredBefore`) - rather than the originally-sketched `RevokedTokenStore` deny-list. The validator consults - it after signature validation; the default no-op store preserves stateless behavior. +- ~~A token-revocation SPI is deferred.~~ Shipped as `AccessTokenStore` (ADR 0015): an allow-list + (`record` / `exists` / `delete` / `deleteAllForUser` / `deleteExpiredBefore`) rather than the + originally sketched `RevokedTokenStore` deny-list. The validator consults it after signature + validation, and the default no-op store preserves stateless behaviour. - ~~Refresh-token issuance is out of scope.~~ Shipped as the `pk-auth-refresh-tokens` module - (ADR 0013): rotating refresh tokens with family-based replay defense, exposed via + (ADR 0013): rotating refresh tokens with family-based replay defence, exposed via `POST /auth/refresh`. diff --git a/docs/adr/0006-userlookup-spi-not-owned.md b/docs/adr/0006-userlookup-spi-not-owned.md index 381d03af..c3604fa7 100644 --- a/docs/adr/0006-userlookup-spi-not-owned.md +++ b/docs/adr/0006-userlookup-spi-not-owned.md @@ -8,53 +8,45 @@ Accepted. ## Context -pk-auth needs a stable mapping from `(username, email)` to `UserHandle` (the -WebAuthn user identifier — a stable, opaque byte string). Two designs were on -the table: - -1. **Own the user table.** pk-auth ships `pkauth_users (user_handle, username, - email, display_name, created_at)`, owns the writes, and exposes a CRUD admin - surface. Host apps query it for additional user data via FK. -2. **Treat user lookup as an SPI.** pk-auth declares the - `UserLookup` interface (lookup by username / email / handle, plus - "find-or-create" semantics for first-passkey registration). The host app +pk-auth needs a stable mapping from `(username, email)` to `UserHandle` (the WebAuthn user +identifier, a stable, opaque byte string). Two designs were on the table: + +1. Own the user table. pk-auth ships `pkauth_users (user_handle, username, email, display_name, + created_at)`, owns the writes, and exposes a CRUD admin surface. Host apps query it for + additional user data via FK. +2. Treat user lookup as an SPI. pk-auth declares the `UserLookup` interface (lookup by username, + email, or handle, plus "find-or-create" semantics for first-passkey registration). The host app implements it against its own user store. -Option 1 is friendlier for greenfield demos but pushes pk-auth into the -data-ownership business: the host app inevitably has its own user table with -columns pk-auth doesn't know about (preferences, role bindings, billing IDs). -Replicating that data — or making pk-auth's users authoritative — locks the host -into pk-auth's schema choices and creates a one-way migration trap. +Option 1 is friendlier for greenfield demos but pushes pk-auth into the data-ownership business. +The host app inevitably has its own user table with columns pk-auth does not know about +(preferences, role bindings, billing IDs). Replicating that data, or making pk-auth's users +authoritative, locks the host into pk-auth's schema choices and creates a one-way migration trap. -Option 2 mirrors the rest of pk-auth's architecture (every persistence concern -is an SPI; `CredentialStore`, `ChallengeStore`, `BackupCodeStore`, etc.). It also -matches how IdPs like Keycloak and OIDC providers treat user federation: the -identity provider owns "what is a credential" and delegates "what is a user" to -the system that already has answers. +Option 2 mirrors the rest of pk-auth's architecture, where every persistence concern is an SPI +(`CredentialStore`, `ChallengeStore`, `BackupCodeStore`, and so on). It also matches how IdPs such +as Keycloak and OIDC providers treat user federation: the identity provider owns "what is a +credential" and delegates "what is a user" to the system that already has answers. ## Decision -`UserLookup` is an SPI in `pk-auth-core`. The testkit ships an `InMemoryUserLookup` -for demos and tests; production hosts implement it against their existing user -store. The pk-auth admin API and ceremony flows route every "who is this?" call -through the SPI. +`UserLookup` is an SPI in `pk-auth-core`. The testkit ships an `InMemoryUserLookup` for demos and +tests; production hosts implement it against their existing user store. The pk-auth admin API and +ceremony flows route every "who is this?" call through the SPI. -The `UserHandle` mapping itself (handle ↔ username) is the host's -responsibility too — the host returns the same `UserHandle` for the same -`username` across calls, otherwise WebAuthn breaks. A typical implementation -stores `user_handle` as a `BYTEA` column on the host's `users` table with a +The `UserHandle` mapping itself (handle to username) is also the host's responsibility. The host +returns the same `UserHandle` for the same `username` across calls; otherwise WebAuthn breaks. A +typical implementation stores `user_handle` as a `BYTEA` column on the host's `users` table with a unique index. ## Consequences -- **Pro**: pk-auth never holds the user's PII. Host apps with strict data - ownership (HIPAA / GDPR / multi-tenant) can adopt without arguing about - schema. -- **Pro**: First-passkey registration ("does this username exist? if not, - create a user with a fresh handle") is a single SPI method — atomic in the - host's store, not split across two systems. -- **Con**: Trivial demos need an in-memory `UserLookup`. The testkit ships - one; the example apps wire it. -- **Con**: Misimplemented `UserLookup` (returning different handles for the - same username across calls) silently breaks WebAuthn. Documented in the SPI's - contract and asserted by the testkit's contract tests. +- Pro: pk-auth never holds the user's PII. Host apps with strict data ownership requirements + (HIPAA, GDPR, multi-tenant) can adopt it without negotiating schema. +- Pro: first-passkey registration ("does this username exist? if not, create a user with a fresh + handle") is a single SPI method, atomic in the host's store and not split across two systems. +- Con: trivial demos need an in-memory `UserLookup`. The testkit ships one, and the example apps + wire it. +- Con: a misimplemented `UserLookup` (returning different handles for the same username across + calls) silently breaks WebAuthn. The SPI's contract documents this, and the testkit's contract + tests assert it. diff --git a/docs/adr/0007-dynamodb-local-vs-localstack.md b/docs/adr/0007-dynamodb-local-vs-localstack.md index ff56c8f6..ceda81b1 100644 --- a/docs/adr/0007-dynamodb-local-vs-localstack.md +++ b/docs/adr/0007-dynamodb-local-vs-localstack.md @@ -8,26 +8,46 @@ Accepted. ## Context -The brief (§3) leaves the DynamoDB integration-test backend choice open: "LocalStack for DynamoDB or `amazon/dynamodb-local` — pick whichever is faster on a cold CI run and document the choice in an ADR." pk-auth's persistence module needs a backend it can spin up via Testcontainers, run thousands of small operations against, and tear down cleanly. +The brief (§3) leaves the DynamoDB integration-test backend choice open: "LocalStack for DynamoDB or +`amazon/dynamodb-local`: pick whichever is faster on a cold CI run and document the choice in an +ADR." pk-auth's persistence module needs a backend it can spin up via Testcontainers, run thousands +of small operations against, and tear down cleanly. Two practical options: -- **`amazon/dynamodb-local`** — Amazon's official local emulator. JAR-based, single-purpose. The Docker image is ~250 MB. Starts in 1–3 seconds. Supports a strict subset of the production API (recent CRUD + GSI + TTL) and matches the wire format exactly. No IAM, no STS, no auto-scaling, no streams. -- **LocalStack** — multi-AWS-service emulator (DynamoDB, S3, SQS, Lambda, …). Docker image is ~1.5 GB. Starts in 5–10 seconds on a warm cache and longer cold. The DynamoDB implementation is itself a wrapper around `dynamodb-local`. Adds value when you need IAM/STS/cross-service flows. +- `amazon/dynamodb-local` is Amazon's official local emulator. It is JAR-based and single-purpose. + The Docker image is about 250 MB, and it starts in 1 to 3 seconds. It supports a strict subset of + the production API (recent CRUD, GSI, and TTL) and matches the wire format exactly. It has no IAM, + no STS, no auto-scaling, and no streams. +- LocalStack is a multi-AWS-service emulator (DynamoDB, S3, SQS, Lambda, and others). The Docker + image is about 1.5 GB, and it starts in 5 to 10 seconds on a warm cache and longer cold. Its + DynamoDB implementation is itself a wrapper around `dynamodb-local`. It adds value when a test + needs IAM, STS, or cross-service flows. ## Decision -Use `amazon/dynamodb-local:latest` via Testcontainers. pk-auth's persistence tests only exercise the DynamoDB control-plane and data-plane operations the production module uses; we never touch IAM, STS, KMS, or any other service. The smaller image, lower cold-start time, and tighter API surface make `dynamodb-local` the better fit. +Use `amazon/dynamodb-local:latest` via Testcontainers. pk-auth's persistence tests exercise only +the DynamoDB control-plane and data-plane operations the production module uses, and never touch +IAM, STS, KMS, or any other service. The smaller image, lower cold-start time, and tighter API +surface make `dynamodb-local` the better fit. -Concretely, `DynamoDbLocalFixture` starts one container per JVM (Testcontainers reuse enabled), then the `DynamoDbCeremonyIntegrationTest` creates a fresh pair of tables (random suffix) so concurrent test classes don't collide. +Concretely, `DynamoDbLocalFixture` starts one container per JVM (Testcontainers reuse enabled), then +the `DynamoDbCeremonyIntegrationTest` creates a fresh pair of tables (random suffix) so concurrent +test classes do not collide. ## Consequences -- **Positive — faster CI.** ~5–7 seconds saved on every cold persistence-test run versus LocalStack. Local dev iteration is also noticeably snappier. -- **Positive — smaller surface, fewer surprises.** dynamodb-local matches the production API more faithfully than LocalStack's reimplementation. -- **Negative — no IAM / STS / cross-service tests possible from this container.** Phase 5 does not need them; a future module that wires AWS IAM into auth would need a different fixture (LocalStack or real cloud). -- **Negative — no DynamoDB Streams.** pk-auth doesn't use streams; if a future module does, we'll need a different backend or accept the LocalStack startup cost. +- Positive, faster CI: about 5 to 7 seconds saved on every cold persistence-test run versus + LocalStack. Local dev iteration is also noticeably faster. +- Positive, a smaller surface with fewer surprises: `dynamodb-local` matches the production API more + faithfully than LocalStack's reimplementation. +- Negative, no IAM, STS, or cross-service tests from this container: Phase 5 does not need them, + and a future module that wires AWS IAM into auth would need a different fixture (LocalStack or + real cloud). +- Negative, no DynamoDB Streams: pk-auth does not use streams. If a future module does, it needs a + different backend or must accept the LocalStack startup cost. ## Open follow-ups -- If we ever add Stream-based cache invalidation or KMS-encrypted attributes, the choice gets revisited. Document with a successor ADR. +- If pk-auth adds stream-based cache invalidation or KMS-encrypted attributes, the choice is + revisited and recorded in a successor ADR. diff --git a/docs/adr/0008-dynamodb-single-table-design.md b/docs/adr/0008-dynamodb-single-table-design.md index b13d42a2..958d3612 100644 --- a/docs/adr/0008-dynamodb-single-table-design.md +++ b/docs/adr/0008-dynamodb-single-table-design.md @@ -8,13 +8,15 @@ Accepted. ## Context -The brief (§6.7, §6.7.1) calls for a single physical DynamoDB table holding every pk-auth entity (credentials, challenges, backup codes, OTP codes) plus a separate `PkAuthUsers` table for the host-app's user data. This ADR captures the why and pins down the key shapes. +The brief (§6.7, §6.7.1) calls for a single physical DynamoDB table holding every pk-auth entity +(credentials, challenges, backup codes, OTP codes) plus a separate `PkAuthUsers` table for the +host-app's user data. ## Decision ### Two tables -| Table | Purpose | Why separate? | +| Table | Purpose | Reason for separation | |---|---|---| | `PkAuthCore` | Credentials, challenges, and (Phase 6) backup codes + OTP codes. | These are all pk-auth-owned entities; co-locating them in one table cuts the per-request control-plane overhead and supports cross-entity access patterns at a single user handle. | | `PkAuthUsers` | Username ↔ user-handle lookup with the host-app's user attributes. | Users are host-app data (brief §6.6, §6.7). Keeping the table separate lets host apps swap in their own user store without touching the single-table; their `UserLookup` implementation simply targets a different table. | @@ -24,7 +26,7 @@ The brief (§6.7, §6.7.1) calls for a single physical DynamoDB table holding ev | Item | `pk` | `sk` | GSI1 (`gsi1-credential-by-id`) | Notes | |---|---|---|---|---| | Credential | `USER#{userHandleB64}` | `CRED#{credentialIdB64}` | `gsi1pk=CRED#{credentialIdB64}`, `gsi1sk=META` | One row per registered passkey. GSI1 supports the assertion-time "find credential by id" path where the user handle is not yet known. | -| Challenge | `CHAL#{challengeId}` | `META` | — | TTL attribute set so DynamoDB evicts expired challenges. `takeOnce` is `DeleteItem` with `ReturnValues=ALL_OLD` for atomic single-use. | +| Challenge | `CHAL#{challengeId}` | `META` | None | TTL attribute set so DynamoDB evicts expired challenges. `takeOnce` is `DeleteItem` with `ReturnValues=ALL_OLD` for atomic single-use. | Phase 6 (shipped): @@ -33,7 +35,8 @@ Phase 6 (shipped): | BackupCode | `USER#{userHandleB64}` | `BACKUP#{codeId}` | | OtpCode | `USER#{userHandleB64}` | `OTP#{otpId}` | -These piggyback on the same table. Listing a user's backup codes is a single `Query` with `sk begins_with BACKUP#`. +These piggyback on the same table. Listing a user's backup codes is a single `Query` with +`sk begins_with BACKUP#`. ### Separate `PkAuthUsers` table @@ -41,7 +44,7 @@ These piggyback on the same table. Listing a user's backup codes is a single `Qu |---|---|---| | `pk` | `String` (HASH) | `USER#{userHandleB64}` | | `sk` | `String` (RANGE) | `META` (room to expand later) | -| `gsi1pk` | `String` (GSI HASH) | `USERNAME#{usernameLowercase}` — case-folded so username lookups are predictable. | +| `gsi1pk` | `String` (GSI HASH) | `USERNAME#{usernameLowercase}`, case-folded so username lookups are predictable. | | `gsi1sk` | `String` (GSI RANGE) | `META` | | `userHandle` | `String` | Same value as `pk` minus the `USER#` prefix. | | `username` | `String` | Host-app login identifier. | @@ -51,21 +54,33 @@ These piggyback on the same table. Listing a user's backup codes is a single `Qu ### Binary fields encoded as base64url strings -Per brief §6.7, every binary field (challenge bytes, credential id, COSE public key) is stored as a **base64url string** rather than a binary attribute. Reasoning: +Per brief §6.7, every binary field (challenge bytes, credential id, COSE public key) is stored as a +base64url string rather than a binary attribute. The reasons: -- Console inspection: operators can read item values in the DynamoDB console without manual base64 decoding. -- Cross-language: the same wire encoding is used on every code path (JSON in / JSON out, JWT subject, the JS SDK). -- Migration friendliness: future schema changes won't have to translate between B and S attribute types. +- Console inspection: operators can read item values in the DynamoDB console without manual base64 + decoding. +- Cross-language: the same wire encoding is used on every code path (JSON in / JSON out, JWT + subject, the JS SDK). +- Migration friendliness: future schema changes do not have to translate between B and S attribute + types. -The minor size overhead (~33%) is acceptable for pk-auth's data sizes — credential public keys are ~80 bytes, and a single challenge is 32 bytes. +The size overhead of about 33% is acceptable at pk-auth's data sizes: credential public keys are +about 80 bytes, and a single challenge is 32 bytes. ## Consequences -- **Positive — cheap user-scoped queries.** Listing a user's credentials, backup codes, or OTP codes is a single `Query` on the same partition. -- **Positive — host-app independence.** A host app that already runs DynamoDB can point `DynamoDbUserLookup` at its own existing users table by reusing the `UserItem` schema (or implementing `UserLookup` against an entirely different schema). -- **Positive — schema simplicity.** Four attribute definitions for the core table (`pk`, `sk`, `gsi1pk`, `gsi1sk`); no migrations were required when Phase 6 item types were added. -- **Negative — hot-partition risk on a single user with thousands of credentials.** Not a realistic scenario for consumer passkeys, but worth flagging. -- **Negative — GSI projection.** `gsi1-credential-by-id` projects ALL attributes; that's twice the storage cost on credential rows. We accept it because the assertion path needs the full credential to verify, and avoiding a second `GetItem` per assertion is the right trade for the latency floor. +- Positive, cheap user-scoped queries: listing a user's credentials, backup codes, or OTP codes is a + single `Query` on the same partition. +- Positive, host-app independence: a host app that already runs DynamoDB can point + `DynamoDbUserLookup` at its own existing users table by reusing the `UserItem` schema (or + implementing `UserLookup` against an entirely different schema). +- Positive, schema simplicity: the core table has four attribute definitions (`pk`, `sk`, `gsi1pk`, + `gsi1sk`), and adding the Phase 6 item types required no migrations. +- Negative, hot-partition risk on a single user with thousands of credentials: the scenario is + unrealistic for consumer passkeys. +- Negative, GSI projection: `gsi1-credential-by-id` projects ALL attributes, which doubles the + storage cost on credential rows. The assertion path needs the full credential to verify, and the + projection avoids a second `GetItem` per assertion, which lowers the latency floor. ## Open follow-ups diff --git a/docs/adr/0009-jackson-3-over-jackson-2.md b/docs/adr/0009-jackson-3-over-jackson-2.md index 2bf3ced1..9e09dbd8 100644 --- a/docs/adr/0009-jackson-3-over-jackson-2.md +++ b/docs/adr/0009-jackson-3-over-jackson-2.md @@ -8,38 +8,63 @@ Accepted. ## Context -The build brief (§3) called for Jackson 2.x throughout the core. At Phase 1 we discovered that WebAuthn4J `0.31.x` — the version line the brief also names — migrated its internal CBOR/JSON parsing to Jackson 3 (`tools.jackson.databind`). Jackson 2 and Jackson 3 sit under different root packages and can coexist on the classpath, so three options were open: +The build brief (§3) called for Jackson 2.x throughout the core. At Phase 1, WebAuthn4J `0.31.x`, +the version line the brief also names, was found to have migrated its internal CBOR/JSON parsing to +Jackson 3 (`tools.jackson.databind`). Jackson 2 and Jackson 3 sit under different root packages and +can coexist on the classpath, so three options were open: -1. Pin WebAuthn4J `0.30.3` (the last Jackson-2 release) and keep our core on Jackson 2. -2. Take WebAuthn4J `0.31.x` and ship two Jackson lineages — Jackson 2 for our DTOs, Jackson 3 transitively for WebAuthn4J. -3. Take WebAuthn4J `0.31.x` and migrate our DTOs to Jackson 3 (`tools.jackson`). +1. Pin WebAuthn4J `0.30.3` (the last Jackson-2 release) and keep the pk-auth core on Jackson 2. +2. Take WebAuthn4J `0.31.x` and ship two Jackson lineages: Jackson 2 for pk-auth DTOs, and Jackson 3 + transitively for WebAuthn4J. +3. Take WebAuthn4J `0.31.x` and migrate the pk-auth DTOs to Jackson 3 (`tools.jackson`). -The constraints that made (3) the right call: +The constraints that favour option 3: -- The brief explicitly states we want **latest stable** versions everywhere. Forgoing a WebAuthn4J minor release to keep an older Jackson contradicts that priority. -- Shipping two Jackson stacks bloats the runtime classpath and creates a continuous "which one am I using?" tax for adapter authors, persistence modules, and host applications. -- Jackson 3 has been stable since `3.0.0` (May 2025). The `tools.jackson.core:jackson-databind:3.1.x` line is production-ready. The migration cost for our DTOs is contained to one file (`PkAuthObjectMappers`) plus the test imports. -- `com.fasterxml.jackson.core:jackson-annotations` is a separate jar from databind in Jackson 3 — it stays at the classical `com.fasterxml.jackson.annotation` package and works with both Jackson 2 and 3. Our DTO annotations (`@JsonInclude`, `@JsonProperty`) do not change. +- The brief requires the latest stable versions everywhere. Forgoing a WebAuthn4J minor release to + keep an older Jackson contradicts that priority. +- Shipping two Jackson stacks bloats the runtime classpath and creates a continuous "which one is in + use?" cost for adapter authors, persistence modules, and host applications. +- Jackson 3 has been stable since `3.0.0` (May 2025). The + `tools.jackson.core:jackson-databind:3.1.x` line is production-ready. The migration cost for the + DTOs is contained to one file (`PkAuthObjectMappers`) plus the test imports. +- `com.fasterxml.jackson.core:jackson-annotations` is a separate jar from databind in Jackson 3. It + stays at the classical `com.fasterxml.jackson.annotation` package and works with both Jackson 2 + and 3, so the DTO annotations (`@JsonInclude`, `@JsonProperty`) do not change. ## Decision -`pk-auth-core` standardizes on **Jackson 3** (`tools.jackson.databind 3.1.4`). The shared `ObjectMapper` factory uses the Jackson 3 `JsonMapper.builder()` flow, `ValueSerializer` / `ValueDeserializer`, and `changeDefaultPropertyInclusion(...)` for the NON_NULL output policy. We continue to use the classical `com.fasterxml.jackson.core:jackson-annotations 2.22` artifact for annotations. +`pk-auth-core` standardises on Jackson 3 (`tools.jackson.databind 3.1.4`). The shared `ObjectMapper` +factory uses the Jackson 3 `JsonMapper.builder()` flow, `ValueSerializer` / `ValueDeserializer`, and +`changeDefaultPropertyInclusion(...)` for the NON_NULL output policy. Annotations continue to come +from the classical `com.fasterxml.jackson.core:jackson-annotations 2.22` artefact. -Jackson 3's `tools.jackson.databind` bundles `java.time` and `Jdk8` datatype support directly into databind, so the separate `jackson-datatype-jsr310` and `jackson-datatype-jdk8` dependencies are no longer required. +Jackson 3's `tools.jackson.databind` bundles `java.time` and `Jdk8` datatype support directly into +databind, so the separate `jackson-datatype-jsr310` and `jackson-datatype-jdk8` dependencies are no +longer required. ## Consequences -- **Positive:** Single Jackson stack on the runtime classpath, matching WebAuthn4J 0.31.x. -- **Positive:** Pre-existing build brief §3 wording ("Jackson 2.x") is superseded by this ADR. Future ADRs that update the brief should reference this one. -- **Negative — Dropwizard interop risk.** Dropwizard 4 currently uses Jackson 2 internally for its own request/response binding. The Dropwizard adapter module (Phase 9) will need to: - - Either run two `ObjectMapper` instances (one Jackson 2 for Dropwizard's framework path, one Jackson 3 for our pk-auth payloads), or - - Wait for Dropwizard 5 to migrate to Jackson 3, or +- Positive: a single Jackson stack on the runtime classpath, matching WebAuthn4J 0.31.x. +- Positive: the build brief §3 wording ("Jackson 2.x") is superseded by this ADR. Future ADRs that + update the brief should reference this one. +- Negative, Dropwizard interop risk: Dropwizard 4 currently uses Jackson 2 internally for its own + request/response binding. The Dropwizard adapter module (Phase 9) needs one of three strategies, + and Phase 9 explores them. Spring Boot 4 ships Jackson 3 natively, and Micronaut 4 still bundles + Jackson 2. + - Run two `ObjectMapper` instances (one Jackson 2 for Dropwizard's framework path, one Jackson 3 + for pk-auth payloads). + - Wait for Dropwizard 5 to migrate to Jackson 3. - Translate at the Jersey resource boundary. - Phase 9 will explore this. Spring Boot 4 ships Jackson 3 natively; Micronaut 4 still bundles Jackson 2. -- **Negative:** Adapter authors familiar with Jackson 2 must learn the slightly different builder API. This is well documented upstream. -- **Negative:** Some Jackson ecosystem extensions (e.g., older Afterburner, certain Money/Joda modules) had not yet released Jackson 3 jars at the time of writing. None are on pk-auth's roadmap. +- Negative: adapter authors familiar with Jackson 2 must learn the slightly different builder API, + which upstream documents well. +- Negative: some Jackson ecosystem extensions (for example older Afterburner and certain Money and + Joda modules) had not yet released Jackson 3 jars as of the ADR date. None are on pk-auth's + roadmap. ## Open follow-ups -- Phase 9 must produce a follow-up ADR documenting the concrete strategy chosen for Dropwizard's Jackson-2 internals. See [ADR 0010](0010-dropwizard-track-latest.md) for the Dropwizard versioning decision and the `PkAuthJacksonBridge` approach at the Jackson 2/3 boundary. -- The build brief `docs/history/pk-auth-build-brief.md` §3 line "Jackson 2.x" is now historical — supersede in the next brief revision. +- Phase 9 must produce a follow-up ADR documenting the concrete strategy chosen for Dropwizard's + Jackson-2 internals. See [ADR 0010](0010-dropwizard-track-latest.md) for the Dropwizard versioning + decision and the `PkAuthJacksonBridge` approach at the Jackson 2/3 boundary. +- The build brief `docs/history/pk-auth-build-brief.md` §3 line "Jackson 2.x" is now historical, and + the next brief revision supersedes it. diff --git a/docs/adr/0010-dropwizard-track-latest.md b/docs/adr/0010-dropwizard-track-latest.md index 222d62c5..dd4ebed8 100644 --- a/docs/adr/0010-dropwizard-track-latest.md +++ b/docs/adr/0010-dropwizard-track-latest.md @@ -8,54 +8,47 @@ Accepted. Supersedes the 4.x pin in brief §6.11. ## Context -The original brief mandated Dropwizard 4.x. The reason was conservative: 5.x -moved to Jetty 12 / Jersey 4 / Jakarta EE 10 and the Phase 9 adapter was -written against 4.x. The Dependabot config blocked semver-major bumps on -`io.dropwizard:*` to keep accidental upgrades from breaking the build. +The original brief mandated Dropwizard 4.x. The reason was conservative: 5.x moved to Jetty 12, +Jersey 4, and Jakarta EE 10, and the Phase 9 adapter was written against 4.x. The Dependabot config +blocked semver-major bumps on `io.dropwizard:*` to keep accidental upgrades from breaking the build. Two facts changed the calculus: -1. **The adapter's Dropwizard 5 surface is trivially clean.** A spike on - 5.0.1 compiled with no code changes — the public types we consume - (`ConfiguredBundle`, `Bootstrap`, `Environment`, `AuthDynamicFeature`, - `AuthValueFactoryProvider`, `AuthFilter`, `Authenticator`, - `AuthenticationException`, `@Auth`) all kept their package addresses across - the major. The transitive Jetty 11 → 12 / Jersey 3.1 → 4 churn lands behind - Dropwizard's own facade and never reaches the adapter. - -2. **The "deliberate framework refresh" pattern.** When Spring Boot 4 / Spring - Security 7 shipped, the project treated it as a normal upgrade rather than a - permanent pin (see git log for commit `7b59f01`). Applying the same posture - to Dropwizard keeps the codebase contemporary with upstream without inviting - surprise breaks: Dependabot proposes the bump, the build either resolves - cleanly or fails fast on a feature branch, and the team decides per major. - -The win we *don't* get on 5.x: Jackson 3 alignment. Dropwizard 5 still ships -its own Jackson 2 ObjectMapper (`com.fasterxml.jackson.core`), so -`PkAuthJacksonBridge` keeps translating between Dropwizard's mapper and -pk-auth-core's Jackson 3 types. That simplification waits for a future -Dropwizard major. +1. The adapter's Dropwizard 5 surface is clean. A spike on 5.0.1 compiled with no code changes. The + public types the adapter consumes (`ConfiguredBundle`, `Bootstrap`, `Environment`, + `AuthDynamicFeature`, `AuthValueFactoryProvider`, `AuthFilter`, `Authenticator`, + `AuthenticationException`, `@Auth`) all kept their package addresses across the major. The + transitive Jetty 11 to 12 and Jersey 3.1 to 4 churn lands behind Dropwizard's own facade and + never reaches the adapter. + +2. The framework refresh pattern is established. When Spring Boot 4 and Spring Security 7 shipped, + the project treated them as a normal upgrade rather than a permanent pin (see git log for commit + `7b59f01`). Applying the same posture to Dropwizard keeps the codebase contemporary with upstream + without inviting surprise breaks: Dependabot proposes the bump, the build either resolves cleanly + or fails fast on a feature branch, and the team decides per major. + +Dropwizard 5 does not bring Jackson 3 alignment. It still ships its own Jackson 2 ObjectMapper +(`com.fasterxml.jackson.core`), so `PkAuthJacksonBridge` keeps translating between Dropwizard's +mapper and pk-auth-core's Jackson 3 types. That simplification waits for a future Dropwizard major. ## Decision -The `pk-auth-dropwizard` adapter tracks the latest released Dropwizard. The -catalog pin is the current latest (5.0.2 at this time). The Dependabot -`io.dropwizard:* version-update:semver-major` ignore rule has been removed — -future majors will surface as PRs the same way every other dependency does. +The `pk-auth-dropwizard` adapter tracks the latest released Dropwizard. The catalog pin is the +current latest (5.0.2 at this time). The Dependabot `io.dropwizard:* version-update:semver-major` +ignore rule has been removed, and future majors surface as PRs the same way every other dependency +does. -Brief §6.11 now reads "Targets the latest released Dropwizard" rather than -"Targets Dropwizard 4.x." +Brief §6.11 now reads "Targets the latest released Dropwizard" rather than "Targets Dropwizard +4.x." ## Consequences -- **Pro**: One fewer permanent pin to defend. The codebase stays close to - upstream and benefits from the Jersey / Jetty modernization Dropwizard - ships. -- **Pro**: A clear precedent (this ADR + the Spring Boot 4 commit) makes - future framework refresh decisions repeatable. -- **Con**: Dependabot can propose a Dropwizard major that breaks - `pk-auth-dropwizard`. The mitigation is per-PR — the build will fail, the - PR stays unmerged until someone updates the adapter or chooses to defer. -- **Neutral**: The Jackson 2 bridge stays in place. Documented in the brief - and in `PkAuthJacksonBridge`'s class javadoc; this ADR is the canonical - reason for not deleting it yet. +- Pro: one fewer permanent pin to maintain. The codebase stays close to upstream and benefits from + the Jersey and Jetty modernisation Dropwizard ships. +- Pro: a clear precedent (this ADR and the Spring Boot 4 commit) makes future framework refresh + decisions repeatable. +- Con: Dependabot can propose a Dropwizard major that breaks `pk-auth-dropwizard`. The mitigation + is per PR: the build fails, and the PR stays unmerged until someone updates the adapter or + chooses to defer. +- Neutral: the Jackson 2 bridge stays in place. The brief and the class javadoc of + `PkAuthJacksonBridge` document it, and this ADR is the canonical reason for not deleting it yet. diff --git a/docs/adr/0011-spring-boot-4.md b/docs/adr/0011-spring-boot-4.md index 18a4f8e7..dc4d11f6 100644 --- a/docs/adr/0011-spring-boot-4.md +++ b/docs/adr/0011-spring-boot-4.md @@ -8,60 +8,51 @@ Accepted. ## Context -`pk-auth-spring-boot-starter` was originally written against Spring Boot 3.x / -Spring Security 6. Both projects shipped majors (Spring Boot 4.0, Spring -Security 7.0) ahead of pk-auth's first published release, and the build catalog -moved to the new majors during early development (`spring-boot = "4.0.6"`, -`spring-security = "7.0.5"` in `gradle/libs.versions.toml`). - -Two facts shaped the decision to ride the new majors rather than pin to the -3.x/6.x line: - -1. **Jackson 3 alignment.** ADR 0009 standardized `pk-auth-core` on Jackson 3 - (`tools.jackson.databind`). Spring Boot 4 ships Jackson 3 natively, which - removes the boundary translation work the Dropwizard adapter still needs - (`PkAuthJacksonBridge`). Pinning to Spring Boot 3 would have required a - second Jackson bridge or a permanent Jackson-2 carve-out in the Spring - starter. - -2. **Pre-1.0 release stance.** Per `docs/stability.md`, pk-auth is pre-1.0: - adopters pin exact versions and review release notes between bumps. There - is no support promise that would be cheaper to keep on the older Spring - majors. The "track the latest framework major" posture is already the +`pk-auth-spring-boot-starter` was originally written against Spring Boot 3.x and Spring Security 6. +Both projects shipped majors (Spring Boot 4.0, Spring Security 7.0) ahead of pk-auth's first +published release, and the build catalog moved to the new majors during early development +(`spring-boot = "4.0.6"`, `spring-security = "7.0.5"` in `gradle/libs.versions.toml`). + +Two facts shaped the decision to ride the new majors rather than pin to the 3.x/6.x line: + +1. Jackson 3 alignment. ADR 0009 standardised `pk-auth-core` on Jackson 3 + (`tools.jackson.databind`). Spring Boot 4 ships Jackson 3 natively, which removes the boundary + translation work the Dropwizard adapter still needs (`PkAuthJacksonBridge`). Pinning to Spring + Boot 3 would have required a second Jackson bridge or a permanent Jackson-2 carve-out in the + Spring starter. + +2. The pre-1.0 release stance. Per `docs/stability.md`, pk-auth is pre-1.0: adopters pin exact + versions and review release notes between bumps. No support promise exists that would be cheaper + to keep on the older Spring majors. The "track the latest framework major" posture is already the precedent for Dropwizard (ADR 0010). -The notable Spring Security 7 shift is the deprecation of -`WebSecurityConfigurerAdapter` and the move to a fully lambda / DSL-based -`SecurityFilterChain` bean — `PkAuthWebAutoConfiguration` already builds the -chain that way, so adopting Security 7 was a no-op for the starter's public +The notable Spring Security 7 shift is the deprecation of `WebSecurityConfigurerAdapter` and the +move to a fully lambda-based, DSL-based `SecurityFilterChain` bean. `PkAuthWebAutoConfiguration` +already builds the chain that way, so adopting Security 7 changed nothing in the starter's public config surface. ## Decision -The `pk-auth-spring-boot-starter` adapter tracks the latest released Spring -Boot major. Current pins are `spring-boot = 4.0.6` and `spring-security = -7.0.5`. Future majors are evaluated per-release: if the public surface the -starter exposes (`PkAuthProperties`, `SecurityFilterChain` wiring, -`PkAuthJwtAuthenticationFilter`) compiles cleanly on the new line and the -starter's tests pass, the bump lands; otherwise the bump waits behind an issue -that names the breaking change. +The `pk-auth-spring-boot-starter` adapter tracks the latest released Spring Boot major. Current pins +are `spring-boot = 4.0.6` and `spring-security = 7.0.5`. Future majors are evaluated per release. If +the public surface the starter exposes (`PkAuthProperties`, `SecurityFilterChain` wiring, +`PkAuthJwtAuthenticationFilter`) compiles cleanly on the new line and the starter's tests pass, the +bump lands. Otherwise the bump waits behind an issue that names the breaking change. ## Consequences -- **Pro**: Single Jackson stack in the Spring starter's runtime (Jackson 3 in - both pk-auth and Spring). No bridge module is needed. -- **Pro**: Adopters on the latest Spring Boot 4.x see one consistent versioned - starter rather than a back-ported variant. -- **Con**: Adopters who must stay on Spring Boot 3 cannot use the published - starter. The current pre-1.0 stance treats this as acceptable; if it becomes - a blocker, a `pk-auth-spring-boot-3-starter` companion module is the - fallback plan (no work scheduled). -- **Con**: When Spring Boot 5 lands, this ADR's "track the latest" stance - commits the project to a follow-on bump rather than a long pin. The - mitigation is the same as ADR 0010's: Dependabot proposes, the build either - resolves cleanly or fails on a feature branch, and the team decides. +- Pro: a single Jackson stack in the Spring starter's runtime (Jackson 3 in both pk-auth and + Spring). No bridge module is needed. +- Pro: adopters on the latest Spring Boot 4.x see one consistent versioned starter rather than a + back-ported variant. +- Con: adopters who must stay on Spring Boot 3 cannot use the published starter. The current + pre-1.0 stance treats this as acceptable. If it becomes a blocker, a + `pk-auth-spring-boot-3-starter` companion module is the fallback plan (no work scheduled). +- Con: when Spring Boot 5 lands, the "track the latest" stance commits the project to a follow-on + bump rather than a long pin. The mitigation is the same as in ADR 0010: Dependabot proposes, the + build either resolves cleanly or fails on a feature branch, and the team decides. ## Open follow-ups -- Document the Jackson alignment win in `docs/history/pk-auth-build-brief.md` §6.11 (the - brief still references Spring Boot 3 in places). +- Document the Jackson alignment benefit in `docs/history/pk-auth-build-brief.md` §6.11 (the brief + still references Spring Boot 3 in places). diff --git a/docs/adr/0012-micronaut-4.md b/docs/adr/0012-micronaut-4.md index b3683f67..f636d51f 100644 --- a/docs/adr/0012-micronaut-4.md +++ b/docs/adr/0012-micronaut-4.md @@ -8,48 +8,40 @@ Accepted. ## Context -`pk-auth-micronaut` was written directly against Micronaut 4.x (the build -catalog currently pins `micronaut = "4.10.25"`). The decision to skip -Micronaut 3 entirely was made at module creation: Micronaut 3 reached end of -maintenance during pk-auth's first development cycle, and adopters starting -new projects in 2026 are expected on Micronaut 4. - -Unlike Spring Boot 4 (ADR 0011), Micronaut 4 has **not** moved to Jackson 3. -The official `micronaut-jackson-databind` integration still binds Jackson 2 -(`com.fasterxml.jackson.databind`) as Micronaut's framework path. pk-auth-core -runs on Jackson 3 (ADR 0009), so the Micronaut adapter sits on a similar -Jackson-2/3 boundary as the Dropwizard adapter — pk-auth's controllers -exchange Jackson 3 trees, but the Micronaut framework binds request/response -DTOs through its own Jackson 2 mapper. `PkAuthJacksonBridge` (introduced for -Dropwizard) is reused here. +`pk-auth-micronaut` was written directly against Micronaut 4.x (the build catalog currently pins +`micronaut = "4.10.25"`). The decision to skip Micronaut 3 entirely was made at module creation: +Micronaut 3 reached end of maintenance during pk-auth's first development cycle, and adopters +starting new projects in 2026 are expected on Micronaut 4. + +Unlike Spring Boot 4 (ADR 0011), Micronaut 4 has not moved to Jackson 3. The official +`micronaut-jackson-databind` integration still binds Jackson 2 (`com.fasterxml.jackson.databind`) +as Micronaut's framework path. pk-auth-core runs on Jackson 3 (ADR 0009), so the Micronaut adapter +sits on a Jackson-2/3 boundary similar to the Dropwizard adapter's. pk-auth's controllers exchange +Jackson 3 trees, but the Micronaut framework binds request/response DTOs through its own Jackson 2 +mapper. `PkAuthJacksonBridge` (introduced for Dropwizard) is reused here. ## Decision -The `pk-auth-micronaut` adapter targets the latest released Micronaut 4.x. -The same "track latest within the current major" posture used by Dropwizard -(ADR 0010) and Spring Boot (ADR 0011) applies: Dependabot proposes the bump, -the build resolves or fails fast, and the team decides per release. +The `pk-auth-micronaut` adapter targets the latest released Micronaut 4.x. The same "track latest +within the current major" posture used by Dropwizard (ADR 0010) and Spring Boot (ADR 0011) applies: +Dependabot proposes the bump, the build resolves or fails fast, and the team decides per release. -Micronaut 5 (when it ships) will be reassessed as its own follow-up ADR — the -expected pivot will be either Jackson 3 alignment (eliminating the bridge) or -a different framework-internal mapper, either of which may motivate adapter -work. +Micronaut 5 (when it ships) is reassessed in its own follow-up ADR. The expected pivot is either +Jackson 3 alignment (eliminating the bridge) or a different framework-internal mapper, and either +may motivate adapter work. ## Consequences -- **Pro**: Adopters on Micronaut 4.x — the current LTS line — get a starter - that matches their framework version. -- **Pro**: Aligns the Micronaut adapter's stability posture with Spring Boot - and Dropwizard, so all three adapters follow the same rule and adopters can - predict pk-auth's framework-version policy. -- **Con**: The Jackson 2/3 boundary remains in the Micronaut adapter. The - bridge code is shared with Dropwizard, so the maintenance cost is not - duplicated. -- **Con**: When Micronaut 5 lands, an additional ADR and code work are - expected. No deprecation cycle is offered to adopters on Micronaut 3 — the - pre-1.0 release stance covers this. +- Pro: adopters on Micronaut 4.x, the current LTS line, get a starter that matches their framework + version. +- Pro: the Micronaut adapter's stability posture aligns with Spring Boot and Dropwizard, so all + three adapters follow the same rule and adopters can predict pk-auth's framework-version policy. +- Con: the Jackson 2/3 boundary remains in the Micronaut adapter. The bridge code is shared with + Dropwizard, so the maintenance cost is not duplicated. +- Con: when Micronaut 5 lands, an additional ADR and code work are expected. No deprecation cycle + is offered to adopters on Micronaut 3; the pre-1.0 release stance covers this. ## Open follow-ups -- Revisit if Micronaut publishes a Jackson 3 binding for 4.x — that would - remove the bridge in the Micronaut adapter without waiting for a major. +- Revisit if Micronaut publishes a Jackson 3 binding for 4.x, which would remove the bridge in the + Micronaut adapter without waiting for a major. diff --git a/docs/adr/0013-refresh-tokens-family-rotation.md b/docs/adr/0013-refresh-tokens-family-rotation.md index c7a7f0a0..71967d29 100644 --- a/docs/adr/0013-refresh-tokens-family-rotation.md +++ b/docs/adr/0013-refresh-tokens-family-rotation.md @@ -8,35 +8,30 @@ Accepted. ## Context -Through 1.0, pk-auth issued stateless access JWTs and stopped there. Real -consumers — motif at `/home/wolpert/projects/motif` is the canonical example -— hit the same wall: short-lived access tokens (15 minutes for web, longer -for CLI) need a refresh primitive so the bearer doesn't re-authenticate -every cycle. Without one in the library, every consumer rolls their own -table, their own atomicity story, and their own replay defense. Several -roll versions with subtle races that a malicious bearer can exploit. +Through 1.0, pk-auth issued stateless access JWTs and stopped there. Real consumers (motif at +`/home/wolpert/projects/motif` is the canonical example) hit the same wall: short-lived access +tokens (15 minutes for web, longer for CLI) need a refresh primitive so the bearer does not +re-authenticate every cycle. Without one in the library, every consumer rolls its own table, its own +atomicity story, and its own replay defence. Several roll versions with subtle races that a +malicious bearer can exploit. The hard parts are: -1. **Replay detection.** A leaked refresh token used after the legitimate - client has already rotated must be detectable. The detection has to be - provably atomic — a select-then-update sequence races against the legit - client and produces undefined behaviour. -2. **Response on replay.** Once detected, the entire session has to be - killed (both the attacker and the legit client must re-authenticate). - The cost is one re-auth for the user; the alternative is a leaked - session that survives the legit client's rotation. -3. **Atomic rotation.** Mark-parent-used and insert-successor must commit - together. A non-atomic sequence lets a concurrent rotator scorch the - family between mark and insert, leaving a fresh successor un-revoked - inside an otherwise-dead family. - -Motif solved this with a family model: every rotation chain shares a -`family_id` (the root token's `refresh_id`). Each rotation produces a child -in the same family with a link to the parent. Detection is a single -conditional `UPDATE`; response is a family-wide revoke. The same shape -generalises cleanly to pk-auth's other backends (DynamoDB via -`TransactWriteItems`, in-memory via a `ConcurrentHashMap.compute` block). +1. Replay detection. A leaked refresh token used after the legitimate client has already rotated + must be detectable. The detection has to be provably atomic, because a select-then-update + sequence races against the legitimate client and produces undefined behaviour. +2. The response on replay. Once a replay is detected, the entire session has to be killed, so both + the attacker and the legitimate client must re-authenticate. The cost is one re-authentication + for the user; the alternative is a leaked session that survives the legitimate client's rotation. +3. Atomic rotation. Marking the parent used and inserting the successor must commit together. A + non-atomic sequence lets a concurrent rotator scorch the family between mark and insert, leaving + a fresh successor un-revoked inside an otherwise dead family. + +Motif solved this with a family model: every rotation chain shares a `family_id` (the root token's +`refresh_id`). Each rotation produces a child in the same family with a link to the parent. +Detection is a single conditional `UPDATE`, and the response is a family-wide revoke. The same shape +generalises cleanly to pk-auth's other backends (DynamoDB via `TransactWriteItems`, in-memory via a +`ConcurrentHashMap.compute` block). ## Decision @@ -52,122 +47,115 @@ public final class RefreshTokenService { } ``` -**Wire format.** `"{refreshId}.{secret}"` where both halves are base64url -(no padding). `refreshId` is 16 random bytes (22 chars); `secret` is 32 -random bytes (43 chars). The on-the-wire string is opaque to clients — -they never need to parse it. - -**Hash-at-rest.** `SHA-256(secret)` is persisted, the raw secret is not. -Single-shot, no salt — acceptable because the input is 256 bits from -`SecureRandom` and the hash is never exposed. - -**Hash-before-mark-used.** The service hashes the presented secret and -compares against the stored row hash *before* invoking the atomic -mark-used primitive. A presented refresh-id with the wrong secret returns -`Unknown`, never `Replayed`, and never sets `used_at` on the legitimate -row. This is an operational invariant; the parity test -`wrongSecretReturnsUnknownAndDoesNotBurnLegitToken` enforces it. - -**Atomic mark-and-insert via the SPI.** The `RefreshTokenRepository` SPI -exposes `rotateAtomically(parentRefreshId, now, successor)` returning -`true` iff the parent was fresh AND the successor was inserted, atomic on +### Wire format + +The wire format is `"{refreshId}.{secret}"`, where both halves are base64url (no padding). +`refreshId` is 16 random bytes (22 chars), and `secret` is 32 random bytes (43 chars). The +on-the-wire string is opaque to clients, which never need to parse it. + +### Hash at rest + +`SHA-256(secret)` is persisted, and the raw secret is not. The hash is single-shot and unsalted. +That is acceptable because the input is 256 bits from `SecureRandom` and the hash is never exposed. + +### Hash before mark-used + +The service hashes the presented secret and compares it against the stored row hash before invoking +the atomic mark-used primitive. A presented refresh-id with the wrong secret returns `Unknown`, +never `Replayed`, and never sets `used_at` on the legitimate row. This is an operational invariant, +and the parity test `wrongSecretReturnsUnknownAndDoesNotBurnLegitToken` enforces it. + +### Atomic mark-and-insert via the SPI + +The `RefreshTokenRepository` SPI exposes `rotateAtomically(parentRefreshId, now, successor)`. It +returns `true` if and only if the parent was fresh and the successor was inserted, atomically on every backend: -- **JDBI:** `jdbi.inTransaction(...)` wrapping a single conditional - `UPDATE` on the parent and an `INSERT` for the successor. -- **DynamoDB:** `TransactWriteItems` with a `ConditionExpression` on the - parent's primary item (used_at + revoked_at attribute_not_exists, - expires_at > :now) and conditional puts for the successor's primary and - index items. -- **In-memory testkit:** `ConcurrentHashMap.compute(parentId, ...)` block - that checks the freshness predicate and inserts the successor under - `putIfAbsent` before returning the updated parent. - -**Family scorch outside the rotation.** When `rotateAtomically` returns -`false` (lost the race, or parent flipped used/revoked between read and -write), the service calls `revokeFamily(familyId, ROTATION_REPLAY)` -outside the failed-rotation scope. That revoke always commits, even if -the rotation transaction itself rolled back — losing rotators always -scorch. - -**Non-negotiable concurrent rotation race test.** A `CountDownLatch`-gated -test launches 8 threads all rotating the same root token simultaneously. -Exactly one thread returns `Success`; the other 7 return `Replayed`; the -entire family (root + the winner's successor) ends up revoked. This test -must pass against: - -- The in-memory testkit (drives the `compute` path) -- JDBI against a real Postgres container (drives the JDBI transaction - path) -- DynamoDB against DynamoDB Local (drives `TransactWriteItems`) - -The shared `RefreshTokenScenarios` class lives in `pk-auth-testkit` so -all three backends drive the same nine scenarios. - -**Sealed result type.** `RotateResult` has five variants: - -- `Success(pair, claimsForAccessIssue)` — winner's response -- `Replayed(familyId, userHandle)` — losers' response after family scorch -- `Expired()` — past-due token (no family revocation; expired is not a - replay signal) -- `Unknown()` — refresh-id not found, malformed wire token, or wrong - secret -- `Revoked(reason)` — family was previously scorched for any reason - -Adapters map `Success` → 200; the four failures → 401 with a typed -`detail` body. The browser SDK's `PkAuthClient.refresh(wireToken)` -returns a typed `RefreshResult` sum, never throws on 401. - -**Composition.** The service does not call `PkAuthJwtIssuer` itself. -`RotateResult.Success` carries a `RotatedClaims` projection (userHandle + -audience + deviceId) the caller hands to the issuer if it wants to mint a -fresh access JWT. Adapters provide a small `RefreshHandler` helper that -ties the two together; hosts that want their own access-token shape -compose differently. - -**User-deletion fan-out integration.** A `RefreshTokenServiceDeletionListener` -implements `UserDeletionListener` and calls `service.revokeAllForUser(handle, -USER_DELETED)`. Each adapter auto-registers it via the same DI mechanism -that wires the other listeners (Spring auto-collection, Dagger -`@ElementsIntoSet`, Micronaut `Collection`). +- JDBI: `jdbi.inTransaction(...)` wraps a single conditional `UPDATE` on the parent and an `INSERT` + for the successor. +- DynamoDB: `TransactWriteItems` with a `ConditionExpression` on the parent's primary item (used_at + and revoked_at attribute_not_exists, expires_at > :now) and conditional puts for the successor's + primary and index items. +- In-memory testkit: a `ConcurrentHashMap.compute(parentId, ...)` block checks the freshness + predicate and inserts the successor under `putIfAbsent` before returning the updated parent. + +### Family scorch outside the rotation + +When `rotateAtomically` returns `false` (the rotator lost the race, or the parent flipped +used/revoked between read and write), the service calls `revokeFamily(familyId, ROTATION_REPLAY)` +outside the failed-rotation scope. That revoke always commits, even if the rotation transaction +itself rolled back, so losing rotators always scorch. + +### Concurrent rotation race test + +A `CountDownLatch`-gated test launches 8 threads all rotating the same root token simultaneously. +Exactly one thread returns `Success`, the other 7 return `Replayed`, and the entire family (root +plus the winner's successor) ends up revoked. This test must pass against: + +- The in-memory testkit (drives the `compute` path). +- JDBI against a real Postgres container (drives the JDBI transaction path). +- DynamoDB against DynamoDB Local (drives `TransactWriteItems`). + +The shared `RefreshTokenScenarios` class lives in `pk-auth-testkit` so all three backends drive the +same nine scenarios. + +### Sealed result type + +`RotateResult` has five variants: + +- `Success(pair, claimsForAccessIssue)`: the winner's response. +- `Replayed(familyId, userHandle)`: the losers' response after the family scorch. +- `Expired()`: a past-due token. No family revocation occurs, because expiry is not a replay signal. +- `Unknown()`: the refresh-id was not found, the wire token was malformed, or the secret was wrong. +- `Revoked(reason)`: the family was previously scorched for any reason. + +Adapters map `Success` to 200 and the four failures to 401 with a typed `detail` body. The browser +SDK's `PkAuthClient.refresh(wireToken)` returns a typed `RefreshResult` sum and never throws on 401. + +### Composition + +The service does not call `PkAuthJwtIssuer` itself. `RotateResult.Success` carries a `RotatedClaims` +projection (userHandle, audience, and deviceId) that the caller hands to the issuer to mint a fresh +access JWT. Adapters provide a small `RefreshHandler` helper that ties the two together. Hosts that +want their own access-token shape compose differently. + +### User-deletion fan-out integration + +A `RefreshTokenServiceDeletionListener` implements `UserDeletionListener` and calls +`service.revokeAllForUser(handle, USER_DELETED)`. Each adapter auto-registers it via the same DI +mechanism that wires the other listeners (Spring auto-collection, Dagger `@ElementsIntoSet`, +Micronaut `Collection`). ## Consequences -- **Pro**: Server-revocable session model end-to-end. Consumers like - motif can delete their `RefreshTokenManager` and the supporting code - paths. -- **Pro**: The family-scorch + atomic-rotate combo provably detects - replay regardless of which side (attacker or legit client) presents - first. The concurrent race test is the proof at the persistence - boundary. -- **Pro**: Composability preserved — `RefreshTokenService` and - `PkAuthJwtIssuer` are independent. Hosts assemble them as they like. -- **Pro**: Operator-rare admin actions (logout, "log out everywhere", - user delete) all reduce to a single SPI call. -- **Con**: One database table (Postgres `refresh_tokens` / DynamoDB - `RT#/RTU#/RTF#` items). Operators need a daily cleanup cron — documented - in `docs/operator-guide.md`. -- **Con**: The DynamoDB three-item layout (primary + user-index + - family-index) writes 4 items per rotation (parent update + 3 successor - items). For high-rotation workloads this is the main cost driver — but - refresh rotations are by definition infrequent (once per access-token - lifetime) so the total throughput is small. -- **Con**: A false-positive replay (legit client's next refresh hits an - already-revoked family for any reason) requires re-authentication. The - user sees a single login prompt; the cost is one ceremony. Acceptable. +- Pro: the session model is server-revocable end to end. Consumers like motif can delete their + `RefreshTokenManager` and the supporting code paths. +- Pro: the combination of family scorch and atomic rotation provably detects replay regardless of + which side (attacker or legitimate client) presents first. The concurrent race test is the proof + at the persistence boundary. +- Pro: composability is preserved. `RefreshTokenService` and `PkAuthJwtIssuer` are independent, and + hosts assemble them as they like. +- Pro: the admin actions that operators rarely perform (logout, "log out everywhere", user delete) + all reduce to a single SPI call. +- Con: the module adds one database table (Postgres `refresh_tokens`, or DynamoDB `RT#/RTU#/RTF#` + items). Operators need a daily cleanup cron, documented in `docs/operator-guide.md`. +- Con: the DynamoDB three-item layout (primary, user-index, and family-index) writes 4 items per + rotation (parent update plus 3 successor items). For high-rotation workloads this is the main + cost driver, but refresh rotations are infrequent by definition (once per access-token lifetime), + so the total throughput is small. +- Con: a false-positive replay (the legitimate client's next refresh hits an already-revoked family + for any reason) requires re-authentication. The user sees a single login prompt, and the cost is + one ceremony. ## Open follow-ups -- A device-binding field on `RefreshTokenRecord` is reserved (the SPI - carries `Optional deviceId`) but neither the issuer nor the - refresh handler currently populates it from a typed device-id concept. - When pk-auth adds a `DeviceRegistry` SPI (post-1.1) the field becomes - load-bearing for per-device revocation. -- A `RefreshTtlPolicy` based on JWT audience parallels the access-token - `TokenTtlPolicy` (ADR 0014). They're independent; hosts can serve - web=15m-access/14d-refresh and cli=1h-access/90d-refresh from one - configuration block. -- The refresh-token row currently doesn't store the original auth method - (passkey vs backup-code vs magic-link) — the new access token minted - on rotation always carries `AuthMethod.REFRESH`. Hosts that need the - original provenance must look it up via their own session table. +- A device-binding field on `RefreshTokenRecord` is reserved (the SPI carries + `Optional deviceId`), but neither the issuer nor the refresh handler currently populates + it from a typed device-id concept. When pk-auth adds a `DeviceRegistry` SPI (post-1.1), the field + becomes load-bearing for per-device revocation. +- A `RefreshTtlPolicy` based on JWT audience parallels the access-token `TokenTtlPolicy` (ADR 0014). + The two are independent, and hosts can serve web=15m-access/14d-refresh and + cli=1h-access/90d-refresh from one configuration block. +- The refresh-token row currently does not store the original auth method (passkey, backup-code, or + magic-link). The new access token minted on rotation always carries `AuthMethod.REFRESH`. Hosts + that need the original provenance must look it up via their own session table. diff --git a/docs/adr/0014-per-audience-ttl-policy.md b/docs/adr/0014-per-audience-ttl-policy.md index d5faeb60..a1a79e7e 100644 --- a/docs/adr/0014-per-audience-ttl-policy.md +++ b/docs/adr/0014-per-audience-ttl-policy.md @@ -8,51 +8,45 @@ Accepted. ## Context -Through 1.0, `JwtConfig.tokenTtl` was a single `Duration` — every JWT issued by -`PkAuthJwtIssuer` had the same lifetime. Real multi-client deployments rarely -fit this shape. The motif consumer -(`/home/wolpert/projects/motif`) discovered the gap first: web sessions want -15-minute access tokens, CLI sessions want hour-plus tokens (one re-auth per -work session, not per coffee break), and mobile sits in between. Hosts forced -to share a single TTL either give the web client a too-long token (security -loss) or give the CLI client a too-short one (UX loss). - -Motif's `ConfigRefreshTokenPolicy` already maps a `session_kind` enum -(`web`/`ios`/`android`/`cli`) to per-kind TTLs and is consumed at issue time. -We could either: +Through 1.0, `JwtConfig.tokenTtl` was a single `Duration`, so every JWT issued by `PkAuthJwtIssuer` +had the same lifetime. Real multi-client deployments rarely fit this shape. The motif consumer +(`/home/wolpert/projects/motif`) discovered the gap first: web sessions want 15-minute access +tokens, CLI sessions want hour-plus tokens (one re-authentication per work session, not per coffee +break), and mobile sits in between. Hosts forced to share a single TTL either give the web client a +too-long token (security loss) or give the CLI client a too-short one (UX loss). + +Motif's `ConfigRefreshTokenPolicy` already maps a `session_kind` enum (`web`/`ios`/`android`/`cli`) +to per-kind TTLs and is consumed at issue time. Two designs were available: 1. Add a `Map ttlByAudience` field on `JwtConfig`. -2. Introduce a `TokenTtlPolicy` SPI with `accessTtl(audience) -> Duration` and - let hosts implement it however they want. - -The map-on-config option is simpler but locks the dispatch logic into -configuration: a host that wants tier-based TTLs ("admin sessions get 5 -minutes regardless of audience") or business-hours-aware TTLs has to either -override `PkAuthJwtIssuer` or push that logic up into their own issuer. - -The SPI keeps that knob open at the cost of introducing one more public type. -For a library whose whole job is composability, the SPI wins. Adapters bind a -default static-map implementation via factory methods (`TokenTtlPolicy.fixed(...)`, -`TokenTtlPolicy.single(...)`) so the 90% case stays one line of configuration. - -A second question is which identifier the policy is keyed on. JWT's `aud` -claim is the closest existing concept — it already identifies "who is this -token for." Motif's `session_kind` is functionally equivalent but pk-auth -shouldn't invent a new label when the standard claim covers the same axis. -Hosts pick whatever audience strings make sense for their topology; the policy -doesn't enumerate them. This means `JwtConfig.audience` (singular) is no -longer a single accepted value — it's the *default* audience plus the set -declared by the policy's `knownAudiences()`. The validator accepts the union. - -Versioning: this is a breaking shape change to `JwtConfig` and -`PkAuthProperties.Jwt` / `PkAuthConfig.Jwt` / `PkAuthConfiguration.Jwt`. It -lands in 1.1.0. Per `docs/stability.md` and the pattern set by ADRs 0010 and -0011, the project favours clean breaks at minor boundaries over carrying -compatibility shims; 1.0 hosts upgrading to 1.1 swap `Duration tokenTtl` for -`TokenTtlPolicy ttlPolicy` and rename `audience` accessors to -`defaultAudience`. The adapter records keep a backward-compatible auxiliary -constructor so hosts that don't care about per-audience TTLs see only a -parameter rename, not a positional shift. +2. Introduce a `TokenTtlPolicy` SPI with `accessTtl(audience) -> Duration` and let hosts implement + it however they want. + +The map-on-config option is simpler but locks the dispatch logic into configuration. A host that +wants tier-based TTLs ("admin sessions get 5 minutes regardless of audience") or +business-hours-aware TTLs has to either override `PkAuthJwtIssuer` or push that logic up into its +own issuer. + +The SPI keeps that knob open at the cost of introducing one more public type. For a library whose +whole job is composability, the SPI is the better fit. Adapters bind a default static-map +implementation via factory methods (`TokenTtlPolicy.fixed(...)`, `TokenTtlPolicy.single(...)`) so +the 90% case stays one line of configuration. + +A second question is which identifier the policy is keyed on. JWT's `aud` claim is the closest +existing concept, because it already identifies "who is this token for." Motif's `session_kind` is +functionally equivalent, but pk-auth uses the standard claim rather than inventing a new label. +Hosts pick whatever audience strings make sense for their topology, and the policy does not +enumerate them. As a result, `JwtConfig.audience` (singular) is no longer a single accepted value. +It is the default audience plus the set declared by the policy's `knownAudiences()`, and the +validator accepts the union. + +Versioning: this is a breaking shape change to `JwtConfig` and `PkAuthProperties.Jwt` / +`PkAuthConfig.Jwt` / `PkAuthConfiguration.Jwt`. It lands in 1.1.0. Per `docs/stability.md` and the +pattern set by ADRs 0010 and 0011, the project favours clean breaks at minor boundaries over +carrying compatibility shims. 1.0 hosts upgrading to 1.1 swap `Duration tokenTtl` for +`TokenTtlPolicy ttlPolicy` and rename `audience` accessors to `defaultAudience`. The adapter records +keep a backward-compatible auxiliary constructor, so hosts that do not use per-audience TTLs see +only a parameter rename, not a positional shift. ## Decision @@ -68,48 +62,42 @@ public interface TokenTtlPolicy { } ``` -Replace `JwtConfig.tokenTtl: Duration` with `JwtConfig.ttlPolicy: TokenTtlPolicy`. -Rename `JwtConfig.audience` to `JwtConfig.defaultAudience` and add a derived -`allowedAudiences()` accessor returning `{defaultAudience} ∪ ttlPolicy.knownAudiences()`. +Replace `JwtConfig.tokenTtl: Duration` with `JwtConfig.ttlPolicy: TokenTtlPolicy`. Rename +`JwtConfig.audience` to `JwtConfig.defaultAudience` and add a derived `allowedAudiences()` accessor +returning `{defaultAudience} ∪ ttlPolicy.knownAudiences()`. -`JwtClaims` gains an optional `audience` field. The issuer reads it; null -means "use `defaultAudience`." The validator returns the matched audience on -`JwtVerificationResult.Success.claims().audience()` so consumers can read what -the token was actually issued for. +`JwtClaims` gains an optional `audience` field. The issuer reads it, and null means "use +`defaultAudience`." The validator returns the matched audience on +`JwtVerificationResult.Success.claims().audience()` so consumers can read what the token was +actually issued for. -Spring Boot / Dropwizard / Micronaut config records each rename their -`tokenTtl` field to `defaultTtl` and add `ttlsByAudience: Map`. -Adapters build the policy via `TokenTtlPolicy.fixed(...)` when the map is -non-empty, or `TokenTtlPolicy.single(...)` otherwise. +Spring Boot, Dropwizard, and Micronaut config records each rename their `tokenTtl` field to +`defaultTtl` and add `ttlsByAudience: Map`. Adapters build the policy via +`TokenTtlPolicy.fixed(...)` when the map is non-empty, or `TokenTtlPolicy.single(...)` otherwise. ## Consequences -- **Pro**: Hosts can serve different client kinds from one issuer without - forking `PkAuthJwtIssuer`. -- **Pro**: The SPI surface is two methods. Static factories cover the common - "fixed map" and "single TTL" cases; advanced hosts can implement their own - policy (admin-session TTLs, time-of-day TTLs, A/B-test TTLs) without - touching pk-auth. -- **Pro**: The validator now accepts the union of audiences known to the - policy, which is the natural shape for multi-client deployments. A single - validator bean serves web, mobile, and CLI without separate beans per - audience. -- **Con**: Breaking change at the canonical `JwtConfig` constructor. Hosts - upgrading from 1.0 to 1.1 must update direct constructor calls (the - `JwtConfig.defaults(...)` factory and the adapter properties are unchanged - for the single-TTL case). Called out in `CHANGELOG.md`. -- **Con**: `JwtClaims` records an extra (nullable) field. The legacy 5-arg - constructor is preserved for source compatibility with code that constructs - claims directly; the canonical 6-arg constructor is the new shape. -- **Con**: Hosts that implement `TokenTtlPolicy` themselves are responsible - for keeping `knownAudiences()` consistent with what they dispatch on. - Inconsistency manifests as `WrongAudience` rejections at validation time, - which is detectable but not statically prevented. +- Pro: hosts can serve different client kinds from one issuer without forking `PkAuthJwtIssuer`. +- Pro: the SPI surface is two methods. Static factories cover the common "fixed map" and "single + TTL" cases, and advanced hosts can implement their own policy (admin-session TTLs, time-of-day + TTLs, A/B-test TTLs) without touching pk-auth. +- Pro: the validator accepts the union of audiences known to the policy, which is the natural shape + for multi-client deployments. A single validator bean serves web, mobile, and CLI without separate + beans per audience. +- Con: the canonical `JwtConfig` constructor changes incompatibly. Hosts upgrading from 1.0 to 1.1 + must update direct constructor calls (the `JwtConfig.defaults(...)` factory and the adapter + properties are unchanged for the single-TTL case). `CHANGELOG.md` records the change. +- Con: `JwtClaims` records an extra (nullable) field. The legacy 5-arg constructor is preserved for + source compatibility with code that constructs claims directly, and the canonical 6-arg + constructor is the new shape. +- Con: hosts that implement `TokenTtlPolicy` themselves are responsible for keeping + `knownAudiences()` consistent with what they dispatch on. Inconsistency manifests as + `WrongAudience` rejections at validation time, which is detectable but not statically prevented. ## Open follow-ups -- A future ADR will define a `RefreshTtlPolicy` parallel for the refresh-token - module (PR 3 in the 1.1 rollout). -- If a real consumer asks for it, expose the matched audience as a typed claim - rather than only on the validator's reconstructed `JwtClaims`. Not worth - the JWT-body bloat absent demand. +- A future ADR will define a `RefreshTtlPolicy` parallel for the refresh-token module (PR 3 in the + 1.1 rollout). +- If a real consumer asks for it, the matched audience can be exposed as a typed claim rather than + only on the validator's reconstructed `JwtClaims`. Without demand, the JWT-body bloat is not + justified. diff --git a/docs/adr/0015-stateful-access-tokens.md b/docs/adr/0015-stateful-access-tokens.md index ab49e35d..f71d111b 100644 --- a/docs/adr/0015-stateful-access-tokens.md +++ b/docs/adr/0015-stateful-access-tokens.md @@ -8,34 +8,32 @@ Accepted. ## Context -Through 1.0, pk-auth issued stateless JWTs (ADR 0005) and exposed a {@link -RevocationCheck} SPI as a deny-list escape hatch: hosts that needed early -invalidation could keep a small in-process set of "revoked jtis" and the -validator would consult it. The pattern works but doesn't match the way every -serious consumer ends up writing the code. Motif's `MotifJwtIssuer` -demonstrates the actual pattern in production use: +Through 1.0, pk-auth issued stateless JWTs (ADR 0005) and exposed a `RevocationCheck` SPI as a +deny-list escape hatch: hosts that needed early invalidation could keep a small in-process set of +"revoked jtis" and the validator would consult it. The pattern works but does not match the way +every serious consumer ends up writing the code. Motif's `MotifJwtIssuer` demonstrates the actual +pattern in production use: 1. On every issue, persist the JTI to an `access_tokens` table. 2. On every validate, look the JTI up; if absent, reject. 3. On logout, delete the JTI. 4. On user delete, delete every JTI for that user. -The deny-list shape inverts that: a *positive* allow-list means "this token -was issued by us and has not been deleted" — and "deleted" covers logout, -admin revocation, password reset, account compromise response, and user -deletion uniformly. The cost is one row per issued token plus one read per -validation. For the deployments that need revocability at all, this is the -right cost shape — small, predictable, and equivalent to a session table. +The deny-list shape inverts that. A positive allow-list means "this token was issued by pk-auth and +has not been deleted", and "deleted" covers logout, admin revocation, password reset, account +compromise response, and user deletion uniformly. The cost is one row per issued token plus one read +per validation. For the deployments that need revocability at all, this cost shape is small, +predictable, and equivalent to a session table. -The 1.1.0 release adds the positive-allow primitive without removing the -existing deny-list. Both coexist: +The 1.1.0 release adds the positive-allow primitive without removing the existing deny-list. Both +coexist: -- `RevocationCheck` — fast, in-process deny-list. Right for hosts that issue - many millions of tokens per day and only want to invalidate a tiny subset - proactively (e.g. a "session revoked" stream from a security event bus). -- `AccessTokenStore` — durable allow-list. Right for hosts that issue - meaningfully fewer tokens (admin sessions, mobile clients) and want logout - to take effect before the JWT's `exp`. This is the paved road. +- `RevocationCheck` is a fast, in-process deny-list. It suits hosts that issue many millions of + tokens per day and only want to invalidate a tiny subset proactively (for example a "session + revoked" stream from a security event bus). +- `AccessTokenStore` is a durable allow-list. It suits hosts that issue meaningfully fewer tokens + (admin sessions, mobile clients) and want logout to take effect before the JWT's `exp`. It is the + recommended path. ## Decision @@ -53,65 +51,60 @@ public interface AccessTokenStore { } ``` -`PkAuthJwtIssuer.issue(JwtClaims)` always calls `store.record(...)` after -signing and before returning the wire token. If `record` throws, issuance -fails — partial state (token returned but unrecorded) is not tolerated. +`PkAuthJwtIssuer.issue(JwtClaims)` always calls `store.record(...)` after signing and before +returning the wire token. If `record` throws, issuance fails; partial state (token returned but +unrecorded) is not tolerated. -`PkAuthJwtValidator.validate(String)` calls `store.exists(jti)` after -signature, issuer, audience, and skew checks but alongside the existing -`RevocationCheck`. A `false` return (jti not in store) maps to -`JwtVerificationResult.Revoked` — the same outcome as a deny-list hit, so -consumers don't have to learn a new sealed-result variant. +`PkAuthJwtValidator.validate(String)` calls `store.exists(jti)` after the signature, issuer, +audience, and skew checks, alongside the existing `RevocationCheck`. A `false` return (jti not in +store) maps to `JwtVerificationResult.Revoked`, the same outcome as a deny-list hit, so consumers do +not have to learn a new sealed-result variant. -The default binding is `AccessTokenStore.noop()`: `record` discards, -`exists` returns `true` for every jti, and the delete methods return zero. -This preserves stateless JWT behaviour for hosts that don't bind a real -store, so the "feature is opt-in by binding a different bean" pattern stays -clean — no `TokenMode` enum, no two-place configuration footgun. +The default binding is `AccessTokenStore.noop()`: `record` discards, `exists` returns `true` for +every jti, and the delete methods return zero. This preserves stateless JWT behaviour for hosts that +do not bind a real store, so the "feature is opt-in by binding a different bean" pattern stays +clean, with no `TokenMode` enum and no two-place configuration pitfall. -Implementations ship in `pk-auth-testkit` (in-memory), `pk-auth-persistence-jdbi` -(Postgres, Flyway V8), and `pk-auth-persistence-dynamodb` (single-table with -DynamoDB native TTL on the row's `ttl` attribute). +Implementations ship in `pk-auth-testkit` (in-memory), `pk-auth-persistence-jdbi` (Postgres, Flyway +V8), and `pk-auth-persistence-dynamodb` (single-table with DynamoDB native TTL on the row's `ttl` +attribute). Adapter wiring: -- Spring Boot starter — `@Bean AccessTokenStore` defaulting to noop, with - `@ConditionalOnMissingBean` so JDBI/Dynamo modules (or host beans) win. -- Dropwizard bundle — `PersistenceBindings.accessTokenStore()` defaults to - noop; hosts pass a real store via the builder. -- Micronaut adapter — `@Singleton AccessTokenStore` factory method, - overridable via the host's own `@Singleton` declaration. +- Spring Boot starter: `@Bean AccessTokenStore` defaulting to noop, with `@ConditionalOnMissingBean` + so the JDBI and DynamoDB modules (or host beans) take precedence. +- Dropwizard bundle: `PersistenceBindings.accessTokenStore()` defaults to noop, and hosts pass a + real store via the builder. +- Micronaut adapter: a `@Singleton AccessTokenStore` factory method, overridable via the host's own + `@Singleton` declaration. ## Consequences -- **Pro**: Server-side logout works end-to-end with no host-side code - beyond binding the JDBI/Dynamo `AccessTokenStore` bean and calling - `store.delete(userHandle, jti)` from the logout endpoint. -- **Pro**: User deletion (ADR 0016) becomes a one-call operation: - `UserDeletionService.deleteUser(handle)` fans out to every listener - including the access-token cleanup. -- **Pro**: No new sealed-result variant. `Revoked` covers both deny-list - hits and store-misses. -- **Pro**: The noop default keeps the stateless path strictly free — every - validation call still hits `exists(...)` but it's an `always-true` lambda. -- **Con**: One row per issued token. Hosts that issue at high volume - (millions/day) and don't need fast revocation should stick with - `RevocationCheck` + a small deny-list. The library does not auto-detect - this; the operator picks the binding. -- **Con**: The `ttl` field on `JwtClaims` isn't yet a first-class device-id - channel — when stateful, `record(...)` always passes `Optional.empty()` - for `deviceId`. A future ADR (when refresh tokens land) extends this - surface to bind issued JWTs to a refresh family/device for "log out this +- Pro: server-side logout works end to end with no host-side code beyond binding the JDBI or + DynamoDB `AccessTokenStore` bean and calling `store.delete(userHandle, jti)` from the logout + endpoint. +- Pro: user deletion (ADR 0016) becomes a one-call operation: + `UserDeletionService.deleteUser(handle)` fans out to every listener, including the access-token + cleanup. +- Pro: no new sealed-result variant is needed. `Revoked` covers both deny-list hits and store + misses. +- Pro: the noop default keeps the stateless path cost-free. Every validation call still hits + `exists(...)`, but it is an always-true lambda. +- Con: the store holds one row per issued token. Hosts that issue at high volume (millions per day) + and do not need fast revocation should stay with `RevocationCheck` and a small deny-list. The + library does not auto-detect this, and the operator picks the binding. +- Con: the `ttl` field on `JwtClaims` is not yet a first-class device-id channel. When stateful, + `record(...)` always passes `Optional.empty()` for `deviceId`. A future ADR (when refresh tokens + land) extends this surface to bind issued JWTs to a refresh family or device for "log out this device" granularity. -- **Con**: The validator's hot path now includes a store lookup. For the - noop case this is a hash-lookup-then-return; for JDBI it's one indexed - query per validate. Adopters needing the absolute lowest validation - latency (e.g. CDN-near edge validation) should benchmark before enabling. +- Con: the validator's hot path now includes a store lookup. For the noop case this is a + hash-lookup-then-return, and for JDBI it is one indexed query per validate. Adopters needing the + absolute lowest validation latency (for example CDN-near edge validation) should benchmark before + enabling. ## Open follow-ups -- Adding `deviceId` to `JwtClaims` and threading it through `record(...)` - becomes meaningful once PR 3 (refresh tokens) lands and device-bound - sessions are a first-class concept. -- Operator-guide entry for the daily `deleteExpiredBefore(now)` cleanup - cron — currently only documented inline. +- Adding `deviceId` to `JwtClaims` and threading it through `record(...)` becomes meaningful once + PR 3 (refresh tokens) lands and device-bound sessions are a first-class concept. +- An operator-guide entry for the daily `deleteExpiredBefore(now)` cleanup cron is pending; the + cron is currently documented only inline. diff --git a/docs/adr/0016-user-deletion-fan-out.md b/docs/adr/0016-user-deletion-fan-out.md index 8d4e34d4..c727af9b 100644 --- a/docs/adr/0016-user-deletion-fan-out.md +++ b/docs/adr/0016-user-deletion-fan-out.md @@ -8,132 +8,109 @@ Accepted. ## Context -Through 1.0, pk-auth had no first-class user-deletion primitive. Hosts -manually called `CredentialRepository.delete(...)`, `BackupCodeRepository. -deleteByUserHandle(...)`, etc. once per credential category. With the 1.1 -addition of `AccessTokenStore` (ADR 0015) and the forthcoming refresh-token -service (PR 3), the number of categories grows and a single one-call -abstraction starts to matter. - -Motif's `OwnerLifecycleService` is the existence proof: a single -`revokeAll(ownerId)` call iterates every registered -`AuthMethodRevocationListener` inside a single JDBI transaction with a -shared `Handle`, so listeners run atomically — either every credential -category is wiped or none is. - -pk-auth can't adopt the same model verbatim. Persistence in pk-auth spans -three substrates: - -- JDBI (Postgres connection pool + `Handle` transactions) -- DynamoDB (AWS SDK enhanced client; no transactional `Handle` analogue - beyond `TransactWriteItems` which is scoped to a single table at a time) -- In-memory testkit collections - -There is no shared transactional substrate across these. A motif-style -"one Handle, one transaction" model is structurally impossible without -forcing every persistence backend into a single shape, which would -preclude DynamoDB and in-memory backends. +Through 1.0, pk-auth had no first-class user-deletion primitive. Hosts manually called +`CredentialRepository.delete(...)`, `BackupCodeRepository.deleteByUserHandle(...)`, and so on once +per credential category. With the 1.1 addition of `AccessTokenStore` (ADR 0015) and the forthcoming +refresh-token service (PR 3), the number of categories grows and a single one-call abstraction +starts to matter. + +Motif's `OwnerLifecycleService` is the existence proof: a single `revokeAll(ownerId)` call iterates +every registered `AuthMethodRevocationListener` inside a single JDBI transaction with a shared +`Handle`, so listeners run atomically. Either every credential category is wiped or none is. + +pk-auth cannot adopt the same model verbatim. Persistence in pk-auth spans three substrates: + +- JDBI (Postgres connection pool and `Handle` transactions). +- DynamoDB (AWS SDK enhanced client; no transactional `Handle` analogue beyond `TransactWriteItems`, + which is scoped to a single table at a time). +- In-memory testkit collections. + +There is no shared transactional substrate across these. A motif-style "one Handle, one +transaction" model is structurally impossible without forcing every persistence backend into a +single shape, which would preclude the DynamoDB and in-memory backends. Two options remained: -1. Require every listener to participate in a coordinator-driven 2PC - protocol so partial failures roll back across substrates. -2. Run listeners sequentially, each in its own scope; tolerate partial - failure; log and report. +1. Require every listener to participate in a coordinator-driven 2PC protocol so partial failures + roll back across substrates. +2. Run listeners sequentially, each in its own scope, tolerating partial failure and logging and + reporting it. -Option 1 is correct but expensive — both to design and to operate. Real -2PC across JDBC and DynamoDB requires a transaction manager (XA) that -DynamoDB doesn't natively support, so we'd be inventing one. +Option 1 is correct but expensive, both to design and to operate. Real 2PC across JDBC and DynamoDB +requires a transaction manager (XA) that DynamoDB does not natively support, so pk-auth would have +to invent one. -Option 2 is correct enough for the use case. User deletion is an admin -operation, not a hot path. The expected operator response to a partial -failure is "look at the structured log, retry the deletion." The retry -is safe because `UserDeletionListener` implementations are required to be +Option 2 is correct enough for the use case. User deletion is an admin operation, not a hot path. +The expected operator response to a partial failure is "look at the structured log, retry the +deletion." The retry is safe because `UserDeletionListener` implementations are required to be idempotent (deleting a user with no rows is a no-op that returns zero). -The trade-off is observable: a partial deletion leaves the user with some -credential categories intact, which is a security-relevant state if not -remediated. We accept this with two mitigations: +The trade-off is observable: a partial deletion leaves the user with some credential categories +intact, which is a security-relevant state if not remediated. Two mitigations apply: -1. Every listener invocation emits a structured `pkauth.user.deletion` - log event with `outcome=ok|failed` and the failing exception. Operators - monitor the log stream. -2. `UserDeletionService.deleteUser(handle)` returns a `UserDeletionResult` - record with succeeded/failed counts and a list of failed listener names, - so callers (admin endpoints, scripts) can programmatically detect - partial failure and trigger their own retry / paging logic. +1. Every listener invocation emits a structured `pkauth.user.deletion` log event with + `outcome=ok|failed` and the failing exception. Operators monitor the log stream. +2. `UserDeletionService.deleteUser(handle)` returns a `UserDeletionResult` record with + succeeded/failed counts and a list of failed listener names, so callers (admin endpoints, + scripts) can programmatically detect partial failure and trigger their own retry or paging logic. ## Decision -Introduce `UserDeletionService` and `UserDeletionListener` in -`pk-auth-core` (`com.codeheadsystems.pkauth.lifecycle`). The service runs -listeners in the iteration order of the supplied collection, catches -`RuntimeException` per listener, logs a structured event, and returns a +Introduce `UserDeletionService` and `UserDeletionListener` in `pk-auth-core` +(`com.codeheadsystems.pkauth.lifecycle`). The service runs listeners in the iteration order of the +supplied collection, catches `RuntimeException` per listener, logs a structured event, and returns a `UserDeletionResult`. Default listener bindings shipped by the library: -- `CredentialRepositoryDeletionListener` — calls - `CredentialRepository.deleteByUserHandle(...)`. Method added to the SPI - in 1.1.0 (breaking — all implementations updated; see CHANGELOG). -- `BackupCodeRepositoryDeletionListener` — uses the existing +- `CredentialRepositoryDeletionListener` calls `CredentialRepository.deleteByUserHandle(...)`. The + method was added to the SPI in 1.1.0 (a breaking change; all implementations were updated; see + `CHANGELOG.md`). +- `BackupCodeRepositoryDeletionListener` uses the existing `BackupCodeRepository.deleteByUserHandle(...)`. -- `OtpRepositoryDeletionListener` — uses the new - `OtpRepository.deleteByUserHandle(...)` method (also breaking SPI - addition in 1.1.0). -- `AccessTokenStoreDeletionListener` — uses - `AccessTokenStore.deleteAllForUser(...)`. Harmless in stateless mode - (the noop store returns zero). - -Adapter wiring collects every `UserDeletionListener` bean into a single -service: - -- **Spring Boot starter** — `@Bean` for each library listener + a - `UserDeletionService` bean that takes `List`, - which Spring auto-populates. -- **Dropwizard bundle** — Dagger `@IntoSet` multibindings; `PkAuthModule` - contributes credential + access-token listeners, `AltFlowsModule` - contributes backup-code + OTP listeners. The slim component (no - alt-flows) gets two listeners; the full component gets four. -- **Micronaut adapter** — `@Singleton UserDeletionListener` factory - methods + a `UserDeletionService` bean injecting - `Collection`. - -Hosts add their own listeners by declaring their own bean in the -adapter's DI framework — the service picks them up via the same -collection. +- `OtpRepositoryDeletionListener` uses the new `OtpRepository.deleteByUserHandle(...)` method (also + a breaking SPI addition in 1.1.0). +- `AccessTokenStoreDeletionListener` uses `AccessTokenStore.deleteAllForUser(...)`. It is harmless + in stateless mode (the noop store returns zero). + +Adapter wiring collects every `UserDeletionListener` bean into a single service: + +- Spring Boot starter: a `@Bean` for each library listener, plus a `UserDeletionService` bean that + takes `List`, which Spring auto-populates. +- Dropwizard bundle: Dagger `@IntoSet` multibindings. `PkAuthModule` contributes the credential and + access-token listeners, and `AltFlowsModule` contributes the backup-code and OTP listeners. The + slim component (no alt-flows) gets two listeners, and the full component gets four. +- Micronaut adapter: `@Singleton UserDeletionListener` factory methods, plus a + `UserDeletionService` bean injecting `Collection`. + +Hosts add their own listeners by declaring their own bean in the adapter's DI framework, and the +service picks them up via the same collection. ## Consequences -- **Pro**: One-call user deletion across every credential category the - library manages. Hosts replace ad-hoc cleanup with - `userDeletionService.deleteUser(handle)`. -- **Pro**: New library features (refresh tokens in PR 3) integrate - automatically by adding their own `UserDeletionListener` to the same - collection. No central registry to update. -- **Pro**: Adopter-supplied listeners participate first-class — a host - with its own user-keyed table (avatar storage, app-specific session - data) wires a listener and gets it called alongside the library's. -- **Con**: Not atomic. A partial deletion leaves some credential - categories intact. Operators must monitor the structured log and retry. - We accept this; see Context for the alternative-rejected (XA across - JDBC and DynamoDB). -- **Con**: The structured log is the audit trail. If operators don't - consume the `pkauth.user.deletion` event stream, failed deletions go - unnoticed until a user complains. -- **Con**: Adding `deleteByUserHandle` to `CredentialRepository` and - `OtpRepository` SPIs is breaking for downstream impls. The 1.1.0 release - note calls this out; in practice every shipped pk-auth persistence - module is updated as part of the same release. +- Pro: one-call user deletion across every credential category the library manages. Hosts replace + ad-hoc cleanup with `userDeletionService.deleteUser(handle)`. +- Pro: new library features (refresh tokens in PR 3) integrate automatically by adding their own + `UserDeletionListener` to the same collection. No central registry needs updating. +- Pro: adopter-supplied listeners participate as first-class members. A host with its own user-keyed + table (avatar storage, app-specific session data) wires a listener, and the service calls it + alongside the library's. +- Con: the operation is not atomic. A partial deletion leaves some credential categories intact. + Operators must monitor the structured log and retry. The alternative rejected in Context is XA + across JDBC and DynamoDB. +- Con: the structured log is the audit trail. If operators do not consume the + `pkauth.user.deletion` event stream, failed deletions go unnoticed until a user complains. +- Con: adding `deleteByUserHandle` to the `CredentialRepository` and `OtpRepository` SPIs is + breaking for downstream implementations. The 1.1.0 release note calls this out, and every shipped + pk-auth persistence module is updated as part of the same release. ## Open follow-ups -- An admin endpoint surfacing `userDeletionService.deleteUser(handle)` - would let hosts trigger fan-out from a REST call. Not in 1.1.0 scope. -- A retry helper that takes a `UserDeletionResult.failedListenerNames` - list and re-runs just those listeners would simplify the operator - retry flow. Deferred until a real consumer asks for it. -- If a future persistence backend joins (e.g. a single transactional - store covering every SPI), the service could opportunistically detect - it and switch to a single-transaction fan-out. Out of scope until that - backend exists. +- An admin endpoint surfacing `userDeletionService.deleteUser(handle)` would let hosts trigger + fan-out from a REST call. It is not in 1.1.0 scope. +- A retry helper that takes a `UserDeletionResult.failedListenerNames` list and re-runs just those + listeners would simplify the operator retry flow. It is deferred until a real consumer asks for + it. +- If a future persistence backend joins (for example a single transactional store covering every + SPI), the service could opportunistically detect it and switch to a single-transaction fan-out. + This is out of scope until that backend exists. diff --git a/docs/adr/0017-sonarqube-cloud-static-analysis.md b/docs/adr/0017-sonarqube-cloud-static-analysis.md index 4f8305a2..133af985 100644 --- a/docs/adr/0017-sonarqube-cloud-static-analysis.md +++ b/docs/adr/0017-sonarqube-cloud-static-analysis.md @@ -8,39 +8,67 @@ Superseded by [ADR 0018](0018-remove-sonarqube-cloud.md). ## Context -pk-auth already gates quality at build time: Spotless (formatting + SPDX), Error Prone with strict JSpecify null discipline, per-module JaCoCo coverage floors (≥80% on `pk-auth-core`, ≥70% on adapters), and Stryker mutation testing on the browser SDK. What's missing is a *trend* view of the JVM code — coverage over time, code smells, security hotspots, and duplication tracked across commits rather than only pass/fail at HEAD. +pk-auth already gates quality at build time: Spotless (formatting and SPDX), Error Prone with strict +JSpecify null discipline, per-module JaCoCo coverage floors (≥80% on `pk-auth-core`, ≥70% on +adapters), and Stryker mutation testing on the browser SDK. What is missing is a trend view of the +JVM code: coverage over time, code smells, security hotspots, and duplication tracked across commits +rather than only pass/fail at HEAD. -We want a hosted dashboard that: +The project needs a hosted dashboard that: -- ingests the JaCoCo XML we already produce, so no new coverage machinery is needed; +- ingests the JaCoCo XML the build already produces, so no new coverage machinery is needed; - understands the multi-module Gradle build as one project; - is free for a public/open-source repository; -- runs from CI rather than requiring a separate scan service that can't see our build. +- runs from CI rather than requiring a separate scan service that cannot see the build. Options considered: -- **SonarQube Cloud (formerly SonarCloud)** — hosted, free for public repos, first-class Gradle scanner (`org.sonarqube`), auto-detects JaCoCo XML, integrates with GitHub PR decoration. No infrastructure to run. -- **Self-hosted SonarQube Community Edition** — same analyzer, but we'd host and maintain a server + database. Overkill for an open-source library; nobody owns the ops. -- **Codecov / Coveralls** — coverage trend only; no static analysis, hotspots, or duplication. Narrower than what we want. +- SonarQube Cloud (formerly SonarCloud) is hosted, free for public repos, has a first-class Gradle + scanner (`org.sonarqube`), auto-detects JaCoCo XML, and integrates with GitHub PR decoration. It + needs no infrastructure to run. +- Self-hosted SonarQube Community Edition uses the same analyser, but the project would host and + maintain a server and database. This is excessive for an open-source library with no owner for + the operations. +- Codecov and Coveralls provide coverage trend only, with no static analysis, hotspots, or + duplication. They are narrower than the requirement. ## Decision -Adopt **SonarQube Cloud** via the `org.sonarqube` Gradle plugin, driven from CI. - -- The plugin (version pinned in `gradle/libs.versions.toml`) is applied to the **root** project only; multi-module aggregation is built in. The `sonar` block in the root `build.gradle.kts` carries `projectKey`, `organization`, host URL, and `sonar.exclusions` (the example apps and the generated browser SDK `dist/`). -- Coverage is **not** reconfigured — the scanner auto-detects each module's `build/reports/jacoco/test/jacocoTestReport.xml`, already emitted by `pkauth.test-conventions`. The CI step runs `./gradlew build jacocoTestReport sonar`. -- Analysis runs as a dedicated `sonar` job in `ci.yml`, gated to pushes and same-repo PRs (fork PRs can't read `SONAR_TOKEN`), with `fetch-depth: 0` so Sonar gets full SCM blame for new-code attribution. -- We use **CI-based analysis**, with Automatic Analysis turned off on the Sonar side — Automatic can't run our Gradle build or see JaCoCo coverage. +Adopt SonarQube Cloud via the `org.sonarqube` Gradle plugin, driven from CI. + +- The plugin (version pinned in `gradle/libs.versions.toml`) is applied to the root project only, + and multi-module aggregation is built in. The `sonar` block in the root `build.gradle.kts` carries + `projectKey`, `organization`, host URL, and `sonar.exclusions` (the example apps and the generated + browser SDK `dist/`). +- Coverage is not reconfigured. The scanner auto-detects each module's + `build/reports/jacoco/test/jacocoTestReport.xml`, already emitted by `pkauth.test-conventions`. + The CI step runs `./gradlew build jacocoTestReport sonar`. +- Analysis runs as a dedicated `sonar` job in `ci.yml`, gated to pushes and same-repo PRs (fork PRs + cannot read `SONAR_TOKEN`), with `fetch-depth: 0` so Sonar gets full SCM blame for new-code + attribution. +- The analysis is CI-based, with Automatic Analysis turned off on the Sonar side, because Automatic + Analysis cannot run the Gradle build or see JaCoCo coverage. ## Consequences -- **Positive — trend visibility.** Coverage, code smells, security hotspots, and duplication are tracked per commit with a quality gate on *new code*, complementing (not replacing) the existing per-module JaCoCo floors. -- **Positive — no new coverage plumbing.** Sonar consumes the JaCoCo XML we already generate. -- **Negative — config cache caveat.** The `sonar` task is not Gradle configuration-cache compatible, and `gradle.properties` enables the config cache globally. CI invokes the analysis with `--no-configuration-cache`; this is a documented, scoped exception, not a global change. -- **Negative — fork PRs get no analysis.** GitHub withholds secrets from fork PRs, so the `sonar` job is skipped there. External contributors' branches are covered once merged to `main`; the standard trade-off for an open-source repo. -- **Negative — heavier CI job.** The `sonar` job runs the full `build` (including the Docker-backed Testcontainers integration tests) so coverage exists when the scanner reads it. Acceptable on `ubuntu-latest`; can be optimized later by sharing coverage artifacts with the existing `build` job. +- Positive, trend visibility: coverage, code smells, security hotspots, and duplication are tracked + per commit with a quality gate on new code, complementing (not replacing) the existing per-module + JaCoCo floors. +- Positive, no new coverage plumbing: Sonar consumes the JaCoCo XML the build already generates. +- Negative, a configuration-cache caveat: the `sonar` task is not Gradle configuration-cache + compatible, and `gradle.properties` enables the configuration cache globally. CI invokes the + analysis with `--no-configuration-cache`, a documented and scoped exception rather than a global + change. +- Negative, no analysis on fork PRs: GitHub withholds secrets from fork PRs, so the `sonar` job is + skipped there. External contributors' branches are covered once merged to `main`, the standard + trade-off for an open-source repo. +- Negative, a heavier CI job: the `sonar` job runs the full `build` (including the Docker-backed + Testcontainers integration tests) so coverage exists when the scanner reads it. This is + acceptable on `ubuntu-latest` and can be optimised later by sharing coverage artefacts with the + existing `build` job. ## Open follow-ups -- If we want the quality gate to block merges, wire the Sonar status check into branch protection. -- If CI time becomes a concern, have the `sonar` job download JaCoCo artifacts from the `build` job instead of rebuilding. +- To make the quality gate block merges, wire the Sonar status check into branch protection. +- If CI time becomes a concern, the `sonar` job can download JaCoCo artefacts from the `build` job + instead of rebuilding. diff --git a/docs/adr/0018-remove-sonarqube-cloud.md b/docs/adr/0018-remove-sonarqube-cloud.md index d5ff6de3..b0dd806a 100644 --- a/docs/adr/0018-remove-sonarqube-cloud.md +++ b/docs/adr/0018-remove-sonarqube-cloud.md @@ -11,20 +11,20 @@ Accepted. Supersedes [ADR 0017](0017-sonarqube-cloud-static-analysis.md). ADR 0017 adopted SonarQube Cloud for trend visibility and a new-code quality gate. In practice the gate produced low-signal friction rather than insight: -- Its findings largely **duplicate tooling we already run at build time, more precisely**: null - discipline is enforced by Error Prone + JSpecify, formatting/SPDX by Spotless, and test - effectiveness by per-module JaCoCo floors plus Stryker mutation testing on the browser SDK. -- Several rules **conflicted with deliberate project conventions** — e.g. `S4449` demands JSR-305 - `javax.annotation.Nullable` while we standardize on JSpecify, and `S2583`/`S2589` flagged correct - defensive null-guards on framework-injected parameters as dead code. -- The **new-code gate fired on attribution noise**: editing one line of an existing file surfaced a - pre-existing, idiomatic pattern (`HttpResponse` on a Micronaut `@Controller`) as a "new" issue, - failing the gate over a non-defect. - -Keeping the gate green required suppressions that litter the build with rule exclusions — paying -maintenance cost to satisfy a tool that told us less than the gates we already trust. The one thing -ADR 0017 genuinely wanted — **coverage tracked at line and branch level** — JaCoCo already provides -natively and offline. +- Its findings largely duplicate tooling already run at build time, more precisely: Error Prone and + JSpecify enforce null discipline, Spotless enforces formatting and SPDX headers, and per-module + JaCoCo floors plus Stryker mutation testing on the browser SDK enforce test effectiveness. +- Several rules conflicted with project conventions. For example, `S4449` demands JSR-305 + `javax.annotation.Nullable` while the project standardises on JSpecify, and `S2583` and `S2589` + flagged correct defensive null-guards on framework-injected parameters as dead code. +- The new-code gate fired on attribution noise. Editing one line of an existing file surfaced a + pre-existing, idiomatic pattern (`HttpResponse` on a Micronaut `@Controller`) as a "new" issue + and failed the gate over a non-defect. + +Keeping the gate green required suppressions that litter the build with rule exclusions, a +maintenance cost paid to satisfy a tool that reported less than the gates already in place. The one +thing ADR 0017 needed, coverage tracked at line and branch level, JaCoCo already provides natively +and offline. ## Decision @@ -32,29 +32,29 @@ Remove the SonarQube Cloud integration and enforce coverage directly with JaCoCo - Drop the `org.sonarqube` Gradle plugin and its `sonar { }` block from the root build, the version catalog entry, the `sonar` CI job, and the SonarCloud README badges. -- Gate **both `LINE` and `BRANCH`** counters (previously line only), via a **hybrid** of one central +- Gate both `LINE` and `BRANCH` counters (previously line only), via a hybrid of one central baseline plus a few per-module overrides: - - **Baseline** lives in `pkauth.test-conventions` and applies to every published library module: + - The baseline lives in `pkauth.test-conventions` and applies to every published library module: `LINE` ≥70%, `BRANCH` ≥55%. It is keyed off the `java-library` plugin (applied by - `pkauth.library-conventions`) so it covers the libraries but **not** the `examples/*` demos, - which apply `test-conventions` only for the JaCoCo report and are intentionally ungated. - - **Overrides** stay in a module's own `build.gradle.kts` and raise only the limits above the - baseline (Gradle enforces all rules, so an override never restates the floor): `pk-auth-core` / - `pk-auth-jwt` / `pk-auth-admin-api` keep the ≥80% line bar from the brief §11; `pk-auth-jwt`, - `pk-auth-backup-codes`, `pk-auth-otp`, and `pk-auth-refresh-tokens` pin `BRANCH` near their - current (high) coverage. Every other module rides the baseline. - - Branch floors are static — set below current measured coverage to lock in today's level and fail - on regression; raise them as coverage improves. These gates run under `check`, which the `build` - CI job already invokes. + `pkauth.library-conventions`), so it covers the libraries but not the `examples/*` demos, which + apply `test-conventions` only for the JaCoCo report and are ungated. + - Overrides stay in a module's own `build.gradle.kts` and raise only the limits above the + baseline (Gradle enforces all rules, so an override never restates the floor). `pk-auth-core`, + `pk-auth-jwt`, and `pk-auth-admin-api` keep the ≥80% line bar from the brief §11. + `pk-auth-jwt`, `pk-auth-backup-codes`, `pk-auth-otp`, and `pk-auth-refresh-tokens` pin `BRANCH` + near their current (high) coverage. Every other module rides the baseline. + - Branch floors are static: they are set below current measured coverage to lock in today's level + and fail on regression, and they are raised as coverage improves. These gates run under + `check`, which the `build` CI job already invokes. ## Consequences -- **Positive — no external service, no attribution noise.** Coverage is enforced deterministically - from the build we already run; there is nothing to suppress. -- **Positive — branch coverage is now a hard gate**, not just a dashboard number, catching untested - conditionals that line coverage alone misses. -- **Negative — no hosted trend dashboard.** We lose cross-commit graphs, duplication tracking, and - the security-hotspot review surface. The first two were redundant with existing gates; if hotspot - review is wanted later, prefer a tool that doesn't gate on new-code attribution. -- **Negative — branch floors are static.** They must be raised by hand as coverage improves; +- Positive, no external service and no attribution noise: coverage is enforced deterministically + from the existing build, and there is nothing to suppress. +- Positive, branch coverage as a hard gate rather than a dashboard number: the gate catches + untested conditionals that line coverage alone misses. +- Negative, no hosted trend dashboard: the project loses cross-commit graphs, duplication tracking, + and the security-hotspot review surface. The first two were redundant with existing gates. If + hotspot review is wanted later, a tool that does not gate on new-code attribution is preferable. +- Negative, static branch floors: they are raised by hand as coverage improves, and the `jacocoTestReport` HTML shows current numbers when revising them. diff --git a/docs/adr/0019-crypto-agility-post-quantum-readiness.md b/docs/adr/0019-crypto-agility-post-quantum-readiness.md index e8657433..e08f535b 100644 --- a/docs/adr/0019-crypto-agility-post-quantum-readiness.md +++ b/docs/adr/0019-crypto-agility-post-quantum-readiness.md @@ -8,80 +8,80 @@ Accepted. ## Context -A passkey is a public-key credential. Its signature algorithm — ES256 (ECDSA P-256), EdDSA -(Ed25519), RS256/RS384 (RSA) — rests on discrete-log / factoring hardness that a -cryptographically-relevant quantum computer (CRQC) running Shor's algorithm would break. None -exists today, but stored public keys are long-lived, so the project should be honest about the -posture and remove the obstacles to a future migration. +A passkey is a public-key credential. Its signature algorithm, whether ES256 (ECDSA P-256), EdDSA +(Ed25519), or RS256/RS384 (RSA), rests on discrete-log or factoring hardness that a +cryptographically relevant quantum computer (CRQC) running Shor's algorithm would break. No such +computer exists today, but stored public keys are long-lived, so the project states its posture +accurately and removes the obstacles to a future migration. Two concrete problems existed in the code: -1. **Two divergent, hardcoded COSE algorithm lists.** The registration *create-options* sent to the - browser hardcoded `-7` (ES256), `-8` (EdDSA), `-257` (RS256) in - `DefaultPasskeyAuthenticationService`. The registration *verify* path hardcoded a different list — - `DEFAULT_PUB_KEY_PARAMS` (ES256, EdDSA, RS256, ES384, RS384) in `WebAuthn4JConverters`. The two +1. Two divergent, hardcoded COSE algorithm lists. The registration create-options sent to the + browser hardcoded `-7` (ES256), `-8` (EdDSA), and `-257` (RS256) in + `DefaultPasskeyAuthenticationService`. The registration verify path hardcoded a different list, + `DEFAULT_PUB_KEY_PARAMS` (ES256, EdDSA, RS256, ES384, RS384), in `WebAuthn4JConverters`. The two could drift, and neither was operator-configurable. -2. **No way to see which stored credentials use which algorithm**, so a future "re-enroll off - algorithm X" campaign had nothing to drive it. +2. No way to see which stored credentials use which algorithm, so a future "re-enrol off algorithm + X" campaign had nothing to drive it. -It is important to be precise about scope. **No post-quantum signature algorithm can be added to -pk-auth today.** The choice is gated end-to-end by the authenticator hardware, CTAP2/FIDO2, the -WebAuthn/COSE registry, and WebAuthn4J's verifier — none of which yet standardize or implement a -PQC signature (e.g. an ML-DSA / FIPS 204 COSE binding). Inventing COSE identifiers or faking -ML-DSA/Dilithium support would be dishonest and non-interoperable. The realistic goal is therefore -**crypto-agility and accurate documentation**, not new algorithms. +The scope is limited. No post-quantum signature algorithm can be added to pk-auth today. The choice +is gated end to end by the authenticator hardware, CTAP2/FIDO2, the WebAuthn/COSE registry, and +WebAuthn4J's verifier, none of which yet standardises or implements a PQC signature (for example an +ML-DSA / FIPS 204 COSE binding). Inventing COSE identifiers or faking ML-DSA/Dilithium support would +be non-interoperable. The realistic goal is therefore crypto-agility and accurate documentation, not +new algorithms. A related but separate question is the post-ceremony JWT. The earlier "HS256 for dev, ES256 for -production" framing was misleading: HMAC-SHA256 is *not* broken by Shor, and with the enforced -≥ 256-bit key it retains ~128-bit security under Grover — making HS256 the quantum-conservative -choice for a single-issuer/single-verifier deployment. ES256 JWTs exist for untrusted third-party -verification and *are* Shor-vulnerable, with exposure bounded by the short token TTL. +production" framing was misleading. HMAC-SHA256 is not broken by Shor, and with the enforced +≥ 256-bit key it retains about 128-bit security under Grover, which makes HS256 the +quantum-conservative choice for a single-issuer, single-verifier deployment. ES256 JWTs exist for +untrusted third-party verification and are Shor-vulnerable, with exposure bounded by the short token +TTL. ## Decision -1. **One source of truth for COSE algorithms.** Introduce a framework-neutral `CoseAlgorithm` enum +1. A single source of truth for COSE algorithms. Introduce a framework-neutral `CoseAlgorithm` enum and carry two ordered lists on `CeremonyConfig`: `offeredAlgorithms` (advertised in create-options) and `acceptedAlgorithms` (enforced on verify). Both the create-options ceremony - and the WebAuthn4J verify path derive their lists from this config; the two hardcoded lists are - removed. `acceptedAlgorithms` is authoritative; `offeredAlgorithms` must be a subset and may be - narrower. Operators can narrow either without code changes. + and the WebAuthn4J verify path derive their lists from this config, and the two hardcoded lists + are removed. `acceptedAlgorithms` is authoritative, and `offeredAlgorithms` must be a subset and + may be narrower. Operators can narrow either without code changes. -2. **Backward-compatible defaults.** The default `acceptedAlgorithms` is the **union** of everything +2. Backward-compatible defaults. The default `acceptedAlgorithms` is the union of everything previously accepted (ES256, EdDSA, RS256, ES384, RS384), so no already-registered credential can fail verification. The default `offeredAlgorithms` stays the historical create-options subset - (ES256, EdDSA, RS256). A new 5-arg `CeremonyConfig` convenience constructor applies these defaults - so every existing call site compiles and behaves identically. + (ES256, EdDSA, RS256). A new 5-arg `CeremonyConfig` convenience constructor applies these + defaults, so every existing call site compiles and behaves identically. -3. **Per-credential algorithm visibility.** `CredentialAlgorithms.coseAlgorithm(record)` decodes the - COSE algorithm already embedded in the stored public key — **no schema change** — and +3. Per-credential algorithm visibility. `CredentialAlgorithms.coseAlgorithm(record)` decodes the + COSE algorithm already embedded in the stored public key, with no schema change, and `AdminService.listCredentialsByAlgorithm(actor, target, coseAlgorithm)` reports which credentials - use a given algorithm, the read side a re-enrollment campaign drives off. + use a given algorithm. This is the read side a re-enrolment campaign drives off. -4. **Honest JWT framing.** Re-document HS256 vs ES256 as a trust-topology choice (symmetric, shared - trust boundary vs asymmetric, untrusted third-party verification) with the post-quantum tradeoff - spelled out. **No signing behavior or default changes.** +4. Accurate JWT framing. HS256 versus ES256 is documented as a trust-topology choice (symmetric, + shared trust boundary versus asymmetric, untrusted third-party verification) with the + post-quantum trade-off spelled out. No signing behaviour or default changes. -5. **Documentation.** Add a *Post-quantum readiness* section to the threat model and a TLS - hybrid-KEM ("harvest-now, decrypt-later") note to the operator guide, the latter framed explicitly - as an operator action at the TLS terminator, not a library change. +5. Documentation. Add a "Post-quantum readiness" section to the threat model and a TLS hybrid-KEM + ("harvest-now, decrypt-later") note to the operator guide. The note is framed as an operator + action at the TLS terminator, not a library change. ## Consequences -- **Positive — the divergence bug is gone.** Offered and accepted algorithms come from one config; +- Positive, the divergence bug is gone: offered and accepted algorithms come from one config, so they can no longer silently drift, and both are operator-tunable. -- **Positive — a PQC signature becomes a small, localized change.** When the ecosystem standardizes - one, adding it is a new `CoseAlgorithm` constant plus its WebAuthn4J mapping — the ceremony and +- Positive, a PQC signature becomes a small, localised change: when the ecosystem standardises one, + adding it is a new `CoseAlgorithm` constant plus its WebAuthn4J mapping, because the ceremony and verify code are already config-driven. -- **Positive — migration is observable.** Operators can enumerate credentials by algorithm before a - CRQC exists and stage re-enrollment. -- **Neutral — no new algorithms, by design.** This ADR deliberately ships zero new signature - algorithms; it is readiness, not a PQC implementation. -- **Negative — `CeremonyConfig` grew two fields.** Mitigated by the defaulting convenience - constructor and the `from(...)` overload, so existing construction sites and adapters are - unaffected. -- **Constraint — the symmetric story is already fine.** Random secrets (refresh 32 B, OTP pepper - ≥ 16 B / 32 B recommended, challenge 32 B) and HS256 (≥ 32 B key) are 256-bit-class and - Grover-resistant; no change was needed or made there. +- Positive, migration is observable: operators can enumerate credentials by algorithm before a CRQC + exists and stage re-enrolment. +- Neutral, no new algorithms: this ADR ships zero new signature algorithms. It provides readiness, + not a PQC implementation. +- Negative, `CeremonyConfig` grew two fields: the defaulting convenience constructor and the + `from(...)` overload leave existing construction sites and adapters unaffected. +- Constraint, the symmetric side needs no change: random secrets (refresh 32 B, OTP pepper ≥ 16 B + with 32 B recommended, challenge 32 B) and HS256 (≥ 32 B key) are 256-bit-class and + Grover-resistant, so nothing changed there. ## Open follow-ups @@ -92,5 +92,5 @@ verification and *are* Shor-vulnerable, with exposure bounded by the short token Dropwizard adapter. Each adapter null-coalesces to the core defaults and passes the lists to the seven-argument `CeremonyConfig.from(...)`. All three example apps set them explicitly (to the defaults) as living documentation. -- Revisit when a COSE-registered post-quantum signature algorithm lands in WebAuthn4J; adding it is - the localized change this ADR was written to enable. +- Revisit when a COSE-registered post-quantum signature algorithm lands in WebAuthn4J. Adding it is + the localised change this ADR enables. diff --git a/docs/adr/0020-browser-sdk-testing-strategy.md b/docs/adr/0020-browser-sdk-testing-strategy.md index 2c5ddc73..814bd28c 100644 --- a/docs/adr/0020-browser-sdk-testing-strategy.md +++ b/docs/adr/0020-browser-sdk-testing-strategy.md @@ -13,83 +13,83 @@ cooperating pieces: the WebAuthn browser API, the fetch layer, CBOR/base64url en pk-auth server's registration and authentication endpoints. There are three distinct ways to test this code: -1. **Pure unit tests with mocks** — each component is tested in isolation; collaborators are - replaced with mock objects that assert call sites. -2. **Simulated-environment unit tests** — a lightweight in-memory HTTP server (no real Java process) +1. Pure unit tests with mocks. Each component is tested in isolation, and collaborators are replaced + with mock objects that assert call sites. +2. Simulated-environment unit tests. A lightweight in-memory HTTP server (no real Java process) handles `/auth/**` requests while a Node.js runtime stands in for the browser. Tests drive the - full client stack against this fake server, with per-test control over server behavior. -3. **Integration / end-to-end tests** — the real Java server runs (via Docker or a local process) - and a headless Chromium browser executes the WebAuthn flow over the network. - -The developer-experience constraint that anchors the comparison is: **a developer must be able to -open an IDE and run unit tests without unreasonable setup**. "Reasonable" means `npm install`. -"Unreasonable" means Docker, a running JVM, or a Chrome binary — those are prerequisites for -integration work, not for exploring a specific client code path. - -Pure mocks (option 1) let each component be exercised in total isolation and are excellent for -corner cases that are hard to trigger otherwise, but they bind tests to implementation details -(which function was called, in what order) and leave the seams between components untested. - -A simulated environment (option 2) replaces only the I/O boundary — the HTTP server and the -browser WebAuthn API — while running the full SDK stack in Node.js. It tests the integration -points between components without Docker or a real browser. It is easy to step-through-debug in -an IDE and straightforward to trigger failure paths (server timeouts, unexpected status codes, -authenticator errors) that would be cumbersome to produce against a real server. - -End-to-end tests (option 3) give the strongest production signal — real crypto, real browser -security boundaries, real server persistence — but are the most expensive to set up, the slowest -to run, and the hardest to steer toward a specific code path or failure mode. Debugging requires + full client stack against this fake server, with per-test control over server behaviour. +3. Integration and end-to-end tests. The real Java server runs (via Docker or a local process) and a + headless Chromium browser executes the WebAuthn flow over the network. + +The developer-experience constraint that anchors the comparison is that a developer must be able to +open an IDE and run unit tests without unreasonable setup. Reasonable setup means `npm install`. +Unreasonable setup means Docker, a running JVM, or a Chrome binary, which are prerequisites for +integration work and not for exploring a specific client code path. + +Pure mocks (option 1) let each component be exercised in total isolation and suit corner cases that +are hard to trigger otherwise, but they bind tests to implementation details (which function was +called, in what order) and leave the seams between components untested. + +A simulated environment (option 2) replaces only the I/O boundary (the HTTP server and the browser +WebAuthn API) while running the full SDK stack in Node.js. It tests the integration points between +components without Docker or a real browser. It is easy to step-through-debug in an IDE, and it +makes failure paths (server timeouts, unexpected status codes, authenticator errors) straightforward +to trigger where they would be cumbersome to produce against a real server. + +End-to-end tests (option 3) give the strongest production signal (real crypto, real browser security +boundaries, real server persistence), but they are the most expensive to set up, the slowest to run, +and the hardest to steer toward a specific code path or failure mode. Debugging requires coordinating client and server concurrently. -The project already runs Playwright end-to-end suites under `examples/` (opt-in via -`PK_RUN_E2E=1`). The question this ADR resolves is what the *primary* unit testing strategy for -the SDK itself should be and how the three approaches relate to one another. +The project already runs Playwright end-to-end suites under `examples/` (opt-in via `PK_RUN_E2E=1`). +The open question is which approach is the primary unit testing strategy for the SDK itself and how +the three approaches relate to one another. ## Decision -**Prefer the simulated-environment approach (option 2) as the primary unit-testing strategy for -the browser SDK.** Option 1 (mock-based) is a valid complement for edge cases. Option 3 -(end-to-end) is the existing Playwright suite under `examples/`. +The simulated-environment approach (option 2) is the primary unit-testing strategy for the browser +SDK. Option 1 (mock-based) is a valid complement for edge cases. Option 3 (end-to-end) is the +existing Playwright suite under `examples/`. Concretely: -- **Simulated environment** — a lightweight Node.js HTTP server stubs the pk-auth `/auth/**` - endpoints in memory. Per-test fixtures can replace any server response (status code, body, - timing) to exercise specific client behavior without a running JVM. The WebAuthn browser API +- Simulated environment: a lightweight Node.js HTTP server stubs the pk-auth `/auth/**` endpoints in + memory. Per-test fixtures can replace any server response (status code, body, timing) to exercise + specific client behaviour without a running JVM. The WebAuthn browser API (`navigator.credentials.create` / `.get`) is replaced with a fake authenticator that produces - structurally valid CBOR/COSE artifacts. No Docker. No Chrome. Tests run with `npm test` from - any IDE that can drive `vitest`. -- **Mock-based unit tests** remain appropriate when a failure mode is difficult to reproduce in - the simulated environment (e.g. a network socket close mid-response, a CBOR encoding edge case) - or when a very specific internal behavior needs pinning. They are not the default. -- **End-to-end tests** (`PK_RUN_E2E=1`) provide the integration gate against the real Java server - and a real browser. They gate CI but are not expected to cover the full error-path matrix — that - is the simulated environment's job. - -The simulated environment should remain flexible (tests can inject arbitrary server responses) but -is intentionally *not* production-hardened (no persistence, no rate limiting, no challenge -store). Its only contract is behavioral fidelity to the real server's happy path and -well-defined error responses. + structurally valid CBOR/COSE artefacts. The tests need no Docker and no Chrome, and run with + `npm test` from any IDE that can drive `vitest`. +- Mock-based unit tests remain appropriate when a failure mode is difficult to reproduce in the + simulated environment (for example a network socket close mid-response or a CBOR encoding edge + case) or when a very specific internal behaviour needs pinning. They are not the default. +- End-to-end tests (`PK_RUN_E2E=1`) provide the integration gate against the real Java server and a + real browser. They gate CI but do not cover the full error-path matrix, which is the simulated + environment's job. + +The simulated environment stays flexible (tests can inject arbitrary server responses) but is not +production-hardened (no persistence, no rate limiting, no challenge store). Its only contract is +behavioural fidelity to the real server's happy path and well-defined error responses. ## Consequences -- **Positive — seam coverage without infra cost.** The integration points between the fetch layer, - encoding, and ceremony orchestration are exercised in every unit test run; regressions at those - seams surface immediately without Docker or a browser. -- **Positive — controllable failure paths.** Server timeouts, 4xx/5xx responses, and - authenticator failures can be injected deterministically, producing test coverage that is - impractical to achieve against a real server. -- **Positive — IDE-friendly.** `npm install` is the only prerequisite; tests run and debug in - any IDE with a Node.js adapter. -- **Positive — loose coupling.** Tests assert observable outcomes (resolved/rejected promises, +- Positive, seam coverage without infrastructure cost: the integration points between the fetch + layer, encoding, and ceremony orchestration are exercised in every unit test run, and regressions + at those seams surface immediately without Docker or a browser. +- Positive, controllable failure paths: server timeouts, 4xx/5xx responses, and authenticator + failures can be injected deterministically, producing test coverage that is impractical to achieve + against a real server. +- Positive, IDE-friendliness: `npm install` is the only prerequisite, and tests run and debug in any + IDE with a Node.js adapter. +- Positive, loose coupling: tests assert observable outcomes (resolved or rejected promises, returned credential objects) rather than internal call sequences, so internal refactors do not require test rewrites. -- **Negative — the fake server and fake authenticator must stay in sync with the real - implementations.** If the Java server changes a response shape, the simulated server must be - updated or tests will silently diverge from production. The end-to-end suite is the backstop. -- **Negative — the Node.js runtime is not a browser.** Subtle browser-specific behaviors (CSP, +- Negative, synchronisation between the fakes and the real implementations: the fake server and fake + authenticator must stay in sync with the real ones. If the Java server changes a response shape, + the simulated server must be updated, or tests silently diverge from production. The end-to-end + suite is the backstop. +- Negative, the Node.js runtime is not a browser: subtle browser-specific behaviours (CSP, secure-context enforcement, platform authenticator UX) are not exercised by the simulated - environment; only the end-to-end suite can catch those. -- **Constraint — end-to-end tests remain opt-in** (`PK_RUN_E2E=1`) and are not expected to cover - the full failure matrix. They validate the happy path and critical flows against real infra; - the unit suite owns error-path breadth. + environment, and only the end-to-end suite can catch them. +- Constraint, end-to-end tests remain opt-in (`PK_RUN_E2E=1`) and do not cover the full failure + matrix. They validate the happy path and critical flows against real infrastructure, and the unit + suite owns error-path breadth. diff --git a/docs/operator-guide.md b/docs/operator-guide.md index 60b15786..d94e47e0 100644 --- a/docs/operator-guide.md +++ b/docs/operator-guide.md @@ -1,127 +1,116 @@ # pk-auth Operator Guide -What an operator running pk-auth in production needs to know: secrets, persistence, -observability, rotation, and the most common ways a deployment goes wrong. +What an operator running pk-auth in production needs to know: secrets, persistence, observability, +rotation, and the most common ways a deployment goes wrong. ## 1. Required environment -pk-auth ships as a JVM library — Spring Boot 4, Dropwizard 5, or Micronaut 4 -adapters all consume the same core. A typical production deployment needs: +pk-auth ships as a JVM library. The Spring Boot 4, Dropwizard 5, and Micronaut 4 adapters all +consume the same core. A typical production deployment needs: -- **JDK 21** (records, sealed types, virtual threads). Earlier JDKs will not compile. -- **Postgres 16+** (when using `pk-auth-persistence-jdbi`). The host runs the - shipped Flyway migrations; no adapter runs them for you (see §3). -- **DynamoDB** (when using `pk-auth-persistence-dynamodb`) — two tables: a - single-table `PkAuthCore` carrying every pk-auth auth item plus a separate - `PkAuthUsers` table for the host-app user records the `UserLookup` SPI reads. - See ADR 0008 for the table layout. -- **At least one trusted dispatcher** for magic links + OTP if you enable those - flows. The testkit's `LoggingEmailSender` / `LoggingSmsSender` log secrets to - stdout; never use them in production. +- JDK 21 (records, sealed types, virtual threads). Earlier JDKs do not compile the library. +- Postgres 16+ (when using `pk-auth-persistence-jdbi`). The host runs the shipped Flyway + migrations; no adapter runs them (see §3). +- DynamoDB (when using `pk-auth-persistence-dynamodb`), with two tables: a single-table + `PkAuthCore` carrying every pk-auth auth item, plus a separate `PkAuthUsers` table for the + host-app user records the `UserLookup` SPI reads. See ADR 0008 for the table layout. +- At least one trusted dispatcher for magic links and OTP when those flows are enabled. The + testkit's `LoggingEmailSender` and `LoggingSmsSender` log secrets to stdout and are never used in + production. ## 2. Secrets | Setting | Min length | Notes | |---|---|---| -| `pkauth.jwt.secret` (HS256) | 32 bytes | Hard fail at boot if shorter. Rotate by issuing a fresh secret and tolerating a grace window (issue + verify in parallel — pk-auth itself does not rotate; the host shoulds run two issuers behind a load balancer until tokens expire). | -| `pkauth.relying-party.id` | n/a | The eTLD+1 (e.g. `example.com`, NOT `auth.example.com`). Cross-subdomain passkeys all bind to this. Once a credential is registered against an RP ID, it cannot be re-registered against a different one without a fresh enrollment. | -| `pkauth.relying-party.origins` | n/a | Strict allow-list of `https://` origins. WebAuthn rejects mismatches; expand the list as you add subdomains. | -| OTP pepper (`pkauth.otp.pepper`) | 16 bytes decoded (32+ recommended) | Base64-encoded per-deployment pepper for OTP hashes only — OTP codes are hashed with HMAC-SHA256(pepper, code), not Argon2id. (Backup codes use Argon2id with no pepper.) Hard fail at boot if the value is not valid Base64 or decodes to fewer than 16 bytes. If unset, the adapter auto-generates a throwaway per-startup pepper **only when `pkauth.dev-mode=true`** (dev only — it invalidates outstanding OTPs across restarts and across cluster instances); with dev-mode off, an unset pepper is a hard boot failure. Treat as a long-lived secret; rotating it invalidates every existing OTP hash. | +| `pkauth.jwt.secret` (HS256) | 32 bytes | Hard fail at boot if shorter. Rotation issues a fresh secret and tolerates a grace window (issue and verify in parallel). pk-auth itself does not rotate; the host runs two issuers behind a load balancer until tokens expire. | +| `pkauth.relying-party.id` | n/a | The eTLD+1 (for example `example.com`, not `auth.example.com`). Cross-subdomain passkeys all bind to this value. A credential registered against an RP ID cannot be re-registered against a different one without a fresh enrolment. | +| `pkauth.relying-party.origins` | n/a | Strict allow-list of `https://` origins. WebAuthn rejects mismatches, so the list expands as subdomains are added. | +| OTP pepper (`pkauth.otp.pepper`) | 16 bytes decoded (32+ recommended) | Base64-encoded per-deployment pepper for OTP hashes only: OTP codes are hashed with HMAC-SHA256(pepper, code), not Argon2id. (Backup codes use Argon2id with no pepper.) Hard fail at boot if the value is not valid Base64 or decodes to fewer than 16 bytes. If unset, the adapter auto-generates a throwaway per-startup pepper only when `pkauth.dev-mode=true` (dev only: it invalidates outstanding OTPs across restarts and across cluster instances); with dev-mode off, an unset pepper is a hard boot failure. The pepper is a long-lived secret, and rotating it invalidates every existing OTP hash. | -Recommended: stash secrets in a KMS/Secrets Manager and inject as environment -variables (`PKAUTH_JWT_SECRET`, `PKAUTH_OTP_PEPPER`). The adapters bind both. +Secrets belong in a KMS or Secrets Manager, injected as environment variables +(`PKAUTH_JWT_SECRET`, `PKAUTH_OTP_PEPPER`). The adapters bind both. ### User Verification (UV) -`CeremonyConfig.userVerification` defaults to **`REQUIRED`**. With this default -WebAuthn4J enforces the asserted `flagUV` on every registration and -authentication, so each ceremony must carry a per-ceremony biometric or PIN — -this is what makes a passkey a genuine factor (something you *have* **and** -something you *are*/*know*). +`CeremonyConfig.userVerification` defaults to `REQUIRED`. With this default WebAuthn4J enforces the +asserted `flagUV` on every registration and authentication, so each ceremony must carry a +per-ceremony biometric or PIN. This is what makes a passkey a genuine factor: something the user +has plus something the user is or knows. -Relaxing it to `PREFERRED` or `DISCOURAGED` accepts a present-but-unverified -authenticator (mere user-presence, no biometric/PIN). A passkey then degrades to -"something you have" alone, which materially weakens the factor for every user. -**Generally do not relax UV.** The only standard reason to opt out is supporting -UV-incapable roaming hardware security keys; if you do, scope it deliberately and -understand the trade-off. +Relaxing the setting to `PREFERRED` or `DISCOURAGED` accepts a present-but-unverified authenticator +(mere user-presence, no biometric or PIN). A passkey then degrades to "something the user has" +alone, which materially weakens the factor for every user. The only standard reason to opt out is +supporting UV-incapable roaming hardware security keys, and a deployment that opts out for that +reason scopes the relaxation narrowly because of the trade-off above. ### COSE signature algorithms (crypto-agility) Two ceremony knobs control which COSE signature algorithms are used (ADR 0019): -- `pkauth.ceremony.offered-algorithms` — advertised to the authenticator in - registration create-options. Default: `[ES256, EdDSA, RS256]`. -- `pkauth.ceremony.accepted-algorithms` — the verify allow-list; a credential - whose algorithm is absent is rejected on registration. Default (and the - enforced superset): `[ES256, EdDSA, RS256, ES384, RS384]`. - -`offered` must be a subset of `accepted`. The defaults are the historical union, -so leaving them unset changes nothing. You may **narrow** either (e.g. drop RSA), -but narrowing `accepted` can reject **already-registered** credentials that use a -removed algorithm — drive a re-enrollment campaign first (see -`AdminService.listCredentialsByAlgorithm`). In Spring and Micronaut these bind -from `application.yml`; in Dropwizard they are the `PkAuthConfig.Ceremony` -record's `offeredAlgorithms` / `acceptedAlgorithms` components. All three example -apps set them explicitly (to the defaults) as living documentation. There is no -post-quantum signature algorithm to select yet — see `docs/threat-model.md` -(Post-quantum readiness). +- `pkauth.ceremony.offered-algorithms` is advertised to the authenticator in registration + create-options. Default: `[ES256, EdDSA, RS256]`. +- `pkauth.ceremony.accepted-algorithms` is the verify allow-list; a credential whose algorithm is + absent is rejected on registration. Default (and the enforced superset): + `[ES256, EdDSA, RS256, ES384, RS384]`. + +`offered` must be a subset of `accepted`. The defaults are the historical union, so leaving them +unset changes nothing. Either list can be narrowed (for example to drop RSA), but narrowing +`accepted` can reject already-registered credentials that use a removed algorithm. A re-enrolment +campaign (see `AdminService.listCredentialsByAlgorithm`) precedes any narrowing. In Spring and +Micronaut these bind from `application.yml`; in Dropwizard they are the `PkAuthConfig.Ceremony` +record's `offeredAlgorithms` / `acceptedAlgorithms` components. All three example apps set them +explicitly (to the defaults) as living documentation. No post-quantum signature algorithm is +available to select yet; see the "Post-quantum readiness" section of `docs/threat-model.md`. ## 3. Persistence migrations ### JDBI / Postgres - Flyway resources live in `pk-auth-persistence-jdbi/src/main/resources/db/migration`. -- No adapter runs these migrations. The host must run them: add - `classpath:db/migration` from this artifact to its own Flyway locations, - targeting `PkAuthJdbiSchema.CURRENT_SCHEMA_VERSION`. - `PkAuthJdbiSchema.migrateForDevelopment(DataSource)` exists for demos and - integration tests only. -- The shipped baseline is split across `V1__credentials.sql`, - `V2__challenges.sql`, `V3__backup_codes.sql`, `V4__otp_codes.sql`, and - `V5__example_users.sql` — five tables (`credentials`, `challenges`, - `backup_codes`, `otp_codes`, `users`) with no `pkauth_` prefix. +- No adapter runs these migrations. The host runs them by adding `classpath:db/migration` from this + artefact to its own Flyway locations, targeting `PkAuthJdbiSchema.CURRENT_SCHEMA_VERSION`. + `PkAuthJdbiSchema.migrateForDevelopment(DataSource)` exists for demos and integration tests only. +- The shipped baseline is split across `V1__credentials.sql`, `V2__challenges.sql`, + `V3__backup_codes.sql`, `V4__otp_codes.sql`, and `V5__example_users.sql`: five tables + (`credentials`, `challenges`, `backup_codes`, `otp_codes`, `users`) with no `pkauth_` prefix. `V6__audit_soft_delete.sql` adds the append-only `pkauth_audit_events` table. - `V7__credentials_hard_delete.sql` drops the `revoked_at` / `revoked_reason` - columns on `credentials` — credential delete is a hard delete, with the - audit record captured as a structured log event (`pkauth.credential.deleted`). - `V8__create_access_tokens.sql` and `V9__create_refresh_tokens.sql` add the - 1.1.0 `access_tokens` and `refresh_tokens` tables; `V10__refresh_tokens_amr.sql` - adds the `amr` (RFC 8176 authentication-method-reference) column to - `refresh_tokens`. `V11` and `V12` follow; see the migration directory for the - current set. -- Magic-link tokens are not persisted: the JWT is the credential, and the - consumed-JTI store is in-memory by default (see `ConsumedJtiStore` SPI for a - multi-replica override). -- The unique key on credential ID is byte-array shaped — do **not** introduce a - string-encoded column without a migration. + `V7__credentials_hard_delete.sql` drops the `revoked_at` / `revoked_reason` columns on + `credentials`: credential delete is a hard delete, with the audit record captured as a structured + log event (`pkauth.credential.deleted`). `V8__create_access_tokens.sql` and + `V9__create_refresh_tokens.sql` add the 1.1.0 `access_tokens` and `refresh_tokens` tables; + `V10__refresh_tokens_amr.sql` adds the `amr` (RFC 8176 authentication-method-reference) column to + `refresh_tokens`. `V11` and `V12` follow; the migration directory lists the current set. +- Magic-link tokens are not persisted: the JWT is the credential, and the consumed-JTI store is + in-memory by default (see the `ConsumedJtiStore` SPI for a multi-replica override). +- The unique key on credential ID is byte-array shaped. Introducing a string-encoded column for it + requires a migration. ### DynamoDB -- Two physical tables (see ADR 0008): `PkAuthCore` holds every pk-auth auth item - (credentials, challenges, backup codes, OTP codes, and the 1.1.0 token rows), - and `PkAuthUsers` holds the host-app user records the `UserLookup` SPI reads. - Provision both before the app starts; the adapter does not create them. -- The DynamoDB-native TTL attribute is `ttl` (epoch seconds) — enable TTL on the - `ttl` attribute of the `PkAuthCore` table. It is set on challenge - (`ChallengeItem`) and OTP (`OtpItem`) items so DynamoDB evicts them after expiry. (Magic-link tokens - are never persisted, in any backend.) -- 1.1.0 adds `access_tokens` and `refresh_tokens` items on the same `PkAuthCore` - table (ADR 0015, 0013), both pruned by the native `ttl` attribute. Access-token - rows set `ttl` to their `expiresAt` epoch second; refresh-token rows set it to - `expiresAt + cleanupRetention` (default 30 days) so used/revoked rows survive the - forensic-retention window before the background sweep removes them — matching the - JDBI cleanup semantics. TTL must be enabled on the table for this to work. -- Capacity-mode: on-demand is recommended for steady reads but bursty registration; - provisioned only makes sense once you have a stable signing/verification baseline. +- Two physical tables exist (see ADR 0008): `PkAuthCore` holds every pk-auth auth item (credentials, + challenges, backup codes, OTP codes, and the 1.1.0 token rows), and `PkAuthUsers` holds the + host-app user records the `UserLookup` SPI reads. Both tables are provisioned before the app + starts; the adapter does not create them. +- The DynamoDB-native TTL attribute is `ttl` (epoch seconds), and TTL is enabled on the `ttl` + attribute of the `PkAuthCore` table. The attribute is set on challenge (`ChallengeItem`) and OTP + (`OtpItem`) items so DynamoDB evicts them after expiry. (Magic-link tokens are never persisted, in + any backend.) +- 1.1.0 adds `access_tokens` and `refresh_tokens` items on the same `PkAuthCore` table (ADR 0015, + 0013), both pruned by the native `ttl` attribute. Access-token rows set `ttl` to their + `expiresAt` epoch second. Refresh-token rows set it to `expiresAt + cleanupRetention` (default 30 + days), so used and revoked rows survive the forensic-retention window before the background sweep + removes them, matching the JDBI cleanup semantics. TTL must be enabled on the table for this to + work. +- Capacity mode: on-demand is recommended for steady reads but bursty registration. Provisioned + capacity makes sense only once a stable signing and verification baseline exists. ### Token-table cleanup (1.1.0) -The new stateful access-token store (ADR 0015) and refresh-token store -(ADR 0013) keep used/revoked rows around for a configurable retention -window so operators have a forensic trail. Schedule a daily cleanup job: +The stateful access-token store (ADR 0015) and the refresh-token store (ADR 0013) keep used and +revoked rows for a configurable retention window so operators have a forensic trail. A daily +cleanup job removes expired rows. -**JDBI / Postgres** — call the SPI methods or run the canonical SQL: +On JDBI / Postgres, the job calls the SPI methods or runs the canonical SQL: ```sql -- Access tokens: drop rows whose exp has passed. @@ -134,121 +123,114 @@ DELETE FROM refresh_tokens AND (used_at IS NOT NULL OR revoked_at IS NOT NULL); ``` -**DynamoDB** — native TTL handles routine expiry asynchronously. If you -need synchronous pruning (operator action / test), call -`DynamoDbAccessTokenStore.deleteExpiredBefore(Instant)` and -`DynamoDbRefreshTokenRepository.deleteExpiredBefore(Instant)` — -both walk the primary items and remove anything past the cutoff. +On DynamoDB, native TTL handles routine expiry asynchronously. Synchronous pruning (operator action +or test) calls `DynamoDbAccessTokenStore.deleteExpiredBefore(Instant)` and +`DynamoDbRefreshTokenRepository.deleteExpiredBefore(Instant)`, both of which walk the primary items +and remove anything past the cutoff. -A daily cron is sufficient for both tables; neither row count grows -unboundedly because TTL is set at issue time. +A daily cron is sufficient for both tables. Neither row count grows unboundedly, because TTL is set +at issue time. ## 4. Observability -Every ceremony and admin operation emits structured logs at INFO. Suggested fields -to forward into your SIEM: +Every ceremony and admin operation emits structured logs at INFO. Suggested fields to forward into a +SIEM: - `userHandle` (base64url), `challengeId`, `credentialId` -- `ceremony.phase` (`start` / `finish`) and `ceremony.step` (`registration` / - `authentication`) -- `verification.kind` (`signature` / `originPolicy` / `rpIdPolicy` / - `counterRegression` / `attestationPolicy`) +- `ceremony.phase` (`start` / `finish`) and `ceremony.step` (`registration` / `authentication`) +- `verification.kind` (`signature` / `originPolicy` / `rpIdPolicy` / `counterRegression` / + `attestationPolicy`) - `result` (`success` / `denied:`) -Counter regression and origin mismatch both surface as INFO log entries with a -distinct `result.denied.reason`. Alert on either — they are signals of credential -cloning or a misconfigured RP. +Counter regression and origin mismatch both surface as INFO log entries with a distinct +`result.denied.reason`. Both are signals of credential cloning or a misconfigured RP, and both +warrant alerts. Recommended dashboards: -- p99 of `registration.finish` / `authentication.finish` (target < 200ms with - Postgres on the same VPC). -- 4xx by reason on `/auth/passkeys/*` (origin mismatch is almost always config - drift; counter regression is almost always an issue). -- Backup-code redemption and OTP attempt rates per user (the SPIs already - rate-limit, but operator-side alerts catch credential-stuffing). +- p99 of `registration.finish` / `authentication.finish` (target < 200ms with Postgres on the same + VPC). +- 4xx by reason on `/auth/passkeys/*` (origin mismatch is almost always config drift; counter + regression is almost always an issue). +- Backup-code redemption and OTP attempt rates per user (the SPIs already rate-limit, but + operator-side alerts catch credential-stuffing). -## 5. Rotation and re-enrollment +## 5. Rotation and re-enrolment -- **Passkey rotation**: users delete and re-add via `DELETE /auth/admin/credentials/{id}` - and a fresh registration ceremony. The "last credential" guard returns 409 — that - is intentional. Encourage users to add a second passkey before removing the first. -- **JWT secret rotation**: roll via the dual-issuer pattern in §2. -- **RP ID change**: a one-way migration. Every existing passkey is invalidated. Plan - a re-enrollment campaign with backup codes / magic links as the bridge. +- Passkey rotation: users delete and re-add via `DELETE /auth/admin/credentials/{id}` and a fresh + registration ceremony. The "last credential" guard returns 409. A user adds a second passkey + before removing the first. +- JWT secret rotation: rolls via the dual-issuer pattern in §2. +- RP ID change: a one-way migration. Every existing passkey is invalidated. The change needs a + re-enrolment campaign with backup codes or magic links as the bridge. -## 6. Failure modes worth practicing +## 6. Failure modes | Symptom | Likely cause | First check | |---|---|---| -| Browser shows "Relying party not registrable" | RP ID doesn't match the page's domain | The `pkauth.relying-party.id` config and the page's actual host | -| 4xx on `authentication.finish` with `counter_regression` | A counter wound back — either credential clone or counter-0 (synced) passkey crossing devices | Inspect the credential's `backupEligible` flag; if true, consider switching the policy to `warn` | -| `Challenge expired` 4xx | Five-minute default TTL elapsed | Often a slow user; do not extend the TTL — re-issue start | -| DynamoDB `ConditionalCheckFailedException` on `takeOnce` | Two clients tried to consume the same challenge | Expected; only one succeeds. If the rate is high, inspect for double-submit on the client | -| Spring Security 7 chain mounts before the pk-auth filter | Filter order regression | Verify the starter's `pkAuthSecurityFilterChain` bean (`PkAuthWebAutoConfiguration`, `@Order(HIGHEST_PRECEDENCE + 10)`) has the higher precedence in the host's chain | +| Browser shows "Relying party not registrable" | RP ID does not match the page's domain | The `pkauth.relying-party.id` config and the page's actual host | +| 4xx on `authentication.finish` with `counter_regression` | A counter wound back: either a credential clone or a counter-0 (synced) passkey crossing devices | The credential's `backupEligible` flag; if true, consider switching the policy to `warn` | +| `Challenge expired` 4xx | Five-minute default TTL elapsed | Often a slow user; the remedy is to re-issue `start`, not to extend the TTL | +| DynamoDB `ConditionalCheckFailedException` on `takeOnce` | Two clients tried to consume the same challenge | Expected; only one succeeds. If the rate is high, the client, for double-submit | +| Spring Security 7 chain mounts before the pk-auth filter | Filter order regression | The starter's `pkAuthSecurityFilterChain` bean (`PkAuthWebAutoConfiguration`, `@Order(HIGHEST_PRECEDENCE + 10)`) has the higher precedence in the host's chain | ## 7. Disabling the admin endpoints -The account-admin surface (`/auth/admin/**` — list / rename / delete passkeys, -regenerate backup codes, email / phone verification) lives in the optional -`com.codeheadsystems:pk-auth-admin-api` module. If a deployment drives those -operations out-of-band (an internal console, a separate service) and wants a -smaller public HTTP surface, the admin endpoints can be turned **off by -configuration alone — no source changes to pk-auth**. In every adapter the rule -is the same: the admin routes mount **only when `pk-auth-admin-api` is on the -runtime classpath**. Leave it off and no `/auth/admin/**` routes are registered -(requests get a clean 404); the ceremony, JWT, and refresh endpoints are -unaffected. - -| Adapter | How admin is wired | To disable | +The account-admin surface (`/auth/admin/**`: list, rename, and delete passkeys, regenerate backup +codes, email and phone verification) lives in the optional `com.codeheadsystems:pk-auth-admin-api` +module. A deployment that drives those operations out-of-band (an internal console or a separate +service) and wants a smaller public HTTP surface turns the admin endpoints off by configuration +alone, with no source changes to pk-auth. In every adapter the rule is the same: the admin routes +mount only when `pk-auth-admin-api` is on the runtime classpath. When it is absent, no +`/auth/admin/**` routes are registered (requests get a clean 404), and the ceremony, JWT, and +refresh endpoints are unaffected. + +| Adapter | How admin is wired | Disabling | |---|---|---| -| Spring Boot (`pk-auth-spring-boot-starter`) | `pk-auth-admin-api` is `compileOnly`; `PkAuthAdminAutoConfiguration` is `@ConditionalOnClass(AdminService)` | Do not add `pk-auth-admin-api` as a runtime dependency (the starter does not pull it transitively) | -| Dropwizard (`pk-auth-dropwizard`) | `pk-auth-admin-api` is `compileOnly`; the bundle mounts `PkAuthAdminResource` only when admin is wired | Omit `pk-auth-admin-api`, **or** register the bundle with the no-admin constructor `new PkAuthBundle(persistence)` | -| Micronaut (`pk-auth-micronaut`) | `pk-auth-admin-api` is `compileOnly`; `PkAuthAdminFactory` is `@Requires(classes = AdminService.class)` and `PkAuthAdminController` is `@Requires(beans = AdminService.class)` | Do not add `pk-auth-admin-api` as a runtime dependency (the adapter does not pull it transitively) | - -For Maven/Gradle consumers this is purely a dependency decision in the host -application — the admin module is opt-in. The three example apps under -`examples/` declare `pk-auth-admin-api` explicitly because they exercise the full -admin walkthrough; a production host that wants the ceremony surface only simply -leaves that line out. - -> **Note (Micronaut).** Keep `PkAuthAdminFactory` (the `@Requires`-gated -> `AdminService` bean) separate from `PkAuthFactory`. Because Micronaut's -> generated bean definition for a `@Factory` references the return types of its -> factory methods, hosting the optional `AdminService` bean on the main factory -> would make `PkAuthFactory` unloadable when `pk-auth-admin-api` is absent -> (`NoClassDefFoundError` on the first ceremony request). The split keeps the -> always-on factory free of any reference to the optional module. - -## 8. Transport security & post-quantum (harvest-now, decrypt-later) - -pk-auth's own primitives are post-quantum aware (see `docs/threat-model.md` → -*Post-quantum readiness*), but the one genuine **harvest-now, decrypt-later** risk -in a real deployment is **outside this library**: an adversary who records your -**TLS** sessions today can decrypt them later once a cryptographically-relevant -quantum computer (CRQC) breaks the classical key exchange that protected them. Those -sessions carry pk-auth's bearer material in transit — access JWTs, refresh tokens, -magic-link URLs, OTP codes. Unlike a WebAuthn assertion (challenge–response, nothing -to harvest), a recorded ciphertext *is* harvestable. - -TLS is terminated at your edge — load balancer, reverse proxy, or CDN — **not** in -pk-auth. So the mitigation is an **operator action**, not a library change: - -- **Enable a hybrid post-quantum key exchange at the TLS terminator / CDN.** The - current deployable standard is the hybrid group **`X25519MLKEM768`** (X25519 + - ML-KEM-768 / FIPS 203), which stays classically secure even if the PQC half is - later faulted, and is already supported by recent OpenSSL/BoringSSL, major CDNs, - and current browsers. Configure it wherever you terminate TLS: - - Nginx/OpenSSL 3.5+: include the hybrid group in `ssl_ecdh_curve` / - `Groups`/`Curves` (e.g. `X25519MLKEM768:X25519`). - - Behind a CDN (Cloudflare, etc.): enable post-quantum / hybrid key agreement in - the edge TLS settings. -- **Keep token TTLs short.** A short access-token TTL (default 1 hour) and rotating - refresh tokens bound the value of any session an attacker does eventually decrypt. - -This does not change anything pk-auth signs or stores; it hardens the channel the -tokens travel over. There is no pk-auth setting for it — it lives entirely in your -TLS-terminating layer. +| Spring Boot (`pk-auth-spring-boot-starter`) | `pk-auth-admin-api` is `compileOnly`; `PkAuthAdminAutoConfiguration` is `@ConditionalOnClass(AdminService)` | `pk-auth-admin-api` absent from the runtime dependencies (the starter does not pull it transitively) | +| Dropwizard (`pk-auth-dropwizard`) | `pk-auth-admin-api` is `compileOnly`; the bundle mounts `PkAuthAdminResource` only when admin is wired | `pk-auth-admin-api` omitted, or the bundle registered with the no-admin constructor `new PkAuthBundle(persistence)` | +| Micronaut (`pk-auth-micronaut`) | `pk-auth-admin-api` is `compileOnly`; `PkAuthAdminFactory` is `@Requires(classes = AdminService.class)` and `PkAuthAdminController` is `@Requires(beans = AdminService.class)` | `pk-auth-admin-api` absent from the runtime dependencies (the adapter does not pull it transitively) | + +For Maven and Gradle consumers this is purely a dependency decision in the host application, because +the admin module is opt-in. The three example apps under `examples/` declare `pk-auth-admin-api` +explicitly because they exercise the full admin walkthrough. A production host that wants the +ceremony surface only leaves that line out. + +### Micronaut factory split + +`PkAuthAdminFactory` (the `@Requires`-gated `AdminService` bean) is kept separate from +`PkAuthFactory`. Micronaut's generated bean definition for a `@Factory` references the return types +of its factory methods, so hosting the optional `AdminService` bean on the main factory would make +`PkAuthFactory` unloadable when `pk-auth-admin-api` is absent (`NoClassDefFoundError` on the first +ceremony request). The split keeps the always-on factory free of any reference to the optional +module. + +## 8. Transport security and post-quantum exposure + +pk-auth's own primitives are post-quantum aware (see the "Post-quantum readiness" section of +`docs/threat-model.md`), but the one **harvest-now, decrypt-later** risk in a real +deployment lies outside this library. An adversary who records TLS sessions today can decrypt them +later, once a cryptographically relevant quantum computer (CRQC) breaks the classical key exchange +that protected them. Those sessions carry pk-auth's bearer material in transit: access JWTs, +refresh tokens, magic-link URLs, and OTP codes. A WebAuthn assertion (challenge-response) leaves +nothing to harvest, but a recorded ciphertext is harvestable. + +TLS terminates at the edge (load balancer, reverse proxy, or CDN), not in pk-auth. The mitigation is +therefore an operator action and not a library change: + +1. Enable a hybrid post-quantum key exchange at the TLS terminator or CDN. The current deployable + standard is the hybrid group `X25519MLKEM768` (X25519 plus ML-KEM-768 / FIPS 203), which stays + classically secure even if the PQC half is later faulted, and which recent OpenSSL and BoringSSL, + major CDNs, and current browsers already support. The setting goes wherever TLS terminates: + - Nginx/OpenSSL 3.5+: include the hybrid group in `ssl_ecdh_curve` / `Groups`/`Curves` (for + example `X25519MLKEM768:X25519`). + - Behind a CDN (Cloudflare, etc.): enable post-quantum or hybrid key agreement in the edge TLS + settings. +2. Keep token TTLs short. A short access-token TTL (default 1 hour) and rotating refresh tokens + bound the value of any session an attacker does eventually decrypt. + +This does not change anything pk-auth signs or stores; it hardens the channel the tokens travel +over. No pk-auth setting controls it, because it lives entirely in the TLS-terminating layer. ## 9. Threat model diff --git a/docs/stability.md b/docs/stability.md index f9c55b87..603e8c45 100644 --- a/docs/stability.md +++ b/docs/stability.md @@ -2,32 +2,31 @@ ## Current status: 2.x -pk-auth is **post-1.0** as of the `v1.0.0` tag. The current development line is -the `version` in `gradle.properties` (a `2.x` `-SNAPSHOT`); the 1.1, 1.2, 1.3, -and 2.0 releases have shipped. See [`CHANGELOG.md`](../CHANGELOG.md) for the -per-release delta. +pk-auth is post-1.0 as of the `v1.0.0` tag. The current development line is the `version` in +`gradle.properties` (a `2.x` `-SNAPSHOT`); the 1.1, 1.2, 1.3, and 2.0 releases have shipped. See +[`CHANGELOG.md`](../CHANGELOG.md) for the per-release delta. Post-1.0, pk-auth follows [Semantic Versioning](https://semver.org/): | Change type | Version bump | |---|---| -| Backwards-compatible additions to SPIs or REST wire format | MINOR (2.x) | -| Breaking change to any SPI method signature, removal of a method, or breaking wire-format change | MAJOR (2.0, 3.0, …) | -| Bug fixes that do not alter the public API | PATCH (2.x.y) | +| Backwards-compatible additions to SPIs or REST wire format | Minor (2.x) | +| Breaking change to any SPI method signature, removal of a method, or breaking wire-format change | Major (2.0, 3.0, …) | +| Bug fixes that do not alter the public API | Patch (2.x.y) | -Configuration key renames or removals are treated as breaking changes and -require a MAJOR bump. +Configuration key renames or removals are breaking changes and require a major bump. -> **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 landed -> together at 1.1.0; see `CHANGELOG.md` for the migration notes. From -> 1.1.0 onward the SemVer contract above is binding. +### 1.1 migration -Pin to an exact version in your build file and review the changelog before -upgrading: +The 1.1.0 release carried a small number of 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 landed together at 1.1.0; see `CHANGELOG.md` for the +migration notes. From 1.1.0 onward the SemVer contract above is binding. + +### Pinning + +Consumers pin an exact version in the build file and review the changelog before upgrading: ```kotlin // build.gradle.kts @@ -36,33 +35,33 @@ implementation("com.codeheadsystems:pk-auth-core:2.x.y") ## SPI surfaces -The following interfaces are the **extension points** that host applications -implement. All are covered by the SemVer contract above (currently the 2.x line). +The following interfaces are the extension points that host applications implement. All are +covered by the SemVer contract above (currently the 2.x line). | SPI interface | Module | Purpose | |---|---|---| -| `CredentialRepository` | `pk-auth-core` | Store and retrieve WebAuthn credential records. 1.1.0 adds `deleteByUserHandle` (breaking — see `CHANGELOG.md`). | +| `CredentialRepository` | `pk-auth-core` | Store and retrieve WebAuthn credential records. 1.1.0 adds `deleteByUserHandle` (breaking; see `CHANGELOG.md`). | | `UserLookup` | `pk-auth-core` | Resolve a username to an internal user handle | | `ChallengeStore` | `pk-auth-core` | Issue and atomically consume ceremony challenges | | `BackupCodeRepository` | `pk-auth-core` (`spi`; implemented by the persistence modules, consumed by `pk-auth-backup-codes`) | Store and verify Argon2id-hashed backup codes | -| `OtpRepository` | `pk-auth-core` (`spi`; implemented by the persistence modules, consumed by `pk-auth-otp`) | Store and verify time-limited OTP codes. 1.1.0 adds `deleteByUserHandle` (breaking — see `CHANGELOG.md`). | +| `OtpRepository` | `pk-auth-core` (`spi`; implemented by the persistence modules, consumed by `pk-auth-otp`) | Store and verify time-limited OTP codes. 1.1.0 adds `deleteByUserHandle` (breaking; see `CHANGELOG.md`). | | `EmailSender` | `pk-auth-magic-link` | Dispatch magic-link emails | | `SmsSender` | `pk-auth-otp` | Dispatch phone OTP messages | -| `AccessTokenStore` | `pk-auth-jwt` *(1.1.0)* | Persist every issued JWT JTI for stateful (server-revocable) access tokens. Default `noop()` preserves stateless behaviour. See ADR 0015. | +| `AccessTokenStore` | `pk-auth-jwt` (1.1.0) | Persist every issued JWT JTI for stateful (server-revocable) access tokens. Default `noop()` preserves stateless behaviour. See ADR 0015. | | `RevocationCheck` | `pk-auth-jwt` | In-process deny-list for valid JWTs that should be rejected. Orthogonal to `AccessTokenStore`. | -| `TokenTtlPolicy` | `pk-auth-jwt` *(1.1.0)* | Per-audience access-token TTL dispatch. `TokenTtlPolicy.single(ttl)` and `.fixed(default, overrides)` cover the common cases. See ADR 0014. | -| `RefreshTokenRepository` | `pk-auth-refresh-tokens` *(1.1.0)* | Storage SPI for the rotating refresh-token primitive. The `rotateAtomically` method is contractually required to mark-used + insert-successor atomically. See ADR 0013. | -| `UserDeletionListener` | `pk-auth-core` *(1.1.0)* | Hook for the `UserDeletionService` fan-out; library-shipped listeners cover credentials, backup codes, OTPs, access tokens, and refresh tokens. See ADR 0016. | +| `TokenTtlPolicy` | `pk-auth-jwt` (1.1.0) | Per-audience access-token TTL dispatch. `TokenTtlPolicy.single(ttl)` and `.fixed(default, overrides)` cover the common cases. See ADR 0014. | +| `RefreshTokenRepository` | `pk-auth-refresh-tokens` (1.1.0) | Storage SPI for the rotating refresh-token primitive. The contract requires `rotateAtomically` to mark the parent used and insert the successor atomically. See ADR 0013. | +| `UserDeletionListener` | `pk-auth-core` (1.1.0) | Hook for the `UserDeletionService` fan-out; library-shipped listeners cover credentials, backup codes, OTPs, access tokens, and refresh tokens. See ADR 0016. | | `ConsumedJtiStore` | `pk-auth-core` | Mark magic-link JTIs consumed across replicas; in-memory default ships as Caffeine cache | | `CeremonyRateLimiter` | `pk-auth-core` | Per-IP / per-username throttle on WebAuthn `start*` / `finish*` endpoints; in-memory default ships | ## Configuration keys -All configuration lives under the `pkauth.*` prefix. Renamed keys will be kept -as deprecated aliases for at least one minor release before removal. +All configuration lives under the `pkauth.*` prefix. Renamed keys are kept as deprecated aliases +for at least one minor release before removal. ## Wire format (REST + TypeScript SDK) The REST API shape (`/auth/**` routes) and the TypeScript SDK (`@pk-auth/passkeys-browser`) are versioned together. Breaking REST changes -require a MAJOR bump, the same as SPI changes. +require a major bump, the same as SPI changes. diff --git a/docs/style.md b/docs/style.md new file mode 100644 index 00000000..74096d67 --- /dev/null +++ b/docs/style.md @@ -0,0 +1,83 @@ +# Documentation style + +The register, the emphasis rules, and the punctuation conventions every document under `docs/` is +written to, together with the terminology it uses. A reference manual describes a system; it does +not argue for one. + +Most of what follows is subtractive. Where a rule and a habit disagree, the rule wins and the habit +is the defect. + +## Scope + +Everything by CLAUDE.md is in scope, including javadoc and the site. + +## Register + +Documentation is written in the third person, in the present tense, about the system. + +- The subject of a sentence is the thing being described, not the reader and not the author. Write + "a `PUT` raises a proposal", not "you will get a proposal back". +- Second person is correct in two places. The first is the numbered steps of a procedure, where the + imperative is the clearest form: "Upload the bundle." "Roll the service." Everything around those + steps returns to the third person. +- Do not address the reader's expectations, assumptions, or feelings. "That is the platform working, + not failing" and "worth knowing without opening it" describe a conversation rather than a system. +- Do not write in the first person, singular or plural. The documentation has no narrator. + +## Emphasis + +Bold marks the first occurrence of a defined term in the document that defines it. It has no other +use. Bold applied to a clause for stress has a cumulative effect: where a fifth of the text is +emphasised, emphasis carries no information. + +- No bold for stress, contrast, warning, or surprise. +- No capitalised words for stress: `NOT`, `ONE`, `NEVER`, `ALWAYS`, `TENANT`. Capitals are for + acronyms, HTTP methods, environment variables, enum constants, and other identifiers that are + genuinely spelled that way. +- No italics for stress. Italics mark a term quoted as a term, and little else. + +Where a fact is important, give it its own sentence, its own paragraph, or its own heading. Position +carries emphasis in a reference manual; typography does not. + +## Headings + +A heading is an index entry and a link target. It labels the material beneath it. + +- Write a noun phrase. "Scope resolution", not "`governanceRole` is read identically at both scopes, + and the route is what distinguishes them". +- No commas, no conjunctions joining two clauses, no question forms, no verbs of judgement + ("deliberately", "on purpose", "worth", "why"). +- Eight words is the practical ceiling, and twelve is the limit, counting a code span as one word. +- Headings are link anchors. Renaming one is an interface change: every cross-reference to the old + anchor is updated in the same commit. + +## Justification + +State what the system does. Explain a decision only where a reader who does not know it would draw a +wrong conclusion, and then explain it plainly, in its own sentence. + +- Do not defend a design against an imagined objection. "That is deliberate rather than unfinished", + "rather than an oversight" and "this is the design working" all answer a criticism nobody reading + a manual has made. +- Do not certify a claim's provenance in passing. "Measured, not reasoned about", "read out of the + source", "and none of it is hedged" and "stated as a position" are assurances about the author's + diligence. Where provenance genuinely matters, such as a benchmark's conditions, it is content: + give it a sentence that says what was measured, on what, and when. +- Do not write about the document. A document does not explain why it exists, why it is separate + from another document, how many times it has been corrected, or what it is not. Routing belongs in + the directory's entry point. + +## Punctuation and mechanics + +- No em dashes and no spaced double hyphens. Use a comma, a semicolon, a colon, parentheses, or + two sentences. +- Use a serial comma. +- British spelling in prose: `behaviour`, `serialise`, `materialise`. Technical terms keep the + spelling of the thing they name: the HTTP header is `Authorization`, and an identifier is quoted + exactly as the source spells it. +- Code formatting for anything a machine reads: identifiers, file paths, property keys, environment + variables, HTTP methods and paths, status codes, literal values. +- Wrap prose at 100 columns, matching the rest of the corpus. +- Tables take a header row that names the columns. A table is for material with a repeating shape; + prose with pipes in it is not a table. + diff --git a/docs/threat-model.md b/docs/threat-model.md index a235d617..0d851eca 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -1,8 +1,7 @@ # pk-auth Threat Model -Scope: the pk-auth library, its three host adapters, and the wire contract its TS -SDK consumes. This document is **not** a deployment review — host applications still -own their TLS, network ingress, secrets-management, and authorization layers. +Scope: the pk-auth library, its three host adapters, and the wire contract its TS SDK consumes. +Host applications own their TLS, network ingress, secrets management, and authorisation layers. ## Trust boundaries @@ -18,21 +17,20 @@ own their TLS, network ingress, secrets-management, and authorization layers. SIEM / metrics ``` -Boundaries cross-checked in this model: -1. **Browser ↔ adapter** — untrusted client. WebAuthn assertions and admin calls. -2. **Adapter ↔ persistence** — trusted; assumes operator runs Postgres/DynamoDB on - a private network. -3. **Adapter ↔ secrets / KMS** — trusted; assumes the host injects credentials at - boot. -4. **Source ↔ build/distribution pipeline** — semi-trusted. The artifacts pk-auth - ships (Maven Central jars, the npm SDK) are only as trustworthy as the - dependencies, build tools, and CI actions that produce them. See - [Supply chain](#supply-chain). -5. **Human ↔ AI coding agent** — semi-trusted, and the most unusual boundary here. - Substantially all of this code was written by an AI agent (see the README - disclaimer). The agent is unaccountable, can be steered by the untrusted content - it reads, and produces code faster than it can be deeply reviewed. Its output is - gated only by human review. See [AI-authored code (provenance)](#ai-authored-code-provenance). +The model covers five boundaries: + +1. Browser ↔ adapter: untrusted client. WebAuthn assertions and admin calls. +2. Adapter ↔ persistence: trusted; assumes the operator runs Postgres or DynamoDB on a private + network. +3. Adapter ↔ secrets / KMS: trusted; assumes the host injects credentials at boot. +4. Source ↔ build/distribution pipeline: semi-trusted. The artefacts pk-auth ships (Maven Central + jars, the npm SDK) are only as trustworthy as the dependencies, build tools, and CI actions that + produce them. See [Supply chain](#supply-chain). +5. Human ↔ AI coding agent: semi-trusted, and the most unusual boundary here. Substantially all of + this code was written by an AI agent (see the README disclaimer). The agent is unaccountable, can + be steered by the untrusted content it reads, and produces code faster than it can be deeply + reviewed. Its output is gated only by human review. See + [AI-authored code (provenance)](#ai-authored-code-provenance). ## STRIDE pass @@ -40,11 +38,11 @@ Boundaries cross-checked in this model: | Threat | Mitigation | |---|---| -| Attacker forges a WebAuthn assertion | EC P-256/RSA signature verified against the registered public key via WebAuthn4J. Attestation conveyance is `NONE` by default; the default manager validates client data, origin, RP-ID, and assertion signatures but does NOT verify attestation statements. Hosts requiring attestation verification must opt into the strict manager (open finding). Counter regression catches some clone scenarios. | +| Attacker forges a WebAuthn assertion | EC P-256/RSA signature verified against the registered public key via WebAuthn4J. Attestation conveyance is `NONE` by default; the default manager validates client data, origin, RP-ID, and assertion signatures but does not verify attestation statements. Hosts requiring attestation verification must opt into the strict manager (open finding). Counter regression catches some clone scenarios. | | Attacker forges a JWT | HS256 signature with ≥ 32-byte secret. JWT verification enforces issuer, audience, expiry. | -| Attacker spoofs the relying party | RP ID and origin are checked server-side on every `finish` call — config-driven allow-list (`pkauth.relying-party.origins`). Cross-origin attempts are rejected with `origin_mismatch`. | +| Attacker spoofs the relying party | RP ID and origin are checked server-side on every `finish` call, using a config-driven allow-list (`pkauth.relying-party.origins`). Cross-origin attempts are rejected with `origin_mismatch`. | | Attacker reuses a backup code | Backup codes are Argon2id-hashed, single-use, and atomically claimed inside the SPI (`atomic claim or fail`). | -| Attacker presents a passkey the legitimate user never consciously authorized (lost/borrowed device with a key already enrolled, or a key with no user-presence factor) | User Verification defaults to `REQUIRED` (`CeremonyConfig.defaults()`), so WebAuthn4J enforces the asserted `flagUV` on every ceremony — the authenticator must prove a per-ceremony biometric or PIN, making the passkey a genuine "something you have **and** something you are/know" factor. Relaxing UV to `PREFERRED` or `DISCOURAGED` removes that guarantee: a present-but-unverified authenticator (mere user-presence, no biometric/PIN) is accepted, so a passkey degrades to "something you have" alone. Do not relax UV unless a deployment specifically needs UV-incapable hardware security keys, and understand it weakens the factor for everyone. | +| Attacker presents a passkey the legitimate user never consciously authorised (lost/borrowed device with a key already enrolled, or a key with no user-presence factor) | User Verification defaults to `REQUIRED` (`CeremonyConfig.defaults()`), so WebAuthn4J enforces the asserted `flagUV` on every ceremony. The authenticator must prove a per-ceremony biometric or PIN, making the passkey a genuine factor: something the user has plus something the user is or knows. Relaxing UV to `PREFERRED` or `DISCOURAGED` removes that guarantee: a present-but-unverified authenticator (mere user-presence, no biometric or PIN) is accepted, so a passkey degrades to something the user has alone. The relaxation fits only a deployment that specifically needs UV-incapable hardware security keys, and it weakens the factor for every user. | ### Tampering @@ -66,245 +64,246 @@ Boundaries cross-checked in this model: | Threat | Mitigation | |---|---| | Disclose a credential's public key | Public keys are public. Their disclosure leaks no authentication material. | -| Disclose backup codes | Backup codes are returned plaintext **exactly once** at regeneration time. The server only retains Argon2id hashes. | -| Disclose magic-link tokens | A magic-link token is a signed single-use HS256 JWT (it carries a `pkauth.purpose` claim and is bound by the HS256 signature — not a random value stored hashed). It is never persisted: single-use is enforced by recording its JTI in a `ConsumedJtiStore` after redemption (JWT TTL 15m, consumed-JTI TTL 30m). The token only exists in the outbound email on the dispatcher path. Use a real email sender in production, and inject a shared `ConsumedJtiStore` for multi-replica deployments so a token can't be replayed across replicas. | -| Disclose JWT secret via logs | The starter never logs `pkauth.jwt.secret`. Other secrets (Argon2 pepper, RP origins) are bound only via env vars; do not echo them in `--debug` traces. | -| Enumerate usernames | `/auth/passkeys/registration/start` returns the same shape for any username (a fresh user handle is created); `/auth/passkeys/authentication/start` does not leak existence — it returns an `allowCredentials` list that an unknown user would receive empty. Avoid mounting routes that surface user existence (e.g. "/users/exists"). | +| Disclose backup codes | Backup codes are returned plaintext exactly once, at regeneration time. The server only retains Argon2id hashes. | +| Disclose magic-link tokens | A magic-link token is a signed single-use HS256 JWT (it carries a `pkauth.purpose` claim and is bound by the HS256 signature, and is not a random value stored hashed). It is never persisted: single-use is enforced by recording its JTI in a `ConsumedJtiStore` after redemption (JWT TTL 15m, consumed-JTI TTL 30m). The token only exists in the outbound email on the dispatcher path. Production uses a real email sender, and multi-replica deployments inject a shared `ConsumedJtiStore` so a token cannot be replayed across replicas. | +| Disclose JWT secret via logs | The starter never logs `pkauth.jwt.secret`. Other secrets (Argon2 pepper, RP origins) are bound only via env vars and must not be echoed in `--debug` traces. | +| Enumerate usernames | `/auth/passkeys/registration/start` returns the same shape for any username (a fresh user handle is created); `/auth/passkeys/authentication/start` does not leak existence, because it returns an `allowCredentials` list that an unknown user would receive empty. Routes that surface user existence (for example `/users/exists`) are not mounted. | ### Denial of Service | Threat | Mitigation | |---|---| | Flood challenges | Challenges expire in 5 minutes by default and are stored by `ChallengeId`. pk-auth ships a `CeremonyRateLimiter` SPI (with an in-memory Caffeine-backed default) keyed on client IP and username; the `start*` / `finish*` endpoints short-circuit with `429 Retry-After` when the limiter denies. For multi-replica deployments, replace the default with a shared-store implementation. Heavy floods should still be filtered at the host's WAF / API gateway upstream of the adapter. | -| Hash-burn via repeated backup-code attempts | Backup codes are Argon2id-hashed (intentionally CPU-heavy), so a wrong code costs the attacker as much as a right one. The SPIs ship per-credential attempt counters; the magic-link and backup-code modules also ship per-user sliding-window rate limiters (in-memory defaults, override for multi-replica). | -| Brute-force / flood of OTP attempts | OTP codes are hashed with cheap HMAC-SHA256, **not** a CPU-heavy KDF — the 10^6 code space makes hash cost pointless, so the hash burn argument does **not** apply here. The defence is instead the per-credential attempt cap (default 5 verifies per code, enforced atomically in the repository) plus the per-(user, phone) send rate limiter (default 3 codes per 15-minute window). A wrong code is cheap to check but exhausts the cap quickly, and the rate limiter bounds how many fresh codes an attacker can mint. | -| Exhaust DB connections | Connection pooling is the host's responsibility. Recommend HikariCP for JDBI, the AWS SDK's default for DynamoDB. | +| Hash-burn via repeated backup-code attempts | Backup codes are Argon2id-hashed (CPU-heavy), so a wrong code costs the attacker as much as a right one. The SPIs ship per-credential attempt counters; the magic-link and backup-code modules also ship per-user sliding-window rate limiters (in-memory defaults, override for multi-replica). | +| Brute-force / flood of OTP attempts | OTP codes are hashed with cheap HMAC-SHA256, not a CPU-heavy KDF. The 10^6 code space makes hash cost pointless, so the hash-burn argument does not apply here. The defence is instead the per-credential attempt cap (default 5 verifies per code, enforced atomically in the repository) plus the per-(user, phone) send rate limiter (default 3 codes per 15-minute window). A wrong code is cheap to check but exhausts the cap quickly, and the rate limiter bounds how many fresh codes an attacker can mint. | +| Exhaust DB connections | Connection pooling is the host's responsibility. HikariCP is recommended for JDBI, and the AWS SDK's default for DynamoDB. | ### Elevation of Privilege | Threat | Mitigation | |---|---| -| Use a backup code to register a new passkey for someone else's account | Backup codes authenticate the holder; the admin API still requires a JWT for the *acting* user, and rename/delete enforce `actor == owner`. A backup code alone cannot register a passkey on a different account — only re-bootstrap the existing one. | -| Use a stolen JWT to act as another user | JWTs are stateless and bearer; mitigation lives at the transport layer (HTTPS) and TTL (default 1 hour). Do not extend TTL without offsetting it with explicit revocation. Hosts requiring revocation (logout-all, account-disable) should implement the `RevocationCheck` SPI in `pk-auth-jwt`. | -| Delete every passkey to lock yourself in / out | `AdminService.deleteCredential` enforces a "last-credential guard" returning 409 Conflict when a delete would leave zero credentials. Backup codes remain available as a recovery path. | +| Use a backup code to register a new passkey for someone else's account | Backup codes authenticate the holder; the admin API still requires a JWT for the acting user, and rename/delete enforce `actor == owner`. A backup code alone cannot register a passkey on a different account; it can only re-bootstrap the existing one. | +| Use a stolen JWT to act as another user | JWTs are stateless and bearer; mitigation lives at the transport layer (HTTPS) and TTL (default 1 hour). Extending the TTL requires offsetting it with explicit revocation. Hosts requiring revocation (logout-all, account-disable) should implement the `RevocationCheck` SPI in `pk-auth-jwt`. | +| Delete every passkey to lock the user in or out | `AdminService.deleteCredential` enforces a "last-credential guard" returning 409 Conflict when a delete would leave zero credentials. Backup codes remain available as a recovery path. | | Cross-tenant access | The library is single-tenant. Multi-tenant deployments need an outer key (e.g. `tenantId`) routed at the host. pk-auth does not provide one. | -## Known limitations (in scope but not solved) +## Known limitations -- **Attestation policy**: attestation conveyance is `NONE` by default. The default manager validates client data, origin, RP-ID, and assertion signatures but does NOT verify attestation statements. Hooks exist for MDS3 / metadata-service validation (`AttestationTrustPolicy`), but no MDS3 fetcher is bundled. Sites with FIDO attestation requirements must opt into the strict manager and implement their own MDS3 fetcher. -- **Counter regression**: rejected by default. Configurable to `warn` for sites that primarily - expect counter-0 synced passkeys (Apple/Google ecosystems). Switching to `warn` weakens the +- Attestation policy: attestation conveyance is `NONE` by default. The default manager validates + client data, origin, RP-ID, and assertion signatures but does not verify attestation statements. + Hooks exist for MDS3 / metadata-service validation (`AttestationTrustPolicy`), but no MDS3 fetcher + is bundled. Sites with FIDO attestation requirements must opt into the strict manager and + implement their own MDS3 fetcher. +- Counter regression: rejected by default. Configurable to `warn` for sites that primarily expect + counter-0 synced passkeys (Apple/Google ecosystems). Switching to `warn` weakens the clone-detection signal. -- **JWT revocation**: JWTs are valid until `exp` by default. The `RevocationCheck` SPI (added in `pk-auth-jwt`) allows hosts to implement revocation (logout-all, account-disable). Without it, accept the TTL bound on session length and prefer short TTLs. -- **Passkey export / migration**: not the library's concern. The user's authenticator owns the - key material; pk-auth never sees it. +- JWT revocation: JWTs are valid until `exp` by default. The `RevocationCheck` SPI (added in + `pk-auth-jwt`) allows hosts to implement revocation (logout-all, account-disable). Without it, the + TTL bounds session length, so short TTLs are preferred. +- Passkey export and migration: not the library's concern. The user's authenticator owns the key + material, and pk-auth never sees it. ## Post-quantum readiness -This section states the project's posture honestly: pk-auth is **post-quantum *aware* and -crypto-agile where it can be**, but it cannot ship a post-quantum *passkey signature* today -because none exists in the stack it sits on. The goal here is accurate framing plus the -agility hooks that make a future migration a configuration change, not a rewrite. - -### What is Shor-vulnerable, and why this library can't fix it yet - -The passkey signature algorithms — ES256 (ECDSA P-256), EdDSA (Ed25519), and RS256/RS384 -(RSA) — all rest on problems (discrete log, factoring) that a cryptographically-relevant -quantum computer (CRQC) running Shor's algorithm would break. None exists today, and the -timeline is uncertain, but the threat is real for long-lived public keys. - -The library **cannot** unilaterally fix this. The signature algorithm of a passkey is gated, -end to end, by layers pk-auth does not own: - -- the **authenticator hardware / secure element** (what keypairs it can generate), -- **CTAP2 / FIDO2** (what the authenticator and platform negotiate), -- **WebAuthn / the COSE algorithm registry** (what `PublicKeyCredentialParameters.alg` - values are even defined), and -- **WebAuthn4J** (what this library's verifier can actually validate). - -No post-quantum signature scheme (e.g. an ML-DSA / FIPS 204 COSE binding) is standardized -across that chain or implemented by WebAuthn4J as of this writing. pk-auth therefore does -**not** invent COSE identifiers or advertise algorithms nothing in the ecosystem can produce -or verify. When the ecosystem standardizes one, adding it is a single new constant in -`CoseAlgorithm` plus its WebAuthn4J mapping — by design (see [crypto-agility hooks](#crypto-agility-hooks-present-today)). - -### Why the exposure is bounded - -WebAuthn is a **challenge–response** protocol, not encryption. An assertion proves possession -of a private key *now*, against a fresh server-issued challenge; there is no long-lived -ciphertext for an attacker to harvest and decrypt later. A CRQC does **not** retroactively -break past authentications. - -The real long-term liability is the **stored public key**: once a CRQC exists, an attacker who -has the public key (public by definition) could forge assertions for that credential. The -mitigation is re-enrollment onto a post-quantum algorithm *before* a CRQC arrives — which is -exactly what the per-credential algorithm visibility below is for. (The genuine -harvest-now/decrypt-later risk in a deployment is recorded **TLS** sessions carrying bearer -tokens, which is terminated outside this library — see the operator guide.) - -### Crypto-agility hooks present today - -Concrete seams added so a migration is configuration, not surgery (ADR 0019): - -- **Single injectable COSE algorithm list.** `CeremonyConfig.offeredAlgorithms` (advertised in - create-options) and `CeremonyConfig.acceptedAlgorithms` (enforced on verify) are the one - source of truth, expressed in the framework-neutral `CoseAlgorithm` enum. Both the - create-options ceremony and the WebAuthn4J verify path derive their lists from this config — - the two formerly-divergent hardcoded lists are gone. The accepted list is authoritative; - the offered list may be a narrower subset, and an operator can narrow *either* without code - changes. The default accepted set is the **union** of everything historically accepted - (ES256, EdDSA, RS256, ES384, RS384), so no already-registered credential can fail - verification. -- **Per-credential algorithm visibility.** `CredentialAlgorithms.coseAlgorithm(record)` decodes - the COSE algorithm already stored on every credential (no schema change), and - `AdminService.listCredentialsByAlgorithm(actor, target, coseAlgorithm)` reports which of a - user's credentials use a given (e.g. quantum-vulnerable) algorithm — the read side a future - re-enrollment campaign drives off. -- **A symmetric-resistant JWT path.** The HS256 post-ceremony token (with the enforced - ≥ 256-bit key) is HMAC-SHA256, which Shor does not break; see [Symmetric primitives](#symmetric-primitives-already-quantum-conservative). - -### Symmetric primitives already quantum-conservative +pk-auth is post-quantum aware and crypto-agile where it can be, but it cannot ship a post-quantum +passkey signature today because none exists in the stack it sits on. The agility hooks make a future +migration a configuration change, not a rewrite. + +### Shor-vulnerable algorithms + +The passkey signature algorithms, ES256 (ECDSA P-256), EdDSA (Ed25519), and RS256/RS384 (RSA), all +rest on problems (discrete log, factoring) that a cryptographically relevant quantum computer (CRQC) +running Shor's algorithm would break. No such computer exists today, and the timeline is uncertain, +but the threat is real for long-lived public keys. + +The library cannot unilaterally fix this. The signature algorithm of a passkey is gated, end to end, +by layers pk-auth does not own: + +- the authenticator hardware or secure element (what keypairs it can generate), +- CTAP2 / FIDO2 (what the authenticator and platform negotiate), +- WebAuthn and the COSE algorithm registry (which `PublicKeyCredentialParameters.alg` values are + defined), and +- WebAuthn4J (what this library's verifier can actually validate). + +No post-quantum signature scheme (for example an ML-DSA / FIPS 204 COSE binding) is standardised +across that chain or implemented by WebAuthn4J at present. pk-auth therefore does not invent COSE +identifiers or advertise algorithms nothing in the ecosystem can produce or verify. When the +ecosystem standardises one, adding it is a single new constant in `CoseAlgorithm` plus its +WebAuthn4J mapping (see [crypto-agility hooks](#crypto-agility-hooks)). + +### Bounded exposure + +WebAuthn is a challenge-response protocol, not encryption. An assertion proves possession of a +private key at that moment, against a fresh server-issued challenge. No long-lived ciphertext exists +for an attacker to harvest and decrypt later, and a CRQC does not retroactively break past +authentications. + +The real long-term liability is the stored public key. Once a CRQC exists, an attacker who has the +public key (public by definition) could forge assertions for that credential. The mitigation is +re-enrolment onto a post-quantum algorithm before a CRQC arrives, which is what the per-credential +algorithm visibility below supports. The harvest-now, decrypt-later risk in a deployment is +recorded TLS sessions carrying bearer tokens. TLS terminates outside this library; see the +[operator guide](./operator-guide.md#8-transport-security-and-post-quantum-exposure). + +### Crypto-agility hooks + +The following seams make a migration configuration rather than surgery (ADR 0019): + +- Single injectable COSE algorithm list: `CeremonyConfig.offeredAlgorithms` (advertised in + create-options) and `CeremonyConfig.acceptedAlgorithms` (enforced on verify) are the one source of + truth, expressed in the framework-neutral `CoseAlgorithm` enum. Both the create-options ceremony + and the WebAuthn4J verify path derive their lists from this config, and the two formerly + divergent hardcoded lists are gone. The accepted list is authoritative, the offered list may be a + narrower subset, and an operator can narrow either without code changes. The default accepted set + is the union of everything historically accepted (ES256, EdDSA, RS256, ES384, RS384), so no + already-registered credential can fail verification. +- Per-credential algorithm visibility: `CredentialAlgorithms.coseAlgorithm(record)` decodes the COSE + algorithm already stored on every credential (no schema change), and + `AdminService.listCredentialsByAlgorithm(actor, target, coseAlgorithm)` reports which of a user's + credentials use a given (for example quantum-vulnerable) algorithm. This is the read side a future + re-enrolment campaign drives off. +- A symmetric-resistant JWT path: the HS256 post-ceremony token (with the enforced ≥ 256-bit key) is + HMAC-SHA256, which Shor does not break. See + [Quantum-conservative symmetric primitives](#quantum-conservative-symmetric-primitives). + +### Quantum-conservative symmetric primitives The non-signature secrets pk-auth generates are all 256-bit random values, which only Grover's -algorithm targets — and Grover merely halves the effective bit-strength, leaving ~128-bit -security at 256 bits. No action needed: +algorithm targets, and Grover merely halves the effective bit-strength, leaving about 128-bit +security at 256 bits. No action is needed: | Secret | Size | Post-quantum status | |---|---|---| -| JWT HS256 signing secret | ≥ 32 bytes (enforced) | HMAC-SHA256 — Shor N/A; ~128-bit under Grover | +| JWT HS256 signing secret | ≥ 32 bytes (enforced) | HMAC-SHA256; Shor N/A; ~128-bit under Grover | | Refresh-token secret | 32 bytes | SHA-256 hashed at rest; ~128-bit under Grover | | OTP pepper | ≥ 16 bytes (32+ recommended) | HMAC-SHA256 keying; ~128-bit under Grover at 32 B | | Ceremony challenge | 32 bytes | Random nonce, single-use; not a long-term liability | | Backup codes | ~50 bits + Argon2id | Entropy + KDF + rate limit dominate; not a quantum issue | -ES256 (asymmetric) JWTs, by contrast, **are** Shor-vulnerable — they exist for untrusted -third-party verification, and their exposure is bounded by the short token TTL. For a -single-issuer/single-verifier deployment, HS256 with a strong key is the quantum-conservative -default (see the `pk-auth-jwt` package Javadoc and ADR 0019). +ES256 (asymmetric) JWTs, by contrast, are Shor-vulnerable. They exist for untrusted third-party +verification, and their exposure is bounded by the short token TTL. For a single-issuer, +single-verifier deployment, HS256 with a strong key is the quantum-conservative default (see the +`pk-auth-jwt` package Javadoc and ADR 0019). ## Supply chain -pk-auth is a security library published to Maven Central and npm, so a compromise of -its build or its dependencies propagates directly into every downstream application — -a higher-leverage target than any single deployment. The dominant modern attack vector -is not a typosquat but a **malicious release of an otherwise-legitimate dependency or -CI action** (maintainer-account takeover, a poisoned transitive, or a retroactively -repointed Git tag — cf. the `tj-actions/changed-files` tag poisoning, March 2025). -The controls below are defense-in-depth; none alone is sufficient. +pk-auth is a security library published to Maven Central and npm, so a compromise of its build or +its dependencies propagates directly into every downstream application. That makes it a +higher-leverage target than any single deployment. The dominant modern attack vector is not a +typosquat but a malicious release of an otherwise-legitimate dependency or CI action +(maintainer-account takeover, a poisoned transitive, or a retroactively repointed Git tag; compare +the `tj-actions/changed-files` tag poisoning, March 2025). The controls below form a defence in +depth, and none is sufficient alone. | Threat | Mitigation | |---|---| -| A dependency or plugin is swapped for a malicious build (hijacked artifact, MITM of a repository) | Repositories are restricted to `mavenCentral()` + `gradlePluginPortal()` over HTTPS — no HTTP or untrusted mirrors — and Maven Central artifacts are immutable, so a published coordinate cannot be silently re-bytes'd. All versions are pinned in a single version catalog with no dynamic (`+` / `latest.release`) ranges. **Gradle dependency-verification checksums are intentionally *not* used:** with Dependabot auto-merging patch/minor bumps, the metadata would have to be regenerated automatically from whatever was just downloaded and approved unattended, which notarizes the artifact rather than vetting it — providing no protection against a malicious *release* (the actual threat) while breaking the build on every bump. The "is this version known-bad?" question is instead answered by `dependency-review-action` (next row). | -| The Gradle distribution itself is tampered with | `gradle-wrapper.properties` carries `distributionSha256Sum`, so the wrapper refuses to run a distribution that doesn't match the published checksum. CI additionally runs `gradle/actions/wrapper-validation` to confirm `gradle-wrapper.jar` matches a known-good Gradle release. | -| A CI action is repointed to malicious code via a mutable tag | Every GitHub Actions `uses:` is pinned to a full **commit SHA** (with the human-readable version in a trailing comment), not a floating `@vN` tag. This is most important for third-party actions that run in privileged jobs (e.g. `softprops/action-gh-release` in the release pipeline). | -| A compromised dependency release is merged automatically | The Dependabot auto-merge workflow only auto-approves **patch and minor** bumps, and **never** auto-merges GitHub Actions updates (a privileged surface) — majors and action bumps require human review. A green CI run is not treated as evidence that a newly published version is trustworthy. | +| A dependency or plugin is swapped for a malicious build (hijacked artefact, MITM of a repository) | Repositories are restricted to `mavenCentral()` + `gradlePluginPortal()` over HTTPS, with no HTTP or untrusted mirrors, and Maven Central artefacts are immutable, so a published coordinate cannot be silently replaced with different bytes. All versions are pinned in a single version catalog with no dynamic (`+` / `latest.release`) ranges. Gradle dependency-verification checksums are not used. With Dependabot auto-merging patch/minor bumps, the metadata would have to be regenerated automatically from whatever was just downloaded and approved unattended, which notarises the artefact rather than vetting it. The checksums would therefore give no protection against a malicious release (the actual threat) while breaking the build on every bump. The "is this version known-bad?" question is answered by `dependency-review-action` (next row). | +| The Gradle distribution itself is tampered with | `gradle-wrapper.properties` carries `distributionSha256Sum`, so the wrapper refuses to run a distribution that does not match the published checksum. CI additionally runs `gradle/actions/wrapper-validation` to confirm `gradle-wrapper.jar` matches a known-good Gradle release. | +| A CI action is repointed to malicious code via a mutable tag | Every GitHub Actions `uses:` is pinned to a full commit SHA (with the human-readable version in a trailing comment), not a floating `@vN` tag. Pinning matters most for third-party actions that run in privileged jobs (for example `softprops/action-gh-release` in the release pipeline). | +| A compromised dependency release is merged automatically | The Dependabot auto-merge workflow auto-approves only patch and minor bumps, and never auto-merges GitHub Actions updates (a privileged surface). Majors and action bumps require human review. A green CI run is not treated as evidence that a newly published version is trustworthy. | | A known-vulnerable dependency is introduced | `actions/dependency-review-action` fails any PR that adds a dependency with a high-severity advisory before it can merge. Dependabot tracks the `gradle`, `npm`, and `github-actions` ecosystems (the npm SDK and each demo's Playwright e2e suite included) so fixes are surfaced promptly. | -| A forged or unsigned release reaches consumers | Maven Central artifacts are GPG-signed in CI (`release.yml`); the signing key, passphrase, and Central Portal credentials are injected from GitHub secrets at publish time, scoped to the publish steps only and never exposed to the third-party release action. Releases are triggered exclusively by maintainer-pushed `vX.Y.Z` tags or manual dispatch, so untrusted PR code cannot trigger a publish. CI itself runs on `pull_request` with `contents: read`, so fork PRs execute without secret access. | +| A forged or unsigned release reaches consumers | Maven Central artefacts are GPG-signed in CI (`release.yml`); the signing key, passphrase, and Central Portal credentials are injected from GitHub secrets at publish time, scoped to the publish steps only and never exposed to the third-party release action. Releases are triggered exclusively by maintainer-pushed `vX.Y.Z` tags or manual dispatch, so untrusted PR code cannot trigger a publish. CI itself runs on `pull_request` with `contents: read`, so fork PRs execute without secret access. | | Secrets leak through the repository | No secrets are committed (`.gitignore` covers `.env`); release credentials exist only as GitHub secrets and, on the ephemeral runner, in a `chmod 600` `~/.gradle/gradle.properties` written at publish time. | -**Residual risk.** Without artifact-checksum pinning, the project trusts Maven -Central's and the Gradle Plugin Portal's own integrity (immutability + TLS) for the -bytes behind each pinned coordinate; a compromise of those registries themselves is -out of scope. Auto-merged patch/minor dependency bumps are gated by CI and -`dependency-review-action` but are not human-reviewed, so a malicious release that is -not yet in the advisory database could land — keep the auto-merge window to patch/minor -and rely on the advisory feed and post-merge Dependabot alerts. The npm SDK is published -manually (see `RELEASE.md`) and is not yet covered by a provenance attestation -(`npm publish --provenance`). Neither the Maven nor npm release is reproducible-build -verified. These are accepted for now; revisit if the project adopts SLSA provenance. +### Supply-chain residual risk + +Without artefact-checksum pinning, the project trusts Maven Central's and the Gradle Plugin Portal's +own integrity (immutability plus TLS) for the bytes behind each pinned coordinate. A compromise of +those registries themselves is out of scope. Auto-merged patch and minor dependency bumps are gated +by CI and `dependency-review-action` but are not human-reviewed, so a malicious release that is not +yet in the advisory database could land. The auto-merge window stays limited to patch and minor +bumps, with the advisory feed and post-merge Dependabot alerts as the backstop. The npm SDK is +published manually (see `RELEASE.md`) and is not yet covered by a provenance attestation +(`npm publish --provenance`). Neither the Maven nor the npm release is verified as a reproducible +build. These risks are accepted for now, and adopting SLSA provenance would revisit them. ## AI-authored code (provenance) -Substantially all of pk-auth's code was written by an AI coding agent, with a human -acting as product manager and architect (see the README's *DISCLAIMER from the human* -and *Comments from the human*). That makes the **authoring step itself** a supply-chain -surface, distinct from the dependency/build chain above — and a less well-understood -one, since the security posture of AI coding agents is still nascent. - -Conventional software trust rests on accountable human authorship: identifiable people -whose access is vetted and whose mistakes are bounded by skill and reviewable intent. -An AI author breaks those assumptions. It is **unaccountable** (no identity, stake, or -persistence), **manipulable** through the content it reads, **high-volume** (it emits -confident, idiomatic-looking code faster than anyone can deeply review — which erodes -the very review it depends on), and is **itself an opaque supply chain** (model weights, -inference provider, agent harness, system prompt, and any connected tools). The right -mental model is to treat AI-generated code like a contribution from an *unvetted external -contributor with very high output and no accountability* — trustworthy only after -independent human review, never by provenance. The dominant realistic risk is **not** a -deliberately planted backdoor (low probability) but confident-but-subtly-wrong code, -manipulation via injected instructions, and the human review process decaying under volume. - -The table below describes the situation **as it actually stands today** — these are -current realities and partial mitigations, not guarantees the project enforces. +Substantially all of pk-auth's code was written by an AI coding agent, with a human acting as +product manager and architect (see the README's "DISCLAIMER from the human" and "Comments from the +human"). That makes the authoring step itself a supply-chain surface, distinct from the +dependency/build chain above, and a less well-understood one, since the security posture of AI +coding agents is still nascent. + +Conventional software trust rests on accountable human authorship: identifiable people whose access +is vetted and whose mistakes are bounded by skill and reviewable intent. An AI author breaks those +assumptions. It is unaccountable (no identity, stake, or persistence), manipulable through the +content it reads, high-volume (it emits confident, idiomatic-looking code faster than anyone can +deeply review, which erodes the very review it depends on), and itself an opaque supply chain +(model weights, inference provider, agent harness, system prompt, and any connected tools). The +working assumption is that AI-generated code is a contribution from an unvetted external +contributor with very high output and no accountability, trustworthy only after independent human +review and never by provenance. The dominant realistic risk is confident-but-subtly-wrong code, +manipulation via injected instructions, and the human review process decaying under volume. A +deliberately planted backdoor is a low-probability risk. + +The table below records current realities and partial mitigations, not guarantees the project +enforces. | Threat | Current reality / partial mitigation | |---|---| -| Confident-but-subtly-wrong security code (auth bypass, weak defaults, timing leaks) that passes review *because* it looks idiomatic | The code has **not** had an independent security review; the README states this plainly. The human author reviews and merges every PR but is not acting as a dedicated security reviewer, and intends to seek a third-party review. The test suites, CI gate, and `dependency-review-action` catch some classes; defense-in-depth means no single AI-written check is the only control. The earlier in-repo security review was *itself* AI-produced and carries the same caveat. | +| Confident-but-subtly-wrong security code (auth bypass, weak defaults, timing leaks) that passes review because it looks idiomatic | The code has not had an independent security review, and the README records this. The human author reviews and merges every PR but is not acting as a dedicated security reviewer, and intends to seek a third-party review. The test suites, CI gate, and `dependency-review-action` catch some classes; defence in depth means no single AI-written check is the only control. The earlier in-repo security review was also AI-produced and carries the same caveat. | | Hallucinated or typosquatted (“slopsquat”) dependency introduced by the agent | Dependencies are pinned in a single version catalog with no dynamic ranges; `dependency-review-action` runs on PRs; the human reviews each diff. No automated check specifically verifies that a newly added coordinate is the genuine, intended package. | -| Indirect prompt injection — malicious instructions hidden in content the agent ingests (issue text, dependency changelogs/release notes, fetched web pages, repo files, tool / MCP output) steering it to insert a backdoor, weaken a check, or exfiltrate | The agent does read such content during development (e.g. dependency release notes, fetched checksums/SHAs, repo files). The mitigation in place is human review of the resulting diffs and the harness's per-action permission prompts. There is no automated detection of injected instructions; reviewing the *entire* diff (not just the described change) is the practical defense. | -| Excessive agency — the agent can edit code and CI, push branches, open PRs, and invoke the network | Today the **human** triggers tagged releases, holds the GPG signing keys and Central Portal / npm credentials, and merges PRs; the agent holds none of those and operates under the harness's permission model. Releases are signed and maintainer-gated (see [Supply chain](#supply-chain)). | -| Model / harness / provider compromise (poisoned weights, a malicious harness or MCP update) — a “trusting trust” problem one layer up | Trusted by necessity and out of scope to fully mitigate. Reliance is reduced only indirectly: independent test oracles, pinned build tooling, and human review of output. | -| Review fatigue / automation bias — AI output outpacing genuine scrutiny | A real, unquantified limitation for a project of this size built this way. Acknowledged here rather than mitigated; the honest control is keeping unreviewed volume within what a human can actually understand and own. | - -**Residual risk.** This is the bluntest item in the model: pk-auth's code is AI-authored -and, absent the third-party security review noted in the README, should be treated as -**not production-trustworthy on provenance grounds** — evaluate it on its merits, with -your own review, before relying on it. Note the self-referential limit: this section was -itself written by the AI agent, so it cannot be relied upon *because* the agent produced -it — a manipulated or mistaken agent would not faithfully document its own risks. A human -must validate this analysis like any other AI-authored artifact in the repo. +| Indirect prompt injection: malicious instructions hidden in content the agent ingests (issue text, dependency changelogs/release notes, fetched web pages, repo files, tool / MCP output) steering it to insert a backdoor, weaken a check, or exfiltrate | The agent does read such content during development (for example dependency release notes, fetched checksums/SHAs, repo files). The mitigation in place is human review of the resulting diffs and the harness's per-action permission prompts. There is no automated detection of injected instructions; the practical defence is reviewing the whole diff, not just the described change. | +| Excessive agency: the agent can edit code and CI, push branches, open PRs, and invoke the network | Today the human triggers tagged releases, holds the GPG signing keys and Central Portal / npm credentials, and merges PRs; the agent holds none of those and operates under the harness's permission model. Releases are signed and maintainer-gated (see [Supply chain](#supply-chain)). | +| Model / harness / provider compromise (poisoned weights, a malicious harness or MCP update), a “trusting trust” problem one layer up | Trusted by necessity and out of scope to fully mitigate. Reliance is reduced only indirectly: independent test oracles, pinned build tooling, and human review of output. | +| Review fatigue / automation bias: AI output outpacing genuine scrutiny | An unquantified limitation for a project of this size built this way. No mitigation exists beyond keeping unreviewed volume within what a human can actually understand and own. | + +### Provenance residual risk + +pk-auth's code is AI-authored. Absent the third-party security review noted in the README, it is not +production-trustworthy on provenance grounds, and a deployment relies on it only after its own +review. The same limit applies to the repository's security analyses, which are AI-authored +artefacts too: a manipulated or mistaken agent does not faithfully document its own risks, so a +human validates those analyses like any other AI-authored artefact. ## Token revocation Two complementary primitives ship with pk-auth as of 1.1.0: -1. **`AccessTokenStore` (ADR 0015)** — positive allow-list. Every issued JWT's JTI is - persisted server-side; the validator looks it up on every request. Deleting the row - (logout, admin revoke, password reset, user delete) immediately invalidates the - bearer, well before its `exp`. This is the paved-road for revocability. -2. **`RevocationCheck` (1.0)** — negative deny-list. Lightweight in-process hook for hosts - that issue many millions of tokens per day and want to invalidate a small subset - proactively (e.g. a "session revoked" stream from a security event bus). Still - supported; orthogonal to `AccessTokenStore`. - -The default `AccessTokenStore.noop()` keeps the legacy stateless-JWT behaviour for hosts -that prefer it — the call costs an always-true return. Choose the binding based on traffic -shape, not as a feature toggle. - -For session-lifetime management (re-using a single login across days/weeks without -re-authenticating), the rotating refresh-token primitive is the paved road — see -"Refresh-token replay defense" below. - -## Refresh-token replay defense - -Rotating refresh tokens with family-based replay detection are shipped via -`pk-auth-refresh-tokens` (ADR 0013). The properties the design guarantees: - -- **Atomic mark-and-insert.** The SPI's `rotateAtomically` primitive marks the parent - used AND inserts the successor as a single atomic operation (JDBI transaction, - DynamoDB `TransactWriteItems`, or in-memory `compute` block). A non-atomic sequence - has a window where a concurrent rotator's family-scorch can miss the freshly-inserted - successor; the SPI contract forbids this and the concurrent-race test enforces it. -- **Hash-before-mark-used.** The service hashes the presented secret and compares - against the stored row hash *before* invoking `rotateAtomically`. A presented - refresh-id with the wrong secret returns `Unknown`, never burns the legitimate - token's `used_at`. This is enforced by the - `wrongSecretReturnsUnknownAndDoesNotBurnLegitToken` parity test on every backend. -- **Replay → family scorch.** Re-presenting a used or revoked refresh from a known - family triggers a `revokeFamily(familyId, ROTATION_REPLAY)` call that runs outside - the failed rotation. The result is that BOTH the attacker AND the legitimate client - lose the session — the legit user sees their next refresh fail and is redirected to - login. The cost is one ceremony; the alternative is a leaked session that survives - the legit client's rotation. -- **Concurrent rotation: exactly one wins.** The non-negotiable race test launches 8 - threads rotating the same token simultaneously. Exactly one thread returns - `RotateResult.Success`; the other 7 return `RotateResult.Replayed`; the entire - family (root + the winner's successor) is revoked. This test passes against - Postgres (JDBI transaction path) and DynamoDB Local (`TransactWriteItems` path) on - every CI run. -- **Hash-at-rest.** The 32-byte secret is SHA-256-hashed before storage; the raw - secret is never persisted. The wire token (`{refreshId}.{secret}`, base64url on - both halves) must NEVER be logged. +1. `AccessTokenStore` (ADR 0015) is a positive allow-list. Every issued JWT's JTI is persisted + server-side, and the validator looks it up on every request. Deleting the row (logout, admin + revoke, password reset, user delete) immediately invalidates the bearer, well before its `exp`. + This is the recommended path for revocability. +2. `RevocationCheck` (1.0) is a negative deny-list. It is a lightweight in-process hook for hosts + that issue many millions of tokens per day and want to invalidate a small subset proactively (for + example a "session revoked" stream from a security event bus). It remains supported and is + orthogonal to `AccessTokenStore`. + +The default `AccessTokenStore.noop()` keeps the legacy stateless-JWT behaviour for hosts that prefer +it, and the call costs an always-true return. Hosts choose the binding by traffic shape, not as a +feature toggle. + +For session-lifetime management (re-using a single login across days or weeks without +re-authenticating), the rotating refresh-token primitive is the recommended path; see +[Refresh-token replay defence](#refresh-token-replay-defence). + +## Refresh-token replay defence + +Rotating refresh tokens with family-based replay detection are shipped via `pk-auth-refresh-tokens` +(ADR 0013). The design guarantees these properties: + +- Atomic mark-and-insert: the SPI's `rotateAtomically` primitive marks the parent used and inserts + the successor as a single atomic operation (JDBI transaction, DynamoDB `TransactWriteItems`, or + in-memory `compute` block). A non-atomic sequence has a window where a concurrent rotator's + family-scorch can miss the freshly inserted successor. The SPI contract forbids this, and the + concurrent-race test enforces it. +- Hash before mark-used: the service hashes the presented secret and compares it against the stored + row hash before invoking `rotateAtomically`. A presented refresh-id with the wrong secret returns + `Unknown` and never burns the legitimate token's `used_at`. The + `wrongSecretReturnsUnknownAndDoesNotBurnLegitToken` parity test enforces this on every backend. +- Replay triggers a family scorch: re-presenting a used or revoked refresh from a known family + triggers a `revokeFamily(familyId, ROTATION_REPLAY)` call that runs outside the failed rotation. + Both the attacker and the legitimate client lose the session, and the legitimate user sees the + next refresh fail and is redirected to login. The cost is one ceremony; the alternative is a + leaked session that survives the legitimate client's rotation. +- Concurrent rotation has exactly one winner: the race test launches 8 threads rotating the same + token simultaneously. Exactly one thread returns `RotateResult.Success`, the other 7 return + `RotateResult.Replayed`, and the entire family (root plus the winner's successor) is revoked. This + test passes against Postgres (JDBI transaction path) and DynamoDB Local (`TransactWriteItems` + path) on every CI run. +- Hash at rest: the 32-byte secret is SHA-256-hashed before storage, and the raw secret is never + persisted. The wire token (`{refreshId}.{secret}`, base64url on both halves) must never be logged. ## Data at rest @@ -319,48 +318,45 @@ pk-auth stores the following PII in its persistence layers: ### DynamoDB -DynamoDB enables SSE-S3 at rest by default. For higher assurance, enable -**SSE-KMS with a customer-managed CMK** so that key rotation and access auditing are -under the operator's control. Configure this in the table settings; pk-auth does not -create the table at runtime. +DynamoDB enables SSE-S3 at rest by default. For higher assurance, SSE-KMS with a customer-managed +CMK puts key rotation and access auditing under the operator's control. The setting lives in the +table configuration, because pk-auth does not create the table at runtime. ### Postgres (JDBI) -Postgres does **not** enable column-level or filesystem encryption by default. The -`phone_e164` and `email` columns are stored as plaintext. Operators should consider: - -- **(a) Filesystem / volume encryption** — encrypt the underlying block device or - filesystem (e.g. LUKS, AWS EBS encryption). Protects against physical media theft - but not SQL-level data exfiltration. -- **(b) Column-level encryption with `pgcrypto`** — wrap `phone_e164` / `email` at - write time using `pgp_sym_encrypt` / `pgp_sym_decrypt`. The encryption key must be - injected from outside the database. pk-auth's Flyway schema does not do this today; - hosts with strict data-classification requirements should layer it in a custom - migration on top of the shipped baseline. -- **(c) HMAC-keyed index columns** — if exact-match lookups on `email` or `phone_e164` - are required but storing plaintext is unacceptable, store an HMAC (SHA-256 with a - server-side key) in a separate indexed column and perform queries against that column. - The plaintext is then replaced with the encrypted ciphertext in the main column. +Postgres does not enable column-level or filesystem encryption by default. The `phone_e164` and +`email` columns are stored as plaintext. Operators should consider: + +- (a) Filesystem / volume encryption: encrypt the underlying block device or filesystem (for example + LUKS, AWS EBS encryption). This protects against physical media theft but not SQL-level data + exfiltration. +- (b) Column-level encryption with `pgcrypto`: wrap `phone_e164` / `email` at write time using + `pgp_sym_encrypt` / `pgp_sym_decrypt`. The encryption key must be injected from outside the + database. pk-auth's Flyway schema does not do this today, and hosts with strict + data-classification requirements should layer it in a custom migration on top of the shipped + baseline. +- (c) HMAC-keyed index columns: if exact-match lookups on `email` or `phone_e164` are required but + storing plaintext is unacceptable, store an HMAC (SHA-256 with a server-side key) in a separate + indexed column and perform queries against that column. The plaintext is then replaced with the + encrypted ciphertext in the main column. ## Operator self-test -A deployment that survives the following spot checks is in reasonable shape: +The following spot checks verify a deployment: -1. Start the demo with the wrong `pkauth.relying-party.id`. Confirm Chrome rejects with - "relying party not registrable." -2. Register a passkey, capture the assertion's signing counter, then replay an old assertion - with a lower counter. Confirm the server rejects with `counter_regression`. +1. Start the demo with the wrong `pkauth.relying-party.id`. Confirm Chrome rejects with "relying + party not registrable." +2. Register a passkey, capture the assertion's signing counter, then replay an old assertion with a + lower counter. Confirm the server rejects with `counter_regression`. 3. Delete the only credential on a user via the admin API. Confirm a 409. -4. Send the JWT in `Authorization: Bearer ` to a host-app endpoint that does not - call `PkAuthJwtValidator`. Confirm the host's own filter chain rejects it (proves pk-auth - isn't the only thing standing between the bearer and your code). +4. Send the JWT in `Authorization: Bearer ` to a host-app endpoint that does not call + `PkAuthJwtValidator`. Confirm the host's own filter chain rejects it, which shows that pk-auth is + not the only thing standing between the bearer and the host code. 5. Restart the app and confirm Flyway / DynamoDB schema reconciles without manual intervention. ---- +## Source verification -## Document drift - -Documentation in this file trails code changes. For ground truth, `grep` the source: +The source is the ground truth for the SPI surface. These commands locate it: ```sh # Find all SPI interfaces @@ -369,5 +365,3 @@ grep -r "interface.*Repository\|interface.*Store\|interface.*Sender\|interface.* # Find challenge handling grep -r "takeOnce\|ChallengeStore" pk-auth-core/src ``` - -If this document contradicts the code, the code wins. diff --git a/docs/transactional-semantics.md b/docs/transactional-semantics.md index c2220070..6be244da 100644 --- a/docs/transactional-semantics.md +++ b/docs/transactional-semantics.md @@ -2,81 +2,75 @@ ## Overview -pk-auth does **not** require host SPI implementations to share a transactional -context. Each SPI method is called independently, and pk-auth makes no assumptions -about whether two SPI calls happen inside a shared database transaction. +pk-auth does not require host SPI implementations to share a transactional context. Each SPI method +is called independently, and pk-auth makes no assumptions about whether two SPI calls happen inside +a shared database transaction. -This is a deliberate design choice that allows hosts to mix heterogeneous backends -(e.g. DynamoDB for `ChallengeStore` + Postgres for `CredentialRepository`) without -requiring a distributed transaction coordinator. +This allows hosts to mix heterogeneous backends (for example DynamoDB for `ChallengeStore` and +Postgres for `CredentialRepository`) without a distributed transaction coordinator. ## Ceremony-finish ordering -At the end of a WebAuthn registration or authentication ceremony, pk-auth calls the -SPIs in this order: +At the end of a WebAuthn registration or authentication ceremony, pk-auth calls the SPIs in this +order: -1. `ChallengeStore.takeOnce(challengeId)` — the challenge is atomically consumed and - can never be replayed, regardless of what happens next. -2. Signature / assertion verification (in-memory, no SPI). -3. `CredentialRepository.save(credential)` — the new or updated credential is persisted. +1. `ChallengeStore.takeOnce(challengeId)`: the challenge is atomically consumed and can never be + replayed, regardless of what happens next. +2. Signature and assertion verification (in-memory, no SPI). +3. `CredentialRepository.save(credential)`: the new or updated credential is persisted. -**If step 3 fails** (e.g. a transient database error), the challenge from step 1 is -already consumed. The ceremony cannot be retried with the same challenge. The user must -restart the ceremony from the beginning, which will cause pk-auth to issue a fresh -challenge. +If step 3 fails (for example a transient database error), the challenge from step 1 is already +consumed. The ceremony cannot be retried with the same challenge. The user restarts the ceremony +from the beginning, which causes pk-auth to issue a fresh challenge. -## Why this is acceptable +## Restart cost -Challenges are short-lived (default TTL: 5 minutes). A forced restart is a minor -inconvenience in the rare case of a transient failure, and it avoids significant -complexity: +Challenges are short-lived (default TTL: 5 minutes). A forced restart is a minor inconvenience in +the rare case of a transient failure, and it avoids significant complexity: - No `TransactionTemplate` SPI hook is needed. -- Heterogeneous backends (Dynamo + Postgres + Redis) can be mixed freely. -- The worst-case outcome of a failed save is that the user retries registration or - authentication — not that an invalid credential is accepted. +- Heterogeneous backends (Dynamo, Postgres, and Redis) can be mixed freely. +- The worst-case outcome of a failed save is that the user retries registration or authentication. + An invalid credential is never accepted. -## Hosts wanting stronger guarantees +## Stronger guarantees for hosts -Hosts that want atomicity across `ChallengeStore` and `CredentialRepository` can -implement both SPIs against the same backing store and wrap their calls in a shared -transaction. For example, a Postgres-only deployment can implement both SPIs using -JDBI and rely on a single database transaction. +Hosts that need atomicity across `ChallengeStore` and `CredentialRepository` can implement both SPIs +against the same backing store and wrap their calls in a shared transaction. For example, a +Postgres-only deployment can implement both SPIs using JDBI and rely on a single database +transaction. -pk-auth does not provide a `TransactionTemplate` SPI hook today. If your deployment -requires cross-SPI atomicity, both SPIs must target the same transactional resource. +pk-auth does not provide a `TransactionTemplate` SPI hook today. A deployment that requires +cross-SPI atomicity must have both SPIs target the same transactional resource. ## Other SPI pairs -The same non-transactional property applies wherever pk-auth calls multiple SPIs in -sequence: +The same non-transactional property applies wherever pk-auth calls multiple SPIs in sequence: | Sequence | Failure scenario | Recovery | |---|---|---| | `ChallengeStore.takeOnce` → `CredentialRepository.save` | Save fails; challenge is consumed. | User restarts ceremony (new challenge issued). | -| `BackupCodeRepository.consume` → JWT mint | Mint fails (e.g. secret missing). | Code is consumed; user contacts support or regenerates codes. | +| `BackupCodeRepository.consume` → JWT mint | Mint fails (for example a missing secret). | Code is consumed; user contacts support or regenerates codes. | | `OtpRepository.consume` → phone-verified flag write | Flag write fails. | OTP is consumed; user requests a new OTP and re-verifies. | -In every case, the "consume once" side of the operation succeeds before the downstream -write, and a failure leaves the user in a recoverable (if inconvenient) state. +In every case, the "consume once" side of the operation succeeds before the downstream write, and a +failure leaves the user in a recoverable (if inconvenient) state. -## Where pk-auth *does* require atomicity inside an SPI +## Atomicity inside a single SPI call -The general "no shared transaction across SPI calls" stance does not extend to -operations that pk-auth declares atomic inside a single SPI call. Three surfaces -require the implementation to commit a multi-step change atomically. The -parity-test suite exercises this on every shipped backend — directly under -concurrency for `takeOnce` and `rotateAtomically` (multi-thread / `CountDownLatch` -races), and via round-trip parity tests for the backup-code and OTP `consume` +The general "no shared transaction across SPI calls" stance does not extend to operations that +pk-auth declares atomic inside a single SPI call. Three surfaces require the implementation to +commit a multi-step change atomically. The parity-test suite exercises this on every shipped +backend: directly under concurrency for `takeOnce` and `rotateAtomically` (multi-thread and +`CountDownLatch` races), and via round-trip parity tests for the backup-code and OTP `consume` paths. | SPI method | Atomicity contract | |---|---| | `ChallengeStore.takeOnce(challengeId)` | Read + delete (or read + mark consumed) commit as one step. A second concurrent caller for the same challenge must receive `Optional.empty()`. | -| `BackupCodeRepository.consume` / `OtpRepository.consume` | The id-keyed mark-used commits as one atomic step, so exactly one of N concurrent callers wins. (The Argon2 / HMAC hash compare runs earlier in the service layer and is deliberately racy; the `consume` return value is the single source of truth for which caller won.) | -| `RefreshTokenRepository.rotateAtomically` *(1.1.0)* | Mark the parent refresh row used AND insert the successor row in a single atomic operation — JDBI transaction, DynamoDB `TransactWriteItems`, or in-memory `ConcurrentHashMap.compute`. A non-atomic implementation has a window where a concurrent rotator's family-scorch can miss the freshly-inserted successor; the contract forbids this and the `concurrentRotationExactlyOneSucceedsFamilyRevoked` parity test (8 threads + `CountDownLatch`) enforces it against in-memory, real Postgres, and DynamoDB Local. See [ADR 0013](./adr/0013-refresh-tokens-family-rotation.md). | +| `BackupCodeRepository.consume` / `OtpRepository.consume` | The id-keyed mark-used commits as one atomic step, so exactly one of N concurrent callers wins. (The Argon2 / HMAC hash compare runs earlier in the service layer and can race; the `consume` return value is the single source of truth for which caller won.) | +| `RefreshTokenRepository.rotateAtomically` (1.1.0) | Mark the parent refresh row used and insert the successor row in a single atomic operation: a JDBI transaction, DynamoDB `TransactWriteItems`, or in-memory `ConcurrentHashMap.compute`. A non-atomic implementation has a window where a concurrent rotator's family-scorch can miss the freshly inserted successor. The contract forbids this, and the `concurrentRotationExactlyOneSucceedsFamilyRevoked` parity test (8 threads and `CountDownLatch`) enforces it against in-memory, real Postgres, and DynamoDB Local. See [ADR 0013](./adr/0013-refresh-tokens-family-rotation.md). | -Cross-SPI atomicity is still **not** required; the `UserDeletionService` -fan-out, for example, runs each registered listener independently with -best-effort + idempotent semantics and reports per-listener success/failure -through `UserDeletionResult`. +Cross-SPI atomicity is not required. The `UserDeletionService` fan-out, for example, runs each +registered listener independently with best-effort, idempotent semantics and reports per-listener +success or failure through `UserDeletionResult`. diff --git a/examples/dropwizard-demo/README.md b/examples/dropwizard-demo/README.md index 7f427b95..4d57d750 100644 --- a/examples/dropwizard-demo/README.md +++ b/examples/dropwizard-demo/README.md @@ -1,48 +1,49 @@ # pk-auth Dropwizard demo -Single-page demo of the [pk-auth Dropwizard adapter](../../pk-auth-dropwizard). Boots a -Dropwizard 5 application with the in-memory testkit SPIs, mounts the four passkey ceremony -endpoints under `/auth/passkeys/**`, and the admin endpoints under `/auth/admin/**`, then serves an -HTML+vanilla-JS page that drives the full flow with `navigator.credentials.{create,get}`. +A single-page demo of the [pk-auth Dropwizard adapter](../../pk-auth-dropwizard). It boots a +Dropwizard 5 application with the in-memory testkit SPIs, mounts the four passkey ceremony endpoints +under `/auth/passkeys/**` and the admin endpoints under `/auth/admin/**`, and serves an HTML and +vanilla-JS page that drives the full flow with `navigator.credentials.{create,get}`. -## Run it +## Running ```bash ./gradlew :examples:dropwizard-demo:run ``` -The application listens on **http://localhost:8080**. Open it in a passkey-capable browser -(Chrome / Edge / Safari / Firefox 130+) — your platform authenticator handles the WebAuthn -ceremony, the server stores the credential in memory, and the post-login page renders the -decoded JWT plus the admin controls. +The application listens on `http://localhost:8080`. A passkey-capable browser (Chrome, Edge, Safari, +or Firefox 130+) opens the page. The platform authenticator handles the WebAuthn ceremony, the +server stores the credential in memory, and the post-login page renders the decoded JWT and the +admin controls. -To stop, hit `Ctrl-C`. +`Ctrl-C` stops the application. -## What's in the demo +## Demo contents | Section | Endpoint | Notes | |---|---|---| -| Register passkey | `POST /auth/passkeys/registration/start`, `POST /auth/passkeys/registration/finish` | First credential mints the user account; subsequent calls add additional passkeys (multi-passkey). | +| Register passkey | `POST /auth/passkeys/registration/start`, `POST /auth/passkeys/registration/finish` | The first credential creates the user account; later calls add further passkeys (multi-passkey). | | Sign in | `POST /auth/passkeys/authentication/start`, `POST /auth/passkeys/authentication/finish` | On success returns a pk-auth JWT (HS256). | | Account summary | `GET /auth/admin/account` | Credential count, remaining backup codes, verification flags. | -| List / rename / delete passkeys | `GET/PATCH/DELETE /auth/admin/credentials*` | Last-credential guard rejects deletions that would lock the user out. | -| Backup codes | `POST /auth/admin/backup-codes/regenerate`, `GET /auth/admin/backup-codes/count` | Plaintext shown exactly once. | -| Magic link | `POST /auth/admin/email/start-verification` | `LoggingEmailSender` writes the URL to the server log — paste the token back into the page or call `complete-verification` directly. | +| List, rename, delete passkeys | `GET/PATCH/DELETE /auth/admin/credentials*` | The last-credential guard rejects deletions that would lock the user out. | +| Backup codes | `POST /auth/admin/backup-codes/regenerate`, `GET /auth/admin/backup-codes/count` | Plaintext is shown exactly once. | +| Magic link | `POST /auth/admin/email/start-verification` | `LoggingEmailSender` writes the URL to the server log. The token is pasted back into the page, or `complete-verification` is called directly. | | Phone OTP | `POST /auth/admin/phone/start-verification`, `POST /auth/admin/phone/complete-verification` | `LoggingSmsSender` writes the code to the server log. | -| JWT contents | rendered client-side | Post-login the page shows the decoded JWT payload. | +| JWT contents | Rendered client-side | After login the page shows the decoded JWT payload. | ## Persistence variants -The demo accepts a `--persistence` flag indicating which backing store to wire (brief §6.14): +`DemoApplication` selects the backing store from the `PKAUTH_PERSISTENCE` environment variable, or +from the `pkauth.persistence` system property when the variable is unset. The values are `memory` +(the default), `jdbi`, and `dynamodb`: ```bash -./gradlew :examples:dropwizard-demo:run -Dpkauth.persistence=jdbi # default -./gradlew :examples:dropwizard-demo:run -Dpkauth.persistence=dynamodb +PKAUTH_PERSISTENCE=dynamodb ./gradlew :examples:dropwizard-demo:run ``` -In v0.x both flavors fall back to the in-memory SPIs so the demo runs out of the box. The -[`docker-compose.yml`](./docker-compose.yml) ships a Postgres 16 + DynamoDB Local stack for when -the JDBI / DynamoDB modules' "all-in-one" factories land (Phase 12 polish): +All three values currently use the in-memory SPIs, so the demo runs without external services. The +[`docker-compose.yml`](./docker-compose.yml) ships a Postgres 16 and DynamoDB Local stack for use +once the JDBI and DynamoDB modules provide "all-in-one" factories: ```bash docker compose up -d @@ -50,8 +51,8 @@ docker compose up -d ## End-to-end tests -Playwright drives the full registration → login → manage passkeys → backup codes → -magic link → OTP flow against Chrome's CDP virtual WebAuthn authenticator: +Playwright drives the full registration, login, passkey management, backup codes, magic link, and +OTP flow against Chrome's CDP virtual WebAuthn authenticator: ```sh (cd examples/dropwizard-demo/e2e && npm install) @@ -59,16 +60,17 @@ magic link → OTP flow against Chrome's CDP virtual WebAuthn authenticator: ``` The Playwright config's `webServer` block starts the demo on demand via -`./gradlew :examples:dropwizard-demo:run`. Set `PK_DEMO_EXTERNAL=1` to run against a +`./gradlew :examples:dropwizard-demo:run`. With `PK_DEMO_EXTERNAL=1`, the suite runs against a pre-started demo. ## Production caveats -This is a **demo**, not a production deployment recipe: +The demo is not a production deployment recipe. It differs from a production deployment in three +ways: - The JWT signing secret is hard-coded. -- All persistence is process-local and erases on restart. -- `LoggingEmailSender` / `LoggingSmsSender` print verification tokens to the application log, - which would be a security hole in production. +- All persistence is process-local and erased on restart. +- `LoggingEmailSender` and `LoggingSmsSender` print verification tokens to the application log, + which is a security hole in production. -For production wiring, see `docs/operator-guide.md` (lands in Phase 12). +Production wiring is described in [`docs/operator-guide.md`](../../docs/operator-guide.md). diff --git a/examples/dropwizard-demo/src/main/java/com/codeheadsystems/pkauth/demo/dropwizard/DemoApplication.java b/examples/dropwizard-demo/src/main/java/com/codeheadsystems/pkauth/demo/dropwizard/DemoApplication.java index 495c51b6..013a13fa 100644 --- a/examples/dropwizard-demo/src/main/java/com/codeheadsystems/pkauth/demo/dropwizard/DemoApplication.java +++ b/examples/dropwizard-demo/src/main/java/com/codeheadsystems/pkauth/demo/dropwizard/DemoApplication.java @@ -20,16 +20,17 @@ import org.slf4j.LoggerFactory; /** - * Single-page Dropwizard demo for pk-auth. Boots with in-memory persistence by default; the brief - * §6.14 calls for {@code --persistence=jdbi} (default) and {@code --persistence=dynamodb} flavors — - * both are stubbed below; the JDBI variant routes to the in-memory SPIs in v0.x so the demo runs - * without external Postgres. A real JDBI / DynamoDB wiring lands when the JDBI and DynamoDB modules - * surface a higher-level {@code build()} factory (Phase 12 polish). + * Single-page Dropwizard demo for pk-auth. The persistence flavour is read from the {@code + * PKAUTH_PERSISTENCE} environment variable, then from the {@code pkauth.persistence} system + * property, and defaults to {@code memory}. Every flavour, including {@code jdbi} and {@code + * dynamodb}, currently routes to the testkit's in-memory SPIs, so the demo runs without external + * services. Real JDBI and DynamoDB wiring awaits a higher-level {@code build()} factory in those + * modules. * - *

As of pk-auth 0.9.1 the demo uses {@link PkAuthBundle}'s alt-flow auto-wiring constructor — - * the bundle internally builds the backup-code, magic-link, OTP, and admin services from the YAML - * config plus the senders handed in via {@link AltFlowOptions}. The previous hand-wiring done by - * the demo (~30 lines) is now done once inside the bundle and shared across the three adapters. + *

The demo uses {@link PkAuthBundle}'s alt-flow auto-wiring constructor, and the bundle + * internally builds the backup-code, magic-link, OTP, and admin services from the YAML config plus + * the senders handed in via {@link AltFlowOptions}. The bundle performs this wiring once, shared + * across the three adapters, so the demo carries no hand-wiring. */ public final class DemoApplication extends Application { diff --git a/examples/dropwizard-demo/src/main/java/com/codeheadsystems/pkauth/demo/dropwizard/DemoConfiguration.java b/examples/dropwizard-demo/src/main/java/com/codeheadsystems/pkauth/demo/dropwizard/DemoConfiguration.java index 8f1d1fc7..01a81d17 100644 --- a/examples/dropwizard-demo/src/main/java/com/codeheadsystems/pkauth/demo/dropwizard/DemoConfiguration.java +++ b/examples/dropwizard-demo/src/main/java/com/codeheadsystems/pkauth/demo/dropwizard/DemoConfiguration.java @@ -48,7 +48,7 @@ public void setPkAuth(PkAuthConfig pkAuth) { this.pkAuth = pkAuth; } - /** 32-byte deterministic dev secret. Never use this in production. */ + /** 32-byte deterministic dev secret, not for production use. */ static byte[] defaultDevSecret() { byte[] secret = new byte[32]; for (int i = 0; i < secret.length; i++) { @@ -58,8 +58,8 @@ static byte[] defaultDevSecret() { } /** - * Deterministic dev OTP pepper. Production deploys must supply this via YAML / env-var and never - * commit a value to source. + * Deterministic dev OTP pepper. Production deployments must supply this via YAML or an + * environment variable; a value is never committed to source. */ static byte[] defaultDevOtpPepper() { byte[] pepper = new byte[32]; diff --git a/examples/dropwizard-demo/src/test/java/com/codeheadsystems/pkauth/demo/dropwizard/DemoSmokeTest.java b/examples/dropwizard-demo/src/test/java/com/codeheadsystems/pkauth/demo/dropwizard/DemoSmokeTest.java index da472f40..b79d658e 100644 --- a/examples/dropwizard-demo/src/test/java/com/codeheadsystems/pkauth/demo/dropwizard/DemoSmokeTest.java +++ b/examples/dropwizard-demo/src/test/java/com/codeheadsystems/pkauth/demo/dropwizard/DemoSmokeTest.java @@ -14,8 +14,8 @@ import org.junit.jupiter.api.extension.ExtendWith; /** - * Smoke test: spin the demo app up and verify the static asset, ceremony endpoints, and admin - * endpoint surfaces are all reachable. Uses an ephemeral port so CI doesn't collide on 8080. + * Smoke test that starts the demo app and verifies the static asset, ceremony endpoints, and admin + * endpoint surfaces are all reachable. Uses an ephemeral port so CI does not collide on 8080. */ @ExtendWith(DropwizardExtensionsSupport.class) final class DemoSmokeTest { diff --git a/examples/micronaut-demo/README.md b/examples/micronaut-demo/README.md index 9c0fd1e9..d5e9780a 100644 --- a/examples/micronaut-demo/README.md +++ b/examples/micronaut-demo/README.md @@ -2,18 +2,19 @@ A runnable Micronaut application exercising every pk-auth flow against the testkit's in-memory SPIs. -## Run +## Running ```sh ./gradlew :examples:micronaut-demo:run ``` -The app boots on `http://localhost:8080`. Endpoints: +The application boots on `http://localhost:8080`. Endpoints: - `POST /auth/passkeys/registration/start` - `POST /auth/passkeys/registration/finish` - `POST /auth/passkeys/authentication/start` -- `POST /auth/passkeys/authentication/finish` — returns a JWT in both the body and the `Authorization` header on success. +- `POST /auth/passkeys/authentication/finish` (returns a JWT in both the body and the + `Authorization` header on success) - `GET /auth/admin/account` (requires `Authorization: Bearer `) - `GET /auth/admin/credentials` - `PATCH /auth/admin/credentials/{credentialIdB64}` (body: `{"label":"..."}`) @@ -21,22 +22,23 @@ The app boots on `http://localhost:8080`. Endpoints: - `POST /auth/admin/backup-codes/regenerate` - `GET /auth/admin/backup-codes/count` - `POST /auth/admin/email/start-verification` (body: `{"email":"..."}`) -- `POST /auth/admin/email/complete-verification` (body: `{"token":"..."}`) — unauthenticated. +- `POST /auth/admin/email/complete-verification` (body: `{"token":"..."}`; unauthenticated) - `POST /auth/admin/phone/start-verification` (body: `{"phone":"+15551234567"}`) - `POST /auth/admin/phone/complete-verification` (body: `{"phone":"...","code":"123456"}`) -## Persistence flavors +## Persistence flavours -The demo defaults to the testkit's in-memory SPIs so it runs without external infra. To wire real -persistence: +The demo defaults to the testkit's in-memory SPIs, so it runs without external infrastructure. Real +persistence is wired in as follows: -- **JDBI / Postgres** — add `pk-auth-persistence-jdbi` and provide a `Jdbi` bean. -- **DynamoDB** — add `pk-auth-persistence-dynamodb` and provide `DynamoDbClient` / `DynamoDbEnhancedClient` beans plus a `PkAuthDynamoTables`. +- JDBI and Postgres: add `pk-auth-persistence-jdbi` and provide a `Jdbi` bean. +- DynamoDB: add `pk-auth-persistence-dynamodb` and provide `DynamoDbClient` and + `DynamoDbEnhancedClient` beans plus a `PkAuthDynamoTables`. -Replace the `InMemoryPersistenceFactory` with a factory that surfaces those repos. +A factory that surfaces those repositories replaces `InMemoryPersistenceFactory`. -For hands-on poking at those backends, [`docker-compose.yml`](./docker-compose.yml) ships a -Postgres 16 + DynamoDB Local stack (none of which the default in-memory profile needs): +The [`docker-compose.yml`](./docker-compose.yml) ships a Postgres 16 and DynamoDB Local stack for +exercising those backends. The default in-memory profile needs none of it: ```bash docker compose up -d @@ -44,24 +46,23 @@ docker compose up -d ## Frontend -The demo's SPA lives in `src/main/resources/public/index.html` and `demo.js`, both of -which consume the shared `@pk-auth/passkeys-browser` SDK (bundled into the demo's -classpath at build time by the `processResources` Copy task). The SDK is served at -`/passkeys-browser/index.js` via the `StaticAssetsController`. +The demo's SPA lives in `src/main/resources/public/index.html` and `demo.js`, both of which consume +the shared `@pk-auth/passkeys-browser` SDK. The `processResources` Copy task bundles the SDK into +the demo's classpath at build time, and `StaticAssetsController` serves it at +`/passkeys-browser/index.js`. -> The idiomatic Micronaut path here would be `micronaut.router.static-resources` in -> `application.yml`. That binding wasn't picking the classpath path up in this demo's -> runtime, so the controller serves the three files explicitly. A fresh project would -> not need the workaround. +The idiomatic Micronaut binding is `micronaut.router.static-resources` in `application.yml`. That +binding does not resolve the classpath path in this demo's runtime, so the controller serves the +three files explicitly. A new project can use the idiomatic binding. -The demo sets `pkauth.relying-party.id=localhost` (and friends) via JVM system -properties on the `run` task — Micronaut's nested `@ConfigurationProperties` binding -didn't propagate the YAML keys through `PkAuthConfiguration.RelyingParty` reliably. +The `run` task sets `pkauth.relying-party.id=localhost` (and the related keys) as JVM system +properties. Micronaut's nested `@ConfigurationProperties` binding does not reliably propagate the +YAML keys through `PkAuthConfiguration.RelyingParty`. ## End-to-end tests -Playwright drives the full registration → login → manage passkeys → backup codes → -magic link → OTP flow against Chrome's CDP virtual WebAuthn authenticator: +Playwright drives the full registration, login, passkey management, backup codes, magic link, and +OTP flow against Chrome's CDP virtual WebAuthn authenticator: ```sh (cd examples/micronaut-demo/e2e && npm install) @@ -69,5 +70,5 @@ magic link → OTP flow against Chrome's CDP virtual WebAuthn authenticator: ``` The Playwright config's `webServer` block starts the demo on demand via -`./gradlew :examples:micronaut-demo:run`. Set `PK_DEMO_EXTERNAL=1` to run against a +`./gradlew :examples:micronaut-demo:run`. With `PK_DEMO_EXTERNAL=1`, the suite runs against a pre-started demo. diff --git a/examples/micronaut-demo/src/main/java/com/codeheadsystems/pkauth/demo/micronaut/StaticAssetsController.java b/examples/micronaut-demo/src/main/java/com/codeheadsystems/pkauth/demo/micronaut/StaticAssetsController.java index 98c120ae..7a05ba0c 100644 --- a/examples/micronaut-demo/src/main/java/com/codeheadsystems/pkauth/demo/micronaut/StaticAssetsController.java +++ b/examples/micronaut-demo/src/main/java/com/codeheadsystems/pkauth/demo/micronaut/StaticAssetsController.java @@ -21,10 +21,9 @@ /** * Serves the demo's single-page HTML, the shared SDK ESM bundle, and the demo glue script from * {@code src/main/resources/public/}. The YAML {@code micronaut.router.static-resources} block is - * the idiomatic Micronaut approach for this, but it didn't reliably pick up the classpath path in - * this demo's runtime (a fresh project would not hit the same issue) — so we serve them via three - * explicit handlers instead. Keeps the demo dependency-free of Micronaut features beyond - * {@code @Controller}. + * the idiomatic Micronaut approach for this, but it does not reliably pick up the classpath path in + * this demo's runtime, so three explicit handlers serve them instead. This keeps the demo free of + * Micronaut features beyond {@code @Controller}. */ @Controller("/") public final class StaticAssetsController { diff --git a/examples/spring-boot-demo/README.md b/examples/spring-boot-demo/README.md index ef0b383e..530d7910 100644 --- a/examples/spring-boot-demo/README.md +++ b/examples/spring-boot-demo/README.md @@ -1,21 +1,20 @@ # pk-auth Spring Boot demo A single-page exercise of every flow in the pk-auth credential layer: passkey registration -(including multi-passkey), passkey login, list/rename/delete credentials, regenerate -backup codes (view-once), magic-link email verification, SMS OTP phone verification, and -account summary. The post-login UI also decodes the issued JWT so you can see the claims. +(including multi-passkey), passkey login, list/rename/delete credentials, regenerate backup codes +(view-once), magic-link email verification, SMS OTP phone verification, and account summary. The +post-login UI also decodes the issued JWT and displays the claims. ## Running -The default profile boots with the testkit's in-memory adapters and needs no external -services: +The default profile boots with the testkit's in-memory adapters and needs no external services: ```sh ./gradlew :examples:spring-boot-demo:bootRun # open http://localhost:8080 ``` -Use the `application` plugin's `run` task interchangeably: +The `application` plugin's `run` task is interchangeable with `bootRun`: ```sh ./gradlew :examples:spring-boot-demo:run @@ -23,8 +22,8 @@ Use the `application` plugin's `run` task interchangeably: ### Switching persistence backends -Brief §6.14 requires both JDBI and DynamoDB variants. Start the external services with -`docker compose up -d`, then point the demo at one or the other: +Both JDBI and DynamoDB variants are selectable. `docker compose up -d` starts the external +services, and the `demo.persistence` property selects one or the other: ```sh # Postgres-backed (Flyway migrations run at startup): @@ -34,39 +33,37 @@ Brief §6.14 requires both JDBI and DynamoDB variants. Start the external servic ./gradlew :examples:spring-boot-demo:bootRun --args='--demo.persistence=dynamodb' ``` -> The wiring beans for `jdbi` and `dynamodb` are stubbed out in this demo — at the -> moment the runtime flag toggles the property, but the actual `Jdbi` / `DynamoDbClient` -> beans need to be supplied by the host app. A future iteration of this demo will ship -> those as profile-conditional `@Configuration` classes. For now, the `memory` profile -> is the supported runnable path; the JDBI / DynamoDB autoconfigs in the starter are -> validated by the starter's tests. +The wiring beans for `jdbi` and `dynamodb` are stubbed out in this demo. The runtime flag toggles +the property, and the host application supplies the actual `Jdbi` and `DynamoDbClient` beans. The +`memory` profile is the supported runnable path. The starter's tests validate the JDBI and DynamoDB +autoconfigs. ## Endpoint surface -The demo's HTML hits the standard pk-auth endpoints — there's no demo-specific REST -surface beyond `GET /` returning the SPA. The endpoint table: - -| Method | Path | Purpose | -|--------|---------------------------------------------------|---------| -| POST | /auth/passkeys/registration/start | Begin registration | -| POST | /auth/passkeys/registration/finish | Finish registration | -| POST | /auth/passkeys/authentication/start | Begin assertion | -| POST | /auth/passkeys/authentication/finish | Finish assertion (mints JWT) | -| GET | /auth/admin/account | Current user summary | -| GET | /auth/admin/credentials | List passkeys | -| PATCH | /auth/admin/credentials/{credentialId} | Rename a passkey | -| DELETE | /auth/admin/credentials/{credentialId} | Delete a passkey | -| POST | /auth/admin/backup-codes/regenerate | View-once plaintext codes | -| GET | /auth/admin/backup-codes/count | Remaining count | -| POST | /auth/admin/email/start-verification | Send magic link | -| POST | /auth/admin/email/complete-verification | Consume token (unauth) | -| POST | /auth/admin/phone/start-verification | Send OTP | -| POST | /auth/admin/phone/complete-verification | Verify OTP | +The demo's HTML uses the standard pk-auth endpoints. The only demo-specific route is `GET /`, which +returns the SPA. + +| Method | Path | Purpose | +|--------|------|---------| +| `POST` | `/auth/passkeys/registration/start` | Begin registration | +| `POST` | `/auth/passkeys/registration/finish` | Finish registration | +| `POST` | `/auth/passkeys/authentication/start` | Begin assertion | +| `POST` | `/auth/passkeys/authentication/finish` | Finish assertion (mints JWT) | +| `GET` | `/auth/admin/account` | Current user summary | +| `GET` | `/auth/admin/credentials` | List passkeys | +| `PATCH` | `/auth/admin/credentials/{credentialId}` | Rename a passkey | +| `DELETE` | `/auth/admin/credentials/{credentialId}` | Delete a passkey | +| `POST` | `/auth/admin/backup-codes/regenerate` | View-once plaintext codes | +| `GET` | `/auth/admin/backup-codes/count` | Remaining count | +| `POST` | `/auth/admin/email/start-verification` | Send magic link | +| `POST` | `/auth/admin/email/complete-verification` | Consume token (unauthenticated) | +| `POST` | `/auth/admin/phone/start-verification` | Send OTP | +| `POST` | `/auth/admin/phone/complete-verification` | Verify OTP | ## End-to-end tests -Playwright drives the full registration → login → manage passkeys → backup codes → -magic link → OTP flow against Chrome's CDP virtual WebAuthn authenticator. Run with: +Playwright drives the full registration, login, passkey management, backup codes, magic link, and +OTP flow against Chrome's CDP virtual WebAuthn authenticator: ```sh (cd examples/spring-boot-demo/e2e && npm install) @@ -74,17 +71,17 @@ magic link → OTP flow against Chrome's CDP virtual WebAuthn authenticator. Run ``` The Playwright config's `webServer` block starts the demo on demand via -`./gradlew :examples:spring-boot-demo:run`. To run against a pre-started demo (e.g. -in CI alongside Postgres / DynamoDB Local), set `PK_DEMO_EXTERNAL=1`. +`./gradlew :examples:spring-boot-demo:run`. With `PK_DEMO_EXTERNAL=1`, the suite runs against a +pre-started demo, for example in CI alongside Postgres and DynamoDB Local. ## Notes -- Magic-link tokens and SMS OTPs are logged to the server console (the demo's senders - are `LoggingEmailSender` / `LoggingSmsSender`). Copy them out of the log and paste - back into the form to complete the corresponding flow. -- The demo's SPA lives in `src/main/resources/static/index.html` and `demo.js`, both of - which consume the `@pk-auth/passkeys-browser` SDK (bundled into the demo's static - resources at build time by the `processResources` Copy task). -- WebAuthn requires a secure origin. `http://localhost:8080` is secure-by-loopback in - every major browser. Running the demo behind a remote host needs HTTPS. -- Virtual threads are enabled (`spring.threads.virtual.enabled=true`) per brief §11. +- Magic-link tokens and SMS OTPs are logged to the server console by the demo's senders, + `LoggingEmailSender` and `LoggingSmsSender`. The token or code is copied from the log and pasted + into the form to complete the corresponding flow. +- The demo's SPA lives in `src/main/resources/static/index.html` and `demo.js`, both of which + consume the `@pk-auth/passkeys-browser` SDK. The `processResources` Copy task bundles the SDK into + the demo's static resources at build time. +- WebAuthn requires a secure origin. `http://localhost:8080` is secure-by-loopback in every major + browser. Running the demo behind a remote host needs HTTPS. +- Virtual threads are enabled with `spring.threads.virtual.enabled=true`. diff --git a/examples/spring-boot-demo/src/main/java/com/codeheadsystems/pkauth/examples/spring/SpringBootDemoApplication.java b/examples/spring-boot-demo/src/main/java/com/codeheadsystems/pkauth/examples/spring/SpringBootDemoApplication.java index 6649a29e..2ccf8928 100644 --- a/examples/spring-boot-demo/src/main/java/com/codeheadsystems/pkauth/examples/spring/SpringBootDemoApplication.java +++ b/examples/spring-boot-demo/src/main/java/com/codeheadsystems/pkauth/examples/spring/SpringBootDemoApplication.java @@ -7,7 +7,7 @@ /** * Runnable entry point for the pk-auth Spring Boot demo. The auto-configuration in * pk-auth-spring-boot-starter wires every ceremony and admin endpoint; relying-party identity is - * configured via {@code pkauth.relying-party.*} in {@code application.yml} — no custom + * configured via {@code pkauth.relying-party.*} in {@code application.yml}; no custom * {@code @Bean @Primary RelyingPartyConfig} override is needed. To run: * *

diff --git a/examples/spring-boot-demo/src/test/java/com/codeheadsystems/pkauth/examples/spring/SpringBootDemoApplicationTest.java b/examples/spring-boot-demo/src/test/java/com/codeheadsystems/pkauth/examples/spring/SpringBootDemoApplicationTest.java
index cc1f046f..6b6ad82f 100644
--- a/examples/spring-boot-demo/src/test/java/com/codeheadsystems/pkauth/examples/spring/SpringBootDemoApplicationTest.java
+++ b/examples/spring-boot-demo/src/test/java/com/codeheadsystems/pkauth/examples/spring/SpringBootDemoApplicationTest.java
@@ -20,8 +20,8 @@
  * 
    *
  • the application context refreshes cleanly, *
  • the SPA index page is served at {@code /}, - *
  • the starter's ceremony endpoint is reachable (the demo doesn't redefine it — this proves - * the starter wiring is present in the demo's classpath). + *
  • the starter's ceremony endpoint is reachable (the demo does not redefine it, so this + * confirms the starter wiring is present on the demo's classpath). *
* *

Full ceremony coverage lives in {@code pk-auth-spring-boot-starter}'s integration tests. 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 d9a1fb39..f9e5340e 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 @@ -5,7 +5,7 @@ /** * 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 + * account. The 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 diff --git a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/AdminRequests.java b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/AdminRequests.java index 4ffb5df8..780753e0 100644 --- a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/AdminRequests.java +++ b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/AdminRequests.java @@ -35,7 +35,7 @@ public record RenameCredential(@Nullable String label) {} public record StartEmailVerification(@Nullable String email) {} /** - * Body for {@code POST /auth/admin/email/complete-verification}. Unauthenticated per brief §6.9 — + * Body for {@code POST /auth/admin/email/complete-verification}. Unauthenticated per brief §6.9; * the token identifies the user. * * @param token verification token previously dispatched to the user's inbox diff --git a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/AdminResponseMapper.java b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/AdminResponseMapper.java index 14c0e105..ef641aef 100644 --- a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/AdminResponseMapper.java +++ b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/AdminResponseMapper.java @@ -10,7 +10,7 @@ /** * Framework-neutral mapper from {@link AdminResult} to an HTTP response shape. Each adapter * (Spring, Dropwizard, Micronaut) holds a thin wrapper that converts the returned {@link - * AdminResponse} into its native HTTP type — {@link AdminResponseMapper} owns the status codes, the + * AdminResponse} into its native HTTP type; {@link AdminResponseMapper} owns the status codes, the * {@code Retry-After} header floor, and the unified error envelope shape. * *

Before this helper existed each adapter hand-rolled the same {@code switch} over the {@link 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 bff8cd6e..62741568 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 @@ -17,10 +17,10 @@ public sealed interface AdminResult { /** Operation succeeded. */ record Success(T value) implements AdminResult {} - /** The targeted resource (credential, user, …) doesn't exist. */ + /** The targeted resource (credential, user, …) does not exist. */ record NotFound() implements AdminResult {} - /** Authorizer denied the operation. */ + /** The {@link AdminAuthorizer} denied the operation. */ record Forbidden() implements AdminResult {} /** Request payload failed structural / semantic validation. */ 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 9a5cb99b..24ea0f27 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 @@ -2,8 +2,8 @@ package com.codeheadsystems.pkauth.admin; /** - * Tunable safety rules. Defaults match brief §6.9 — the last-credential guard is on; deleting a - * credential that would leave the user with zero credentials AND zero remaining backup codes is + * Tunable safety rules. Defaults match brief §6.9: the last-credential guard is on, and deleting a + * credential that would leave the user with zero credentials and zero remaining backup codes is * rejected with a {@link AdminResult.Conflict}. * * @param allowDeleteWithoutBackupCodes when true, callers can delete the last credential even when 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 5d427b67..222ee97d 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 @@ -25,7 +25,7 @@ public interface AdminService { * COSE algorithm identifier, e.g. {@code -7} for ES256). Read-side support for a crypto-agility / * post-quantum re-enrollment campaign: an operator can enumerate which credentials still use a * Shor-vulnerable algorithm and prompt those users to re-enroll. The algorithm is read from the - * already-stored COSE key — no schema change. See ADR 0019. + * already-stored COSE key; no schema change. See ADR 0019. * * @since 2.1.0 */ @@ -56,8 +56,8 @@ AdminResult deleteCredential( AdminResult startEmailVerification(UserHandle actor, UserHandle target, String email); /** - * Consumes a magic-link token and marks the user's email verified. Intentionally takes no {@code - * actor} — the brief mounts this endpoint as unauthenticated. + * Consumes a magic-link token and marks the user's email verified. Takes no {@code actor}; the + * brief mounts this endpoint as unauthenticated. * * @since 0.9.1 */ diff --git a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/BackupCodesCountResponse.java b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/BackupCodesCountResponse.java index cd78082a..db466ae2 100644 --- a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/BackupCodesCountResponse.java +++ b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/BackupCodesCountResponse.java @@ -2,7 +2,7 @@ package com.codeheadsystems.pkauth.admin; /** - * Wire-shape response for {@code GET /auth/admin/backup-codes/count} — JSON {@code {"remaining": + * Wire-shape response for {@code GET /auth/admin/backup-codes/count}: JSON {@code {"remaining": * n}}. Promoted out of the per-adapter controllers so every adapter emits the identical envelope; * before promotion the Spring adapter wrapped this in {@code Map.of(...)} while Dropwizard and * Micronaut returned the bare integer, which made the count endpoint the only admin endpoint whose diff --git a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/CredentialSummary.java b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/CredentialSummary.java index 5e3b2b02..56fafac2 100644 --- a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/CredentialSummary.java +++ b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/CredentialSummary.java @@ -18,7 +18,7 @@ * *

The {@code credentialId} field is the type-safe {@link CredentialId} value class (was raw * {@code byte[]} prior to 0.9.1). Wire JSON is unchanged: {@link CredentialId} has a Jackson - * (de)serializer registered in {@code PkAuthObjectMappers} that emits a base64url string, matching + * (de)serialiser registered in {@code PkAuthObjectMappers} that emits a base64url string, matching * the historical {@code byte[]} encoding. * * @since 0.9.1 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 e088a58c..a6c9db45 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 @@ -306,12 +306,12 @@ private static AdminResult notConfigured(String feature) { /** * 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 + * {@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 {@link #create(Dependencies, + *

An instance is passed 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. * @@ -323,7 +323,7 @@ public record Dependencies( @Nullable BackupCodeService backupCodeService, @Nullable MagicLinkService magicLinkService, @Nullable OtpService otpService) { - /** Compact constructor — enforces non-null on the two always-required collaborators. */ + /** Compact constructor that enforces non-null on the two always-required collaborators. */ public Dependencies { Objects.requireNonNull(credentialRepository, "credentialRepository"); Objects.requireNonNull(userLookup, "userLookup"); @@ -350,7 +350,7 @@ public static Dependencies passkeyOnly( * @since 0.9.1 */ public record Config(AdminAuthorizer authorizer, AdminSafetyConfig safetyConfig) { - /** Compact constructor — enforces non-null on every field. */ + /** Compact constructor that enforces non-null on every field. */ public Config { Objects.requireNonNull(authorizer, "authorizer"); Objects.requireNonNull(safetyConfig, "safetyConfig"); diff --git a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/EmailVerificationResult.java b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/EmailVerificationResult.java index b2f61dfc..5df1c1dd 100644 --- a/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/EmailVerificationResult.java +++ b/pk-auth-admin-api/src/main/java/com/codeheadsystems/pkauth/admin/EmailVerificationResult.java @@ -5,7 +5,7 @@ import java.util.Objects; /** - * Wire-shape response for {@code POST /auth/admin/email/complete-verification} — JSON {@code + * Wire-shape response for {@code POST /auth/admin/email/complete-verification}: JSON {@code * {"userHandle": ""}}. Promoted out of the per-adapter controllers so every adapter * emits the identical envelope; before promotion the Spring adapter wrapped the handle in {@code * Map.of(...)} while Dropwizard and Micronaut returned the bare {@code UserHandle} value (which @@ -13,9 +13,9 @@ * *

The {@code userHandle} component is the type-safe {@link UserHandle} value class (was raw * {@code String} until 0.9.1's byte-array-removal sweep). Wire JSON is unchanged because {@link - * UserHandle} has a registered Jackson serializer that emits a base64url-encoded (RFC 4648 §5, no - * padding) string of the handle bytes — the same shape adapters previously hand-stitched with - * {@code Base64Url.encode(handle.value())}. + * UserHandle} has a registered Jackson serialiser that emits a base64url-encoded (RFC 4648 §5, no + * padding) string of the handle bytes; this is the same shape adapters previously hand-stitched + * with {@code Base64Url.encode(handle.value())}. * * @param userHandle the verified user's handle (serialises as base64url string on the wire) * @since 0.9.1 diff --git a/pk-auth-admin-api/src/test/java/com/codeheadsystems/pkauth/admin/AdminResponseShapesTest.java b/pk-auth-admin-api/src/test/java/com/codeheadsystems/pkauth/admin/AdminResponseShapesTest.java index 16b5259b..7f1ec5dd 100644 --- a/pk-auth-admin-api/src/test/java/com/codeheadsystems/pkauth/admin/AdminResponseShapesTest.java +++ b/pk-auth-admin-api/src/test/java/com/codeheadsystems/pkauth/admin/AdminResponseShapesTest.java @@ -26,7 +26,7 @@ void emailVerificationResultRejectsNullHandle() { /** * Locks the JSON wire shape that promoted the per-adapter ad-hoc encoding: {@code * {"userHandle":""}}. The pk-auth Jackson module is responsible for emitting {@link - * UserHandle} as a base64url string — adapter call-sites no longer call {@code + * UserHandle} as a base64url string; adapter call-sites no longer call {@code * Base64Url.encode(...)} explicitly. */ @Test 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 2b57bcde..e068dd60 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 @@ -525,7 +525,7 @@ private void saveCredentialWithEs256Key(UserHandle user, byte[] credentialId) { /** * Captures the emailed magic-link URL so a test can recover the token. Since 2.3.0 {@link - * MagicLinkService.SendResult.Sent} carries only the jti, so — as for a real recipient — the + * MagicLinkService.SendResult.Sent} carries only the jti, so, as for a real recipient, the * emailed link is the only route to the token. */ private static final class CapturingEmailSender implements EmailSender { diff --git a/pk-auth-backup-codes/src/main/java/com/codeheadsystems/pkauth/backupcodes/BackupCodeService.java b/pk-auth-backup-codes/src/main/java/com/codeheadsystems/pkauth/backupcodes/BackupCodeService.java index d5291e52..d7b529ca 100644 --- a/pk-auth-backup-codes/src/main/java/com/codeheadsystems/pkauth/backupcodes/BackupCodeService.java +++ b/pk-auth-backup-codes/src/main/java/com/codeheadsystems/pkauth/backupcodes/BackupCodeService.java @@ -28,12 +28,12 @@ * against the user's stored hashes and consumes the matching record on success. * *

Verification is rate-limited via a {@link BackupCodeRateLimiter} (default: 5 attempts per user - * per 60 seconds) to bound the CPU cost of the Argon2id work. Supply a custom {@link - * BackupCodeRateLimiter} via the {@link Dependencies} record (or {@link Config}) to integrate with - * a shared (Redis/DB-backed) store in multi-instance deployments. + * per 60 seconds) to bound the CPU cost of the Argon2id work. A custom {@link + * BackupCodeRateLimiter} supplied via the {@link Dependencies} record (or {@link Config}) + * integrates with a shared (Redis/DB-backed) store in multi-instance deployments. * - *

Construct via {@link #create(Dependencies, Config)} (or {@link #create(Dependencies)} for the - * all-default case). + *

Instances are built via {@link #create(Dependencies, Config)} (or {@link + * #create(Dependencies)} for the all-default case). */ public final class BackupCodeService { @@ -42,11 +42,10 @@ public final class BackupCodeService { /** * Length of each plaintext code in characters. At length 10 over the 32-symbol {@link #ALPHABET} - * each code carries ~50 bits of entropy (10 × log2(32)). This is not a quantum concern: - * the real attack cost is dominated by the Argon2id verify hash plus the per-user verify rate - * limiter ({@link #DEFAULT_RATE_LIMIT} attempts per {@link #DEFAULT_RATE_WINDOW}), not by brute - * search of the code space. {@code CODE_LENGTH} is the lever if a deployment wants more margin; - * the default is deliberately left unchanged. + * each code carries ~50 bits of entropy (10 × log2(32)). This is not a quantum concern: the real + * attack cost is dominated by the Argon2id verify hash plus the per-user verify rate limiter + * ({@link #DEFAULT_RATE_LIMIT} attempts per {@link #DEFAULT_RATE_WINDOW}), not by brute search of + * the code space. {@code CODE_LENGTH} is the lever if a deployment wants more margin. */ public static final int CODE_LENGTH = 10; @@ -99,7 +98,7 @@ public final class BackupCodeService { * *

The default implementation ({@link InMemoryBackupCodeRateLimiter}) uses an in-process * counter that resets after {@link #DEFAULT_RATE_WINDOW}. Production multi-instance deployments - * SHOULD override with a shared (Redis/DB-backed) implementation so rate limits are not + * should override with a shared (Redis/DB-backed) implementation so rate limits are not * multiplied by the cluster size. */ public interface BackupCodeRateLimiter { @@ -120,9 +119,9 @@ public interface BackupCodeRateLimiter { * Result of a {@link #verify} call. * *

    - *
  • {@link Success} — the candidate matched an unconsumed code; the code is now consumed. - *
  • {@link NoMatch} — the candidate did not match any unconsumed code. - *
  • {@link RateLimited} — the user has exceeded the per-window verify limit; no Argon2id work + *
  • {@link Success}: the candidate matched an unconsumed code; the code is now consumed. + *
  • {@link NoMatch}: the candidate did not match any unconsumed code. + *
  • {@link RateLimited}: the user has exceeded the per-window verify limit; no Argon2id work * was performed. Retry after {@link RateLimited#retryAfterSeconds()} seconds. *
*/ @@ -198,11 +197,11 @@ public List generate(UserHandle user) { * If the per-user rate limit is exceeded, {@link VerifyResult.RateLimited} is returned * immediately with no Argon2id work. Otherwise {@link VerifyResult.NoMatch} is returned. * - *

Constant-time guarantee: the method always iterates every stored code row - * (both consumed and unconsumed). For consumed rows a dummy Argon2id verification is performed - * against a fixed throwaway hash so that each loop iteration takes approximately the same - * wall-clock time. This eliminates timing side-channels that would otherwise reveal the number of - * unconsumed codes or the position of the matching slot. + *

Constant-time guarantee: the method always iterates every stored code row (both consumed and + * unconsumed). For consumed rows a dummy Argon2id verification is performed against a fixed + * throwaway hash so that each loop iteration takes approximately the same wall-clock time. This + * eliminates timing side-channels that would otherwise reveal the number of unconsumed codes or + * the position of the matching slot. */ public VerifyResult verify(UserHandle user, String candidate) { Objects.requireNonNull(user, "user"); @@ -319,7 +318,7 @@ private String newCode() { * Canonical holder of the required collaborators for {@link BackupCodeService}. * *

The {@code rateLimiter} defaults to {@link InMemoryBackupCodeRateLimiter} when constructed - * via {@link #of(BackupCodeRepository, ClockProvider)}; multi-instance deployments SHOULD supply + * via {@link #of(BackupCodeRepository, ClockProvider)}; multi-instance deployments should supply * a shared (Redis/DB-backed) implementation directly to the canonical constructor. * * @since 0.9.1 @@ -328,7 +327,7 @@ public record Dependencies( BackupCodeRepository repository, ClockProvider clockProvider, BackupCodeRateLimiter rateLimiter) { - /** Compact constructor — enforces non-null on all required collaborators. */ + /** Compact constructor that enforces non-null on all required collaborators. */ public Dependencies { Objects.requireNonNull(repository, "repository"); Objects.requireNonNull(clockProvider, "clockProvider"); @@ -363,7 +362,7 @@ public record Config( int parallelism, int codeCount, int rateLimit) { - /** Compact constructor — enforces non-null on object-typed fields and positive ranges. */ + /** Compact constructor that enforces non-null on object-typed fields and positive ranges. */ public Config { Objects.requireNonNull(random, "random"); Objects.requireNonNull(argon2, "argon2"); @@ -405,8 +404,8 @@ public static Config defaults() { /** * Simple in-process rate limiter backed by {@link InMemoryWindowCounter}. * - *

FOR DEV / SINGLE-INSTANCE USE ONLY. Production multi-instance deployments - * MUST replace this with a shared (Redis/DB-backed) {@link BackupCodeRateLimiter} implementation; + *

Intended for development and single-instance use only. Production multi-instance deployments + * must replace this with a shared (Redis/DB-backed) {@link BackupCodeRateLimiter} implementation; * otherwise the per-replica limit multiplies by the cluster size. * * @since 0.9.1 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 b2a1f51d..8b32bc33 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 @@ -16,11 +16,11 @@ public sealed interface AssertionResult { * policy). */ enum CounterStatus { - /** Counter advanced as expected — no anomaly detected. */ + /** Counter advanced as expected; no anomaly detected. */ OK, /** * Counter regressed but was accepted because {@code CounterRegressionPolicy.WARN} is active. - * Operators should alert on this status; the stored counter was NOT advanced. + * Operators should alert on this status; the stored counter was not advanced. */ REGRESSED_WARN } @@ -29,14 +29,13 @@ enum CounterStatus { * Assertion succeeded; the credential's sign count and lastUsedAt should be updated. * *

Check {@link #counterStatus()} to distinguish a clean success from one accepted despite - * counter regression under WARN policy. Callers MUST supply {@code counterStatus} explicitly — - * the prior convenience constructor that defaulted to {@link CounterStatus#OK} was removed in - * 0.9.1 to force the counter-status decision to be visible at every call site. + * counter regression under WARN policy. Callers must supply {@code counterStatus} explicitly; the + * convenience constructor that defaulted to {@link CounterStatus#OK} was removed in 0.9.1. * *

The {@code credentialId} component is the type-safe {@link CredentialId} value class (was * raw {@code byte[]} prior to 0.9.1). Adapter mappers and JWT helpers consume the value class * directly; wire JSON is unchanged because {@link CredentialId} has a registered Jackson - * (de)serializer that emits base64url. + * (de)serialiser that emits base64url. * * @since 0.9.1 */ @@ -83,16 +82,14 @@ record OriginMismatch(String expected, String actual) implements AssertionResult } /** - * Received counter is less than or equal to the stored counter — authenticator cloning risk. + * Received counter is less than or equal to the stored counter (authenticator cloning risk). * - *

No compact constructor: this record intentionally accepts any pair of {@code long} - * values, including negatives, equal values, and {@code received > stored}. The variant is - * constructed at exactly one site ({@code - * DefaultPasskeyAuthenticationService.handleCounterRegression}) where the inputs are already - * validated against the authenticator-supplied sign count and the stored credential row; an extra - * compact-ctor invariant here would only hide a bug at the construction site, not catch one at - * the boundary. Treat {@code stored} and {@code received} as informational telemetry fields, not - * as enforced contracts. + *

This record has no compact constructor and accepts any pair of {@code long} values, + * including negatives, equal values, and {@code received > stored}. The variant is constructed at + * one site ({@code DefaultPasskeyAuthenticationService.handleCounterRegression}) where the inputs + * are already validated against the authenticator-supplied sign count and the stored credential + * row. The {@code stored} and {@code received} fields are informational telemetry, not enforced + * contracts. * * @since 0.9.1 */ 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 62b3b768..3cf2130a 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 @@ -13,8 +13,8 @@ * Canonical wire contract for the four ceremony endpoints. Every adapter ({@code * pk-auth-spring-boot-starter}, {@code pk-auth-dropwizard}, {@code pk-auth-micronaut}) routes its * {@link RegistrationResult} / {@link AssertionResult} through this mapper so the resulting JSON - * body and HTTP status code are byte-identical across adapters. That's what lets the {@code - * @pk-auth/passkeys-browser} TypeScript SDK target one wire shape and Just Work everywhere. + * body and HTTP status code are byte-identical across adapters. This lets the {@code + * @pk-auth/passkeys-browser} TypeScript SDK target one wire shape across adapters. * *

Body shape: every non-success carries {@code {"outcome": "", ...}}. Success * carries {@code {"outcome": "success", ...}} plus result-specific fields. @@ -58,19 +58,19 @@ private static CeremonyResponse rateLimitedResponse() { } /** - * Carries a wire-format response: HTTP status code, a JSON-serializable body, and response - * headers. Adapters MUST copy {@link #headers()} onto the native HTTP response (e.g. {@code + * Carries a wire-format response: HTTP status code, a JSON-serialisable body, and response + * headers. Adapters must copy {@link #headers()} onto the native HTTP response (e.g. {@code * Retry-After} on a 429); a body-only adapter silently drops them. * *

The {@code headers} component was added in 2.1.0; the two-arg constructor preserves the * prior {@code (status, body)} call sites with no headers. * *

Both maps are copied into unmodifiable views that keep the caller's iteration order, so the - * ordered bodies this mapper builds serialize with {@code outcome} first (since 2.3.0; earlier + * ordered bodies this mapper builds serialise with {@code outcome} first (since 2.3.0; earlier * versions used {@code Map.copyOf}, which drops order). Null keys and values are still rejected. * * @param status the HTTP status code - * @param body the JSON-serializable response body + * @param body the JSON-serialisable response body * @param headers response headers to copy onto the native HTTP response (since 2.1.0) */ public record CeremonyResponse( @@ -91,7 +91,7 @@ private static Map orderedCopy(Map source) { * Convenience constructor for a response with no extra headers. * * @param status the HTTP status code - * @param body the JSON-serializable response body + * @param body the JSON-serialisable response body */ public CeremonyResponse(int status, Map body) { this(status, body, Map.of()); diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/PublicKeyCredentialCreationOptionsJson.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/PublicKeyCredentialCreationOptionsJson.java index 60113f81..c30b94f7 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/PublicKeyCredentialCreationOptionsJson.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/PublicKeyCredentialCreationOptionsJson.java @@ -11,11 +11,11 @@ * Server payload for {@code navigator.credentials.create()}. Mirrors the WebAuthn JSON Spec §5.1.3 * "PublicKeyCredentialCreationOptionsJSON". * - *

Privacy invariant: {@code excludeCredentials} is always non-null and is - * serialized as a JSON array (possibly empty). Emitting {@code null} (or omitting the field) for - * brand-new users while emitting a populated list for existing users would create an - * account-enumeration oracle on the public {@code permitAll} start-registration endpoint. Callers - * MUST pass an empty list rather than {@code null} when there are no credentials to exclude. + *

Privacy invariant: {@code excludeCredentials} is always non-null and is serialised as a JSON + * array (possibly empty). Emitting {@code null} (or omitting the field) for brand-new users while + * emitting a populated list for existing users would create an account-enumeration oracle on the + * public {@code permitAll} start-registration endpoint. Callers must pass an empty list rather than + * {@code null} when there are no credentials to exclude. * * @since 0.9.1 */ diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/PublicKeyCredentialRequestOptionsJson.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/PublicKeyCredentialRequestOptionsJson.java index 7b0adb37..bb23d569 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/PublicKeyCredentialRequestOptionsJson.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/PublicKeyCredentialRequestOptionsJson.java @@ -12,11 +12,11 @@ * Server payload for {@code navigator.credentials.get()}. Mirrors the WebAuthn JSON Spec §5.1.4 * "PublicKeyCredentialRequestOptionsJSON". * - *

Privacy invariant: {@code allowCredentials} is always non-null and is - * serialized as a JSON array (possibly empty). Emitting {@code null} (or omitting the field) for - * unknown users while emitting a populated list for known users would create an account-enumeration - * oracle on the public {@code permitAll} start-authentication endpoint. Callers MUST pass an empty - * list rather than {@code null} when there are no credentials to allow. + *

Privacy invariant: {@code allowCredentials} is always non-null and is serialised as a JSON + * array (possibly empty). Emitting {@code null} (or omitting the field) for unknown users while + * emitting a populated list for known users would create an account-enumeration oracle on the + * public {@code permitAll} start-authentication endpoint. Callers must pass an empty list rather + * than {@code null} when there are no credentials to allow. * * @since 0.9.1 */ 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 8e70aaf3..dfd0af2f 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 @@ -16,11 +16,10 @@ public record StartAuthenticationRequest( /** * Rejects a username longer than {@link StartRegistrationRequest#MAX_USERNAME_LENGTH}. A {@code - * null} username is still valid — that is the usernameless / discoverable-credential flow. Blank - * is deliberately NOT rejected here (unlike the registration request): an unknown username - * already yields the same empty {@code allowCredentials} shape as a known one, so there is no - * enumeration signal to protect, and tightening it would change existing behaviour for no - * security gain. + * null} username is still valid; that is the usernameless / discoverable-credential flow. Blank + * is not rejected here (unlike the registration request): an unknown username already yields the + * same empty {@code allowCredentials} shape as a known one, so there is no enumeration signal to + * protect. * * @since 2.3.0 */ diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/StartAuthenticationResult.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/StartAuthenticationResult.java index 949cef79..367b3634 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/StartAuthenticationResult.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/StartAuthenticationResult.java @@ -9,10 +9,10 @@ * thrown across the adapter boundary. * *

    - *
  • {@link Started} — the limiter allowed the call; carries the {@link + *
  • {@link Started}: the limiter allowed the call; carries the {@link * StartAuthenticationResponse} envelope the browser consumes. - *
  • {@link RateLimited} — the configured {@code CeremonyRateLimiter} refused the call before - * any challenge was created; adapters map it to HTTP {@code 429}. + *
  • {@link RateLimited}: the configured {@code CeremonyRateLimiter} refused the call before any + * challenge was created; adapters map it to HTTP {@code 429}. *
* * @since 2.0.0 @@ -47,7 +47,7 @@ record RateLimited(String bucket) implements StartAuthenticationResult { /** * Convenience for embedded/test callers that do not configure a rate limiter and therefore never * expect a refusal. Returns the {@link StartAuthenticationResponse} on {@link Started}; throws on - * {@link RateLimited}. Adapter controllers must NOT use this — they pattern-match the sum so the + * {@link RateLimited}. Adapter controllers must not use this; they pattern-match the sum so the * {@code 429} path is handled explicitly. * * @return the start-authentication envelope. 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 999ea2c0..1ae54006 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 @@ -23,7 +23,7 @@ public record StartRegistrationRequest( *

The username is attacker-chosen on this {@code permitAll} endpoint and is retained well * beyond the request: it keys the per-username rate-limit bucket for the limiter's whole window, * and {@code startRegistration} passes it to {@link - * com.codeheadsystems.pkauth.spi.UserLookup#getOrCreateHandle} — which persists a user row before + * com.codeheadsystems.pkauth.spi.UserLookup#getOrCreateHandle}, which persists a user row before * any credential exists. Unbounded, both of those grow with whatever the caller sends. 256 * comfortably covers an email address used as a username. * diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/StartRegistrationResult.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/StartRegistrationResult.java index e7a4060d..263955bd 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/StartRegistrationResult.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/StartRegistrationResult.java @@ -9,10 +9,10 @@ * thrown across the adapter boundary. * *

    - *
  • {@link Started} — the limiter allowed the call; carries the {@link + *
  • {@link Started}: the limiter allowed the call; carries the {@link * StartRegistrationResponse} envelope the browser consumes. - *
  • {@link RateLimited} — the configured {@code CeremonyRateLimiter} refused the call before - * any challenge was created; adapters map it to HTTP {@code 429}. + *
  • {@link RateLimited}: the configured {@code CeremonyRateLimiter} refused the call before any + * challenge was created; adapters map it to HTTP {@code 429}. *
* * @since 2.0.0 @@ -47,7 +47,7 @@ record RateLimited(String bucket) implements StartRegistrationResult { /** * Convenience for embedded/test callers that do not configure a rate limiter and therefore never * expect a refusal. Returns the {@link StartRegistrationResponse} on {@link Started}; throws on - * {@link RateLimited}. Adapter controllers must NOT use this — they pattern-match the sum so the + * {@link RateLimited}. Adapter controllers must not use this; they pattern-match the sum so the * {@code 429} path is handled explicitly. * * @return the start-registration envelope. 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 c721e061..ddc467b6 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 @@ -33,7 +33,7 @@ public String wireName() { /** * Parses a WebAuthn transport wire string into a {@code Transport}. Returns {@link - * Optional#empty()} when the string is null or not a recognized value (e.g. when an authenticator + * Optional#empty()} when the string is null or not a recognised value (e.g. when an authenticator * advertises a future transport this enum does not yet enumerate). */ public static Optional fromWire(@Nullable String s) { diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/package-info.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/package-info.java index fd35db71..2aeb78e6 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/package-info.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/api/package-info.java @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -/** Public DTOs (records) and sealed result types — pk-auth's wire contract. */ +/** Public DTOs (records) and sealed result types that form pk-auth's wire contract. */ @org.jspecify.annotations.NullMarked package com.codeheadsystems.pkauth.api; diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/ceremony/InMemoryCeremonyRateLimiter.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/ceremony/InMemoryCeremonyRateLimiter.java index be9cd715..bb1dff2e 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/ceremony/InMemoryCeremonyRateLimiter.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/ceremony/InMemoryCeremonyRateLimiter.java @@ -15,16 +15,16 @@ * Tracks two independent fixed-window buckets (per-IP and per-username), each an {@link * InMemoryWindowCounter} expiring after the configured window. * - *

FOR DEV / SINGLE-INSTANCE USE ONLY. Production deployments MUST replace this - * with a shared (Redis/DB-backed) {@link CeremonyRateLimiter} implementation, otherwise per-replica - * counters multiply by the cluster size. For example, with a limit of 30 starts per minute and a - * 3-node cluster, an attacker can issue up to 90 starts per minute because each replica tracks its - * own independent counter. Wire a production-grade implementation via the {@code - * PasskeyAuthenticationServices.Builder#ceremonyRateLimiter(...)} seam. + *

This implementation is for development and single-instance use only. Production deployments + * must replace it with a shared (Redis/DB-backed) {@link CeremonyRateLimiter} implementation, + * otherwise per-replica counters multiply by the cluster size. For example, with a limit of 30 + * starts per minute and a 3-node cluster, an attacker can issue up to 90 starts per minute because + * each replica tracks its own independent counter. Wire a production-grade implementation via the + * {@code PasskeyAuthenticationServices.Builder#ceremonyRateLimiter(...)} seam. * - *

The defaults are intentionally generous so legitimate UX (a user fumbling their authenticator, - * a flaky network forcing a retry) does not trip the limiter, but are tight enough to throttle - * single-source enumeration / brute-force at a meaningful rate. Hosts that want different limits + *

The defaults are generous enough that ordinary use (a user fumbling their authenticator, a + * flaky network forcing a retry) does not trip the limiter, and tight enough to throttle + * single-source enumeration and brute force at a meaningful rate. Hosts that want different limits * should construct this class with explicit values or supply their own SPI implementation. * * @since 0.9.1 @@ -43,7 +43,7 @@ public final class InMemoryCeremonyRateLimiter implements CeremonyRateLimiter { /** * Maximum tracked keys per bucket map. Both maps are keyed by attacker-influenced values (source * IP, submitted username) on {@code permitAll} endpoints, and {@code expireAfterWrite} alone - * retains every distinct key for the full window — so without a size bound the maps grow with the + * retains every distinct key for the full window; without a size bound the maps grow with the * caller's key variety, not with the number of real users. Caffeine evicts near-LRU entries at * the cap; an evicted counter simply restarts, which costs at most one extra allowance to the * least-active key and never grants an unbounded budget to an active one. Same value as (and 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 139e5d22..aaa39022 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 @@ -15,7 +15,7 @@ * Framework-neutral entry point for WebAuthn ceremonies. Implemented by the core's {@code * DefaultPasskeyAuthenticationService} and consumed by every framework adapter. * - *

No exceptions cross this boundary for ceremony-flow failures — every failure mode is a variant + *

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.). * @@ -24,10 +24,10 @@ public interface PasskeyAuthenticationService { /** - * Issue {@code PublicKeyCredentialCreationOptions} for a new registration and store the matching - * challenge in the {@code ChallengeStore} for later verification. The returned envelope carries - * both the WebAuthn options the browser consumes and the {@code ChallengeId} the client must - * round-trip in {@code finishRegistration}. + * Issues {@code PublicKeyCredentialCreationOptions} for a new registration and stores the + * matching challenge in the {@code ChallengeStore} for later verification. The returned envelope + * carries both the WebAuthn options the browser consumes and the {@code ChallengeId} the client + * must round-trip in {@code finishRegistration}. */ default StartRegistrationResult startRegistration(StartRegistrationRequest req) { return startRegistration(req, null); @@ -35,13 +35,13 @@ default StartRegistrationResult startRegistration(StartRegistrationRequest req) /** * Variant of {@link #startRegistration(StartRegistrationRequest)} that consults the configured - * {@code CeremonyRateLimiter} against {@code clientIp}. Adapter controllers SHOULD prefer this + * {@code CeremonyRateLimiter} against {@code clientIp}. Adapter controllers should prefer this * overload so the per-IP rate limit takes effect; the no-IP overload bypasses the per-IP bucket * and is retained for callers (tests, embedded scenarios) that cannot supply a source IP. * * @param req start request * @param clientIp source IP address of the HTTP request, or {@code null} when the host cannot - * determine one — the limiter implementation decides how to handle the null case + * determine one; the limiter implementation decides how to handle the null case * @return start-registration result; {@link StartRegistrationResult.RateLimited} when the limiter * refuses the call (before any challenge is created), otherwise {@link * StartRegistrationResult.Started} @@ -50,7 +50,7 @@ default StartRegistrationResult startRegistration(StartRegistrationRequest req) StartRegistrationResult startRegistration( StartRegistrationRequest req, @Nullable String clientIp); - /** Verify a registration response and produce a persistable credential record. */ + /** Verifies a registration response and produces a persistable credential record. */ default RegistrationResult finishRegistration(FinishRegistrationRequest req) { return finishRegistration(req, null); } @@ -58,7 +58,7 @@ default RegistrationResult finishRegistration(FinishRegistrationRequest req) { /** * Variant of {@link #finishRegistration(FinishRegistrationRequest)} that consults the configured * {@code CeremonyRateLimiter} against {@code clientIp} before running the WebAuthn attestation - * verification. Adapter controllers SHOULD prefer this overload so the per-IP rate limit takes + * verification. Adapter controllers should prefer this overload so the per-IP rate limit takes * effect. * * @param req finish request @@ -70,7 +70,7 @@ default RegistrationResult finishRegistration(FinishRegistrationRequest req) { RegistrationResult finishRegistration(FinishRegistrationRequest req, @Nullable String clientIp); /** - * Issue {@code PublicKeyCredentialRequestOptions} for an authentication ceremony and store the + * Issues {@code PublicKeyCredentialRequestOptions} for an authentication ceremony and stores the * matching challenge in the {@code ChallengeStore}. The returned envelope carries both the * WebAuthn options the browser consumes and the {@code ChallengeId} the client must round-trip in * {@code finishAuthentication}. @@ -94,7 +94,7 @@ default StartAuthenticationResult startAuthentication(StartAuthenticationRequest StartAuthenticationResult startAuthentication( StartAuthenticationRequest req, @Nullable String clientIp); - /** Verify an authentication response. */ + /** Verifies an authentication response. */ default AssertionResult finishAuthentication(FinishAuthenticationRequest req) { return finishAuthentication(req, null); } 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 a27eb6b0..3ba461b3 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 @@ -30,7 +30,7 @@ public final class PasskeyAuthenticationServices { private PasskeyAuthenticationServices() {} - /** Builder so adapter modules can wire only the SPIs they actually customize. */ + /** Builder so adapter modules can wire only the SPIs they actually customise. */ public static Builder builder() { return new Builder(); } @@ -103,7 +103,7 @@ public Builder ceremonyConfig(CeremonyConfig ceremonyConfig) { return this; } - /** Override the {@link SecureRandom} used for challenge generation (test seam). */ + /** Overrides the {@link SecureRandom} used for challenge generation (test seam). */ public Builder secureRandom(SecureRandom secureRandom) { this.secureRandom = secureRandom; return this; @@ -115,9 +115,9 @@ public Builder metrics(Metrics metrics) { } /** - * Override the {@link CeremonyRateLimiter} consulted on every ceremony entrypoint. When unset, + * Overrides the {@link CeremonyRateLimiter} consulted on every ceremony entrypoint. When unset, * the builder wires an {@link InMemoryCeremonyRateLimiter} with the default per-IP / - * per-username allowances. Multi-replica deployments MUST supply a shared (Redis / DB-backed) + * per-username allowances. Multi-replica deployments must supply a shared (Redis / DB-backed) * implementation here; the in-memory default tracks counters per-process and per-replica * counters multiply by the cluster size. * 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 66082f39..eddb429a 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 @@ -13,15 +13,15 @@ * Ceremony-level policy knobs. {@link #defaults()} holds the conservative defaults; their security * rationale is in {@code docs/threat-model.md}. * - *

Crypto-agility. {@link #offeredAlgorithms()} and {@link - * #acceptedAlgorithms()} are the single source of truth for which COSE signature algorithms a - * deployment advertises in the create-options ceremony and accepts on registration verification, - * respectively. Both the create-options path and the WebAuthn4J verify path derive their algorithm - * lists from this config — there are no hardcoded algorithm lists elsewhere. The {@code accepted} - * list is authoritative (a credential whose algorithm is absent from it is rejected on verify); - * {@code offered} must be a subset of {@code accepted} and may be narrower so a deployment can - * advertise fewer algorithms than it still honors for already-registered credentials. See {@code - * docs/threat-model.md} (Post-quantum readiness) and ADR 0019. + *

Crypto-agility: {@link #offeredAlgorithms()} and {@link #acceptedAlgorithms()} are the single + * source of truth for which COSE signature algorithms a deployment advertises in the create-options + * ceremony and accepts on registration verification, respectively. Both the create-options path and + * the WebAuthn4J verify path derive their algorithm lists from this config; there are no hardcoded + * algorithm lists elsewhere. The {@code accepted} list is authoritative (a credential whose + * algorithm is absent from it is rejected on verify); {@code offered} must be a subset of {@code + * accepted} and may be narrower so a deployment can advertise fewer algorithms than it still + * honours for already-registered credentials. See {@code docs/threat-model.md} (Post-quantum + * readiness) and ADR 0019. * * @since 0.9.0 */ @@ -39,8 +39,8 @@ public record CeremonyConfig( /** * Algorithms offered to the authenticator in the registration create-options ({@code - * PublicKeyCredentialParameters}). The historical offered set: ES256, EdDSA, RS256. Deliberately - * a subset of {@link #DEFAULT_ACCEPTED_ALGORITHMS}. + * PublicKeyCredentialParameters}). The historical offered set: ES256, EdDSA, RS256. The set is a + * subset of {@link #DEFAULT_ACCEPTED_ALGORITHMS}. * * @since 2.1.0 */ @@ -48,10 +48,10 @@ public record CeremonyConfig( List.of(CoseAlgorithm.ES256, CoseAlgorithm.EdDSA, CoseAlgorithm.RS256); /** - * Algorithms accepted on registration verification — the source of truth. The default is the + * Algorithms accepted on registration verification; the source of truth. The default is the * historical {@code WebAuthn4JConverters} verify set: ES256, EdDSA, RS256, ES384, RS384. It is - * the union of everything previously accepted so no already-registered credential can - * fail verification. + * the union of everything previously accepted so no already-registered credential can fail + * verification. * * @since 2.1.0 */ @@ -89,8 +89,8 @@ public record CeremonyConfig( /** * Backward-compatible constructor that applies {@link #DEFAULT_OFFERED_ALGORITHMS} and {@link * #DEFAULT_ACCEPTED_ALGORITHMS}. Existing call sites that predate the crypto-agility fields keep - * compiling and keep the exact historical algorithm behavior; pass the seven-argument canonical - * constructor (or {@link #from}) to override the algorithm lists. + * compiling and keep the exact historical algorithm behaviour. The seven-argument canonical + * constructor (or {@link #from}) overrides the algorithm lists. * * @since 0.9.0 */ @@ -114,7 +114,7 @@ public CeremonyConfig( * Conservative defaults appropriate for a consumer passkey deployment. * *

{@code userVerification} defaults to {@link UserVerificationRequirement#REQUIRED} so - * WebAuthn4J enforces the asserted {@code flagUV} on every assertion — a deployment that wants to + * WebAuthn4J enforces the asserted {@code flagUV} on every assertion; a deployment that wants to * relax this (e.g. for hardware security keys without UV) must opt in explicitly. */ public static CeremonyConfig defaults() { @@ -132,8 +132,8 @@ public static CeremonyConfig defaults() { * Builds a {@link CeremonyConfig} from raw host configuration where any knob the host left unset * is {@code null}. Each null field falls back to the conservative value from {@link #defaults()} * (e.g. {@code userVerification=REQUIRED}, {@code counterRegression=REJECT}); a {@code null} - * never weakens a knob — a host must pass a non-null value to relax a default. Centralizes the - * per-field default-coalescing every adapter previously performed by hand. + * never weakens a knob; a host must pass a non-null value to relax a default. Centralises + * per-field default coalescing for all adapters. * * @param challengeTtl challenge TTL, or null for the default. * @param userVerification UV requirement, or null for the default. @@ -164,7 +164,7 @@ public static CeremonyConfig from( * ResidentKeyRequirement, AttestationConveyance, CounterRegressionPolicy)} that also lets a host * narrow or reorder the COSE algorithm lists. A {@code null} {@code offeredAlgorithms} / {@code * acceptedAlgorithms} falls back to {@link #DEFAULT_OFFERED_ALGORITHMS} / {@link - * #DEFAULT_ACCEPTED_ALGORITHMS}, preserving the historical algorithm behavior. + * #DEFAULT_ACCEPTED_ALGORITHMS}, preserving the historical algorithm behaviour. * * @param challengeTtl challenge TTL, or null for the default. * @param userVerification UV requirement, or null for the default. diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/config/CoseAlgorithm.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/config/CoseAlgorithm.java index 710e7218..c52c0a57 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/config/CoseAlgorithm.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/config/CoseAlgorithm.java @@ -4,19 +4,17 @@ /** * The COSE signature algorithms pk-auth can offer to an authenticator and accept on a passkey * registration. This is the single, framework-neutral vocabulary that both the create-options - * ceremony and the WebAuthn4J verification path map from (see {@link CeremonyConfig}); it replaces - * the two formerly-divergent hardcoded lists. + * ceremony and the WebAuthn4J verification path map from (see {@link CeremonyConfig}). * *

Each constant carries its IANA COSE * algorithm identifier (the negative integer that appears as label {@code 3} in a COSE key and * in {@code PublicKeyCredentialParameters.alg} on the wire). * - *

Post-quantum note. Every algorithm here (ECDSA, EdDSA, RSA-PKCS1) is - * Shor-vulnerable. No post-quantum signature scheme is yet standardized in the WebAuthn/COSE/FIDO2 - * stack or implemented by WebAuthn4J, so none can be added today. This enum is the seam through - * which a quantum-safe COSE algorithm would be introduced once the ecosystem supports it — without - * touching the ceremony or verification code. See {@code docs/threat-model.md} (Post-quantum - * readiness) and ADR 0019. + *

Post-quantum note: every algorithm here (ECDSA, EdDSA, RSA-PKCS1) is Shor-vulnerable. No + * post-quantum signature scheme is yet standardised in the WebAuthn/COSE/FIDO2 stack or implemented + * by WebAuthn4J, so none can be added today. This enum is the seam through which a quantum-safe + * COSE algorithm would be introduced once the ecosystem supports it, without touching the ceremony + * or verification code. See {@code docs/threat-model.md} (Post-quantum readiness) and ADR 0019. * * @since 2.1.0 */ 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 a6d01da7..653c1d97 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 @@ -10,9 +10,9 @@ * @since 0.9.0 */ public enum CounterRegressionPolicy { - /** Reject the assertion (cloning risk). Default for hardware-token-heavy deployments. */ + /** Rejects the assertion (cloning risk). Default for hardware-token-heavy deployments. */ REJECT, - /** Log a warning but accept the assertion. Appropriate for synced-passkey-heavy deployments. */ + /** Logs a warning but accepts the assertion. Appropriate for synced-passkey-heavy deployments. */ WARN } 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 13c136b0..9015a3fb 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 @@ -34,9 +34,10 @@ public record RelyingPartyConfig(String id, String name, Set origins) { /** * Builds a {@link RelyingPartyConfig} from raw host configuration, applying the validation and - * the canonical "required — no defaults" error message every adapter shares. RP id, name, and - * origins are mandatory (there is deliberately no default); a missing or blank value raises an - * {@link IllegalStateException} naming the {@code pkauth.relying-party.*} configuration keys. + * the canonical error message every adapter shares (the properties are required, with no + * defaults). RP id, name, and origins are mandatory (there is no default); a missing or blank + * value raises an {@link IllegalStateException} naming the {@code pkauth.relying-party.*} + * configuration keys. * * @param id the RP ID (eTLD+1), or null/blank if unset. * @param name human-readable RP name, or null/blank if unset. diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/credential/CredentialAlgorithms.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/credential/CredentialAlgorithms.java index 074b0308..198aff47 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/credential/CredentialAlgorithms.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/credential/CredentialAlgorithms.java @@ -8,11 +8,11 @@ /** * Read-side helpers for reasoning about the COSE signature algorithm a stored credential uses. * - *

WebAuthn4J records the algorithm inside the COSE-encoded public key ({@link - * CredentialRecord#publicKeyCose()}), so the algorithm is already persisted on every credential — + *

WebAuthn4J records the algorithm inside the COSE-encoded public key ({@link + * CredentialRecord#publicKeyCose()}), so the algorithm is already persisted on every credential; * there is no separate column and no schema change is needed to read it. This lets an operator - * answer "which stored credentials use a (Shor-vulnerable) algorithm I want to migrate off?" and - * drive a re-enrollment campaign, without expanding the {@code CredentialRepository} SPI. + * answer "which stored credentials use a (Shor-vulnerable) algorithm that needs migrating?" and + * drive a re-enrolment campaign, without expanding the {@code CredentialRepository} SPI. * *

The return value is the IANA COSE algorithm identifier (e.g. {@code -7} for ES256), matching * {@link com.codeheadsystems.pkauth.config.CoseAlgorithm#coseValue()} so callers can compare 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 c3857797..735bef3f 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 @@ -52,9 +52,9 @@ public record CredentialRecord( * arbitrarily large blob out of storage and out of every UI that renders a credential list. It is * a nickname ("Work laptop", "MacBook Touch ID"), so 64 is generous. * - *

This is a length bound only — it is not an escaping mechanism. A label is untrusted - * text: renderers MUST escape it for their output context (see the demos' credential list, which - * builds DOM nodes with {@code textContent} rather than interpolating into {@code innerHTML}). + *

This is a length bound only, not an escaping mechanism. A label is untrusted text: renderers + * must escape it for their output context (see the demos' credential list, which builds DOM nodes + * with {@code textContent} rather than interpolating into {@code innerHTML}). * * @since 2.3.0 */ 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 e0626747..f59d5b65 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 @@ -4,7 +4,8 @@ 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, which indicates a + * programmer error. * * @since 0.9.0 */ 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 c9c6e5c2..834f406b 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 @@ -3,15 +3,15 @@ /** * Stable string codes that adapter modules map to HTTP error responses. Each code's string value is - * the wire contract — do not rename without bumping the public API version. + * the wire contract; renaming a code requires bumping the public API version. * - *

Reserved wire vocabulary. Not every constant defined here is emitted by the currently - * shipped adapter mappers. Codes that are not yet wired through (for example {@link - * #CONFIGURATION}, {@link #ILLEGAL_STATE}, {@link #CREDENTIAL_DUPLICATE}, {@link #ORIGIN_MISMATCH}, - * {@link #ATTESTATION_REJECTED}, {@link #COUNTER_REGRESSION}, {@link #USER_VERIFICATION_REQUIRED}, - * {@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. + *

Not every constant defined here is emitted by the currently shipped adapter mappers. Codes + * that are not yet wired through (for example {@link #CONFIGURATION}, {@link #ILLEGAL_STATE}, + * {@link #CREDENTIAL_DUPLICATE}, {@link #ORIGIN_MISMATCH}, {@link #ATTESTATION_REJECTED}, {@link + * #COUNTER_REGRESSION}, {@link #USER_VERIFICATION_REQUIRED}, {@link #INVALID_SIGNATURE}) are + * reserved as part of the wire contract, and 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 */ diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/ChallengeGenerator.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/ChallengeGenerator.java index 3233d4f0..77b8ccb2 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/ChallengeGenerator.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/ChallengeGenerator.java @@ -5,9 +5,9 @@ /** * Produces WebAuthn challenge bytes. The challenge's store handle is a separate, random {@code - * ChallengeId} (see {@link com.codeheadsystems.pkauth.api.ChallengeId#random()}) — the id is - * deliberately independent of the challenge bytes, so the store key never exposes the challenge and - * the finish-time binding rests solely on the byte comparison in {@link ChallengeValidator}. + * ChallengeId} (see {@link com.codeheadsystems.pkauth.api.ChallengeId#random()}); the id is + * independent of the challenge bytes, so the store key never exposes the challenge and the + * finish-time binding rests solely on the byte comparison in {@link ChallengeValidator}. * *

32 random bytes matches the WebAuthn level 3 recommendation of "at least 16 bytes" with * comfortable headroom. @@ -22,7 +22,9 @@ public ChallengeGenerator() { this(new SecureRandom()); } - /** Test seam: inject a {@link SecureRandom} for deterministic challenge generation in tests. */ + /** + * Test seam that accepts a {@link SecureRandom} for deterministic challenge generation in tests. + */ public ChallengeGenerator(SecureRandom random) { this.random = random; } diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/ChallengeValidation.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/ChallengeValidation.java index 17b3d1db..091a42b0 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/ChallengeValidation.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/ChallengeValidation.java @@ -65,7 +65,7 @@ record InvalidEncoding(String detail) implements ChallengeValidation { } } - /** No record exists for the challenge id — unknown, expired, or already consumed. */ + /** No record exists for the challenge id: unknown, expired, or already consumed. */ record MissingOrConsumed() implements ChallengeValidation { public String detail() { return "unknown, expired, or already-consumed challenge"; diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/ChallengeValidator.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/ChallengeValidator.java index 1b2f4085..07031f95 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/ChallengeValidator.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/ChallengeValidator.java @@ -11,11 +11,11 @@ import java.util.Optional; /** - * Runs all preflight checks on a registration- or authentication-finish request before the - * heavy WebAuthn4J verification is invoked. + * Runs all preflight checks on a registration- or authentication-finish request before the heavy + * WebAuthn4J verification is invoked. * *

Extracted from {@code DefaultPasskeyAuthenticationService} so the ceremony methods read as - * {@code validate → verify → map → emit} instead of an inlined wall of bail-out branches. + * {@code validate → verify → map → emit} without inlined bail-out branches. * *

The validator is stateless beyond its collaborators. Each {@link #validate} call: * @@ -23,8 +23,8 @@ *

  • parses the client-supplied {@code clientDataJSON}; *
  • checks the ceremony marker (registration vs authentication); *
  • checks the origin against {@link OriginValidator}; - *
  • consumes the {@link ChallengeRecord} via {@link ChallengeStore#takeOnce} (one-shot — even - * on later failure the record is gone), keyed by the opaque {@link ChallengeId} the client + *
  • consumes the {@link ChallengeRecord} via {@link ChallengeStore#takeOnce} (one-shot; even on + * later failure the record is gone), keyed by the opaque {@link ChallengeId} the client * round-trips from the start response; *
  • checks the stored record's purpose, byte equality (the cryptographic binding between the * stored challenge and the bytes the authenticator signed), and expiry. @@ -111,7 +111,7 @@ public ChallengeValidator( } /** - * Validate a finish-ceremony request. + * Validates a finish-ceremony request. * * @param ceremony which ceremony this finish belongs to * @param challengeId the explicit challenge id sent on the request diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/ClientDataJsonParser.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/ClientDataJsonParser.java index 475dd212..f4f3d2a9 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/ClientDataJsonParser.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/ClientDataJsonParser.java @@ -11,9 +11,9 @@ * {@code origin} fields out of a registration or assertion response before handing the rest off to * WebAuthn4J for full cryptographic verification. * - *

    This parser intentionally does not validate the contents — it just exposes the fields - * pk-auth needs to look up the {@code ChallengeRecord} in the store. All real validation - * (signature, RP id, origin allow-list, counter, attestation) happens inside WebAuthn4J. + *

    This parser does not validate the contents; it exposes the fields pk-auth needs to look up the + * {@code ChallengeRecord} in the store. All real validation (signature, RP id, origin allow-list, + * counter, attestation) happens inside WebAuthn4J. */ public final class ClientDataJsonParser { @@ -29,7 +29,7 @@ public static ClientData parse(byte[] clientDataJsonBytes) { return MAPPER.readValue(json, ClientData.class); } - /** A subset of WebAuthn's {@code CollectedClientData} — only the fields pk-auth reads. */ + /** A subset of WebAuthn's {@code CollectedClientData}: only the fields pk-auth reads. */ public record ClientData(String type, String challenge, String origin) { /** Decodes the base64url-encoded challenge into raw bytes. */ 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 5b995319..6713c54a 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 @@ -74,8 +74,9 @@ /** * Default {@link PasskeyAuthenticationService} backed by WebAuthn4J's {@link WebAuthnManager}. * - *

    Construct via {@link com.codeheadsystems.pkauth.ceremony.PasskeyAuthenticationServices} (the - * public factory in the {@code ceremony} package). + *

    Instances are constructed via {@link + * com.codeheadsystems.pkauth.ceremony.PasskeyAuthenticationServices} (the public factory in the + * {@code ceremony} package). * *

    The two finish-ceremony methods follow a four-step shape: * @@ -94,8 +95,8 @@ public final class DefaultPasskeyAuthenticationService implements PasskeyAuthent /** * Client-side {@code timeout} hint sent in the WebAuthn options. Independent of {@link - * CeremonyConfig#challengeTtl()}, the server-side challenge lifetime; keep that TTL at least this - * long so a challenge never expires while the browser prompt is still open. + * CeremonyConfig#challengeTtl()}, the server-side challenge lifetime. The challenge TTL should be + * at least this long so a challenge never expires while the browser prompt is still open. */ private static final long DEFAULT_TIMEOUT_MS = 60_000L; @@ -159,10 +160,10 @@ public DefaultPasskeyAuthenticationService( /** * Starts a passkey registration ceremony. * - *

    Privacy invariant: {@code excludeCredentials} on the returned options is - * always a (possibly empty) list — never {@code null}. Emitting {@code null} for brand-new - * usernames while emitting a populated list for existing users on this {@code permitAll} endpoint - * would create an account-enumeration oracle. Mirrors the same privacy guard in {@code + *

    Privacy invariant: {@code excludeCredentials} on the returned options is always a (possibly + * empty) list, never {@code null}. Emitting {@code null} for brand-new usernames while emitting a + * populated list for existing users on this {@code permitAll} endpoint would create an + * account-enumeration oracle. Mirrors the same privacy guard in {@code * MagicLinkService.startLogin}. * * @since 0.9.1 @@ -287,13 +288,13 @@ private RegistrationData verifyRegistrationWithW4j( } /** - * Effective user-verification requirement for a finish step: {@code true} (UV required) if EITHER - * the global {@link CeremonyConfig#userVerification()} OR the per-request requirement resolved at - * start (persisted on {@link ChallengeRecord#userVerification()}) is {@code REQUIRED} — i.e. the - * stricter of the two. This enforces a per-request step-up {@code REQUIRED} server-side even when - * the global default is relaxed to {@code PREFERRED}/{@code DISCOURAGED}, and never weakens the - * global config. A {@code null} recorded requirement (legacy record) contributes nothing, so the - * global config still applies. + * Effective user-verification requirement for a finish step: {@code true} (UV required) if either + * the global {@link CeremonyConfig#userVerification()} or the per-request requirement resolved at + * start (persisted on {@link ChallengeRecord#userVerification()}) is {@code REQUIRED}, that is, + * the stricter of the two. This enforces a per-request step-up {@code REQUIRED} server-side even + * when the global default is relaxed to {@code PREFERRED}/{@code DISCOURAGED}, and never weakens + * the global config. A {@code null} recorded requirement (legacy record) contributes nothing, so + * the global config still applies. */ private boolean effectiveUserVerificationRequired(ChallengeRecord challengeRecord) { boolean configRequired = @@ -419,10 +420,10 @@ private RegistrationResult persistRegistration( /** * Starts a passkey authentication ceremony. * - *

    Privacy invariant: {@code allowCredentials} on the returned options is - * always a (possibly empty) list — never {@code null}. Emitting {@code null} for unknown - * usernames while emitting a populated list for known users on this {@code permitAll} endpoint - * would create an account-enumeration oracle. Mirrors the same privacy guard in {@code + *

    Privacy invariant: {@code allowCredentials} on the returned options is always a (possibly + * empty) list, never {@code null}. Emitting {@code null} for unknown usernames while emitting a + * populated list for known users on this {@code permitAll} endpoint would create an + * account-enumeration oracle. Mirrors the same privacy guard in {@code * MagicLinkService.startLogin}. * * @since 0.9.1 @@ -844,7 +845,7 @@ private static String outcomeTag(Object result) { * Consults the configured {@link CeremonyRateLimiter} for both the per-IP and per-username * buckets on a {@code start*} call. Returns the name of the bucket that denied the call ({@code * "ip"} or {@code "username"}), or {@code null} when both buckets allow it. The caller surfaces a - * non-null result as the {@code RateLimited} variant of the relevant start-result sum — and MUST + * non-null result as the {@code RateLimited} variant of the relevant start-result sum, and must * do so before creating any challenge, so a throttled caller never touches the ChallengeStore. */ private @Nullable String rateLimitedBucket( diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/WebAuthn4JConverters.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/WebAuthn4JConverters.java index b2debaa1..474f5169 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/WebAuthn4JConverters.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/WebAuthn4JConverters.java @@ -42,7 +42,7 @@ private WebAuthn4JConverters() {} /** * Builds the WebAuthn4J {@code pubKeyCredParams} verify list from the ceremony config's accepted - * algorithms. This is the only place the verify path learns which algorithms are allowed — there + * algorithms. This is the only place the verify path learns which algorithms are allowed; there * is no hardcoded default list. WebAuthn4J rejects a registration whose COSE key algorithm is * absent from this list, so narrowing {@code accepted} in {@link * com.codeheadsystems.pkauth.config.CeremonyConfig} narrows what verifies. @@ -56,7 +56,9 @@ public static List pubKeyCredParams( return List.copyOf(params); } - /** Maps our framework-neutral {@link CoseAlgorithm} to WebAuthn4J's COSE algorithm constant. */ + /** + * Maps pk-auth's framework-neutral {@link CoseAlgorithm} to WebAuthn4J's COSE algorithm constant. + */ static COSEAlgorithmIdentifier toW4j(CoseAlgorithm alg) { return switch (alg) { case ES256 -> COSEAlgorithmIdentifier.ES256; @@ -67,7 +69,7 @@ static COSEAlgorithmIdentifier toW4j(CoseAlgorithm alg) { }; } - /** Builds a WebAuthn4J {@link ServerProperty} from our RP config and a challenge. */ + /** Builds a WebAuthn4J {@link ServerProperty} from the RP config and a challenge. */ public static ServerProperty serverProperty(RelyingPartyConfig rp, byte[] challenge) { Set origins = new LinkedHashSet<>(); for (String o : rp.origins()) { @@ -99,7 +101,7 @@ public static AuthenticationRequest toAuthenticationRequest(AuthenticationRespon } /** - * Reconstructs a WebAuthn4J {@link CredentialRecordImpl} from our stored {@link + * Reconstructs a WebAuthn4J {@link CredentialRecordImpl} from the stored {@link * CredentialRecord}. Used as the {@code authenticator} argument to {@code * AuthenticationParameters}. */ @@ -128,12 +130,12 @@ public static CredentialRecordImpl toW4jCredentialRecord( transports); } - /** Serializes a WebAuthn4J {@link COSEKey} to CBOR bytes for our credential storage. */ + /** Serialises a WebAuthn4J {@link COSEKey} to CBOR bytes for pk-auth's credential storage. */ public static byte[] serializeCoseKey(COSEKey coseKey, ObjectConverter objectConverter) { return objectConverter.getCborMapper().writeValueAsBytes(coseKey); } - /** WebAuthn4J's UV-required boolean from our {@link UserVerificationRequirement}. */ + /** WebAuthn4J's UV-required boolean from pk-auth's {@link UserVerificationRequirement}. */ public static boolean userVerificationRequired(UserVerificationRequirement uv) { return uv == UserVerificationRequirement.REQUIRED; } diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/package-info.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/package-info.java index 68b6c4ba..834a4c81 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/package-info.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/internal/package-info.java @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -/** Internal wiring — implementation detail, NOT exported via module-info. */ +/** Internal wiring; an implementation detail that is not exported via module-info. */ @org.jspecify.annotations.NullMarked package com.codeheadsystems.pkauth.internal; 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 2f2dbdcf..8c3f9c41 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 @@ -6,7 +6,7 @@ /** * 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 + * every binary field; pk-auth uses this codec uniformly for both serialisation and any * application-level base64url conversions. * * @since 0.9.0 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 38afcd9b..9f9161ed 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 @@ -27,8 +27,8 @@ *

  • {@code byte[]} fields encode as base64url with no padding (RFC 4648 §5). *
  • {@link UserHandle} and {@link CredentialId} encode as a base64url string of their bytes; * {@link ChallengeId} encodes as its string value. - *
  • {@code java.time} types serialize as ISO-8601 strings, never as numeric timestamps. - *
  • Unknown properties on the wire fail deserialization (strict input). + *
  • {@code java.time} types serialise as ISO-8601 strings, never as numeric timestamps. + *
  • Unknown properties on the wire fail deserialisation (strict input). *
  • Null properties are omitted on output. * * @@ -58,12 +58,12 @@ public static JsonMapper.Builder builder() { /** * Returns a fresh {@link SimpleModule} registering the byte[] / {@link UserHandle} / {@link - * CredentialId} / {@link ChallengeId} (de)serializers used by pk-auth's wire contract. Adapter + * CredentialId} / {@link ChallengeId} (de)serialisers used by pk-auth's wire contract. Adapter * modules that drive a host-framework {@link ObjectMapper} (Spring Boot 4 / Micronaut on Jackson * 3, etc.) register this module so their mapper produces the same wire shape the core's mapper * does. * - * @since 0.9.1 {@link CredentialId} (de)serializer registered alongside {@link UserHandle} so + * @since 0.9.1 {@link CredentialId} (de)serialiser registered alongside {@link UserHandle} so * adapters no longer need per-call-site {@code Base64Url.encode(...)} / {@code * CredentialId.of(...)} stitching. */ diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/lifecycle/UserDeletionListener.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/lifecycle/UserDeletionListener.java index 34917027..556ceaa2 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/lifecycle/UserDeletionListener.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/lifecycle/UserDeletionListener.java @@ -8,14 +8,14 @@ * deleted. Implementations are typically thin adapters around a repository / store, deleting all * rows owned by the supplied {@link UserHandle}. * - *

    Contract. + *

    Contract: * *

      - *
    • Idempotent. A listener may be invoked more than once for the same user (e.g. a retry - * after a transient failure). Calls must converge on "all rows for this user are absent" - * without throwing on second-and-later invocations. - *
    • Best-effort isolated. Each listener runs in its own scope; failures are logged and - * the service continues with remaining listeners. Listeners must not assume earlier listeners + *
    • Idempotent: a listener may be invoked more than once for the same user (e.g. a retry after + * a transient failure). Calls must converge on "all rows for this user are absent" without + * throwing on second-and-later invocations. + *
    • Best-effort isolated: each listener runs in its own scope; failures are logged and the + * service continues with remaining listeners. Listeners must not assume earlier listeners * succeeded. *
    * diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/lifecycle/UserDeletionService.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/lifecycle/UserDeletionService.java index de5c6767..1db1b40e 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/lifecycle/UserDeletionService.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/lifecycle/UserDeletionService.java @@ -15,20 +15,18 @@ * Adapter modules ({@code pk-auth-spring-boot-starter}, {@code pk-auth-dropwizard}, {@code * pk-auth-micronaut}) collect every listener bean and pass them in. * - *

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

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

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

    Listeners do not share a transaction, because the persistence SPIs span multiple datasources + * (JDBC pool, DynamoDB client, in-memory collections) with no shared transactional substrate. See + * ADR 0016. * - *

    Operational guidance. Failed listeners are non-fatal — the service does not throw — - * because the caller (typically an admin endpoint) usually prefers a partial cleanup with audit - * trail to a hard failure that leaves the user in an even worse half-state. Operators should watch - * the structured log for non-zero {@code failed} counts and retry, since {@link + *

    Failed listeners are non-fatal and the service does not throw. A partial cleanup with an audit + * trail is preferable to a hard failure that leaves the user in a worse half-state. Operators + * should watch the structured log for non-zero {@code failed} counts and retry, since {@link * UserDeletionListener} implementations are required to be idempotent. * * @since 1.1.0 @@ -41,8 +39,8 @@ public final class UserDeletionService { /** * Constructs the service with the supplied listeners. Iteration order of the collection is the - * order in which listeners run; pass a {@code List} (or other ordered collection) when order - * matters. The collection is copied defensively. + * order in which listeners run; callers pass a {@code List} (or other ordered collection) when + * order matters. The collection is copied defensively. */ public UserDeletionService(Collection listeners) { Objects.requireNonNull(listeners, "listeners"); 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 fb325884..f2c00428 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 @@ -13,10 +13,10 @@ */ public interface Metrics { - /** Increment a counter, optionally tagged with key/value pairs. */ + /** Increments a counter, optionally tagged with key/value pairs. */ void incrementCounter(String name, String... tags); - /** Record a timing measurement. */ + /** Records a timing measurement. */ void recordTimer(String name, Duration duration, String... tags); /** Returns a metrics implementation that drops every measurement. */ diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/ratelimit/InMemoryWindowCounter.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/ratelimit/InMemoryWindowCounter.java index 147ae848..50c6169f 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/ratelimit/InMemoryWindowCounter.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/ratelimit/InMemoryWindowCounter.java @@ -20,12 +20,12 @@ *

    Each rate-limiter SPI in this project (e.g. {@code BackupCodeRateLimiter}, {@code * MagicLinkRateLimiter}, {@code CeremonyRateLimiter}) defines its own key signature. The * single-instance default implementation of each one composes a {@code String} key and delegates to - * this counter — so the storage, TTL, and concurrency model is identical across features, while the + * this counter, so the storage, TTL, and concurrency model is identical across features, while the * caller-facing interface stays specific to the feature. * - *

    FOR DEV / SINGLE-INSTANCE USE ONLY. Production multi-instance deployments - * MUST replace the per-feature default with a shared (Redis/DB-backed) implementation. Each replica - * holds its own counter here, so the effective limit multiplies by the cluster size. + *

    This counter is for development and single-instance use only. Production multi-instance + * deployments must replace the per-feature default with a shared (Redis/DB-backed) implementation. + * Each replica holds its own counter here, so the effective limit multiplies by the cluster size. * * @since 0.9.1 */ 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 136f3084..35a95877 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 @@ -11,15 +11,15 @@ * (appropriate for consumer passkeys); custom implementations can plug in MDS3 / metadata-service * validation. * - *

    Attestation-statement guarantees: pk-auth wires webauthn4j in non-strict mode - * for registration. When the relying party requests {@link AttestationConveyance#NONE} (the library - * default for consumer passkeys), webauthn4j does not cryptographically verify the - * attestation statement — the {@link AttestationData#format() format} field handed to a policy is - * informational only, supplied by the authenticator and not cross-checked against a trust chain. A - * policy that needs cryptographic provenance (e.g. enterprise enrolment, MDS3 attestation - * 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. + *

    Attestation-statement guarantees: pk-auth wires webauthn4j in non-strict mode for + * registration. When the relying party requests {@link AttestationConveyance#NONE} (the library + * default for consumer passkeys), webauthn4j does not cryptographically verify the attestation + * statement; the {@link AttestationData#format() format} field handed to a policy is informational + * only, supplied by the authenticator and not cross-checked against a trust chain. A policy that + * needs cryptographic provenance (e.g. enterprise enrolment, MDS3 attestation verification) must + * request {@link AttestationConveyance#DIRECT} (or stricter) via {@code RelyingPartyConfig} so the + * underlying WebAuthn manager runs the format-specific verifier. Until then, {@code format} is + * untrusted metadata. * * @since 0.9.0 */ @@ -29,7 +29,7 @@ public interface AttestationTrustPolicy { * Evaluates the attestation presented during registration and decides whether to trust it. * * @param data the parsed attestation material for the registering credential. - * @return a {@link Decision} — trusted, or rejected with a reason. + * @return a {@link Decision}: trusted, or rejected with a reason. * @since 0.9.0 */ Decision evaluate(AttestationData data); 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 676ba2ad..6ba5dfac 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 @@ -41,16 +41,16 @@ record StoredBackupCode( * the partition under which the code lives; implementations use it to address the row directly * instead of scanning. * - *

    Implementations MUST guarantee single-use semantics: the guarded UPDATE (or conditional + *

    Implementations must guarantee single-use semantics: the guarded UPDATE (or conditional * write) must only succeed when an unconsumed row for {@code (userHandle, codeId)} exists. - * Concurrent callers observing the same code as unconsumed must NOT both receive {@code true} — + * Concurrent callers observing the same code as unconsumed must not both receive {@code true}; * exactly one must win. Return value: * *

      - *
    • {@code true} — this call atomically transitioned an unconsumed row to consumed and the + *
    • {@code true}: this call atomically transitioned an unconsumed row to consumed and the * caller is the unique winner. The caller may now mint a credential / JWT from this code. - *
    • {@code false} — the row does not exist, was already consumed, or a concurrent caller won - * the race. The caller MUST treat this as a verification miss. + *
    • {@code false}: the row does not exist, was already consumed, or a concurrent caller won + * the race. The caller must treat this as a verification miss. *
    * * @since 0.9.1 @@ -68,10 +68,11 @@ record StoredBackupCode( * Atomically replaces all backup codes for a user: deletes every existing code and inserts the * supplied records in a single logical unit of work. * - *

    Implementations MUST execute the delete and the inserts as a single atomic operation. A - * mid-operation failure must NOT leave the user with a partial or empty set of codes — readers - * must observe either the original set or the new set in its entirety. Use a database transaction - * (JDBI), {@code TransactWriteItems} (DynamoDB), or equivalent locking (in-memory). + *

    Implementations must execute the delete and the inserts as a single atomic operation. A + * mid-operation failure must not leave the user with a partial or empty set of codes; readers + * must observe either the original set or the new set in its entirety. Implementations use a + * database transaction (JDBI), {@code TransactWriteItems} (DynamoDB), or equivalent locking + * (in-memory). * * @param userHandle the user whose codes are being replaced; must be non-null * @param records the new set of codes (may be empty) diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/CeremonyRateLimiter.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/CeremonyRateLimiter.java index 762d1bcd..ba6c5264 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/CeremonyRateLimiter.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/CeremonyRateLimiter.java @@ -14,15 +14,15 @@ * *

    Contract: each {@code tryAcquire*} method returns {@code true} when the call is allowed and * {@code false} when the configured limit for the bucket has been exhausted within the limiter's - * window. Implementations MUST be race-safe — concurrent calls observing the same bucket near its + * window. Implementations must be race-safe; concurrent calls observing the same bucket near its * limit must converge to at most {@code limit} {@code true} returns per window. * *

    The ceremony service consults the limiter exactly once per public entrypoint: * *

      - *
    • {@code startRegistration} / {@code startAuthentication} — IP bucket only when the request + *
    • {@code startRegistration} / {@code startAuthentication}: IP bucket only when the request * carries no username; IP + username buckets when a username is supplied. - *
    • {@code finishRegistration} / {@code finishAuthentication} — IP bucket only (these endpoints + *
    • {@code finishRegistration} / {@code finishAuthentication}: IP bucket only (these endpoints * do not carry a username on the wire; binding is via the {@code challengeId}). *
    * @@ -32,11 +32,11 @@ * {@code StartAuthenticationResult.RateLimited} / {@code StartRegistrationResult.RateLimited} for * {@code start*}. * - *

    Production deployments with more than one replica MUST override this SPI with a - * shared, race-safe limiter (Redis token-bucket, etc.). The default implementation supplied by - * pk-auth ({@code InMemoryCeremonyRateLimiter}) tracks counters in a per-process Caffeine cache and - * is for single-instance or dev use only; per-replica counters multiply by the cluster - * size. This SPI mirrors the {@code MagicLinkRateLimiter} pattern: pk-auth ships a Caffeine-backed + *

    Production deployments with more than one replica must override this SPI with a shared, + * race-safe limiter (Redis token-bucket, etc.). The default implementation supplied by pk-auth + * ({@code InMemoryCeremonyRateLimiter}) tracks counters in a per-process Caffeine cache and is for + * single-instance or development use only, so per-replica counters multiply by the cluster size. + * This SPI mirrors the {@code MagicLinkRateLimiter} pattern: pk-auth ships a Caffeine-backed * default suitable for dev / single-node deployments, and emits a startup WARN when the in-memory * default is wired so operators notice before a production rollout. * @@ -47,8 +47,8 @@ public interface CeremonyRateLimiter { /** * Returns whether a ceremony call from {@code ip} is allowed under the current per-IP budget. * - * @param ip the source IP address (may be {@code null} if the host cannot determine it — in that - * case implementations SHOULD fall back to a single shared bucket or treat as {@code true}) + * @param ip the source IP address (may be {@code null} if the host cannot determine it; in that + * case implementations should fall back to a single shared bucket or treat as {@code true}) * @return {@code true} when allowed; {@code false} when the per-IP budget for the current window * is exhausted * @since 0.9.1 @@ -60,12 +60,11 @@ public interface CeremonyRateLimiter { * budget. Only called by the ceremony service for {@code start*} requests that carry a username; * {@code finish*} requests have no username on the wire and consult only the per-IP bucket. * - *

    The value arrives already case-folded ({@code toLowerCase(Locale.ROOT)}), - * because {@link UserLookup} implementations resolve usernames case-insensitively. Since 2.3.0 - * the ceremony service folds it before calling, so implementations MUST use the argument as given - * and MUST NOT re-derive a bucket key from a differently-cased source — case variants of one - * username would otherwise get independent budgets and multiply the effective limit against a - * single account. + *

    The value arrives already case-folded ({@code toLowerCase(Locale.ROOT)}), because {@link + * UserLookup} implementations resolve usernames case-insensitively. Since 2.3.0 the ceremony + * service folds it before calling, so implementations must use the argument as given and must not + * re-derive a bucket key from a differently-cased source; case variants of one username would + * otherwise get independent budgets and multiply the effective limit against a single account. * * @param username the case-folded username from the start ceremony request; never {@code null} * @return {@code true} when allowed; {@code false} when the per-username budget for the current 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 fffe75b0..3ffae5f1 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 @@ -42,7 +42,7 @@ public record ChallengeRecord( } /** - * Back-compatible constructor for callers that don't carry a resolved user-verification + * Back-compatible constructor for callers that do not carry a resolved user-verification * requirement; equivalent to passing {@code null} for {@code userVerification}. * * @since 2.2.0 @@ -77,7 +77,7 @@ public int hashCode() { * Which ceremony issued this challenge. Host-facing vocabulary matches the {@code * /auth/authentication/*} URL family rather than WebAuthn's internal "assertion" wording. * - * @since 0.9.1 — renamed from {@code ASSERTION}; pre-1.0 break with no deprecation. + * @since 0.9.1, renamed from {@code ASSERTION}; pre-1.0 break with no deprecation. */ public enum Purpose { REGISTRATION, 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 b2b648df..dd4d2073 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 @@ -7,7 +7,7 @@ /** * 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 + * #takeOnce}: reading a challenge must remove it from the store in the same operation to prevent * replay. * * @since 0.9.0 @@ -18,17 +18,17 @@ public interface ChallengeStore { * Stores {@code record} under {@code id} with the given {@code ttl}. Replaces any existing entry * bound to the same id (last-writer-wins). * - *

    Overwrite semantics. A {@code ChallengeId} is generated server-side and is - * cryptographically unique per ceremony, so collisions in normal operation indicate a deliberate - * retry by the same caller. Overwriting on collision is the safer choice in that case because it - * invalidates the prior challenge (which {@link #takeOnce} would otherwise still accept). - * Implementations MUST replace the prior record atomically — there must be no window in which - * {@code takeOnce} could return the old record after this method returns. + *

    Overwrite semantics: a {@code ChallengeId} is generated server-side and is cryptographically + * unique per ceremony, so collisions in normal operation indicate a deliberate retry by the same + * caller. Overwriting on collision is the safer choice in that case because it invalidates the + * prior challenge (which {@link #takeOnce} would otherwise still accept). Implementations must + * replace the prior record atomically; there must be no window in which {@code takeOnce} could + * return the old record after this method returns. * - *

    TTL. {@code ttl} must be strictly positive. Zero or negative values are - * rejected with {@link IllegalArgumentException}; a non-positive TTL would expose a challenge - * that is immediately replayable (or worse, never expires by clock arithmetic), which defeats the - * single-use guarantee. Callers that want to "never store" should simply not call this method. + *

    TTL: {@code ttl} must be strictly positive. Zero or negative values are rejected with {@link + * IllegalArgumentException}; a non-positive TTL would expose a challenge that is immediately + * replayable (or worse, never expires by clock arithmetic), which defeats the single-use + * guarantee. Callers that want to "never store" should simply not call this method. * * @param id the challenge identifier; must be non-null * @param record the challenge payload; must be non-null @@ -40,7 +40,7 @@ public interface ChallengeStore { void put(ChallengeId id, ChallengeRecord record, Duration ttl); /** - * Atomically remove and return the challenge bound to {@code id}, if present. Returns empty if + * Atomically removes and returns the challenge bound to {@code id}, if present. Returns empty if * the id was never stored, has already been consumed, or has expired. */ Optional takeOnce(ChallengeId id); diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/ConsumedJtiStore.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/ConsumedJtiStore.java index 951ac340..1f987859 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/ConsumedJtiStore.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/ConsumedJtiStore.java @@ -9,13 +9,13 @@ * *

    The contract is a single atomic operation: {@link #tryConsume(String, Duration)} returns * {@code true} the first time a given {@code jti} is presented and {@code false} on every - * subsequent call within the TTL. Implementations MUST make this check-and-set race-safe — + * subsequent call within the TTL. Implementations must make this check-and-set race-safe; * concurrent calls with the same {@code jti} must observe exactly one {@code true} return. * - *

    Production deployments with more than one replica MUST override this SPI with a - * shared, race-safe store (Redis {@code SET NX EX}, a database row with a unique index, etc.). The - * default implementation supplied by pk-auth ({@code InMemoryConsumedJtiStore}) tracks state in a - * per-process Caffeine cache and is for single-instance or dev use only; a magic-link + *

    Production deployments with more than one replica must override this SPI with a shared, + * race-safe store (Redis {@code SET NX EX}, a database row with a unique index, etc.). The default + * implementation supplied by pk-auth ({@code InMemoryConsumedJtiStore}) tracks state in a + * per-process Caffeine cache and is for single-instance or development use only, so a magic-link * consumed on replica A is freely replayable on replica B within its TTL. * *

    This SPI mirrors the {@code MagicLinkRateLimiter} pattern: pk-auth ships a Caffeine-backed @@ -32,7 +32,7 @@ public interface ConsumedJtiStore { * Records {@code jti} as consumed and returns whether this call observed it for the first time. * * @param jti the JWT id of the token being consumed; must be non-null and non-empty - * @param ttl how long the record must be retained — should be at least the JWT's remaining + * @param ttl how long the record must be retained; should be at least the JWT's remaining * lifetime plus any validator clock-skew tolerance, otherwise an expired-but-still-accepted * token could be replayed * @return {@code true} if this {@code jti} had not previously been recorded and is now stored; 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 ef2d23c9..1c2812d0 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 @@ -17,8 +17,8 @@ public interface CredentialRepository { /** - * Inserts a new credential record. Implementations MUST reject a duplicate {@code credentialId} - * by throwing {@link DuplicateCredentialException} rather than overwriting the existing row — a + * Inserts a new credential record. Implementations must reject a duplicate {@code credentialId} + * by throwing {@link DuplicateCredentialException} rather than overwriting the existing row; a * duplicate at registration-finish is a replay/clobber attempt, not an update. * * @param record the credential to persist. @@ -48,7 +48,7 @@ public interface CredentialRepository { /** * Updates the stored signature counter and last-used timestamp after a successful assertion. This * is a last-writer-wins overwrite of the two fields (no compare-and-set); the WebAuthn4J counter - * verification that authorizes the new value runs in the service before this call. A missing row + * verification that authorises the new value runs in the service before this call. A missing row * is a silent no-op. * * @param credentialId the credential that just authenticated. @@ -61,22 +61,22 @@ public interface CredentialRepository { /** * Renames the credential identified by {@code credentialId}, but only if it is owned by {@code * userHandle}. Implementations must include {@code user_handle} in the predicate so that a forged - * or guessed credential id cannot be used to rename another user's credential — pure - * defense-in-depth on top of the service-layer ownership check. A row mismatch is a silent no-op - * (no exception); the caller has already established existence via {@code findByCredentialId}. + * or guessed credential id cannot be used to rename another user's credential; this is defence in + * depth on top of the service-layer ownership check. A row mismatch is a silent no-op (no + * exception); the caller has already established existence via {@code findByCredentialId}. */ void updateLabel(UserHandle userHandle, CredentialId credentialId, String label); /** * Hard-deletes the credential row, but only if it is owned by {@code userHandle}. Implementations - * must include {@code user_handle} in the predicate (defense in depth — see {@link - * #updateLabel}). Soft-delete (e.g. a {@code revoked_at} marker) is not permitted on this SPI. + * must include {@code user_handle} in the predicate (defence in depth; see {@link #updateLabel}). + * Soft-delete (e.g. a {@code revoked_at} marker) is not permitted on this SPI. * *

    Audit history for credential deletions is the responsibility of the host's structured log * pipeline. pk-auth's {@code DefaultAdminService.deleteCredential} emits a {@code * pkauth.credential.deleted} INFO log event (containing the base64url credential id and user - * handle) around every call to this method; consume that signal rather than persisting deletion - * tombstones inside the credentials table. + * handle) around every call to this method; hosts consume that signal rather than persisting + * deletion tombstones inside the credentials table. */ void delete(UserHandle userHandle, CredentialId credentialId); @@ -86,7 +86,7 @@ public interface CredentialRepository { * may also call it directly for bulk-revocation flows. * *

    Returns the number of rows removed (best-effort; used for structured logging). Must be - * idempotent — a call against a user with no remaining credentials returns {@code 0}. + * idempotent: a call against a user with no remaining credentials returns {@code 0}. * * @since 1.1.0 */ diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/MessageFormatter.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/MessageFormatter.java index 611d3f8c..839c4916 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/MessageFormatter.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/MessageFormatter.java @@ -8,9 +8,9 @@ * *

    Feature services (magic-link, OTP, ...) ship a {@code Default*Formatter} that preserves the * original hard-coded copy. Host applications override this SPI to inject branded subject lines, - * localized bodies, or templated HTML without forking the feature service itself. + * localised bodies, or templated HTML without forking the feature service itself. * - *

    Implementations MUST be deterministic, thread-safe, and side-effect-free — they are invoked + *

    Implementations must be deterministic, thread-safe, and side-effect-free; they are invoked * inside the service's send path, so any I/O (database lookups, remote template rendering) will * directly impact the latency of the send and the rate-limit semantics. Caching belongs inside the * implementation. @@ -18,8 +18,8 @@ * @param the feature-specific context type (e.g. {@code MagicLinkContext}, {@code OtpContext}) * carrying everything the formatter needs to render the message: user handle, recipient * address, the generated link or code, and any per-flow metadata - * @param the feature-specific message type (e.g. {@code MagicLinkMessage}, {@code OtpMessage}) - * — a small record consumed by the delivery SPI ({@code EmailSender.send}, {@code + * @param the feature-specific message type (e.g. {@code MagicLinkMessage}, {@code OtpMessage}), + * a small record consumed by the delivery SPI ({@code EmailSender.send}, {@code * SmsSender.send}) * @since 0.9.1 */ 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 e590ef76..865c1f82 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 @@ -7,7 +7,7 @@ /** * 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. + * origins (e.g., embedded webviews), at their own risk. * * @since 0.9.0 */ 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 25d4d7c1..6b433c8f 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 @@ -75,14 +75,14 @@ record StoredOtp( * scans. * *

    Returns {@link OptionalInt#empty()} when no row exists for the supplied {@code (userHandle, - * otpId)} pair. Callers MUST treat the empty case as "no active OTP" — not as a zero-count - * attempt — so that a phantom verify against a deleted / never-issued row cannot masquerade as a + * otpId)} pair. Callers must treat the empty case as "no active OTP", not as a zero-count + * attempt, so that a phantom verify against a deleted / never-issued row cannot masquerade as a * successful low-attempt verification. * * @param userHandle owner of the OTP record * @param otpId the OTP record to increment - * @return the attempts value after the increment, or {@link OptionalInt#empty()} if the - * row does not exist + * @return the attempts value after the increment, or {@link OptionalInt#empty()} if the row does + * not exist * @since 0.9.1 */ OptionalInt incrementAttempts(UserHandle userHandle, String otpId); @@ -103,7 +103,7 @@ record StoredOtp( /** * Returns how many OTPs were issued for the supplied (user, phone) since {@code since}. Used by - * the service for rate limiting (brief §6.5 — at most 3 per 15 minutes). + * the service for rate limiting (brief §6.5: at most 3 per 15 minutes). */ int countSince(UserHandle userHandle, String phoneE164, Instant since); @@ -112,7 +112,7 @@ record StoredOtp( * com.codeheadsystems.pkauth.lifecycle.UserDeletionService} during user-deletion fan-out. * *

    Returns the number of rows removed (best-effort; used for structured logging). Must be - * idempotent — a call against a user with no remaining rows returns {@code 0}. + * idempotent: a call against a user with no remaining rows returns {@code 0}. * * @since 1.1.0 */ diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/PkAuthPersistenceException.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/PkAuthPersistenceException.java index d9e5c2a5..c1cc35ef 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/PkAuthPersistenceException.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/PkAuthPersistenceException.java @@ -12,12 +12,12 @@ *

    The contract pk-auth promises to its adapters: any unexpected backend failure inside an SPI * call surfaces here. Adapters install a single framework-specific exception handler that maps * {@code PkAuthPersistenceException} to a stable {@code 503} response (operational issue, retry - * possible) with a sanitized body — instead of every adapter accidentally returning a 500 HTML page - * with a stack trace. + * possible) with a sanitised body, instead of each adapter returning a 500 HTML page with a stack + * trace. * - *

    Implementer contract. Every shipped implementation in this repo (JDBI, DynamoDB, - * testkit in-memory) MUST wrap the backend's native unexpected exception in this type so adapters - * can rely on a single catch arm: + *

    Implementer contract: every shipped implementation in this repository (JDBI, DynamoDB, testkit + * in-memory) must wrap the backend's native unexpected exception in this type so adapters can rely + * on a single catch arm: * *

      *
    • JDBI repos wrap {@code org.jdbi.v3.core.JdbiException} (and any other unchecked exception @@ -25,18 +25,18 @@ *
    • DynamoDB repos wrap {@code software.amazon.awssdk.core.exception.SdkException} (network * failures, throttling, schema drift). {@code ConditionalCheckFailedException} that is part * of a documented false-return / boolean-result path (e.g. {@code consume} or {@code - * save}-duplicate-detection) is NOT wrapped — it's an expected control-flow signal, not a + * save}-duplicate-detection) is not wrapped; it is an expected control-flow signal, not a * backend outage. - *
    • Testkit in-memory repos have no real backend, but per this contract MUST still wrap any + *
    • Testkit in-memory repos have no real backend, but per this contract must still wrap any * unexpected {@link RuntimeException} that escapes (programming error, NPE from a malformed * record) so the adapter exception mappers fire uniformly in unit / integration tests. *
    * - *

    Operation identifier. The {@link #operation()} string MUST be {@code - * "."} (e.g. {@code "credentials.save"}, {@code "otp.incrementAttempts"}) so - * operators can pinpoint the failing call from a single log line. + *

    Operation identifier: the {@link #operation()} string must be {@code "."} + * (e.g. {@code "credentials.save"}, {@code "otp.incrementAttempts"}) so operators can pinpoint the + * failing call from a single log line. * - *

    Adapters MUST NOT catch this and silently swallow it — the host needs to know that persistence + *

    Adapters must not catch this and silently swallow it; the host needs to know that persistence * is degraded. * *

    The dedicated subtype {@link DuplicateCredentialException} signals the specific duplicate-row diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/PkAuthPersistenceResponse.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/PkAuthPersistenceResponse.java index 6b9026d0..aa414162 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/PkAuthPersistenceResponse.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/PkAuthPersistenceResponse.java @@ -9,8 +9,8 @@ * PkAuthPersistenceException} escapes an SPI call: HTTP {@value #STATUS} with a {@code * {"error":"persistence_failure","operation":"..."}} body. * - *

    Centralizing the status and body here keeps the three adapters' exception handlers from - * drifting and guarantees a host-side DB outage surfaces as the same sanitized {@code 503} from + *

    Centralising the status and body here keeps the three adapters' exception handlers from + * drifting and guarantees a host-side DB outage surfaces as the same sanitised {@code 503} from * every adapter, instead of one accidentally leaking a framework-default 500 with a stack trace. * Each adapter keeps only its framework-specific glue (response type, logging). * 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 cfc01fa3..867b3f2d 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 @@ -15,8 +15,8 @@ public interface UserLookup { /** * Reserved username key used by pk-auth internals to represent a usernameless ceremony (e.g. a - * discoverable-credential assertion where no username was supplied). Hosts MUST NOT use this - * value as a real username; treat it as an opaque sentinel. + * discoverable-credential assertion where no username was supplied). Hosts must not use this + * value as a real username; it is an opaque sentinel. * * @since 0.9.1 */ @@ -46,13 +46,13 @@ public interface UserLookup { UserHandle getOrCreateHandle(String username); /** - * Returns the email address bound to {@code handle} if the host has one — used by {@code + * Returns the email address bound to {@code handle} if the host has one; used by {@code * MagicLinkService} flows to guarantee that the email the caller supplies for an * email-verification magic-link actually belongs to the target user (instead of trusting a * caller-supplied address that an attacker could substitute). * *

    Default returns {@link Optional#empty()}; in that case {@code MagicLinkService} logs a - * warning and proceeds with the caller-supplied email — the host is responsible for binding + * warning and proceeds with the caller-supplied email; the host is responsible for binding * elsewhere. Hosts that store an email on the user record should override this to return it so * magic-link verification can reject mismatched addresses. * diff --git a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/package-info.java b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/package-info.java index 21ec067f..272b5a23 100644 --- a/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/package-info.java +++ b/pk-auth-core/src/main/java/com/codeheadsystems/pkauth/spi/package-info.java @@ -3,12 +3,12 @@ /** * Service-provider interfaces implemented by persistence, time, and policy adapters. * - *

    Exception contract. Every method on every SPI in this package promises that an - * operational failure of the underlying backend (DB connection drop, DynamoDB throttle, etc.) - * surfaces as a {@link com.codeheadsystems.pkauth.spi.PkAuthPersistenceException}. Implementers - * should wrap their backend's native exception in that type. Adapters install one framework - * exception handler that maps it to a stable {@code 503} response so adopters get a consistent - * error shape instead of framework-default 500 HTML. + *

    Exception contract: every method on every SPI in this package promises that an operational + * failure of the underlying backend (DB connection drop, DynamoDB throttle, etc.) surfaces as a + * {@link com.codeheadsystems.pkauth.spi.PkAuthPersistenceException}. Implementers should wrap their + * backend's native exception in that type. Adapters install one framework exception handler that + * maps it to a stable {@code 503} response so adopters get a consistent error shape instead of + * framework-default 500 HTML. * *

    {@link IllegalArgumentException} is reserved for caller-side programming errors (null or * malformed inputs); adapters typically map those to {@code 400}. Anything else that escapes is diff --git a/pk-auth-core/src/main/java/module-info.java b/pk-auth-core/src/main/java/module-info.java index 70f5f43e..e25cbc1f 100644 --- a/pk-auth-core/src/main/java/module-info.java +++ b/pk-auth-core/src/main/java/module-info.java @@ -2,8 +2,7 @@ /** * pk-auth core module: framework-neutral SPIs, DTOs, sealed result types, configuration, and the - * {@code PasskeyAuthenticationService} contract. The {@code internal} package is intentionally not - * exported. + * {@code PasskeyAuthenticationService} contract. The {@code internal} package is not exported. */ module com.codeheadsystems.pkauth.core { requires transitive org.jspecify; 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 index 9d480717..c67094ea 100644 --- a/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/PublicApiSinceTagTest.java +++ b/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/PublicApiSinceTagTest.java @@ -18,13 +18,15 @@ /** * 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 + * silently rotting on the most load-bearing types; a failing type needs {@code @since } in + * its 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. */ + /** + * Packages exported by {@code module-info.java}. Must stay in sync with the module declaration. + */ private static final Set EXPORTED_PACKAGES = Set.of( "api", 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 6bd7f653..2cdbc0d7 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 @@ -269,8 +269,7 @@ void responseRejectsNullBodyValues() { /** * 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. + * {@code switch} in the mapper is already compile-time exhaustive; this guards the test coverage. */ @Test void everyRegistrationVariantIsCoveredByATest() { diff --git a/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/internal/DefaultPasskeyAuthenticationServiceAlgorithmTest.java b/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/internal/DefaultPasskeyAuthenticationServiceAlgorithmTest.java index a5cba355..0c55d509 100644 --- a/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/internal/DefaultPasskeyAuthenticationServiceAlgorithmTest.java +++ b/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/internal/DefaultPasskeyAuthenticationServiceAlgorithmTest.java @@ -46,8 +46,8 @@ /** * Verifies that the COSE algorithm lists flow from {@link CeremonyConfig} into both the - * create-options ceremony (offered) and the WebAuthn4J verify path (accepted) — the single source - * of truth introduced for crypto-agility / post-quantum readiness (ADR 0019). + * create-options ceremony (offered) and the WebAuthn4J verify path (accepted): the single source of + * truth for crypto-agility / post-quantum readiness (ADR 0019). */ class DefaultPasskeyAuthenticationServiceAlgorithmTest { diff --git a/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/internal/DefaultPasskeyAuthenticationServiceRegistrationTest.java b/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/internal/DefaultPasskeyAuthenticationServiceRegistrationTest.java index 80203227..67c4a2e2 100644 --- a/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/internal/DefaultPasskeyAuthenticationServiceRegistrationTest.java +++ b/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/internal/DefaultPasskeyAuthenticationServiceRegistrationTest.java @@ -61,7 +61,7 @@ /** * Happy-path registration ({@code persistRegistration}) and the {@code evaluateAttestation} - * decision branches, which the main service test doesn't reach because it never returns a verified + * decision branches, which the main service test does not reach because it never returns a verified * {@link RegistrationData} from the mocked {@link WebAuthnManager}. */ class DefaultPasskeyAuthenticationServiceRegistrationTest { diff --git a/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/ratelimit/InMemoryWindowCounterTest.java b/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/ratelimit/InMemoryWindowCounterTest.java index bdcbf671..b9d6772d 100644 --- a/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/ratelimit/InMemoryWindowCounterTest.java +++ b/pk-auth-core/src/test/java/com/codeheadsystems/pkauth/ratelimit/InMemoryWindowCounterTest.java @@ -7,7 +7,7 @@ import java.time.Duration; import org.junit.jupiter.api.Test; -/** Unit coverage for the Caffeine-backed sliding-window counter. */ +/** Unit coverage for the Caffeine-backed fixed-window counter. */ class InMemoryWindowCounterTest { private final InMemoryWindowCounter counter = new InMemoryWindowCounter(Duration.ofMinutes(1)); diff --git a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/PkAuthBundle.java b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/PkAuthBundle.java index db089e9c..7f15d856 100644 --- a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/PkAuthBundle.java +++ b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/PkAuthBundle.java @@ -45,16 +45,16 @@ * / magic-link / backup-code blocks of {@link * com.codeheadsystems.pkauth.dropwizard.config.PkAuthConfig}. When the alt-flow constructor * is used the bundle additionally mounts {@link PkAuthAdminResource} at {@code /auth/admin} - * without further wiring from the host — matching the Spring and Micronaut adapters. + * without further wiring from the host, matching the Spring and Micronaut adapters. * * - *

    Jackson coexistence. Dropwizard 5 still wires Jackson 2 internally; pk-auth-core uses - * Jackson 3 (ADR 0009). The bundle teaches Dropwizard's Jackson 2 {@code ObjectMapper} how to read - * and write base64url byte arrays and pk-auth's value types — see {@link PkAuthJacksonBridge}. Wire - * compatibility is verified end-to-end by the integration tests. + *

    Dropwizard 5 still wires Jackson 2 internally; pk-auth-core uses Jackson 3 (ADR 0009). The + * bundle teaches Dropwizard's Jackson 2 {@code ObjectMapper} how to read and write base64url byte + * arrays and pk-auth's value types; see {@link PkAuthJacksonBridge}. Wire compatibility is verified + * end-to-end by the integration tests. * * @param the host application's Configuration type. - * @since 0.9.1 — alt-flow auto-wiring constructor added; legacy two-arg constructor preserved. + * @since 0.9.1 (alt-flow auto-wiring constructor added; legacy two-arg constructor preserved). */ public class PkAuthBundle implements ConfiguredBundle { @@ -80,7 +80,7 @@ public PkAuthBundle(PersistenceBindings persistence) { /** * Legacy constructor: the host has already hand-built an {@link AdminService} and hands it to the * bundle. The bundle mounts the admin resource but does not auto-wire backup-code / magic-link / - * OTP services itself. Prefer the {@link AltFlowOptions} constructor for new applications. + * OTP services itself. The {@link AltFlowOptions} constructor is preferred for new applications. * * @param persistence the SPI implementations used by the ceremony service. * @param adminService the admin service, or null to skip admin endpoint registration. @@ -105,7 +105,7 @@ public PkAuthBundle(PersistenceBindings persistence, @Nullable AdminService admi * construction throws otherwise. * * @param persistence the SPI implementations used by the ceremony service and alt-flows. - * @param altFlowOptions host-supplied senders, authorizer, and dev-mode toggles. + * @param altFlowOptions host-supplied senders, authoriser, and dev-mode toggles. * @since 0.9.1 */ public PkAuthBundle(PersistenceBindings persistence, AltFlowOptions altFlowOptions) { diff --git a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/auth/PkAuthDropwizardAuthFilter.java b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/auth/PkAuthDropwizardAuthFilter.java index d4239ec5..72933242 100644 --- a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/auth/PkAuthDropwizardAuthFilter.java +++ b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/auth/PkAuthDropwizardAuthFilter.java @@ -14,11 +14,11 @@ /** * Bearer-token {@link io.dropwizard.auth.AuthFilter} that extracts a JWT from the {@code * Authorization: Bearer ...} header and delegates verification to a {@link - * PkAuthDropwizardAuthenticator}. Brief §6.11 — "JWT validation via a Jersey filter". + * PkAuthDropwizardAuthenticator}. Brief §6.11: "JWT validation via a Jersey filter". * - *

    We hand-roll this rather than use Dropwizard's bundled {@code OAuthCredentialAuthFilter} - * because we want the wire-credential type to be {@link PkAuthPasskeyCredentials} (strongly typed - * in the bundle's DI graph) rather than {@code String}. + *

    This filter is hand-rolled instead of using Dropwizard's bundled {@code + * OAuthCredentialAuthFilter}, so that the wire-credential type is {@link PkAuthPasskeyCredentials} + * (strongly typed in the bundle's DI graph) rather than {@code String}. */ @Priority(Priorities.AUTHENTICATION) public final class PkAuthDropwizardAuthFilter @@ -26,7 +26,7 @@ public final class PkAuthDropwizardAuthFilter private static final String BEARER_PREFIX = "Bearer "; - /** Builder honoring the standard {@link AuthFilter.AuthFilterBuilder} contract. */ + /** Builder honouring the standard {@link AuthFilter.AuthFilterBuilder} contract. */ public static final class Builder extends AuthFilterBuilder< PkAuthPasskeyCredentials, PkAuthPasskeyPrincipal, PkAuthDropwizardAuthFilter> { diff --git a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/auth/PkAuthDropwizardAuthenticator.java b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/auth/PkAuthDropwizardAuthenticator.java index 5a34b2e2..8310b416 100644 --- a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/auth/PkAuthDropwizardAuthenticator.java +++ b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/auth/PkAuthDropwizardAuthenticator.java @@ -13,11 +13,11 @@ /** * Dropwizard {@link Authenticator} that validates a pk-auth JWT and surfaces a {@link - * PkAuthPasskeyPrincipal}. Brief §6.11 — "Dropwizard → a Jersey {@code Authenticator}". + * PkAuthPasskeyPrincipal}. Brief §6.11: "Dropwizard → a Jersey {@code Authenticator}". * *

    Verification failures (bad signature, expired, wrong issuer/audience) return {@link * Optional#empty()}, which causes {@link PkAuthDropwizardAuthFilter} to emit a 401. Malformed - * payloads are intentionally treated the same: we never leak the underlying reason to the client. + * payloads are treated the same; the underlying reason is never leaked to the client. */ public final class PkAuthDropwizardAuthenticator implements Authenticator { 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 a3f64ace..30545c2b 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 @@ -18,8 +18,8 @@ * com.codeheadsystems.pkauth.dropwizard.HasPkAuthConfig}) and the bundle pulls it out at {@code * run()} time. * - *

    Defaults are intentionally not provided here — every field is required so misconfiguration - * fails at start-up rather than silently using a dev-only value in production. + *

    Defaults are not provided here: every field is required, so misconfiguration fails at start-up + * instead of silently using a dev-only value in production. * *

    The alt-flow blocks ({@link Otp}, {@link MagicLink}, {@link BackupCode}) are optional at the * record level so a host that only ships passkey ceremony endpoints does not have to provide them. @@ -172,9 +172,9 @@ public Ceremony(@Nullable Duration challengeTtl) { /** * OTP service tunables. Mirrors Spring's {@code pkauth.otp} and Micronaut's {@code pkauth.otp}. * - *

    {@code pepper} is the server-side HMAC pepper for OTP-code hashing — required in production, - * no default. Decoded bytes must be at least 16 (32+ recommended). A captured DB dump without - * this value cannot brute-force outstanding codes. + *

    {@code pepper} is the server-side HMAC pepper for OTP-code hashing; required in production, + * with no default. Decoded bytes must be at least 16 (32+ recommended). A captured DB dump + * without this value cannot brute-force outstanding codes. * * @param pepper raw pepper bytes; ≥ 16 bytes required. * @since 0.9.1 @@ -202,7 +202,7 @@ public byte[] pepper() { * The Dropwizard adapter requires hosts to spell baseUrl out so production deploys cannot fall * through to a development default. * - * @param baseUrl the URL prefix the magic-link will be assembled against — required, no default. + * @param baseUrl the URL prefix the magic-link will be assembled against; required, no default. * @since 0.9.1 */ public record MagicLink(String baseUrl) { @@ -217,8 +217,8 @@ public record MagicLink(String baseUrl) { /** * Backup-code service tunables. The current {@code BackupCodeService} surface has no required * configuration beyond its SPI collaborators, so this block exists purely as a future seam - * (matching Spring / Micronaut's symmetric block shape). Pass an empty record if you want the - * service auto-wired with library defaults. + * (matching Spring / Micronaut's symmetric block shape). An empty record auto-wires the service + * with library defaults. * * @since 0.9.1 */ @@ -229,7 +229,7 @@ public record BackupCode() {} * com.codeheadsystems.pkauth.refresh.spi.RefreshTokenRepository} via {@link * com.codeheadsystems.pkauth.dropwizard.dagger.PersistenceBindings.Builder#refreshTokenRepository}. * - * @param defaultTtl how long a refresh token lasts when its audience isn't in {@code + * @param defaultTtl how long a refresh token lasts when its audience is not in {@code * ttlsByAudience}; null defaults to {@link RefreshTokenConfig#DEFAULT_REFRESH_TTL}. * @param ttlsByAudience per-audience refresh TTL overrides. * @param cleanupRetention forensic-retention window; null defaults to {@link diff --git a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/AltFlowsModule.java b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/AltFlowsModule.java index 767e6ba2..209510fd 100644 --- a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/AltFlowsModule.java +++ b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/AltFlowsModule.java @@ -64,7 +64,7 @@ public final class AltFlowsModule { * {@link IllegalStateException} is raised at module construction time when the bundle was * registered with alt-flow auto-wiring but the {@link PersistenceBindings} omitted them. * - * @param options host-supplied alt-flow knobs (senders, authorizer, dev-mode flag). + * @param options host-supplied alt-flow knobs (senders, authoriser, dev-mode flag). * @param backupCodeRepository backup-code SPI; required. * @param otpRepository OTP SPI; required. * @since 0.9.1 @@ -216,7 +216,7 @@ PkAuthAdminResource provideAdminResource(AdminService adminService) { return new PkAuthAdminResource(adminService); } - /** User-deletion listener for backup codes — only present in the full component. */ + /** User-deletion listener for backup codes; only present in the full component. */ @Provides @Singleton @IntoSet @@ -224,7 +224,7 @@ UserDeletionListener provideBackupCodeDeletionListener(BackupCodeRepository repo return new BackupCodeRepositoryDeletionListener(repo); } - /** User-deletion listener for OTPs — only present in the full component. */ + /** User-deletion listener for OTPs; only present in the full component. */ @Provides @Singleton @IntoSet @@ -233,13 +233,13 @@ UserDeletionListener provideOtpDeletionListener(OtpRepository repo) { } /** - * Host-supplied tunables that do not live in {@link PkAuthConfig} (they're framework-level + * Host-supplied tunables that do not live in {@link PkAuthConfig} (they are framework-level * collaborators, not YAML-bindable values). * * @param emailSender concrete {@link EmailSender}; null + {@code devMode=true} unlocks {@link * LoggingEmailSender}, null + {@code devMode=false} fails fast at startup. * @param smsSender concrete {@link SmsSender}; same fail-fast policy as {@code emailSender}. - * @param adminAuthorizer {@link DefaultAdminService} authorizer; null uses the library default + * @param adminAuthorizer {@link DefaultAdminService} authoriser; null uses the library default * ({@link AdminAuthorizer#subjectScoped()}). * @param adminSafetyConfig {@link DefaultAdminService} safety config; null uses {@link * AdminSafetyConfig#defaults()}. diff --git a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PersistenceBindings.java b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PersistenceBindings.java index 3fb80b9e..a808a295 100644 --- a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PersistenceBindings.java +++ b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PersistenceBindings.java @@ -13,11 +13,11 @@ /** * SPI bag the host application hands to the bundle so the same Dagger graph supports JDBI, - * DynamoDB, and in-memory persistence without re-generating components per backend. Brief §6.11 — + * DynamoDB, and in-memory persistence without re-generating components per backend. Brief §6.11: * "@Module PersistenceModule (overridable)". * *

    Only the credential, user-lookup, and challenge-store SPIs are required (they back the four - * ceremony endpoints). Backup-code, magic-link, and OTP SPIs are optional — supply them when + * ceremony endpoints). Backup-code, magic-link, and OTP SPIs are optional; supply them when * registering the admin resource. */ public final class PersistenceBindings { @@ -69,9 +69,9 @@ public OtpRepository otpRepository() { } /** - * Returns the configured {@link AccessTokenStore}. Defaults to {@link AccessTokenStore#noop()} — - * stateless JWT behaviour. Hosts that want server-side access-token revocation supply a real - * store (e.g. {@code JdbiAccessTokenStore}) via {@link + * Returns the configured {@link AccessTokenStore}. Defaults to {@link AccessTokenStore#noop()}, + * which gives stateless JWT behaviour. Hosts that want server-side access-token revocation supply + * a real store (e.g. {@code JdbiAccessTokenStore}) via {@link * Builder#accessTokenStore(AccessTokenStore)}. * * @since 1.1.0 @@ -81,7 +81,7 @@ public AccessTokenStore accessTokenStore() { } /** - * Returns the configured {@link RefreshTokenRepository}, or {@code null} when the host hasn't + * Returns the configured {@link RefreshTokenRepository}, or {@code null} when the host has not * wired refresh tokens. The bundle mounts {@code /auth/refresh} only when this is non-null, with * or without alt-flow auto-wiring. * diff --git a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PkAuthCeremonyGraph.java b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PkAuthCeremonyGraph.java index a496197b..a86ab1e6 100644 --- a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PkAuthCeremonyGraph.java +++ b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PkAuthCeremonyGraph.java @@ -11,7 +11,7 @@ /** * Provision methods shared by {@link PkAuthComponent} and {@link PkAuthFullComponent}: everything - * {@link PkAuthModule} materializes for the ceremony endpoints, the auth filter, and the optional + * {@link PkAuthModule} materialises for the ceremony endpoints, the auth filter, and the optional * refresh endpoint. The bundle registers Jersey resources against this type so both components * share one registration path. * diff --git a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PkAuthComponent.java b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PkAuthComponent.java index e1571724..528d4675 100644 --- a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PkAuthComponent.java +++ b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PkAuthComponent.java @@ -5,13 +5,13 @@ import jakarta.inject.Singleton; /** - * Dagger 2 component that materializes everything the bundle hands to Jersey. Brief §6.11 — + * Dagger 2 component that materialises everything the bundle hands to Jersey. Brief §6.11: * "@Component(modules = {...}) PkAuthComponent with provision methods for the Jersey resources". * - *

    The component intentionally only exposes the ceremony pieces declared on {@link - * PkAuthCeremonyGraph} (resource + JWT issuer/validator + authenticator + deletion service + - * optional refresh handler). The admin graph lives in {@link PkAuthFullComponent} so applications - * without {@code pk-auth-admin-api} on the classpath don't pay for it. + *

    The component exposes only the ceremony pieces declared on {@link PkAuthCeremonyGraph} + * (resource + JWT issuer/validator + authenticator + deletion service + optional refresh handler). + * The admin graph lives in {@link PkAuthFullComponent} so applications without {@code + * pk-auth-admin-api} on the classpath do not pay for it. */ @Singleton @Component(modules = {PkAuthModule.class}) diff --git a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PkAuthFullComponent.java b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PkAuthFullComponent.java index c84daf1f..43b55f1b 100644 --- a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PkAuthFullComponent.java +++ b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/dagger/PkAuthFullComponent.java @@ -10,13 +10,13 @@ import jakarta.inject.Singleton; /** - * Dagger component that wires the passkey ceremony graph and the alt-flow services - * (backup-codes, magic-link, OTP) plus an {@link AdminService} and the {@link PkAuthAdminResource}. + * Dagger component that wires the passkey ceremony graph and the alt-flow services (backup-codes, + * magic-link, OTP) plus an {@link AdminService} and the {@link PkAuthAdminResource}. * *

    The ceremony provision methods are inherited from {@link PkAuthCeremonyGraph}. Hosts that only * need the four ceremony endpoints stay on {@link PkAuthComponent}. Hosts that want the admin - * endpoint surface auto-wired register the bundle with the alt-flow constructor — the bundle then - * materializes this component instead. + * endpoint surface auto-wired register the bundle with the alt-flow constructor; the bundle then + * materialises this component instead. * *

    Generated wire compatibility is verified by {@code PkAuthBundleAltFlowsIntegrationTest}. * 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 a442f85d..bac1fc30 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 @@ -112,8 +112,8 @@ CeremonyConfig provideCeremonyConfig(PkAuthConfig cfg) { } /** - * Default in-memory {@link CeremonyRateLimiter}. Hosts MUST override this binding with a shared - * (Redis / DB-backed) implementation in multi-replica deployments — see {@link + * Default in-memory {@link CeremonyRateLimiter}. Hosts must override this binding with a shared + * (Redis / DB-backed) implementation in multi-replica deployments; see {@link * InMemoryCeremonyRateLimiter} javadoc. * * @since 0.9.1 @@ -219,7 +219,7 @@ Optional provideRefreshTokenService( /** * Provides an {@code Optional} threaded through Dagger so the component can * surface a nullable value without forcing every downstream graph to know about refresh tokens. - * Empty when {@code PersistenceBindings.refreshTokenRepository()} is null — the bundle then skips + * Empty when {@code PersistenceBindings.refreshTokenRepository()} is null; the bundle then skips * registering the refresh resource. */ @Provides @@ -230,8 +230,8 @@ Optional provideRefreshHandler( } /** - * Optional refresh-token deletion listener. When refresh tokens aren't wired, contributes an - * empty set to the {@link UserDeletionService}'s listener multibinding — the deletion fan-out + * Optional refresh-token deletion listener. When refresh tokens are not wired, contributes an + * empty set to the {@link UserDeletionService}'s listener multibinding; the deletion fan-out * silently skips the refresh branch. */ @Provides diff --git a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/json/PkAuthJacksonBridge.java b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/json/PkAuthJacksonBridge.java index e5cec972..d2c547f3 100644 --- a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/json/PkAuthJacksonBridge.java +++ b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/json/PkAuthJacksonBridge.java @@ -17,9 +17,9 @@ /** * Teaches Dropwizard's Jackson 2 {@code ObjectMapper} to read and write the pk-auth wire types. - * Dropwizard 5 still ships Jackson 2 (brief §6.11); pk-auth-core uses Jackson 3 (ADR 0009). Rather - * than fork the entire JSON contract, we apply matched serializers on Dropwizard's mapper so - * payloads round-trip identically. + * Dropwizard 5 still ships Jackson 2 (brief §6.11); pk-auth-core uses Jackson 3 (ADR 0009). The + * bridge applies matched serialisers to Dropwizard's mapper rather than forking the entire JSON + * contract, so payloads round-trip identically. * *

    Specifically: * @@ -30,8 +30,8 @@ *

  • {@link ChallengeId} -> its raw {@code value} string. * * - *

    The bridge intentionally does NOT touch Dropwizard's other Jackson modules (jdk8, java-time) - * because the bundled Dropwizard mapper already wires those. + *

    The bridge does not touch Dropwizard's other Jackson modules (jdk8, java-time) because the + * bundled Dropwizard mapper already wires those. */ public final class PkAuthJacksonBridge { diff --git a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/resource/PkAuthCeremonyResource.java b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/resource/PkAuthCeremonyResource.java index 0c01d40b..0f1f4867 100644 --- a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/resource/PkAuthCeremonyResource.java +++ b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/resource/PkAuthCeremonyResource.java @@ -24,8 +24,8 @@ import org.slf4j.LoggerFactory; /** - * The four WebAuthn ceremony endpoints. Mounted under {@code /auth/passkeys} — same path scheme as - * the Spring and Micronaut adapters. Every endpoint delegates to {@link CeremonyOrchestrator}, + * The four WebAuthn ceremony endpoints. Mounted under {@code /auth/passkeys}, the same path scheme + * as the Spring and Micronaut adapters. Every endpoint delegates to {@link CeremonyOrchestrator}, * which owns the JWT-mint / label-lookup / wire-mapping pipeline shared across adapters. */ @Path("/auth/passkeys") diff --git a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/resource/PkAuthPersistenceExceptionMapper.java b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/resource/PkAuthPersistenceExceptionMapper.java index da7ea014..76dfbc04 100644 --- a/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/resource/PkAuthPersistenceExceptionMapper.java +++ b/pk-auth-dropwizard/src/main/java/com/codeheadsystems/pkauth/dropwizard/resource/PkAuthPersistenceExceptionMapper.java @@ -12,8 +12,8 @@ /** * Maps the SPI exception contract (see {@link * com.codeheadsystems.pkauth.spi.PkAuthPersistenceException}) to a stable {@code 503 Service - * Unavailable} JSON response — same wire shape as the Spring and Micronaut adapters. Registered by - * {@link com.codeheadsystems.pkauth.dropwizard.PkAuthBundle}. + * Unavailable} JSON response, with the same wire shape as the Spring and Micronaut adapters. + * Registered by {@link com.codeheadsystems.pkauth.dropwizard.PkAuthBundle}. */ @Provider public class PkAuthPersistenceExceptionMapper diff --git a/pk-auth-dropwizard/src/test/java/com/codeheadsystems/pkauth/dropwizard/PkAuthBundleAltFlowsIntegrationTest.java b/pk-auth-dropwizard/src/test/java/com/codeheadsystems/pkauth/dropwizard/PkAuthBundleAltFlowsIntegrationTest.java index 5e6aa1e7..64bb9103 100644 --- a/pk-auth-dropwizard/src/test/java/com/codeheadsystems/pkauth/dropwizard/PkAuthBundleAltFlowsIntegrationTest.java +++ b/pk-auth-dropwizard/src/test/java/com/codeheadsystems/pkauth/dropwizard/PkAuthBundleAltFlowsIntegrationTest.java @@ -32,7 +32,7 @@ /** * Integration test for {@link PkAuthBundle}'s alt-flow auto-wiring constructor. Asserts that the * bundle builds the alt-flow services and admin resource entirely from {@link PkAuthConfig} + - * {@link AltFlowOptions} — the host has zero hand-wiring code. + * {@link AltFlowOptions}; the host has zero hand-wiring code. */ @ExtendWith(DropwizardExtensionsSupport.class) final class PkAuthBundleAltFlowsIntegrationTest { diff --git a/pk-auth-dropwizard/src/test/java/com/codeheadsystems/pkauth/dropwizard/admin/PkAuthAdminResourceTest.java b/pk-auth-dropwizard/src/test/java/com/codeheadsystems/pkauth/dropwizard/admin/PkAuthAdminResourceTest.java index 03bd3708..b7c8ff87 100644 --- a/pk-auth-dropwizard/src/test/java/com/codeheadsystems/pkauth/dropwizard/admin/PkAuthAdminResourceTest.java +++ b/pk-auth-dropwizard/src/test/java/com/codeheadsystems/pkauth/dropwizard/admin/PkAuthAdminResourceTest.java @@ -20,7 +20,7 @@ /** * Direct unit coverage of {@link PkAuthAdminResource}'s missing-body branches, which are awkward to - * reach over HTTP (e.g. PATCH isn't supported by the default JAX-RS client connector). + * reach over HTTP (e.g. PATCH is not supported by the default JAX-RS client connector). */ class PkAuthAdminResourceTest { diff --git a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/composition/PkAuthComposition.java b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/composition/PkAuthComposition.java index 0185f77c..6bba8f2f 100644 --- a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/composition/PkAuthComposition.java +++ b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/composition/PkAuthComposition.java @@ -23,10 +23,10 @@ * *

    Adapter modules (Spring auto-configuration, Dropwizard Dagger module, Micronaut factory) call * these static factories from their per-framework providers so the construction graph is named in - * one place. The CeremonyConfig / JwtConfig merge-with-defaults policy is intentionally - * not encoded here — each adapter binds those from its own configuration shape and is free - * to apply its own conservative or permissive defaults; only the downstream "I have a {@code - * CeremonyConfig}, give me a service" assembly is shared. + * one place. The CeremonyConfig / JwtConfig merge-with-defaults policy is not encoded here: each + * adapter binds those from its own configuration shape and is free to apply its own conservative or + * permissive defaults. Only the downstream assembly of a service from an existing {@code + * CeremonyConfig} is shared. * * @since 0.9.1 */ @@ -67,7 +67,7 @@ public record JwtPair(PkAuthJwtIssuer issuer, PkAuthJwtValidator validator) {} * Wires the {@link PkAuthJwtIssuer} / {@link PkAuthJwtValidator} pair around the same {@link * JwtConfig}, {@link JwtKeyset}, and {@link ClockProvider}. Adapters that prefer to register each * as its own bean can call {@link #passkeyAuthenticationService} and then construct issuer / - * validator directly — both shapes are supported. + * validator directly; both shapes are supported. * * @since 0.9.1 */ @@ -78,8 +78,7 @@ public static JwtPair jwtPair(JwtConfig config, JwtKeyset keyset, ClockProvider } /** - * Wires the ceremony orchestrator. Three trivial-looking lines that were duplicated in all three - * adapter modules — collapsed here. + * Wires the ceremony orchestrator. * * @since 0.9.1 */ diff --git a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/AccessTokenStore.java b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/AccessTokenStore.java index 055484b0..aa2ebe7b 100644 --- a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/AccessTokenStore.java +++ b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/AccessTokenStore.java @@ -6,9 +6,9 @@ import java.util.Optional; /** - * SPI for persisting issued JWT JTIs ("stateful access tokens"). Bind a real implementation when - * the host needs server-side revocation of access tokens — e.g. an immediate logout that rejects - * the bearer's still-unexpired JWT, or a "log out everywhere" admin action. + * SPI for persisting issued JWT JTIs ("stateful access tokens"). Hosts bind a real implementation + * when they need server-side revocation of access tokens, for example an immediate logout that + * rejects the bearer's still-unexpired JWT, or a "log out everywhere" admin action. * *

    {@link PkAuthJwtIssuer#issue(JwtClaims)} calls {@link #record record} for every issued token; * {@link PkAuthJwtValidator#validate(String)} calls {@link #exists exists} after signature and @@ -17,19 +17,19 @@ * persists nothing, preserving stateless-JWT behaviour for hosts that want it. * *

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

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

    Implementations must be safe to call from many threads concurrently. Failures from {@link - * #record} must propagate so issuance fails — partial state is unacceptable. See ADR 0015. + * #record} must propagate so issuance fails; partial state is unacceptable. See ADR 0015. * * @since 1.1.0 */ @@ -65,23 +65,23 @@ void record( *

    The {@link #noop()} implementation returns {@code true} unconditionally so stateless * deployments behave as if no store were involved. * - *

    Fail closed on outage. If the backing store is unreachable, throw rather - * than returning {@code true}: a thrown exception propagates out of {@link + *

    Fail closed on outage: if the backing store is unreachable, implementations throw rather + * than return {@code true}: a thrown exception propagates out of {@link * PkAuthJwtValidator#validate(String)} so the host's filter rejects the request (no token - * accepted), whereas returning {@code true} would fail open and accept a - * possibly-revoked token during the outage. Host integrations must therefore treat a thrown - * {@code validate(...)} as an authentication failure, not swallow it into success. + * accepted), whereas returning {@code true} would fail open and accept a possibly-revoked token + * during the outage. Host integrations must therefore treat a thrown {@code validate(...)} as an + * authentication failure, not swallow it into success. */ boolean exists(String jti); /** * Removes the row for the given {@code jti}, but only if it is owned by {@code userHandle}. * Idempotent. Returns {@code true} iff a row was deleted; an ownership mismatch returns {@code - * false} (silent — same shape as "not found", so callers can't use this to probe for jti - * existence across users). The {@code userHandle} scope is defense-in-depth on top of the + * false} (silent, with the same shape as "not found", so callers cannot use this to probe for jti + * existence across users). The {@code userHandle} scope is defence in depth on top of the * service-layer ownership check: even with a 122-bit random JTI making cross-user collision * implausible, requiring the owner on the predicate ensures a future caller forwarding a - * client-supplied jti without an ownership check can't escalate into IDOR. + * client-supplied jti without an ownership check cannot escalate into IDOR. */ boolean delete(UserHandle userHandle, String jti); @@ -95,16 +95,16 @@ void record( /** * Operator cleanup hook: removes rows whose {@code expires_at} is strictly less than {@code - * before}. Stateful access tokens have a natural pruning window — once {@code exp} has passed, - * the bearer is rejected on the {@code exp} check anyway, so the row is no longer load-bearing. - * Schedule this as a periodic job; see {@code docs/operator-guide.md}. + * before}. Stateful access tokens have a natural pruning window: once {@code exp} has passed, the + * bearer is rejected on the {@code exp} check anyway, so the row is no longer load-bearing. Hosts + * schedule this as a periodic job; see {@code docs/operator-guide.md}. */ int deleteExpiredBefore(Instant before); /** * Returns a no-op store: {@link #record} discards, {@link #exists} returns {@code true} for every * jti, and the delete/cleanup methods all return zero. This is the default binding when the host - * has not supplied a real implementation — issuance and validation behave as if no server-side + * has not supplied a real implementation; issuance and validation behave as if no server-side * state existed. */ static AccessTokenStore noop() { diff --git a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/AccessTokenStoreDeletionListener.java b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/AccessTokenStoreDeletionListener.java index c7781f9f..dab871ef 100644 --- a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/AccessTokenStoreDeletionListener.java +++ b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/AccessTokenStoreDeletionListener.java @@ -8,7 +8,7 @@ /** * Bridges {@link AccessTokenStore#deleteAllForUser(UserHandle)} into the {@link * com.codeheadsystems.pkauth.lifecycle.UserDeletionService} fan-out. Lives in {@code pk-auth-jwt} - * because the SPI does — {@code pk-auth-core} cannot depend on {@code pk-auth-jwt}. + * because the SPI does; {@code pk-auth-core} cannot depend on {@code pk-auth-jwt}. * *

    Adapter modules register this listener whenever an {@link AccessTokenStore} bean is present * (which is always, since the default is {@link AccessTokenStore#noop()}); the noop store's {@code diff --git a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/AuthMethod.java b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/AuthMethod.java index 06c52999..c4dd1a75 100644 --- a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/AuthMethod.java +++ b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/AuthMethod.java @@ -30,7 +30,7 @@ public String wireValue() { } /** - * Parse a wire value back into an enum constant. + * Parses a wire value back into an enum constant. * * @throws IllegalArgumentException if {@code value} is not a known wire value */ diff --git a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/CeremonyOrchestrator.java b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/CeremonyOrchestrator.java index 871be6f6..f47cd025 100644 --- a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/CeremonyOrchestrator.java +++ b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/CeremonyOrchestrator.java @@ -22,13 +22,12 @@ /** * Framework-neutral orchestrator for the four WebAuthn ceremony endpoints. Adapters (Spring, * Dropwizard, Micronaut) hold a single instance of this class and delegate each endpoint to one - * method here, so the post-processing — JWT minting on a successful assertion, credential-label - * lookup, and {@link CeremonyWireMapper} dispatch — lives in exactly one place. + * method here, so the post-processing (JWT minting on a successful assertion, credential-label + * lookup, and {@link CeremonyWireMapper} dispatch) lives in one place. * - *

    Before this helper existed each adapter's controller hand-rolled the same - * finish-authentication pipeline (call service, switch on {@link AssertionResult}, mint JWT, look - * up label, map to wire shape), with subtle drift between them. This orchestrator removes that - * drift. + *

    The orchestrator holds the finish-authentication pipeline (call service, switch on {@link + * AssertionResult}, mint JWT, look up label, map to wire shape), so the three adapters share one + * implementation. * *

    {@code start*} endpoints are pass-throughs to {@link PasskeyAuthenticationService}; they live * here purely for symmetry so adapters can hold one dependency instead of three. 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 3afd5770..149a3aca 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 @@ -13,18 +13,17 @@ * *

    The {@code defaultAudience} is the audience the issuer uses when {@link JwtClaims#audience()} * is null on a call to {@link PkAuthJwtIssuer#issue(JwtClaims)}, and is always accepted by the - * validator. Additional accepted audiences come from {@link TokenTtlPolicy#knownAudiences()} — see + * validator. Additional accepted audiences come from {@link TokenTtlPolicy#knownAudiences()}; see * {@link #allowedAudiences()} for the resolved set. * - *

    Access-token TTL is your revocation window in stateless mode. With the - * default {@link AccessTokenStore#noop() no-op access-token store}, pk-auth issues stateless JWTs - * that cannot be invalidated before their {@code exp} — a logout or user-disable only stops - * new tokens, while already-issued access tokens stay valid until they expire. The access - * TTL ({@link #DEFAULT_TOKEN_TTL} = 1 hour by default, or per-audience via {@link TokenTtlPolicy}) - * is therefore the worst-case window an attacker keeps access after credentials are pulled; keep it - * short (minutes-to-an-hour) and pair it with rotating refresh tokens for long sessions. Hosts that - * need immediate revocation must bind a real {@link AccessTokenStore} (stateful mode); see - * ADR 0015. + *

    In stateless mode, the access-token TTL is the revocation window. With the default {@link + * AccessTokenStore#noop() no-op access-token store}, pk-auth issues stateless JWTs that cannot be + * invalidated before their {@code exp}; a logout or user-disable only stops new tokens, while + * already-issued access tokens stay valid until they expire. The access TTL ({@link + * #DEFAULT_TOKEN_TTL} = 1 hour by default, or per-audience via {@link TokenTtlPolicy}) is therefore + * the worst-case window an attacker keeps access after credentials are pulled. A short TTL (minutes + * to an hour) paired with rotating refresh tokens suits long sessions. Hosts that need immediate + * revocation must bind a real {@link AccessTokenStore} (stateful mode); see ADR 0015. * * @param issuer the {@code iss} claim value * @param defaultAudience the audience used when {@link JwtClaims#audience()} is absent at issue @@ -85,8 +84,8 @@ public Set allowedAudiences() { } /** - * Convenience constructor with the documented defaults — a single-TTL policy and the standard - * skew values. + * Convenience constructor with the documented defaults: a single-TTL policy and the standard skew + * values. */ public static JwtConfig defaults(String issuer, String audience) { return new JwtConfig( @@ -104,7 +103,7 @@ public static JwtConfig defaults(String issuer, String audience) { * 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 + * Centralising 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. diff --git a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtKeyset.java b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtKeyset.java index bf0840ba..1b516e73 100644 --- a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtKeyset.java +++ b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtKeyset.java @@ -36,12 +36,12 @@ private JwtKeyset(JWK signingKey, JWSAlgorithm algorithm, List verification /** * HS256 (symmetric) keyset. Appropriate whenever the issuer and verifier share a trust boundary - * (the common single-issuer/single-verifier deployment) — not a "dev-only" mode. The supplied - * secret must be at least 256 bits per RFC 7518 §3.2; Nimbus rejects shorter keys. With a {@code - * >= 256}-bit key HMAC-SHA256 is also the quantum-conservative choice (Shor does not apply; - * Grover leaves ~128-bit effective security). Use {@link #es256(ECKey, ECKey...)} instead only - * when an untrusted third party must verify tokens without the power to mint them. See the - * package Javadoc and {@code docs/threat-model.md} (Post-quantum readiness). + * (the common single-issuer/single-verifier deployment); it is not a "dev-only" mode. The + * supplied secret must be at least 256 bits per RFC 7518 §3.2; Nimbus rejects shorter keys. With + * a {@code >= 256}-bit key HMAC-SHA256 is also the quantum-conservative choice (Shor does not + * apply; Grover leaves ~128-bit effective security). {@link #es256(ECKey, ECKey...)} is the + * alternative when an untrusted third party must verify tokens without the power to mint them. + * See the package Javadoc and {@code docs/threat-model.md} (Post-quantum readiness). */ public static JwtKeyset hs256(byte[] secret) { Objects.requireNonNull(secret, "secret"); @@ -58,8 +58,8 @@ public static JwtKeyset hs256(byte[] secret) { /** * ES256 keyset. {@code current} is the active signing key; tokens it issues are signed by it. - * Each entry in {@code retired} remains valid for verification — typical pattern for graceful key - * rotation. + * Each entry in {@code retired} remains valid for verification; this is the typical pattern for + * graceful key rotation. */ public static JwtKeyset es256(ECKey current, ECKey... retired) { Objects.requireNonNull(current, "current"); diff --git a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtSecretResolver.java b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtSecretResolver.java index 51850d67..3c8782d9 100644 --- a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtSecretResolver.java +++ b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtSecretResolver.java @@ -6,10 +6,9 @@ /** * Shared JWT-secret resolution policy for every adapter (Spring, Micronaut, Dropwizard). * - *

    pk-auth's HS256 JWT issuer/validator need a stable signing key. A randomly-minted per-startup - * key — previously the Spring starter's silent fallback when {@code pkauth.jwt.secret} was unset — - * is unsafe: it breaks multi-instance deployments, invalidates outstanding tokens across restarts, - * and silently masks misconfiguration. This resolver enforces a fail-fast policy: + *

    pk-auth's HS256 JWT issuer/validator need a stable signing key. A randomly generated + * per-startup key is unsafe: it breaks multi-instance deployments, invalidates outstanding tokens + * across restarts, and silently masks misconfiguration. This resolver enforces a fail-fast policy: * *

      *
    • Configured secret present → require ≥ 32 bytes when interpreted as UTF-8. @@ -30,7 +29,7 @@ private JwtSecretResolver() { } /** - * Validate the configured shared secret and return its UTF-8 bytes. + * Validates the configured shared secret and returns its UTF-8 bytes. * * @param configured the raw value of {@code pkauth.jwt.secret} (may be {@code null} or blank) * @return the secret encoded as UTF-8 bytes (≥ 32 bytes) diff --git a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/NoopAccessTokenStore.java b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/NoopAccessTokenStore.java index 99ddc5c5..af7b6141 100644 --- a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/NoopAccessTokenStore.java +++ b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/NoopAccessTokenStore.java @@ -7,7 +7,7 @@ /** * Default {@link AccessTokenStore} that persists nothing and accepts every jti. Hosts that wire a - * real store override this binding. The class is package-private — clients reach it via {@link + * real store override this binding. The class is package-private; clients reach it via {@link * AccessTokenStore#noop()}. */ final class NoopAccessTokenStore implements AccessTokenStore { diff --git a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/PkAuthCeremonyJwt.java b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/PkAuthCeremonyJwt.java index 77ec61e6..ca1d96b7 100644 --- a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/PkAuthCeremonyJwt.java +++ b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/PkAuthCeremonyJwt.java @@ -7,18 +7,17 @@ /** * Shared JWT-minting helper used by every adapter (Spring, Dropwizard, Micronaut) immediately after - * a successful WebAuthn assertion. Before this helper existed each adapter constructed {@link - * JwtClaims#forPasskey} inline, with a different {@code amr} list per adapter; this helper - * standardizes the claim shape so a token minted by the Spring adapter is byte-equivalent (modulo - * {@code jti}/{@code iat}) to one minted by Dropwizard or Micronaut. + * a successful WebAuthn assertion. This helper standardises the claim shape so a token minted by + * the Spring adapter is byte-equivalent (modulo {@code jti}/{@code iat}) to one minted by + * Dropwizard or Micronaut. * - *

      Standardized {@code amr}: {@link #ASSERTION_AMR} — {@code ["pkauth", "webauthn"]}. The - * RFC 8176 registry doesn't define a passkey-specific token, so we use the descriptive {@code + *

      Standardised {@code amr}: {@link #ASSERTION_AMR} is {@code ["pkauth", "webauthn"]}. The RFC + * 8176 registry does not define a passkey-specific token, so the claim uses the descriptive {@code * "pkauth"} (the library tag) plus the standard {@code "webauthn"} that consumers can recognise. * - *

      This helper deliberately takes no {@code CredentialRepository} — the credential label is the - * adapter's concern (it appears in the HTTP response body, not the JWT), so the adapter looks it up - * once when it needs it, NOT through this helper. + *

      This helper takes no {@code CredentialRepository}; the credential label is the adapter's + * concern (it appears in the HTTP response body, not the JWT), so the adapter looks it up once when + * it needs it, not through this helper. */ public final class PkAuthCeremonyJwt { diff --git a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtIssuer.java b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtIssuer.java index b5979b85..f46724af 100644 --- a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtIssuer.java +++ b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtIssuer.java @@ -22,8 +22,8 @@ * claims plus {@code pkauth.method}, {@code pkauth.cred}, {@code pkauth.amr}. * *

      If a non-noop {@link AccessTokenStore} is bound, each issued token's jti is persisted in the - * store before {@link #issue(JwtClaims)} returns. A store failure propagates — partial state - * (signed token returned but unrecorded) is intentionally not tolerated. + * store before {@link #issue(JwtClaims)} returns. A store failure propagates; partial state (signed + * token returned but unrecorded) is not tolerated. */ public final class PkAuthJwtIssuer { @@ -53,8 +53,8 @@ public PkAuthJwtIssuer(JwtConfig config, JwtKeyset keyset, ClockProvider clockPr /** * Constructs an issuer that records each issued JTI through the supplied {@link - * AccessTokenStore}. Use this constructor when the host needs server-side revocation of access - * tokens; pair with a {@link PkAuthJwtValidator} configured with the same store so {@link + * AccessTokenStore}. Hosts that need server-side revocation of access tokens use this constructor + * together with a {@link PkAuthJwtValidator} configured with the same store, so {@link * AccessTokenStore#exists(String)} is consulted at validation time. * * @since 1.1.0 @@ -75,9 +75,9 @@ public PkAuthJwtIssuer( * JwtClaims#audience()} when set, falling back to {@link JwtConfig#defaultAudience()}; the * access-token TTL is then looked up via {@link JwtConfig#ttlPolicy()} keyed by that audience. * - *

      The issued jti is recorded in the configured {@link AccessTokenStore} before the - * wire token is returned. If recording fails, the exception propagates and no token is surfaced - * to the caller — the host must retry or surface the failure upstream. + *

      The issued jti is recorded in the configured {@link AccessTokenStore} before the wire token + * is returned. If recording fails, the exception propagates and no token is surfaced to the + * caller; the host must retry or surface the failure upstream. */ public String issue(JwtClaims claims) { return issue(claims, null); @@ -85,16 +85,16 @@ public String issue(JwtClaims claims) { /** * Issues a signed JWT whose lifetime is {@code ttlOverride} instead of the per-audience - * access-token TTL resolved from {@link JwtConfig#ttlPolicy()}. Use this for short-lived, - * single-purpose tokens (e.g. magic links) that must NOT inherit the full access-token validity - * window — a magic link minted at the 1-hour access TTL stays redeemable as a bearer token (and + * access-token TTL resolved from {@link JwtConfig#ttlPolicy()}. This suits short-lived, + * single-purpose tokens (e.g. magic links) that must not inherit the full access-token validity + * window: a magic link minted at the 1-hour access TTL stays redeemable as a bearer token (and * replayable past its single-use JTI retention) far longer than intended. A {@code null} {@code * ttlOverride} falls back to the audience's access TTL, making this exactly equivalent to {@link * #issue(JwtClaims)}. * * @param claims the claims to sign * @param ttlOverride the token lifetime, or {@code null} to use the audience access TTL - * @return the serialized, signed JWT + * @return the serialised, signed JWT * @since 2.2.0 */ public String issue(JwtClaims claims, @Nullable Duration ttlOverride) { diff --git a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtValidator.java b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtValidator.java index 73711f2e..9b82fb57 100644 --- a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtValidator.java +++ b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtValidator.java @@ -31,11 +31,11 @@ * Validates pk-auth JWTs produced by {@link PkAuthJwtIssuer}. Separate from issuance so adapter * modules can take this class without pulling in signing keys. * - *

      No revocation by default. Once issued, a token is considered valid until its - * {@code exp} claim expires. If your application needs to invalidate tokens early (e.g., on - * logout-all or user disable), supply a custom {@link RevocationCheck} via the four-argument - * constructor. The default behaviour is {@link RevocationCheck#allow()}, which never revokes. A - * revoked token produces a {@link JwtVerificationResult.Revoked} result rather than {@link + *

      No revocation is applied by default. Once issued, a token is considered valid until its {@code + * exp} claim expires. An application that needs to invalidate tokens early (e.g., on logout-all or + * user disable) supplies a custom {@link RevocationCheck} via the four-argument constructor. The + * default behaviour is {@link RevocationCheck#allow()}, which never revokes. A revoked token + * produces a {@link JwtVerificationResult.Revoked} result rather than {@link * JwtVerificationResult.Success}. */ public final class PkAuthJwtValidator { @@ -58,8 +58,8 @@ public PkAuthJwtValidator(JwtConfig config, JwtKeyset keyset, ClockProvider cloc /** * Constructs a validator with a custom {@link RevocationCheck} and the default no-op {@link - * AccessTokenStore}. Use this constructor when you need lightweight deny-list invalidation - * without persisting every issued JTI. + * AccessTokenStore}. This constructor suits lightweight deny-list invalidation without persisting + * every issued JTI. */ public PkAuthJwtValidator( JwtConfig config, @@ -73,8 +73,8 @@ public PkAuthJwtValidator( * Constructs a validator with both a custom {@link RevocationCheck} and a custom {@link * AccessTokenStore}. The store is consulted on every {@link #validate(String)} call after * signature and standard-claim checks; an absent jti yields {@link - * JwtVerificationResult.Revoked}. Wire the same store on the matching {@link PkAuthJwtIssuer} so - * issued JTIs are recorded. + * JwtVerificationResult.Revoked}. The matching {@link PkAuthJwtIssuer} must be wired with the + * same store so issued JTIs are recorded. * * @since 1.1.0 */ @@ -231,17 +231,17 @@ public JwtVerificationResult validate(String token) { * *

      When the token header carries a {@code kid} and at least one key in the keyset also has a * {@code kid} assigned, only the key(s) whose {@code kid} matches the header value are tried. - * This prevents a forged token claiming {@code kid=X} from being accepted by a different key Y — - * the actual security property being enforced. + * This prevents a forged token claiming {@code kid=X} from being accepted by a different key Y, + * which is the security property enforced. * *

      If the keyset contains at least one key with an assigned {@code kid} but the token header * carries no {@code kid}, the token is rejected. Without this rule a leaked retired key (still * present in the keyset for rotation) could sign a kid-less token and be silently accepted, * defeating kid-based rotation. * - *

      If the keyset contains no keys with an assigned {@code kid} (fully legacy keyset, - * e.g. HS256 built without explicit key IDs), the {@code kid} filter is skipped entirely and all - * keys are tried so that legacy single-key deployments continue to work without modification. + *

      If the keyset contains no keys with an assigned {@code kid} (fully legacy keyset, e.g. HS256 + * built without explicit key IDs), the {@code kid} filter is skipped entirely and all keys are + * tried so that legacy single-key deployments continue to work without modification. */ private boolean verifyAgainstCandidateKeys(SignedJWT jwt, @Nullable String kid) { // Fetch the full set of verification keys (no filter yet). diff --git a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/RevocationCheck.java b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/RevocationCheck.java index bc847942..c1304517 100644 --- a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/RevocationCheck.java +++ b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/RevocationCheck.java @@ -6,7 +6,7 @@ * should provide an implementation backed by their datastore. Default implementation is a no-op so * the library remains stateless by default. * - *

      Register a custom implementation by passing it to the {@link + *

      A custom implementation is registered by passing it to the {@link * PkAuthJwtValidator#PkAuthJwtValidator(JwtConfig, JwtKeyset, * com.codeheadsystems.pkauth.spi.ClockProvider, RevocationCheck)} constructor. * @@ -16,15 +16,15 @@ public interface RevocationCheck { /** - * Return {@code true} if the token (identified by its {@code jti} / {@code sub}) has been revoked - * and must be rejected. + * Returns {@code true} if the token (identified by its {@code jti} / {@code sub}) has been + * revoked and must be rejected. * - *

      Handle a {@code null} jti. A token may carry no {@code jti}, in which case - * {@code jti} is {@code null} here. An implementation that maintains a deny-list keyed solely on - * {@code jti} must decide explicitly what to do with a jti-less token — typically reject it (the - * deny-list can never prove it un-revoked) or fall back to a {@code sub}-scoped rule. A naive - * {@code denyList.contains(jti)} silently lets every jti-less token through. (In stateful mode, - * {@link PkAuthJwtValidator} already rejects jti-less tokens before this check via the {@link + *

      Null jti handling: a token may carry no {@code jti}, in which case {@code jti} is {@code + * null} here. An implementation that maintains a deny-list keyed solely on {@code jti} must + * decide explicitly what to do with a jti-less token: typically reject it (the deny-list can + * never prove it un-revoked) or fall back to a {@code sub}-scoped rule. A naive {@code + * denyList.contains(jti)} silently lets every jti-less token through. (In stateful mode, {@link + * PkAuthJwtValidator} already rejects jti-less tokens before this check via the {@link * AccessTokenStore}; this guidance matters for stateless deployments that rely only on a {@code * RevocationCheck}.) * 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 2376493b..d6ec9844 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 @@ -18,9 +18,9 @@ * *

      {@link #knownAudiences()} declares which audiences this policy is configured for. The * validator (and adapters that want to enumerate accepted audiences for documentation or OpenAPI - * surfaces) reads this set. Returning an empty set is the conservative default — the validator - * falls back to accepting only {@link JwtConfig#defaultAudience()} in that case. The built-in - * {@link #fixed(Duration, Map)} factory wires this automatically from the override map's keys. + * surfaces) reads this set. Returning an empty set is the conservative default; the validator falls + * back to accepting only {@link JwtConfig#defaultAudience()} in that case. The built-in {@link + * #fixed(Duration, Map)} factory wires this automatically from the override map's keys. */ public interface TokenTtlPolicy { @@ -44,7 +44,7 @@ default Set knownAudiences() { * defaultTtl} for any audience not present in the map. * *

      {@link #knownAudiences()} on the returned policy is the union of {@code overrides.keySet()}. - * The default TTL audience is not implied — a token issued for an audience that is neither in the + * The default TTL audience is not implied; a token issued for an audience that is neither in the * override map nor equal to {@link JwtConfig#defaultAudience()} will use {@code defaultTtl} but * will not be accepted by the validator unless the host declares the audience another way. */ @@ -89,7 +89,7 @@ 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. + * centralising 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. diff --git a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/package-info.java b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/package-info.java index 44d26e82..372a14a8 100644 --- a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/package-info.java +++ b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/package-info.java @@ -3,29 +3,26 @@ /** * JWT issuance and validation for pk-auth-issued tokens. * - *

      HS256 vs ES256 is a trust-topology choice, not a dev-vs-prod one. The older - * "HS256 for dev, ES256 for production" framing was misleading. Both are production-grade; pick by - * who needs to verify the token: + *

      The choice between HS256 and ES256 depends on trust topology, not on a + * development-versus-production distinction. Both are production-grade; the deciding factor is who + * needs to verify the token: * *

        - *
      • HS256 (symmetric) — the issuer and verifier share one secret. This is the - * right default when pk-auth both mints and validates the token (the common - * single-issuer/single-verifier deployment). It is also the quantum-conservative - * choice: HMAC-SHA256 is not broken by Shor's algorithm, and with a {@code >= 256}-bit key - * (enforced by {@link com.codeheadsystems.pkauth.jwt.JwtKeyset#hs256(byte[])}) Grover's - * algorithm leaves roughly 128-bit effective security — comfortably safe. Its limitation is - * trust, not cryptography: anyone who can verify can also forge, so the secret must never - * leave the trust boundary. - *
      • ES256 (asymmetric) — exists for untrusted third-party - * verification: publish the public key so external services validate tokens without the - * power to mint them. That capability costs post-quantum exposure — ES256 is an - * elliptic-curve signature and is Shor-vulnerable. The exposure is bounded by the (short) - * token TTL: a forged signature is only useful within the lifetime of a token, and a CRQC - * does not yet exist. + *
      • HS256 (symmetric): the issuer and verifier share one secret. This is the right default when + * pk-auth both mints and validates the token (the common single-issuer/single-verifier + * deployment). It is also the quantum-conservative choice: HMAC-SHA256 is not broken by + * Shor's algorithm, and with a {@code >= 256}-bit key (enforced by {@link + * com.codeheadsystems.pkauth.jwt.JwtKeyset#hs256(byte[])}) Grover's algorithm leaves roughly + * 128-bit effective security. Its limitation is trust, not cryptography: anyone who can + * verify can also forge, so the secret must never leave the trust boundary. + *
      • ES256 (asymmetric): exists for untrusted third-party verification. Publishing the public + * key lets external services validate tokens without the power to mint them. That capability + * costs post-quantum exposure: ES256 is an elliptic-curve signature and is Shor-vulnerable. + * The exposure is bounded by the (short) token TTL: a forged signature is only useful within + * the lifetime of a token, and a CRQC does not yet exist. *
      * - *

      Neither default nor signing behavior is changed by this note; see {@code docs/threat-model.md} - * (Post-quantum readiness) and ADR 0019. + *

      See {@code docs/threat-model.md} (Post-quantum readiness) and ADR 0019. */ @org.jspecify.annotations.NullMarked package com.codeheadsystems.pkauth.jwt; diff --git a/pk-auth-jwt/src/test/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtValidatorEdgeCasesTest.java b/pk-auth-jwt/src/test/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtValidatorEdgeCasesTest.java index bb9d45e9..37131438 100644 --- a/pk-auth-jwt/src/test/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtValidatorEdgeCasesTest.java +++ b/pk-auth-jwt/src/test/java/com/codeheadsystems/pkauth/jwt/PkAuthJwtValidatorEdgeCasesTest.java @@ -23,7 +23,7 @@ /** * Exercises the malformed / missing-claim / wrong-shape branches of {@link - * PkAuthJwtValidator#validate(String)} that the happy-path round-trip tests don't reach. Each test + * PkAuthJwtValidator#validate(String)} that the happy-path round-trip tests do not reach. Each test * hand-builds a structurally valid, correctly signed JWT and then perturbs exactly one claim so the * validator's defensive branch fires. */ diff --git a/pk-auth-magic-link/src/main/java/com/codeheadsystems/pkauth/magiclink/DefaultMagicLinkFormatter.java b/pk-auth-magic-link/src/main/java/com/codeheadsystems/pkauth/magiclink/DefaultMagicLinkFormatter.java index c0e02709..bb526438 100644 --- a/pk-auth-magic-link/src/main/java/com/codeheadsystems/pkauth/magiclink/DefaultMagicLinkFormatter.java +++ b/pk-auth-magic-link/src/main/java/com/codeheadsystems/pkauth/magiclink/DefaultMagicLinkFormatter.java @@ -7,7 +7,7 @@ * Default {@link MessageFormatter} for magic-link emails. Preserves the original hard-coded subject * and body strings that {@link MagicLinkService} used before the {@code MessageFormatter} SPI was * introduced (item #25 of the consolidated review). Host applications that need branding, - * localization, or HTML bodies inject their own {@code MessageFormatter} bean instead. * * @since 0.9.1 diff --git a/pk-auth-magic-link/src/main/java/com/codeheadsystems/pkauth/magiclink/EmailSender.java b/pk-auth-magic-link/src/main/java/com/codeheadsystems/pkauth/magiclink/EmailSender.java index a7700485..27a9a85c 100644 --- a/pk-auth-magic-link/src/main/java/com/codeheadsystems/pkauth/magiclink/EmailSender.java +++ b/pk-auth-magic-link/src/main/java/com/codeheadsystems/pkauth/magiclink/EmailSender.java @@ -6,7 +6,7 @@ * logging dev sender and a JavaMail skeleton; production deployments wire a real provider. * *

      The method is named {@code send} (rather than {@code sendMagicLink}) so the same SPI can be - * reused by future email-bearing flows without renaming — see item #25 of the consolidated review. + * reused by future email-bearing flows without renaming; see item #25 of the consolidated review. * Subject and body are produced upstream by a {@link * com.codeheadsystems.pkauth.spi.MessageFormatter}, so this SPI's only job is delivery. * diff --git a/pk-auth-magic-link/src/main/java/com/codeheadsystems/pkauth/magiclink/InMemoryConsumedJtiStore.java b/pk-auth-magic-link/src/main/java/com/codeheadsystems/pkauth/magiclink/InMemoryConsumedJtiStore.java index e9f6fabf..bcb76073 100644 --- a/pk-auth-magic-link/src/main/java/com/codeheadsystems/pkauth/magiclink/InMemoryConsumedJtiStore.java +++ b/pk-auth-magic-link/src/main/java/com/codeheadsystems/pkauth/magiclink/InMemoryConsumedJtiStore.java @@ -10,16 +10,16 @@ /** * Caffeine-backed in-process {@link ConsumedJtiStore}. * - *

      FOR DEV / SINGLE-INSTANCE USE ONLY. Production deployments with more than one - * replica MUST replace this with a shared (Redis/DB-backed) {@link ConsumedJtiStore} implementation - * — a token consumed on one replica is freely replayable on another within its TTL window, because - * each replica tracks its own independent cache. {@link MagicLinkService} emits a single startup - * WARN when this implementation is wired so the misconfiguration is visible in logs before a - * production rollout. + *

      Intended for development and single-instance use only. Production deployments with more than + * one replica must replace this with a shared (Redis/DB-backed) {@link ConsumedJtiStore} + * implementation: a token consumed on one replica is freely replayable on another within its TTL + * window, because each replica tracks its own independent cache. {@link MagicLinkService} emits a + * single startup WARN when this implementation is wired so the misconfiguration is visible in logs + * before a production rollout. * *

      The cache uses {@code expireAfterWrite} so entries are evicted at TTL relative to their first - * (successful) insertion. The {@link #tryConsume(String, Duration)} contract — race-safe - * check-and-set — is satisfied by {@code ConcurrentMap#putIfAbsent}, which is atomic. + * (successful) insertion. The {@link #tryConsume(String, Duration)} contract (race-safe + * check-and-set) is satisfied by {@code ConcurrentMap#putIfAbsent}, which is atomic. * * @since 0.9.1 */ @@ -30,7 +30,7 @@ public final class InMemoryConsumedJtiStore implements ConsumedJtiStore { /** * Creates a new in-memory store. * - * @param maxTtl upper bound on how long a consumed-JTI record is retained — entries written via + * @param maxTtl upper bound on how long a consumed-JTI record is retained; entries written via * {@link #tryConsume(String, Duration)} share this single expireAfterWrite policy regardless * of the {@code ttl} argument passed at call time. Sized once at construction because * Caffeine does not support per-entry TTLs without a more expensive eviction policy; callers diff --git a/pk-auth-magic-link/src/main/java/com/codeheadsystems/pkauth/magiclink/JavaMailEmailSender.java b/pk-auth-magic-link/src/main/java/com/codeheadsystems/pkauth/magiclink/JavaMailEmailSender.java index 7b418978..077cab65 100644 --- a/pk-auth-magic-link/src/main/java/com/codeheadsystems/pkauth/magiclink/JavaMailEmailSender.java +++ b/pk-auth-magic-link/src/main/java/com/codeheadsystems/pkauth/magiclink/JavaMailEmailSender.java @@ -4,15 +4,13 @@ import java.util.Objects; /** - * Intentional starter scaffold for the Jakarta Mail (JavaMail) {@link EmailSender}. Per brief §3, - * pk-auth ships only the SPI — host applications add the Jakarta Mail dependency and replace {@link - * #send} with the real SMTP transport. The class ships in this form on purpose: it captures the - * canonical SMTP constructor shape (host, port, From address) and fails loudly if it is ever - * invoked unmodified. + * Starter scaffold for the Jakarta Mail (JavaMail) {@link EmailSender}. Per brief §3, pk-auth ships + * only the SPI; host applications add the Jakarta Mail dependency and replace {@link #send} with + * the real SMTP transport. The class ships in this form: it captures the canonical SMTP constructor + * shape (host, port, From address) and fails loudly if it is ever invoked unmodified. * - *

      This is not a half-finished feature. Do not remove it during dead-code sweeps; it is - * one of the two SDK-naming anchors (alongside {@code TwilioSmsSender}) that documents which - * external libraries the project expects hosts to bring. + *

      The class is one of the two SDK-naming anchors (alongside {@code TwilioSmsSender}) that + * document which external libraries the project expects hosts to bring. * * @since 0.9.1 */ diff --git a/pk-auth-magic-link/src/main/java/com/codeheadsystems/pkauth/magiclink/MagicLinkContext.java b/pk-auth-magic-link/src/main/java/com/codeheadsystems/pkauth/magiclink/MagicLinkContext.java index d7a34823..2f9502b9 100644 --- a/pk-auth-magic-link/src/main/java/com/codeheadsystems/pkauth/magiclink/MagicLinkContext.java +++ b/pk-auth-magic-link/src/main/java/com/codeheadsystems/pkauth/magiclink/MagicLinkContext.java @@ -11,10 +11,10 @@ * * @param user the user the link is being sent to. May be {@code null} only for the privacy-safe * "user-not-found" branch of the login flow (in which case the service does not actually invoke - * the formatter — the parameter is nullable for completeness, not because real callers will - * observe null). + * the formatter; the parameter is nullable for completeness, and real callers do not observe + * null). * @param email the recipient address. Always non-null in practice. - * @param magicLinkUrl the fully-formed URL the user is expected to click — already includes the + * @param magicLinkUrl the fully-formed URL the user is expected to click; it already includes the * issued JWT as a query parameter and is safe to embed verbatim in the body. * @param purpose either {@link MagicLinkService#PURPOSE_EMAIL_VERIFY} or {@link * MagicLinkService#PURPOSE_LOGIN}. Formatters typically branch on this value to choose subject diff --git a/pk-auth-magic-link/src/main/java/com/codeheadsystems/pkauth/magiclink/MagicLinkMessage.java b/pk-auth-magic-link/src/main/java/com/codeheadsystems/pkauth/magiclink/MagicLinkMessage.java index f096e734..ff7ec103 100644 --- a/pk-auth-magic-link/src/main/java/com/codeheadsystems/pkauth/magiclink/MagicLinkMessage.java +++ b/pk-auth-magic-link/src/main/java/com/codeheadsystems/pkauth/magiclink/MagicLinkMessage.java @@ -6,7 +6,7 @@ * handed to {@link EmailSender#send(String, String, String)}. * * @param subject the subject line. Must be non-null. - * @param body the body text. Must be non-null; may be plaintext or HTML — the choice is the + * @param body the body text. Must be non-null; may be plaintext or HTML; the choice is the * formatter's, and the {@link EmailSender} implementation is expected to honour it. * @since 0.9.1 */ diff --git a/pk-auth-magic-link/src/main/java/com/codeheadsystems/pkauth/magiclink/MagicLinkService.java b/pk-auth-magic-link/src/main/java/com/codeheadsystems/pkauth/magiclink/MagicLinkService.java index 89df4cb1..917123a0 100644 --- a/pk-auth-magic-link/src/main/java/com/codeheadsystems/pkauth/magiclink/MagicLinkService.java +++ b/pk-auth-magic-link/src/main/java/com/codeheadsystems/pkauth/magiclink/MagicLinkService.java @@ -35,22 +35,22 @@ * Sends and consumes magic-link tokens. Two flows per brief §6.4: * *

        - *
      • Email verification — the {@code pkauth.purpose} claim is {@code email-verify}. - *
      • Passwordless login — the {@code pkauth.purpose} claim is {@code login}. + *
      • Email verification: the {@code pkauth.purpose} claim is {@code email-verify}. + *
      • Passwordless login: the {@code pkauth.purpose} claim is {@code login}. *
      * *

      Single-use is enforced by recording consumed JTI values in a {@link ConsumedJtiStore} whose * entries expire after {@code consumedJtiTtl} (default: {@link #DEFAULT_CONSUMED_JTI_TTL}). The * SPI's default in-process implementation ({@link InMemoryConsumedJtiStore}) is dev/single-instance - * only — multi-replica deployments MUST inject a shared (Redis/DB-backed) store, otherwise a token + * only; multi-replica deployments must inject a shared (Redis/DB-backed) store, otherwise a token * redeemed on one replica can be redeemed again on another within its TTL window. The service logs - * a startup WARN when the in-memory default is wired. Rate limiting (brief §6.4 — N emails per + * a startup WARN when the in-memory default is wired. Rate limiting (brief §6.4: N emails per * user/purpose per hour) is tracked through {@link MagicLinkRateLimiter}, which follows the same * pattern (dev-only in-process default; production replaces with a shared implementation). * - *

      Construct via {@link #create(Dependencies, Config)} (or {@link #create(Dependencies, String)} - * for the all-default case). Required collaborators live in {@link Dependencies}; tunables (rate - * limit, consumed-JTI TTL, base URL) live in {@link Config}. + *

      Instances are built via {@link #create(Dependencies, Config)} (or {@link #create(Dependencies, + * String)} for the all-default case). Required collaborators live in {@link Dependencies}; tunables + * (rate limit, consumed-JTI TTL, base URL) live in {@link Config}. */ public final class MagicLinkService { @@ -70,9 +70,9 @@ public final class MagicLinkService { public static final Duration DEFAULT_TTL = Duration.ofMinutes(15); /** - * Default TTL for the consumed-JTI cache. Set comfortably larger than {@link #DEFAULT_TTL} so a - * JWT that's already expired can't be replayed even if the validator's clock-skew tolerance - * accepts it briefly after expiry. + * Default TTL for the consumed-JTI cache. The value is comfortably larger than {@link + * #DEFAULT_TTL} so a JWT that has already expired cannot be replayed even if the validator's + * clock-skew tolerance accepts it briefly after expiry. */ public static final Duration DEFAULT_CONSUMED_JTI_TTL = Duration.ofMinutes(30); @@ -85,10 +85,10 @@ public final class MagicLinkService { /** * Dedicated audience for magic-link JWTs. Magic-link tokens are minted with this audience (not * the application's resource-server audience) so that the host's ordinary {@link - * PkAuthJwtValidator} — which only accepts the application audience — rejects them. This is what + * PkAuthJwtValidator} (which only accepts the application audience) rejects them. This is what * prevents a magic-link token (which sits in an email inbox / proxy log) from being replayed as - * an API bearer/access token. Wire the service via {@link Dependencies#ofDedicatedAudience} so - * the magic-link issuer and validator are both scoped to this audience. + * an API bearer/access token. Wiring the service via {@link Dependencies#ofDedicatedAudience} + * scopes the magic-link issuer and validator both to this audience. * * @since 2.2.0 */ @@ -99,7 +99,7 @@ public sealed interface SendResult { /** * Email was dispatched. * - *

      Carries only the token's {@code jti} — an opaque, non-secret correlation id safe to log or + *

      Carries only the token's {@code jti}: an opaque, non-secret correlation id safe to log or * echo to a caller. The magic-link token itself is a bearer credential that authenticates as * the user without inbox access, so it is never surfaced here; it reaches only {@link * EmailSender}. Before 2.3.0 this component was named {@code tokenJti} but actually carried the @@ -107,8 +107,8 @@ public sealed interface SendResult { * login credential. * *

      Empty string when {@link MagicLinkService#startLogin} short-circuits (unknown user, or no - * bound address) — those paths return the same {@code Sent} shape to prevent account - * enumeration and have no token to identify. + * bound address); those paths return the same {@code Sent} shape to prevent account enumeration + * and have no token to identify. * * @param jti the issued token's JWT ID, or {@code ""} when no token was issued * @since 2.3.0 @@ -144,15 +144,15 @@ record AlreadyConsumed() implements ConsumeResult {} /** * Token is otherwise valid but was minted for a different {@code pkauth.purpose} than the * endpoint requires (e.g. a {@code login} token presented to the email-verification flow). The - * single-use JTI is deliberately not consumed in this case, so the token stays usable - * at its intended endpoint. + * single-use JTI is not consumed in this case, so the token stays usable at its intended + * endpoint. * * @since 2.2.0 */ record WrongPurpose(String expectedPurpose, String actualPurpose) implements ConsumeResult {} } - /** Pluggable rate limiter — defaults to an in-process Caffeine counter. */ + /** Pluggable rate limiter; defaults to an in-process Caffeine counter. */ public interface MagicLinkRateLimiter { /** Returns the current count and whether a new send is allowed. */ int countAndIncrement(UserHandle user, String purpose, Instant now); @@ -209,7 +209,7 @@ public static MagicLinkService create(Dependencies deps, Config config) { /** * Convenience overload that builds a {@link Config} carrying the supplied {@code baseUrl} and the - * documented defaults for every other tunable. The {@code baseUrl} has no library default — it is + * documented defaults for every other tunable. The {@code baseUrl} has no library default; it is * a host-specific URL prefix. * * @since 0.9.1 @@ -222,7 +222,7 @@ public static MagicLinkService create(Dependencies deps, String baseUrl) { /** * Sends a verification email containing a magic link tied to {@code email}. If the host has * implemented {@link UserLookup#emailFor(UserHandle)}, the supplied {@code email} must equal the - * bound value (constant-time compare) — otherwise a caller could mint a "verified" claim for an + * bound value (constant-time compare); otherwise a caller could mint a "verified" claim for an * arbitrary address. If the host has not implemented {@code emailFor}, the binding check is * skipped (with a warning log) and the send proceeds. * @@ -267,29 +267,27 @@ public SendResult startEmailVerification(UserHandle user, String email) { /** * Sends a login email to the user with the supplied username. * - *

      Privacy invariant (result-shape only): this method ALWAYS returns {@link - * SendResult.Sent}, regardless of whether the supplied username exists in the system. When no - * user is found the method returns early (skipping JWT issuance and email dispatch) but returns - * the same {@code Sent} shape as a successful send, so the response body never reveals whether an - * account exists. Callers MUST NOT rely on a {@link SendResult.UserNotFound} outcome from this - * method — that variant is produced only by signup flows where confirming account non-existence - * is intentional. + *

      Privacy invariant (result-shape only): this method always returns {@link SendResult.Sent}, + * regardless of whether the supplied username exists in the system. When no user is found the + * method returns early (skipping JWT issuance and email dispatch) but returns the same {@code + * Sent} shape as a successful send, so the response body never reveals whether an account exists. + * Callers must not rely on a {@link SendResult.UserNotFound} outcome from this method; that + * variant is produced only by signup flows where confirming account non-existence is intentional. * - *

      This method is NOT constant-time. The not-found path returns before JWT - * issuance and the (typically blocking) email dispatch, so a known username incurs measurably - * more latency than an unknown one; an attacker who can time responses can still enumerate - * accounts. Equalising this is impractical at the library layer because SMTP/transport latency - * dominates and varies — hosts that need timing-side-channel resistance should front this with a - * uniform-latency wrapper or rate-limit and monitor for enumeration probing. + *

      This method is not constant-time. The not-found path returns before JWT issuance and the + * (typically blocking) email dispatch, so a known username incurs measurably more latency than an + * unknown one; an attacker who can time responses can still enumerate accounts. Equalising this + * is impractical at the library layer because SMTP/transport latency dominates and varies; hosts + * that need timing-side-channel resistance should front this with a uniform-latency wrapper or + * rate-limit and monitor for enumeration probing. * - *

      The login link is delivered ONLY to the address bound to the resolved user - * via {@link UserLookup#emailFor(UserHandle)} — never to the caller-supplied {@code email}. - * Sending to a caller-supplied address would let an attacker request a login token for any - * account by username and have it delivered to an address they control (account takeover). When - * the host has not implemented {@code emailFor} (no trusted destination exists), the send is - * skipped and the same enumeration-resistant {@link SendResult.Sent} shape is returned. The - * {@code email} parameter is retained for source/back-compatibility but is not used as the - * delivery address. + *

      The login link is delivered only to the address bound to the resolved user via {@link + * UserLookup#emailFor(UserHandle)}, never to the caller-supplied {@code email}. Sending to a + * caller-supplied address would let an attacker request a login token for any account by username + * and have it delivered to an address they control (account takeover). When the host has not + * implemented {@code emailFor} (no trusted destination exists), the send is skipped and the same + * enumeration-resistant {@link SendResult.Sent} shape is returned. The {@code email} parameter is + * retained for source/back-compatibility but is not used as the delivery address. * * @since 0.9.1 */ @@ -343,8 +341,8 @@ public ConsumeResult finishVerification(String token) { * pkauth.purpose} claim equals {@code requiredPurpose}. This closes a cross-purpose token * confusion: without it, a token minted for one flow (e.g. {@link #PURPOSE_LOGIN}) satisfies * another flow's consume check (e.g. {@link #PURPOSE_EMAIL_VERIFY}), letting one ceremony's token - * stand in for another. The purpose is checked before the single-use JTI is consumed, so - * a cross-purpose attempt does not burn the token at its legitimate endpoint. + * stand in for another. The purpose is checked before the single-use JTI is consumed, so a + * cross-purpose attempt does not burn the token at its legitimate endpoint. * * @param token the magic-link JWT * @param requiredPurpose the purpose the caller demands, or {@code null} to accept any purpose @@ -427,11 +425,11 @@ private static String jtiOf(String token) { /** * Canonical holder of the required collaborators for {@link MagicLinkService}. * - *

      The {@code consumedJtiStore} enforces single-use; multi-replica deployments MUST supply a + *

      The {@code consumedJtiStore} enforces single-use; multi-replica deployments must supply a * shared (Redis/DB-backed) implementation. The {@code messageFormatter} renders the {@link * MagicLinkContext} (user, recipient, URL, purpose) into a {@link MagicLinkMessage} that is * passed verbatim to {@link EmailSender#send(String, String, String)}; supply a host-specific - * formatter to brand or localize the email copy without forking this service. + * formatter to brand or localise the email copy without forking this service. * * @since 0.9.1 */ @@ -443,7 +441,7 @@ public record Dependencies( ClockProvider clockProvider, ConsumedJtiStore consumedJtiStore, MessageFormatter messageFormatter) { - /** Compact constructor — enforces non-null on every collaborator. */ + /** Compact constructor that enforces non-null on every collaborator. */ public Dependencies { Objects.requireNonNull(issuer, "issuer"); Objects.requireNonNull(validator, "validator"); @@ -478,18 +476,18 @@ public static Dependencies of( } /** - * Builds {@link Dependencies} with a dedicated magic-link JWT issuer and - * validator, both scoped to {@link MagicLinkService#DEFAULT_AUDIENCE} and derived from the - * host's {@code keyset} and {@code issuerName} (pass the resource-server issuer so the {@code - * iss} claim is consistent). This is the recommended wiring: because magic-link tokens carry - * the magic-link audience rather than the application audience, the host's resource-server - * {@link PkAuthJwtValidator} rejects them, so a magic-link token cannot be replayed as an API - * bearer/access token (token-confusion defense). The dedicated issuer also uses a no-op - * access-token store, so magic-link jtis never pollute the host's {@code AccessTokenStore}. + * Builds {@link Dependencies} with a dedicated magic-link JWT issuer and validator, both scoped + * to {@link MagicLinkService#DEFAULT_AUDIENCE} and derived from the host's {@code keyset} and + * {@code issuerName} (pass the resource-server issuer so the {@code iss} claim is consistent). + * This is the recommended wiring: because magic-link tokens carry the magic-link audience + * rather than the application audience, the host's resource-server {@link PkAuthJwtValidator} + * rejects them, so a magic-link token cannot be replayed as an API bearer/access token + * (token-confusion defence). The dedicated issuer also uses a no-op access-token store, so + * magic-link jtis never pollute the host's {@code AccessTokenStore}. * - *

      Prefer this over {@link #of} for production wiring. Token lifetime is controlled by {@link - * MagicLinkService} (the {@link Config#tokenTtl()}), not the issuer's access-token TTL, so the - * audience config's TTL policy is irrelevant here. + *

      This is preferred over {@link #of} for production wiring. Token lifetime is controlled by + * {@link MagicLinkService} (the {@link Config#tokenTtl()}), not the issuer's access-token TTL, + * so the audience config's TTL policy is irrelevant here. * * @param keyset the host's signing keyset (shared with the resource-server issuer) * @param issuerName the {@code iss} claim value (the resource-server issuer name) @@ -527,22 +525,22 @@ public static Dependencies ofDedicatedAudience( /** * Tunable configuration for {@link MagicLinkService}. * - *

      {@code baseUrl} is required (no library default) — it is the host-specific URL prefix + *

      {@code baseUrl} is required (no library default); it is the host-specific URL prefix * inserted into outbound emails. Every other field has a documented default exposed via {@link * #defaults(String)}. * - *

      The default {@link MagicLinkRateLimiter} is {@link InMemoryRateLimiter} — DEV / - * SINGLE-INSTANCE ONLY. Multi-replica deployments MUST replace it with a shared (Redis/DB-backed) - * implementation. + *

      The default {@link MagicLinkRateLimiter} is {@link InMemoryRateLimiter}, which is intended + * for development and single-instance use only. Multi-replica deployments must replace it with a + * shared (Redis/DB-backed) implementation. * - *

      {@code consumedJtiTtl} must be at least as long as {@code tokenTtl}. - * Single-use is enforced by retaining each consumed JTI for {@code consumedJtiTtl}; if that - * retention is shorter than the token's own validity, a still-unexpired token becomes redeemable - * again once its JTI entry is evicted, defeating single-use. This service now OWNS the token TTL - * ({@code tokenTtl}, default {@link #DEFAULT_TTL} = 15m) and issues magic links with it - * explicitly (rather than inheriting the JWT issuer's 1h access TTL), so the invariant is - * enforced at construction: the compact constructor rejects {@code consumedJtiTtl < tokenTtl}. - * The defaults are safe (30m retention vs 15m token TTL). + *

      {@code consumedJtiTtl} must be at least as long as {@code tokenTtl}. Single-use is enforced + * by retaining each consumed JTI for {@code consumedJtiTtl}; if that retention is shorter than + * the token's own validity, a still-unexpired token becomes redeemable again once its JTI entry + * is evicted, defeating single-use. This service owns the token TTL ({@code tokenTtl}, default + * {@link #DEFAULT_TTL} = 15m) and issues magic links with it explicitly (rather than inheriting + * the JWT issuer's 1h access TTL), so the invariant is enforced at construction: the compact + * constructor rejects {@code consumedJtiTtl < tokenTtl}. The defaults are safe (30m retention vs + * 15m token TTL). * * @since 0.9.1 */ @@ -553,9 +551,9 @@ public record Config( Duration tokenTtl, Duration consumedJtiTtl) { /** - * Compact constructor — enforces non-null on every field, rejects a {@code baseUrl} that isn't - * an http(s) URL or that carries whitespace / CRLF (which would enable header-splitting if the - * value flowed into a response header), and enforces {@code consumedJtiTtl >= tokenTtl} so + * Compact constructor that enforces non-null on every field, rejects a {@code baseUrl} that is + * not an http(s) URL or that carries whitespace / CRLF (which would enable header-splitting if + * the value flowed into a response header), and enforces {@code consumedJtiTtl >= tokenTtl} so * single-use cannot be defeated by JTI eviction while a token is still valid. Hosts running in * dev mode may pass {@code http://}; production deployments are expected to pass {@code * https://}. @@ -628,11 +626,11 @@ public static Config defaults(String baseUrl) { /** * Simple in-memory rate limiter backed by {@link InMemoryWindowCounter}. * - *

      FOR DEV / SINGLE-INSTANCE USE ONLY. Production deployments MUST replace - * this with a shared (Redis/DB-backed) {@link MagicLinkRateLimiter} implementation, otherwise + *

      Intended for development and single-instance use only. Production deployments must replace + * this with a shared (Redis/DB-backed) {@link MagicLinkRateLimiter} implementation; otherwise * per-replica rate limits multiply by the cluster size. For example, with a limit of 5 emails per * hour and a 3-node cluster, an attacker can send up to 15 emails per hour because each replica - * tracks its own independent counter. Wire a production-grade implementation via the {@link + * tracks its own independent counter. A production-grade implementation is wired via the {@link * Config} record passed to {@link #create(Dependencies, Config)}. * * @since 0.9.1 @@ -661,7 +659,7 @@ public void reset() { counter.reset(); } - /** Exposed for diagnostics — tracks active counter keys. */ + /** Exposed for diagnostics: tracks active counter keys. */ public Set keys() { return counter.keys(); } diff --git a/pk-auth-magic-link/src/test/java/com/codeheadsystems/pkauth/magiclink/MagicLinkServiceEdgeCasesTest.java b/pk-auth-magic-link/src/test/java/com/codeheadsystems/pkauth/magiclink/MagicLinkServiceEdgeCasesTest.java index 53c8d1dd..4d2e2a8f 100644 --- a/pk-auth-magic-link/src/test/java/com/codeheadsystems/pkauth/magiclink/MagicLinkServiceEdgeCasesTest.java +++ b/pk-auth-magic-link/src/test/java/com/codeheadsystems/pkauth/magiclink/MagicLinkServiceEdgeCasesTest.java @@ -35,7 +35,7 @@ class MagicLinkServiceEdgeCasesTest { private static final String AUDIENCE = "https://app.example.com"; private static final String BASE_URL = "https://app.example.com/auth/magic"; - /** UserLookup that maps users to a bound email so we can test the binding check. */ + /** UserLookup that maps users to a bound email so the test can exercise the binding check. */ private static final class BoundEmailUserLookup implements UserLookup { private final InMemoryUserLookup delegate = new InMemoryUserLookup(); private final Map emails = new java.util.HashMap<>(); @@ -94,7 +94,7 @@ private MagicLinkService buildService( /** * Captures the emailed magic-link URL so a test can recover the token. Since 2.3.0 {@link - * MagicLinkService.SendResult.Sent} carries only the jti, so — as for a real recipient — the + * MagicLinkService.SendResult.Sent} carries only the jti, so, as for a real recipient, the * emailed link is the only route to the token. */ private static final class CapturingEmailSender implements EmailSender { diff --git a/pk-auth-magic-link/src/test/java/com/codeheadsystems/pkauth/magiclink/MagicLinkServiceTest.java b/pk-auth-magic-link/src/test/java/com/codeheadsystems/pkauth/magiclink/MagicLinkServiceTest.java index 2f5a88d7..01c7f2ef 100644 --- a/pk-auth-magic-link/src/test/java/com/codeheadsystems/pkauth/magiclink/MagicLinkServiceTest.java +++ b/pk-auth-magic-link/src/test/java/com/codeheadsystems/pkauth/magiclink/MagicLinkServiceTest.java @@ -237,7 +237,7 @@ public void send(String to, String subject, String body) { /** * Extracts the magic-link token from the most recently dispatched email. Since 2.3.0 the token * is no longer returned on {@link MagicLinkService.SendResult.Sent} (it carries only the jti), - * so the emailed link is the only place a test — like a real recipient — can obtain it. + * so the emailed link is the only place a test, like a real recipient, can obtain it. */ String lastToken() { assertThat(sent).isNotEmpty(); diff --git a/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthAdminController.java b/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthAdminController.java index 5d5d9a05..29ad887b 100644 --- a/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthAdminController.java +++ b/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthAdminController.java @@ -34,16 +34,15 @@ /** * Admin controller mounting the brief §6.9 endpoints under {@code /auth/admin/**}. All * authenticated endpoints require the {@link PkAuthJwtAuthenticationFilter} to have attached a user - * handle to the request; {@code complete-email-verification} is intentionally unauthenticated. + * handle to the request; {@code complete-email-verification} is unauthenticated. * *

      Every {@link AdminResult} is routed through {@link AdminResponseMapper} so the JSON shape is * byte-for-byte identical across the Spring, Dropwizard, and Micronaut adapters. * - *

      Mounted only when an {@link AdminService} bean is present — which, because {@code - * pk-auth-admin-api} is a {@code compileOnly} dependency of this adapter, happens only when the - * host keeps that module on its runtime classpath. A host that omits it gets no {@code - * /auth/admin/**} routes at all, matching the optional-admin contract of the Spring Boot starter - * and the Dropwizard bundle. + *

      Mounted only when an {@link AdminService} bean is present; because {@code pk-auth-admin-api} + * is a {@code compileOnly} dependency of this adapter, that happens only when the host keeps that + * module on its runtime classpath. A host that omits it gets no {@code /auth/admin/**} routes at + * all, matching the optional-admin contract of the Spring Boot starter and the Dropwizard bundle. * * @since 0.9.1 */ diff --git a/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthAdminFactory.java b/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthAdminFactory.java index 89948810..e3febaf7 100644 --- a/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthAdminFactory.java +++ b/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthAdminFactory.java @@ -14,7 +14,7 @@ /** * Wires the optional admin surface. {@code pk-auth-admin-api} is a {@code compileOnly} dependency - * of this adapter — the same opt-in contract the Spring Boot starter expresses with + * of this adapter, the same opt-in contract the Spring Boot starter expresses with * {@code @ConditionalOnClass} and the Dropwizard bundle with a runtime classpath check. Keeping the * {@link AdminService} bean in its own {@link Factory} (rather than on {@link PkAuthFactory}) means * the main factory class carries no reference to {@code pk-auth-admin-api}; a host that omits the diff --git a/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthCeremonyController.java b/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthCeremonyController.java index 0cd7fc8c..4e3f358c 100644 --- a/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthCeremonyController.java +++ b/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthCeremonyController.java @@ -27,13 +27,13 @@ import org.slf4j.LoggerFactory; /** - * Mounts the four WebAuthn ceremony endpoints under {@code /auth/passkeys/**} — same path scheme as - * the Spring and Dropwizard adapters. Every endpoint delegates to {@link CeremonyOrchestrator}, + * Mounts the four WebAuthn ceremony endpoints under {@code /auth/passkeys/**}, the same path scheme + * as the Spring and Dropwizard adapters. Every endpoint delegates to {@link CeremonyOrchestrator}, * which owns the JWT-mint / label-lookup / wire-mapping pipeline shared across adapters. * - *

      Threading. pk-auth's SPI is blocking (JDBC, DynamoDB SDK, etc.); this adapter - * dispatches every endpoint to {@link TaskExecutors#BLOCKING} so Micronaut's Netty event loop is - * never parked on a synchronous repository call. + *

      Threading: pk-auth's SPI is blocking (JDBC, DynamoDB SDK, etc.); this adapter dispatches every + * endpoint to {@link TaskExecutors#BLOCKING} so Micronaut's Netty event loop is never parked on a + * synchronous repository call. */ @Controller("/auth/passkeys") @Produces(MediaType.APPLICATION_JSON) diff --git a/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthConfiguration.java b/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthConfiguration.java index 7b68951a..65933b07 100644 --- a/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthConfiguration.java +++ b/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthConfiguration.java @@ -14,8 +14,8 @@ * env-var with the standard Micronaut mapping: {@code pkauth.jwt.secret} ↔ {@code * PKAUTH_JWT_SECRET}. * - *

      Relying-party id/name/origins and JWT issuer/audience/secret are required — there are no - * adapter defaults. Misconfiguration surfaces as a startup failure rather than a silent dev-mode + *

      Relying-party id/name/origins and JWT issuer/audience/secret are required; there are no + * adapter defaults. Misconfiguration surfaces as a startup failure instead of a silent dev-mode * boot. */ @ConfigurationProperties("pkauth") @@ -77,7 +77,7 @@ public void setRefresh(Refresh refresh) { /** * Whether to enable in-memory testkit SPIs and dev-only logging senders, plus per-startup random - * OTP pepper auto-generation when {@code pkauth.otp.pepper} is unset. Defaults to {@code false} — + * OTP pepper auto-generation when {@code pkauth.otp.pepper} is unset. Defaults to {@code false}; * production deployments must supply real SPI beans / senders / pepper. * * @return {@code true} when {@code pkauth.dev-mode=true}, {@code false} otherwise @@ -88,7 +88,7 @@ public boolean isDevMode() { } /** - * Bind the {@code pkauth.dev-mode} property. + * Binds the {@code pkauth.dev-mode} property. * * @param devMode whether dev-mode wiring is enabled * @since 0.9.1 @@ -138,7 +138,7 @@ public void setAcceptedAlgorithms(@Nullable List acceptedAlgorith /** * Relying-party identity nested config. {@code id}, {@code name}, and {@code origins} are all - * required — there are no defaults. {@link PkAuthFactory} validates them at startup. + * required; there are no defaults. {@link PkAuthFactory} validates them at startup. */ @ConfigurationProperties("relying-party") public static final class RelyingParty { @@ -173,7 +173,7 @@ public void setOrigins(@Nullable List origins) { /** * JWT issuance and validation config. {@code issuer}, {@code audience}, and {@code secret} are - * all required — there are no adapter defaults. {@link PkAuthFactory} validates them at startup. + * all required; there are no adapter defaults. {@link PkAuthFactory} validates them at startup. * *

      {@code defaultTtl} sets the access-token TTL applied to audiences not listed in {@code * ttlsByAudience}; null defers to {@link @@ -234,8 +234,8 @@ public void setTtlsByAudience(@Nullable Map ttlsByAudience) { * OTP service tunables. * *

      {@code pkauth.otp.pepper} is a Base64-encoded server-side HMAC pepper (≥ 16 decoded bytes; - * 32+ recommended). Required in production. When unset, the factory will only auto-generate a - * per-startup random pepper if {@code pkauth.dev-mode=true}. A per-startup pepper invalidates + * 32+ recommended). Required in production. When unset, the factory auto-generates a per-startup + * random pepper only if {@code pkauth.dev-mode=true}. A per-startup pepper invalidates * outstanding OTPs across restarts and across cluster instances. */ @ConfigurationProperties("otp") 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 0b9b03d5..0f3aa163 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 @@ -119,7 +119,7 @@ PkAuthJwtIssuer jwtIssuer( } /** - * Shared ceremony orchestrator — JWT mint + label lookup + wire mapping. Lives in {@code + * Shared ceremony orchestrator covering JWT mint, label lookup, and wire mapping. Lives in {@code * pk-auth-jwt} so every adapter holds a single dependency rather than three. * * @since 0.9.1 @@ -195,7 +195,7 @@ RefreshHandler refreshHandler(RefreshTokenService service, PkAuthJwtIssuer issue } /** - * Default in-memory {@link CeremonyRateLimiter} — hosts running more than one replica MUST supply + * Default in-memory {@link CeremonyRateLimiter}; hosts running more than one replica must supply * a shared (Redis / DB-backed) bean to replace this. See {@link InMemoryCeremonyRateLimiter} * javadoc. * diff --git a/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthIntrospections.java b/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthIntrospections.java index 0e1004bb..8235d89a 100644 --- a/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthIntrospections.java +++ b/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthIntrospections.java @@ -33,9 +33,9 @@ /** * Registers Micronaut bean-introspection metadata for every pk-auth wire type that crosses the - * Micronaut serialization boundary. The records live in {@code pk-auth-core} so we can't annotate - * them in place; the {@code @Introspected(classes = …)} sweep on this placeholder class teaches - * Micronaut's compile-time processor to generate introspection adapters for each one. + * Micronaut serialisation boundary. The records live in {@code pk-auth-core} so they cannot be + * annotated in place; the {@code @Introspected(classes = …)} sweep on this placeholder class + * teaches Micronaut's compile-time processor to generate introspection adapters for each one. */ @Introspected( classes = { diff --git a/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthJwtAuthenticationFilter.java b/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthJwtAuthenticationFilter.java index 139cc5e7..d979f111 100644 --- a/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthJwtAuthenticationFilter.java +++ b/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthJwtAuthenticationFilter.java @@ -15,14 +15,14 @@ /** * Validates the JWT on {@code Authorization: Bearer …} headers (when present) and stashes the * authenticated {@link UserHandle} on the request as the attribute {@value #ATTR_USER_HANDLE}. - * Missing / malformed tokens are NOT rejected here — admin controller methods enforce - * "authenticated user required" themselves. This keeps the filter pluggable and avoids Micronaut - * Security's generic-heavy SecurityRule surface. + * Missing / malformed tokens are not rejected here; admin controller methods enforce "authenticated + * user required" themselves. This keeps the filter pluggable and avoids Micronaut Security's + * generic-heavy SecurityRule surface. * - *

      Threading. Token validation itself is non-blocking (HS256 or RS256 verification only), - * so the filter does NOT dispatch to a blocking executor and is safe to run on the Netty event - * loop. The pk-auth SPI is blocking, but the SPI is invoked downstream from the {@code @Controller} - * — see {@link PkAuthCeremonyController} / {@link PkAuthAdminController}, which both carry + *

      Threading: token validation itself is non-blocking (HS256 or RS256 verification only), so the + * filter does not dispatch to a blocking executor and is safe to run on the Netty event loop. The + * pk-auth SPI is blocking, but the SPI is invoked downstream from the {@code @Controller}; see + * {@link PkAuthCeremonyController} / {@link PkAuthAdminController}, which both carry * {@code @ExecuteOn(TaskExecutors.BLOCKING)}. */ @Filter("/auth/admin/**") diff --git a/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthPersistenceExceptionHandler.java b/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthPersistenceExceptionHandler.java index b702afb2..f4949146 100644 --- a/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthPersistenceExceptionHandler.java +++ b/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthPersistenceExceptionHandler.java @@ -16,7 +16,7 @@ /** * Maps the SPI exception contract (see {@link * com.codeheadsystems.pkauth.spi.PkAuthPersistenceException}) to a stable {@code 503 Service - * Unavailable} JSON response — same wire shape as the Spring and Dropwizard adapters. + * Unavailable} JSON response, with the same wire shape as the Spring and Dropwizard adapters. */ @Produces @Singleton diff --git a/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthRefreshController.java b/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthRefreshController.java index 41f43706..40655228 100644 --- a/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthRefreshController.java +++ b/pk-auth-micronaut/src/main/java/com/codeheadsystems/pkauth/micronaut/PkAuthRefreshController.java @@ -18,7 +18,7 @@ /** * Mounts the refresh endpoint at {@code POST /auth/refresh}. Only registered when a {@link * RefreshHandler} bean is present (which itself requires a {@code RefreshTokenRepository}). The - * controller is a thin wrapper around the shared handler — rotation logic and error mapping live in + * controller is a thin wrapper around the shared handler; rotation logic and error mapping live in * {@code pk-auth-refresh-tokens}. * * @since 1.1.0 diff --git a/pk-auth-micronaut/src/test/java/com/codeheadsystems/pkauth/micronaut/PkAuthAdminEndpointsCoverageTest.java b/pk-auth-micronaut/src/test/java/com/codeheadsystems/pkauth/micronaut/PkAuthAdminEndpointsCoverageTest.java index e08a8a31..75e0121f 100644 --- a/pk-auth-micronaut/src/test/java/com/codeheadsystems/pkauth/micronaut/PkAuthAdminEndpointsCoverageTest.java +++ b/pk-auth-micronaut/src/test/java/com/codeheadsystems/pkauth/micronaut/PkAuthAdminEndpointsCoverageTest.java @@ -21,7 +21,7 @@ import org.junit.jupiter.api.Test; /** - * Drives the authenticated admin endpoints the smoke tests don't reach, covering the {@code actor + * Drives the authenticated admin endpoints the smoke tests do not reach, covering the {@code actor * != null} happy branch and the non-null request-body path of each handler. */ @MicronautTest diff --git a/pk-auth-micronaut/src/test/java/com/codeheadsystems/pkauth/micronaut/PkAuthIntrospectionsCoverageTest.java b/pk-auth-micronaut/src/test/java/com/codeheadsystems/pkauth/micronaut/PkAuthIntrospectionsCoverageTest.java index cc775f61..66b038ec 100644 --- a/pk-auth-micronaut/src/test/java/com/codeheadsystems/pkauth/micronaut/PkAuthIntrospectionsCoverageTest.java +++ b/pk-auth-micronaut/src/test/java/com/codeheadsystems/pkauth/micronaut/PkAuthIntrospectionsCoverageTest.java @@ -21,8 +21,8 @@ /** * Asserts that {@link PkAuthIntrospections} lists every record (and sealed-interface variant) that * ships in the pk-auth wire packages ({@code com.codeheadsystems.pkauth.api} and {@code - * com.codeheadsystems.pkauth.admin}). A new wire type that Micronaut never sees will deserialize as - * a {@code 400 Bad Request} at runtime — this build-time guard catches the omission instead. + * com.codeheadsystems.pkauth.admin}). A new wire type that Micronaut never sees will deserialise as + * a {@code 400 Bad Request} at runtime; this build-time guard catches the omission instead. * * @since 0.9.1 */ @@ -32,8 +32,8 @@ class PkAuthIntrospectionsCoverageTest { List.of("com.codeheadsystems.pkauth.api", "com.codeheadsystems.pkauth.admin"); /** - * Records that are intentionally NOT in the introspection sweep — server-side configuration or - * helper-internal records that never cross the Micronaut serialization boundary. + * Records that are intentionally not in the introspection sweep: server-side configuration or + * helper-internal records that never cross the Micronaut serialisation boundary. */ private static final Set EXEMPT = Set.of( diff --git a/pk-auth-otp/src/main/java/com/codeheadsystems/pkauth/otp/DefaultOtpFormatter.java b/pk-auth-otp/src/main/java/com/codeheadsystems/pkauth/otp/DefaultOtpFormatter.java index e66c9653..8957a75b 100644 --- a/pk-auth-otp/src/main/java/com/codeheadsystems/pkauth/otp/DefaultOtpFormatter.java +++ b/pk-auth-otp/src/main/java/com/codeheadsystems/pkauth/otp/DefaultOtpFormatter.java @@ -7,7 +7,7 @@ * Default {@link MessageFormatter} for OTP SMS messages. Preserves the original hard-coded body * ({@code "Your verification code is XXXXXX"}) that {@link OtpService} used before the {@code * MessageFormatter} SPI was introduced (item #25 of the consolidated review). Host applications - * inject their own {@code MessageFormatter} bean to brand or localize the + * inject their own {@code MessageFormatter} bean to brand or localise the * SMS body. * * @since 0.9.1 diff --git a/pk-auth-otp/src/main/java/com/codeheadsystems/pkauth/otp/OtpContext.java b/pk-auth-otp/src/main/java/com/codeheadsystems/pkauth/otp/OtpContext.java index e6a745fd..5318dd3e 100644 --- a/pk-auth-otp/src/main/java/com/codeheadsystems/pkauth/otp/OtpContext.java +++ b/pk-auth-otp/src/main/java/com/codeheadsystems/pkauth/otp/OtpContext.java @@ -10,8 +10,8 @@ * * @param user the user receiving the code; never {@code null}. * @param phoneE164 the destination phone number in E.164 format; never {@code null}. - * @param code the freshly generated 6-digit OTP, as plaintext digits. Formatters MUST embed this - * verbatim in the rendered SMS body — the user types it back to complete verification. + * @param code the freshly generated 6-digit OTP, as plaintext digits. Formatters must embed this + * verbatim in the rendered SMS body; the user types it back to complete verification. * @since 0.9.1 */ public record OtpContext(UserHandle user, String phoneE164, String code) {} diff --git a/pk-auth-otp/src/main/java/com/codeheadsystems/pkauth/otp/OtpMessage.java b/pk-auth-otp/src/main/java/com/codeheadsystems/pkauth/otp/OtpMessage.java index 8fc74912..c55954f9 100644 --- a/pk-auth-otp/src/main/java/com/codeheadsystems/pkauth/otp/OtpMessage.java +++ b/pk-auth-otp/src/main/java/com/codeheadsystems/pkauth/otp/OtpMessage.java @@ -5,7 +5,7 @@ * Rendered SMS payload produced by a {@link com.codeheadsystems.pkauth.spi.MessageFormatter} and * handed to {@link SmsSender#send(String, String)}. * - * @param body the SMS body. Must be non-null and is sent verbatim — formatters are responsible for + * @param body the SMS body. Must be non-null and is sent verbatim; formatters are responsible for * keeping it within carrier length limits. * @since 0.9.1 */ diff --git a/pk-auth-otp/src/main/java/com/codeheadsystems/pkauth/otp/OtpPepperResolver.java b/pk-auth-otp/src/main/java/com/codeheadsystems/pkauth/otp/OtpPepperResolver.java index 6f97f62c..24cbb65a 100644 --- a/pk-auth-otp/src/main/java/com/codeheadsystems/pkauth/otp/OtpPepperResolver.java +++ b/pk-auth-otp/src/main/java/com/codeheadsystems/pkauth/otp/OtpPepperResolver.java @@ -11,17 +11,17 @@ /** * Shared OTP-pepper resolution policy used by every adapter (Spring, Micronaut, Dropwizard). * - *

      The OTP service hashes inbound 6-digit codes with an HMAC keyed by a server-side - * pepper — a Base64-encoded ≥ 16-byte secret. Without a stable pepper, OTPs minted before - * a restart cannot be verified afterwards (and cannot be verified at all on a replica with a - * different pepper). This resolver centralises the policy: + *

      The OTP service hashes inbound 6-digit codes with an HMAC keyed by a server-side pepper, a + * Base64-encoded ≥ 16-byte secret. Without a stable pepper, OTPs minted before a restart cannot be + * verified afterwards (and cannot be verified at all on a replica with a different pepper). This + * resolver centralises the policy: * *

        *
      • Configured pepper present → decode Base64 and require ≥ 16 decoded bytes (32+ recommended). - *
      • Unset AND {@code devMode=true} → generate a per-startup random pepper and log a loud + *
      • Unset and {@code devMode=true} → generate a per-startup random pepper and log a loud * warning. Per-startup peppers invalidate outstanding OTPs across restarts and across cluster - * instances — dev only. - *
      • Unset AND {@code devMode} false/unset → fail fast at startup. + * instances; dev only. + *
      • Unset and {@code devMode} false/unset → fail fast at startup. *
      * *

      Adapters call {@link #resolve(Supplier, BooleanSupplier)} once during factory wiring and pass @@ -39,12 +39,12 @@ private OtpPepperResolver() { } /** - * Resolve the OTP pepper bytes per the policy described in the class javadoc. + * Resolves the OTP pepper bytes per the policy described in the class javadoc. * * @param configuredPepper supplier for the configured Base64 pepper value (may return {@code * null} or blank when unset) - * @param devMode supplier for the {@code pkauth.dev-mode} flag — only consulted when the pepper - * is unset, so adapters can defer reading the flag + * @param devMode supplier for the {@code pkauth.dev-mode} flag; only consulted when the pepper is + * unset, so adapters can defer reading the flag * @return at least 16 bytes of pepper material * @throws IllegalStateException when the configured pepper is invalid, or when no pepper is set * and {@code devMode} is false diff --git a/pk-auth-otp/src/main/java/com/codeheadsystems/pkauth/otp/OtpService.java b/pk-auth-otp/src/main/java/com/codeheadsystems/pkauth/otp/OtpService.java index 84e353f4..db341b17 100644 --- a/pk-auth-otp/src/main/java/com/codeheadsystems/pkauth/otp/OtpService.java +++ b/pk-auth-otp/src/main/java/com/codeheadsystems/pkauth/otp/OtpService.java @@ -35,9 +35,10 @@ * pepper (never stored in the database) means a DB dump alone cannot enumerate codes; the attacker * also needs the pepper secret. * - *

      Construct via {@link #create(Dependencies, Config)} (or {@link #create(Dependencies, byte[])} - * for the all-default case). Required collaborators (repository, sender, clock, formatter) live in - * {@link Dependencies}; tunables (pepper, TTL, attempt caps, rate-limit) live in {@link Config}. + *

      Instances are built via {@link #create(Dependencies, Config)} (or {@link #create(Dependencies, + * byte[])} for the all-default case). Required collaborators (repository, sender, clock, formatter) + * live in {@link Dependencies}; tunables (pepper, TTL, attempt caps, rate-limit) live in {@link + * Config}. */ public final class OtpService { @@ -94,7 +95,7 @@ record Expired() implements VerifyResult {} /** * Server-side pepper used as the HMAC key when hashing OTP codes. This value must never be stored - * in the database — a DB dump alone cannot enumerate codes without it. The 10^6 search space is + * in the database; a DB dump alone cannot enumerate codes without it. The 10^6 search space is * manageable only if an attacker also possesses the pepper; combined with per-attempt limiting * this provides defence-in-depth. */ @@ -132,7 +133,7 @@ public static OtpService create(Dependencies deps, Config config) { /** * Convenience overload that builds a {@link Config} with all defaults and the supplied {@code - * pepper}. The pepper has no sensible library default — hosts must supply a Base64-encoded ≥16 + * pepper}. The pepper has no sensible library default; hosts must supply a Base64-encoded ≥16 * byte secret. * * @since 0.9.1 @@ -294,9 +295,9 @@ static String maskPhone(String phone) { * *

      The {@code messageFormatter} renders the {@link OtpContext} (user, phone, generated code) * into an {@link OtpMessage} that is passed verbatim to {@link SmsSender#send(String, String)}. - * Pass {@link DefaultOtpFormatter} to keep the historical hard-coded {@code "Your verification - * code is XXXXXX"} body, or supply a host-specific formatter to brand or localize the SMS copy - * without forking this service. + * Supplying {@link DefaultOtpFormatter} keeps the historical hard-coded {@code "Your verification + * code is XXXXXX"} body; a host-specific formatter can brand or localise the SMS copy without + * forking this service. * * @since 0.9.1 */ @@ -305,7 +306,7 @@ public record Dependencies( SmsSender smsSender, ClockProvider clockProvider, MessageFormatter messageFormatter) { - /** Compact constructor — enforces non-null on all required collaborators. */ + /** Compact constructor that enforces non-null on all required collaborators. */ public Dependencies { Objects.requireNonNull(repository, "repository"); Objects.requireNonNull(smsSender, "smsSender"); @@ -314,7 +315,7 @@ public record Dependencies( } /** - * Convenience factory that wires {@link DefaultOtpFormatter} as the message formatter — the + * Convenience factory that wires {@link DefaultOtpFormatter} as the message formatter, the * historical default body. * * @since 0.9.1 @@ -328,8 +329,8 @@ public static Dependencies of( /** * Tunable configuration for {@link OtpService}. * - *

      The HMAC {@code pepper} is required (no library default) — hosts must supply a - * Base64-encoded ≥16 byte secret. Every other field has a sensible default exposed via {@link + *

      The HMAC {@code pepper} is required (no library default); hosts must supply a Base64-encoded + * ≥16 byte secret. Every other field has a sensible default exposed via {@link * #defaults(byte[])}. * * @since 0.9.1 @@ -341,7 +342,7 @@ public record Config( int maxAttempts, int rateLimit, Duration rateWindow) { - /** Compact constructor — validates pepper length, field ranges, and non-null fields. */ + /** Compact constructor that validates pepper length, field ranges, and non-null fields. */ public Config { Objects.requireNonNull(random, "random"); Objects.requireNonNull(pepper, "pepper"); diff --git a/pk-auth-otp/src/main/java/com/codeheadsystems/pkauth/otp/SmsSender.java b/pk-auth-otp/src/main/java/com/codeheadsystems/pkauth/otp/SmsSender.java index a431d667..7acf30be 100644 --- a/pk-auth-otp/src/main/java/com/codeheadsystems/pkauth/otp/SmsSender.java +++ b/pk-auth-otp/src/main/java/com/codeheadsystems/pkauth/otp/SmsSender.java @@ -3,11 +3,11 @@ /** * SPI for sending an SMS containing an OTP. Implementations live in adapter modules / host - * applications. The brief intentionally ships only a {@link LoggingSmsSender} for dev and a - * skeleton {@link TwilioSmsSender}. + * applications. The brief ships only a {@link LoggingSmsSender} for dev and a skeleton {@link + * TwilioSmsSender}. * *

      The method is named {@code send} (rather than {@code sendOtp}) so the same SPI can be reused - * by future SMS-bearing flows without renaming — see item #25 of the consolidated review. The body + * by future SMS-bearing flows without renaming; see item #25 of the consolidated review. The body * is produced upstream by a {@link com.codeheadsystems.pkauth.spi.MessageFormatter}, so this SPI's * only job is delivery. * diff --git a/pk-auth-otp/src/main/java/com/codeheadsystems/pkauth/otp/TwilioSmsSender.java b/pk-auth-otp/src/main/java/com/codeheadsystems/pkauth/otp/TwilioSmsSender.java index 5356dbb1..904f4c39 100644 --- a/pk-auth-otp/src/main/java/com/codeheadsystems/pkauth/otp/TwilioSmsSender.java +++ b/pk-auth-otp/src/main/java/com/codeheadsystems/pkauth/otp/TwilioSmsSender.java @@ -4,14 +4,13 @@ import java.util.Objects; /** - * Intentional starter scaffold for the Twilio {@link SmsSender}. Per brief §3, pk-auth ships only - * the SPI — host applications add the Twilio SDK dependency and replace {@link #send} with the real - * REST call. The class ships in this form on purpose: it captures the canonical Twilio constructor - * shape (account SID, auth token, sender number) and fails loudly if it is ever invoked unmodified. + * Starter scaffold for the Twilio {@link SmsSender}. Per brief §3, pk-auth ships only the SPI; host + * applications add the Twilio SDK dependency and replace {@link #send} with the real REST call. The + * class ships in this form: it captures the canonical Twilio constructor shape (account SID, auth + * token, sender number) and fails loudly if it is ever invoked unmodified. * - *

      This is not a half-finished feature. Do not remove it during dead-code sweeps; it is - * one of the two SDK-naming anchors (alongside {@code JavaMailEmailSender}) that documents which - * external libraries the project expects hosts to bring. + *

      The class is one of the two SDK-naming anchors (alongside {@code JavaMailEmailSender}) that + * document which external libraries the project expects hosts to bring. * * @since 0.9.1 */ diff --git a/pk-auth-otp/src/test/java/com/codeheadsystems/pkauth/otp/OtpServiceTest.java b/pk-auth-otp/src/test/java/com/codeheadsystems/pkauth/otp/OtpServiceTest.java index 1c71e6b1..5ff3a60e 100644 --- a/pk-auth-otp/src/test/java/com/codeheadsystems/pkauth/otp/OtpServiceTest.java +++ b/pk-auth-otp/src/test/java/com/codeheadsystems/pkauth/otp/OtpServiceTest.java @@ -152,7 +152,7 @@ void expiredOtpIsRejected() { /** * {@link OtpRepository} decorator that ignores the {@code now} expiry filter (delegates with - * {@link Instant#MIN}), modelling a host repository that does not filter expired rows in-store — + * {@link Instant#MIN}), modelling a host repository that does not filter expired rows in-store, * so the service-level expiry re-check is the thing under test. */ private static final class NonFilteringOtpRepository implements OtpRepository { diff --git a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/AccessTokenItem.java b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/AccessTokenItem.java index 217bfb8f..0c0811dc 100644 --- a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/AccessTokenItem.java +++ b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/AccessTokenItem.java @@ -10,10 +10,10 @@ * pk/sk addresses: * *

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

      The {@code ttl} attribute is set to {@code expiresAt.epochSecond}, so DynamoDB's native TTL 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 6e43f05a..3ea6648c 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 @@ -112,7 +112,7 @@ public static BackupCodeItem fromRecord(StoredBackupCode c) { return item; } - /** Materializes the row as a {@link StoredBackupCode}. */ + /** Materialises the row as a {@link StoredBackupCode}. */ public StoredBackupCode toRecord() { return new StoredBackupCode( codeId, 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 a93cb35f..e4cbfe16 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 @@ -116,7 +116,7 @@ public static ChallengeItem build(ChallengeId id, ChallengeRecord record) { return item; } - /** Materializes the stored row back into a {@link ChallengeRecord}. */ + /** Materialises the stored row back into a {@link ChallengeRecord}. */ public ChallengeRecord toRecord() { return new ChallengeRecord( Base64Url.decode(challenge), 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 48fb732f..e274d707 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 @@ -191,7 +191,7 @@ public void setVersion(Long version) { this.version = version; } - /** Translates one of our {@link CredentialRecord} values into a DynamoDB item. */ + /** Translates a {@link CredentialRecord} value into a DynamoDB item. */ public static CredentialItem fromRecord(CredentialRecord r) { String credIdB64 = r.credentialId().b64url(); String userB64 = Base64Url.encode(r.userHandle().value()); 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 99a000b4..f627fd67 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 @@ -23,7 +23,7 @@ * attribute prunes expired rows in the background; {@link #deleteExpiredBefore(Instant)} provides * synchronous cleanup for tests and operator workflows. * - *

      The two writes are non-atomic — primary first, then user-index. A failure between them leaves + *

      The two writes are non-atomic: primary first, then user-index. A failure between them leaves * the primary item live (validator-correct) and the user-index missing (a future deleteAllForUser * would not see this jti). Since user deletion is an operator-rare flow and the {@link * com.codeheadsystems.pkauth.lifecycle.UserDeletionListener} contract is idempotent, the operator 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 f44749e0..560696ec 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 @@ -36,13 +36,13 @@ /** * {@link RefreshTokenRepository} backed by the {@code PkAuthCore} single table. The load-bearing * {@link #rotateAtomically} primitive uses {@code TransactWriteItems} to commit "mark parent used" - * and "insert successor" as a single atomic operation — without that, a concurrent replay-revoker + * and "insert successor" as a single atomic operation; without that, a concurrent replay-revoker * could miss the freshly-inserted successor. * - *

      Each issued JTI lives at up to three item addresses (primary + user-index + family-index) — - * the user-index and family-index items aren't load-bearing for correctness (the primary item is - * the authority on used/revoked state) but they make {@code revokeAllForUser} and {@code - * revokeFamily} O(family-size) rather than full-table scans. + *

      Each issued JTI lives at up to three item addresses (primary + user-index + family-index); the + * user-index and family-index items are not load-bearing for correctness (the primary item is the + * authority on used/revoked state) but they make {@code revokeAllForUser} and {@code revokeFamily} + * O(family-size) rather than full-table scans. * *

      Native DynamoDB TTL on the {@code ttl} attribute prunes rows in the background; {@link * #deleteExpiredBefore(Instant)} provides synchronous cleanup for tests and operator workflows. @@ -76,9 +76,9 @@ public DynamoDbRefreshTokenRepository( /** * @param cleanupRetention how long past {@code expiresAt} the native {@code ttl} attribute keeps - * a row before DynamoDB prunes it — must match the {@link + * a row before DynamoDB prunes it; must match the {@link * RefreshTokenConfig#cleanupRetention()} the host runs the service with, so the background - * sweep honors the same forensic window the JDBI backend does. + * sweep honours the same forensic window the JDBI backend does. */ public DynamoDbRefreshTokenRepository( DynamoDbEnhancedClient enhanced, PkAuthDynamoTables tables, Duration cleanupRetention) { @@ -257,11 +257,11 @@ public boolean rotateAtomically( /** * True only when the {@code rotateAtomically} transaction was cancelled because the parent's - * freshness condition failed — the legitimate replay/race signal. The parent's conditional {@code - * UpdateItem} is the first action added to the transaction, so its reason is at index 0; a {@code - * ConditionalCheckFailed} code there means the parent was already used, revoked, or expired. Any - * other cancellation reason (throughput, transaction conflict, validation) is transient and - * returns false here so the caller rethrows rather than scorching the family. + * freshness condition failed; this is the legitimate replay/race signal. The parent's conditional + * {@code UpdateItem} is the first action added to the transaction, so its reason is at index 0; a + * {@code ConditionalCheckFailed} code there means the parent was already used, revoked, or + * expired. Any other cancellation reason (throughput, transaction conflict, validation) is + * transient and returns false here so the caller rethrows rather than scorching the family. */ private static boolean isParentFreshnessFailure(TransactionCanceledException cancelled) { List reasons = cancelled.cancellationReasons(); @@ -273,8 +273,8 @@ private static boolean isParentFreshnessFailure(TransactionCanceledException can /** * True only when the {@code create} transaction was cancelled because the primary item's {@code - * attribute_not_exists(pk)} guard failed — i.e. the refreshId already exists. The primary {@code - * Put} is the first action added to the transaction, so its reason is at index 0; a {@code + * attribute_not_exists(pk)} guard failed, that is, the refreshId already exists. The primary + * {@code Put} is the first action added to the transaction, so its reason is at index 0; a {@code * ConditionalCheckFailed} code there is the duplicate signal. Any other cancellation reason * (throughput, transaction conflict, validation) is transient and is rethrown by the caller for * {@code DynamoDbSupport.wrap} to map. @@ -334,7 +334,7 @@ public int revokeAllForUser(UserHandle userHandle, Instant now, RevokeReason rea /** * Marks one family/user-index member's primary item revoked via a conditional, scalar-only {@code - * UpdateItem} that writes ONLY {@code revokedAtIso}/{@code revokedReason} — never a + * UpdateItem} that writes only {@code revokedAtIso}/{@code revokedReason}, never a * read-modify-write of the whole item. A full-item {@code putItem} (the prior approach) would * carry a stale snapshot and could silently revert a {@code usedAtIso} mark set concurrently by * {@code rotateAtomically}, corrupting the rotation/audit trail. {@code attribute_exists(pk)} 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 d3d47dbf..8a528bb0 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 @@ -40,8 +40,8 @@ static T wrap(String op, Supplier body) { /** * 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). + * table's instant encoding; it is {@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. 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 index 6750fac4..fd579f40 100644 --- 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 @@ -3,9 +3,9 @@ /** * 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. + * is a {@code } string; centralising the prefixes here prevents a writer and a reader + * from silently disagreeing on a prefix; a mismatch does not throw, and the row is simply not + * found. Each constant includes its trailing {@code '#'} separator. * *

      Package-private: these are an internal detail of the DynamoDB adapter, not part of any public * contract. 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 12a0ca2e..4a2b61b6 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 @@ -152,7 +152,7 @@ public static OtpItem fromRecord(StoredOtp o) { return item; } - /** Materializes the row as a {@link StoredOtp}. */ + /** Materialises the row as a {@link StoredOtp}. */ public StoredOtp toRecord() { return new StoredOtp( otpId, diff --git a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/PkAuthDynamoTables.java b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/PkAuthDynamoTables.java index 09f34f51..7afeffa1 100644 --- a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/PkAuthDynamoTables.java +++ b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/PkAuthDynamoTables.java @@ -5,8 +5,8 @@ /** * Holds the names of the two physical DynamoDB tables pk-auth uses. The {@code core} table carries - * credentials and challenges (and in Phase 6, backup codes and OTP records); the {@code users} - * table carries the host-app user records the {@code UserLookup} SPI reads from. + * credentials, challenges, backup codes, and OTP records; the {@code users} table carries the + * host-app user records the {@code UserLookup} SPI reads from. * * @param core single-table for credentials, challenges, etc. (see ADR 0008) * @param users the {@code PkAuthUsers} table; separate per brief §6.7 because users are host-app diff --git a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/RefreshTokenItem.java b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/RefreshTokenItem.java index f4979fc1..bdbada7a 100644 --- a/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/RefreshTokenItem.java +++ b/pk-auth-persistence-dynamodb/src/main/java/com/codeheadsystems/pkauth/persistence/dynamodb/RefreshTokenItem.java @@ -10,16 +10,16 @@ * share this bean, distinguished by the {@code pk}/{@code sk} pattern: * *

        - *
      • Primary: {@code pk = "RT#"}, {@code sk = "RT#"} — fast lookup by + *
      • Primary: {@code pk = "RT#"}, {@code sk = "RT#"}; fast lookup by * refreshId. The load-bearing row for rotation. - *
      • User-index: {@code pk = "USER#"}, {@code sk = "RT#"} — listing + *
      • User-index: {@code pk = "USER#"}, {@code sk = "RT#"}; listing * and {@code revokeAllForUser} fan-out alongside this user's other state. - *
      • Family-index: {@code pk = "RTF#"}, {@code sk = "RT#"} — fast scorch of + *
      • Family-index: {@code pk = "RTF#"}, {@code sk = "RT#"}; fast scorch of * every member of a family. *
      * *

      Two epoch-second attributes drive the lifecycle. {@code expiresAtEpoch} is the token's hard - * expiry and is what the {@code rotateAtomically} freshness condition compares against — a numeric + * expiry and is what the {@code rotateAtomically} freshness condition compares against; a numeric * compare avoids the variable-precision pitfall of comparing {@code Instant.toString()} ISO * strings. {@code ttl} is {@code expiresAt + cleanupRetention}.epochSecond and is the attribute * DynamoDB's native TTL prunes on, so used / revoked / expired rows survive the forensic-retention 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 946d8497..c449bf98 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 @@ -10,7 +10,7 @@ import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSecondarySortKey; import software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbSortKey; -/** Row in the separate {@code PkAuthUsers} table — host-app data per brief §6.7. */ +/** Row in the separate {@code PkAuthUsers} table: host-app data per brief §6.7. */ @DynamoDbBean public final class UserItem { @@ -119,10 +119,10 @@ public static UserItem build(UserHandle handle, String username, String displayN /** * Builds the username-uniqueness marker row: {@code pk = USERNAME#}, {@code sk = * META}. A conditional put of this item ({@code attribute_not_exists(pk)}) is what actually - * enforces one handle per username — a GSI cannot, because GSIs do not enforce uniqueness. - * Deliberately leaves {@code gsi1pk} unset so the sparse username GSI does not index it (only the - * real {@link DynamoKeys#USER} row is indexed and returned by lookups); it carries {@code - * userHandle} so a racing loser can recover the winner's handle with a strongly-consistent read. + * enforces one handle per username; a GSI cannot, because GSIs do not enforce uniqueness. Leaves + * {@code gsi1pk} unset so the sparse username GSI does not index it (only the real {@link + * DynamoKeys#USER} row is indexed and returned by lookups); it carries {@code userHandle} so a + * racing loser can recover the winner's handle with a strongly-consistent read. */ static UserItem usernameMarker(UserHandle handle, String username) { UserItem item = new UserItem(); diff --git a/pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbCredentialRepositoryIntegrationTest.java b/pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbCredentialRepositoryIntegrationTest.java index 44ce9887..43f1bd77 100644 --- a/pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbCredentialRepositoryIntegrationTest.java +++ b/pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbCredentialRepositoryIntegrationTest.java @@ -18,7 +18,7 @@ /** * Direct CRUD against {@link DynamoDbCredentialRepository}, covering the mutation paths ({@code * updateSignCount}, {@code updateLabel}, {@code delete}, {@code deleteByUserHandle}) the shared - * ceremony scenarios don't exercise — including the clone-detection counter guard and the + * ceremony scenarios do not exercise, including the clone-detection counter guard and the * ownership-mismatch no-ops. */ @Testcontainers diff --git a/pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbUserLookupIntegrationTest.java b/pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbUserLookupIntegrationTest.java index 71fde251..2b9470c5 100644 --- a/pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbUserLookupIntegrationTest.java +++ b/pk-auth-persistence-dynamodb/src/test/java/com/codeheadsystems/pkauth/persistence/dynamodb/DynamoDbUserLookupIntegrationTest.java @@ -21,9 +21,9 @@ import org.testcontainers.junit.jupiter.Testcontainers; /** - * Covers {@link DynamoDbUserLookup}'s registration / lookup surface — {@code getOrCreateHandle} + * Covers {@link DynamoDbUserLookup}'s registration / lookup surface: {@code getOrCreateHandle} * (create + idempotent re-fetch), {@code findHandleByUsername}, {@code register}, and {@code - * findViewByHandle} — which the ceremony scenarios only touch via {@code getOrCreateHandle}. + * findViewByHandle}. The ceremony scenarios only touch this surface via {@code getOrCreateHandle}. */ @Testcontainers @DisabledIfEnvironmentVariable(named = "PKAUTH_SKIP_TESTCONTAINERS", matches = "1") 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 b7ea4cd1..4140e1ef 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 @@ -27,7 +27,7 @@ import org.jspecify.annotations.Nullable; /** - * {@link CredentialRepository} backed by the Phase 5 {@code credentials} table. + * {@link CredentialRepository} backed by the {@code credentials} table. * *

      {@link #delete(UserHandle, CredentialId)} is a hard delete (since V7): the row is removed * outright. Audit history is the responsibility of the service layer's structured log pipeline @@ -171,7 +171,7 @@ public void updateLabel(UserHandle userHandle, CredentialId credentialId, String } /** - * Hard-deletes the credential row. Audit history is the service layer's responsibility — {@code + * Hard-deletes the credential row. Audit history is the service layer's responsibility: {@code * DefaultAdminService.deleteCredential} emits a {@code pkauth.credential.deleted} structured log * event around this call. */ 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 688dfe90..b017dfdd 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 @@ -24,7 +24,7 @@ /** * {@link RefreshTokenRepository} backed by the {@code refresh_tokens} table (Flyway V9; the {@code * amr} column added in V10). The load-bearing {@link #rotateAtomically} uses a JDBI transaction - * that wraps a conditional {@code UPDATE} on the parent + an {@code INSERT} for the successor — see + * that wraps a conditional {@code UPDATE} on the parent + an {@code INSERT} for the successor; see * ADR 0013. * * @since 1.1.0 diff --git a/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/PkAuthJdbiSchema.java b/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/PkAuthJdbiSchema.java index cfa76718..53367dc0 100644 --- a/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/PkAuthJdbiSchema.java +++ b/pk-auth-persistence-jdbi/src/main/java/com/codeheadsystems/pkauth/persistence/jdbi/PkAuthJdbiSchema.java @@ -7,11 +7,11 @@ /** * Convenience helper to run pk-auth's Flyway migrations against a {@link DataSource}. * - *

      Development / test use only. The {@link #migrateForDevelopment(DataSource)} - * helper is intentionally scoped to demos and integration tests. Production hosts must - * drive Flyway themselves so they can control the target schema version, baseline settings, - * out-of-order handling, and other operational parameters. To do so, add {@code - * classpath:db/migration} from this artifact to your existing Flyway location list. + *

      For development and test use only. The {@link #migrateForDevelopment(DataSource)} helper is + * scoped to demos and integration tests. Production hosts must drive Flyway themselves so they can + * control the target schema version, baseline settings, out-of-order handling, and other + * operational parameters, by adding {@code classpath:db/migration} from this artifact to the host's + * existing Flyway location list. * *

      The current schema version shipped by this library is {@value #CURRENT_SCHEMA_VERSION}. */ @@ -30,8 +30,8 @@ private PkAuthJdbiSchema() {} * Runs pk-auth's Flyway migrations up to {@link #CURRENT_SCHEMA_VERSION} on the supplied {@code * dataSource}. Idempotent. * - *

      For development and integration testing only. Do not call this from - * production code; production hosts should configure Flyway independently. + *

      For development and integration testing only. Production code must not call this method; + * production hosts should configure Flyway independently. */ public static void migrateForDevelopment(DataSource dataSource) { Flyway.configure() diff --git a/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiCredentialRepositoryIntegrationTest.java b/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiCredentialRepositoryIntegrationTest.java index 1c8f3c7e..6b3e804e 100644 --- a/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiCredentialRepositoryIntegrationTest.java +++ b/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiCredentialRepositoryIntegrationTest.java @@ -18,7 +18,7 @@ /** * Direct CRUD against {@link JdbiCredentialRepository}, covering the mutation paths ({@code * updateSignCount}, {@code updateLabel}, {@code delete}, {@code deleteByUserHandle}) and the - * transport (de)serialization the shared ceremony scenarios don't exercise — including the + * transport (de)serialisation the shared ceremony scenarios do not exercise, including the * clone-detection counter guard and the ownership-scoped no-ops. */ @Testcontainers diff --git a/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiRepositoryExceptionWrappingTest.java b/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiRepositoryExceptionWrappingTest.java index ddd72745..da9ba423 100644 --- a/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiRepositoryExceptionWrappingTest.java +++ b/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiRepositoryExceptionWrappingTest.java @@ -24,10 +24,10 @@ /** * Unit tests asserting that every JDBI repo wraps native {@code JdbiException}s in {@link * PkAuthPersistenceException}. The shared SPI contract requires this so adapters can map a DB - * outage to a single 503 response. We exercise the wrap branch by pointing JDBI at an unreachable - * JDBC URL: every operation fails at connection time with a {@code ConnectionException} (a {@code - * JdbiException} subclass), which the {@code wrap} helper must surface as a typed persistence - * exception. + * outage to a single 503 response. The test exercises the wrap branch by pointing JDBI at an + * unreachable JDBC URL: every operation fails at connection time with a {@code ConnectionException} + * (a {@code JdbiException} subclass), which the {@code wrap} helper must surface as a typed + * persistence exception. */ class JdbiRepositoryExceptionWrappingTest { diff --git a/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiUserLookupIntegrationTest.java b/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiUserLookupIntegrationTest.java index 2bed56cc..7cc62c99 100644 --- a/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiUserLookupIntegrationTest.java +++ b/pk-auth-persistence-jdbi/src/test/java/com/codeheadsystems/pkauth/persistence/jdbi/JdbiUserLookupIntegrationTest.java @@ -13,9 +13,9 @@ import org.testcontainers.junit.jupiter.Testcontainers; /** - * Covers {@link JdbiUserLookup}'s registration / lookup surface — {@code getOrCreateHandle} (create + * Covers {@link JdbiUserLookup}'s registration / lookup surface: {@code getOrCreateHandle} (create * + idempotent re-fetch), {@code findHandleByUsername}, {@code register}, and {@code - * findViewByHandle} / {@code readView} — which the ceremony scenarios only touch via {@code + * findViewByHandle} / {@code readView}. The ceremony scenarios only touch this surface via {@code * getOrCreateHandle}. */ @Testcontainers 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 554f5deb..31f8dcde 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 @@ -11,9 +11,9 @@ * * @param ttlPolicy per-audience refresh TTL dispatch * @param secretBytes how many random bytes to generate for each refresh-token secret. Default 32 - * (256 bits) — high enough that the hash-at-rest is brute-force resistant without salt. + * (256 bits); high enough that the hash-at-rest is brute-force resistant without salt. * @param refreshIdBytes how many random bytes for the {@code refreshId} half of the wire format. - * Default 16 (128 bits) — more than enough to identify a row without collision. + * Default 16 (128 bits); more than enough to identify a row without collision. * @param cleanupRetention how long after a row is used or revoked to keep it around for forensic * visibility. Operator-driven cleanup ({@link * com.codeheadsystems.pkauth.refresh.spi.RefreshTokenRepository#deleteExpiredBefore}) uses this @@ -67,7 +67,7 @@ public static RefreshTokenConfig defaults() { * 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. + * centralising 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. diff --git a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenPair.java b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenPair.java index c8409b45..f6e35828 100644 --- a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenPair.java +++ b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenPair.java @@ -5,10 +5,10 @@ /** * Result of issuing or rotating a refresh token. The {@link #wireToken()} is the only value that - * goes to the client — the bearer presents it on the next refresh; the {@link #record()} is the + * goes to the client (the bearer presents it on the next refresh); the {@link #record()} is the * server-side projection (with hash, never the raw secret). * - * @param wireToken the {@code "{refreshId}.{secret}"} string to return to the client; must NEVER be + * @param wireToken the {@code "{refreshId}.{secret}"} string to return to the client; must never be * logged * @param record the persisted shape (defensive copy of the hash, no raw secret) * @since 1.1.0 diff --git a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenRecord.java b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenRecord.java index 2c64277f..aaae1e14 100644 --- a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenRecord.java +++ b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenRecord.java @@ -16,7 +16,7 @@ *

      Family model: every chain of rotations shares a {@code familyId} (the {@code refreshId} of the * family root). Replaying a used token in any family member scorches the entire family. * - * @param refreshId opaque identifier (16 random bytes, base64url-encoded — 22 chars) + * @param refreshId opaque identifier (16 random bytes, base64url-encoded, 22 chars) * @param tokenHash SHA-256 of the raw 32-byte secret * @param userHandle owning user * @param audience the audience this refresh is scoped to (drives per-audience TTL via {@link @@ -28,8 +28,8 @@ * for the family root * @param issuedAt when this token was issued * @param expiresAt when this token expires (absolute) - * @param usedAt set when the atomic rotate primitive transitions this row from fresh to used — the - * load-bearing field for replay defense + * @param usedAt set when the atomic rotate primitive transitions this row from fresh to used; the + * load-bearing field for replay defence * @param revokedAt set when this token (or its family) is revoked * @param revokedReason categorical reason matching {@link #revokedAt}; present iff {@code * revokedAt} is set diff --git a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenService.java b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenService.java index 8930acab..0193df58 100644 --- a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenService.java +++ b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RefreshTokenService.java @@ -16,15 +16,15 @@ import org.slf4j.LoggerFactory; /** - * Issues, rotates, and revokes refresh tokens. See ADR 0013 for the family-based replay-defense + * Issues, rotates, and revokes refresh tokens. See ADR 0013 for the family-based replay-defence * design and the operational invariants this implementation must preserve. * *

      Wire format is {@code "{refreshId}.{secret}"} where both halves are base64url. Only the * SHA-256 hash of the raw secret bytes is persisted; the wire token never gets logged. * - *

      This service does NOT issue access tokens itself — {@link #rotate(String)} returns the data - * the caller needs to mint a fresh JWT via {@link com.codeheadsystems.pkauth.jwt.PkAuthJwtIssuer}. - * The two primitives stay composable. + *

      This service does not issue access tokens itself; {@link #rotate(String)} returns the data the + * caller needs to mint a fresh JWT via {@link com.codeheadsystems.pkauth.jwt.PkAuthJwtIssuer}. The + * two primitives stay composable. * * @since 1.1.0 */ @@ -53,7 +53,7 @@ public RefreshTokenService( /** * Convenience constructor using a fresh {@link SecureRandom}. Production deployments typically - * pick this — the explicit-RNG ctor exists for tests with deterministic seeds. + * use this; the explicit-RNG ctor exists for tests with deterministic seeds. */ public RefreshTokenService( RefreshTokenRepository repository, RefreshTokenConfig config, ClockProvider clockProvider) { @@ -64,7 +64,7 @@ public RefreshTokenService( * Issues a fresh refresh token belonging to a new family (root of the rotation chain), defaulting * the carried {@code amr} to {@code ["user"]}. * - * @deprecated since 1.3.0 — prefer {@link #issue(UserHandle, String, Optional, List)} so the + * @deprecated since 1.3.0; prefer {@link #issue(UserHandle, String, Optional, List)} so the * refresh family records the original authentication method references (RFC 8176) and * refreshed access tokens reflect how the session was first established instead of a generic * {@code ["user"]}. Retained for source compatibility. @@ -76,8 +76,8 @@ public RefreshTokenPair issue(UserHandle userHandle, String audience, OptionalImplementations are expected to be cheap and side-effect-free. Built-in factories cover the * common cases: * *

        - *
      • {@link #fixed(Duration, Map)} — static map of audience → TTL with a default fallback - *
      • {@link #single(Duration)} — same TTL for every audience + *
      • {@link #fixed(Duration, Map)}: static map of audience → TTL with a default fallback + *
      • {@link #single(Duration)}: same TTL for every audience *
      * * @since 1.1.0 @@ -31,7 +31,7 @@ public interface RefreshTtlPolicy { /** * Audiences this policy explicitly knows about. Empty means "validator falls back to the default - * audience set elsewhere" — analogous to {@link TokenTtlPolicy#knownAudiences()}. + * audience set elsewhere"; analogous to {@link TokenTtlPolicy#knownAudiences()}. */ default Set knownAudiences() { return Set.of(); @@ -40,7 +40,7 @@ default Set knownAudiences() { /** * 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 + * single-vs-fixed dispatch every adapter performs; centralising 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}. diff --git a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RevokeReason.java b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RevokeReason.java index 23100e7e..7eb80f5e 100644 --- a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RevokeReason.java +++ b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RevokeReason.java @@ -14,8 +14,8 @@ public enum RevokeReason { LOGOUT, /** - * A used or revoked token in this family was presented again — the load-bearing replay defense - * outcome. The entire family is revoked and the bearer must re-authenticate. + * A used or revoked token in this family was presented again; this is the load-bearing replay + * defence outcome. The entire family is revoked and the bearer must re-authenticate. */ ROTATION_REPLAY, diff --git a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RotateResult.java b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RotateResult.java index aebea3bd..29735d7f 100644 --- a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RotateResult.java +++ b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RotateResult.java @@ -34,7 +34,7 @@ record Success(RefreshTokenPair pair, RotatedClaims claimsForAccessIssue) /** * A used-or-revoked token from a known family was presented. The entire family has been scorched * ({@link RevokeReason#ROTATION_REPLAY}); the bearer must re-authenticate. The legitimate user - * sees the next refresh fail and is redirected to the login page — that's the expected + * sees the next refresh fail and is redirected to the login page; that is the expected * operational signal. */ record Replayed(String familyId, UserHandle userHandle) implements RotateResult { @@ -48,7 +48,7 @@ record Replayed(String familyId, UserHandle userHandle) implements RotateResult record Expired() implements RotateResult {} /** - * The presented {@code refreshId} did not match any persisted row — either the token was never + * The presented {@code refreshId} did not match any persisted row: either the token was never * issued by this RP, was issued long enough ago that the row has been cleaned up, or the wire * format is malformed. No state changes. */ diff --git a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RotatedClaims.java b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RotatedClaims.java index 3850f406..48f13002 100644 --- a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RotatedClaims.java +++ b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/RotatedClaims.java @@ -9,9 +9,9 @@ /** * Data the consumer needs to mint a fresh access token after a successful refresh-token rotation. * Returned inside {@link RotateResult.Success} so the caller (typically the refresh HTTP endpoint) - * can hand the values to {@link com.codeheadsystems.pkauth.jwt.PkAuthJwtIssuer} — {@link - * com.codeheadsystems.pkauth.refresh.RefreshTokenService} deliberately does NOT call the issuer - * itself, to keep the two primitives composable. + * can hand the values to {@link com.codeheadsystems.pkauth.jwt.PkAuthJwtIssuer}; {@link + * com.codeheadsystems.pkauth.refresh.RefreshTokenService} does not call the issuer itself, which + * keeps the two primitives composable. * * @param userHandle owning user (from the rotated token's row) * @param audience audience the new access token should be scoped to (same as the rotated token's) diff --git a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/package-info.java b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/package-info.java index f961afe8..a3f4b6a9 100644 --- a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/package-info.java +++ b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/package-info.java @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT /** - * Rotating refresh tokens with family-based replay defense. + * Rotating refresh tokens with family-based replay defence. * *

      The {@link com.codeheadsystems.pkauth.refresh.RefreshTokenService} is the public entry point: * issue a token for a user, rotate it on every refresh, revoke a family on logout, or revoke every @@ -12,8 +12,8 @@ *

      Wire format is {@code "{refreshId}.{secret}"} where both halves are base64url; only the * SHA-256 hash of the secret is persisted. The {@link * com.codeheadsystems.pkauth.refresh.spi.RefreshTokenRepository} SPI declares the atomic mark-used - * contract that backs the replay defense — a single conditional UPDATE / DynamoDB conditional write - * that succeeds iff the token is fresh. + * contract that backs the replay defence; it is a single conditional UPDATE / DynamoDB conditional + * write that succeeds iff the token is fresh. * *

      The service does not call {@link com.codeheadsystems.pkauth.jwt.PkAuthJwtIssuer} on its own. * It returns the data the consumer needs to mint a fresh access token, keeping the two primitives 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 index 7787a5a5..54e37e59 100644 --- 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 @@ -35,7 +35,7 @@ public static String encode(List 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 + * 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. diff --git a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/spi/RefreshTokenRepository.java b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/spi/RefreshTokenRepository.java index d53297b5..bb30c9d9 100644 --- a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/spi/RefreshTokenRepository.java +++ b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/spi/RefreshTokenRepository.java @@ -13,17 +13,17 @@ * by: * *

        - *
      • {@code pk-auth-testkit} — in-memory, for unit tests / dev boots - *
      • {@code pk-auth-persistence-jdbi} — Postgres + Flyway V9 - *
      • {@code pk-auth-persistence-dynamodb} — single-table on {@code PkAuthCore} + *
      • {@code pk-auth-testkit}: in-memory, for unit tests / dev boots + *
      • {@code pk-auth-persistence-jdbi}: Postgres + Flyway V9 + *
      • {@code pk-auth-persistence-dynamodb}: single-table on {@code PkAuthCore} *
      * - *

      Load-bearing replay defense. {@link #rotateAtomically(String, Instant, - * RefreshTokenRecord)} must mark the parent used AND insert the successor as a single atomic - * operation. Implementations MUST NOT decompose this into a separate mark-then-insert sequence — a - * concurrent rotator's family-scorch run between the two would miss the freshly-inserted successor. - * The service relies on a {@code false} return to indicate "another caller already used or revoked - * this token" and triggers a family-wide scorch in that branch. + *

      Load-bearing replay defence: {@link #rotateAtomically(String, Instant, RefreshTokenRecord)} + * must mark the parent used and insert the successor as a single atomic operation. Implementations + * must not decompose this into a separate mark-then-insert sequence; a concurrent rotator's + * family-scorch run between the two would miss the freshly-inserted successor. The service relies + * on a {@code false} return to indicate "another caller already used or revoked this token" and + * triggers a family-wide scorch in that branch. * *

      See ADR 0013 for the design rationale. * @@ -33,7 +33,7 @@ public interface RefreshTokenRepository { /** * Persists a freshly-issued or freshly-rotated refresh token. Implementations should reject a - * duplicate {@code refreshId} — this is a server-side bug, not a normal operation. + * duplicate {@code refreshId}; this is a server-side bug, not a normal operation. */ void create(RefreshTokenRecord record); @@ -41,15 +41,14 @@ public interface RefreshTokenRepository { Optional findByRefreshId(String refreshId); /** - * Atomic mark-and-insert: marks {@code parentRefreshId} used iff still fresh AND inserts the + * Atomic mark-and-insert: marks {@code parentRefreshId} used iff still fresh and inserts the * supplied {@code successor} row, as a single atomic operation. Returns {@code true} iff the * parent was fresh and the successor was inserted; {@code false} iff the parent was already used, * revoked, expired, or absent (no successor row created). * - *

      Load-bearing replay defense. The atomicity is the entire point — a - * non-atomic sequence (mark, then insert) has a window where another concurrent rotator can call - * {@link #revokeFamily} between the two and miss the newly-inserted successor. Backend - * implementations MUST use: + *

      Load-bearing replay defence: the atomicity is the entire point; a non-atomic sequence (mark, + * then insert) has a window where another concurrent rotator can call {@link #revokeFamily} + * between the two and miss the newly-inserted successor. Backend implementations must use: * *

        *
      • JDBI: {@code jdbi.inTransaction(handle -> {...})} wrapping a conditional {@code UPDATE} @@ -60,8 +59,8 @@ public interface RefreshTokenRepository { *
      * *

      The predicate "fresh" means: {@code used_at IS NULL AND revoked_at IS NULL AND expires_at > - * :now}. On {@code false}, the service triggers a family-wide scorch — that revoke runs - * outside the failed rotation's scope so it commits regardless. + * :now}. On {@code false}, the service triggers a family-wide scorch; that revoke runs outside + * the failed rotation's scope so it commits regardless. * * @param parentRefreshId the {@code refreshId} of the token being rotated; its row must exist * @param now timestamp to write into {@code used_at} on success @@ -72,7 +71,7 @@ public interface RefreshTokenRepository { boolean rotateAtomically(String parentRefreshId, Instant now, RefreshTokenRecord successor); /** - * Revokes every unrevoked row in {@code familyId} with the supplied reason. Idempotent — + * Revokes every unrevoked row in {@code familyId} with the supplied reason. Idempotent; * already-revoked rows are left untouched (their original {@code revoked_reason} is preserved for * forensic visibility). Returns the number of rows newly marked revoked. */ @@ -89,14 +88,14 @@ public interface RefreshTokenRepository { List findByUserHandle(UserHandle userHandle); /** - * Lists every refresh-token row in {@code familyId}. Intended for tests and forensic inspection — - * production code paths normally don't need this. + * Lists every refresh-token row in {@code familyId}. Intended for tests and forensic inspection; + * production code paths normally do not need this. */ List findByFamilyId(String familyId); /** * Operator cleanup hook: deletes rows whose retention window has elapsed. Implementations should - * remove rows where {@code expires_at < cutoff} AND (the row is consumed or revoked before {@code + * remove rows where {@code expires_at < cutoff} and (the row is consumed or revoked before {@code * cutoff}). Returns the number of rows deleted. Documented in {@code docs/operator-guide.md}. */ int deleteExpiredBefore(Instant cutoff); diff --git a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/web/RefreshHandler.java b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/web/RefreshHandler.java index 4b864820..85a08296 100644 --- a/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/web/RefreshHandler.java +++ b/pk-auth-refresh-tokens/src/main/java/com/codeheadsystems/pkauth/refresh/web/RefreshHandler.java @@ -10,8 +10,8 @@ /** * Framework-neutral helper that turns a presented refresh token into either a {@link * RefreshResponse} (success) or a {@link RefreshErrorResponse} (any of the typed failures). Each - * adapter's HTTP layer wraps this — Spring's controller, Dropwizard's resource, Micronaut's - * controller — so the rotation logic and error mapping live in one place. + * adapter's HTTP layer wraps this (Spring's controller, Dropwizard's resource, Micronaut's + * controller) so the rotation logic and error mapping live in one place. * * @since 1.1.0 */ diff --git a/pk-auth-refresh-tokens/src/main/java/module-info.java b/pk-auth-refresh-tokens/src/main/java/module-info.java index bde9d138..0b3f8d96 100644 --- a/pk-auth-refresh-tokens/src/main/java/module-info.java +++ b/pk-auth-refresh-tokens/src/main/java/module-info.java @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT /** - * pk-auth refresh-tokens module: rotating opaque tokens with family-based replay defense. + * pk-auth refresh-tokens module: rotating opaque tokens with family-based replay defence. * * @since 1.1.0 */ diff --git a/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshTokenServiceTest.java b/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshTokenServiceTest.java index a5acaa58..776657e1 100644 --- a/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshTokenServiceTest.java +++ b/pk-auth-refresh-tokens/src/test/java/com/codeheadsystems/pkauth/refresh/RefreshTokenServiceTest.java @@ -22,8 +22,8 @@ /** * Drives the shared {@link RefreshTokenScenarios} (so {@code RefreshTokenService} coverage is * attributed to this module and its JaCoCo gate is actually enforced) plus the service-level edge - * cases the cross-backend parity scenarios don't reach: wire-format parsing, the deprecated default - * {@code amr} overload, argument validation, revoked-vs-replayed disambiguation, and the + * cases the cross-backend parity scenarios do not reach: wire-format parsing, the deprecated + * default {@code amr} overload, argument validation, revoked-vs-replayed disambiguation, and the * deterministic-RNG constructor. */ class RefreshTokenServiceTest { 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 1c3bdb1e..8c354dc1 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 @@ -24,7 +24,7 @@ * *

      The {@link ConditionalOnClass} guard uses the class-name string form so this autoconfig can be * loaded without {@code pk-auth-admin-api} present without triggering {@code NoClassDefFoundError} - * on the {@code @Bean} return types — Spring resolves the class names lazily. + * on the {@code @Bean} return types; Spring resolves the class names lazily. */ @AutoConfiguration(after = PkAuthAutoConfiguration.class) @ConditionalOnClass(name = "com.codeheadsystems.pkauth.admin.AdminService") @@ -38,7 +38,7 @@ public AdminAuthorizer pkAuthAdminAuthorizer() { /** * 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 + * 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}). */ 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 563e773c..1b6fc2fa 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 @@ -61,20 +61,20 @@ *

        *
      • Core service ({@code PasskeyAuthenticationService}) via the {@link PkAuthComposition} * framework-neutral wiring helper. - *
      • SPI implementations — {@code CredentialRepository} / {@code UserLookup} / {@code + *
      • SPI implementations: {@code CredentialRepository} / {@code UserLookup} / {@code * ChallengeStore} / {@code BackupCodeRepository} / {@code OtpRepository}. Each is provided - * only when no host-app bean of the same type exists ({@link ConditionalOnMissingBean}) AND - * the host has explicitly opted in to the in-memory testkit defaults by setting {@code + * only when no host-app bean of the same type exists ({@link ConditionalOnMissingBean}) and + * the host has opted in to the in-memory testkit defaults by setting {@code * pkauth.dev-mode=true}. Without that flag, a host that fails to declare its own SPI beans - * will fail to start — preventing accidental production deploys backed by single-JVM, + * fails to start, which prevents accidental production deploys backed by single-JVM, * non-persistent storage. Host apps that want JDBI or DynamoDB declare those beans themselves * (typically in their own {@code @Configuration}). *
      • JWT issuer + validator backed by {@link JwtKeyset#hs256(byte[])}. {@code pkauth.jwt.secret} - * is required — there is no random-key fallback. Hosts that need ES256 supply a {@code + * is required; there is no random-key fallback. Hosts that need ES256 supply a {@code * JwtKeyset} bean themselves. *
      • Default sender beans ({@link LoggingEmailSender}, {@link LoggingSmsSender}) so the alt-flow - * services compose. Per brief §12 #7, "Don't write production-quality email/SMS senders" — - * the logging shims are deliberate. + * services compose. Per brief §12 #7, "Don't write production-quality email/SMS senders", so + * only logging shims are provided. *
      * *

      Web-tier wiring (the ceremony controller, JWT filter, admin controller) lives in {@link @@ -172,8 +172,8 @@ public OtpRepository pkAuthOtpRepository() { } /** - * Default in-memory ceremony rate limiter. Hosts MUST replace this with a shared (Redis / - * DB-backed) {@link CeremonyRateLimiter} bean in multi-replica deployments — see {@link + * Default in-memory ceremony rate limiter. Hosts must replace this with a shared (Redis / + * DB-backed) {@link CeremonyRateLimiter} bean in multi-replica deployments; see {@link * InMemoryCeremonyRateLimiter}'s class javadoc. * * @since 0.9.1 @@ -281,7 +281,7 @@ public UserDeletionListener pkAuthCredentialRepositoryDeletionListener( return new CredentialRepositoryDeletionListener(repository); } - /** Listener: deletes every backup code owned by the user — only when a repository is wired. */ + /** Listener: deletes every backup code owned by the user; only when a repository is wired. */ @Bean @ConditionalOnBean(BackupCodeRepository.class) public UserDeletionListener pkAuthBackupCodeRepositoryDeletionListener( @@ -289,7 +289,7 @@ public UserDeletionListener pkAuthBackupCodeRepositoryDeletionListener( return new BackupCodeRepositoryDeletionListener(repository); } - /** Listener: deletes every OTP row owned by the user — only when a repository is wired. */ + /** Listener: deletes every OTP row owned by the user; only when a repository is wired. */ @Bean @ConditionalOnBean(OtpRepository.class) public UserDeletionListener pkAuthOtpRepositoryDeletionListener(OtpRepository repository) { @@ -307,7 +307,7 @@ public UserDeletionListener pkAuthAccessTokenStoreDeletionListener(AccessTokenSt /** * Collects every {@link UserDeletionListener} bean and wires the fan-out service. Hosts can * register additional listeners by declaring their own {@code @Bean UserDeletionListener - * myCustomListener(...)} — Spring auto-collects all beans of the interface type. + * myCustomListener(...)}; Spring auto-collects all beans of the interface type. */ @Bean @ConditionalOnMissingBean @@ -332,7 +332,7 @@ public RefreshTokenConfig pkAuthRefreshTokenConfig(PkAuthProperties props) { refresh.defaultTtl(), refresh.ttlsByAudience(), refresh.cleanupRetention()); } - /** {@link RefreshTokenService} bean — only when a {@link RefreshTokenRepository} is wired. */ + /** {@link RefreshTokenService} bean; only when a {@link RefreshTokenRepository} is wired. */ @Bean @ConditionalOnMissingBean @ConditionalOnBean(RefreshTokenRepository.class) @@ -361,13 +361,13 @@ public RefreshHandler pkAuthRefreshHandler( // -- Alt-flow services ----------------------------------------------------------------------- /** - * Logging email/SMS senders are dev-only: they write the full message body — which contains the - * magic-link token or OTP code — to the application log. Gating them behind {@code + * Logging email/SMS senders are dev-only: they write the full message body (which contains the + * 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}) 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 + * 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 diff --git a/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/autoconfigure/PkAuthDynamoDbPersistenceAutoConfiguration.java b/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/autoconfigure/PkAuthDynamoDbPersistenceAutoConfiguration.java index 3c6fdd99..c22d03f4 100644 --- a/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/autoconfigure/PkAuthDynamoDbPersistenceAutoConfiguration.java +++ b/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/autoconfigure/PkAuthDynamoDbPersistenceAutoConfiguration.java @@ -26,13 +26,13 @@ *

        *
      • {@code pk-auth-persistence-dynamodb} is on the classpath, *
      • {@link DynamoDbEnhancedClient}, {@link DynamoDbClient}, and {@link PkAuthDynamoTables} are - * host-supplied beans (the AWS SDK is bring-your-own — we never construct the client because - * it owns credentials and region). + * host-supplied beans (the AWS SDK is bring-your-own; the starter never constructs the client + * because it owns credentials and region). *
      * *

      Per brief §6.10, "if both jdbi and dynamodb are on the classpath, jdbi wins". This autoconfig * implements that as: {@link PkAuthJdbiPersistenceAutoConfiguration} runs first ({@code before = - * PkAuthAutoConfiguration.class}); its beans register and we skip ours via {@code + * PkAuthAutoConfiguration.class}); its beans register and this autoconfig skips its own via {@code * ConditionalOnMissingBean}. */ @AutoConfiguration(after = PkAuthJdbiPersistenceAutoConfiguration.class) diff --git a/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/autoconfigure/PkAuthJdbiPersistenceAutoConfiguration.java b/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/autoconfigure/PkAuthJdbiPersistenceAutoConfiguration.java index f6e3c5ff..5a87898e 100644 --- a/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/autoconfigure/PkAuthJdbiPersistenceAutoConfiguration.java +++ b/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/autoconfigure/PkAuthJdbiPersistenceAutoConfiguration.java @@ -23,14 +23,14 @@ * *

        *
      • {@code pk-auth-persistence-jdbi} is on the classpath, and - *
      • a {@link Jdbi} bean is provided by the host application (we do not own the datasource — the - * brief leaves connection pool and migration ownership to the host). + *
      • a {@link Jdbi} bean is provided by the host application (the starter does not own the + * datasource; the brief leaves connection pool and migration ownership to the host). *
      * - *

      This autoconfig is loaded before {@link PkAuthAutoConfiguration} so the JDBI beans - * register first; the testkit in-memory defaults then back off via {@link - * ConditionalOnMissingBean}. If both JDBI and DynamoDB modules are on the classpath the host app - * picks the winner by which underlying client bean it supplies — see the README for the convention. + *

      This autoconfig is loaded before {@link PkAuthAutoConfiguration} so the JDBI beans register + * first; the testkit in-memory defaults then back off via {@link ConditionalOnMissingBean}. If both + * JDBI and DynamoDB modules are on the classpath the host app picks the winner by which underlying + * client bean it supplies; see the README for the convention. */ @AutoConfiguration(before = PkAuthAutoConfiguration.class) @ConditionalOnClass({Jdbi.class, JdbiCredentialRepository.class}) diff --git a/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/autoconfigure/PkAuthWebAutoConfiguration.java b/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/autoconfigure/PkAuthWebAutoConfiguration.java index db39b7e1..25f4413c 100644 --- a/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/autoconfigure/PkAuthWebAutoConfiguration.java +++ b/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/autoconfigure/PkAuthWebAutoConfiguration.java @@ -39,10 +39,10 @@ *

        *
      • permits every {@code /auth/passkeys/**} request (the ceremony itself is the authentication; * it cannot require authentication to start), - *
      • permits {@code /auth/admin/email/complete-verification} (brief §6.9 — unauthenticated + *
      • permits {@code /auth/admin/email/complete-verification} (brief §6.9: unauthenticated * because the magic-link token identifies the user), *
      • requires authentication for all other {@code /auth/admin/**} requests, - *
      • disables CSRF (stateless JWT — see ADR 0005), + *
      • disables CSRF (stateless JWT; see ADR 0005), *
      • disables form login and HTTP basic. *
      * @@ -59,10 +59,10 @@ public class PkAuthWebAutoConfiguration { private static final Logger LOG = LoggerFactory.getLogger(PkAuthWebAutoConfiguration.class); /** - * Register the pk-auth Jackson 3 module on Spring's autoconfigured {@code ObjectMapper}. Spring - * Boot 4 standardized on Jackson 3 ({@code tools.jackson.*}), the same namespace pk-auth-core's + * Registers the pk-auth Jackson 3 module on Spring's autoconfigured {@code ObjectMapper}. Spring + * Boot 4 standardised on Jackson 3 ({@code tools.jackson.*}), the same namespace pk-auth-core's * ObjectMapper uses (ADR 0009); the core's {@link PkAuthObjectMappers#pkAuthModule()} supplies - * the byte[] / UserHandle / ChallengeId (de)serializers, so Spring's mapper produces the same + * the byte[] / UserHandle / ChallengeId (de)serialisers, so Spring's mapper produces the same * base64url-no-padding wire format the testkit and core mapper use. Spring picks up every {@code * tools.jackson.databind.module.JacksonModule} bean automatically. */ @@ -78,12 +78,12 @@ public PkAuthJwtAuthenticationFilter pkAuthJwtAuthenticationFilter(PkAuthJwtVali } /** - * Keep the JWT filter out of the global servlet filter chain. Because {@link + * Keeps the JWT filter out of the global servlet filter chain. Because {@link * #pkAuthJwtAuthenticationFilter} is exposed as a top-level {@link * org.springframework.web.filter.OncePerRequestFilter} bean, Spring Boot would otherwise - * auto-register it with the servlet container so it runs on every request path — meaning - * a valid pk-auth JWT would populate the {@code SecurityContext} application-wide, even outside - * {@code /auth/**}. Disabling the registration confines the filter to where we wire it + * auto-register it with the servlet container so it runs on every request path, meaning a valid + * pk-auth JWT would populate the {@code SecurityContext} application-wide, even outside {@code + * /auth/**}. Disabling the registration confines the filter to where this configuration wires it * explicitly: inside {@link #pkAuthSecurityFilterChain} via {@code addFilterBefore}. * * @param filter the JWT authentication filter bean to suppress from global registration @@ -101,13 +101,13 @@ public PkAuthJwtAuthenticationFilter pkAuthJwtAuthenticationFilter(PkAuthJwtVali } /** - * Pin the pk-auth {@code /auth/**} chain to an early order. Spring Security consults {@link + * Pins the pk-auth {@code /auth/**} chain to an early order. Spring Security consults {@link * SecurityFilterChain} beans in {@link Order} sequence and the first whose matcher matches wins - * exclusively. A host app that defines its own chain — especially a broad catch-all with no - * {@code securityMatcher}, which matches everything — at the default (lowest) precedence would - * otherwise swallow {@code /auth/**} before this chain is consulted, silently disabling pk-auth's - * rules on the public ceremony/refresh endpoints. Running near the highest precedence ensures our - * narrow {@code /auth/**} matcher is evaluated first. + * exclusively. A host app that defines its own chain (especially a broad catch-all with no {@code + * securityMatcher}, which matches everything) at the default (lowest) precedence would otherwise + * swallow {@code /auth/**} before this chain is consulted, silently disabling pk-auth's rules on + * the public ceremony/refresh endpoints. Running near the highest precedence ensures the narrow + * {@code /auth/**} matcher is evaluated first. */ @Bean @Order(Ordered.HIGHEST_PRECEDENCE + 10) @@ -158,10 +158,10 @@ public CeremonyOrchestrator pkAuthCeremonyOrchestrator( } /** - * Ceremony controller. Spring component-scan doesn't run on starter packages, so the bean is + * Ceremony controller. Spring component-scan does not run on starter packages, so the bean is * registered here (Spring MVC maps any {@code @RestController} bean, however it was defined) and - * host apps don't need a {@code @ComponentScan}. A host-supplied {@link PkAuthCeremonyController} - * bean replaces this default. + * host apps do not need a {@code @ComponentScan}. A host-supplied {@link + * PkAuthCeremonyController} bean replaces this default. */ @Bean @ConditionalOnMissingBean @@ -170,7 +170,7 @@ public PkAuthCeremonyController pkAuthCeremonyController(CeremonyOrchestrator or } /** - * Refresh controller — only mounted when a {@link RefreshHandler} is present (which itself + * Refresh controller; only mounted when a {@link RefreshHandler} is present (which itself * requires a {@code RefreshTokenRepository} bean wired by the host). * * @since 1.1.0 @@ -184,8 +184,8 @@ public PkAuthRefreshController pkAuthRefreshController(RefreshHandler handler) { /** * Maps SPI {@link com.codeheadsystems.pkauth.spi.PkAuthPersistenceException} to a stable {@code - * 503} JSON body — see the controller-advice class for the wire shape. Registered explicitly - * because Spring Boot starters don't run component-scan on the starter's own packages. + * 503} JSON body; see the controller-advice class for the wire shape. Registered explicitly + * because Spring Boot starters do not run component-scan on the starter's own packages. */ @Bean @ConditionalOnMissingBean 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 1d40c3a4..0343dd6c 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 @@ -27,7 +27,7 @@ * @param otp OTP service tunables (optional) * @param devMode {@code true} to enable in-memory testkit SPIs and logging email/SMS senders, plus * auto-generation of a per-startup OTP pepper when {@code otp.pepper} is unset. Defaults to - * {@code false} — production deployments must supply real SPI beans, real senders, and a + * {@code false}; production deployments must supply real SPI beans, real senders, and a * configured pepper. {@code @since 0.9.1} */ @ConfigurationProperties("pkauth") @@ -41,8 +41,8 @@ public record PkAuthProperties( /** * Normalises the optional blocks ({@code ceremony}, {@code otp}, {@code refresh}) to their - * defaults so callers don't have to null-check. Required blocks ({@code relyingParty}, {@code - * jwt}) are left as the framework bound them — if absent, downstream wiring fails fast with a + * defaults so callers do not have to null-check. Required blocks ({@code relyingParty}, {@code + * jwt}) are left as the framework bound them; if absent, downstream wiring fails fast with a * clear message. */ public PkAuthProperties { @@ -58,7 +58,7 @@ public record PkAuthProperties( } /** - * Relying-party identity. All three fields are required (no defaults) — set {@code + * Relying-party identity. All three fields are required (no defaults); set {@code * pkauth.relying-party.id}, {@code .name}, and at least one {@code .origins[]} value. * * @param id WebAuthn relying-party id (typically the registrable domain, e.g. {@code @@ -70,8 +70,8 @@ public record RelyingParty(String id, String name, Set origins) {} /** * JWT issuance and validation. {@code issuer}, {@code audience}, and {@code secret} are all - * required — there is no random-key fallback. Only HS256 is configurable from properties; - * adapters needing ES256 wire a {@code JwtKeyset} bean explicitly. + * required; there is no random-key fallback. Only HS256 is configurable from properties; adapters + * needing ES256 wire a {@code JwtKeyset} bean explicitly. * * @param issuer the {@code iss} claim (required) * @param audience the default {@code aud} claim, used when {@link @@ -93,11 +93,11 @@ public record Jwt( /** * Ceremony tunables forwarded to {@code CeremonyConfig}. Every field is optional; any left unset - * falls back to {@link com.codeheadsystems.pkauth.config.CeremonyConfig#defaults()} — notably + * 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. + * UV-less security keys) must be an explicit host choice. * * @param challengeTtl how long an issued challenge remains valid (default 5 minutes) * @param userVerification WebAuthn UV requirement (default {@code REQUIRED}) @@ -108,7 +108,7 @@ public record Jwt( * RS256); must be a subset of {@code acceptedAlgorithms} * @param acceptedAlgorithms COSE algorithms accepted on registration verify (default the * historical union ES256, EdDSA, RS256, ES384, RS384); narrowing this can reject - * already-registered credentials, so change deliberately + * already-registered credentials, so narrowing is an explicit host choice * @since 2.0.0 */ public record Ceremony( @@ -130,7 +130,7 @@ public static Ceremony defaults() { * * @param pepper Base64-encoded server-side HMAC pepper for OTP code hashing. Decoded bytes must * be at least 16 bytes (32+ recommended). Required in production. When unset, the starter - * will only auto-generate a per-startup random pepper if {@code pkauth.dev-mode=true}. A + * auto-generates a per-startup random pepper only if {@code pkauth.dev-mode=true}. A * per-startup pepper invalidates outstanding OTPs across restarts and across cluster * instances, which is unsafe in production. */ @@ -146,7 +146,7 @@ public static Otp defaults() { * present in the application context (the JDBI and DynamoDB persistence modules each provide * one). * - * @param defaultTtl how long an issued refresh token lasts when its audience isn't listed in + * @param defaultTtl how long an issued refresh token lasts when its audience is not listed in * {@code ttlsByAudience}. Null defaults to {@code 14d}. * @param ttlsByAudience per-audience refresh TTL overrides (e.g. {@code web=PT336H, * cli=PT2160H}). Empty/null means every audience uses {@code defaultTtl}. diff --git a/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/security/PkAuthJwtAuthenticationFilter.java b/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/security/PkAuthJwtAuthenticationFilter.java index 2adf08fe..b2e92929 100644 --- a/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/security/PkAuthJwtAuthenticationFilter.java +++ b/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/security/PkAuthJwtAuthenticationFilter.java @@ -17,29 +17,29 @@ * Validates the {@code Authorization: Bearer …} header against {@link PkAuthJwtValidator} and, on * success, populates the {@link SecurityContextHolder} with a {@link PkAuthJwtAuthenticationToken}. * - *

      The filter is intentionally permissive: it does not reject a request that has - * no token or an invalid token. Endpoints that require authentication enforce that via Spring - * Security's {@code authorizeHttpRequests} chain (configured in the autoconfiguration). Brief §6.10 - * mentions an explicit goal of "JWT validation filter that produces a {@code - * PkAuthJwtAuthenticationToken}" — that is exactly what we do here, no more. + *

      The filter is permissive: it does not reject a request that has no token or an invalid token. + * Endpoints that require authentication enforce that via Spring Security's {@code + * authorizeHttpRequests} chain (configured in the autoconfiguration). Brief §6.10 mentions an + * explicit goal of "JWT validation filter that produces a {@code PkAuthJwtAuthenticationToken}"; + * the filter does that and nothing more. * - *

      Header-only by design. Only the {@code Authorization: Bearer …} header is consulted. - * Cookies are never read for authentication, which is why the pk-auth filter chain disables CSRF — - * that posture is correct only as long as no auth path treats a cookie as proof of identity. Do not - * add a cookie fallback here; host apps that want session-cookie auth should layer their own filter - * ahead of this one and accept the CSRF responsibilities that come with it. + *

      Only the {@code Authorization: Bearer …} header is consulted. Cookies are never read for + * authentication, which is why the pk-auth filter chain disables CSRF. That posture is correct only + * as long as no auth path treats a cookie as proof of identity. A cookie fallback does not belong + * in this filter; host apps that want session-cookie auth should layer their own filter ahead of + * this one and accept the CSRF responsibilities that come with it. * - *

      Additive, not authoritative-clearing. The filter only ever sets the - * authentication on a successful token; it never clears a pre-existing one on an invalid - * or absent token. Under the shipped stateless chain ({@code SessionCreationPolicy.STATELESS}) the - * context starts empty every request, so this is moot. But a host that layers a - * session-establishing filter ahead of this one would let a request bearing a valid - * session cookie plus a bogus or expired bearer keep the session identity. If you combine the two, - * clear the context on a non-{@code Success} result here, or order this filter first. + *

      The filter is additive: it only ever sets the authentication on a successful token and never + * clears a pre-existing one on an invalid or absent token. Under the shipped stateless chain + * ({@code SessionCreationPolicy.STATELESS}) the context starts empty every request, so this is + * moot. A host that layers a session-establishing filter ahead of this one would let a request + * bearing a valid session cookie plus a bogus or expired bearer keep the session identity. A host + * that combines the two should clear the context on a non-{@code Success} result, or order this + * filter first. * - *

      Design note. This filter intentionally bypasses Spring's {@code AuthenticationManager} - * for zero-overhead JWT validation. There is no companion {@code AuthenticationProvider} — host - * apps that need the canonical manager pipeline can declare their own filter + provider. + *

      This filter bypasses Spring's {@code AuthenticationManager} for zero-overhead JWT validation. + * There is no companion {@code AuthenticationProvider}; host apps that need the canonical manager + * pipeline can declare their own filter + provider. */ public final class PkAuthJwtAuthenticationFilter extends OncePerRequestFilter { diff --git a/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/security/PkAuthJwtAuthenticationToken.java b/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/security/PkAuthJwtAuthenticationToken.java index 7b319a48..c5850254 100644 --- a/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/security/PkAuthJwtAuthenticationToken.java +++ b/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/security/PkAuthJwtAuthenticationToken.java @@ -14,9 +14,8 @@ * {@link UserHandle}; the underlying {@link JwtClaims} is available via {@link #getClaims()} for * downstream controllers that want to inspect the authentication method or credential id. * - *

      This is intentionally permissive — the filter sets it once the JWT signature and standard - * claims (iss / aud / exp) have been validated by {@link com.codeheadsystems.pkauth.jwt - * .PkAuthJwtValidator}. + *

      This token is permissive: the filter sets it once the JWT signature and standard claims (iss / + * aud / exp) have been validated by {@link com.codeheadsystems.pkauth.jwt .PkAuthJwtValidator}. */ public final class PkAuthJwtAuthenticationToken extends AbstractAuthenticationToken { diff --git a/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/web/PkAuthExceptionHandler.java b/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/web/PkAuthExceptionHandler.java index d00591eb..bcff5185 100644 --- a/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/web/PkAuthExceptionHandler.java +++ b/pk-auth-spring-boot-starter/src/main/java/com/codeheadsystems/pkauth/spring/web/PkAuthExceptionHandler.java @@ -13,7 +13,7 @@ /** * Maps the SPI exception contract (see {@link * com.codeheadsystems.pkauth.spi.PkAuthPersistenceException}) to a stable {@code 503 Service - * Unavailable} response with a {@code {"error": "persistence_failure", "operation": "..."}} body — + * Unavailable} response with a {@code {"error": "persistence_failure", "operation": "..."}} body, * the same envelope used by the admin and ceremony error responses. This means a host-side DB * outage surfaces consistently to the {@code @pk-auth/passkeys-browser} SDK instead of leaking * framework-default 500 HTML. 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 629d9864..77b61d56 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 @@ -24,14 +24,14 @@ import org.springframework.boot.test.context.runner.ApplicationContextRunner; /** - * Guards the production-safety invariant: the testkit's in-memory SPI defaults must NOT activate + * Guards the production-safety invariant: the testkit's in-memory SPI defaults must not activate * unless the host opts in via {@code pkauth.dev-mode=true}. Without the flag, a host that forgets * to declare persistence beans should fail to start rather than silently boot against per-JVM * in-memory storage. * *

      Required adapter config (RP id/name/origins, JWT issuer/audience/secret) is set explicitly on - * every case so we reach the dev-mode guard rather than tripping the new fail-fast guards on those - * properties. + * every case so the test reaches the dev-mode guard rather than tripping the new fail-fast guards + * on those properties. */ class PkAuthDevModeGuardTest { diff --git a/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthDynamoDbPersistenceAutoConfigurationTest.java b/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthDynamoDbPersistenceAutoConfigurationTest.java index 8722a0b1..56514e57 100644 --- a/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthDynamoDbPersistenceAutoConfigurationTest.java +++ b/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthDynamoDbPersistenceAutoConfigurationTest.java @@ -11,14 +11,14 @@ /** * The DynamoDB autoconfig is only meaningfully exercised by an integration test that wires the AWS - * SDK against a DynamoDB Local container (deferred to the demo's tests in Phase 8.x). Here we - * verify the negative path: without the host-supplied beans the autoconfig backs off cleanly and - * leaves the in-memory testkit defaults from {@link + * SDK against a DynamoDB Local container (deferred to the demo's tests). The test verifies the + * negative path: without the host-supplied beans the autoconfig backs off cleanly and leaves the + * in-memory testkit defaults from {@link * com.codeheadsystems.pkauth.spring.autoconfigure.PkAuthAutoConfiguration} undisturbed. * *

      Activating the autoconfig with mock clients would crash inside the DynamoDB SDK because the * persistence module's constructors immediately call {@code DynamoDbEnhancedClient.table(...)} to - * compute schema metadata — the mock has no real table backing it. The demo app exercises the + * compute schema metadata; the mock has no real table backing it. The demo app exercises the * positive path with a real DDB Local container. */ class PkAuthDynamoDbPersistenceAutoConfigurationTest { diff --git a/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthJdbiPersistenceAutoConfigurationTest.java b/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthJdbiPersistenceAutoConfigurationTest.java index 64097388..1c984647 100644 --- a/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthJdbiPersistenceAutoConfigurationTest.java +++ b/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthJdbiPersistenceAutoConfigurationTest.java @@ -17,8 +17,9 @@ /** * Verifies the JDBI persistence autoconfig activates iff a {@link Jdbi} bean is present, and - * registers every SPI bean once activated. We don't run real queries — Jdbi is wired against an H2 - * datasource that's never touched, since the test only inspects the application context. + * registers every SPI bean once activated. The test does not run real queries; Jdbi is wired + * against an H2 datasource that is never touched, since the test only inspects the application + * context. */ class PkAuthJdbiPersistenceAutoConfigurationTest { diff --git a/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthTestApplication.java b/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthTestApplication.java index 50b58ded..daaa5bad 100644 --- a/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthTestApplication.java +++ b/pk-auth-spring-boot-starter/src/test/java/com/codeheadsystems/pkauth/spring/PkAuthTestApplication.java @@ -19,9 +19,9 @@ public class PkAuthTestApplication { /** - * Override the property-based default so the {@code FakeAuthenticator}'s {@code example.com} + * Overrides the property-based default so the {@code FakeAuthenticator}'s {@code example.com} * origin is on the allow-list. The starter's default uses {@code localhost:8080} which {@code - * FakeAuthenticator} doesn't sign for. + * FakeAuthenticator} does not sign for. */ @Bean @Primary @@ -30,7 +30,7 @@ public RelyingPartyConfig testRelyingPartyConfig() { } /** - * Wire an in-memory refresh-token repository so the {@code /auth/refresh} endpoint and the + * Wires an in-memory refresh-token repository so the {@code /auth/refresh} endpoint and the * matching deletion-fan-out listener activate in tests. Production hosts bind a real backend * (JDBI or DynamoDB). * diff --git a/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/AccessTokenStoreScenarios.java b/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/AccessTokenStoreScenarios.java index 542a46b5..ae36db2c 100644 --- a/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/AccessTokenStoreScenarios.java +++ b/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/AccessTokenStoreScenarios.java @@ -14,7 +14,8 @@ * InMemoryAccessTokenStoreTest}, JDBI integration tests, and DynamoDB integration tests so every * backend behaves identically. * - *

      Construct with a fresh, empty store and invoke each scenario from a {@code @Test} method. + *

      Each scenario runs from a {@code @Test} method against a fresh, empty store passed to the + * constructor. * * @since 1.1.0 */ @@ -39,7 +40,9 @@ public void recordThenExistsThenDelete() { assertThat(store.delete(user, "jti-A")).isFalse(); } - /** Ownership mismatch is a silent no-op — caller can't probe for jti existence across users. */ + /** + * Ownership mismatch is a silent no-op; the caller cannot probe for jti existence across users. + */ public void deleteRejectsForeignOwner() { UserHandle alice = UserHandle.of(new byte[] {1}); UserHandle bob = UserHandle.of(new byte[] {2}); diff --git a/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/BackupCodeRepositoryScenarios.java b/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/BackupCodeRepositoryScenarios.java index 251e4647..cdfcd3d6 100644 --- a/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/BackupCodeRepositoryScenarios.java +++ b/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/BackupCodeRepositoryScenarios.java @@ -18,10 +18,10 @@ /** * Backend-agnostic acceptance scenarios for {@link BackupCodeRepository}, focused on the atomic - * single-use ({@code consume}) contract. Run against the in-memory testkit repo and every real - * backend (JDBI, DynamoDB) so the double-spend guarantee — a recovery code can be redeemed at most - * once — cannot regress in one implementation while passing in another. Mirrors {@link - * RefreshTokenScenarios}'s concurrent rotation race for the refresh-token path. + * single-use ({@code consume}) contract. The scenarios run against the in-memory testkit repo and + * every real backend (JDBI, DynamoDB) so the double-spend guarantee (a recovery code can be + * redeemed at most once) cannot regress in one implementation while passing in another. Mirrors + * {@link RefreshTokenScenarios}'s concurrent rotation race for the refresh-token path. * * @since 2.0.0 */ 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 26cfc72b..e3099752 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 @@ -22,12 +22,13 @@ import java.util.Optional; /** - * Shared ceremony scenarios used as the acceptance bar for Phase 3 (in-memory) and Phase 5 (JDBI, - * DynamoDB). Persistence modules drive a real backend through the same flow that {@code + * Shared ceremony scenarios used as the acceptance bar for the in-memory, JDBI, and DynamoDB + * backends. Persistence modules drive a real backend through the same flow that {@code * DefaultPasskeyAuthenticationService} runs in production. * - *

      Construct with a {@link PasskeyAuthenticationService}, a {@link FakeAuthenticator}, and direct - * access to the repositories the service was built from. Then invoke the named scenarios. + *

      The constructor takes a {@link PasskeyAuthenticationService}, a {@link FakeAuthenticator}, and + * direct access to the repositories the service was built from. The named scenarios are then + * invoked. */ public final class CeremonyScenarios { @@ -84,7 +85,7 @@ public void registrationThenAssertionBumpsSignCount() { /** * Usernameless flow: startAuthentication with a null username returns options with an empty - * {@code allowCredentials} list (never {@code null} — account-enumeration guard); the + * {@code allowCredentials} list (never {@code null}; account-enumeration guard); the * FakeAuthenticator picks the sole registered credential and assertion succeeds. */ public void usernamelessFlowSucceedsWithSingleCredential() { @@ -116,7 +117,7 @@ public UserHandle register() { /** * 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 + * 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). */ diff --git a/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/ChallengeStoreScenarios.java b/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/ChallengeStoreScenarios.java index ff1f2915..70d9ab78 100644 --- a/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/ChallengeStoreScenarios.java +++ b/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/ChallengeStoreScenarios.java @@ -20,12 +20,12 @@ import java.util.concurrent.atomic.AtomicInteger; /** - * Shared compliance scenarios for {@link ChallengeStore} implementations — the single-use contract + * Shared compliance scenarios for {@link ChallengeStore} implementations: the single-use contract * every backend (in-memory, JDBI, DynamoDB) must honour. {@link ChallengeStore#takeOnce} is the - * load-bearing primitive behind WebAuthn replay defense: a challenge must be consumable exactly + * load-bearing primitive behind WebAuthn replay defence: a challenge must be consumable exactly * once, even under concurrent finish requests, or an assertion could be replayed. * - *

      Drive these from each backend's test class (passing a fresh, empty store) so the atomic + *

      Each backend's test class drives these scenarios with a fresh, empty store so the atomic * read-and-remove contract is verified identically everywhere, not just asserted single-threaded. * * @since 1.3.0 @@ -50,9 +50,9 @@ public void takeOnceConsumesExactlyOnce() { } /** - * The non-negotiable atomicity test: N threads call {@link ChallengeStore#takeOnce} on the same - * id simultaneously and exactly one must receive the record. A non-atomic read-then-delete would - * let two threads both observe and "consume" the same challenge, enabling assertion replay. + * The atomicity test: N threads call {@link ChallengeStore#takeOnce} on the same id + * simultaneously and exactly one must receive the record. A non-atomic read-then-delete would let + * two threads both observe and "consume" the same challenge, enabling assertion replay. */ public void concurrentTakeOnceYieldsExactlyOneWinner() throws Exception { ChallengeId id = new ChallengeId("tck-concurrent-take-once"); diff --git a/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/FakeAuthenticator.java b/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/FakeAuthenticator.java index df74c862..9a654d15 100644 --- a/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/FakeAuthenticator.java +++ b/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/FakeAuthenticator.java @@ -49,8 +49,8 @@ * *

      State is kept per-instance: each registration creates a new credential whose private key is * remembered for subsequent assertions. The authenticator increments its sign counter on each - * assertion; tests that want to exercise counter-regression behavior can set the counter explicitly - * via {@link Builder#forceSignCount(long)}. + * assertion; tests that want to exercise counter-regression behaviour can set the counter + * explicitly via {@link Builder#forceSignCount(long)}. */ public final class FakeAuthenticator { diff --git a/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/InMemoryEverything.java b/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/InMemoryEverything.java index 81c9e7f4..6c815ff0 100644 --- a/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/InMemoryEverything.java +++ b/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/InMemoryEverything.java @@ -12,9 +12,9 @@ /** * Fully wired test harness: an {@link InMemoryCredentialRepository}, {@link InMemoryUserLookup}, * {@link InMemoryChallengeStore}, a {@link FakeAuthenticator}, and a {@link - * PasskeyAuthenticationService} built from them via the Phase 2 factory. + * PasskeyAuthenticationService} built from them. * - *

      Construct via {@link #defaults()} for the brief's standard config, or {@link #builder()} for + *

      {@link #defaults()} constructs the brief's standard config, and {@link #builder()} gives * fine-grained control. */ public final class InMemoryEverything { diff --git a/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/InMemoryRefreshTokenRepository.java b/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/InMemoryRefreshTokenRepository.java index 22f40b5f..08705e61 100644 --- a/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/InMemoryRefreshTokenRepository.java +++ b/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/InMemoryRefreshTokenRepository.java @@ -15,7 +15,7 @@ /** * In-memory {@link RefreshTokenRepository}. Backed by a {@link ConcurrentHashMap}; the load-bearing - * {@link #rotateAtomically} primitive speculatively inserts the successor, then serializes the + * {@link #rotateAtomically} primitive speculatively inserts the successor, then serialises the * parent's mark-used via {@link java.util.concurrent.ConcurrentMap#compute}, rolling the successor * back if the parent was not fresh. Exactly one of N concurrent rotators of the same parent wins. * diff --git a/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/OtpRepositoryScenarios.java b/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/OtpRepositoryScenarios.java index 123c5062..ebcb7d9f 100644 --- a/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/OtpRepositoryScenarios.java +++ b/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/OtpRepositoryScenarios.java @@ -18,10 +18,10 @@ /** * Backend-agnostic acceptance scenarios for {@link OtpRepository}, focused on the atomic single-use - * ({@code consume}) contract. Run against the in-memory testkit repo and every real backend (JDBI, - * DynamoDB) so the double-spend guarantee — an OTP can be verified at most once — cannot regress in - * one implementation while passing in another. Mirrors {@link RefreshTokenScenarios}'s concurrent - * rotation race for the refresh-token path. + * ({@code consume}) contract. The scenarios run against the in-memory testkit repo and every real + * backend (JDBI, DynamoDB) so the double-spend guarantee (an OTP can be verified at most once) + * cannot regress in one implementation while passing in another. Mirrors {@link + * RefreshTokenScenarios}'s concurrent rotation race for the refresh-token path. * * @since 2.0.0 */ diff --git a/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/RefreshTokenScenarios.java b/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/RefreshTokenScenarios.java index ddcf6cb6..6923f967 100644 --- a/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/RefreshTokenScenarios.java +++ b/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/RefreshTokenScenarios.java @@ -28,12 +28,12 @@ import java.util.concurrent.TimeUnit; /** - * Shared parity scenarios for {@link RefreshTokenRepository} implementations. Drive every method - * from in-memory, JDBI, and DynamoDB test classes so all three backends honour the same contract — + * Shared parity scenarios for {@link RefreshTokenRepository} implementations. The in-memory, JDBI, + * and DynamoDB test classes drive every method so all three backends honour the same contract, * especially the load-bearing {@link #concurrentRotationExactlyOneSucceedsFamilyRevoked()} race - * test, which is the non-negotiable acceptance criterion from the plan. + * test, which is the acceptance criterion from the plan. * - *

      Construct with a fresh empty repository and a controllable clock. Each scenario is + *

      The constructor takes a fresh empty repository and a controllable clock. Each scenario is * self-contained and can run in isolation. * * @since 1.1.0 @@ -143,15 +143,15 @@ public void expiredTokenRotationReturnsExpired() { } } - /** A wire token whose refreshId doesn't match any row returns Unknown. */ + /** A wire token whose refreshId does not match any row returns Unknown. */ public void unknownRefreshIdReturnsUnknown() { assertThat(service.rotate("nonExistentRefreshId.aaaa")) .isInstanceOf(RotateResult.Unknown.class); } /** - * Presenting the right refreshId with the wrong secret returns Unknown — and crucially does NOT - * mark the legitimate token used. This is the hash-before-mark-used invariant from ADR 0013. + * Presenting the right refreshId with the wrong secret returns Unknown, and does not mark the + * legitimate token used. This is the hash-before-mark-used invariant from ADR 0013. */ public void wrongSecretReturnsUnknownAndDoesNotBurnLegitToken() { RefreshTokenPair root = service.issue(USER, AUDIENCE, Optional.empty(), AMR); @@ -188,10 +188,10 @@ public void revokeAllForUserRevokesEveryActiveFamily() { } /** - * The non-negotiable concurrent rotation race test. Eight threads all rotate the same root token - * simultaneously: exactly one must win and the rest see {@link RotateResult.Replayed}. The entire - * family — both the root AND any successor inserted by the winner — must end up revoked. Modeled - * on motif's {@code concurrent_sameSecret_exactlyOneSucceeds_familyRevoked}. + * The concurrent rotation race test. Eight threads all rotate the same root token simultaneously: + * exactly one must win and the rest see {@link RotateResult.Replayed}. The entire family (both + * the root and any successor inserted by the winner) must end up revoked. Modelled on motif's + * {@code concurrent_sameSecret_exactlyOneSucceeds_familyRevoked}. */ public void concurrentRotationExactlyOneSucceedsFamilyRevoked() throws Exception { RefreshTokenPair root = service.issue(USER, AUDIENCE, Optional.empty(), AMR); 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 d0936133..6c809f82 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 @@ -8,8 +8,8 @@ import org.junit.jupiter.api.Test; /** - * Phase 3 acceptance test: drive {@link CeremonyScenarios} against the in-memory implementations. - * The same scenarios are also driven against JDBI and DynamoDB backends in Phase 5. + * Acceptance test that drives {@link CeremonyScenarios} against the in-memory implementations. The + * same scenarios are also driven against the JDBI and DynamoDB backends. */ class FullCeremonyTest { diff --git a/site/fonts/README.md b/site/fonts/README.md index 2f297088..6a052436 100644 --- a/site/fonts/README.md +++ b/site/fonts/README.md @@ -1,8 +1,8 @@ # Self-hosted fonts All three families are licensed under the SIL Open Font License 1.1 -() and are vendored here as subset/variable WOFF2 -so the site makes no third-party (e.g. Google Fonts) network requests. +() and are vendored here as subset or variable WOFF2, so the site +makes no third-party network requests (for example, to Google Fonts). | File | Family | Source | |---|---|---| @@ -10,6 +10,5 @@ so the site makes no third-party (e.g. Google Fonts) network requests. | `inter-normal.woff2` | Inter (Rasmus Andersson) | | | `jetbrains-mono-normal.woff2` | JetBrains Mono | | -WOFF2 builds retrieved via the Fontsource distribution -(). Each upstream ships its own `OFL.txt`; retain -attribution if you redistribute. +The WOFF2 builds come from the Fontsource distribution (). Each upstream +ships its own `OFL.txt`, and redistribution retains that attribution. diff --git a/site/index.html b/site/index.html index 56f61350..a574d295 100644 --- a/site/index.html +++ b/site/index.html @@ -3,8 +3,8 @@ - pk-auth — Passkey-first authentication for the JVM - + pk-auth: Passkey-first authentication for the JVM + @@ -12,14 +12,14 @@ - - + + - - + + @@ -31,7 +31,7 @@ "@context": "https://schema.org", "@type": "SoftwareSourceCode", "name": "pk-auth", - "description": "Passkey-first WebAuthn authentication library for the JVM, with framework-neutral core and adapters for Spring Boot 4, Dropwizard 5, and Micronaut 4.", + "description": "Passkey-first WebAuthn authentication library for the JVM, with a framework-neutral core and adapters for Spring Boot 4, Dropwizard 5, and Micronaut 4.", "programmingLanguage": "Java", "license": "https://opensource.org/licenses/MIT", "codeRepository": "https://github.com/codeheadsystems/pk-auth", @@ -68,7 +68,7 @@ font-display: swap; src: url("fonts/jetbrains-mono-normal.woff2") format("woff2"); } - /* ============== PALETTE — LIGHT (warm parchment specimen) ============== */ + /* ============== PALETTE: LIGHT (warm parchment specimen) ============== */ :root { --paper: #F1E7CE; --paper-light: #F8F1DD; @@ -126,7 +126,7 @@ --s6: 36px; --s7: 56px; --s8: 88px; --s9: 128px; } - /* ============== PALETTE — DARK (lamplit study) ============== */ + /* ============== PALETTE: DARK (lamplit study) ============== */ :root[data-theme="dark"] { --paper: #221A12; --paper-light: #2B2118; @@ -579,7 +579,7 @@ .btn:hover .arrow { transform: translateX(3px); } .btn--down:hover .arrow { transform: translateY(3px); } - /* hero specimen seal — background watermark in the right gutter */ + /* hero specimen seal: background watermark in the right gutter */ .hero-seal { position: absolute; right: -40px; top: 64px; @@ -1042,9 +1042,9 @@

      -

      JDBI 3 + Postgres + Flyway. Migrations run automatically. Atomic-claim via conditional UPDATE ... WHERE consumed = FALSE.

      +

      JDBI 3 + Postgres + Flyway. The host runs the shipped migrations; no adapter runs them. Atomic-claim via conditional UPDATE ... WHERE consumed = FALSE.

      com.codeheadsystems:pk-auth-persistence-dynamodb DynamoDB
      -

      AWS SDK v2 Enhanced client, single physical table. Native ttl honored on challenges, OTPs, access tokens, and refresh tokens.

      +

      AWS SDK v2 Enhanced client, single physical table. Native ttl honoured on challenges, OTPs, access tokens, and refresh tokens.

      @@ -1525,7 +1527,7 @@

      Alternate flows

      com.codeheadsystems:pk-auth-refresh-tokens Sessions -

      Rotating refresh tokens with family-based replay defense. Adds POST /auth/refresh (one ceremony / one row per rotation) and needs a RefreshTokenRepository. See ADR 0013.

      +

      Rotating refresh tokens with family-based replay defence. Adds POST /auth/refresh (one ceremony, one row per rotation) and needs a RefreshTokenRepository. See ADR 0013.

      @@ -1554,7 +1556,7 @@

      Alternate flows

      § 005.E · 1 artifact + 1 npm -

      Admin & SDK

      +

      Admin and SDK

      Optional
      @@ -1563,14 +1565,14 @@

      Admin & SDK

      com.codeheadsystems:pk-auth-admin-api /auth/admin/**
      -

      Framework-neutral admin service: account summary, credential rename / delete (with last-credential guard), backup-code regeneration, email & phone verification.

      +

      Framework-neutral admin service: account summary, credential rename and delete (with last-credential guard), backup-code regeneration, and email and phone verification.

      npm:@pk-auth/passkeys-browser TypeScript
      -

      Zero-dependency ceremony + admin clients, on npm (npm install @pk-auth/passkeys-browser) — its version tracks the server release. ESM and CJS bundles. Handles all ArrayBuffer ↔ base64url wrangling around navigator.credentials. The refresh client returns a typed result sum — it never throws on 401.

      +

      Zero-dependency ceremony and admin clients, published on npm (npm install @pk-auth/passkeys-browser); the version tracks the server release. ESM and CJS bundles. Handles all ArrayBuffer ↔ base64url conversion around navigator.credentials. The refresh client returns a typed result sum and does not throw on 401.

      @@ -1589,7 +1591,7 @@

      Admin & SDK

      § 006wire contract
      -

      Same JSON. Three adapters.

      +

      One wire contract across three adapters

      Every adapter mounts the same paths under /auth/** and consumes the same JSON shapes. The TypeScript SDK is written against this @@ -1603,7 +1605,7 @@

      Ceremony · unauthenticated

      POST /auth/passkeys/registration/start - returns {challengeId, publicKey} — WebAuthn create() options + returns {challengeId, publicKey}, the WebAuthn create() options
      POST @@ -1613,7 +1615,7 @@

      Ceremony · unauthenticated

      POST /auth/passkeys/authentication/start - returns {challengeId, publicKey} — WebAuthn get() options + returns {challengeId, publicKey}, the WebAuthn get() options
      POST @@ -1623,7 +1625,7 @@

      Ceremony · unauthenticated

      POST /auth/refresh - rotates the refresh token; returns {refreshToken, accessToken, expiresAt}, or 401 {detail, reason} on replay. Mounted only with pk-auth-refresh-tokens + a bound RefreshTokenRepository. + rotates the refresh token; returns {refreshToken, accessToken, expiresAt}, or 401 {detail, reason} on replay. Mounted only with pk-auth-refresh-tokens and a bound RefreshTokenRepository.
      @@ -1646,11 +1648,11 @@

      Admin · bearer jwt required

      - Conventions. Bytes on the wire are base64url with no padding (RFC 4648 §5). - Every non-success response carries {"outcome": "<snake_case_code>"} — e.g. - origin_mismatch, challenge_expired, rate_limited (HTTP 429) — plus an optional - context field such as detail or reason. The full per-variant table lives in - DESIGN.md § 4. + Bytes on the wire are base64url with no padding (RFC 4648 §5). + Every non-success response carries {"outcome": "<snake_case_code>"}, for example + origin_mismatch, challenge_expired, or rate_limited (HTTP 429), plus an optional + context field such as detail or reason. + DESIGN.md § 4 holds the full per-variant table.
      @@ -1658,15 +1660,15 @@

      Admin · bearer jwt required

      -
      § 007security & maturity
      +
      § 007security and maturity
      -

      Defaults that refuse, not warn.

      +

      Strict security defaults

      Choices that change the security contract are explicit configuration. - Defaults are the strict path: rejection on origin mismatch, rejection on - counter regression, single-use challenges with a five-minute ceiling. - (The one deliberate exception is attestation, which defaults to none — - see the maturity note below.) The full STRIDE pass lives in the threat model. + Defaults take the strict path: rejection on origin mismatch, rejection on + counter regression, and single-use challenges with a five-minute ceiling. + Attestation is the exception, defaulting to none; the project maturity + note below describes it. The threat model holds the full STRIDE analysis.

      @@ -1674,7 +1676,7 @@

      Defaults that refuse, not warn.

      01 · origin

      Strict origin allow-list

      -

      Every finish call validates the client-reported origin against the configured allow-list. Mismatches return origin_mismatch — there is no “permit” mode.

      +

      Every finish call validates the client-reported origin against the configured allow-list. Mismatches return origin_mismatch; there is no “permit” mode.

      Threat model ↗
      @@ -1695,32 +1697,32 @@

      Argon2id everywhere

      05 · lockout

      Last-credential guard

      -

      Deleting the last passkey when no backup codes remain returns 409 Conflict (by default). Backup codes are the documented recovery path; encourage users to enroll a second passkey before removing the first.

      +

      Deleting the last passkey when no backup codes remain returns 409 Conflict (by default). Backup codes are the documented recovery path. Enrolling a second passkey before removing the first avoids lockout.

      06 · pii

      No PII ownership

      -

      pk-auth never stores names, emails, or display names of its own. The UserLookup SPI is the only channel to user data, and your implementation decides what crosses the boundary.

      +

      pk-auth never stores names, emails, or display names of its own. The UserLookup SPI is the only channel to user data, and the host's implementation decides what crosses the boundary.

      07 · flood

      Built-in rate limiter

      -

      The CeremonyRateLimiter SPI throttles per-IP and per-username; an in-memory Caffeine default ships. Swap for a shared store across replicas. A WAF / API gateway upstream is still recommended for heavy floods.

      +

      The CeremonyRateLimiter SPI throttles per-IP and per-username; an in-memory Caffeine default ships. A shared store replaces the default across replicas. A WAF or API gateway upstream is still recommended for heavy floods.

      08 · tokens
      -

      Stateless by default, revocable when you need it

      -

      Authentication returns a short-lived JWT (one-hour default). When you need true server-side revocation — logout-everywhere, admin-revoke, user-delete before exp — the AccessTokenStore SPI is the paved road; RevocationCheck is the lighter deny-list option.

      +

      Stateless tokens with optional revocation

      +

      Authentication returns a short-lived JWT (one-hour default). True server-side revocation (logout-everywhere, admin-revoke, user-delete before exp) uses the AccessTokenStore SPI; RevocationCheck is the lighter deny-list option.

      09 · sessions
      -

      Refresh-token replay defense

      -

      Rotating refresh tokens are one row per rotation with atomic mark-and-insert. A detected replay scorches the entire token family, forcing re-authentication. See ADR 0013.

      +

      Refresh-token replay defence

      +

      Rotating refresh tokens are one row per rotation with atomic mark-and-insert. A detected replay revokes the entire token family, forcing re-authentication. See ADR 0013.

      10 · audit
      -

      Structured logs first

      -

      Credential deletion emits pkauth.credential.deleted. Every ceremony logs userHandle, credentialId, origin, and counter; ship to an immutable store and alert on regressions.

      +

      Structured logging

      +

      Credential deletion emits pkauth.credential.deleted. Every ceremony logs userHandle, credentialId, origin, and counter. Shipping these logs to an immutable store and alerting on regressions is recommended.

      @@ -1728,16 +1730,17 @@

      Structured logs first

      Project maturity

      pk-auth is MIT-licensed and AI-authored under human architectural direction. It ships a full - STRIDE threat model, an SPI stability policy, and a green CI / end-to-end suite — but it has - not yet undergone independent third-party security review. It is - production-grade in engineering rigor, but not yet security-audited — evaluate it - accordingly, pin a version, and read the changelog before upgrading. + STRIDE threat model, an SPI stability policy, and a CI and end-to-end suite. It has + not yet undergone independent third-party security review. Its engineering rigour is + production-grade, but it is not security-audited. Pinning a version and reading the + changelog before upgrading is recommended.

      - Note that attestation conveyance defaults to none (any authenticator is accepted); hosts that + Attestation conveyance defaults to none, so any authenticator is accepted; hosts that need attestation verification must opt into the strict WebAuthn4J manager. - Read the STRIDE threat model - and the stability & versioning policy. + The STRIDE threat model + and the stability and versioning policy + are the reference documents for security and compatibility.

      @@ -1752,12 +1755,12 @@

      Project maturity

      pk-auth

      A passkey credential layer for the JVM. MIT-licensed. - Three adapters, one wire contract, your user table. + Three adapters, one wire contract, and the host's user table.

      v2.1.0 · JDK 21 · WebAuthn4J