# PASTE THIS INTO EACH CARGO SPOKE PROJECT — v6 (Masked-Hybrid Custom OIDC)

> Copy everything below the line into the spoke's platform chat. It tells that
> project's AI exactly what to remove, add, and configure to join Cargo One Login
> using the **masked-hybrid Custom OIDC Provider (relying party)** model.

---

## Brief: Federate this app to Cargo identity (auth.cargo.ac)

We are joining the **Cargo identity federation**. This app keeps its OWN data
backend **exactly as it is** and stops running its own login. Instead it
registers **Cargo Account** as a **Custom OIDC Provider** with **Auto-discovery
OFF** and **manual endpoints**, and lets that backend mint its OWN native session
after the user authenticates on Cargo.

This is textbook OIDC federation (hub-and-spoke SSO): **Cargo Account** is the
OpenID Provider, **this app's backend** is a Relying Party. Verification is
asymmetric via the hub **JWKS** — **no shared JWT secret ever leaves Cargo.**

### ⚠️ TWO PERMANENT RULES — READ BEFORE ANYTHING ELSE

**RULE A — The public auth domain and the OIDC issuer are intentionally
different.** The public authentication domain is `https://auth.cargo.ac`. The
OIDC **issuer** is whatever **live OIDC discovery returns** (the identity
provider's backend host). These are **not** the same value, this is **by design**,
and it **will not change**. Never assume they are identical. **JWT validation
must always use the live-discovery issuer.**

```text
Public Authentication Domain : https://auth.cargo.ac
Official OIDC Issuer         : whatever live discovery returns (the backend host)
```

**RULE B — Cargo A/C is the identity authority.** Cargo owns usernames, Cargo
Account Numbers, organizations, permissions, roles, scopes, entitlements and
identity relationships. The underlying auth vendor is just the current
implementation; do not build anything that assumes a specific vendor.

### ⚠️ VERIFY, DON'T COPY BLINDLY
This brief is guidance and **may contain stale values**. Before using ANY value:

1. **VERIFY** every endpoint, claim and scope against **live OIDC discovery**.
2. **COPY the `issuer`** from live discovery verbatim — **never invent it** and
   never replace it with `auth.cargo.ac`.
3. **SANITY-CHECK** pasted code against this app's real stack. If it conflicts, **stop and ask**.
4. If this brief disagrees with live discovery, **the live system wins**.

```bash
# Read the REAL issuer + endpoints from discovery and use THOSE:
curl https://auth.cargo.ac/auth/v1/.well-known/openid-configuration
# The "issuer" field is the value you paste as Issuer (it is the backend host).
```

### ❌ DO NOT use Auto-discovery, and DO NOT use "Third-Party Auth"
- **Auto-discovery must be OFF.** If it is ON, the provider trusts the discovery
  document's `issuer` AND would route the browser to the issuer host — defeating
  the branded experience. With Auto-discovery OFF you set the Issuer to the
  backend host (so the `iss` JWT check passes) while pointing the visible
  endpoints at `auth.cargo.ac`.
- **Do not use the "Third-Party Auth" screen.** It makes your backend trust
  foreign tokens and would require sharing Cargo's JWT secret (a critical
  security risk). Use **Authentication → Providers → Add provider → Custom OIDC
  Provider** with manual endpoints instead.

### The flow you must implement (NON-NEGOTIABLE)

```text
User on https://<this-app>  (not signed in)
  → clicks one button: supabase.auth.signInWithOAuth({ provider: 'custom:cargo-ac' })
  → redirected to auth.cargo.ac → signs in ONCE on Cargo (or returned instantly via SSO)
  → YOUR data backend finishes the code exchange at:
        https://<your-data-backend>/auth/v1/callback
  → it verifies the id_token against the Cargo JWKS, JIT-creates the user,
    and mints YOUR OWN native session
  → your app reads the session and shows the dashboard.
```

## Target Spoke Configuration (subject to Gate 2 validation)

> This is the **Target** configuration. It is promoted to **Permanent** only
> after the CargoWorks AI pilot passes **Gate 2** (provider compatibility).

```text
Provider type            = Custom OIDC Provider
Provider name / id       = custom:cargo-ac
Auto-discovery           = OFF
Issuer                   = <EXACT value copied from live discovery — the backend host>
Authorization endpoint   = https://auth.cargo.ac/auth/v1/oauth/authorize
Token endpoint           = https://auth.cargo.ac/auth/v1/oauth/token
Userinfo endpoint        = https://auth.cargo.ac/auth/v1/oauth/userinfo
JWKS endpoint            = https://auth.cargo.ac/auth/v1/.well-known/jwks.json
Scopes                   = openid profile email
Allow users without email = OFF
Client ID                = <sent privately by Cargo — confidential>
Client Secret            = <sent privately by Cargo — confidential>
Callback URL             = https://api.<your-app>/auth/v1/callback  (your OWN branded backend)
```

