Implementing Authorization Code with PKCE for SPAs in Genesys Cloud

Implementing Authorization Code with PKCE for SPAs in Genesys Cloud

What You Will Build

  • A JavaScript single-page application that authenticates a user against Genesys Cloud without exposing client secrets.
  • The implementation uses the OAuth 2.0 Authorization Code flow with Proof Key for Code Exchange (PKCE) to secure the exchange between the browser and the identity provider.
  • The tutorial covers the complete lifecycle: generating the PKCE challenge, redirecting the user, handling the callback, exchanging the code for tokens, and refreshing expired sessions.

Prerequisites

  • OAuth Client Type: Public Client. You must create an OAuth Client in the Genesys Cloud Admin Console with the “Public” setting enabled. Do not use Confidential clients for SPAs.
  • Required Scopes: openid, profile, email, and any application-specific scopes (e.g., analytics:read, user:read).
  • Runtime: Modern web browser (Chrome, Firefox, Edge) supporting ES6+ modules or a bundler like Vite/Webpack.
  • External Dependencies: None. This tutorial uses the native fetch API and standard JavaScript crypto libraries (window.crypto).

Authentication Setup

Securing a Single-Page Application (SPA) requires avoiding the storage of client secrets in the front-end code. The Authorization Code flow with PKCE mitigates the risk of authorization code interception attacks. The process involves two distinct phases:

  1. Authorization Request: The application generates a cryptographic secret (code_verifier) and derives a public challenge (code_challenge) from it. It sends the challenge to Genesys Cloud.
  2. Token Exchange: After the user consents, Genesys Cloud returns an authorization code. The application sends this code back along with the original code_verifier. Genesys Cloud verifies that the challenge matches the verifier before issuing tokens.

Generating the PKCE Code Verifier and Challenge

You must generate a cryptographically random string for the code_verifier. The code_challenge is the SHA-256 hash of this verifier, Base64URL-encoded.

/**
 * Generates a cryptographically secure random string.
 * @param {number} length - The desired length of the string.
 * @returns {string} The random string.
 */
async function generateRandomString(length) {
  const array = new Uint8Array(length);
  window.crypto.getRandomValues(array);
  return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
}

/**
 * Base64URL encodes a string.
 * @param {string} input - The string to encode.
 * @returns {string} The Base64URL encoded string.
 */
function base64URLEncode(str) {
  return btoa(String.fromCharCode.apply(null, new Uint8Array(str)))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

/**
 * Generates the PKCE code challenge from the code verifier.
 * @param {string} codeVerifier - The raw code verifier string.
 * @returns {Promise<string>} The SHA-256 hashed and Base64URL encoded challenge.
 */
async function generateCodeChallenge(codeVerifier) {
  const encoder = new TextEncoder();
  const data = encoder.encode(codeVerifier);
  const hashBuffer = await window.crypto.subtle.digest('SHA-256', data);
  return base64URLEncode(hashBuffer);
}

Initiating the Authorization Request

Construct the authorization URL using the Genesys Cloud OAuth endpoint. You must include the code_challenge and code_challenge_method=S256.

const CLIENT_ID = 'your_public_client_id';
const REDIRECT_URI = 'http://localhost:3000/callback'; // Must match registered URI
const SCOPES = 'openid profile email user:read';

async function initiateLogin() {
  // 1. Generate PKCE values
  const codeVerifier = await generateRandomString(64);
  const codeChallenge = await generateCodeChallenge(codeVerifier);

  // 2. Store the verifier securely for the token exchange step.
  // In production, use sessionStorage or a secure memory store. 
  // Do NOT use localStorage for sensitive tokens or verifiers if possible, 
  // but sessionStorage is acceptable for the verifier as it clears on tab close.
  sessionStorage.setItem('pkce_code_verifier', codeVerifier);

  // 3. Construct the authorization URL
  const authUrl = new URL('https://login.mypurecloud.com/as/authorization.oauth2');
  authUrl.searchParams.append('response_type', 'code');
  authUrl.searchParams.append('client_id', CLIENT_ID);
  authUrl.searchParams.append('redirect_uri', REDIRECT_URI);
  authUrl.searchParams.append('scope', SCOPES);
  authUrl.searchParams.append('code_challenge_method', 'S256');
  authUrl.searchParams.append('code_challenge', codeChallenge);
  
  // Optional: Add a unique state parameter to prevent CSRF
  const state = await generateRandomString(32);
  sessionStorage.setItem('pkce_state', state);
  authUrl.searchParams.append('state', state);

  // 4. Redirect the user
  window.location.href = authUrl.toString();
}

Implementation

Step 1: Handling the Authorization Callback

When the user completes authentication, Genesys Cloud redirects to your REDIRECT_URI with a code and state parameter. You must validate the state and exchange the code for tokens.

/**
 * Handles the callback from the Genesys Cloud authorization server.
 */
async function handleCallback() {
  const urlParams = new URLSearchParams(window.location.search);
  
  // 1. Check for errors in the redirect
  const error = urlParams.get('error');
  if (error) {
    console.error('OAuth Error:', error, urlParams.get('error_description'));
    return;
  }

  const code = urlParams.get('code');
  const returnedState = urlParams.get('state');

  // 2. Validate State (CSRF Protection)
  const storedState = sessionStorage.getItem('pkce_state');
  if (!returnedState || returnedState !== storedState) {
    throw new Error('State mismatch. Potential CSRF attack detected.');
  }

  // 3. Retrieve the Code Verifier
  const codeVerifier = sessionStorage.getItem('pkce_code_verifier');
  if (!codeVerifier) {
    throw new Error('PKCE code verifier not found. Session may have expired.');
  }

  // 4. Exchange Code for Tokens
  await exchangeCodeForTokens(code, codeVerifier);
}

/**
 * Exchanges the authorization code for access and refresh tokens.
 * @param {string} code - The authorization code from the callback.
 * @param {string} codeVerifier - The original PKCE code verifier.
 */
async function exchangeCodeForTokens(code, codeVerifier) {
  const tokenUrl = 'https://login.mypurecloud.com/as/token.oauth2';

  const params = new URLSearchParams();
  params.append('grant_type', 'authorization_code');
  params.append('client_id', CLIENT_ID);
  params.append('redirect_uri', REDIRECT_URI);
  params.append('code', code);
  params.append('code_verifier', codeVerifier);

  try {
    const response = await fetch(tokenUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Accept': 'application/json'
      },
      body: params
    });

    if (!response.ok) {
      const errorData = await response.json();
      throw new Error(`Token exchange failed: ${errorData.error_description || response.statusText}`);
    }

    const tokens = await response.json();
    
    // 5. Store tokens securely
    // Note: In a real app, consider using httpOnly cookies for refresh tokens if possible,
    // but for pure SPA, memory or sessionStorage is common for access tokens.
    sessionStorage.setItem('access_token', tokens.access_token);
    sessionStorage.setItem('refresh_token', tokens.refresh_token);
    sessionStorage.setItem('token_expiry', Date.now() + (tokens.expires_in * 1000));

    console.log('Authentication successful.');
    window.location.href = '/dashboard'; // Redirect to app home

  } catch (error) {
    console.error('Failed to exchange code:', error);
  }
}

Step 2: Making API Requests with the Access Token

Once you have the access token, you must attach it to every API request using the Authorization: Bearer <token> header.

/**
 * Fetches current user information using the Genesys Cloud API.
 * @returns {Promise<Object>} The user profile object.
 */
async function getCurrentUser() {
  const accessToken = sessionStorage.getItem('access_token');
  
  if (!accessToken) {
    throw new Error('No access token available. Please login.');
  }

  const apiUrl = 'https://api.mypurecloud.com/api/v2/users/me';

  try {
    const response = await fetch(apiUrl, {
      method: 'GET',
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Accept': 'application/json'
      }
    });

    if (response.status === 401) {
      // Token is likely expired. Trigger refresh flow.
      await refreshToken();
      return getCurrentUser(); // Retry
    }

    if (!response.ok) {
      throw new Error(`API request failed: ${response.statusText}`);
    }

    return await response.json();

  } catch (error) {
    console.error('Error fetching user:', error);
    throw error;
  }
}

Step 3: Implementing Token Refresh Logic

Access tokens expire (typically after 1 hour). You must use the refresh token to obtain a new access token without re-logging the user. This is critical for maintaining a seamless user experience.

