Skip to content

Auth expansion β€” multi-provider sign-in, Pending membership, policy-driven login (Phase 1) ​

Date: 2026-07-14 Β· Status: approved (design review in chat) Β· Linear: pending (workspace at free issue limit) Branch: feature/auth-expansion-phase1

1. Context and goal ​

Dispatch is pivoting from a single-cooperative instance to a multi-organization SaaS. Today sign-in is Google-only and hard-gated to the gravitasacumen.org Workspace in four places: the handle_new_user DB trigger (rejects other domains), the hd: param the web client sends, the "Internal" Google consent screen, and the login-page copy. This phase opens authentication:

  • Google β€” any account (gate removed).
  • GitHub β€” new provider.
  • Apple β€” wired in code/config but feature-flagged off until an Apple Developer Program membership ($99/yr) and credentials exist.
  • Email + password β€” full flow: signup, email verification, password reset. Email (not username) is the identifier.

Organizations themselves (create/join/invites, subdomain and custom-domain resolution, per-org login settings, SAML) are Phase 2 β€” but Phase 1 builds the seams they plug into.

2. Decisions log ​

DecisionChoice
Org modelMulti-org B2B: sign up β†’ create or join an org (Phase 2). Phase 1 leaves new users in Pending.
Password identifierEmail + password (Supabase-native). No usernames.
Apple timingBuild behind a flag; enable when credentials exist.
Per-org "login IDs and keys"Per-org policy (method toggles) + per-org enterprise SAML SSO (org brings IdP keys; Supabase Pro). Social providers stay platform-owned OAuth apps. Phase 2.
Account linkingSupabase default: auto-link identities sharing a verified email β€” one human, one account. Unverified emails never auto-link.
Email verificationRequired for password signups (enable_confirmations = true locally to mirror hosted).
ApproachB β€” full auth revamp (dedicated routes, dev-bypass removal, MFA scaffold), not the minimal expansion.

