Files
2026-05-27 23:29:53 +01:00

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> 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"

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).

  • WHEN cookieName: "tok" is configured AND a request carries Cookie: tok=<jwt>
  • THEN the resolver returns the projected Principal
  • 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"