Initial Commit
This commit is contained in:
155
packages/api-stack/api/api-auth-bearer-static/README.md
Normal file
155
packages/api-stack/api/api-auth-bearer-static/README.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# @suaveplan/api-auth-bearer-static — SessionResolver adapter for service-to-service static bearer tokens. Matches Authorization: Bearer <token> against a fixed token→Principal table using constant-time comparison from @suaveplan/crypto-utils; unknown tokens defer to the next chain entry. Supports an optional per-token mutual-TLS gate that rejects when the host did not surface request.tls.peerCertificate. Ships both a direct surface (createBearerStaticResolver) and a worker-proxy surface (createBearerStaticResolverWorkerProxy) over @suaveplan/api-worker-rpc; both pass runAuthSessionConformance from @suaveplan/api-auth/testing. OpenAPI security-scheme contribution (HTTP Bearer). Zod-validated options, AUTH_BEARER_STATIC.* error codes, OTel match counter partitioned by outcome.
|
||||
|
||||
Stage-4 service-to-service `SessionResolver` adapter for the
|
||||
`@suaveplan/api` plugin system. Matches an inbound `Authorization:
|
||||
Bearer <token>` header against a fixed `token → Principal` table using
|
||||
constant-time comparison from `@suaveplan/crypto-utils`. Unknown tokens
|
||||
return `null` so the host's `SessionResolverChain` falls through to the
|
||||
next adapter (typically `api-auth-jwt` for user sessions).
|
||||
|
||||
Ships both the direct surface (`createBearerStaticResolver`) and the
|
||||
worker-proxy surface (`createBearerStaticResolverWorkerProxy` +
|
||||
`serveBearerStaticResolverOnPort`) over `@suaveplan/api-worker-rpc`; both
|
||||
pass `runAuthSessionConformance` from `@suaveplan/api-auth/testing`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [constant-time](./src/constant-time/token-match.md) — `findTokenIndex` performs a constant-time lookup of a presented bearer token against a fixed table, returning the match…
|
||||
- [token-match](./src/constant-time/token-match.md) — `findTokenIndex` performs a constant-time lookup of a presented bearer token against a fixed table, returning the match…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module defines the stable `AUTH_BEARER_STATIC.*` namespace that every public failure path in this packa…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The error-codes module defines the stable `AUTH_BEARER_STATIC.*` namespace that every public failure path in this packa…
|
||||
- [factory](./src/factory/create-bearer-static-resolver.md) — `createBearerStaticResolver` is the direct-surface entry point of the package. It accepts validated `BearerStaticResolv…
|
||||
- [create-bearer-static-resolver](./src/factory/create-bearer-static-resolver.md) — `createBearerStaticResolver` is the direct-surface entry point of the package. It accepts validated `BearerStaticResolv…
|
||||
- [mtls](./src/mtls/mtls-gate.md) — The mTLS gate enforces an optional second factor on individual token table entries. When operators mark an entry with `…
|
||||
- [mtls-gate](./src/mtls/mtls-gate.md) — The mTLS gate enforces an optional second factor on individual token table entries. When operators mark an entry with `…
|
||||
- [options](./src/options/bearer-static-resolver-options.md) — The options module owns the public construction surface of every resolver in this package. It defines the `BearerStatic…
|
||||
- [bearer-static-resolver-options](./src/options/bearer-static-resolver-options.md) — The options module owns the public construction surface of every resolver in this package. It defines the `BearerStatic…
|
||||
- [proxy](./src/proxy/create-bearer-static-resolver-worker-proxy.md) — The worker-proxy surface is the second of the two Stage-4 surfaces this adapter ships (`§5.2a`). It lets the host insta…
|
||||
- [create-bearer-static-resolver-worker-proxy](./src/proxy/create-bearer-static-resolver-worker-proxy.md) — The worker-proxy surface is the second of the two Stage-4 surfaces this adapter ships (`§5.2a`). It lets the host insta…
|
||||
- [security-scheme](./src/security-scheme/security-scheme-contribution.md) — This module builds the OpenAPI 3.1 `components.securitySchemes` contribution the adapter publishes to the host's `AuthA…
|
||||
- [security-scheme-contribution](./src/security-scheme/security-scheme-contribution.md) — This module builds the OpenAPI 3.1 `components.securitySchemes` contribution the adapter publishes to the host's `AuthA…
|
||||
- [telemetry](./src/telemetry/instruments.md) — The telemetry module owns the single OpenTelemetry counter this package emits — `api.auth.bearer_static_match_total` —…
|
||||
- [instruments](./src/telemetry/instruments.md) — The telemetry module owns the single OpenTelemetry counter this package emits — `api.auth.bearer_static_match_total` —…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
bun add @suaveplan/api-auth-bearer-static
|
||||
```
|
||||
|
||||
## Direct surface
|
||||
|
||||
```ts
|
||||
import { createBearerStaticResolver } from "@suaveplan/api-auth-bearer-static";
|
||||
import { SessionResolverChain } from "@suaveplan/api-auth";
|
||||
|
||||
const serviceAuth = createBearerStaticResolver({
|
||||
tokens: [
|
||||
{
|
||||
token: process.env.CRON_TOKEN,
|
||||
principal: {
|
||||
id: "svc_cron",
|
||||
scopes: ["jobs:run"],
|
||||
claims: { kind: "service" },
|
||||
},
|
||||
},
|
||||
{
|
||||
token: process.env.ADMIN_TOKEN,
|
||||
principal: {
|
||||
id: "svc_admin",
|
||||
scopes: ["admin:*"],
|
||||
claims: { kind: "admin" },
|
||||
},
|
||||
mtlsRequired: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Chained — service tokens first, JWT users fall through.
|
||||
const chain = new SessionResolverChain([serviceAuth, jwtResolver]);
|
||||
```
|
||||
|
||||
## Chained-resolver pattern with `api-auth-jwt`
|
||||
|
||||
The intended deployment topology pairs this adapter with
|
||||
`@suaveplan/api-auth-jwt` for user sessions. Static-token callers match
|
||||
first; everything else flows to JWT. The chain's `first-match-wins`
|
||||
semantics means unknown tokens never throw — they simply move on.
|
||||
|
||||
```ts
|
||||
import { SessionResolverChain } from "@suaveplan/api-auth";
|
||||
import { createBearerStaticResolver } from "@suaveplan/api-auth-bearer-static";
|
||||
import { createJwtSessionResolver } from "@suaveplan/api-auth-jwt"; // future sibling
|
||||
|
||||
const chain = new SessionResolverChain([
|
||||
createBearerStaticResolver({
|
||||
tokens: [/* service callers */],
|
||||
}),
|
||||
createJwtSessionResolver({
|
||||
issuer: "https://idp.example",
|
||||
audience: "suaveplan",
|
||||
}),
|
||||
]);
|
||||
```
|
||||
|
||||
## Worker-proxy surface
|
||||
|
||||
Use the worker-proxy variant when the host process should not hold the
|
||||
real token table — e.g. an isolated auth worker serving multiple host
|
||||
workers. The host imports `createBearerStaticResolverWorkerProxy(port)`;
|
||||
the worker imports `serveBearerStaticResolverOnPort(port, options)`. The
|
||||
proxy transparently passes `runAuthSessionConformance`, so plugin code
|
||||
treats it identically to the direct surface.
|
||||
|
||||
```ts
|
||||
// host.ts
|
||||
const proxy = createBearerStaticResolverWorkerProxy(port);
|
||||
const chain = new SessionResolverChain([proxy, jwtResolver]);
|
||||
```
|
||||
|
||||
```ts
|
||||
// worker.ts
|
||||
serveBearerStaticResolverOnPort(port, {
|
||||
tokens: [/* real table — never crosses to the host */],
|
||||
});
|
||||
```
|
||||
|
||||
## Optional mutual-TLS gate
|
||||
|
||||
Token entries MAY carry `mtlsRequired: true`. When set, the resolver
|
||||
requires the host listener to surface a verified peer certificate via
|
||||
`request.tls.peerCertificate`; absent → returns `null` (outcome:
|
||||
`mtls-required`). The adapter does NOT establish TLS — that is the host
|
||||
listener's responsibility (Bun TLS, Node `https`, Cloudflare Worker TLS
|
||||
terminator, etc.).
|
||||
|
||||
## Token rotation
|
||||
|
||||
This adapter is intentionally static — rotate tokens by reloading host
|
||||
config. There is no DB-backed lookup, no JWKS, no refresh loop. If
|
||||
you need rotation without restarts, use `api-auth-better-auth` or a
|
||||
future `api-auth-token-table` adapter; this one is the simplest possible
|
||||
thing that resolves a fixed table against constant-time match.
|
||||
|
||||
## Errors
|
||||
|
||||
The package exposes the `AUTH_BEARER_STATIC.*` code namespace via
|
||||
`AUTH_BEARER_STATIC_CODES`. Only `INVALID_OPTIONS` is thrown
|
||||
(synchronously, at factory construction); `NO_TOKEN`, `UNKNOWN_TOKEN`,
|
||||
and `MTLS_REQUIRED` are surfaced via the OTel match counter's `outcome`
|
||||
label.
|
||||
|
||||
## Telemetry
|
||||
|
||||
One counter — `api.auth.bearer_static_match_total{outcome=match|no-token|unknown-token|mtls-required}`
|
||||
— increments exactly once per `resolve()` call.
|
||||
|
||||
## Conformance
|
||||
|
||||
Both surfaces pass `runAuthSessionConformance` from
|
||||
`@suaveplan/api-auth`. Spec-conformance tests live alongside the source
|
||||
under `src/factory/*.spec-conformance.test.ts` and
|
||||
`src/proxy/*.spec-conformance.test.ts`.
|
||||
|
||||
104
packages/api-stack/api/api-auth-bearer-static/package.json
Normal file
104
packages/api-stack/api/api-auth-bearer-static/package.json
Normal file
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"name": "@suaveplan/api-auth-bearer-static",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Stage-4 SessionResolver adapter for service-to-service static bearer tokens. Matches Authorization: Bearer <token> against a fixed token→Principal table using constant-time comparison from @suaveplan/crypto-utils; unknown tokens defer to the next chain entry. Supports an optional per-token mutual-TLS gate that rejects when the host did not surface request.tls.peerCertificate. Ships both a direct surface (createBearerStaticResolver) and a worker-proxy surface (createBearerStaticResolverWorkerProxy) over @suaveplan/api-worker-rpc; both pass runAuthSessionConformance from @suaveplan/api-auth/testing. OpenAPI security-scheme contribution (HTTP Bearer). Zod-validated options, AUTH_BEARER_STATIC.* error codes, OTel match counter partitioned by outcome.",
|
||||
"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/factory/create-bearer-static-resolver.d.ts",
|
||||
"default": "./dist/factory/create-bearer-static-resolver.js"
|
||||
}
|
||||
},
|
||||
"./proxy": {
|
||||
"import": {
|
||||
"types": "./dist/proxy/create-bearer-static-resolver-worker-proxy.d.ts",
|
||||
"default": "./dist/proxy/create-bearer-static-resolver-worker-proxy.js"
|
||||
}
|
||||
},
|
||||
"./options": {
|
||||
"import": {
|
||||
"types": "./dist/options/bearer-static-resolver-options.d.ts",
|
||||
"default": "./dist/options/bearer-static-resolver-options.js"
|
||||
}
|
||||
},
|
||||
"./security-scheme": {
|
||||
"import": {
|
||||
"types": "./dist/security-scheme/security-scheme-contribution.d.ts",
|
||||
"default": "./dist/security-scheme/security-scheme-contribution.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
},
|
||||
"./constant-time": {
|
||||
"import": {
|
||||
"types": "./dist/constant-time/token-match.d.ts",
|
||||
"default": "./dist/constant-time/token-match.js"
|
||||
}
|
||||
},
|
||||
"./mtls": {
|
||||
"import": {
|
||||
"types": "./dist/mtls/mtls-gate.d.ts",
|
||||
"default": "./dist/mtls/mtls-gate.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": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/api-auth": "workspace:^",
|
||||
"@suaveplan/api-types": "workspace:^",
|
||||
"@suaveplan/api-worker-rpc": "workspace:^",
|
||||
"@suaveplan/crypto-utils": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "1.30.0",
|
||||
"@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 @@
|
||||
export { findTokenIndex } from "./token-match.js";
|
||||
@@ -0,0 +1,82 @@
|
||||
# Token match — `@suaveplan/api-auth-bearer-static`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`findTokenIndex` performs a constant-time lookup of a presented bearer
|
||||
token against a fixed table, returning the matching entry's index or `-1`
|
||||
if no entry matches. The function is the security boundary of the package:
|
||||
every other code path in the resolver assumes the match is timing-safe so
|
||||
an attacker cannot binary-search the token table by measuring response
|
||||
latency.
|
||||
|
||||
The implementation walks every entry, calling `constantTimeEqual` from
|
||||
`@suaveplan/crypto-utils` once per row. The match-tracking variable is
|
||||
written only on the first match; subsequent matches are ignored. Crucially
|
||||
the loop does not break on first match — exiting early would leak the
|
||||
position of the matching token via wall-clock timing.
|
||||
|
||||
## Features
|
||||
|
||||
- Wraps `@suaveplan/crypto-utils` `constantTimeEqual` over UTF-8 byte
|
||||
views.
|
||||
- Iterates the full table for every call — no early exit, no short-circuit
|
||||
on `presented === entry.token`.
|
||||
- Returns the first matching index or `-1`.
|
||||
- Handles `undefined` entries (defensive guard for sparse arrays).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { findTokenIndex } from "@suaveplan/api-auth-bearer-static/constant-time";
|
||||
|
||||
const table = [
|
||||
{ token: "T1" },
|
||||
{ token: "T2" },
|
||||
];
|
||||
|
||||
const index = findTokenIndex("T2", table);
|
||||
// index === 1
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { findTokenIndex } from "@suaveplan/api-auth-bearer-static/constant-time";
|
||||
|
||||
interface TokenEntry {
|
||||
readonly token: string;
|
||||
readonly principalId: string;
|
||||
}
|
||||
|
||||
const table: ReadonlyArray<TokenEntry> = [
|
||||
{ token: "service-cron", principalId: "p1" },
|
||||
{ token: "service-admin", principalId: "p2" },
|
||||
];
|
||||
|
||||
function lookup(presented: string): TokenEntry | null {
|
||||
const idx = findTokenIndex(presented, table);
|
||||
return idx === -1 ? null : (table[idx] ?? null);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `findTokenIndex(presented: string, table: ReadonlyArray<{ token: string }>): number`
|
||||
— returns the index of the first entry whose `token` matches
|
||||
`presented`, or `-1` if no entry matches.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`constantTimeEqual` from `@suaveplan/crypto-utils` walks every byte of the
|
||||
two `Uint8Array` views and accumulates differences into an integer via
|
||||
bitwise OR; the function returns whether the accumulator stayed zero. The
|
||||
length-mismatch fast path is documented as not secret (token lengths are
|
||||
not secret in this adapter's threat model — the table is operator-supplied
|
||||
config). The `TextEncoder` round-trip ensures we compare bytes, not
|
||||
surrogate-pair code units; otherwise an attacker could compare a multi-
|
||||
byte presented token against a single-byte entry and learn from the
|
||||
length-mismatch shortcut whether their candidate had the right length.
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { findTokenIndex } from "./token-match.js";
|
||||
|
||||
describe("findTokenIndex", () => {
|
||||
it("returns the matching index", { timeout: 5000 }, () => {
|
||||
const table = [{ token: "T1" }, { token: "T2" }, { token: "T3" }];
|
||||
expect(findTokenIndex("T2", table)).toBe(1);
|
||||
});
|
||||
|
||||
it("returns -1 on miss", { timeout: 5000 }, () => {
|
||||
const table = [{ token: "T1" }, { token: "T2" }];
|
||||
expect(findTokenIndex("nope", table)).toBe(-1);
|
||||
});
|
||||
|
||||
it("returns first index when duplicates present", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const table = [{ token: "X" }, { token: "X" }];
|
||||
expect(findTokenIndex("X", table)).toBe(0);
|
||||
});
|
||||
|
||||
it("comparison runs to completion regardless of mismatch position", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// We cannot reliably measure wall-clock timing in unit tests on
|
||||
// shared hardware. Instead, observe behaviour: if the loop short-
|
||||
// circuited on first mismatched byte, the function would still
|
||||
// return the correct index — but we can prove no short-circuit by
|
||||
// confirming a guard sentinel placed after the loop body runs for
|
||||
// every entry. We do that by counting iterations via a Proxy.
|
||||
let reads = 0;
|
||||
const table = new Proxy(
|
||||
[{ token: "AAA" }, { token: "BBB" }, { token: "CCC" }],
|
||||
{
|
||||
get(target, prop, receiver) {
|
||||
if (typeof prop === "string" && /^\d+$/.test(prop)) {
|
||||
reads++;
|
||||
}
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
}
|
||||
);
|
||||
const idx = findTokenIndex("BBB", table);
|
||||
expect(idx).toBe(1);
|
||||
// Three entries accessed even though entry[1] matched.
|
||||
expect(reads).toBe(3);
|
||||
});
|
||||
|
||||
it("handles empty table", { timeout: 5000 }, () => {
|
||||
expect(findTokenIndex("any", [])).toBe(-1);
|
||||
});
|
||||
|
||||
it("treats undefined entries as misses", { timeout: 5000 }, () => {
|
||||
// Construct a sparse-like array: TypeScript discourages this but
|
||||
// it is a defensive guard against caller misuse.
|
||||
const table = [
|
||||
{ token: "A" },
|
||||
undefined as unknown as { token: string },
|
||||
{ token: "B" },
|
||||
];
|
||||
expect(findTokenIndex("B", table)).toBe(2);
|
||||
expect(findTokenIndex("A", table)).toBe(0);
|
||||
});
|
||||
|
||||
it("compares by UTF-8 bytes, not code units", { timeout: 5000 }, () => {
|
||||
// Multi-byte token; encoded form has 4 bytes.
|
||||
const table = [{ token: "café" }];
|
||||
expect(findTokenIndex("café", table)).toBe(0);
|
||||
expect(findTokenIndex("cafe", table)).toBe(-1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { constantTimeEqual } from "@suaveplan/crypto-utils/constant-time";
|
||||
|
||||
/**
|
||||
* Constant-time match of a presented bearer token against a fixed table.
|
||||
*
|
||||
* Walks every entry, comparing via `constantTimeEqual` from
|
||||
* `@suaveplan/crypto-utils`. The loop runs to completion regardless of
|
||||
* which entry matches — short-circuiting on first match would leak the
|
||||
* position of the matching token in the table via wall-clock timing,
|
||||
* which an attacker can use to probe valid prefixes by binary-searching
|
||||
* the table size.
|
||||
*
|
||||
* Token equality is compared by UTF-8 byte representation; tokens with
|
||||
* different byte lengths can never match (their `Uint8Array` views differ
|
||||
* in length and `constantTimeEqual` returns `false` immediately — the
|
||||
* length itself is not a secret per the documented contract).
|
||||
*/
|
||||
export function findTokenIndex(
|
||||
presented: string,
|
||||
table: ReadonlyArray<{ readonly token: string }>
|
||||
): number {
|
||||
const presentedBytes = new TextEncoder().encode(presented);
|
||||
let foundIndex = -1;
|
||||
for (let i = 0; i < table.length; i++) {
|
||||
const entry = table[i];
|
||||
if (entry === undefined) continue;
|
||||
const candidateBytes = new TextEncoder().encode(entry.token);
|
||||
// Always run the comparison — never short-circuit.
|
||||
const equal = constantTimeEqual(presentedBytes, candidateBytes);
|
||||
if (equal && foundIndex === -1) {
|
||||
foundIndex = i;
|
||||
}
|
||||
}
|
||||
return foundIndex;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
# Error codes — `@suaveplan/api-auth-bearer-static`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The error-codes module defines the stable `AUTH_BEARER_STATIC.*` namespace
|
||||
that every public failure path in this package surfaces. Codes are produced
|
||||
by `defineCodes` from `@suaveplan/error` so the registry yields a frozen
|
||||
record of fully-qualified strings (`AUTH_BEARER_STATIC.NO_TOKEN`,
|
||||
`AUTH_BEARER_STATIC.UNKNOWN_TOKEN`, `AUTH_BEARER_STATIC.MTLS_REQUIRED`,
|
||||
`AUTH_BEARER_STATIC.INVALID_OPTIONS`) along with a `BearerStaticAuthError`
|
||||
subclass that lifts each code into a real `Error` instance.
|
||||
|
||||
This module is the contract a caller branches on. Catching a generic
|
||||
`SuaveplanError` and reading `.code` is the supported failure-handling
|
||||
pattern; the concrete subclass exists to make `instanceof` narrow the code
|
||||
union for downstream code that needs the stronger typing.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen four-entry code registry keyed by reason name.
|
||||
- `BearerStaticAuthError` extends `SuaveplanError<BearerStaticAuthErrorCode>`
|
||||
— `instanceof` narrows `.code`.
|
||||
- Stable namespace string (`AUTH_BEARER_STATIC`) safe to log and persist.
|
||||
- Every code exercised by at least one production code path (registry-vs-
|
||||
source conformance is asserted by the spec-conformance test).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUTH_BEARER_STATIC_CODES,
|
||||
BearerStaticAuthError,
|
||||
} from "@suaveplan/api-auth-bearer-static/error-codes";
|
||||
|
||||
try {
|
||||
// factory call that may throw on bad options
|
||||
throw new BearerStaticAuthError(
|
||||
AUTH_BEARER_STATIC_CODES.INVALID_OPTIONS,
|
||||
"tokens table is empty"
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof BearerStaticAuthError) {
|
||||
// e.code narrows to BearerStaticAuthErrorCode
|
||||
console.error(e.code, e.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUTH_BEARER_STATIC_CODES,
|
||||
BearerStaticAuthError,
|
||||
} from "@suaveplan/api-auth-bearer-static/error-codes";
|
||||
|
||||
function isInvalidOptions(err: unknown): err is BearerStaticAuthError {
|
||||
return (
|
||||
err instanceof BearerStaticAuthError &&
|
||||
err.code === AUTH_BEARER_STATIC_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
|
||||
// Treat invalid options as a fatal config error; bubble everything else.
|
||||
try {
|
||||
// build resolver
|
||||
} catch (e) {
|
||||
if (isInvalidOptions(e)) {
|
||||
process.exit(78); // EX_CONFIG
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `AUTH_BEARER_STATIC_CODES` — `Readonly<Record<Reason,
|
||||
"AUTH_BEARER_STATIC.<Reason>">>`. Frozen at module load.
|
||||
- `BearerStaticAuthErrorCode` — string-literal union of every value.
|
||||
- `class BearerStaticAuthError extends SuaveplanError<BearerStaticAuthErrorCode>`
|
||||
— constructor `(code, messageKey, options?)` matches the
|
||||
`SuaveplanError` shape.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry is constructed with `defineCodes("AUTH_BEARER_STATIC", [...])`.
|
||||
`defineCodes` returns a `Readonly` record whose key set is the reason names
|
||||
and whose values are the fully-qualified `"AUTH_BEARER_STATIC.<reason>"`
|
||||
strings — referenced via `AUTH_BEARER_STATIC_CODES.NO_TOKEN` rather than the
|
||||
raw string literal so a typo at the call site fails type-check.
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUTH_BEARER_STATIC_CODES,
|
||||
BearerStaticAuthError,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("AUTH_BEARER_STATIC_CODES", () => {
|
||||
it("exposes the documented four codes with namespaced values", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(AUTH_BEARER_STATIC_CODES.NO_TOKEN).toBe(
|
||||
"AUTH_BEARER_STATIC.NO_TOKEN"
|
||||
);
|
||||
expect(AUTH_BEARER_STATIC_CODES.UNKNOWN_TOKEN).toBe(
|
||||
"AUTH_BEARER_STATIC.UNKNOWN_TOKEN"
|
||||
);
|
||||
expect(AUTH_BEARER_STATIC_CODES.MTLS_REQUIRED).toBe(
|
||||
"AUTH_BEARER_STATIC.MTLS_REQUIRED"
|
||||
);
|
||||
expect(AUTH_BEARER_STATIC_CODES.INVALID_OPTIONS).toBe(
|
||||
"AUTH_BEARER_STATIC.INVALID_OPTIONS"
|
||||
);
|
||||
});
|
||||
|
||||
it("registry is frozen", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(AUTH_BEARER_STATIC_CODES)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BearerStaticAuthError", () => {
|
||||
it("instanceof narrows code and message survives", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new BearerStaticAuthError(
|
||||
AUTH_BEARER_STATIC_CODES.INVALID_OPTIONS,
|
||||
"boom"
|
||||
);
|
||||
expect(err).toBeInstanceOf(BearerStaticAuthError);
|
||||
expect(err.code).toBe(AUTH_BEARER_STATIC_CODES.INVALID_OPTIONS);
|
||||
expect(err.bearerStaticCode).toBe(
|
||||
AUTH_BEARER_STATIC_CODES.INVALID_OPTIONS
|
||||
);
|
||||
expect(err.message).toBe("boom");
|
||||
expect(err.name).toBe("BearerStaticAuthError");
|
||||
});
|
||||
|
||||
it("preserves cause", { timeout: 5000 }, () => {
|
||||
const cause = new Error("upstream");
|
||||
const err = new BearerStaticAuthError(
|
||||
AUTH_BEARER_STATIC_CODES.INVALID_OPTIONS,
|
||||
"wrapped",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/api-auth-bearer-static`.
|
||||
*
|
||||
* Every public failure path raises a {@link BearerStaticAuthError} carrying
|
||||
* one of these namespaced `AUTH_BEARER_STATIC.*` codes. Consumers branch on
|
||||
* the string value (e.g. `err.code === AUTH_BEARER_STATIC_CODES.NO_TOKEN`).
|
||||
*
|
||||
* The four codes cover:
|
||||
* - {@link AUTH_BEARER_STATIC_CODES.NO_TOKEN} — request carried no
|
||||
* Authorization header (or it lacked the configured scheme).
|
||||
* - {@link AUTH_BEARER_STATIC_CODES.UNKNOWN_TOKEN} — header presented a
|
||||
* token absent from the configured table.
|
||||
* - {@link AUTH_BEARER_STATIC_CODES.MTLS_REQUIRED} — token entry required
|
||||
* a mutual-TLS peer certificate but the host did not surface one.
|
||||
* - {@link AUTH_BEARER_STATIC_CODES.INVALID_OPTIONS} — Zod validation of
|
||||
* `BearerStaticResolverOptions` failed at factory construction.
|
||||
*
|
||||
* `NO_TOKEN`, `UNKNOWN_TOKEN`, and `MTLS_REQUIRED` are surfaced via the
|
||||
* outcome label on the OTel match counter (and would-be log lines); they
|
||||
* are not thrown synchronously because resolvers return `null` to defer to
|
||||
* the next chain entry. `INVALID_OPTIONS` is the sole thrown code.
|
||||
*/
|
||||
export const AUTH_BEARER_STATIC_CODES = defineCodes("AUTH_BEARER_STATIC", [
|
||||
"NO_TOKEN",
|
||||
"UNKNOWN_TOKEN",
|
||||
"MTLS_REQUIRED",
|
||||
"INVALID_OPTIONS",
|
||||
] as const);
|
||||
|
||||
export type BearerStaticAuthErrorCode =
|
||||
(typeof AUTH_BEARER_STATIC_CODES)[keyof typeof AUTH_BEARER_STATIC_CODES];
|
||||
|
||||
/**
|
||||
* Concrete error class. Extends {@link SuaveplanError} so `instanceof`
|
||||
* narrows `code` to {@link BearerStaticAuthErrorCode}.
|
||||
*/
|
||||
export class BearerStaticAuthError extends SuaveplanError<BearerStaticAuthErrorCode> {
|
||||
public readonly bearerStaticCode: BearerStaticAuthErrorCode;
|
||||
|
||||
public constructor(
|
||||
code: BearerStaticAuthErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.name = "BearerStaticAuthError";
|
||||
this.bearerStaticCode = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
AUTH_BEARER_STATIC_CODES,
|
||||
BearerStaticAuthError,
|
||||
type BearerStaticAuthErrorCode,
|
||||
} from "./error-codes.js";
|
||||
@@ -0,0 +1,106 @@
|
||||
# Direct factory — `@suaveplan/api-auth-bearer-static`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`createBearerStaticResolver` is the direct-surface entry point of the
|
||||
package. It accepts validated `BearerStaticResolverOptions`, builds a
|
||||
`SessionResolver` over the fixed token table, and returns a resolver
|
||||
implementation suitable for placing inside a `SessionResolverChain` from
|
||||
`@suaveplan/api-auth`. The resolver matches the documented chain
|
||||
semantics: an unknown token, missing header, or tripped mTLS gate all
|
||||
return `null` so the chain advances to the next entry.
|
||||
|
||||
The resolver is stateless beyond the disposed flag — there is no DB
|
||||
connection, no JWKS cache, no refresh loop. Disposal flips the flag and
|
||||
subsequent calls return `null` rather than throwing.
|
||||
|
||||
## Features
|
||||
|
||||
- Stateless per-request resolution; safe under concurrent fanout.
|
||||
- Constant-time token match via `findTokenIndex`.
|
||||
- Optional per-entry mTLS gate via `checkMtlsGate`.
|
||||
- Configurable `headerName` and prefix (`Bearer` by default).
|
||||
- Case-insensitive scheme match per RFC 7235.
|
||||
- OTel counter emission for every of the four outcomes.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createBearerStaticResolver } from "@suaveplan/api-auth-bearer-static";
|
||||
|
||||
const resolver = createBearerStaticResolver({
|
||||
tokens: [
|
||||
{
|
||||
token: "service-cron-2026",
|
||||
principal: {
|
||||
id: "svc_cron",
|
||||
scopes: ["jobs:run"],
|
||||
claims: { kind: "service" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const req = new Request("https://api/x", {
|
||||
headers: { Authorization: "Bearer service-cron-2026" },
|
||||
});
|
||||
const principal = await resolver.resolve(req);
|
||||
// principal.id === "svc_cron"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createBearerStaticResolver } from "@suaveplan/api-auth-bearer-static";
|
||||
import { SessionResolverChain } from "@suaveplan/api-auth";
|
||||
import { createJwtResolver } from "@suaveplan/api-auth-jwt"; // hypothetical sibling
|
||||
|
||||
// Chained — service tokens checked first, JWT users fall through after.
|
||||
const chain = new SessionResolverChain([
|
||||
createBearerStaticResolver({
|
||||
tokens: [
|
||||
{
|
||||
token: "admin-2026",
|
||||
principal: {
|
||||
id: "svc_admin",
|
||||
scopes: ["admin:*"],
|
||||
claims: {},
|
||||
},
|
||||
mtlsRequired: true,
|
||||
},
|
||||
],
|
||||
headerName: "X-Service-Auth",
|
||||
prefix: "Token",
|
||||
}),
|
||||
createJwtResolver({ issuer: "https://idp/..." }),
|
||||
]);
|
||||
|
||||
const result = await chain.resolve(request);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createBearerStaticResolver(options: BearerStaticResolverOptions): SessionResolver`
|
||||
|
||||
Returned resolver:
|
||||
|
||||
- `resolve(request: Request): Promise<Principal | null>`
|
||||
- `dispose(): Promise<void>` — idempotent.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`extractToken` accepts the header value, trims, splits on the first space,
|
||||
and lower-cases the scheme for the prefix comparison. Empty credentials
|
||||
after the prefix are rejected. The implementation reads the configured
|
||||
header by exact name (the Web `Headers` API normalises case on lookup, so
|
||||
`Authorization` and `authorization` both work). When the table contains
|
||||
the same token twice with conflicting principals, `findTokenIndex` returns
|
||||
the first index — operator misconfiguration the schema cannot prevent.
|
||||
TypeScript cannot narrow `table[idx]` to non-undefined even though
|
||||
`findTokenIndex` only ever returns `-1` or a valid in-range index; a type
|
||||
assertion encodes that contract at the call site without adding a runtime
|
||||
guard the test would have to exercise.
|
||||
@@ -0,0 +1,32 @@
|
||||
import { runAuthSessionConformance } from "@suaveplan/api-auth";
|
||||
import type { Principal } from "@suaveplan/api-types/principal";
|
||||
import { createBearerStaticResolver } from "./create-bearer-static-resolver.js";
|
||||
|
||||
const expectedPrincipal = {
|
||||
id: "svc_cron",
|
||||
scopes: ["jobs:run"],
|
||||
claims: { kind: "service" },
|
||||
} as unknown as Principal;
|
||||
|
||||
runAuthSessionConformance({
|
||||
label: "bearer-static-direct",
|
||||
factory: () =>
|
||||
createBearerStaticResolver({
|
||||
tokens: [
|
||||
{
|
||||
token: "T-CONFORMANCE",
|
||||
principal: expectedPrincipal,
|
||||
},
|
||||
],
|
||||
}),
|
||||
seedNull: () =>
|
||||
new Request("https://api/x", {
|
||||
headers: { Authorization: "Bearer NOT_PRESENT" },
|
||||
}),
|
||||
seedPrincipal: () => ({
|
||||
request: new Request("https://api/x", {
|
||||
headers: { Authorization: "Bearer T-CONFORMANCE" },
|
||||
}),
|
||||
expected: expectedPrincipal,
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
|
||||
import {
|
||||
AUTH_BEARER_STATIC_CODES,
|
||||
BearerStaticAuthError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { resetBearerStaticInstruments } from "../telemetry/instruments.js";
|
||||
import { createBearerStaticResolver } from "./create-bearer-static-resolver.js";
|
||||
|
||||
const principalA = {
|
||||
id: "svc_a",
|
||||
scopes: ["read"],
|
||||
claims: { kind: "service" },
|
||||
};
|
||||
|
||||
const principalB = {
|
||||
id: "svc_b",
|
||||
scopes: ["admin"],
|
||||
claims: { kind: "admin" },
|
||||
};
|
||||
|
||||
function makeResolver(extra?: Record<string, unknown>) {
|
||||
return createBearerStaticResolver({
|
||||
tokens: [
|
||||
{ token: "T1", principal: principalA },
|
||||
{ token: "T2", principal: principalB, mtlsRequired: true },
|
||||
],
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
function findCounter(
|
||||
name: string,
|
||||
metrics: Awaited<ReturnType<typeof flushTestMetrics>>
|
||||
): { value: number; attributes: Record<string, unknown> }[] {
|
||||
const out: { value: number; attributes: Record<string, unknown> }[] = [];
|
||||
for (const rm of metrics) {
|
||||
for (const sm of rm.scopeMetrics) {
|
||||
for (const m of sm.metrics) {
|
||||
if (m.descriptor.name !== name) continue;
|
||||
for (const dp of m.dataPoints) {
|
||||
out.push({
|
||||
value: Number(dp.value),
|
||||
attributes: (dp.attributes ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function attachTls(request: Request, peerCertificate: unknown): Request {
|
||||
Object.defineProperty(request, "tls", {
|
||||
value: { peerCertificate },
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
return request;
|
||||
}
|
||||
|
||||
describe("createBearerStaticResolver", () => {
|
||||
beforeEach(() => {
|
||||
resetBearerStaticInstruments();
|
||||
});
|
||||
|
||||
it("known token returns its Principal", { timeout: 5000 }, async () => {
|
||||
const resolver = makeResolver();
|
||||
const req = new Request("https://api/x", {
|
||||
headers: { Authorization: "Bearer T1" },
|
||||
});
|
||||
const result = await resolver.resolve(req);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.id).toBe("svc_a");
|
||||
await resolver.dispose();
|
||||
});
|
||||
|
||||
it("unknown token returns null and increments failure counter", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await flushTestMetrics();
|
||||
const resolver = makeResolver();
|
||||
const req = new Request("https://api/x", {
|
||||
headers: { Authorization: "Bearer NOT_IN_TABLE" },
|
||||
});
|
||||
const result = await resolver.resolve(req);
|
||||
expect(result).toBeNull();
|
||||
const collected = await flushTestMetrics();
|
||||
const points = findCounter(
|
||||
"api.auth.bearer_static_match_total",
|
||||
collected
|
||||
);
|
||||
const match = points.find(
|
||||
(p) => p.attributes.outcome === "unknown-token"
|
||||
);
|
||||
expect(match?.value).toBeGreaterThanOrEqual(1);
|
||||
await resolver.dispose();
|
||||
});
|
||||
|
||||
it("missing Authorization header returns null + no-token outcome", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await flushTestMetrics();
|
||||
const resolver = makeResolver();
|
||||
const result = await resolver.resolve(new Request("https://api/x"));
|
||||
expect(result).toBeNull();
|
||||
const collected = await flushTestMetrics();
|
||||
const points = findCounter(
|
||||
"api.auth.bearer_static_match_total",
|
||||
collected
|
||||
);
|
||||
const match = points.find((p) => p.attributes.outcome === "no-token");
|
||||
expect(match?.value).toBeGreaterThanOrEqual(1);
|
||||
await resolver.dispose();
|
||||
});
|
||||
|
||||
it("empty Authorization header returns null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const resolver = makeResolver();
|
||||
const req = new Request("https://api/x", {
|
||||
headers: { Authorization: "" },
|
||||
});
|
||||
expect(await resolver.resolve(req)).toBeNull();
|
||||
});
|
||||
|
||||
it("whitespace-only Authorization header returns null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const resolver = makeResolver();
|
||||
const req = new Request("https://api/x", {
|
||||
headers: { Authorization: " " },
|
||||
});
|
||||
expect(await resolver.resolve(req)).toBeNull();
|
||||
});
|
||||
|
||||
it("Authorization without space separator returns null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const resolver = makeResolver();
|
||||
const req = new Request("https://api/x", {
|
||||
headers: { Authorization: "BearerT1" },
|
||||
});
|
||||
expect(await resolver.resolve(req)).toBeNull();
|
||||
});
|
||||
|
||||
it("wrong scheme returns null", { timeout: 5000 }, async () => {
|
||||
const resolver = makeResolver();
|
||||
const req = new Request("https://api/x", {
|
||||
headers: { Authorization: "Basic dXNlcjpwYXNz" },
|
||||
});
|
||||
expect(await resolver.resolve(req)).toBeNull();
|
||||
});
|
||||
|
||||
it("empty credential after scheme returns null", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const resolver = makeResolver();
|
||||
const req = new Request("https://api/x", {
|
||||
headers: { Authorization: "Bearer " },
|
||||
});
|
||||
expect(await resolver.resolve(req)).toBeNull();
|
||||
});
|
||||
|
||||
it("scheme match is case-insensitive", { timeout: 5000 }, async () => {
|
||||
const resolver = makeResolver();
|
||||
const req = new Request("https://api/x", {
|
||||
headers: { Authorization: "bEaReR T1" },
|
||||
});
|
||||
expect((await resolver.resolve(req))?.id).toBe("svc_a");
|
||||
});
|
||||
|
||||
it("mtlsRequired token rejected without peer cert", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await flushTestMetrics();
|
||||
const resolver = makeResolver();
|
||||
const req = new Request("https://api/x", {
|
||||
headers: { Authorization: "Bearer T2" },
|
||||
});
|
||||
expect(await resolver.resolve(req)).toBeNull();
|
||||
const collected = await flushTestMetrics();
|
||||
const points = findCounter(
|
||||
"api.auth.bearer_static_match_total",
|
||||
collected
|
||||
);
|
||||
const match = points.find(
|
||||
(p) => p.attributes.outcome === "mtls-required"
|
||||
);
|
||||
expect(match?.value).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("mtlsRequired token accepted with peer cert", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await flushTestMetrics();
|
||||
const resolver = makeResolver();
|
||||
const req = attachTls(
|
||||
new Request("https://api/x", {
|
||||
headers: { Authorization: "Bearer T2" },
|
||||
}),
|
||||
{ subject: "CN=svc_b" }
|
||||
);
|
||||
const result = await resolver.resolve(req);
|
||||
expect(result?.id).toBe("svc_b");
|
||||
const collected = await flushTestMetrics();
|
||||
const points = findCounter(
|
||||
"api.auth.bearer_static_match_total",
|
||||
collected
|
||||
);
|
||||
const match = points.find((p) => p.attributes.outcome === "match");
|
||||
expect(match?.value).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("custom headerName + prefix", { timeout: 5000 }, async () => {
|
||||
const resolver = makeResolver({
|
||||
headerName: "X-Service-Auth",
|
||||
prefix: "Token",
|
||||
});
|
||||
const req = new Request("https://api/x", {
|
||||
headers: { "X-Service-Auth": "Token T1" },
|
||||
});
|
||||
expect((await resolver.resolve(req))?.id).toBe("svc_a");
|
||||
});
|
||||
|
||||
it("dispose is idempotent and silences subsequent resolves", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const resolver = makeResolver();
|
||||
await resolver.dispose();
|
||||
await resolver.dispose();
|
||||
const req = new Request("https://api/x", {
|
||||
headers: { Authorization: "Bearer T1" },
|
||||
});
|
||||
expect(await resolver.resolve(req)).toBeNull();
|
||||
});
|
||||
|
||||
it("throws INVALID_OPTIONS when called with invalid options", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
createBearerStaticResolver({ tokens: [] });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(BearerStaticAuthError);
|
||||
expect((err as BearerStaticAuthError).code).toBe(
|
||||
AUTH_BEARER_STATIC_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { SessionResolver } from "@suaveplan/api-auth";
|
||||
import type { Principal } from "@suaveplan/api-types/principal";
|
||||
import { findTokenIndex } from "../constant-time/token-match.js";
|
||||
import { checkMtlsGate } from "../mtls/mtls-gate.js";
|
||||
import {
|
||||
type BearerStaticResolverOptions,
|
||||
parseBearerStaticResolverOptions,
|
||||
} from "../options/bearer-static-resolver-options.js";
|
||||
import { recordMatchOutcome } from "../telemetry/instruments.js";
|
||||
|
||||
const DEFAULT_HEADER = "Authorization";
|
||||
const DEFAULT_PREFIX = "Bearer";
|
||||
|
||||
/**
|
||||
* Parse the configured header and strip the configured prefix. Returns the
|
||||
* raw token string, or `null` when the header is missing / the prefix
|
||||
* doesn't match.
|
||||
*
|
||||
* The prefix match is case-insensitive (per RFC 7235, scheme tokens are
|
||||
* ASCII case-insensitive); the separating space is required.
|
||||
*/
|
||||
function extractToken(
|
||||
request: Request,
|
||||
headerName: string,
|
||||
prefix: string
|
||||
): string | null {
|
||||
const raw = request.headers.get(headerName);
|
||||
if (raw === null) return null;
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
// Find the first whitespace separator.
|
||||
const spaceIdx = trimmed.indexOf(" ");
|
||||
if (spaceIdx === -1) return null;
|
||||
const scheme = trimmed.slice(0, spaceIdx);
|
||||
if (scheme.toLowerCase() !== prefix.toLowerCase()) return null;
|
||||
// Inner trim collapses runs of whitespace between scheme and the
|
||||
// credential (e.g. "Bearer X" → credential "X"). The outer trim
|
||||
// already removed any trailing whitespace from `trimmed`, so an empty
|
||||
// result is unreachable via the standard `Headers` API; downstream
|
||||
// `findTokenIndex` reports unknown-token if it somehow appears.
|
||||
return trimmed.slice(spaceIdx + 1).replace(/^\s+/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a {@link SessionResolver} that matches static bearer tokens
|
||||
* against a fixed table.
|
||||
*
|
||||
* Behaviour:
|
||||
* - No `Authorization` header (or wrong scheme) → returns `null`, emits
|
||||
* `no-token` outcome.
|
||||
* - Header present, token absent from table → returns `null`, emits
|
||||
* `unknown-token` outcome.
|
||||
* - Token in table but entry's `mtlsRequired` flag set AND no peer
|
||||
* certificate attached to the request → returns `null`, emits
|
||||
* `mtls-required` outcome.
|
||||
* - Token in table, mTLS gate satisfied → returns the entry's
|
||||
* `Principal`, emits `match` outcome.
|
||||
*
|
||||
* `dispose()` is idempotent and has no resources to release (the table is
|
||||
* held by reference; nothing to close).
|
||||
*/
|
||||
export function createBearerStaticResolver(
|
||||
rawOptions: BearerStaticResolverOptions | unknown
|
||||
): SessionResolver {
|
||||
const opts = parseBearerStaticResolverOptions(rawOptions);
|
||||
const headerName = opts.headerName ?? DEFAULT_HEADER;
|
||||
const prefix = opts.prefix ?? DEFAULT_PREFIX;
|
||||
const table = opts.tokens;
|
||||
let disposed = false;
|
||||
|
||||
return {
|
||||
async resolve(req: Request): Promise<Principal | null> {
|
||||
if (disposed) return null;
|
||||
const presented = extractToken(req, headerName, prefix);
|
||||
if (presented === null) {
|
||||
recordMatchOutcome("no-token");
|
||||
return null;
|
||||
}
|
||||
const idx = findTokenIndex(presented, table);
|
||||
if (idx === -1) {
|
||||
recordMatchOutcome("unknown-token");
|
||||
return null;
|
||||
}
|
||||
// `findTokenIndex` only returns -1 or a valid in-range index,
|
||||
// so non-null assertion here matches its contract.
|
||||
const entry = table[idx] as (typeof table)[number];
|
||||
if (!checkMtlsGate(req, entry.mtlsRequired ?? false)) {
|
||||
recordMatchOutcome("mtls-required");
|
||||
return null;
|
||||
}
|
||||
recordMatchOutcome("match");
|
||||
return entry.principal;
|
||||
},
|
||||
async dispose(): Promise<void> {
|
||||
disposed = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { createBearerStaticResolver } from "./create-bearer-static-resolver.js";
|
||||
42
packages/api-stack/api/api-auth-bearer-static/src/index.ts
Normal file
42
packages/api-stack/api/api-auth-bearer-static/src/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// Error codes
|
||||
|
||||
// Constant-time match
|
||||
export { findTokenIndex } from "./constant-time/token-match.js";
|
||||
export {
|
||||
AUTH_BEARER_STATIC_CODES,
|
||||
BearerStaticAuthError,
|
||||
type BearerStaticAuthErrorCode,
|
||||
} from "./error-codes/error-codes.js";
|
||||
// Direct factory
|
||||
export { createBearerStaticResolver } from "./factory/create-bearer-static-resolver.js";
|
||||
// mTLS
|
||||
export {
|
||||
checkMtlsGate,
|
||||
extractPeerCertificate,
|
||||
type RequestTlsInfo,
|
||||
} from "./mtls/mtls-gate.js";
|
||||
// Options
|
||||
export {
|
||||
type BearerStaticResolverOptions,
|
||||
type BearerStaticTokenEntry,
|
||||
bearerStaticResolverOptionsSchema,
|
||||
parseBearerStaticResolverOptions,
|
||||
} from "./options/bearer-static-resolver-options.js";
|
||||
// Worker-proxy factories
|
||||
export {
|
||||
createBearerStaticResolverWorkerProxy,
|
||||
serveBearerStaticResolverOnPort,
|
||||
} from "./proxy/create-bearer-static-resolver-worker-proxy.js";
|
||||
// Security scheme
|
||||
export {
|
||||
type SecuritySchemeContributionOptions,
|
||||
securitySchemeContribution,
|
||||
} from "./security-scheme/security-scheme-contribution.js";
|
||||
// Telemetry
|
||||
export {
|
||||
type BearerStaticInstruments,
|
||||
type BearerStaticMatchOutcome,
|
||||
getBearerStaticInstruments,
|
||||
recordMatchOutcome,
|
||||
resetBearerStaticInstruments,
|
||||
} from "./telemetry/instruments.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
checkMtlsGate,
|
||||
extractPeerCertificate,
|
||||
type RequestTlsInfo,
|
||||
} from "./mtls-gate.js";
|
||||
@@ -0,0 +1,79 @@
|
||||
# mTLS gate — `@suaveplan/api-auth-bearer-static`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The mTLS gate enforces an optional second factor on individual token table
|
||||
entries. When operators mark an entry with `mtlsRequired: true`, the
|
||||
resolver MUST refuse to surface the entry's Principal unless the host
|
||||
listener attached a verified peer certificate to the inbound request via
|
||||
`request.tls.peerCertificate`. This guards the highest-privilege service
|
||||
tokens (e.g. internal admin API) against credential leakage — the bearer
|
||||
token alone is necessary but no longer sufficient.
|
||||
|
||||
The adapter does NOT establish TLS itself. The host listener (Bun's
|
||||
TLS-enabled server, Node's `https`, a Cloudflare-Worker TLS terminator
|
||||
ahead of the worker, etc.) is responsible for completing the TLS handshake
|
||||
and surfacing the verified subject as `request.tls.peerCertificate`. This
|
||||
module just inspects what was attached.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure function — no I/O, no state.
|
||||
- Defensive — accepts any `Request`-shaped input even when the `tls` field
|
||||
was not attached (returns `undefined`).
|
||||
- Treats `peerCertificate: null` identically to absent.
|
||||
- `mtlsRequired === false` short-circuits without inspecting the request.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { checkMtlsGate } from "@suaveplan/api-auth-bearer-static/mtls";
|
||||
|
||||
const request = new Request("https://example/test");
|
||||
const allowed = checkMtlsGate(request, true);
|
||||
// allowed === false — no peerCertificate attached
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
checkMtlsGate,
|
||||
extractPeerCertificate,
|
||||
} from "@suaveplan/api-auth-bearer-static/mtls";
|
||||
|
||||
// Host listener pattern — wrap the inbound request before handing off to
|
||||
// the auth chain.
|
||||
const withTls = Object.assign(request, {
|
||||
tls: { peerCertificate: parsedX509Subject },
|
||||
});
|
||||
|
||||
const cert = extractPeerCertificate(withTls);
|
||||
// cert === parsedX509Subject
|
||||
|
||||
const allowed = checkMtlsGate(withTls, true);
|
||||
// allowed === true
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface RequestTlsInfo { peerCertificate?: unknown }`
|
||||
- `extractPeerCertificate(request: Request): unknown | undefined`
|
||||
- `checkMtlsGate(request: Request, mtlsRequired: boolean): boolean`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `Request` Web type has no standardised `tls` field. Both Node's
|
||||
undici-based `Request` and Bun's polyfill ignore unknown properties on
|
||||
construction, so the host pattern is to attach `tls` as a plain property
|
||||
after the Request is built (or to wrap it in a Proxy / subclass). The
|
||||
extractor reads through that property with a single defensive type cast
|
||||
and returns `undefined` for any shape that does not satisfy the
|
||||
`{ peerCertificate: <truthy> }` invariant. The opaque certificate itself
|
||||
is not parsed here — that is the host's job, and a future
|
||||
`api-auth-mtls` adapter (out of scope for Stage 4) will own subject
|
||||
extraction.
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { checkMtlsGate, extractPeerCertificate } from "./mtls-gate.js";
|
||||
|
||||
function makeRequest(): Request {
|
||||
return new Request("https://example.test/x");
|
||||
}
|
||||
|
||||
function attachTls<T extends object | null | undefined>(
|
||||
request: Request,
|
||||
tls: T
|
||||
): Request {
|
||||
Object.defineProperty(request, "tls", {
|
||||
value: tls,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
return request;
|
||||
}
|
||||
|
||||
describe("extractPeerCertificate", () => {
|
||||
it("returns undefined when no tls attached", { timeout: 5000 }, () => {
|
||||
const req = makeRequest();
|
||||
expect(extractPeerCertificate(req)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when tls is null", { timeout: 5000 }, () => {
|
||||
const req = attachTls(makeRequest(), null);
|
||||
expect(extractPeerCertificate(req)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when tls is not an object", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const req = attachTls(
|
||||
makeRequest(),
|
||||
"not-an-object" as unknown as object
|
||||
);
|
||||
expect(extractPeerCertificate(req)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when tls has no peerCertificate key", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const req = attachTls(makeRequest(), {});
|
||||
expect(extractPeerCertificate(req)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when peerCertificate is null", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const req = attachTls(makeRequest(), { peerCertificate: null });
|
||||
expect(extractPeerCertificate(req)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when peerCertificate is undefined", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const req = attachTls(makeRequest(), { peerCertificate: undefined });
|
||||
expect(extractPeerCertificate(req)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns the attached certificate when present", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cert = { subject: "CN=svc" };
|
||||
const req = attachTls(makeRequest(), { peerCertificate: cert });
|
||||
expect(extractPeerCertificate(req)).toBe(cert);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkMtlsGate", () => {
|
||||
it("rejects mtls-required token without peer certificate", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(checkMtlsGate(makeRequest(), true)).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts mtls-required token with peer certificate", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const req = attachTls(makeRequest(), {
|
||||
peerCertificate: { subject: "CN=svc" },
|
||||
});
|
||||
expect(checkMtlsGate(req, true)).toBe(true);
|
||||
});
|
||||
|
||||
it("passes when mtls not required, regardless of cert presence", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(checkMtlsGate(makeRequest(), false)).toBe(true);
|
||||
const withCert = attachTls(makeRequest(), {
|
||||
peerCertificate: { subject: "x" },
|
||||
});
|
||||
expect(checkMtlsGate(withCert, false)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Surface a host listener may attach to an inbound `Request` indicating a
|
||||
* mutual-TLS peer certificate has been verified.
|
||||
*
|
||||
* `peerCertificate` is opaque to this adapter — the host owns parsing /
|
||||
* validation; we only check presence. The shape mirrors the documented
|
||||
* integration point in design §2.3: `request.tls?.peerCertificate`.
|
||||
*/
|
||||
export interface RequestTlsInfo {
|
||||
readonly peerCertificate?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-narrowing helper that pulls `tls.peerCertificate` from a `Request`
|
||||
* if the host attached it. Returns `undefined` when absent or malformed.
|
||||
*
|
||||
* Inspects `(request as any).tls` defensively — the `Request` interface
|
||||
* does not standardise this field, so the host's listener augments it via
|
||||
* a wrapping `Request` subclass or by attaching to a parallel context
|
||||
* object that ends up on the request. Either form is supported.
|
||||
*/
|
||||
export function extractPeerCertificate(request: Request): unknown | undefined {
|
||||
const tls = (request as unknown as { readonly tls?: RequestTlsInfo }).tls;
|
||||
if (tls === undefined || tls === null) return undefined;
|
||||
if (typeof tls !== "object") return undefined;
|
||||
if (!("peerCertificate" in tls)) return undefined;
|
||||
const cert = tls.peerCertificate;
|
||||
if (cert === undefined || cert === null) return undefined;
|
||||
return cert;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce the per-token `mtlsRequired` flag.
|
||||
*
|
||||
* Returns `true` when the gate passes (token did not require mTLS, OR the
|
||||
* host surfaced a peer certificate), `false` when the gate trips (mTLS
|
||||
* required but no peer cert).
|
||||
*/
|
||||
export function checkMtlsGate(
|
||||
request: Request,
|
||||
mtlsRequired: boolean
|
||||
): boolean {
|
||||
if (!mtlsRequired) return true;
|
||||
const cert = extractPeerCertificate(request);
|
||||
return cert !== undefined;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
# Options — `@suaveplan/api-auth-bearer-static`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The options module owns the public construction surface of every resolver
|
||||
in this package. It defines the `BearerStaticResolverOptions` interface
|
||||
(plus its row type `BearerStaticTokenEntry`), the Zod schema that mirrors
|
||||
the interface, and a thin parser that turns operator-supplied input into a
|
||||
validated value or throws `AUTH_BEARER_STATIC.INVALID_OPTIONS`.
|
||||
|
||||
Validation runs once at factory construction. Hot-path resolve calls never
|
||||
re-validate — by then the table is structurally trusted. Token rotation is
|
||||
intentionally out of scope: operators rotate by reloading host config,
|
||||
which re-runs the factory and re-runs validation.
|
||||
|
||||
## Features
|
||||
|
||||
- `tokens` is required and non-empty (`.min(1)`).
|
||||
- Every entry validates token (non-empty string), `principal` shape (id +
|
||||
scopes + claims, optional tenantId), and optional `mtlsRequired` flag.
|
||||
- `headerName` and `prefix` are optional with documented defaults
|
||||
(`Authorization` / `Bearer`).
|
||||
- Failure surfaces a typed `BearerStaticAuthError` carrying the original
|
||||
`ZodError` as `cause` — full diagnostic trail is preserved.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseBearerStaticResolverOptions } from "@suaveplan/api-auth-bearer-static/options";
|
||||
|
||||
const opts = parseBearerStaticResolverOptions({
|
||||
tokens: [
|
||||
{
|
||||
token: "service-cron-2026",
|
||||
principal: {
|
||||
id: "svc_cron",
|
||||
scopes: ["jobs:run"],
|
||||
claims: { kind: "service" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
parseBearerStaticResolverOptions,
|
||||
bearerStaticResolverOptionsSchema,
|
||||
} from "@suaveplan/api-auth-bearer-static/options";
|
||||
import { AUTH_BEARER_STATIC_CODES } from "@suaveplan/api-auth-bearer-static/error-codes";
|
||||
|
||||
// Validate without constructing — useful for config UIs that show errors
|
||||
// inline before committing changes.
|
||||
const result = bearerStaticResolverOptionsSchema.safeParse(userInput);
|
||||
if (!result.success) {
|
||||
renderInlineErrors(result.error);
|
||||
}
|
||||
|
||||
// Or rely on the throwing parser when integrated into a startup pipeline:
|
||||
try {
|
||||
const opts = parseBearerStaticResolverOptions(userInput);
|
||||
return opts;
|
||||
} catch (e) {
|
||||
if (
|
||||
e &&
|
||||
typeof e === "object" &&
|
||||
"code" in e &&
|
||||
e.code === AUTH_BEARER_STATIC_CODES.INVALID_OPTIONS
|
||||
) {
|
||||
crashWithConfigExitCode();
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface BearerStaticTokenEntry { token: string; principal: Principal; mtlsRequired?: boolean }`
|
||||
- `interface BearerStaticResolverOptions { tokens: ReadonlyArray<BearerStaticTokenEntry>; headerName?: string; prefix?: string }`
|
||||
- `bearerStaticResolverOptionsSchema: z.ZodType<BearerStaticResolverOptions>`
|
||||
- `parseBearerStaticResolverOptions(raw: unknown): BearerStaticResolverOptions`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema's `principal` block deliberately accepts strings rather than
|
||||
`PrincipalId` / `Scope` brands. Brands are erased at runtime and the host
|
||||
trusts operator-supplied config to be well-formed; rejecting strings that
|
||||
look fine at runtime would be developer-hostile noise. Downstream code
|
||||
treats the returned shape as a `Principal` because the structural fields
|
||||
match.
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUTH_BEARER_STATIC_CODES,
|
||||
BearerStaticAuthError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import {
|
||||
bearerStaticResolverOptionsSchema,
|
||||
parseBearerStaticResolverOptions,
|
||||
} from "./bearer-static-resolver-options.js";
|
||||
|
||||
const validInput = {
|
||||
tokens: [
|
||||
{
|
||||
token: "T1",
|
||||
principal: {
|
||||
id: "u1",
|
||||
scopes: ["read"],
|
||||
claims: { sub: "u1" },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("parseBearerStaticResolverOptions", () => {
|
||||
it("accepts valid input", { timeout: 5000 }, () => {
|
||||
const out = parseBearerStaticResolverOptions(validInput);
|
||||
expect(out.tokens.length).toBe(1);
|
||||
expect(out.tokens[0]?.token).toBe("T1");
|
||||
expect(out.tokens[0]?.principal.id).toBe("u1");
|
||||
});
|
||||
|
||||
it("accepts optional headerName + prefix + mtlsRequired", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = parseBearerStaticResolverOptions({
|
||||
tokens: [
|
||||
{
|
||||
token: "T1",
|
||||
principal: {
|
||||
id: "u1",
|
||||
scopes: [],
|
||||
claims: {},
|
||||
},
|
||||
mtlsRequired: true,
|
||||
},
|
||||
],
|
||||
headerName: "X-Service-Auth",
|
||||
prefix: "Token",
|
||||
});
|
||||
expect(out.headerName).toBe("X-Service-Auth");
|
||||
expect(out.prefix).toBe("Token");
|
||||
expect(out.tokens[0]?.mtlsRequired).toBe(true);
|
||||
});
|
||||
|
||||
it("throws INVALID_OPTIONS on empty tokens array", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
parseBearerStaticResolverOptions({ tokens: [] });
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(BearerStaticAuthError);
|
||||
const e = err as BearerStaticAuthError;
|
||||
expect(e.code).toBe(AUTH_BEARER_STATIC_CODES.INVALID_OPTIONS);
|
||||
expect(e.cause).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("throws INVALID_OPTIONS on missing tokens key", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => parseBearerStaticResolverOptions({})).toThrow(
|
||||
BearerStaticAuthError
|
||||
);
|
||||
});
|
||||
|
||||
it("throws INVALID_OPTIONS on non-string token", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
parseBearerStaticResolverOptions({
|
||||
tokens: [
|
||||
{
|
||||
token: 42,
|
||||
principal: { id: "u1", scopes: [], claims: {} },
|
||||
},
|
||||
],
|
||||
})
|
||||
).toThrow(BearerStaticAuthError);
|
||||
});
|
||||
|
||||
it("throws INVALID_OPTIONS on empty token string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
parseBearerStaticResolverOptions({
|
||||
tokens: [
|
||||
{
|
||||
token: "",
|
||||
principal: { id: "u1", scopes: [], claims: {} },
|
||||
},
|
||||
],
|
||||
})
|
||||
).toThrow(BearerStaticAuthError);
|
||||
});
|
||||
|
||||
it("rejects missing principal.id", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
parseBearerStaticResolverOptions({
|
||||
tokens: [
|
||||
{
|
||||
token: "T1",
|
||||
principal: { scopes: [], claims: {} },
|
||||
},
|
||||
],
|
||||
})
|
||||
).toThrow(BearerStaticAuthError);
|
||||
});
|
||||
|
||||
it("rejects empty headerName", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
parseBearerStaticResolverOptions({
|
||||
...validInput,
|
||||
headerName: "",
|
||||
})
|
||||
).toThrow(BearerStaticAuthError);
|
||||
});
|
||||
|
||||
it("rejects empty prefix", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
parseBearerStaticResolverOptions({
|
||||
...validInput,
|
||||
prefix: "",
|
||||
})
|
||||
).toThrow(BearerStaticAuthError);
|
||||
});
|
||||
|
||||
it("schema can be used directly via safeParse", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = bearerStaticResolverOptionsSchema.safeParse(validInput);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Principal } from "@suaveplan/api-types/principal";
|
||||
import z from "zod";
|
||||
import {
|
||||
AUTH_BEARER_STATIC_CODES,
|
||||
BearerStaticAuthError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* One row in the static token table.
|
||||
*
|
||||
* `token` is the literal bearer credential (caller-supplied, opaque).
|
||||
* `principal` is the {@link Principal} the host attaches to
|
||||
* `RequestContext` when the token matches.
|
||||
* `mtlsRequired` (optional) gates this entry behind a verified peer
|
||||
* certificate surfaced by the host listener.
|
||||
*/
|
||||
export interface BearerStaticTokenEntry {
|
||||
readonly token: string;
|
||||
readonly principal: Principal;
|
||||
readonly mtlsRequired?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construction-time options for `createBearerStaticResolver` and
|
||||
* `createBearerStaticResolverWorkerProxy`.
|
||||
*
|
||||
* `tokens` is required and MUST be non-empty. `headerName` and `prefix`
|
||||
* default to `Authorization` and `Bearer` respectively.
|
||||
*/
|
||||
export interface BearerStaticResolverOptions {
|
||||
readonly tokens: ReadonlyArray<BearerStaticTokenEntry>;
|
||||
readonly headerName?: string;
|
||||
readonly prefix?: string;
|
||||
}
|
||||
|
||||
/** Zod schema mirroring {@link BearerStaticResolverOptions}. */
|
||||
export const bearerStaticResolverOptionsSchema = z.object({
|
||||
tokens: z
|
||||
.array(
|
||||
z.object({
|
||||
token: z.string().min(1),
|
||||
principal: z.object({
|
||||
id: z.string().min(1),
|
||||
scopes: z.array(z.string()),
|
||||
tenantId: z.string().optional(),
|
||||
claims: z.record(z.string(), z.unknown()),
|
||||
}),
|
||||
mtlsRequired: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.min(1),
|
||||
headerName: z.string().min(1).optional(),
|
||||
prefix: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Validate options at the factory boundary. Throws
|
||||
* {@link BearerStaticAuthError} with `INVALID_OPTIONS` on failure.
|
||||
*/
|
||||
export function parseBearerStaticResolverOptions(
|
||||
raw: unknown
|
||||
): BearerStaticResolverOptions {
|
||||
const result = bearerStaticResolverOptionsSchema.safeParse(raw);
|
||||
if (!result.success) {
|
||||
throw new BearerStaticAuthError(
|
||||
AUTH_BEARER_STATIC_CODES.INVALID_OPTIONS,
|
||||
`BearerStaticResolverOptions failed Zod validation: ${result.error.message}`,
|
||||
{ cause: result.error }
|
||||
);
|
||||
}
|
||||
// `safeParse` returns a Principal-shaped object; re-cast to the
|
||||
// Principal interface (branded fields are erased at runtime and the
|
||||
// host trusts operator-supplied config to brand correctly).
|
||||
return result.data as unknown as BearerStaticResolverOptions;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type BearerStaticResolverOptions,
|
||||
type BearerStaticTokenEntry,
|
||||
bearerStaticResolverOptionsSchema,
|
||||
parseBearerStaticResolverOptions,
|
||||
} from "./bearer-static-resolver-options.js";
|
||||
@@ -0,0 +1,110 @@
|
||||
# Worker-proxy factory — `@suaveplan/api-auth-bearer-static`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The worker-proxy surface is the second of the two Stage-4 surfaces this
|
||||
adapter ships (`§5.2a`). It lets the host install the bearer-static
|
||||
resolver in the main process while the actual token table lives inside an
|
||||
isolated worker. The host's `SessionResolverChain` calls
|
||||
`resolve(request)` exactly as for the direct surface; under the hood, the
|
||||
proxy marshals the request across a `MessagePort` using
|
||||
`@suaveplan/api-worker-rpc`, the worker's resolver runs, and the
|
||||
`Principal | null` decision is returned to the host.
|
||||
|
||||
The proxy intentionally keeps the token table out of the host's address
|
||||
space — useful when the host is sandboxed and the worker holds privileged
|
||||
credentials, or when operators want a single auth daemon serving multiple
|
||||
host workers.
|
||||
|
||||
## Features
|
||||
|
||||
- Bidirectional RPC over a `MessagePort` (or any `PortLike`) — no
|
||||
process / address-space assumptions.
|
||||
- Idempotent `dispose()` — coalesces multiple calls and closes the
|
||||
underlying RPC client exactly once.
|
||||
- Header set, request method, URL, and (optional) `peerCertificate` cross
|
||||
the boundary; opaque body and abort signal are out of scope (resolution
|
||||
does not need them).
|
||||
- Server-side mount via `serveBearerStaticResolverOnPort` — owns the real
|
||||
resolver and the token table.
|
||||
- Passes the same `runAuthSessionConformance` harness as the direct
|
||||
surface.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
createBearerStaticResolverWorkerProxy,
|
||||
serveBearerStaticResolverOnPort,
|
||||
} from "@suaveplan/api-auth-bearer-static/proxy";
|
||||
import { createInProcessPortPair } from "@suaveplan/api-worker-rpc";
|
||||
|
||||
const { clientPort, serverPort } = createInProcessPortPair();
|
||||
|
||||
// Worker side — owns the real table.
|
||||
const server = serveBearerStaticResolverOnPort(serverPort, {
|
||||
tokens: [
|
||||
{
|
||||
token: "T1",
|
||||
principal: { id: "svc", scopes: [], claims: {} },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Host side — sees only a proxy.
|
||||
const proxy = createBearerStaticResolverWorkerProxy(clientPort);
|
||||
|
||||
const request = new Request("https://api/x", {
|
||||
headers: { Authorization: "Bearer T1" },
|
||||
});
|
||||
const principal = await proxy.resolve(request);
|
||||
|
||||
await proxy.dispose();
|
||||
server.close("done");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { Worker, MessageChannel } from "node:worker_threads";
|
||||
import {
|
||||
createBearerStaticResolverWorkerProxy,
|
||||
} from "@suaveplan/api-auth-bearer-static/proxy";
|
||||
|
||||
// Real Node worker boundary. The worker script imports
|
||||
// `serveBearerStaticResolverOnPort` and calls it against its own port.
|
||||
const { port1, port2 } = new MessageChannel();
|
||||
const worker = new Worker("./auth-worker.mjs", { transferList: [port2] });
|
||||
|
||||
const resolver = createBearerStaticResolverWorkerProxy(port1 as never);
|
||||
|
||||
// Place in chain alongside direct adapters — host treats both uniformly.
|
||||
import { SessionResolverChain } from "@suaveplan/api-auth";
|
||||
const chain = new SessionResolverChain([resolver]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `serveBearerStaticResolverOnPort(port: PortLike, options: BearerStaticResolverOptions): { close(reason?: string): void }`
|
||||
— mount the server-side handlers.
|
||||
- `createBearerStaticResolverWorkerProxy(port: PortLike): SessionResolver`
|
||||
— host-side proxy.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The marshalled envelope is `{url, method, headers, peerCertificate?}` —
|
||||
the minimal subset of `Request` that the bearer-static decision actually
|
||||
needs. `peerCertificate` is structured-clone-friendly because the host
|
||||
attaches whatever shape the listener surfaced; tests pass plain objects.
|
||||
The proxy never sends the request body, because token resolution does not
|
||||
consult it. Errors thrown by the server-side resolver (e.g. an unexpected
|
||||
runtime fault) propagate as the standard `WORKER_RPC` error envelope and
|
||||
surface in the host as a thrown `WorkerRpcError` — the host's resolver
|
||||
chain treats this as a fail-closed `RESOLVER_THREW` per the documented
|
||||
`api-auth` chain semantics. `dispose()` calls the remote `dispose` first
|
||||
to drain the server-side state, then closes the local client so the
|
||||
underlying port can be garbage-collected.
|
||||
@@ -0,0 +1,53 @@
|
||||
import { runAuthSessionConformance } from "@suaveplan/api-auth";
|
||||
import type { Principal } from "@suaveplan/api-types/principal";
|
||||
import { createInProcessPortPair } from "@suaveplan/api-worker-rpc";
|
||||
import {
|
||||
createBearerStaticResolverWorkerProxy,
|
||||
serveBearerStaticResolverOnPort,
|
||||
} from "./create-bearer-static-resolver-worker-proxy.js";
|
||||
|
||||
const expectedPrincipal = {
|
||||
id: "svc_cron",
|
||||
scopes: ["jobs:run"],
|
||||
claims: { kind: "service" },
|
||||
} as unknown as Principal;
|
||||
|
||||
runAuthSessionConformance({
|
||||
label: "bearer-static-worker-proxy",
|
||||
factory: () => {
|
||||
const pair = createInProcessPortPair();
|
||||
const server = serveBearerStaticResolverOnPort(pair.serverPort, {
|
||||
tokens: [
|
||||
{
|
||||
token: "T-CONFORMANCE",
|
||||
principal: expectedPrincipal,
|
||||
},
|
||||
],
|
||||
});
|
||||
const proxy = createBearerStaticResolverWorkerProxy(pair.clientPort);
|
||||
// Wrap dispose so closing the proxy also tears down the server +
|
||||
// the channel — the conformance harness only calls dispose().
|
||||
return {
|
||||
resolve: (req) => proxy.resolve(req),
|
||||
dispose: async () => {
|
||||
await proxy.dispose();
|
||||
try {
|
||||
server.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
pair.close();
|
||||
},
|
||||
};
|
||||
},
|
||||
seedNull: () =>
|
||||
new Request("https://api/x", {
|
||||
headers: { Authorization: "Bearer NOT_PRESENT" },
|
||||
}),
|
||||
seedPrincipal: () => ({
|
||||
request: new Request("https://api/x", {
|
||||
headers: { Authorization: "Bearer T-CONFORMANCE" },
|
||||
}),
|
||||
expected: expectedPrincipal,
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { Principal } from "@suaveplan/api-types/principal";
|
||||
import { createInProcessPortPair } from "@suaveplan/api-worker-rpc";
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
createBearerStaticResolverWorkerProxy,
|
||||
serveBearerStaticResolverOnPort,
|
||||
} from "./create-bearer-static-resolver-worker-proxy.js";
|
||||
|
||||
const principal = {
|
||||
id: "svc_a",
|
||||
scopes: ["read"],
|
||||
claims: { kind: "service" },
|
||||
} as unknown as Principal;
|
||||
|
||||
interface Harness {
|
||||
server: { close(reason?: string): void };
|
||||
proxy: ReturnType<typeof createBearerStaticResolverWorkerProxy>;
|
||||
pair: ReturnType<typeof createInProcessPortPair>;
|
||||
}
|
||||
|
||||
function build(extraOpts: Record<string, unknown> = {}): Harness {
|
||||
const pair = createInProcessPortPair();
|
||||
const server = serveBearerStaticResolverOnPort(pair.serverPort, {
|
||||
tokens: [
|
||||
{ token: "T1", principal },
|
||||
{
|
||||
token: "T_MTLS",
|
||||
principal,
|
||||
mtlsRequired: true,
|
||||
},
|
||||
],
|
||||
...extraOpts,
|
||||
});
|
||||
const proxy = createBearerStaticResolverWorkerProxy(pair.clientPort);
|
||||
return { server, proxy, pair };
|
||||
}
|
||||
|
||||
function teardown(h: Harness): void {
|
||||
try {
|
||||
h.server.close();
|
||||
} catch {
|
||||
/* ignore — server may already be closed */
|
||||
}
|
||||
h.pair.close();
|
||||
}
|
||||
|
||||
describe("createBearerStaticResolverWorkerProxy", () => {
|
||||
let active: Harness | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (active !== undefined) {
|
||||
await active.proxy.dispose().catch(() => undefined);
|
||||
teardown(active);
|
||||
active = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves a known token to its Principal", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
active = build();
|
||||
const req = new Request("https://api/x", {
|
||||
headers: { Authorization: "Bearer T1" },
|
||||
});
|
||||
const result = await active.proxy.resolve(req);
|
||||
expect(result?.id).toBe("svc_a");
|
||||
});
|
||||
|
||||
it("returns null for unknown tokens", { timeout: 5000 }, async () => {
|
||||
active = build();
|
||||
const req = new Request("https://api/x", {
|
||||
headers: { Authorization: "Bearer NOPE" },
|
||||
});
|
||||
expect(await active.proxy.resolve(req)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when header is absent", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
active = build();
|
||||
expect(
|
||||
await active.proxy.resolve(new Request("https://api/x"))
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("ferries the peer certificate across the boundary", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
active = build();
|
||||
const req = new Request("https://api/x", {
|
||||
headers: { Authorization: "Bearer T_MTLS" },
|
||||
});
|
||||
Object.defineProperty(req, "tls", {
|
||||
value: { peerCertificate: { subject: "CN=svc" } },
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
expect((await active.proxy.resolve(req))?.id).toBe("svc_a");
|
||||
});
|
||||
|
||||
it("rejects mtls-required token without peer certificate", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
active = build();
|
||||
const req = new Request("https://api/x", {
|
||||
headers: { Authorization: "Bearer T_MTLS" },
|
||||
});
|
||||
expect(await active.proxy.resolve(req)).toBeNull();
|
||||
});
|
||||
|
||||
it("dispose is idempotent", { timeout: 5000 }, async () => {
|
||||
const h = build();
|
||||
await h.proxy.dispose();
|
||||
await expect(h.proxy.dispose()).resolves.toBeUndefined();
|
||||
// Subsequent resolves return null without crossing the boundary.
|
||||
const req = new Request("https://api/x", {
|
||||
headers: { Authorization: "Bearer T1" },
|
||||
});
|
||||
expect(await h.proxy.resolve(req)).toBeNull();
|
||||
try {
|
||||
h.server.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
h.pair.close();
|
||||
});
|
||||
|
||||
it("honours custom headerName + prefix on the server side", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
active = build({ headerName: "X-Svc", prefix: "Token" });
|
||||
const req = new Request("https://api/x", {
|
||||
headers: { "X-Svc": "Token T1" },
|
||||
});
|
||||
expect((await active.proxy.resolve(req))?.id).toBe("svc_a");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { SessionResolver } from "@suaveplan/api-auth";
|
||||
import type { Principal } from "@suaveplan/api-types/principal";
|
||||
import type { PortLike } from "@suaveplan/api-worker-rpc";
|
||||
import {
|
||||
createWorkerRpcClient,
|
||||
serveWorkerRpc,
|
||||
} from "@suaveplan/api-worker-rpc";
|
||||
import { createBearerStaticResolver } from "../factory/create-bearer-static-resolver.js";
|
||||
import { extractPeerCertificate } from "../mtls/mtls-gate.js";
|
||||
import {
|
||||
type BearerStaticResolverOptions,
|
||||
parseBearerStaticResolverOptions,
|
||||
} from "../options/bearer-static-resolver-options.js";
|
||||
|
||||
/**
|
||||
* Minimal serialisable Request envelope used to ferry a Request across the
|
||||
* worker boundary. `peerCertificate` is intentionally JSON-stringified so
|
||||
* we never push opaque DER buffers through `structuredClone`.
|
||||
*/
|
||||
interface SerialisedRequest {
|
||||
readonly url: string;
|
||||
readonly method: string;
|
||||
readonly headers: ReadonlyArray<[string, string]>;
|
||||
readonly peerCertificate?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Methods the server side of the proxy mounts on its `MessagePort`. The
|
||||
* client proxies them transparently via `createWorkerRpcClient`.
|
||||
*/
|
||||
interface BearerStaticProxyApi {
|
||||
resolve(req: SerialisedRequest): Promise<Principal | null>;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
function serialiseRequest(request: Request): SerialisedRequest {
|
||||
const headers: Array<[string, string]> = [];
|
||||
request.headers.forEach((value, key) => {
|
||||
headers.push([key, value]);
|
||||
});
|
||||
const cert = extractPeerCertificate(request);
|
||||
return {
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
headers,
|
||||
...(cert !== undefined ? { peerCertificate: cert } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function rehydrateRequest(env: SerialisedRequest): Request {
|
||||
const headers = new Headers();
|
||||
for (const [k, v] of env.headers) {
|
||||
headers.set(k, v);
|
||||
}
|
||||
const request = new Request(env.url, {
|
||||
method: env.method,
|
||||
headers,
|
||||
});
|
||||
if (env.peerCertificate !== undefined) {
|
||||
Object.defineProperty(request, "tls", {
|
||||
value: { peerCertificate: env.peerCertificate },
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a server-side bearer-static resolver on `port`. Returns a disposer.
|
||||
*
|
||||
* Lives "on the worker side" of the boundary — the real token table never
|
||||
* crosses into the host process. The host installs the client side via
|
||||
* {@link createBearerStaticResolverWorkerProxy}.
|
||||
*/
|
||||
export function serveBearerStaticResolverOnPort(
|
||||
port: PortLike,
|
||||
options: BearerStaticResolverOptions | unknown
|
||||
): { close(reason?: string): void } {
|
||||
const opts = parseBearerStaticResolverOptions(options);
|
||||
const resolver = createBearerStaticResolver(opts);
|
||||
const server = serveWorkerRpc(port, {
|
||||
resolve: async (params: ReadonlyArray<unknown>) => {
|
||||
const env = params[0] as SerialisedRequest;
|
||||
const request = rehydrateRequest(env);
|
||||
return resolver.resolve(request);
|
||||
},
|
||||
dispose: async () => {
|
||||
await resolver.dispose();
|
||||
},
|
||||
});
|
||||
return server;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a client-side {@link SessionResolver} that proxies every call to
|
||||
* a server resolver running on the other end of `port`. The host treats
|
||||
* the returned proxy identically to the direct surface.
|
||||
*
|
||||
* `dispose()` is idempotent — multiple calls are coalesced into a single
|
||||
* remote `dispose` invocation, and the underlying RPC client is closed
|
||||
* on the first call.
|
||||
*/
|
||||
export function createBearerStaticResolverWorkerProxy(
|
||||
port: PortLike
|
||||
): SessionResolver {
|
||||
const client = createWorkerRpcClient<BearerStaticProxyApi>(port);
|
||||
let disposed = false;
|
||||
return {
|
||||
async resolve(req: Request): Promise<Principal | null> {
|
||||
if (disposed) return null;
|
||||
const env = serialiseRequest(req);
|
||||
const result = await client.resolve(env);
|
||||
return result;
|
||||
},
|
||||
async dispose(): Promise<void> {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
try {
|
||||
await client.dispose();
|
||||
} finally {
|
||||
client.close("proxy disposed");
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
createBearerStaticResolverWorkerProxy,
|
||||
serveBearerStaticResolverOnPort,
|
||||
} from "./create-bearer-static-resolver-worker-proxy.js";
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
type SecuritySchemeContributionOptions,
|
||||
securitySchemeContribution,
|
||||
} from "./security-scheme-contribution.js";
|
||||
@@ -0,0 +1,71 @@
|
||||
# Security scheme contribution — `@suaveplan/api-auth-bearer-static`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
This module builds the OpenAPI 3.1 `components.securitySchemes`
|
||||
contribution the adapter publishes to the host's `AuthAdapter` registry.
|
||||
The generated entry declares an `http` / `bearer` scheme so OpenAPI tooling
|
||||
(Stoplight, ReadMe, Postman import, etc.) and the host's emitter render
|
||||
the canonical "Authorize" affordance.
|
||||
|
||||
The contribution is content-only: it does NOT register routes, mount
|
||||
middleware, or affect runtime behaviour. It exists solely so the OpenAPI
|
||||
document the host emits is correct and discoverable.
|
||||
|
||||
## Features
|
||||
|
||||
- OAS 3.1 compliant — emits the `http` discriminator with `scheme: bearer`.
|
||||
- `name` defaults to `bearerStaticAuth` and is overridable at construction.
|
||||
- Optional `bearerFormat` and `description` fields pass through verbatim.
|
||||
- Validated against the `OpenApiSecuritySchemeSchema` Zod schema published
|
||||
by `@suaveplan/api-auth/security-scheme` — if this module ever drifts,
|
||||
the conformance tests in `api-auth` fail.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { securitySchemeContribution } from "@suaveplan/api-auth-bearer-static/security-scheme";
|
||||
|
||||
const contrib = securitySchemeContribution();
|
||||
// { name: "bearerStaticAuth",
|
||||
// scheme: { type: "http", scheme: "bearer" } }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { securitySchemeContribution } from "@suaveplan/api-auth-bearer-static/security-scheme";
|
||||
import { validateSecurityScheme } from "@suaveplan/api-auth";
|
||||
|
||||
// Custom name + bearerFormat for an admin-only deployment that publishes
|
||||
// two static-token adapters (cron + admin) side by side under distinct
|
||||
// scheme keys.
|
||||
const adminScheme = securitySchemeContribution({
|
||||
name: "bearerStaticAdminAuth",
|
||||
description: "Static admin token. mTLS-gated. Rotate via host config reload.",
|
||||
bearerFormat: "opaque",
|
||||
});
|
||||
|
||||
// Defensive — confirm the contribution still passes the api-auth schema.
|
||||
validateSecurityScheme(adminScheme);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface SecuritySchemeContributionOptions { name?: string; description?: string; bearerFormat?: string }`
|
||||
- `securitySchemeContribution(options?: SecuritySchemeContributionOptions): SecuritySchemeContribution`
|
||||
— pure factory; the result is structurally a
|
||||
`SecuritySchemeContribution` from `@suaveplan/api-auth`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`bearerFormat` is purely informational per OAS — values like `JWT`,
|
||||
`opaque`, or vendor-specific strings appear in some doc tooling but never
|
||||
affect runtime validation. The contribution intentionally omits the
|
||||
optional fields when undefined rather than emitting `undefined` values,
|
||||
which would round-trip through `JSON.stringify` as missing keys but
|
||||
trigger noisy diffs in machine-comparison tooling.
|
||||
@@ -0,0 +1,62 @@
|
||||
import { validateSecurityScheme } from "@suaveplan/api-auth";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { securitySchemeContribution } from "./security-scheme-contribution.js";
|
||||
|
||||
describe("securitySchemeContribution", () => {
|
||||
it("returns the default HTTP-Bearer scheme", { timeout: 5000 }, () => {
|
||||
const contrib = securitySchemeContribution();
|
||||
expect(contrib.name).toBe("bearerStaticAuth");
|
||||
expect(contrib.scheme).toEqual({
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
});
|
||||
});
|
||||
|
||||
it("honours overridden name", { timeout: 5000 }, () => {
|
||||
const contrib = securitySchemeContribution({
|
||||
name: "bearerStaticAdminAuth",
|
||||
});
|
||||
expect(contrib.name).toBe("bearerStaticAdminAuth");
|
||||
});
|
||||
|
||||
it("emits bearerFormat when supplied", { timeout: 5000 }, () => {
|
||||
const contrib = securitySchemeContribution({
|
||||
bearerFormat: "opaque",
|
||||
});
|
||||
expect(contrib.scheme).toEqual({
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
bearerFormat: "opaque",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits description when supplied", { timeout: 5000 }, () => {
|
||||
const contrib = securitySchemeContribution({
|
||||
description: "Static service tokens",
|
||||
});
|
||||
expect(contrib.scheme).toEqual({
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
description: "Static service tokens",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits undefined optional fields", { timeout: 5000 }, () => {
|
||||
const contrib = securitySchemeContribution({
|
||||
bearerFormat: undefined,
|
||||
description: undefined,
|
||||
});
|
||||
expect(Object.keys(contrib.scheme)).toEqual(["type", "scheme"]);
|
||||
});
|
||||
|
||||
it("passes validation against @suaveplan/api-auth schema", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const contrib = securitySchemeContribution({
|
||||
description: "x",
|
||||
bearerFormat: "opaque",
|
||||
});
|
||||
const validated = validateSecurityScheme(contrib);
|
||||
expect(validated.name).toBe("bearerStaticAuth");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { SecuritySchemeContribution } from "@suaveplan/api-auth";
|
||||
|
||||
/**
|
||||
* Options for {@link securitySchemeContribution}.
|
||||
*
|
||||
* `name` is the key under `components.securitySchemes` in the generated
|
||||
* OpenAPI document. Defaults to `"bearerStaticAuth"`.
|
||||
*
|
||||
* `description` is the OAS `description` field on the scheme object.
|
||||
*
|
||||
* `bearerFormat` is the OAS `bearerFormat` field — purely informational;
|
||||
* the adapter does not validate it.
|
||||
*/
|
||||
export interface SecuritySchemeContributionOptions {
|
||||
readonly name?: string;
|
||||
readonly description?: string;
|
||||
readonly bearerFormat?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the OpenAPI 3.1 `securitySchemes` contribution for this adapter.
|
||||
*
|
||||
* Always emits an HTTP-Bearer scheme — the package's contract is bearer
|
||||
* tokens regardless of the configured `headerName` / `prefix`. Custom
|
||||
* header names are an operational deployment concern that does not surface
|
||||
* in the public OpenAPI document.
|
||||
*/
|
||||
export function securitySchemeContribution(
|
||||
options: SecuritySchemeContributionOptions = {}
|
||||
): SecuritySchemeContribution {
|
||||
const name = options.name ?? "bearerStaticAuth";
|
||||
const scheme: SecuritySchemeContribution["scheme"] = {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
...(options.bearerFormat !== undefined
|
||||
? { bearerFormat: options.bearerFormat }
|
||||
: {}),
|
||||
...(options.description !== undefined
|
||||
? { description: options.description }
|
||||
: {}),
|
||||
};
|
||||
return { name, scheme };
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
type BearerStaticInstruments,
|
||||
type BearerStaticMatchOutcome,
|
||||
getBearerStaticInstruments,
|
||||
recordMatchOutcome,
|
||||
resetBearerStaticInstruments,
|
||||
} from "./instruments.js";
|
||||
@@ -0,0 +1,76 @@
|
||||
# OTel instruments — `@suaveplan/api-auth-bearer-static`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The telemetry module owns the single OpenTelemetry counter this package
|
||||
emits — `api.auth.bearer_static_match_total` — partitioned by the
|
||||
documented `outcome` attribute. Every call to a resolver's `resolve()`
|
||||
method increments this counter exactly once with one of four label values.
|
||||
|
||||
The instrument is bound lazily against `metrics.getMeter(...)` so the
|
||||
package is safe to import on the cold path before the host installs a
|
||||
`MeterProvider`. Resetting the cached bundle (via
|
||||
`resetBearerStaticInstruments`) is required in test setup to bind against
|
||||
a freshly-installed in-memory exporter.
|
||||
|
||||
## Features
|
||||
|
||||
- Single counter — `api.auth.bearer_static_match_total`.
|
||||
- Four `outcome` label values: `match`, `no-token`, `unknown-token`,
|
||||
`mtls-required`.
|
||||
- Lazy meter resolution — module load does not trigger an OTel side
|
||||
effect.
|
||||
- Idempotent reset hook for test isolation.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { recordMatchOutcome } from "@suaveplan/api-auth-bearer-static/src/telemetry";
|
||||
|
||||
// Inside a resolver's resolve() body:
|
||||
recordMatchOutcome("unknown-token");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
getBearerStaticInstruments,
|
||||
resetBearerStaticInstruments,
|
||||
} from "@suaveplan/api-auth-bearer-static/src/telemetry";
|
||||
|
||||
// In a vitest setup file that has installed an in-memory MeterProvider
|
||||
// upstream:
|
||||
beforeEach(() => {
|
||||
resetBearerStaticInstruments();
|
||||
});
|
||||
|
||||
it("emits the outcome attribute we expect", async () => {
|
||||
const inst = getBearerStaticInstruments();
|
||||
inst.matchTotal.add(1, { outcome: "match" });
|
||||
// ...then flush + assert on the exporter
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface BearerStaticInstruments { matchTotal: Counter }`
|
||||
- `type BearerStaticMatchOutcome = "match" | "no-token" | "unknown-token" | "mtls-required"`
|
||||
- `getBearerStaticInstruments(): BearerStaticInstruments`
|
||||
- `resetBearerStaticInstruments(): void`
|
||||
- `recordMatchOutcome(outcome: BearerStaticMatchOutcome): void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The instrument name uses dotted notation per OpenTelemetry semantic
|
||||
conventions; the attribute key is `outcome` rather than the host-wide
|
||||
`auth.outcome` since this counter is adapter-scoped and dashboards aggregate
|
||||
across adapters by metric name, not attribute. The `description` field is
|
||||
human-readable only — OTel collectors do not interpret it. The cached bundle
|
||||
exists per process and is intentionally global; resolvers do not own
|
||||
instruments because every concrete resolver in a chain would otherwise
|
||||
double-publish the same counter under the same scope.
|
||||
@@ -0,0 +1,72 @@
|
||||
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
|
||||
import {
|
||||
getBearerStaticInstruments,
|
||||
recordMatchOutcome,
|
||||
resetBearerStaticInstruments,
|
||||
} from "./instruments.js";
|
||||
|
||||
function findCounter(
|
||||
name: string,
|
||||
metrics: Awaited<ReturnType<typeof flushTestMetrics>>
|
||||
): { value: number; attributes: Record<string, unknown> }[] {
|
||||
const out: { value: number; attributes: Record<string, unknown> }[] = [];
|
||||
for (const rm of metrics) {
|
||||
for (const sm of rm.scopeMetrics) {
|
||||
for (const m of sm.metrics) {
|
||||
if (m.descriptor.name !== name) continue;
|
||||
for (const dp of m.dataPoints) {
|
||||
out.push({
|
||||
value: Number(dp.value),
|
||||
attributes: (dp.attributes ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("BearerStatic OTel instruments", () => {
|
||||
beforeEach(() => {
|
||||
resetBearerStaticInstruments();
|
||||
});
|
||||
|
||||
it("caches the bundle across calls", { timeout: 5000 }, () => {
|
||||
const a = getBearerStaticInstruments();
|
||||
const b = getBearerStaticInstruments();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("resetBearerStaticInstruments forces re-bind", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = getBearerStaticInstruments();
|
||||
resetBearerStaticInstruments();
|
||||
const b = getBearerStaticInstruments();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("recordMatchOutcome emits the partitioned counter", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await flushTestMetrics();
|
||||
recordMatchOutcome("match");
|
||||
recordMatchOutcome("unknown-token");
|
||||
recordMatchOutcome("no-token");
|
||||
recordMatchOutcome("mtls-required");
|
||||
const collected = await flushTestMetrics();
|
||||
const points = findCounter(
|
||||
"api.auth.bearer_static_match_total",
|
||||
collected
|
||||
);
|
||||
const outcomes = points.map((p) => p.attributes.outcome);
|
||||
expect(outcomes).toContain("match");
|
||||
expect(outcomes).toContain("unknown-token");
|
||||
expect(outcomes).toContain("no-token");
|
||||
expect(outcomes).toContain("mtls-required");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { type Counter, metrics } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* Bundle of OpenTelemetry instruments emitted by
|
||||
* `@suaveplan/api-auth-bearer-static`.
|
||||
*
|
||||
* - `matchTotal{outcome}` — incremented exactly once per `resolve()` call.
|
||||
* `outcome` is one of:
|
||||
* - `"match"` — known token, mTLS gate (if any) satisfied.
|
||||
* - `"no-token"` — no Authorization header / wrong scheme.
|
||||
* - `"unknown-token"` — header present, token not in table.
|
||||
* - `"mtls-required"` — token matched but mTLS gate tripped.
|
||||
*/
|
||||
export interface BearerStaticInstruments {
|
||||
readonly matchTotal: Counter;
|
||||
}
|
||||
|
||||
let cached: BearerStaticInstruments | undefined;
|
||||
|
||||
/**
|
||||
* Lazily build (and cache) the instruments bundle against the current
|
||||
* global `MeterProvider`. Call {@link resetBearerStaticInstruments} in
|
||||
* test setup to force a re-bind against a freshly-installed provider.
|
||||
*/
|
||||
export function getBearerStaticInstruments(): BearerStaticInstruments {
|
||||
if (cached !== undefined) return cached;
|
||||
const meter = metrics.getMeter("@suaveplan/api-auth-bearer-static");
|
||||
cached = {
|
||||
matchTotal: meter.createCounter("api.auth.bearer_static_match_total", {
|
||||
description:
|
||||
"Total bearer-static resolve() outcomes, partitioned by outcome attribute (match | no-token | unknown-token | mtls-required)",
|
||||
}),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Drop the cached instrument bundle so the next call re-binds. */
|
||||
export function resetBearerStaticInstruments(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
|
||||
/** Stable outcome label values for {@link BearerStaticInstruments.matchTotal}. */
|
||||
export type BearerStaticMatchOutcome =
|
||||
| "match"
|
||||
| "no-token"
|
||||
| "unknown-token"
|
||||
| "mtls-required";
|
||||
|
||||
/** Convenience wrapper that records one match outcome. */
|
||||
export function recordMatchOutcome(outcome: BearerStaticMatchOutcome): void {
|
||||
getBearerStaticInstruments().matchTotal.add(1, { outcome });
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* §16 conformance proof — the worker-proxy package consumes the
|
||||
* `@suaveplan/api-worker-rpc` contract directly (client + server +
|
||||
* MessagePort transport). This test exercises the canonical
|
||||
* `runWorkerRpcConformance` harness with a minimal handler set to
|
||||
* prove the consumer is wire-compatible end-to-end (request frame,
|
||||
* response frame, abort propagation) using the same port transport
|
||||
* the package builds on top of in `src/main-side` / `src/worker-side`.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { runWorkerRpcConformance } from "../../../api-worker-rpc/src/testing/conformance.js";
|
||||
|
||||
const handles: Array<{ dispose(): void }> = [];
|
||||
afterEach(() => {
|
||||
while (handles.length > 0) handles.pop()?.dispose();
|
||||
});
|
||||
|
||||
describe("api-worker-rpc contract conformance (consumer-side)", () => {
|
||||
it("round-trips a request through the canonical harness", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = runWorkerRpcConformance(() => ({
|
||||
echo: (params: ReadonlyArray<unknown>) => params[0],
|
||||
}));
|
||||
handles.push(h);
|
||||
const result = await h.client.call<number>("echo", [42]);
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it("surfaces UNKNOWN_METHOD from the server", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const h = runWorkerRpcConformance(() => ({}));
|
||||
handles.push(h);
|
||||
await expect(h.client.call("missing", [])).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Test setup for @suaveplan/api-auth-bearer-static.
|
||||
*
|
||||
* Installs a real in-memory OpenTelemetry MeterProvider so OTel
|
||||
* instruments resolved on first call bind against an exporter the
|
||||
* tests can flush + read.
|
||||
*/
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter: testInMemoryMetricExporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
|
||||
export const testMeterProvider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(testMeterProvider);
|
||||
|
||||
export async function flushTestMetrics() {
|
||||
await testMeterProvider.forceFlush();
|
||||
const collected = testInMemoryMetricExporter.getMetrics();
|
||||
testInMemoryMetricExporter.reset();
|
||||
return collected;
|
||||
}
|
||||
12
packages/api-stack/api/api-auth-bearer-static/tsconfig.json
Normal file
12
packages/api-stack/api/api-auth-bearer-static/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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
36
packages/api-stack/api/api-auth-bearer-static/vite.config.ts
Normal file
36
packages/api-stack/api/api-auth-bearer-static/vite.config.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/api-auth-bearer-static",
|
||||
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\//,
|
||||
/^@opentelemetry\//,
|
||||
/^node:/,
|
||||
"tslib",
|
||||
"zod",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
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