Implementing Authorization Code with PKCE for Single-Page Applications

Implementing Authorization Code with PKCE for Single-Page Applications

What You Will Build

  • A client-side JavaScript module that authenticates a user against Genesys Cloud or NICE CXone using the Authorization Code flow with Proof Key for Code Exchange (PKCE).
  • The code handles the redirect to the identity provider, processes the authorization code callback, and exchanges the code for an access token without a backend server.
  • The implementation uses modern vanilla JavaScript (ES6+) and the crypto Web API, making it suitable for any single-page application (SPA) framework like React, Angular, or Vue.

Prerequisites

  • OAuth Client Type: Public Client (SPA). The client must be registered in the Genesys Cloud Admin Portal or NICE CXone Admin Console as a “Public” or “SPA” client type. Secret-based flows are insecure in browsers and must not be used.
  • Required Scopes: openid, offline_access (if refresh tokens are needed), and application-specific scopes (e.g., user:read, analytics:read).
  • Runtime Requirements: A modern browser supporting the Web Crypto API (window.crypto).
  • External Dependencies: None. This tutorial uses standard browser APIs. No external libraries like oidc-client-js are required, though they are recommended for production complexity.

Authentication Setup

The Authorization Code flow with PKCE is the standard for SPAs because it eliminates the need to store a client secret in the browser bundle. Instead, it uses a cryptographic verifier/challenge pair to prove that the token request comes from the same entity that initiated the authorization request.

Generate the PKCE Code Verifier and Challenge

The first step is generating a random string (the code verifier) and deriving a challenge from it. The challenge is sent to the authorization server during the redirect, and the verifier is sent when exchanging the code for a token.

// utils/oauth-pkce.js

/**
 * Generates a random string of bytes and encodes it as Base64URL.
 * @param {number} length - The number of random bytes to generate.
 * @returns {Promise<string>} The Base64URL encoded 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('');
}

/**
 * Encodes a string to Base64URL.
 * @param {string} input - The string to encode.
 * @returns {string} The Base64URL encoded string.
 */
function base64URLEncode(str) {
    return btoa(str)
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=/g, '');
}

/**
 * Creates the PKCE code verifier and challenge.
 * @returns {Promise<{ codeVerifier: string, codeChallenge: string }>}
 */
async function generatePKCEParams() {
    // Generate a random 32-byte string for the verifier
    const codeVerifier = await generateRandomString(32);
    
    // Hash the verifier using SHA-256 and encode to Base64URL for the challenge
    const encoder = new TextEncoder();
    const data = encoder.encode(codeVerifier);
    const hashBuffer = await window.crypto.subtle.digest('SHA-256', data);
    const codeChallenge = base64URLEncode(String.fromCharCode(...new Uint8Array(hashBuffer)));

    return { codeVerifier, codeChallenge };
}

Constructing the Authorization Request

You must redirect the user to the identity provider’s authorization endpoint. The URL must include the client ID, redirect URI, response type, scopes, state, and the PKCE code challenge.

Genesys Cloud Endpoint: https://login.mypurecloud.com/as/authorization
NICE CXone Endpoint: https://platform.us.niceincontact.com/oauth2/v2/authorize

// auth/login.js

const GENESYS_CONFIG = {
    clientId: 'YOUR_CLIENT_ID',
    redirectUri: 'http://localhost:3000/callback',
    scope: 'openid offline_access user:read',
    authorizationEndpoint: 'https://login.mypurecloud.com/as/authorization'
};

const CXONE_CONFIG = {
    clientId: 'YOUR_CLIENT_ID',
    redirectUri: 'http://localhost:3000/callback',
    scope: 'openid offline_access',
    authorizationEndpoint: 'https://platform.us.niceincontact.com/oauth2/v2/authorize'
};

/**
 * Initiates the OAuth login flow.
 * @param {Object} config - The platform configuration object.
 */
