@pixpilot/pkce-auth
v0.10.0
Published
Core PKCE authentication package for OAuth 2.0 flows with TypeScript support
Readme
@pixpilot/pkce-auth
Secure OAuth 2.0 authentication with PKCE (Proof Key for Code Exchange).
⚠️ Security First: Before deploying to production, review the Security Checklist to ensure proper security configuration.
Installation
pnpm add @pixpilot/pkce-authQuick Start
Recommended: Class-Based API
The easiest way to use this library. Configure once and use for both steps:
import type { AuthStorageOperations } from '@pixpilot/pkce-auth';
import { PKCEAuthClient } from '@pixpilot/pkce-auth';
// 1. Implement storage operations (see "Storage Adapters" section below)
const storage: AuthStorageOperations = {
getAuthState: async (state) => {
/* retrieve state and verifier */
},
updateCodeHash: async (state, hash) => {
/* bind code to state */
},
markCodeAsUsed: async (code) => {
/* prevent code reuse - MUST be atomic! */
},
saveTokens: async (access, refresh) => {
/* save tokens securely */
},
cleanupAuthState: async (state) => {
/* cleanup state */
},
};
// 2. Configure once with shared settings
const auth = new PKCEAuthClient({
loginUrl: 'https://oauth.example.com/authorize',
redirectUrl: 'https://yourapp.com/callback',
exchangeUrl: 'https://oauth.example.com/token',
clientId: 'your_client_id',
scope: 'read write',
});
// 3. Start auth flow
const { authUrl } = await auth.startAuthFlow();
window.location.href = authUrl;
// 4. After callback, exchange code for tokens
const tokens = await auth.handleAuthCallback({
authCode: 'code_from_callback',
receivedState: 'state_from_callback',
storage,
});Alternative: Individual Functions
Use individual functions if you need more control or have different configs per step:
import type { AuthStorageOperations } from '@pixpilot/pkce-auth';
import { handleAuthCallback, startAuthFlow } from '@pixpilot/pkce-auth';
// 1. Implement storage operations (see "Storage Adapters" section below)
const storage: AuthStorageOperations = {
getAuthState: async (state) => {
/* retrieve state and verifier */
},
updateCodeHash: async (state, hash) => {
/* bind code to state */
},
markCodeAsUsed: async (code) => {
/* prevent code reuse - MUST be atomic! */
},
saveTokens: async (access, refresh) => {
/* save tokens securely */
},
cleanupAuthState: async (state) => {
/* cleanup state */
},
};
// 2. Start auth flow
const { authUrl, state } = await startAuthFlow({
loginUrl: 'https://oauth.example.com/authorize',
redirectUrl: 'https://yourapp.com/callback',
exchangeUrl: 'https://oauth.example.com/token',
clientId: 'your_client_id',
scope: 'read write',
});
// 3. Redirect to authorization server
window.location.href = authUrl;
// 4. After callback, exchange code for tokens
const tokens = await handleAuthCallback({
authCode: 'code_from_callback',
receivedState: 'state_from_callback',
exchangeUrl: 'https://oauth.example.com/token',
storage,
});Low-Level API (Manual Storage)
Use this for basic OAuth flows where you manage storage yourself:
import { exchangeAuthCode, generatePKCE } from '@pixpilot/pkce-auth';
// 1. Generate PKCE challenge
const { codeVerifier, codeChallenge } = await generatePKCE();
// 2. Build authorization URL
const params = new URLSearchParams({
client_id: 'your_client_id',
redirect_uri: 'https://yourapp.com/callback',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
response_type: 'code',
});
const authUrl = `https://oauth.example.com/authorize?${params}`;
// 3. Store code verifier and redirect
sessionStorage.setItem('code_verifier', codeVerifier);
window.location.href = authUrl;
// 4. After callback, exchange code for tokens
const tokens = await exchangeAuthCode('https://oauth.example.com/token', {
code: authCode,
codeVerifier: sessionStorage.getItem('code_verifier'),
clientId: 'your_client_id',
redirectUrl: 'https://yourapp.com/callback',
});Development Mode
For local development with http://localhost, enable development mode:
import { PKCEAuthClient } from '@pixpilot/pkce-auth';
const auth = new PKCEAuthClient({
loginUrl: 'http://localhost:3000/authorize',
redirectUrl: 'http://localhost:3000/callback',
exchangeUrl: 'http://localhost:3000/token',
clientId: 'dev-client-id',
allowHttpLocalhost: true, // Allows localhost per RFC 8252
});
// Console will show a development notice:
// ⚠️ HTTP localhost enabled for development (RFC 8252 compliant)Important:
- Production: Leave
allowHttpLocalhost: false(default) for security - Development: Set
allowHttpLocalhost: trueto enable localhost per RFC 8252 - Standards: OAuth 2.0 for Native Apps explicitly endorses localhost loopback
**Important:**
- **Production:** Never set `allowHttpLocalhost: true`
- **Default behavior:** Strict HTTPS-only validation (production-safe)
- **Development:** Explicitly set `allowHttpLocalhost: true` to allow localhost
## Storage Adapters
The high-level API uses platform-agnostic storage adapters. Implement these interfaces for your platform:
### Basic Example (Chrome Extension)
```typescript
import type {
AuthStateStorageAdapter,
CodeHashStorageAdapter,
CodeStorageAdapter,
} from '@pixpilot/pkce-auth';
// For storing used authorization codes
const codeAdapter: CodeStorageAdapter = {
get: async (key) => {
const result = await chrome.storage.local.get(key);
return result[key] ?? {};
},
set: async (key, value) => {
await chrome.storage.local.set({ [key]: value });
},
};
// For storing auth state (state parameter + code verifier)
const authStateAdapter: AuthStateStorageAdapter = {
get: async (key) => {
const result = await chrome.storage.local.get(key);
return result[key] ?? null;
},
set: async (key, value) => {
await chrome.storage.local.set({ [key]: value });
},
remove: async (key) => {
await chrome.storage.local.remove(key);
},
};
// For storing code hashes (prevents code substitution)
const codeHashAdapter: CodeHashStorageAdapter = {
get: async (key) => {
const result = await chrome.storage.local.get(key);
return result[key] ?? null;
},
set: async (key, value) => {
await chrome.storage.local.set({ [key]: value });
},
};Platform Examples
import AsyncStorage from '@react-native-async-storage/async-storage';
const codeAdapter: CodeStorageAdapter = {
get: async (key) => {
const json = await AsyncStorage.getItem(key);
return json ? JSON.parse(json) : {};
},
set: async (key, value) => {
await AsyncStorage.setItem(key, JSON.stringify(value));
},
};
const authStateAdapter: AuthStateStorageAdapter = {
get: async (key) => {
const json = await AsyncStorage.getItem(key);
return json ? JSON.parse(json) : null;
},
set: async (key, value) => {
await AsyncStorage.setItem(key, JSON.stringify(value));
},
remove: async (key) => {
await AsyncStorage.removeItem(key);
},
};import { readFile, unlink, writeFile } from 'node:fs/promises';
const codeAdapter: CodeStorageAdapter = {
get: async (key) => {
try {
const json = await readFile(`./storage/${key}.json`, 'utf-8');
return JSON.parse(json);
} catch {
return {};
}
},
set: async (key, value) => {
await writeFile(`./storage/${key}.json`, JSON.stringify(value));
},
};
const authStateAdapter: AuthStateStorageAdapter = {
get: async (key) => {
try {
const json = await readFile(`./storage/${key}.json`, 'utf-8');
return JSON.parse(json);
} catch {
return null;
}
},
set: async (key, value) => {
await writeFile(`./storage/${key}.json`, JSON.stringify(value));
},
remove: async (key) => {
try {
await unlink(`./storage/${key}.json`);
} catch {
// File may not exist
}
},
};Helper Functions
Use these to build your AuthStorageOperations:
import {
getAuthState,
markCodeAsUsed,
saveAuthState,
updateCodeHash,
} from '@pixpilot/pkce-auth';
// Create the storage operations object
const storage: AuthStorageOperations = {
getAuthState: async (state) => getAuthState(state, authStateAdapter),
updateCodeHash: async (state, hash) => updateCodeHash(state, hash, codeHashAdapter),
markCodeAsUsed: async (code) => markCodeAsUsed(code, codeAdapter),
saveTokens: async (access, refresh) => {
// Your token storage logic
},
cleanupAuthState: async (state) => {
await authStateAdapter.remove(state);
},
};URL Validation
By default, startAuthFlow validates URLs with isHttpsOrLocalhostUrl (HTTPS + localhost HTTP for development).
Built-in Validators
import { isHttpsOrLocalhostUrl, isHttpsUrl, startAuthFlow } from '@pixpilot/pkce-auth';
// Production: HTTPS only
await startAuthFlow({
loginUrl: 'https://auth.example.com/authorize',
validateLoginUrl: isHttpsUrl,
});
// Development: HTTPS or localhost HTTP
await startAuthFlow({
loginUrl: 'http://localhost:3000/authorize',
validateLoginUrl: isHttpsOrLocalhostUrl, // default
});Custom Protocol Validators
For platform-specific protocols (Chrome extensions, mobile deep links):
import { createCustomProtocolValidator } from '@pixpilot/pkce-auth';
// Chrome extension
const isChromeExtension = createCustomProtocolValidator(
'chrome-extension:',
(hostname) => /^[\da-p]{32}$/u.test(hostname), // Validate extension ID
);
// Mobile deep link
const isMobileDeepLink = createCustomProtocolValidator('myapp:', (hostname) =>
/^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*)+$/iu.test(hostname),
);
await startAuthFlow({
redirectUrl: 'chrome-extension://abcd.../callback',
validateRedirectUrl: isChromeExtension,
});Security Features:
- Rejects URLs with embedded credentials (
user:pass@host) - Prevents open redirect attacks (
@in hostname) - Ensures hostname is not empty
- HTTPS validator rejects IP addresses (phishing protection)
Built-in Validators
isHttpsUrl(url)
Validates HTTPS URLs only. Use for production OAuth flows.
import { isHttpsUrl, startAuthFlow } from '@pixpilot/pkce-auth';
const result = await startAuthFlow({
loginUrl: 'https://auth.example.com/authorize',
validateLoginUrl: isHttpsUrl,
});isHttpsOrLocalhostUrl(url)
Validates HTTPS or localhost HTTP URLs. Use for development.
import { isHttpsOrLocalhostUrl, startAuthFlow } from '@pixpilot/pkce-auth';
const result = await startAuthFlow({
loginUrl: 'http://localhost:3000/authorize', // ✓ Allowed in dev
validateLoginUrl: isHttpsOrLocalhostUrl,
});Custom Protocol Validators
Create validators for platform-specific protocols:
import { createCustomProtocolValidator } from '@pixpilot/pkce-auth';
// Chrome extension validator
const isChromeExtension = createCustomProtocolValidator(
'chrome-extension:',
(hostname) => /^[\da-p]{32}$/u.test(hostname), // Validate extension ID
);
// Mobile deep link validator
const isMobileDeepLink = createCustomProtocolValidator('myapp:', (hostname) =>
/^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*)+$/iu.test(hostname),
);
// Use in auth flow
await startAuthFlow({
redirectUrl: 'chrome-extension://abcd.../callback',
validateRedirectUrl: isChromeExtension,
});Security Checks
All validators include these security checks:
- ✓ Rejects URLs with embedded credentials (
user:pass@host) - ✓ Prevents open redirect attacks (
@in hostname) - ✓ Ensures hostname is not empty
- ✓ HTTPS validator rejects IP addresses (phishing protection)
For Platform Packages
Platform-specific packages (Chrome, React Native, etc.) should compose these validators:
// In @pixpilot/pkce-auth-chrome
import {
createCustomProtocolValidator,
isHttpsOrLocalhostUrl,
isHttpsUrl,
} from '@pixpilot/pkce-auth';
export const isChromeExtensionUrl = createCustomProtocolValidator(
'chrome-extension:',
(hostname) => /^[\da-p]{32}$/u.test(hostname),
);
export async function isHttpsOrChromeExtensionOrLocalhostUrl(
url: string,
): Promise<boolean> {
const parsed = new URL(url);
if (parsed.protocol === 'https:') return isHttpsUrl(url);
if (parsed.protocol === 'chrome-extension:') return await isChromeExtensionUrl(url);
if (parsed.protocol === 'http:') return isHttpsOrLocalhostUrl(url);
return false;
}Security
Critical: Atomic Code Marking
The markCodeAsUsed function must be atomic to prevent authorization code reuse:
❌ Insecure (Race Condition):
// BAD: Check-then-set is NOT atomic
async function markCodeAsUsed(code: string): Promise<boolean> {
const used = await db.get(`used_${code}`);
if (used) return false;
await db.set(`used_${code}`, true); // ← Race window!
return true;
}✅ Secure (Atomic Operation):
// SQL with unique constraint
async function markCodeAsUsed(code: string): Promise<boolean> {
const result = await db.query(
'INSERT INTO used_codes (code, timestamp) VALUES (?, ?) ON CONFLICT DO NOTHING',
[code, Date.now()],
);
return result.rowsAffected > 0;
}
// Redis SET NX (set if not exists)
async function markCodeAsUsedRedis(code: string): Promise<boolean> {
const EXPIRATION_SECONDS = 3600;
const wasSet = await redis.set(`used_${code}`, '1', 'NX', 'EX', EXPIRATION_SECONDS);
return wasSet === 'OK';
}Error Handling
All authentication errors throw AuthError with detailed codes:
import { AuthError, handleAuthCallback } from '@pixpilot/pkce-auth';
try {
await handleAuthCallback(config);
} catch (error) {
if (error instanceof AuthError) {
console.error(`Auth failed: ${error.code} (ID: ${error.id})`);
switch (error.code) {
case 'INVALID_STATE':
case 'STATE_MISMATCH':
// CSRF attack or expired session
break;
case 'CODE_REUSED':
// Replay attack detected
break;
case 'CODE_BINDING_FAILED':
// Code injection attack
break;
}
}
}Error Codes:
INVALID_STATE- State not found or expiredSTATE_MISMATCH- State mismatch (CSRF protection)CODE_REUSED- Code already used (replay attack)CODE_BINDING_FAILED- Code hash mismatch (injection attack)INVALID_VERIFIER- Custom verifier validation failedINVALID_EXCHANGE_URL- Exchange URL failed validation
Best Practices
- HTTPS Only: Use
isHttpsUrlvalidator in production - Secure Storage: Store state and verifier in encrypted storage
- State Expiration: Auth states automatically expire after 10 minutes
- Input Validation: All inputs validated for max length (prevents DoS)
- Constant-Time Comparison: Prevents timing attacks
- Atomic Operations: Code marking uses triple verification with retry logic
API Reference
Core Functions
generatePKCE()
Generates PKCE code verifier and challenge.
Returns: { codeVerifier: string, codeChallenge: string }
exchangeAuthCode(exchangeUrl, request)
Exchanges authorization code for access tokens.
Parameters:
exchangeUrl- OAuth token endpoint URLrequest.code- Authorization code from callbackrequest.codeVerifier- Code verifier fromgeneratePKCE()request.clientId- (optional) OAuth client IDrequest.redirectUrl- (optional) Redirect URI
Returns: { access_token: string, refresh_token?: string | null, user?: object }
startAuthFlow(config)
Starts complete OAuth flow with PKCE.
Parameters:
config.loginUrl- Authorization server URLconfig.redirectUrl- Your callback URLconfig.exchangeUrl- Token exchange endpointconfig.clientId- OAuth client IDconfig.scope- (optional) OAuth scopesconfig.validateLoginUrl- (optional) Login URL validator functionconfig.validateRedirectUrl- (optional) Redirect URI validator
Returns: { authUrl: string, state: string, codeVerifier: string }
handleAuthCallback(config)
Handles OAuth callback and exchanges code for tokens.
Parameters:
config.authCode- Code from callback URLconfig.receivedState- State from callback URLconfig.exchangeUrl- Token exchange endpointconfig.storage- Storage operations implementation
Returns: { access_token: string, refresh_token?: string | null }
Storage Helpers
markCodeAsUsed(code, storage, storageKey?)
Atomically marks authorization code as used (prevents replay attacks).
Returns: Promise<boolean> - true if marked successfully
getAuthState(state, storage)
Retrieves auth state with expiration checking.
Returns: Promise<StoredAuthState | null>
saveAuthState(state, codeVerifier, storage)
Saves auth state with timestamp.
Returns: Promise<void>
updateCodeHash(state, codeHash, storage)
Atomically binds code hash to state (prevents code substitution).
Returns: Promise<string> - The bound hash
Validation
verifyPKCEChallenge(codeVerifier, codeChallenge)
Verifies PKCE challenge matches verifier (for OAuth servers).
Returns: Promise<boolean>
Constants
import { MAX_LENGTHS } from '@pixpilot/pkce-auth';
// Maximum allowed lengths for OAuth parameters
MAX_LENGTHS.STATE; // 256
MAX_LENGTHS.AUTH_CODE; // 512Security Checklist for Implementers
Before deploying to production, ensure you've implemented these critical security measures:
Required Security Measures
[ ] Redirect URI Validation: Implement strict whitelist validation for redirect URIs
import { startAuthFlow } from '@pixpilot/pkce-auth'; const ALLOWED_REDIRECT_URIS = [ 'https://myapp.com/callback', 'https://myapp.com/auth/callback', ]; await startAuthFlow({ loginUrl: 'https://auth.example.com/authorize', redirectUrl: 'https://myapp.com/callback', exchangeUrl: 'https://auth.example.com/token', validateRedirectUrl: (uri) => ALLOWED_REDIRECT_URIS.includes(uri), });[ ] Token Storage: Use platform-specific encrypted storage
- Web: Web Crypto API with IndexedDB encryption
- Chrome Extensions:
chrome.storage.local(automatically encrypted) - React Native:
react-native-keychainor Expo SecureStore - iOS: iOS Keychain
- Android: Android Keystore with EncryptedSharedPreferences
- Node.js: Encrypted file storage or secure key management service
[ ] Rate Limiting: Implement rate limiting at the application level
// Example: Limit auth attempts per IP/user // - startAuthFlow: Max 10 requests per minute // - handleAuthCallback: Max 5 requests per minute // - exchangeAuthCode: Max 3 requests per minute[ ] HTTPS Enforcement: Strict HTTPS in production, development mode for localhost
import { startAuthFlow } from '@pixpilot/pkce-auth'; // Production: Default strict HTTPS validation (no allowHttpLocalhost flag) await startAuthFlow({ loginUrl: 'https://auth.example.com/authorize', redirectUrl: 'https://myapp.com/callback', exchangeUrl: 'https://auth.example.com/token', // allowHttpLocalhost: false (default) - HTTPS only }); // Development: Enable localhost support (RFC 8252 compliant) await startAuthFlow({ loginUrl: 'http://localhost:3000/authorize', redirectUrl: 'https://myapp.com/callback', exchangeUrl: 'https://auth.example.com/token', allowHttpLocalhost: true, // RFC 8252 OAuth 2.0 for Native Apps });[ ] Storage Backend: Ensure your storage adapter supports concurrent access if needed
- For multi-instance deployments, use shared storage (Redis, database)
- For single-instance apps, local storage is sufficient
- Test concurrent callback handling scenarios
Additional Security Recommendations
[ ] URL Validation: Validate OAuth provider URLs against a whitelist
const ALLOWED_AUTH_DOMAINS = ['auth.example.com']; (url) => { const parsed = new URL(url); return parsed.protocol === 'https:' && ALLOWED_AUTH_DOMAINS.includes(parsed.hostname); };[ ] State Entropy: Use the library's built-in 256-bit random state (no action needed)
[ ] Code Verifier Security: Let the
pkce-challengelibrary generate verifiers (no action needed)[ ] Error Logging: Implement secure error logging that doesn't expose sensitive data
// ✅ Good: Log error codes/IDs console.error(`Auth failed: ${error.code} (${error.id})`); // ❌ Bad: Don't log state, codes, or tokens console.error(`Failed with state: ${state}`); // DON'T DO THIS[ ] Timeout Handling: Implement proper timeout handling for auth flows
- Auth state expires after 10 minutes (built-in)
- Used codes expire after 1 hour (built-in)
- Implement UI timeouts for pending auth flows
[ ] Content Security Policy: Configure CSP headers for web applications
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src https://auth.example.com" />[ ] Insecure Localhost: Ensure
allowHttpLocalhostis NOT enabled in production// ❌ Never do this in production startAuthFlow({ // ... allowHttpLocalhost: true, // DANGEROUS IN PRODUCTION });[ ] HTTP Localhost: Ensure
allowHttpLocalhostis appropriate for your environment// ⚠️ Review localhost settings for production vs development startAuthFlow({ // ... allowHttpLocalhost: true, // OK for development (RFC 8252) }); // ✅ Production typically uses HTTPS endpoints startAuthFlow({ // ... // allowHttpLocalhost defaults to false });
Testing Checklist
Before production deployment, test these scenarios:
- [ ] Authorization code reuse is prevented
- [ ] CSRF attacks are blocked (invalid state parameter)
- [ ] Code injection attacks fail (code binding verification)
- [ ] Expired states are rejected (10 minute expiration)
- [ ] HTTP localhost is configured appropriately (
allowHttpLocalhostsetting matches environment) - [ ] Localhost HTTP URLs work in development, secure HTTPS in production
- [ ] Expired codes are cleaned up (1 hour expiration)
- [ ] Concurrent auth flows don't interfere with each other
- [ ] Invalid redirect URIs are rejected
- [ ] Non-HTTPS URLs are rejected in production mode
License
MIT
