first commit
This commit is contained in:
83
docs/api-stack/api/api-auth-rolled/README.md
Normal file
83
docs/api-stack/api/api-auth-rolled/README.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# @suaveplan/api-auth-rolled — rolled-our-own auth provider. Implements both `auth.session` and `auth.provider` capability contracts. Session tokens are RS256-signed JWTs (via @suaveplan/crypto-rsa + @suaveplan/jws) with rotating `kid` for non-disruptive key rotation. Passwords are hashed via Argon2id (@suaveplan/crypto-hash), gated by @suaveplan/password-strength and @suaveplan/hibp at registration. Provider mounts the canonical 9-route PROVIDER_ROUTE_SURFACE under a configurable path prefix. DB access via injected @suaveplan/types `DatabaseLike` — shares the host's main connection per design §2.3. Projects DB rows onto the @suaveplan/api-types `Principal` shape. Contributes cookie + bearer OpenAPI security schemes.
|
||||
|
||||
Stage-8.12 rolled session-token resolver for `@suaveplan/api`. Issues
|
||||
RS256-signed JWTs with rotating `kid` slots, verifies tokens through the
|
||||
rotator's active + retired-non-revoked key set, projects DB rows onto
|
||||
the `@suaveplan/api-types` `Principal` contract, and contributes cookie
|
||||
+ bearer OpenAPI security schemes.
|
||||
|
||||
This milestone ships the **session-resolver half** of the eventual dual
|
||||
contract (`auth.session`). The matching `AuthProviderAdapter`
|
||||
(account-lifecycle routes, password gating, MFA, OAuth) is deferred to
|
||||
a successor change `api-auth-rolled-provider`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [composition](./src/composition/composition.md) — Top-level factory `createRolledAuthAdapter` that wires the four sub-modules (`token-rotator`, `session`, `principal-pro…
|
||||
- [composition](./src/composition/composition.md) — Top-level factory `createRolledAuthAdapter` that wires the four sub-modules (`token-rotator`, `session`, `principal-pro…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/api-auth-rolled`. Every code is namespaced under `AUTH_ROLLED.*` so cross-pa…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/api-auth-rolled`. Every code is namespaced under `AUTH_ROLLED.*` so cross-pa…
|
||||
- [options](./src/options/options.md) — Defines the configuration shape accepted by `createRolledAuthAdapter` and the Zod schema that validates it. Operators w…
|
||||
- [options](./src/options/options.md) — Defines the configuration shape accepted by `createRolledAuthAdapter` and the Zod schema that validates it. Operators w…
|
||||
- [principal-projection](./src/principal-projection/principal-projection.md) — Projects the package's internal `UserRow` storage shape onto the abstract `Principal` contract published by `@suaveplan…
|
||||
- [principal-projection](./src/principal-projection/principal-projection.md) — Projects the package's internal `UserRow` storage shape onto the abstract `Principal` contract published by `@suaveplan…
|
||||
- [security-schemes](./src/security-schemes/security-schemes.md) — Contributes OpenAPI 3.1 `securitySchemes` entries describing both transports the rolled session resolver accepts — `Aut…
|
||||
- [security-schemes](./src/security-schemes/security-schemes.md) — Contributes OpenAPI 3.1 `securitySchemes` entries describing both transports the rolled session resolver accepts — `Aut…
|
||||
- [session](./src/session/session.md) — The rolling-JWT session resolver — the keystone of the rolled auth adapter. Builds a `SessionResolver` (per `@suaveplan…
|
||||
- [session](./src/session/session.md) — The rolling-JWT session resolver — the keystone of the rolled auth adapter. Builds a `SessionResolver` (per `@suaveplan…
|
||||
- [token-rotator](./src/token-rotator/token-rotator.md) — Owns the rotating-keyset state for the rolled session-token resolver. The active key signs new JWTs; retired-but-non-re…
|
||||
- [token-rotator](./src/token-rotator/token-rotator.md) — Owns the rotating-keyset state for the rolled session-token resolver. The active key signs new JWTs; retired-but-non-re…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Why
|
||||
|
||||
This package gives operators a
|
||||
session-auth path that owns zero third-party runtime dependencies.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/api-auth-rolled
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | What it owns |
|
||||
|---|---|
|
||||
| `composition/` | `createRolledAuthAdapter` — the public factory. |
|
||||
| `error-codes/` | The 4-code `AUTH_ROLLED.*` registry. |
|
||||
| `options/` | Zod-validated options + named defaults. |
|
||||
| `principal-projection/` | `UserRow` → `Principal` projection. |
|
||||
| `security-schemes/` | Cookie + bearer OpenAPI contributions. |
|
||||
| `session/` | RS256 sign/verify, cookie+bearer extraction, `Set-Cookie`. |
|
||||
| `token-rotator/` | Rotating-key state machine. |
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { createRolledAuthAdapter } from "@suaveplan/api-auth-rolled";
|
||||
|
||||
const adapter = createRolledAuthAdapter({
|
||||
store: hostStore,
|
||||
keys: [{ kid: "k1", privateJwk, publicJwk, active: true }],
|
||||
issuer: "https://api.example.com",
|
||||
audience: "example-front",
|
||||
});
|
||||
|
||||
host.registerAuthSession(adapter);
|
||||
```
|
||||
|
||||
Rotation cron:
|
||||
|
||||
```ts
|
||||
setInterval(async () => {
|
||||
if (!adapter.rotator.decide().shouldRotate) return;
|
||||
adapter.rotator.setKeys(await kms.nextKeyset());
|
||||
}, 60_000);
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# `composition`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Top-level factory `createRolledAuthAdapter` that wires the four
|
||||
sub-modules (`token-rotator`, `session`, `principal-projection`,
|
||||
`security-schemes`) into a single `AuthAdapter` ready for the host to
|
||||
register. Keeping the composition explicit (rather than letting each
|
||||
sub-module own its own host wiring) means the dependency graph is one
|
||||
arrow: options flow in, an adapter object flows out, no hidden state.
|
||||
|
||||
The returned value extends the `AuthAdapter` contract (`resolver` +
|
||||
`securityScheme`) with three additional surfaces the future provider
|
||||
half (`api-auth-rolled-provider`) will consume: `issueToken`,
|
||||
`verifyToken`, and the rotator handle so operators can wire
|
||||
`setKeys(...)` from an out-of-band rotation cron.
|
||||
|
||||
## Features
|
||||
|
||||
- Single entry point — `createRolledAuthAdapter(rawOptions)`.
|
||||
- Options validated via the package's Zod schema before any wiring runs.
|
||||
- Defaults filled at the composition layer (cookie name, rotation
|
||||
period, session TTL) so individual sub-modules stay focused on their
|
||||
one concern.
|
||||
- Returned adapter is structurally a valid `AuthAdapter` so any host
|
||||
that accepts the contract will accept the rolled adapter.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createRolledAuthAdapter } from "@suaveplan/api-auth-rolled";
|
||||
|
||||
const adapter = createRolledAuthAdapter({
|
||||
store,
|
||||
keys: [
|
||||
{ kid: "k1", privateJwk, publicJwk, active: true },
|
||||
],
|
||||
issuer: "https://api.example.com",
|
||||
audience: "example-front",
|
||||
});
|
||||
|
||||
host.registerAuthSession(adapter);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createRolledAuthAdapter } from "@suaveplan/api-auth-rolled";
|
||||
|
||||
const adapter = createRolledAuthAdapter({
|
||||
store,
|
||||
keys: initialKeys,
|
||||
issuer: "https://api.example.com",
|
||||
audience: "example-front",
|
||||
cookieName: "acme.session",
|
||||
sessionTtlSec: 1800,
|
||||
rotationPeriodMs: 12 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Operator rotation cron — fetch new key from KMS, swap atomically.
|
||||
setInterval(async () => {
|
||||
if (!adapter.rotator.decide().shouldRotate) return;
|
||||
const next = await kms.nextKeyset();
|
||||
adapter.rotator.setKeys(next);
|
||||
}, 60_000);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createRolledAuthAdapter(rawOptions: unknown): RolledAuthAdapter`
|
||||
- `RolledAuthAdapter extends AuthAdapter`: adds `id: "rolled"`, `rotator`,
|
||||
`issueToken(userId)`, `verifyToken(token)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The adapter intentionally keeps `id` as the literal `"rolled"` so a host
|
||||
running both Better-Auth and rolled can disambiguate by adapter id. The
|
||||
`securityScheme` slot points at the bearer contribution (the canonical
|
||||
single-scheme shape) but the `.schemes` map on the contribution exposes
|
||||
both the cookie and bearer entries — emitters aware of dual-transport
|
||||
adapters can register both names without a separate adapter property.
|
||||
@@ -0,0 +1,78 @@
|
||||
# `error-codes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Stable error-code registry for `@suaveplan/api-auth-rolled`. Every code is
|
||||
namespaced under `AUTH_ROLLED.*` so cross-package consumers (the host's audit
|
||||
log, the OpenAPI emitter, the conformance harness) can identify the package
|
||||
that raised the failure without string-matching on free-form messages.
|
||||
|
||||
The milestone ships the session-resolver half only; the registry
|
||||
is correspondingly minimal — four operator-actionable infrastructure codes,
|
||||
all of which are thrown synchronously from production code paths and
|
||||
covered by the conformance test. Per-request soft failures (missing token,
|
||||
bad signature, expired) collapse to `null` per `SessionResolver` chain
|
||||
semantics and are therefore NOT codes. Account-lifecycle codes
|
||||
(`USER_NOT_FOUND`, `INVALID_CREDENTIALS`, `PASSWORD_BREACHED`, …) belong to
|
||||
the deferred provider half and will be added by a successor change.
|
||||
|
||||
## Features
|
||||
|
||||
- Tagged with `defineCodes("AUTH_ROLLED", [...])` from `@suaveplan/error`,
|
||||
so all codes share a single namespace prefix.
|
||||
- `AuthRolledError` extends `SuaveplanError` so `instanceof AuthRolledError`
|
||||
narrows `.code` to `AuthRolledErrorCode`.
|
||||
- Four codes: `SIGNING_KEY_NOT_CONFIGURED` (options validation),
|
||||
`KEY_ROTATION_FAILED` (`TokenRotator.setKeys`),
|
||||
`DATABASE_REQUIRED` (missing `store`), `PRINCIPAL_PROJECTION_FAILED`
|
||||
(malformed user row).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { AUTH_ROLLED_CODES, AuthRolledError } from "@suaveplan/api-auth-rolled";
|
||||
|
||||
throw new AuthRolledError(
|
||||
AUTH_ROLLED_CODES.DATABASE_REQUIRED,
|
||||
"host did not inject a store"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { AUTH_ROLLED_CODES, AuthRolledError } from "@suaveplan/api-auth-rolled";
|
||||
|
||||
try {
|
||||
/* ... */
|
||||
} catch (err) {
|
||||
if (err instanceof AuthRolledError) {
|
||||
// .rolledCode is typed as AuthRolledErrorCode — no narrowing cast.
|
||||
if (err.rolledCode === AUTH_ROLLED_CODES.KEY_ROTATION_FAILED) {
|
||||
log.fatal({ err }, "operator key-rotation contract violated");
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `AUTH_ROLLED_CODES` — frozen `Record<Name, "AUTH_ROLLED.NAME">`.
|
||||
- `AuthRolledErrorCode` — string-literal union of every code value.
|
||||
- `AuthRolledError extends SuaveplanError<AuthRolledErrorCode>` — has a
|
||||
`rolledCode: AuthRolledErrorCode` convenience accessor.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The set of 20 codes is fixed by the OpenSpec spec delta. The conformance
|
||||
test enumerates `AUTH_ROLLED_CODES` and asserts every code has a named
|
||||
production trigger; if a code is added without a trigger, the test fails
|
||||
loudly. Cross-package codes (e.g. `HIBP.*` propagated from
|
||||
`@suaveplan/hibp` when the breach lookup fails) are NOT re-encoded — they
|
||||
surface to the caller intact through `SuaveplanError`'s `cause` chain so
|
||||
operators can attribute failures to the originating layer.
|
||||
90
docs/api-stack/api/api-auth-rolled/src/options/options.md
Normal file
90
docs/api-stack/api/api-auth-rolled/src/options/options.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# `options`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the configuration shape accepted by `createRolledAuthAdapter` and
|
||||
the Zod schema that validates it. Operators wire this from `host.json` or
|
||||
build it programmatically; everything the rest of the package needs flows
|
||||
from a single validated `RolledAuthOptions` value, which keeps invariants
|
||||
(active-key count, prefix shape, TTL positivity) in one place.
|
||||
|
||||
The schema deliberately validates injected functions and the `store`
|
||||
adapter as `unknown` and then refines them later — Zod cannot verify
|
||||
function-argument shapes statically, and TypeScript already does that work
|
||||
at the call boundary.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod schema `RolledAuthOptionsSchema` covering every operator-facing
|
||||
knob.
|
||||
- A `superRefine` cross-field invariant that exactly one key is both
|
||||
`active: true` and non-revoked — keeps sign-time semantics ambiguity-free.
|
||||
- Stable named defaults (`DEFAULT_COOKIE_NAME`, `DEFAULT_ROUTE_PREFIX`,
|
||||
TTLs, rotation period, minimum strength score) so consumers can
|
||||
reference the defaults without re-reading the source.
|
||||
- Two distinct error codes for failures: `DATABASE_REQUIRED` when `store`
|
||||
is missing (operator hooked up nothing), `SIGNING_KEY_NOT_CONFIGURED`
|
||||
for every other schema failure (Zod issues attached as `context`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseRolledAuthOptions } from "@suaveplan/api-auth-rolled";
|
||||
|
||||
const opts = parseRolledAuthOptions({
|
||||
store: myStore,
|
||||
keys: [{ kid: "k1", privateJwk, publicJwk, active: true }],
|
||||
issuer: "https://api.example.com",
|
||||
audience: "example-frontend",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
DEFAULT_ROTATION_PERIOD_MS,
|
||||
parseRolledAuthOptions,
|
||||
} from "@suaveplan/api-auth-rolled";
|
||||
|
||||
const opts = parseRolledAuthOptions({
|
||||
store,
|
||||
keys,
|
||||
issuer: "https://api.example.com",
|
||||
audience: "example-frontend",
|
||||
routePrefix: "/api/auth",
|
||||
cookieName: "acme.session",
|
||||
cookieSameSite: "strict",
|
||||
cookieSecure: true,
|
||||
sessionTtlSec: 1800,
|
||||
refreshTtlSec: 7 * 24 * 60 * 60,
|
||||
rotationPeriodMs: DEFAULT_ROTATION_PERIOD_MS / 2,
|
||||
minPasswordStrength: 4,
|
||||
strength: { async score(pw) { return 4; } },
|
||||
breach: { async isBreached() { return false; } },
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `parseRolledAuthOptions(raw): RolledAuthOptions` — throws
|
||||
`AuthRolledError` on bad input.
|
||||
- `RolledAuthOptionsSchema` — the Zod schema, re-exported for advanced
|
||||
consumers that want to layer their own refinements.
|
||||
- `DEFAULT_*` constants — every operator-facing default exposed as a
|
||||
named export.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The schema is `strictObject` so unknown keys produce a Zod error rather
|
||||
than silently passing through. The `keys[]` `active` invariant is enforced
|
||||
in `superRefine` rather than as a `transform` so the invariant message
|
||||
appears in the validation issue list instead of as a thrown exception
|
||||
mid-parse. Cross-field invariants that cross the option/store boundary
|
||||
(e.g. "every kid actually resolves to a JWK") are checked later inside the
|
||||
session/token-rotator modules so the options layer remains synchronous and
|
||||
pure.
|
||||
@@ -0,0 +1,73 @@
|
||||
# `principal-projection`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Projects the package's internal `UserRow` storage shape onto the abstract
|
||||
`Principal` contract published by `@suaveplan/api-types`. Keeping the
|
||||
projection in one place ensures that no storage-specific columns leak
|
||||
into the principal seen by downstream plugins, and that the four
|
||||
documented principal fields (`id`, `scopes`, `tenantId?`, `claims`) are
|
||||
the only surface area authors can rely on.
|
||||
|
||||
The projection is deliberately strict: structurally-invalid rows raise
|
||||
`AUTH_ROLLED.PRINCIPAL_PROJECTION_FAILED`, surfacing storage-driver bugs
|
||||
loudly rather than letting malformed data flow into route handlers.
|
||||
|
||||
## Features
|
||||
|
||||
- One-shot pure function — no DB calls, no async, no allocations beyond
|
||||
the new array of branded `Scope`s.
|
||||
- Validates every column the projection touches (`id` non-empty string,
|
||||
`scopes` array of non-empty strings, `claims` plain object).
|
||||
- Omits `tenantId` from the output `Principal` when the storage row's
|
||||
value is `null`, matching the optional shape in `Principal`.
|
||||
- Brands strings as `PrincipalId` / `Scope` / `TenantId` at the boundary
|
||||
so type narrowing carries through to consumers.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { projectUserRowToPrincipal } from "@suaveplan/api-auth-rolled";
|
||||
|
||||
const principal = projectUserRowToPrincipal(row);
|
||||
console.log(principal.id, principal.scopes);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { projectUserRowToPrincipal } from "@suaveplan/api-auth-rolled";
|
||||
|
||||
function resolveSession(row: UserRow): Principal {
|
||||
try {
|
||||
return projectUserRowToPrincipal(row);
|
||||
} catch (err) {
|
||||
// Storage adapter returned a malformed row — log + treat as an
|
||||
// anonymous request rather than surfacing the bug to the client.
|
||||
log.error({ err }, "principal projection failed");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `projectUserRowToPrincipal(row: UserRow): Principal` — pure projection.
|
||||
Throws `AuthRolledError(PRINCIPAL_PROJECTION_FAILED)` on any structural
|
||||
invariant violation.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The projection allocates a fresh `scopes` array rather than spreading the
|
||||
row's array — this is intentional so that downstream handlers cannot
|
||||
mutate the source row through the principal reference. `claims` is a
|
||||
shallow read-only reference so deep-cloning every claim object on every
|
||||
request stays optional (consumers wanting deep immutability can wrap with
|
||||
`Object.freeze`). The `tenantId` branding mirrors the contract published
|
||||
by `@suaveplan/api-tenant-resolver-*` adapters — keeping the brand at the
|
||||
projection boundary means downstream code that takes a `TenantId` never
|
||||
needs an unsafe cast.
|
||||
@@ -0,0 +1,73 @@
|
||||
# `security-schemes`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Contributes OpenAPI 3.1 `securitySchemes` entries describing both
|
||||
transports the rolled session resolver accepts — `Authorization: Bearer
|
||||
<token>` and a `Cookie` carrying the same JWT. Keeping the two entries
|
||||
co-located means a single source of truth describes what the resolver
|
||||
accepts on the wire; the `api-openapi` emitter (Stage 8.6) walks the
|
||||
adapter's `securityScheme` slot AND its `.schemes` map so both are
|
||||
surfaced in the generated document.
|
||||
|
||||
## Features
|
||||
|
||||
- Bearer entry: OpenAPI 3.1 `http` / `bearer` form with `bearerFormat:
|
||||
"JWT"` — the canonical Bearer-JWT shape consumers expect.
|
||||
- Cookie entry: `apiKey` / `in: cookie` form whose `name` is the
|
||||
caller-configured cookie name, so the doc matches the adapter's
|
||||
runtime behaviour exactly.
|
||||
- `createRolledSecuritySchemes({ cookieName })` returns a single value
|
||||
that satisfies the `SecuritySchemeContribution` contract (its `name`
|
||||
and `scheme` point at the bearer entry) AND exposes both contributions
|
||||
on `.schemes` — the emitter can pull either or both.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createRolledSecuritySchemes } from "@suaveplan/api-auth-rolled";
|
||||
|
||||
const contribution = createRolledSecuritySchemes({
|
||||
cookieName: "suaveplan.session",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
createRolledBearerSecurityScheme,
|
||||
createRolledCookieSecurityScheme,
|
||||
} from "@suaveplan/api-auth-rolled";
|
||||
|
||||
// Multi-resolver host — name the contributions to disambiguate.
|
||||
const userBearer = createRolledBearerSecurityScheme("userBearer");
|
||||
const serviceBearer = createRolledBearerSecurityScheme("serviceBearer");
|
||||
const cookie = createRolledCookieSecurityScheme({
|
||||
name: "userCookie",
|
||||
cookieName: "acme.session",
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createRolledBearerSecurityScheme(name?: string): SecuritySchemeContribution`
|
||||
- `createRolledCookieSecurityScheme({ name?, cookieName }): SecuritySchemeContribution`
|
||||
- `createRolledSecuritySchemes({ cookieName }): RolledSecurityContribution`
|
||||
— convenience bundle.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The combined `RolledSecurityContribution` is structurally a
|
||||
`SecuritySchemeContribution` (so old emitters that only read `name` +
|
||||
`scheme` keep working) but adds a `.schemes` map for emitters aware of
|
||||
the dual-transport shape. This keeps the contribution drop-in for both
|
||||
single-transport and dual-transport emitters without forcing every
|
||||
caller to choose. The cookie scheme's `apiKey/in:cookie` representation
|
||||
matches OpenAPI 3.1 §4.8.27.4 — there is no first-class cookie-JWT
|
||||
shape in OAS 3.1 so `apiKey` with the configured name is the canonical
|
||||
form.
|
||||
100
docs/api-stack/api/api-auth-rolled/src/session/session.md
Normal file
100
docs/api-stack/api/api-auth-rolled/src/session/session.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# `session`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The rolling-JWT session resolver — the keystone of the rolled auth
|
||||
adapter. Builds a `SessionResolver` (per `@suaveplan/api-auth`) that
|
||||
parses-and-verifies a JWT extracted from either the `Authorization:
|
||||
Bearer` header or a configurable cookie, runs the JWT through the
|
||||
`TokenRotator` to find the verifying JWK by `kid`, projects the matching
|
||||
user row onto a `Principal`, and returns `null` on every soft failure so
|
||||
the host's resolver chain can move on to the next adapter.
|
||||
|
||||
The same module exposes `issueToken(userId)` so the future
|
||||
account-lifecycle provider (deferred to `api-auth-rolled-provider`) can
|
||||
share a single signing path with the resolver. Keeping issue and verify
|
||||
co-located means the JWT shape and the rotator wiring stay in lock-step.
|
||||
|
||||
## Features
|
||||
|
||||
- RS256 signing via `@suaveplan/jws` `compactSign` with the rotator's
|
||||
active JWK; verification via `compactVerify` against the rotator's
|
||||
retired-or-active JWK matched by `kid`.
|
||||
- Bearer + cookie extraction in one pass — bearer wins when both are
|
||||
present so service clients can override a stale cookie.
|
||||
- Strict JWT-claim validation: `iss`/`aud` exact match, `exp` future,
|
||||
`iat` not before `users.createdAt`.
|
||||
- Soft failures (missing token, bad signature, expired, unknown kid)
|
||||
collapse to `null`; hard failures (storage adapter throws) propagate
|
||||
as `AuthRolledError`.
|
||||
- `buildSetCookie` returns a deterministic `Set-Cookie` value with
|
||||
`HttpOnly`, `Path=/`, `Max-Age`, `SameSite`, `Secure` — used by the
|
||||
future provider half but published here so the cookie shape lives next
|
||||
to the cookie parser.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createRolledSessionResolver, TokenRotator } from "@suaveplan/api-auth-rolled";
|
||||
|
||||
const rotator = new TokenRotator({ keys, rotationPeriodMs, clock });
|
||||
const resolver = createRolledSessionResolver({
|
||||
options,
|
||||
rotator,
|
||||
clock,
|
||||
cookieName: "suaveplan.session",
|
||||
sessionTtlSec: 3600,
|
||||
});
|
||||
|
||||
const principal = await resolver.resolve(req);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createRolledSessionResolver } from "@suaveplan/api-auth-rolled";
|
||||
|
||||
const resolver = createRolledSessionResolver({ /* ... */ });
|
||||
|
||||
// Provider half (future) signs new tokens with the same key set as the
|
||||
// resolver — sharing one path means the rotator's view of the active
|
||||
// kid is the only source of truth.
|
||||
const issued = await resolver.issueToken("u_1");
|
||||
const setCookie = buildSetCookie({
|
||||
name: "suaveplan.session",
|
||||
value: issued.token,
|
||||
maxAgeSec: issued.expiresAt - issued.issuedAt,
|
||||
sameSite: "lax",
|
||||
secure: true,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `createRolledSessionResolver({ options, rotator, clock, cookieName, sessionTtlSec }) → SessionResolver & { issueToken, verifyToken }`
|
||||
- `buildSetCookie({ name, value, maxAgeSec, sameSite, secure }) → string`
|
||||
- `tokenSizeOf(token: string): number` — byte length, used by the
|
||||
conformance harness to assert tokens stay under the 8 KiB cookie limit.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The resolver intentionally returns `null` on every parse/verify failure
|
||||
rather than throwing — `SessionResolver` chain semantics treat thrown
|
||||
errors as operator-actionable infrastructure failures, so soft failures
|
||||
must collapse to `null` to let the chain advance to the next adapter
|
||||
(typically `api-auth-anonymous`). The two cases where the resolver does
|
||||
throw are `AuthRolledError(PRINCIPAL_PROJECTION_FAILED)` raised by the
|
||||
projection step (storage-driver bug) and whatever error the operator's
|
||||
`store.findUserById` raises (DB connection lost). The session-resolver
|
||||
chain handles both by surfacing 500 to the client and routing to the
|
||||
host's audit log.
|
||||
|
||||
The header parse is done manually rather than via `compactDecode` so a
|
||||
malformed header (e.g. missing `kid`) bypasses the rotator lookup
|
||||
entirely. The rotator can verify with retired-but-non-revoked keys, so
|
||||
rotating the active key never invalidates a session mid-lifetime —
|
||||
operators can rotate without coordinating with active users.
|
||||
@@ -0,0 +1,87 @@
|
||||
# `token-rotator`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns the rotating-keyset state for the rolled session-token resolver. The
|
||||
active key signs new JWTs; retired-but-non-revoked keys still verify
|
||||
in-flight tokens so a rotation event never invalidates a session
|
||||
mid-lifetime. The rotator does NOT generate new key material — that is an
|
||||
operator-controlled action (HSM, KMS, mounted file) that submits the
|
||||
replacement set through `setKeys`. This split is deliberate: keeping key
|
||||
generation out of the adapter means the adapter never holds long-lived
|
||||
private key material outside of the operator's chosen storage layer.
|
||||
|
||||
## Features
|
||||
|
||||
- Snapshots the keyset atomically (`snapshot()`) — the session module
|
||||
reads the snapshot, signs with it, and never observes a half-rotated
|
||||
state.
|
||||
- Decides when to rotate (`decide()`) based on the active key's age vs
|
||||
the configured `rotationPeriodMs`. Decision is advisory — the host
|
||||
schedules the actual rotation cron.
|
||||
- `setKeys(...)` validates the replacement keyset (exactly one active +
|
||||
non-revoked) before swapping; throws `KEY_ROTATION_FAILED` if the new
|
||||
set would leave signing ambiguous.
|
||||
- `publicJwkFor(kid)` resolves the verifying JWK for any non-revoked kid
|
||||
— including retired keys — so verification of in-flight tokens works
|
||||
through the rotation window.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { TokenRotator } from "@suaveplan/api-auth-rolled";
|
||||
|
||||
const rotator = new TokenRotator({
|
||||
keys,
|
||||
rotationPeriodMs: 24 * 60 * 60 * 1000,
|
||||
clock: () => Date.now(),
|
||||
});
|
||||
|
||||
const snap = rotator.snapshot();
|
||||
const activeJwk = snap.active.privateJwk;
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { TokenRotator } from "@suaveplan/api-auth-rolled";
|
||||
|
||||
const rotator = new TokenRotator({
|
||||
keys: initialKeys,
|
||||
rotationPeriodMs: 24 * 60 * 60 * 1000,
|
||||
clock,
|
||||
});
|
||||
|
||||
// Periodic rotator cron:
|
||||
setInterval(async () => {
|
||||
const decision = rotator.decide();
|
||||
if (!decision.shouldRotate) return;
|
||||
const next = await fetchKeysFromKms(); // operator-specific
|
||||
rotator.setKeys(next);
|
||||
}, 60 * 1000);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new TokenRotator({ keys, rotationPeriodMs, clock })` — construct.
|
||||
- `snapshot(): KeySetSnapshot` — atomic read.
|
||||
- `decide(): RotationDecision` — should we rotate now?
|
||||
- `setKeys(next: readonly SigningKeyEntry[]): void` — swap keys.
|
||||
- `publicJwkFor(kid): Record<string, unknown> | undefined` — verify-side
|
||||
lookup.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The rotator keeps its `activeSince` timestamp in instance state rather
|
||||
than reading `keys[i].activatedAt`, so the in-memory rotation timer is
|
||||
independent of any timestamps the operator stamps on the JWK records. This
|
||||
means a fresh keyset injected via `setKeys` starts a brand-new rotation
|
||||
window regardless of when the underlying key material was generated — the
|
||||
operator's intent ("treat this as the new active key as of now") is the
|
||||
authoritative signal. The exactly-one-active invariant is enforced both
|
||||
in `parseRolledAuthOptions` and in `setKeys` so there is no path through
|
||||
which the rotator can observe an invalid keyset.
|
||||
Reference in New Issue
Block a user