first commit
This commit is contained in:
109
docs/api-stack/api/api-auth/README.md
Normal file
109
docs/api-stack/api/api-auth/README.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# @suaveplan/api-auth - Abstract session-resolver contract for the Suaveplan API plugin system. Defines the SessionResolver interface (Request → Principal | null), SessionResolverChain (ordered, first-match-wins, fail-closed-on-throw), SecuritySchemeContribution (OpenAPI emission metadata), CsrfPolicy default rules, the composite AuthAdapter, the AUTH_CODES registry, and a runAuthSessionConformance harness reused by every concrete resolver in Stages 4/6/8.
|
||||
|
||||
**$2** for the Suaveplan API plugin system. Defines
|
||||
the abstract session-resolver shape (`Request → Principal | null`) used by the
|
||||
host on every request, plus the first-match-wins runner, the OpenAPI
|
||||
security-scheme contribution, the default CSRF policy, the composite
|
||||
`AuthAdapter`, and the conformance harness every concrete resolver
|
||||
implementation passes.
|
||||
|
||||
This package is **session resolution only** — account lifecycle (login,
|
||||
register, oauth callback, password reset) lives in the separate
|
||||
`@suaveplan/api-auth-provider` package per design doc §2.3.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [capability-id](./src/capability-id/capability-id.md) — Pins this package's `CapabilityId` contribution as a stand-alone literal type plus runtime constant. The `CapabilityId`…
|
||||
- [capability-id](./src/capability-id/capability-id.md) — Pins this package's `CapabilityId` contribution as a stand-alone literal type plus runtime constant. The `CapabilityId`…
|
||||
- [chain](./src/chain/chain.md) — Ordered, first-match-wins runtime over an array of `SessionResolver`s. The chain is the integration point at design-doc…
|
||||
- [chain](./src/chain/chain.md) — Ordered, first-match-wins runtime over an array of `SessionResolver`s. The chain is the integration point at design-doc…
|
||||
- [csrf-policy](./src/csrf-policy/csrf-policy.md) — Exposes a single pure predicate, `CsrfPolicy.requiresCsrf(scheme)`, that defaults CSRF protection ON for any cookie-bas…
|
||||
- [csrf-policy](./src/csrf-policy/csrf-policy.md) — Exposes a single pure predicate, `CsrfPolicy.requiresCsrf(scheme)`, that defaults CSRF protection ON for any cookie-bas…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable, namespaced error-code registry for the `@suaveplan/api-auth` package and the concrete `AuthError` c…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable, namespaced error-code registry for the `@suaveplan/api-auth` package and the concrete `AuthError` c…
|
||||
- [resolver](./src/resolver/resolver.md) — `SessionResolver` is the abstract per-request session-resolver shape that every concrete adapter in Stages 4/6/8 (`api-…
|
||||
- [resolver](./src/resolver/resolver.md) — `SessionResolver` is the abstract per-request session-resolver shape that every concrete adapter in Stages 4/6/8 (`api-…
|
||||
- [security-scheme](./src/security-scheme/security-scheme.md) — Defines the shape every concrete `SessionResolver` MUST contribute so the `api-openapi` package can emit the right `com…
|
||||
- [security-scheme](./src/security-scheme/security-scheme.md) — Defines the shape every concrete `SessionResolver` MUST contribute so the `api-openapi` package can emit the right `com…
|
||||
- [session-adapter](./src/session-adapter/session-adapter.md) — `AuthAdapter` is the composite shape every concrete session-auth adapter exports — pairing the per-request `SessionReso…
|
||||
- [session-adapter](./src/session-adapter/session-adapter.md) — `AuthAdapter` is the composite shape every concrete session-auth adapter exports — pairing the per-request `SessionReso…
|
||||
- [testing](./src/testing/conformance.md) — A reusable Vitest `describe(...)` block every concrete `SessionResolver` must pass before publishing. The harness cover…
|
||||
- [conformance](./src/testing/conformance.md) — A reusable Vitest `describe(...)` block every concrete `SessionResolver` must pass before publishing. The harness cover…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## What's inside
|
||||
|
||||
| Module | Subpath | Purpose |
|
||||
|-------------------------------|----------------------------------------|---------------------------------------------------------------|
|
||||
| `SessionResolver` interface | `@suaveplan/api-auth/resolver` | Abstract `resolve(req) → Principal \| null` + `dispose`. |
|
||||
| `SessionResolverChain` runner | `@suaveplan/api-auth/chain` | Ordered first-match-wins; fail-closed-on-throw by default. |
|
||||
| Security-scheme contract | `@suaveplan/api-auth/security-scheme` | Zod-validated OpenAPI security-scheme metadata. |
|
||||
| CSRF policy default | `@suaveplan/api-auth/csrf-policy` | Cookie schemes → ON; bearer / oauth2 / OIDC → OFF. |
|
||||
| Composite `AuthAdapter` | `@suaveplan/api-auth/session-adapter` | `{ resolver, securityScheme, csrf? }` registration shape. |
|
||||
| `auth.session` capability id | `@suaveplan/api-auth/capability-id` | Re-export of `"auth.session"` with type-level assertion. |
|
||||
| `AUTH_CODES` registry | `@suaveplan/api-auth/error-codes` | Frozen `AUTH.*` error-code registry + `AuthError` class. |
|
||||
| Conformance harness | `@suaveplan/api-auth/testing` | `runAuthSessionConformance(spec)` for concrete resolvers. |
|
||||
|
||||
## Co-located documentation
|
||||
|
||||
Each module ships a sibling `.md`:
|
||||
|
||||
- [`src/resolver/resolver.md`](src/resolver/resolver.md)
|
||||
- [`src/chain/chain.md`](src/chain/chain.md)
|
||||
- [`src/security-scheme/security-scheme.md`](src/security-scheme/security-scheme.md)
|
||||
- [`src/csrf-policy/csrf-policy.md`](src/csrf-policy/csrf-policy.md)
|
||||
- [`src/session-adapter/session-adapter.md`](src/session-adapter/session-adapter.md)
|
||||
- [`src/capability-id/capability-id.md`](src/capability-id/capability-id.md)
|
||||
- [`src/error-codes/error-codes.md`](src/error-codes/error-codes.md)
|
||||
- [`src/testing/conformance.md`](src/testing/conformance.md)
|
||||
|
||||
## Quick example
|
||||
|
||||
```ts
|
||||
import { SessionResolverChain } from "@suaveplan/api-auth/chain";
|
||||
import { validateAuthAdapter } from "@suaveplan/api-auth/session-adapter";
|
||||
import type { SessionResolver } from "@suaveplan/api-auth/resolver";
|
||||
|
||||
declare const jwt: SessionResolver;
|
||||
declare const staticBearer: SessionResolver;
|
||||
|
||||
const jwtAdapter = validateAuthAdapter({
|
||||
resolver: jwt,
|
||||
securityScheme: {
|
||||
name: "bearerAuth",
|
||||
scheme: { type: "http", scheme: "bearer", bearerFormat: "JWT" },
|
||||
},
|
||||
});
|
||||
const bearerAdapter = validateAuthAdapter({
|
||||
resolver: staticBearer,
|
||||
securityScheme: {
|
||||
name: "staticBearer",
|
||||
scheme: { type: "http", scheme: "bearer" },
|
||||
},
|
||||
});
|
||||
|
||||
const chain = new SessionResolverChain([
|
||||
jwtAdapter.resolver,
|
||||
bearerAdapter.resolver,
|
||||
]);
|
||||
|
||||
const principal = await chain.resolve(new Request("https://example.test"));
|
||||
```
|
||||
|
||||
## Versioning
|
||||
|
||||
1.0`. Graduates to `1.0.0` when the Stage 1
|
||||
contract bundle (`api-types`, `api-auth`, `api-tenant-resolver`,
|
||||
`api-secrets`, `api-db-adapter`) all reach 1.0.
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dep | Why |
|
||||
|---------------------------|------------------------------------------------------|
|
||||
| `@suaveplan/api-types` | `Principal`, `CapabilityId`. |
|
||||
| `@suaveplan/error` | `SuaveplanError`, `defineCodes`. |
|
||||
| `@suaveplan/types` | Cross-package contracts (logger, clock, etc.). |
|
||||
| `tslib` | TypeScript runtime helpers (catalog). |
|
||||
| `zod` | Boundary validation for security-scheme contributions. |
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# `capability-id` — `"auth.session"` contribution
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Pins this package's `CapabilityId` contribution as a stand-alone literal type
|
||||
plus runtime constant. The `CapabilityId` union in
|
||||
`@suaveplan/api-types/capabilities` already enumerates `"auth.session"`
|
||||
directly; this module ties the api-auth package to that identifier so any
|
||||
future rename surfaces here first as a build break.
|
||||
|
||||
Exporting the runtime literal as `AUTH_SESSION_CAPABILITY_ID` lets plugin
|
||||
manifests declare `requires: [AUTH_SESSION_CAPABILITY_ID] as const` without
|
||||
hard-coding the string at the manifest site — refactor-safe and centrally
|
||||
documented.
|
||||
|
||||
## Features
|
||||
|
||||
- Compile-time `const _assignable: CapabilityId = AUTH_SESSION_CAPABILITY_ID`
|
||||
asserts membership at build time. If `api-types` ever drops the token, this
|
||||
package fails to type-check rather than silently regressing.
|
||||
- Type and value are exported together so the consumer can pick the form that
|
||||
fits the call site without re-stringifying.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { AUTH_SESSION_CAPABILITY_ID } from "@suaveplan/api-auth/capability-id";
|
||||
|
||||
const requires = [AUTH_SESSION_CAPABILITY_ID] as const;
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Use the literal type to constrain a manifest builder helper:
|
||||
|
||||
```ts
|
||||
import {
|
||||
AUTH_SESSION_CAPABILITY_ID,
|
||||
type AuthSessionCapabilityId,
|
||||
} from "@suaveplan/api-auth/capability-id";
|
||||
import type { CapabilityId } from "@suaveplan/api-types/capabilities";
|
||||
|
||||
function manifestRequires<const T extends readonly CapabilityId[]>(req: T): T {
|
||||
return req;
|
||||
}
|
||||
|
||||
const requires = manifestRequires([
|
||||
AUTH_SESSION_CAPABILITY_ID,
|
||||
"logger",
|
||||
] satisfies readonly (AuthSessionCapabilityId | "logger")[]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
|---------------------------------|------|----------------------------------------------|
|
||||
| `AuthSessionCapabilityId` | type | The literal `"auth.session"`. |
|
||||
| `AUTH_SESSION_CAPABILITY_ID` | const| `"auth.session"` runtime value. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The `void _assignable` keeps the assignment from being tree-shaken; without
|
||||
it Rollup MAY drop the line and the build-time assertion silently regresses
|
||||
to no-op.
|
||||
- This module is import-time-only — no behaviour at runtime beyond the
|
||||
string literal binding.
|
||||
- The literal is exported as both a `type` and a runtime `const` deliberately:
|
||||
consumers that build manifests imperatively want the runtime value; consumers
|
||||
that constrain compile-time-only structures (a `definePlugin` builder, for
|
||||
example) want the literal type. Re-stringifying `"auth.session"` at the
|
||||
consumer side would defeat the purpose of having the package central.
|
||||
- The pairing with `@suaveplan/api-types/capabilities` is deliberately
|
||||
one-directional. `api-types` is the source of truth for the closed union;
|
||||
this package only re-asserts membership and binds the runtime value. If a
|
||||
future change wanted to flip the relationship — have api-auth contribute via
|
||||
declaration merging instead — that would require relaxing the `CapabilityId`
|
||||
union in api-types to be `open` (i.e. `string & { brand }`), which is a
|
||||
larger contract change documented as out-of-scope here.
|
||||
- The runtime constant uses a type annotation rather than `as const`: the
|
||||
annotation makes the assignable check above explicit, and `as const` would
|
||||
widen any future enum-style rewrite.
|
||||
89
docs/api-stack/api/api-auth/src/chain/chain.md
Normal file
89
docs/api-stack/api/api-auth/src/chain/chain.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# `chain` — `SessionResolverChain`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Ordered, first-match-wins runtime over an array of `SessionResolver`s. The
|
||||
chain is the integration point at design-doc §6 step 3: the host's per-request
|
||||
pipeline calls `chain.resolve(req)` exactly once and routes the result into the
|
||||
`RequestContext.principal` slot (`publicRoutes` decides 401 vs `null`).
|
||||
|
||||
The chain owns three orthogonal contracts that are easy to get wrong if every
|
||||
adapter re-implements them: (1) iteration order — first non-null wins, no
|
||||
later resolver is invoked; (2) fail-closed throw semantics — a resolver
|
||||
exception surfaces `AUTH.RESOLVER_THREW` and short-circuits the chain, with an
|
||||
opt-in `continueOnThrow` escape hatch documented for SaaS-provider flakiness;
|
||||
(3) empty-chain rejection — `new SessionResolverChain([])` throws
|
||||
`AUTH.CHAIN_EMPTY` at construction so deployment misconfiguration cannot mask
|
||||
itself as "no resolvers, every request 401s."
|
||||
|
||||
## Features
|
||||
|
||||
- Class form implements `SessionResolver` itself — the chain can be nested
|
||||
inside another chain, used as the host's only resolver, or composed with
|
||||
middleware identically.
|
||||
- Idempotent `dispose()` propagates to every wrapped resolver.
|
||||
- Cause-chain propagation via `AuthError`'s `cause` option; the index of the
|
||||
throwing resolver is recorded in `context.resolverIndex`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { SessionResolverChain } from "@suaveplan/api-auth/chain";
|
||||
import type { SessionResolver } from "@suaveplan/api-auth/resolver";
|
||||
|
||||
declare const jwtResolver: SessionResolver;
|
||||
declare const bearerStaticResolver: SessionResolver;
|
||||
|
||||
const chain = new SessionResolverChain([jwtResolver, bearerStaticResolver]);
|
||||
|
||||
const principal = await chain.resolve(new Request("https://example.test"));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Enable `continueOnThrow` for a deployment that fronts an intentionally flaky
|
||||
SaaS auth upstream — the chain logs the cause via the `AuthError` it would
|
||||
have thrown, but keeps moving to the static-bearer fallback resolver:
|
||||
|
||||
```ts
|
||||
import { SessionResolverChain } from "@suaveplan/api-auth/chain";
|
||||
import type { SessionResolver } from "@suaveplan/api-auth/resolver";
|
||||
|
||||
declare const saasJwtResolver: SessionResolver;
|
||||
declare const staticBearerResolver: SessionResolver;
|
||||
|
||||
const chain = new SessionResolverChain(
|
||||
[saasJwtResolver, staticBearerResolver],
|
||||
{ continueOnThrow: true },
|
||||
);
|
||||
|
||||
const req = new Request("https://example.test/api/orders", {
|
||||
headers: { authorization: "Bearer st_ops_token" },
|
||||
});
|
||||
|
||||
const principal = await chain.resolve(req); // may come from staticBearerResolver
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
|---------------------------------|--------|------------------------------------------------------------|
|
||||
| `SessionResolverChain` | class | `new(resolvers, options?)`; implements `SessionResolver`. |
|
||||
| `SessionResolverChainOptions` | type | `{ continueOnThrow?: boolean }`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The chain iterates synchronously over `this.resolvers` and awaits each
|
||||
resolver in turn — concurrent resolution is intentionally NOT offered.
|
||||
Concurrency would change the "first match wins" semantics into "first to
|
||||
return wins," producing a non-deterministic principal selection when two
|
||||
resolvers both recognise the request.
|
||||
- The throw-handling branch records the resolver index but NOT the resolver
|
||||
identity — concrete adapters MAY redact stack traces, and the chain has no
|
||||
inspection contract on its members beyond `resolve` + `dispose`.
|
||||
- `dispose()` is idempotent via a `disposed` flag; the conformance harness
|
||||
calls it twice and asserts no throw.
|
||||
90
docs/api-stack/api/api-auth/src/csrf-policy/csrf-policy.md
Normal file
90
docs/api-stack/api/api-auth/src/csrf-policy/csrf-policy.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# `csrf-policy` — `CsrfPolicy.requiresCsrf`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Exposes a single pure predicate, `CsrfPolicy.requiresCsrf(scheme)`, that
|
||||
defaults CSRF protection ON for any cookie-based scheme and OFF for any scheme
|
||||
where the caller must actively attach the credential. This default codifies the
|
||||
design-doc §6 rule "Cookie-based auth adapters trigger CSRF verification" so
|
||||
every concrete adapter inherits the right behaviour without restating it.
|
||||
|
||||
The package exposes only the default; the host (`api-host`) applies per-route
|
||||
`csrf: false` overrides on top. Keeping the default and the override separate
|
||||
means a route-level opt-out is always auditable as an explicit decision in the
|
||||
manifest, never a side-effect of which adapter happened to be wired.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure function — no side effects, no module-load cost.
|
||||
- Frozen container object so test code can `expect(CsrfPolicy)` reliably and
|
||||
consumers cannot monkey-patch the predicate.
|
||||
- Total over the `OpenApiSecurityScheme` discriminated union — no `default`
|
||||
branch means an added scheme type at the schema layer will surface a
|
||||
type-level reminder to revisit the policy.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CsrfPolicy } from "@suaveplan/api-auth/csrf-policy";
|
||||
|
||||
const needsCsrf = CsrfPolicy.requiresCsrf({
|
||||
type: "apiKey",
|
||||
in: "cookie",
|
||||
name: "sp_session",
|
||||
}); // → true
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Drive a host-side CSRF middleware off the policy, with a per-route override
|
||||
applied at the call site:
|
||||
|
||||
```ts
|
||||
import { CsrfPolicy } from "@suaveplan/api-auth/csrf-policy";
|
||||
import type { OpenApiSecurityScheme } from "@suaveplan/api-auth/security-scheme";
|
||||
|
||||
interface RouteCsrfDecision {
|
||||
readonly required: boolean;
|
||||
readonly reason: "scheme-default" | "route-override";
|
||||
}
|
||||
|
||||
function decideCsrf(
|
||||
scheme: OpenApiSecurityScheme,
|
||||
routeOverride: boolean | undefined,
|
||||
): RouteCsrfDecision {
|
||||
if (routeOverride !== undefined) {
|
||||
return { required: routeOverride, reason: "route-override" };
|
||||
}
|
||||
return {
|
||||
required: CsrfPolicy.requiresCsrf(scheme),
|
||||
reason: "scheme-default",
|
||||
};
|
||||
}
|
||||
|
||||
const decision = decideCsrf(
|
||||
{ type: "http", scheme: "bearer" },
|
||||
undefined,
|
||||
);
|
||||
// → { required: false, reason: "scheme-default" }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
|----------------------------|-------------|----------------------------------------------------------|
|
||||
| `CsrfPolicy.requiresCsrf` | function | `(scheme) => boolean`. Cookie schemes → `true`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The predicate inspects `type === "apiKey" && in === "cookie"` — apiKey-in-
|
||||
header is treated identically to `Authorization: Bearer …` for CSRF purposes
|
||||
because the caller must attach it explicitly.
|
||||
- `oauth2` and `openIdConnect` default OFF — both are bearer-token-derived in
|
||||
practice; if a deployment exposes an oauth2 access token via cookie, that's
|
||||
a custom apiKey-in-cookie scheme, not the `oauth2` scheme.
|
||||
- `Object.freeze` guards the predicate slot; the predicate itself is a regular
|
||||
method and TypeScript prevents reassignment via the `Readonly` inference.
|
||||
84
docs/api-stack/api/api-auth/src/error-codes/error-codes.md
Normal file
84
docs/api-stack/api/api-auth/src/error-codes/error-codes.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# `error-codes` — `AUTH_CODES` registry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the stable, namespaced error-code registry for the `@suaveplan/api-auth`
|
||||
package and the concrete `AuthError` class. Every code is reachable from a
|
||||
production throw site somewhere in the package (chain construction, chain
|
||||
execution, adapter registration, or principal-shape validation). The registry
|
||||
is frozen via `defineCodes("AUTH", […])` so accidental drift between thrown
|
||||
codes and the documented set is impossible.
|
||||
|
||||
Consumers — including downstream concrete resolvers in Stages 4/6/8 such as
|
||||
`api-auth-jwt`, `api-auth-anonymous`, and `api-auth-bearer-static` — branch on
|
||||
the string literal (`"AUTH.RESOLVER_THREW"` etc.) or compare to the constant
|
||||
(`AUTH_CODES.RESOLVER_THREW`). The literal form is preferred at the API
|
||||
boundary; the constant is preferred inside the package itself so the linter
|
||||
flags typos as type errors.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen string-literal union of all four codes.
|
||||
- `AuthError` extends `SuaveplanError<AuthErrorCode>` so `instanceof AuthError`
|
||||
narrows `code` correctly.
|
||||
- Cause-chain propagation: the optional `options.cause` field is forwarded to
|
||||
the base class.
|
||||
- Public constructor (the base class is `protected`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { AUTH_CODES, AuthError } from "@suaveplan/api-auth/error-codes";
|
||||
|
||||
if (chain.length === 0) {
|
||||
throw new AuthError(AUTH_CODES.CHAIN_EMPTY, "at least one resolver required");
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Re-throw a resolver error with a cause chain and inspect it downstream:
|
||||
|
||||
```ts
|
||||
import { AUTH_CODES, AuthError } from "@suaveplan/api-auth/error-codes";
|
||||
|
||||
try {
|
||||
await resolver.resolve(req);
|
||||
} catch (cause) {
|
||||
throw new AuthError(AUTH_CODES.RESOLVER_THREW, "resolver A threw", {
|
||||
cause,
|
||||
context: { resolverIndex: 0 },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
import { AuthError } from "@suaveplan/api-auth/error-codes";
|
||||
|
||||
if (err instanceof AuthError && err.code === "AUTH.RESOLVER_THREW") {
|
||||
logger.warn("upstream session resolver failed", { cause: err.cause });
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
|----------------|--------|-----------------------------------------------------------------|
|
||||
| `AUTH_CODES` | const | Frozen lookup `{ RESOLVER_THREW, CHAIN_EMPTY, … }`. |
|
||||
| `AuthErrorCode`| type | Literal union of the four code strings. |
|
||||
| `AuthError` | class | Concrete `SuaveplanError` subclass narrowed to `AuthErrorCode`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `defineCodes("AUTH", [...] as const)` produces an object literal with
|
||||
string-literal value types, which is what gives `AuthErrorCode` its narrow
|
||||
union. Removing `as const` would widen the type to `string`.
|
||||
- The class field `authCode` mirrors `code` for ergonomic narrowing on
|
||||
pre-instantiated error objects (some test runners obscure the inherited
|
||||
`code` field via Proxy wrappers).
|
||||
- Code values follow the `AUTH.<UPPER_SNAKE>` template enforced by
|
||||
`@suaveplan/api-types`' `ErrorCode` template literal type.
|
||||
119
docs/api-stack/api/api-auth/src/resolver/resolver.md
Normal file
119
docs/api-stack/api/api-auth/src/resolver/resolver.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# `resolver` — `SessionResolver` contract
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`SessionResolver` is the abstract per-request session-resolver shape that every
|
||||
concrete adapter in Stages 4/6/8 (`api-auth-jwt`, `api-auth-anonymous`,
|
||||
`api-auth-bearer-static`, `api-auth-better-auth`, `api-auth-rolled`) implements.
|
||||
It deliberately captures only the universal vocabulary — inspect a WHATWG
|
||||
`Request`, return a `Principal | null`, dispose — so any concrete resolver can
|
||||
plug behind the host's chain runner without further coordination.
|
||||
|
||||
The tri-valued return contract (principal / null / throw) collapses to two
|
||||
host-level outcomes: the chain short-circuits on the first principal, the host
|
||||
applies its `publicRoutes` decision when every resolver returned `null`, and
|
||||
`AUTH.RESOLVER_THREW` is surfaced when any resolver throws (fail-closed by
|
||||
default per design doc §6).
|
||||
|
||||
## Features
|
||||
|
||||
- Generic over `P extends Principal` — concrete adapters MAY narrow `Principal`
|
||||
via interface merging without losing assignability to the chain's `Principal`
|
||||
return type.
|
||||
- Async by construction; resolvers MAY fetch JWKs, hit Redis, or call a token
|
||||
introspection endpoint.
|
||||
- `dispose` is part of the contract, not a side concern — the host blue/green
|
||||
rotates resolver chains and relies on `dispose` to release JWK caches, close
|
||||
HTTP keepalive pools, etc.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
A minimal resolver that always returns null (the "anonymous fallback" pattern
|
||||
codified in `api-auth-anonymous`):
|
||||
|
||||
```ts
|
||||
import type { SessionResolver } from "@suaveplan/api-auth/resolver";
|
||||
|
||||
const anonymous: SessionResolver = {
|
||||
async resolve() {
|
||||
return null;
|
||||
},
|
||||
async dispose() {
|
||||
/* no resources to release */
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
A resolver that reads a `Bearer` token, parses claims, and returns a typed
|
||||
principal — with `dispose` releasing a token-cache eviction timer:
|
||||
|
||||
```ts
|
||||
import type { SessionResolver } from "@suaveplan/api-auth/resolver";
|
||||
import type { Principal, PrincipalId, Scope } from "@suaveplan/api-types/principal";
|
||||
|
||||
interface JwtClaims {
|
||||
sub: string;
|
||||
scope?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
class JwtResolver implements SessionResolver {
|
||||
private readonly evictTimer: ReturnType<typeof setInterval>;
|
||||
private disposed = false;
|
||||
|
||||
public constructor(private readonly verifyJwt: (raw: string) => Promise<JwtClaims | null>) {
|
||||
this.evictTimer = setInterval(() => {
|
||||
/* prune cache */
|
||||
}, 30_000);
|
||||
}
|
||||
|
||||
public async resolve(req: Request): Promise<Principal | null> {
|
||||
const header = req.headers.get("authorization");
|
||||
if (!header?.startsWith("Bearer ")) {
|
||||
return null;
|
||||
}
|
||||
const claims = await this.verifyJwt(header.slice("Bearer ".length));
|
||||
if (claims === null) {
|
||||
return null;
|
||||
}
|
||||
const scopes = (claims.scope ?? "").split(" ").filter(Boolean) as readonly Scope[];
|
||||
return {
|
||||
id: claims.sub as PrincipalId,
|
||||
scopes,
|
||||
claims,
|
||||
};
|
||||
}
|
||||
|
||||
public async dispose(): Promise<void> {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
this.disposed = true;
|
||||
clearInterval(this.evictTimer);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
|-------------------|-------------|------------------------------------------------------|
|
||||
| `SessionResolver` | interface | `resolve(req): Promise<P \| null>` + `dispose()`. |
|
||||
| `P` (generic) | type param | Defaults to `Principal`. Narrowing is opt-in. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `Request` is the WHATWG Fetch `Request` (DOM lib). Node ≥18, Bun, Deno, and
|
||||
every Workers runtime all expose it as a global; `api-auth` does not import
|
||||
any Node-specific request type so the contract is universal.
|
||||
- Returning `null` for "credential present but invalid" is acceptable but
|
||||
discouraged — emit `AUTH.PRINCIPAL_INVALID_SHAPE` instead, so the host can
|
||||
distinguish "no credential" from "bad credential" in metrics.
|
||||
- `dispose` SHOULD be idempotent; tests in the conformance harness call it
|
||||
twice in a row and assert no throw.
|
||||
@@ -0,0 +1,89 @@
|
||||
# `security-scheme` — `SecuritySchemeContribution`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the shape every concrete `SessionResolver` MUST contribute so the
|
||||
`api-openapi` package can emit the right `components.securitySchemes` block in
|
||||
the generated OpenAPI document (design doc §7 step 3). Exposed as both a Zod
|
||||
schema (`SecuritySchemeContributionSchema`) and an inferred TypeScript type
|
||||
(`SecuritySchemeContribution`), with a `validateSecurityScheme()` boundary
|
||||
helper used by the host's adapter-registration pipeline.
|
||||
|
||||
Validation at the boundary is mandatory: without it, a typo in a concrete
|
||||
adapter's `name` or `scheme.type` would silently emit an invalid OpenAPI
|
||||
document and break every downstream client generator. The host therefore calls
|
||||
`validateSecurityScheme()` from its registration code and aborts boot rather
|
||||
than serve traffic with a half-configured document.
|
||||
|
||||
## Features
|
||||
|
||||
- Discriminated-union schema covering the four OAS 3.1 scheme types
|
||||
(`apiKey`, `http`, `oauth2`, `openIdConnect`).
|
||||
- Boundary validator throws `AUTH.SECURITY_SCHEME_MISSING` on absence or shape
|
||||
mismatch — the host's documented signal to abort boot.
|
||||
- Cause-chain propagation: Zod's `issues` array is attached as `context.issues`
|
||||
so the operator log explains exactly which field rejected.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { validateSecurityScheme } from "@suaveplan/api-auth/security-scheme";
|
||||
|
||||
const contribution = validateSecurityScheme({
|
||||
name: "bearerAuth",
|
||||
scheme: { type: "http", scheme: "bearer", bearerFormat: "JWT" },
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Use the inferred type to constrain an adapter factory, then call the validator
|
||||
at registration time:
|
||||
|
||||
```ts
|
||||
import {
|
||||
type SecuritySchemeContribution,
|
||||
validateSecurityScheme,
|
||||
} from "@suaveplan/api-auth/security-scheme";
|
||||
|
||||
interface AdapterFactory {
|
||||
readonly securityScheme: SecuritySchemeContribution;
|
||||
}
|
||||
|
||||
function registerAdapter(adapter: AdapterFactory): void {
|
||||
const validated = validateSecurityScheme(adapter.securityScheme);
|
||||
openApiRegistry.add(validated);
|
||||
}
|
||||
|
||||
const cookieAdapter: AdapterFactory = {
|
||||
securityScheme: {
|
||||
name: "sessionCookie",
|
||||
scheme: { type: "apiKey", in: "cookie", name: "sp_session" },
|
||||
},
|
||||
};
|
||||
|
||||
registerAdapter(cookieAdapter);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
|------------------------------------|--------|--------------------------------------------------------------|
|
||||
| `OpenApiSecuritySchemeSchema` | const | Zod discriminated union over the four OAS 3.1 scheme types. |
|
||||
| `OpenApiSecurityScheme` | type | `z.infer<typeof OpenApiSecuritySchemeSchema>`. |
|
||||
| `SecuritySchemeContributionSchema` | const | `{ name, scheme }` Zod object. |
|
||||
| `SecuritySchemeContribution` | type | Inferred contribution shape. |
|
||||
| `validateSecurityScheme` | fn | Boundary validator; throws `AUTH.SECURITY_SCHEME_MISSING`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `import z from "zod"` (default import, per project rule).
|
||||
- `z.discriminatedUnion("type", […])` is preferred over a plain union — it
|
||||
surfaces a much sharper Zod error when the input has an unknown `type`.
|
||||
- The contribution schema is the host-visible shape; concrete adapters MAY
|
||||
extend their internal scheme record with extension fields (`x-*`) but the
|
||||
host validates and forwards only the OAS-known keys.
|
||||
@@ -0,0 +1,81 @@
|
||||
# `session-adapter` — composite `AuthAdapter`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`AuthAdapter` is the composite shape every concrete session-auth adapter
|
||||
exports — pairing the per-request `SessionResolver` with the OpenAPI
|
||||
`SecuritySchemeContribution` and the optional adapter-level CSRF override.
|
||||
`validateAuthAdapter()` is the boundary helper the host calls before stitching
|
||||
an adapter into its resolver chain; it throws `AUTH.SECURITY_SCHEME_MISSING`
|
||||
when the contribution is absent or malformed.
|
||||
|
||||
This package owns the marriage of "what does the credential look like on the
|
||||
wire" (security scheme) and "how do we turn it into a Principal" (resolver) so
|
||||
that downstream consumers — `api-host`, `api-openapi`, and the host's CSRF
|
||||
middleware — read a single source-of-truth shape instead of three loosely
|
||||
coupled fields scattered across every concrete adapter package.
|
||||
|
||||
## Features
|
||||
|
||||
- `csrf` is optional, intentionally: an unset slot defers to the
|
||||
`CsrfPolicy.requiresCsrf(scheme)` default, while a set value pins per-adapter
|
||||
intent (auditable in the host's adapter table).
|
||||
- `validateAuthAdapter` is identity on the happy path so it composes with
|
||||
registration pipelines that need to forward the validated value.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { type AuthAdapter, validateAuthAdapter } from "@suaveplan/api-auth/session-adapter";
|
||||
import type { SessionResolver } from "@suaveplan/api-auth/resolver";
|
||||
|
||||
declare const jwtResolver: SessionResolver;
|
||||
|
||||
const adapter: AuthAdapter = {
|
||||
resolver: jwtResolver,
|
||||
securityScheme: {
|
||||
name: "bearerAuth",
|
||||
scheme: { type: "http", scheme: "bearer", bearerFormat: "JWT" },
|
||||
},
|
||||
};
|
||||
|
||||
validateAuthAdapter(adapter);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Register multiple adapters at host startup; the validator centralises the
|
||||
fail-fast and the host stitches the resolvers into a `SessionResolverChain`:
|
||||
|
||||
```ts
|
||||
import { SessionResolverChain } from "@suaveplan/api-auth/chain";
|
||||
import { type AuthAdapter, validateAuthAdapter } from "@suaveplan/api-auth/session-adapter";
|
||||
|
||||
function buildChain(adapters: readonly AuthAdapter[]): SessionResolverChain {
|
||||
const validated = adapters.map((a) => validateAuthAdapter(a));
|
||||
return new SessionResolverChain(validated.map((a) => a.resolver));
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
|-----------------------|-------------|---------------------------------------------------------------|
|
||||
| `AuthAdapter` | interface | `{ resolver, securityScheme, csrf? }`. |
|
||||
| `validateAuthAdapter` | function | Boundary validator; throws `AUTH.SECURITY_SCHEME_MISSING`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `csrf` is a `boolean | undefined` field rather than a richer `CsrfDecision`
|
||||
record. The host's middleware combines this slot with the per-route override
|
||||
and the `CsrfPolicy.requiresCsrf` default into a final decision — the
|
||||
adapter does NOT need three-valued logic for the same intent.
|
||||
- `validateAuthAdapter` returns its argument unchanged so call sites can write
|
||||
`const a = validateAuthAdapter(buildAdapter(opts));` without re-binding.
|
||||
- The structural type is `interface` (not `type`) so concrete adapter packages
|
||||
MAY use declaration merging to attach extension fields without forking the
|
||||
contract.
|
||||
101
docs/api-stack/api/api-auth/src/testing/conformance.md
Normal file
101
docs/api-stack/api/api-auth/src/testing/conformance.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# `testing` — `runAuthSessionConformance` harness
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
A reusable Vitest `describe(...)` block every concrete `SessionResolver` must
|
||||
pass before publishing. The harness covers the three behaviours that define a
|
||||
correct resolver implementation independent of the underlying credential
|
||||
shape: returning `null` for unrecognised requests, returning the documented
|
||||
`Principal` for a recognised request, and an idempotent `dispose()`.
|
||||
|
||||
Concrete adapters in Stages 4/6/8 (`api-auth-jwt`, `api-auth-anonymous`,
|
||||
`api-auth-bearer-static`, `api-auth-better-auth`, `api-auth-rolled`) call
|
||||
`runAuthSessionConformance({ … })` from one of their `*.test.ts` files. The
|
||||
single conformance suite means every adapter ships against the same observable
|
||||
contract; new adapters drop in without re-deriving the test plan.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure function — wires `describe` / `it` from `@suaveplan/testing/runner` so
|
||||
the suite runs identically under Vitest (default) and `bun:test` (under the
|
||||
`bun` test condition).
|
||||
- Factory pattern — adapters re-construct their resolver inside each `it` so
|
||||
parallel runners do not share resolver state across tests.
|
||||
- Every `it` carries `{ timeout: 5000 }` per the project's hard rule.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { runAuthSessionConformance } from "@suaveplan/api-auth/testing";
|
||||
import type { PrincipalId, Scope } from "@suaveplan/api-types/principal";
|
||||
|
||||
import { buildAnonymousResolver } from "../src/anonymous.js";
|
||||
|
||||
runAuthSessionConformance({
|
||||
label: "anonymous",
|
||||
factory: () => buildAnonymousResolver(),
|
||||
seedNull: () => new Request("https://example.test/api"),
|
||||
seedPrincipal: () => ({
|
||||
request: new Request("https://example.test/api", {
|
||||
headers: { "x-test-principal": "1" },
|
||||
}),
|
||||
expected: {
|
||||
id: "anon" as PrincipalId,
|
||||
scopes: [] as readonly Scope[],
|
||||
claims: {},
|
||||
},
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Compose the harness with adapter-specific extra coverage (per-adapter codes,
|
||||
JWK rotation, etc.):
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from "@suaveplan/testing/runner";
|
||||
import { runAuthSessionConformance } from "@suaveplan/api-auth/testing";
|
||||
|
||||
import { buildJwtResolver, JWT_CODES } from "../src/index.js";
|
||||
|
||||
runAuthSessionConformance({
|
||||
label: "jwt-hs256",
|
||||
factory: async () => buildJwtResolver({ jwks: testJwks }),
|
||||
seedNull: () => new Request("https://example.test/api"),
|
||||
seedPrincipal: () => ({
|
||||
request: new Request("https://example.test/api", {
|
||||
headers: { authorization: `Bearer ${testToken}` },
|
||||
}),
|
||||
expected: testPrincipal,
|
||||
}),
|
||||
});
|
||||
|
||||
describe("jwt-hs256 specific", () => {
|
||||
it("emits JWT.EXPIRED on stale token", { timeout: 5000 }, async () => {
|
||||
/* … */
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Kind | Description |
|
||||
|-----------------------------------|-------------|----------------------------------------------------------------------|
|
||||
| `runAuthSessionConformance` | function | Adds a `describe(...)` block; runs three baseline scenarios. |
|
||||
| `AuthSessionConformanceFactory` | interface | `{ factory, label, seedNull, seedPrincipal }`. |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The harness asserts `id` + `scopes` equality on the principal, not deep
|
||||
equality of `claims` — `claims` is intentionally free-form and adapters may
|
||||
add provider-specific fields that callers must not pin in conformance.
|
||||
- `dispose()` is asserted to resolve to `undefined`, not just "not throw" —
|
||||
some legacy resolvers were returning the wrong shape from the second
|
||||
invocation; this assertion caught it.
|
||||
- `expect` and `describe`/`it` come from `@suaveplan/testing/runner` per
|
||||
project rule 11.
|
||||
Reference in New Issue
Block a user