3. Target architecture (recorded for Phase 2's anchor) ​

An org is resolved from the hostname β€” acme.dispatch.app (wildcard subdomain) or dispatch.acme.com (verified custom domain). The login page renders from a LoginPolicy:

ts
interface LoginPolicy {
  methods: ('google' | 'apple' | 'github' | 'password' | 'sso')[];
  samlDomain?: string; // present when the org has an enterprise SSO connection
}

Phase 2 feeds this from the resolved org's config and adds an org-admin Login settings page (method toggles + SAML connection management, enforced server-side, signInWithSSO({ domain }) client-side). Phase 1 hard-codes the platform default policy (all methods except sso; apple filtered out while environment.auth.appleEnabled is false).

4. Phase 1 design ​

4.1 Supabase config (supabase/config.toml) ​

  • [auth.external.github]: enabled = true, client_id = "env(GITHUB_CLIENT_ID)", secret = "env(GITHUB_SECRET)".
  • [auth.external.apple]: block present, enabled = false, commented env(APPLE_*) placeholders β€” flip on when credentials exist.
  • [auth.external.google]: unchanged block; the client stops sending hd:.
  • [auth.email]: enable_confirmations = true so local behaves like hosted prod (emails land in the bundled mail catcher).

4.2 Migrations (two files β€” enum constraint) ​

Postgres refuses to use an enum value in the same transaction that ADD VALUEs it, and Supabase wraps each migration in a transaction β€” so this is two migration files, not one:

  1. File A: alter type public.membership_role add value 'Pending'; (nothing else).
  2. File B: everything below.

Then, in file B:

  1. Make 'Pending' the membership_role column default on public.users.
  2. Rewrite handle_new_user: no domain rejection. Insert the profile for every new auth user with membership_role = 'Pending'; keep the admin-allowlist promotion (allow-listed emails still become Admin). Display name from provider metadata in precedence order: raw_user_meta_data->>'full_name', ->>'name', ->>'user_name' (GitHub), else the email local-part. (Apple supplies the name only on first consent; same keys.)
  3. RLS hardening: public.is_member() β€” a stable SQL function reading the JWT claim the existing access-token hook already injects: coalesce(auth.jwt()->>'membership_role', 'Pending') <> 'Pending' (no table lookup). Sweep every existing policy that grants member-data access to authenticated and add is_member(); Pending users keep exactly two rights: select/update on their own public.users row. The access-token hook itself is unchanged (it already emits whatever role the profile has).

membership_role claim note: Go's RequireRole lists don't include Pending, so the API rejects Pending users by construction; libs/auth gains a RolePending constant and tests proving it's excluded.

4.3 Angular auth surface ​

Routes (all standalone, external templates per repo convention):

RoutePage
/loginPolicy-driven: OAuth buttons rendered from LoginPolicy.methods (Google, GitHub; Apple appears when flagged on) + email/password panel + links to /signup, /reset-password.
/signupName, email, password β†’ signUp() β†’ "check your email" notice with resend.
/verifyLanding for the confirmation link; success β†’ session β†’ redirect by role; error β†’ resend option.
/reset-passwordEmail form β†’ requestPasswordReset() β†’ sent notice.
/reset-password/updateNew-password form (session arrives via the recovery link / PASSWORD_RECOVERY event).
/pendingAuthenticated Pending users land here: "you're in β€” awaiting an organization", sign-out. This page is the Phase 2 seam (it becomes create-org / join-by-invite).
/auth/callbackUnchanged.

AuthService changes:

  • signInWithGoogle() β†’ signInWithProvider(p: 'google' | 'github' | 'apple'); drops hd:, keeps prompt: 'select_account' for Google only.
  • New: signInWithPassword(email, pw), signUp(name, email, pw), requestPasswordReset(email), updatePassword(pw), resendVerification(email), signInWithSSO(domain) stub (throws "not available" β€” Phase 2).
  • Role union gains 'Pending'; MANAGER_ROLES unchanged.
  • Guards: member routes redirect Pending β†’ /pending; /pending requires a session; auth pages redirect signed-in non-Pending users to / (the existing role-based landing).
  • environment.workspaceDomain is deleted if nothing references it after the hd: removal (the admin feature has its own DOMAIN constant and is untouched).

Dev bypass removed entirely: the login button, environment.devAuthBypass/devBypass, canDevBypass, isLoopbackHost/isLocalSupabase, and signInAsDevManager are deleted. The seeded dev principal (script kept, scripts/dev-principal-bypass.sh renamed to dev-principal-seed.sh) signs in through the real password form. E2E and unit tests that used the bypass switch to password sign-in.

Copy: auth surfaces say organization, never cooperative/Workspace; the login page no longer names a domain.

4.4 MFA scaffolding (TOTP) β€” cuttable if Phase 1 must shrink ​

Supabase-native auth.mfa:

  • Enroll: a "Security" section (new /settings/security page): QR + manual secret, verify a code to activate, list + remove the factor.
  • Challenge: after password sign-in, if the account has a verified factor (getAuthenticatorAssuranceLevel() reports nextLevel = 'aal2'), show a code prompt before proceeding.
  • Out of scope: enforcing MFA on OAuth sign-ins, recovery codes, admin MFA policies.

4.5 Go API ​

  • libs/auth: RolePending constant; tests that RequireRole(ManagerRoles...) and member-level checks all reject it. JWT verification itself is provider-agnostic β€” no other change.
  • apps/api/internal/admins: unchanged β€” the back-office allowlist remains @gravitasacumen.org (platform operators, not org members).

4.6 Email delivery and ops prerequisites (mirror of the GAC-48 pattern) ​

ItemWhere
Google consent screen Internal β†’ ExternalGoogle Cloud Console (existing client)
GitHub OAuth app (prod): callback https://<project-ref>.supabase.co/auth/v1/callbackGitHub β†’ Settings β†’ Developer settings β†’ OAuth Apps
GitHub OAuth app (local): callback http://127.0.0.1:54321/auth/v1/callback; GITHUB_CLIENT_ID / GITHUB_SECRET into .env + .env.examplesame
SMTP provider for hosted Supabase (verification + reset emails); suggestion: ResendSupabase Dashboard β†’ Auth β†’ SMTP
Enable Google/GitHub providers + redirect URLs on the hosted projectSupabase Dashboard β†’ Auth β†’ Providers
Apple: Services ID, key, team ID β€” when the developer account existsApple Developer portal β†’ flip appleEnabled + config

4.7 Error handling ​

  • All AuthService methods return a typed { ok: true } | { ok: false; message: string } result; pages render inline DaisyUI alerts (no toasts for auth errors). Supabase error codes mapped to human copy: wrong credentials, unconfirmed email (offer resend), email already registered (offer sign-in/reset), weak password, expired/used recovery link (offer re-request), rate limits.
  • OAuth callback errors (user cancelled, provider error) surface on /login via query-param detection in the callback page.
  • A Pending user hitting any API route gets 403 (existing behavior for unknown roles) β€” the web app never calls member APIs while routing them to /pending.

4.8 Testing ​

  • Unit (web): AuthService methods with a mocked Supabase client; guard matrix (signed out / Pending / member / manager Γ— route classes); each new page component (form validation, error rendering, policy-driven button set).
  • Unit (Go): RolePending exclusion in libs/auth.
  • E2E (Playwright, local stack): password signup β†’ fetch confirmation link from the mail-catcher HTTP API β†’ verify β†’ land on /pending; password sign-in as seeded member; full reset flow; Pending-user redirect. OAuth flows: manual checklist in the PR (not headlessly automatable).
  • Coverage gate (nx test web --configuration=coverage) stays green.

5. Phase 2 outline (separate spec, not started) ​

Org entity + membership + invites; hostname→org resolution (wildcard DNS on an apex domain, custom-domain verification, Cloudflare Pages multi-domain, per-domain Supabase redirect allowlist, dynamic API CORS); org Login settings page (method toggles + SAML connections); per-org LoginPolicy storage with server-side enforcement; signInWithSSO for real; /pending becomes create-org / join-by-invite. Ops flags: SAML needs Supabase Pro; domains need the apex + DNS setup. Product-wide cooperative→organization rename rides along.

6. Non-goals (Phase 1) ​

Org CRUD/invites, domain resolution, SAML, per-org policy storage, username login, MFA on OAuth, recovery codes, admin-allowlist changes, terminology rename outside auth surfaces.