Skip to content

fix: resolve security, correctness, and maintenance review findings#99

Merged
wolpert merged 3 commits into
mainfrom
fix/review-findings-2.1.0
Jun 26, 2026
Merged

fix: resolve security, correctness, and maintenance review findings#99
wolpert merged 3 commits into
mainfrom
fix/review-findings-2.1.0

Conversation

@wolpert

@wolpert wolpert commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Addresses findings from a multi-agent review of the library (security, correctness, test, maintainability). One commit; the full ./gradlew check gate passes (spotless, unit + Testcontainers integration tests, JaCoCo).

HIGH

  • jwtJwtClaims rejects reserved claim names, so caller-supplied additionalClaims can no longer overwrite iss/sub/aud/exp/jti/pkauth.* (impersonation / TTL-bypass via the documented claim-merge feature).
  • otp — off-by-one fixed: after the unconditional increment, reject with > (not >=) so exactly maxAttempts code comparisons happen while keeping the anti-bypass behavior. +2 regression tests.
  • admin — OTP AttemptsExceededRateLimited (429) so brute-force lockout is visible at the HTTP layer; ordinary mismatch/expiry keep their typed 200 body. +1 test.

MEDIUM

  • magic-link — purpose enforced at the consume boundary (finishVerification(token, requiredPurpose) + ConsumeResult.WrongPurpose); a login token can't be replayed at the email-verify endpoint and the single-use JTI isn't burned on a cross-purpose attempt. +1 test.
  • dynamodbrevokeFamily/revokeAllForUser use a scalar-only conditional UpdateItem instead of read-modify-write putItem, so a concurrent usedAt mark can't be clobbered.
  • testkitInMemoryRefreshTokenRepository.rotateAtomically no longer does a nested cross-key write inside ConcurrentHashMap.compute.
  • docs(CLAUDE) — list pk-auth-admin-api, place AdminResult in its real module, add the missing SPIs.

LOW

  • lifecycle package @NullMarked; InMemoryWindowCounter fixed-window doc; JDBI Update try-with-resources (×2); DynamoDB OTP latest-by-parsed-Instant; magic-link TTL invariant doc; AltFlowsModule stale-TODO comment; error_prone_annotations into the version catalog (8 hardcoded coords removed).

Deferred to its own commit (in this PR)

  • M5 — ceremony 429 Retry-After header. This is a wire-contract change to the exported CeremonyResponse record across all three adapters, so it lands as a separate commit with its own @since.

Test plan

  • ./gradlew check (spotless + all tests + JaCoCo), Docker up for Testcontainers
  • New regression tests for the JWT, OTP, admin-OTP-lockout, and magic-link-purpose fixes

🤖 Generated with Claude Code

Address issues surfaced by a multi-agent review of the library.

HIGH
- jwt: reject reserved claim names in JwtClaims so caller-supplied
  additionalClaims can no longer overwrite iss/sub/aud/exp/jti/pkauth.*
  (impersonation / TTL-bypass via the documented claim-merge feature).
- otp: fix off-by-one that allowed only maxAttempts-1 code comparisons.
  After the unconditional increment, reject with `>` (not `>=`) so exactly
  maxAttempts guesses are checked while keeping the anti-bypass behavior.
- admin: map OTP AttemptsExceeded to RateLimited (429) instead of a 200
  whose failure was buried in the body, so brute-force lockout is visible
  at the HTTP layer. Ordinary mismatch/expiry keep their typed 200 body.

MEDIUM
- magic-link: enforce the pkauth.purpose claim at the consume boundary
  (finishVerification(token, requiredPurpose) + ConsumeResult.WrongPurpose)
  so a login token can't be replayed at the email-verify endpoint; the
  single-use JTI is not burned on a cross-purpose attempt.
- dynamodb: revokeFamily/revokeAllForUser now use a scalar-only conditional
  UpdateItem instead of a read-modify-write putItem, so a concurrent usedAt
  mark set by rotateAtomically can no longer be clobbered.
- testkit: InMemoryRefreshTokenRepository.rotateAtomically no longer does a
  nested cross-key write inside ConcurrentHashMap.compute (contract
  violation). The successor is inserted first and rolled back on a lost race.
- docs(CLAUDE): list pk-auth-admin-api, place AdminResult in its real module,
  and add the missing SPIs (ConsumedJtiStore, CeremonyRateLimiter,
  RevocationCheck, MessageFormatter).

LOW
- core: mark the lifecycle package @NullMarked (matches the other 10).
- core: correct InMemoryWindowCounter doc (fixed-window, not sliding).
- jdbi: close Update statements via try-with-resources in
  JdbiCredentialRepository.save and the backup-code audit insert.
- dynamodb: order OTP findLatestActive by parsed Instant, not the
  variable-precision ISO string.
- magic-link: document the consumedJtiTtl >= token-TTL invariant.
- dropwizard: drop the stale TODO reference in AltFlowsModule javadoc.
- build: add error_prone_annotations to the version catalog and replace
  8 hardcoded coordinates across 4 build files.

Deferred to its own commit: ceremony 429 Retry-After header, which is a
wire-contract change to the exported CeremonyResponse record across all
three adapters and warrants an @since/ADR of its own.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@wolpert wolpert enabled auto-merge (rebase) June 26, 2026 15:03
wolpert and others added 2 commits June 26, 2026 08:03
The admin endpoints already emit Retry-After on their rate-limit refusals,
but the unauthenticated ceremony endpoints (registration/authentication
start + finish) returned a bare 429, so clients and proxies got no backoff
hint on the most attack-exposed surface.

Add a `headers` component to the exported CeremonyWireMapper.CeremonyResponse
record (with a backward-compatible two-arg `(status, body)` constructor) and
emit `Retry-After: 60` on every ceremony 429. The value is a conservative
constant matching InMemoryCeremonyRateLimiter.DEFAULT_WINDOW; the
CeremonyRateLimiter SPI is boolean-only and exposes no per-call window, so an
exact remaining-time can't be computed — Retry-After is advisory, so a stale
hint is permitted. All three adapters (Spring/Micronaut/Dropwizard) now copy
CeremonyResponse.headers() onto the native HTTP response.

This was split out of the review-findings commit because it changes the
public wire-contract record across all adapters.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@wolpert wolpert merged commit 79550d8 into main Jun 26, 2026
8 checks passed
@wolpert wolpert deleted the fix/review-findings-2.1.0 branch June 26, 2026 15:12
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