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);
        });
}