API Reference
OAuth / OIDC Endpoints

OAuth / OIDC Endpoints

The endpoints client apps and libraries talk to. Full flow narrative: The OAuth Flow.


GET /oauth/authorize

Browser navigation endpoint — the front door of every login.

ParamRequiredValue
client_idyesRegistered ID
redirect_uriyesExact registered URI
response_typeyescode
scopeyesSpace-separated subset of allowed_scopes
staterecommendedEchoed back verbatim
code_challengeyesBASE64URL(SHA256(verifier))
code_challenge_methodyesS256

Redirect outcomes:

validation error          → 400 JSON envelope (never a redirect)
no session                → 302 /login?next=<authorize URL>
session, unverified acct  → 302 /verify-email?next=<authorize URL>
consent missing/expanding → 302 /consent?<same params>
all good                  → 302 redirect_uri?code=…&state=…

POST /oauth/consent

Programmatic consent used by the Cockpit SPA. Auth: session.

{
  "client_id": "bajeti-web-x7k2p9",
  "redirect_uri": "https://bajeti.cameltech.co/callback",
  "scope": "openid profile",
  "state": "9tQ2xKp7Lm3nV8bC",
  "code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
  "code_challenge_method": "S256",
  "approve": true
}

Everything is re-validated server-side (client status, redirect match, PKCE, scope subset). Unverified account → 403 account must verify email or phone before connecting an app.

Approve200 { "success": true, "data": { "redirect_uri": "https://bajeti.cameltech.co/callback?code=…&state=…" } }

Decline ("approve": false) → 200 { "success": true, "data": { "redirect_uri": "…?error=access_denied&state=…" } }

Send your user to that redirect_uri to complete the flow.


POST /oauth/token

Rate limit: 30/min/IP · Body: form-urlencoded · Response: raw OAuth2 JSON.

Grant: authorization_code

grant_type=authorization_code
code=<43-char code>
redirect_uri=<identical to authorize call>
client_id=bajeti-android
code_verifier=<the raw verifier>
client_secret=cask_…            ← confidential clients only

Grant: refresh_token

grant_type=refresh_token
refresh_token=<latest token>
client_id=bajeti-android
client_secret=cask_…            ← confidential clients only

Success

{
  "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjIwMjYtMDEiLCJ0eXAiOiJKV1QifQ…",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "qRs2T-vVh7wY4zA9bC1dF3gH5jK7lM0nP2qS4uW6xE8yZ0aB2cD",
  "scope": "openid profile"
}

Headers: Cache-Control: no-store, Pragma: no-cache. Refresh tokens rotate on every refresh grant; reuse of a rotated one revokes the whole family.

Errors

StatuserrorTrigger
400invalid_requestMissing required fields
400unsupported_grant_typeUnknown grant type
400invalid_grantCode expired/used/mismatched; bad verifier; unknown/expired refresh token
400invalid_grant + refresh token reuse detected, all sessions for this client were revokedRotated-token replay → family revoked server-side
401invalid_client (+ WWW-Authenticate: Basic realm="camel-accounts")Wrong/missing secret; disabled confidential client
500server_errorInternal
{ "error": "invalid_grant", "error_description": "the provided authorization grant is invalid, expired, or already used" }

GET /oauth/userinfo

Auth: Authorization: Bearer <access_token>. Raw claims JSON — scope-gated:

{
  "sub": "b3f1c92e-4d5a-4f6b-8a7c-9e0d1f2a3b4c",
  "name": "Amina Juma",
  "email": "amina@example.com",
  "email_verified": true,
  "phone_number": "+255712345678",
  "phone_number_verified": true
}

Claim mapping: profilename,picture · emailemail,email_verified · phonephone_number,phone_number_verified · sub always. Absent attributes are omitted. 401 envelope on missing/expired/revoked bearer.


GET /oauth/jwks

RS256 public keys with kid; served with Cache-Control: public, max-age=300. During rotation multiple keys coexist for a 15-minute overlap window — always route by kid.

{ "keys": [ { "kty": "RSA", "use": "sig", "alg": "RS256", "kid": "2026-01", "n": "0vx7agoebGcQ…", "e": "AQAB" } ] }

GET /.well-known/openid-configuration

Full discovery document (Cache-Control: public, max-age=3600) — feed this to AppAuth/oidc-client-ts/etc:

{
  "issuer": "https://accounts.camelcreatives.com",
  "authorization_endpoint": "https://accounts.camelcreatives.com/oauth/authorize",
  "token_endpoint": "https://accounts.camelcreatives.com/oauth/token",
  "jwks_uri": "https://accounts.camelcreatives.com/oauth/jwks",
  "userinfo_endpoint": "https://accounts.camelcreatives.com/oauth/userinfo",
  "response_types_supported": ["code"],
  "scopes_supported": ["openid", "profile", "email", "phone"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "subject_types_supported": ["public"],
  "id_token_signing_alg_values_supported": ["RS256"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none"]
}

No ID token is issued — use the access token with /oauth/userinfo.


GET /oauth/clients/{client_id}

Public branding metadata (powers consent screens). No auth.

curl -s "$ISSUER/oauth/clients/bajeti-web-x7k2p9"
{
  "success": true,
  "data": {
    "client_id": "bajeti-web-x7k2p9",
    "name": "Bajeti Web",
    "allowed_scopes": ["openid", "profile"],
    "logo_url": "https://bajeti.cameltech.co/logo.png",
    "homepage_url": "https://bajeti.cameltech.co",
    "privacy_policy_url": "https://bajeti.cameltech.co/privacy",
    "status": "production",
    "first_party": false
  }
}

Never exposes redirect URIs or secrets. Optional fields are omitted when unset. 404 unknown client.

POST /oauth/revoke

Revoke a user's grant for a client — deletes consent, kills the refresh-token family and live access tokens immediately. Auth: session.

{ "client_id": "bajeti-web-x7k2p9" }

{ "success": true, "message": "connection revoked" }