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.
| Param | Required | Value |
|---|---|---|
client_id | yes | Registered ID |
redirect_uri | yes | Exact registered URI |
response_type | yes | code |
scope | yes | Space-separated subset of allowed_scopes |
state | recommended | Echoed back verbatim |
code_challenge | yes | BASE64URL(SHA256(verifier)) |
code_challenge_method | yes | S256 |
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.
Approve → 200 { "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 onlyGrant: refresh_token
grant_type=refresh_token
refresh_token=<latest token>
client_id=bajeti-android
client_secret=cask_… ← confidential clients onlySuccess
{
"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
| Status | error | Trigger |
|---|---|---|
| 400 | invalid_request | Missing required fields |
| 400 | unsupported_grant_type | Unknown grant type |
| 400 | invalid_grant | Code expired/used/mismatched; bad verifier; unknown/expired refresh token |
| 400 | invalid_grant + refresh token reuse detected, all sessions for this client were revoked | Rotated-token replay → family revoked server-side |
| 401 | invalid_client (+ WWW-Authenticate: Basic realm="camel-accounts") | Wrong/missing secret; disabled confidential client |
| 500 | server_error | Internal |
{ "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: profile → name,picture · email → email,email_verified · phone → phone_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" }