Implementing Authorization Code with PKCE for Single-Page Applications in Genesys Cloud

Implementing Authorization Code with PKCE for Single-Page Applications in Genesys Cloud

What You Will Build

  • You will build a client-side JavaScript module that authenticates a user with Genesys Cloud using the Authorization Code flow enhanced with Proof Key for Code Exchange (PKCE).
  • This implementation uses the Genesys Cloud OAuth 2.0 endpoints directly via the Fetch API, avoiding the need for server-side token exchange logic.
  • The tutorial covers the full lifecycle: code challenge generation, authorization request, token exchange, and secure local storage handling.

Prerequisites

  • OAuth Client Type: Public Client (SPA). You must register a new OAuth client in the Genesys Cloud Admin Portal with the “Public” toggle enabled.
  • Required Scopes: openid, profile, offline_access (for refresh tokens), and any application-specific scopes (e.g., conversation:read).
  • SDK/API Version: Genesys Cloud Platform API v2.
  • Runtime: Modern web browser supporting ES6+ modules, crypto.subtle, and fetch.
  • External Dependencies: None. This implementation uses native browser APIs. No third-party libraries are required.

Authentication Setup

Single-page applications (SPAs) cannot securely store client secrets. Therefore, the standard Authorization Code flow is insufficient because it relies on server-side secret verification. PKCE (RFC 7636) solves this by adding a cryptographic verifier generated by the client. The client sends a hashed version of this verifier (the code challenge) during the authorization request and the raw verifier during the token exchange. This prevents authorization code interception attacks.

Step 1: Generating the Code Verifier and Challenge

The first step is to generate a high-entropy random string (the code verifier) and derive the code challenge from it. The code challenge is the base64url-encoded SHA-256 hash of the code verifier.

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

/**
 * Encodes a string to base64url format.
 * @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(/=+$/, '');
}

/**
 * Creates the code challenge from the code verifier.
 * @param {string} verifier - The raw code verifier.
 * @returns {Promise<string>} The base64url-encoded SHA-256 hash.
 */
async function createCodeChallenge(verifier) {
    const encoder = new TextEncoder();
    const data = encoder.encode(verifier);
    const hashBuffer = await window.crypto.subtle.digest('SHA-256', data);
    return base64URLEncode(new Uint8Array(hashBuffer));
}

/**
 * Generates both the verifier and challenge.
 * @returns {Promise<{ verifier: string, challenge: string }>}
 */
async function generatePKCEParams() {
    const verifier = generateRandomString(64);
    const challenge = await createCodeChallenge(verifier);
    return { verifier, challenge };
}

Step 2: Constructing the Authorization Request

Once you have the code_challenge, you redirect the user to the Genesys Cloud authorization endpoint. You must include the code_challenge and code_challenge_method=S256 parameters.

const AUTH_ENDPOINT = 'https://login.mypurecloud.com/as/authorization.oauth2';
const CLIENT_ID = 'YOUR_PUBLIC_CLIENT_ID';
const REDIRECT_URI = 'http://localhost:3000/callback';

async function initiateLogin() {
    try {
        const { verifier, challenge } = await generatePKCEParams();

        // Store the verifier securely in memory or sessionStorage
        // DO NOT store in localStorage if possible, as it is accessible by XSS
        sessionStorage.setItem('pkce_verifier', verifier);

        const params = new URLSearchParams({
            response_type: 'code',
            client_id: CLIENT_ID,
            redirect_uri: REDIRECT_URI,
            code_challenge: challenge,
            code_challenge_method: 'S256',
            scope: 'openid profile offline_access conversation:read',
            state: generateRandomString(16) // Always use state for CSRF protection
        });

        window.location.href = `${AUTH_ENDPOINT}?${params.toString()}`;
    } catch (error) {
        console.error('Failed to generate PKCE params:', error);
    }
}

Critical Parameters:

  • code_challenge: The SHA-256 hash of the verifier.
  • code_challenge_method: Must be S256.
  • state: A random value to prevent Cross-Site Request Forgery (CSRF). You must validate this on return.

Step 3: Handling the Callback and Exchanging the Code

After the user authenticates, Genesys Cloud redirects back to your REDIRECT_URI with an authorization_code and the state. You must exchange this code for access and refresh tokens.

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

