Sign-in flow (Authorization Code + PKCE)
Sign in “with Calab” step by step — from the button in your app to a verified ID token and profile.
Calab supports a single flow — Authorization Code with PKCE S256. It is the same for every client type; the only difference is how the client authenticates at /token.
The examples use:
| Value | Example |
|---|---|
| Workspace issuer | https://id.example.com/oidc/workspaces/0192a000-0000-7000-8000-0000000000aa |
client_id | calab_client_e07JrYTVpGfRYJ0pQaF6yzkME6S9W856DgsKNWOKvgE |
| Secret (web app with server only) | calab_os_VWMV4RGIRsisgKhdE7ohFW_QoAdNWS4n9GkM3Ty1kb4 |
redirect_uri | https://crm.example.com/auth/calab/callback |
Diagram
Solid arrows are requests, dashed ones are responses and redirects. Steps 3–6 happen in the user's browser; steps 8–11 are server to server (for SPAs and native apps — directly from the client).
Step 1. Prepare PKCE, state and nonce
For every sign-in attempt, generate and keep in your app's session:
code_verifier— 43–128 characters fromA–Z a–z 0–9 - . _ ~(typically 32 random bytes in base64url);code_challenge=BASE64URL(SHA-256(code_verifier))without=padding — exactly 43 characters;state— a random 1–512 byte string that protects against CSRF;nonce— a random 1–512 byte string that binds the ID token to this attempt.
# Generating the values in a shell (openssl ≥ 1.1)
CODE_VERIFIER=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl base64 | tr '+/' '-_' | tr -d '=')
STATE=$(openssl rand -hex 16)
NONCE=$(openssl rand -hex 16)import { createHash, randomBytes } from 'node:crypto';
const codeVerifier = randomBytes(32).toString('base64url');
const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url');
const state = randomBytes(16).toString('hex');
const nonce = randomBytes(16).toString('hex');
// keep codeVerifier, state and nonce in the user's server-side sessionimport base64, hashlib, secrets
code_verifier = secrets.token_urlsafe(32)
code_challenge = base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).rstrip(b"=").decode()
state = secrets.token_hex(16)
nonce = secrets.token_hex(16)
# keep code_verifier, state and nonce in the user's server-side sessionStep 2. Send the user to /authorize
Redirect the browser to the authorization_endpoint:
https://id.example.com/oidc/workspaces/0192a000-0000-7000-8000-0000000000aa/authorize
?response_type=code
&client_id=calab_client_e07JrYTVpGfRYJ0pQaF6yzkME6S9W856DgsKNWOKvgE
&redirect_uri=https%3A%2F%2Fcrm.example.com%2Fauth%2Fcalab%2Fcallback
&scope=openid%20profile%20email
&state=4f1c2a…
&nonce=9b7e30…
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256| Parameter | Required | Value |
|---|---|---|
response_type | yes | code |
client_id | yes | From the client settings |
redirect_uri | yes | Exactly one of the registered URIs |
scope | yes | openid, optionally profile and email — no wider than the client allows |
state, nonce | yes | 1–512 bytes |
code_challenge | yes | 43 base64url characters |
code_challenge_method | yes | S256 (plain is not supported) |
response_mode | no | query only |
prompt | no | login, consent, both, or none (on its own) |
max_age | no | Seconds; the Calab sign-in must be no older than this |
Each parameter appears exactly once. request, request_uri, claims, id_token_hint, resource and audience are rejected with invalid_request; acr_values is accepted and ignored. You may also send the same parameters as a form POST (application/x-www-form-urlencoded, no query string).
Step 3. The user signs in and approves
Calab redirects the browser to the consent screen on the identity origin (https://id.example.com/oauth/consent?request=…). A person who is not signed in to Calab signs in first; if the workspace requires SSO, they confirm through the corporate IdP. Then they see “Allow sign-in to application?” with the application name, the workspace, their account name and the requested data. See Consent screen.
A sign-in request lives for 10 minutes.
Step 4. Handle the response at redirect_uri
Success:
https://crm.example.com/auth/calab/callback
?code=calab_oc_Laufm1V1F0gnbG0nHG4NcwvsM6wHkhQOmk4AH8CzCSw
&state=4f1c2a…
&iss=https%3A%2F%2Fid.example.com%2Foidc%2Fworkspaces%2F0192a000-0000-7000-8000-0000000000aaA refusal or an error arrives at the same address with error instead of code (for example error=access_denied), again with state and iss.
Always check that:
statematches the one kept in the session;issequals this workspace's issuer (mix-up protection, RFC 9207).
Step 5. Exchange the code for tokens
POST to the token_endpoint with an application/x-www-form-urlencoded body and no query string:
| Parameter | Value |
|---|---|
grant_type | authorization_code |
code | From step 4 |
redirect_uri | Byte-for-byte the same as in step 2 |
code_verifier | From step 1 |
client_id | Public clients only (SPA, native) |
A confidential client authenticates only with Authorization: Basic base64(client_id:secret) (each part URL-encoded first, per RFC 6749). client_secret in the body and client_assertion are refused (invalid_client). The scope parameter is not allowed in a code exchange.
curl -s https://id.example.com/oidc/workspaces/0192a000-0000-7000-8000-0000000000aa/token \
-u 'calab_client_e07JrYTVpGfRYJ0pQaF6yzkME6S9W856DgsKNWOKvgE:calab_os_VWMV4RGIRsisgKhdE7ohFW_QoAdNWS4n9GkM3Ty1kb4' \
-d grant_type=authorization_code \
-d code=calab_oc_Laufm1V1F0gnbG0nHG4NcwvsM6wHkhQOmk4AH8CzCSw \
--data-urlencode redirect_uri=https://crm.example.com/auth/calab/callback \
-d code_verifier="$CODE_VERIFIER"const issuer = 'https://id.example.com/oidc/workspaces/0192a000-0000-7000-8000-0000000000aa';
const basic = Buffer.from(
`${encodeURIComponent(process.env.CALAB_CLIENT_ID)}:${encodeURIComponent(process.env.CALAB_CLIENT_SECRET)}`,
).toString('base64');
const res = await fetch(`${issuer}/token`, {
method: 'POST',
headers: { Authorization: `Basic ${basic}`, 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: req.query.code,
redirect_uri: 'https://crm.example.com/auth/calab/callback',
code_verifier: session.codeVerifier,
}),
});
if (!res.ok) throw new Error(`token: ${res.status} ${(await res.json()).error}`);
const tokens = await res.json();import os, requests
issuer = "https://id.example.com/oidc/workspaces/0192a000-0000-7000-8000-0000000000aa"
res = requests.post(
f"{issuer}/token",
auth=(os.environ["CALAB_CLIENT_ID"], os.environ["CALAB_CLIENT_SECRET"]),
data={
"grant_type": "authorization_code",
"code": request.args["code"],
"redirect_uri": "https://crm.example.com/auth/calab/callback",
"code_verifier": session["code_verifier"],
},
timeout=10,
)
res.raise_for_status()
tokens = res.json()For a public client, drop the Authorization header and add client_id to the body.
Response:
{
"access_token": "calab_oa_TvZdRRrAeU21FG2au71ffTJmnZjhaX536xpn9Kw1S9o",
"token_type": "Bearer",
"expires_in": 300,
"scope": "email openid profile",
"refresh_token": "calab_or_g7KMt03Mx5dxElMQi0aw3v-eG08Xrqf6Pv9QQNDSjZQ",
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ik…"
}refresh_token is present only when the client has “Allow continued access” turned on and the person agreed to it. A code is single-use and lives for 60 seconds; presenting an already used code again from the same client revokes everything issued for it.
Step 6. Verify the ID token
The ID token is a JWT signed with RS256. Pick the key by kid from jwks_uri and check:
| Claim | Check |
|---|---|
iss | Equals the workspace issuer |
aud | Equals your client_id |
exp, iat | Not expired (it lives as long as the access token — up to 5 minutes) |
nonce | Equals the value from step 1 |
auth_time | When the person actually signed in — use it with max_age |
Example payload:
{
"iss": "https://id.example.com/oidc/workspaces/0192a000-0000-7000-8000-0000000000aa",
"sub": "7qCZMpOp2e-PYx7A9VRiAGUu8o-MF1kgOZG0umGs_HM",
"aud": "calab_client_e07JrYTVpGfRYJ0pQaF6yzkME6S9W856DgsKNWOKvgE",
"iat": 1792141200,
"exp": 1792141500,
"auth_time": 1792140900,
"nonce": "9b7e30…",
"name": "Anna Peters",
"email": "anna@example.com",
"email_verified": true
}Identify the person by the (iss, sub) pair, not by email: sub is stable for a member within this workspace, while the email may change or be absent. Calab does not issue acr or amr.
import { createRemoteJWKSet, jwtVerify } from 'jose';
const jwks = createRemoteJWKSet(new URL(`${issuer}/jwks`));
const { payload } = await jwtVerify(tokens.id_token, jwks, {
issuer,
audience: process.env.CALAB_CLIENT_ID,
algorithms: ['RS256'],
});
if (payload.nonce !== session.nonce) throw new Error('nonce mismatch');
const accountKey = `${payload.iss}|${payload.sub}`;import jwt # PyJWT >= 2.4
jwks = jwt.PyJWKClient(f"{issuer}/jwks")
key = jwks.get_signing_key_from_jwt(tokens["id_token"])
claims = jwt.decode(tokens["id_token"], key.key, algorithms=["RS256"],
audience=os.environ["CALAB_CLIENT_ID"], issuer=issuer)
if claims["nonce"] != session["nonce"]:
raise ValueError("nonce mismatch")
account_key = f'{claims["iss"]}|{claims["sub"]}'Step 7 (optional). Fetch the profile
For fresh data, call GET /userinfo with the access token. Details, and how to keep access going, are in UserInfo, refresh and revoke.
Silent re-authentication
prompt=none issues a code without any UI when the person has a live Calab session in this browser and has already consented to this client for the same scopes. Otherwise Calab immediately returns to redirect_uri with login_required, consent_required or interaction_required — start a normal sign-in then.
Off-the-shelf libraries
Calab follows OIDC Discovery, so you usually only need the workspace issuer, the client_id, the secret and the redirect URI. Make sure the library enables PKCE S256, sends a nonce, uses client_secret_basic for a web client and checks the iss parameter of the authorization response.