# Connect flow (/docs/auth/connect-flow)

Step by step — authorize with PKCE, exchange the code, call /connect/key, store the gateway key. With a browser CORS note.

The connect flow is ordinary OpenID Connect plus one codai endpoint. When it ends you hold a `codai_…` gateway key that belongs to **your user**, scoped to **their consent to your app**, and you call `https://ai.codai.ro` exactly as the [gateway docs](../gateway/index.mdx) describe.

```text
  your app ──1 redirect──▶ auth.codai.ro/auth ──login + consent──▶ your redirect_uri?code=…&state=…
  your app ──2 POST /token (code + code_verifier)──▶ { access_token, id_token, … }
  your app ──3 GET /connect/key (Bearer access_token)──▶ { api_key: "codai_…", base_url, … }
  your app ──4 Authorization: Bearer codai_… ──▶ ai.codai.ro/v1/…   (as the user)
```

<Steps>
<Step>

### Build the authorize URL [#build-the-authorize-url]

PKCE is mandatory: generate a random `code_verifier` (43 – 128 URL-safe chars), derive `code_challenge = base64url(sha256(verifier))`, and keep the verifier plus a random `state` in the user's session.

```text
GET https://auth.codai.ro/auth
  ?client_id=<your client_id>
  &response_type=code
  &redirect_uri=<a registered redirect URI, exact match>
  &scope=openid%20email%20profile%20inference%20keys:manage
  &state=<random>
  &code_challenge=<S256 of the verifier>
  &code_challenge_method=S256
```

Optional additions: `offline_access` in `scope` for a refresh token (only if your client has the `refresh_token` grant), `nonce` if you validate ID tokens the strict way, `acr_values=urn:codai:acr:mfa` to require a second factor, `resource=https://ai.codai.ro/v1` to be explicit about the audience (it is also the default).

The user signs in (or signs up) and sees a consent screen listing your requested [scopes](./scopes.mdx). Cancel sends them back with `?error=access_denied&error_description=User%20cancelled%20the%20authorization.`

</Step>
<Step>

### Exchange the code [#exchange-the-code]

Back at your `redirect_uri`, check `state`, then `POST /token`:

<Tabs items={['curl', 'TypeScript', 'Python']}>
<Tab value="curl">

```bash
curl https://auth.codai.ro/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "code=$CODE" \
  --data-urlencode "redirect_uri=https://app.example.com/callback" \
  --data-urlencode "client_id=$CLIENT_ID" \
  --data-urlencode "code_verifier=$VERIFIER"
```

</Tab>
<Tab value="TypeScript">

```ts
const tokenRes = await fetch('https://auth.codai.ro/token', {
  method: 'POST',
  headers: { 'content-type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    code,
    redirect_uri: 'https://app.example.com/callback',
    client_id: CLIENT_ID,
    code_verifier: verifier,
  }),
});
const tok = (await tokenRes.json()) as { access_token: string; id_token: string; expires_in: number; scope: string; refresh_token?: string };
```

</Tab>
<Tab value="Python">

```python
tok = requests.post(
    "https://auth.codai.ro/token",
    data={
        "grant_type": "authorization_code",
        "code": code,
        "redirect_uri": "https://app.example.com/callback",
        "client_id": CLIENT_ID,
        "code_verifier": verifier,
    },
    timeout=15,
).json()
```

</Tab>
</Tabs>

A confidential client authenticates here too — `Authorization: Basic base64(client_id:client_secret)` (`client_secret_basic`), `client_secret` in the form (`client_secret_post`), or a `client_assertion` JWT (`private_key_jwt`). A public client sends `client_id` only.

Response:

```json
{
  "access_token": "opaque…",
  "id_token": "eyJhbGciOiJSUzI1NiIs…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "openid email profile inference keys:manage",
  "refresh_token": "…only with offline_access…"
}
```

The access token is opaque and lives one hour. You need it for exactly one more call.

</Step>
<Step>

### Mint the gateway key [#mint-the-gateway-key]

`GET /connect/key` with the access token. The token must carry `keys:manage` **or** `inference`.

