Getting Started
Quick Start

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 dev

Prerequisites

  • A registered client_id with your exact redirect_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 subnever 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" | jq

Complete 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_uri matches 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