Skip to main content

Overview

CaseXchange implements OAuth 2.0 with OpenID Connect (OIDC) extensions, acting as an authorization server to enable:
  1. Third-Party Authentication - “Sign in with CaseXchange” for external applications
  2. API Access Delegation - Authorized API access for integrations (e.g., Salesforce, case management systems)
CaseXchange supports the Authorization Code flow with PKCE (Proof Key for Code Exchange) for enhanced security.

Discovery & Endpoints

OIDC Discovery Document

CaseXchange exposes a standard OpenID Connect discovery endpoint for automatic configuration:
GET /.well-known/openid-configuration
Response:
{
  "issuer": "https://api.casexchange.com",
  "authorization_endpoint": "https://api.casexchange.com/api/oauth/authorize",
  "token_endpoint": "https://api.casexchange.com/api/oauth/token",
  "userinfo_endpoint": "https://api.casexchange.com/api/oauth/userinfo",
  "revocation_endpoint": "https://api.casexchange.com/api/oauth/revoke",
  "jwks_uri": "https://api.casexchange.com/.well-known/jwks.json",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256", "plain"],
  "scopes_supported": [
    "openid",
    "email",
    "profile",
    "cases:read",
    "cases:write",
    "user:read",
    "firm:read"
  ],
  "token_endpoint_auth_methods_supported": [
    "client_secret_post",
    "client_secret_basic"
  ]
}

Core Endpoints

  • Discovery: GET /.well-known/openid-configuration
  • Authorization: GET /api/oauth/authorize
  • Token Exchange: POST /api/oauth/token
  • User Info: GET /api/oauth/userinfo
  • Token Revocation: POST /api/oauth/revoke
  • JWKS: GET /.well-known/jwks.json

Registering an OAuth Client

Before integrating with CaseXchange OAuth, you must register your application as an OAuth client.

Using the Admin API

curl -X POST https://api.casexchange.com/api/v1/admin/oauth-clients \
  -H "x-admin-api-key: YOUR_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Your Application Name",
    "description": "Description of your integration",
    "redirectUris": [
      "https://yourapp.com/oauth/callback",
      "https://yourapp.com/api/auth/callback/casexchange"
    ],
    "allowed_scopes": [
      "openid",
      "email",
      "profile",
      "cases:read",
      "cases:write"
    ]
  }'
Response:
{
  "success": true,
  "data": {
    "clientId": "client_abc123...",
    "clientSecret": "secret_xyz789..."
  },
  "message": "Client created successfully. Save the client secret - it will not be shown again."
}
Save both clientId and clientSecret immediately. The secret is only displayed once and cannot be retrieved later.

Authorization Code Flow

Step 1: Authorization Request

Redirect the user’s browser to the authorization endpoint:
GET /api/oauth/authorize
  ?response_type=code
  &client_id=<client_id>
  &redirect_uri=<redirect_uri>
  &scope=<scopes>
  &state=<state>
  &code_challenge=<challenge>
  &code_challenge_method=S256
Parameters:
  • response_type (required) - Must be code
  • client_id (required) - Your OAuth client ID
  • redirect_uri (required) - Must exactly match a registered redirect URI
  • scope (optional) - Space-separated list of scopes (e.g., openid email profile cases:read)
  • state (recommended) - Random string for CSRF protection
  • code_challenge (recommended) - PKCE code challenge using S256
  • code_challenge_method (optional) - S256 or plain (required if code_challenge provided)
Example:
const state = generateRandomString();
const codeVerifier = generateRandomString();
const codeChallenge = base64UrlEncode(sha256(codeVerifier));

const authUrl = `https://api.casexchange.com/api/oauth/authorize?` +
  `response_type=code` +
  `&client_id=${clientId}` +
  `&redirect_uri=${encodeURIComponent(redirectUri)}` +
  `&scope=${encodeURIComponent('openid email profile cases:read')}` +
  `&state=${state}` +
  `&code_challenge=${codeChallenge}` +
  `&code_challenge_method=S256`;

// Redirect user to authUrl
window.location.href = authUrl;
The user will be presented with a consent screen. After approval, they’ll be redirected to your redirect_uri with an authorization code:
https://yourapp.com/oauth/callback?code=AUTH_CODE&state=STATE_VALUE

Step 2: Token Exchange

