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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@ org.gradle.jvmargs=-Xmx2g \

# Project identity
group=com.codeheadsystems
version=2.1.0-SNAPSHOT
version=2.2.0-SNAPSHOT
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<StoredOtp> findLatestActive(UserHandle userHandle, String phoneE164, Instant now);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> RESERVED_CLAIM_NAMES =
Set.of(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -131,7 +131,7 @@ record AlreadyConsumed() implements ConsumeResult {}
* single-use JTI is deliberately <em>not</em> 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 {}
}
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading