From f3ea5ac76e7771a9675bcfe9b00558c8678b72c7 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Thu, 30 Apr 2026 18:21:38 +0200 Subject: [PATCH] docs: add client magic link auth replacement design --- ...026-04-30-client-magic-link-auth-design.md | 500 ++++++++++++++++++ 1 file changed, 500 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-30-client-magic-link-auth-design.md diff --git a/docs/superpowers/specs/2026-04-30-client-magic-link-auth-design.md b/docs/superpowers/specs/2026-04-30-client-magic-link-auth-design.md new file mode 100644 index 0000000..6e68f5a --- /dev/null +++ b/docs/superpowers/specs/2026-04-30-client-magic-link-auth-design.md @@ -0,0 +1,500 @@ +# Client Magic Link Auth Replacement + +**Date**: 2026-04-30 +**Status**: Approved +**Supersedes**: +- `docs/superpowers/specs/2026-04-15-client-magic-links-design.md` +- `docs/superpowers/specs/2026-04-18-client-password-invite-flow-design.md` + +## Overview + +Replace the current `profiles.is_client` model and Supabase-auth-based client portal flow with a fully separate client authentication system owned by the application backend. + +The new model is: + +- client identity lives in `public.clients`, not `auth.users` or `public.profiles` +- client access to tablos lives in a dedicated access table, separate from collaborator access +- invitation and login both use magic links sent by email +- the magic link is a one-time identity proof +- successful exchange sets a persistent stateless JWT session cookie +- `apps/clients` authenticates against backend-owned cookies and backend APIs, not browser Supabase Auth sessions + +This keeps Supabase as the database, but removes Supabase Auth from the client-portal trust model. + +## Problem Statement + +The current implementation couples client access to the main authentication system too tightly. + +### Current issues + +1. `profiles.is_client` models client users as a special subtype of main authenticated users. +2. Client identity currently depends on `auth.users` and `public.profiles`, which conflicts with the requirement for a separate client auth system. +3. `apps/clients` currently depends on Supabase Auth sessions and browser-side Supabase reads. +4. The existing invite flow is centered around onboarding a Supabase-authenticated account rather than establishing a standalone client session model. +5. The current architecture makes it hard to express a clean separation between collaborator auth and client auth. + +## Goals + +- Remove `profiles.is_client` as the source of truth for client identity +- Introduce a separate client identity table in the public schema +- Support one global client identity per email across all tablos +- Use email magic links for both invitations and later self-serve login +- Exchange a valid link for a persistent stateless JWT session cookie +- Keep client access independent from Supabase Auth tables and sessions +- Move `apps/clients` to backend-authorized APIs instead of direct browser Supabase data access + +## Non-Goals + +- Keeping client users represented in `auth.users` +- Using Supabase Auth sessions for the client portal +- Building immediate session revocation into v1 +- Supporting self-service signup without invitation +- Embedding the complete authorization graph inside the session JWT + +## Approved Product Decisions + +The following decisions were explicitly approved during brainstorming: + +- client auth is separate from Supabase Auth +- the invite flow uses a magic link +- successful link exchange results in a persistent browser session +- client identity is global by normalized email, not scoped per tablo +- the session mechanism starts simple with a stateless JWT cookie +- access revocation does not need to terminate active sessions immediately +- when a session expires, clients can re-enter their email and receive a new login link +- `BetterAuth` is not required for the first version + +## Chosen Approach + +Implement a custom minimal client-auth layer in the backend instead of introducing `BetterAuth` in v1. + +The backend owns: + +- client identity records +- client access grants +- magic-link issuance and one-time consumption +- session cookie creation and verification +- authorization checks for client routes and client data + +`BetterAuth` remains a possible future evolution if richer auth lifecycle features become necessary, but it is not part of the first implementation. + +This approach is recommended because it fits the current codebase better than introducing a second auth framework while `apps/clients` is still being migrated away from direct Supabase browser access. + +## Architecture + +### Core boundary + +There are now two distinct auth systems: + +- collaborator auth for `app.xtablo.com` +- client auth for `clients.xtablo.com` + +They must not share session mechanisms or identity tables. + +### Trust model + +For the client portal: + +- the browser does not hold a Supabase Auth session +- the browser does not use `supabase.auth` for login state +- the browser does not directly query protected client data from Supabase +- the backend verifies the client cookie and performs authorization on every protected client API request + +### High-level flow + +1. An admin invites a client email to a tablo. +2. The backend upserts a client identity and ensures a client access grant exists. +3. The backend creates a one-time magic-link record and emails the link. +4. The client clicks the link. +5. The backend validates and consumes the link, then sets a persistent JWT cookie. +6. `apps/clients` uses that cookie for authenticated access to backend APIs. +7. When the cookie expires, the client can request a fresh login link by email. + +## Data Model + +### `public.clients` + +One row per normalized email. + +Suggested columns: + +| Column | Type | Notes | +|--------|------|-------| +| `id` | uuid or bigint PK | Stable client identity | +| `email` | text | Original or canonical email | +| `normalized_email` | text | Lowercased and normalized, unique | +| `first_name` | text nullable | Optional | +| `last_name` | text nullable | Optional | +| `phone` | text nullable | Optional | +| `last_login_at` | timestamptz nullable | Updated on successful login | +| `created_at` | timestamptz | Default `now()` | +| `updated_at` | timestamptz | Default `now()` | + +Required constraints: + +- unique index on `normalized_email` + +### `public.client_access` + +Source of truth for which tablos a client may access. + +Suggested columns: + +| Column | Type | Notes | +|--------|------|-------| +| `id` | bigint PK | | +| `client_id` | FK -> `clients.id` | | +| `tablo_id` | FK -> `tablos.id` | | +| `granted_by` | FK -> `profiles.id` | Collaborator/admin who granted access | +| `granted_at` | timestamptz | Default `now()` | +| `revoked_at` | timestamptz nullable | Null means active | +| `created_at` | timestamptz | Default `now()` | + +Required constraints: + +- one active access row per `(client_id, tablo_id)` + +### `public.client_magic_links` + +Tracks invite and login links and enforces one-time use. + +Suggested columns: + +| Column | Type | Notes | +|--------|------|-------| +| `id` | bigint PK | | +| `client_id` | FK -> `clients.id` | | +| `email` | text | Denormalized for audit/debugging | +| `purpose` | text | `invite` or `login` | +| `token_hash` | text nullable | Preferred if storing hashed token | +| `jti` | text nullable | Alternative lookup key if JWT carries `jti` | +| `redirect_to` | text nullable | Optional target route after exchange | +| `expires_at` | timestamptz | | +| `consumed_at` | timestamptz nullable | One-time-use marker | +| `created_by` | uuid nullable | Admin profile id for invite flow, null for self-serve login | +| `created_at` | timestamptz | Default `now()` | + +Notes: + +- even if the magic link payload is JWT-based, the backend still stores a row +- this table is required for one-time use, expiry enforcement, and auditability + +### Optional audit table + +`public.client_auth_events` is optional in v1 but recommended. + +Suggested event types: + +- `invite_issued` +- `login_link_issued` +- `magic_link_consumed` +- `login_failed` +- `logout` + +## Session Model + +### Cookie shape + +The backend issues a stateless JWT session cookie after a successful magic-link exchange. + +Recommended cookie settings: + +- `HttpOnly` +- `Secure` +- `SameSite=Lax` +- scoped to the client portal domain + +Suggested TTL: + +- start with `7 days` + +### JWT claims + +The session JWT should stay minimal and represent identity, not the full access graph. + +Suggested claims: + +- `sub`: `client_id` +- `email` +- `type`: `client_session` +- `iat` +- `exp` + +Optional future claim: + +- `access_version` + +### Revocation behavior + +This design intentionally accepts eventual revocation. + +If a client loses access while holding a still-valid cookie: + +- the cookie may remain structurally valid until expiry +- a new login link should not be issued once the client has no active access +- authorization checks for protected resources should still evaluate access against `client_access` + +This means the design does not need a server-stored session table to invalidate cookies early. The cookie lifecycle and the resource-authorization lifecycle remain separate. + +The design does not require a `client_sessions` table in v1. + +## End-To-End Flows + +### Admin invite flow + +1. A collaborator in `apps/main` invites a client email to a specific tablo. +2. The backend normalizes the email. +3. The backend upserts a row in `public.clients`. +4. The backend ensures an active `client_access` row exists for that `(client_id, tablo_id)`. +5. The backend creates a `client_magic_links` row with `purpose = 'invite'`. +6. The backend emails a one-time magic link pointing to a backend exchange endpoint or a portal route that immediately exchanges the token. +7. When consumed successfully, the link is marked used and cannot be reused. + +### First access + +1. The client clicks the invite link. +2. The backend validates: + - the token signature or identity + - the existence of the backing `client_magic_links` row + - not expired + - not already consumed +3. The backend sets the client session cookie. +4. The backend marks the link consumed. +5. The browser is redirected into `clients.xtablo.com`, ideally to the invited tablo route. + +### Self-serve login flow + +1. The client visits the client login page. +2. The client enters their email address. +3. The backend returns a neutral success response regardless of whether access exists. +4. If the email maps to a client with active access, the backend creates a `purpose = 'login'` magic link and sends the email. +5. The client clicks the link and goes through the same exchange path as an invite. + +### Logout + +1. The client chooses logout in the portal. +2. The backend clears the session cookie. +3. No DB session cleanup is required in v1. + +## Authorization Model + +### Principle + +The cookie proves client identity. Authorization remains server-side. + +The session JWT must not be treated as the source of truth for tablo-level permissions. + +### Per-request behavior + +On each protected client API request: + +1. verify the JWT cookie +2. load the client identity +3. check requested resource access against `client_access` +4. allow or deny based on active access + +### Why not store access in the JWT + +Embedding tablo access claims in the session token would make revocation and multi-tablo growth harder to manage. It is simpler and safer to keep the JWT small and query `client_access` during authorization. + +## API Design Implications + +### New backend responsibilities + +The backend needs a dedicated client-auth surface, for example: + +- invite a client to a tablo +- request a login link by email +- exchange a magic link for a cookie +- clear a client session cookie +- fetch current client identity +- fetch client-visible tablos and tablo-scoped resources + +### Client app data access + +`apps/clients` should move from this model: + +- browser Supabase session +- browser `supabase.from(...)` + +to this model: + +- browser requests to backend APIs +- cookie attached automatically by the browser +- backend performs identity resolution and authorization + +This is the most important architectural migration in the proposal. + +## Frontend Impact On `apps/clients` + +### Login UI + +The login page becomes an email-only form for magic-link requests. + +Behavior: + +- submit email +- show neutral confirmation state +- no password field +- no self-service signup + +### Session bootstrap + +The link target should perform an exchange step and then redirect into the client portal. + +Possible shapes: + +- backend endpoint directly exchanges and redirects +- frontend route receives the token and immediately calls the backend exchange endpoint + +The backend-driven exchange-and-redirect path is preferred because it reduces client-side token handling. + +### Auth gate + +`apps/clients` should no longer check Supabase session state. + +Instead it should: + +- call a backend "current client" endpoint or equivalent +- treat cookie-backed success as authenticated +- redirect unauthenticated users to the email login page + +### Protected data + +Every client-visible page should be backed by backend endpoints that enforce `client_access`. + +## Migration Strategy + +### Phase 1: Schema introduction + +- add `public.clients` +- add `public.client_access` +- add `public.client_magic_links` +- optionally add `public.client_auth_events` + +### Phase 2: Auth backend + +- add magic-link issuance endpoints +- add exchange endpoint +- add cookie verification middleware +- add logout endpoint + +### Phase 3: Client portal backend surface + +- add backend endpoints for client-visible data currently read directly from Supabase in `apps/clients` +- enforce client authorization there + +### Phase 4: Client portal frontend migration + +- replace Supabase Auth session handling with cookie-backed auth +- replace direct Supabase browser queries with backend API calls +- update logout behavior + +### Phase 5: Invite flow switch + +- stop creating or depending on `profiles.is_client` +- stop creating client auth users in Supabase Auth +- switch admin invite actions to the new client-auth flow + +### Phase 6: Cleanup + +- remove old `is_client` checks in middleware and frontend routing +- remove obsolete client invite/setup code that depends on Supabase Auth +- drop `profiles.is_client` when rollout is complete + +## Security And Failure Handling + +### Magic-link rules + +- magic links must be one-time use +- magic links must expire quickly relative to the session cookie +- expired, missing, or consumed links must fail clearly + +Suggested starting TTL: + +- invite/login link: `15 minutes` to `1 hour` + +### Login enumeration resistance + +Self-serve login initiation must return a neutral response such as: + +> If this email can access the client portal, a connection link has been sent. + +This avoids exposing which emails exist in the client system. + +### Rate limiting + +Rate limit login-link issuance by: + +- email +- source IP + +### Cookie failure handling + +If session verification fails: + +- clear the cookie +- return an unauthorized response or redirect to the client login page + +### Link reuse handling + +If a link was already consumed: + +- show a clear invalid-link state +- offer a way to request a fresh login link + +## Testing Strategy + +### Database tests + +- unique normalized email in `clients` +- unique active access for `(client_id, tablo_id)` +- one-time consumption behavior for `client_magic_links` + +### API tests + +- inviting a new email upserts a client and grants access +- inviting an already-known email reuses the same global client identity +- self-serve login only issues a link when active access exists, while still returning a neutral response shape +- exchange endpoint consumes a valid link exactly once +- expired or already-consumed links are rejected +- protected client endpoints require a valid cookie +- protected client endpoints enforce `client_access` +- logout clears the cookie + +### Frontend tests + +- login page sends email-link requests and shows a neutral success state +- invalid or expired links show the right recovery path +- successful exchange redirects to the intended tablo +- authenticated portal routes rely on backend-authenticated cookie state +- logout returns the user to the login page + +## Trade-Offs + +### Why this is simpler + +- no second auth framework in v1 +- no session table in v1 +- no dependency on Supabase Auth lifecycle for client users +- clean data separation between collaborators and clients + +### What this defers + +- immediate revocation of active sessions +- device/session inventory +- logout-all-devices +- richer account security features that a framework like `BetterAuth` could eventually help with + +## Recommendation + +Proceed with a custom backend-owned client auth system based on: + +- `public.clients` +- `public.client_access` +- `public.client_magic_links` +- one-time magic links for invite and login +- stateless JWT session cookie +- backend-only authorization for client portal resources + +This is the cleanest replacement for `profiles.is_client` and best matches the approved product direction.