Exchange the authorization code for access and refresh tokens (server-to-server call): Using client_secret_post (form-urlencoded):
POST /api/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=<authorization_code>
&redirect_uri=<redirect_uri>
&code_verifier=<code_verifier>
&client_id=<client_id>
&client_secret=<client_secret>
Using client_secret_basic (HTTP Basic Auth):
POST /api/oauth/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic <base64(client_id:client_secret)>

grant_type=authorization_code
&code=<authorization_code>
&redirect_uri=<redirect_uri>
&code_verifier=<code_verifier>
Alternative JSON format:
POST /api/oauth/token
Content-Type: application/json
Authorization: Basic <base64(client_id:client_secret)>

{
  "grant_type": "authorization_code",
  "code": "<authorization_code>",
  "redirect_uri": "<redirect_uri>",
  "code_verifier": "<code_verifier>"
}
Response:
{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "refresh_abc123...",
  "scope": "openid email profile cases:read cases:write",
  "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Token Fields:
  • access_token - JWT access token for API calls (expires in 1 hour)
  • token_type - Always Bearer
  • expires_in - Access token lifetime in seconds (3600 = 1 hour)
  • refresh_token - Use to obtain new access tokens (expires in 30 days)
  • scope - Granted scopes (may differ from requested)
  • id_token - OpenID Connect ID token (if openid scope requested)

Step 3: Using Access Tokens

Include the access token in the Authorization header for API requests:
GET /api/v1/cases
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
Access Token Structure (JWT):
{
  "sub": "user-uuid",
  "email": "[email protected]",
  "name": "John Doe",
  "role": "FIRM_ADMIN",
  "firm_id": "firm-uuid",
  "client_id": "client_abc123",
  "scope": "openid email profile cases:read cases:write",
  "iss": "https://api.casexchange.com",
  "iat": 1234567890,
  "exp": 1234571490
}

Step 4: Refresh Tokens

When the access token expires, use the refresh token to obtain a new one:
POST /api/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token=<refresh_token>
&client_id=<client_id>
&client_secret=<client_secret>
Response:
{
  "access_token": "new_access_token...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "new_refresh_token...",
  "scope": "openid email profile cases:read cases:write"
}
Refresh Token Rotation: Refresh tokens are rotated on each use for security. Always store the new refresh token from the response. The old refresh token becomes invalid immediately.

Step 5: Revoke Tokens

Revoke access or refresh tokens when they’re compromised or no longer needed:
POST /api/oauth/revoke
Content-Type: application/x-www-form-urlencoded
Authorization: Basic <base64(client_id:client_secret)>

token=<token_to_revoke>
&token_type_hint=access_token
Parameters:
  • token (required) - The access token or refresh token to revoke
  • token_type_hint (optional) - access_token or refresh_token
Returns 200 OK on success. The token is immediately invalidated.

Scopes

CaseXchange supports the following OAuth scopes: Identity Scopes:
  • openid - Required for OpenID Connect (ID token and user identity)
  • email - Access to user’s email address
  • profile - Access to user’s profile information (name, role)
API Scopes:
  • cases:read - Read access to cases, referrals, firms, routing
  • cases:write - Create, update, and re-refer cases
  • user:read - Read user profile information
  • firm:read - Read firm information
Principle of Least Privilege: Only request the minimum scopes your integration requires. Excessive scope requests may be denied during OAuth client approval or user consent.

Use Case 1: Sign In with CaseXchange

Enable users to authenticate to your application using their CaseXchange account.

Implementation with Auth.js / NextAuth

1. Configure Environment Variables:
CASEXCHANGE_OAUTH_URL=https://api.casexchange.com
CASEXCHANGE_CLIENT_ID=client_abc123...
CASEXCHANGE_CLIENT_SECRET=secret_xyz789...
2. Configure OAuth Provider:
// auth.config.ts
import type { NextAuthConfig } from 'next-auth';

export default {
  providers: [
    {
      id: 'casexchange',
      name: 'CaseXchange',
      type: 'oidc',
      issuer: process.env.CASEXCHANGE_OAUTH_URL,
      clientId: process.env.CASEXCHANGE_CLIENT_ID,
      clientSecret: process.env.CASEXCHANGE_CLIENT_SECRET,
      authorization: {
        params: {
          scope: 'openid profile email'
        }
      },
      checks: ['pkce', 'state'],
      client: {
        token_endpoint_auth_method: 'client_secret_post'
      },
      profile(profile) {
        return {
          id: profile.sub,
          email: profile.email,
          name: profile.name,
          role: profile.role,
          firmId: profile.firm_id,
        }
      }
    }
  ]
} satisfies NextAuthConfig;
3. Add Sign In Button:
import { signIn } from 'next-auth/react';

export function SignInButton() {
  return (
    <button onClick={() => signIn('casexchange')}>
      Sign in with CaseXchange
    </button>
  );
}

User Provisioning Flow

When a user signs in with CaseXchange for the first time:
  1. Authorization - User is redirected to CaseXchange, authenticates, and grants consent
  2. Token Exchange - Your app exchanges authorization code for tokens
  3. Profile Retrieval - ID token contains user profile:
    {
      "sub": "user-uuid",
      "email": "[email protected]",
      "name": "John Doe",
      "firm_id": "firm-uuid",
      "firm_name": "Smith & Associates",
      "role": "ATTORNEY"
    }
    
  4. User Creation - Your app creates a local user record linked to CaseXchange identity
  5. Session Established - User is logged in to your application

Use Case 2: Salesforce / Litify Integration

Enable Salesforce to make authenticated API calls to CaseXchange on behalf of users.

Architecture

OAuth Flow:
Admin → Authorize Integration → OAuth Flow → Store Tokens
Salesforce → Check Expiry → Refresh if Needed → API Call

Salesforce Implementation

Step 1: Create Custom Setting for Token Storage

Create a Hierarchy Custom Setting named CaseXchange_OAuth_Config__c: Fields:
  • Client_Id__c (Text, 255) - OAuth client ID
  • Client_Secret__c (Text, 255) - OAuth client secret
  • Access_Token__c (Text Area, 1000) - Current access token
  • Refresh_Token__c (Text Area, 1000) - Refresh token
  • Token_Expires_At__c (DateTime) - When access token expires
  • Authorized_User_Email__c (Text, 255) - Email of authorizing user
  • OAuth_State__c (Text, 255) - CSRF protection state
Use Custom Settings (not Custom Metadata) because they can be updated via Apex, which is required for automatic token refresh.

Step 2: Token Management in Apex

Get Access Token with Auto-Refresh:
@TestVisible
private static String getAccessToken() {
    CaseXchange_OAuth_Config__c config = CaseXchange_OAuth_Config__c.getInstance();

    if (config == null || String.isBlank(config.Access_Token__c)) {
        throw new AuraHandledException(
            'CaseXchange OAuth not configured. Please authorize the integration.'
        );
    }

    // Check if token expires soon (5 min buffer)
    if (config.Token_Expires_At__c != null &&
        config.Token_Expires_At__c < DateTime.now().addMinutes(5)) {
        refreshAccessToken(config);
        config = CaseXchange_OAuth_Config__c.getInstance();
    }

    return config.Access_Token__c;
}
Refresh Token Logic:
@TestVisible
private static void refreshAccessToken(CaseXchange_OAuth_Config__c config) {
    HttpRequest req = new HttpRequest();
    req.setEndpoint('callout:CaseXchange_API/oauth/token');
    req.setMethod('POST');
    req.setHeader('Content-Type', 'application/x-www-form-urlencoded');
    req.setTimeout(60000);

    String body =
        'grant_type=refresh_token' +
        '&refresh_token=' + EncodingUtil.urlEncode(config.Refresh_Token__c, 'UTF-8') +
        '&client_id=' + EncodingUtil.urlEncode(config.Client_Id__c, 'UTF-8') +
        '&client_secret=' + EncodingUtil.urlEncode(config.Client_Secret__c, 'UTF-8');

    req.setBody(body);

    HttpResponse res = new Http().send(req);

    if (res.getStatusCode() == 200) {
        Map<String, Object> tokenResponse =
            (Map<String, Object>)JSON.deserializeUntyped(res.getBody());

        // Store new tokens
        config.Access_Token__c = (String)tokenResponse.get('access_token');
        config.Refresh_Token__c = (String)tokenResponse.get('refresh_token');
        config.Token_Expires_At__c = DateTime.now().addSeconds(
            (Integer)tokenResponse.get('expires_in')
        );

        upsert config;
    } else {
        System.debug(LoggingLevel.ERROR, 'Token refresh failed: ' + res.getBody());
        throw new AuraHandledException(
            'Failed to refresh access token. Please re-authorize the integration.'
        );
    }
}
Make Authenticated API Calls:
private static HttpRequest createRequest(String method, String path) {
    HttpRequest req = new HttpRequest();
    req.setEndpoint('callout:CaseXchange_API' + path);
    req.setMethod(method);
    req.setHeader('Content-Type', 'application/json');
    req.setHeader('Authorization', 'Bearer ' + getAccessToken());
    return req;
}

Step 3: OAuth Authorization Handler

Create CaseXchangeOAuthHandler.cls:
public with sharing class CaseXchangeOAuthHandler {
    private static final String AUTH_ENDPOINT =
        'callout:CaseXchange_API/oauth/authorize';
    private static final String TOKEN_ENDPOINT =
        'callout:CaseXchange_API/oauth/token';
    private static final String SCOPES =
        'openid profile email cases:read cases:write';

    @AuraEnabled
    public static String getAuthorizationUrl() {
        CaseXchange_OAuth_Config__c config =
            CaseXchange_OAuth_Config__c.getInstance();

        if (config == null || String.isBlank(config.Client_Id__c)) {
            throw new AuraHandledException(
                'OAuth client not configured in Custom Settings'
            );
        }

        // Generate CSRF protection state
        String state = generateState();
        storeState(state);

        String redirectUri = URL.getOrgDomainUrl().toExternalForm() +
                            '/apex/CXOAuthCallback';

        return AUTH_ENDPOINT +
            '?response_type=code' +
            '&client_id=' + EncodingUtil.urlEncode(config.Client_Id__c, 'UTF-8') +
            '&redirect_uri=' + EncodingUtil.urlEncode(redirectUri, 'UTF-8') +
            '&scope=' + EncodingUtil.urlEncode(SCOPES, 'UTF-8') +
            '&state=' + EncodingUtil.urlEncode(state, 'UTF-8');
    }

    @AuraEnabled
    public static String handleCallback(String code, String state, String error) {
        if (String.isNotBlank(error)) {
            throw new AuraHandledException('Authorization failed: ' + error);
        }

        if (String.isBlank(code) || String.isBlank(state)) {
            throw new AuraHandledException('Missing authorization code or state');
        }

        if (!validateState(state)) {
            throw new AuraHandledException('Invalid state. Possible CSRF attack.');
        }

        exchangeCodeForTokens(code);
        return 'Authorization successful!';
    }

    private static void exchangeCodeForTokens(String code) {
        CaseXchange_OAuth_Config__c config =
            CaseXchange_OAuth_Config__c.getInstance();

        HttpRequest req = new HttpRequest();
        req.setEndpoint(TOKEN_ENDPOINT);
        req.setMethod('POST');
        req.setHeader('Content-Type', 'application/x-www-form-urlencoded');
        req.setTimeout(60000);

        String redirectUri = URL.getOrgDomainUrl().toExternalForm() +
                            '/apex/CXOAuthCallback';

        String body =
            'grant_type=authorization_code' +
            '&code=' + EncodingUtil.urlEncode(code, 'UTF-8') +
            '&redirect_uri=' + EncodingUtil.urlEncode(redirectUri, 'UTF-8') +
            '&client_id=' + EncodingUtil.urlEncode(config.Client_Id__c, 'UTF-8') +
            '&client_secret=' + EncodingUtil.urlEncode(config.Client_Secret__c, 'UTF-8');

        req.setBody(body);

        HttpResponse res = new Http().send(req);

        if (res.getStatusCode() == 200) {
            Map<String, Object> tokenResponse =
                (Map<String, Object>)JSON.deserializeUntyped(res.getBody());

            config.Access_Token__c = (String)tokenResponse.get('access_token');
            config.Refresh_Token__c = (String)tokenResponse.get('refresh_token');
            config.Token_Expires_At__c = DateTime.now().addSeconds(
                (Integer)tokenResponse.get('expires_in')
            );
            config.Authorized_User_Email__c = UserInfo.getUserEmail();

            upsert config;
        } else {
            System.debug(LoggingLevel.ERROR, 'Token exchange failed: ' + res.getBody());
            throw new AuraHandledException('Failed to exchange code for tokens');
        }
    }

    private static String generateState() {
        Blob randomBlob = Crypto.generateAesKey(128);
        return EncodingUtil.base64Encode(randomBlob);
    }

    private static void storeState(String state) {
        CaseXchange_OAuth_Config__c config =
            CaseXchange_OAuth_Config__c.getInstance();
        if (config == null) {
            config = new CaseXchange_OAuth_Config__c();
        }
        config.OAuth_State__c = state;
        upsert config;
    }

    private static Boolean validateState(String state) {
        CaseXchange_OAuth_Config__c config =
            CaseXchange_OAuth_Config__c.getInstance();
        return config != null &&
               String.isNotBlank(config.OAuth_State__c) &&
               config.OAuth_State__c == state;
    }
}

Step 4: OAuth Callback Page

Create Visualforce Page CXOAuthCallback.page:
<apex:page controller="CaseXchangeOAuthCallbackController"
           action="{!handleCallback}">
    <apex:slds />
    <div class="slds-scope">
        <div class="slds-align_absolute-center" style="height: 100vh;">
            <apex:outputPanel rendered="{!success}">
                <h1 class="slds-text-heading_large">
                    Authorization Successful
                </h1>
                <p>CaseXchange integration is now connected. You can close this window.</p>
            </apex:outputPanel>

            <apex:outputPanel rendered="{!NOT(success)}">
                <h1 class="slds-text-heading_large">
                    Authorization Failed
                </h1>
                <p class="slds-text-color_error">{!errorMessage}</p>
            </apex:outputPanel>
        </div>
    </div>
</apex:page>
Create Controller CaseXchangeOAuthCallbackController.cls:
public class CaseXchangeOAuthCallbackController {
    public Boolean success { get; set; }
    public String errorMessage { get; set; }

    public PageReference handleCallback() {
        String code = ApexPages.currentPage().getParameters().get('code');
        String state = ApexPages.currentPage().getParameters().get('state');
        String error = ApexPages.currentPage().getParameters().get('error');

        try {
            CaseXchangeOAuthHandler.handleCallback(code, state, error);
            success = true;
        } catch (Exception e) {
            success = false;
            errorMessage = e.getMessage();
            System.debug(LoggingLevel.ERROR, 'OAuth callback error: ' + e.getMessage());
        }

        return null; // Stay on current page
    }
}

Step 5: Register OAuth Client for Salesforce

Use the CaseXchange Admin API to register your Salesforce org:
curl -X POST https://api.casexchange.com/api/v1/admin/oauth-clients \
  -H "x-admin-api-key: YOUR_ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Salesforce Production",
    "description": "OAuth client for Salesforce Production Org",
    "redirectUris": [
      "https://YOUR-ORG.my.salesforce.com/apex/CXOAuthCallback",
      "https://YOUR-ORG.lightning.force.com/apex/CXOAuthCallback",
      "https://YOUR-ORG.my.salesforce.com/apex/CX_OAuth_Callback",
      "https://YOUR-ORG.lightning.force.com/apex/CX_OAuth_Callback"
    ],
    "allowed_scopes": [
      "openid",
      "profile",
      "email",
      "cases:read",
      "cases:write"
    ]
  }'
