Implementing the Authorization Code OAuth Flow with PKCE for a Single-Page Application

Implementing the Authorization Code OAuth Flow with PKCE for a Single-Page Application

What You Will Build

  • A secure, client-side authentication flow that exchanges an authorization code for an access token without exposing client secrets.
  • The tutorial uses the Genesys Cloud CX OAuth 2.0 endpoints and the platformClient JavaScript SDK.
  • The implementation covers modern JavaScript (ES6+) using fetch and crypto APIs.

Prerequisites

  • OAuth Client Type: Public Client (Single Page Application).
  • Required Scopes: login is required for the initial grant. Additional scopes (e.g., user:me, conversation:read) must be requested during the authorization step.
  • SDK Version: Genesys Cloud platform-client v138.0.0 or later.
  • Runtime: Any modern browser supporting ES Modules and the Web Crypto API.
  • Dependencies: @genesys/cloud/purecloud-platform-client (installed via npm or CDN).

Authentication Setup

Single-page applications (SPAs) cannot securely store client secrets. Exposing a client secret in browser-based JavaScript allows any user to inspect the source code and steal credentials. To solve this, OAuth 2.1 and OIDC recommend Proof Key for Code Exchange (PKCE).

PKCE prevents authorization code interception attacks. It works by generating a random code_verifier on the client. You derive a code_challenge from this verifier and send it to the authorization server. When you exchange the code for a token, you send the original code_verifier. The server verifies that the challenge matches the verifier, proving the request comes from the legitimate client that initiated the flow.

Generating PKCE Values

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

/**
 * Generates a cryptographically random string for PKCE.
 * @returns {string} A random string suitable for code_verifier.
 */
async function generateCodeVerifier() {
    const array = new Uint8Array(32);
    window.crypto.getRandomValues(array);
    return btoa(String.fromCharCode.apply(null, array))
        .replace(/\+/g, '-')
        .replace(/\=/g, '')
        .replace(/\//g, '_');
}

/**
 * Derives the code_challenge from the code_verifier using SHA-256.
 * @param {string} verifier - The code_verifier generated previously.
 * @returns {string} The base64url encoded SHA-256 hash.
 */
async function generateCodeChallenge(verifier) {
    const encoder = new TextEncoder();
    const data = encoder.encode(verifier);
    const hashBuffer = await window.crypto.subtle.digest('SHA-256', data);
    const hashArray = Array.from(new Uint8Array(hashBuffer));
    const hashBase64 = hashArray.map(b => String.fromCharCode(b)).join('');
    return btoa(hashBase64)
        .replace(/\+/g, '-')
        .replace(/\=/g, '')
        .replace(/\//g, '_');
}

Implementation

Step 1: Redirect to the Authorization Server

The first step is to construct the authorization URL. You must include your client_id, the redirect_uri (which must be registered in the Genesys Cloud developer console), the response_type set to code, and the PKCE code_challenge.

You should also store the code_verifier in a secure location. In a simple SPA, sessionStorage is acceptable because it clears when the tab closes and is not accessible to other tabs or origins. Do not use localStorage if possible, as it persists longer and is more vulnerable to XSS.

const GENESYS_ORGANIZATION_ID = 'YOUR_ORGANIZATION_ID';
const CLIENT_ID = 'YOUR_PUBLIC_CLIENT_ID';
const REDIRECT_URI = 'http://localhost:3000/callback';
const SCOPE = 'login user:me conversation:read';

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

        // 2. Store verifier for the token exchange step
        sessionStorage.setItem('pkce_verifier', codeVerifier);

        // 3. Construct the authorization URL
        const authUrl = new URL(`https://${GENESYS_ORGANIZATION_ID}.mygen.com/oauth/authorize`);
        
        authUrl.searchParams.append('client_id', CLIENT_ID);
        authUrl.searchParams.append('response_type', 'code');
        authUrl.searchParams.append('redirect_uri', REDIRECT_URI);
        authUrl.searchParams.append('scope', SCOPE);
        authUrl.searchParams.append('code_challenge', codeChallenge);
        authUrl.searchParams.append('code_challenge_method', 'S256'); // Explicitly state the method

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

    } catch (error) {
        console.error('Failed to initiate login:', error);
        // Handle UI error state here
    }
}

Expected Response:
The browser redirects to the Genesys Cloud login page. Upon successful authentication, Genesys redirects back to your REDIRECT_URI with a query parameter code and state (if you included it).

Error Handling:
If the client_id is invalid or the redirect_uri is not registered, Genesys returns an error page. You should detect if the URL contains ?error= and display a user-friendly message.

