Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

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

View File

@@ -0,0 +1,8 @@
import { compactSign, compactVerify, jwkToCryptoKey } from "@suaveplan/jws";
const secret = new TextEncoder().encode("super-secret-test-key-32bytes!");
const tok = await compactSign(JSON.stringify({sub:"u1", iss:"i", aud:"a"}), secret, "HS256");
console.log("token", tok);
const jwk = { kty: "oct", k: Buffer.from(secret).toString("base64url") };
const key = jwkToCryptoKey(jwk, "HS256").key;
const res = await compactVerify(tok, key, { algorithms: ["HS256"] });
console.log("payload", res.payloadString);

View File

@@ -0,0 +1,97 @@
{
"name": "@suaveplan/api-auth-jwt",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Stage-4 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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./resolver": {
"import": {
"types": "./dist/resolver/resolver.d.ts",
"default": "./dist/resolver/resolver.js"
}
},
"./jwks-cache": {
"import": {
"types": "./dist/jwks-cache/jwks-cache.d.ts",
"default": "./dist/jwks-cache/jwks-cache.js"
}
},
"./claims-mapper": {
"import": {
"types": "./dist/claims-mapper/claims-mapper.d.ts",
"default": "./dist/claims-mapper/claims-mapper.js"
}
},
"./security-scheme": {
"import": {
"types": "./dist/security-scheme/security-scheme.d.ts",
"default": "./dist/security-scheme/security-scheme.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./options": {
"import": {
"types": "./dist/options/options.d.ts",
"default": "./dist/options/options.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/api-auth": "workspace:^",
"@suaveplan/api-types": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/jws": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/crypto-rsa": "workspace:^",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

View File

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

View File

@@ -0,0 +1,174 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AUTH_JWT_CODES,
type AuthJwtError,
} from "../error-codes/error-codes.js";
import { defaultClaimsToPrincipal, runClaimsMapper } from "./claims-mapper.js";
describe("defaultClaimsToPrincipal", () => {
it("projects sub + scope + tenant_id", { timeout: 5000 }, () => {
const p = defaultClaimsToPrincipal({
sub: "user_1",
scope: "billing:read profile:read",
tenant_id: "acme",
});
expect(p?.id).toBe("user_1");
expect(p?.scopes).toEqual(["billing:read", "profile:read"]);
expect(p?.tenantId).toBe("acme");
});
it("supports array-form scopes", { timeout: 5000 }, () => {
const p = defaultClaimsToPrincipal({
sub: "user_2",
scopes: ["a", "b"],
});
expect(p?.scopes).toEqual(["a", "b"]);
expect(p?.tenantId).toBeUndefined();
});
it("falls back to `tid` claim for tenant", { timeout: 5000 }, () => {
const p = defaultClaimsToPrincipal({
sub: "user_3",
tid: "ms-tenant",
});
expect(p?.tenantId).toBe("ms-tenant");
});
it("returns null when sub missing", { timeout: 5000 }, () => {
expect(defaultClaimsToPrincipal({})).toBeNull();
expect(defaultClaimsToPrincipal({ sub: "" })).toBeNull();
expect(defaultClaimsToPrincipal({ sub: 123 as never })).toBeNull();
});
it("filters empty scope segments", { timeout: 5000 }, () => {
const p = defaultClaimsToPrincipal({
sub: "u",
scope: "a b",
});
expect(p?.scopes).toEqual(["a", "b"]);
});
it("ignores non-string scopes array entries", { timeout: 5000 }, () => {
const p = defaultClaimsToPrincipal({
sub: "u",
scopes: ["a", 2 as never, "b"],
});
expect(p?.scopes).toEqual(["a", "b"]);
});
it("ignores non-string scope claim", { timeout: 5000 }, () => {
const p = defaultClaimsToPrincipal({
sub: "u",
scope: 7 as never,
});
expect(p?.scopes).toEqual([]);
});
it("ignores non-string tenant_id", { timeout: 5000 }, () => {
const p = defaultClaimsToPrincipal({
sub: "u",
tenant_id: 7 as never,
});
expect(p?.tenantId).toBeUndefined();
});
});
describe("runClaimsMapper", () => {
it("passes through valid Principal", { timeout: 5000 }, () => {
const p = runClaimsMapper(defaultClaimsToPrincipal, { sub: "u" });
expect(p?.id).toBe("u");
});
it("propagates null", { timeout: 5000 }, () => {
expect(runClaimsMapper(() => null, { sub: "u" })).toBeNull();
});
it("throws AUTH_JWT.INVALID_CLAIMS_MAPPER when mapper throws", {
timeout: 5000,
}, () => {
try {
runClaimsMapper(
() => {
throw new Error("boom");
},
{ sub: "u" }
);
throw new Error("expected throw");
} catch (err) {
expect((err as AuthJwtError).code).toBe(
AUTH_JWT_CODES.INVALID_CLAIMS_MAPPER
);
}
});
it("throws when mapper returns non-Principal", { timeout: 5000 }, () => {
try {
runClaimsMapper(() => ({ bogus: 1 }) as never, { sub: "u" });
throw new Error("expected throw");
} catch (err) {
expect((err as AuthJwtError).code).toBe(
AUTH_JWT_CODES.INVALID_CLAIMS_MAPPER
);
}
});
it("rejects mapper returning a non-object", { timeout: 5000 }, () => {
try {
runClaimsMapper(() => "u" as never, { sub: "u" });
throw new Error("expected throw");
} catch (err) {
expect((err as AuthJwtError).code).toBe(
AUTH_JWT_CODES.INVALID_CLAIMS_MAPPER
);
}
});
it("rejects mapper returning Principal-shape with null claims", {
timeout: 5000,
}, () => {
try {
runClaimsMapper(
() =>
({
id: "u",
scopes: [],
claims: null,
}) as never,
{ sub: "u" }
);
throw new Error("expected throw");
} catch (err) {
expect((err as AuthJwtError).code).toBe(
AUTH_JWT_CODES.INVALID_CLAIMS_MAPPER
);
}
});
it("rejects mapper missing string id", { timeout: 5000 }, () => {
try {
runClaimsMapper(
() => ({ id: 1, scopes: [], claims: {} }) as never,
{ sub: "u" }
);
throw new Error("expected throw");
} catch (err) {
expect((err as AuthJwtError).code).toBe(
AUTH_JWT_CODES.INVALID_CLAIMS_MAPPER
);
}
});
it("rejects mapper with non-array scopes", { timeout: 5000 }, () => {
try {
runClaimsMapper(
() => ({ id: "u", scopes: "a", claims: {} }) as never,
{ sub: "u" }
);
throw new Error("expected throw");
} catch (err) {
expect((err as AuthJwtError).code).toBe(
AUTH_JWT_CODES.INVALID_CLAIMS_MAPPER
);
}
});
});

View File

@@ -0,0 +1,105 @@
import type {
Claims,
Principal,
PrincipalId,
Scope,
} from "@suaveplan/api-types/principal";
import type { TenantId } from "@suaveplan/api-types/tenant";
import { AUTH_JWT_CODES, AuthJwtError } from "../error-codes/error-codes.js";
import type { ClaimsToPrincipal } from "./claims-mapper.types.js";
/**
* Default claims → Principal projection.
*
* - `sub` → `id`
* - `scope` (space-delimited string) or `scopes` (array) → `scopes`
* - `tenant_id` or `tid` → `tenantId`
* - remaining claims passed through verbatim as `claims`
*
* Returns `null` when `sub` is missing or not a string. RFC 7519 §4.1.2
* marks `sub` as optional but in practice every IdP we target issues it;
* a token without `sub` is treated as "not a user identity" and deferred
* to the next resolver rather than synthesised.
*/
export const defaultClaimsToPrincipal: ClaimsToPrincipal = (claims) => {
const sub = claims["sub"];
if (typeof sub !== "string" || sub.length === 0) {
return null;
}
const scopes = extractScopes(claims);
const tenantId = extractTenantId(claims);
const principal: Principal = tenantId
? {
id: sub as PrincipalId,
scopes,
tenantId,
claims,
}
: {
id: sub as PrincipalId,
scopes,
claims,
};
return principal;
};
/**
* Run a caller-supplied claims mapper, catching synchronous throws and
* surfacing them as `AUTH_JWT.INVALID_CLAIMS_MAPPER`. A mapper that
* returns `null` is the documented "not my caller" path.
*/
export function runClaimsMapper(
mapper: ClaimsToPrincipal,
claims: Claims
): Principal | null {
let result: Principal | null;
try {
result = mapper(claims);
} catch (cause) {
throw new AuthJwtError(
AUTH_JWT_CODES.INVALID_CLAIMS_MAPPER,
"claimsToPrincipal threw",
{ cause }
);
}
if (result === null) {
return null;
}
if (!isPrincipalLike(result)) {
throw new AuthJwtError(
AUTH_JWT_CODES.INVALID_CLAIMS_MAPPER,
"claimsToPrincipal returned a non-Principal value"
);
}
return result;
}
function extractScopes(claims: Claims): readonly Scope[] {
const scope = claims["scope"];
if (typeof scope === "string" && scope.length > 0) {
return scope.split(" ").filter((s) => s.length > 0) as Scope[];
}
const arr = claims["scopes"];
if (Array.isArray(arr)) {
return arr.filter((s): s is string => typeof s === "string") as Scope[];
}
return [];
}
function extractTenantId(claims: Claims): TenantId | undefined {
const tenant = claims["tenant_id"] ?? claims["tid"];
if (typeof tenant === "string" && tenant.length > 0) {
return tenant as TenantId;
}
return undefined;
}
function isPrincipalLike(value: unknown): value is Principal {
if (value === null || typeof value !== "object") return false;
const record = value as Record<string, unknown>;
if (typeof record["id"] !== "string") return false;
if (!Array.isArray(record["scopes"])) return false;
const claims = record["claims"];
if (claims === null || typeof claims !== "object") return false;
return true;
}

View File

@@ -0,0 +1,12 @@
import type { Claims, Principal } from "@suaveplan/api-types/principal";
/**
* Pluggable projection from verified JWT claims onto a {@link Principal}.
*
* Returning `null` signals "I do not consider this token a valid identity
* for my application" — the resolver collapses that to `null` so the
* upstream chain moves on. Throwing is treated as an
* `AUTH_JWT.INVALID_CLAIMS_MAPPER` infrastructure failure (the operator
* misconfigured the mapper) rather than a per-request soft failure.
*/
export type ClaimsToPrincipal = (claims: Claims) => Principal | null;

View File

@@ -0,0 +1,5 @@
export {
defaultClaimsToPrincipal,
runClaimsMapper,
} from "./claims-mapper.js";
export type { ClaimsToPrincipal } from "./claims-mapper.types.js";

View File

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

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AUTH_JWT_CODES, AuthJwtError } from "./error-codes.js";
describe("AUTH_JWT_CODES", () => {
it("exposes the three documented codes", { timeout: 5000 }, () => {
expect(AUTH_JWT_CODES.JWKS_FETCH_FAILED).toBe(
"AUTH_JWT.JWKS_FETCH_FAILED"
);
expect(AUTH_JWT_CODES.INVALID_KEY_FORMAT).toBe(
"AUTH_JWT.INVALID_KEY_FORMAT"
);
expect(AUTH_JWT_CODES.INVALID_CLAIMS_MAPPER).toBe(
"AUTH_JWT.INVALID_CLAIMS_MAPPER"
);
});
it("AuthJwtError narrows code", { timeout: 5000 }, () => {
const err = new AuthJwtError(AUTH_JWT_CODES.JWKS_FETCH_FAILED, "boom");
expect(err).toBeInstanceOf(AuthJwtError);
expect(err.code).toBe(AUTH_JWT_CODES.JWKS_FETCH_FAILED);
expect(err.jwtCode).toBe(AUTH_JWT_CODES.JWKS_FETCH_FAILED);
expect(err.message).toBe("boom");
});
it("AuthJwtError accepts cause", { timeout: 5000 }, () => {
const cause = new Error("upstream");
const err = new AuthJwtError(
AUTH_JWT_CODES.INVALID_KEY_FORMAT,
"wrap",
{ cause }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,39 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-auth-jwt`.
*
* Only the codes the package throws synchronously / on internal infrastructure
* failure are listed here. Per-request soft failures (bad token, expired,
* wrong audience, etc.) are NOT codes — they collapse to `null` per
* `SessionResolver` chain semantics. Listing them here would conflate
* "infrastructure problem the operator must fix" with "this caller is not
* authenticated yet", which the host treats as 401/anonymous.
*/
export const AUTH_JWT_CODES = defineCodes("AUTH_JWT", [
"JWKS_FETCH_FAILED",
"INVALID_KEY_FORMAT",
"INVALID_CLAIMS_MAPPER",
] as const);
export type AuthJwtErrorCode =
(typeof AUTH_JWT_CODES)[keyof typeof AUTH_JWT_CODES];
/**
* Concrete error class for the api-auth-jwt package. Extends
* {@link SuaveplanError} so `instanceof AuthJwtError` narrows the `code`
* property to {@link AuthJwtErrorCode}.
*/
export class AuthJwtError extends SuaveplanError<AuthJwtErrorCode> {
public readonly jwtCode: AuthJwtErrorCode;
public constructor(
code: AuthJwtErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.jwtCode = code;
}
}

View File

@@ -0,0 +1,5 @@
export {
AUTH_JWT_CODES,
AuthJwtError,
type AuthJwtErrorCode,
} from "./error-codes.js";

View File

@@ -0,0 +1,25 @@
// Surfaces the AUTH_JWT.* registry to consumers via `import "@suaveplan/api-auth-jwt"`.
import "./error-codes/error-codes.js";
export {
defaultClaimsToPrincipal,
runClaimsMapper,
} from "./claims-mapper/claims-mapper.js";
export type { ClaimsToPrincipal } from "./claims-mapper/claims-mapper.types.js";
export {
AUTH_JWT_CODES,
AuthJwtError,
type AuthJwtErrorCode,
} from "./error-codes/error-codes.js";
export { JwksCache, type JwksDocument } from "./jwks-cache/jwks-cache.js";
export {
type JwtSessionResolverOptions,
JwtSessionResolverOptionsSchema,
type ValidatedJwtSessionResolverOptions,
validateOptions,
} from "./options/options.js";
export { createJwtSessionResolver } from "./resolver/resolver.js";
export {
createJwtBearerSecurityScheme,
JWT_BEARER_SCHEME,
} from "./security-scheme/security-scheme.js";

View File

@@ -0,0 +1 @@
export { JwksCache, type JwksDocument } from "./jwks-cache.js";

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

View File

@@ -0,0 +1,455 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AUTH_JWT_CODES, AuthJwtError } from "../error-codes/error-codes.js";
import { JwksCache } from "./jwks-cache.js";
interface MockFetchHandle {
readonly fetchImpl: typeof fetch;
readonly calls: () => number;
setResponder(fn: () => Promise<Response> | Response): void;
}
function makeFetch(
initial: () => Promise<Response> | Response
): MockFetchHandle {
let count = 0;
let responder = initial;
const impl: typeof fetch = async () => {
count++;
return responder();
};
return {
fetchImpl: impl,
calls: () => count,
setResponder(fn) {
responder = fn;
},
};
}
function jwksResponse(keys: Array<Record<string, unknown>>): Response {
return new Response(JSON.stringify({ keys }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
describe("JwksCache", () => {
it("returns key by kid", { timeout: 5000 }, async () => {
const now = 1000;
const f = makeFetch(() =>
jwksResponse([{ kid: "a", kty: "oct", k: "AAA" }])
);
const cache = new JwksCache({
url: "https://idp.example/jwks",
ttlMs: 1000,
negativeMs: 100,
clock: () => now,
fetchImpl: f.fetchImpl,
});
const key = await cache.getKeyForKid("a");
expect(key?.["kid"]).toBe("a");
expect(f.calls()).toBe(1);
});
it("caches within TTL — second call does not refetch", {
timeout: 5000,
}, async () => {
let now = 0;
const f = makeFetch(() =>
jwksResponse([{ kid: "a", kty: "oct", k: "AAA" }])
);
const cache = new JwksCache({
url: "https://idp.example/jwks",
ttlMs: 1000,
negativeMs: 100,
clock: () => now,
fetchImpl: f.fetchImpl,
});
await cache.getKeyForKid("a");
now = 500;
await cache.getKeyForKid("a");
expect(f.calls()).toBe(1);
});
it("refetches after TTL expiry", { timeout: 5000 }, async () => {
let now = 0;
const f = makeFetch(() =>
jwksResponse([{ kid: "a", kty: "oct", k: "AAA" }])
);
const cache = new JwksCache({
url: "https://idp.example/jwks",
ttlMs: 1000,
negativeMs: 100,
clock: () => now,
fetchImpl: f.fetchImpl,
});
await cache.getKeyForKid("a");
now = 1500;
await cache.getKeyForKid("a");
expect(f.calls()).toBe(2);
});
it("kid miss triggers exactly one refresh", { timeout: 5000 }, async () => {
let now = 0;
let phase = 0;
const f = makeFetch(() => {
if (phase === 0) {
return jwksResponse([{ kid: "a", kty: "oct", k: "AAA" }]);
}
return jwksResponse([{ kid: "b", kty: "oct", k: "BBB" }]);
});
const cache = new JwksCache({
url: "https://idp.example/jwks",
ttlMs: 60_000,
negativeMs: 100,
clock: () => now,
fetchImpl: f.fetchImpl,
});
await cache.getKeyForKid("a"); // primes cache, 1 call
now = 10;
phase = 1;
const found = await cache.getKeyForKid("b"); // miss → refresh
expect(found?.["kid"]).toBe("b");
expect(f.calls()).toBe(2);
});
it("kid miss against single-key set without kid in token returns the key", {
timeout: 5000,
}, async () => {
const now = 0;
const f = makeFetch(() => jwksResponse([{ kty: "oct", k: "AAA" }]));
const cache = new JwksCache({
url: "https://idp.example/jwks",
ttlMs: 1000,
negativeMs: 100,
clock: () => now,
fetchImpl: f.fetchImpl,
});
const k = await cache.getKeyForKid(undefined);
expect(k).toBeDefined();
});
it("undefined kid against multi-key set returns undefined", {
timeout: 5000,
}, async () => {
const now = 0;
const f = makeFetch(() =>
jwksResponse([
{ kid: "a", kty: "oct", k: "AAA" },
{ kid: "b", kty: "oct", k: "BBB" },
])
);
const cache = new JwksCache({
url: "https://idp.example/jwks",
ttlMs: 1000,
negativeMs: 100,
clock: () => now,
fetchImpl: f.fetchImpl,
});
expect(await cache.getKeyForKid(undefined)).toBeUndefined();
});
it("dedupes concurrent fetches", { timeout: 5000 }, async () => {
const now = 0;
let resolveBody: (() => void) | undefined;
const gate = new Promise<void>((res) => {
resolveBody = res;
});
const f = makeFetch(async () => {
await gate;
return jwksResponse([{ kid: "a", kty: "oct", k: "AAA" }]);
});
const cache = new JwksCache({
url: "https://idp.example/jwks",
ttlMs: 1000,
negativeMs: 100,
clock: () => now,
fetchImpl: f.fetchImpl,
});
const p1 = cache.getKeyForKid("a");
const p2 = cache.getKeyForKid("a");
resolveBody!();
const [k1, k2] = await Promise.all([p1, p2]);
expect(k1?.["kid"]).toBe("a");
expect(k2?.["kid"]).toBe("a");
expect(f.calls()).toBe(1);
});
it("forceRefresh clears positive cache", { timeout: 5000 }, async () => {
const now = 0;
const f = makeFetch(() =>
jwksResponse([{ kid: "a", kty: "oct", k: "AAA" }])
);
const cache = new JwksCache({
url: "https://idp.example/jwks",
ttlMs: 60_000,
negativeMs: 100,
clock: () => now,
fetchImpl: f.fetchImpl,
});
await cache.getKeyForKid("a");
await cache.forceRefresh();
expect(f.calls()).toBe(2);
});
it("throws JWKS_FETCH_FAILED when fetch rejects and no prior cache", {
timeout: 5000,
}, async () => {
const f = makeFetch(async () => {
throw new Error("network down");
});
const cache = new JwksCache({
url: "https://idp.example/jwks",
ttlMs: 1000,
negativeMs: 100,
clock: () => 0,
fetchImpl: f.fetchImpl,
});
try {
await cache.getKeyForKid("a");
throw new Error("expected throw");
} catch (err) {
expect((err as AuthJwtError).code).toBe(
AUTH_JWT_CODES.JWKS_FETCH_FAILED
);
}
});
it("throws JWKS_FETCH_FAILED on non-2xx", { timeout: 5000 }, async () => {
const f = makeFetch(() => new Response("nope", { status: 500 }));
const cache = new JwksCache({
url: "https://idp.example/jwks",
ttlMs: 1000,
negativeMs: 100,
clock: () => 0,
fetchImpl: f.fetchImpl,
});
try {
await cache.getKeyForKid("a");
throw new Error("expected throw");
} catch (err) {
expect((err as AuthJwtError).code).toBe(
AUTH_JWT_CODES.JWKS_FETCH_FAILED
);
}
});
it("throws JWKS_FETCH_FAILED on non-JSON body", {
timeout: 5000,
}, async () => {
const f = makeFetch(() => new Response("not-json", { status: 200 }));
const cache = new JwksCache({
url: "https://idp.example/jwks",
ttlMs: 1000,
negativeMs: 100,
clock: () => 0,
fetchImpl: f.fetchImpl,
});
try {
await cache.getKeyForKid("a");
throw new Error("expected throw");
} catch (err) {
expect((err as AuthJwtError).code).toBe(
AUTH_JWT_CODES.JWKS_FETCH_FAILED
);
}
});
it("throws JWKS_FETCH_FAILED on missing keys array", {
timeout: 5000,
}, async () => {
const f = makeFetch(
() =>
new Response(JSON.stringify({}), {
status: 200,
headers: { "content-type": "application/json" },
})
);
const cache = new JwksCache({
url: "https://idp.example/jwks",
ttlMs: 1000,
negativeMs: 100,
clock: () => 0,
fetchImpl: f.fetchImpl,
});
try {
await cache.getKeyForKid("a");
throw new Error("expected throw");
} catch (err) {
expect((err as AuthJwtError).code).toBe(
AUTH_JWT_CODES.JWKS_FETCH_FAILED
);
}
});
it("throws JWKS_FETCH_FAILED when keys contains non-object", {
timeout: 5000,
}, async () => {
const f = makeFetch(
() =>
new Response(JSON.stringify({ keys: [null] }), {
status: 200,
headers: { "content-type": "application/json" },
})
);
const cache = new JwksCache({
url: "https://idp.example/jwks",
ttlMs: 1000,
negativeMs: 100,
clock: () => 0,
fetchImpl: f.fetchImpl,
});
try {
await cache.getKeyForKid("a");
throw new Error("expected throw");
} catch (err) {
expect((err as AuthJwtError).code).toBe(
AUTH_JWT_CODES.JWKS_FETCH_FAILED
);
}
});
it("returns stale document on transient fetch failure", {
timeout: 5000,
}, async () => {
let now = 0;
let phase: "ok" | "fail" = "ok";
const f = makeFetch(() => {
if (phase === "fail") throw new Error("down");
return jwksResponse([{ kid: "a", kty: "oct", k: "AAA" }]);
});
const cache = new JwksCache({
url: "https://idp.example/jwks",
ttlMs: 100,
negativeMs: 60_000,
clock: () => now,
fetchImpl: f.fetchImpl,
});
await cache.getKeyForKid("a"); // ok, cached
phase = "fail";
now = 200; // TTL expired
const k = await cache.getKeyForKid("a"); // returns stale
expect(k?.["kid"]).toBe("a");
});
it("negative cache window short-circuits later attempts when no prior doc", {
timeout: 5000,
}, async () => {
let now = 0;
const f = makeFetch(async () => {
throw new Error("down");
});
const cache = new JwksCache({
url: "https://idp.example/jwks",
ttlMs: 1000,
negativeMs: 60_000,
clock: () => now,
fetchImpl: f.fetchImpl,
});
await expect(cache.getKeyForKid("a")).rejects.toBeInstanceOf(
AuthJwtError
);
now = 1; // still inside the negative window
await expect(cache.getKeyForKid("a")).rejects.toBeInstanceOf(
AuthJwtError
);
expect(f.calls()).toBe(1);
});
it("throws JWKS_FETCH_FAILED when body is JSON null", {
timeout: 5000,
}, async () => {
const f = makeFetch(
() =>
new Response("null", {
status: 200,
headers: { "content-type": "application/json" },
})
);
const cache = new JwksCache({
url: "https://idp.example/jwks",
ttlMs: 1000,
negativeMs: 100,
clock: () => 0,
fetchImpl: f.fetchImpl,
});
await expect(cache.getKeyForKid("a")).rejects.toBeInstanceOf(
AuthJwtError
);
});
it("serves stale document while negative-cached", {
timeout: 5000,
}, async () => {
let now = 0;
let phase: "ok" | "fail" = "ok";
const f = makeFetch(() => {
if (phase === "fail") throw new Error("down");
return jwksResponse([{ kid: "a", kty: "oct", k: "AAA" }]);
});
const cache = new JwksCache({
url: "https://idp.example/jwks",
ttlMs: 100,
negativeMs: 60_000,
clock: () => now,
fetchImpl: f.fetchImpl,
});
await cache.getKeyForKid("a"); // populates cache
phase = "fail";
now = 200; // TTL expired → attempts refetch → fails → negative-cached + stale returned
const k1 = await cache.getKeyForKid("a");
expect(k1?.["kid"]).toBe("a");
// Subsequent call within negative window: should still return stale
// without another fetch attempt.
now = 300;
const callsBefore = f.calls();
const k2 = await cache.getKeyForKid("a");
expect(k2?.["kid"]).toBe("a");
expect(f.calls()).toBe(callsBefore);
});
it("formats non-Error fetch rejections", { timeout: 5000 }, async () => {
const f = makeFetch(async () => {
// Throw a non-Error to exercise the String(err) branch in
// the stringifyError helper.
const nonError: unknown = "string-rejection";
throw nonError;
});
const cache = new JwksCache({
url: "https://idp.example/jwks",
ttlMs: 1000,
negativeMs: 100,
clock: () => 0,
fetchImpl: f.fetchImpl,
});
try {
await cache.getKeyForKid("a");
throw new Error("expected throw");
} catch (err) {
expect((err as AuthJwtError).code).toBe(
AUTH_JWT_CODES.JWKS_FETCH_FAILED
);
expect((err as AuthJwtError).message).toContain("string-rejection");
}
});
it("guards against repeat kid miss within the same fetch", {
timeout: 5000,
}, async () => {
const now = 0;
const f = makeFetch(() =>
jwksResponse([{ kid: "a", kty: "oct", k: "AAA" }])
);
const cache = new JwksCache({
url: "https://idp.example/jwks",
ttlMs: 60_000,
negativeMs: 100,
clock: () => now,
fetchImpl: f.fetchImpl,
});
// First call fetches and document does not contain "missing".
const m = await cache.getKeyForKid("missing");
// missing → refresh attempt with same doc → still undefined
expect(m).toBeUndefined();
});
});

View File

@@ -0,0 +1,190 @@
import type { Clock } from "@suaveplan/types";
import { AUTH_JWT_CODES, AuthJwtError } from "../error-codes/error-codes.js";
/**
* Parsed JWKS document. Keys are stored as `Record<string, unknown>` so the
* downstream `@suaveplan/jws` `jwkToCryptoKey` helper can validate the JWK
* shape per-algorithm — duplicating that validation here would couple this
* cache to the algorithm catalogue.
*/
export interface JwksDocument {
readonly keys: ReadonlyArray<Record<string, unknown>>;
}
interface CacheEntry {
readonly doc: JwksDocument;
readonly fetchedAtMs: number;
}
interface NegativeCacheEntry {
readonly failedAtMs: number;
}
/**
* TTL-bounded JWKS cache with in-flight request dedup and a short negative
* cache window. Stage-4 resolvers consult `getKeyForKid` once per token; a
* `kid` miss against the cached document triggers exactly one refetch via
* the in-flight promise.
*/
export class JwksCache {
private readonly url: string;
private readonly ttlMs: number;
private readonly negativeMs: number;
private readonly clock: Clock;
private readonly fetchImpl: typeof fetch;
private entry: CacheEntry | undefined;
private negative: NegativeCacheEntry | undefined;
private inflight: Promise<JwksDocument> | undefined;
public constructor(opts: {
url: string;
ttlMs: number;
negativeMs: number;
clock: Clock;
fetchImpl: typeof fetch;
}) {
this.url = opts.url;
this.ttlMs = opts.ttlMs;
this.negativeMs = opts.negativeMs;
this.clock = opts.clock;
this.fetchImpl = opts.fetchImpl;
}
/**
* Resolve a JWK for the given `kid`, refreshing once if absent.
* Throws `AUTH_JWT.JWKS_FETCH_FAILED` when both the cache and a fresh
* fetch fail to produce the document.
*/
public async getKeyForKid(
kid: string | undefined
): Promise<Record<string, unknown> | undefined> {
const first = await this.getDocumentTracked();
const match = findKey(first.doc, kid);
if (match !== undefined) return match;
if (kid === undefined) return undefined;
// kid miss. If we just fetched fresh, there is no reason to refetch
// immediately — the same IdP would return the same document. If the
// document came from cache, force a refresh in case the IdP has
// rotated keys.
if (first.fresh) return undefined;
const refreshed = await this.forceRefresh();
return findKey(refreshed, kid);
}
/** Force a refresh — used on `kid` miss and on operator-triggered rotation. */
public async forceRefresh(): Promise<JwksDocument> {
this.entry = undefined;
return this.getDocument();
}
private async getDocument(): Promise<JwksDocument> {
const tracked = await this.getDocumentTracked();
return tracked.doc;
}
private async getDocumentTracked(): Promise<{
readonly doc: JwksDocument;
readonly fresh: boolean;
}> {
const now = this.clock();
if (this.entry && now - this.entry.fetchedAtMs <= this.ttlMs) {
return { doc: this.entry.doc, fresh: false };
}
if (
this.negative &&
now - this.negative.failedAtMs <= this.negativeMs
) {
// Negative-cached: caller cannot wait. If we have an old entry,
// return it (better stale than 401-storm); otherwise raise.
if (this.entry) return { doc: this.entry.doc, fresh: false };
throw new AuthJwtError(
AUTH_JWT_CODES.JWKS_FETCH_FAILED,
"JWKS endpoint negative-cached and no prior document available"
);
}
if (this.inflight) {
const doc = await this.inflight;
return { doc, fresh: true };
}
this.inflight = this.doFetch();
try {
const doc = await this.inflight;
this.entry = { doc, fetchedAtMs: this.clock() };
this.negative = undefined;
return { doc, fresh: true };
} catch (err) {
this.negative = { failedAtMs: this.clock() };
if (this.entry) return { doc: this.entry.doc, fresh: false };
throw err;
} finally {
this.inflight = undefined;
}
}
private async doFetch(): Promise<JwksDocument> {
let res: Response;
try {
res = await this.fetchImpl(this.url, { method: "GET" });
} catch (cause) {
throw new AuthJwtError(
AUTH_JWT_CODES.JWKS_FETCH_FAILED,
`JWKS fetch threw: ${stringifyError(cause)}`,
{ cause }
);
}
if (!res.ok) {
throw new AuthJwtError(
AUTH_JWT_CODES.JWKS_FETCH_FAILED,
`JWKS fetch returned ${res.status}`,
{ context: { status: res.status } }
);
}
let body: unknown;
try {
body = await res.json();
} catch (cause) {
throw new AuthJwtError(
AUTH_JWT_CODES.JWKS_FETCH_FAILED,
"JWKS body was not valid JSON",
{ cause }
);
}
if (!isJwksDocument(body)) {
throw new AuthJwtError(
AUTH_JWT_CODES.JWKS_FETCH_FAILED,
"JWKS body was not a JSON object with a `keys` array"
);
}
return body;
}
}
function findKey(
doc: JwksDocument,
kid: string | undefined
): Record<string, unknown> | undefined {
if (kid === undefined) {
// No kid in token: only one key in the set is unambiguous.
return doc.keys.length === 1 ? doc.keys[0] : undefined;
}
for (const k of doc.keys) {
if (k["kid"] === kid) return k;
}
return undefined;
}
function isJwksDocument(value: unknown): value is JwksDocument {
if (value === null || typeof value !== "object") return false;
const keys = (value as Record<string, unknown>)["keys"];
if (!Array.isArray(keys)) return false;
for (const k of keys) {
if (k === null || typeof k !== "object") return false;
}
return true;
}
function stringifyError(err: unknown): string {
if (err instanceof Error) return err.message;
return String(err);
}

View File

@@ -0,0 +1,6 @@
export {
type JwtSessionResolverOptions,
JwtSessionResolverOptionsSchema,
type ValidatedJwtSessionResolverOptions,
validateOptions,
} from "./options.js";

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

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { AUTH_JWT_CODES, AuthJwtError } from "../error-codes/error-codes.js";
import { validateOptions } from "./options.js";
describe("validateOptions", () => {
it("applies defaults", { timeout: 5000 }, () => {
const v = validateOptions({
jwksUrl: "https://idp.example/jwks",
issuer: "https://idp.example",
audience: "https://api.example",
algorithms: ["RS256"],
});
expect(v.headerName).toBe("authorization");
expect(v.clockToleranceSec).toBe(0);
expect(v.jwksCacheTtlMs).toBe(10 * 60 * 1000);
expect(v.jwksNegativeCacheMs).toBe(30 * 1000);
});
it("rejects when neither jwksUrl nor staticKey is supplied", {
timeout: 5000,
}, () => {
try {
validateOptions({
issuer: "x",
audience: "y",
algorithms: ["RS256"],
} as never);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(AuthJwtError);
expect((err as AuthJwtError).code).toBe(
AUTH_JWT_CODES.INVALID_KEY_FORMAT
);
}
});
it("rejects when both jwksUrl and staticKey are supplied", {
timeout: 5000,
}, () => {
try {
validateOptions({
jwksUrl: "https://idp.example/jwks",
staticKey: { kty: "oct", k: "AAA" },
issuer: "x",
audience: "y",
algorithms: ["HS256"],
});
throw new Error("expected throw");
} catch (err) {
expect((err as AuthJwtError).code).toBe(
AUTH_JWT_CODES.INVALID_KEY_FORMAT
);
}
});
it("rejects empty algorithms array", { timeout: 5000 }, () => {
try {
validateOptions({
jwksUrl: "https://idp.example/jwks",
issuer: "x",
audience: "y",
algorithms: [],
});
throw new Error("expected throw");
} catch (err) {
expect((err as AuthJwtError).code).toBe(
AUTH_JWT_CODES.INVALID_KEY_FORMAT
);
}
});
it("accepts static key with multi-issuer list", { timeout: 5000 }, () => {
const v = validateOptions({
staticKey: { kty: "oct", k: "BBB" },
issuer: ["a", "b"],
audience: ["x", "y"],
algorithms: ["HS256"],
headerName: "x-token",
cookieName: "tok",
clockToleranceSec: 5,
jwksCacheTtlMs: 1000,
jwksNegativeCacheMs: 100,
});
expect(v.issuer).toEqual(["a", "b"]);
expect(v.audience).toEqual(["x", "y"]);
expect(v.headerName).toBe("x-token");
expect(v.cookieName).toBe("tok");
expect(v.clockToleranceSec).toBe(5);
});
});

View File

@@ -0,0 +1,138 @@
import type { Clock } from "@suaveplan/types";
import z from "zod";
import type { ClaimsToPrincipal } from "../claims-mapper/claims-mapper.types.js";
import { AUTH_JWT_CODES, AuthJwtError } from "../error-codes/error-codes.js";
/**
* Zod schema for the static-key form: a minimal JWK shape sufficient to
* dispatch to {@link import("@suaveplan/jws").jwkToCryptoKey}. We do not
* re-implement full JWK validation here — `@suaveplan/jws` enforces that at
* verify time. We only need to confirm the field exists and is an object.
*/
const StaticKeySchema = z
.object({
kty: z.string().min(1),
})
.passthrough();
/**
* Runtime-validated options for {@link import("../resolver/resolver.js").createJwtSessionResolver}.
*
* Either `jwksUrl` or `staticKey` MUST be supplied. Construction throws
* `AUTH_JWT.INVALID_KEY_FORMAT` synchronously when neither is supplied,
* when both are supplied, or when `staticKey` fails JWK shape validation.
*/
export const JwtSessionResolverOptionsSchema = z
.object({
jwksUrl: z.string().url().optional(),
staticKey: StaticKeySchema.optional(),
issuer: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]),
audience: z.union([
z.string().min(1),
z.array(z.string().min(1)).min(1),
]),
algorithms: z.array(z.string().min(1)).min(1),
headerName: z.string().min(1).default("authorization"),
cookieName: z.string().min(1).optional(),
clockToleranceSec: z.number().int().nonnegative().default(0),
jwksCacheTtlMs: z
.number()
.int()
.positive()
.default(10 * 60 * 1000),
jwksNegativeCacheMs: z
.number()
.int()
.nonnegative()
.default(30 * 1000),
})
.strict();
/** Inferred runtime-validated options bag. */
export type ValidatedJwtSessionResolverOptions = z.infer<
typeof JwtSessionResolverOptionsSchema
>;
/**
* Public options surface accepted by `createJwtSessionResolver`. Mirrors the
* Zod schema and adds injectable, non-serialisable boundary objects (clock,
* fetch impl, claims mapper) that Zod cannot validate.
*/
export interface JwtSessionResolverOptions {
/**
* JWKS URL to fetch keys from. Mutually exclusive with {@link staticKey}.
*/
readonly jwksUrl?: string;
/**
* Single static JWK to verify against. Mutually exclusive with
* {@link jwksUrl}. Useful for HS-family symmetric keys or for embedded
* deployments that ship a single signing key.
*/
readonly staticKey?: Record<string, unknown>;
/** Required `iss` claim — string or list of acceptable issuers. */
readonly issuer: string | readonly string[];
/** Required `aud` claim — string or list of acceptable audiences. */
readonly audience: string | readonly string[];
/** Required alg allowlist passed straight through to `@suaveplan/jws`. */
readonly algorithms: readonly string[];
/** HTTP header to read the Bearer token from. Defaults to `authorization`. */
readonly headerName?: string;
/** Optional cookie name. When set, cookie is read if header is absent. */
readonly cookieName?: string;
/** Clock skew tolerance in seconds (default 0). */
readonly clockToleranceSec?: number;
/** JWKS positive cache TTL in ms (default 10 min). */
readonly jwksCacheTtlMs?: number;
/** JWKS negative cache TTL in ms (default 30s). */
readonly jwksNegativeCacheMs?: number;
/** Injectable clock for tests (defaults to `Date.now`). */
readonly clock?: Clock;
/** Injectable fetch impl (defaults to global `fetch`). */
readonly fetchImpl?: typeof fetch;
/**
* Override the default claims projection. Receives the verified JWT
* payload claims; returns the `Principal` to attach to the request, or
* `null` to defer to the next resolver in the chain.
*/
readonly claimsToPrincipal?: ClaimsToPrincipal;
}
/**
* Validate and freeze a {@link JwtSessionResolverOptions} bag. Throws
* `AUTH_JWT_INVALID_KEY_FORMAT` synchronously on any boundary failure.
*/
export function validateOptions(
raw: JwtSessionResolverOptions
): ValidatedJwtSessionResolverOptions {
// Drop runtime-only fields (clock, fetchImpl, claimsToPrincipal) before
// Zod validation; the schema is `.strict()` and cannot represent them.
const {
clock: _clock,
fetchImpl: _fetchImpl,
claimsToPrincipal: _claimsToPrincipal,
...schemaInput
} = raw;
void _clock;
void _fetchImpl;
void _claimsToPrincipal;
const parsed = JwtSessionResolverOptionsSchema.safeParse(schemaInput);
if (!parsed.success) {
throw new AuthJwtError(
AUTH_JWT_CODES.INVALID_KEY_FORMAT,
"JwtSessionResolverOptions failed schema validation",
{ context: { issues: parsed.error.issues } }
);
}
const data = parsed.data;
const hasJwks = data.jwksUrl !== undefined;
const hasStatic = data.staticKey !== undefined;
if (hasJwks === hasStatic) {
throw new AuthJwtError(
AUTH_JWT_CODES.INVALID_KEY_FORMAT,
hasJwks
? "JwtSessionResolverOptions: jwksUrl and staticKey are mutually exclusive"
: "JwtSessionResolverOptions: one of jwksUrl or staticKey is required"
);
}
return data;
}

View File

@@ -0,0 +1 @@
export { createJwtSessionResolver } from "./resolver.js";

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

View File

@@ -0,0 +1,67 @@
import { runAuthSessionConformance } from "@suaveplan/api-auth";
import type { Principal } from "@suaveplan/api-types/principal";
import { compactSign } from "@suaveplan/jws";
import { createJwtSessionResolver } from "./resolver.js";
const SECRET = new TextEncoder().encode("super-secret-test-key-32-bytes!!");
const STATIC_JWK = {
kty: "oct",
k: Buffer.from(SECRET).toString("base64url"),
};
const ISSUER = "https://idp.example";
const AUDIENCE = "https://api.example";
const SUBJECT = "user_conformance";
const seedTokenPromise = compactSign(
JSON.stringify({ iss: ISSUER, aud: AUDIENCE, sub: SUBJECT }),
SECRET,
"HS256"
);
// Resolve once at module load so factory() can be synchronous-friendly.
let seedToken: string | undefined;
async function ensureToken(): Promise<string> {
if (seedToken === undefined) seedToken = await seedTokenPromise;
return seedToken;
}
runAuthSessionConformance({
label: "jwt-static-key",
factory: () =>
createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
}),
seedNull: () =>
new Request("https://api.example/anon", {
headers: { "x-other": "y" },
}),
seedPrincipal: () => {
// Need to attach a real signed token; use a synchronously-built request
// by pre-resolving on first call. Cast to satisfy the harness shape.
if (seedToken === undefined) {
// First call triggers async-loaded token; harness will await
// factory() so we can pre-seed by awaiting here through a
// module-level promise.
// We exploit the fact that the factory returns the same fresh
// resolver each call, but seedPrincipal is sync — so we resolve
// the token via a top-level await alternative below.
throw new Error("seed token not yet primed");
}
const expected: Principal = {
id: SUBJECT as never,
scopes: [] as never[],
claims: { iss: ISSUER, aud: AUDIENCE, sub: SUBJECT },
};
return {
request: new Request("https://api.example/me", {
headers: { authorization: `Bearer ${seedToken}` },
}),
expected,
};
},
});
// Top-level await to prime the token before vitest runs the suite.
await ensureToken();

View File

@@ -0,0 +1,850 @@
import { compactSign } from "@suaveplan/jws";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createJwtSessionResolver } from "./resolver.js";
const SECRET_BYTES = new TextEncoder().encode(
"super-secret-test-key-32-bytes!!"
);
const STATIC_JWK = {
kty: "oct",
k: Buffer.from(SECRET_BYTES).toString("base64url"),
};
const ISSUER = "https://idp.example";
const AUDIENCE = "https://api.example";
async function signJwt(
payload: Record<string, unknown>,
overrides: { alg?: string } = {}
): Promise<string> {
return compactSign(
JSON.stringify({
iss: ISSUER,
aud: AUDIENCE,
sub: "user_1",
...payload,
}),
SECRET_BYTES,
overrides.alg ?? "HS256"
);
}
describe("createJwtSessionResolver — extraction + verification", () => {
it("resolves a valid Bearer token to a Principal", {
timeout: 5000,
}, async () => {
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
});
const tok = await signJwt({ sub: "user_42" });
const req = new Request("https://api.example/x", {
headers: { authorization: `Bearer ${tok}` },
});
const p = await r.resolve(req);
expect(p?.id).toBe("user_42");
await r.dispose();
});
it("returns null when no Authorization header is present", {
timeout: 5000,
}, async () => {
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
});
expect(
await r.resolve(new Request("https://api.example/x"))
).toBeNull();
await r.dispose();
});
it("returns null when scheme is not Bearer", {
timeout: 5000,
}, async () => {
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
});
const tok = await signJwt({});
expect(
await r.resolve(
new Request("https://api.example/x", {
headers: { authorization: `Basic ${tok}` },
})
)
).toBeNull();
await r.dispose();
});
it("returns null when token is malformed", { timeout: 5000 }, async () => {
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
});
expect(
await r.resolve(
new Request("https://api.example/x", {
headers: { authorization: "Bearer not-a-jwt" },
})
)
).toBeNull();
await r.dispose();
});
it("returns null when alg is not on the allowlist", {
timeout: 5000,
}, async () => {
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["RS256"],
});
const tok = await signJwt({});
const req = new Request("https://api.example/x", {
headers: { authorization: `Bearer ${tok}` },
});
expect(await r.resolve(req)).toBeNull();
await r.dispose();
});
it("returns null on bad signature", { timeout: 5000 }, async () => {
const wrongSecret = new TextEncoder().encode(
"nope-nope-nope-nope-nope-nope-32"
);
const tok = await compactSign(
JSON.stringify({
iss: ISSUER,
aud: AUDIENCE,
sub: "u",
}),
wrongSecret,
"HS256"
);
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
});
const req = new Request("https://api.example/x", {
headers: { authorization: `Bearer ${tok}` },
});
expect(await r.resolve(req)).toBeNull();
await r.dispose();
});
it("returns null on wrong issuer", { timeout: 5000 }, async () => {
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: "https://other.example",
audience: AUDIENCE,
algorithms: ["HS256"],
});
const tok = await signJwt({});
const req = new Request("https://api.example/x", {
headers: { authorization: `Bearer ${tok}` },
});
expect(await r.resolve(req)).toBeNull();
await r.dispose();
});
it("returns null on wrong audience", { timeout: 5000 }, async () => {
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: "https://other.example",
algorithms: ["HS256"],
});
const tok = await signJwt({});
const req = new Request("https://api.example/x", {
headers: { authorization: `Bearer ${tok}` },
});
expect(await r.resolve(req)).toBeNull();
await r.dispose();
});
it("rejects token with iss matching none of a multi-issuer list", {
timeout: 5000,
}, async () => {
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ["https://x.example", "https://y.example"],
audience: AUDIENCE,
algorithms: ["HS256"],
});
const tok = await signJwt({}); // iss = ISSUER, not in list
expect(
await r.resolve(
new Request("https://api.example/x", {
headers: { authorization: `Bearer ${tok}` },
})
)
).toBeNull();
await r.dispose();
});
it("skips non-string entries inside an aud array", {
timeout: 5000,
}, async () => {
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
});
// aud array has a number first then a valid match — verifies the
// array iterator survives non-string entries.
const tok = await compactSign(
JSON.stringify({
iss: ISSUER,
aud: [42, AUDIENCE],
sub: "u",
}),
SECRET_BYTES,
"HS256"
);
expect(
await r.resolve(
new Request("https://api.example/x", {
headers: { authorization: `Bearer ${tok}` },
})
)
).not.toBeNull();
await r.dispose();
});
it("rejects token where aud is non-string non-array", {
timeout: 5000,
}, async () => {
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
});
const tok = await compactSign(
JSON.stringify({ iss: ISSUER, aud: 42, sub: "u" }),
SECRET_BYTES,
"HS256"
);
expect(
await r.resolve(
new Request("https://api.example/x", {
headers: { authorization: `Bearer ${tok}` },
})
)
).toBeNull();
await r.dispose();
});
it("returns null when cookie configured but no cookie header present", {
timeout: 5000,
}, async () => {
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
cookieName: "tok",
});
expect(
await r.resolve(new Request("https://api.example/x"))
).toBeNull();
await r.dispose();
});
it("accepts array-form issuer + array-form audience options", {
timeout: 5000,
}, async () => {
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: [ISSUER, "https://other.example"],
audience: [AUDIENCE, "https://second.example"],
algorithms: ["HS256"],
});
const tok = await signJwt({});
const p = await r.resolve(
new Request("https://api.example/x", {
headers: { authorization: `Bearer ${tok}` },
})
);
expect(p).not.toBeNull();
await r.dispose();
});
it("accepts array audience match", { timeout: 5000 }, async () => {
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
});
const tok = await compactSign(
JSON.stringify({
iss: ISSUER,
aud: ["x", AUDIENCE],
sub: "u",
}),
SECRET_BYTES,
"HS256"
);
expect(
await r.resolve(
new Request("https://api.example/x", {
headers: { authorization: `Bearer ${tok}` },
})
)
).not.toBeNull();
await r.dispose();
});
it("rejects array audience that does not match", {
timeout: 5000,
}, async () => {
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
});
const tok = await compactSign(
JSON.stringify({ iss: ISSUER, aud: ["x", "y"], sub: "u" }),
SECRET_BYTES,
"HS256"
);
expect(
await r.resolve(
new Request("https://api.example/x", {
headers: { authorization: `Bearer ${tok}` },
})
)
).toBeNull();
await r.dispose();
});
it("rejects expired token", { timeout: 5000 }, async () => {
const fixed = 2_000_000_000_000;
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
clock: () => fixed,
});
const tok = await compactSign(
JSON.stringify({
iss: ISSUER,
aud: AUDIENCE,
sub: "u",
exp: 1, // long past
}),
SECRET_BYTES,
"HS256"
);
expect(
await r.resolve(
new Request("https://api.example/x", {
headers: { authorization: `Bearer ${tok}` },
})
)
).toBeNull();
await r.dispose();
});
it("rejects not-yet-valid token", { timeout: 5000 }, async () => {
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
clock: () => 1000,
});
const tok = await compactSign(
JSON.stringify({
iss: ISSUER,
aud: AUDIENCE,
sub: "u",
nbf: 9_999_999_999,
}),
SECRET_BYTES,
"HS256"
);
expect(
await r.resolve(
new Request("https://api.example/x", {
headers: { authorization: `Bearer ${tok}` },
})
)
).toBeNull();
await r.dispose();
});
it("honours clockToleranceSec for slightly expired tokens", {
timeout: 5000,
}, async () => {
const now = 1_700_000_000;
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
clockToleranceSec: 60,
clock: () => now * 1000,
});
const tok = await compactSign(
JSON.stringify({
iss: ISSUER,
aud: AUDIENCE,
sub: "u",
exp: now - 30, // 30s expired, within 60s tolerance
}),
SECRET_BYTES,
"HS256"
);
expect(
await r.resolve(
new Request("https://api.example/x", {
headers: { authorization: `Bearer ${tok}` },
})
)
).not.toBeNull();
await r.dispose();
});
it("returns null when claims payload is not an object", {
timeout: 5000,
}, async () => {
const tok = await compactSign(
JSON.stringify("not-a-claims-object"),
SECRET_BYTES,
"HS256"
);
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
});
expect(
await r.resolve(
new Request("https://api.example/x", {
headers: { authorization: `Bearer ${tok}` },
})
)
).toBeNull();
await r.dispose();
});
it("returns null when claims payload is not valid JSON", {
timeout: 5000,
}, async () => {
const tok = await compactSign("not-json", SECRET_BYTES, "HS256");
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
});
expect(
await r.resolve(
new Request("https://api.example/x", {
headers: { authorization: `Bearer ${tok}` },
})
)
).toBeNull();
await r.dispose();
});
it("reads from cookie when configured", { timeout: 5000 }, async () => {
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
cookieName: "tok",
});
const tok = await signJwt({ sub: "cookie-user" });
const req = new Request("https://api.example/x", {
headers: { cookie: `other=1; tok=${tok}; trailing=2` },
});
const p = await r.resolve(req);
expect(p?.id).toBe("cookie-user");
await r.dispose();
});
it("header wins over cookie when both are present", {
timeout: 5000,
}, async () => {
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
cookieName: "tok",
});
const headerTok = await signJwt({ sub: "header-user" });
const cookieTok = await signJwt({ sub: "cookie-user" });
const req = new Request("https://api.example/x", {
headers: {
authorization: `Bearer ${headerTok}`,
cookie: `tok=${cookieTok}`,
},
});
const p = await r.resolve(req);
expect(p?.id).toBe("header-user");
await r.dispose();
});
it("returns null when cookie is configured but absent", {
timeout: 5000,
}, async () => {
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
cookieName: "tok",
});
const req = new Request("https://api.example/x", {
headers: { cookie: "other=1" },
});
expect(await r.resolve(req)).toBeNull();
await r.dispose();
});
it("decodes percent-encoded cookie values", { timeout: 5000 }, async () => {
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
cookieName: "tok",
});
const tok = await signJwt({ sub: "u" });
const encoded = encodeURIComponent(tok);
expect(
await r.resolve(
new Request("https://api.example/x", {
headers: { cookie: `tok=${encoded}` },
})
)
).not.toBeNull();
await r.dispose();
});
it("non-standard header accepts raw token form", {
timeout: 5000,
}, async () => {
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
headerName: "x-api-token",
});
const tok = await signJwt({});
expect(
await r.resolve(
new Request("https://api.example/x", {
headers: { "x-api-token": tok },
})
)
).not.toBeNull();
await r.dispose();
});
it("non-standard header still accepts Bearer prefix", {
timeout: 5000,
}, async () => {
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
headerName: "x-api-token",
});
const tok = await signJwt({});
expect(
await r.resolve(
new Request("https://api.example/x", {
headers: { "x-api-token": `Bearer ${tok}` },
})
)
).not.toBeNull();
await r.dispose();
});
it("dispose is idempotent", { timeout: 5000 }, async () => {
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
});
await r.dispose();
await r.dispose();
});
it("returns null after dispose", { timeout: 5000 }, async () => {
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
});
await r.dispose();
const tok = await signJwt({});
expect(
await r.resolve(
new Request("https://api.example/x", {
headers: { authorization: `Bearer ${tok}` },
})
)
).toBeNull();
});
it("returns null when Bearer header is empty after scheme", {
timeout: 5000,
}, async () => {
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
cookieName: "tok",
});
// "Bearer x" then trimmed-token is "x" with length 1 > 0 — use
// a Bearer scheme with only whitespace after the trim to exercise
// the tok.length === 0 branch.
expect(
await r.resolve(
new Request("https://api.example/x", {
headers: { authorization: "Bearer " },
})
)
).toBeNull();
await r.dispose();
});
it("returns null when token first segment has illegal characters", {
timeout: 5000,
}, async () => {
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
});
expect(
await r.resolve(
new Request("https://api.example/x", {
headers: { authorization: "Bearer @@@.payload.sig" },
})
)
).toBeNull();
await r.dispose();
});
it("returns null when header segment is not JSON", {
timeout: 5000,
}, async () => {
// valid base64url but garbage content → JSON.parse fails
const garbage = Buffer.from("garbage").toString("base64url");
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
});
expect(
await r.resolve(
new Request("https://api.example/x", {
headers: {
authorization: `Bearer ${garbage}.AA.BB`,
},
})
)
).toBeNull();
await r.dispose();
});
it("returns null when header has no alg", { timeout: 5000 }, async () => {
const headerOnly = Buffer.from('{"typ":"JWT"}').toString("base64url");
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
});
expect(
await r.resolve(
new Request("https://api.example/x", {
headers: {
authorization: `Bearer ${headerOnly}.AA.BB`,
},
})
)
).toBeNull();
await r.dispose();
});
it("returns null when token has no dot", { timeout: 5000 }, async () => {
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
});
expect(
await r.resolve(
new Request("https://api.example/x", {
headers: { authorization: "Bearer abcdef" },
})
)
).toBeNull();
await r.dispose();
});
it("returns null when header decodes to non-object JSON", {
timeout: 5000,
}, async () => {
const headerOnly = Buffer.from('"a-string"').toString("base64url");
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
});
expect(
await r.resolve(
new Request("https://api.example/x", {
headers: {
authorization: `Bearer ${headerOnly}.AA.BB`,
},
})
)
).toBeNull();
await r.dispose();
});
it("rejects token when static key shape does not match alg", {
timeout: 5000,
}, async () => {
// staticKey is an RSA JWK but the token alg is HS256 (allowed).
// jwkToCryptoKey throws JWK_INVALID — resolver collapses to null.
const r = createJwtSessionResolver({
staticKey: { kty: "RSA", n: "bogus-but-string", e: "AQAB" },
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
});
const tok = await signJwt({});
expect(
await r.resolve(
new Request("https://api.example/x", {
headers: { authorization: `Bearer ${tok}` },
})
)
).toBeNull();
await r.dispose();
});
it("skips cookie parts without '='", { timeout: 5000 }, async () => {
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
cookieName: "tok",
});
// Cookie part with no '=' (just a flag-like name) and another
// with '=' at position 0 — both must be skipped.
expect(
await r.resolve(
new Request("https://api.example/x", {
headers: {
cookie: "no-equals-here; =leadingequals; other=1",
},
})
)
).toBeNull();
await r.dispose();
});
it("returns null when cookie value is empty", {
timeout: 5000,
}, async () => {
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
cookieName: "tok",
});
expect(
await r.resolve(
new Request("https://api.example/x", {
headers: { cookie: "tok=" },
})
)
).toBeNull();
await r.dispose();
});
it("tolerates malformed percent-encoded cookie value", {
timeout: 5000,
}, async () => {
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
cookieName: "tok",
});
// %ZZ is invalid percent-encoding → decodeURIComponent throws.
// Resolver should fall back to the raw value (no crash).
expect(
await r.resolve(
new Request("https://api.example/x", {
headers: { cookie: "tok=bad%ZZval" },
})
)
).toBeNull();
await r.dispose();
});
it("supports custom claimsToPrincipal", { timeout: 5000 }, async () => {
const r = createJwtSessionResolver({
staticKey: STATIC_JWK,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
claimsToPrincipal: (claims) => {
const sub = claims["sub"];
if (typeof sub !== "string") return null;
return {
id: `prefixed:${sub}` as never,
scopes: [] as never[],
claims,
};
},
});
const tok = await signJwt({ sub: "alice" });
const p = await r.resolve(
new Request("https://api.example/x", {
headers: { authorization: `Bearer ${tok}` },
})
);
expect(p?.id).toBe("prefixed:alice");
await r.dispose();
});
});

View File

@@ -0,0 +1,240 @@
import type { SessionResolver } from "@suaveplan/api-auth";
import type { Claims, Principal } from "@suaveplan/api-types/principal";
import { compactVerify, jwkToCryptoKey } from "@suaveplan/jws";
import type { Clock } from "@suaveplan/types";
import {
defaultClaimsToPrincipal,
runClaimsMapper,
} from "../claims-mapper/claims-mapper.js";
import { JwksCache } from "../jwks-cache/jwks-cache.js";
import {
type JwtSessionResolverOptions,
validateOptions,
} from "../options/options.js";
const defaultClock: Clock = () => Date.now();
/**
* Build a `SessionResolver` that verifies a JWT presented on the
* `Authorization` header (or a configured cookie) and projects the
* payload claims onto a `Principal`.
*
* Returns `null` on any per-request soft failure (missing token,
* malformed token, bad signature, wrong issuer/audience, expired,
* not-yet-valid, no matching JWKS key). Throws only on operator-actionable
* infrastructure failures via the documented `AUTH_JWT_*` codes.
*/
export function createJwtSessionResolver(
raw: JwtSessionResolverOptions
): SessionResolver {
const opts = validateOptions(raw);
const clock = raw.clock ?? defaultClock;
const fetchImpl = raw.fetchImpl ?? globalThis.fetch;
const mapper = raw.claimsToPrincipal ?? defaultClaimsToPrincipal;
const issuers = normaliseList(opts.issuer);
const audiences = normaliseList(opts.audience);
const headerName = opts.headerName.toLowerCase();
const cookieName = opts.cookieName;
const jwks =
opts.jwksUrl !== undefined
? new JwksCache({
url: opts.jwksUrl,
ttlMs: opts.jwksCacheTtlMs,
negativeMs: opts.jwksNegativeCacheMs,
clock,
fetchImpl,
})
: undefined;
let disposed = false;
return {
async resolve(req: Request): Promise<Principal | null> {
if (disposed) return null;
const token = extractToken(req, headerName, cookieName);
if (token === undefined) return null;
const claims = await verifyAndDecode({
token,
algorithms: opts.algorithms,
staticKey: opts.staticKey,
jwks,
});
if (claims === null) return null;
if (!matchesString(claims["iss"], issuers)) return null;
if (!matchesAudience(claims["aud"], audiences)) return null;
const nowSec = Math.floor(clock() / 1000);
const tolerance = opts.clockToleranceSec;
const exp = claims["exp"];
if (typeof exp === "number" && nowSec - tolerance >= exp) {
return null;
}
const nbf = claims["nbf"];
if (typeof nbf === "number" && nowSec + tolerance < nbf) {
return null;
}
return runClaimsMapper(mapper, claims);
},
async dispose() {
disposed = true;
},
};
}
interface VerifyArgs {
readonly token: string;
readonly algorithms: readonly string[];
readonly staticKey: Record<string, unknown> | undefined;
readonly jwks: JwksCache | undefined;
}
async function verifyAndDecode(args: VerifyArgs): Promise<Claims | null> {
const algs = [...args.algorithms];
// Pre-parse alg+kid from the protected header so we can produce a key
// in the shape the JWS algorithm impl expects (RsaPublicKey for RS/PS,
// Uint8Array for HS/ES/EdDSA). The downstream `compactVerify` re-parses
// and re-validates the header itself.
const parsedHeader = parseTokenHeader(args.token);
if (parsedHeader === null) return null;
if (!algs.includes(parsedHeader.alg)) return null;
// JWKS lookup is outside the try block — its only failure is
// `AUTH_JWT.JWKS_FETCH_FAILED` which is an operator-actionable error
// and MUST propagate to the host.
let jwkSource: Record<string, unknown>;
if (args.staticKey !== undefined) {
jwkSource = args.staticKey;
} else {
const jwk = await args.jwks!.getKeyForKid(parsedHeader.kid);
if (jwk === undefined) return null;
jwkSource = jwk;
}
let resolvedKey: unknown;
try {
const parsed = jwkToCryptoKey(jwkSource, parsedHeader.alg);
resolvedKey = parsed.key;
} catch {
// JWK shape mismatch / algorithm mismatch — per-request soft
// failure. The token referenced a key whose JWK we cannot use.
return null;
}
let result: Awaited<ReturnType<typeof compactVerify>>;
try {
result = await compactVerify(args.token, resolvedKey, {
algorithms: algs,
});
} catch {
return null;
}
let claims: unknown;
try {
claims = JSON.parse(result.payloadString);
} catch {
return null;
}
if (claims === null || typeof claims !== "object") return null;
return claims as Claims;
}
function extractToken(
req: Request,
headerName: string,
cookieName: string | undefined
): string | undefined {
const headerValue = req.headers.get(headerName);
if (headerValue !== null) {
const trimmed = headerValue.trim();
if (trimmed.toLowerCase().startsWith("bearer ")) {
const tok = trimmed.slice("bearer ".length).trim();
// tok length is guaranteed >0 here: trim before startsWith
// proves at least one non-space char follows the "Bearer " prefix.
return tok;
}
if (trimmed.length > 0 && headerName !== "authorization") {
// Non-standard header: accept raw token form too.
return trimmed;
}
}
if (cookieName !== undefined) {
const cookieHeader = req.headers.get("cookie");
if (cookieHeader !== null) {
const tok = parseCookie(cookieHeader, cookieName);
if (tok !== undefined && tok.length > 0) return tok;
}
}
return undefined;
}
function parseCookie(header: string, name: string): string | undefined {
const parts = header.split(";");
for (const part of parts) {
const eq = part.indexOf("=");
if (eq <= 0) continue;
const k = part.slice(0, eq).trim();
if (k !== name) continue;
const v = part.slice(eq + 1).trim();
try {
return decodeURIComponent(v);
} catch {
return v;
}
}
return undefined;
}
interface ParsedTokenHeader {
readonly alg: string;
readonly kid: string | undefined;
}
function parseTokenHeader(token: string): ParsedTokenHeader | null {
const dot = token.indexOf(".");
if (dot <= 0) return null;
const segment = token.slice(0, dot);
let json: string;
try {
json = base64UrlDecodeToString(segment);
} catch {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch {
return null;
}
if (parsed === null || typeof parsed !== "object") return null;
const alg = (parsed as Record<string, unknown>)["alg"];
if (typeof alg !== "string") return null;
const kidRaw = (parsed as Record<string, unknown>)["kid"];
const kid = typeof kidRaw === "string" ? kidRaw : undefined;
return { alg, kid };
}
function base64UrlDecodeToString(input: string): string {
if (!/^[A-Za-z0-9_-]*$/.test(input)) {
throw new Error("non-url-alphabet");
}
return Buffer.from(input, "base64url").toString("utf-8");
}
function normaliseList(value: string | readonly string[]): readonly string[] {
return typeof value === "string" ? [value] : value;
}
function matchesString(value: unknown, allowed: readonly string[]): boolean {
if (typeof value !== "string") return false;
for (const a of allowed) {
if (a === value) return true;
}
return false;
}
function matchesAudience(value: unknown, allowed: readonly string[]): boolean {
if (typeof value === "string") return matchesString(value, allowed);
if (Array.isArray(value)) {
for (const v of value) {
if (matchesString(v, allowed)) return true;
}
}
return false;
}

View File

@@ -0,0 +1,4 @@
export {
createJwtBearerSecurityScheme,
JWT_BEARER_SCHEME,
} from "./security-scheme.js";

View File

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

View File

@@ -0,0 +1,28 @@
import { validateSecurityScheme } from "@suaveplan/api-auth";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
createJwtBearerSecurityScheme,
JWT_BEARER_SCHEME,
} from "./security-scheme.js";
describe("security-scheme", () => {
it("builds canonical Bearer JWT contribution", { timeout: 5000 }, () => {
const c = createJwtBearerSecurityScheme();
expect(c.name).toBe("bearerAuth");
expect(c.scheme.type).toBe("http");
if (c.scheme.type === "http") {
expect(c.scheme.scheme).toBe("bearer");
expect(c.scheme.bearerFormat).toBe("JWT");
}
});
it("renames the contribution", { timeout: 5000 }, () => {
const c = createJwtBearerSecurityScheme("userAuth");
expect(c.name).toBe("userAuth");
});
it("passes api-auth validateSecurityScheme", { timeout: 5000 }, () => {
const validated = validateSecurityScheme(JWT_BEARER_SCHEME);
expect(validated.name).toBe("bearerAuth");
});
});

View File

@@ -0,0 +1,32 @@
import type { SecuritySchemeContribution } from "@suaveplan/api-auth";
/**
* OpenAPI 3.1 `securitySchemes` contribution for the JWT Bearer adapter.
*
* Produces the `http` / `bearer` form with `bearerFormat: "JWT"` — the
* canonical Bearer-JWT scheme consumed by Stage-8 OpenAPI emission
* (`@suaveplan/api-openapi`). The contribution name defaults to
* `bearerAuth`; callers may rename it when running multiple JWT
* resolvers in the same host (e.g. user-side vs service-side tokens).
*/
export function createJwtBearerSecurityScheme(
name = "bearerAuth"
): SecuritySchemeContribution {
return {
name,
scheme: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
description:
"Bearer JWT verified by @suaveplan/api-auth-jwt. Token is read from the `Authorization` header (or configured cookie). Verification uses the JWKS endpoint or static key configured on the adapter.",
},
};
}
/**
* Convenience pre-built contribution under the canonical `bearerAuth`
* name — exposed for callers that only ever run a single JWT resolver.
*/
export const JWT_BEARER_SCHEME: SecuritySchemeContribution =
createJwtBearerSecurityScheme();

View File

@@ -0,0 +1,265 @@
import { generateKeyPairSync } from "node:crypto";
import {
createServer,
type IncomingMessage,
type Server,
type ServerResponse,
} from "node:http";
import {
parseRsaPrivateKeyJwk,
type RsaPrivateKey,
} from "@suaveplan/crypto-rsa";
import { compactSign } from "@suaveplan/jws";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AUTH_JWT_CODES,
AuthJwtError,
} from "../../src/error-codes/error-codes.js";
import { createJwtSessionResolver } from "../../src/resolver/resolver.js";
interface KeyPair {
readonly publicJwk: Record<string, unknown>;
readonly signingKey: RsaPrivateKey;
}
function genRsaKeyPair(kid: string): KeyPair {
const { publicKey, privateKey } = generateKeyPairSync("rsa", {
modulusLength: 2048,
});
const publicJwk = {
...(publicKey.export({ format: "jwk" }) as Record<string, unknown>),
kid,
alg: "RS256",
use: "sig",
};
const privateJwk = privateKey.export({ format: "jwk" }) as Record<
string,
unknown
>;
const signingKey = parseRsaPrivateKeyJwk(privateJwk as never);
return { publicJwk, signingKey };
}
interface ServerHandle {
readonly url: string;
readonly server: Server;
fetchCount(): number;
setBody(body: string | (() => string)): void;
setStatus(status: number): void;
}
async function startJwksServer(initial: () => string): Promise<ServerHandle> {
let count = 0;
let bodyFn: () => string = initial;
let status = 200;
const server = createServer(
(_req: IncomingMessage, res: ServerResponse) => {
count++;
res.statusCode = status;
res.setHeader("content-type", "application/json");
res.end(bodyFn());
}
);
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve)
);
const addr = server.address();
if (addr === null || typeof addr === "string") {
throw new Error("server.listen returned no address");
}
return {
url: `http://127.0.0.1:${addr.port}/jwks.json`,
server,
fetchCount: () => count,
setBody(body) {
bodyFn = typeof body === "function" ? body : () => body;
},
setStatus(s) {
status = s;
},
};
}
async function closeServer(server: Server): Promise<void> {
await new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
});
}
const ISSUER = "https://idp.example";
const AUDIENCE = "https://api.example";
describe("integration: RS256 + real JWKS server", () => {
it("end-to-end signs with @suaveplan/jws and verifies via JWKS endpoint", {
timeout: 30000,
}, async () => {
const pair = genRsaKeyPair("kid-1");
const handle = await startJwksServer(() =>
JSON.stringify({ keys: [pair.publicJwk] })
);
try {
const token = await compactSign(
JSON.stringify({
iss: ISSUER,
aud: AUDIENCE,
sub: "u_integration",
scope: "a b",
}),
pair.signingKey,
"RS256"
);
const resolver = createJwtSessionResolver({
jwksUrl: handle.url,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["RS256"],
});
const principal = await resolver.resolve(
new Request("https://api.example/me", {
headers: { authorization: `Bearer ${token}` },
})
);
expect(principal?.id).toBe("u_integration");
expect(principal?.scopes).toEqual(["a", "b"]);
await resolver.dispose();
expect(handle.fetchCount()).toBe(1);
} finally {
await closeServer(handle.server);
}
});
it("refetches JWKS when kid rotates", { timeout: 30000 }, async () => {
const oldPair = genRsaKeyPair("kid-old");
const newPair = genRsaKeyPair("kid-new");
const handle = await startJwksServer(() =>
JSON.stringify({ keys: [oldPair.publicJwk] })
);
try {
const resolver = createJwtSessionResolver({
jwksUrl: handle.url,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["RS256"],
});
const oldToken = await compactSign(
JSON.stringify({
iss: ISSUER,
aud: AUDIENCE,
sub: "old-user",
}),
oldPair.signingKey,
"RS256",
{ protectedHeader: { kid: "kid-old" } }
);
const p1 = await resolver.resolve(
new Request("https://api.example/me", {
headers: { authorization: `Bearer ${oldToken}` },
})
);
expect(p1?.id).toBe("old-user");
// Rotate the JWKS document.
handle.setBody(JSON.stringify({ keys: [newPair.publicJwk] }));
const newToken = await compactSign(
JSON.stringify({
iss: ISSUER,
aud: AUDIENCE,
sub: "new-user",
}),
newPair.signingKey,
"RS256",
{ protectedHeader: { kid: "kid-new" } }
);
const p2 = await resolver.resolve(
new Request("https://api.example/me", {
headers: { authorization: `Bearer ${newToken}` },
})
);
// Two fetches: once on first call, once on kid miss for newToken.
expect(handle.fetchCount()).toBe(2);
expect(p2?.id).toBe("new-user");
await resolver.dispose();
} finally {
await closeServer(handle.server);
}
});
it("returns null when JWKS has no matching kid and document is fresh", {
timeout: 30000,
}, async () => {
const pair = genRsaKeyPair("kid-a");
const otherPair = genRsaKeyPair("kid-b");
const handle = await startJwksServer(() =>
JSON.stringify({ keys: [pair.publicJwk] })
);
try {
const token = await compactSign(
JSON.stringify({
iss: ISSUER,
aud: AUDIENCE,
sub: "u",
}),
otherPair.signingKey,
"RS256",
{ protectedHeader: { kid: "kid-not-in-jwks" } }
);
const resolver = createJwtSessionResolver({
jwksUrl: handle.url,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["RS256"],
});
const p = await resolver.resolve(
new Request("https://api.example/me", {
headers: { authorization: `Bearer ${token}` },
})
);
expect(p).toBeNull();
await resolver.dispose();
} finally {
await closeServer(handle.server);
}
});
it("propagates JWKS_FETCH_FAILED when endpoint returns 500", {
timeout: 30000,
}, async () => {
const handle = await startJwksServer(() => "boom");
handle.setStatus(500);
try {
const resolver = createJwtSessionResolver({
jwksUrl: handle.url,
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["RS256"],
});
// Construct a token with a kid so the JWKS fetch is attempted.
const pair = genRsaKeyPair("kid-x");
const token = await compactSign(
JSON.stringify({
iss: ISSUER,
aud: AUDIENCE,
sub: "u",
}),
pair.signingKey,
"RS256"
);
try {
await resolver.resolve(
new Request("https://api.example/me", {
headers: { authorization: `Bearer ${token}` },
})
);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(AuthJwtError);
expect((err as AuthJwtError).code).toBe(
AUTH_JWT_CODES.JWKS_FETCH_FAILED
);
}
await resolver.dispose();
} finally {
await closeServer(handle.server);
}
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/api-auth-jwt",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [/^@suaveplan\//, /^node:/, "tslib", "zod"],
},
},
});

View File

@@ -0,0 +1,31 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});