# UnlikeOtherAuthenticator integration guide

UnlikeOtherAuthenticator (UOA) is a centralized OAuth and authentication service. A client application does NOT post raw configuration to UOA. The client exposes an HTTPS `config_url` that returns a signed RS256 config JWT, and UOA fetches and verifies that JWT on every auth request. There is no per-app OAuth client registration in UOA — the trust comes from the signed config JWT alone.

For machine-readable JSON, endpoint schemas, and config contracts, use [/api](/api).

## Two trust mechanisms (do not confuse them)

UOA uses two independent secrets. They cover different things and are stored in completely different places.

| Mechanism | Used for | Where it lives | Cryptography |
|---|---|---|---|
| **RS256 signing key + JWKS** | Signing the config JWT returned by your `config_url`. UOA verifies the signature on every fetch. | The PUBLIC JWK is registered with UOA (Phase 0 below). The PRIVATE key stays in your client backend only. | Asymmetric. RS256. UOA never sees the private key. |
| **Per-domain client secret** | Bearer authorization for backend-to-backend calls (`/auth/token`, `/auth/revoke`, `/domain/*`, etc.). | Created in the UOA Admin UI under **Configuration > Secrets** for one specific domain. Shown once. | Symmetric. `SHA256(domain + clientSecret)` is the bearer token. UOA stores only an HMAC digest of that hash. |

## Identifying UOA-issued values at a glance

Everything UOA mints is prefixed so you never mistake it for some other opaque string:

| Value | Prefix | Example |
|---|---|---|
| `client_secret` (per-domain) | `uoa_sec_` | `uoa_sec_2b9Xf…` |
| Claim token (embedded in claim URL) | `uoa_claim_` | `uoa_claim_7d4X…` |
| Public-JWK fingerprint (shown in admin + /api) | `uoa_fp_` | `uoa_fp_OYO4_OIgDb1…` |

The hashing rule is unchanged: **the entire string you were given** is the input. When UOA hands you `uoa_sec_abc123…`, pass that whole string as `clientSecret` into `SHA256(domain + clientSecret)`. Do not strip the prefix. `client_hash` is still the 64-hex SHA256 output; `hash_prefix` is still its first 12 hex chars.

You need BOTH to ship a working integration. Phase 0 + Phase 1 below cover them in order.

## Service discovery

- Home: [/](./)
- Admin UI: [/admin](/admin)
- LLM guide: [/llm](/llm) (this page)
- JSON API schema: [/api](/api)
- Config JWKS (RS256 public keys UOA accepts): [/.well-known/jwks.json](/.well-known/jwks.json)
- Health check: [/health](/health)
- Production-safe config validator: `POST /config/validate`
- DEBUG-only validator with custom JWKS: `POST /config/verify` (only when `DEBUG_ENABLED=true` and `NODE_ENV !== 'production'`)

---

## Phase 0 — Generate your RS256 signing keypair

Every config JWT is RS256-signed. You need an RSA-2048 keypair whose PUBLIC JWK is discoverable at a JWKS URL UOA can fetch, and whose PRIVATE key stays in your backend.

### 0.1 Generate an RSA-2048 keypair

OpenSSL:

```bash
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out config-signing.private.pem
openssl rsa -in config-signing.private.pem -pubout -out config-signing.public.pem
```

Node (`jose` ≥ 5):

```js
import { generateKeyPair, exportJWK } from 'jose';

const { publicKey, privateKey } = await generateKeyPair('RS256', { modulusLength: 2048, extractable: true });
const publicJwk = await exportJWK(publicKey);
publicJwk.kid = 'voicepos-2026-04';   // pick a stable ID; rotate by adding a NEW kid, never reuse
publicJwk.alg = 'RS256';
publicJwk.use = 'sig';
console.log(JSON.stringify(publicJwk, null, 2));
```

### 0.2 Required public JWK shape

UOA's JWKS validator accepts ONLY these members and REJECTS the document if any private members are present.

```json
{
  "kty": "RSA",
  "kid": "voicepos-2026-04",
  "alg": "RS256",
  "use": "sig",
  "n": "<base64url modulus>",
  "e": "AQAB"
}
```

- `kty` MUST be `"RSA"`. EC, OKP, and oct keys are rejected.
- `kid` MUST be a non-empty string. It MUST be unique across all JWKs registered to your `domain`, and collisions with other partners' `kid`s are rejected.
- `n` and `e` MUST be non-empty base64url strings.
- `alg` SHOULD be `"RS256"` (the only algorithm UOA accepts for config JWTs).
- The following members are FORBIDDEN and will cause UOA to reject the JWK: `d`, `p`, `q`, `dp`, `dq`, `qi`, `oth`. Never paste a private key.