Save the clientId and clientSecret in your CaseXchange_OAuth_Config__c Custom Setting.

Security Best Practices

PKCE (Proof Key for Code Exchange)

  • Always use PKCE for public clients (mobile apps, SPAs, native apps)
  • Recommended for all clients including confidential clients
  • Method: Use S256 (SHA-256) instead of plain
  • Protection: Prevents authorization code interception attacks
Implementation:
// Generate code verifier (random string)
const codeVerifier = base64UrlEncode(crypto.randomBytes(32));

// Generate code challenge
const codeChallenge = base64UrlEncode(
  crypto.createHash('sha256').update(codeVerifier).digest()
);

// Include in authorization request
const authUrl = `${authEndpoint}?` +
  `code_challenge=${codeChallenge}` +
  `&code_challenge_method=S256` +
  // ... other params

// Include verifier in token exchange
const tokenRequest = {
  grant_type: 'authorization_code',
  code: authorizationCode,
  code_verifier: codeVerifier,
  // ... other params
};

State Parameter

  • Always include state parameter in authorization requests
  • Generate random value for each authorization request
  • Validate on callback that state matches the stored value
  • Purpose: Prevents CSRF attacks

Token Storage

  • Encrypt refresh tokens at rest
  • Never expose tokens in client-side code or URLs
  • Store securely in backend systems only
  • Rotate regularly using the refresh token flow
  • Revoke immediately if compromised

