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
84 changes: 84 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,90 @@ 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.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
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` /
`startAuthentication` return `StartRegistrationResult` /
`StartAuthenticationResult` (`Started | RateLimited`) instead of the bare
response envelope, mirroring the `finish*` ceremonies, and the
`CeremonyRateLimitedException` type is removed. A rate-limit refusal is now a
value every adapter pattern-matches (compile-checked exhaustiveness) rather
than an exception thrown across the boundary. Hosts using the Spring /
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(…)`
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
`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
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
the secret-dependent SonarQube / Stryker steps on Dependabot PRs.

### Added

- **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
construction instead of producing never-verifiable or instantly-expired codes.
- **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.

### Fixed

- **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
indeterminable reason fails closed.
- **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
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
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
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
S5852 / CWE-1333); `encode` / `decode` use `replaceAll` with string literals,
byte-identical output.
- **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

Patch release: a browser-SDK build fix, a Micronaut admin toggle, and
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## 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 (`gradle.properties` → currently `1.3.1-SNAPSHOT`). It is *not* an identity provider: it owns passkeys/credentials, never users — 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 (`gradle.properties` → currently `2.0.0-SNAPSHOT`). 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). Read the relevant ADR before changing cross-module behavior.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ tests and Playwright end-to-end suites:
## Status

1.0.0 cut the stable baseline; the current development line is
**1.3.1-SNAPSHOT** (see `gradle.properties` for the authoritative
**2.0.0-SNAPSHOT** (see `gradle.properties` for the authoritative
version). The 1.1 line added per-audience JWT TTLs, the
`AccessTokenStore` (stateful access tokens), the
`UserDeletionService` fan-out, and the `pk-auth-refresh-tokens`
Expand Down
2 changes: 1 addition & 1 deletion RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ 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.

> The committed `version` in `package.json` is the in-development line
> (e.g. `1.3.0-SNAPSHOT`), mirroring `gradle.properties`. `SNAPSHOT` is a valid
> (e.g. `2.0.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
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Use one of these private channels instead:

Please include, as far as you can:

- The affected module(s) and version (e.g. `pk-auth-core 1.3.0`).
- The affected module(s) and version (e.g. `pk-auth-core 2.0.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.
Expand Down
2 changes: 1 addition & 1 deletion clients/passkeys-browser/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pk-auth/passkeys-browser",
"version": "1.3.1",
"version": "2.0.0",
"description": "pk-auth browser SDK: WebAuthn ceremonies + admin operations against the pk-auth wire contract.",
"license": "MIT",
"author": "Ned Wolpert",
Expand Down
6 changes: 3 additions & 3 deletions docs/stability.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# pk-auth Stability and Versioning

## Current status: 1.x
## Current status: 2.x

pk-auth is **post-1.0** as of the `v1.0.0` tag. The current development line is
**1.3.1-SNAPSHOT** (`gradle.properties` is authoritative); the 1.1, 1.2, and
**2.0.0-SNAPSHOT** (`gradle.properties` is authoritative); the 1.1, 1.2, and
1.3 releases have shipped. See [`CHANGELOG.md`](../CHANGELOG.md) for the
per-release delta.

The 1.x line follows [Semantic Versioning](https://semver.org/):
Post-1.0, pk-auth follows [Semantic Versioning](https://semver.org/):

| Change type | Version bump |
|---|---|
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=1.3.1-SNAPSHOT
version=2.0.0-SNAPSHOT
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ private static <T> AdminResult<T> notConfigured(String feature) {
* Config)} overload) to construct a service. Using a record keeps construction sites concise and
* self-documenting through Java's named component syntax.
*
* @since 1.3.1
* @since 2.0.0
*/
public record Dependencies(
CredentialRepository credentialRepository,
Expand All @@ -297,7 +297,7 @@ public record Dependencies(
* Convenience for a passkey-only deployment: credentials and user lookup only, with every
* alt-flow service absent.
*
* @since 1.3.1
* @since 2.0.0
*/
public static Dependencies passkeyOnly(
CredentialRepository credentialRepository, UserLookup userLookup) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* any challenge was created; adapters map it to HTTP {@code 429}.
* </ul>
*
* @since 1.3.1
* @since 2.0.0
*/
public sealed interface StartAuthenticationResult
permits StartAuthenticationResult.Started, StartAuthenticationResult.RateLimited {
Expand All @@ -24,7 +24,7 @@ public sealed interface StartAuthenticationResult
* The limiter allowed the ceremony; {@code response} carries the WebAuthn request options.
*
* @param response the start-authentication envelope.
* @since 1.3.1
* @since 2.0.0
*/
record Started(StartAuthenticationResponse response) implements StartAuthenticationResult {
public Started {
Expand All @@ -36,7 +36,7 @@ record Started(StartAuthenticationResponse response) implements StartAuthenticat
* The configured rate limiter refused the call before any challenge was created.
*
* @param bucket which limiter bucket denied the call ({@code "ip"} or {@code "username"}).
* @since 1.3.1
* @since 2.0.0
*/
record RateLimited(String bucket) implements StartAuthenticationResult {
public RateLimited {
Expand All @@ -52,7 +52,7 @@ record RateLimited(String bucket) implements StartAuthenticationResult {
*
* @return the start-authentication envelope.
* @throws IllegalStateException if this result is {@link RateLimited}.
* @since 1.3.1
* @since 2.0.0
*/
default StartAuthenticationResponse responseOrThrow() {
if (this instanceof Started started) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* any challenge was created; adapters map it to HTTP {@code 429}.
* </ul>
*
* @since 1.3.1
* @since 2.0.0
*/
public sealed interface StartRegistrationResult
permits StartRegistrationResult.Started, StartRegistrationResult.RateLimited {
Expand All @@ -24,7 +24,7 @@ public sealed interface StartRegistrationResult
* The limiter allowed the ceremony; {@code response} carries the WebAuthn creation options.
*
* @param response the start-registration envelope.
* @since 1.3.1
* @since 2.0.0
*/
record Started(StartRegistrationResponse response) implements StartRegistrationResult {
public Started {
Expand All @@ -36,7 +36,7 @@ record Started(StartRegistrationResponse response) implements StartRegistrationR
* The configured rate limiter refused the call before any challenge was created.
*
* @param bucket which limiter bucket denied the call ({@code "ip"} or {@code "username"}).
* @since 1.3.1
* @since 2.0.0
*/
record RateLimited(String bucket) implements StartRegistrationResult {
public RateLimited {
Expand All @@ -52,7 +52,7 @@ record RateLimited(String bucket) implements StartRegistrationResult {
*
* @return the start-registration envelope.
* @throws IllegalStateException if this result is {@link RateLimited}.
* @since 1.3.1
* @since 2.0.0
*/
default StartRegistrationResponse responseOrThrow() {
if (this instanceof Started started) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ public static CeremonyConfig defaults() {
* @param attestationConveyance attestation conveyance, or null for the default.
* @param counterRegression counter-regression policy, or null for the default.
* @return the resolved ceremony config.
* @since 1.3.1
* @since 2.0.0
*/
public static CeremonyConfig from(
@Nullable Duration challengeTtl,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public record RelyingPartyConfig(String id, String name, Set<String> origins) {
* @param name human-readable RP name, or null/blank if unset.
* @param origins acceptable client-reported origins, or null/empty if unset; copied defensively.
* @return the validated relying-party config.
* @since 1.3.1
* @since 2.0.0
*/
public static RelyingPartyConfig from(
@Nullable String id, @Nullable String name, @Nullable Collection<String> origins) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* 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).
*
* @since 1.3.1
* @since 2.0.0
*/
public final class PkAuthPersistenceResponse {

Expand All @@ -32,7 +32,7 @@ private PkAuthPersistenceResponse() {}
*
* @param exception the persistence failure to render.
* @return an insertion-ordered map with {@code error} and {@code operation} keys.
* @since 1.3.1
* @since 2.0.0
*/
public static Map<String, String> body(PkAuthPersistenceException exception) {
Map<String, String> body = new LinkedHashMap<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ public static JwtConfig defaults(String issuer, String audience) {
* @param defaultTtl the access-token TTL, or {@code null} for {@link #DEFAULT_TOKEN_TTL}.
* @param ttlsByAudience per-audience TTL overrides, or {@code null}/empty for a uniform TTL.
* @return the assembled config.
* @since 1.3.1
* @since 2.0.0
*/
public static JwtConfig from(
String issuer,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ public String toString() {
* @param defaultTtl the fallback TTL for any audience not in {@code overrides}.
* @param overrides per-audience TTL overrides, or {@code null}/empty for a uniform TTL.
* @return the resolved policy.
* @since 1.3.1
* @since 2.0.0
*/
static TokenTtlPolicy from(Duration defaultTtl, @Nullable Map<String, Duration> overrides) {
return overrides == null || overrides.isEmpty()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ public static RefreshTokenConfig defaults() {
* @param cleanupRetention forensic retention window, or {@code null} for {@link
* #DEFAULT_CLEANUP_RETENTION}.
* @return the assembled config.
* @since 1.3.1
* @since 2.0.0
*/
public static RefreshTokenConfig from(
@Nullable Duration defaultTtl,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ default Set<String> knownAudiences() {
* @param defaultTtl the fallback TTL for any audience not in {@code overrides}.
* @param overrides per-audience TTL overrides, or {@code null}/empty for a uniform TTL.
* @return the resolved policy.
* @since 1.3.1
* @since 2.0.0
*/
static RefreshTtlPolicy from(Duration defaultTtl, @Nullable Map<String, Duration> overrides) {
return overrides == null || overrides.isEmpty()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* <p>The comma separator is safe because {@code RefreshTokenRecord} rejects any {@code amr} entry
* containing a {@code ','} at construction time, so a round-trip is lossless.
*
* @since 1.3.1
* @since 2.0.0
*/
public final class Amr {

Expand All @@ -27,7 +27,7 @@ private Amr() {}
*
* @param amr the non-empty, comma-free references from a {@code RefreshTokenRecord}.
* @return the comma-joined storage form.
* @since 1.3.1
* @since 2.0.0
*/
public static String encode(List<String> amr) {
return String.join(",", amr);
Expand All @@ -40,7 +40,7 @@ public static String encode(List<String> amr) {
*
* @param stored the stored comma-separated value, possibly {@code null} or blank.
* @return the decoded references, never empty.
* @since 1.3.1
* @since 2.0.0
*/
public static List<String> decode(@Nullable String stored) {
if (stored == null || stored.isBlank()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ public record Jwt(
* @param residentKey discoverable-credential requirement (default {@code PREFERRED})
* @param attestation attestation conveyance preference (default {@code NONE})
* @param counterRegression signature-counter regression policy (default {@code REJECT})
* @since 1.3.1
* @since 2.0.0
*/
public record Ceremony(
@Nullable Duration challengeTtl,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
* once — cannot regress in one implementation while passing in another. Mirrors {@link
* RefreshTokenScenarios}'s concurrent rotation race for the refresh-token path.
*
* @since 1.3.1
* @since 2.0.0
*/
public final class BackupCodeRepositoryScenarios {

Expand All @@ -41,7 +41,7 @@ public BackupCodeRepositoryScenarios(BackupCodeRepository repository) {
* code.
*
* @throws Exception if a worker thread is interrupted.
* @since 1.3.1
* @since 2.0.0
*/
public void concurrentConsumeYieldsExactlyOneWinner() throws Exception {
UserHandle user = UserHandle.random();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
* one implementation while passing in another. Mirrors {@link RefreshTokenScenarios}'s concurrent
* rotation race for the refresh-token path.
*
* @since 1.3.1
* @since 2.0.0
*/
public final class OtpRepositoryScenarios {

Expand All @@ -43,7 +43,7 @@ public OtpRepositoryScenarios(OtpRepository repository) {
* enabling OTP reuse.
*
* @throws Exception if a worker thread is interrupted.
* @since 1.3.1
* @since 2.0.0
*/
public void concurrentConsumeYieldsExactlyOneWinner() throws Exception {
UserHandle user = UserHandle.random();
Expand Down
Loading
Loading