¶ Browser
Starts at ReplyBox, completes Google OAuth at UtopiaSpace, then returns to the ReplyBox callback carrying only an opaque ticket.
Source: docs/utopia-sso-design.md
Let users sign into ReplyBox with their UtopiaSpace (Supabase Google) identity, without rewriting ReplyBox's existing auth. After SSO, ReplyBox issues its own JWT cookies, and existing org membership + RBAC continue to work unchanged.
2. Architecture summary
UtopiaSpace (utopia-hub) is Vite + React with Supabase Edge Functions (Deno) as its only server surface. There is no Node/Next backend. ReplyBox's FastAPI calls replybox-sso-verify directly; it never sees Supabase Auth.
Starts at ReplyBox, completes Google OAuth at UtopiaSpace, then returns to the ReplyBox callback carrying only an opaque ticket.
Sets signed state, redeems the ticket server-to-server, finds or links the user, and issues ReplyBox cookies.
replybox-sso-issue — authenticated; mints a one-time ticket and redirects to ReplyBox. replybox-sso-verify — server-to-server; ReplyBox calls it to redeem a ticket.
Browser ReplyBox API (FastAPI) UtopiaSpace (Supabase Edge Fns) Supabase
|
| GET /auth/utopia/login
|------------------------>|
| 302 -> Utopia login (redirect_uri = ReplyBox callback)
|<------------------------|
|
| (Google OAuth at UtopiaSpace; user already / now authenticated)
|--------------------------------------------> issue fn
| check replybox.access; mint ticket T; store sha256(T)
| 302 -> ReplyBox /auth/utopia/callback?ticket=T
|<--------------------------------------------|
|
| GET /auth/utopia/callback?ticket=T
|------------------------>|
| | POST verify (HMAC-signed, body={ticket:T})
| |------------------------------------------> verify fn
| | burn ticket; return {profile_id,email,name,avatar}
| |<------------------------------------------|
| | find-or-link User by utopia_profile_id, else by email
| | _issue_cookies(response, user)
| 302 -> dashboard + Set-Cookie replybox_access / replybox_refresh
|<------------------------|
1. Goal
replybox_access / replybox_refresh cookies.
RBACOrg membership and permissions keep working unchanged.
Existing usersSSO links to existing rows.
Non-goals
profile_id is never placed in a URL; it is exchanged server-to-server via a one-time ticket.
Login flow
GET /auth/utopia/login sets a signed state cookie and 302s to the UtopiaSpace login with the ReplyBox callback as redirect_uri.
Google OAuth at UtopiaSpace; the user is already, or now, authenticated.
The issue fn checks replybox.access, mints ticket T, stores sha256(T), and sends the browser to /auth/utopia/callback?ticket=T.
ReplyBox POSTs the HMAC-signed body {ticket:T} to verify, which burns the ticket and returns the profile.
Find-or-link the user by utopia_profile_id, else by email; _issue_cookies(response, user); 302 to the dashboard.
3. Trust & security model
The trust boundary is a valid response from replybox-sso-verify, authenticated by a shared secret. ReplyBox does not validate Supabase JWTs and has no Supabase keys.
sha256(ticket).
One-timeRedeemed (burned) on first verify; replays fail.
Short-livedTTL 120s.
Bound to ReplyBoxThe ticket row records the intended audience.
Verify call authHMAC header scheme mirroring the connector pattern in apps/api/app/security/hmac.py: HMAC-SHA256(secret, "{ts}\n{METHOD}\n{path}\n{body}") in X-Replybox-Timestamp / X-Replybox-Signature.
SecretUTOPIA_SSO_SHARED_SECRET, distinct from INTERNAL_HMAC_SECRET. A static bearer secret was acceptable for v1; HMAC preferred for replay resistance.
No PII in URLThe callback URL carries only the opaque ticket. profile_id, email, name come back in the verify response body, server-to-server.
CSRF / redirect safety/auth/utopia/login sets a signed state cookie; the callback validates it. The final redirect is restricted to an allowlisted internal path (no open redirect).
4. ReplyBox database change
0056_utopia_ssodown_revision = "0041_message_deleted" (current head at time of writing).
Adds utopia_profile_id, utopia_linked_at, and auth_provider. Existing rows: auth_provider='password', utopia_profile_id=NULL — untouched.
password_hash nullableSo SSO-only users need no password.
uq_users_utopia_profile_id: many NULLs allowed, but ≤1 ReplyBox user per Utopia profile.
downgrade() drops the index, the three columns, and (best-effort) re-tightens password_hash to NOT NULL only if no NULLs exist.
op.add_column("users", sa.Column("utopia_profile_id", postgresql.UUID(as_uuid=True), nullable=True))
op.add_column("users", sa.Column("utopia_linked_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("users", sa.Column("auth_provider", sa.String(length=20),
nullable=False, server_default="password"))
op.alter_column("users", "password_hash", existing_type=sa.String(255), nullable=True)
op.create_index(
"uq_users_utopia_profile_id", "users", ["utopia_profile_id"], unique=True,
postgresql_where=sa.text("utopia_profile_id IS NOT NULL"),
)
password_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
utopia_profile_id: Mapped[Optional[UUID]] = mapped_column(PG_UUID(as_uuid=True), nullable=True)
utopia_linked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
auth_provider: Mapped[str] = mapped_column(String(20), nullable=False,
default="password", server_default="password")
5. ReplyBox API changes
5.1 Config
Real values discovered in utopia-hub/.env + supabase/config.toml.
UTOPIA_SSO_ENABLED: bool = False
# UtopiaSpace prod frontend = https://utopia-hub.vercel.app (login route: /login)
UTOPIA_LOGIN_URL: str = "https://utopia-hub.vercel.app/login"
# Supabase Edge Functions base = https://fxfxensvinkgmipaylrs.functions.supabase.co
UTOPIA_SSO_VERIFY_URL: str = "https://fxfxensvinkgmipaylrs.functions.supabase.co/replybox-sso-verify"
UTOPIA_SSO_SHARED_SECRET: str = "" # HMAC secret shared with the verify Edge Function (set per-env)
UTOPIA_SSO_REDIRECT_PATH: str = "/inbox" # post-login landing (allowlisted internal path)
UTOPIA_SSO_ENABLED, require the URL + secret values.
Login redirectThe redirect to UTOPIA_LOGIN_URL carries ?app=replybox&redirect_uri=<api>/auth/utopia/callback.
Login URL correctionLater fixed from .../login to .../replybox-sso; see 6.5.
5.2 Routes
New router, prefix /auth/utopia (under /api/v1).
/auth/utopia/statusSSO enabled flag; gates the web button (Phase D).
GET/auth/utopia/loginUnauthenticated. 302 to UTOPIA_LOGIN_URL with ?app=replybox&redirect_uri=<api>/auth/utopia/callback. Sets a short-lived signed state cookie, mode=login (CSRF + flow marker).
GET/auth/utopia/linkRequires the current ReplyBox session (CurrentUser dep). Same redirect, but signed state carries mode=link + the current user.id. Entry point for the different-email flow.
GET/auth/utopia/callback?ticket=...&state=...Validate state (signature + expiry); reject missing/expired ticket. utopia_sso.redeem_ticket(ticket) → {profile_id, email, full_name, avatar_url}. If state.mode == "link": link_to_user(db, user_id=state.user_id, profile) (ignores email); else user = await utopia_sso.find_or_link_user(db, profile) (by profile_id, then email, then create). _issue_cookies(response, user) → 302 to UTOPIA_SSO_REDIRECT_PATH.
POST/auth/utopia/unlinkRequires session. Allowed only if password_hash IS NOT NULL. Clears utopia_profile_id / utopia_linked_at; sets auth_provider='password'.
replybox_utopia_state cookie (CSRF + mode/uid).
5.3 Service
async def redeem_ticket(ticket: str) -> UtopiaProfile:
# POST UTOPIA_SSO_VERIFY_URL with HMAC headers, body {"ticket": ticket}.
# 200 -> parse profile; non-200 -> HTTPException(401, "invalid SSO ticket").
async def find_or_link_user(db, profile: UtopiaProfile) -> User:
# 1) by utopia_profile_id -> return (already linked).
# 2) else by email (CITEXT) -> LINK: set utopia_profile_id, utopia_linked_at,
# auth_provider stays 'password' (keeps password login available). commit.
# 3) else CREATE: User(email, display_name, avatar_url,
# password_hash=None, auth_provider='utopia',
# utopia_profile_id=..., utopia_linked_at=now). NO organization assigned.
# set last_login_at. commit.
User row with no org membership. They authenticate but land in an empty state until an admin invites them. RBAC is unaffected because it is keyed on memberships, of which they have none yet.
5.4 Password-login guard
In authenticate(), after loading the user:
if user is None or user.password_hash is None or not verify_password(...) or not user.is_active:
raise HTTPException(401, "invalid credentials")
Prevents SSO-only (passwordless) accounts from being brute-forced via the password endpoint and avoids a verify_password(None) crash.
5.5 What does NOT change
get_current_user
/auth/refresh
/auth/logout
/auth/me
Org membership
require_permission
All RBAC
They key on user.id from the cookie and are agnostic to auth method. SSO sessions are indistinguishable from password sessions downstream.
Security note (out of scope but flagged): utopia-hub/.env ships a service_role JWT under the VITE_SUPABASE_ANON_KEY name; VITE_ vars are bundled into the browser. Rotate the anon/service keys independently of this work — it does not block SSO.
5.6 Account-linking flows
There are two ways an existing ReplyBox user becomes linked. Register stays as a fallback throughout.
User clicks "Login with UtopiaSpace". verify returns an email equal to an existing ReplyBox user's email (CITEXT match). ReplyBox links utopia_profile_id onto that row and issues cookies.
Trust basis: the email is Google-verified and arrives server-to-server, so it proves ownership. auth_provider stays 'password' so password login keeps working.
Required when emails don't match. Never guess a match across different emails; the user links while logged in:
GET /auth/utopia/link.
3SSO round-trip runs with state.mode=link carrying the current user.id.
4Callback links the returned profile_id to the currently authenticated user, ignoring email. Explicit intent = unambiguous, secure.
5Future SSO logins match by profile_id, so the differing emails never matter again.
A login-mode SSO whose email matches nobody creates a user with password_hash=None, auth_provider='utopia', and no organization.
Do we prompt? Yes. For password users with utopia_profile_id IS NULL, show a one-time banner / Settings card: "Connect your UtopiaSpace account for one-click login." Same-email users may just click the login button; different-email users must use Flow 2.
profile_id linked to a different ReplyBox user → reject: "This UtopiaSpace account is already linked to another ReplyBox account."
No session on linkLink callback with no valid session cookie → treat as login, not link.
Unlink guardUnlink only when password_hash IS NOT NULL, otherwise the user would be locked out.
Login-mode matchEmail matches an existing user → auto-link (Flow 1); matches nobody → create-user-no-org.
6. UtopiaSpace (utopia-hub) changes — as built (Phase B)
The permission model is permissions(resource, action) + role_permissions + memberships, checked by the existing has_permission(resource, action) SQL function (keys off auth.uid()). profiles has id, email, full_name, profile_picture_url, deleted_at, and user_status constrained to pending|active|inactive|resigned.
6.1 Migration
supabase/migrations/20260619120000_replybox_sso.sql
public.replybox_sso_tickets (id, ticket_hash unique, profile_id FK→profiles ON DELETE CASCADE, audience default 'replybox', created_at, expires_at, redeemed_at), index on expires_at.
RLSEnabled with no policies → server-only (service role bypasses RLS).
SeedPermission row ('replybox','access') (idempotent).
Functionpublic.user_has_permission(p_user uuid, p_resource text, p_action text) — a SECURITY DEFINER variant of has_permission that takes an explicit user id (the verify function has no auth.uid() session).
6.2 Access gate
Modeled as resource='replybox', action='access'. Grant it via role_permissions to whichever roles may enter ReplyBox (assignment is a data step, not code). Issue refuses to mint a ticket without it.
6.3 Edge Function
SPA-invoked, JWT-verified. config.toml: verify_jwt = true. Called by the logged-in SPA via supabase.functions.invoke('replybox-sso-issue') (session token auto-attached).
admin.auth.getUser(token) → profile_id).
2Checks replybox.access (user-scoped has_permission, or user_has_permission fallback). 403 if not.
3Mints ticket = base64url(32 random bytes); stores only sha256(ticket) with TTL 120s.
4Returns { ticket, callback_url, expires_in }.
callback_url is server-configured (REPLYBOX_CALLBACK_URL), NOT client-supplied → no open-redirect. The SPA then sets window.location to ${callback_url}?ticket=${ticket}. profile_id never reaches the browser.
6.4 Edge Function
Server-to-server, HMAC-authed. config.toml: verify_jwt = false (no Supabase session; ReplyBox signs with HMAC).
app/security/hmac.py): message = ${ts}\nPOST\n${SIGN_PATH}\n${rawBody}, signature = hex(HMAC_SHA256(secret, message)). Timestamp tolerance REPLYBOX_SSO_TS_TOLERANCE_SECONDS (default 300s for cross-cloud skew). Timing-safe compare.
2Burns the ticket atomically (below). Zero rows → 401 invalid, expired, or used ticket.
3Re-checks replybox.access for profile_id (defense-in-depth) → 403 if revoked.
4Loads the profile; rejects deleted or non-active profiles (403 inactive_profile).
5Returns { profile_id, email, full_name, avatar_url (=profile_picture_url), user_status }.
update … set redeemed_at=now() where ticket_hash=$1 and
redeemed_at is null and expires_at>now() and audience='replybox' returning profile_id
6.5 SPA hand-off route — as built
src/routes/ReplyboxSso.tsx, registered in src/main.tsx as a public sibling of sign-in / auth/callback (outside MainLayout's session guard).
ReplyBox sends the browser to UTOPIA_LOGIN_URL = https://utopia-hub.vercel.app/replybox-sso.
<Navigate to="/sign-in?callbackUrl=/replybox-sso"> reuses the OAuth round-trip; AuthCallback returns here after Google login.
supabase.functions.invoke('replybox-sso-issue') → { ticket, callback_url } → window.location.assign(${callback_url}?ticket=${ticket}). 403 no_replybox_access → friendly error.
Route correction: utopia-hub's sign-in route is /sign-in, NOT /login. ReplyBox's UTOPIA_LOGIN_URL was fixed from .../login to .../replybox-sso.
Local port clash: utopia-hub's Vite dev server uses :3000 (same as ReplyBox web). For local testing, point UTOPIA_LOGIN_URL at the deployed utopia-hub, or run utopia-hub on another port.
6.6 Edge Function env vars (set per Supabase environment)
SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEYbothStandard (already present).
SUPABASE_ANON_KEYissueUser-scoped client for has_permission (optional; falls back).
REPLYBOX_CALLBACK_URLissueReplyBox callback, e.g. https://<api>/auth/utopia/callback.
REPLYBOX_SSO_TICKET_TTL_SECONDSissueDefault 120.
REPLYBOX_SSO_SHARED_SECRETverifyMust equal ReplyBox UTOPIA_SSO_SHARED_SECRET.
REPLYBOX_SSO_TS_TOLERANCE_SECONDSverifyDefault 300.
Phase C contract lock
ReplyBox's redeem_ticket() must POST to UTOPIA_SSO_VERIFY_URL, sign with the SAME secret, the constant path "/replybox-sso-verify", method POST, and send { "ticket": … } as the exact raw body that was signed (serialize once, sign and send the identical bytes).
"/replybox-sso-verify" — NOT the live URL path. Avoids Supabase's /functions/v1/... prefix breaking the signature.
Verify URLhttps://fxfxensvinkgmipaylrs.functions.supabase.co/replybox-sso-verify
SecretsReplyBox UTOPIA_SSO_SHARED_SECRET == Edge Function REPLYBOX_SSO_SHARED_SECRET.
7–8. Web change & rollout
Web: add a "Login with UtopiaSpace" button on the login page that links to GET /auth/utopia/login. Render it only when SSO is enabled (small /auth/config flag or build env). The password form remains the default during transition.
No behavior change: migration 0056_utopia_sso, config keys + validator, password-login guard, User model fields. Existing logins unaffected.
replybox_sso_tickets table + user_has_permission + replybox.access seed (migration 20260619120000_replybox_sso.sql), replybox-sso-issue + replybox-sso-verify Edge Functions, config.toml verify_jwt blocks.
app/api/v1/auth_utopia.py + app/services/utopia_sso.py, registered in app/api/v1/__init__.py. Routes under /api/v1: status, login, link (auth), callback, unlink (auth).
Behind the enable flag, gated on GET /auth/utopia/status.
Deployment wiring: point the Edge Function's REPLYBOX_CALLBACK_URL at https://<api-host>/api/v1/auth/utopia/callback, set ReplyBox UTOPIA_SSO_VERIFY_URL + UTOPIA_SSO_SHARED_SECRET (== the Edge Function's REPLYBOX_SSO_SHARED_SECRET), then UTOPIA_SSO_ENABLED=true.
9–10. Test plan & decisions
9
utopia_profile_id set), still able to password-login.
Returning SSO user → matched by utopia_profile_id (fast path).
Replay same ticket → 401 (burned).
Expired ticket (>120s) → 401.
Tampered/forged verify response (bad HMAC) → rejected.
User without replybox.access → issue fn refuses; no ticket minted.
Refresh, logout, org-scoped RBAC endpoints work identically for SSO sessions.
Partial unique index: two ReplyBox users cannot link the same profile_id.
10
app/security/hmac.py), not a static bearer.
Access gating → explicit replybox.access permission in utopia-hub.
UtopiaSpace login URL → https://utopia-hub.vercel.app/login (superseded by the route correction to /replybox-sso).
Edge Functions base → https://fxfxensvinkgmipaylrs.functions.supabase.co.
Still to nail down at build time
replybox.access row in utopia-hub's permissions/role_permissions and which roles get it.
Final UTOPIA_SSO_REDIRECT_PATH (web route after login; /inbox assumed).
Rotate the leaked service_role key surfaced via VITE_SUPABASE_ANON_KEY (separate task).