154 lines
5.8 KiB
Markdown
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"`
|