first commit
This commit is contained in:
155
docs/api-stack/api/api-auth-bearer-static/README.md
Normal file
155
docs/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`.
|
||||
|
||||
@@ -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,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,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,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 @@
|
||||
# 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,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,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,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.
|
||||
Reference in New Issue
Block a user