🔐PKCE Implementation Guide
This comprehensive guide covers implementing PKCE (Proof Key for Code Exchange) for Single Page Applications (SPAs) and native mobile applications using Oten IDP.
Table of Contents
Overview
PKCE (Proof Key for Code Exchange) is a security extension to OAuth 2.0 that prevents authorization code interception attacks. It's required for public clients that cannot securely store client secrets.
What is PKCE?
PKCE works by generating a cryptographically random string called a "code verifier" and its derived "code challenge". The code challenge is sent with the authorization request, and the code verifier is sent with the token request to prove that the same client that initiated the flow is completing it.
Key Benefits
Prevents authorization code interception attacks
No client secret required for public clients
Works with custom URL schemes in mobile apps
Compatible with all OAuth 2.0 flows
Recommended by OAuth 2.1 for all clients
When to Use PKCE
✅ Always Use PKCE For:
Single Page Applications (SPAs)
React, Vue, Angular applications
Vanilla JavaScript applications
Progressive Web Apps (PWAs)
Native Mobile Applications
iOS applications (Swift, Objective-C)
Android applications (Kotlin, Java)
Cross-platform apps (React Native, Flutter, Xamarin)
Desktop Applications
Electron applications
Native desktop apps with embedded browsers
⚠️ Consider PKCE For:
Server-side applications (additional security layer)
Hybrid applications with both server and client components
PKCE Flow Diagrams
SPA PKCE Flow

Native App PKCE Flow