async function initiateLogin(config) {
    const { codeVerifier, codeChallenge } = await generatePKCEParams();

    // Store the verifier securely. In a real app, use sessionStorage or a secure cookie.
    // Do NOT store in localStorage due to XSS risks if possible, but for SPAs it is often the only option 
    // if HttpOnly cookies are not managed by a backend.
    sessionStorage.setItem('oauth_code_verifier', codeVerifier);
    sessionStorage.setItem('oauth_state', crypto.randomUUID()); // Generate a unique state

    const params = new URLSearchParams({
        response_type: 'code',
        client_id: config.clientId,
        redirect_uri: config.redirectUri,
        scope: config.scope,
        code_challenge: codeChallenge,
        code_challenge_method: 'S256',
        state: sessionStorage.getItem('oauth_state')
    });

    const authUrl = `${config.authorizationEndpoint}?${params.toString()}`;
    
    // Redirect the user
    window.location.href = authUrl;
}

Implementation

Step 1: Handling the Callback and Exchanging the Code

When the user grants permission, the identity provider redirects back to your redirectUri with an authorization code and the state. You must exchange this code for an access token by posting to the token endpoint.

Genesys Cloud Token Endpoint: https://api.mypurecloud.com/api/v2/oauth/token
NICE CXone Token Endpoint: https://platform.us.niceincontact.com/oauth2/v2/token

// auth/callback.js

/**
 * Exchanges the authorization code for an access token.
 * @param {string} code - The authorization code from the query string.
 * @param {Object} config - The platform configuration object.
 * @returns {Promise<Object>} The token response object.
 */
async function exchangeCodeForToken(code, config) {
    const codeVerifier = sessionStorage.getItem('oauth_code_verifier');
    const expectedState = sessionStorage.getItem('oauth_state');
    
    // Clear sensitive session data immediately after retrieval
    sessionStorage.removeItem('oauth_code_verifier');
    sessionStorage.removeItem('oauth_state');

    if (!codeVerifier) {
        throw new Error('PKCE Code Verifier not found. Session may have expired.');
    }

    const tokenEndpoint = config.tokenEndpoint || 
        (config.provider === 'genesys' 
            ? 'https://api.mypurecloud.com/api/v2/oauth/token'
            : 'https://platform.us.niceincontact.com/oauth2/v2/token');

    const formData = new URLSearchParams({
        grant_type: 'authorization_code',
        client_id: config.clientId,
        redirect_uri: config.redirectUri,
        code: code,
        code_verifier: codeVerifier
    });

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

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

        const tokens = await response.json();
        
        // Validate state if provided in the initial request to prevent CSRF
        // Note: The state is usually returned in the callback URL query params, 
        // not in the token response. You must validate it before calling this function.
        
        return tokens;
    } catch (error) {
        console.error('OAuth Token Exchange Error:', error);
        throw error;
    }
}

Step 2: Parsing the Callback URL

Your application must detect when it is on the callback page, parse the query parameters, validate the state, and trigger the token exchange.

// app/main.js

/**
 * Main entry point for handling the OAuth callback.
 */
async function handleCallback(config) {
    const urlParams = new URLSearchParams(window.location.search);
    const code = urlParams.get('code');
    const state = urlParams.get('state');
    const error = urlParams.get('error');

    // Handle user denial or other errors from the IdP
    if (error) {
        console.error('Authorization failed:', urlParams.get('error_description'));
        window.location.href = '/login?error=denied';
        return;
    }

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

    // Validate State to prevent CSRF
    const storedState = sessionStorage.getItem('oauth_state');
    if (state !== storedState) {
        console.error('State mismatch. Potential CSRF attack detected.');
        sessionStorage.clear();
        return;
    }

    try {
        const tokens = await exchangeCodeForToken(code, config);
        
        // Store tokens securely
        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));

        // Redirect to the main app dashboard
        window.location.href = '/dashboard';
    } catch (err) {
        console.error('Failed to exchange code:', err);
        window.location.href = '/login?error=token_exchange';
    }
}

