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, andfetch. - 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 beS256.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_idmust match the one used in the authorization request. - The
code_verifiermust match the original random string. If it does not, the server returnsinvalid_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_verifiersent in the token exchange request does not match thecode_challengesent 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
verifierstring in both steps. Verify yourbase64URLEncodefunction handles padding removal correctly. Genesys Cloud expects standard Base64url encoding (RFC 7636).
Error: invalid_client
- What causes it: The
client_idis 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_secretwhich 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:readrequires the client to have the “View conversations” permission.
Error: redirect_uri_mismatch
- What causes it: The
redirect_uriin 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 usehttp://localhost:3000/callback/.