/**
 * Refreshes the access token using the stored refresh token.
 */
async function refreshToken() {
  const refreshToken = sessionStorage.getItem('refresh_token');
  
  if (!refreshToken) {
    throw new Error('No refresh token available. Please login again.');
  }

  const tokenUrl = 'https://login.mypurecloud.com/as/token.oauth2';

  const params = new URLSearchParams();
  params.append('grant_type', 'refresh_token');
  params.append('client_id', CLIENT_ID);
  params.append('refresh_token', refreshToken);

  try {
    const response = await fetch(tokenUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Accept': 'application/json'
      },
      body: params
    });

    if (!response.ok) {
      const errorData = await response.json();
      
      // If refresh fails (e.g., token revoked or expired), force re-login
      if (response.status === 400 || response.status === 401) {
        sessionStorage.clear();
        throw new Error('Session expired. Please login again.');
      }
      
      throw new Error(`Refresh failed: ${errorData.error_description}`);
    }

    const tokens = await response.json();
    
    // Update stored tokens
    sessionStorage.setItem('access_token', tokens.access_token);
    // Note: refresh_token rotation may occur; update if present
    if (tokens.refresh_token) {
      sessionStorage.setItem('refresh_token', tokens.refresh_token);
    }
    sessionStorage.setItem('token_expiry', Date.now() + (tokens.expires_in * 1000));

    return tokens.access_token;

  } catch (error) {
    console.error('Failed to refresh token:', error);
    throw error;
  }
}

Complete Working Example

This complete module demonstrates the integration of PKCE generation, authorization, token exchange, and API usage.

// oauth-client.js

const CONFIG = {
  clientId: 'your_public_client_id',
  redirectUri: 'http://localhost:3000/callback',
  scopes: 'openid profile email user:read',
  authDomain: 'https://login.mypurecloud.com',
  apiDomain: 'https://api.mypurecloud.com'
};

// --- Crypto Utilities ---

async function generateRandomString(length) {
  const array = new Uint8Array(length);
  window.crypto.getRandomValues(array);
  return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
}

function base64URLEncode(str) {
  return btoa(String.fromCharCode.apply(null, new Uint8Array(str)))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

async function generateCodeChallenge(codeVerifier) {
  const encoder = new TextEncoder();
  const data = encoder.encode(codeVerifier);
  const hashBuffer = await window.crypto.subtle.digest('SHA-256', data);
  return base64URLEncode(hashBuffer);
}

// --- Core OAuth Flow ---

async function login() {
  const codeVerifier = await generateRandomString(64);
  const codeChallenge = await generateCodeChallenge(codeVerifier);
  const state = await generateRandomString(32);

  sessionStorage.setItem('pkce_code_verifier', codeVerifier);
  sessionStorage.setItem('pkce_state', state);

  const authUrl = new URL(`${CONFIG.authDomain}/as/authorization.oauth2`);
  authUrl.searchParams.append('response_type', 'code');
  authUrl.searchParams.append('client_id', CONFIG.clientId);
  authUrl.searchParams.append('redirect_uri', CONFIG.redirectUri);
  authUrl.searchParams.append('scope', CONFIG.scopes);
  authUrl.searchParams.append('code_challenge_method', 'S256');
  authUrl.searchParams.append('code_challenge', codeChallenge);
  authUrl.searchParams.append('state', state);

  window.location.href = authUrl.toString();
}

async function handleCallback() {
  const urlParams = new URLSearchParams(window.location.search);
  
  if (urlParams.get('error')) {
    throw new Error(urlParams.get('error_description'));
  }

  const code = urlParams.get('code');
  const returnedState = urlParams.get('state');
  const storedState = sessionStorage.getItem('pkce_state');

  if (!returnedState || returnedState !== storedState) {
    throw new Error('State mismatch.');
  }

  const codeVerifier = sessionStorage.getItem('pkce_code_verifier');
  if (!codeVerifier) {
    throw new Error('Code verifier missing.');
  }

  const tokenUrl = `${CONFIG.authDomain}/as/token.oauth2`;
  const params = new URLSearchParams({
    grant_type: 'authorization_code',
    client_id: CONFIG.clientId,
    redirect_uri: CONFIG.redirectUri,
    code: code,
    code_verifier: codeVerifier
  });

  const response = await fetch(tokenUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: params
  });

  if (!response.ok) throw new Error('Token exchange failed.');

  const tokens = await response.json();
  sessionStorage.setItem('access_token', tokens.access_token);
  sessionStorage.setItem('refresh_token', tokens.refresh_token);
  sessionStorage.setItem('token_expiry', Date.now() + (tokens.expires_in * 1000));
  
  window.location.href = '/';
}

