🔐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

SPA PKCE Flow

Native App PKCE Flow

Native App PKCE Flow

API Reference

PKCE Parameters

Authorization Request Parameters

Parameter
Required
Description
Example

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

Parameter
Required
Description
Example

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 function

  • BASE64URL() is base64url encoding (RFC 4648 Section 5)

Endpoints

Authorization Endpoint

GET https://account.oten.com/v1/oauth/authorize

Parameters (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/token

Request 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-configuration

Key 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 sessions

Native 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 attacks

Token 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 tokens

Common 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 entropy

2. 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

Error Code
Description
Cause
Solution

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

Error Code
Description
Cause
Solution

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_grant error

  • Error 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_request

  • Error 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');
}

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

  • Cause: Missing or malformed PKCE parameters

  • Solution: Ensure code_challenge and code_challenge_method are included

invalid_grant

  • Cause: Code verifier doesn't match the challenge

  • Solution: Verify the same code verifier is used throughout the flow

unsupported_code_challenge_method

  • Cause: Using unsupported challenge method

  • Solution: Only use S256 method

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

Security Guidelines

Implementation Resources


Need Help? Contact our Support Team for assistance with PKCE implementation.

Last updated