Core Concepts
The OAuth Flow

The OAuth Flow

Camel Accounts implements the OAuth 2.0 Authorization Code flow with PKCE (S256 only — plain is rejected outright). This page walks through every hop with the exact HTTP traffic.

Flow at a glance

Browser                          Camel Accounts               Your App
   │                                   │                          │
   │ ① GET /oauth/authorize?...        │                          │
   ├──────────────────────────────────>│                          │
   │                                   │ validate client, redirect_uri,
   │                                   │ PKCE, scope  (400 on error)
   │                                   │
   │ ② sign in + consent (Cockpit UI)  │
   │<─────────────────────────────────>│
   │                                   │
   │ ③ 302 → redirect_uri?code=…&state=…                          │
   │<──────────────────────────────────┤                          │
   │                                   │                          │
   │                                   │ ④ POST /oauth/token      │
   │                                   │    (code + verifier)     │
   │                                   │<─────────────────────────┤
   │                                   │ ⑤ tokens (raw JSON)      │
   │                                   ├─────────────────────────>│

① The authorization request

Full browser navigation (link, window.location, or Custom Tab):

GET /oauth/authorize
  ?client_id=bajeti-android
  &redirect_uri=bajeti://oauth/callback
  &response_type=code
  &scope=openid%20profile
  &state=9tQ2xKp7Lm3nV8bC
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256
ParameterRequiredDescription
client_idyesYour registered client ID
redirect_uriyesMust match a registered URI exactly (byte-for-byte)
response_typeyesAlways code
scopeyesSpace-separated subset of your client's allowed_scopes
staterecommendedOpaque value echoed back to you; use it for CSRF protection
code_challengeyesBASE64URL(SHA256(code_verifier))
code_challenge_methodyesAlways S256

Server-side validation order

Malformed requests are answered with a 400 JSON envelope (never a redirect) so attackers can't probe via redirects:

{ "success": false, "message": "redirect_uri does not match a registered URI for this client", "errors": ["..."] }

All validation-failure messages:

ConditionMessage
Unknown client_idinvalid_client
redirect_uri not an exact registered matchredirect_uri does not match a registered URI for this client
Client disabled by its developerthis app has been disabled by its developer and cannot be connected
Missing PKCEcode_challenge is required
plain or missing methodcode_challenge_method must be S256
Scope beyond allowed_scopesrequested scope is not allowed for this client
response_typecoderesponse_type must be 'code'

Then the redirect ladder

If validation passed, the server walks the user through whatever's missing:

valid request?
  ├── no camel_session cookie ──────► 302 /login?next=<original authorize URL>
  ├── session but email/phone unverified ► 302 /verify-email?next=<original URL>
  ├── no consent yet (or scope grew) ───► 302 /consent?<same query params>
  └── everything fine ─────────────────► code minted immediately

This ladder is why second app, same user = zero clicks: session exists + consent already granted → straight to step ③. That is cross-app SSO.

② Login & consent

Happens entirely on Camel Accounts' Cockpit UI. Users can sign in with password (+ TOTP if MFA is enabled), a passkey, or Google/GitHub. The consent screen shows your client's branding (logo_url, name, homepage/privacy links) and exactly which scopes are requested; scope expansion after a previous consent triggers a fresh prompt.

You can render branding previews yourself:

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
  }
}

③ The callback

Success — code appended to your exact redirect URI:

bajeti://oauth/callback?code=43charBase64url&state=9tQ2xKp7Lm3nV8bC

Declined consent:

bajeti://oauth/callback?error=access_denied&state=…

Code properties you must design around:

  • Single-use — replaying it fails
  • 90-second TTL
  • Bound server-side to (client_id, redirect_uri, code_challenge) — swapping any of them fails the exchange

④⑤ Token exchange

curl -s -X POST "$ISSUER/oauth/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=authorization_code \
  -d code=Xk7fQ2mN8pRtV5wYzA1bC4dE6gH9jK3lM0nP2qS5uX8z \
  -d redirect_uri=bajeti://oauth/callback \
  -d client_id=bajeti-android \
  -d code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

Body is form-encoded, per the OAuth spec — standard libraries send it correctly automatically. JSON bodies are not accepted.

Success — note this response is raw OAuth2 JSON, not the usual {success,data} envelope (so AppAuth and friends work unmodified):

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

Response headers include Cache-Control: no-store and Pragma: no-cache.

Confidential clients add -d client_secret=cask_…. Wrong or missing secret → 401 invalid_client with a WWW-Authenticate: Basic realm="camel-accounts" header.

Every token-endpoint error

StatuserrorWhen
400invalid_requestMissing code/redirect_uri/client_id/code_verifier (or refresh_token/client_id)
400unsupported_grant_typeAnything except authorization_code / refresh_token
400invalid_grantCode expired (90 s), already used, redirect/client mismatch, wrong code_verifier; also expired/unknown refresh tokens
400invalid_grantRefresh-token reuse detected — description reads refresh token reuse detected, all sessions for this client were revoked; the whole family has been revoked server-side
401invalid_clientConfidential secret wrong/missing, or disabled confidential client
500server_errorInternal failure

Error shape:

{ "error": "invalid_grant", "error_description": "the provided authorization grant is invalid, expired, or already used" }

After the flow

Common pitfalls

SymptomCauseFix
400 envelope at /authorize about redirectURI differs by a slash/scheme/host caseCopy the registered string byte-for-byte
invalid_grant on exchange>90 s elapsed or code used twiceExchange immediately in the callback handler
invalid_grant on exchange, code is freshredirect_uri here ≠ the one sent to /authorizeUse the identical value in both places
invalid_grant with fresh verifierVerifier doesn't hash to the challenge (encoding bug)Base64url-encode without padding; don't re-encode the challenge
invalid_clientSecret mismatch on confidential clientRotate the secret; redeploy both sides atomically