Redirect URI Validation

  • Exact match required - No partial matching or wildcards
  • Register all environments separately (dev, staging, production)
  • Use HTTPS in production (HTTP only allowed for localhost)
  • Validate strictly on both authorization and token endpoints

Token Expiration Handling

  • Proactively refresh access tokens before expiration
  • Use 5-minute buffer to account for clock skew
  • Handle 401 errors by refreshing and retrying
  • Implement exponential backoff for retry logic
Example:
async function makeAuthenticatedRequest(url, options = {}) {
  // Check if token expires soon (5 min buffer)
  if (tokenExpiresAt < Date.now() + 5 * 60 * 1000) {
    await refreshAccessToken();
  }

  const response = await fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      'Authorization': `Bearer ${accessToken}`
    }
  });

  // Handle token expiration
  if (response.status === 401) {
    await refreshAccessToken();
    return fetch(url, {
      ...options,
      headers: {
        ...options.headers,
        'Authorization': `Bearer ${accessToken}`
      }
    });
  }

  return response;
}

Troubleshooting

Error: “redirect_uri is not registered for this client”

Cause: The redirect URI in your request doesn’t exactly match a registered URI. Solution:
  • Check for exact match including protocol, domain, port, and path
  • Verify no trailing slashes or query parameters
  • Ensure URI is registered for the correct environment

