npm package: @pixpilot/pkce-auth
Description: Core PKCE authentication package for OAuth 2.0 flows with TypeScript support
Author: m.doaie
Latest version: 0.10.0

# @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](#security-checklist-for-implementers) to ensure proper security configuration.

## Installation

```bash
pnpm add @pixpilot/pkce-auth
```

## Quick Start

### Recommended: Class-Based API

The easiest way to use this library. Configure once and use for both steps:

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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: true` to 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

<details>
<summary>React Native (AsyncStorage)</summary>

```typescript
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);
  },
};
```

</details>

<details>
<summary>Node.js (File System)</summary>

```typescript
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
    }
  },
};
```

</details>

### Helper Functions

Use these to build your `AuthStorageOperations`:

```typescript
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

```typescript
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):

```typescript
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.

```typescript
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.

```typescript
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:

```typescript
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:

```typescript
// 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):**

```typescript
// 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):**

```typescript
// 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:

```typescript
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 expired
- `STATE_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 failed
- `INVALID_EXCHANGE_URL` - Exchange URL failed validation

### Best Practices

- **HTTPS Only**: Use `isHttpsUrl` validator 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 URL
- `request.code` - Authorization code from callback
- `request.codeVerifier` - Code verifier from `generatePKCE()`
- `request.clientId` - (optional) OAuth client ID
- `request.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 URL
- `config.redirectUrl` - Your callback URL
- `config.exchangeUrl` - Token exchange endpoint
- `config.clientId` - OAuth client ID
- `config.scope` - (optional) OAuth scopes
- `config.validateLoginUrl` - (optional) Login URL validator function
- `config.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 URL
- `config.receivedState` - State from callback URL
- `config.exchangeUrl` - Token exchange endpoint
- `config.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

```typescript
import { MAX_LENGTHS } from '@pixpilot/pkce-auth';

// Maximum allowed lengths for OAuth parameters
MAX_LENGTHS.STATE; // 256
MAX_LENGTHS.AUTH_CODE; // 512
```

## Security 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

  ```typescript
  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-keychain` or 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

  ```typescript
  // 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

  ```typescript
  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

  ```typescript
  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-challenge` library generate verifiers (no action needed)

- [ ] **Error Logging**: Implement secure error logging that doesn't expose sensitive data

  ```typescript
  // ✅ 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

  ```html
  <meta
    http-equiv="Content-Security-Policy"
    content="default-src 'self'; connect-src https://auth.example.com"
  />
  ```

- [ ] **Insecure Localhost**: Ensure `allowHttpLocalhost` is NOT enabled in production

  ```typescript
  // ❌ Never do this in production
  startAuthFlow({
    // ...
    allowHttpLocalhost: true, // DANGEROUS IN PRODUCTION
  });
  ```

- [ ] **HTTP Localhost**: Ensure `allowHttpLocalhost` is appropriate for your environment

  ```typescript
  // ⚠️ 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 (`allowHttpLocalhost` setting 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