Files
suaveplan/openspec/archive/2026-05-26-api-auth-better-auth/proposal.md
2026-05-27 23:29:53 +01:00

5.9 KiB

@suaveplan/api-auth-better-auth — Better-Auth adapter implementing both auth contracts

Package: @suaveplan/api-auth-better-auth Status: PROPOSED Type: new-package Stage: 6 (Distributed transactions + Better-Auth) — see umbrella openspec/changes/api-plugin-foundation/

Why

Per design doc §2.3 the foundation deliberately separates two auth contracts: auth.session (Request → Principal | null, on every request) and auth.provider (account-lifecycle routes — register / login / logout / oauth / verify-email). Conflating them is the failure mode that makes most plugin systems painful to integrate with third-party auth. Shipping Better-Auth on day one is how we know those contracts hold up against a real non-trivial third-party library — wrapping it surfaces the contract pinch points before they ossify.

Better-Auth is opinionated about owning its own DB connection. This package reflects that reality: it consumes its own DB rather than the host's main DB. It then maps Better-Auth's user/session shapes onto our Principal contract so plugins never see Better-Auth-shaped types — only the foundation's Principal.

What changes

Net-new package depending on @suaveplan/api-types, @suaveplan/api-auth (session contract), @suaveplan/api-auth-provider (provider contract), @suaveplan/api-router, @suaveplan/error, @suaveplan/logger, @suaveplan/opentelemetry, better-auth (peer), and zod.

  • ADDED: createBetterAuthSession(opts) — implements the auth.session capability. Reads cookies/bearer per Better-Auth conventions, maps Better-Auth's session shape into Principal.
  • ADDED: createBetterAuthProvider(opts) — implements the auth.provider capability. Mounts account-lifecycle routes (default prefix /auth/*, configurable via routePrefix). Includes OAuth flow.
  • ADDED: BetterAuthOptions — Zod-validated config: database (separate from host main DB), baseUrl, routePrefix (default /auth), secret, oauth.providers (GitHub mandatory in spec, others optional), session.cookieName, session.cookieSameSite, principalMapping (function mapping Better-Auth user → Principal).
  • ADDED: mapBetterAuthUserToPrincipal(user, session) — pure helper producing a Principal from Better-Auth's shapes. Default mapping; replaceable via principalMapping option.
  • ADDED: Wired-only-one-side support — host config can mount the session adapter WITHOUT the provider adapter (operator wants Better-Auth's session resolver but a different account-lifecycle path, e.g. Clerk for accounts but Better-Auth as the session reader against a shared cookie). Each factory is independent.
  • ADDED: GitHub OAuth integration as the canonical reference flow.
  • ADDED: AUTH_BA_* error codes: AUTH_BA_INVALID_SESSION, AUTH_BA_DB_UNREACHABLE, AUTH_BA_OAUTH_PROVIDER_REJECTED, AUTH_BA_OAUTH_STATE_MISMATCH, AUTH_BA_USER_NOT_FOUND, AUTH_BA_EMAIL_NOT_VERIFIED, AUTH_BA_RATE_LIMITED, AUTH_BA_INVALID_REDIRECT, AUTH_BA_PRINCIPAL_MAPPING_FAILED, AUTH_BA_ROUTE_PREFIX_COLLISION.
  • ADDED: OTel instruments: auth.better_auth.sessions_resolved_total, auth.better_auth.session_resolve_duration_ms, auth.better_auth.oauth_flows_total { provider, outcome }, auth.better_auth.principal_mapping_errors_total.
  • ADDED: ./testing subpath — re-export of runAuthSessionConformance AND runAuthProviderConformance bound to factories backed by an integration Postgres container.

Out of scope

  • Re-export of Better-Auth's broader feature surface (passkeys, magic links, 2FA flows beyond OTP, organisation management). Operators wire those via Better-Auth config directly; the wrapper just plumbs them through.
  • A non-Postgres backend for Better-Auth. Better-Auth supports multiple databases; the conformance test pins Postgres because that's what the design doc says is integration-tested. Other backends MAY work but are not contracted.
  • The abstract auth.session and auth.provider contracts — those live in api-auth and api-auth-provider (Stage 1).

Source mapping

Genesis-original. Source of design intent: docs/superpowers/specs/2026-05-13-api-plugin-foundation-design.md.

Requirement Design doc section
Implements both auth.session AND auth.provider §2.3 (bullet 4)
Owns its own DB connection (separate from plugin DBs) §2.3 (bullet 4)
Maps Better-Auth user/session onto our Principal §2.3 (bullet 4)
Integration-tested against real Postgres §2.3 (bullet 4)
Default route prefix /auth/* (configurable) §2.3 (paragraph 2)
Operator MAY wire only the session side OR only the provider side §2.3 (paragraph 2 "Mix-and-match")
Surfaces contract pinch points before they ossify (= our verification stance) §2.3 (bullet 4)

Verification approach

  • src/session/create-session.test.ts — pure-unit tests over mapBetterAuthUserToPrincipal; missing-fields, extra-claims, scope translation.
  • src/session/create-session.spec-conformance.test.ts — runs runAuthSessionConformance against the factory backed by a real Postgres container.
  • src/provider/create-provider.spec-conformance.test.ts — runs runAuthProviderConformance against the factory backed by a real Postgres container.
  • src/oauth/github-oauth.test.ts (integration, 60000ms) — end-to-end GitHub OAuth flow against a mock OAuth server; pins state-cookie handling, code exchange, principal mapping.
  • src/route-prefix/route-prefix.test.ts — configurable prefix; collision detection vs host-reserved prefixes raises AUTH_BA_ROUTE_PREFIX_COLLISION.
  • src/independence/session-without-provider.test.ts — operator wires only the session adapter; assert provider routes are NOT mounted AND session resolution still works.
  • tests/integration/postgres-better-auth.test.ts (60000ms) — full lifecycle: register a user via provider routes, log in, hit a protected route, verify ctx.principal reflects the Better-Auth user.

Migration impact

None — additive only.