async function handleCallback() {
    const urlParams = new URLSearchParams(window.location.search);
    const code = urlParams.get('code');
    const state = urlParams.get('state');
    const error = urlParams.get('error');

    if (error) {
        console.error('Authorization Error:', error);
        return;
    }

    if (!code) {
        console.error('No authorization code found.');
        return;
    }

    // Retrieve the verifier stored earlier
    const verifier = sessionStorage.getItem('pkce_verifier');
    if (!verifier) {
        console.error('PKCE verifier not found in session.');
        return;
    }

    // Clean up the verifier from storage immediately after use
    sessionStorage.removeItem('pkce_verifier');

    try {
        const response = await fetch(TOKEN_ENDPOINT, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
            },
            body: new URLSearchParams({
                grant_type: 'authorization_code',
                code: code,
                redirect_uri: REDIRECT_URI,
                client_id: CLIENT_ID,
                code_verifier: verifier
            })
        });

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

        const tokens = await response.json();
        console.log('Authentication Successful', tokens);
        storeTokens(tokens);
        
        // Redirect to the main application route
        window.location.href = '/dashboard';
    } catch (error) {
        console.error('Token exchange error:', error);
    }
}

function storeTokens(tokens) {
    // In production, consider using httpOnly cookies via a backend proxy
    // for better security against XSS.
    localStorage.setItem('genesys_access_token', tokens.access_token);
    localStorage.setItem('genesys_refresh_token', tokens.refresh_token);
    localStorage.setItem('genesys_token_expiry', Date.now() + (tokens.expires_in * 1000));
}

Required OAuth Scopes for Token Endpoint:

  • The client_id must match the one used in the authorization request.
  • The code_verifier must match the original random string. If it does not, the server returns invalid_grant.

Step 4: Refreshing the Access Token

SPAs should not re-prompt the user for login when the access token expires. Use the refresh_token obtained in the previous step.

async function refreshToken() {
    const refreshToken = localStorage.getItem('genesys_refresh_token');
    
    if (!refreshToken) {
        throw new Error('No refresh token available. Re-authentication required.');
    }

    try {
        const response = await fetch(TOKEN_ENDPOINT, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
            },
            body: new URLSearchParams({
                grant_type: 'refresh_token',
                refresh_token: refreshToken,
                client_id: CLIENT_ID
            })
        });

        if (!response.ok) {
            const errorData = await response.json();
            // If refresh token is invalid, force re-login
            if (errorData.error === 'invalid_grant') {
                localStorage.clear();
                window.location.href = '/login';
                return;
            }
            throw new Error(`Refresh failed: ${errorData.error_description}`);
        }

        const newTokens = await response.json();
        storeTokens(newTokens);
        return newTokens.access_token;
    } catch (error) {
        console.error('Refresh token error:', error);
        throw error;
    }
}

Complete Working Example

This is a complete, self-contained auth.js module for a single-page application. It handles initialization, login, callback processing, and API request interception.

// auth.js

const CONFIG = {
    AUTH_ENDPOINT: 'https://login.mypurecloud.com/as/authorization.oauth2',
    TOKEN_ENDPOINT: 'https://login.mypurecloud.com/as/token.oauth2',
    CLIENT_ID: 'YOUR_PUBLIC_CLIENT_ID',
    REDIRECT_URI: 'http://localhost:3000/callback',
    SCOPES: 'openid profile offline_access conversation:read'
};

// --- PKCE Utilities ---

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 createCodeChallenge(verifier) {
    const encoder = new TextEncoder();
    const data = encoder.encode(verifier);
    const hashBuffer = await window.crypto.subtle.digest('SHA-256', data);
    return base64URLEncode(new Uint8Array(hashBuffer));
}

// --- Auth Flow ---

async function login() {
    const verifier = generateRandomString(64);
    const challenge = await createCodeChallenge(verifier);
    const state = generateRandomString(16);

    sessionStorage.setItem('pkce_verifier', verifier);
    sessionStorage.setItem('auth_state', state);

    const params = new URLSearchParams({
        response_type: 'code',
        client_id: CONFIG.CLIENT_ID,
        redirect_uri: CONFIG.REDIRECT_URI,
        code_challenge: challenge,
        code_challenge_method: 'S256',
        scope: CONFIG.SCOPES,
        state: state
    });

    window.location.href = `${CONFIG.AUTH_ENDPOINT}?${params.toString()}`;
}

