Calimatic OIDC Integration Guide
How to integrate your application with the Calimatic Auth platform using standard OAuth 2.0 / OpenID Connect.
Table of Contents
- Quick Start
- Integration for Calimatic Apps
- Integration for Third-Party Apps
- Next.js Integration
- React SPA Integration
- Backend / Server-Side Integration
- Machine-to-Machine (M2M)
- User Provisioning & Auto-Licensing
- Handling User Sessions
- Logout
- Migrating from Legacy Exchange Flow
- Troubleshooting
1. Quick Start
Prerequisites
- Register your application as an OAuth client (see Client Registration)
- Note your
client_idandclient_secret - Register your redirect URI(s)
The Flow in 30 Seconds
Your App Calimatic Auth
──────── ──────────────
1. Redirect user ──────────────► /api/v1/oidc/authorize
│
▼
Login page (branded)
│
▼
Consent page (if first time)
│
2. Receive code ◄──────────────── Redirect to your callback
with ?code=...&state=...
│
▼
3. POST /api/v1/oidc/token ────► Exchange code for tokens
with code + code_verifier
│
4. Receive tokens ◄────────────── { access_token, id_token,
refresh_token }
│
▼
5. GET /api/v1/oidc/userinfo ──► Get user profile
with Bearer access_token
│
6. Receive user claims ◄───────── { sub, name, email, org... }
Client Registration
Option A: Request from Calimatic admin — Ask the Calimatic team to register your app and provide credentials.
Option B: Dynamic registration — Call the registration endpoint:
curl -X POST https://auth.calimatic.com/api/v1/oidc/register \
-H "Content-Type: application/json" \
-d '{
"client_name": "My App",
"redirect_uris": ["https://myapp.com/auth/callback", "http://localhost:3001/auth/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"scope": "openid profile email organization"
}'
Save the client_id and client_secret from the response. The secret is shown only once.
2. Integration for Calimatic Apps
Calimatic internal apps (Partners Portal, EdTech, Enterprise, etc.) should use the full scope set to get organization context and permissions.
Recommended Configuration
Scopes: openid profile email organization permissions
Grant Types: authorization_code, refresh_token
PKCE: Required (S256)
Consent: Can be disabled for first-party apps (require_consent: false)
Recommended Scopes for Calimatic Apps
| App | Scopes |
|---|---|
| Partners Portal | openid profile email organization |
| EdTech Platform | openid profile email organization permissions |
| Enterprise Platform | openid profile email organization permissions |
| Calimatic Connect | openid profile email organization |
| AI Teaching Assistant | openid profile email organization |
| Scheduler | openid profile email organization |
| Admin Dashboard | openid profile email organization permissions |
Accessing Organization Context
With the organization scope, the access token and userinfo response include:
{
"organization_id": "uuid-of-primary-org",
"organization_memberships": [
{
"organization_id": "uuid-of-org-1",
"role": "admin"
},
{
"organization_id": "uuid-of-org-2",
"role": "teacher"
}
],
"user_type": "customer_admin"
}
Accessing Permissions
With the permissions scope:
{
"permissions": [
"org:manage",
"org:users:read",
"org:users:manage",
"licenses:read"
]
}
These are platform-level permissions (organization management, licensing). App-specific permissions (e.g., channel management, referral tracking) are managed within each application, not by the auth platform.
3. Integration for Third-Party Apps
Third-party applications integrate using standard OAuth 2.0 — no Calimatic-specific SDK required.
Recommended Configuration
Scopes: openid profile email
Grant Types: authorization_code, refresh_token
PKCE: Required
Consent: Required (users must approve)
Third-party apps cannot request organization or permissions scopes unless explicitly granted by a Calimatic administrator.
What Third-Party Apps Receive
With openid profile email:
{
"sub": "unique-user-id",
"name": "Jane Smith",
"given_name": "Jane",
"family_name": "Smith",
"email": "jane@school.com",
"email_verified": true,
"picture": "https://..."
}
Discovery-Based Integration
Any OAuth 2.0 / OIDC client library can auto-configure by pointing to the discovery URL:
https://auth.calimatic.com/.well-known/openid-configuration
4. Next.js Integration
Using NextAuth.js v5
The recommended approach for Next.js applications.
Install:
npm install next-auth
src/auth.ts:
import NextAuth from "next-auth";
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
{
id: "calimatic",
name: "Calimatic",
type: "oidc",
issuer: "https://auth.calimatic.com",
clientId: process.env.CALIMATIC_CLIENT_ID!,
clientSecret: process.env.CALIMATIC_CLIENT_SECRET!,
authorization: {
params: {
scope: "openid profile email organization",
code_challenge_method: "S256",
},
},
profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
image: profile.picture,
};
},
},
],
callbacks: {
async jwt({ token, account, profile }) {
// On initial sign-in, save tokens and profile claims
if (account) {
token.accessToken = account.access_token;
token.refreshToken = account.refresh_token;
token.expiresAt = account.expires_at;
token.idToken = account.id_token;
}
if (profile) {
token.organizationId = profile.organization_id;
token.organizationMemberships = profile.organization_memberships;
token.userType = profile.user_type;
}
return token;
},
async session({ session, token }) {
session.accessToken = token.accessToken as string;
session.user.organizationId = token.organizationId as string;
session.user.organizationMemberships = token.organizationMemberships;
session.user.userType = token.userType as string;
return session;
},
},
});
src/app/api/auth/[...nextauth]/route.ts:
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
.env.local:
CALIMATIC_CLIENT_ID=cca_your_client_id
CALIMATIC_CLIENT_SECRET=ccas_your_client_secret
NEXTAUTH_URL=http://localhost:3001
NEXTAUTH_SECRET=your-random-secret
Usage in Server Components:
import { auth } from "@/auth";
export default async function Dashboard() {
const session = await auth();
if (!session) {
redirect("/api/auth/signin");
}
return (
<div>
<h1>Welcome, {session.user.name}</h1>
<p>Organization: {session.user.organizationId}</p>
</div>
);
}
Usage in Client Components:
"use client";
import { useSession, signIn, signOut } from "next-auth/react";
export function AuthButton() {
const { data: session, status } = useSession();
if (status === "loading") return <div>Loading...</div>;
if (session) {
return (
<div>
<p>Signed in as {session.user.email}</p>
<button onClick={() => signOut()}>Sign out</button>
</div>
);
}
return <button onClick={() => signIn("calimatic")}>Sign in with Calimatic</button>;
}
Manual Integration (Without NextAuth)
If you prefer manual control over the OAuth flow:
src/lib/auth.ts:
import { randomBytes, createHash } from "crypto";
const ISSUER = "https://auth.calimatic.com";
const CLIENT_ID = process.env.CALIMATIC_CLIENT_ID!;
const CLIENT_SECRET = process.env.CALIMATIC_CLIENT_SECRET!;
const REDIRECT_URI = process.env.NEXTAUTH_URL + "/auth/callback";
// Generate PKCE pair
export function generatePKCE() {
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
// Build authorization URL
export function getAuthorizationUrl(state: string, pkce: { challenge: string }) {
const url = new URL(`${ISSUER}/api/v1/oidc/authorize`);
url.searchParams.set("client_id", CLIENT_ID);
url.searchParams.set("redirect_uri", REDIRECT_URI);
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", "openid profile email organization");
url.searchParams.set("state", state);
url.searchParams.set("code_challenge", pkce.challenge);
url.searchParams.set("code_challenge_method", "S256");
return url.toString();
}
// Exchange code for tokens
export async function exchangeCode(code: string, codeVerifier: string) {
const res = await fetch(`${ISSUER}/api/v1/oidc/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code_verifier: codeVerifier,
}),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error_description || error.error);
}
return res.json();
}
// Refresh tokens
export async function refreshTokens(refreshToken: string) {
const res = await fetch(`${ISSUER}/api/v1/oidc/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error_description || error.error);
}
return res.json();
}
// Fetch user info
export async function getUserInfo(accessToken: string) {
const res = await fetch(`${ISSUER}/api/v1/oidc/userinfo`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) {
throw new Error("Failed to fetch user info");
}
return res.json();
}
src/app/auth/callback/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import { exchangeCode, getUserInfo } from "@/lib/auth";
export async function GET(req: NextRequest) {
const code = req.nextUrl.searchParams.get("code");
const state = req.nextUrl.searchParams.get("state");
const error = req.nextUrl.searchParams.get("error");
if (error) {
return NextResponse.redirect(new URL(`/login?error=${error}`, req.url));
}
// Verify state matches what we stored
const cookieStore = await cookies();
const savedState = cookieStore.get("oauth_state")?.value;
const codeVerifier = cookieStore.get("oauth_code_verifier")?.value;
if (!code || !state || state !== savedState || !codeVerifier) {
return NextResponse.redirect(new URL("/login?error=invalid_state", req.url));
}
// Exchange code for tokens
const tokens = await exchangeCode(code, codeVerifier);
// Fetch user profile
const user = await getUserInfo(tokens.access_token);
// Store session (use your preferred session strategy)
// Example: set encrypted cookie, write to DB, etc.
const response = NextResponse.redirect(new URL("/dashboard", req.url));
// Clean up OAuth cookies
response.cookies.delete("oauth_state");
response.cookies.delete("oauth_code_verifier");
// Set session cookie (simplified — use proper encryption in production)
response.cookies.set("session", JSON.stringify({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
user,
}), {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: tokens.expires_in,
});
return response;
}
5. React SPA Integration
For single-page applications without a backend, use PKCE with a public client.
Register as a Public Client
curl -X POST https://auth.calimatic.com/api/v1/oidc/register \
-H "Content-Type: application/json" \
-d '{
"client_name": "My SPA",
"redirect_uris": ["https://myapp.com/callback", "http://localhost:5173/callback"],
"grant_types": ["authorization_code"],
"token_endpoint_auth_method": "none",
"scope": "openid profile email"
}'
PKCE Helper
// src/lib/pkce.ts
export async function generatePKCE(): Promise<{
verifier: string;
challenge: string;
}> {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
const verifier = base64UrlEncode(array);
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest("SHA-256", data);
const challenge = base64UrlEncode(new Uint8Array(hash));
return { verifier, challenge };
}
function base64UrlEncode(buffer: Uint8Array): string {
return btoa(String.fromCharCode(...buffer))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
Login Flow
// src/lib/auth.ts
const ISSUER = "https://auth.calimatic.com";
const CLIENT_ID = "cca_your_client_id";
const REDIRECT_URI = window.location.origin + "/callback";
export async function login() {
const { verifier, challenge } = await generatePKCE();
const state = crypto.randomUUID();
// Store for callback verification
sessionStorage.setItem("oauth_code_verifier", verifier);
sessionStorage.setItem("oauth_state", state);
const url = new URL(`${ISSUER}/api/v1/oidc/authorize`);
url.searchParams.set("client_id", CLIENT_ID);
url.searchParams.set("redirect_uri", REDIRECT_URI);
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", "openid profile email");
url.searchParams.set("state", state);
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
window.location.href = url.toString();
}
export async function handleCallback(): Promise<{
accessToken: string;
user: Record<string, unknown>;
}> {
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
const state = params.get("state");
const error = params.get("error");
if (error) throw new Error(params.get("error_description") || error);
const savedState = sessionStorage.getItem("oauth_state");
const codeVerifier = sessionStorage.getItem("oauth_code_verifier");
if (!code || state !== savedState || !codeVerifier) {
throw new Error("Invalid callback state");
}
// Clean up
sessionStorage.removeItem("oauth_state");
sessionStorage.removeItem("oauth_code_verifier");
// Exchange code for tokens (public client — no secret)
const tokenRes = await fetch(`${ISSUER}/api/v1/oidc/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: codeVerifier,
}),
});
if (!tokenRes.ok) {
const err = await tokenRes.json();
throw new Error(err.error_description || err.error);
}
const tokens = await tokenRes.json();
// Fetch user profile
const userRes = await fetch(`${ISSUER}/api/v1/oidc/userinfo`, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
const user = await userRes.json();
return { accessToken: tokens.access_token, user };
}
React Component
// src/pages/Callback.tsx
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { handleCallback } from "../lib/auth";
export function Callback() {
const navigate = useNavigate();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
handleCallback()
.then(({ accessToken, user }) => {
// Store in your state management (Zustand, Redux, Context, etc.)
localStorage.setItem("access_token", accessToken);
navigate("/dashboard");
})
.catch((err) => setError(err.message));
}, [navigate]);
if (error) return <div>Error: {error}</div>;
return <div>Signing in...</div>;
}
6. Backend / Server-Side Integration
Python (Flask / Django)
import requests
import hashlib
import base64
import os
ISSUER = "https://auth.calimatic.com"
CLIENT_ID = os.environ["CALIMATIC_CLIENT_ID"]
CLIENT_SECRET = os.environ["CALIMATIC_CLIENT_SECRET"]
REDIRECT_URI = os.environ["REDIRECT_URI"]
def generate_pkce():
"""Generate PKCE code verifier and challenge."""
verifier = base64.urlsafe_b64encode(os.urandom(32)).rstrip(b"=").decode()
challenge = (
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
.rstrip(b"=")
.decode()
)
return verifier, challenge
def get_authorization_url(state: str, code_challenge: str) -> str:
"""Build the authorization URL."""
params = {
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"response_type": "code",
"scope": "openid profile email organization",
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}
return f"{ISSUER}/api/v1/oidc/authorize?" + "&".join(
f"{k}={requests.utils.quote(v)}" for k, v in params.items()
)
def exchange_code(code: str, code_verifier: str) -> dict:
"""Exchange authorization code for tokens."""
resp = requests.post(
f"{ISSUER}/api/v1/oidc/token",
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": REDIRECT_URI,
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"code_verifier": code_verifier,
},
)
resp.raise_for_status()
return resp.json()
def get_user_info(access_token: str) -> dict:
"""Fetch user profile from the UserInfo endpoint."""
resp = requests.get(
f"{ISSUER}/api/v1/oidc/userinfo",
headers={"Authorization": f"Bearer {access_token}"},
)
resp.raise_for_status()
return resp.json()
def refresh_tokens(refresh_token: str) -> dict:
"""Refresh an expired access token."""
resp = requests.post(
f"{ISSUER}/api/v1/oidc/token",
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
},
)
resp.raise_for_status()
return resp.json()
PHP (Laravel)
Use Laravel Socialite with the OpenID Connect driver, or any generic OAuth 2.0 library:
// config/services.php
'calimatic' => [
'client_id' => env('CALIMATIC_CLIENT_ID'),
'client_secret' => env('CALIMATIC_CLIENT_SECRET'),
'redirect' => env('APP_URL') . '/auth/callback',
'issuer' => 'https://auth.calimatic.com',
'token_url' => 'https://auth.calimatic.com/api/v1/oidc/token',
'authorize_url' => 'https://auth.calimatic.com/api/v1/oidc/authorize',
'userinfo_url' => 'https://auth.calimatic.com/api/v1/oidc/userinfo',
],
Any Language — Using Discovery
Most OAuth/OIDC libraries support auto-configuration via the discovery URL. Point your library to:
https://auth.calimatic.com/.well-known/openid-configuration
The library will automatically discover all endpoints, supported scopes, and signing keys.
7. Machine-to-Machine (M2M)
For server-to-server communication without user context, use the Client Credentials grant.
curl -X POST https://auth.calimatic.com/api/v1/oidc/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=cca_your_client_id" \
-d "client_secret=ccas_your_client_secret" \
-d "scope=openid"
Response:
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "openid"
}
Access token claims for M2M:
{
"iss": "https://auth.calimatic.com",
"sub": "cca_your_client_id",
"aud": "cca_your_client_id",
"scope": "openid",
"token_type": "access_token",
"client_id": "cca_your_client_id",
"application": "your_app_name"
}
Use this to authenticate API calls between services. The sub claim is the client ID (not a user ID).
8. User Provisioning & Auto-Licensing
Registered app clients can create users on behalf of an organization. When an app invites a user, the platform automatically assigns the calling app's license to the new user.
App Registration Model
The platform follows a three-layer model (similar to Okta):
| Layer | What | How |
|---|---|---|
| App Registration | Register the app with the platform | Admin creates via POST /api/v1/admin/app-clients, or dynamic registration via POST /api/v1/oidc/register |
| Org Enablement | Enable the app for a specific organization | Admin creates organization_app_access record with isEnabled: true |
| User Assignment | Grant a user access to the app | user_app_licenses record created (manually, via bulk assign, or auto-assigned on invite) |
Inviting Users via App Client
An app client can invite users to an organization using its client credentials:
curl -X POST https://auth.calimatic.com/api/v1/organizations/{orgId}/users \
-H "Content-Type: application/json" \
-H "x-client-id: cca_your_client_id" \
-H "x-client-secret: ccas_your_client_secret" \
-d '{
"email": "user@example.com",
"firstName": "Jane",
"lastName": "Smith",
"role": "member"
}'
Auto-License Assignment
When a user is invited, app licenses are assigned automatically:
-
App client calls the invite API: The calling app's identity (
requestor.application) is automatically included in the license list. The user gets a license for the calling app without any extra configuration. -
Admin UI invite with explicit apps: The admin selects specific apps in the invite dialog. Only apps that are enabled for the organization are assigned.
-
No apps specified: If no
applicationsarray is provided and no app client identity is present, the user receives licenses for all enabled apps on the organization.
Validation: Only apps enabled on the target organization (present in organization_app_access with isEnabled: true) are assigned. Unknown or disabled apps are silently filtered out.
Bulk Invite
curl -X POST https://auth.calimatic.com/api/v1/organizations/{orgId}/users/bulk-invite \
-H "Content-Type: application/json" \
-H "x-client-id: cca_your_client_id" \
-H "x-client-secret: ccas_your_client_secret" \
-d '{
"users": [
{ "email": "user1@example.com", "firstName": "User", "lastName": "One", "role": "member" },
{ "email": "user2@example.com", "firstName": "User", "lastName": "Two", "role": "member" }
]
}'
The calling app's license is automatically assigned to all users in the batch.
Registering a New App
To add a new app to the platform:
- Register the app client (admin or dynamic registration)
- Enable it for organizations — create
organization_app_accessrecords for each org that should have access - Assign licenses — users can be assigned licenses via the admin UI, bulk assign API, or automatically when invited by the app
9. Handling User Sessions
Token Refresh Strategy
Access tokens expire after 1 hour (default). Implement proactive refresh:
let accessToken: string;
let refreshToken: string;
let expiresAt: number; // Unix timestamp
async function getValidAccessToken(): Promise<string> {
// Refresh 5 minutes before expiry
if (Date.now() / 1000 > expiresAt - 300) {
const tokens = await refreshTokens(refreshToken);
accessToken = tokens.access_token;
refreshToken = tokens.refresh_token;
expiresAt = Math.floor(Date.now() / 1000) + tokens.expires_in;
}
return accessToken;
}
Verifying Access Tokens Locally
For backend services that receive access tokens, verify them without calling the introspection endpoint:
import { createRemoteJWKSet, jwtVerify } from "jose";
const JWKS = createRemoteJWKSet(
new URL("https://auth.calimatic.com/api/v1/oidc/jwks")
);
async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: "https://auth.calimatic.com",
audience: "cca_your_client_id", // Your client_id
});
return payload;
}
Using the Introspection Endpoint
For opaque tokens or when you need to check revocation status:
async function introspectToken(token: string): Promise<boolean> {
const res = await fetch("https://auth.calimatic.com/api/v1/oidc/introspect", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
token,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
});
const data = await res.json();
return data.active === true;
}
10. Logout
RP-Initiated Logout
Redirect the user to the end-session endpoint to log them out of the Calimatic Auth platform:
function logout(idToken: string) {
const url = new URL("https://auth.calimatic.com/api/v1/oidc/end-session");
url.searchParams.set("id_token_hint", idToken);
url.searchParams.set("post_logout_redirect_uri", "https://myapp.com/logged-out");
url.searchParams.set("state", "some-state");
window.location.href = url.toString();
}
The platform will:
- Revoke all refresh tokens for the user + client
- Clear the platform session
- Redirect to
post_logout_redirect_uri?state=some-state
Token Revocation
Revoke a refresh token programmatically:
await fetch("https://auth.calimatic.com/api/v1/oidc/revoke", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
token: refreshToken,
token_type_hint: "refresh_token",
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
});
11. Migrating from Legacy Exchange Flow
If your application currently uses the legacy POST /api/v1/auth/exchange-code and POST /api/v1/auth/exchange flow, here is how to migrate to the standard OIDC flow.
Legacy Flow (Deprecated)
1. User visits auth platform login page
2. After login, auth platform calls POST /api/v1/auth/exchange-code
3. Returns a one-time code
4. Client app exchanges code via POST /api/v1/auth/exchange
with { code, clientId, clientSecret }
5. Receives Keycloak tokens + user profile
New OIDC Flow
1. Client app redirects to GET /api/v1/oidc/authorize
2. User authenticates on branded login page
3. Authorization code returned via redirect
4. Client exchanges code via POST /api/v1/oidc/token
with standard OAuth2 parameters + PKCE
5. Receives platform-signed JWTs + user profile via /userinfo
Migration Steps
-
Register your app as an OIDC client (via admin or dynamic registration)
- Set
redirect_uristo your callback URL - Set
allowed_scopesto match what you need
- Set
-
Update your login flow:
Before:
Link to: https://auth.calimatic.com/login?callbackUrl=https://myapp.com/callback&app=partnersAfter:
Redirect to: https://auth.calimatic.com/api/v1/oidc/authorize ?client_id=...&redirect_uri=...&response_type=code&scope=openid+profile+email&state=...&code_challenge=...&code_challenge_method=S256 -
Update your callback handler:
Before:
// Receive ?code=... on callback const res = await fetch("/api/v1/auth/exchange", { method: "POST", body: JSON.stringify({ code, clientId, clientSecret }), }); const { accessToken, user } = await res.json();After:
// Receive ?code=...&state=... on callback const res = await fetch("https://auth.calimatic.com/api/v1/oidc/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, client_secret: CLIENT_SECRET, code_verifier: savedCodeVerifier, }), }); const tokens = await res.json(); // tokens.access_token, tokens.id_token, tokens.refresh_token -
Update token verification:
- Before: Trusted Keycloak tokens directly
- After: Verify platform-signed JWTs using JWKS at
/api/v1/oidc/jwks
-
The legacy endpoints remain operational during the transition period. Both flows can coexist.
Key Differences
| Aspect | Legacy Flow | OIDC Flow |
|---|---|---|
| Token issuer | Keycloak | Calimatic platform |
| Token format | Keycloak JWT | Platform-signed RS256 JWT |
| Code exchange | Custom API | Standard OAuth 2.0 |
| PKCE | Not supported | Required (S256) |
| Consent | Not supported | Built-in consent screen |
| Token refresh | Via Keycloak directly | Via platform token endpoint |
| Scopes | N/A | Standard OIDC scopes |
| Discovery | N/A | /.well-known/openid-configuration |
12. Troubleshooting
Common Errors
invalid_client
- Cause: Wrong
client_idorclient_secret, or client is deactivated. - Fix: Verify credentials. Check that the client is active. If the secret was lost, rotate it via admin.
invalid_grant — "Authorization code is invalid, expired, or already used"
- Cause: The authorization code has expired (60s TTL), was already exchanged, or doesn't match the client.
- Fix: Ensure you exchange the code within 60 seconds. Codes are one-time use. Do not retry a code exchange.
invalid_grant — "redirect_uri does not match"
- Cause: The
redirect_uriin the token request doesn't exactly match the one used in the authorize request. - Fix: Use the exact same
redirect_uriin both requests. No trailing slashes or extra parameters.
invalid_grant — "PKCE verification failed"
- Cause: The
code_verifierdoesn't match thecode_challengesent during authorization. - Fix: Ensure you store the
code_verifierbetween the authorize redirect and the token exchange. Verify your S256 implementation:BASE64URL(SHA256(code_verifier)).
invalid_scope
- Cause: Requested a scope that's not supported or not allowed for this client.
- Fix: Check
allowed_scopeson the client. Use only scopes from the discovery document.
access_denied
- Cause: The user clicked "Deny" on the consent screen.
- Fix: Handle this gracefully in your app. Show a message explaining that authorization was denied.
invalid_token (UserInfo endpoint)
- Cause: The access token is expired, malformed, or not a valid platform-issued JWT.
- Fix: Refresh the token using the refresh_token grant. Verify you're sending the
access_token(not theid_tokenorrefresh_token).
temporarily_unavailable (429)
- Cause: Rate limit exceeded.
- Fix: Implement exponential backoff. Check the
Retry-Afterheader.
Debugging Tips
-
Check the discovery document:
curl https://auth.calimatic.com/.well-known/openid-configuration | jq . -
Decode a JWT to inspect claims:
echo "eyJhbGciOi..." | cut -d. -f2 | base64 -d 2>/dev/null | jq . -
Introspect a token:
curl -X POST https://auth.calimatic.com/api/v1/oidc/introspect \ -d "token=eyJhbGciOi..." \ -d "client_id=cca_..." \ -d "client_secret=ccas_..." -
Verify JWKS is accessible:
curl https://auth.calimatic.com/api/v1/oidc/jwks | jq . -
Test the full flow with curl:
# Step 1: Generate PKCE VERIFIER=$(openssl rand -base64 32 | tr -d '=' | tr '+/' '-_') CHALLENGE=$(echo -n "$VERIFIER" | openssl dgst -sha256 -binary | base64 | tr -d '=' | tr '+/' '-_') # Step 2: Open in browser echo "https://auth.calimatic.com/api/v1/oidc/authorize?client_id=YOUR_ID&redirect_uri=http://localhost:3001/callback&response_type=code&scope=openid+profile+email&state=test&code_challenge=$CHALLENGE&code_challenge_method=S256" # Step 3: After login, copy the code from the callback URL # Step 4: Exchange code curl -X POST https://auth.calimatic.com/api/v1/oidc/token \ -d "grant_type=authorization_code" \ -d "code=PASTE_CODE_HERE" \ -d "redirect_uri=http://localhost:3001/callback" \ -d "client_id=YOUR_ID" \ -d "client_secret=YOUR_SECRET" \ -d "code_verifier=$VERIFIER" # Step 5: Use the access token curl https://auth.calimatic.com/api/v1/oidc/userinfo \ -H "Authorization: Bearer ACCESS_TOKEN_HERE"