500 lines
16 KiB
Markdown
500 lines
16 KiB
Markdown
# 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.
|