Files
2026-05-27 23:29:53 +01:00

154 lines
5.8 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-auth-rolled (api-auth-rolled)
**Package**: `@suaveplan/api-auth-rolled`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
> **Scope note (Stage 8.12 keystone):** this change ships the rolled
> **session-resolver** half of the eventual dual contract. Issues RS256 JWTs
> with rotating `kid` slots, projects DB rows onto the `Principal` contract,
> and contributes cookie + bearer OpenAPI security schemes. The matching
> `AuthProviderAdapter` half (account-lifecycle routes, password gating,
> MFA, OAuth) is deferred to a successor change `api-auth-rolled-provider`
> so this milestone can ship at 100% coverage in a single iteration.
## ADDED Requirements
### Requirement: Implements `auth.session`
The package SHALL expose `createRolledAuthAdapter` returning an
`AuthAdapter` (session resolver + security scheme contribution).
#### Scenario: adapter exposes the auth.session contract
- WHEN `createRolledAuthAdapter({ store, keys, issuer, audience })` is called
- THEN the returned value implements `{ resolver, securityScheme }` per
`@suaveplan/api-auth/session-adapter`
**Verified by:** `src/session/session.test.ts::"adapter exposes resolver and securityScheme"`
---
### Requirement: Session tokens are RS256-signed JWTs with rotating `kid`
The package SHALL issue session tokens as RS256 JWTs signed via
`@suaveplan/crypto-rsa` + `@suaveplan/jws`. Keys are identified by `kid`
so rotation is possible without invalidating in-flight sessions. The
`TokenRotator` manages an active key (signs) and retired-but-non-revoked
keys (verify only). Expired tokens cause `resolveSession` to return null
(soft failure per `SessionResolver` chain semantics) — the host's chain
then emits 401.
#### Scenario: expired token rejected
- WHEN a JWT whose `exp` is in the past is presented
- THEN `resolveSession` returns null
#### Scenario: token signed by retired key still verifies
- WHEN a JWT signed under `kid=k0` is presented after the active key has
rotated to `kid=k1`, and `k0` is retired but not revoked
- THEN `resolveSession` returns the projected principal
#### Scenario: token signed by revoked key rejected
- WHEN a JWT whose `kid` is revoked is presented
- THEN `resolveSession` returns null
**Verified by:** `src/session/session.test.ts::"expired token returns null"`
AND `src/session/session.test.ts::"retired but non-revoked kid still verifies"`
AND `src/session/session.test.ts::"revoked kid rejected"`
---
### Requirement: Principal projection maps DB rows to api-types `Principal`
The package SHALL project each authenticated user row into the
`Principal` shape from `api-types` (`id`, `scopes`, optional `tenantId`,
`claims`).
#### Scenario: projection includes scopes and tenantId
- WHEN a user with scopes `["billing:read"]` and `tenantId = "acme"` is
resolved
- THEN `Principal` reflects those values exactly AND no extra fields leak
**Verified by:** `src/principal-projection/principal-projection.test.ts::"projects scopes and tenantId"`
---
### Requirement: Shares host's main DB connection (no separate connection)
The package SHALL accept a `store` adapter passed in by the host and MUST
NOT open its own DB connection. The store interface is structural and
satisfied by any storage layer the host wires.
#### Scenario: adapter requires store at construction
- WHEN `createRolledAuthAdapter({})` is called without `store`
- THEN it throws `AuthRolledError(DATABASE_REQUIRED)`
**Verified by:** `src/options/options.test.ts::"throws DATABASE_REQUIRED when store is missing"`
---
### Requirement: Comprehensive error code coverage
The package SHALL throw at least one production path per error code in
`AUTH_ROLLED_*`. The composition test enumerates the registry and asserts
every code has a production trigger.
#### Scenario: every error code is triggered by at least one production path
- WHEN the conformance test enumerates `AUTH_ROLLED_*`
- THEN each code maps to at least one named production scenario
**Verified by:** `src/composition/composition.spec-conformance.test.ts::"every AUTH_ROLLED code is triggered"`
---
### Requirement: Contributes OpenAPI security schemes
The package SHALL contribute `securitySchemes` to `api-openapi` describing
the cookie + bearer schemes.
#### Scenario: schemes are picked up by emitter
- WHEN the adapter is inspected
- THEN `securityScheme.schemes.bearer` and `securityScheme.schemes.cookie`
are present and validate against `OpenApiSecuritySchemeSchema`
**Verified by:** `src/security-schemes/security-schemes.test.ts::"contributes cookie and bearer schemes"`
---
### Requirement: Rotating-key TokenRotator
The package SHALL expose a `TokenRotator` that surfaces `snapshot()`,
`decide()` (advisory rotation timing), `setKeys()` (operator-driven swap)
and `publicJwkFor(kid)` (verifier lookup). The rotator MUST enforce
exactly-one-active-non-revoked on every keyset swap.
#### Scenario: setKeys with no active key rejected
- WHEN `setKeys([key("k1", active=false)])` is called
- THEN it throws `AuthRolledError(KEY_ROTATION_FAILED)`
#### Scenario: setKeys with two active keys rejected
- WHEN a replacement keyset contains two active+non-revoked keys
- THEN `setKeys` throws `AuthRolledError(KEY_ROTATION_FAILED)`
#### Scenario: decide reports shouldRotate=true after period elapses
- WHEN `rotationPeriodMs = 1000` and 1500 ms elapse on the injected clock
- THEN `decide().shouldRotate === true` AND `decide().retiringKid` equals
the active kid
**Verified by:** `src/token-rotator/token-rotator.test.ts::"setKeys rejects a replacement with zero active keys"`
AND `src/token-rotator/token-rotator.test.ts::"setKeys rejects a replacement with two active keys"`
AND `src/token-rotator/token-rotator.test.ts::"decide returns shouldRotate=true once the period elapses"`