OAuth apps (PKCE)
Act on a member's behalf with per-scope consent that the member or the organization can revoke at any time — never beyond the member's own permissions.
Register an app
POST /api/v1/oauth-apps (oauth_app:write) registers a public client — CLIs, desktop and mobile apps, PKCE and no secret — or a confidential one, whose client_secret (ycs_…) is returned once and rotated with POST /api/v1/oauth-apps/{app_id}/rotate-secret. Redirect URIs are absolute, https or loopback http, and matched exactly; the app's scopes bound what any consent may ask for.
# POST /api/v1/oauth-apps (oauth_app:write)
{ "name": "Acme CLI", "app_type": "public",
"redirect_uris": ["http://127.0.0.1:8765/callback"],
"scopes": ["openid", "usage:read"], "homepage": "https://acme.dev" }
# 201 — client_secret: null for public apps, shown once for confidential
{ "app": { "id": "oapp_01J…", "client_id": "ycl_9f2k…a71", "state": "active", "has_secret": false, … },
"client_secret": null }Authorization code with PKCE
Consent is per scope and per user, given on the console's consent screen and revocable at any time — by the user (GET /api/v1/oauth/grants, POST …/grants/{grant_id}/revoke) or by the organization (/api/v1/oauth-apps/grants). Revoking a grant or an app revokes every token issued under it in the same transaction. PKCE with S256 is required for every client, public or confidential.
# 1. send the user to the consent screen with a PKCE challenge
GET https://yelno.ai/auth/oauth/authorize
?client_id=ycl_9f2k…a71
&response_type=code
&redirect_uri=https://app.example.com/callback
&scope=openid%20usage:read
&code_challenge=<base64url(sha256(verifier))>&code_challenge_method=S256
&state=…
# allow → https://app.example.com/callback?code=…&state=…
# deny → https://app.example.com/callback?error=access_denied&state=…
# 2. exchange the code (form-encoded or JSON;
# confidential clients add client_secret in the body or HTTP Basic)
POST /api/v1/oauth/token
grant_type=authorization_code&code=…&redirect_uri=https://app.example.com/callback
&client_id=ycl_9f2k…a71&code_verifier=…
{ "access_token": "<JWT>", "token_type": "Bearer", "expires_in": 3600,
"refresh_token": "yrt_…", "scope": "openid usage:read", "organization_id": "org_01J8…" }
# 3. call the management API as the user
Authorization: Bearer <access_token> # 1 h; grant_type=refresh_token renews (30 d, rotating)
GET /api/v1/oauth/userinfo # openid → sub, organization_id, azp (+ email scope)
GET /api/v1/usage # usage:readScopes
A token can never exceed the granting member's own permissions: each scope maps onto management permissions that are AND-ed with the member's live role, so a scope only ever narrows. No scope covers API keys' secret material, provider credentials or content — and none covers inference: user tokens drive the management API, not /v1/chat/completions. GET /api/v1/oauth/scopes serves this table for the consent screen.
Tokens and revocation
Access tokens are JWTs that live one hour and carry scp, azp (the client id) and the member's live role. Refresh tokens live 30 days and rotate on every use: presenting an already-rotated refresh token revokes every token of the grant, and presenting an authorization code twice revokes the tokens it produced. POST /api/v1/oauth/revoke (RFC 7009) revokes a token and always answers 200. A revoked token gets 401 token_revoked; a call outside the granted scopes gets 403 insufficient_scope. Token-endpoint errors use the house envelope with the RFC 6749 code in error.code — invalid_request, invalid_client (401), invalid_grant, unsupported_grant_type, invalid_scope.