From a7fe828d325a9bcf972abf45e7f6be6f65aa55ec Mon Sep 17 00:00:00 2001 From: Till JS Date: Sun, 19 Apr 2026 19:45:42 +0200 Subject: [PATCH] refactor(auth): extract sso-origins SSOT + harden drift test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TRUSTED_ORIGINS was defined inside better-auth.config.ts, which pulls in the whole Better Auth stack just to read a list of hostnames. Anyone who wants to consume the list (infra tooling, compose-env generators, monitoring) had to either duplicate it or pay the import cost. - New `sso-origins.ts` — zero-dep module exposing `PRODUCTION_TRUSTED_ORIGINS` + `LOCAL_TRUSTED_ORIGINS` + the combined `TRUSTED_ORIGINS` list. This is now the canonical place to add a new top-level SSO origin. - `better-auth.config.ts` imports + re-exports so existing consumers keep working without a touch. - `sso-config.spec.ts` imports directly from `./sso-origins` (cleaner coupling) and now HARD-FAILS when mana-auth CORS_ORIGINS contains a production origin that isn't in trustedOrigins. Previously this was a `console.warn` only, meaning dead-drift could silently accumulate and then surface as a confusing runtime auth rejection. - Root CLAUDE.md "Adding an app to SSO" updated to point at the SSOT and mention the new hard-fail direction. No current drift — the mana-auth CORS_ORIGINS already match. The hardened assertion is defensive for future changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 4 +-- .../mana-auth/src/auth/better-auth.config.ts | 30 +++------------- .../mana-auth/src/auth/sso-config.spec.ts | 23 ++++++------ services/mana-auth/src/auth/sso-origins.ts | 35 +++++++++++++++++++ 4 files changed, 51 insertions(+), 41 deletions(-) create mode 100644 services/mana-auth/src/auth/sso-origins.ts diff --git a/CLAUDE.md b/CLAUDE.md index e8e2dc047..efcfcb9cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,9 +112,9 @@ Default new user-typed fields to **encrypt**; default new IDs/timestamps/sort-ke All servers use `@mana/shared-hono` with `authMiddleware()`. Tokens are EdDSA JWTs issued by `mana-auth` with claims `{sub, email, role, sid, tier, exp, iss, aud}`. Cross-app SSO works across `*.mana.how`. See [`.claude/guidelines/authentication.md`](.claude/guidelines/authentication.md) and `services/mana-auth/`. **Adding an app to SSO** requires updating *all three*: -1. `trustedOrigins` in `services/mana-auth/src/auth/better-auth.config.ts` +1. `PRODUCTION_TRUSTED_ORIGINS` in `services/mana-auth/src/auth/sso-origins.ts` (the SSOT — better-auth.config.ts re-exports from here) 2. `CORS_ORIGINS` for mana-auth in `docker-compose.macmini.yml` -3. Run `pnpm test -- src/auth/sso-config.spec.ts` from `services/mana-auth/` +3. Run `bun test src/auth/sso-config.spec.ts` from `services/mana-auth/` — now hard-fails on drift in either direction ### Access tiers diff --git a/services/mana-auth/src/auth/better-auth.config.ts b/services/mana-auth/src/auth/better-auth.config.ts index 2181958f3..a93709408 100644 --- a/services/mana-auth/src/auth/better-auth.config.ts +++ b/services/mana-auth/src/auth/better-auth.config.ts @@ -37,33 +37,11 @@ import { sendMagicLinkEmail, } from '../email/send'; import { sourceAppStore, passwordResetRedirectStore } from './stores'; +import { TRUSTED_ORIGINS } from './sso-origins'; -/** - * Single source of truth for SSO trusted origins. - * - * Better Auth rejects any cross-origin auth request whose Origin header - * isn't in this list — silent login failure on mis-configured apps. When - * adding a new top-level domain (NOT a path under mana.how), update both: - * - * 1. This array - * 2. The `mana-auth` `CORS_ORIGINS` env var in - * `docker-compose.macmini.yml` (must be a superset of this list) - * - * `sso-config.spec.ts` enforces both invariants. The unified app under - * `mana.how` does NOT need per-module subdomains here — modules are routed - * by path on the same origin. - */ -export const TRUSTED_ORIGINS: string[] = [ - // Unified app — all productivity apps live under mana.how - 'https://mana.how', - 'https://auth.mana.how', - // Separate apps (not part of the unified app) - 'https://arcade.mana.how', // Games - 'https://whopxl.mana.how', // Games - // Local development - 'http://localhost:3001', - 'http://localhost:5173', -]; +// Re-export so existing imports (`import { TRUSTED_ORIGINS } from './better-auth.config'`) +// keep working. New code should import from './sso-origins' directly. +export { TRUSTED_ORIGINS }; /** * JWT Custom Payload Interface diff --git a/services/mana-auth/src/auth/sso-config.spec.ts b/services/mana-auth/src/auth/sso-config.spec.ts index e57ea962b..522dedc89 100644 --- a/services/mana-auth/src/auth/sso-config.spec.ts +++ b/services/mana-auth/src/auth/sso-config.spec.ts @@ -26,7 +26,7 @@ import { describe, it, expect } from 'bun:test'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { TRUSTED_ORIGINS } from './better-auth.config'; +import { TRUSTED_ORIGINS } from './sso-origins'; const REPO_ROOT = join(import.meta.dir, '../../../..'); const COMPOSE_FILE = join(REPO_ROOT, 'docker-compose.macmini.yml'); @@ -106,21 +106,18 @@ describe('SSO ↔ docker-compose CORS_ORIGINS consistency', () => { expect(missing).toEqual([]); }); - it('reports CORS_ORIGINS entries that are NOT in trustedOrigins (dead drift)', () => { - // This is a soft assertion — extra entries don't break the SSO - // loop, but they're a sign of stale config that should be cleaned - // up. We log them so they're visible in CI without failing the - // build. Convert to a hard expect() once the cleanup ships. + it('mana-auth CORS_ORIGINS contains NO entries outside trustedOrigins (no dead drift)', () => { + // Hard-fail on extras: if CORS lists an origin Better Auth doesn't + // trust, the server accepts the preflight but then silently rejects + // the auth request — worst-of-both-worlds. Tightened from a warning + // to a hard assertion on 2026-04-19 per audit. + // Fix: either add the origin to TRUSTED_ORIGINS (in sso-origins.ts) + // or remove it from the mana-auth CORS_ORIGINS in + // docker-compose.macmini.yml. const extras = corsOrigins.filter( (o) => o.startsWith('https://') && !TRUSTED_ORIGINS.includes(o as (typeof TRUSTED_ORIGINS)[number]) ); - if (extras.length > 0) { - console.warn( - `[sso-config.spec] mana-auth CORS_ORIGINS contains ${extras.length} origin(s) not in trustedOrigins (likely stale post-consolidation): ${extras.join(', ')}` - ); - } - // No assertion — see comment above. Will be tightened once - // audit item from REFACTORING_AUDIT_2026_04.md lands. + expect(extras).toEqual([]); }); }); diff --git a/services/mana-auth/src/auth/sso-origins.ts b/services/mana-auth/src/auth/sso-origins.ts new file mode 100644 index 000000000..21cba495a --- /dev/null +++ b/services/mana-auth/src/auth/sso-origins.ts @@ -0,0 +1,35 @@ +/** + * Single source of truth for SSO trusted origins. + * + * Extracted into a standalone module (no Better Auth imports) so it can + * also be consumed by infra tooling (compose env generators, monitoring + * jobs, etc.) without pulling in the full auth stack. + * + * Better Auth rejects any cross-origin auth request whose Origin header + * isn't in this list — silent login failure on mis-configured apps. When + * adding a new top-level domain (NOT a path under mana.how), update both: + * + * 1. `PRODUCTION_TRUSTED_ORIGINS` below + * 2. The `mana-auth` `CORS_ORIGINS` env var in + * `docker-compose.macmini.yml` (must be a superset of this list) + * + * `sso-config.spec.ts` enforces both invariants. The unified app under + * `mana.how` does NOT need per-module subdomains here — modules are routed + * by path on the same origin. + */ + +/** HTTPS origins Better Auth accepts in production. */ +export const PRODUCTION_TRUSTED_ORIGINS = [ + // Unified app — all productivity apps live under mana.how + 'https://mana.how', + 'https://auth.mana.how', + // Separate apps (not part of the unified app) + 'https://arcade.mana.how', // Games + 'https://whopxl.mana.how', // Games +] as const; + +/** Local dev origins — web dev server + the auth server itself. */ +export const LOCAL_TRUSTED_ORIGINS = ['http://localhost:3001', 'http://localhost:5173'] as const; + +/** Full trusted-origins list passed to Better Auth. */ +export const TRUSTED_ORIGINS: string[] = [...PRODUCTION_TRUSTED_ORIGINS, ...LOCAL_TRUSTED_ORIGINS];