first commit
This commit is contained in:
135
docs/api-stack/api/api-auth-jwt/README.md
Normal file
135
docs/api-stack/api/api-auth-jwt/README.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# @suaveplan/api-auth-jwt — concrete JWT session resolver for @suaveplan/api-auth. Verifies a JWT presented on the Authorization header (or a configurable cookie) using @suaveplan/jws against either a JWKS endpoint (TTL + negative-cache + in-flight dedup) or a single static JsonWebKey; projects validated claims onto a Principal via an overridable mapper. Returns null on parse/verify failure (chain semantics) and contributes a Bearer JWT OpenAPI security scheme.
|
||||
|
||||
Stage-4 concrete `SessionResolver` for the
|
||||
[`@suaveplan/api`](../api/README.md) plugin system. Verifies a JWT
|
||||
presented on the `Authorization: Bearer <jwt>` header (or a configured
|
||||
cookie) against either a remote JWKS endpoint or a single static JWK,
|
||||
then projects the validated claims onto the foundation's `Principal`
|
||||
contract via an overridable mapper.
|
||||
|
||||
This is the adapter you pair with hosted identity providers (Clerk,
|
||||
Auth0, Cognito, Stytch, custom Keycloak deployments) or with any
|
||||
self-hosted JWT-issuing provider. It is **session-resolver only** —
|
||||
login / register / refresh routes are the responsibility of an `auth`
|
||||
provider, not this package.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [claims-mapper](./src/claims-mapper/claims-mapper.md) — Default and runtime helpers for converting a verified JWT payload (`Claims`) into a `Principal` from `@suaveplan/api-ty…
|
||||
- [claims-mapper](./src/claims-mapper/claims-mapper.md) — Default and runtime helpers for converting a verified JWT payload (`Claims`) into a `Principal` from `@suaveplan/api-ty…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/api-auth-jwt`, exposing the `AUTH_JWT.*` namespace via `defineCodes` from `@…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/api-auth-jwt`, exposing the `AUTH_JWT.*` namespace via `defineCodes` from `@…
|
||||
- [jwks-cache](./src/jwks-cache/jwks-cache.md) — In-process JWKS cache for `@suaveplan/api-auth-jwt`. Fetches a JWKS document from a remote endpoint, holds it under a p…
|
||||
- [jwks-cache](./src/jwks-cache/jwks-cache.md) — In-process JWKS cache for `@suaveplan/api-auth-jwt`. Fetches a JWKS document from a remote endpoint, holds it under a p…
|
||||
- [options](./src/options/options.md) — Defines the Zod-validated boundary for `createJwtSessionResolver` and the companion `validateOptions` helper. The bound…
|
||||
- [options](./src/options/options.md) — Defines the Zod-validated boundary for `createJwtSessionResolver` and the companion `validateOptions` helper. The bound…
|
||||
- [resolver](./src/resolver/resolver.md) — Stage-4 concrete `SessionResolver` factory for the API plugin system. Reads a JWT from the request (Authorization h…
|
||||
- [resolver](./src/resolver/resolver.md) — Stage-4 concrete `SessionResolver` factory for the API plugin system. Reads a JWT from the request (Authorization h…
|
||||
- [security-scheme](./src/security-scheme/security-scheme.md) — Builds the `SecuritySchemeContribution` this adapter contributes to the generated OpenAPI document. The shape is the ca…
|
||||
- [security-scheme](./src/security-scheme/security-scheme.md) — Builds the `SecuritySchemeContribution` this adapter contributes to the generated OpenAPI document. The shape is the ca…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Surface
|
||||
|
||||
- [`./resolver`](src/resolver/resolver.md) — `createJwtSessionResolver(opts)`.
|
||||
- [`./jwks-cache`](src/jwks-cache/jwks-cache.md) — `JwksCache` with TTL,
|
||||
negative cache, in-flight dedup, `kid`-miss refresh.
|
||||
- [`./claims-mapper`](src/claims-mapper/claims-mapper.md) —
|
||||
`defaultClaimsToPrincipal` + `runClaimsMapper` helpers.
|
||||
- [`./security-scheme`](src/security-scheme/security-scheme.md) —
|
||||
`JWT_BEARER_SCHEME` and `createJwtBearerSecurityScheme(name)` for
|
||||
OpenAPI emission.
|
||||
- [`./error-codes`](src/error-codes/error-codes.md) — `AUTH_JWT_*` codes
|
||||
+ `AuthJwtError` class.
|
||||
- [`./options`](src/options/options.md) — Zod-validated options
|
||||
boundary.
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { createJwtSessionResolver } from "@suaveplan/api-auth-jwt/resolver";
|
||||
import { JWT_BEARER_SCHEME } from "@suaveplan/api-auth-jwt/security-scheme";
|
||||
|
||||
const resolver = createJwtSessionResolver({
|
||||
jwksUrl: "https://idp.example/.well-known/jwks.json",
|
||||
issuer: "https://idp.example",
|
||||
audience: "https://api.example",
|
||||
algorithms: ["RS256"],
|
||||
});
|
||||
|
||||
// Use in an api-host AuthAdapter:
|
||||
const adapter = {
|
||||
resolver,
|
||||
securityScheme: JWT_BEARER_SCHEME,
|
||||
};
|
||||
```
|
||||
|
||||
## Pairing with hosted IdPs
|
||||
|
||||
The package is designed to be paired with an external identity provider
|
||||
that already issues JWTs. The recipe is the same for every provider —
|
||||
plug in the provider's JWKS URL, issuer, and audience, then add a custom
|
||||
`claimsToPrincipal` when the provider uses non-standard claim names.
|
||||
|
||||
### Clerk
|
||||
|
||||
```ts
|
||||
createJwtSessionResolver({
|
||||
jwksUrl: "https://your-instance.clerk.accounts.dev/.well-known/jwks.json",
|
||||
issuer: "https://your-instance.clerk.accounts.dev",
|
||||
audience: "your-frontend-api",
|
||||
algorithms: ["RS256"],
|
||||
claimsToPrincipal: (claims) => {
|
||||
const sub = claims["sub"];
|
||||
if (typeof sub !== "string") return null;
|
||||
return {
|
||||
id: sub as never,
|
||||
scopes: [] as never[],
|
||||
tenantId: typeof claims["org_id"] === "string"
|
||||
? (claims["org_id"] as never)
|
||||
: undefined,
|
||||
claims,
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Auth0
|
||||
|
||||
```ts
|
||||
createJwtSessionResolver({
|
||||
jwksUrl: "https://your-tenant.auth0.com/.well-known/jwks.json",
|
||||
issuer: "https://your-tenant.auth0.com/",
|
||||
audience: "https://api.example.com",
|
||||
algorithms: ["RS256"],
|
||||
});
|
||||
```
|
||||
|
||||
### Cognito
|
||||
|
||||
```ts
|
||||
createJwtSessionResolver({
|
||||
jwksUrl: "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_XXXXXX/.well-known/jwks.json",
|
||||
issuer: "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_XXXXXX",
|
||||
audience: "your-cognito-app-client-id",
|
||||
algorithms: ["RS256"],
|
||||
});
|
||||
```
|
||||
|
||||
## Semantics
|
||||
|
||||
`resolve()` returns `null` on any per-request soft failure (missing
|
||||
token, malformed token, bad signature, wrong issuer, wrong audience,
|
||||
expired, not-yet-valid, no matching JWKS key). That collapse lets the
|
||||
upstream `SessionResolverChain` fall through to the next adapter
|
||||
(anonymous, static-bearer, etc.). It throws only on infrastructure
|
||||
failures — `AUTH_JWT.JWKS_FETCH_FAILED` when the JWKS endpoint is
|
||||
unreachable, `AUTH_JWT.INVALID_KEY_FORMAT` for a malformed static key,
|
||||
`AUTH_JWT.INVALID_CLAIMS_MAPPER` when the caller's mapper throws or
|
||||
returns a non-Principal value.
|
||||
|
||||
## License
|
||||
|
||||
MIT — see the repository.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# `claims-mapper` — `Claims → Principal` projection
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Default and runtime helpers for converting a verified JWT payload
|
||||
(`Claims`) into a `Principal` from `@suaveplan/api-types`. The default
|
||||
projection follows OAuth 2 / OIDC conventions: `sub` for subject,
|
||||
space-delimited `scope` (or array-form `scopes`) for authorisation
|
||||
scopes, and `tenant_id` / `tid` for the tenant. Callers can override
|
||||
the projection entirely to read non-standard claims (e.g. Clerk's
|
||||
`org_id`) or to enforce additional invariants — return `null` to defer
|
||||
to the next resolver in the chain.
|
||||
|
||||
## Features
|
||||
|
||||
- `defaultClaimsToPrincipal` — drop-in OAuth 2 / OIDC projection.
|
||||
- `runClaimsMapper` — wraps a caller-supplied mapper; catches throws,
|
||||
rejects non-Principal returns, surfaces both as
|
||||
`AUTH_JWT.INVALID_CLAIMS_MAPPER`.
|
||||
- Returns `null` when the token has no `sub` — that is the documented
|
||||
"not a user identity" path and lets the chain move on.
|
||||
- Brands `PrincipalId`, `Scope`, and `TenantId` via type assertion at
|
||||
the trust boundary; downstream consumers see opaque branded strings.
|
||||
- Passes the full claims record through as `principal.claims` so
|
||||
downstream tenant resolvers / authorisation rules can read raw values
|
||||
without a second token parse.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { defaultClaimsToPrincipal } from "@suaveplan/api-auth-jwt/claims-mapper";
|
||||
|
||||
const principal = defaultClaimsToPrincipal({
|
||||
sub: "user_123",
|
||||
scope: "billing:read profile:read",
|
||||
tenant_id: "acme",
|
||||
});
|
||||
// → { id: "user_123", scopes: ["billing:read", "profile:read"], tenantId: "acme", claims: {...} }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Override the projection to read Clerk-style `org_id` and enforce a
|
||||
non-empty scopes list:
|
||||
|
||||
```ts
|
||||
import type { ClaimsToPrincipal } from "@suaveplan/api-auth-jwt/claims-mapper";
|
||||
|
||||
const clerkMapper: ClaimsToPrincipal = (claims) => {
|
||||
const sub = claims["sub"];
|
||||
const scope = claims["scope"];
|
||||
const org = claims["org_id"];
|
||||
if (typeof sub !== "string" || typeof scope !== "string") return null;
|
||||
return {
|
||||
id: sub as never,
|
||||
scopes: scope.split(" ") as never[],
|
||||
tenantId: typeof org === "string" ? (org as never) : undefined,
|
||||
claims,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ClaimsToPrincipal` — `(claims) => Principal | null`.
|
||||
- `defaultClaimsToPrincipal: ClaimsToPrincipal` — the built-in projection.
|
||||
- `runClaimsMapper(mapper, claims): Principal | null` — invoke a mapper
|
||||
with structured error reporting.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The default mapper deliberately tolerates either string-form (`"a b c"`)
|
||||
or array-form (`["a", "b", "c"]`) scopes — RFC 6749 §3.3 uses the
|
||||
former, while several IdPs (Auth0, Cognito) emit the latter. Empty
|
||||
strings are filtered out so a stray double-space does not produce a
|
||||
zero-length scope token. Tenant extraction prefers `tenant_id` (the
|
||||
Suaveplan-canonical name) over `tid` (the Microsoft / OIDC name) but
|
||||
accepts both. A mapper that throws is treated as an operator bug, not a
|
||||
caller error — surfacing it as a `null` would silently mask broken
|
||||
configuration.
|
||||
@@ -0,0 +1,76 @@
|
||||
# `error-codes` — `AUTH_JWT_*` stable codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Stable error-code registry for `@suaveplan/api-auth-jwt`, exposing the
|
||||
`AUTH_JWT.*` namespace via `defineCodes` from `@suaveplan/error` and a
|
||||
matching `AuthJwtError` subclass of `SuaveplanError`. The registry lists
|
||||
only the codes the package throws — namely the infrastructure failures an
|
||||
operator must act on. Per-request soft failures (bad token, expired
|
||||
signature, wrong audience, etc.) deliberately do not get codes; they
|
||||
collapse to `null` so that the upstream `SessionResolverChain` can move on
|
||||
to the next resolver, which is the documented chain semantic.
|
||||
|
||||
## Features
|
||||
|
||||
- `AUTH_JWT_CODES.JWKS_FETCH_FAILED` — JWKS endpoint unreachable or
|
||||
returned a non-2xx after the configured retries, and there is no cached
|
||||
set old enough to use. Operators see this in logs / metrics; callers
|
||||
see a 401.
|
||||
- `AUTH_JWT_CODES.INVALID_KEY_FORMAT` — `options.staticKey` failed JWK
|
||||
schema validation at construction time.
|
||||
- `AUTH_JWT_CODES.INVALID_CLAIMS_MAPPER` — `options.claimsToPrincipal`
|
||||
threw or returned a value that is not a `Principal` or `null`.
|
||||
- `AuthJwtError` carries the matching `AuthJwtErrorCode`, integrates with
|
||||
the `SuaveplanError` cause-chain, redaction, and OTel span-event
|
||||
adapters from `@suaveplan/error`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { AUTH_JWT_CODES, AuthJwtError } from "@suaveplan/api-auth-jwt/error-codes";
|
||||
|
||||
try {
|
||||
// ... resolver internals ...
|
||||
} catch (cause) {
|
||||
throw new AuthJwtError(
|
||||
AUTH_JWT_CODES.JWKS_FETCH_FAILED,
|
||||
"JWKS endpoint unreachable",
|
||||
{ cause }
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Branch on the code at the boundary to map infrastructure failures to
|
||||
operator alerts rather than user-facing 401s:
|
||||
|
||||
```ts
|
||||
import { AUTH_JWT_CODES, AuthJwtError } from "@suaveplan/api-auth-jwt/error-codes";
|
||||
|
||||
function isOperatorActionable(err: unknown): err is AuthJwtError {
|
||||
if (!(err instanceof AuthJwtError)) return false;
|
||||
return err.code === AUTH_JWT_CODES.JWKS_FETCH_FAILED;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `AUTH_JWT_CODES` — frozen record produced by `defineCodes("AUTH_JWT", [...])`.
|
||||
- `AuthJwtErrorCode` — union of the three string literals.
|
||||
- `AuthJwtError extends SuaveplanError<AuthJwtErrorCode>` — `jwtCode`
|
||||
mirrors `code` for ergonomic `instanceof` narrowing.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The three-code registry is intentionally small. Adding "soft" failure
|
||||
codes here would tempt consumers to branch on them, defeating the chain
|
||||
semantic that `null` means "not my caller". If a richer breakdown is
|
||||
needed downstream, emit it on the failure counter (`reason` attribute)
|
||||
rather than as a separate `AUTH_JWT.*` code. Registry membership is the
|
||||
public contract; only add codes through an OpenSpec change.
|
||||
91
docs/api-stack/api/api-auth-jwt/src/jwks-cache/jwks-cache.md
Normal file
91
docs/api-stack/api/api-auth-jwt/src/jwks-cache/jwks-cache.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# `jwks-cache` — TTL-bounded JWKS resolution
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
In-process JWKS cache for `@suaveplan/api-auth-jwt`. Fetches a JWKS
|
||||
document from a remote endpoint, holds it under a positive TTL,
|
||||
negative-caches transient failures, dedupes concurrent fetches into a
|
||||
single network request, and supports forced refresh on a `kid` miss
|
||||
(the standard "the IdP rotated keys" recovery path). Throws
|
||||
`AUTH_JWT.JWKS_FETCH_FAILED` only when neither a fresh fetch nor a stale
|
||||
cached document is available — under partial outages we prefer
|
||||
stale-but-valid keys to a 401 storm.
|
||||
|
||||
## Features
|
||||
|
||||
- Positive TTL (default 10 minutes) with monotonic clock injection.
|
||||
- Negative cache window (default 30 seconds) on fetch failure — within
|
||||
the window callers see the stale document if any, otherwise the
|
||||
error.
|
||||
- In-flight dedup: concurrent `getKeyForKid` callers share a single
|
||||
network round-trip.
|
||||
- `forceRefresh()` for operator-triggered rotation.
|
||||
- `kid` miss against a cached document triggers exactly one refresh.
|
||||
- JSON shape validation (`{ keys: object[] }`) at the boundary.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { JwksCache } from "@suaveplan/api-auth-jwt/jwks-cache";
|
||||
|
||||
const cache = new JwksCache({
|
||||
url: "https://idp.example/.well-known/jwks.json",
|
||||
ttlMs: 10 * 60 * 1000,
|
||||
negativeMs: 30 * 1000,
|
||||
clock: { nowMs: () => Date.now() },
|
||||
fetchImpl: fetch,
|
||||
});
|
||||
|
||||
const jwk = await cache.getKeyForKid("kid-2024-01");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Inject a fake clock and a controllable fetch impl for tests of the
|
||||
expiry and dedup behaviours:
|
||||
|
||||
```ts
|
||||
let now = 0;
|
||||
const calls: number[] = [];
|
||||
const cache = new JwksCache({
|
||||
url: "https://idp.example/.well-known/jwks.json",
|
||||
ttlMs: 1000,
|
||||
negativeMs: 100,
|
||||
clock: { nowMs: () => now },
|
||||
fetchImpl: async () => {
|
||||
calls.push(now);
|
||||
return new Response(JSON.stringify({ keys: [{ kid: "a", kty: "RSA", n: "...", e: "AQAB" }] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
},
|
||||
});
|
||||
await cache.getKeyForKid("a");
|
||||
now = 500;
|
||||
await cache.getKeyForKid("a"); // cache hit, no second fetch
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class JwksCache` — constructor takes `{ url, ttlMs, negativeMs, clock,
|
||||
fetchImpl }`.
|
||||
- `getKeyForKid(kid?: string): Promise<Record<string, unknown> | undefined>`
|
||||
— fetch-as-needed, returns `undefined` when no key matches.
|
||||
- `forceRefresh(): Promise<JwksDocument>` — clear cache and refetch.
|
||||
- `JwksDocument` — `{ keys: ReadonlyArray<Record<string, unknown>> }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
When the token carries no `kid`, the cache only returns a key when the
|
||||
JWKS contains exactly one entry — ambiguous lookups must be a 401, not
|
||||
a guess. The negative cache window is short by design (30s default);
|
||||
longer windows accumulate authentication outages, shorter windows
|
||||
hammer the IdP. The in-flight dedup uses a single `Promise` field
|
||||
rather than a queue — the second concurrent caller awaits the same
|
||||
promise as the first. The "kid miss after a just-fetched document"
|
||||
guard prevents infinite refresh loops when the IdP legitimately does
|
||||
not have a matching key.
|
||||
82
docs/api-stack/api/api-auth-jwt/src/options/options.md
Normal file
82
docs/api-stack/api/api-auth-jwt/src/options/options.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# `options` — `JwtSessionResolverOptions` boundary
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the Zod-validated boundary for `createJwtSessionResolver` and the
|
||||
companion `validateOptions` helper. The boundary enforces every static
|
||||
contract the runtime depends on: exactly one of `jwksUrl` or `staticKey`,
|
||||
non-empty `algorithms` allowlist, mandatory `issuer` and `audience`, a
|
||||
positive cache TTL, and a non-negative negative-cache window. Anything
|
||||
the schema cannot encode (injectable clock, fetch impl, claims mapper
|
||||
function) is part of the wider `JwtSessionResolverOptions` TypeScript
|
||||
type but is not Zod-validated — those are dev-time contracts.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict-mode object schema rejects unknown keys at construction.
|
||||
- Mutual exclusion check for `jwksUrl` / `staticKey` — both supplied or
|
||||
neither supplied throws `AUTH_JWT.INVALID_KEY_FORMAT`.
|
||||
- Sensible defaults: header name `authorization`, JWKS TTL 10 min,
|
||||
negative cache 30s, clock tolerance 0.
|
||||
- Accepts `issuer` and `audience` as either a single string or a
|
||||
non-empty list of strings — JWS verify enforces membership.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { validateOptions } from "@suaveplan/api-auth-jwt/options";
|
||||
|
||||
const opts = validateOptions({
|
||||
jwksUrl: "https://idp.example/.well-known/jwks.json",
|
||||
issuer: "https://idp.example",
|
||||
audience: "https://api.example",
|
||||
algorithms: ["RS256"],
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Custom header, cookie fallback, tighter cache TTL, and a custom claims
|
||||
mapper:
|
||||
|
||||
```ts
|
||||
import { validateOptions } from "@suaveplan/api-auth-jwt/options";
|
||||
|
||||
const opts = validateOptions({
|
||||
staticKey: { kty: "oct", k: "..." },
|
||||
issuer: ["https://idp1.example", "https://idp2.example"],
|
||||
audience: "https://api.example",
|
||||
algorithms: ["HS256"],
|
||||
headerName: "x-api-token",
|
||||
cookieName: "sp_session",
|
||||
clockToleranceSec: 5,
|
||||
jwksCacheTtlMs: 60_000,
|
||||
jwksNegativeCacheMs: 5_000,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `JwtSessionResolverOptionsSchema` — Zod object schema.
|
||||
- `JwtSessionResolverOptions` — public surface (includes non-serialisable
|
||||
fields like `clock`, `fetchImpl`, `claimsToPrincipal`).
|
||||
- `ValidatedJwtSessionResolverOptions` — inferred shape after defaults
|
||||
are applied.
|
||||
- `validateOptions(raw)` — synchronous validator; throws `AuthJwtError`
|
||||
with code `AUTH_JWT.INVALID_KEY_FORMAT` on any boundary failure.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema is intentionally `.strict()` so a typo in a key name fails
|
||||
loudly at construction rather than silently activating defaults. `iss`
|
||||
and `aud` are both required because token-level `iss` / `aud` confusion
|
||||
is a documented attack vector — every Stage-4 deployment is expected to
|
||||
pin both. `algorithms` is also mandatory and non-empty — `@suaveplan/jws`
|
||||
already refuses to verify without an allowlist, but we re-enforce it at
|
||||
the API boundary so the error surface is consistent. The mutual
|
||||
exclusion check after parsing matches what the spec calls out: a single
|
||||
key source is the only way to reason about JWKS cache invalidation.
|
||||
108
docs/api-stack/api/api-auth-jwt/src/resolver/resolver.md
Normal file
108
docs/api-stack/api/api-auth-jwt/src/resolver/resolver.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# `resolver` — `createJwtSessionResolver`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Stage-4 concrete `SessionResolver` factory for the API plugin
|
||||
foundation. Reads a JWT from the request (Authorization header or
|
||||
configurable cookie), verifies it via `@suaveplan/jws` against either a
|
||||
JWKS endpoint (cached and refreshed on `kid` miss) or a single static
|
||||
JWK, validates `iss`, `aud`, `exp`, `nbf` against the configured policy,
|
||||
and projects the validated claims onto a `Principal` via an overridable
|
||||
mapper. The factory is the only public entry point — internals
|
||||
(`JwksCache`, claims mapper, error registry) are surface-exported for
|
||||
testability but most consumers use `createJwtSessionResolver(opts)` alone.
|
||||
|
||||
## Features
|
||||
|
||||
- `Authorization: Bearer <jwt>` header extraction with case-insensitive
|
||||
scheme match; configurable header name accepts a raw token form for
|
||||
non-standard headers (e.g. `x-api-token: <jwt>`).
|
||||
- Cookie fallback when `cookieName` is supplied; header still wins when
|
||||
both are present.
|
||||
- JWKS resolution via `JwksCache` with positive TTL, negative cache,
|
||||
in-flight dedup, and `kid`-miss refresh.
|
||||
- Static-key path for HS-family symmetric tokens or single-key
|
||||
deployments.
|
||||
- Mandatory `iss` and `aud` enforcement (single string or list); `exp`
|
||||
and `nbf` enforced when present with configurable `clockToleranceSec`.
|
||||
- Per-request soft failures collapse to `null` so the upstream
|
||||
`SessionResolverChain` can fall through to the next adapter; operator
|
||||
failures (JWKS unreachable, malformed mapper, malformed static key)
|
||||
surface as `AuthJwtError` with the documented codes.
|
||||
- Idempotent `dispose()` flips an internal flag so subsequent
|
||||
`resolve()` calls return `null` rather than emitting late traffic on
|
||||
a torn-down resolver.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createJwtSessionResolver } from "@suaveplan/api-auth-jwt/resolver";
|
||||
|
||||
const resolver = createJwtSessionResolver({
|
||||
jwksUrl: "https://idp.example/.well-known/jwks.json",
|
||||
issuer: "https://idp.example",
|
||||
audience: "https://api.example",
|
||||
algorithms: ["RS256"],
|
||||
});
|
||||
|
||||
const req = new Request("https://api.example/users", {
|
||||
headers: { authorization: "Bearer eyJ..." },
|
||||
});
|
||||
const principal = await resolver.resolve(req);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Static HS256 key, cookie fallback, custom claims mapper, and an
|
||||
injectable clock for deterministic temporal-claim tests:
|
||||
|
||||
```ts
|
||||
import { createJwtSessionResolver } from "@suaveplan/api-auth-jwt/resolver";
|
||||
|
||||
const resolver = createJwtSessionResolver({
|
||||
staticKey: { kty: "oct", k: "<base64url-secret>" },
|
||||
issuer: ["https://idp1.example", "https://idp2.example"],
|
||||
audience: "https://api.example",
|
||||
algorithms: ["HS256"],
|
||||
cookieName: "sp_session",
|
||||
clockToleranceSec: 5,
|
||||
clock: { nowMs: () => 1_700_000_000_000 },
|
||||
claimsToPrincipal: (claims) => {
|
||||
const sub = claims["sub"];
|
||||
if (typeof sub !== "string") return null;
|
||||
return {
|
||||
id: sub as never,
|
||||
scopes: [] as never[],
|
||||
tenantId: typeof claims["org_id"] === "string"
|
||||
? (claims["org_id"] as never)
|
||||
: undefined,
|
||||
claims,
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createJwtSessionResolver(opts: JwtSessionResolverOptions): SessionResolver`
|
||||
— returns the `@suaveplan/api-auth` `SessionResolver` shape (`resolve` +
|
||||
idempotent `dispose`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The resolver deliberately separates "this is not my caller" (chain
|
||||
falls through, returns `null`) from "the host is mis-configured"
|
||||
(throws `AuthJwtError`). Verification failures inside `@suaveplan/jws`
|
||||
return `null` so a stray Bearer header from an unrelated IdP cannot
|
||||
401-storm the API — the chain's next adapter (anonymous, static key,
|
||||
etc.) still gets its turn. The temporal-claim check uses a single
|
||||
clock reading per request to avoid TOCTOU between the `exp` and `nbf`
|
||||
arms. We re-implement `iss`/`aud`/`exp`/`nbf` enforcement here rather
|
||||
than push it into `@suaveplan/jws` because those are JWT-claim
|
||||
concerns, not JWS-protected-header concerns — the JWS package
|
||||
deliberately handles only RFC 7515 (signature) and leaves RFC 7519
|
||||
(payload claims) to its consumers.
|
||||
@@ -0,0 +1,66 @@
|
||||
# `security-scheme` — OpenAPI Bearer JWT contribution
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Builds the `SecuritySchemeContribution` this adapter contributes to the
|
||||
generated OpenAPI document. The shape is the canonical OAS 3.1
|
||||
`http` / `bearer` scheme with `bearerFormat: "JWT"`. Stage-8
|
||||
`@suaveplan/api-openapi` consumes the contribution and inlines it under
|
||||
`components.securitySchemes[name]`, then references it from operation
|
||||
`security` blocks for non-public routes.
|
||||
|
||||
## Features
|
||||
|
||||
- Canonical Bearer-JWT scheme — no surprise extensions, no proprietary
|
||||
fields.
|
||||
- Configurable contribution name (`bearerAuth` default) so multiple JWT
|
||||
resolvers can coexist (e.g. user-side `bearerAuth` + service-side
|
||||
`serviceBearerAuth`).
|
||||
- `JWT_BEARER_SCHEME` constant for callers that only ever expose one
|
||||
JWT resolver.
|
||||
- Human-readable `description` field that links the scheme back to the
|
||||
adapter so docs readers understand where verification happens.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { JWT_BEARER_SCHEME } from "@suaveplan/api-auth-jwt/security-scheme";
|
||||
|
||||
const adapter = {
|
||||
resolver: createJwtSessionResolver(...).resolver,
|
||||
securityScheme: JWT_BEARER_SCHEME,
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Rename the contribution for a dual-resolver host:
|
||||
|
||||
```ts
|
||||
import { createJwtBearerSecurityScheme } from "@suaveplan/api-auth-jwt/security-scheme";
|
||||
|
||||
const userScheme = createJwtBearerSecurityScheme("userAuth");
|
||||
const serviceScheme = createJwtBearerSecurityScheme("serviceAuth");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createJwtBearerSecurityScheme(name = "bearerAuth")` →
|
||||
`SecuritySchemeContribution` from `@suaveplan/api-auth`.
|
||||
- `JWT_BEARER_SCHEME` — pre-built `bearerAuth`-named contribution.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `http` / `bearer` form is the only OAS 3.1 scheme that documents
|
||||
"this endpoint accepts a Bearer JWT" without forcing the OpenAPI
|
||||
emitter to inline OAuth-flow metadata that this adapter does not own.
|
||||
The `bearerFormat` field is documentation-only per the spec, but
|
||||
populating it as `"JWT"` is the convention every OAS reader (Swagger
|
||||
UI, Redoc, Stoplight) recognises. Tenant or scope expectations are
|
||||
expressed by individual route handlers, not the scheme itself —
|
||||
keeping the scheme orthogonal to the route catalogue keeps it stable
|
||||
across plugin upgrades.
|
||||
Reference in New Issue
Block a user