codai 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 describe.

  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)

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.

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. Cancel sends them back with ?error=access_denied&error_description=User%20cancelled%20the%20authorization.

Exchange the code

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

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"

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:

{
  "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.

Mint the gateway key

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

curl https://auth.codai.ro/connect/key \
  -H "Authorization: Bearer $ACCESS_TOKEN"
{
  "api_key": "codai_xxxxxxxx",
  "api_key_id": "3d6b…-uuid",
  "base_url": "https://ai.codai.ro/v1",
  "model": "codai",
  "already_issued": false
}

Prop

Type

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.

StatuserrorMeaning
401invalid_tokenNo bearer, or Token unknown or expired. — one hour has passed, or the token was revoked.
403insufficient_scopeThe token lacks both keys:manage and inference, or the grant carries no key-minting scope.
400invalid_grantThe token has no consent grant behind it (a client-credentials style token cannot mint keys).

Call the gateway as the user

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> so their task list attributes it correctly.

Calling /connect/key 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.

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.

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

  • 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.

On this page