```bash
curl https://auth.codai.ro/connect/key \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

```json
{
  "api_key": "codai_xxxxxxxx",
  "api_key_id": "3d6b…-uuid",
  "base_url": "https://ai.codai.ro/v1",
  "model": "codai",
  "already_issued": false
}
```

<TypeTable
  type={{
    api_key: { type: 'string | null', description: 'The plaintext gateway key. Returned exactly once per consent; null afterwards.' },
    api_key_id: { type: 'string', description: 'The key\'s id — what the user sees in hub → Keys, labelled "OAuth · <your client name>".' },
    base_url: { type: 'string', description: 'OpenAI-style base URL including /v1. Point an OpenAI SDK here as-is; strip /v1 for the codai SDKs, which add it themselves.' },
    model: { type: 'string', description: 'The model name to send.', default: "'codai'" },
    already_issued: { type: 'boolean', description: 'true when this consent already minted its key and the plaintext is gone.' },
  }}
/>

**One key per consent.** The IdP never stores the plaintext. If you call `/connect/key` again for the same grant you get `{ "api_key": null, "already_issued": true, … }` — there is nothing to retrieve. Persist the key (encrypted) the moment you receive it. Lost it? Send the user through the authorize step again: a fresh consent is a fresh grant and mints a fresh key, and the user can revoke the old one in the hub.

Consent grants live 90 days; when one expires the user reconnects and you store the new key.

| Status | `error` | Meaning |
| --- | --- | --- |
| `401` | `invalid_token` | No bearer, or *Token unknown or expired.* — one hour has passed, or the token was revoked. |
| `403` | `insufficient_scope` | The token lacks both `keys:manage` and `inference`, or the grant carries no key-minting scope. |
| `400` | `invalid_grant` | The token has no consent grant behind it (a client-credentials style token cannot mint keys). |

</Step>
<Step>

### Call the gateway as the user [#call-the-gateway-as-the-user]

```ts
import OpenAI from 'openai';

const client = new OpenAI({ apiKey: connected.api_key, baseURL: connected.base_url }); // base_url already ends in /v1
const reply = await client.chat.completions.create({ model: connected.model, messages: [{ role: 'user', content: 'Hello from my app' }] });
```

Usage lands on the user's plan and shows up in their hub under the key labelled with your app's name. Send [`X-Codai-Client: <your-app>/<version>`](../gateway/headers.mdx#client-identity) so their task list attributes it correctly.

</Step>
</Steps>

## Calling `/connect/key` from a browser [#calling-connectkey-from-a-browser]

`/connect/key` answers CORS preflights only for an **allow-list** of origins, and the allow-list is codai's, not derived from your redirect URIs. Concretely:

- No `Origin` header (server-to-server, curl, native apps) → the request works and no CORS headers are added.
- Allowed origin → `Access-Control-Allow-Origin: <origin>`, `Vary: Origin`; preflight `204` with `Access-Control-Allow-Methods: GET, OPTIONS`, `Access-Control-Allow-Headers: authorization, content-type, x-codai-device-name`, `Access-Control-Max-Age: 86400`.
- Any other origin → preflight `403 { "error": "origin_not_allowed" }`; a direct `GET` still runs but the browser cannot read the response.

<Callout type="warn">
A third-party single-page app cannot call `/connect/key` cross-origin. Do the code exchange **and** the key mint on your backend, then hand the key (or, better, a session bound to it) to the browser. `/token` is different — its CORS follows your registered redirect URIs for public clients, so a SPA may exchange the code directly and still needs the backend only for `/connect/key`.
</Callout>

If your product genuinely needs a browser-only flow, ask us to add your origin to the allow-list when the client is registered.

## Refreshing and revoking [#refreshing-and-revoking]

- With `offline_access`, `POST /token` with `grant_type=refresh_token&refresh_token=…&client_id=…` returns a new access token and — for public clients always, for confidential ones past 70 % of TTL — a rotated refresh token. The *gateway key does not expire with the access token*; you only need a fresh access token if you want to call `/me` or re-check the grant.
- `POST /token/revocation` with `token=<refresh_token>` ends the session on your side. Revoking the **grant** (the user disconnecting in the hub) revokes the gateway key too; your next call to `ai.codai.ro` is `401 invalid_api_key` — prompt the user to connect again.
- `GET /me` with the access token returns the claims for the granted scopes: `sub`, `email`, `email_verified`, `name`, `picture`, plus `role` and `sid`.