The browser stays on `auth.cargo.ac` the whole time; the backend host appears
only inside the signed token's `iss` claim — which is exactly what RULE A
requires.

## The four gates (pilot must pass before any other spoke)

CargoWorks AI is the pilot. **No other spoke migrates until all four pass.**

- **Gate 1 — Endpoint validation.** Confirm each endpoint works through the
  branded domain:
  ```text
  https://auth.cargo.ac/auth/v1/oauth/authorize
  https://auth.cargo.ac/auth/v1/oauth/token
  https://auth.cargo.ac/auth/v1/oauth/userinfo
  https://auth.cargo.ac/auth/v1/.well-known/jwks.json
  https://auth.cargo.ac/auth/v1/.well-known/openid-configuration
  ```
  If any fails through `auth.cargo.ac`, temporarily use the issuer-host endpoint
  until the provider clarifies — do not block on it.
- **Gate 2 — Provider compatibility.** Verify the backend honors Auto-discovery
  OFF + manual Issuer/Authorization/Token/Userinfo/JWKS. If any of these are
  ignored, overridden, or discovery is forced internally, **stop and report** —
  rollout pauses for architecture review.
- **Gate 3 — Token claims validation.** Confirm the identity contract claims are
  present and correct (see "Identity contract" below) after login, refresh,
  refresh-token rotation, and org switch — and reachable by RLS.
- **Gate 4 — First spoke cutover.** Only after Gates 1–3 pass do you flip
  production traffic and promote this Target config to Permanent.

### STEP 0 — Brand your OWN data backend first (custom domain)
Do this **before** adding the provider. Your data backend must be served from
your OWN branded domain (e.g. `https://api.<your-app>`), not the vendor host.
The OIDC **Callback URL** the form shows is generated from this host — if your
backend still answers on a vendor URL, the callback will be a vendor URL too,
and **Cargo will not allowlist it**.

- Backend dashboard → **Settings → Custom Domains** → activate `api.<your-app>`.
- Confirm it is the project's **live API URL** (not just "added/verifying").
- The Callback URL inherits THIS host.

```text
https://api.<your-app>        ✅ branded — required for go-live
https://<ref>.<vendor>.co     ❌ raw vendor host — NOT acceptable for the callback
```

### STEP 1 — Turn OFF every local auth method
In your backend → Authentication → Providers, disable Email/password, Phone,
magic links/OTP and all social providers. Remove from this app:
- Sign-up / register pages and any "create account" form.
- Email + password login forms and password-reset screens.
- Every `<input type="password">` — passwords are entered ONLY on Cargo.
- `signUp` / `signInWithPassword` / `resetPasswordForEmail` / `signInWithOtp` calls.
- Any custom `/auth/callback` or `/auth/silent` routes from older instructions (retired).

### STEP 2 — Register Cargo as a Custom OIDC Provider (Auto-discovery OFF)
Authentication → Providers → Add provider → **Custom OIDC Provider**:
- Provider name / id: `custom:cargo-ac`
- **Auto-discovery: OFF** (enter endpoints manually)
- **Issuer:** the EXACT `issuer` value you copied from live discovery (the
  backend host) — do NOT type `https://auth.cargo.ac/auth/v1` here.
- Authorization endpoint: `https://auth.cargo.ac/auth/v1/oauth/authorize`
- Token endpoint: `https://auth.cargo.ac/auth/v1/oauth/token`
- Userinfo endpoint: `https://auth.cargo.ac/auth/v1/oauth/userinfo`
- JWKS endpoint: `https://auth.cargo.ac/auth/v1/.well-known/jwks.json`
- Client ID / Secret: `<sent privately by Cargo — confidential>`
- Scopes: `openid profile email`
- **Allow users without email: OFF** — Cargo always returns an email.

### STEP 3 — Copy the Callback URL and confirm it to Cargo
The provider form shows a **Callback URL** on your data backend. With STEP 0 done
it reads `https://api.<your-app>/auth/v1/callback`. Copy it **verbatim** and send
it to Cargo to add to the redirect allowlist. Matching is **exact** (scheme, host,
path, no trailing slash).

> 🛑 **STOP if this shows a `*.<vendor>.co` (raw vendor) host.** That means your
> custom domain isn't the live API URL yet — go back to STEP 0. A vendor-host
> callback will NOT be allowlisted and the app cannot go live.

### STEP 4 — One button, native session
```js
await supabase.auth.signInWithOAuth({
  provider: "custom:cargo-ac",
  options: { redirectTo: "https://<this-app>" },
});
// Read the resulting NATIVE session like any normal client session:
supabase.auth.onAuthStateChange((_event, session) => { /* ... */ });
```

