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| Parameter | Required | Description |
|---|---|---|
client_id | yes | Your registered client ID |
redirect_uri | yes | Must match a registered URI exactly (byte-for-byte) |
response_type | yes | Always code |
scope | yes | Space-separated subset of your client's allowed_scopes |
state | recommended | Opaque value echoed back to you; use it for CSRF protection |
code_challenge | yes | BASE64URL(SHA256(code_verifier)) |
code_challenge_method | yes | Always 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:
| Condition | Message |
|---|---|
Unknown client_id | invalid_client |
redirect_uri not an exact registered match | redirect_uri does not match a registered URI for this client |
| Client disabled by its developer | this app has been disabled by its developer and cannot be connected |
| Missing PKCE | code_challenge is required |
plain or missing method | code_challenge_method must be S256 |
Scope beyond allowed_scopes | requested scope is not allowed for this client |
response_type ≠ code | response_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 immediatelyThis 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=9tQ2xKp7Lm3nV8bCDeclined 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_wW1gFWFOEjXkBody 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
| Status | error | When |
|---|---|---|
| 400 | invalid_request | Missing code/redirect_uri/client_id/code_verifier (or refresh_token/client_id) |
| 400 | unsupported_grant_type | Anything except authorization_code / refresh_token |
| 400 | invalid_grant | Code expired (90 s), already used, redirect/client mismatch, wrong code_verifier; also expired/unknown refresh tokens |
| 400 | invalid_grant | Refresh-token reuse detected — description reads refresh token reuse detected, all sessions for this client were revoked; the whole family has been revoked server-side |
| 401 | invalid_client | Confidential secret wrong/missing, or disabled confidential client |
| 500 | server_error | Internal failure |
Error shape:
{ "error": "invalid_grant", "error_description": "the provided authorization grant is invalid, expired, or already used" }After the flow
- Store the refresh token securely; treat access tokens as short-lived cache
- Verify access tokens on your backend (Backend verification)
- Refresh proactively (Tokens & Claims)
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
400 envelope at /authorize about redirect | URI differs by a slash/scheme/host case | Copy the registered string byte-for-byte |
invalid_grant on exchange | >90 s elapsed or code used twice | Exchange immediately in the callback handler |
invalid_grant on exchange, code is fresh | redirect_uri here ≠ the one sent to /authorize | Use the identical value in both places |
invalid_grant with fresh verifier | Verifier doesn't hash to the challenge (encoding bug) | Base64url-encode without padding; don't re-encode the challenge |
invalid_client | Secret mismatch on confidential client | Rotate the secret; redeploy both sides atomically |