# Capability Spec: api-auth-jwt **Package**: `@suaveplan/api-auth-jwt` **Status**: SHIPPED (0.1.0) `@suaveplan/api-auth-jwt` is a Stage-4 concrete `SessionResolver` for the `@suaveplan/api` plugin foundation. It verifies a JWT presented on the `Authorization: Bearer ` header (or a configured cookie) against either a remote JWKS endpoint (TTL + negative-cache + in-flight dedup + `kid`-miss refresh) or a single static JWK using `@suaveplan/jws`, then projects the verified claims onto a `Principal` via an overridable mapper. It is session-resolver-only — no login, register, logout, or refresh routes — and is intended to be paired with hosted IdPs (Clerk, Auth0, Cognito) or with any JWT-issuing provider. ## Requirements ### Requirement: Session-resolver-only surface The package SHALL implement the `SessionResolver` contract from `@suaveplan/api-auth`. It MUST NOT contribute HTTP routes — login, logout, and refresh are provider responsibilities and live elsewhere. #### Scenario: factory returns a SessionResolver - **WHEN** `createJwtSessionResolver({ staticKey, issuer, audience, algorithms })` is called with valid options - **THEN** the returned object has `resolve(req): Promise` and `dispose(): Promise` AND no other methods **Verified by:** `src/resolver/resolver.test.ts::"resolves a valid Bearer token to a Principal"` ### Requirement: JWT verification against JWKS or static key The package SHALL verify tokens using `@suaveplan/jws` against either a JWKS URL (with TTL caching, negative cache, in-flight dedup, and `kid`-miss refresh) or a single static `JsonWebKey`. The two key sources are mutually exclusive at construction. Algorithms MUST be restricted to the allowlist in `options.algorithms`; any token whose protected-header `alg` is not on the allowlist returns `null`. #### Scenario: token signed with non-allowlisted alg - **WHEN** a token signed with `HS256` is presented to a resolver whose `algorithms: ["RS256"]` - **THEN** `resolve()` returns `null` #### Scenario: JWKS `kid` miss triggers a refresh - **WHEN** a token's `kid` is not in the cached JWKS AND the cache entry was served from cache (not freshly fetched) - **THEN** the JWKS endpoint is refetched exactly once AND the new key is looked up against the refreshed document **Verified by:** `src/resolver/resolver.test.ts::"returns null when alg is not on the allowlist"` AND `tests/integration/integration.test.ts::"refetches JWKS when kid rotates"` ### Requirement: Conformance to the api-auth SessionResolver contract The resolver SHALL pass `runAuthSessionConformance` from `@suaveplan/api-auth/testing`. #### Scenario: jwt-static-key conformance - **WHEN** `runAuthSessionConformance` runs over a resolver built with `staticKey` (HS256) - **THEN** every invariant passes (null on unrecognised request, expected `Principal` on recognised request, idempotent `dispose`) **Verified by:** `src/resolver/resolver.spec-conformance.test.ts` ### Requirement: Claim → Principal projection is overridable The package SHALL provide a `defaultClaimsToPrincipal` mapper that projects `sub` → `id`, `scope` (string) or `scopes` (array) → `scopes`, `tenant_id` or `tid` → `tenantId`, and the entire claims record → `claims`. Callers MAY supply `options.claimsToPrincipal` to override. A mapper that throws or returns a non-Principal value MUST raise `AUTH_JWT.INVALID_CLAIMS_MAPPER`. #### Scenario: custom mapper reads non-standard claim - **WHEN** `options.claimsToPrincipal` reads `org_id` instead of `tenant_id` - **THEN** the produced `Principal.tenantId` reflects `org_id` **Verified by:** `src/claims-mapper/claims-mapper.test.ts` AND `src/resolver/resolver.test.ts::"supports custom claimsToPrincipal"` ### Requirement: Token extraction honours header and cookie sources The package SHALL extract the token from the configured header (default `Authorization: Bearer `) or from a cookie named via `options.cookieName`. If both are present the header MUST win. The header name is configurable; non-standard header names accept a raw token form (no `Bearer ` prefix required). #### Scenario: cookie path yields a Principal - **WHEN** `cookieName: "tok"` is configured AND a request carries `Cookie: tok=` - **THEN** the resolver returns the projected `Principal` #### Scenario: header takes precedence over cookie - **WHEN** a request carries BOTH a valid Bearer header AND a valid cookie under `tok` - **THEN** the Principal reflects the header token's claims **Verified by:** `src/resolver/resolver.test.ts::"reads from cookie when configured"` AND `src/resolver/resolver.test.ts::"header wins over cookie when both are present"` ### Requirement: Options Zod-validated `JwtSessionResolverOptions` MUST be Zod-validated at construction. Supplying neither `jwksUrl` nor `staticKey`, or supplying both, MUST throw `AUTH_JWT.INVALID_KEY_FORMAT`. An empty `algorithms` allowlist MUST also throw `AUTH_JWT.INVALID_KEY_FORMAT`. #### Scenario: missing key source throws - **WHEN** `createJwtSessionResolver({ issuer, audience, algorithms })` - **THEN** a `AuthJwtError` with code `AUTH_JWT.INVALID_KEY_FORMAT` is thrown synchronously **Verified by:** `src/options/options.test.ts::"rejects when neither jwksUrl nor staticKey is supplied"` ### Requirement: Temporal claims enforcement The resolver SHALL reject tokens whose `exp` has elapsed (relative to the injected `clock`, with `clockToleranceSec` skew) AND whose `nbf` has not yet been reached. Issuer (`iss`) and audience (`aud`) MUST be checked against the configured allowlist; string and array forms of `aud` are both supported. #### Scenario: expired token rejected - **WHEN** a token with `exp` in the past (beyond tolerance) is presented - **THEN** the resolver returns `null` **Verified by:** `src/resolver/resolver.test.ts::"rejects expired token"` AND `src/resolver/resolver.test.ts::"rejects not-yet-valid token"` AND `src/resolver/resolver.test.ts::"honours clockToleranceSec for slightly expired tokens"` ### Requirement: OpenAPI security-scheme contribution The package SHALL export a `SecuritySchemeContribution` (from `@suaveplan/api-auth`) for the canonical Bearer JWT scheme (`http` / `bearer` / `bearerFormat: "JWT"`). The contribution name MUST default to `bearerAuth` and MUST be renameable. #### Scenario: renamed contribution - **WHEN** `createJwtBearerSecurityScheme("userAuth")` is called - **THEN** the returned contribution's `name` is `"userAuth"` AND `validateSecurityScheme` accepts it **Verified by:** `src/security-scheme/security-scheme.test.ts` ### Requirement: Infrastructure failures propagate, soft failures collapse to null JWKS endpoint failures (unreachable, non-2xx, non-JSON body, missing keys array) MUST raise `AUTH_JWT.JWKS_FETCH_FAILED`. Static-key shape failures MUST raise `AUTH_JWT.INVALID_KEY_FORMAT`. Claims mapper bugs MUST raise `AUTH_JWT.INVALID_CLAIMS_MAPPER`. Every other per-request failure MUST collapse to `null` so the upstream `SessionResolverChain` can fall through to the next adapter. #### Scenario: 500 on JWKS endpoint propagates - **WHEN** the JWKS endpoint returns HTTP 500 AND no cached document is available - **THEN** `resolve()` throws `AuthJwtError` with code `AUTH_JWT.JWKS_FETCH_FAILED` **Verified by:** `tests/integration/integration.test.ts::"propagates JWKS_FETCH_FAILED when endpoint returns 500"`