Error: “invalid_grant - authorization code has expired”

Cause: Authorization codes expire after 10 minutes. Solution:
  • Exchange authorization codes immediately after receiving them
  • Don’t cache or reuse authorization codes
  • Implement proper error handling and user messaging

Error: “invalid_client”

Cause: Client authentication failed (wrong credentials or method). Solution:
  • Verify client_id and client_secret are correct
  • Check token_endpoint_auth_method matches your implementation
  • For Salesforce: Use client_secret_post (form-urlencoded)
  • Ensure credentials are properly URL-encoded

Error: “Token refresh failed” in Salesforce

Cause: Refresh token expired or invalidated. Solution:
  • Check if refresh token is older than 30 days
  • Verify no manual token revocation occurred
  • Re-authorize the integration to get new tokens
  • Implement user notifications for re-authorization needs

Error: “issuer claim mismatch”

Cause: OIDC issuer in discovery document doesn’t match configuration. Solution:
  • Verify CASEXCHANGE_OAUTH_URL exactly matches issuer in discovery
  • Check for trailing slashes (should not have one)
  • Ensure discovery endpoint is accessible: curl ${ISSUER}/.well-known/openid-configuration

Testing

Local Development Setup

1. Start CaseXchange Backend:
cd /path/to/case-xchange/packages/backend
npm run dev
# Running on http://localhost:3001
2. Use ngrok for Public URL (if needed for external apps):
ngrok http 3001
# Forwarding https://abc123.ngrok.io -> http://localhost:3001
3. Register Test OAuth Client:
curl -X POST http://localhost:3001/api/v1/admin/oauth-clients \
  -H "x-admin-api-key: YOUR_DEV_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Local Development",
    "redirectUris": ["http://localhost:3000/oauth/callback"],
    "allowed_scopes": ["openid", "email", "profile", "cases:read"]
  }'

Testing Checklist

  • Authorization request redirects to login page
  • Consent screen displays requested scopes
  • Authorization code is returned to redirect URI
  • Token exchange succeeds and returns all expected tokens
  • Access token includes correct claims (sub, email, firm_id, etc.)
  • ID token validates and contains expected claims
  • API calls with access token succeed
  • Token refresh works and rotates refresh token
  • Expired tokens are handled gracefully
  • Token revocation invalidates tokens immediately
  • PKCE validation prevents code interception
  • State validation prevents CSRF attacks

Rate Limits

OAuth endpoints have the following rate limits:
  • Authorization endpoint: 100 requests/minute per IP
  • Token endpoint: 60 requests/minute per client
  • Userinfo endpoint: 120 requests/minute per token
  • Revocation endpoint: 60 requests/minute per client
Exceeding these limits returns 429 Too Many Requests with a Retry-After header.

Support

For OAuth integration support:
  • Review this documentation and linked examples
  • Check the OIDC discovery endpoint for current configuration
  • Contact CaseXchange support with your OAuth client ID for assistance