### 0.3 Store the private key in your client backend ONLY

The private PEM (or `privateKey` from `jose.generateKeyPair`) lives in your application backend's secret store. It must NEVER be committed to git, sent to UOA, or accessible from a browser. Rotate by generating a new key with a new `kid`, publishing it on your JWKS URL, and signing new config JWTs with the new `kid` — keep the old `kid` published until all caches expire (UOA caches the JWKS for ~10 minutes).

---

## Phase 1 — Auto-onboard with one `/auth` call

UOA uses **auto-onboarding**. There is no admin-side "Add Domain" button anymore; you register yourself by making one `/auth` call with a config JWT that contains two extra payload fields. A UOA superuser then sees your request in the admin console, approves it, and UOA emails you a one-time claim link.

### 1.1 Publish your JWKS URL on the SAME hostname as your `config_url`

Stand up a public HTTPS endpoint that returns a standard JWKS document with your public JWK:

```http
GET https://api.voicepos.unlikeotherai.com/.well-known/jwks.json

{ "keys": [ { "kty": "RSA", "kid": "voicepos-2026-04", "alg": "RS256", "use": "sig", "n": "...", "e": "AQAB" } ] }
```

- Hostname of `jwks_url` MUST equal the `domain` claim in your config JWT (case-insensitive). Cross-host JWKS are rejected with `INTEGRATION_JWKS_HOST_MISMATCH`.
- Same SSRF rules as `config_url`: HTTPS only, public DNS only, 5s timeout, 64 KiB cap, 3 redirects max.

### 1.2 Include `jwks_url` and `contact_email` in your config JWT payload

Add two optional fields to the payload described in Phase 2.4. These are only required on the auto-onboarding call; after approval they are inert but harmless.

```json
{
  "domain": "api.voicepos.unlikeotherai.com",
  "jwks_url": "https://api.voicepos.unlikeotherai.com/.well-known/jwks.json",
  "contact_email": "ops@voicepos.com",
  "redirect_urls": ["https://app.voicepos.unlikeotherai.com/oauth/callback"],
  "enabled_auth_methods": ["email_password", "google"],
  "ui_theme": { "...": "see /api" },
  "language_config": "en"
}
```

### 1.3 Make ONE `/auth` call to trigger auto-discovery

Open `/auth?config_url=<your_config_url>&redirect_url=<callback>&code_challenge=<S256>&code_challenge_method=S256` in a browser. UOA will:

1. Fetch and decode your config JWT (unverified) to read `jwks_url` and `contact_email`.
2. Verify `URL(jwks_url).hostname === payload.domain`.
3. Fetch `jwks_url` through the same SSRF-protected pipeline as `config_url`.
4. Verify the config JWT signature against the published public JWK.
5. Schema-validate the payload so it can store a safe `config_summary`.
6. Insert a PENDING row in `client_domain_integration_requests` (or touch the existing one).
7. Render a friendly **"Integration pending review"** page. No auth flow runs yet.

The browser now shows the pending page. Do not retry in a loop — UOA has everything it needs.

### 1.4 Wait for the approval email

A UOA superuser sees your request in **/admin > New Integrations**, inspects the fingerprint, `jwks_url`, and verified `config_summary`, and clicks **Accept**. UOA:

- Creates the `client_domains` row, the first `client_domain_jwks` row, and a new client secret inside one DB transaction.
- **Delivers the credentials one of two ways** depending on what the superuser picked on Accept:
  - **Email claim link (default):** `contact_email` receives a link of the form `https://<uoa-host>/integrations/claim/<token>`. The token is single-use and expires after 24 hours. You claim the secret yourself.
  - **Reveal to admin:** the superuser sees `domain`, `client_secret`, `client_hash`, and `hash_prefix` once in the admin UI and passes them to you through your own secure channel. No email is sent.

If the superuser declines, no email is sent. Contact your UOA superuser if you expected approval but did not receive an email (or a secret from them directly) within a business day.

### 1.5 Open the claim link and copy the secret ONCE

1. Open the claim link in a browser. You will see a "Confirm" page — link scanners and email previewers cannot burn the token because consumption requires a `POST`.
2. Click **Reveal secret**. UOA POSTs to `/integrations/claim/:token/confirm`, marks the token used, and renders the one-time reveal page containing `domain`, `client_secret`, `client_hash`, and `hash_prefix`.
3. Copy `client_secret` and `client_hash` into your backend secret store immediately. The page warns "This is the only time this secret will be displayed." Refreshing or re-opening the link returns the invalid-link page.

