docs: add client magic link auth replacement design

This commit is contained in:
Arthur Belleville 2026-04-30 18:21:38 +02:00
parent b1e9d74857
commit f3ea5ac76e
No known key found for this signature in database

View file

@ -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.