// --- API Helper ---

async function apiGet(endpoint) {
  let accessToken = sessionStorage.getItem('access_token');
  const expiry = parseInt(sessionStorage.getItem('token_expiry') || '0');

  // Check if token is expired or about to expire (within 60 seconds)
  if (!accessToken || Date.now() >= expiry - 60000) {
    const refreshToken = sessionStorage.getItem('refresh_token');
    if (refreshToken) {
      await refreshAccessToken();
      accessToken = sessionStorage.getItem('access_token');
    } else {
      throw new Error('Session expired. Please login.');
    }
  }

  const response = await fetch(`${CONFIG.apiDomain}${endpoint}`, {
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      'Accept': 'application/json'
    }
  });

  if (response.status === 401) {
    // Retry once with fresh token
    await refreshAccessToken();
    return apiGet(endpoint); // Recursive retry
  }

  if (!response.ok) {
    throw new Error(`API Error: ${response.status} ${response.statusText}`);
  }

  return response.json();
}

async function refreshAccessToken() {
  const refreshToken = sessionStorage.getItem('refresh_token');
  const tokenUrl = `${CONFIG.authDomain}/as/token.oauth2`;
  
  const params = new URLSearchParams({
    grant_type: 'refresh_token',
    client_id: CONFIG.clientId,
    refresh_token: refreshToken
  });

  const response = await fetch(tokenUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: params
  });

  if (!response.ok) {
    sessionStorage.clear();
    throw new Error('Token refresh failed. Please login again.');
  }

  const tokens = await response.json();
  sessionStorage.setItem('access_token', tokens.access_token);
  if (tokens.refresh_token) {
    sessionStorage.setItem('refresh_token', tokens.refresh_token);
  }
  sessionStorage.setItem('token_expiry', Date.now() + (tokens.expires_in * 1000));
}

// --- Initialization ---

document.addEventListener('DOMContentLoaded', () => {
  const urlParams = new URLSearchParams(window.location.search);
  if (urlParams.has('code')) {
    handleCallback().catch(err => console.error(err));
  } else {
    // Example: Fetch user data on load
    apiGet('/api/v2/users/me')
      .then(user => console.log('Logged in as:', user.name))
      .catch(err => console.error('Not logged in or error:', err));
  }
});

Common Errors & Debugging

Error: invalid_grant during Token Exchange

  • Cause: The authorization code has expired (codes are short-lived, usually 10 minutes) or the code_verifier does not match the code_challenge sent in the initial request.
  • Fix: Ensure the code_verifier stored in sessionStorage is identical to the one used to generate the challenge. Verify that the user completed the login flow immediately and did not sit on the consent screen for an extended period.

Error: invalid_client during Token Exchange

  • Cause: The client_id provided in the token request does not match the one associated with the authorization code, or the client is not configured as “Public”.
  • Fix: Verify the CLIENT_ID in your code matches the OAuth Client created in the Genesys Cloud Admin Console. Ensure the “Public” checkbox is enabled for the client.

Error: access_denied or consent_required

  • Cause: The user denied the requested scopes, or the scopes requested exceed the permissions granted to the OAuth Client in the admin console.
  • Fix: Check the OAuth Client configuration in Genesys Cloud Admin Console. Ensure all requested scopes (e.g., user:read) are explicitly granted to the client.

Error: redirect_uri_mismatch

  • Cause: The redirect_uri in the authorization request does not exactly match one of the registered redirect URIs for the OAuth Client.
  • Fix: Ensure the URI includes the correct scheme (http/https), port, and path. Trailing slashes matter. If you are developing locally, ensure http://localhost:3000/callback is explicitly listed in the allowed redirect URIs.

Official References