**What UOA stores.** UOA never persists the raw secret. `client_domains` holds the domain row, and `client_domain_secrets` holds an HMAC-SHA256 digest of `SHA256(domain + clientSecret)` keyed with the deployment-wide `SHARED_SECRET`, plus a 16-character display prefix used to identify the active secret in the admin UI. There is no decryption path.

**Computing the bearer token from the client secret.** If you only stored `client_secret`, recompute the bearer at runtime:

```js
import { createHash } from 'node:crypto';
const clientHash = createHash('sha256').update(domain + clientSecret).digest('hex'); // 64 hex chars
// Authorization: Bearer <clientHash>
```

**Resend claim link.** If you lose the email before clicking, ask a UOA superuser to use **Resend claim link** on the accepted request. The old token is revoked and a fresh one is emailed (or revealed in-UI if the superuser picks reveal mode on resend).

**Disabling.** A superuser can set a domain to `disabled` on the Secrets page; UOA rejects every domain bearer request for that domain until it is re-enabled.

### 1.6 Adding or deactivating signing keys later

Once your domain is registered, a superuser can add additional RSA JWKs through **Admin > Secrets > domain > Signing Keys**, or deactivate an old `kid`. Rotation flow: publish the new `kid` on your JWKS URL, ask a superuser to register it, start signing with the new `kid`, and once traffic with the old `kid` drains the superuser deactivates the old row.

---

## Phase 2 — Implement your `config_url` endpoint

Expose a public HTTPS GET endpoint on the SAME hostname you registered as the domain. UOA fetches it server-side on every `/auth` request.

### 2.1 Network requirements UOA enforces

- HTTPS only. HTTP, file, ftp, gopher schemes are rejected.
- Public DNS only. UOA resolves the hostname and rejects loopback, link-local, RFC1918 private ranges, IPv4-mapped IPv6, NAT64, multicast, and unspecified addresses. Cloud Run egress will reach you over public internet — your endpoint must be reachable from there.
- Hard timeout: 5 seconds end-to-end including redirects.
- Max 3 redirects. Each hop is re-validated with the same SSRF rules.
- Max response body: 64 KiB. Anything larger is rejected.
- UOA sends `Accept: text/plain, application/json` and `User-Agent: UnlikeOtherAuthenticator/config-fetch/<version>`.

### 2.2 Accepted response formats

UOA accepts ANY of these as the body of a `200 OK` response. Pick whichever is easiest:

1. **Bare JWT** (preferred): the response body is the JWT compact serialization, three base64url segments separated by dots. `Content-Type: application/jwt` or `text/plain`.
2. **`Bearer <jwt>`**: the body starts with the literal string `Bearer ` followed by the JWT.
3. **JSON envelope**: `{ "jwt": "<jwt>" }` — the field name may be `jwt`, `token`, `config_jwt`, `configJwt`, or `configJWT`. `Content-Type: application/json`.

Anything else (HTML, error JSON, empty body) fails with `CONFIG_FETCH_FAILED`.

### 2.3 Required JWT header

```json
{ "alg": "RS256", "kid": "voicepos-2026-04", "typ": "JWT" }
```

- `alg` MUST be exactly `"RS256"`. `HS256`, `none`, `ES256`, `PS256`, etc. are rejected.
- `kid` MUST be present, non-empty, and MUST resolve to a registered JWK — either a `client_domain_jwks` row for your domain, or the legacy deployment-wide `CONFIG_JWKS_JSON`. On the FIRST `/auth` call from a new domain the `kid` will not yet be registered; that is the signal that triggers auto-discovery against your `jwks_url` (see Phase 1). All subsequent calls must use a `kid` that resolves directly in UOA's tables — otherwise the request fails with `CONFIG_JWT_INVALID`.
- `typ` is optional but recommended.

### 2.4 Required payload fields

```json
{
  "domain": "api.voicepos.unlikeotherai.com",
  "jwks_url": "https://api.voicepos.unlikeotherai.com/.well-known/jwks.json",
  "contact_email": "ops@voicepos.com",
  "redirect_urls": ["https://app.voicepos.unlikeotherai.com/oauth/callback"],
  "enabled_auth_methods": ["email_password", "google"],
  "ui_theme": { "...": "see /api for the full ui_theme contract" },
  "language_config": "en"
}
```

