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

https://exo.now/docs/adr/0004-exo-id-central-identity-provider

> Status: accepted · Date: 2026-09-25



# ADR 0004: Exo ID — Exo is the central identity provider; apps are relying parties [#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](0003-convex-auth-v2-identity-plane.md)

## Context [#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 [#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) [#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](../exo-id.md).

## Consequences [#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.
