PKCE Implementation Guide
Last updated
Last updated
This comprehensive guide covers implementing PKCE (Proof Key for Code Exchange) for Single Page Applications (SPAs) and native mobile applications using Oten IDP.
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.
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.
Where:
Parameters (Direct in URL for Public Clients):
Request Body (application/x-www-form-urlencoded):
Note: No client_secret required for public clients using PKCE.
Oten IDP supports OpenID Connect Discovery. The configuration is available at:
Key PKCE-related fields:
SPAs:
Native Apps:
Symptoms:
Causes:
Solutions:
Symptoms:
Causes:
Solutions:
Symptoms:
Causes:
Solutions:
Symptoms:
Causes:
Solutions:
Symptoms:
iOS Solutions:
Android Solutions:
Symptoms: Token exchange fails with invalid_grant Causes:
Solutions:
Symptoms: Callback validation fails Causes:
Solutions:
Symptoms: Authorization fails immediately Causes:
Solutions:
Need Help? Contact our Support Team for assistance with PKCE implementation.
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
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)
Server-side applications (additional security layer)
Hybrid applications with both server and client components


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)
code_verifier
Yes
Original random string used to generate code_challenge
"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
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 = BASE64URL(SHA256(code_verifier))SHA256() is the SHA256 hash function
BASE64URL() is base64url encoding (RFC 4648 Section 5)
GET https://account.oten.com/v1/oauth/authorizeStandard OAuth 2.0 parameters
PKCE parameters (code_challenge, code_challenge_method)
POST https://account.oten.com/v1/oauth/tokengrant_type=authorization_code
&code={authorization_code}
&redirect_uri={redirect_uri}
&client_id={client_id}
&code_verifier={code_verifier}https://account.oten.com/.well-known/openid-configuration{
"code_challenge_methods_supported": ["S256"],
"authorization_endpoint": "https://account.oten.com/v1/oauth/authorize",
"token_endpoint": "https://account.oten.com/v1/oauth/token"
}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');
}
}// 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
}// 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
};
};// 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;// 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
};
};// 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)
}
}// 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")
}
}
}// 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
}
}// 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
)
}
}
}
}// ✅ 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('');
}// ✅ 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 sessions// ✅ 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")// ✅ 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 attacks// ✅ 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 tokens// ❌ 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 entropy// ❌ 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...');// ❌ 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');
}// ❌ Incorrect: Allowing HTTP in production
const redirectURI = "http://myapp.com/callback";
// ✅ Correct: Always use HTTPS in production
const redirectURI = "https://myapp.com/callback";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
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)
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
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
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
);
}
}// 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.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');
});
});Token exchange fails with invalid_grant error
Error description mentions code verifier validation
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
// ✅ 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');
}Authorization request fails with invalid_request
Error mentions missing PKCE parameters
PKCE parameters not included in JAR
JAR creation doesn't include code challenge
Code challenge is null or undefined
// ✅ 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');
}Callback handling fails with CSRF error
State validation throws error
State parameter was modified during redirect
Multiple login attempts with same state
Session storage cleared between requests
// ✅ 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');
}Authorization flow fails before redirect
JAR endpoint returns errors
Invalid client credentials
Malformed JAR payload
Network connectivity issues
// ✅ 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');
}App doesn't receive callback on mobile
Deep link not triggering app
// ✅ 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)
}<!-- ✅ 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 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();
});
}// 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();
}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
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
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
Cause: Missing or malformed PKCE parameters
Solution: Ensure code_challenge and code_challenge_method are included
Cause: Code verifier doesn't match the challenge
Solution: Verify the same code verifier is used throughout the flow
Cause: Using unsupported challenge method
Solution: Only use S256 method
// 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.');
}
}// 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);
});
});// 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');
});
});// 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();Different code verifier used in token request
Code verifier corrupted during storage
Multiple authorization flows running simultaneously
Ensure single source of truth for code verifier
Validate storage/retrieval mechanisms
Clear stored parameters before new flow
State not properly stored
Multiple browser tabs/windows
Session storage cleared
Use sessionStorage for SPAs
Implement proper state management
Handle multiple tab scenarios
Redirect URI not registered
HTTP vs HTTPS mismatch
Port number differences
Verify client configuration
Ensure exact URI match
Use dynamic port handling for development
// 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();
}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
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
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
Desktop Applications
Electron applications
Native desktop apps with embedded browsers