- `domain` MUST exactly equal the hostname of the `config_url` UOA fetched. Mismatch fails with `CONFIG_DOMAIN_MISMATCH`.
- `jwks_url` and `contact_email` are **required on the first auto-onboarding call** and optional thereafter. `jwks_url` MUST be HTTPS and share the hostname of `domain`. `contact_email` is where UOA sends the one-time claim link on approval. See Phase 1 for the full flow.
- `redirect_urls` MUST be a non-empty array of absolute HTTP/HTTPS URLs. The runtime `redirect_url` must match one of these entries **byte-for-byte**, including scheme, host, port, path, AND query string. No normalization, no prefix matching, no query wildcards. If you need to propagate per-request CSRF / PKCE state, carry it out-of-band (`sessionStorage`, first-party cookie) — never on the URL. See Phase 3.1.
- `enabled_auth_methods` MUST be a non-empty array. Allowed values: `email_password`, `google`, `facebook`, `github`, `linkedin`, `apple`. There is no separate social-provider allowlist — listing a provider here both enables and allows it.
- `ui_theme` is required. See `/api` for the full contract (colors as hex only, radii/font sizes as CSS lengths, button + card styles, logo with required `url` and `alt`).
- `language_config` is one IETF code or a non-empty array of codes.

The payload MUST NOT contain `SHARED_SECRET`, the `client_secret` from Phase 1, the `client_hash`, refresh tokens, OAuth codes, or any other secret. UOA scans for known secret patterns and refuses the config if it sees one.

Optional fields are documented at `/api` under `config_jwt_documentation.optional_fields`, including `2fa_enabled`, `debug_enabled`, `user_scope`, `allow_registration`, `registration_mode`, `allowed_registration_domains`, `registration_domain_mapping`, `session.*`, `org_features.*`, and `access_requests.*`.

### 2.5 Sign the JWT

```js
import { SignJWT, importPKCS8 } from 'jose';

const privateKey = await importPKCS8(process.env.UOA_CONFIG_SIGNING_PRIVATE_KEY_PEM, 'RS256');
const jwt = await new SignJWT(payload)
  .setProtectedHeader({ alg: 'RS256', kid: 'voicepos-2026-04', typ: 'JWT' })
  .setIssuedAt()
  .sign(privateKey);
return new Response(jwt, { headers: { 'content-type': 'application/jwt' } });
```

Do NOT cache the JWT for long (UOA fetches every request and caches the JWKS for ~10 minutes). Re-signing per request is fine; the limit is 5 seconds per fetch.

---

## Phase 3 — Trigger the auth flow

Open the auth UI from the browser:

```text
GET /auth?config_url=<your_config_endpoint_url>
        &redirect_url=<your_callback_url>
        &code_challenge=<S256_challenge>
        &code_challenge_method=S256
```

- `config_url` is the HTTPS endpoint from Phase 2. UOA URL-decodes it before fetching.
- `redirect_url` MUST appear EXACTLY in `redirect_urls` from Phase 2's payload.
- PKCE is mandatory: generate a random 43-128 char `code_verifier`, hash it with SHA-256, base64url-encode, and pass as `code_challenge`. `code_challenge_method` MUST be `S256`.

After the user authenticates, UOA redirects to `<redirect_url>?code=<authorization_code>`. The code is single-use and short-lived; treat it as sensitive.

### 3.1 Carrying CSRF / PKCE state across the callback

`redirect_url` is matched byte-for-byte against `config.redirect_urls`, **including the query string**. If you normally round-trip OAuth state as a query parameter (`?state=…`), UOA will reject the request with `REDIRECT_URL_NOT_ALLOWED`.

Pick one of these transports instead:

- **`sessionStorage` (recommended).** On `/start`, return the opaque state token alongside the redirect URL. Stash it in `sessionStorage` under a provider-scoped key, then read-and-delete on the callback page before POSTing to your token-exchange endpoint. Binds the token to the originating tab and avoids URL mutation.
- **First-party cookie.** Set a `__Host-sso_state` cookie with `SameSite=Lax; Secure; HttpOnly; Path=/auth/callback`. Works across full page reloads; requires a same-origin callback.
- **Fragment (`#state=…`).** Only viable if the callback is a SPA; the browser strips fragments before the request hits UOA, so UOA won't see it. Fragile and easy to misuse — prefer `sessionStorage`.

Do NOT append `state` (or any per-request query parameter) to `redirect_url`. The allowlist match is exact, and every added byte will be rejected.

**Worked example — sessionStorage round-trip.**

```ts
// /start — backend returns the redirect URL and an opaque state token separately.
// GET https://app.example.com/sso/start -> { redirectUrl, stateToken }

// Caller (Admin UI):
const { redirectUrl, stateToken } = await fetch('/sso/start').then((r) => r.json());
sessionStorage.setItem('sso:state:google', stateToken);
window.location.assign(redirectUrl); // redirectUrl == one of config.redirect_urls, verbatim

// /auth/callback — UOA has appended ?code=… to the exact allowlisted URL.
const code = new URLSearchParams(window.location.search).get('code');
const stateToken = sessionStorage.getItem('sso:state:google');
sessionStorage.removeItem('sso:state:google'); // read-and-delete: single use

await fetch('/sso/exchange', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ code, stateToken }), // backend validates stateToken, then calls POST /auth/token
});
```