// Usage in your app's entry point
if (window.location.pathname === '/callback') {
    handleCallback(GENESYS_CONFIG);
}

Step 3: Making Authenticated API Requests

Once you have the access token, you include it in the Authorization header of subsequent API requests.

// api/client.js

/**
 * Makes an authenticated GET request to a Genesys Cloud endpoint.
 * @param {string} endpoint - The API endpoint path (e.g., '/api/v2/users/me').
 * @param {string} token - The access token.
 * @returns {Promise<Object>} The JSON response data.
 */
async function getAuthenticatedData(endpoint, token) {
    if (!token) {
        throw new Error('No access token available. Please log in.');
    }

    const baseUrl = 'https://api.mypurecloud.com';
    const url = `${baseUrl}${endpoint}`;

    try {
        const response = await fetch(url, {
            method: 'GET',
            headers: {
                'Authorization': `Bearer ${token}`,
                'Content-Type': 'application/json',
                'Accept': 'application/json'
            }
        });

        if (response.status === 401) {
            // Token expired or invalid. Trigger refresh flow.
            console.warn('Token expired. Refresh required.');
            await refreshAccessToken();
            // Retry logic would go here
        }

        if (!response.ok) {
            const errorData = await response.json();
            throw new Error(`API Error ${response.status}: ${errorData.message || response.statusText}`);
        }

        return await response.json();
    } catch (error) {
        console.error('API Request Failed:', error);
        throw error;
    }
}

// Example usage
async function loadUserProfile() {
    const token = localStorage.getItem('genesys_access_token');
    const profile = await getAuthenticatedData('/api/v2/users/me', token);
    console.log('User Profile:', profile);
    return profile;
}

Complete Working Example

Below is a consolidated, minimal HTML file that demonstrates the entire flow. This can be saved as index.html and opened in a browser (note: for fetch to work correctly with CORS and local storage, it is best served via a local server like python -m http.server or npx serve).

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>PKCE OAuth Demo</title>
    <style>
        body { font-family: sans-serif; padding: 2rem; }
        button { padding: 10px 20px; cursor: pointer; }
        .hidden { display: none; }
    </style>
