exoDocs
Decisions (ADRs)

Decisions and roadmap

ADR 0004: Exo ID — Exo is the central identity provider; apps are relying parties

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 customJwt provider in their convex/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).

  1. 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 mutation exoId.authorize, then location.replace(redirect_uri?code&state).
  2. exoId.authorize (mutation): requires an authenticated Exo user; validates app, redirect URI, and an S256 challenge; stores only sha256(code) in exoIdCodes with a 60-second expiry; codes are single-use.
  3. 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 in exoIdSessions, 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 kid from EXO_ID_JWKS; claims iss, aud=<app>, sub=<exo user id>, email, email_verified (boolean), name, iat, exp = iat + 900.
  4. POST ${ISSUER}/v1/revoke {app, refresh_token} → always 200.
  5. GET ${ISSUER}/.well-known/jwks.json → public keys only, Cache-Control: public, max-age=300, CORS *.
  6. GET ${ISSUER}/.well-known/openid-configuration → issuer, jwks_uri, token_endpoint, authorization_endpoint (from deployment env EXO_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 customJwt verification 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-in stays in place until Construct leaves Clerk; Exo ID lives at /id/authorize and does not depend on Clerk.

Source: docs/adr/0004-exo-id-central-identity-provider.md

On this page