Step 2: Exchange the Code for a Token

Once the user is redirected back to your application, you must capture the code from the URL query parameters. You then send a POST request to the token endpoint.

Crucially, you must include the code_verifier in this request. The server will hash the provided verifier and compare it to the code_challenge sent in Step 1. If they do not match, the server returns an invalid_grant error.

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

    if (error) {
        console.error('Authorization failed:', urlParams.get('error_description'));
        return null;
    }

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

    const codeVerifier = sessionStorage.getItem('pkce_verifier');
    if (!codeVerifier) {
        console.error('PKCE verifier not found in session storage.');
        return null;
    }

    try {
        const tokenUrl = `https://${GENESYS_ORGANIZATION_ID}.mygen.com/oauth/token`;
        
        const response = await fetch(tokenUrl, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
                'Accept': 'application/json'
            },
            body: new URLSearchParams({
                grant_type: 'authorization_code',
                code: authCode,
                redirect_uri: REDIRECT_URI,
                client_id: CLIENT_ID,
                code_verifier: codeVerifier
            }).toString()
        });

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

        const tokenData = await response.json();
        
        // Clean up the verifier as it is no longer needed
        sessionStorage.removeItem('pkce_verifier');

        // Remove the code from the URL to prevent replay attacks via browser history
        window.history.replaceState({}, document.title, window.location.pathname);

        return tokenData;

    } catch (error) {
        console.error('Token exchange error:', error);
        // Handle 400/401 errors: usually invalid_grant if code is expired or verifier mismatch
        return null;
    }
}

OAuth Scopes Required:
The login scope is implicit in the initial authorization. The token response will contain an access_token and a refresh_token (if the client is configured for offline access, though SPAs typically rely on short-lived access tokens and re-authentication).

Step 3: Initialize the SDK and Make an API Call

With the access token, you can initialize the Genesys Cloud SDK. The SDK handles header injection and basic error parsing.

import { PlatformClient } from '@genesys/cloud/purecloud-platform-client';

async function authenticateAndFetchUser(tokenData) {
    if (!tokenData || !tokenData.access_token) {
        console.error('No access token available.');
        return;
    }

    // Initialize the platform client
    const platformClient = new PlatformClient();

    // Set the access token
    // The SDK expects the token to be set on the default API client
    platformClient.setAccessToken(tokenData.access_token);

    try {
        // Fetch the current user's profile
        // Endpoint: GET /api/v2/users/me
        const userResponse = await platformClient.Users.getUsersMe();
        
        console.log('Authenticated User:', userResponse.body);
        
        // Example: Display the user's name
        document.getElementById('user-display').innerText = `Welcome, ${userResponse.body.name}`;

        // Schedule token refresh before expiration
        scheduleTokenRefresh(tokenData.expires_in);

    } catch (error) {
        if (error.status === 401) {
            console.error('Access token expired or invalid. Re-authenticate.');
            initiateLogin();
        } else {
            console.error('API Error:', error);
        }
    }
}

function scheduleTokenRefresh(expiresInSeconds) {
    // Refresh slightly before expiration to avoid race conditions
    const refreshTime = (expiresInSeconds - 60) * 1000; 
    
    setTimeout(async () => {
        // In a real SPA, you would use the refresh_token here if available
        // For PKCE SPAs, often it is easier to re-initiate the silent auth flow
        // or redirect to login if refresh_token is not supported/available.
        console.log('Token expiring soon. Consider refreshing.');
    }, refreshTime);
}

Complete Working Example

This example combines the logic into a single HTML file structure for clarity. In production, split this into modules.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Genesys Cloud PKCE Login</title>
    <style>
        body { font-family: sans-serif; padding: 20px; }
        .hidden { display: none; }
    </style>
