From 234c379fefe1ea9dea5f9058c20bbf4a939e9521 Mon Sep 17 00:00:00 2001 From: Ned Wolpert Date: Sat, 27 Jun 2026 20:52:36 -0700 Subject: [PATCH] chore(release): prepare 2.2.0 - Bump version 2.1.0-SNAPSHOT -> 2.2.0-SNAPSHOT (the post-2.1.0 development line open was missed after v2.1.0 was tagged). - Correct @since on the 11 public API elements introduced after v2.1.0 from 2.1.0 to 2.2.0 (the 9 genuine 2.1.0 markers in files unchanged since the tag are left as-is). - Add the 2.2.0 CHANGELOG entry: a security-and-correctness release (magic-link replay/audience/email-binding, per-request UV enforcement, JWT claim-override + OTP brute-force fixes, DynamoDB/JDBI correctness, Retry-After on ceremony 429s). Notes the one breaking SPI change (OtpRepository.findLatestActive gains an Instant now param) and Flyway V11. Tagging v2.2.0 triggers the publish workflow. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 78 +++++++++++++++++++ gradle.properties | 2 +- .../pkauth/spi/ChallengeRecord.java | 2 +- .../pkauth/spi/OtpRepository.java | 2 +- .../codeheadsystems/pkauth/jwt/JwtClaims.java | 2 +- .../pkauth/jwt/PkAuthJwtIssuer.java | 2 +- .../pkauth/magiclink/MagicLinkService.java | 10 +-- .../PkAuthWebAutoConfiguration.java | 2 +- .../pkauth/testkit/InMemoryUserLookup.java | 2 +- 9 files changed, 90 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 729e5da..e0d5fa7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,84 @@ The 0.x line is treated as a single pre-stable development series — see 1.0.0 stabilisation cut; for 0.x history consult `git log` against the relevant tags. +## [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 +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 + 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 + access token. +- **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 + `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 + `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 + (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. +- `MagicLinkService.Dependencies.ofDedicatedAudience(...)` and + `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`). + +### Changed + +- **`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 + dedicated magic-link issuer uses a no-op store). +- Spring: the pk-auth `/auth/**` security filter chain is pinned to an early + `@Order` so a host catch-all chain can't shadow it, and the JWT filter is no + longer auto-registered as a global servlet filter (it runs only inside the + chain). +- Maintenance: routine Dependabot bumps (Gradle, gh-actions, npm), and the + Spotless version pin / docs were reconciled (the project tracks the 8.x line). + +### Fixed + +- DynamoDB refresh-token `create()` writes its primary + index items in a single + atomic `TransactWriteItems`, so a partial write can't orphan a primary that a + family revoke would later miss; a duplicate `refreshId` now surfaces as + `PkAuthPersistenceException`. +- DynamoDB family/user revoke uses a scalar conditional `UpdateItem` instead of a + read-modify-write that could clobber a concurrent `usedAt`; the in-memory + testkit no longer does a cross-key write inside `ConcurrentHashMap.compute`. +- JDBI `updateSignCount` records `last_used_at` for sync passkeys that always + report `signCount = 0` (a strict-regression check is still enforced). +- `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 A backward-compatible minor release. The headline is **crypto-agility and diff --git a/gradle.properties b/gradle.properties index 9df120d..20601c1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,4 +21,4 @@ org.gradle.jvmargs=-Xmx2g \ # Project identity group=com.codeheadsystems -version=2.1.0-SNAPSHOT +version=2.2.0-SNAPSHOT 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 d7eb1ea..fffe75b 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 @@ -45,7 +45,7 @@ public record ChallengeRecord( * Back-compatible constructor for callers that don't carry a resolved user-verification * requirement; equivalent to passing {@code null} for {@code userVerification}. * - * @since 2.1.0 + * @since 2.2.0 */ public ChallengeRecord( byte[] challenge, Purpose purpose, @Nullable UserHandle userHandle, Instant expiresAt) { 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 02016e7..25d4d7c 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 @@ -64,7 +64,7 @@ record StoredOtp( * @param userHandle owning user * @param phoneE164 destination phone in E.164 format * @param now the caller's current instant (from the host ClockProvider) - * @since 2.1.0 + * @since 2.2.0 */ Optional findLatestActive(UserHandle userHandle, String phoneE164, Instant now); diff --git a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtClaims.java b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtClaims.java index d8af840..d8a4ac8 100644 --- a/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtClaims.java +++ b/pk-auth-jwt/src/main/java/com/codeheadsystems/pkauth/jwt/JwtClaims.java @@ -38,7 +38,7 @@ public record JwtClaims( * registered set plus the {@code pkauth.*} private claims mirror {@code * PkAuthJwtValidator.removeKnownClaims}. * - * @since 2.1.0 + * @since 2.2.0 */ private static final Set RESERVED_CLAIM_NAMES = Set.of( 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 5328a5d..b5979b8 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 @@ -95,7 +95,7 @@ public String issue(JwtClaims claims) { * @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 - * @since 2.1.0 + * @since 2.2.0 */ public String issue(JwtClaims claims, @Nullable Duration ttlOverride) { Objects.requireNonNull(claims, "claims"); 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 7da9469..b665fa5 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 @@ -90,7 +90,7 @@ public final class MagicLinkService { * 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. * - * @since 2.1.0 + * @since 2.2.0 */ public static final String DEFAULT_AUDIENCE = "pkauth:magic-link"; @@ -131,7 +131,7 @@ record AlreadyConsumed() implements ConsumeResult {} * single-use JTI is deliberately not consumed in this case, so the token stays usable * at its intended endpoint. * - * @since 2.1.0 + * @since 2.2.0 */ record WrongPurpose(String expectedPurpose, String actualPurpose) implements ConsumeResult {} } @@ -334,7 +334,7 @@ public ConsumeResult finishVerification(String token) { * @param requiredPurpose the purpose the caller demands, or {@code null} to accept any purpose * (the caller then inspects {@link ConsumeResult.Success#purpose()} itself) * @return the consume outcome - * @since 2.1.0 + * @since 2.2.0 */ public ConsumeResult finishVerification(String token, @Nullable String requiredPurpose) { Objects.requireNonNull(token, "token"); @@ -467,7 +467,7 @@ public static Dependencies of( * @param userLookup the user lookup SPI * @param clockProvider the clock * @return dependencies wired to a magic-link-scoped issuer + validator - * @since 2.1.0 + * @since 2.2.0 */ public static Dependencies ofDedicatedAudience( JwtKeyset keyset, @@ -559,7 +559,7 @@ public record Config( /** * Back-compatible constructor that defaults {@code tokenTtl} to {@link #DEFAULT_TTL} (15m). * - * @since 2.1.0 + * @since 2.2.0 */ public Config( String baseUrl, int rateLimit, MagicLinkRateLimiter rateLimiter, Duration consumedJtiTtl) { 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 e4f6edb..b30c7aa 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 @@ -88,7 +88,7 @@ public PkAuthJwtAuthenticationFilter pkAuthJwtAuthenticationFilter(PkAuthJwtVali * * @param filter the JWT authentication filter bean to suppress from global registration * @return a disabled registration so the container does not mount the filter globally - * @since 2.1.0 + * @since 2.2.0 */ @Bean @ConditionalOnMissingBean(name = "pkAuthJwtAuthenticationFilterRegistration") diff --git a/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/InMemoryUserLookup.java b/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/InMemoryUserLookup.java index 7fae5f4..0ee7af6 100644 --- a/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/InMemoryUserLookup.java +++ b/pk-auth-testkit/src/main/java/com/codeheadsystems/pkauth/testkit/InMemoryUserLookup.java @@ -66,7 +66,7 @@ public UserHandle register(String username, String displayName) { * #emailFor(UserHandle)}. Useful for flows (e.g. magic-link login) that deliver only to the * address bound to the resolved user. * - * @since 2.1.0 + * @since 2.2.0 */ public UserHandle register(String username, String displayName, String email) { UserHandle handle = register(username, displayName);