Initial Commit
This commit is contained in:
129
openspec/specs/api-auth-better-auth/spec.md
Normal file
129
openspec/specs/api-auth-better-auth/spec.md
Normal file
@@ -0,0 +1,129 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: api-auth-better-auth (api-auth-better-auth)
|
||||
|
||||
**Package**: `@suaveplan/api-auth-better-auth`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Ships two independently-wireable capability surfaces
|
||||
|
||||
The package SHALL export two factory functions — `createBetterAuthSession(opts)` implementing the `auth.session` contract from `@suaveplan/api-auth`, and `createBetterAuthProvider(opts)` implementing the `auth.provider` contract from `@suaveplan/api-auth-provider`. Each MUST be usable independently. Operators MAY wire only the session adapter (keeping their existing account-lifecycle routes elsewhere, e.g. Clerk) OR only the provider adapter (rare) OR both. Wiring only one side MUST NOT mount any routes from the other side.
|
||||
|
||||
#### Scenario: session-only wiring exposes no provider routes
|
||||
|
||||
- **WHEN** host config mounts `createBetterAuthSession(opts)` but does NOT mount `createBetterAuthProvider`
|
||||
- **THEN** routes under `routePrefix` (default `/auth/*`) are NOT registered AND `auth.session` is callable
|
||||
|
||||
#### Scenario: provider-only wiring exposes no session resolver
|
||||
|
||||
- **WHEN** host config mounts `createBetterAuthProvider(opts)` but does NOT mount `createBetterAuthSession`
|
||||
- **THEN** the provider's account-lifecycle routes are registered AND the `auth.session` slot is filled by whatever other resolver host config supplies (e.g. JWT)
|
||||
|
||||
**Verified by:** `src/independence/session-without-provider.test.ts::"session adapter mounts without provider routes"` AND `src/independence/session-without-provider.test.ts::"provider adapter mounts without claiming auth.session"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Better-Auth owns its own DB connection
|
||||
|
||||
The package SHALL construct its own Postgres connection (via `BetterAuthOptions.database`) distinct from the host's main DB and from every plugin's DB. Better-Auth's schema (users, sessions, accounts, verification, oauth state) lives in that connection. The wrapper MUST NOT attempt to share the host's metadata DB or any plugin's DB — Better-Auth's opinionated model forbids it and bridging would defeat upstream migrations.
|
||||
|
||||
#### Scenario: configured DB is a distinct connection from the host metadata DB
|
||||
|
||||
- **WHEN** `BetterAuthOptions.database` is set
|
||||
- **THEN** the wrapper instantiates its own connection AND does NOT call into `api-host`'s metadata-DB handle
|
||||
|
||||
**Verified by:** `tests/integration/postgres-better-auth.test.ts::"Better-Auth uses its own DB connection independent of host metadata DB"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Maps Better-Auth user + session onto our Principal contract
|
||||
|
||||
`createBetterAuthSession` SHALL resolve a session via Better-Auth's session APIs, then map the resulting user + session into a `Principal` per `@suaveplan/api-types`. The default mapping populates `id` from `user.id`, `scopes` from `user.roles` (translated via `principalMapping.scopeFromRoles`), `tenantId` from `user.tenantId` (when present), and `claims` from `user` minus its sensitive fields. Operators MAY override the mapping via `BetterAuthOptions.principalMapping`. A mapping failure MUST throw `AUTH_BA_PRINCIPAL_MAPPING_FAILED` (not silently emit a partial principal).
|
||||
|
||||
#### Scenario: well-formed Better-Auth user produces a Principal
|
||||
|
||||
- **WHEN** Better-Auth returns a session for a known user
|
||||
- **THEN** `auth.session(request)` returns a `Principal` whose `id` equals `user.id` AND whose `scopes` reflect the role mapping
|
||||
|
||||
#### Scenario: mapping failure surfaces explicitly
|
||||
|
||||
- **WHEN** the custom `principalMapping` function throws
|
||||
- **THEN** the session resolver propagates `AUTH_BA_PRINCIPAL_MAPPING_FAILED` AND does NOT return a `null` principal
|
||||
|
||||
**Verified by:** `src/session/create-session.test.ts::"maps well-formed Better-Auth user to Principal"` AND `src/session/create-session.test.ts::"mapping failure throws AUTH_BA_PRINCIPAL_MAPPING_FAILED"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Configurable route prefix with collision detection
|
||||
|
||||
`createBetterAuthProvider` SHALL mount account-lifecycle routes under `routePrefix` (default `/auth`) using the canonical names from `PROVIDER_ROUTE_SURFACE` (`@suaveplan/api-auth-provider`): `POST /register`, `POST /login`, `POST /logout`, `POST /refresh`, `GET /verify-email/:token`, `GET /oauth/:provider/start`, `GET /oauth/:provider/callback`, `POST /forgot-password`, `POST /reset-password`. Better-Auth's native idiom (`/sign-up`, `/sign-in`, `/sign-out`) is adapted to canonical names inside this package; consumers of the provider see the canonical surface only. The provider MUST refuse to mount if the requested prefix collides with a host-reserved prefix — the reserved-prefix list is sourced from the `@suaveplan/api-host` reserved-prefix contract (NOT hard-coded in this package). Install fails with `AUTH_BA_ROUTE_PREFIX_COLLISION`.
|
||||
|
||||
#### Scenario: default prefix mounts every documented route
|
||||
|
||||
- **WHEN** the provider mounts with `routePrefix` left at default
|
||||
- **THEN** every documented route is reachable under `/auth/*`
|
||||
|
||||
#### Scenario: collision with reserved prefix is refused
|
||||
|
||||
- **WHEN** `routePrefix: "/_host"` is supplied
|
||||
- **THEN** install fails with `AUTH_BA_ROUTE_PREFIX_COLLISION`
|
||||
|
||||
**Verified by:** `src/route-prefix/route-prefix.test.ts::"default prefix mounts every documented route"` AND `src/route-prefix/route-prefix.test.ts::"reserved-prefix collision fails with AUTH_BA_ROUTE_PREFIX_COLLISION"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: End-to-end GitHub OAuth flow
|
||||
|
||||
The provider SHALL ship a working GitHub OAuth flow as the canonical reference. The full flow — authorization redirect with state cookie, code exchange, user-info fetch, principal mapping, session cookie issue — MUST be exercised end-to-end against a mock OAuth server in an integration test that the spec pins. OAuth state-mismatch MUST fail with `AUTH_BA_OAUTH_STATE_MISMATCH`; provider rejection MUST fail with `AUTH_BA_OAUTH_PROVIDER_REJECTED`.
|
||||
|
||||
#### Scenario: full GitHub OAuth flow yields an authenticated session
|
||||
|
||||
- **WHEN** the user completes `GET /auth/oauth/github/start` → `GET /auth/oauth/github/callback?code=...&state=...` against the mock OAuth IdP from `@suaveplan/testing-integration-oauth-mock`
|
||||
- **THEN** a session cookie is set AND a subsequent request resolves to a `Principal` whose claims include the GitHub user id
|
||||
|
||||
#### Scenario: state mismatch is rejected
|
||||
|
||||
- **WHEN** the callback arrives with a `state` that does not match the issued cookie
|
||||
- **THEN** the callback responds with `AUTH_BA_OAUTH_STATE_MISMATCH`
|
||||
|
||||
**Verified by:** `src/oauth/github-oauth.test.ts::"full GitHub OAuth flow yields authenticated Principal"` AND `src/oauth/github-oauth.test.ts::"state mismatch rejected with AUTH_BA_OAUTH_STATE_MISMATCH"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Conformance suites pass against real Postgres
|
||||
|
||||
The package SHALL pass `runAuthSessionConformance` (from `@suaveplan/api-auth/testing`) AND `runAuthProviderConformance` (from `@suaveplan/api-auth-provider/testing`) when its factories are backed by a real Postgres container via `@suaveplan/testing-integration-postgres`.
|
||||
|
||||
#### Scenario: session conformance passes
|
||||
|
||||
- **WHEN** the suite runs against `createBetterAuthSession(opts)` with Postgres-backed Better-Auth
|
||||
- **THEN** every invariant in `runAuthSessionConformance` passes
|
||||
|
||||
#### Scenario: provider conformance passes
|
||||
|
||||
- **WHEN** the suite runs against `createBetterAuthProvider(opts)` with Postgres-backed Better-Auth
|
||||
- **THEN** every invariant in `runAuthProviderConformance` passes
|
||||
|
||||
**Verified by:** `src/session/create-session.spec-conformance.test.ts::"createBetterAuthSession passes runAuthSessionConformance against Postgres"` AND `src/provider/create-provider.spec-conformance.test.ts::"createBetterAuthProvider passes runAuthProviderConformance against Postgres"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OTel surface
|
||||
|
||||
The package SHALL emit:
|
||||
|
||||
- `auth.better_auth.sessions_resolved_total` counter `{ outcome }` (`"resolved"`, `"null"`, `"error"`).
|
||||
- `auth.better_auth.session_resolve_duration_ms` histogram.
|
||||
- `auth.better_auth.oauth_flows_total` counter `{ provider, outcome }`.
|
||||
- `auth.better_auth.principal_mapping_errors_total` counter.
|
||||
|
||||
#### Scenario: successful session resolution increments the counter
|
||||
|
||||
- **WHEN** `auth.session(request)` returns a non-null `Principal`
|
||||
- **THEN** an in-memory `MeterProvider` observes `sessions_resolved_total{outcome="resolved"}` increment by 1
|
||||
|
||||
**Verified by:** `src/instruments/instruments.test.ts::"sessions_resolved_total increments on successful resolution"`
|
||||
Reference in New Issue
Block a user