### STEP 5 — Users are provisioned automatically (do NOT build this)
On a user's first successful Cargo login your backend **auto-creates**:
- `auth.users` — a new row with YOUR OWN local uuid
- `auth.identities` — links provider `custom:cargo-ac` → the Cargo `sub` (their canonical id)
- `public.profiles` — created by your `handle_new_user` trigger (recommended)

This is standard Just-In-Time provisioning — identical to first-time Google
sign-in. Later logins reuse the same user. Write **zero** provisioning code.
Your RLS keeps using `auth.uid()` (your local id); use the identity's
`provider_id` (the `sub`) to correlate back to Cargo.

### STEP 6 — Verify entitlement (provisioning ≠ authorization)
Cargo only issues a token for your app to users whose organization has an
**active grant** for it (enforced at the Cargo consent screen). As a backstop,
check the `app_access` claim after sign-in:

```js
const { data } = await supabase.auth.getSession();
const claims = data.session?.user?.app_metadata ?? {};
const allowed = (claims.app_access ?? []).includes("<your-app-key>");
if (!allowed) { await supabase.auth.signOut(); showRequestAccess(); }
```

### STEP 7 — Global logout
```js
await supabase.auth.signOut(); // ends THIS app's native session
await fetch("https://auth.cargo.ac/auth/v1/logout", { method: "POST", credentials: "include" });
```

### Identity contract — claims you MUST have (Gate 3)
Authentication succeeding is **not** enough. Confirm ALL of these claims are
present and correct after login, token refresh, refresh-token rotation, and
organization switch — and that they are readable by your RLS and authorization
logic:

```text
username            cargo_account_number   identity_type   user_type
permissions[]       orgs[]                 active_org      app_roles
amr                 scope
```

A spoke that authenticates but lacks these claims is a **FAILED migration**.

### Database rules
- Keep ALL your tables, storage, functions and RLS — unchanged.
- `auth.uid()` is your local user id; it is stable per person across logins.
- Read extra identity from token claims (see the identity contract above).
- Never write to the auth schema yourself; provisioning is automatic.

### Client registration (request from the Cargo admin)
Ask admin.cargo.ac → Integration → OAuth → OAuth Clients to register:
- `client_name`: <this app's name>
- `client_type`: **confidential** (client_id + secret, delivered out of band)
- `redirect_uris`: `https://<your-data-backend>/auth/v1/callback`
- `scopes`: `openid profile email` · `grant_types`: `authorization_code`, `refresh_token`

### FAQ — settings people get stuck on
- **Should I turn Auto-discovery ON?** **No — keep it OFF.** With it ON, the
  provider adopts the discovery `issuer` and routes the browser to the issuer
  host, breaking the branded experience. OFF lets you set Issuer = backend host
  (so `iss` validates) while the visible endpoints stay on `auth.cargo.ac`.
- **What do I put as the Issuer?** The EXACT `issuer` string from live discovery
  (the backend host). It will NOT be `auth.cargo.ac` — that's expected and
  correct (RULE A). Do not invent or "rebrand" it.
- **Why is the Callback URL grayed out / a `*.<vendor>.co` address?** That field
  is your backend's OWN fixed redirect endpoint (your live API base URL +
  `/auth/v1/callback`); it can't be edited. It shows a raw vendor host only because
  your branded custom domain isn't the live API URL yet — finish STEP 0.
- **Should "Allow users without email" be ON?** **No — keep it OFF.** Cargo
  always returns an email; ON would permit emailless ghost users and break
  email-based JIT provisioning.
- **The form wants a Client ID / Secret I don't have.** Expected — Cargo issues
  a confidential Client ID + Secret per app and sends them out of band.

### Done when
- [ ] Your data backend is served from your BRANDED custom domain (`api.<your-app>`), not a vendor host.
- [ ] Every local auth provider (Email/Phone/Google) is OFF; no password fields anywhere.
- [ ] Cargo is registered as a **Custom OIDC Provider with Auto-discovery OFF**, NOT Third-Party Auth.
- [ ] Issuer = the EXACT live-discovery value (backend host), copied not invented.
- [ ] Manual endpoints (authorize/token/userinfo/jwks) all point at `https://auth.cargo.ac/auth/v1/...`.
- [ ] "Allow users without email" is OFF; Scopes = `openid profile email`.
- [ ] Confidential Client ID + Secret entered.
- [ ] The BRANDED Callback URL (`https://api.<your-app>/auth/v1/callback`) was sent to Cargo and allowlisted — NOT a `*.<vendor>.co` URL.
- [ ] One button calls `signInWithOAuth('custom:cargo-ac')`.
- [ ] First login JIT-provisions the user and mints YOUR OWN native session; RLS still works.
- [ ] After sign-in the app checks the `app_access` claim and signs out non-entitled users.
- [ ] The identity-contract claims are all present (login, refresh, rotation, org switch).
- [ ] Global logout fires `signOut()` + the Cargo global logout endpoint.
- [ ] Every value above was re-confirmed against live discovery before shipping.