The backend validates `stateToken` against whatever it issued in `/start` (short TTL, single use, bound to session) before calling `POST /auth/token`. The state token never touches `redirect_url`.

---

## Phase 4 — Backend token exchange

This call is server-to-server. The browser MUST never see the bearer token.

```text
POST /auth/token?config_url=<your_config_endpoint_url>
Authorization: Bearer <client_hash from Phase 1>
Content-Type: application/json

{
  "code": "<authorization_code>",
  "redirect_url": "<same callback URL used in Phase 3>",
  "code_verifier": "<the PKCE verifier whose SHA-256 produced code_challenge>"
}
```

### 4.1 Canonical response body

The authorization-code grant returns exactly the shape below. **There is no top-level `user` field.** User identity is carried as claims inside `access_token`. If your RP code reads `response.user.id` you will always get `undefined` — decode the JWT and read `sub` instead.

```json
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.<payload>.<sig>",
  "expires_in": 1800,
  "refresh_token": "<opaque, server-side only>",
  "refresh_token_expires_in": 2592000,
  "token_type": "Bearer",
  "firstLogin": {
    "memberships": {
      "orgs":  [{ "orgId": "org_…", "role": "member" }],
      "teams": [{ "teamId": "tm_…", "orgId": "org_…", "role": "member" }]
    },
    "pending_invites": [
      { "inviteId": "inv_…", "type": "team", "orgId": "org_…", "teamId": "tm_…", "teamName": "…" }
    ],
    "capabilities": { "can_create_org": false, "can_accept_invite": true }
  }
}
```

Store the refresh token server-side ONLY; browser clients never receive or persist refresh tokens. `firstLogin` is only present on the authorization-code grant; refresh-token grants never include it.

**Field-casing warning.** The outer envelope is snake_case (`access_token`, `refresh_token`, `expires_in`, `refresh_token_expires_in`). The key `firstLogin` itself and the IDs inside `memberships.*` and `pending_invites[]` (`orgId`, `teamId`, `inviteId`, `teamName`) are camelCase. `pending_invites` and `capabilities.can_*` are snake_case. Do not assume one style throughout.

### 4.2 Access-token JWT claims

The `access_token` is a JWT (compact JWS, three base64url segments). Decode the payload — no signature verification on the RP side (see the trust-model note below).

| Claim | Source | Meaning |
|---|---|---|
| `sub` | standard | **Stable external user id.** Use this as the RP's foreign key into the UOA user. |
| `email` | custom | User's primary email. Advisory — user may change it; `sub` is the stable identity. |
| `role` | custom | **Platform-side UOA role** — `"user"` or `"superuser"`. Do NOT use this for tenant/org authorization. See 4.4. |
| `domain` | custom | The integration domain from your config JWT. Confirms which integration minted this token. |
| `client_id` | custom | `SHA256(domain + clientSecret)` hex. Identifies the exact client credential used. |
| `org` | custom (optional) | Present only when `org_features.enabled` and the user has an org on this domain. Shape: `{ org_id, org_role, teams[], team_roles{}, groups?[], group_admin?[] }`. |
| `iss` | standard | UOA host, e.g. `authentication.unlikeotherai.com`. |
| `aud` | standard | Always `"uoa:access-token"`. |
| `iat`, `exp` | standard | Epoch seconds. Respect `exp`. |

Minimal decode (no verification):

```ts
import { decodeJwt } from 'jose';
const claims = decodeJwt(response.access_token);
const userId = claims.sub;                 // stable
const email = claims.email as string;      // advisory
const platformRole = claims.role as 'user' | 'superuser';
```

### 4.3 Trust model — access tokens are HS256-signed

Access tokens are signed with `HS256` using the deployment-wide `SHARED_SECRET`. **RPs cannot and should not cryptographically verify them.** The config JWKS at `/.well-known/jwks.json` is for verifying RS256 *config* JWTs, not access tokens, and there is no UOA-side public JWKS for access tokens.

The RP trust model is channel-based:

1. You received the `access_token` as the body of an HTTPS response to your backend's `POST /auth/token` call.
2. That call was authenticated with your per-domain `client_hash` bearer, which only UOA and your backend know.
3. You passed `code` + `code_verifier` (PKCE) that only your tab could have produced.