</head>
<body>
    <h1>Genesys Cloud PKCE Login Demo</h1>
    
    <div id="login-view">
        <p>Click below to authenticate via Genesys Cloud.</p>
        <button id="login-btn">Login with Genesys Cloud</button>
    </div>

    <div id="app-view" class="hidden">
        <h2>Welcome, <span id="user-name">User</span></h2>
        <p>Access Token: <code id="token-preview"></code></p>
        <button id="logout-btn">Logout</button>
    </div>

    <script>
        // Configuration
        const CONFIG = {
            clientId: 'YOUR_CLIENT_ID_HERE',
            redirectUri: window.location.origin + window.location.pathname,
            scope: 'openid offline_access user:read',
            authEndpoint: 'https://login.mypurecloud.com/as/authorization',
            tokenEndpoint: 'https://api.mypurecloud.com/api/v2/oauth/token'
        };

        // DOM Elements
        const loginView = document.getElementById('login-view');
        const appView = document.getElementById('app-view');
        const loginBtn = document.getElementById('login-btn');
        const logoutBtn = document.getElementById('logout-btn');
        const userNameSpan = document.getElementById('user-name');
        const tokenPreview = document.getElementById('token-preview');

        // PKCE 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(str)
                .replace(/\+/g, '-')
                .replace(/\//g, '_')
                .replace(/=/g, '');
        }

        async function generatePKCEParams() {
            const codeVerifier = await generateRandomString(32);
            const encoder = new TextEncoder();
            const data = encoder.encode(codeVerifier);
            const hashBuffer = await window.crypto.subtle.digest('SHA-256', data);
            const codeChallenge = base64URLEncode(String.fromCharCode(...new Uint8Array(hashBuffer)));
            return { codeVerifier, codeChallenge };
        }

        // Login Flow
        loginBtn.addEventListener('click', async () => {
            const { codeVerifier, codeChallenge } = await generatePKCEParams();
            const state = crypto.randomUUID();
            
            sessionStorage.setItem('oauth_code_verifier', codeVerifier);
            sessionStorage.setItem('oauth_state', state);

            const params = new URLSearchParams({
                response_type: 'code',
                client_id: CONFIG.clientId,
                redirect_uri: CONFIG.redirectUri,
                scope: CONFIG.scope,
                code_challenge: codeChallenge,
                code_challenge_method: 'S256',
                state: state
            });

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

        // Callback Handling
        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) {
                alert('Login failed: ' + urlParams.get('error_description'));
                return;
            }

            if (!code) return;

            const storedState = sessionStorage.getItem('oauth_state');
            if (state !== storedState) {
                alert('State mismatch');
                return;
            }

            const codeVerifier = sessionStorage.getItem('oauth_code_verifier');
            sessionStorage.clear(); // Clean up

            const formData = new URLSearchParams({
                grant_type: 'authorization_code',
                client_id: CONFIG.clientId,
                redirect_uri: CONFIG.redirectUri,
                code: code,
                code_verifier: codeVerifier
            });

            try {
                const response = await fetch(CONFIG.tokenEndpoint, {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                    body: formData
                });

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

                const tokens = await response.json();
                localStorage.setItem('genesys_access_token', tokens.access_token);
                
                // Fetch User Profile
                const userResponse = await fetch('https://api.mypurecloud.com/api/v2/users/me', {
                    headers: { 'Authorization': `Bearer ${tokens.access_token}` }
                });
                const user = await userResponse.json();
                
                showApp(user.name, tokens.access_token);
            } catch (err) {
                console.error(err);
                alert('Failed to authenticate');
            }
        }

        function showApp(name, token) {
            loginView.classList.add('hidden');
            appView.classList.remove('hidden');
            userNameSpan.textContent = name;
            tokenPreview.textContent = token.substring(0, 20) + '...';
        }

        // Logout
        logoutBtn.addEventListener('click', () => {
            localStorage.removeItem('genesys_access_token');
            window.location.reload();
        });

        // Init
        if (window.location.search.includes('code=')) {
            handleCallback();
        } else if (localStorage.getItem('genesys_access_token')) {
            // Optional: Auto-login if token exists and is valid
            // For this demo, we just show the login button
        }
    </script>
</body>
</html>

Common Errors & Debugging

Error: invalid_grant during token exchange

Cause: The authorization code has expired (they are short-lived, typically 5-10 minutes) or the PKCE code verifier does not match the code challenge.

Fix: Ensure you are using the exact codeVerifier generated before the redirect. Do not regenerate it on the callback page. Check that you are encoding the challenge using SHA-256 and Base64URL correctly.

// Debugging PKCE Mismatch
async function verifyPKCE() {
    const verifier = "test_verifier_string"; // Replace with actual
    const encoder = new TextEncoder();
    const data = encoder.encode(verifier);
    const hashBuffer = await window.crypto.subtle.digest('SHA-256', data);
    const challenge = btoa(String.fromCharCode(...new Uint8Array(hashBuffer)))
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=/g, '');
    console.log('Verifier:', verifier);
    console.log('Challenge:', challenge);
}

Error: 401 Unauthorized on API calls

Cause: The access token is expired or missing scopes.

Fix: Check the expires_in field from the token response. Implement a refresh token flow before making API calls if the token is near expiration. Ensure the scope requested in the authorization step includes the permissions required for the API endpoint.

Error: CORS Policy Blocked

Cause: The browser blocks the request because the redirect_uri in your code does not exactly match the redirect_uri registered in the Genesys Cloud/CXone admin portal.

Fix: Check the Admin Portal. If you registered http://localhost:3000/callback, you cannot use http://localhost:3000/. The paths must match exactly.

Official References