This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
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.1.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; per-decision rationale lives in docs/adr/ (Nygard format, numbered). Read the relevant ADR before changing cross-module behavior.
JDK 21 is required (Gradle's toolchain fetches one if absent). Node ≥ 20 + npm are needed for the browser SDK, which Gradle drives automatically.
./gradlew check # full gate: spotlessCheck + test + jacoco coverage verify
./gradlew clean build test # aggregate test across all subprojects
./gradlew :pk-auth-core:test # one module's tests
./gradlew :pk-auth-jwt:test --tests "com.codeheadsystems.pkauth.jwt.PkAuthJwtIssuerTest" # one test class
./gradlew :pk-auth-core:test --tests "*.SomeTest.someMethod" # one test method
./gradlew spotlessApply # auto-fix formatting (google-java-format, SPDX header, imports)
./gradlew :examples:spring-boot-demo:run # runnable demo at http://localhost:8080 (also dropwizard-demo / micronaut-demo — one at a time, all bind 8080)- Integration tests are ordinary
testtasks — the JDBI/DynamoDB persistence modules use Testcontainers (Postgres / DynamoDB Local), so./gradlew :pk-auth-persistence-jdbi:testneeds Docker running. There is no separateintegrationTesttask or JUnit tag. - Playwright end-to-end suites live under the
examples/demos and need Chrome (drives a CDP virtual WebAuthn authenticator). They are wired into each demo'scheckvia thee2eTesttask (pkauth.e2e-conventions) but are opt-in — they only run whenPK_RUN_E2E=1(or-PrunE2e) is set, so a default./gradlew checkand the CIbuildjob stay fast and Chrome-free. Run one withPK_RUN_E2E=1 ./gradlew :examples:spring-boot-demo:e2eTest; the task itself doesnpm ci+npx playwright install chromeand boots the demo via its Gradleruntask. CI runs all three in thee2ematrix job (.github/workflows/ci.yml, withPW_INSTALL_DEPS=1so Chrome's OS deps are installed too). - The Gradle build cache is disabled on purpose (Spotless 8.x classloader bug) — see the comment in
gradle.properties. Don't re-enable it.
Dependency arrows always point inward. Adapters depend on core; core depends on no adapter, no framework, no servlet/HTTP API, no JDBC/DynamoDB.
pk-auth-core— framework- and persistence-neutral. Knows WebAuthn (WebAuthn4J), the wire contract, and declares the SPIs.PasskeyAuthenticationServiceis the ceremony entry point. The exported packages areapi,ceremony,config,credential,error,json,lifecycle,metrics, andspi(enforced viamodule-info.java); everything else is module-internal.- SPIs (ports) — narrow interfaces the host implements:
UserLookup,CredentialRepository,ChallengeStore(required);BackupCodeRepository,OtpRepository,EmailSender,SmsSender,RefreshTokenRepository,AccessTokenStore,TokenTtlPolicy,RevocationCheck,UserDeletionListener,AttestationTrustPolicy,OriginValidator,ClockProvider,ConsumedJtiStore,CeremonyRateLimiter,MessageFormatter(optional / feature-gated). SeeDESIGN.md§6 for the required-vs-optional table. - Adapters —
pk-auth-spring-boot-starter(Spring Boot 4 / Security 7 autoconfigure),pk-auth-dropwizard(Dropwizard 5ConfiguredBundle+ Dagger 2),pk-auth-micronaut(Micronaut 4@Factory+@Filter, deliberately not Micronaut Security). Each mounts the same/auth/**JSON contract and pattern-matches the core's sealed result sums into HTTP status codes.
Feature modules (pk-auth-backup-codes, pk-auth-magic-link, pk-auth-otp, pk-auth-refresh-tokens, pk-auth-admin-api) and persistence modules (pk-auth-persistence-jdbi, pk-auth-persistence-dynamodb, in-memory pk-auth-testkit) implement core-declared SPIs and are wired in by the host. (pk-auth-admin-api hosts AdminService / AdminResult<T> and the account/credential/backup-code/email/phone admin operations mounted by all three adapters at /auth/admin/**.)
- Sealed result sums, not exceptions. Ceremony and admin operations return sealed interfaces —
AdminResult<T>(Success | NotFound | Forbidden | ValidationFailed | Conflict | RateLimited, declared inpk-auth-admin-api),RegistrationResult,AssertionResult(core),RotateResult(pk-auth-refresh-tokens),JwtVerificationResult(pk-auth-jwt). Adapters map these to HTTP; never throw across that boundary. When you add a variant, every adapter's*ResultMappermust handle it. - Wire bytes are base64url, no padding (RFC 4648 §5). Jackson 3 adapters get this from
PkAuthObjectMappers.pkAuthModule(); the Dropwizard adapter is still on Jackson 2 and uses thePkAuthJacksonBridge. finishendpoints are not idempotent — challenges are single-use viaChallengeStore.takeOnce. There is no shared transaction across SPIs:takeOnceis consumed beforeCredentialRepository.save; a failed save forces a ceremony restart. This is intentional — seedocs/transactional-semantics.md.- All three adapters mount identical
/auth/**paths (/auth/passkeys/**,/auth/refresh,/auth/admin/**) — Dropwizard's Jersey resources use the same@Path("/auth/passkeys")etc. as Spring/Micronaut, so the TS SDK targets one path scheme everywhere (no per-client path override). - DI annotations differ by adapter on purpose (
CONTRIBUTING.md§9): Spring and Micronaut auto-detect the single constructor — do not add@Autowired/@Inject. Dropwizard's Dagger 2 wiring requires@Injecton the injected constructor. Match the module you're in. - Atomic-claim operations return
booleanso the caller can detect a race-lost claim — JDBI uses conditionalUPDATE ... WHERE consumed_at IS NULL; DynamoDB usesConditionExpression(failed condition →ConditionalCheckFailedExceptionmapped toConflict/Expired). - DynamoDB is single-table (
DynamoDbTable<T>per item type; refresh tokens write 3 items per token for the jti/user/family indexes). JDBI uses Flyway migrations atpk-auth-persistence-jdbi/src/main/resources/db/migration/— bumpPkAuthJdbiSchema.CURRENT_SCHEMA_VERSIONwhen adding one.
- SPDX header
// SPDX-License-Identifier: MITon every Java file (Spotless adds/checks it). @since X.Y.ZJavadoc on every new/modified public API element across the library modules (the in-flight target is the active version ingradle.propertieswith the-SNAPSHOTsuffix dropped — always checkgradle.propertiesrather than trusting a hardcoded number here). Full policy inCONTRIBUTING.md§7.- JSpecify null discipline —
@NonNull/@Nullableon every public param and return; Error Prone enforces it. Compilation runs-Xlint:allwith strict Error Prone. - Records for DTOs/configs/result variants; sealed interfaces for closed sums. Public API sealed via
module-info.javaexports. - Conventional commits (
feat(core):,fix(jdbi):,docs(adr):,build:,ci:). Small, atomic. - New dependencies go through
gradle/libs.versions.toml(the version catalog) and are justified in the commit/ADR. Build conventions live inbuild-logic/(pkauth.java-conventions,library-conventions,test-conventions,publish-conventions), applied per-module. - No
TODOin main without a linked GitHub issue. - Non-trivial cross-module decisions get an ADR under
docs/adr/, numbered sequentially. - JaCoCo gate: ≥ 80% line coverage on
pk-auth-core, ≥ 70% on adapters (wired per-module viaJacocoCoverageVerification).
clients/passkeys-browser/ is a zero-dep TypeScript SDK (@pk-auth/passkeys-browser, ESM + CJS, vitest-tested). Its dist/ is gitignored — Gradle's :buildPasskeysBrowserSdk runs npm ci && npm run build (tsup) and the demos' processResources copy the output in. The Gradle build is the source of truth; don't commit built bundles.