Because all three hold, the token's issuer is UOA by construction. Do not expose `access_token` to the browser; do not forward it to third parties; and treat it as opaque beyond decoding claims for user identity / expiry. When you need to validate a presented access token later, call UOA (e.g. use it in the `X-UOA-Access-Token` header against UOA's own endpoints such as `GET /org/me`) rather than attempting local verification.

### 4.4 Which role to honour for authorization

The JWT `role` claim (`"user"` | `"superuser"`) is the **UOA platform role** — it gates access to UOA's own admin surfaces, NOT to the RP's business features. It is almost never the right role for RP authorization decisions.

Use this precedence inside your RP:

1. **Per-tenant role:** `firstLogin.memberships.orgs[].role` (on first login) — subsequently, fetch the current role via `GET /org/me`. This is what your RP should honour for org-scoped authorization.
2. **Per-team role:** `firstLogin.memberships.teams[].role`.
3. **Platform role (`claims.role`):** only relevant if the RP itself is a UOA-internal admin surface. Treat unknown values as `"user"`.

`superuser` in the JWT does NOT mean the user is an admin *inside your product*; it only means they can use UOA's admin UI.

### 4.5 First-login tenant bootstrapping — empty memberships

When `firstLogin.memberships.orgs` is empty, the user is authenticated but has no tenant on this domain yet. Do NOT fall back to a synthetic tenant (`"default"`, the user's email domain, etc.) — you will cross-contaminate users. Branch on `capabilities`:

| `capabilities.can_create_org` | `capabilities.can_accept_invite` | RP action |
|---|---|---|
| `true` | any | Show "Create your organisation" UI. Your backend calls `POST /org/organisations` (domain-hash auth, `name` + `owner_id = claims.sub`). After success, re-issue the session and re-fetch `GET /org/me`. |
| `false` | `true` | User has a pending invite. Show "Accept invitation" UI; the invite link is delivered by email from UOA — or you can resolve it yourself via `firstLogin.pending_invites[0]`. |
| `false` | `false` | No tenant and no path to one. Reject the login with a "Contact your administrator" screen — do NOT silently grant access. Your UOA superuser must provision the org/team. |

The server-side behaviour controlling whether the first-login payload is already non-empty is set in the config JWT — see `registration_domain_mapping`, `org_features.auto_create_personal_org_on_first_login`, and `org_features.pending_invites_block_auto_create` at `/api`.

### 4.6 Reference implementation — authorization code → RP session

```ts
import { decodeJwt } from 'jose';

const res = await fetch(
  `${UOA}/auth/token?config_url=${encodeURIComponent(CONFIG_URL)}`,
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${CLIENT_HASH}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      code,
      redirect_url: redirectUrl,
      code_verifier: codeVerifier,
    }),
  },
);
if (!res.ok) throw new Error('UOA token exchange failed');
const body = await res.json() as {
  access_token: string;
  expires_in: number;
  refresh_token: string;
  refresh_token_expires_in: number;
  token_type: 'Bearer';
  firstLogin?: {
    memberships: { orgs: Array<{ orgId: string; role: string }>; teams: Array<{ teamId: string; orgId: string; role: string }> };
    pending_invites: Array<{ inviteId: string; type: 'team'; orgId: string; teamId: string; teamName: string }>;
    capabilities: { can_create_org: boolean; can_accept_invite: boolean };
  };
};

const claims = decodeJwt(body.access_token);
const externalUserId = claims.sub!;                // stable user id
const email = String(claims.email);                // advisory

const firstOrg = body.firstLogin?.memberships.orgs[0];
if (!firstOrg) {
  if (body.firstLogin?.capabilities.can_create_org) return redirectToCreateOrg();
  if (body.firstLogin?.capabilities.can_accept_invite) return redirectToAcceptInvite();
  return redirectToContactAdmin();
}
const tenantId = firstOrg.orgId;
const tenantRole = firstOrg.role;                  // use THIS for authz, not claims.role

await storeRefreshTokenServerSide(body.refresh_token, body.refresh_token_expires_in);
await issueRpSession({ externalUserId, email, tenantId, tenantRole });
```

Server-side behaviour on first verified login is controlled by `org_features`:

- `registration_domain_mapping` (top-level config) places the user into a configured org + team when the email domain matches.
- `auto_create_personal_org_on_first_login` (default `false`) creates a personal org with the user as `owner` plus a default team when no mapping matches. Skipped when `pending_invites_block_auto_create` is `true` and a pending invite exists for the email.
- `allow_user_create_org` (default `false`) gates `POST /org/organisations` for end-users. Superusers bypass. Keep `false` for admin-provisioned tenants.

To revoke on logout:

```text
POST /auth/revoke?config_url=<your_config_endpoint_url>
Authorization: Bearer <client_hash>
Content-Type: application/json

{ "refresh_token": "<refresh token to revoke>" }
```

Domain admin APIs (`/domain/users`, `/domain/logs`, etc.) and team-invite / access-request review APIs use the same `Authorization: Bearer <client_hash>` mechanism. The old global shared-secret bearer is NOT accepted for any customer-facing endpoint.

---

## Phase 5 — Server startup payload: kill switch + feature flags

Your backend can request the startup payload using the same signed config JWT trust path as `/auth/login` and `/auth/register`: pass `config_url`, UOA fetches the RS256 config JWT, verifies the signature, validates the payload, and checks that `domain` matches the `config_url` hostname.

```text
GET /apps/startup?config_url=<your_config_endpoint_url>&appIdentifier=com.acme.ios&platform=ios&versionName=1.5.0&buildNumber=142
```

Optional query params:

- `userId` — applies per-user flag overrides and kill-switch test targeting when the user belongs to the app's org.
- `versionCode` — Android numeric version code.
- `teamId` — reserved for multi-team flag resolution.

Response:

```json
{
  "killSwitch": null,
  "flags": { "dark_mode": true, "new_checkout": false },
  "cacheTtl": 300,
  "serverTime": "2026-04-22T12:00:00.000Z"
}
```

- Unknown, inactive, or cross-domain apps return a clear startup payload: `killSwitch: null`, `flags: {}`.
- Feature flags return a flat key-to-boolean map. If feature flags are disabled for the App, `flags` is `{}`.
- A matched hard or maintenance kill switch appears in `killSwitch`; callers should block startup before loading app content.

---

## Validate at every step

UOA ships a production-safe validator that runs the same pipeline as the auth runtime:

```text
POST /config/validate
Content-Type: application/json

{ "config_url": "https://api.voicepos.unlikeotherai.com/auth/config" }
```

Body may instead be `{ "config_jwt": "<jwt>" }` or `{ "config": { ... raw payload ... } }`. Source priority is `config` > `config_jwt` > `config_url`.

The response includes:

- `ok`: every executed check passed.
- `checks`: per-stage results for `source`, `fetch`, `decode`, `secret_scan`, `signature`, `schema`, `runtime_policy`, `domain_match`.
- `issues`: structured failures with stage, code, summary, details.
- `recommendations`: required next steps and optional customization guidance (logo, custom font, language selector, token TTL, org features, access requests).
- `config_summary`: a safe parsed summary when schema validation succeeds.

In a non-production deployment with `DEBUG_ENABLED=true` you can additionally pass `jwks_url` to `POST /config/verify` to verify against a JWKS other than `CONFIG_JWKS_URL` — useful for testing a new `kid` before it is registered with the production UOA.

---

## Errors and what they actually mean

| Error code | Stage | Almost-always cause |
|---|---|---|
| `CONFIG_FETCH_FAILED` | UOA fetching your `config_url` | Endpoint unreachable from UOA, returned non-200, returned > 64 KiB, took > 5s, returned a body that did not contain a recognizable JWT, or resolved to a private/blocked IP. Check the **Connection Errors** page in /admin for the captured request/response context. |
| `CONFIG_URL_NETWORK_ERROR` | UOA fetching your `config_url` | TLS, DNS, or socket-level failure before HTTP could complete. |
| `CONFIG_JWT_INVALID` | Header / signature verification | Your `kid` is not registered and your payload does not include `jwks_url` + `contact_email` to trigger auto-discovery. Other causes: `alg` is not `RS256`, `kid` missing, signature does not match the registered public key, JWKS endpoint unreachable. |
| `INTEGRATION_JWKS_HOST_MISMATCH` | Auto-discovery | The hostname of the `jwks_url` you published in the payload does not match the `domain` claim (case-insensitive). Fix: host the JWKS on the same hostname. |
| `INTEGRATION_KID_NOT_IN_JWKS` | Auto-discovery | UOA fetched your `jwks_url` but the JWT's `kid` is not present in the document. Fix: publish the correct public JWK with the `kid` you signed with. |
| `INTEGRATION_PENDING_REVIEW` | Auto-discovery | A valid request has been captured and is waiting for a UOA superuser to approve. Wait for the email to `contact_email`; do not retry in a loop. |
| `INTEGRATION_DECLINED` | Auto-discovery | A UOA superuser declined your integration request for this domain. Contact support. |
| `CONFIG_DOMAIN_MISMATCH` | Post-decode | `payload.domain` does not exactly match the hostname of the `config_url` UOA fetched. Hostnames are compared case-insensitively but must otherwise be identical (no trailing dot, no port mismatch). |
| `REDIRECT_URL_NOT_ALLOWED` | `/auth` + `/auth/token` | `redirect_url` is not in `config.redirect_urls`. **Common cause:** the bare URL is allowlisted but a per-request `?state=…` (or any query parameter) was appended — matching is byte-for-byte including the query string. Carry state out-of-band (see Phase 3.1), do not mutate the URL. |
| Schema validation failures | Schema stage | A required field is missing or malformed. `/config/validate` returns the exact JSON path and reason in `issues`. |
| `auth_failed` (final redirect) | Post-callback | Intentionally generic. With `allow_registration: false`, social login does not create users — the user must already exist for that domain. Check `/internal/admin/handshake-errors`. |
| Google `redirect_uri_mismatch` | Provider | Your Google OAuth client does not list the exact callback URL UOA generated from `PUBLIC_BASE_URL` + `/auth/callback/google`. |

For deep diagnostics of failed `/auth` requests, a UOA superuser can open **/admin > Security > Connection Errors**. UOA records the sanitized request/response context for handshake failures, including JWT header/payload (with secrets redacted), the failing phase, and the resolved `config_url`.

---

## What NOT to do

- Do NOT use HS256 or any algorithm other than RS256 for the CONFIG JWT. UOA rejects everything else on the config-signing path. (Access tokens returned by `/auth/token` are separately HS256-signed by UOA and are not your concern — see 4.3.)
- Do NOT reuse a `kid` after rotation. Always pick a new `kid`.
- Do NOT put `client_secret`, `client_hash`, `SHARED_SECRET`, refresh tokens, or OAuth codes into the config JWT payload.
- Do NOT call `/auth/token` or `/auth/revoke` from the browser. The bearer is backend-only.
- Do NOT host `config_url` on a private DNS name, internal load balancer, loopback, or VPN-only host. UOA fetches over the public internet and rejects private IPs.
- Do NOT assume a `200` from your `config_url` in a browser implies UOA can fetch it — UOA enforces SSRF rules a browser does not.
- Do NOT replay OAuth `code` values from logs or chat. They are one-time credentials.
- Do NOT skip `/config/validate` before pointing real users at UOA.
- Do NOT append `?state=…` (or any per-request query) to `redirect_url`. The allowlist match is byte-for-byte; your `/start` endpoint must return the state token **separately** so the caller can stash it in `sessionStorage` or a first-party cookie. See Phase 3.1.
- Do NOT assume `POST /auth/token` returns a top-level `user` object. It does not. See 4.1.
- Do NOT attempt to verify `access_token` against the config JWKS. It is HS256 and not RP-verifiable. See 4.3.
- Do NOT fall back to a synthetic tenant ID (`"default"`, email domain, etc.) when `firstLogin.memberships.orgs` is empty. See 4.5.
- Do NOT use `claims.role` for RP authorization. It is the UOA platform role, not your tenant role. See 4.4.

---

## Admin access

The first-party Admin UI is served from [/admin](/admin). It dogfoods the same auth system with its own first-party config:

- `/admin/login` redirects into `/auth` with PKCE.
- The admin config is served from `/internal/admin/config` and verified against the public JWKS at `/.well-known/jwks.json`.
- The admin config uses the admin domain, disables registration, and allows only Google.
- `/admin/auth/callback` exchanges the authorization code at `POST /internal/admin/token`.
- Admin access tokens are signed with `ADMIN_ACCESS_TOKEN_SECRET`.
- Only `role: "superuser"` tokens for `ADMIN_AUTH_DOMAIN` can access `/internal/admin/*`.
- `ADMIN_AUTH_DOMAIN` defaults to the resolved auth service identifier (inferred from `PUBLIC_BASE_URL` unless `AUTH_SERVICE_IDENTIFIER` overrides it).
- DB-backed deployments also require a `SUPERUSER` row in `domain_roles` for that admin domain.

---

## Operational endpoints

- `GET /domain/users` — list users for a domain.
- `GET /domain/logs` — domain login logs.
- `GET /org/me` — current user's org context.
- `POST /email/send` — send a transactional email for a configured domain. Supply `X-UOA-Config-JWT: <signed config JWT>`; UOA verifies the RS256 config JWT directly from the header, requires the domain email config to be enabled and SES verification/DKIM to both be `Success`, then sends `{ to, subject, text, html?, reply_to? }`.
- `GET /internal/admin/handshake-errors` — sanitized handshake and config JWT errors for superusers, including redacted request/response context when `config_url` fetches fail before a JWT can be decoded.

Use [/api](/api) for the complete JSON endpoint schema and the canonical `config_jwt` field contract.