Initial Commit
This commit is contained in:
161
openspec/specs/api-auth-jwt/spec.md
Normal file
161
openspec/specs/api-auth-jwt/spec.md
Normal file
@@ -0,0 +1,161 @@
|
||||
<!-- 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 -->
|
||||
Reference in New Issue
Block a user