API Reference
PKCE Parameters
Authorization Request Parameters
code_challenge
Yes
Base64URL-encoded SHA256 hash of code_verifier
"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
code_challenge_method
Yes
Hash method used for code_challenge
"S256" (only supported method)
Token Request Parameters
code_verifier
Yes
Original random string used to generate code_challenge
"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
Code Verifier Requirements
Length: 43-128 characters
Characters:
[A-Z],[a-z],[0-9],-,.,_,~Entropy: Minimum 256 bits of entropy
Generation: Use cryptographically secure random number generator
Code Challenge Generation
code_challenge = BASE64URL(SHA256(code_verifier))Where:
SHA256()is the SHA256 hash functionBASE64URL()is base64url encoding (RFC 4648 Section 5)
Endpoints
Authorization Endpoint
GET https://account.oten.com/v1/oauth/authorizeParameters (Direct in URL for Public Clients):
Standard OAuth 2.0 parameters
PKCE parameters (
code_challenge,code_challenge_method)
Token Endpoint
POST https://account.oten.com/v1/oauth/tokenRequest Body (application/x-www-form-urlencoded):
grant_type=authorization_code
&code={authorization_code}
&redirect_uri={redirect_uri}
&client_id={client_id}
&code_verifier={code_verifier}Note: No client_secret required for public clients using PKCE.
Discovery Configuration
Oten IDP supports OpenID Connect Discovery. The configuration is available at:
https://account.oten.com/.well-known/openid-configurationKey PKCE-related fields:
{
"code_challenge_methods_supported": ["S256"],
"authorization_endpoint": "https://account.oten.com/v1/oauth/authorize",
"token_endpoint": "https://account.oten.com/v1/oauth/token"
}SPA Implementation
Vanilla JavaScript Implementation
Complete PKCE Helper Class
class OtenPKCE {
constructor(clientId, redirectUri) {
this.clientId = clientId;
this.redirectUri = redirectUri;
this.baseURL = 'https://account.oten.com';
}
// Generate cryptographically secure PKCE parameters
async generatePKCE() {
// Generate code verifier (43-128 characters)
const codeVerifier = this.generateRandomString(128);
// Generate code challenge using SHA256
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const digest = await window.crypto.subtle.digest('SHA-256', data);
const codeChallenge = this.base64URLEncode(digest);
return {
codeVerifier,
codeChallenge,
codeChallengeMethod: 'S256'
};
}
// Generate cryptographically secure random string
generateRandomString(length) {
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
const randomValues = new Uint8Array(length);
window.crypto.getRandomValues(randomValues);
return Array.from(randomValues, byte => charset[byte % charset.length]).join('');
}
// Base64URL encode (RFC 4648 Section 5)
base64URLEncode(buffer) {
const base64 = btoa(String.fromCharCode(...new Uint8Array(buffer)));
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
// Generate secure state parameter
generateState() {
return this.generateRandomString(32);
}
// Initiate login flow
async login(options = {}) {
try {
// Generate PKCE parameters
const pkce = await this.generatePKCE();
const state = this.generateState();
// Store parameters in session storage
sessionStorage.setItem('code_verifier', pkce.codeVerifier);
sessionStorage.setItem('oauth_state', state);
// Create authorization URL directly (no JAR for public clients)
const authURL = new URL('https://account.oten.com/v1/oauth/authorize');
authURL.searchParams.set('client_id', this.clientId);
authURL.searchParams.set('response_type', 'code');
authURL.searchParams.set('redirect_uri', this.redirectUri);
authURL.searchParams.set('scope', options.scope || 'openid profile email');
authURL.searchParams.set('state', state);
authURL.searchParams.set('code_challenge', pkce.codeChallenge);
authURL.searchParams.set('code_challenge_method', pkce.codeChallengeMethod);
// Optional parameters
if (options.prompt) authURL.searchParams.set('prompt', options.prompt);
if (options.uiLocales) authURL.searchParams.set('ui_locales', options.uiLocales);
if (options.loginHint) authURL.searchParams.set('login_hint', options.loginHint);
if (options.maxAge) authURL.searchParams.set('max_age', options.maxAge.toString());
window.location.href = authURL.toString();
} catch (error) {
console.error('Login failed:', error);
throw error;
}
}
// Handle callback from authorization server
async handleCallback() {
try {
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
const error = urlParams.get('error');
// Check for OAuth errors
if (error) {
const errorDescription = urlParams.get('error_description') || error;
throw new Error(`OAuth error: ${errorDescription}`);
}
// Validate required parameters
if (!code) {
throw new Error('Authorization code not found in callback');
}
if (!state) {
throw new Error('State parameter not found in callback');
}
// Validate state parameter (CSRF protection)
const storedState = sessionStorage.getItem('oauth_state');
if (!storedState || state !== storedState) {
throw new Error('Invalid state parameter - possible CSRF attack');
}
// Get code verifier
const codeVerifier = sessionStorage.getItem('code_verifier');
if (!codeVerifier) {
throw new Error('Code verifier not found in session storage');
}
// Clean up session storage
sessionStorage.removeItem('oauth_state');
sessionStorage.removeItem('code_verifier');
// Exchange code for tokens
const tokens = await this.exchangeCodeForTokens(code, codeVerifier);
// Store tokens securely
this.storeTokens(tokens);
return tokens;
} catch (error) {
console.error('Callback handling failed:', error);
throw error;
}
}
// Exchange authorization code for tokens
async exchangeCodeForTokens(code, codeVerifier) {
const tokenResponse = await fetch(`${this.baseURL}/v1/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: this.redirectUri,
client_id: this.clientId,
code_verifier: codeVerifier
})
});
if (!tokenResponse.ok) {
const errorData = await tokenResponse.json().catch(() => ({}));
throw new Error(`Token exchange failed: ${errorData.error || tokenResponse.status}`);
}
return await tokenResponse.json();
}
// Store tokens securely
storeTokens(tokens) {
// Store in localStorage (consider more secure storage for production)
localStorage.setItem('access_token', tokens.access_token);
localStorage.setItem('refresh_token', tokens.refresh_token);
localStorage.setItem('id_token', tokens.id_token);
// Parse and store user info from ID token
const userInfo = this.parseJWT(tokens.id_token);
localStorage.setItem('user_info', JSON.stringify(userInfo));
}
// Parse JWT token (client-side parsing for display only)
parseJWT(token) {
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
return JSON.parse(jsonPayload);
} catch (error) {
console.error('JWT parsing failed:', error);
return null;
}
}
// Check if user is authenticated
isAuthenticated() {
const accessToken = localStorage.getItem('access_token');
if (!accessToken) return false;
// Check token expiration
const userInfo = JSON.parse(localStorage.getItem('user_info') || '{}');
if (userInfo.exp && Date.now() >= userInfo.exp * 1000) {
this.logout();
return false;
}
return true;
}
// Logout user
logout() {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('id_token');
localStorage.removeItem('user_info');
}
}Usage Example
// Initialize PKCE client
const authClient = new OtenPKCE(
'your-client-id',
'https://yourapp.com/callback'
);
// Login button click handler
document.getElementById('login-btn').addEventListener('click', async () => {
try {
await authClient.login({
scope: 'openid profile email',
prompt: 'consent',
ui_locales: 'en-US'
});
} catch (error) {
console.error('Login failed:', error);
alert('Login failed: ' + error.message);
}
});
// Handle callback on callback page
if (window.location.pathname === '/callback') {
authClient.handleCallback()
.then(tokens => {
console.log('Login successful:', tokens);
window.location.href = '/dashboard';
})
.catch(error => {
console.error('Callback failed:', error);
alert('Login failed: ' + error.message);
window.location.href = '/';
});
}
// Check authentication status
if (authClient.isAuthenticated()) {
console.log('User is authenticated');
// Show authenticated UI
} else {
console.log('User is not authenticated');
// Show login UI
}React Implementation
React Hook for PKCE Authentication
// hooks/useOtenAuth.ts
import { useState, useEffect, useCallback } from 'react';
interface AuthTokens {
access_token: string;
refresh_token: string;
id_token: string;
}
interface UserInfo {
sub: string;
email: string;
name: string;
picture?: string;
exp: number;
}
interface AuthConfig {
clientId: string;
redirectUri: string;
baseURL?: string; // Optional, defaults to Oten production
}
interface LoginOptions {
scope?: string;
prompt?: string;
uiLocales?: string;
loginHint?: string;
maxAge?: number;
}
export const useOtenAuth = (config: AuthConfig) => {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [user, setUser] = useState<UserInfo | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const baseURL = config.baseURL || 'https://account.oten.com';
// Generate PKCE parameters
const generatePKCE = useCallback(async () => {
const generateRandomString = (length: number): string => {
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
const randomValues = new Uint8Array(length);
window.crypto.getRandomValues(randomValues);
return Array.from(randomValues, byte => charset[byte % charset.length]).join('');
};
const base64URLEncode = (buffer: ArrayBuffer): string => {
const base64 = btoa(String.fromCharCode(...new Uint8Array(buffer)));
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
};
const codeVerifier = generateRandomString(128);
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const digest = await window.crypto.subtle.digest('SHA-256', data);
const codeChallenge = base64URLEncode(digest);
return {
codeVerifier,
codeChallenge,
codeChallengeMethod: 'S256'
};
}, []);
// Generate secure state parameter
const generateState = useCallback((): string => {
const randomValues = new Uint8Array(32);
window.crypto.getRandomValues(randomValues);
return Array.from(randomValues, byte => byte.toString(16).padStart(2, '0')).join('');
}, []);
// Parse JWT token
const parseJWT = useCallback((token: string): UserInfo | null => {
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
);
return JSON.parse(jsonPayload);
} catch (error) {
console.error('JWT parsing failed:', error);
return null;
}
}, []);
// Check authentication status
const checkAuth = useCallback(() => {
const accessToken = localStorage.getItem('access_token');
const userInfoStr = localStorage.getItem('user_info');
if (!accessToken || !userInfoStr) {
setIsAuthenticated(false);
setUser(null);
return;
}
try {
const userInfo: UserInfo = JSON.parse(userInfoStr);
// Check token expiration
if (userInfo.exp && Date.now() >= userInfo.exp * 1000) {
logout();
return;
}
setIsAuthenticated(true);
setUser(userInfo);
} catch (error) {
console.error('Auth check failed:', error);
logout();
}
}, []);
// Login function
const login = useCallback(async (options: LoginOptions = {}) => {
try {
setError(null);
setIsLoading(true);
// Generate PKCE parameters
const pkce = await generatePKCE();
const state = generateState();
// Store parameters in session storage
sessionStorage.setItem('code_verifier', pkce.codeVerifier);
sessionStorage.setItem('oauth_state', state);
// Create authorization URL directly (no JAR for public clients)
const authURL = new URL('https://account.oten.com/v1/oauth/authorize');
authURL.searchParams.set('client_id', config.clientId);
authURL.searchParams.set('response_type', 'code');
authURL.searchParams.set('redirect_uri', config.redirectUri);
authURL.searchParams.set('scope', options.scope || 'openid profile email');
authURL.searchParams.set('state', state);
authURL.searchParams.set('code_challenge', pkce.codeChallenge);
authURL.searchParams.set('code_challenge_method', pkce.codeChallengeMethod);
// Optional parameters
if (options.prompt) authURL.searchParams.set('prompt', options.prompt);
if (options.uiLocales) authURL.searchParams.set('ui_locales', options.uiLocales);
if (options.loginHint) authURL.searchParams.set('login_hint', options.loginHint);
if (options.maxAge) authURL.searchParams.set('max_age', options.maxAge.toString());
window.location.href = authURL.toString();
} catch (error) {
console.error('Login failed:', error);
setError(error instanceof Error ? error.message : 'Login failed');
setIsLoading(false);
}
}, [config, generatePKCE, generateState]);
// Handle callback
const handleCallback = useCallback(async (): Promise<AuthTokens | null> => {
try {
setError(null);
setIsLoading(true);
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
const error = urlParams.get('error');
if (error) {
const errorDescription = urlParams.get('error_description') || error;
throw new Error(`OAuth error: ${errorDescription}`);
}
if (!code || !state) {
throw new Error('Missing authorization code or state parameter');
}
// Validate state parameter
const storedState = sessionStorage.getItem('oauth_state');
if (!storedState || state !== storedState) {
throw new Error('Invalid state parameter - possible CSRF attack');
}
// Get code verifier
const codeVerifier = sessionStorage.getItem('code_verifier');
if (!codeVerifier) {
throw new Error('Code verifier not found in session storage');
}
// Clean up session storage
sessionStorage.removeItem('oauth_state');
sessionStorage.removeItem('code_verifier');
// Exchange code for tokens
const tokenResponse = await fetch(`${config.baseURL}/v1/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: config.redirectUri,
client_id: config.clientId,
code_verifier: codeVerifier
})
});
if (!tokenResponse.ok) {
const errorData = await tokenResponse.json().catch(() => ({}));
throw new Error(`Token exchange failed: ${errorData.error || tokenResponse.status}`);
}
const tokens: AuthTokens = await tokenResponse.json();
// Store tokens
localStorage.setItem('access_token', tokens.access_token);
localStorage.setItem('refresh_token', tokens.refresh_token);
localStorage.setItem('id_token', tokens.id_token);
// Parse and store user info
const userInfo = parseJWT(tokens.id_token);
if (userInfo) {
localStorage.setItem('user_info', JSON.stringify(userInfo));
setUser(userInfo);
setIsAuthenticated(true);
}
setIsLoading(false);
return tokens;
} catch (error) {
console.error('Callback handling failed:', error);
setError(error instanceof Error ? error.message : 'Callback handling failed');
setIsLoading(false);
return null;
}
}, [config, parseJWT]);
// Logout function
const logout = useCallback(() => {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('id_token');
localStorage.removeItem('user_info');
sessionStorage.removeItem('oauth_state');
sessionStorage.removeItem('code_verifier');
setIsAuthenticated(false);
setUser(null);
setError(null);
}, []);
// Initialize auth state on mount
useEffect(() => {
checkAuth();
setIsLoading(false);
}, [checkAuth]);
return {
isAuthenticated,
user,
isLoading,
error,
login,
logout,
handleCallback
};
};React Component Examples
// components/LoginButton.tsx
import React from 'react';
import { useOtenAuth } from '../hooks/useOtenAuth';
const LoginButton: React.FC = () => {
const { login, isLoading, error } = useOtenAuth({
clientId: process.env.REACT_APP_CLIENT_ID!,
redirectUri: `${window.location.origin}/callback`
});
const handleLogin = () => {
login({
scope: 'openid profile email',
prompt: 'consent',
uiLocales: 'en-US'
});
};
return (
<div>
<button
onClick={handleLogin}
disabled={isLoading}
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
{isLoading ? 'Logging in...' : 'Login with Oten'}
</button>
{error && (
<div className="text-red-500 mt-2">
Error: {error}
</div>
)}
</div>
);
};
export default LoginButton;// components/CallbackHandler.tsx
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useOtenAuth } from '../hooks/useOtenAuth';
const CallbackHandler: React.FC = () => {
const navigate = useNavigate();
const { handleCallback, isLoading, error } = useOtenAuth({
clientId: process.env.REACT_APP_CLIENT_ID!,
redirectUri: `${window.location.origin}/callback`
});
useEffect(() => {
const processCallback = async () => {
try {
const tokens = await handleCallback();
if (tokens) {
// Redirect to dashboard or home page
navigate('/dashboard');
} else {
navigate('/login?error=callback_failed');
}
} catch (error) {
console.error('Callback processing failed:', error);
navigate('/login?error=callback_failed');
}
};
processCallback();
}, [handleCallback, navigate]);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-500"></div>
<p className="mt-4 text-gray-600">Processing login...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="text-red-500 text-xl mb-4">Login Failed</div>
<p className="text-gray-600 mb-4">{error}</p>
<button
onClick={() => navigate('/login')}
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
Try Again
</button>
</div>
</div>
);
}
return null;
};
export default CallbackHandler;// components/UserProfile.tsx
import React from 'react';
import { useOtenAuth } from '../hooks/useOtenAuth';
const UserProfile: React.FC = () => {
const { isAuthenticated, user, logout } = useOtenAuth({
clientId: process.env.REACT_APP_CLIENT_ID!,
redirectUri: `${window.location.origin}/callback`
});
if (!isAuthenticated || !user) {
return null;
}
return (
<div className="bg-white shadow rounded-lg p-6">
<div className="flex items-center space-x-4">
{user.picture && (
<img
src={user.picture}
alt="Profile"
className="w-16 h-16 rounded-full"
/>
)}
<div>
<h2 className="text-xl font-semibold text-gray-900">{user.name}</h2>
<p className="text-gray-600">{user.email}</p>
<p className="text-sm text-gray-500">ID: {user.sub}</p>
</div>
</div>
<div className="mt-4">
<button
onClick={logout}
className="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded"
>
Logout
</button>
</div>
</div>
);
};
export default UserProfile;Vue.js Implementation
Vue Composition API
// composables/useOtenAuth.ts
import { ref, computed, onMounted } from 'vue';
interface AuthTokens {
access_token: string;
refresh_token: string;
id_token: string;
}
interface UserInfo {
sub: string;
email: string;
name: string;
picture?: string;
exp: number;
}
interface AuthConfig {
clientId: string;
redirectUri: string;
baseURL?: string; // Optional, defaults to Oten production
}
interface LoginOptions {
scope?: string;
prompt?: string;
uiLocales?: string;
loginHint?: string;
maxAge?: number;
}
export const useOtenAuth = (config: AuthConfig) => {
const isAuthenticated = ref<boolean>(false);
const user = ref<UserInfo | null>(null);
const isLoading = ref<boolean>(true);
const error = ref<string | null>(null);
const baseURL = config.baseURL || 'https://account.oten.com';
// Generate PKCE parameters
const generatePKCE = async () => {
const generateRandomString = (length: number): string => {
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
const randomValues = new Uint8Array(length);
window.crypto.getRandomValues(randomValues);
return Array.from(randomValues, byte => charset[byte % charset.length]).join('');
};
const base64URLEncode = (buffer: ArrayBuffer): string => {
const base64 = btoa(String.fromCharCode(...new Uint8Array(buffer)));
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
};
const codeVerifier = generateRandomString(128);
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const digest = await window.crypto.subtle.digest('SHA-256', data);
const codeChallenge = base64URLEncode(digest);
return {
codeVerifier,
codeChallenge,
codeChallengeMethod: 'S256'
};
};
// Login function
const login = async (options: LoginOptions = {}) => {
try {
error.value = null;
isLoading.value = true;
const pkce = await generatePKCE();
const state = crypto.randomUUID();
sessionStorage.setItem('code_verifier', pkce.codeVerifier);
sessionStorage.setItem('oauth_state', state);
// Create authorization URL directly (no JAR for public clients)
const authURL = new URL('https://account.oten.com/v1/oauth/authorize');
authURL.searchParams.set('client_id', config.clientId);
authURL.searchParams.set('response_type', 'code');
authURL.searchParams.set('redirect_uri', config.redirectUri);
authURL.searchParams.set('scope', options.scope || 'openid profile email');
authURL.searchParams.set('state', state);
authURL.searchParams.set('code_challenge', pkce.codeChallenge);
authURL.searchParams.set('code_challenge_method', pkce.codeChallengeMethod);
// Optional parameters
if (options.prompt) authURL.searchParams.set('prompt', options.prompt);
if (options.uiLocales) authURL.searchParams.set('ui_locales', options.uiLocales);
if (options.loginHint) authURL.searchParams.set('login_hint', options.loginHint);
if (options.maxAge) authURL.searchParams.set('max_age', options.maxAge.toString());
window.location.href = authURL.toString();
} catch (err) {
error.value = err instanceof Error ? err.message : 'Login failed';
isLoading.value = false;
}
};
// Handle callback
const handleCallback = async (): Promise<AuthTokens | null> => {
try {
error.value = null;
isLoading.value = true;
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
const oauthError = urlParams.get('error');
if (oauthError) {
throw new Error(`OAuth error: ${urlParams.get('error_description') || oauthError}`);
}
if (!code || !state) {
throw new Error('Missing authorization code or state parameter');
}
const storedState = sessionStorage.getItem('oauth_state');
if (!storedState || state !== storedState) {
throw new Error('Invalid state parameter');
}
const codeVerifier = sessionStorage.getItem('code_verifier');
if (!codeVerifier) {
throw new Error('Code verifier not found');
}
sessionStorage.removeItem('oauth_state');
sessionStorage.removeItem('code_verifier');
const tokenResponse = await fetch(`${config.baseURL}/v1/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: config.redirectUri,
client_id: config.clientId,
code_verifier: codeVerifier
})
});
if (!tokenResponse.ok) {
throw new Error('Token exchange failed');
}
const tokens: AuthTokens = await tokenResponse.json();
// Store tokens and update state
localStorage.setItem('access_token', tokens.access_token);
localStorage.setItem('refresh_token', tokens.refresh_token);
localStorage.setItem('id_token', tokens.id_token);
checkAuth();
return tokens;
} catch (err) {
error.value = err instanceof Error ? err.message : 'Callback failed';
return null;
} finally {
isLoading.value = false;
}
};
// Check authentication status
const checkAuth = () => {
const accessToken = localStorage.getItem('access_token');
const idToken = localStorage.getItem('id_token');
if (!accessToken || !idToken) {
isAuthenticated.value = false;
user.value = null;
return;
}
try {
// Parse ID token
const base64Url = idToken.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
);
const userInfo: UserInfo = JSON.parse(jsonPayload);
if (userInfo.exp && Date.now() >= userInfo.exp * 1000) {
logout();
return;
}
isAuthenticated.value = true;
user.value = userInfo;
} catch (err) {
logout();
}
};
// Logout function
const logout = () => {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('id_token');
sessionStorage.removeItem('oauth_state');
sessionStorage.removeItem('code_verifier');
isAuthenticated.value = false;
user.value = null;
error.value = null;
};
// Initialize on mount
onMounted(() => {
checkAuth();
isLoading.value = false;
});
return {
isAuthenticated: computed(() => isAuthenticated.value),
user: computed(() => user.value),
isLoading: computed(() => isLoading.value),
error: computed(() => error.value),
login,
logout,
handleCallback
};
};Native App Implementation
iOS Implementation (Swift)
PKCE Helper Class
// OtenAuth.swift
import Foundation
import CryptoKit
import AuthenticationServices
class OtenAuth: NSObject, ObservableObject {
@Published var isAuthenticated = false
@Published var user: UserInfo?
@Published var isLoading = false
@Published var error: String?
private let clientId: String
private let redirectUri: String
private let baseURL = "https://account.oten.com"
private var codeVerifier: String?
private var state: String?
struct UserInfo: Codable {
let sub: String
let email: String
let name: String
let picture: String?
let exp: TimeInterval
}
struct AuthTokens: Codable {
let accessToken: String
let refreshToken: String
let idToken: String
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case refreshToken = "refresh_token"
case idToken = "id_token"
}
}
init(clientId: String, redirectUri: String) {
self.clientId = clientId
self.redirectUri = redirectUri
super.init()
checkAuthenticationStatus()
}
// MARK: - PKCE Generation
private func generateCodeVerifier() -> String {
let charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
return String((0..<128).compactMap { _ in charset.randomElement() })
}
private func generateCodeChallenge(from verifier: String) -> String {
let data = Data(verifier.utf8)
let hash = SHA256.hash(data: data)
return Data(hash).base64URLEncodedString()
}
private func generateState() -> String {
return UUID().uuidString.replacingOccurrences(of: "-", with: "")
}
// MARK: - Authentication Flow
func login(scope: String = "openid profile email",
prompt: String? = nil,
uiLocales: String? = nil) {
DispatchQueue.main.async {
self.isLoading = true
self.error = nil
}
// Generate PKCE parameters
let codeVerifier = generateCodeVerifier()
let codeChallenge = generateCodeChallenge(from: codeVerifier)
let state = generateState()
// Store parameters securely
self.codeVerifier = codeVerifier
self.state = state
// Store in Keychain for security
storeInKeychain(key: "code_verifier", value: codeVerifier)
storeInKeychain(key: "oauth_state", value: state)
// Create authorization URL directly (no JAR for public clients)
var urlComponents = URLComponents(string: "\(baseURL)/v1/oauth/authorize")!
urlComponents.queryItems = [
URLQueryItem(name: "client_id", value: clientId),
URLQueryItem(name: "response_type", value: "code"),
URLQueryItem(name: "redirect_uri", value: redirectUri),
URLQueryItem(name: "scope", value: scope),
URLQueryItem(name: "state", value: state),
URLQueryItem(name: "code_challenge", value: codeChallenge),
URLQueryItem(name: "code_challenge_method", value: "S256")
]
// Add optional parameters
if let prompt = prompt {
urlComponents.queryItems?.append(URLQueryItem(name: "prompt", value: prompt))
}
if let uiLocales = uiLocales {
urlComponents.queryItems?.append(URLQueryItem(name: "ui_locales", value: uiLocales))
}
guard let authURL = urlComponents.url else {
DispatchQueue.main.async {
self.error = "Failed to create authorization URL"
self.isLoading = false
}
return
}
// Present authentication session
presentAuthenticationSession(url: authURL)
}
private func presentAuthenticationSession(url: URL) {
let authSession = ASWebAuthenticationSession(
url: authURL,
callbackURLScheme: URL(string: redirectUri)?.scheme
) { [weak self] callbackURL, error in
DispatchQueue.main.async {
self?.handleAuthenticationCallback(url: callbackURL, error: error)
}
}
authSession.presentationContextProvider = self
authSession.prefersEphemeralWebBrowserSession = false
authSession.start()
}
private func handleAuthenticationCallback(url: URL?, error: Error?) {
isLoading = false
if let error = error {
self.error = error.localizedDescription
return
}
guard let url = url,
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let queryItems = components.queryItems else {
self.error = "Invalid callback URL"
return
}
// Extract parameters
let code = queryItems.first { $0.name == "code" }?.value
let state = queryItems.first { $0.name == "state" }?.value
let oauthError = queryItems.first { $0.name == "error" }?.value
if let oauthError = oauthError {
let errorDescription = queryItems.first { $0.name == "error_description" }?.value
self.error = "OAuth error: \(errorDescription ?? oauthError)"
return
}
guard let code = code, let state = state else {
self.error = "Missing authorization code or state"
return
}
// Validate state
guard let storedState = retrieveFromKeychain(key: "oauth_state"),
state == storedState else {
self.error = "Invalid state parameter - possible CSRF attack"
return
}
// Get code verifier
guard let codeVerifier = retrieveFromKeychain(key: "code_verifier") else {
self.error = "Code verifier not found"
return
}
// Clean up stored values
removeFromKeychain(key: "oauth_state")
removeFromKeychain(key: "code_verifier")
// Exchange code for tokens
exchangeCodeForTokens(code: code, codeVerifier: codeVerifier)
}
private func exchangeCodeForTokens(code: String, codeVerifier: String) {
guard let url = URL(string: "\(baseURL)/v1/oauth/token") else {
self.error = "Invalid token URL"
return
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
let parameters = [
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirectUri,
"client_id": clientId,
"code_verifier": codeVerifier
]
let body = parameters.map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")" }
.joined(separator: "&")
request.httpBody = body.data(using: .utf8)
URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
DispatchQueue.main.async {
if let error = error {
self?.error = error.localizedDescription
return
}
guard let data = data else {
self?.error = "No data received"
return
}
do {
let tokens = try JSONDecoder().decode(AuthTokens.self, from: data)
self?.storeTokens(tokens)
self?.parseUserInfo(from: tokens.idToken)
} catch {
self?.error = "Failed to parse tokens: \(error.localizedDescription)"
}
}
}.resume()
}
// MARK: - Token Management
private func storeTokens(_ tokens: AuthTokens) {
storeInKeychain(key: "access_token", value: tokens.accessToken)
storeInKeychain(key: "refresh_token", value: tokens.refreshToken)
storeInKeychain(key: "id_token", value: tokens.idToken)
}
private func parseUserInfo(from idToken: String) {
let parts = idToken.split(separator: ".")
guard parts.count == 3,
let payloadData = Data(base64URLEncoded: String(parts[1])) else {
self.error = "Invalid ID token format"
return
}
do {
let userInfo = try JSONDecoder().decode(UserInfo.self, from: payloadData)
// Check token expiration
if userInfo.exp < Date().timeIntervalSince1970 {
logout()
return
}
self.user = userInfo
self.isAuthenticated = true
} catch {
self.error = "Failed to parse user info: \(error.localizedDescription)"
}
}
func checkAuthenticationStatus() {
guard let idToken = retrieveFromKeychain(key: "id_token") else {
isAuthenticated = false
user = nil
return
}
parseUserInfo(from: idToken)
}
func logout() {
removeFromKeychain(key: "access_token")
removeFromKeychain(key: "refresh_token")
removeFromKeychain(key: "id_token")
removeFromKeychain(key: "oauth_state")
removeFromKeychain(key: "code_verifier")
isAuthenticated = false
user = nil
error = nil
}
// MARK: - Keychain Helpers
private func storeInKeychain(key: String, value: String) {
let data = value.data(using: .utf8)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
}
private func retrieveFromKeychain(key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let string = String(data: data, encoding: .utf8) else {
return nil
}
return string
}
private func removeFromKeychain(key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key
]
SecItemDelete(query as CFDictionary)
}
}
// MARK: - ASWebAuthenticationPresentationContextProviding
extension OtenAuth: ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
return ASPresentationAnchor()
}
}
// MARK: - Data Extension for Base64URL
extension Data {
func base64URLEncodedString() -> String {
return base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
init?(base64URLEncoded string: String) {
var base64 = string
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
// Add padding if needed
let remainder = base64.count % 4
if remainder > 0 {
base64 += String(repeating: "=", count: 4 - remainder)
}
self.init(base64Encoded: base64)
}
}SwiftUI Usage Example
// ContentView.swift
import SwiftUI
struct ContentView: View {
@StateObject private var auth = OtenAuth(
clientId: "your-client-id",
redirectUri: "myapp://callback"
)
var body: some View {
NavigationView {
VStack(spacing: 20) {
if auth.isAuthenticated, let user = auth.user {
// Authenticated view
VStack(spacing: 16) {
AsyncImage(url: URL(string: user.picture ?? "")) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Circle()
.fill(Color.gray.opacity(0.3))
}
.frame(width: 80, height: 80)
.clipShape(Circle())
Text(user.name)
.font(.title2)
.fontWeight(.semibold)
Text(user.email)
.font(.body)
.foregroundColor(.secondary)
Button("Logout") {
auth.logout()
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
} else {
// Login view
VStack(spacing: 16) {
Image(systemName: "person.circle.fill")
.font(.system(size: 80))
.foregroundColor(.blue)
Text("Welcome")
.font(.title)
.fontWeight(.bold)
Text("Sign in with Oten to continue")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Button("Sign In") {
auth.login()
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(auth.isLoading)
}
}
if auth.isLoading {
ProgressView("Authenticating...")
.progressViewStyle(CircularProgressViewStyle())
}
if let error = auth.error {
Text("Error: \(error)")
.foregroundColor(.red)
.font(.caption)
.multilineTextAlignment(.center)
}
}
.padding()
.navigationTitle("Oten Auth")
}
}
}Android Implementation (Kotlin)
PKCE Authentication Manager
// OtenAuth.kt
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import kotlinx.coroutines.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.*
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
@Serializable
data class UserInfo(
val sub: String,
val email: String,
val name: String,
val picture: String? = null,
val exp: Long
)
@Serializable
data class AuthTokens(
val access_token: String,
val refresh_token: String,
val id_token: String
)
class OtenAuth(
private val context: Context,
private val clientId: String,
private val redirectUri: String
) {
private val baseUrl = "https://account.oten.com"
private val httpClient = OkHttpClient()
private val json = Json { ignoreUnknownKeys = true }
private val _isAuthenticated = MutableLiveData<Boolean>()
val isAuthenticated: LiveData<Boolean> = _isAuthenticated
private val _user = MutableLiveData<UserInfo?>()
val user: LiveData<UserInfo?> = _user
private val _isLoading = MutableLiveData<Boolean>()
val isLoading: LiveData<Boolean> = _isLoading
private val _error = MutableLiveData<String?>()
val error: LiveData<String?> = _error
private val encryptedPrefs by lazy {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"oten_auth",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
init {
checkAuthenticationStatus()
}
// MARK: - PKCE Generation
private fun generateCodeVerifier(): String {
val charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
val random = SecureRandom()
return (1..128)
.map { charset[random.nextInt(charset.length)] }
.joinToString("")
}
private fun generateCodeChallenge(verifier: String): String {
val bytes = verifier.toByteArray(Charsets.UTF_8)
val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest)
}
private fun generateState(): String {
return UUID.randomUUID().toString().replace("-", "")
}
// MARK: - Authentication Flow
fun login(
scope: String = "openid profile email",
prompt: String? = null,
uiLocales: String? = null
) {
CoroutineScope(Dispatchers.Main).launch {
try {
_isLoading.value = true
_error.value = null
// Generate PKCE parameters
val codeVerifier = generateCodeVerifier()
val codeChallenge = generateCodeChallenge(codeVerifier)
val state = generateState()
// Store parameters securely
encryptedPrefs.edit()
.putString("code_verifier", codeVerifier)
.putString("oauth_state", state)
.apply()
// Create authorization URL directly (no JAR for public clients)
val authUri = Uri.parse("$baseUrl/v1/oauth/authorize")
.buildUpon()
.appendQueryParameter("client_id", clientId)
.appendQueryParameter("response_type", "code")
.appendQueryParameter("redirect_uri", redirectUri)
.appendQueryParameter("scope", scope)
.appendQueryParameter("state", state)
.appendQueryParameter("code_challenge", codeChallenge)
.appendQueryParameter("code_challenge_method", "S256")
// Add optional parameters
prompt?.let { authUri.appendQueryParameter("prompt", it) }
uiLocales?.let { authUri.appendQueryParameter("ui_locales", it) }
presentAuthenticationSession(authUri.build())
} catch (e: Exception) {
_error.value = e.message
_isLoading.value = false
}
}
}
private fun presentAuthenticationSession(authUri: Uri) {
val customTabsIntent = CustomTabsIntent.Builder()
.setShowTitle(true)
.build()
customTabsIntent.launchUrl(context, authUri)
_isLoading.value = false
}
fun handleCallback(intent: Intent) {
CoroutineScope(Dispatchers.Main).launch {
try {
_isLoading.value = true
_error.value = null
val uri = intent.data ?: throw Exception("No callback URI found")
val code = uri.getQueryParameter("code")
val state = uri.getQueryParameter("state")
val error = uri.getQueryParameter("error")
if (error != null) {
val errorDescription = uri.getQueryParameter("error_description") ?: error
throw Exception("OAuth error: $errorDescription")
}
if (code == null || state == null) {
throw Exception("Missing authorization code or state parameter")
}
// Validate state
val storedState = encryptedPrefs.getString("oauth_state", null)
if (storedState == null || state != storedState) {
throw Exception("Invalid state parameter - possible CSRF attack")
}
// Get code verifier
val codeVerifier = encryptedPrefs.getString("code_verifier", null)
?: throw Exception("Code verifier not found")
// Clean up stored values
encryptedPrefs.edit()
.remove("oauth_state")
.remove("code_verifier")
.apply()
// Exchange code for tokens
val tokens = exchangeCodeForTokens(code, codeVerifier)
storeTokens(tokens)
parseUserInfo(tokens.id_token)
} catch (e: Exception) {
_error.value = e.message
_isLoading.value = false
}
}
}
private suspend fun exchangeCodeForTokens(code: String, codeVerifier: String): AuthTokens =
suspendCoroutine { continuation ->
val formBody = FormBody.Builder()
.add("grant_type", "authorization_code")
.add("code", code)
.add("redirect_uri", redirectUri)
.add("client_id", clientId)
.add("code_verifier", codeVerifier)
.build()
val request = Request.Builder()
.url("$baseUrl/v1/oauth/token")
.post(formBody)
.build()
httpClient.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
continuation.resumeWithException(e)
}
override fun onResponse(call: Call, response: Response) {
try {
val responseBody = response.body?.string()
if (response.isSuccessful && responseBody != null) {
val tokens = json.decodeFromString<AuthTokens>(responseBody)
continuation.resume(tokens)
} else {
continuation.resumeWithException(Exception("Token exchange failed: ${response.code}"))
}
} catch (e: Exception) {
continuation.resumeWithException(e)
}
}
})
}
// MARK: - Token Management
private fun storeTokens(tokens: AuthTokens) {
encryptedPrefs.edit()
.putString("access_token", tokens.access_token)
.putString("refresh_token", tokens.refresh_token)
.putString("id_token", tokens.id_token)
.apply()
}
private fun parseUserInfo(idToken: String) {
try {
val parts = idToken.split(".")
if (parts.size != 3) {
throw Exception("Invalid ID token format")
}
val payload = parts[1]
val decodedBytes = Base64.getUrlDecoder().decode(payload)
val userInfo = json.decodeFromString<UserInfo>(String(decodedBytes))
// Check token expiration
if (userInfo.exp < System.currentTimeMillis() / 1000) {
logout()
return
}
_user.value = userInfo
_isAuthenticated.value = true
_isLoading.value = false
} catch (e: Exception) {
_error.value = "Failed to parse user info: ${e.message}"
_isLoading.value = false
}
}
fun checkAuthenticationStatus() {
val idToken = encryptedPrefs.getString("id_token", null)
if (idToken != null) {
parseUserInfo(idToken)
} else {
_isAuthenticated.value = false
_user.value = null
}
}
fun logout() {
encryptedPrefs.edit()
.remove("access_token")
.remove("refresh_token")
.remove("id_token")
.remove("oauth_state")
.remove("code_verifier")
.apply()
_isAuthenticated.value = false
_user.value = null
_error.value = null
}
}Android Activity Usage Example
// MainActivity.kt
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
class MainActivity : ComponentActivity() {
private lateinit var auth: OtenAuth
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
auth = OtenAuth(
context = this,
clientId = "your-client-id",
redirectUri = "myapp://callback"
)
setContent {
OtenAuthTheme {
AuthScreen(auth = auth)
}
}
// Handle callback if this activity was launched by redirect
handleCallbackIfNeeded(intent)
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
handleCallbackIfNeeded(intent)
}
private fun handleCallbackIfNeeded(intent: Intent?) {
if (intent?.data?.scheme == "myapp") {
auth.handleCallback(intent)
}
}
}
@Composable
fun AuthScreen(auth: OtenAuth) {
val isAuthenticated by auth.isAuthenticated.observeAsState(false)
val user by auth.user.observeAsState()
val isLoading by auth.isLoading.observeAsState(false)
val error by auth.error.observeAsState()
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
if (isAuthenticated && user != null) {
// Authenticated view
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = user!!.name,
style = MaterialTheme.typography.headlineSmall
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = user!!.email,
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { auth.logout() },
modifier = Modifier.fillMaxWidth()
) {
Text("Logout")
}
}
}
} else {
// Login view
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Welcome",
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Sign in with Oten to continue",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = { auth.login() },
enabled = !isLoading,
modifier = Modifier.fillMaxWidth()
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(if (isLoading) "Signing In..." else "Sign In")
}
}
}
error?.let { errorMessage ->
Spacer(modifier = Modifier.height(16.dp))
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Text(
text = "Error: $errorMessage",
modifier = Modifier.padding(16.dp),
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
}Security Considerations
PKCE Security Best Practices
Code Verifier Generation
// ✅ Correct: Use cryptographically secure random generation
function generateSecureCodeVerifier() {
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
const randomValues = new Uint8Array(128);
window.crypto.getRandomValues(randomValues);
return Array.from(randomValues, byte => charset[byte % charset.length]).join('');
}
// ❌ Incorrect: Using Math.random() (not cryptographically secure)
function generateInsecureCodeVerifier() {
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
return Array.from({length: 128}, () => charset[Math.floor(Math.random() * charset.length)]).join('');
}Secure Storage
SPAs:
// ✅ Correct: Use sessionStorage for temporary storage
sessionStorage.setItem('code_verifier', codeVerifier);
sessionStorage.setItem('oauth_state', state);
// ❌ Incorrect: Using localStorage for sensitive data
localStorage.setItem('code_verifier', codeVerifier); // Persists across sessionsNative Apps:
// ✅ Correct: Use Keychain (iOS) or Keystore (Android)
storeInKeychain(key: "code_verifier", value: codeVerifier)
// ❌ Incorrect: Using UserDefaults (iOS) or SharedPreferences (Android)
UserDefaults.standard.set(codeVerifier, forKey: "code_verifier")State Parameter Validation
// ✅ Correct: Always validate state parameter
const storedState = sessionStorage.getItem('oauth_state');
if (!storedState || state !== storedState) {
throw new Error('Invalid state parameter - possible CSRF attack');
}
// ❌ Incorrect: Skipping state validation
// This leaves your app vulnerable to CSRF attacksToken Expiration Handling
// ✅ Correct: Check token expiration
function isTokenValid(idToken) {
try {
const payload = parseJWT(idToken);
return payload.exp > Date.now() / 1000;
} catch (error) {
return false;
}
}
// ❌ Incorrect: Not checking expiration
// This could lead to using expired tokensCommon Security Pitfalls
1. Insufficient Code Verifier Entropy
// ❌ Incorrect: Too short, predictable
const codeVerifier = Math.random().toString(36).substring(7); // Only ~43 bits entropy
// ✅ Correct: Sufficient length and entropy
const codeVerifier = generateSecureCodeVerifier(); // 128 characters, ~768 bits entropy2. Code Verifier Leakage
// ❌ Incorrect: Logging sensitive data
console.log('Code verifier:', codeVerifier);
console.log('Authorization URL:', authURL); // May contain code_challenge
// ✅ Correct: Avoid logging sensitive parameters
console.log('Starting authorization flow...');3. Improper Redirect URI Validation
// ❌ Incorrect: Not validating redirect URI
window.location.href = callbackURL; // Could be malicious
// ✅ Correct: Validate redirect URI
if (callbackURL.startsWith(expectedRedirectURI)) {
window.location.href = callbackURL;
} else {
throw new Error('Invalid redirect URI');
}4. Missing HTTPS Enforcement
// ❌ Incorrect: Allowing HTTP in production
const redirectURI = "http://myapp.com/callback";
// ✅ Correct: Always use HTTPS in production
const redirectURI = "https://myapp.com/callback";Platform-Specific Security
SPA Security
Content Security Policy (CSP): Implement strict CSP headers
Subresource Integrity (SRI): Use SRI for external scripts
HTTPS Only: Never use HTTP in production
Session Storage: Use sessionStorage, not localStorage for temporary data
Native App Security
Custom URL Schemes: Use app-specific schemes (e.g.,
com.yourcompany.yourapp://)Certificate Pinning: Pin SSL certificates for API calls
App Transport Security: Enable ATS on iOS
Network Security Config: Configure secure networking on Android
Secure Storage: Always use Keychain (iOS) or Keystore (Android)
Error Handling
Common PKCE Errors
Authorization Endpoint Errors
invalid_request
Missing or invalid PKCE parameters
Missing code_challenge or code_challenge_method
Ensure PKCE parameters are included in JAR
invalid_request
Invalid code challenge method
Using unsupported method (not S256)
Always use code_challenge_method: "S256"
invalid_client
Client authentication failed
Invalid client_id or JAR signature
Verify client_id and JAR signing
Token Endpoint Errors
invalid_grant
Code verifier validation failed
code_verifier doesn't match code_challenge
Ensure same verifier used for challenge generation
invalid_grant
Authorization code not issued with PKCE
Code was issued without PKCE but verifier provided
Always include PKCE in authorization request
invalid_request
Missing code verifier
PKCE required but code_verifier not provided
Include code_verifier in token request
Error Handling Implementation
JavaScript Error Handling
class PKCEError extends Error {
constructor(message, code, description) {
super(message);
this.name = 'PKCEError';
this.code = code;
this.description = description;
}
}
async function handleAuthorizationCallback() {
try {
const urlParams = new URLSearchParams(window.location.search);
const error = urlParams.get('error');
if (error) {
const errorDescription = urlParams.get('error_description');
const errorUri = urlParams.get('error_uri');
throw new PKCEError(
`Authorization failed: ${error}`,
error,
errorDescription
);
}
// Continue with normal flow...
} catch (error) {
if (error instanceof PKCEError) {
// Handle OAuth-specific errors
console.error('OAuth Error:', error.code, error.description);
switch (error.code) {
case 'invalid_request':
showUserMessage('Invalid request. Please try again.');
break;
case 'access_denied':
showUserMessage('Access denied. Authorization was cancelled.');
break;
case 'server_error':
showUserMessage('Server error. Please try again later.');
break;
default:
showUserMessage('Authentication failed. Please try again.');
}
} else {
// Handle other errors
console.error('Unexpected error:', error);
showUserMessage('An unexpected error occurred.');
}
}
}
async function exchangeCodeForTokens(code, codeVerifier) {
try {
const response = await fetch('/api/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
code,
code_verifier: codeVerifier,
client_id: clientId,
redirect_uri: redirectUri
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new PKCEError(
`Token exchange failed: ${response.status}`,
errorData.error || 'token_exchange_failed',
errorData.error_description || `HTTP ${response.status}`
);
}
return await response.json();
} catch (error) {
if (error instanceof PKCEError) {
throw error;
}
throw new PKCEError(
'Network error during token exchange',
'network_error',
error.message
);
}
}Testing and Validation
PKCE Implementation Testing
Unit Tests for PKCE Generation
// pkce.test.js
import { generatePKCE, validateCodeChallenge } from './pkce.js';
describe('PKCE Implementation', () => {
test('generatePKCE creates valid parameters', async () => {
const pkce = await generatePKCE();
// Test code verifier
expect(pkce.codeVerifier).toBeDefined();
expect(pkce.codeVerifier.length).toBeGreaterThanOrEqual(43);
expect(pkce.codeVerifier.length).toBeLessThanOrEqual(128);
expect(/^[A-Za-z0-9\-._~]+$/.test(pkce.codeVerifier)).toBe(true);
// Test code challenge
expect(pkce.codeChallenge).toBeDefined();
expect(pkce.codeChallenge.length).toBe(43); // Base64URL encoded SHA256
expect(/^[A-Za-z0-9\-_]+$/.test(pkce.codeChallenge)).toBe(true);
// Test method
expect(pkce.codeChallengeMethod).toBe('S256');
});
test('code challenge is deterministic for same verifier', async () => {
const verifier = 'test-verifier-123';
const challenge1 = await generateCodeChallenge(verifier);
const challenge2 = await generateCodeChallenge(verifier);
expect(challenge1).toBe(challenge2);
});
test('different verifiers produce different challenges', async () => {
const pkce1 = await generatePKCE();
const pkce2 = await generatePKCE();
expect(pkce1.codeVerifier).not.toBe(pkce2.codeVerifier);
expect(pkce1.codeChallenge).not.toBe(pkce2.codeChallenge);
});
test('code verifier has sufficient entropy', async () => {
const verifiers = new Set();
// Generate 1000 verifiers and ensure they're all unique
for (let i = 0; i < 1000; i++) {
const pkce = await generatePKCE();
verifiers.add(pkce.codeVerifier);
}
expect(verifiers.size).toBe(1000);
});
});Integration Tests
// integration.test.js
import { OtenPKCE } from './oten-pkce.js';
describe('PKCE Integration Tests', () => {
let authClient;
beforeEach(() => {
authClient = new OtenPKCE(
'test-client-id',
'https://test.example.com/callback',
'https://test-backend.example.com'
);
// Mock sessionStorage
global.sessionStorage = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn()
};
// Mock fetch
global.fetch = jest.fn();
});
test('login flow stores PKCE parameters', async () => {
// Mock JAR creation response
fetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ requestJWT: 'mock-jar-token' })
});
// Mock window.location
delete window.location;
window.location = { href: '' };
await authClient.login();
// Verify PKCE parameters were stored
expect(sessionStorage.setItem).toHaveBeenCalledWith(
'code_verifier',
expect.stringMatching(/^[A-Za-z0-9\-._~]{43,128}$/)
);
expect(sessionStorage.setItem).toHaveBeenCalledWith(
'oauth_state',
expect.stringMatching(/^[A-Za-z0-9]{32}$/)
);
});
test('callback validates state parameter', async () => {
// Setup stored state
sessionStorage.getItem.mockImplementation((key) => {
if (key === 'oauth_state') return 'test-state-123';
if (key === 'code_verifier') return 'test-verifier-123';
return null;
});
// Mock URL with mismatched state
Object.defineProperty(window, 'location', {
value: {
search: '?code=test-code&state=different-state'
}
});
await expect(authClient.handleCallback()).rejects.toThrow(
'Invalid state parameter'
);
});
test('successful callback exchanges code for tokens', async () => {
// Setup stored parameters
sessionStorage.getItem.mockImplementation((key) => {
if (key === 'oauth_state') return 'test-state-123';
if (key === 'code_verifier') return 'test-verifier-123';
return null;
});
// Mock URL with valid callback
Object.defineProperty(window, 'location', {
value: {
search: '?code=test-code&state=test-state-123'
}
});
// Mock token exchange response
fetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
access_token: 'test-access-token',
refresh_token: 'test-refresh-token',
id_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'
})
});
const tokens = await authClient.handleCallback();
expect(tokens).toBeDefined();
expect(tokens.access_token).toBe('test-access-token');
// Verify cleanup
expect(sessionStorage.removeItem).toHaveBeenCalledWith('oauth_state');
expect(sessionStorage.removeItem).toHaveBeenCalledWith('code_verifier');
});
});Manual Testing Checklist
Pre-Implementation Testing
PKCE Flow Testing
Security Testing
Troubleshooting
Common Issues and Solutions
1. "Invalid code verifier" Error
Symptoms:
Token exchange fails with
invalid_granterrorError description mentions code verifier validation
Causes:
Code verifier doesn't match the one used to generate code challenge
Code verifier was modified or corrupted during storage
Different code verifier used between authorization and token requests
Solutions:
// ✅ Ensure same verifier is used throughout the flow
const pkce = await generatePKCE();
sessionStorage.setItem('code_verifier', pkce.codeVerifier); // Store immediately
// Later, in callback
const storedVerifier = sessionStorage.getItem('code_verifier');
if (!storedVerifier) {
throw new Error('Code verifier not found - session may have expired');
}2. "Missing code challenge" Error
Symptoms:
Authorization request fails with
invalid_requestError mentions missing PKCE parameters
Causes:
PKCE parameters not included in JAR
JAR creation doesn't include code challenge
Code challenge is null or undefined
Solutions:
// ✅ Always include PKCE parameters in JAR
const jarPayload = {
// ... other parameters
code_challenge: pkce.codeChallenge,
code_challenge_method: 'S256'
};
// Validate before creating JAR
if (!pkce.codeChallenge || !pkce.codeChallengeMethod) {
throw new Error('PKCE parameters are required');
}3. State Parameter Mismatch
Symptoms:
Callback handling fails with CSRF error
State validation throws error
Causes:
State parameter was modified during redirect
Multiple login attempts with same state
Session storage cleared between requests
Solutions:
// ✅ Generate unique state for each request
const state = crypto.randomUUID(); // Always unique
// ✅ Clear previous state before new login
sessionStorage.removeItem('oauth_state');
sessionStorage.setItem('oauth_state', state);
// ✅ Validate state immediately in callback
const urlState = urlParams.get('state');
const storedState = sessionStorage.getItem('oauth_state');
if (!storedState) {
throw new Error('No stored state found - session may have expired');
}
if (urlState !== storedState) {
throw new Error('State mismatch - possible CSRF attack');
}4. JAR Creation Failures
Symptoms:
Authorization flow fails before redirect
JAR endpoint returns errors
Causes:
Invalid client credentials
Malformed JAR payload
Network connectivity issues
Solutions:
// ✅ Validate JAR payload before sending
function validateJARPayload(payload) {
const required = ['iss', 'aud', 'iat', 'exp', 'client_id', 'redirect_uri'];
for (const field of required) {
if (!payload[field]) {
throw new Error(`Missing required JAR field: ${field}`);
}
}
}
// ✅ Handle JAR creation errors gracefully
try {
const jarResponse = await fetch('/api/create-jar', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(jarPayload)
});
if (!jarResponse.ok) {
const errorText = await jarResponse.text();
throw new Error(`JAR creation failed (${jarResponse.status}): ${errorText}`);
}
const { requestJWT } = await jarResponse.json();
if (!requestJWT) {
throw new Error('JAR not returned from server');
}
} catch (error) {
console.error('JAR creation error:', error);
throw new Error('Failed to create authorization request');
}5. Mobile App Deep Link Issues
Symptoms:
App doesn't receive callback on mobile
Deep link not triggering app
iOS Solutions:
// ✅ Ensure URL scheme is properly configured in Info.plist
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.yourcompany.yourapp.oauth</string>
<key>CFBundleURLSchemes</key>
<array>
<string>yourapp</string>
</array>
</dict>
</array>
// ✅ Handle URL in SceneDelegate
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
guard let url = URLContexts.first?.url else { return }
// Handle OAuth callback
authManager.handleCallback(url: url)
}Android Solutions:
<!-- ✅ Add intent filter to AndroidManifest.xml -->
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="yourapp" />
</intent-filter>
</activity>Debug Tools and Techniques
PKCE Parameter Validation
// Debug helper for PKCE validation
function debugPKCE(codeVerifier, codeChallenge) {
console.group('PKCE Debug Information');
console.log('Code Verifier:', codeVerifier);
console.log('Code Verifier Length:', codeVerifier.length);
console.log('Code Verifier Valid:', /^[A-Za-z0-9\-._~]+$/.test(codeVerifier));
console.log('Code Challenge:', codeChallenge);
console.log('Code Challenge Length:', codeChallenge.length);
console.log('Code Challenge Valid:', /^[A-Za-z0-9\-_]+$/.test(codeChallenge));
// Verify challenge generation
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
crypto.subtle.digest('SHA-256', data).then(digest => {
const expectedChallenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
console.log('Expected Challenge:', expectedChallenge);
console.log('Challenge Match:', codeChallenge === expectedChallenge);
console.groupEnd();
});
}Network Request Debugging
// Debug helper for OAuth requests
function debugOAuthRequest(url, method, body) {
console.group(`OAuth ${method} Request`);
console.log('URL:', url);
console.log('Method:', method);
if (body) {
try {
const parsed = typeof body === 'string' ? JSON.parse(body) : body;
console.log('Body:', parsed);
// Check for sensitive data
if (parsed.code_verifier) {
console.warn('⚠️ Code verifier in request body');
}
if (parsed.client_secret) {
console.warn('⚠️ Client secret in request body');
}
} catch (e) {
console.log('Body (raw):', body);
}
}
console.groupEnd();
}Security Considerations
Code Verifier Security
Generate with sufficient entropy: Use at least 256 bits of entropy
Use cryptographically secure random generators: Never use
Math.random()Store securely: Use secure storage mechanisms (Keychain, Keystore, sessionStorage)
Never log or expose: Code verifiers should never appear in logs or URLs
State Parameter Protection
Always use unique state: Generate a new state for each authorization request
Validate on callback: Always verify the state parameter matches
Use sufficient entropy: State should be unpredictable and unique
Transport Security
HTTPS only: Never use HTTP for OAuth flows in production
Validate redirect URIs: Ensure redirect URIs are properly validated
Secure storage: Use appropriate secure storage for tokens and sensitive data
Error Handling
Common PKCE Errors
invalid_request
invalid_requestCause: Missing or malformed PKCE parameters
Solution: Ensure
code_challengeandcode_challenge_methodare included
invalid_grant
invalid_grantCause: Code verifier doesn't match the challenge
Solution: Verify the same code verifier is used throughout the flow
unsupported_code_challenge_method
unsupported_code_challenge_methodCause: Using unsupported challenge method
Solution: Only use
S256method
Error Recovery Strategies
// Comprehensive error handling
async function handlePKCEError(error) {
switch (error.code) {
case 'invalid_request':
// Regenerate PKCE parameters and retry
console.log('Regenerating PKCE parameters...');
return await retryWithNewPKCE();
case 'invalid_grant':
// Clear stored parameters and restart flow
console.log('Clearing stored parameters...');
clearStoredParameters();
return await restartAuthFlow();
default:
// Log error and show user-friendly message
console.error('PKCE error:', error);
showUserError('Authentication failed. Please try again.');
}
}Testing and Validation
Unit Testing PKCE Components
// Test PKCE parameter generation
describe('PKCE Generation', () => {
test('generates valid code verifier', () => {
const verifier = generateCodeVerifier();
expect(verifier).toHaveLength(128);
expect(verifier).toMatch(/^[A-Za-z0-9\-._~]+$/);
});
test('generates consistent code challenge', () => {
const verifier = 'test-verifier';
const challenge1 = generateCodeChallenge(verifier);
const challenge2 = generateCodeChallenge(verifier);
expect(challenge1).toBe(challenge2);
});
});Integration Testing
// Test complete PKCE flow
describe('PKCE Flow Integration', () => {
test('completes authorization flow successfully', async () => {
const authClient = new OtenPKCE(clientId, redirectUri);
// Mock successful authorization
const mockCallback = 'https://app.com/callback?code=test&state=test';
// Verify flow completion
const tokens = await authClient.handleCallback(mockCallback);
expect(tokens).toHaveProperty('access_token');
expect(tokens).toHaveProperty('id_token');
});
});Manual Testing Checklist
Troubleshooting
Debug Mode Setup
// Enable debug mode for troubleshooting
const authClient = new OtenPKCE(clientId, redirectUri, {
debug: true,
logLevel: 'verbose'
});
// This will log all PKCE parameters and requests
authClient.enableDebugMode();Common Issues and Solutions
Issue: "Code challenge mismatch"
Symptoms: Token exchange fails with invalid_grant Causes:
Different code verifier used in token request
Code verifier corrupted during storage
Multiple authorization flows running simultaneously
Solutions:
Ensure single source of truth for code verifier
Validate storage/retrieval mechanisms
Clear stored parameters before new flow
Issue: "State parameter mismatch"
Symptoms: Callback validation fails Causes:
State not properly stored
Multiple browser tabs/windows
Session storage cleared
Solutions:
Use sessionStorage for SPAs
Implement proper state management
Handle multiple tab scenarios
Issue: "Redirect URI mismatch"
Symptoms: Authorization fails immediately Causes:
Redirect URI not registered
HTTP vs HTTPS mismatch
Port number differences
Solutions:
Verify client configuration
Ensure exact URI match
Use dynamic port handling for development
Debugging Tools
// PKCE flow debugger
function debugPKCEFlow() {
console.group('🔍 PKCE Flow Debug');
// Check stored parameters
const verifier = sessionStorage.getItem('code_verifier');
const state = sessionStorage.getItem('oauth_state');
console.log('Stored verifier:', verifier ? '✅ Present' : '❌ Missing');
console.log('Stored state:', state ? '✅ Present' : '❌ Missing');
// Validate current URL
const url = new URL(window.location.href);
const code = url.searchParams.get('code');
const returnedState = url.searchParams.get('state');
console.log('URL code:', code ? '✅ Present' : '❌ Missing');
console.log('URL state:', returnedState ? '✅ Present' : '❌ Missing');
console.log('State match:', state === returnedState ? '✅ Match' : '❌ Mismatch');
console.groupEnd();
}References and Further Reading
Standards and Specifications
RFC 7636: Proof Key for Code Exchange - Official PKCE specification
RFC 6749: OAuth 2.0 Authorization Framework - OAuth 2.0 base specification
RFC 9101: JWT-Secured Authorization Request (JAR) - JAR specification
OAuth 2.1 Draft - Next version of OAuth with PKCE required
Security Guidelines
OAuth 2.0 Security Best Current Practice - Security recommendations
OAuth 2.0 for Native Apps - Native app specific guidance
OAuth 2.0 for Browser-Based Apps - SPA specific guidance
Implementation Resources
Oten IDP Documentation - Main integration guide
JAR Implementation Guide - Detailed JAR documentation
Error Handling Guide - Error handling best practices
Security Best Practices - General security guidelines
Need Help? Contact our Support Team for assistance with PKCE implementation.
Last updated