</head>
<body>

    <div id="login-screen">
        <h1>Genesys Cloud Integration</h1>
        <button id="login-btn">Login with Genesys</button>
    </div>

    <div id="app-screen" class="hidden">
        <h1>Welcome</h1>
        <p id="user-display">Loading user...</p>
        <button id="logout-btn">Logout</button>
    </div>

    <script type="module">
        import { PlatformClient } from 'https://unpkg.com/@genesys/cloud/purecloud-platform-client@latest/dist/purecloud-platform-client.esm.js';

        const CONFIG = {
            ORG_ID: 'YOUR_ORGANIZATION_ID',
            CLIENT_ID: 'YOUR_PUBLIC_CLIENT_ID',
            REDIRECT_URI: window.location.origin + '/callback.html', // Must match registered URI
            SCOPE: 'login user:me'
        };

        // --- PKCE Helpers ---
        async function generateCodeVerifier() {
            const array = new Uint8Array(32);
            window.crypto.getRandomValues(array);
            return btoa(String.fromCharCode.apply(null, array))
                .replace(/\+/g, '-')
                .replace(/\=/g, '')
                .replace(/\//g, '_');
        }

        async function generateCodeChallenge(verifier) {
            const encoder = new TextEncoder();
            const data = encoder.encode(verifier);
            const hashBuffer = await window.crypto.subtle.digest('SHA-256', data);
            const hashArray = Array.from(new Uint8Array(hashBuffer));
            const hashBase64 = hashArray.map(b => String.fromCharCode(b)).join('');
            return btoa(hashBase64)
                .replace(/\+/g, '-')
                .replace(/\=/g, '')
                .replace(/\//g, '_');
        }

        // --- Flow Logic ---
        async function initiateLogin() {
            const codeVerifier = await generateCodeVerifier();
            const codeChallenge = await generateCodeChallenge(codeVerifier);
            sessionStorage.setItem('pkce_verifier', codeVerifier);

            const authUrl = new URL(`https://${CONFIG.ORG_ID}.mygen.com/oauth/authorize`);
            authUrl.searchParams.append('client_id', CONFIG.CLIENT_ID);
            authUrl.searchParams.append('response_type', 'code');
            authUrl.searchParams.append('redirect_uri', CONFIG.REDIRECT_URI);
            authUrl.searchParams.append('scope', CONFIG.SCOPE);
            authUrl.searchParams.append('code_challenge', codeChallenge);
            authUrl.searchParams.append('code_challenge_method', 'S256');

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

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

            if (error) {
                alert('Login failed: ' + urlParams.get('error_description'));
                window.location.href = '/';
                return;
            }

            if (!code) {
                window.location.href = '/';
                return;
            }

            const codeVerifier = sessionStorage.getItem('pkce_verifier');
            if (!codeVerifier) {
                alert('Security error: PKCE verifier missing.');
                window.location.href = '/';
                return;
            }

            try {
                const tokenResponse = await fetch(`https://${CONFIG.ORG_ID}.mygen.com/oauth/token`, {
                    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: codeVerifier
                    }).toString()
                });

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

                const tokenData = await tokenResponse.json();
                sessionStorage.removeItem('pkce_verifier');
                window.history.replaceState({}, document.title, window.location.pathname);

                await authenticateUser(tokenData.access_token);

            } catch (e) {
                console.error(e);
                alert('Failed to exchange token.');
                window.location.href = '/';
            }
        }

        async function authenticateUser(accessToken) {
            const platformClient = new PlatformClient();
            platformClient.setAccessToken(accessToken);

            try {
                const user = await platformClient.Users.getUsersMe();
                document.getElementById('user-display').innerText = `Hello, ${user.body.name}`;
                document.getElementById('login-screen').classList.add('hidden');
                document.getElementById('app-screen').classList.remove('hidden');
            } catch (e) {
                console.error('User fetch failed', e);
            }
        }

        // --- Event Listeners ---
        document.getElementById('login-btn').addEventListener('click', initiateLogin);
        
        // Check if we are on the callback page
        if (window.location.pathname.includes('callback')) {
            handleCallback();
        }
    </script>
</body>
</html>

Common Errors & Debugging

Error: invalid_grant

  • What causes it: The authorization code has expired, was already used, or the code_verifier does not match the code_challenge.
  • How to fix it: Ensure the code exchange happens immediately after redirect. Verify that the code_verifier stored in sessionStorage is the exact same string used to generate the code_challenge. Check for whitespace or encoding differences.

Error: invalid_client

  • What causes it: The client_id is incorrect, or the client is not configured as a Public Client in the Genesys Cloud developer console.
  • How to fix it: Verify the client_id matches the one in the Genesys Cloud Admin > Developer Console. Ensure the “Client Type” is set to “Public” and the redirect URI is exactly matched (including trailing slashes).

Error: unauthorized_client

  • What causes it: The redirect URI in the request does not match any URI registered for the client.
  • How to fix it: Check the redirect_uri parameter in your authorization request. It must match character-for-character with the URI registered in the Genesys Cloud console.

Error: access_denied

  • What causes it: The user denied the permission request, or the client does not have permission to request the specified scopes.
  • How to fix it: Ensure the scopes requested are allowed for the client application.

Official References