Files
suaveplan/openspec/specs/api-auth-jwt/spec.md
2026-05-27 23:29:53 +01:00

162 lines
7.3 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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 <jwt>` 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<Principal | null>` and `dispose(): Promise<void>` 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 <jwt>`) 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=<jwt>`
- **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"`
<!-- markdownlint-enable MD024 -->