Quick Start
Get "Sign in with Camel Accounts" working end-to-end with nothing but a browser and curl. Then keep the same code shape for production.
All examples use this issuer:
export ISSUER=https://accounts.camelcreatives.com # or http://localhost:8080 in local devPrerequisites
- A registered
client_idwith your exactredirect_uri(see Register a Client) - Your client is a public client (SPA / mobile) — PKCE is the only protection, no secret involved
The snippets below assume the redirect URI
http://localhost:3000/callback. Substitute whatever you registered — it must match byte-for-byte, including scheme and trailing slash.
Step 1: Generate a PKCE pair
Every authorization request needs a random code_verifier (kept secret in your app) and its SHA-256 hash as the code_challenge.
// pkce.js
function base64UrlEncode(buffer) {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
function generateCodeVerifier() {
const bytes = new Uint8Array(32); // 256 bits → 43-char base64url string
crypto.getRandomValues(bytes);
return base64UrlEncode(bytes);
}
async function generateCodeChallenge(verifier) {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
return base64UrlEncode(digest);
}
function generateState() {
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)));
}plain challenges are rejected by the server — always S256.
Step 2: Redirect the user to /oauth/authorize
async function login() {
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
const state = generateState();
// Persist for the callback — sessionStorage survives the round-trip
sessionStorage.setItem("pkce_verifier", verifier);
sessionStorage.setItem("oauth_state", state);
const params = new URLSearchParams({
client_id: "your-app-web",
redirect_uri: "http://localhost:3000/callback",
response_type: "code",
scope: "openid profile email",
state,
code_challenge: challenge,
code_challenge_method: "S256",
});
window.location.href = `${process.env.NEXT_PUBLIC_ISSUER}/oauth/authorize?${params}`;
}What happens next is Camel Accounts' problem, not yours:
- No session cookie → user lands on the login/register screen (
?next=returns them to the flow afterwards) - Account exists but email/phone unverified → verification screen first
- First time with your app → consent screen ("Bajeti wants to access your name and email")
- Already signed in and already consented → instant redirect back, zero clicks (this is what makes cross-app SSO feel magic)
Step 3: Handle the callback
The server redirects to your URI exactly like this:
http://localhost:3000/callback?code=Xk7f...&state=9tQ2...If the user declined consent you get error=access_denied&state=… instead of a code.
async function handleCallback() {
const params = new URLSearchParams(window.location.search);
if (params.get("error")) {
render(`Login failed: ${params.get("error")}`);
return;
}
// CSRF check — state MUST match what you generated in step 2
if (params.get("state") !== sessionStorage.getItem("oauth_state")) {
throw new Error("State mismatch — possible CSRF, abort");
}
await exchangeCode(params.get("code"), sessionStorage.getItem("pkce_verifier"));
}Step 4: Exchange the code at /oauth/token
This endpoint takes application/x-www-form-urlencoded (standard OAuth), not JSON. Authorization codes live for 90 seconds and are single-use — exchange immediately.
async function exchangeCode(code, verifier) {
const res = await fetch(`${process.env.NEXT_PUBLIC_ISSUER}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: "http://localhost:3000/callback",
client_id: "your-app-web",
code_verifier: verifier,
}),
});
if (!res.ok) {
const err = await res.json(); // { error, error_description }
throw new Error(`${err.error}: ${err.error_description}`);
}
const tokens = await res.json();
/*
{
"access_token": "eyJhbGciOiJSUzI1NiIs…", // RS256 JWT, valid 15 min
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "qRs2T…43-char opaque", // rotate on every refresh
"scope": "openid profile email"
}
*/
saveTokens(tokens);
}Confidential clients add one field: client_secret: "cask_…". Public clients never send one.
Step 5: Read the user's identity from /oauth/userinfo
async function loadUser(accessToken) {
const res = await fetch(`${process.env.NEXT_PUBLIC_ISSUER}/oauth/userinfo`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (res.status === 401) return refreshTokenFlow(); // expired → refresh
return res.json();
/*
{
"sub": "b3f1c92e-…-uuid", // ALWAYS present — key your users by this
"name": "Amina Juma", // scope: profile (if set)
"email": "amina@example.com", // scope: email
"email_verified": true,
"phone_number": "+255712345678" // scope: phone
}
*/
}Claims only appear for scopes the user consented to. See Scopes & UserInfo.
Create/look up your local user by sub — never by email or phone (they can change; sub cannot).
Step 6: Refresh before expiry
Access tokens die after 15 minutes. Refresh tokens last 30 days but rotate on every use — store the new one and discard the old immediately.
async function refresh(refreshToken) {
const res = await fetch(`${process.env.NEXT_PUBLIC_ISSUER}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: "your-app-web",
}),
});
if (res.status === 400) {
// invalid_grant = expired, OR reuse detected → whole family revoked.
// Either way the only option is a fresh interactive login.
forceSignOut();
return;
}
const tokens = await res.json();
saveTokens(tokens); // contains a NEW refresh_token — overwrite the old one
}Never retry a failed refresh with the same token. Reusing a rotated token trips theft detection and revokes every session that client has for that user.
Verify it all with curl
You can drive the machine-to-machine parts without any frontend. Register a test account, log in, and read the session:
# 1. Register (201; also emails a verification OTP)
curl -s -X POST "$ISSUER/auth/register" \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"supersecret123"}'
# 2. Login — sets a camel_session cookie in cookies.txt
curl -s -c cookies.txt -X POST "$ISSUER/auth/login" \
-H "Content-Type: application/json" \
-d '{"identifier":"test@example.com","password":"supersecret123"}'
# 3. Who am I?
curl -s -b cookies.txt "$ISSUER/auth/me"And confirm the OIDC surface:
curl -s "$ISSUER/.well-known/openid-configuration" | jq
curl -s "$ISSUER/oauth/jwks" | jqComplete minimal demo page
One HTML file, no build step — paste, set your client_id, open http://localhost:3000:
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Camel Accounts demo</title>
</head>
<body>
<h1>Camel Accounts demo</h1>
<button id="login">Sign in</button>
<pre id="out"></pre>
<script type="module">
const ISSUER = "https://accounts.camelcreatives.com";
const CLIENT_ID = "your-app-web";
const REDIRECT_URI = "http://localhost:3000";
const SCOPES = "openid profile email";
const b64url = (b) =>
btoa(String.fromCharCode(...new Uint8Array(b)))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const rand = () => b64url(crypto.getRandomValues(new Uint8Array(32)));
async function sha256(s) {
return crypto.subtle.digest("SHA-256", new TextEncoder().encode(s));
}
document.getElementById("login").onclick = async () => {
const verifier = rand(), state = rand();
sessionStorage.setItem("v", verifier);
sessionStorage.setItem("s", state);
const q = new URLSearchParams({
client_id: CLIENT_ID, redirect_uri: REDIRECT_URI,
response_type: "code", scope: SCOPES, state,
code_challenge: b64url(await sha256(verifier)),
code_challenge_method: "S256",
});
location.href = `${ISSUER}/oauth/authorize?${q}`;
};
if (location.search.includes("code")) {
const p = new URLSearchParams(location.search);
history.replaceState(null, "", "/");
(async () => {
if (p.get("state") !== sessionStorage.getItem("s")) return out("CSRF!");
const res = await fetch(`${ISSUER}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code", code: p.get("code"),
redirect_uri: REDIRECT_URI, client_id: CLIENT_ID,
code_verifier: sessionStorage.getItem("v"),
}),
});
const t = await res.json();
if (!res.ok) return out(JSON.stringify(t, null, 2));
const u = await fetch(`${ISSUER}/oauth/userinfo`, {
headers: { Authorization: `Bearer ${t.access_token}` },
}).then((r) => r.json());
out(JSON.stringify(u, null, 2));
})();
}
const out = (s) => (document.getElementById("out").textContent = s);
</script>
</body>
</html>Key rules to remember
redirect_urimatches exactly — no wildcards, no trailing-slash tolerance- PKCE is mandatory and
S256-only - Authorization codes: single-use, 90-second TTL
- Access tokens: 15 min · Refresh tokens: 30 days, rotating
- Refresh-token reuse = theft detection → entire token family revoked
- Key your users by
sub, nothing else