# 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"`