Calimatic OIDC Integration Guide

How to integrate your application with the Calimatic Auth platform using standard OAuth 2.0 / OpenID Connect.


Table of Contents

  1. Quick Start
  2. Integration for Calimatic Apps
  3. Integration for Third-Party Apps
  4. Next.js Integration
  5. React SPA Integration
  6. Backend / Server-Side Integration
  7. Machine-to-Machine (M2M)
  8. User Provisioning & Auto-Licensing
  9. Handling User Sessions
  10. Logout
  11. Migrating from Legacy Exchange Flow
  12. Troubleshooting

1. Quick Start

Prerequisites

  1. Register your application as an OAuth client (see Client Registration)
  2. Note your client_id and client_secret
  3. 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

AppScopes
Partners Portalopenid profile email organization
EdTech Platformopenid profile email organization permissions
Enterprise Platformopenid profile email organization permissions
Calimatic Connectopenid profile email organization
AI Teaching Assistantopenid profile email organization
Scheduleropenid profile email organization
Admin Dashboardopenid 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):

LayerWhatHow
App RegistrationRegister the app with the platformAdmin creates via POST /api/v1/admin/app-clients, or dynamic registration via POST /api/v1/oidc/register
Org EnablementEnable the app for a specific organizationAdmin creates organization_app_access record with isEnabled: true
User AssignmentGrant a user access to the appuser_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:

  1. 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.

  2. 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.

  3. No apps specified: If no applications array 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:

  1. Register the app client (admin or dynamic registration)
  2. Enable it for organizations — create organization_app_access records for each org that should have access
  3. 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:

  1. Revoke all refresh tokens for the user + client
  2. Clear the platform session
  3. 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

  1. Register your app as an OIDC client (via admin or dynamic registration)

    • Set redirect_uris to your callback URL
    • Set allowed_scopes to match what you need
  2. Update your login flow:

    Before:

    Link to: https://auth.calimatic.com/login?callbackUrl=https://myapp.com/callback&app=partners
    

    After:

    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
    
  3. 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
    
  4. Update token verification:

    • Before: Trusted Keycloak tokens directly
    • After: Verify platform-signed JWTs using JWKS at /api/v1/oidc/jwks
  5. The legacy endpoints remain operational during the transition period. Both flows can coexist.

Key Differences

AspectLegacy FlowOIDC Flow
Token issuerKeycloakCalimatic platform
Token formatKeycloak JWTPlatform-signed RS256 JWT
Code exchangeCustom APIStandard OAuth 2.0
PKCENot supportedRequired (S256)
ConsentNot supportedBuilt-in consent screen
Token refreshVia Keycloak directlyVia platform token endpoint
ScopesN/AStandard OIDC scopes
DiscoveryN/A/.well-known/openid-configuration

12. Troubleshooting

Common Errors

invalid_client

  • Cause: Wrong client_id or client_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_uri in the token request doesn't exactly match the one used in the authorize request.
  • Fix: Use the exact same redirect_uri in both requests. No trailing slashes or extra parameters.

invalid_grant — "PKCE verification failed"

  • Cause: The code_verifier doesn't match the code_challenge sent during authorization.
  • Fix: Ensure you store the code_verifier between 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_scopes on 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 the id_token or refresh_token).

temporarily_unavailable (429)

  • Cause: Rate limit exceeded.
  • Fix: Implement exponential backoff. Check the Retry-After header.

Debugging Tips

  1. Check the discovery document:

    curl https://auth.calimatic.com/.well-known/openid-configuration | jq .
    
  2. Decode a JWT to inspect claims:

    echo "eyJhbGciOi..." | cut -d. -f2 | base64 -d 2>/dev/null | jq .
    
  3. 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_..."
    
  4. Verify JWKS is accessible:

    curl https://auth.calimatic.com/api/v1/oidc/jwks | jq .
    
  5. 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"