Files
suaveplan/openspec/specs/api-auth-rolled/spec.md
2026-05-27 23:29:53 +01:00

5.8 KiB

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"