From 05cab80f6086401350d3fa150a1324de7ff40193 Mon Sep 17 00:00:00 2001 From: "mintlify[bot]" <109931778+mintlify[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:12:01 +0000 Subject: [PATCH] docs: add Auth Hub integrator guide for PKCE, state, and client allowlist --- docs.json | 1 + guides/dev/auth-hub.mdx | 255 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 guides/dev/auth-hub.mdx diff --git a/docs.json b/docs.json index 7e72b01..1e930fe 100644 --- a/docs.json +++ b/docs.json @@ -88,6 +88,7 @@ "group": "Identity and access", "pages": [ "guides/dev/authentication", + "guides/dev/auth-hub", "articles/identity-and-credentials", "guides/dev/session-keys", "guides/dev/smart-accounts", diff --git a/guides/dev/auth-hub.mdx b/guides/dev/auth-hub.mdx new file mode 100644 index 0000000..3878251 --- /dev/null +++ b/guides/dev/auth-hub.mdx @@ -0,0 +1,255 @@ +--- +title: "Integrate with the ixo Auth Hub" +description: "Sign users into your app through the ixo Auth Hub with PKCE, CSRF state, redirect URI registration, and the consent screen." +--- + +The ixo Auth Hub is the shared OAuth-style login surface for ixo apps. Your app redirects the user to the hub, the user signs in (WorkOS AuthKit — Google, Apple, or Enterprise SSO) and approves your app on a consent screen, then the hub redirects back with a one-time `code` that your app exchanges for the user's session data (address, DID, session key, Matrix credentials). + +This guide covers the integrator contract you need to meet to stay compatible as the hub rolls out enforcement of PKCE, CSRF `state`, and the redirect URI allowlist. + +## Before you start + +You need: + +- A calling app with a stable `redirect_uri` (HTTPS, loopback for local dev, or a custom scheme for native apps). +- The Auth Hub base URL for your target network — see [Networks and endpoints](/reference/networks-and-endpoints). +- The ability to store a short-lived value (the PKCE `code_verifier` and CSRF `state`) between the redirect out and the callback in — typically `sessionStorage` for web, or the equivalent secure store on native. + +## What the hub expects from your app + +The hub now enforces four things at every point where a one-time `code` can be minted (`/api/auth/login`, `/api/auth/register/start`, `/api/auth/complete`) so the requirements can't be downgraded mid-flow: + +1. **PKCE (S256)** — send a `code_challenge` on the initial redirect and the matching `code_verifier` on exchange. +2. **CSRF `state`** — send a random `state` on the initial redirect; verify it matches on callback. +3. **Registered `redirect_uri`** — the exact URI must be registered as one of your app's redirect URIs (loopback addresses are always allowed for local dev). +4. **Consent** — the user sees a Google-style consent screen showing your app's name and logo the first time, then again after 90 days. + +All four are currently rolled out in **report-only** mode across every environment — non-compliant requests are still allowed, but they log a `CLIENT_ADOPTION_GAP` marker so operators can see who still needs to migrate. Adopt them now so the flip to enforced (`REQUIRE_PKCE`, `REQUIRE_STATE`, `REQUIRE_ALLOWLIST` = `true`, rolling devnet → testnet → mainnet) is a no-op for your users. + +## Step 1 — Generate PKCE and state + +Generate a fresh `code_verifier` and `state` per login attempt and store them locally. Derive the `code_challenge` as the base64url-encoded SHA-256 of the verifier (RFC 7636, `S256`). + +```ts +async function sha256Base64Url(input: string): Promise { + const bytes = new TextEncoder().encode(input); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return btoa(String.fromCharCode(...new Uint8Array(digest))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +function randomUrlSafe(length = 43): string { + const bytes = new Uint8Array(length); + crypto.getRandomValues(bytes); + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, "") + .slice(0, length); +} + +const codeVerifier = randomUrlSafe(64); +const codeChallenge = await sha256Base64Url(codeVerifier); +const state = randomUrlSafe(32); + +sessionStorage.setItem("authhub.code_verifier", codeVerifier); +sessionStorage.setItem("authhub.state", state); +``` + +The `code_verifier` never leaves the browser. That's what binds the one-time `code` to the app that started the flow — a phished or leaked `code` can't be redeemed by anyone else. + +## Step 2 — Redirect the user to the hub + +Send the user to `/api/auth/login` with your `redirect_uri`, `state`, `code_challenge`, and `code_challenge_method=S256`. + +```ts +const params = new URLSearchParams({ + redirect_uri: "https://yourapp.example/auth/callback", + state, + code_challenge: codeChallenge, + code_challenge_method: "S256", +}); + +window.location.href = `${AUTH_HUB_BASE_URL}/api/auth/login?${params}`; +``` + +Optional query parameters: + +| Parameter | Purpose | +|-----------|---------| +| `organization_id` | Force a specific WorkOS organization / SSO connection. | +| `matrix_homeserver` | Provision a new user's Matrix identity on a specific homeserver (HTTPS only, reachability-checked). Ignored for returning users. | +| `hide_mnemonic` | Set to `true` to skip the recovery-phrase screen during registration. The mnemonic is still generated and stored encrypted; your app owns backup messaging. | + +### `redirect_uri` scheme policy + +Independent of the allowlist, every `redirect_uri` must pass a scheme-safety gate (enforced in all environments): + +- `https://` — allowed for any host. +- `http://` — allowed **only** for loopback (`localhost`, `127.0.0.1`, `::1`). Plaintext HTTP to a remote host is rejected — the one-time code could be intercepted in transit. +- Custom schemes (`impactsx://…`, `com.example.app:/…`) — allowed, so native and mobile apps keep working. +- `javascript:`, `data:`, `vbscript:`, `blob:`, `file:` — always rejected. + +A disallowed scheme sends the user to a styled same-origin error page (`/auth-error?error=insecure_redirect_uri`); the hub never bounces to an untrusted target. + +## Step 3 — Handle the callback + +The hub redirects the user back to your `redirect_uri` with `code` and `state` on success, or `error` and `error_description` on failure. Verify `state` matches what you stored, then exchange the code. + +```ts +const url = new URL(window.location.href); +const code = url.searchParams.get("code"); +const returnedState = url.searchParams.get("state"); +const error = url.searchParams.get("error"); + +if (error) { + // e.g. access_denied (user cancelled consent), invalid_request, unregistered_client + throw new Error(url.searchParams.get("error_description") ?? error); +} + +const storedState = sessionStorage.getItem("authhub.state"); +if (!code || !returnedState || returnedState !== storedState) { + throw new Error("state mismatch or missing code"); +} +``` + + +If `state` doesn't match, abort the flow. Never exchange a code you can't tie back to a login attempt you started — that's exactly the CSRF case `state` is protecting against. + + +## Step 4 — Exchange the code + +Call `/api/auth/exchange` with the `code` and the `code_verifier` you stashed in Step 1. The hub verifies the S256 hash and returns the user's session data. + +```ts +const codeVerifier = sessionStorage.getItem("authhub.code_verifier")!; +sessionStorage.removeItem("authhub.code_verifier"); +sessionStorage.removeItem("authhub.state"); + +const res = await fetch( + `${AUTH_HUB_BASE_URL}/api/auth/exchange?code=${encodeURIComponent(code)}&code_verifier=${encodeURIComponent(codeVerifier)}`, +); + +if (!res.ok) { + throw new Error(`exchange failed: ${res.status}`); +} + +const session = await res.json(); +// { address, did, sessionKey, matrixHomeserver, matrixAccessToken, ... } +``` + +Codes are one-time and short-lived. A mismatched `code_verifier` returns an error and marks the code as consumed. + +## Redirect URI registration + +The hub matches the incoming `redirect_uri` **exactly** against a per-environment allowlist of registered clients (D1-backed, one redirect URI → one client). There's no `client_id` parameter — the URI is the identity. + +Registration gives your app: + +- A **name and logo** on the consent screen (unknown web clients show the host; native apps with custom schemes show "an app on your device"). +- **Remembered consent** for 90 days per `(user, client)` — only registered clients get this. +- Compatibility once `REQUIRE_ALLOWLIST=true` flips per environment. + +To register (or update) your app in an environment, open a PR to [`ixoworld/ixo-auth-hub`](https://github.com/ixoworld/ixo-auth-hub) adding an entry to the `CLIENTS` map in `sync-clients.mjs` with: + +- `id` — a short stable slug (also the consent key). +- `name` — the human name shown on the consent screen. +- `logo_url` — optional; falls back to a coloured initial-avatar. +- `homepage` — optional. +- One or more **exact** `redirect_uri` values. + +Operators run `node sync-clients.mjs ` to upsert the registry (idempotent, per environment). Changes take effect within about 60 seconds — the worker caches the registry in-memory per isolate. + + +Loopback addresses (`http://localhost:*`, `127.0.0.1`, `::1`) are always accepted for local development regardless of the allowlist and do not need to be registered. Don't add loopback URIs to remote environments. + + +Once `REQUIRE_ALLOWLIST=true` is set for an env, an unregistered `redirect_uri`: + +- At `/login` → renders the styled `/auth-error?error=unregistered_client&redirect_uri=…` page (the hub does **not** bounce to the invalid target). +- At `/register/start` → `400`. +- At `/complete` → `403`. + +The returning-user path (`/callback`, `/verify-email`) is unaffected. + +## The consent screen + +Before any code is handed over, the auth-hub SPA shows a Google-style consent screen with the calling app's name and logo: + +- **Continue** — consent is recorded for 90 days, the code's clock is reset, and the user is redirected to `redirect_uri?code=…&state=…`. +- **Cancel** — the code is invalidated server-side and the user is returned with `redirect_uri?error=access_denied&state=…` (OAuth 2.0 §4.1.2.1). + +On later logins within 90 days the screen is skipped automatically for the same `(user, client)` pair. Only registered clients are remembered; unregistered clients see the screen every time. + +## Rolling out enforcement — the `REQUIRE_*` flags + +`REQUIRE_PKCE`, `REQUIRE_STATE`, and `REQUIRE_ALLOWLIST` are all currently `"false"` in every environment (devnet, testnet, mainnet) — the hub is in **report-only** mode. Non-compliant logins are allowed but logged with a standardised marker: + +``` +CLIENT_ADOPTION_GAP missing= client= redirect_uri= +``` + +Operators watch these markers per environment. Once a feature shows no gaps for the clients that matter, the flag is flipped to `"true"` for that env (rolled devnet → testnet → mainnet). Enforcement is re-asserted at `/login`, `/register/start`, and `/complete` so it can't be downgraded mid-flow. Direct auth-hub logins that don't pass a `redirect_uri` are never affected. + +Migrate proactively: + +- Send `code_challenge` + `code_challenge_method=S256` on `/login` and `code_verifier` on `/exchange`. +- Send a random `state` on `/login` and verify it on callback. +- Register your `redirect_uri` via `sync-clients.mjs`. + +Search Cloudflare logs for `CLIENT_ADOPTION_GAP` filtered by your `redirect_uri` to confirm your app is clean before a flip. + +## Error handling + +When a request is malformed under enforcement (missing PKCE, missing `state`, unregistered redirect, etc.), the hub follows OAuth 2.0 §4.1.2.1 based on whether the `redirect_uri` is trusted: + +- **Trusted** (a registered client or loopback) — the hub redirects **back to your app** with `redirect_uri?error=&error_description=&state=`. Handle these on your callback route like any other OAuth error. +- **Untrusted or missing** — the hub never bounces to that target; it shows the user a styled same-origin error page at `/auth-error?error=&error_description=`. + +Common error codes: `invalid_request` (bad or missing `code_challenge`, missing `state`), `unregistered_client`, `insecure_redirect_uri`, `access_denied` (user cancelled consent), `pkce_failed` and `invalid_or_used_code` (on `/exchange`). + +## Audit log + +Every login and exchange step now writes to a durable, append-only audit log — `login.start`, `login.rejected` (with a reason), `auth.authenticated`, `register.start`, `auth.code_issued`, `consent.grant`, `consent.denied`, `auth.exchange` (success or `pkce_failed`), and `logout` among others. Email addresses and other PII are no longer written to auth logs; WorkOS user ids are stored hashed and codes are stored as SHA-256 hashes so a code's whole lifecycle can be correlated without keeping the secret. This is transparent to your app — no changes required — but it means operators can answer "which client was authorised for which user, when, from where, and did it succeed?" from the audit table. + +## Troubleshooting + + + + Your `redirect_uri` isn't in the allowlist for that environment. Add it via `sync-clients.mjs` and re-run the sync for the target env — it takes effect within about 60 s. + + + `REQUIRE_PKCE` is on for this env and your `/login` request has no `code_challenge`. Generate a `code_verifier`, derive the S256 `code_challenge`, and include `code_challenge_method=S256`. + + + The `code_verifier` you sent doesn't hash to the `code_challenge` from `/login`. Make sure you're re-reading the same verifier you stored before the redirect and haven't double-encoded it. The code is invalidated on failure — restart the login flow. + + + The user hit **Cancel** on the consent screen. The code has been invalidated server-side. Show a "sign in was cancelled" state and let the user retry. + + + Your `redirect_uri` uses `http://` against a non-loopback host, or a rejected scheme (`javascript:`, `data:`, `blob:`, `file:`, `vbscript:`). Use `https://` in production; loopback `http://` is fine for local dev. + + + The `state` returned doesn't match what you stored. Don't exchange the code. This usually means the user opened the callback in a different tab / storage context, or the login was initiated from a third party (CSRF). Restart the flow. + + + +## Next steps + + + + Sign transactions in the browser after login without per-action wallet confirmations. + + + Add on-chain enforcement of which message types a session can sign. + + + Look up the Auth Hub base URL for the target network. + + + Pick the right auth pattern for each IXO API surface. + +