Skip to content

Security-review follow-ups: amr propagation, ChallengeId decoupling, hardening#26

Merged
wolpert merged 5 commits into
mainfrom
security-hardening-followups
Jun 3, 2026
Merged

Security-review follow-ups: amr propagation, ChallengeId decoupling, hardening#26
wolpert merged 5 commits into
mainfrom
security-hardening-followups

Conversation

@wolpert
Copy link
Copy Markdown
Contributor

@wolpert wolpert commented Jun 2, 2026

Follow-ups from the security review (the remaining items after the 1.2.0 fixes). All hardening — no known exploit in any item below. Targets 1.3.0.

What's in here

A1 — Propagate the original amr through refresh rotation

POST /auth/refresh minted access tokens with a hardcoded amr=["user"], erasing how the session was first established (a problem for any policy that keys off amr for step-up). RefreshTokenService now records the RFC 8176 amr on the refresh family and carries it verbatim through every rotation.

  • New issue(UserHandle, String, Optional, List<String>); 3-arg issue(...) deprecated (defaults to ["user"], preserving prior behavior).
  • RefreshTokenRecord / RotatedClaims gain amr; persisted as the amr column (JDBI Flyway V10) and amr attribute (DynamoDB). Pre-existing rows/items default to ["user"].
  • RefreshTokenScenarios asserts amr survives the rotation round-trip on all three backends.

A2 — Decouple ChallengeId from the challenge bytes

The challenge store key was base64url(challenge). It's now an opaque random UUID handle. Finish-time validation still enforces the binding by byte-comparing the stored challenge against the bytes the authenticator signed (and WebAuthn4J re-checks it), so the key no longer reveals or depends on the secret. No wire/SPI change — the browser SDK already round-trips the id. Removes ChallengeGenerator.idOf and the internal IdMismatch variant.

B1 — DynamoDB refresh rotate uses a conditional UpdateItem

rotateAtomically marked the parent used via a full-item PUT from a prior read; it now uses a conditional UpdateItem (ignoreNullsMode(SCALAR_ONLY)) — no pre-read, and it can't clobber a concurrently-written parent attribute. Freshness condition (incl. the 1.2.0 numeric-ttl expiry compare) preserved; behavior unchanged.

C — Documented security contracts (docs only)

Stateless access-TTL = revocation window (JwtConfig); AccessTokenStore.exists must fail closed on outage; custom RevocationCheck must handle a null jti; the Spring JWT filter is additive (never clears a pre-existing SecurityContext).

D1 — Atomic single-use TCK for ChallengeStore

New shared ChallengeStoreScenarios with a concurrent "exactly one of N wins" race test, driven against in-memory, JDBI/Postgres, and DynamoDB — single-use was previously asserted only single-threaded.

Compatibility

  • Source-compatible. The only deprecation is the 3-arg RefreshTokenService.issue(...).
  • Schema: JDBI gains Flyway V10 (ALTER TABLE refresh_tokens ADD COLUMN amr ... DEFAULT 'user'); DynamoDB adds an optional amr attribute. Both default gracefully for pre-existing data. CURRENT_SCHEMA_VERSION"10".

Testing

Full ./gradlew build passes (all module unit + JDBI/Postgres + DynamoDB-Local integration tests, spotless, javadoc, jacoco). The refresh race test and the new challenge-store race test pass on all three backends.

🤖 Generated with Claude Code

wolpert and others added 5 commits June 2, 2026 06:10
…DB rotate

Refreshed access tokens previously carried a hardcoded amr=["user"], losing
the original authentication method. RefreshTokenService now records the RFC
8176 amr on the refresh family and propagates it verbatim through every
rotation, so a token minted from POST /auth/refresh reflects how the session
was first established.

- RefreshTokenRecord + RotatedClaims gain an `amr` field; new
  issue(UserHandle, String, Optional, List<String>) overload. The 3-arg
  issue(...) is deprecated and defaults amr to ["user"] (prior behavior).
- Persisted as the `amr` column (JDBI, Flyway V10) and `amr` attribute
  (DynamoDB); rows/items predating the change default to ["user"].
- RefreshTokenScenarios asserts amr survives the rotation round-trip on all
  three backends.

Also (B1) DynamoDbRefreshTokenRepository.rotateAtomically now marks the parent
used via a conditional UpdateItem (ignoreNullsMode SCALAR_ONLY) instead of a
full-item PUT from a prior read: no pre-read, and it can't clobber a
concurrently-written parent attribute. The freshness condition (incl. the
numeric-ttl expiry compare) is preserved; behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The challenge store key was base64url(challenge) — the id literally was the
challenge bytes. It is now an opaque random handle (ChallengeId.random()),
generated at startRegistration/startAuthentication and round-tripped by the
client unchanged.

Security is unchanged: finish-time validation still enforces the cryptographic
binding by byte-comparing the stored challenge against the bytes the
authenticator signed over (clientData.challenge), and WebAuthn4J independently
re-checks the same challenge during signature verification. The random id no
longer reveals or depends on the challenge. Defense-in-depth, no known exploit.

Removes ChallengeGenerator.idOf(byte[]) and the now-meaningless
ChallengeValidation.IdMismatch variant (a tampered payload now surfaces as
BytesMismatch or MissingOrConsumed). No wire-format or SPI change; the browser
SDK already round-trips the id rather than deriving it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ChallengeStore.takeOnce is the load-bearing primitive behind WebAuthn replay
defense, but single-use was only asserted single-threaded per backend. Add a
shared ChallengeStoreScenarios TCK with a concurrent "exactly one of N threads
wins" race test, and drive it against the in-memory, JDBI/Postgres, and
DynamoDB backends so a non-atomic read-then-delete in any implementation would
fail the suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document the security contracts surfaced by the review (no behavior change):

- JwtConfig: the access-token TTL is the effective revocation window in
  stateless mode (no-op AccessTokenStore) — keep it short and adopt the
  AccessTokenStore SPI for immediate revocation.
- AccessTokenStore.exists: must fail closed (throw) on store outage; returning
  true would fail open and accept possibly-revoked tokens.
- RevocationCheck.isRevoked: custom deny-lists must handle a null jti, or a
  jti-less token slips a jti-only deny-list.
- Spring PkAuthJwtAuthenticationFilter: it is additive — it never clears a
  pre-existing SecurityContext, which matters only if a host layers a
  session filter ahead of it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump the development version to 1.3.0-SNAPSHOT (additive SPI methods => minor)
and add the [1.3.0] CHANGELOG section covering the security-review follow-ups.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@wolpert wolpert merged commit 86fa03f into main Jun 3, 2026
2 checks passed
@wolpert wolpert deleted the security-hardening-followups branch June 3, 2026 12:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant