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
platformClientJavaScript SDK. - The implementation covers modern JavaScript (ES6+) using
fetchandcryptoAPIs.
Prerequisites
- OAuth Client Type: Public Client (Single Page Application).
- Required Scopes:
loginis 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-clientv138.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_verifierdoes not match thecode_challenge. - How to fix it: Ensure the code exchange happens immediately after redirect. Verify that the
code_verifierstored insessionStorageis the exact same string used to generate thecode_challenge. Check for whitespace or encoding differences.
Error: invalid_client
- What causes it: The
client_idis incorrect, or the client is not configured as a Public Client in the Genesys Cloud developer console. - How to fix it: Verify the
client_idmatches 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_uriparameter 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.