Initial Commit
This commit is contained in:
135
packages/api-stack/api/api-auth-jwt/README.md
Normal file
135
packages/api-stack/api/api-auth-jwt/README.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# @suaveplan/api-auth-jwt — concrete JWT session resolver for @suaveplan/api-auth. Verifies a JWT presented on the Authorization header (or a configurable cookie) using @suaveplan/jws against either a JWKS endpoint (TTL + negative-cache + in-flight dedup) or a single static JsonWebKey; projects validated claims onto a Principal via an overridable mapper. Returns null on parse/verify failure (chain semantics) and contributes a Bearer JWT OpenAPI security scheme.
|
||||
|
||||
Stage-4 concrete `SessionResolver` for the
|
||||
[`@suaveplan/api`](../api/README.md) plugin system. Verifies a JWT
|
||||
presented on the `Authorization: Bearer <jwt>` header (or a configured
|
||||
cookie) against either a remote JWKS endpoint or a single static JWK,
|
||||
then projects the validated claims onto the foundation's `Principal`
|
||||
contract via an overridable mapper.
|
||||
|
||||
This is the adapter you pair with hosted identity providers (Clerk,
|
||||
Auth0, Cognito, Stytch, custom Keycloak deployments) or with any
|
||||
self-hosted JWT-issuing provider. It is **session-resolver only** —
|
||||
login / register / refresh routes are the responsibility of an `auth`
|
||||
provider, not this package.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [claims-mapper](./src/claims-mapper/claims-mapper.md) — Default and runtime helpers for converting a verified JWT payload (`Claims`) into a `Principal` from `@suaveplan/api-ty…
|
||||
- [claims-mapper](./src/claims-mapper/claims-mapper.md) — Default and runtime helpers for converting a verified JWT payload (`Claims`) into a `Principal` from `@suaveplan/api-ty…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/api-auth-jwt`, exposing the `AUTH_JWT.*` namespace via `defineCodes` from `@…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/api-auth-jwt`, exposing the `AUTH_JWT.*` namespace via `defineCodes` from `@…
|
||||
- [jwks-cache](./src/jwks-cache/jwks-cache.md) — In-process JWKS cache for `@suaveplan/api-auth-jwt`. Fetches a JWKS document from a remote endpoint, holds it under a p…
|
||||
- [jwks-cache](./src/jwks-cache/jwks-cache.md) — In-process JWKS cache for `@suaveplan/api-auth-jwt`. Fetches a JWKS document from a remote endpoint, holds it under a p…
|
||||
- [options](./src/options/options.md) — Defines the Zod-validated boundary for `createJwtSessionResolver` and the companion `validateOptions` helper. The bound…
|
||||
- [options](./src/options/options.md) — Defines the Zod-validated boundary for `createJwtSessionResolver` and the companion `validateOptions` helper. The bound…
|
||||
- [resolver](./src/resolver/resolver.md) — Stage-4 concrete `SessionResolver` factory for the API plugin system. Reads a JWT from the request (Authorization h…
|
||||
- [resolver](./src/resolver/resolver.md) — Stage-4 concrete `SessionResolver` factory for the API plugin system. Reads a JWT from the request (Authorization h…
|
||||
- [security-scheme](./src/security-scheme/security-scheme.md) — Builds the `SecuritySchemeContribution` this adapter contributes to the generated OpenAPI document. The shape is the ca…
|
||||
- [security-scheme](./src/security-scheme/security-scheme.md) — Builds the `SecuritySchemeContribution` this adapter contributes to the generated OpenAPI document. The shape is the ca…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Surface
|
||||
|
||||
- [`./resolver`](src/resolver/resolver.md) — `createJwtSessionResolver(opts)`.
|
||||
- [`./jwks-cache`](src/jwks-cache/jwks-cache.md) — `JwksCache` with TTL,
|
||||
negative cache, in-flight dedup, `kid`-miss refresh.
|
||||
- [`./claims-mapper`](src/claims-mapper/claims-mapper.md) —
|
||||
`defaultClaimsToPrincipal` + `runClaimsMapper` helpers.
|
||||
- [`./security-scheme`](src/security-scheme/security-scheme.md) —
|
||||
`JWT_BEARER_SCHEME` and `createJwtBearerSecurityScheme(name)` for
|
||||
OpenAPI emission.
|
||||
- [`./error-codes`](src/error-codes/error-codes.md) — `AUTH_JWT_*` codes
|
||||
+ `AuthJwtError` class.
|
||||
- [`./options`](src/options/options.md) — Zod-validated options
|
||||
boundary.
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { createJwtSessionResolver } from "@suaveplan/api-auth-jwt/resolver";
|
||||
import { JWT_BEARER_SCHEME } from "@suaveplan/api-auth-jwt/security-scheme";
|
||||
|
||||
const resolver = createJwtSessionResolver({
|
||||
jwksUrl: "https://idp.example/.well-known/jwks.json",
|
||||
issuer: "https://idp.example",
|
||||
audience: "https://api.example",
|
||||
algorithms: ["RS256"],
|
||||
});
|
||||
|
||||
// Use in an api-host AuthAdapter:
|
||||
const adapter = {
|
||||
resolver,
|
||||
securityScheme: JWT_BEARER_SCHEME,
|
||||
};
|
||||
```
|
||||
|
||||
## Pairing with hosted IdPs
|
||||
|
||||
The package is designed to be paired with an external identity provider
|
||||
that already issues JWTs. The recipe is the same for every provider —
|
||||
plug in the provider's JWKS URL, issuer, and audience, then add a custom
|
||||
`claimsToPrincipal` when the provider uses non-standard claim names.
|
||||
|
||||
### Clerk
|
||||
|
||||
```ts
|
||||
createJwtSessionResolver({
|
||||
jwksUrl: "https://your-instance.clerk.accounts.dev/.well-known/jwks.json",
|
||||
issuer: "https://your-instance.clerk.accounts.dev",
|
||||
audience: "your-frontend-api",
|
||||
algorithms: ["RS256"],
|
||||
claimsToPrincipal: (claims) => {
|
||||
const sub = claims["sub"];
|
||||
if (typeof sub !== "string") return null;
|
||||
return {
|
||||
id: sub as never,
|
||||
scopes: [] as never[],
|
||||
tenantId: typeof claims["org_id"] === "string"
|
||||
? (claims["org_id"] as never)
|
||||
: undefined,
|
||||
claims,
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Auth0
|
||||
|
||||
```ts
|
||||
createJwtSessionResolver({
|
||||
jwksUrl: "https://your-tenant.auth0.com/.well-known/jwks.json",
|
||||
issuer: "https://your-tenant.auth0.com/",
|
||||
audience: "https://api.example.com",
|
||||
algorithms: ["RS256"],
|
||||
});
|
||||
```
|
||||
|
||||
### Cognito
|
||||
|
||||
```ts
|
||||
createJwtSessionResolver({
|
||||
jwksUrl: "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_XXXXXX/.well-known/jwks.json",
|
||||
issuer: "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_XXXXXX",
|
||||
audience: "your-cognito-app-client-id",
|
||||
algorithms: ["RS256"],
|
||||
});
|
||||
```
|
||||
|
||||
## Semantics
|
||||
|
||||
`resolve()` returns `null` on any per-request soft failure (missing
|
||||
token, malformed token, bad signature, wrong issuer, wrong audience,
|
||||
expired, not-yet-valid, no matching JWKS key). That collapse lets the
|
||||
upstream `SessionResolverChain` fall through to the next adapter
|
||||
(anonymous, static-bearer, etc.). It throws only on infrastructure
|
||||
failures — `AUTH_JWT.JWKS_FETCH_FAILED` when the JWKS endpoint is
|
||||
unreachable, `AUTH_JWT.INVALID_KEY_FORMAT` for a malformed static key,
|
||||
`AUTH_JWT.INVALID_CLAIMS_MAPPER` when the caller's mapper throws or
|
||||
returns a non-Principal value.
|
||||
|
||||
## License
|
||||
|
||||
MIT — see the repository.
|
||||
|
||||
8
packages/api-stack/api/api-auth-jwt/_scratch.mts
Normal file
8
packages/api-stack/api/api-auth-jwt/_scratch.mts
Normal 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);
|
||||
97
packages/api-stack/api/api-auth-jwt/package.json
Normal file
97
packages/api-stack/api/api-auth-jwt/package.json
Normal 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"]
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
# `claims-mapper` — `Claims → Principal` projection
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Default and runtime helpers for converting a verified JWT payload
|
||||
(`Claims`) into a `Principal` from `@suaveplan/api-types`. The default
|
||||
projection follows OAuth 2 / OIDC conventions: `sub` for subject,
|
||||
space-delimited `scope` (or array-form `scopes`) for authorisation
|
||||
scopes, and `tenant_id` / `tid` for the tenant. Callers can override
|
||||
the projection entirely to read non-standard claims (e.g. Clerk's
|
||||
`org_id`) or to enforce additional invariants — return `null` to defer
|
||||
to the next resolver in the chain.
|
||||
|
||||
## Features
|
||||
|
||||
- `defaultClaimsToPrincipal` — drop-in OAuth 2 / OIDC projection.
|
||||
- `runClaimsMapper` — wraps a caller-supplied mapper; catches throws,
|
||||
rejects non-Principal returns, surfaces both as
|
||||
`AUTH_JWT.INVALID_CLAIMS_MAPPER`.
|
||||
- Returns `null` when the token has no `sub` — that is the documented
|
||||
"not a user identity" path and lets the chain move on.
|
||||
- Brands `PrincipalId`, `Scope`, and `TenantId` via type assertion at
|
||||
the trust boundary; downstream consumers see opaque branded strings.
|
||||
- Passes the full claims record through as `principal.claims` so
|
||||
downstream tenant resolvers / authorisation rules can read raw values
|
||||
without a second token parse.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { defaultClaimsToPrincipal } from "@suaveplan/api-auth-jwt/claims-mapper";
|
||||
|
||||
const principal = defaultClaimsToPrincipal({
|
||||
sub: "user_123",
|
||||
scope: "billing:read profile:read",
|
||||
tenant_id: "acme",
|
||||
});
|
||||
// → { id: "user_123", scopes: ["billing:read", "profile:read"], tenantId: "acme", claims: {...} }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Override the projection to read Clerk-style `org_id` and enforce a
|
||||
non-empty scopes list:
|
||||
|
||||
```ts
|
||||
import type { ClaimsToPrincipal } from "@suaveplan/api-auth-jwt/claims-mapper";
|
||||
|
||||
const clerkMapper: ClaimsToPrincipal = (claims) => {
|
||||
const sub = claims["sub"];
|
||||
const scope = claims["scope"];
|
||||
const org = claims["org_id"];
|
||||
if (typeof sub !== "string" || typeof scope !== "string") return null;
|
||||
return {
|
||||
id: sub as never,
|
||||
scopes: scope.split(" ") as never[],
|
||||
tenantId: typeof org === "string" ? (org as never) : undefined,
|
||||
claims,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ClaimsToPrincipal` — `(claims) => Principal | null`.
|
||||
- `defaultClaimsToPrincipal: ClaimsToPrincipal` — the built-in projection.
|
||||
- `runClaimsMapper(mapper, claims): Principal | null` — invoke a mapper
|
||||
with structured error reporting.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The default mapper deliberately tolerates either string-form (`"a b c"`)
|
||||
or array-form (`["a", "b", "c"]`) scopes — RFC 6749 §3.3 uses the
|
||||
former, while several IdPs (Auth0, Cognito) emit the latter. Empty
|
||||
strings are filtered out so a stray double-space does not produce a
|
||||
zero-length scope token. Tenant extraction prefers `tenant_id` (the
|
||||
Suaveplan-canonical name) over `tid` (the Microsoft / OIDC name) but
|
||||
accepts both. A mapper that throws is treated as an operator bug, not a
|
||||
caller error — surfacing it as a `null` would silently mask broken
|
||||
configuration.
|
||||
@@ -0,0 +1,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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
defaultClaimsToPrincipal,
|
||||
runClaimsMapper,
|
||||
} from "./claims-mapper.js";
|
||||
export type { ClaimsToPrincipal } from "./claims-mapper.types.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
AUTH_JWT_CODES,
|
||||
AuthJwtError,
|
||||
type AuthJwtErrorCode,
|
||||
} from "./error-codes.js";
|
||||
25
packages/api-stack/api/api-auth-jwt/src/index.ts
Normal file
25
packages/api-stack/api/api-auth-jwt/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1 @@
|
||||
export { JwksCache, type JwksDocument } from "./jwks-cache.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
190
packages/api-stack/api/api-auth-jwt/src/jwks-cache/jwks-cache.ts
Normal file
190
packages/api-stack/api/api-auth-jwt/src/jwks-cache/jwks-cache.ts
Normal 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);
|
||||
}
|
||||
6
packages/api-stack/api/api-auth-jwt/src/options/index.ts
Normal file
6
packages/api-stack/api/api-auth-jwt/src/options/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type JwtSessionResolverOptions,
|
||||
JwtSessionResolverOptionsSchema,
|
||||
type ValidatedJwtSessionResolverOptions,
|
||||
validateOptions,
|
||||
} from "./options.js";
|
||||
82
packages/api-stack/api/api-auth-jwt/src/options/options.md
Normal file
82
packages/api-stack/api/api-auth-jwt/src/options/options.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# `options` — `JwtSessionResolverOptions` boundary
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the Zod-validated boundary for `createJwtSessionResolver` and the
|
||||
companion `validateOptions` helper. The boundary enforces every static
|
||||
contract the runtime depends on: exactly one of `jwksUrl` or `staticKey`,
|
||||
non-empty `algorithms` allowlist, mandatory `issuer` and `audience`, a
|
||||
positive cache TTL, and a non-negative negative-cache window. Anything
|
||||
the schema cannot encode (injectable clock, fetch impl, claims mapper
|
||||
function) is part of the wider `JwtSessionResolverOptions` TypeScript
|
||||
type but is not Zod-validated — those are dev-time contracts.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict-mode object schema rejects unknown keys at construction.
|
||||
- Mutual exclusion check for `jwksUrl` / `staticKey` — both supplied or
|
||||
neither supplied throws `AUTH_JWT.INVALID_KEY_FORMAT`.
|
||||
- Sensible defaults: header name `authorization`, JWKS TTL 10 min,
|
||||
negative cache 30s, clock tolerance 0.
|
||||
- Accepts `issuer` and `audience` as either a single string or a
|
||||
non-empty list of strings — JWS verify enforces membership.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { validateOptions } from "@suaveplan/api-auth-jwt/options";
|
||||
|
||||
const opts = validateOptions({
|
||||
jwksUrl: "https://idp.example/.well-known/jwks.json",
|
||||
issuer: "https://idp.example",
|
||||
audience: "https://api.example",
|
||||
algorithms: ["RS256"],
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Custom header, cookie fallback, tighter cache TTL, and a custom claims
|
||||
mapper:
|
||||
|
||||
```ts
|
||||
import { validateOptions } from "@suaveplan/api-auth-jwt/options";
|
||||
|
||||
const opts = validateOptions({
|
||||
staticKey: { kty: "oct", k: "..." },
|
||||
issuer: ["https://idp1.example", "https://idp2.example"],
|
||||
audience: "https://api.example",
|
||||
algorithms: ["HS256"],
|
||||
headerName: "x-api-token",
|
||||
cookieName: "sp_session",
|
||||
clockToleranceSec: 5,
|
||||
jwksCacheTtlMs: 60_000,
|
||||
jwksNegativeCacheMs: 5_000,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `JwtSessionResolverOptionsSchema` — Zod object schema.
|
||||
- `JwtSessionResolverOptions` — public surface (includes non-serialisable
|
||||
fields like `clock`, `fetchImpl`, `claimsToPrincipal`).
|
||||
- `ValidatedJwtSessionResolverOptions` — inferred shape after defaults
|
||||
are applied.
|
||||
- `validateOptions(raw)` — synchronous validator; throws `AuthJwtError`
|
||||
with code `AUTH_JWT.INVALID_KEY_FORMAT` on any boundary failure.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema is intentionally `.strict()` so a typo in a key name fails
|
||||
loudly at construction rather than silently activating defaults. `iss`
|
||||
and `aud` are both required because token-level `iss` / `aud` confusion
|
||||
is a documented attack vector — every Stage-4 deployment is expected to
|
||||
pin both. `algorithms` is also mandatory and non-empty — `@suaveplan/jws`
|
||||
already refuses to verify without an allowlist, but we re-enforce it at
|
||||
the API boundary so the error surface is consistent. The mutual
|
||||
exclusion check after parsing matches what the spec calls out: a single
|
||||
key source is the only way to reason about JWKS cache invalidation.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
138
packages/api-stack/api/api-auth-jwt/src/options/options.ts
Normal file
138
packages/api-stack/api/api-auth-jwt/src/options/options.ts
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createJwtSessionResolver } from "./resolver.js";
|
||||
108
packages/api-stack/api/api-auth-jwt/src/resolver/resolver.md
Normal file
108
packages/api-stack/api/api-auth-jwt/src/resolver/resolver.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# `resolver` — `createJwtSessionResolver`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Stage-4 concrete `SessionResolver` factory for the API plugin
|
||||
foundation. Reads a JWT from the request (Authorization header or
|
||||
configurable cookie), verifies it via `@suaveplan/jws` against either a
|
||||
JWKS endpoint (cached and refreshed on `kid` miss) or a single static
|
||||
JWK, validates `iss`, `aud`, `exp`, `nbf` against the configured policy,
|
||||
and projects the validated claims onto a `Principal` via an overridable
|
||||
mapper. The factory is the only public entry point — internals
|
||||
(`JwksCache`, claims mapper, error registry) are surface-exported for
|
||||
testability but most consumers use `createJwtSessionResolver(opts)` alone.
|
||||
|
||||
## Features
|
||||
|
||||
- `Authorization: Bearer <jwt>` header extraction with case-insensitive
|
||||
scheme match; configurable header name accepts a raw token form for
|
||||
non-standard headers (e.g. `x-api-token: <jwt>`).
|
||||
- Cookie fallback when `cookieName` is supplied; header still wins when
|
||||
both are present.
|
||||
- JWKS resolution via `JwksCache` with positive TTL, negative cache,
|
||||
in-flight dedup, and `kid`-miss refresh.
|
||||
- Static-key path for HS-family symmetric tokens or single-key
|
||||
deployments.
|
||||
- Mandatory `iss` and `aud` enforcement (single string or list); `exp`
|
||||
and `nbf` enforced when present with configurable `clockToleranceSec`.
|
||||
- Per-request soft failures collapse to `null` so the upstream
|
||||
`SessionResolverChain` can fall through to the next adapter; operator
|
||||
failures (JWKS unreachable, malformed mapper, malformed static key)
|
||||
surface as `AuthJwtError` with the documented codes.
|
||||
- Idempotent `dispose()` flips an internal flag so subsequent
|
||||
`resolve()` calls return `null` rather than emitting late traffic on
|
||||
a torn-down resolver.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createJwtSessionResolver } from "@suaveplan/api-auth-jwt/resolver";
|
||||
|
||||
const resolver = createJwtSessionResolver({
|
||||
jwksUrl: "https://idp.example/.well-known/jwks.json",
|
||||
issuer: "https://idp.example",
|
||||
audience: "https://api.example",
|
||||
algorithms: ["RS256"],
|
||||
});
|
||||
|
||||
const req = new Request("https://api.example/users", {
|
||||
headers: { authorization: "Bearer eyJ..." },
|
||||
});
|
||||
const principal = await resolver.resolve(req);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Static HS256 key, cookie fallback, custom claims mapper, and an
|
||||
injectable clock for deterministic temporal-claim tests:
|
||||
|
||||
```ts
|
||||
import { createJwtSessionResolver } from "@suaveplan/api-auth-jwt/resolver";
|
||||
|
||||
const resolver = createJwtSessionResolver({
|
||||
staticKey: { kty: "oct", k: "<base64url-secret>" },
|
||||
issuer: ["https://idp1.example", "https://idp2.example"],
|
||||
audience: "https://api.example",
|
||||
algorithms: ["HS256"],
|
||||
cookieName: "sp_session",
|
||||
clockToleranceSec: 5,
|
||||
clock: { nowMs: () => 1_700_000_000_000 },
|
||||
claimsToPrincipal: (claims) => {
|
||||
const sub = claims["sub"];
|
||||
if (typeof sub !== "string") return null;
|
||||
return {
|
||||
id: sub as never,
|
||||
scopes: [] as never[],
|
||||
tenantId: typeof claims["org_id"] === "string"
|
||||
? (claims["org_id"] as never)
|
||||
: undefined,
|
||||
claims,
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createJwtSessionResolver(opts: JwtSessionResolverOptions): SessionResolver`
|
||||
— returns the `@suaveplan/api-auth` `SessionResolver` shape (`resolve` +
|
||||
idempotent `dispose`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The resolver deliberately separates "this is not my caller" (chain
|
||||
falls through, returns `null`) from "the host is mis-configured"
|
||||
(throws `AuthJwtError`). Verification failures inside `@suaveplan/jws`
|
||||
return `null` so a stray Bearer header from an unrelated IdP cannot
|
||||
401-storm the API — the chain's next adapter (anonymous, static key,
|
||||
etc.) still gets its turn. The temporal-claim check uses a single
|
||||
clock reading per request to avoid TOCTOU between the `exp` and `nbf`
|
||||
arms. We re-implement `iss`/`aud`/`exp`/`nbf` enforcement here rather
|
||||
than push it into `@suaveplan/jws` because those are JWT-claim
|
||||
concerns, not JWS-protected-header concerns — the JWS package
|
||||
deliberately handles only RFC 7515 (signature) and leaves RFC 7519
|
||||
(payload claims) to its consumers.
|
||||
@@ -0,0 +1,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();
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
240
packages/api-stack/api/api-auth-jwt/src/resolver/resolver.ts
Normal file
240
packages/api-stack/api/api-auth-jwt/src/resolver/resolver.ts
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
createJwtBearerSecurityScheme,
|
||||
JWT_BEARER_SCHEME,
|
||||
} from "./security-scheme.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
12
packages/api-stack/api/api-auth-jwt/tsconfig.json
Normal file
12
packages/api-stack/api/api-auth-jwt/tsconfig.json
Normal 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"]
|
||||
}
|
||||
12
packages/api-stack/api/api-auth-jwt/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-auth-jwt/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
30
packages/api-stack/api/api-auth-jwt/vite.config.ts
Normal file
30
packages/api-stack/api/api-auth-jwt/vite.config.ts
Normal 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"],
|
||||
},
|
||||
},
|
||||
});
|
||||
31
packages/api-stack/api/api-auth-jwt/vitest.config.ts
Normal file
31
packages/api-stack/api/api-auth-jwt/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user