async function handleCallback() {
    const urlParams = new URLSearchParams(window.location.search);
    const code = urlParams.get('code');
    const state = urlParams.get('state');
    const savedState = sessionStorage.getItem('auth_state');

    // Validate state to prevent CSRF
    if (state !== savedState) {
        console.error('State mismatch. Potential CSRF attack.');
        return;
    }

    const verifier = sessionStorage.getItem('pkce_verifier');
    if (!verifier) {
        console.error('Missing PKCE verifier.');
        return;
    }

    // Clear session storage
    sessionStorage.removeItem('pkce_verifier');
    sessionStorage.removeItem('auth_state');

    try {
        const response = await fetch(CONFIG.TOKEN_ENDPOINT, {
            method: 'POST',
            headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
            body: new URLSearchParams({
                grant_type: 'authorization_code',
                code: code,
                redirect_uri: CONFIG.REDIRECT_URI,
                client_id: CONFIG.CLIENT_ID,
                code_verifier: verifier
            })
        });

        if (!response.ok) {
            const err = await response.json();
            throw new Error(err.error_description);
        }

        const data = await response.json();
        localStorage.setItem('genesys_tokens', JSON.stringify(data));
        window.location.href = '/';
    } catch (error) {
        console.error('Auth failed:', error);
    }
}

// --- Token Management ---

function getAccessToken() {
    const tokenData = localStorage.getItem('genesys_tokens');
    if (!tokenData) return null;

    const { access_token, expires_in, issued_at } = JSON.parse(tokenData);
    
    // Simple expiry check (assuming issued_at is epoch seconds if not present, 
    // but standard response usually gives expires_in from request time)
    // For robustness, store expiry timestamp explicitly.
    const expiry = JSON.parse(localStorage.getItem('genesys_token_expiry') || '0');
    
    if (Date.now() > expiry) {
        return null; // Token expired
    }

    return access_token;
}

async function ensureValidToken() {
    let token = getAccessToken();
    if (token) return token;

    const refresh_token = JSON.parse(localStorage.getItem('genesys_tokens') || '{}').refresh_token;
    if (!refresh_token) {
        throw new Error('No refresh token. Login required.');
    }

    try {
        const response = await fetch(CONFIG.TOKEN_ENDPOINT, {
            method: 'POST',
            headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
            body: new URLSearchParams({
                grant_type: 'refresh_token',
                refresh_token: refresh_token,
                client_id: CONFIG.CLIENT_ID
            })
        });

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

        const data = await response.json();
        localStorage.setItem('genesys_tokens', JSON.stringify(data));
        localStorage.setItem('genesys_token_expiry', Date.now() + (data.expires_in * 1000));
        return data.access_token;
    } catch (error) {
        localStorage.removeItem('genesys_tokens');
        localStorage.removeItem('genesys_token_expiry');
        throw error;
    }
}

// --- API Helper ---

async function genesysApiFetch(endpoint, options = {}) {
    const token = await ensureValidToken();
    
    const headers = {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${token}`,
        ...options.headers
    };

    const response = await fetch(endpoint, { ...options, headers });
    
    if (response.status === 401) {
        // Token might have expired mid-request, try one refresh
        try {
            await ensureValidToken();
            const newToken = await ensureValidToken();
            headers['Authorization'] = `Bearer ${newToken}`;
            return fetch(endpoint, { ...options, headers });
        } catch (e) {
            window.location.href = '/login';
            throw e;
        }
    }

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

    return response.json();
}

// Initialize on load
if (window.location.pathname === '/callback') {
    handleCallback();
} else {
    // Check if user is already logged in
    const token = getAccessToken();
    if (!token) {
        // Optional: Auto-redirect to login or show login button
        console.log('User not authenticated.');
    }
}

Common Errors & Debugging

Error: invalid_grant

  • What causes it: The code_verifier sent in the token exchange request does not match the code_challenge sent in the authorization request. This often happens if the base64url encoding implementation differs between the challenge generation and verification, or if the verifier was modified or lost between steps.
  • How to fix it: Ensure you use the exact same verifier string in both steps. Verify your base64URLEncode function handles padding removal correctly. Genesys Cloud expects standard Base64url encoding (RFC 7636).

Error: invalid_client

  • What causes it: The client_id is incorrect, or the client is not registered as a “Public” client.
  • How to fix it: Check your Genesys Cloud Admin Portal. Ensure the OAuth client has the “Public” checkbox enabled. If it is set to “Confidential,” the server will expect a client_secret which SPAs cannot provide securely.

Error: invalid_scope

  • What causes it: The requested scopes are not granted to the OAuth client or the user does not have permission to access those resources.
  • How to fix it: In the Admin Portal, ensure the OAuth client has the specific permissions granted. For example, conversation:read requires the client to have the “View conversations” permission.

Error: redirect_uri_mismatch

  • What causes it: The redirect_uri in the authorization request or token exchange does not exactly match the URI registered in the Admin Portal.
  • How to fix it: Ensure the protocol (http/https), domain, port, and path match exactly. Trailing slashes matter. If you registered http://localhost:3000/callback, you cannot use http://localhost:3000/callback/.

Official References