7.3 KiB
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>anddispose(): 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
HS256is presented to a resolver whosealgorithms: ["RS256"] - THEN
resolve()returnsnull
Scenario: JWKS kid miss triggers a refresh
- WHEN a token's
kidis 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
runAuthSessionConformanceruns over a resolver built withstaticKey(HS256) - THEN every invariant passes (null on unrecognised request, expected
Principalon recognised request, idempotentdispose)
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.claimsToPrincipalreadsorg_idinstead oftenant_id - THEN the produced
Principal.tenantIdreflectsorg_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 carriesCookie: 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
AuthJwtErrorwith codeAUTH_JWT.INVALID_KEY_FORMATis 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
expin 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
nameis"userAuth"ANDvalidateSecuritySchemeaccepts 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()throwsAuthJwtErrorwith codeAUTH_JWT.JWKS_FETCH_FAILED
Verified by: tests/integration/integration.test.ts::"propagates JWKS_FETCH_FAILED when endpoint returns 500"