Decisions and roadmap
ADR 0004: Exo ID — Exo is the central identity provider; apps are relying parties
- Status: accepted
- Date: 2026-09-25
- Amends: 0003-convex-auth-v2-identity-plane.md
Context
ADR 0003 made Convex Auth v2 the shared identity plane but described it as a per-app pattern: every app mounted its own auth core and OAuth provider components and registered its own OAuth clients with Google, GitHub, and Apple. That gives each person a separate identity per app, spreads provider secrets and callback registrations across deployments, and forces every app team into Google Cloud Console and Apple Developer.
Owner directive (2026-09-25): "I'd love for you to do anything related to sign-on to exist at the Exo level. There shouldn't be multiple apps that need access to the cloud console — Exo is the platform provisioning IDs to apps like Construct or Observatory."
Decision
Exo is the sole OAuth client and identity provider for Interspace apps ("Exo ID"). Apps (Observatory, Construct, and every future app) are relying parties.
- Exo's Convex deployment mounts Convex Auth v2 (core + Google/GitHub OAuth; Apple when added)
and holds the only provider registrations and callback URLs
(
<exo-convex-site>/oauth/{google,github}/callback). - Exo issues app-scoped RS256 access tokens from a dedicated Exo ID signing key
(
EXO_ID_PRIVATE_KEY/EXO_ID_JWKS), separate from Exo's own session keys. - Apps verify Exo ID JWTs with a
customJwtprovider in theirconvex/auth.config.ts:issuer: ${EXO_SITE}/exo-id,applicationID: <app>,jwks: ${EXO_SITE}/exo-id/.well-known/jwks.json,algorithm: 'RS256'. - Apps never mount the auth core or OAuth provider components, never hold
AUTH_*or provider client secrets, and never register with Google, GitHub, or Apple. - Onboarding an app is a code change to Exo's registry (
convex/lib/exoIdApps.ts), reviewed in this repo. - Authorization (who may use which app, roles, tenant grants) stays with the app in v1. Exo proves identity and includes the user's email; it does not block issuance on app allowlists.
Contract (v1)
Issuer: ISSUER = ${CONVEX_SITE_URL}/exo-id (staging:
https://frugal-eel-930.convex.site/exo-id).
Registry (convex/lib/exoIdApps.ts): per app, redirectUris (exact match) and origins (CORS).
- Authorize page (Exo web):
GET /id/authorize?app&redirect_uri&state&code_challenge&code_challenge_method=S256. Exo validates app and redirect URI server-side; invalid requests render an error and never redirect. Unauthenticated visitors sign in to Exo (Google/GitHub via Convex Auth v2, returning to the same URL). Authenticated visitors trigger mutationexoId.authorize, thenlocation.replace(redirect_uri?code&state). exoId.authorize(mutation): requires an authenticated Exo user; validates app, redirect URI, and an S256 challenge; stores onlysha256(code)inexoIdCodeswith a 60-second expiry; codes are single-use.POST ${ISSUER}/v1/token(JSON):authorization_code:{app, code, redirect_uri, code_verifier}— code must exist, be unused and unexpired, match app and redirect URI, and satisfy PKCE S256. Any redemption attempt consumes the code.refresh_token:{app, refresh_token}— rotates the refresh token (hashes inexoIdSessions, 30-day lifetime, 30-second grace for the previous token).- 200:
{access_token, token_type: 'Bearer', expires_in, refresh_token, user: {id, email, name}}. - 400:
{error: 'invalid_request' | 'invalid_grant' | 'unsupported_grant_type'}. - CORS: preflight allowed for any registered origin; POST responses echo the Origin only when it belongs to the body's app.
- Access token: RS256, header
kidfromEXO_ID_JWKS; claimsiss,aud=<app>,sub=<exo user id>,email,email_verified(boolean),name,iat,exp = iat + 900.
POST ${ISSUER}/v1/revoke{app, refresh_token}→ always 200.GET ${ISSUER}/.well-known/jwks.json→ public keys only,Cache-Control: public, max-age=300, CORS*.GET ${ISSUER}/.well-known/openid-configuration→issuer,jwks_uri,token_endpoint,authorization_endpoint(from deployment envEXO_ID_AUTHORIZE_URL).
Integration guide: docs/exo-id.md.
Consequences
- ADR 0003's per-app provider mounting, per-app OAuth registration, and "Apple per-app" bullets are superseded (marked in 0003). The rest of 0003 (Convex Auth v2 as the technology, alpha pinning, server-side identity derivation) stands.
- Apps that already mounted their own Convex Auth components (e.g. Observatory, Rampart,
Construct's in-flight work) migrate to
customJwtverification of Exo ID tokens and remove their provider components and secrets. - Exo's Convex deployment becomes a hard dependency for sign-in across the portfolio; its availability and key rotation are platform responsibilities.
- A person has one Exo user id (
sub) across apps; apps key their own records on it. - Exo's Clerk-backed
/sign-instays in place until Construct leaves Clerk; Exo ID lives at/id/authorizeand does not depend on Clerk.