Implementing PKCE for Public Clients
Implementing PKCE for Public Clients
Proof Key for Code Exchange (PKCE) protects authorization code flows for public clients like mobile apps and SPAs. PKCE prevents authorization code interception attacks by requiring clients to generate a random code verifier and including its hash in the authorization request.
// JavaScript implementation of PKCE for SPAs
class PKCEClient {
constructor(clientId, authorizationEndpoint, tokenEndpoint) {
this.clientId = clientId;
this.authorizationEndpoint = authorizationEndpoint;
this.tokenEndpoint = tokenEndpoint;
}
// Generate cryptographically random string
generateRandomString(length) {
const array = new Uint8Array(length);
crypto.getRandomValues(array);
return this.base64UrlEncode(array);
}
// Base64 URL encoding without padding
base64UrlEncode(buffer) {
return btoa(String.fromCharCode(...buffer))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
// SHA256 hash for code challenge
async sha256(plain) {
const encoder = new TextEncoder();
const data = encoder.encode(plain);
const hash = await crypto.subtle.digest('SHA-256', data);
return this.base64UrlEncode(new Uint8Array(hash));
}
// Start authorization flow
async authorize(scopes, redirectUri) {
// Generate PKCE parameters
const codeVerifier = this.generateRandomString(128);
const codeChallenge = await this.sha256(codeVerifier);
const state = this.generateRandomString(32);
// Store in session storage
sessionStorage.setItem('pkce_code_verifier', codeVerifier);
sessionStorage.setItem('pkce_state', state);
// Build authorization URL
const params = new URLSearchParams({
client_id: this.clientId,
response_type: 'code',
redirect_uri: redirectUri,
scope: scopes.join(' '),
state: state,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
// Redirect to authorization server
window.location.href = `${this.authorizationEndpoint}?${params}`;
}
// Handle authorization callback
async 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) {
throw new Error(`Authorization error: ${error}`);
}
// Validate state
const savedState = sessionStorage.getItem('pkce_state');
if (state !== savedState) {
throw new Error('Invalid state parameter');
}
// Exchange code for token
const codeVerifier = sessionStorage.getItem('pkce_code_verifier');
const tokens = await this.exchangeCodeForToken(code, codeVerifier);
// Clean up session storage
sessionStorage.removeItem('pkce_code_verifier');
sessionStorage.removeItem('pkce_state');
return tokens;
}
// Exchange authorization code for tokens
async exchangeCodeForToken(code, codeVerifier) {
const response = await fetch(this.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
client_id: this.clientId,
code_verifier: codeVerifier,
redirect_uri: window.location.origin + window.location.pathname
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Token exchange failed: ${error.error}`);
}
const tokens = await response.json();
// Store tokens securely
this.storeTokens(tokens);
return tokens;
}
// Secure token storage
storeTokens(tokens) {
// For SPAs, store in memory and sessionStorage
this.accessToken = tokens.access_token;
this.refreshToken = tokens.refresh_token;
// Store minimal info in sessionStorage
sessionStorage.setItem('token_expires_at',
Date.now() + (tokens.expires_in * 1000));
// Set up automatic refresh
this.scheduleTokenRefresh(tokens.expires_in);
}
// Automatic token refresh
scheduleTokenRefresh(expiresIn) {
// Refresh 5 minutes before expiration
const refreshTime = (expiresIn - 300) * 1000;
setTimeout(() => {
this.refreshAccessToken();
}, refreshTime);
}
// Refresh access token
async refreshAccessToken() {
if (!this.refreshToken) {
// No refresh token, need to re-authenticate
this.authorize();
return;
}
const response = await fetch(this.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: this.refreshToken,
client_id: this.clientId
})
});
if (!response.ok) {
// Refresh failed, re-authenticate
this.authorize();
return;
}
const tokens = await response.json();
this.storeTokens(tokens);
}
// Make authenticated API request
async apiRequest(url, options = {}) {
// Check token expiration
const expiresAt = sessionStorage.getItem('token_expires_at');
if (!expiresAt || Date.now() > parseInt(expiresAt)) {
await this.refreshAccessToken();
}
// Add authorization header
const headers = {
...options.headers,
'Authorization': `Bearer ${this.accessToken}`
};
const response = await fetch(url, { ...options, headers });
if (response.status === 401) {
// Token might be invalid, try refresh
await this.refreshAccessToken();
// Retry request
return fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${this.accessToken}`
}
});
}
return response;
}
}
// Usage example
const oauth = new PKCEClient(
'your-client-id',
'https://auth.example.com/oauth/authorize',
'https://auth.example.com/oauth/token'
);
// Start login
document.getElementById('login-button').addEventListener('click', () => {
oauth.authorize(['profile', 'api:read'], window.location.origin + '/callback');
});
// Handle callback
if (window.location.pathname === '/callback') {
oauth.handleCallback()
.then(tokens => {
// Redirect to app
window.location.href = '/app';
})
.catch(error => {
console.error('Authentication failed:', error);
});
}