Initial Commit
This commit is contained in:
109
packages/api-stack/api/api-auth/README.md
Normal file
109
packages/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. |
|
||||
|
||||
107
packages/api-stack/api/api-auth/package.json
Normal file
107
packages/api-stack/api/api-auth/package.json
Normal file
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"name": "@suaveplan/api-auth",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Abstract session-resolver contract for the Suaveplan API plugin foundation. 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.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./resolver": {
|
||||
"import": {
|
||||
"types": "./dist/resolver/resolver.d.ts",
|
||||
"default": "./dist/resolver/resolver.js"
|
||||
}
|
||||
},
|
||||
"./chain": {
|
||||
"import": {
|
||||
"types": "./dist/chain/chain.d.ts",
|
||||
"default": "./dist/chain/chain.js"
|
||||
}
|
||||
},
|
||||
"./security-scheme": {
|
||||
"import": {
|
||||
"types": "./dist/security-scheme/security-scheme.d.ts",
|
||||
"default": "./dist/security-scheme/security-scheme.js"
|
||||
}
|
||||
},
|
||||
"./csrf-policy": {
|
||||
"import": {
|
||||
"types": "./dist/csrf-policy/csrf-policy.d.ts",
|
||||
"default": "./dist/csrf-policy/csrf-policy.js"
|
||||
}
|
||||
},
|
||||
"./session-adapter": {
|
||||
"import": {
|
||||
"types": "./dist/session-adapter/session-adapter.d.ts",
|
||||
"default": "./dist/session-adapter/session-adapter.js"
|
||||
}
|
||||
},
|
||||
"./capability-id": {
|
||||
"import": {
|
||||
"types": "./dist/capability-id/capability-id.d.ts",
|
||||
"default": "./dist/capability-id/capability-id.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
},
|
||||
"./testing": {
|
||||
"import": {
|
||||
"types": "./dist/testing/conformance.d.ts",
|
||||
"default": "./dist/testing/conformance.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/api-types": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": ["dist", "src", "README.md"]
|
||||
}
|
||||
@@ -0,0 +1,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.
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { CapabilityId } from "@suaveplan/api-types/capabilities";
|
||||
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
AUTH_SESSION_CAPABILITY_ID,
|
||||
type AuthSessionCapabilityId,
|
||||
} from "./capability-id.js";
|
||||
|
||||
describe("auth.session capability id", () => {
|
||||
it("auth.session is a valid CapabilityId after augmentation", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expectTypeOf<AuthSessionCapabilityId>().toMatchTypeOf<CapabilityId>();
|
||||
expectTypeOf<"auth.session">().toMatchTypeOf<CapabilityId>();
|
||||
});
|
||||
|
||||
it("AUTH_SESSION_CAPABILITY_ID is the literal value", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(AUTH_SESSION_CAPABILITY_ID).toBe("auth.session");
|
||||
});
|
||||
|
||||
it("AUTH_SESSION_CAPABILITY_ID is assignable to CapabilityId", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const id: CapabilityId = AUTH_SESSION_CAPABILITY_ID;
|
||||
expect(id).toBe("auth.session");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { CapabilityId } from "@suaveplan/api-types/capabilities";
|
||||
|
||||
/**
|
||||
* Literal token this package contributes to the global `CapabilityId` union.
|
||||
*
|
||||
* The `CapabilityId` union in `@suaveplan/api-types/capabilities` already
|
||||
* lists `"auth.session"` directly; re-exporting it here as a literal makes the
|
||||
* contribution legible at the consumption site and lets the conformance test
|
||||
* `expectTypeOf<AuthSessionCapabilityId>().toMatchTypeOf<CapabilityId>()` pin
|
||||
* the membership without depending on the order of identifiers inside the
|
||||
* union.
|
||||
*
|
||||
* `AUTH_SESSION_CAPABILITY_ID` exports the runtime literal for places that
|
||||
* declare `requires: [AUTH_SESSION_CAPABILITY_ID] as const`.
|
||||
*/
|
||||
export type AuthSessionCapabilityId = "auth.session";
|
||||
|
||||
export const AUTH_SESSION_CAPABILITY_ID: AuthSessionCapabilityId =
|
||||
"auth.session";
|
||||
|
||||
/**
|
||||
* Compile-time assertion that `"auth.session"` is assignable to
|
||||
* `CapabilityId`. The assignment is the assertion; if `api-types` ever
|
||||
* removed the token, this line would fail to type-check at build time.
|
||||
*/
|
||||
const _assignable: CapabilityId = AUTH_SESSION_CAPABILITY_ID;
|
||||
void _assignable;
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
AUTH_SESSION_CAPABILITY_ID,
|
||||
type AuthSessionCapabilityId,
|
||||
} from "./capability-id.js";
|
||||
89
packages/api-stack/api/api-auth/src/chain/chain.md
Normal file
89
packages/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.
|
||||
255
packages/api-stack/api/api-auth/src/chain/chain.test.ts
Normal file
255
packages/api-stack/api/api-auth/src/chain/chain.test.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import type {
|
||||
Principal,
|
||||
PrincipalId,
|
||||
Scope,
|
||||
} from "@suaveplan/api-types/principal";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import { AUTH_CODES } from "../error-codes/error-codes.js";
|
||||
import type { SessionResolver } from "../resolver/resolver.js";
|
||||
import { SessionResolverChain } from "./chain.js";
|
||||
|
||||
function principalA(): Principal {
|
||||
return {
|
||||
id: "u_a" as PrincipalId,
|
||||
scopes: ["a:read"] as readonly Scope[],
|
||||
claims: {},
|
||||
};
|
||||
}
|
||||
|
||||
function principalB(): Principal {
|
||||
return {
|
||||
id: "u_b" as PrincipalId,
|
||||
scopes: ["b:read"] as readonly Scope[],
|
||||
claims: {},
|
||||
};
|
||||
}
|
||||
|
||||
function nullResolver(): SessionResolver {
|
||||
return {
|
||||
resolve: async () => null,
|
||||
dispose: async () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function staticResolver(p: Principal): SessionResolver {
|
||||
return {
|
||||
resolve: async () => p,
|
||||
dispose: async () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function throwingResolver(message: string): SessionResolver {
|
||||
return {
|
||||
resolve: async () => {
|
||||
throw new Error(message);
|
||||
},
|
||||
dispose: async () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const REQ = new Request("https://example.test");
|
||||
|
||||
describe("SessionResolverChain", () => {
|
||||
it("first non-null wins, later resolvers skipped", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let bCalled = 0;
|
||||
const a = staticResolver(principalA());
|
||||
const b: SessionResolver = {
|
||||
resolve: async () => {
|
||||
bCalled++;
|
||||
return principalB();
|
||||
},
|
||||
dispose: async () => undefined,
|
||||
};
|
||||
const chain = new SessionResolverChain([a, b]);
|
||||
const result = await chain.resolve(REQ);
|
||||
expect(result?.id).toBe(principalA().id);
|
||||
expect(bCalled).toBe(0);
|
||||
});
|
||||
|
||||
it("nulls cascade to the next resolver", { timeout: 5000 }, async () => {
|
||||
const chain = new SessionResolverChain([
|
||||
nullResolver(),
|
||||
staticResolver(principalB()),
|
||||
]);
|
||||
const result = await chain.resolve(REQ);
|
||||
expect(result?.id).toBe(principalB().id);
|
||||
});
|
||||
|
||||
it("all null returns null", { timeout: 5000 }, async () => {
|
||||
const chain = new SessionResolverChain([
|
||||
nullResolver(),
|
||||
nullResolver(),
|
||||
]);
|
||||
const result = await chain.resolve(REQ);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("throw is fail-closed by default", { timeout: 5000 }, async () => {
|
||||
let bCalled = 0;
|
||||
const b: SessionResolver = {
|
||||
resolve: async () => {
|
||||
bCalled++;
|
||||
return principalB();
|
||||
},
|
||||
dispose: async () => undefined,
|
||||
};
|
||||
const chain = new SessionResolverChain([throwingResolver("boom"), b]);
|
||||
await expect(chain.resolve(REQ)).rejects.toMatchObject({
|
||||
code: AUTH_CODES.RESOLVER_THREW,
|
||||
});
|
||||
expect(bCalled).toBe(0);
|
||||
});
|
||||
|
||||
it("throw with continueOnThrow advances to the next resolver", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = new SessionResolverChain(
|
||||
[throwingResolver("boom"), staticResolver(principalB())],
|
||||
{ continueOnThrow: true }
|
||||
);
|
||||
const result = await chain.resolve(REQ);
|
||||
expect(result?.id).toBe(principalB().id);
|
||||
});
|
||||
|
||||
it("empty chain rejected at construction", { timeout: 5000 }, () => {
|
||||
expect(() => new SessionResolverChain([])).toThrowError();
|
||||
try {
|
||||
new SessionResolverChain([]);
|
||||
} catch (err) {
|
||||
expect((err as { code: string }).code).toBe(AUTH_CODES.CHAIN_EMPTY);
|
||||
}
|
||||
});
|
||||
|
||||
it("PRINCIPAL_INVALID_SHAPE thrown for malformed principal returns", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = new SessionResolverChain([
|
||||
{
|
||||
resolve: async () =>
|
||||
({
|
||||
id: 42,
|
||||
scopes: [],
|
||||
claims: {},
|
||||
}) as unknown as Principal,
|
||||
dispose: async () => undefined,
|
||||
},
|
||||
]);
|
||||
await expect(chain.resolve(REQ)).rejects.toMatchObject({
|
||||
code: AUTH_CODES.PRINCIPAL_INVALID_SHAPE,
|
||||
});
|
||||
});
|
||||
|
||||
it("PRINCIPAL_INVALID_SHAPE thrown when scopes is not an array", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = new SessionResolverChain([
|
||||
{
|
||||
resolve: async () =>
|
||||
({
|
||||
id: "u_x",
|
||||
scopes: "wrong",
|
||||
claims: {},
|
||||
}) as unknown as Principal,
|
||||
dispose: async () => undefined,
|
||||
},
|
||||
]);
|
||||
await expect(chain.resolve(REQ)).rejects.toMatchObject({
|
||||
code: AUTH_CODES.PRINCIPAL_INVALID_SHAPE,
|
||||
});
|
||||
});
|
||||
|
||||
it("PRINCIPAL_INVALID_SHAPE thrown when claims missing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = new SessionResolverChain([
|
||||
{
|
||||
resolve: async () =>
|
||||
({
|
||||
id: "u_x",
|
||||
scopes: [],
|
||||
claims: null,
|
||||
}) as unknown as Principal,
|
||||
dispose: async () => undefined,
|
||||
},
|
||||
]);
|
||||
await expect(chain.resolve(REQ)).rejects.toMatchObject({
|
||||
code: AUTH_CODES.PRINCIPAL_INVALID_SHAPE,
|
||||
});
|
||||
});
|
||||
|
||||
it("PRINCIPAL_INVALID_SHAPE thrown when resolver returns non-object", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = new SessionResolverChain([
|
||||
{
|
||||
resolve: async () => "not-an-object" as unknown as Principal,
|
||||
dispose: async () => undefined,
|
||||
},
|
||||
]);
|
||||
await expect(chain.resolve(REQ)).rejects.toMatchObject({
|
||||
code: AUTH_CODES.PRINCIPAL_INVALID_SHAPE,
|
||||
});
|
||||
});
|
||||
|
||||
it("dispose propagates to every resolver", { timeout: 5000 }, async () => {
|
||||
let aDisposed = 0;
|
||||
let bDisposed = 0;
|
||||
const chain = new SessionResolverChain([
|
||||
{
|
||||
resolve: async () => null,
|
||||
dispose: async () => {
|
||||
aDisposed++;
|
||||
},
|
||||
},
|
||||
{
|
||||
resolve: async () => null,
|
||||
dispose: async () => {
|
||||
bDisposed++;
|
||||
},
|
||||
},
|
||||
]);
|
||||
await chain.dispose();
|
||||
expect(aDisposed).toBe(1);
|
||||
expect(bDisposed).toBe(1);
|
||||
});
|
||||
|
||||
it("dispose is idempotent", { timeout: 5000 }, async () => {
|
||||
let calls = 0;
|
||||
const chain = new SessionResolverChain([
|
||||
{
|
||||
resolve: async () => null,
|
||||
dispose: async () => {
|
||||
calls++;
|
||||
},
|
||||
},
|
||||
]);
|
||||
await chain.dispose();
|
||||
await chain.dispose();
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("property — randomly shuffled nulls plus one principal yields that principal", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.array(fc.boolean(), { minLength: 0, maxLength: 10 }),
|
||||
fc.nat(10),
|
||||
async (nullPositions, insertAt) => {
|
||||
const resolvers: SessionResolver[] = nullPositions.map(() =>
|
||||
nullResolver()
|
||||
);
|
||||
const slot = Math.min(insertAt, resolvers.length);
|
||||
resolvers.splice(slot, 0, staticResolver(principalA()));
|
||||
const chain = new SessionResolverChain(resolvers);
|
||||
const result = await chain.resolve(REQ);
|
||||
expect(result?.id).toBe(principalA().id);
|
||||
}
|
||||
),
|
||||
{ numRuns: 25 }
|
||||
);
|
||||
});
|
||||
});
|
||||
115
packages/api-stack/api/api-auth/src/chain/chain.ts
Normal file
115
packages/api-stack/api/api-auth/src/chain/chain.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { Principal } from "@suaveplan/api-types/principal";
|
||||
import { AUTH_CODES, AuthError } from "../error-codes/error-codes.js";
|
||||
import type { SessionResolver } from "../resolver/resolver.js";
|
||||
|
||||
function isPrincipalLike(value: unknown): value is Principal {
|
||||
if (value === null || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record["id"] !== "string") {
|
||||
return false;
|
||||
}
|
||||
if (!Array.isArray(record["scopes"])) {
|
||||
return false;
|
||||
}
|
||||
const claims = record["claims"];
|
||||
if (claims === null || typeof claims !== "object") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for {@link SessionResolverChain}.
|
||||
*/
|
||||
export interface SessionResolverChainOptions {
|
||||
/**
|
||||
* When `false` (the default), a resolver throwing surfaces
|
||||
* `AUTH.RESOLVER_THREW` AND short-circuits the chain — no later resolver
|
||||
* is tried. This is the fail-closed contract documented in the spec.
|
||||
*
|
||||
* When `true`, the chain catches the error and proceeds to the next
|
||||
* resolver as if `null` had been returned. This is an explicit escape
|
||||
* hatch — `api-host` does NOT set it; concrete adapters that wrap an
|
||||
* intentionally flaky upstream (e.g. SaaS auth provider) MAY enable it.
|
||||
*/
|
||||
readonly continueOnThrow?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered, first-match-wins chain of `SessionResolver`s.
|
||||
*
|
||||
* Runtime semantics (design doc §2.3):
|
||||
*
|
||||
* - Each resolver is invoked in array order.
|
||||
* - The first resolver to return a non-null `Principal` wins; later resolvers
|
||||
* are NOT called.
|
||||
* - If every resolver returns `null`, the chain returns `null`.
|
||||
* - If a resolver throws AND `continueOnThrow` is `false` (default), the
|
||||
* chain throws `AUTH.RESOLVER_THREW` with the original error chained as
|
||||
* `cause` — fail closed, do not try later resolvers.
|
||||
* - An empty chain is rejected at construction with `AUTH.CHAIN_EMPTY`.
|
||||
*
|
||||
* Disposal: `dispose()` calls every wrapped resolver's `dispose()` in array
|
||||
* order. Idempotent — calling twice is safe.
|
||||
*/
|
||||
export class SessionResolverChain implements SessionResolver {
|
||||
private readonly resolvers: readonly SessionResolver[];
|
||||
private readonly continueOnThrow: boolean;
|
||||
private disposed = false;
|
||||
|
||||
public constructor(
|
||||
resolvers: readonly SessionResolver[],
|
||||
options: SessionResolverChainOptions = {}
|
||||
) {
|
||||
if (resolvers.length === 0) {
|
||||
throw new AuthError(
|
||||
AUTH_CODES.CHAIN_EMPTY,
|
||||
"SessionResolverChain requires at least one resolver"
|
||||
);
|
||||
}
|
||||
this.resolvers = resolvers;
|
||||
this.continueOnThrow = options.continueOnThrow ?? false;
|
||||
}
|
||||
|
||||
public async resolve(req: Request): Promise<Principal | null> {
|
||||
for (let index = 0; index < this.resolvers.length; index++) {
|
||||
const resolver = this.resolvers[index] as SessionResolver;
|
||||
let result: Principal | null;
|
||||
try {
|
||||
result = await resolver.resolve(req);
|
||||
} catch (cause) {
|
||||
if (this.continueOnThrow) {
|
||||
continue;
|
||||
}
|
||||
throw new AuthError(
|
||||
AUTH_CODES.RESOLVER_THREW,
|
||||
`resolver at index ${index} threw`,
|
||||
{ cause, context: { resolverIndex: index } }
|
||||
);
|
||||
}
|
||||
if (result !== null) {
|
||||
if (!isPrincipalLike(result)) {
|
||||
throw new AuthError(
|
||||
AUTH_CODES.PRINCIPAL_INVALID_SHAPE,
|
||||
`resolver at index ${index} returned an invalid Principal shape`,
|
||||
{ context: { resolverIndex: index } }
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async dispose(): Promise<void> {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
this.disposed = true;
|
||||
for (const resolver of this.resolvers) {
|
||||
await resolver.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
4
packages/api-stack/api/api-auth/src/chain/index.ts
Normal file
4
packages/api-stack/api/api-auth/src/chain/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
SessionResolverChain,
|
||||
type SessionResolverChainOptions,
|
||||
} from "./chain.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { CsrfPolicy } from "./csrf-policy.js";
|
||||
|
||||
describe("CsrfPolicy.requiresCsrf", () => {
|
||||
it("cookie schemes default to CSRF ON", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
CsrfPolicy.requiresCsrf({
|
||||
type: "apiKey",
|
||||
in: "cookie",
|
||||
name: "sp_session",
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("apiKey-in-header defaults to CSRF OFF", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
CsrfPolicy.requiresCsrf({
|
||||
type: "apiKey",
|
||||
in: "header",
|
||||
name: "x-api-key",
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("apiKey-in-query defaults to CSRF OFF", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
CsrfPolicy.requiresCsrf({
|
||||
type: "apiKey",
|
||||
in: "query",
|
||||
name: "api_key",
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("bearer schemes default to CSRF OFF", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
CsrfPolicy.requiresCsrf({ type: "http", scheme: "bearer" })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("oauth2 defaults to CSRF OFF", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
CsrfPolicy.requiresCsrf({
|
||||
type: "oauth2",
|
||||
flows: { implicit: {} },
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("openIdConnect defaults to CSRF OFF", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
CsrfPolicy.requiresCsrf({
|
||||
type: "openIdConnect",
|
||||
openIdConnectUrl:
|
||||
"https://idp.example/.well-known/openid-configuration",
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("CsrfPolicy is frozen", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(CsrfPolicy)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { OpenApiSecurityScheme } from "../security-scheme/security-scheme.js";
|
||||
|
||||
/**
|
||||
* Default CSRF policy keyed off an `OpenApiSecurityScheme`.
|
||||
*
|
||||
* Per design doc §6: "Cookie-based auth adapters trigger CSRF verification."
|
||||
* The default is ON for any scheme that ambient-authority sends with the
|
||||
* request (cookies); OFF for any scheme that requires the caller to actively
|
||||
* attach the credential to the request (bearer tokens, OAuth2 access tokens,
|
||||
* OIDC).
|
||||
*
|
||||
* The host applies per-route overrides on top of this default. This module
|
||||
* exposes only the policy default; the host owns the override path.
|
||||
*/
|
||||
export const CsrfPolicy = Object.freeze({
|
||||
/**
|
||||
* Returns `true` when requests authenticated via the supplied scheme MUST
|
||||
* carry a CSRF token (double-submit, header-pin, or origin check — the
|
||||
* host picks the mechanism). Returns `false` when CSRF verification is
|
||||
* not required by default.
|
||||
*/
|
||||
requiresCsrf(scheme: OpenApiSecurityScheme): boolean {
|
||||
if (scheme.type === "apiKey" && scheme.in === "cookie") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
1
packages/api-stack/api/api-auth/src/csrf-policy/index.ts
Normal file
1
packages/api-stack/api/api-auth/src/csrf-policy/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CsrfPolicy } from "./csrf-policy.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { SessionResolverChain } from "../chain/chain.js";
|
||||
import { validateSecurityScheme } from "../security-scheme/security-scheme.js";
|
||||
import { validateAuthAdapter } from "../session-adapter/session-adapter.js";
|
||||
import { AUTH_CODES } from "./error-codes.js";
|
||||
|
||||
/**
|
||||
* Conformance — every AUTH_CODES entry MUST be reachable from a production
|
||||
* throw site somewhere in this package. The test enumerates the registry and
|
||||
* asserts a throw for each code.
|
||||
*/
|
||||
describe("AUTH_CODES — every code reachable from production path", () => {
|
||||
it("CHAIN_EMPTY thrown by SessionResolverChain construction", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => new SessionResolverChain([])).toThrowError();
|
||||
try {
|
||||
new SessionResolverChain([]);
|
||||
} catch (err) {
|
||||
expect((err as { code: string }).code).toBe(AUTH_CODES.CHAIN_EMPTY);
|
||||
}
|
||||
});
|
||||
|
||||
it("RESOLVER_THREW thrown by chain when underlying resolver throws", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = new SessionResolverChain([
|
||||
{
|
||||
resolve: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
dispose: async () => undefined,
|
||||
},
|
||||
]);
|
||||
await expect(
|
||||
chain.resolve(new Request("https://example.test"))
|
||||
).rejects.toMatchObject({ code: AUTH_CODES.RESOLVER_THREW });
|
||||
});
|
||||
|
||||
it("SECURITY_SCHEME_MISSING thrown when adapter omits scheme", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => validateSecurityScheme(undefined)).toThrowError();
|
||||
try {
|
||||
validateAuthAdapter({
|
||||
resolver: {
|
||||
resolve: async () => null,
|
||||
dispose: async () => undefined,
|
||||
},
|
||||
securityScheme: undefined as never,
|
||||
});
|
||||
} catch (err) {
|
||||
expect((err as { code: string }).code).toBe(
|
||||
AUTH_CODES.SECURITY_SCHEME_MISSING
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("PRINCIPAL_INVALID_SHAPE thrown when resolver returns malformed Principal", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const chain = new SessionResolverChain([
|
||||
{
|
||||
// deliberately wrong shape — missing scopes + claims
|
||||
resolve: async () => ({ id: "p_1" }) as unknown as null,
|
||||
dispose: async () => undefined,
|
||||
},
|
||||
]);
|
||||
await expect(
|
||||
chain.resolve(new Request("https://example.test"))
|
||||
).rejects.toMatchObject({
|
||||
code: AUTH_CODES.PRINCIPAL_INVALID_SHAPE,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AUTH_CODES, AuthError, type AuthErrorCode } from "./error-codes.js";
|
||||
|
||||
describe("AUTH_CODES registry", () => {
|
||||
it("contains exactly the four documented codes", { timeout: 5000 }, () => {
|
||||
expect(Object.values(AUTH_CODES).sort()).toEqual(
|
||||
[
|
||||
"AUTH.CHAIN_EMPTY",
|
||||
"AUTH.PRINCIPAL_INVALID_SHAPE",
|
||||
"AUTH.RESOLVER_THREW",
|
||||
"AUTH.SECURITY_SCHEME_MISSING",
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("every code value follows the AUTH.<UPPER_SNAKE> template", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (const value of Object.values(AUTH_CODES)) {
|
||||
expect(value).toMatch(/^AUTH\.[A-Z_]+$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("AuthError narrows its code to AuthErrorCode", { timeout: 5000 }, () => {
|
||||
const err = new AuthError(AUTH_CODES.CHAIN_EMPTY, "boom");
|
||||
const code: AuthErrorCode = err.code;
|
||||
expect(code).toBe("AUTH.CHAIN_EMPTY");
|
||||
expect(err.authCode).toBe("AUTH.CHAIN_EMPTY");
|
||||
expect(err).toBeInstanceOf(AuthError);
|
||||
expect(err.message).toBe("boom");
|
||||
});
|
||||
|
||||
it("AuthError carries the optional cause", { timeout: 5000 }, () => {
|
||||
const cause = new Error("upstream");
|
||||
const err = new AuthError(AUTH_CODES.RESOLVER_THREW, "wrap", { cause });
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/api-auth`.
|
||||
*
|
||||
* Every public failure path raises an {@link AuthError} carrying one of these
|
||||
* namespaced `AUTH.*` codes. Consumers branch on the string value (e.g.
|
||||
* `error.code === AUTH_CODES.RESOLVER_THREW`).
|
||||
*
|
||||
* The 4 codes cover:
|
||||
* - Resolver throw surfaced by the chain (`RESOLVER_THREW`).
|
||||
* - Empty chain rejected at construction (`CHAIN_EMPTY`).
|
||||
* - Principal returned by a resolver that fails structural validation
|
||||
* (`PRINCIPAL_INVALID_SHAPE`).
|
||||
* - An `AuthAdapter` registered without a `SecuritySchemeContribution`
|
||||
* (`SECURITY_SCHEME_MISSING`).
|
||||
*/
|
||||
export const AUTH_CODES = defineCodes("AUTH", [
|
||||
"RESOLVER_THREW",
|
||||
"CHAIN_EMPTY",
|
||||
"PRINCIPAL_INVALID_SHAPE",
|
||||
"SECURITY_SCHEME_MISSING",
|
||||
] as const);
|
||||
|
||||
export type AuthErrorCode = (typeof AUTH_CODES)[keyof typeof AUTH_CODES];
|
||||
|
||||
/**
|
||||
* Concrete error class for the api-auth package. Extends {@link SuaveplanError}
|
||||
* so `instanceof AuthError` narrows `code` to {@link AuthErrorCode}.
|
||||
*/
|
||||
export class AuthError extends SuaveplanError<AuthErrorCode> {
|
||||
public readonly authCode: AuthErrorCode;
|
||||
|
||||
public constructor(
|
||||
code: AuthErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.authCode = code;
|
||||
}
|
||||
}
|
||||
5
packages/api-stack/api/api-auth/src/error-codes/index.ts
Normal file
5
packages/api-stack/api/api-auth/src/error-codes/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
AUTH_CODES,
|
||||
AuthError,
|
||||
type AuthErrorCode,
|
||||
} from "./error-codes.js";
|
||||
42
packages/api-stack/api/api-auth/src/index.ts
Normal file
42
packages/api-stack/api/api-auth/src/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// Side-effect import — surfaces the error-codes registry to consumers via
|
||||
// `import "@suaveplan/api-auth"`.
|
||||
import "./error-codes/error-codes.js";
|
||||
|
||||
// Capability id
|
||||
export {
|
||||
AUTH_SESSION_CAPABILITY_ID,
|
||||
type AuthSessionCapabilityId,
|
||||
} from "./capability-id/capability-id.js";
|
||||
// Chain
|
||||
export {
|
||||
SessionResolverChain,
|
||||
type SessionResolverChainOptions,
|
||||
} from "./chain/chain.js";
|
||||
// CSRF policy
|
||||
export { CsrfPolicy } from "./csrf-policy/csrf-policy.js";
|
||||
// Error codes
|
||||
export {
|
||||
AUTH_CODES,
|
||||
AuthError,
|
||||
type AuthErrorCode,
|
||||
} from "./error-codes/error-codes.js";
|
||||
// Resolver
|
||||
export type { SessionResolver } from "./resolver/resolver.js";
|
||||
// Security scheme
|
||||
export {
|
||||
type OpenApiSecurityScheme,
|
||||
OpenApiSecuritySchemeSchema,
|
||||
type SecuritySchemeContribution,
|
||||
SecuritySchemeContributionSchema,
|
||||
validateSecurityScheme,
|
||||
} from "./security-scheme/security-scheme.js";
|
||||
// Session adapter
|
||||
export {
|
||||
type AuthAdapter,
|
||||
validateAuthAdapter,
|
||||
} from "./session-adapter/session-adapter.js";
|
||||
// Testing harness
|
||||
export {
|
||||
type AuthSessionConformanceFactory,
|
||||
runAuthSessionConformance,
|
||||
} from "./testing/conformance.js";
|
||||
1
packages/api-stack/api/api-auth/src/resolver/index.ts
Normal file
1
packages/api-stack/api/api-auth/src/resolver/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type { SessionResolver } from "./resolver.js";
|
||||
119
packages/api-stack/api/api-auth/src/resolver/resolver.md
Normal file
119
packages/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,28 @@
|
||||
import type { Principal } from "@suaveplan/api-types/principal";
|
||||
import { describe, expectTypeOf, it } from "@suaveplan/testing/runner";
|
||||
import type { SessionResolver } from "./resolver.js";
|
||||
|
||||
describe("SessionResolver — type-level contract", () => {
|
||||
it("declares resolve and dispose with documented signatures", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expectTypeOf<SessionResolver>().toHaveProperty("resolve");
|
||||
expectTypeOf<SessionResolver>().toHaveProperty("dispose");
|
||||
|
||||
expectTypeOf<SessionResolver["resolve"]>().toEqualTypeOf<
|
||||
(req: Request) => Promise<Principal | null>
|
||||
>();
|
||||
expectTypeOf<SessionResolver["dispose"]>().toEqualTypeOf<
|
||||
() => Promise<void>
|
||||
>();
|
||||
});
|
||||
|
||||
it("is generic over Principal", { timeout: 5000 }, () => {
|
||||
interface AdminPrincipal extends Principal {
|
||||
readonly admin: true;
|
||||
}
|
||||
expectTypeOf<
|
||||
SessionResolver<AdminPrincipal>["resolve"]
|
||||
>().toEqualTypeOf<(req: Request) => Promise<AdminPrincipal | null>>();
|
||||
});
|
||||
});
|
||||
36
packages/api-stack/api/api-auth/src/resolver/resolver.ts
Normal file
36
packages/api-stack/api/api-auth/src/resolver/resolver.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Principal } from "@suaveplan/api-types/principal";
|
||||
|
||||
/**
|
||||
* Abstract per-request session resolver.
|
||||
*
|
||||
* `resolve` is called by the host at step 3 of the per-request pipeline (design
|
||||
* doc §6). The return value semantics are deliberately tri-valued at the
|
||||
* resolver level but collapse to a binary at the host:
|
||||
*
|
||||
* - `Principal` — this resolver recognised the caller; the host attaches it
|
||||
* to `RequestContext` and short-circuits the chain.
|
||||
* - `null` — "I don't recognise this request." The host moves to the next
|
||||
* resolver in the chain. After every resolver returns `null`, the host's
|
||||
* `publicRoutes` decision applies: a non-public route 401s; a public route
|
||||
* proceeds with `principal: null`.
|
||||
*
|
||||
* `dispose` is called once at host shutdown (or whenever the host rebuilds its
|
||||
* resolver chain — e.g. blue/green key rotation). Implementations MUST be
|
||||
* idempotent — the host MAY call `dispose` more than once on cleanup paths.
|
||||
*
|
||||
* @typeParam P - allow concrete resolvers to narrow the principal shape via
|
||||
* module augmentation of `Claims` (or a subclass of `Principal`).
|
||||
* The chain runner widens back to `Principal` at the boundary.
|
||||
*/
|
||||
export interface SessionResolver<P extends Principal = Principal> {
|
||||
/**
|
||||
* Inspect the request and return a principal, or `null` to defer to the
|
||||
* next resolver. MUST be safe to call concurrently — the host fans many
|
||||
* requests through a single resolver instance.
|
||||
*/
|
||||
resolve(req: Request): Promise<P | null>;
|
||||
/**
|
||||
* Tear-down hook. Idempotent — safe to call more than once.
|
||||
*/
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
type OpenApiSecurityScheme,
|
||||
OpenApiSecuritySchemeSchema,
|
||||
type SecuritySchemeContribution,
|
||||
SecuritySchemeContributionSchema,
|
||||
validateSecurityScheme,
|
||||
} from "./security-scheme.js";
|
||||
@@ -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,119 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AUTH_CODES } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
OpenApiSecuritySchemeSchema,
|
||||
SecuritySchemeContributionSchema,
|
||||
validateSecurityScheme,
|
||||
} from "./security-scheme.js";
|
||||
|
||||
describe("SecuritySchemeContribution — schema", () => {
|
||||
it("accepts a valid http-bearer contribution", { timeout: 5000 }, () => {
|
||||
const parsed = SecuritySchemeContributionSchema.parse({
|
||||
name: "bearerAuth",
|
||||
scheme: { type: "http", scheme: "bearer", bearerFormat: "JWT" },
|
||||
});
|
||||
expect(parsed.name).toBe("bearerAuth");
|
||||
});
|
||||
|
||||
it("accepts a valid apiKey-cookie contribution", { timeout: 5000 }, () => {
|
||||
const parsed = SecuritySchemeContributionSchema.parse({
|
||||
name: "session",
|
||||
scheme: { type: "apiKey", in: "cookie", name: "sp_session" },
|
||||
});
|
||||
expect(parsed.scheme.type).toBe("apiKey");
|
||||
});
|
||||
|
||||
it("accepts a valid oauth2 contribution", { timeout: 5000 }, () => {
|
||||
const parsed = SecuritySchemeContributionSchema.parse({
|
||||
name: "oauth",
|
||||
scheme: { type: "oauth2", flows: { implicit: {} } },
|
||||
});
|
||||
expect(parsed.scheme.type).toBe("oauth2");
|
||||
});
|
||||
|
||||
it("accepts a valid openIdConnect contribution", { timeout: 5000 }, () => {
|
||||
const parsed = SecuritySchemeContributionSchema.parse({
|
||||
name: "oidc",
|
||||
scheme: {
|
||||
type: "openIdConnect",
|
||||
openIdConnectUrl:
|
||||
"https://example.test/.well-known/openid-configuration",
|
||||
},
|
||||
});
|
||||
expect(parsed.scheme.type).toBe("openIdConnect");
|
||||
});
|
||||
|
||||
it("rejects an unknown scheme type", { timeout: 5000 }, () => {
|
||||
const result = SecuritySchemeContributionSchema.safeParse({
|
||||
name: "bad",
|
||||
scheme: { type: "mutual-tls" },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an empty name", { timeout: 5000 }, () => {
|
||||
const result = SecuritySchemeContributionSchema.safeParse({
|
||||
name: "",
|
||||
scheme: { type: "http", scheme: "bearer" },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an apiKey with bad `in` value", { timeout: 5000 }, () => {
|
||||
const result = OpenApiSecuritySchemeSchema.safeParse({
|
||||
type: "apiKey",
|
||||
in: "body",
|
||||
name: "x",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateSecurityScheme", () => {
|
||||
it("returns the validated contribution on success", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = validateSecurityScheme({
|
||||
name: "bearerAuth",
|
||||
scheme: { type: "http", scheme: "bearer" },
|
||||
});
|
||||
expect(out.name).toBe("bearerAuth");
|
||||
});
|
||||
|
||||
it("rejects undefined with SECURITY_SCHEME_MISSING", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
validateSecurityScheme(undefined);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as { code: string }).code).toBe(
|
||||
AUTH_CODES.SECURITY_SCHEME_MISSING
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects null with SECURITY_SCHEME_MISSING", { timeout: 5000 }, () => {
|
||||
try {
|
||||
validateSecurityScheme(null as unknown as undefined);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as { code: string }).code).toBe(
|
||||
AUTH_CODES.SECURITY_SCHEME_MISSING
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects malformed contribution with SECURITY_SCHEME_MISSING and surfaces zod issues", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
validateSecurityScheme({ name: "x" } as never);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
const e = err as { code: string; context?: { issues?: unknown } };
|
||||
expect(e.code).toBe(AUTH_CODES.SECURITY_SCHEME_MISSING);
|
||||
expect(Array.isArray(e.context?.issues)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import z from "zod";
|
||||
import { AUTH_CODES, AuthError } from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Minimal OpenAPI 3.1 Security Scheme Object shape that the host accepts from
|
||||
* concrete resolvers. Matches the OAS section "Security Scheme Object" — the
|
||||
* union of the four schemes the foundation natively supports.
|
||||
*
|
||||
* The runtime schema is exported as `OpenApiSecuritySchemeSchema` and the
|
||||
* inferred type as `OpenApiSecurityScheme`.
|
||||
*/
|
||||
export const OpenApiSecuritySchemeSchema = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal("apiKey"),
|
||||
name: z.string().min(1),
|
||||
in: z.enum(["header", "query", "cookie"]),
|
||||
description: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("http"),
|
||||
scheme: z.string().min(1),
|
||||
bearerFormat: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("oauth2"),
|
||||
flows: z.record(z.string(), z.unknown()),
|
||||
description: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("openIdConnect"),
|
||||
openIdConnectUrl: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type OpenApiSecurityScheme = z.infer<typeof OpenApiSecuritySchemeSchema>;
|
||||
|
||||
/**
|
||||
* One adapter's contribution to `components.securitySchemes` in the generated
|
||||
* OpenAPI document. `name` is the map key (e.g. `"bearerAuth"`); `scheme` is
|
||||
* the value the OpenAPI emitter inlines verbatim.
|
||||
*
|
||||
* Schema is exported alongside the type so registration code can validate
|
||||
* adapter input at the boundary (rule: "Zod wired at security-scheme
|
||||
* boundary").
|
||||
*/
|
||||
export const SecuritySchemeContributionSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
scheme: OpenApiSecuritySchemeSchema,
|
||||
});
|
||||
|
||||
export type SecuritySchemeContribution = z.infer<
|
||||
typeof SecuritySchemeContributionSchema
|
||||
>;
|
||||
|
||||
/**
|
||||
* Validate an adapter's security-scheme contribution at registration time.
|
||||
*
|
||||
* Returns the validated contribution on success; throws `AUTH.SECURITY_SCHEME_MISSING`
|
||||
* when the contribution is missing or malformed. Hosts call this from their
|
||||
* adapter-registration pipeline; the throw is the documented signal to abort
|
||||
* boot rather than serve traffic with a half-configured OpenAPI document.
|
||||
*/
|
||||
export function validateSecurityScheme(
|
||||
contribution: SecuritySchemeContribution | undefined
|
||||
): SecuritySchemeContribution {
|
||||
if (contribution === undefined || contribution === null) {
|
||||
throw new AuthError(
|
||||
AUTH_CODES.SECURITY_SCHEME_MISSING,
|
||||
"AuthAdapter registered without a SecuritySchemeContribution"
|
||||
);
|
||||
}
|
||||
const parsed = SecuritySchemeContributionSchema.safeParse(contribution);
|
||||
if (!parsed.success) {
|
||||
throw new AuthError(
|
||||
AUTH_CODES.SECURITY_SCHEME_MISSING,
|
||||
"SecuritySchemeContribution failed schema validation",
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { type AuthAdapter, validateAuthAdapter } from "./session-adapter.js";
|
||||
@@ -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.
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { AUTH_CODES } from "../error-codes/error-codes.js";
|
||||
import type { SessionResolver } from "../resolver/resolver.js";
|
||||
import { type AuthAdapter, validateAuthAdapter } from "./session-adapter.js";
|
||||
|
||||
const noopResolver: SessionResolver = {
|
||||
resolve: async () => null,
|
||||
dispose: async () => undefined,
|
||||
};
|
||||
|
||||
describe("validateAuthAdapter", () => {
|
||||
it("returns the adapter on success", { timeout: 5000 }, () => {
|
||||
const adapter: AuthAdapter = {
|
||||
resolver: noopResolver,
|
||||
securityScheme: {
|
||||
name: "bearerAuth",
|
||||
scheme: { type: "http", scheme: "bearer" },
|
||||
},
|
||||
};
|
||||
expect(validateAuthAdapter(adapter)).toBe(adapter);
|
||||
});
|
||||
|
||||
it("honours an explicit csrf override field", { timeout: 5000 }, () => {
|
||||
const adapter: AuthAdapter = {
|
||||
resolver: noopResolver,
|
||||
securityScheme: {
|
||||
name: "session",
|
||||
scheme: { type: "apiKey", in: "cookie", name: "sp_session" },
|
||||
},
|
||||
csrf: false,
|
||||
};
|
||||
const out = validateAuthAdapter(adapter);
|
||||
expect(out.csrf).toBe(false);
|
||||
});
|
||||
|
||||
it("permits adapter with omitted securityScheme (anonymous tail)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const adapter: AuthAdapter = { resolver: noopResolver };
|
||||
expect(validateAuthAdapter(adapter)).toBe(adapter);
|
||||
});
|
||||
|
||||
it("rejects adapter with malformed securityScheme", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
validateAuthAdapter({
|
||||
resolver: noopResolver,
|
||||
securityScheme: {
|
||||
name: "",
|
||||
scheme: { type: "http", scheme: "" },
|
||||
} as never,
|
||||
});
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect((err as { code: string }).code).toBe(
|
||||
AUTH_CODES.SECURITY_SCHEME_MISSING
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { SessionResolver } from "../resolver/resolver.js";
|
||||
import {
|
||||
type SecuritySchemeContribution,
|
||||
validateSecurityScheme,
|
||||
} from "../security-scheme/security-scheme.js";
|
||||
|
||||
/**
|
||||
* Composite shape a concrete session-auth adapter exports.
|
||||
*
|
||||
* Pairs a resolver with the metadata the host needs to wire it into the
|
||||
* pipeline: the resolver itself (`resolver`), what to emit into the OpenAPI
|
||||
* document (`securityScheme`), and an optional per-adapter CSRF default
|
||||
* override (`csrf`).
|
||||
*
|
||||
* `csrf` defaults to `undefined` — meaning "use the policy in
|
||||
* `@suaveplan/api-auth/csrf-policy`". A concrete adapter MAY pin a
|
||||
* non-default value when it knows better than the generic policy (e.g. a
|
||||
* cookie scheme that uses SameSite=Strict and a per-request nonce internally
|
||||
* MAY pin `csrf: false`).
|
||||
*/
|
||||
export interface AuthAdapter {
|
||||
readonly resolver: SessionResolver;
|
||||
/**
|
||||
* OpenAPI security scheme contribution. Optional — `api-auth-anonymous`
|
||||
* (the trivial null-returning resolver used as the tail of public-API
|
||||
* chains) ships without a scheme because the route security list is empty
|
||||
* in that topology. Every other resolver (Stage 4 onwards) MUST supply one.
|
||||
*/
|
||||
readonly securityScheme?: SecuritySchemeContribution;
|
||||
readonly csrf?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an `AuthAdapter` at host registration time.
|
||||
*
|
||||
* Throws `AUTH.SECURITY_SCHEME_MISSING` if the adapter's `securityScheme` is
|
||||
* present-but-malformed. An omitted `securityScheme` is permitted (anonymous
|
||||
* tail adapter). Returns the adapter unchanged on success so the call site can
|
||||
* chain it.
|
||||
*
|
||||
* The resolver itself is not validated structurally — TypeScript ensures the
|
||||
* shape statically and the conformance harness covers behaviour at test time.
|
||||
*/
|
||||
export function validateAuthAdapter(adapter: AuthAdapter): AuthAdapter {
|
||||
if (adapter.securityScheme !== undefined) {
|
||||
validateSecurityScheme(adapter.securityScheme);
|
||||
}
|
||||
return adapter;
|
||||
}
|
||||
101
packages/api-stack/api/api-auth/src/testing/conformance.md
Normal file
101
packages/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.
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { PrincipalId, Scope } from "@suaveplan/api-types/principal";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { SessionResolver } from "../resolver/resolver.js";
|
||||
import { runAuthSessionConformance } from "./conformance.js";
|
||||
|
||||
const TARGET = "https://example.test/api";
|
||||
|
||||
function buildStubResolver(): SessionResolver {
|
||||
let _disposed = 0;
|
||||
return {
|
||||
resolve: async (req: Request) => {
|
||||
const url = new URL(req.url);
|
||||
if (url.searchParams.get("authed") === "1") {
|
||||
return {
|
||||
id: "u_stub" as PrincipalId,
|
||||
scopes: ["read:self"] as readonly Scope[],
|
||||
claims: { sub: "u_stub" },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
dispose: async () => {
|
||||
_disposed++;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
runAuthSessionConformance({
|
||||
label: "harness-self-test",
|
||||
factory: () => buildStubResolver(),
|
||||
seedNull: () => new Request(TARGET),
|
||||
seedPrincipal: () => ({
|
||||
request: new Request(`${TARGET}?authed=1`),
|
||||
expected: {
|
||||
id: "u_stub" as PrincipalId,
|
||||
scopes: ["read:self"] as readonly Scope[],
|
||||
claims: { sub: "u_stub" },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// Null-only resolver path — exercises the `else` branch of the harness
|
||||
// (the "anonymous tail of every resolver chain" scenario, where
|
||||
// `seedPrincipal` is omitted). Pins coverage of lines 74-87 inside this
|
||||
// package rather than relying on a downstream consumer (api-auth-anonymous).
|
||||
function buildNullOnlyResolver(): SessionResolver {
|
||||
return {
|
||||
resolve: async () => null,
|
||||
dispose: async () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
runAuthSessionConformance({
|
||||
label: "harness-self-test-null-only",
|
||||
factory: () => buildNullOnlyResolver(),
|
||||
seedNull: () => new Request(TARGET),
|
||||
});
|
||||
|
||||
describe("runAuthSessionConformance — harness shape", () => {
|
||||
it("harness covers resolve null, resolve principal, dispose lifecycle", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
// Smoke: build a resolver, exercise the three behaviours directly.
|
||||
const r = buildStubResolver();
|
||||
expect(await r.resolve(new Request(TARGET))).toBeNull();
|
||||
const p = await r.resolve(new Request(`${TARGET}?authed=1`));
|
||||
expect(p?.id).toBe("u_stub");
|
||||
await r.dispose();
|
||||
await expect(r.dispose()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
98
packages/api-stack/api/api-auth/src/testing/conformance.ts
Normal file
98
packages/api-stack/api/api-auth/src/testing/conformance.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { Principal } from "@suaveplan/api-types/principal";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { SessionResolver } from "../resolver/resolver.js";
|
||||
|
||||
/**
|
||||
* Inputs to {@link runAuthSessionConformance}.
|
||||
*
|
||||
* `factory()` MUST return a freshly constructed resolver pre-seeded with two
|
||||
* known requests:
|
||||
*
|
||||
* - `seedNull` — a request the resolver returns `null` for.
|
||||
* - `seedPrincipal` — a request the resolver returns a known principal for,
|
||||
* along with the expected `Principal` object so the assertion can compare.
|
||||
*/
|
||||
export interface AuthSessionConformanceFactory {
|
||||
readonly factory: () => Promise<SessionResolver> | SessionResolver;
|
||||
readonly label: string;
|
||||
readonly seedNull: () => Request;
|
||||
/**
|
||||
* Optional principal seed. Omit (or set to `null`) for resolvers that
|
||||
* never recognise a request — e.g. `api-auth-anonymous`, the "no auth"
|
||||
* tail of every resolver chain. When omitted, the "recognised principal"
|
||||
* scenario is skipped; the null + dispose scenarios still run.
|
||||
*/
|
||||
readonly seedPrincipal?:
|
||||
| (() => {
|
||||
readonly request: Request;
|
||||
readonly expected: Principal;
|
||||
})
|
||||
| null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable harness every concrete `SessionResolver` (Stages 4/6/8) MUST pass.
|
||||
*
|
||||
* Covers the documented behaviours:
|
||||
* - resolve returns `null` for a request the adapter does not recognise.
|
||||
* - resolve returns the expected `Principal` for a recognised request.
|
||||
* - `dispose()` is idempotent — calling it twice is safe.
|
||||
*/
|
||||
export function runAuthSessionConformance(
|
||||
spec: AuthSessionConformanceFactory
|
||||
): void {
|
||||
describe(`SessionResolver conformance — ${spec.label}`, () => {
|
||||
it("resolve returns null for unrecognised request", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const resolver = await spec.factory();
|
||||
try {
|
||||
const result = await resolver.resolve(spec.seedNull());
|
||||
expect(result).toBeNull();
|
||||
} finally {
|
||||
await resolver.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
const seedPrincipal = spec.seedPrincipal;
|
||||
if (seedPrincipal) {
|
||||
it("resolve returns expected principal for recognised request", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const resolver = await spec.factory();
|
||||
try {
|
||||
const { request, expected } = seedPrincipal();
|
||||
const result = await resolver.resolve(request);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.id).toBe(expected.id);
|
||||
expect(result?.scopes).toEqual(expected.scopes);
|
||||
} finally {
|
||||
await resolver.dispose();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
it("resolve returns null for every input (null-only resolver)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const resolver = await spec.factory();
|
||||
try {
|
||||
// Exercise a recognisable-looking request — anonymous-style
|
||||
// resolvers must still return null even when authentication
|
||||
// material is present.
|
||||
const probe = new Request("https://example.test/probe", {
|
||||
headers: { authorization: "Bearer probe.token.xxx" },
|
||||
});
|
||||
expect(await resolver.resolve(probe)).toBeNull();
|
||||
} finally {
|
||||
await resolver.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("dispose is idempotent", { timeout: 5000 }, async () => {
|
||||
const resolver = await spec.factory();
|
||||
await resolver.dispose();
|
||||
await expect(resolver.dispose()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
}
|
||||
4
packages/api-stack/api/api-auth/src/testing/index.ts
Normal file
4
packages/api-stack/api/api-auth/src/testing/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
type AuthSessionConformanceFactory,
|
||||
runAuthSessionConformance,
|
||||
} from "./conformance.js";
|
||||
31
packages/api-stack/api/api-auth/tests/dep-graph.test.ts
Normal file
31
packages/api-stack/api/api-auth/tests/dep-graph.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const PKG = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as {
|
||||
readonly dependencies?: Record<string, string>;
|
||||
readonly peerDependencies?: Record<string, string>;
|
||||
};
|
||||
|
||||
describe("@suaveplan/api-auth dep graph", () => {
|
||||
it("depends on api-types, error, types, tslib, zod (and nothing else)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const deps = Object.keys(PKG.dependencies ?? {}).sort();
|
||||
expect(deps).toEqual(
|
||||
[
|
||||
"@suaveplan/api-types",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
].sort()
|
||||
);
|
||||
});
|
||||
|
||||
it("declares no peerDependencies", { timeout: 5000 }, () => {
|
||||
expect(Object.keys(PKG.peerDependencies ?? {})).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const SRC = join(__dirname, "..", "src");
|
||||
|
||||
function walk(dir: string): readonly string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
out.push(...walk(full));
|
||||
} else if (full.endsWith(".ts")) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("zod import form", () => {
|
||||
it("every src file uses default-import z", { timeout: 5000 }, () => {
|
||||
const bad: string[] = [];
|
||||
const files = walk(SRC);
|
||||
for (const file of files) {
|
||||
const text = readFileSync(file, "utf8");
|
||||
if (/import\s*\{\s*z\s*[},]/.test(text)) {
|
||||
bad.push(file);
|
||||
}
|
||||
}
|
||||
expect(bad).toEqual([]);
|
||||
});
|
||||
|
||||
it("source files importing zod use the default form", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let imports = 0;
|
||||
for (const file of walk(SRC)) {
|
||||
const text = readFileSync(file, "utf8");
|
||||
if (/from\s+["']zod["']/.test(text)) {
|
||||
imports++;
|
||||
expect(text).toMatch(/import\s+z\s+from\s+["']zod["']/);
|
||||
}
|
||||
}
|
||||
expect(imports).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
12
packages/api-stack/api/api-auth/tsconfig.json
Normal file
12
packages/api-stack/api/api-auth/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
12
packages/api-stack/api/api-auth/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-auth/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
30
packages/api-stack/api/api-auth/vite.config.ts
Normal file
30
packages/api-stack/api/api-auth/vite.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/api-auth",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [/^@suaveplan\//, /^node:/, "tslib", "zod"],
|
||||
},
|
||||
},
|
||||
});
|
||||
31
packages/api-stack/api/api-auth/vitest.config.ts
Normal file
31
packages/api-stack/api/api-auth/vitest.config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user