Skip to content
CalabDocs

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:

ValueExample
Workspace issuerhttps://id.example.com/oidc/workspaces/0192a000-0000-7000-8000-0000000000aa
client_idcalab_client_e07JrYTVpGfRYJ0pQaF6yzkME6S9W856DgsKNWOKvgE
Secret (web app with server only)calab_os_VWMV4RGIRsisgKhdE7ohFW_QoAdNWS4n9GkM3Ty1kb4
redirect_urihttps://crm.example.com/auth/calab/callback

Diagram

Sign-in with Calab sequence: browser, client app, /authorize, consent screen, /token, /userinfo Browser Your app /authorize Consent /token /userinfo 1. “Sign in with Calab” 2. 302 → /authorize 3. GET /authorize (PKCE, state, nonce) 4. 303 → consent 5. sign in to Calab, click “Allow” 6. 303 → redirect_uri?code&state&iss 7. code, state, iss 8. POST /token: code + code_verifier 9. access_token, id_token, refresh_token check state, iss, the signature and nonce of id_token 10. GET /userinfo, Authorization: Bearer access_token 11. sub, name, email

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 from A–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)

Step 2. Send the user to /authorize

Redirect the browser to the authorization_endpoint:

text
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
ParameterRequiredValue
response_typeyescode
client_idyesFrom the client settings
redirect_uriyesExactly one of the registered URIs
scopeyesopenid, optionally profile and email — no wider than the client allows
state, nonceyes1–512 bytes
code_challengeyes43 base64url characters
code_challenge_methodyesS256 (plain is not supported)
response_modenoquery only
promptnologin, consent, both, or none (on its own)
max_agenoSeconds; 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:

text
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-0000000000aa

A 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:

  1. state matches the one kept in the session;
  2. iss equals 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:

ParameterValue
grant_typeauthorization_code
codeFrom step 4
redirect_uriByte-for-byte the same as in step 2
code_verifierFrom step 1
client_idPublic 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"

For a public client, drop the Authorization header and add client_id to the body.

Response:

JSON
{
  "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:

ClaimCheck
issEquals the workspace issuer
audEquals your client_id
exp, iatNot expired (it lives as long as the access token — up to 5 minutes)
nonceEquals the value from step 1
auth_timeWhen the person actually signed in — use it with max_age

Example payload:

JSON
{
  "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}`;

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.