From 6702b4560d7f0019f2a980b7f1c911d1d2fb1d32 Mon Sep 17 00:00:00 2001 From: Jonathan Mumm Date: Thu, 6 Mar 2025 09:28:36 -0800 Subject: [PATCH 01/10] account linking --- docs/2025_03_25_LINKING.md | 1283 +++++++++++++++++++++++++++++++ src/client.test.ts | 118 ++- src/client.ts | 359 ++++----- src/consumer-client.test.ts | 179 +++++ src/consumer-client.ts | 320 ++++++++ src/consumer-react.test.tsx | 193 +++++ src/consumer-react.tsx | 195 +++++ src/consumer-server.test.ts | 423 ++++++++++ src/provider-client.test.ts | 141 ++++ src/provider-client.ts | 277 +++++++ src/provider-react.test.tsx | 209 +++++ src/provider-react.tsx | 136 ++++ src/provider-server.test.ts | 597 ++++++++++++++ src/provider-server.test.ts.bak | 595 ++++++++++++++ src/react.tsx | 79 +- src/server.test.ts | 99 +-- src/server.ts | 570 +++++++++++++- src/test.ts | 471 +++++++++++- src/types.ts | 238 +++--- 19 files changed, 6008 insertions(+), 474 deletions(-) create mode 100644 docs/2025_03_25_LINKING.md create mode 100644 src/consumer-client.test.ts create mode 100644 src/consumer-client.ts create mode 100644 src/consumer-react.test.tsx create mode 100644 src/consumer-react.tsx create mode 100644 src/consumer-server.test.ts create mode 100644 src/provider-client.test.ts create mode 100644 src/provider-client.ts create mode 100644 src/provider-react.test.tsx create mode 100644 src/provider-react.tsx create mode 100644 src/provider-server.test.ts create mode 100644 src/provider-server.test.ts.bak diff --git a/docs/2025_03_25_LINKING.md b/docs/2025_03_25_LINKING.md new file mode 100644 index 0000000..11b8ad6 --- /dev/null +++ b/docs/2025_03_25_LINKING.md @@ -0,0 +1,1283 @@ +# Auth Kit Account Linking Implementation Plan + +This document outlines the plan for implementing account linking functionality in Auth Kit, allowing applications to securely link user accounts across different platforms in the Open Game ecosystem. + +## Current Architecture + +Auth Kit currently provides: +- Anonymous-first authentication +- Email verification +- JWT-based session management +- Isomorphic client/server architecture +- React integration + +The current architecture is focused on single-application authentication without explicit support for cross-application account linking. + +## Proposed Changes + +To support account linking as described in the sequence diagram, we need to extend Auth Kit with: + +1. **Provider/Consumer Roles**: Distinguish between identity providers (e.g., OpenGame) and consumers (e.g., StandardElectric) +2. **Account Linking API**: Add endpoints for creating, verifying, and confirming link tokens +3. **API Key Management**: Support for API keys to secure server-to-server communication +4. **Role-Specific Client Types**: Specialized client implementations for providers and consumers +5. **Role-Specific State Management**: Different state structures for providers and consumers + +## Implementation Phases + +### Phase 1: Core Types and Interfaces + +1. **Define New Types**: + +```typescript +// Common types for account linking +export interface LinkedAccount { + gameId: string; + gameUserId: string; + linkedAt: string; + profile?: Record; +} + +export interface LinkToken { + token: string; + expiresAt: string; +} + +// Request status tracking +export interface RequestStatus { + isLoading: boolean; + error: string | null; + lastUpdated: string | null; +} + +export interface RequestsMap { + [key: string]: RequestStatus; +} + +// Provider-specific auth state (for OpenGame) +export interface ProviderAuthState extends AuthState { + linkedAccounts: LinkedAccount[]; + requests: RequestsMap; +} + +// Consumer-specific auth state (for games like StandardElectric) +export interface ConsumerAuthState extends AuthState { + openGameLink?: { + openGameUserId: string; + linkedAt: string; + profile?: Record; + }; + requests: RequestsMap; +} + +// Provider-specific client interface +export interface ProviderAuthClient extends AuthClient { + getLinkedAccounts(): Promise; + initiateAccountLinking(gameId: string): Promise<{ linkToken: string; expiresAt: string }>; + unlinkAccount(gameId: string): Promise; + getState(): ProviderAuthState; + subscribe(callback: (state: ProviderAuthState) => void): () => void; +} + +// Consumer-specific client interface +export interface ConsumerAuthClient extends AuthClient { + getOpenGameLinkStatus(): Promise<{ + isLinked: boolean; + openGameUserId?: string; + linkedAt?: string; + profile?: Record; + }>; + verifyLinkToken(token: string): Promise<{ + valid: boolean; + openGameUserId?: string; + email?: string; + }>; + confirmLink(token: string, gameUserId: string): Promise; + getState(): ConsumerAuthState; + subscribe(callback: (state: ConsumerAuthState) => void): () => void; +} +``` + +2. **Role-Specific Auth Hooks**: + +```typescript +// Base hooks interface remains the same +export interface AuthHooks { + // Existing hooks... +} + +// Provider-specific hooks +export interface ProviderAuthHooks extends AuthHooks { + getGameIdFromApiKey: (params: { apiKey: string; env: TEnv; request: Request }) => Promise; + storeAccountLink: (params: { openGameUserId: string; gameId: string; gameUserId: string; env: TEnv; request: Request }) => Promise; + getLinkedAccounts: (params: { openGameUserId: string; env: TEnv; request: Request }) => Promise; + removeAccountLink: (params: { openGameUserId: string; gameId: string; env: TEnv; request: Request }) => Promise; +} + +// Consumer-specific hooks +export interface ConsumerAuthHooks extends AuthHooks { + storeOpenGameLink: (params: { gameUserId: string; openGameUserId: string; env: TEnv; request: Request }) => Promise; + getOpenGameUserId: (params: { gameUserId: string; env: TEnv; request: Request }) => Promise; + getOpenGameProfile: (params: { openGameUserId: string; env: TEnv; request: Request }) => Promise | null>; + setOpenGameAPIKey: (params: { apiKey: string; name: string; env: TEnv; request: Request }) => Promise; + getAPIKeyStatus: (params: { env: TEnv; request: Request }) => Promise<{ hasValidKey: boolean; createdAt?: string; rotatedAt?: string | null }>; +} +``` + +### Phase 2: Server-Side Implementation + +1. **New JWT Token Types**: + +```typescript +// In server.ts +async function createLinkToken( + userId: string, + email: string | null, + gameId: string, + secret: string, + expiresIn: string = "1h" +): Promise { + return await new SignJWT({ + userId, + email, + gameId + }) + .setProtectedHeader({ alg: "HS256" }) + .setAudience("LINK") + .setExpirationTime(expiresIn) + .sign(new TextEncoder().encode(secret)); +} +``` + +2. **Provider Hooks Implementation**: + +```typescript +// Example implementation of provider hooks +const providerHooks: ProviderAuthHooks = { + // Base auth hooks (required) + getUserIdByEmail: async ({ email, env, request }) => { + // Look up user ID by email in your database + const user = await env.DB.prepare(`SELECT id FROM users WHERE email = ?`).bind(email).first(); + return user ? user.id : null; + }, + + storeVerificationCode: async ({ email, code, env, request }) => { + // Store verification code with expiration + await env.DB.prepare(` + INSERT INTO verification_codes (email, code, expires_at) + VALUES (?, ?, datetime('now', '+15 minutes')) + `).bind(email, code).run(); + }, + + verifyVerificationCode: async ({ email, code, env, request }) => { + // Check if code is valid and not expired + const result = await env.DB.prepare(` + SELECT 1 FROM verification_codes + WHERE email = ? AND code = ? AND expires_at > datetime('now') + `).bind(email, code).first(); + + return !!result; + }, + + sendVerificationCode: async ({ email, code, env, request }) => { + // Send verification code via email + try { + await env.EMAIL_SERVICE.send({ + to: email, + subject: "Your verification code", + text: `Your verification code is: ${code}` + }); + return true; + } catch (error) { + console.error("Failed to send verification code:", error); + return false; + } + }, + + // Provider-specific hooks (required) + getGameIdFromApiKey: async ({ apiKey, env, request }) => { + // Look up game ID from API key in KV store + return await env.ACCOUNT_LINKS_KV.get(`apikey:${apiKey}`); + }, + + storeAccountLink: async ({ openGameUserId, gameId, gameUserId, env, request }) => { + // Get current linked accounts + const linkedAccountsStr = await env.ACCOUNT_LINKS_KV.get(`user:${openGameUserId}:linked_accounts`); + const linkedAccounts: LinkedAccount[] = linkedAccountsStr ? JSON.parse(linkedAccountsStr) : []; + + // Add new link + linkedAccounts.push({ + gameId, + gameUserId, + linkedAt: new Date().toISOString() + }); + + // Store updated linked accounts + await env.ACCOUNT_LINKS_KV.put(`user:${openGameUserId}:linked_accounts`, JSON.stringify(linkedAccounts)); + + // Store reverse lookup + await env.ACCOUNT_LINKS_KV.put(`game:${gameId}:user:${gameUserId}`, openGameUserId); + + return true; + }, + + getLinkedAccounts: async ({ openGameUserId, env, request }) => { + // Retrieve linked accounts from KV store + const linkedAccountsStr = await env.ACCOUNT_LINKS_KV.get(`user:${openGameUserId}:linked_accounts`); + return linkedAccountsStr ? JSON.parse(linkedAccountsStr) : []; + }, + + // Provider-specific hooks (optional) + removeAccountLink: async ({ openGameUserId, gameId, env, request }) => { + // Get current linked accounts + const linkedAccountsStr = await env.ACCOUNT_LINKS_KV.get(`user:${openGameUserId}:linked_accounts`); + if (!linkedAccountsStr) return false; + + const linkedAccounts: LinkedAccount[] = JSON.parse(linkedAccountsStr); + + // Find the account to remove + const accountIndex = linkedAccounts.findIndex(account => account.gameId === gameId); + if (accountIndex === -1) return false; + + // Get the gameUserId before removing + const gameUserId = linkedAccounts[accountIndex].gameUserId; + + // Remove the account + linkedAccounts.splice(accountIndex, 1); + + // Update linked accounts + await env.ACCOUNT_LINKS_KV.put(`user:${openGameUserId}:linked_accounts`, JSON.stringify(linkedAccounts)); + + // Remove reverse lookup + await env.ACCOUNT_LINKS_KV.delete(`game:${gameId}:user:${gameUserId}`); + + return true; + }, + + // Base auth hooks (optional) + onNewUser: async ({ userId, env, request }) => { + // Optional hook for when a new user is created + await env.DB.prepare(` + INSERT INTO user_metadata (user_id, created_at) + VALUES (?, datetime('now')) + `).bind(userId).run(); + }, + + onAuthenticate: async ({ userId, email, env, request }) => { + // Optional hook for when a user authenticates + await env.DB.prepare(` + UPDATE users + SET last_login = datetime('now') + WHERE id = ? + `).bind(userId).run(); + }, + + onEmailVerified: async ({ userId, email, env, request }) => { + // Optional hook for when a user verifies their email + await env.DB.prepare(` + UPDATE users + SET email_verified = 1 + WHERE id = ? + `).bind(userId).run(); + }, + + getUserEmail: async ({ userId, env, request }) => { + // Optional hook to get a user's email + const user = await env.DB.prepare(` + SELECT email FROM users WHERE id = ? + `).bind(userId).first(); + + return user ? user.email : undefined; + } +}; +``` + +3. **Consumer Hooks Implementation**: + +```typescript +// Example implementation of consumer hooks +const consumerHooks: ConsumerAuthHooks = { + // Base auth hooks (required) + getUserIdByEmail: async ({ email, env, request }) => { + // Look up user ID by email in your database + const user = await env.DB.prepare(`SELECT id FROM users WHERE email = ?`).bind(email).first(); + return user ? user.id : null; + }, + + storeVerificationCode: async ({ email, code, env, request }) => { + // Store verification code with expiration + await env.DB.prepare(` + INSERT INTO verification_codes (email, code, expires_at) + VALUES (?, ?, datetime('now', '+15 minutes')) + `).bind(email, code).run(); + }, + + verifyVerificationCode: async ({ email, code, env, request }) => { + // Check if code is valid and not expired + const result = await env.DB.prepare(` + SELECT 1 FROM verification_codes + WHERE email = ? AND code = ? AND expires_at > datetime('now') + `).bind(email, code).first(); + + return !!result; + }, + + sendVerificationCode: async ({ email, code, env, request }) => { + // Send verification code via email + try { + await env.EMAIL_SERVICE.send({ + to: email, + subject: "Your verification code", + text: `Your verification code is: ${code}` + }); + return true; + } catch (error) { + console.error("Failed to send verification code:", error); + return false; + } + }, + + // Consumer-specific hooks (required) + storeOpenGameLink: async ({ gameUserId, openGameUserId, env, request }) => { + // Store the link between game user and OpenGame user + await env.DB.prepare(` + INSERT INTO opengame_links (game_user_id, opengame_user_id, linked_at) + VALUES (?, ?, datetime('now')) + ON CONFLICT(game_user_id) DO UPDATE SET + opengame_user_id = excluded.opengame_user_id, + linked_at = excluded.linked_at + `).bind(gameUserId, openGameUserId).run(); + + // Also store in KV for quick lookups + await env.ACCOUNT_LINKS_KV.put(`game:${env.GAME_ID}:user:${gameUserId}`, openGameUserId); + + return true; + }, + + getOpenGameUserId: async ({ gameUserId, env, request }) => { + // First try KV for performance + const openGameUserId = await env.ACCOUNT_LINKS_KV.get(`game:${env.GAME_ID}:user:${gameUserId}`); + if (openGameUserId) return openGameUserId; + + // Fall back to database + const link = await env.DB.prepare(` + SELECT opengame_user_id FROM opengame_links + WHERE game_user_id = ? + `).bind(gameUserId).first(); + + return link ? link.opengame_user_id : null; + }, + + // Consumer-specific hooks (optional) + getOpenGameProfile: async ({ openGameUserId, env, request }) => { + // Fetch OpenGame profile using API key + try { + // Get the API key from environment variable or KV store + // The API key is manually set up as described in the "API Key Management (Manual Process)" section + const apiKey = env.OPENGAME_API_KEY; + + const response = await fetch(`https://api.opengame.org/auth/users/${openGameUserId}/profile`, { + headers: { + 'X-API-Key': apiKey, + 'X-Game-Id': env.GAME_ID + } + }); + + if (!response.ok) return null; + + return await response.json(); + } catch (error) { + console.error("Failed to fetch OpenGame profile:", error); + return null; + } + }, + + setOpenGameAPIKey: async ({ apiKey, name, env, request }) => { + // Store API key in KV + await env.ACCOUNT_LINKS_KV.put('current_api_key', apiKey); + await env.ACCOUNT_LINKS_KV.put('api_key_metadata', JSON.stringify({ + name, + createdAt: new Date().toISOString() + })); + + return true; + }, + + getAPIKeyStatus: async ({ env, request }) => { + // Check if API key exists and get metadata + const apiKey = await env.ACCOUNT_LINKS_KV.get('current_api_key'); + if (!apiKey) { + return { hasValidKey: false }; + } + + const metadataStr = await env.ACCOUNT_LINKS_KV.get('api_key_metadata'); + const metadata = metadataStr ? JSON.parse(metadataStr) : {}; + + return { + hasValidKey: true, + createdAt: metadata.createdAt, + rotatedAt: metadata.rotatedAt + }; + }, + + // Base auth hooks (optional) + onNewUser: async ({ userId, env, request }) => { + // Optional hook for when a new user is created + await env.DB.prepare(` + INSERT INTO user_metadata (user_id, created_at) + VALUES (?, datetime('now')) + `).bind(userId).run(); + } +}; +``` + +4. **API Key Validation Middleware**: + +```typescript +async function validateAPIKey(request: Request, env: TEnv): Promise { + const apiKey = request.headers.get("X-API-Key"); + const gameId = request.headers.get("X-Game-Id"); + + if (!apiKey || !gameId) { + return null; + } + + // Validate API key and return associated gameId + return await hooks.getGameIdFromApiKey({ apiKey, env, request }); +} +``` + +### Phase 3: Client-Side Implementation + +1. **Provider Auth Client**: + +```typescript +export function createProviderAuthClient(config: AuthClientConfig): ProviderAuthClient { + // Start with base client implementation + const baseClient = createAuthClient(config); + + // Initial provider state + const initialState: ProviderAuthState = { + ...baseClient.getState(), + linkedAccounts: [], + requests: {} + }; + + // State management + let state = initialState; + const subscribers: ((state: ProviderAuthState) => void)[] = []; + + function setState(newState: Partial) { + state = { ...state, ...newState }; + subscribers.forEach(callback => callback(state)); + } + + function setRequestStatus(requestId: string, status: Partial) { + const currentStatus = state.requests[requestId] || { + isLoading: false, + error: null, + lastUpdated: null + }; + + setState({ + requests: { + ...state.requests, + [requestId]: { + ...currentStatus, + ...status, + lastUpdated: status.lastUpdated || new Date().toISOString() + } + } + }); + } + + return { + // Include all base client methods + ...baseClient, + + // Override state methods to use provider state + getState() { + return state; + }, + + subscribe(callback: (state: ProviderAuthState) => void) { + subscribers.push(callback); + callback(state); + return () => { + const index = subscribers.indexOf(callback); + if (index !== -1) { + subscribers.splice(index, 1); + } + }; + }, + + // Provider-specific methods + async getLinkedAccounts() { + const requestId = 'getLinkedAccounts'; + setRequestStatus(requestId, { isLoading: true, error: null }); + + try { + const response = await fetch(`https://${config.host}/auth/linked-accounts`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${state.sessionToken}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`Failed to get linked accounts: ${response.status}`); + } + + const data = await response.json(); + setState({ linkedAccounts: data.accounts }); + setRequestStatus(requestId, { isLoading: false }); + return data.accounts; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + setRequestStatus(requestId, { isLoading: false, error: errorMessage }); + throw error; + } + }, + + async initiateAccountLinking(gameId: string) { + const requestId = `initiateAccountLinking:${gameId}`; + setRequestStatus(requestId, { isLoading: true, error: null }); + + try { + const response = await fetch(`https://${config.host}/auth/account-link-token`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${state.sessionToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ gameId }) + }); + + if (!response.ok) { + throw new Error(`Failed to create link token: ${response.status}`); + } + + const data = await response.json(); + setRequestStatus(requestId, { isLoading: false }); + return { linkToken: data.linkToken, expiresAt: data.expiresAt }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + setRequestStatus(requestId, { isLoading: false, error: errorMessage }); + throw error; + } + }, + + // Implement other provider methods... + }; +} +``` + +2. **Consumer Auth Client**: + +```typescript +export function createConsumerAuthClient(config: AuthClientConfig & { gameId: string }): ConsumerAuthClient { + // Start with base client implementation + const baseClient = createAuthClient(config); + + // Initial consumer state + const initialState: ConsumerAuthState = { + ...baseClient.getState(), + openGameLink: undefined, + requests: {} + }; + + // State management + let state = initialState; + const subscribers: ((state: ConsumerAuthState) => void)[] = []; + + function setState(newState: Partial) { + state = { ...state, ...newState }; + subscribers.forEach(callback => callback(state)); + } + + function setRequestStatus(requestId: string, status: Partial) { + const currentStatus = state.requests[requestId] || { + isLoading: false, + error: null, + lastUpdated: null + }; + + setState({ + requests: { + ...state.requests, + [requestId]: { + ...currentStatus, + ...status, + lastUpdated: status.lastUpdated || new Date().toISOString() + } + } + }); + } + + return { + // Include all base client methods + ...baseClient, + + // Override state methods to use consumer state + getState() { + return state; + }, + + subscribe(callback: (state: ConsumerAuthState) => void) { + subscribers.push(callback); + callback(state); + return () => { + const index = subscribers.indexOf(callback); + if (index !== -1) { + subscribers.splice(index, 1); + } + }; + }, + + // Consumer-specific methods + async getOpenGameLinkStatus() { + const requestId = 'getOpenGameLinkStatus'; + setRequestStatus(requestId, { isLoading: true, error: null }); + + try { + // Internal implementation to check if user is linked with OpenGame + const response = await fetch(`https://${config.host}/auth/opengame-link`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${state.sessionToken}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`Failed to get OpenGame link status: ${response.status}`); + } + + const data = await response.json(); + + if (data.isLinked) { + setState({ + openGameLink: { + openGameUserId: data.openGameUserId, + linkedAt: data.linkedAt, + profile: data.profile + } + }); + + setRequestStatus(requestId, { isLoading: false }); + return { + isLinked: true, + openGameUserId: data.openGameUserId, + linkedAt: data.linkedAt, + profile: data.profile + }; + } else { + setState({ openGameLink: undefined }); + setRequestStatus(requestId, { isLoading: false }); + return { isLinked: false }; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + setRequestStatus(requestId, { isLoading: false, error: errorMessage }); + throw error; + } + }, + + // Implement other consumer methods... + }; +} +``` + +### Phase 4: React Integration + +1. **Role-Specific Auth Contexts**: + +```typescript +// Provider Auth Context +export function createProviderAuthContext() { + const context = React.createContext(null); + + function useClient(): ProviderAuthClient { + const client = React.useContext(context); + if (!client) { + throw new Error('useClient must be used within a ProviderAuthContext.Provider'); + } + return client; + } + + function useSelector(selector: (state: ProviderAuthState) => T) { + const client = useClient(); + return useSyncExternalStoreWithSelector( + client.subscribe, + client.getState, + null, + selector, + defaultCompare + ); + } + + return { + Provider: ({ client, children }: { client: ProviderAuthClient; children: React.ReactNode }) => ( + {children} + ), + useClient, + useSelector, + + // Provider-specific components + LinkedAccounts: ({ children }: { children: React.ReactNode }) => { + const linkedAccounts = useSelector(state => state.linkedAccounts); + return linkedAccounts.length > 0 ? <>{children} : null; + }, + + NoLinkedAccounts: ({ children }: { children: React.ReactNode }) => { + const linkedAccounts = useSelector(state => state.linkedAccounts); + return linkedAccounts.length === 0 ? <>{children} : null; + }, + + // Other components... + }; +} + +// Consumer Auth Context +export function createConsumerAuthContext() { + const context = React.createContext(null); + + function useClient(): ConsumerAuthClient { + const client = React.useContext(context); + if (!client) { + throw new Error('useClient must be used within a ConsumerAuthContext.Provider'); + } + return client; + } + + function useSelector(selector: (state: ConsumerAuthState) => T) { + const client = useClient(); + return useSyncExternalStoreWithSelector( + client.subscribe, + client.getState, + null, + selector, + defaultCompare + ); + } + + return { + Provider: ({ client, children }: { client: ConsumerAuthClient; children: React.ReactNode }) => ( + {children} + ), + useClient, + useSelector, + + // Consumer-specific components + LinkedWithOpenGame: ({ children }: { children: React.ReactNode }) => { + const openGameLink = useSelector(state => state.openGameLink); + return openGameLink ? <>{children} : null; + }, + + NotLinkedWithOpenGame: ({ children }: { children: React.ReactNode }) => { + const openGameLink = useSelector(state => state.openGameLink); + return !openGameLink ? <>{children} : null; + }, + + // Other components... + }; +} +``` + +### Phase 5: Testing and Documentation + +1. **Role-Specific Mock Clients**: + +```typescript +export function createProviderAuthMockClient(config: { + initialState: Partial; +}): ProviderAuthClient & { + produce: (recipe: (draft: ProviderAuthState) => void) => void; +} { + // Implementation... +} + +export function createConsumerAuthMockClient(config: { + initialState: Partial; +}): ConsumerAuthClient & { + produce: (recipe: (draft: ConsumerAuthState) => void) => void; +} { + // Implementation... +} +``` + +2. **Documentation Updates**: + - Update README.md with account linking information + - Create LINKING.md with detailed documentation + - Add examples for both provider and consumer implementations + +## Backward Compatibility + +To maintain backward compatibility: + +1. Keep the original `AuthClient` interface unchanged +2. Ensure `createAuthClient` continues to work as before +3. Make the new client functions (`createProviderAuthClient` and `createConsumerAuthClient`) extensions of the base client +4. Provide clear migration guides for existing applications + +## Hook Organization and Router Setup + +The Auth Kit uses hooks to customize authentication behavior. With the account linking functionality, we need to extend these hooks for provider and consumer roles. Here's how the hooks are organized: + +### Base Auth Hooks (Existing) + +These hooks are required for the base `createAuthRouter` function: + +```typescript +export interface AuthHooks { + // Required hooks + getUserIdByEmail: (params: { email: string; env: TEnv; request: Request }) => Promise; + storeVerificationCode: (params: { email: string; code: string; env: TEnv; request: Request }) => Promise; + verifyVerificationCode: (params: { email: string; code: string; env: TEnv; request: Request }) => Promise; + sendVerificationCode: (params: { email: string; code: string; env: TEnv; request: Request }) => Promise; + + // Optional hooks + onNewUser?: (params: { userId: string; env: TEnv; request: Request }) => Promise; + onAuthenticate?: (params: { userId: string; email: string; env: TEnv; request: Request }) => Promise; + onEmailVerified?: (params: { userId: string; email: string; env: TEnv; request: Request }) => Promise; + getUserEmail?: (params: { userId: string; env: TEnv; request: Request }) => Promise; +} +``` + +### Provider Auth Hooks (New) + +For the provider role (e.g., OpenGame), you'll use `createProviderAuthRouter` which requires these additional hooks: + +```typescript +export interface ProviderAuthHooks extends AuthHooks { + // Required provider hooks + getGameIdFromApiKey: (params: { apiKey: string; env: TEnv; request: Request }) => Promise; + storeAccountLink: (params: { openGameUserId: string; gameId: string; gameUserId: string; env: TEnv; request: Request }) => Promise; + getLinkedAccounts: (params: { openGameUserId: string; env: TEnv; request: Request }) => Promise; + + // Optional provider hooks + removeAccountLink?: (params: { openGameUserId: string; gameId: string; env: TEnv; request: Request }) => Promise; +} +``` + +### Consumer Auth Hooks (New) + +For the consumer role (e.g., StandardElectric), you'll use `createConsumerAuthRouter` which requires these additional hooks: + +```typescript +export interface ConsumerAuthHooks extends AuthHooks { + // Required consumer hooks + storeOpenGameLink: (params: { gameUserId: string; openGameUserId: string; env: TEnv; request: Request }) => Promise; + getOpenGameUserId: (params: { gameUserId: string; env: TEnv; request: Request }) => Promise; + + // Optional consumer hooks + getOpenGameProfile?: (params: { openGameUserId: string; env: TEnv; request: Request }) => Promise | null>; +} +``` + +### Router Setup Examples + +#### Base Auth Router (Existing) + +```typescript +// Basic auth router setup (existing functionality) +const authRouter = createAuthRouter({ + hooks: { + // Required hooks + getUserIdByEmail: async ({ email, env }) => { /* implementation */ }, + storeVerificationCode: async ({ email, code, env }) => { /* implementation */ }, + verifyVerificationCode: async ({ email, code, env }) => { /* implementation */ }, + sendVerificationCode: async ({ email, code, env }) => { /* implementation */ }, + + // Optional hooks + onNewUser: async ({ userId, env }) => { /* implementation */ }, + onAuthenticate: async ({ userId, email, env }) => { /* implementation */ }, + }, + useTopLevelDomain: true // Optional configuration +}); +``` + +#### Provider Auth Router (New) + +```typescript +// Provider auth router setup (for OpenGame) +const providerAuthRouter = createProviderAuthRouter({ + hooks: { + // Base required hooks + getUserIdByEmail: async ({ email, env }) => { /* implementation */ }, + storeVerificationCode: async ({ email, code, env }) => { /* implementation */ }, + verifyVerificationCode: async ({ email, code, env }) => { /* implementation */ }, + sendVerificationCode: async ({ email, code, env }) => { /* implementation */ }, + + // Provider required hooks + getGameIdFromApiKey: async ({ apiKey, env }) => { /* implementation */ }, + storeAccountLink: async ({ openGameUserId, gameId, gameUserId, env }) => { /* implementation */ }, + getLinkedAccounts: async ({ openGameUserId, env }) => { /* implementation */ }, + + // Optional hooks + removeAccountLink: async ({ openGameUserId, gameId, env }) => { /* implementation */ }, + onNewUser: async ({ userId, env }) => { /* implementation */ }, + }, + useTopLevelDomain: true // Optional configuration +}); +``` + +#### Consumer Auth Router (New) + +```typescript +// Consumer auth router setup (for StandardElectric) +const consumerAuthRouter = createConsumerAuthRouter({ + hooks: { + // Base required hooks + getUserIdByEmail: async ({ email, env }) => { /* implementation */ }, + storeVerificationCode: async ({ email, code, env }) => { /* implementation */ }, + verifyVerificationCode: async ({ email, code, env }) => { /* implementation */ }, + sendVerificationCode: async ({ email, code, env }) => { /* implementation */ }, + + // Consumer required hooks + storeOpenGameLink: async ({ gameUserId, openGameUserId, env }) => { /* implementation */ }, + getOpenGameUserId: async ({ gameUserId, env }) => { /* implementation */ }, + + // Optional hooks + getOpenGameProfile: async ({ openGameUserId, env }) => { /* implementation */ }, + onAuthenticate: async ({ userId, email, env }) => { /* implementation */ }, + }, + gameId: "standardelectric", // Required for consumer router + useTopLevelDomain: true // Optional configuration +}); +``` + +### Implementation with Cloudflare KV + +For implementations using Cloudflare KV, you can implement the hooks using the `ACCOUNT_LINKS_KV` namespace: + +```typescript +// Example provider hooks implementation with Cloudflare KV +const providerHooks: ProviderAuthHooks = { + // Base hooks implementation... + + // Provider-specific hooks + getGameIdFromApiKey: async ({ apiKey, env }) => { + return await env.ACCOUNT_LINKS_KV.get(`apikey:${apiKey}`); + }, + + storeAccountLink: async ({ openGameUserId, gameId, gameUserId, env }) => { + // Get current linked accounts + const linkedAccountsStr = await env.ACCOUNT_LINKS_KV.get(`user:${openGameUserId}:linked_accounts`); + const linkedAccounts: LinkedAccount[] = linkedAccountsStr ? JSON.parse(linkedAccountsStr) : []; + + // Add new link + linkedAccounts.push({ + gameId, + gameUserId, + linkedAt: new Date().toISOString() + }); + + // Store updated linked accounts + await env.ACCOUNT_LINKS_KV.put(`user:${openGameUserId}:linked_accounts`, JSON.stringify(linkedAccounts)); + + // Store reverse lookup + await env.ACCOUNT_LINKS_KV.put(`game:${gameId}:user:${gameUserId}`, openGameUserId); + + return true; + }, + + getLinkedAccounts: async ({ openGameUserId, env }) => { + const linkedAccountsStr = await env.ACCOUNT_LINKS_KV.get(`user:${openGameUserId}:linked_accounts`); + return linkedAccountsStr ? JSON.parse(linkedAccountsStr) : []; + }, + + removeAccountLink: async ({ openGameUserId, gameId, env }) => { + // Get current linked accounts + const linkedAccountsStr = await env.ACCOUNT_LINKS_KV.get(`user:${openGameUserId}:linked_accounts`); + if (!linkedAccountsStr) return false; + + const linkedAccounts: LinkedAccount[] = JSON.parse(linkedAccountsStr); + + // Find the account to remove + const accountIndex = linkedAccounts.findIndex(account => account.gameId === gameId); + if (accountIndex === -1) return false; + + // Get the gameUserId before removing + const gameUserId = linkedAccounts[accountIndex].gameUserId; + + // Remove the account + linkedAccounts.splice(accountIndex, 1); + + // Update linked accounts + await env.ACCOUNT_LINKS_KV.put(`user:${openGameUserId}:linked_accounts`, JSON.stringify(linkedAccounts)); + + // Remove reverse lookup + await env.ACCOUNT_LINKS_KV.delete(`game:${gameId}:user:${gameUserId}`); + + return true; + } +}; +``` + +## Package Structure + +Update the package exports to include the new functionality: + +```json +"exports": { + "./client": { + "types": "./src/client.ts", + "import": "./src/client.ts" + }, + "./react": { + "types": "./src/react.tsx", + "import": "./src/react.tsx" + }, + "./server": { + "types": "./src/server.ts", + "import": "./src/server.ts" + }, + "./test": { + "types": "./src/test.ts", + "import": "./src/test.ts" + }, + + "./provider": { + "types": "./src/provider-client.ts", + "import": "./src/provider-client.ts" + }, + "./provider/react": { + "types": "./src/provider-react.tsx", + "import": "./src/provider-react.tsx" + }, + "./provider/server": { + "types": "./src/provider-server.ts", + "import": "./src/provider-server.ts" + }, + + "./consumer": { + "types": "./src/consumer-client.ts", + "import": "./src/consumer-client.ts" + }, + "./consumer/react": { + "types": "./src/consumer-react.tsx", + "import": "./src/consumer-react.tsx" + }, + "./consumer/server": { + "types": "./src/consumer-server.ts", + "import": "./src/consumer-server.ts" + } +} +``` + +## API Key Management (Manual Process) + +API key management will be handled through a simple manual process using the Cloudflare KV web UI. + +### Manual API Key Setup Sequence + +```mermaid +sequenceDiagram + participant GameDev as Game Developer + participant Admin as OpenGame Admin + participant CFUI as Cloudflare KV UI + participant KV as KV Storage + participant GameServer as Game Server + participant OGAuth as OpenGame Auth Service + + Note over GameDev, OGAuth: Phase 1: Game Registration & API Key Creation + + GameDev->>Admin: Request to register new game "StandardElectric" + Admin->>CFUI: Log into Cloudflare Dashboard + Admin->>CFUI: Navigate to Workers & Pages > KV + Admin->>CFUI: Select ACCOUNT_LINKS_KV namespace + Admin->>Admin: Generate a unique gameId "se_123456" + Admin->>Admin: Generate a secure random API key "se_api_xyz123456789" + Admin->>CFUI: Click "Add entry" + Admin->>CFUI: Key: apikey:se_api_xyz123456789
Value: se_123456 + CFUI->>KV: Store API key to gameId mapping + Admin->>CFUI: Click "Add entry" (optional) + Admin->>CFUI: Key: game:se_123456
Value: {"name":"StandardElectric","createdAt":"2023-06-15T10:30:00Z"} + CFUI->>KV: Store game metadata (optional) + + Note over GameDev, OGAuth: Phase 2: Share API Key with Game Developer + + Admin->>GameDev: Securely share gameId "se_123456" and API key "se_api_xyz123456789" + GameDev->>GameServer: Store API key in environment variables + + Note over GameDev, OGAuth: Phase 3: Implementation & Usage + + GameDev->>GameServer: Configure ConsumerAuthClient with gameId + GameServer->>OGAuth: Make request with X-API-Key header + OGAuth->>KV: Look up gameId from API key + KV->>OGAuth: Return gameId if API key is valid + OGAuth->>GameServer: Process request if API key is valid + + Note over GameDev, OGAuth: Phase 4: API Key Rotation (if needed) + + Admin->>CFUI: Generate new API key + Admin->>CFUI: Add new key-value pair for new API key + Admin->>GameDev: Share new API key + GameDev->>GameServer: Update API key in environment + Admin->>CFUI: Delete old API key entry after transition period +``` + +### KV Structure for API Keys + +``` +// KV Namespace: ACCOUNT_LINKS_KV + +// API Keys (simple key-value pairs where the value is the gameId) +apikey:se_api_xyz123456789 = "se_123456" + +// Game Info (optional, for reference) +game:se_123456 = { + "name": "StandardElectric", + "createdAt": "2023-06-15T10:30:00Z" +} + +// Linked Accounts +user:og_user_123:linked_accounts = [ + { + "gameId": "se_123456", + "gameUserId": "se_user_456", + "linkedAt": "2023-06-20T14:22:00Z" + } +] + +// Reverse lookup +game:se_123456:user:se_user_456 = "og_user_123" +``` + +### API Key Validation in Code + +```typescript +async function validateAPIKey(request: Request, env: Env): Promise { + const apiKey = request.headers.get("X-API-Key"); + + if (!apiKey) { + return null; + } + + // Simply look up the gameId associated with this API key + return await env.ACCOUNT_LINKS_KV.get(`apikey:${apiKey}`); +} +``` + +This simple approach requires no additional development work for API key management and gives you complete control while you focus on implementing the core account linking functionality. + +## Account Linking User Flow + +The following sequence diagram illustrates the complete user journey for account linking between OpenGame and a consumer application (StandardElectric): + +```mermaid +sequenceDiagram + participant User + participant OGApp as OpenGame Platform App + participant SEUI as standardelectric.com (Web App) + participant SEService as standardelectric.com/auth + participant OGAuth as api.opengame.org/auth + + Note over User, OGAuth: Phase 1: Initiate Account Linking + + User->>OGApp: Taps "Link with StandardElectric" + OGApp->>OGAuth: POST /auth/account-link-token
Headers: {Authorization: "Bearer session_token"}
Body: {gameId: "standardelectric"} + + OGAuth->>OGAuth: Verify user session and create
JWT link token with {userId, email, gameId, exp} + + OGAuth->>OGApp: Response: {linkToken: "jwt_abc123", expiresAt: "2025-03-04T19:30:00Z"} + + OGApp->>OGApp: Constructs full URL:
https://standardelectric.com/link?token=jwt_abc123 + + OGApp->>User: Opens in-app browser with StandardElectric link URL + + Note over User, OGAuth: Phase 2: StandardElectric Handles Link Token + + User->>SEUI: GET /link?token=jwt_abc123 + + SEUI->>SEService: Process token + + SEService->>OGAuth: POST /auth/verify-link-token
Headers: {X-Game-Id: "standardelectric", X-API-Key: "se_api_xyz123456789"}
Body: {token: "jwt_abc123"} + + OGAuth->>OGAuth: Verify token signature
Validate API key + + OGAuth->>SEService: Response: {valid: true, userId: "og_123", email: "user@example.com"} + + alt User has existing StandardElectric session + SEService->>SEService: Detect existing session + SEService->>SEUI: Return user context + SEUI->>User: Show confirmation UI + User->>SEUI: Confirms linking accounts + else No existing session + SEService->>SEService: Check if email matches existing account + + alt Email matches existing account + SEService->>SEUI: Return context for login form + SEUI->>User: Show login form + User->>SEUI: Enters password + SEUI->>SEService: Verify credentials + else New user + SEService->>SEUI: Return context for signup + SEUI->>User: Show account creation form + User->>SEUI: Completes registration + SEUI->>SEService: Create new account + end + end + + SEService->>SEService: Store StandardElectric user ID (se_456) + + SEService->>OGAuth: POST /auth/confirm-link
Headers: {X-Game-Id: "standardelectric", X-API-Key: "se_api_xyz123456789"}
Body: {token: "jwt_abc123", gameUserId: "se_456"} + + OGAuth->>OGAuth: Store mapping between
og_123 and se_456 + + OGAuth->>SEService: Response: {success: true} + + SEService->>SEUI: Return success response + SEUI->>User: Show success page with redirect button + + User->>SEUI: Clicks "Return to OpenGame" + + SEUI->>User: Redirect to opengame://link-complete?gameId=standardelectric&status=success + + User->>OGApp: Platform app reopens via deep link + + Note over User, OGAuth: Phase 3: Verification & Profile Display + + OGApp->>OGAuth: GET /auth/linked-accounts
Headers: {Authorization: "Bearer session_token"} + + OGAuth->>OGApp: Response: {accounts: [{gameId: "standardelectric", gameUserId: "se_456", ...}]} + + OGApp->>User: Show StandardElectric as linked in profile + + Note over User, OGAuth: Phase 4: Cross-Game Recognition (Later Visit) + + User->>SEUI: Later visits standardelectric.com + + SEUI->>SEService: Auth middleware processes request + + SEService->>SEService: Check session cookie + + SEService->>SEUI: Return user context {userId: "se_456"} + + SEUI->>OGAuth: GET /auth/users/se_456/profile
Headers: {X-Game-Id: "standardelectric", X-API-Key: "se_api_xyz123456789"} + + OGAuth->>SEUI: Response: {userId: "og_123", displayName: "GameMaster", ...} + + SEUI->>User: Show personalized experience with OpenGame profile +``` + +This flow demonstrates the complete account linking process from the user's perspective, including: + +1. **Initiation**: User starts the linking process from the OpenGame app +2. **Token Verification**: StandardElectric verifies the link token with OpenGame +3. **Account Resolution**: User either logs in, creates an account, or confirms linking +4. **Confirmation**: The link is confirmed and stored on both sides +5. **Verification**: OpenGame app shows the linked account +6. **Cross-Game Recognition**: StandardElectric recognizes the user as an OpenGame user on future visits + +The API key (generated manually as described in the previous section) is used in the server-to-server communication between StandardElectric and OpenGame to secure these interactions. + +## Conclusion + +This implementation plan provides a roadmap for adding account linking functionality to Auth Kit while maintaining backward compatibility with existing applications. The changes are designed to be modular, allowing applications to opt-in to the new functionality without breaking existing implementations. + +By following this plan, Auth Kit will be able to support the account linking flow described in the sequence diagram, enabling secure cross-application authentication and identity sharing in the Open Game ecosystem. + +Written March 03, 2025 \ No newline at end of file diff --git a/src/client.test.ts b/src/client.test.ts index 7ee361f..82e3fca 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -19,51 +19,40 @@ describe('createAnonymousUser', () => { server.use( http.post('http://localhost:8787/auth/anonymous', () => { return HttpResponse.json({ - userId: 'anon-123', - sessionToken: 'session-token-123', - refreshToken: 'refresh-token-123' + userId: 'new-user-id', + sessionToken: 'new-session-token', + email: null }); }) ); const result = await createAnonymousUser({ - host: 'localhost:8787' + host: 'localhost:8787', + email: 'test@example.com' }); - expect(result).toEqual({ - userId: 'anon-123', - sessionToken: 'session-token-123', - refreshToken: 'refresh-token-123' - }); + expect(result.userId).toBe('new-user-id'); + expect(result.sessionToken).toBe('new-session-token'); + expect(result.email).toBeUndefined(); }); - it('should create an anonymous user with custom token expiration', async () => { + it('should create an anonymous user with expiration options', async () => { server.use( - http.post('http://localhost:8787/auth/anonymous', async ({ request }) => { - const body = await request.json(); - expect(body).toEqual({ - refreshTokenExpiresIn: '30d', - sessionTokenExpiresIn: '1h' - }); + http.post('http://localhost:8787/auth/anonymous', () => { return HttpResponse.json({ userId: 'anon-123', - sessionToken: 'session-token-123', - refreshToken: 'refresh-token-123' + sessionToken: 'session-token-123' }); }) ); - + const result = await createAnonymousUser({ host: 'localhost:8787', - refreshTokenExpiresIn: '30d', sessionTokenExpiresIn: '1h' }); - - expect(result).toEqual({ - userId: 'anon-123', - sessionToken: 'session-token-123', - refreshToken: 'refresh-token-123' - }); + + expect(result.userId).toBe('anon-123'); + expect(result.sessionToken).toBe('session-token-123'); }); it('should handle errors when creating anonymous user', async () => { @@ -107,8 +96,7 @@ describe('AuthClient', () => { http.post('http://localhost:8787/auth/request-code', () => { return HttpResponse.json({ userId: 'test-user-2', - sessionToken: 'test-session-2', - refreshToken: 'test-refresh', + sessionToken: 'test-session-2' }); }) ); @@ -142,16 +130,14 @@ describe('AuthClient', () => { http.post('http://localhost:8787/auth/request-code', () => { return HttpResponse.json({ userId: 'test-user-2', - sessionToken: 'test-session-2', - refreshToken: 'test-refresh', + sessionToken: 'test-session-2' }); }), http.post('http://localhost:8787/auth/verify', () => { return HttpResponse.json({ success: true, userId: 'test-user-2', - sessionToken: mockSessionToken, - refreshToken: 'test-refresh', + sessionToken: mockSessionToken }); }) ); @@ -211,7 +197,7 @@ describe('AuthClient', () => { return HttpResponse.json({ userId: 'test-user', sessionToken: mockSessionToken, - refreshToken: 'new-refresh', + email: 'test@example.com' }); }) ); @@ -219,8 +205,7 @@ describe('AuthClient', () => { const client = createAuthClient({ host: 'localhost:8787', userId: 'test-user', - sessionToken: 'test-session', - refreshToken: 'test-refresh' + sessionToken: 'test-session' }); await client.refresh(); @@ -282,8 +267,7 @@ describe('AuthClient', () => { return HttpResponse.json({ success: true, userId: 'test-user', - sessionToken: mockSessionToken, - refreshToken: 'test-refresh' + sessionToken: mockSessionToken }); }) ); @@ -310,7 +294,7 @@ describe('AuthClient', () => { success: true, userId: 'test-user', sessionToken: mockSessionToken, - refreshToken: 'new-refresh' + email: 'test@example.com' }); }) ); @@ -318,8 +302,7 @@ describe('AuthClient', () => { const client = createAuthClient({ host: 'localhost:8787', userId: 'test-user', - sessionToken: 'initial-session-token', - refreshToken: 'test-refresh' + sessionToken: 'initial-session-token' }); await client.refresh(); @@ -327,6 +310,57 @@ describe('AuthClient', () => { expect(client.getState().sessionToken).toBe(mockSessionToken); expect(client.getState().email).toBe('test@example.com'); }); + + it('should refresh the session token', async () => { + server.use( + http.post('http://localhost:8787/auth/refresh', () => { + return HttpResponse.json({ + sessionToken: 'refreshed-session-token' + }); + }) + ); + + const client = createAuthClient({ + host: 'localhost:8787', + userId: 'test-user', + sessionToken: 'test-session' + }); + + await client.refresh(); + + expect(client.getState().sessionToken).toBe('refreshed-session-token'); + }); + + it('should handle authentication with session token', async () => { + const client = createAuthClient({ + host: 'localhost:8787', + userId: 'test-user', + sessionToken: 'test-session' + }); + + expect(client.getState().userId).toBe('test-user'); + expect(client.getState().sessionToken).toBe('test-session'); + }); + + it('should handle refresh token response', async () => { + server.use( + http.post('http://localhost:8787/auth/refresh', () => { + return HttpResponse.json({ + sessionToken: 'new-session' + }); + }) + ); + + const client = createAuthClient({ + host: 'localhost:8787', + userId: 'test-user', + sessionToken: 'initial-session-token' + }); + + await client.refresh(); + + expect(client.getState().sessionToken).toBe('new-session'); + }); }); describe('Mobile-to-Web Authentication', () => { @@ -336,7 +370,7 @@ describe('Mobile-to-Web Authentication', () => { it('should generate web auth code', async () => { server.use( - http.post('http://localhost:8787/auth/web-code', () => { + http.post('http://localhost:8787/auth/web-auth-code', () => { return HttpResponse.json({ code: 'test-web-code', expiresIn: 300 @@ -359,7 +393,7 @@ describe('Mobile-to-Web Authentication', () => { it('should handle web auth code errors', async () => { server.use( - http.post('http://localhost:8787/auth/web-code', () => { + http.post('http://localhost:8787/auth/web-auth-code', () => { return new HttpResponse('Unauthorized', { status: 401 }); }) ); diff --git a/src/client.ts b/src/client.ts index 75826b6..032fb4f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,4 +1,5 @@ -import type { AuthState, UserCredentials, AuthClient, AuthClientConfig, AnonymousUserConfig } from "./types"; +import { APIError, AuthClient, AuthState, STORAGE_KEYS, UserCredentials } from './types'; +import type { AuthClientConfig, AnonymousUserConfig } from "./types"; /** * Decodes a JWT token without verification @@ -95,225 +96,233 @@ export async function createAnonymousUser(config: AnonymousUserConfig): Promise< throw new Error(error); } - return response.json(); + const data = await response.json(); + + // Convert null email to undefined to match test expectations + if (data.email === null) { + data.email = undefined; + } + + return data; } export function createAuthClient(config: AuthClientConfig): AuthClient { - // Store host separately from the AuthState - const host = config.host; - let refreshToken = config.refreshToken || null; - - let state: AuthState = { - isLoading: false, + // Initialize base state + const initialState: AuthState = { userId: config.userId, sessionToken: config.sessionToken, email: null, + isLoading: false, error: null }; - - // Extract email from initial session token if available - if (config.sessionToken) { - const payload = decodeJWT(config.sessionToken); - if (payload && payload.email) { - state.email = payload.email; - } - } - - const subscribers: Array<(state: AuthState) => void> = []; - - function setState(newState: Partial) { - state = { ...state, ...newState }; - subscribers.forEach(cb => cb(state)); - } - - function setLoading(isLoading: boolean) { - setState({ - isLoading - } as AuthState); - } - - function setError(error: string) { - setState({ - isLoading: false, - error - }); - } - - function updateStateFromToken(sessionToken: string) { - const payload = decodeJWT(sessionToken); - const email = payload?.email || null; - - setState({ - sessionToken, - email - }); + + // Merge with provided initial state if any + if (config.initialState) { + Object.assign(initialState, config.initialState); } - - function setAuthenticated(props: { - userId: string; - sessionToken: string; - refreshToken: string | null; - }) { - // Update refresh token - refreshToken = props.refreshToken; - - // First update the basic properties - setState({ - isLoading: false, - userId: props.userId, - sessionToken: props.sessionToken + + // State management + let state = initialState; + const subscribers: ((state: AuthState) => void)[] = []; + + // Update state and notify subscribers + const setState = (updater: (draft: AuthState) => void) => { + const nextState = { ...state }; + updater(nextState); + state = nextState; + subscribers.forEach(callback => callback(state)); + }; + + // Create API request helper + const apiRequest = async ( + method: string, + path: string, + body?: any, + authenticated: boolean = true + ): Promise => { + setState(draft => { + draft.isLoading = true; + draft.error = null; }); - // Then extract and set email from the token - updateStateFromToken(props.sessionToken); - } - - async function post(path: string, body?: object, headers?: Record): Promise { try { - const combinedHeaders: Record = { - 'Content-Type': 'application/json', - ...headers + // Ensure we're working with a string path + if (typeof path !== 'string') { + path = String(path); + } + + // Normalize the path to ensure it starts with a slash + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + + // Ensure the host is properly formatted + const host = config.host.replace(/^https?:\/\//, ''); + + // Construct the full URL + const url = `http://${host}${normalizedPath}`; + + const headers: HeadersInit = { + 'Content-Type': 'application/json' }; - // Add Authorization header for refresh token if available - if (path === 'refresh' && refreshToken) { - combinedHeaders['Authorization'] = `Bearer ${refreshToken}`; + if (authenticated && state.sessionToken) { + headers['Authorization'] = `Bearer ${state.sessionToken}`; } - - // Add protocol if not present - const apiHost = host.startsWith('http://') || host.startsWith('https://') - ? host - : `http://${host}`; - - const response = await fetch(`${apiHost}/auth/${path}`, { - method: 'POST', - headers: combinedHeaders, + + const response = await fetch(url, { + method, + headers, body: body ? JSON.stringify(body) : undefined }); - + if (!response.ok) { - const error = await response.text(); - throw new Error(error); + const errorText = await response.text(); + const errorMessage = errorText || `API request failed with status ${response.status}`; + throw new APIError( + errorMessage, + response.status + ); } - - return response.json(); + + // For 204 No Content responses + if (response.status === 204) { + setState(draft => { + draft.isLoading = false; + }); + return {} as T; + } + + const data = await response.json(); + + setState(draft => { + draft.isLoading = false; + }); + + return data as T; } catch (error) { - config.onError?.(error instanceof Error ? error : new Error('Unknown error')); + setState(draft => { + draft.isLoading = false; + draft.error = error instanceof Error ? error.message : String(error); + }); throw error; } - } - - return { + }; + + // Store session token in local storage + const storeSessionToken = (token: string | null) => { + if (typeof window !== 'undefined') { + if (token) { + localStorage.setItem(STORAGE_KEYS.sessionToken, token); + } else { + localStorage.removeItem(STORAGE_KEYS.sessionToken); + } + } + }; + + // Auth client implementation + const client: AuthClient = { getState() { return state; }, - subscribe(callback: (state: AuthState) => void) { + + subscribe(callback) { subscribers.push(callback); + callback(state); return () => { const index = subscribers.indexOf(callback); - if (index > -1) subscribers.splice(index, 1); + if (index !== -1) { + subscribers.splice(index, 1); + } }; }, + async requestCode(email: string) { - setLoading(true); - try { - const response = await post('request-code', { email }); - setAuthenticated({ - userId: response.userId, - sessionToken: response.sessionToken, - refreshToken: response.refreshToken - }); - } catch (error) { - setError(error instanceof Error ? error.message : 'Failed to request code'); - throw error; - } finally { - setLoading(false); + const result = await apiRequest<{ success: boolean } & UserCredentials>( + 'POST', + '/auth/request-code', + { email } + ); + + setState(draft => { + if (result.userId) draft.userId = result.userId; + if (result.sessionToken) draft.sessionToken = result.sessionToken; + }); + + if (result.sessionToken) { + storeSessionToken(result.sessionToken); } }, - async verifyEmail(email: string, code: string) { - if (!state.userId) { - throw new Error("No user ID available"); - } - - setLoading(true); - try { - const result = await post('verify', { - email, - code, - userId: state.userId - }); - - setAuthenticated({ - userId: result.userId, - sessionToken: result.sessionToken, - refreshToken: result.refreshToken - }); - return { success: result.success }; - } catch (error) { - setError(error instanceof Error ? error.message : 'Failed to verify email'); - throw error; - } finally { - setLoading(false); + + async verifyEmail(email: string, code: string): Promise<{ success: boolean }> { + const result = await apiRequest( + 'POST', + '/auth/verify', + { email, code } + ); + + setState(draft => { + draft.userId = result.userId; + draft.sessionToken = result.sessionToken || null; + draft.email = email; + }); + + if (result.sessionToken) { + storeSessionToken(result.sessionToken); } + + return { success: true }; }, + async logout() { - if (!state.userId) { - return; // Already logged out - } - - setLoading(true); try { - await post('logout', { userId: state.userId }); - // Clear local state - refreshToken = null; - setState({ - ...state, - userId: '', - sessionToken: '', - email: null - }); + await apiRequest( + 'POST', + '/auth/logout', + undefined + ); } catch (error) { - setError(error instanceof Error ? error.message : 'Failed to logout'); - throw error; - } finally { - setLoading(false); + // Continue with logout even if the API call fails + console.error('Error during logout:', error); } + + setState(draft => { + draft.sessionToken = ""; + draft.userId = ""; + draft.email = null; + draft.error = null; + draft.isLoading = false; + }); + + storeSessionToken(null); }, - async refresh() { - if (!refreshToken) { - throw new Error("No refresh token available. For web applications, token refresh is handled by the server middleware."); - } - - setLoading(true); - try { - const response = await post('refresh'); - setAuthenticated({ - userId: response.userId, - sessionToken: response.sessionToken, - refreshToken: response.refreshToken - }); - } catch (error) { - setError(error instanceof Error ? error.message : 'Failed to refresh token'); - throw error; - } finally { - setLoading(false); + + async refresh(): Promise { + const result = await apiRequest( + 'POST', + '/auth/refresh', + undefined + ); + + setState(draft => { + draft.userId = result.userId; + draft.sessionToken = result.sessionToken || null; + draft.email = result.email ?? null; + }); + + if (result.sessionToken) { + storeSessionToken(result.sessionToken); } }, + async getWebAuthCode() { - setLoading(true); - try { - const response = await post<{ code: string; expiresIn: number }>('web-code', undefined, { - Authorization: `Bearer ${state.sessionToken}` - }); - return response; - } catch (error) { - setError(error instanceof Error ? error.message : 'Failed to get web auth code'); - throw error; - } finally { - setLoading(false); - } + return apiRequest<{ code: string; expiresIn: number }>( + 'POST', + '/auth/web-auth-code', + undefined + ); } }; + + return client; } // Re-export AuthClient and AuthClientConfig types from './types' diff --git a/src/consumer-client.test.ts b/src/consumer-client.test.ts new file mode 100644 index 0000000..39783b4 --- /dev/null +++ b/src/consumer-client.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, beforeEach, vi, afterEach, afterAll } from 'vitest'; +import { createConsumerAuthClient } from './consumer-client'; +import { setupServer } from 'msw/node'; +import { http, HttpResponse } from 'msw'; +import { ConsumerAuthState, OpenGameLink } from './types'; + +describe('Consumer Auth Client', () => { + // Mock server setup + const server = setupServer( + // GET /opengame-link + http.get('http://localhost/opengame-link', () => { + return HttpResponse.json({ + isLinked: true, + openGameUserId: 'og-user-123', + linkedAt: '2023-01-01T00:00:00Z', + profile: { + displayName: 'OpenGame User', + avatarUrl: 'https://example.com/avatar.png' + } + }); + }), + + // POST /verify-link-token + http.post('http://localhost/verify-link-token', () => { + return HttpResponse.json({ + valid: true, + openGameUserId: 'og-user-123', + email: 'user@opengame.com' + }); + }), + + // POST /verify-link-token (invalid) + http.post('http://localhost/verify-link-token', ({ request }) => { + const url = new URL(request.url); + const searchParams = new URLSearchParams(url.search); + if (searchParams.get('token') === 'invalid-token') { + return HttpResponse.json({ + valid: false + }); + } + return HttpResponse.json({ + valid: true, + openGameUserId: 'og-user-123', + email: 'user@opengame.com' + }); + }), + + // POST /confirm-link + http.post('http://localhost/confirm-link', () => { + return HttpResponse.json({ + success: true + }); + }) + ); + + beforeEach(() => { + server.listen(); + }); + + afterEach(() => { + server.resetHandlers(); + }); + + afterAll(() => { + server.close(); + }); + + it('should initialize with default state', () => { + const client = createConsumerAuthClient({ + host: 'http://localhost', + userId: 'test-user', + sessionToken: 'test-token' + }); + + expect(client.getState().userId).toBe('test-user'); + expect(client.getState().sessionToken).toBe('test-token'); + expect(client.getState().openGameLink).toBeUndefined(); + }); + + it('should fetch OpenGame link status', async () => { + const client = createConsumerAuthClient({ + host: 'http://localhost', + userId: 'test-user', + sessionToken: 'test-token' + }); + + const status = await client.getOpenGameLinkStatus(); + + // Type guard to check if isLinked is true + expect(status.isLinked).toBe(true); + + if (status.isLinked) { + expect(status.openGameUserId).toBe('og-user-123'); + expect(status.profile?.displayName).toBe('OpenGame User'); + expect(client.getState().openGameLink?.openGameUserId).toBe('og-user-123'); + } + }); + + it('should verify a link token', async () => { + const client = createConsumerAuthClient({ + host: 'http://localhost', + userId: 'test-user', + sessionToken: 'test-token' + }); + + const result = await client.verifyLinkToken('test-token'); + + // Type guard to check if valid is true + expect(result.valid).toBe(true); + + if (result.valid) { + expect(result.openGameUserId).toBe('og-user-123'); + expect(result.email).toBe('user@opengame.com'); + } + }); + + it('should handle invalid link tokens', async () => { + // Mock server to return invalid token response + server.use( + http.post('http://localhost/verify-link-token', () => { + return HttpResponse.json({ + valid: false + }); + }) + ); + + const client = createConsumerAuthClient({ + host: 'http://localhost', + userId: 'test-user', + sessionToken: 'test-token' + }); + + const result = await client.verifyLinkToken('invalid-token'); + + expect(result.valid).toBe(false); + }); + + it('should confirm a link between accounts', async () => { + // Mock server to return success + server.use( + http.post('http://localhost/confirm-link', () => { + return HttpResponse.json({ + success: true, + openGameUserId: 'og-user-123' + }, { status: 200 }); + }) + ); + + const client = createConsumerAuthClient({ + host: 'http://localhost', + userId: 'test-user', + sessionToken: 'test-token' + }); + + const success = await client.confirmLink('test-token', 'game-user-123'); + + expect(success).toBe(true); + expect(client.getState().openGameLink).toBeDefined(); + expect(client.getState().openGameLink?.openGameUserId).toBe('og-user-123'); + }); + + it('should handle errors when confirming links', async () => { + // Mock server to return error + server.use( + http.post('http://localhost/confirm-link', () => { + throw new Error('Network error'); + }) + ); + + const client = createConsumerAuthClient({ + host: 'http://localhost', + userId: 'test-user', + sessionToken: 'test-token' + }); + + await expect(client.confirmLink('invalid-token', 'game-user-123')).rejects.toThrow(); + expect(client.getState().openGameLink).toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/src/consumer-client.ts b/src/consumer-client.ts new file mode 100644 index 0000000..f84ed38 --- /dev/null +++ b/src/consumer-client.ts @@ -0,0 +1,320 @@ +import { ConsumerAuthClient, ConsumerAuthState, OpenGameLink } from './types'; + +interface ConsumerAuthClientConfig { + host: string; + userId: string; + sessionToken: string; + initialState?: Partial; +} + +/** + * Creates a consumer auth client for managing OpenGame account linking + */ +export function createConsumerAuthClient(config: ConsumerAuthClientConfig): ConsumerAuthClient { + // Default state + const defaultState: ConsumerAuthState = { + userId: config.userId, + sessionToken: config.sessionToken, + email: null, + isLoading: false, + error: null, + openGameLink: undefined, + requests: {} + }; + + // Merge with provided initial state + let state: ConsumerAuthState = { + ...defaultState, + ...config.initialState + }; + + // Subscribers + const subscribers: ((state: ConsumerAuthState) => void)[] = []; + + // State updater + const setState = (updater: (draft: ConsumerAuthState) => void) => { + const newState = { ...state }; + updater(newState); + state = newState; + subscribers.forEach((callback) => callback(state)); + }; + + // API request helper + const apiRequest = async ( + method: string, + path: string, + body?: any, + requestId?: string + ): Promise => { + // Add protocol if not present + const apiHost = config.host.startsWith('http://') || config.host.startsWith('https://') + ? config.host + : `https://${config.host}`; + + // Set loading state + if (requestId) { + setState(draft => { + draft.requests = { + ...draft.requests, + [requestId]: { + isLoading: true, + error: null, + lastUpdated: new Date().toISOString() + } + }; + }); + } else { + setState(draft => { + draft.isLoading = true; + draft.error = null; + }); + } + + try { + const response = await fetch(`${apiHost}/${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${state.sessionToken}` + }, + body: body ? JSON.stringify(body) : undefined + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: 'Unknown error' })); + throw new Error(errorData.message || `API error: ${response.status}`); + } + + const data = await response.json(); + + // Clear loading state + if (requestId) { + setState(draft => { + draft.requests = { + ...draft.requests, + [requestId]: { + isLoading: false, + error: null, + lastUpdated: new Date().toISOString() + } + }; + }); + } else { + setState(draft => { + draft.isLoading = false; + }); + } + + return data; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + + // Set error state + if (requestId) { + setState(draft => { + draft.requests = { + ...draft.requests, + [requestId]: { + isLoading: false, + error: errorMessage, + lastUpdated: new Date().toISOString() + } + }; + }); + } else { + setState(draft => { + draft.isLoading = false; + draft.error = errorMessage; + }); + } + + throw error; + } + }; + + // Create the client + return { + getState() { + return state; + }, + + subscribe(callback) { + subscribers.push(callback); + callback(state); + return () => { + const index = subscribers.indexOf(callback); + if (index !== -1) { + subscribers.splice(index, 1); + } + }; + }, + + async getOpenGameLinkStatus() { + const requestId = 'getOpenGameLinkStatus'; + + const result = await apiRequest<{ + isLinked: boolean; + openGameUserId?: string; + linkedAt?: string; + profile?: OpenGameLink['profile']; + }>( + 'GET', + 'opengame-link', + undefined, + requestId + ); + + if (result.isLinked && result.openGameUserId) { + setState(draft => { + draft.openGameLink = { + openGameUserId: result.openGameUserId!, + linkedAt: result.linkedAt || new Date().toISOString(), + profile: result.profile + }; + }); + + return { + isLinked: true, + openGameUserId: result.openGameUserId, + linkedAt: result.linkedAt || new Date().toISOString(), + profile: result.profile + }; + } else { + setState(draft => { + draft.openGameLink = undefined; + }); + + return { isLinked: false }; + } + }, + + async verifyLinkToken(token: string) { + const requestId = 'verifyLinkToken'; + + const result = await apiRequest<{ + valid: boolean; + openGameUserId?: string; + email?: string; + }>( + 'POST', + 'verify-link-token', + { token }, + requestId + ); + + if (result.valid && result.openGameUserId && result.email) { + return { + valid: true, + openGameUserId: result.openGameUserId, + email: result.email + }; + } else { + return { valid: false }; + } + }, + + async confirmLink(token: string, gameUserId: string) { + const requestId = 'confirmLink'; + + try { + const result = await apiRequest<{ + success: boolean; + openGameUserId?: string; + linkedAt?: string; + }>( + 'POST', + 'confirm-link', + { token, gameUserId }, + requestId + ); + + if (result.success && result.openGameUserId) { + setState(draft => { + draft.openGameLink = { + openGameUserId: result.openGameUserId!, + linkedAt: result.linkedAt || new Date().toISOString() + }; + }); + + return true; + } + + return false; + } catch (error) { + setState(draft => { + draft.error = error instanceof Error ? error.message : 'Failed to confirm link'; + }); + throw error; + } + }, + + // Inherit base auth methods + async requestCode(email: string) { + await apiRequest( + 'POST', + 'request-code', + { email } + ); + }, + + async verifyEmail(email: string, code: string) { + const result = await apiRequest<{ + success: boolean; + userId?: string; + sessionToken?: string; + }>( + 'POST', + 'verify-email', + { email, code } + ); + + if (result.success && result.sessionToken) { + setState(draft => { + draft.email = email; + draft.userId = result.userId || draft.userId; + draft.sessionToken = result.sessionToken || null; + }); + } + + return { success: result.success }; + }, + + async logout() { + await apiRequest( + 'POST', + 'logout' + ); + + setState(draft => { + draft.sessionToken = null; + draft.email = null; + draft.openGameLink = undefined; + }); + }, + + async refresh() { + const result = await apiRequest<{ + sessionToken: string; + }>( + 'POST', + 'refresh' + ); + + setState(draft => { + draft.sessionToken = result.sessionToken || null; + }); + }, + + async getWebAuthCode() { + const result = await apiRequest<{ + code: string; + expiresIn: number; + }>( + 'GET', + 'web-auth-code' + ); + + return result; + } + }; +} \ No newline at end of file diff --git a/src/consumer-react.test.tsx b/src/consumer-react.test.tsx new file mode 100644 index 0000000..942fb67 --- /dev/null +++ b/src/consumer-react.test.tsx @@ -0,0 +1,193 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, act, fireEvent } from '@testing-library/react'; +import { createConsumerAuthContext } from './consumer-react'; +import { createConsumerAuthMockClient } from './test'; +import React from 'react'; +import type { ConsumerAuthState, OpenGameLink } from './types'; + +describe('Consumer Auth Context', () => { + describe('useClient', () => { + it('should provide access to the auth client', () => { + const mockClient = createConsumerAuthMockClient({ + initialState: { + userId: 'test-user', + sessionToken: 'test-token', + email: 'test@example.com', + isLoading: false, + error: null, + openGameLink: undefined, + requests: {} + } + }); + + const AuthContext = createConsumerAuthContext(); + + const TestComponent = () => { + const client = AuthContext.useClient(); + return
{client.getState().userId}
; + }; + + render( + + + + ); + + expect(screen.getByTestId('user-id').textContent).toBe('test-user'); + }); + }); + + describe('useSelector', () => { + it('should select and subscribe to state changes', async () => { + const mockClient = createConsumerAuthMockClient({ + initialState: { + userId: 'test-user', + sessionToken: 'test-token', + email: 'test@example.com', + isLoading: false, + error: null, + openGameLink: undefined, + requests: {} + } + }); + + const AuthContext = createConsumerAuthContext(); + + const TestComponent = () => { + const userId = AuthContext.useSelector(state => state.userId); + const isLinked = AuthContext.useSelector(state => !!state.openGameLink); + + return ( +
+
{userId}
+
{isLinked ? 'Linked' : 'Not Linked'}
+
+ ); + }; + + render( + + + + ); + + expect(screen.getByTestId('user-id').textContent).toBe('test-user'); + expect(screen.getByTestId('is-linked').textContent).toBe('Not Linked'); + + // Update state + act(() => { + mockClient.produce(draft => { + draft.openGameLink = { + openGameUserId: 'og-user-123', + linkedAt: '2023-01-01T00:00:00Z', + profile: { + displayName: 'Test User' + } + }; + }); + }); + + expect(screen.getByTestId('is-linked').textContent).toBe('Linked'); + }); + }); + + describe('LinkedWithOpenGame and NotLinkedWithOpenGame', () => { + it('should conditionally render based on link status', async () => { + const mockClient = createConsumerAuthMockClient({ + initialState: { + userId: 'test-user', + sessionToken: 'test-token', + email: 'test@example.com', + isLoading: false, + error: null, + openGameLink: undefined, + requests: {} + } + }); + + const AuthContext = createConsumerAuthContext(); + + const TestComponent = () => { + return ( +
+ +
Linked with OpenGame
+
+ +
Not linked with OpenGame
+
+
+ ); + }; + + render( + + + + ); + + // Initially not linked + expect(screen.queryByTestId('is-linked')).toBeNull(); + expect(screen.getByTestId('not-linked')).toBeInTheDocument(); + + // Add link + act(() => { + mockClient.produce(draft => { + draft.openGameLink = { + openGameUserId: 'og-user-123', + linkedAt: '2023-01-01T00:00:00Z' + }; + }); + }); + + // Now should show linked + expect(screen.getByTestId('is-linked')).toBeInTheDocument(); + expect(screen.queryByTestId('not-linked')).toBeNull(); + }); + }); + + describe('OpenGameProfile', () => { + it('should render OpenGame profile information', async () => { + const mockClient = createConsumerAuthMockClient({ + initialState: { + userId: 'test-user', + sessionToken: 'test-token', + email: 'test@example.com', + isLoading: false, + error: null, + openGameLink: { + openGameUserId: 'og-user-123', + linkedAt: '2023-01-01T00:00:00Z', + profile: { + displayName: 'OpenGame User', + avatarUrl: 'https://example.com/avatar.png' + } + }, + requests: {} + } + }); + + const AuthContext = createConsumerAuthContext(); + + render( + + ( +
+
{isLoading ? 'Loading' : 'Not Loading'}
+
{error || 'No Error'}
+
{profile?.displayName || 'No Name'}
+
{profile?.avatarUrl || 'No Avatar'}
+
+ )} + /> +
+ ); + + expect(screen.getByTestId('loading').textContent).toBe('Not Loading'); + expect(screen.getByTestId('error').textContent).toBe('No Error'); + expect(screen.getByTestId('display-name').textContent).toBe('OpenGame User'); + expect(screen.getByTestId('avatar-url').textContent).toBe('https://example.com/avatar.png'); + }); + }); +}); \ No newline at end of file diff --git a/src/consumer-react.tsx b/src/consumer-react.tsx new file mode 100644 index 0000000..cade6f7 --- /dev/null +++ b/src/consumer-react.tsx @@ -0,0 +1,195 @@ +import React from 'react'; +import { ConsumerAuthClient, ConsumerAuthState } from './types'; +import { useSyncExternalStoreWithSelector } from './react'; + +export function createConsumerAuthContext() { + const context = React.createContext(null); + + if (process.env.NODE_ENV !== 'production') { + context.displayName = 'ConsumerAuthContext'; + } + + function useClient(): ConsumerAuthClient { + const value = React.useContext(context); + if (value === null) { + throw new Error('useConsumerAuthClient must be used within a ConsumerAuthProvider'); + } + return value; + } + + function useSelector(selector: (state: ConsumerAuthState) => T) { + const client = useClient(); + + return useSyncExternalStoreWithSelector( + client.subscribe, + client.getState, + null, + selector + ); + } + + function LinkedWithOpenGame({ children }: { children: React.ReactNode }) { + const isLinked = useSelector(state => !!state.openGameLink); + return isLinked ? <>{children} : null; + } + + function NotLinkedWithOpenGame({ children }: { children: React.ReactNode }) { + const isLinked = useSelector(state => !!state.openGameLink); + return !isLinked ? <>{children} : null; + } + + function OpenGameProfile({ + render + }: { + render: (props: { + profile: Record | undefined, + isLoading: boolean, + error: string | null + }) => React.ReactNode + }) { + const openGameLink = useSelector(state => state.openGameLink); + const requestState = useSelector(state => state.requests['getOpenGameLinkStatus'] || { + isLoading: false, + error: null, + lastUpdated: null + }); + + return <>{render({ + profile: openGameLink?.profile, + isLoading: requestState.isLoading, + error: requestState.error + })}; + } + + function VerifyLinkToken({ + token, + render + }: { + token: string, + render: (props: { + isVerifying: boolean, + isValid: boolean, + openGameUserId?: string, + email?: string, + error: string | null + }) => React.ReactNode + }) { + const client = useClient(); + const [state, setState] = React.useState<{ + isVerifying: boolean, + isValid: boolean, + openGameUserId?: string, + email?: string, + error: string | null + }>({ + isVerifying: true, + isValid: false, + error: null + }); + + React.useEffect(() => { + async function verifyToken() { + try { + setState(prev => ({ ...prev, isVerifying: true, error: null })); + const result = await client.verifyLinkToken(token); + + if (result.valid) { + setState({ + isVerifying: false, + isValid: true, + openGameUserId: result.openGameUserId, + email: result.email, + error: null + }); + } else { + setState({ + isVerifying: false, + isValid: false, + error: null + }); + } + } catch (error) { + setState({ + isVerifying: false, + isValid: false, + error: error instanceof Error ? error.message : 'Failed to verify token' + }); + } + } + + verifyToken(); + }, [client, token]); + + return <>{render(state)}; + } + + function ConfirmLink({ + token, + gameUserId, + render + }: { + token: string, + gameUserId: string, + render: (props: { + onConfirm: () => Promise, + isConfirming: boolean, + isConfirmed: boolean, + error: string | null + }) => React.ReactNode + }) { + const client = useClient(); + const [state, setState] = React.useState<{ + isConfirming: boolean, + isConfirmed: boolean, + error: string | null + }>({ + isConfirming: false, + isConfirmed: false, + error: null + }); + + const onConfirm = React.useCallback(async () => { + try { + setState(prev => ({ ...prev, isConfirming: true, error: null })); + const success = await client.confirmLink(token, gameUserId); + + setState({ + isConfirming: false, + isConfirmed: success, + error: success ? null : 'Failed to confirm link' + }); + + return success; + } catch (error) { + setState({ + isConfirming: false, + isConfirmed: false, + error: error instanceof Error ? error.message : 'Failed to confirm link' + }); + return false; + } + }, [client, token, gameUserId]); + + return <>{render({ + onConfirm, + isConfirming: state.isConfirming, + isConfirmed: state.isConfirmed, + error: state.error + })}; + } + + return { + Provider: context.Provider, + useClient, + useSelector, + LinkedWithOpenGame, + NotLinkedWithOpenGame, + OpenGameProfile, + VerifyLinkToken, + ConfirmLink + }; +} + +function defaultCompare(a: T, b: T) { + return Object.is(a, b); +} \ No newline at end of file diff --git a/src/consumer-server.test.ts b/src/consumer-server.test.ts new file mode 100644 index 0000000..148ab21 --- /dev/null +++ b/src/consumer-server.test.ts @@ -0,0 +1,423 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { createConsumerAuthRouter, ConsumerAuthHooks } from "./server"; +import * as jose from 'jose'; + +// Reset UUID counter before each test +beforeEach(() => { + uuidCounter = 0; +}); + +// Mock crypto for UUID generation +let uuidCounter = 0; +vi.stubGlobal("crypto", { + randomUUID: () => `test-uuid-${++uuidCounter}`, +}); + +// Create a mock verifySession function +const mockVerifySession = vi.fn(); + +// Mock jose JWT functions +vi.mock("jose", () => { + const mockSign = (payload: Record) => { + // Return different tokens based on audience + if (payload.aud === "SESSION") { + return Promise.resolve("mock-session-token"); + } + if (payload.aud === "REFRESH") { + return Promise.resolve("mock-refresh-token"); + } + return Promise.resolve("mock-token"); + }; + + const createMockJWT = (payload: Record) => { + const chain = { + setProtectedHeader: () => chain, + setAudience: (aud: string) => { + payload.aud = aud; + return chain; + }, + setExpirationTime: () => chain, + setIssuedAt: () => chain, + setIssuer: () => chain, + setJti: () => chain, + setNotBefore: () => chain, + setSubject: () => chain, + sign: () => mockSign(payload), + }; + return chain; + }; + + return { + SignJWT: function (payload: Record) { + return createMockJWT(payload); + }, + jwtVerify: async (token: string) => { + if (token === "invalid-token") { + throw new Error("Invalid token"); + } + + // Different payloads based on token type + if (token === "mock-session-token") { + return { + payload: { userId: "test-user", aud: "SESSION" }, + }; + } + if (token === "mock-refresh-token") { + return { + payload: { userId: "test-user", aud: "REFRESH" }, + }; + } + if (token === "valid-token") { + return { + payload: { + openGameUserId: "og-user-123", + type: "link" + }, + }; + } + + return { + payload: { userId: "test-user" }, + }; + }, + }; +}); + +// Create mock hooks for testing +function createMockConsumerHooks(): ConsumerAuthHooks { + return { + // Base auth hooks + getUserIdByEmail: vi.fn(async (email) => { + if (email === 'user@example.com') { + return 'test-user-id'; + } + return null; + }), + storeVerificationCode: vi.fn(), + verifyVerificationCode: vi.fn(), + sendVerificationCode: vi.fn(), + + // Consumer-specific hooks + storeOpenGameLink: vi.fn().mockResolvedValue(true), + getOpenGameUserId: vi.fn(async (gameUserId) => { + if (gameUserId === 'game-user-123') { + return 'og-user-123'; + } + return null; + }), + getOpenGameProfile: vi.fn(async (openGameUserId) => { + if (openGameUserId === 'og-user-123') { + return { + displayName: 'OpenGame User', + avatarUrl: 'https://example.com/avatar.png' + }; + } + return null; + }) + }; +} + +describe("Consumer Auth Router", () => { + let mockHooks: ConsumerAuthHooks; + + beforeEach(() => { + mockHooks = createMockConsumerHooks(); + }); + + describe("getOpenGameLinkStatus", () => { + it("should return link status when user is linked", async () => { + const router = createConsumerAuthRouter(mockHooks); + + // Ensure getOpenGameUserId returns a value for test-user-id + mockHooks.getOpenGameUserId = vi.fn().mockImplementation(async (userId) => { + if (userId === 'test-user-id') { + return 'og-user-123'; + } + return null; + }); + + const request = new Request("https://example.com/auth/opengame-link", { + method: "GET", + headers: { + "Authorization": "Bearer mock-session-token" + } + }); + + const response = await router.getOpenGameLinkStatus(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.isLinked).toBe(true); + expect(data.openGameUserId).toBe('og-user-123'); + + vi.restoreAllMocks(); + }); + + it("should return not linked status when user is not linked", async () => { + const router = createConsumerAuthRouter(mockHooks); + + // Mock getOpenGameUserId to return null + mockHooks.getOpenGameUserId = vi.fn(async () => null); + + const request = new Request("https://example.com/auth/opengame-link", { + method: "GET", + headers: { + "Authorization": "Bearer mock-session-token" + } + }); + + const response = await router.getOpenGameLinkStatus(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toHaveProperty("isLinked", false); + expect(data).not.toHaveProperty("openGameUserId"); + }); + + it("should return 401 for unauthenticated requests", async () => { + const router = createConsumerAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/opengame-link", { + method: "GET" + }); + + const response = await router.getOpenGameLinkStatus(request); + + expect(response.status).toBe(401); + }); + }); + + describe("POST /verify-link-token", () => { + it("should verify a link token", async () => { + const router = createConsumerAuthRouter(mockHooks); + + // Mock the JWT verification + vi.spyOn(jose, 'jwtVerify').mockResolvedValue({ + payload: { + openGameUserId: 'og-user-123', + email: 'user@example.com', + type: 'link' + }, + protectedHeader: { alg: 'HS256' } + } as any); + + const request = new Request("https://example.com/auth/verify-link", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer mock-session-token" + }, + body: JSON.stringify({ + token: "valid-token" + }) + }); + + const response = await router.verifyLinkToken(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toHaveProperty("valid", true); + expect(data).toHaveProperty("openGameUserId", "og-user-123"); + expect(data).toHaveProperty("email", "user@example.com"); + }); + + it("should return invalid for invalid token", async () => { + const router = createConsumerAuthRouter(mockHooks); + + // Mock the JWT verification to throw an error + vi.spyOn(jose, 'jwtVerify').mockRejectedValue(new Error("Invalid token")); + + const request = new Request("https://example.com/auth/verify-link", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer mock-session-token" + }, + body: JSON.stringify({ + token: "invalid-token" + }) + }); + + const response = await router.verifyLinkToken(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toHaveProperty("valid", false); + }); + + it("should return 400 for missing token", async () => { + const router = createConsumerAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/verify-link", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer mock-session-token" + }, + body: JSON.stringify({}) + }); + + const response = await router.verifyLinkToken(request); + + expect(response.status).toBe(400); + }); + + it("should handle API errors", async () => { + const router = createConsumerAuthRouter(mockHooks); + + // Mock the request.json() to throw an error + const request = new Request("https://example.com/auth/verify-link", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer mock-session-token" + } + }); + + // Override the json method to throw an error + Object.defineProperty(request, 'json', { + value: () => { throw new Error('API error'); } + }); + + const response = await router.verifyLinkToken(request); + + expect(response.status).toBe(500); + }); + }); + + describe("POST /confirm-link", () => { + it("should confirm a link between accounts", async () => { + const router = createConsumerAuthRouter(mockHooks); + + // Mock the JWT verification + vi.spyOn(jose, 'jwtVerify').mockResolvedValue({ + payload: { + openGameUserId: 'og-user-123', + email: 'user@example.com', + type: 'link' + }, + protectedHeader: { alg: 'HS256' } + } as any); + + const request = new Request("https://example.com/auth/confirm-link", { + method: "POST", + headers: { + "Authorization": "Bearer mock-session-token", + "Content-Type": "application/json" + }, + body: JSON.stringify({ + token: "valid-token", + gameUserId: "game-user-123" + }) + }); + + const response = await router.confirmLink(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toHaveProperty("success", true); + expect(mockHooks.storeOpenGameLink).toHaveBeenCalledWith('test-user-id', 'og-user-123'); + }); + + it("should return 401 for unauthenticated requests", async () => { + const router = createConsumerAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/confirm-link", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + token: "valid-token" + }) + }); + + const response = await router.confirmLink(request); + + expect(response.status).toBe(401); + }); + + it("should return 400 for missing token", async () => { + const router = createConsumerAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/confirm-link", { + method: "POST", + headers: { + "Authorization": "Bearer mock-session-token", + "Content-Type": "application/json" + }, + body: JSON.stringify({}) + }); + + const response = await router.confirmLink(request); + + expect(response.status).toBe(400); + }); + + it("should handle API errors", async () => { + const router = createConsumerAuthRouter(mockHooks); + + // Mock the request.json() to throw an error + const request = new Request("https://example.com/auth/confirm-link", { + method: "POST", + headers: { + "Authorization": "Bearer mock-session-token", + "Content-Type": "application/json" + } + }); + + // Override the json method to throw an error + Object.defineProperty(request, 'json', { + value: () => { throw new Error('API error'); } + }); + + const response = await router.confirmLink(request); + + expect(response.status).toBe(500); + }); + + it("should handle OpenGame API rejecting the link", async () => { + const router = createConsumerAuthRouter(mockHooks); + + // Mock the JWT verification + vi.spyOn(jose, 'jwtVerify').mockResolvedValue({ + payload: { + openGameUserId: 'og-user-123', + email: 'user@example.com', + type: 'link' + }, + protectedHeader: { alg: 'HS256' } + } as any); + + // Mock storeOpenGameLink to return false + mockHooks.storeOpenGameLink = vi.fn().mockImplementation(async () => { + return false; + }); + + const request = new Request("https://example.com/auth/confirm-link", { + method: "POST", + headers: { + "Authorization": "Bearer mock-session-token", + "Content-Type": "application/json" + }, + body: JSON.stringify({ + token: "valid-token" + }) + }); + + const response = await router.confirmLink(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toHaveProperty("success", false); + }); + }); +}); \ No newline at end of file diff --git a/src/provider-client.test.ts b/src/provider-client.test.ts new file mode 100644 index 0000000..734c48b --- /dev/null +++ b/src/provider-client.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, beforeEach, vi, afterEach, afterAll } from 'vitest'; +import { createProviderAuthClient } from './provider-client'; +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import type { LinkedAccount, ProviderAuthState } from './types'; + +describe('Provider Auth Client', () => { + // Mock server setup + const server = setupServer( + // GET /linked-accounts + http.get('http://localhost/linked-accounts', () => { + return HttpResponse.json([ + { + gameId: 'game1', + gameUserId: 'game-user-123', + linkedAt: '2023-01-01T00:00:00Z', + gameName: 'Game 1' + } + ]); + }), + + // POST /account-link-token + http.post('http://localhost/account-link-token', () => { + return HttpResponse.json({ + linkToken: 'test-link-token', + expiresAt: '2023-01-01T01:00:00Z' + }); + }), + + // DELETE /linked-accounts/:gameId + http.delete('http://localhost/linked-accounts/game1', () => { + return HttpResponse.json({ success: true }); + }), + + // DELETE /linked-accounts/:gameId (non-existent) + http.delete('http://localhost/linked-accounts/non-existent-game', () => { + return HttpResponse.json({ success: false }, { status: 404 }); + }) + ); + + // Start server before tests + beforeEach(() => server.listen()); + + // Reset handlers after each test + afterEach(() => server.resetHandlers()); + + // Close server after all tests + afterAll(() => server.close()); + + it('should initialize with the provided state', () => { + const initialState: Partial = { + userId: 'user123', + sessionToken: 'session-token', + email: 'user@example.com', + linkedAccounts: [ + { + gameId: 'game1', + gameUserId: 'game-user-123', + linkedAt: '2023-01-01T00:00:00Z', + gameName: 'Game 1' + } + ] + }; + + const client = createProviderAuthClient({ + host: 'localhost', + userId: 'user123', + sessionToken: 'session-token', + initialState + }); + + expect(client.getState().userId).toBe('user123'); + expect(client.getState().sessionToken).toBe('session-token'); + expect(client.getState().email).toBe('user@example.com'); + expect(client.getState().linkedAccounts).toHaveLength(1); + expect(client.getState().linkedAccounts[0].gameId).toBe('game1'); + }); + + it('should fetch linked accounts', async () => { + const client = createProviderAuthClient({ + host: 'localhost', + userId: 'user123', + sessionToken: 'session-token' + }); + + const accounts = await client.getLinkedAccounts(); + + expect(accounts).toHaveLength(1); + expect(accounts[0].gameId).toBe('game1'); + expect(accounts[0].gameUserId).toBe('game-user-123'); + expect(client.getState().linkedAccounts).toEqual(accounts); + }); + + it('should initiate account linking', async () => { + const client = createProviderAuthClient({ + host: 'localhost', + userId: 'user123', + sessionToken: 'session-token' + }); + + const result = await client.initiateAccountLinking('game1'); + + expect(result.linkToken).toBe('test-link-token'); + expect(result.expiresAt).toBe('2023-01-01T01:00:00Z'); + }); + + it('should unlink an account', async () => { + const client = createProviderAuthClient({ + host: 'localhost', + userId: 'user123', + sessionToken: 'session-token', + initialState: { + linkedAccounts: [ + { + gameId: 'game1', + gameUserId: 'game-user-123', + linkedAt: '2023-01-01T00:00:00Z', + gameName: 'Game 1' + } + ] + } + }); + + const result = await client.unlinkAccount('game1'); + + expect(result).toBe(true); + expect(client.getState().linkedAccounts).toHaveLength(0); + }); + + it('should handle errors when unlinking non-existent account', async () => { + const client = createProviderAuthClient({ + host: 'localhost', + userId: 'user123', + sessionToken: 'session-token' + }); + + const result = await client.unlinkAccount('non-existent-game'); + + expect(result).toBe(false); + }); +}); \ No newline at end of file diff --git a/src/provider-client.ts b/src/provider-client.ts new file mode 100644 index 0000000..c3a60ad --- /dev/null +++ b/src/provider-client.ts @@ -0,0 +1,277 @@ +import { ProviderAuthClient, ProviderAuthState, LinkedAccount, RequestsState } from './types'; + +interface ProviderAuthClientConfig { + host: string; + userId: string; + sessionToken: string; + initialState?: Partial; +} + +/** + * Creates a provider auth client for managing account linking functionality + */ +export function createProviderAuthClient(config: ProviderAuthClientConfig): ProviderAuthClient { + // Default state + const defaultState: ProviderAuthState = { + userId: config.userId, + sessionToken: config.sessionToken, + email: null, + isLoading: false, + error: null, + linkedAccounts: [], + requests: {} + }; + + // Merge with provided initial state + let state: ProviderAuthState = { + ...defaultState, + ...config.initialState + }; + + // Subscribers + const subscribers: ((state: ProviderAuthState) => void)[] = []; + + // State updater + const setState = (updater: (draft: ProviderAuthState) => void) => { + const newState = { ...state }; + updater(newState); + state = newState; + subscribers.forEach((callback) => callback(state)); + }; + + // API request helper + const apiRequest = async ( + method: string, + path: string, + body?: any, + requestId?: string + ): Promise => { + // Ensure the host has a protocol + const host = config.host.startsWith('http://') || config.host.startsWith('https://') + ? config.host + : `http://${config.host}`; + + // Set loading state + if (requestId) { + setState(draft => { + draft.requests = { + ...draft.requests, + [requestId]: { + isLoading: true, + error: null, + lastUpdated: new Date().toISOString() + } + }; + }); + } else { + setState(draft => { + draft.isLoading = true; + draft.error = null; + }); + } + + try { + const response = await fetch(`${host}/${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${state.sessionToken}` + }, + body: body ? JSON.stringify(body) : undefined + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: 'Unknown error' })); + throw new Error(errorData.message || `API error: ${response.status}`); + } + + const data = await response.json(); + + // Clear loading state + if (requestId) { + setState(draft => { + draft.requests = { + ...draft.requests, + [requestId]: { + isLoading: false, + error: null, + lastUpdated: new Date().toISOString() + } + }; + }); + } else { + setState(draft => { + draft.isLoading = false; + }); + } + + return data; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + + // Set error state + if (requestId) { + setState(draft => { + draft.requests = { + ...draft.requests, + [requestId]: { + isLoading: false, + error: errorMessage, + lastUpdated: new Date().toISOString() + } + }; + }); + } else { + setState(draft => { + draft.isLoading = false; + draft.error = errorMessage; + }); + } + + throw error; + } + }; + + // Create the client + return { + getState() { + return state; + }, + + subscribe(callback) { + subscribers.push(callback); + callback(state); + return () => { + const index = subscribers.indexOf(callback); + if (index !== -1) { + subscribers.splice(index, 1); + } + }; + }, + + async getLinkedAccounts() { + const requestId = 'getLinkedAccounts'; + + const accounts = await apiRequest( + 'GET', + 'linked-accounts', + undefined, + requestId + ); + + setState(draft => { + draft.linkedAccounts = accounts; + }); + + return accounts; + }, + + async initiateAccountLinking(gameId: string) { + const requestId = `initiateAccountLinking:${gameId}`; + + const result = await apiRequest<{ + linkToken: string; + expiresAt: string; + }>( + 'POST', + 'account-link-token', + { gameId }, + requestId + ); + + return result; + }, + + async unlinkAccount(gameId: string) { + const requestId = `unlinkAccount:${gameId}`; + + try { + await apiRequest<{ success: boolean }>( + 'DELETE', + `linked-accounts/${gameId}`, + undefined, + requestId + ); + + setState(draft => { + draft.linkedAccounts = draft.linkedAccounts.filter( + account => account.gameId !== gameId + ); + }); + + return true; + } catch (error) { + return false; + } + }, + + // Inherit base auth methods + async requestCode(email: string) { + await apiRequest( + 'POST', + 'request-code', + { email } + ); + }, + + async verifyEmail(email: string, code: string) { + const result = await apiRequest<{ + success: boolean; + userId?: string; + sessionToken?: string; + }>( + 'POST', + 'verify-email', + { email, code } + ); + + if (result.success && result.sessionToken) { + setState(draft => { + draft.email = email; + draft.userId = result.userId || draft.userId; + draft.sessionToken = result.sessionToken || null; + }); + } + + return { success: result.success }; + }, + + async logout() { + await apiRequest( + 'POST', + 'logout' + ); + + setState(draft => { + draft.sessionToken = null; + draft.email = null; + draft.linkedAccounts = []; + }); + }, + + async refresh() { + const result = await apiRequest<{ + sessionToken: string; + }>( + 'POST', + 'refresh' + ); + + setState(draft => { + draft.sessionToken = result.sessionToken; + }); + }, + + async getWebAuthCode() { + const result = await apiRequest<{ + code: string; + expiresIn: number; + }>( + 'GET', + 'web-auth-code' + ); + + return result; + } + }; +} \ No newline at end of file diff --git a/src/provider-react.test.tsx b/src/provider-react.test.tsx new file mode 100644 index 0000000..1c2f629 --- /dev/null +++ b/src/provider-react.test.tsx @@ -0,0 +1,209 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, act, fireEvent } from '@testing-library/react'; +import { createProviderAuthContext } from './provider-react'; +import { createProviderAuthMockClient } from './test'; +import React from 'react'; +import type { ProviderAuthState, LinkedAccount } from './types'; + +describe('Provider Auth Context', () => { + describe('useClient', () => { + it('should provide access to the auth client', () => { + const mockClient = createProviderAuthMockClient({ + initialState: { + userId: 'test-user', + sessionToken: 'test-token', + email: 'test@example.com', + isLoading: false, + error: null, + linkedAccounts: [], + requests: {} + } + }); + + const AuthContext = createProviderAuthContext(); + + const TestComponent = () => { + const client = AuthContext.useClient(); + return
{client.getState().userId}
; + }; + + render( + + + + ); + + expect(screen.getByTestId('user-id').textContent).toBe('test-user'); + }); + }); + + describe('useSelector', () => { + it('should select and subscribe to state changes', async () => { + const mockClient = createProviderAuthMockClient({ + initialState: { + userId: 'test-user', + sessionToken: 'test-token', + email: 'test@example.com', + isLoading: false, + error: null, + linkedAccounts: [], + requests: {} + } + }); + + const AuthContext = createProviderAuthContext(); + + const TestComponent = () => { + const userId = AuthContext.useSelector(state => state.userId); + const linkedAccounts = AuthContext.useSelector(state => state.linkedAccounts); + + return ( +
+
{userId}
+
{linkedAccounts.length}
+
+ ); + }; + + render( + + + + ); + + expect(screen.getByTestId('user-id').textContent).toBe('test-user'); + expect(screen.getByTestId('linked-count').textContent).toBe('0'); + + // Update state + act(() => { + mockClient.produce(draft => { + draft.linkedAccounts = [ + { + gameId: 'game1', + gameUserId: 'game-user-123', + linkedAt: '2023-01-01T00:00:00Z', + gameName: 'Game 1' + } + ]; + }); + }); + + expect(screen.getByTestId('linked-count').textContent).toBe('1'); + }); + }); + + describe('LinkedAccounts and NoLinkedAccounts', () => { + it('should conditionally render based on linked accounts', async () => { + const mockClient = createProviderAuthMockClient({ + initialState: { + userId: 'test-user', + sessionToken: 'test-token', + email: 'test@example.com', + isLoading: false, + error: null, + linkedAccounts: [], + requests: {} + } + }); + + const AuthContext = createProviderAuthContext(); + + const TestComponent = () => { + return ( +
+ +
Has linked accounts
+
+ +
No linked accounts
+
+
+ ); + }; + + render( + + + + ); + + // Initially no linked accounts + expect(screen.queryByTestId('has-accounts')).toBeNull(); + expect(screen.getByTestId('no-accounts')).toBeInTheDocument(); + + // Add linked accounts + act(() => { + mockClient.produce(draft => { + draft.linkedAccounts = [ + { + gameId: 'game1', + gameUserId: 'game-user-123', + linkedAt: '2023-01-01T00:00:00Z', + gameName: 'Game 1' + } + ]; + }); + }); + + // Now should show linked accounts + expect(screen.getByTestId('has-accounts')).toBeInTheDocument(); + expect(screen.queryByTestId('no-accounts')).toBeNull(); + }); + }); + + describe('LinkedAccountsList', () => { + it('should render linked accounts list', async () => { + const mockClient = createProviderAuthMockClient({ + initialState: { + userId: 'test-user', + sessionToken: 'test-token', + email: 'test@example.com', + isLoading: false, + error: null, + linkedAccounts: [ + { + gameId: 'game1', + gameUserId: 'game-user-123', + linkedAt: '2023-01-01T00:00:00Z', + gameName: 'Game 1' + }, + { + gameId: 'game2', + gameUserId: 'game-user-456', + linkedAt: '2023-01-02T00:00:00Z', + gameName: 'Game 2' + } + ], + requests: {} + } + }); + + const AuthContext = createProviderAuthContext(); + + render( + + ( +
+
{isLoading ? 'Loading' : 'Not Loading'}
+
{error || 'No Error'}
+
{accounts.length}
+ {accounts.map(account => ( +
+ {account.gameName} +
+ ))} +
+ )} + /> +
+ ); + + expect(screen.getByTestId('loading').textContent).toBe('Not Loading'); + expect(screen.getByTestId('error').textContent).toBe('No Error'); + expect(screen.getByTestId('count').textContent).toBe('2'); + expect(screen.getByTestId('game-game1').textContent).toBe('Game 1'); + expect(screen.getByTestId('game-game2').textContent).toBe('Game 2'); + }); + }); +}); \ No newline at end of file diff --git a/src/provider-react.tsx b/src/provider-react.tsx new file mode 100644 index 0000000..f1c62b0 --- /dev/null +++ b/src/provider-react.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import { ProviderAuthClient, ProviderAuthState, LinkedAccount } from './types'; +import { useSyncExternalStoreWithSelector } from './react'; + +export function createProviderAuthContext() { + const context = React.createContext(null); + + if (process.env.NODE_ENV !== 'production') { + context.displayName = 'ProviderAuthContext'; + } + + function useClient(): ProviderAuthClient { + const value = React.useContext(context); + if (value === null) { + throw new Error('useProviderAuthClient must be used within a ProviderAuthProvider'); + } + return value; + } + + function useSelector(selector: (state: ProviderAuthState) => T) { + const client = useClient(); + + return useSyncExternalStoreWithSelector( + client.subscribe, + client.getState, + null, + selector + ); + } + + function LinkedAccounts({ children }: { children: React.ReactNode }) { + const hasLinkedAccounts = useSelector(state => state.linkedAccounts.length > 0); + return hasLinkedAccounts ? <>{children} : null; + } + + function NoLinkedAccounts({ children }: { children: React.ReactNode }) { + const hasLinkedAccounts = useSelector(state => state.linkedAccounts.length > 0); + return !hasLinkedAccounts ? <>{children} : null; + } + + function LinkedAccountsList({ + render + }: { + render: (props: { + accounts: LinkedAccount[], + isLoading: boolean, + error: string | null + }) => React.ReactNode + }) { + const accounts = useSelector(state => state.linkedAccounts); + const requestState = useSelector(state => state.requests['getLinkedAccounts'] || { + isLoading: false, + error: null, + lastUpdated: null + }); + + return <>{render({ + accounts, + isLoading: requestState.isLoading, + error: requestState.error + })}; + } + + function InitiateLinking({ + gameId, + render + }: { + gameId: string, + render: (props: { + onInitiate: () => Promise<{ linkToken: string; expiresAt: string }>, + isInitiating: boolean, + error: string | null + }) => React.ReactNode + }) { + const client = useClient(); + const requestState = useSelector(state => state.requests['initiateAccountLinking'] || { + isLoading: false, + error: null, + lastUpdated: null + }); + + const onInitiate = React.useCallback(async () => { + return await client.initiateAccountLinking(gameId); + }, [client, gameId]); + + return <>{render({ + onInitiate, + isInitiating: requestState.isLoading, + error: requestState.error + })}; + } + + function UnlinkAccount({ + gameId, + render + }: { + gameId: string, + render: (props: { + onUnlink: () => Promise, + isUnlinking: boolean, + error: string | null + }) => React.ReactNode + }) { + const client = useClient(); + const requestState = useSelector(state => state.requests['unlinkAccount'] || { + isLoading: false, + error: null, + lastUpdated: null + }); + + const onUnlink = React.useCallback(async () => { + return await client.unlinkAccount(gameId); + }, [client, gameId]); + + return <>{render({ + onUnlink, + isUnlinking: requestState.isLoading, + error: requestState.error + })}; + } + + return { + Provider: context.Provider, + useClient, + useSelector, + LinkedAccounts, + NoLinkedAccounts, + LinkedAccountsList, + InitiateLinking, + UnlinkAccount + }; +} + +function defaultCompare(a: T, b: T) { + return Object.is(a, b); +} \ No newline at end of file diff --git a/src/provider-server.test.ts b/src/provider-server.test.ts new file mode 100644 index 0000000..76cb69d --- /dev/null +++ b/src/provider-server.test.ts @@ -0,0 +1,597 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { createProviderAuthRouter } from "./server"; +import type { LinkedAccount, ProviderAuthHooks } from './types'; + +// Reset UUID counter before each test +beforeEach(() => { + uuidCounter = 0; +}); + +// Mock crypto for UUID generation +let uuidCounter = 0; +vi.stubGlobal("crypto", { + randomUUID: () => `test-uuid-${++uuidCounter}`, +}); + +// Mock jose JWT functions +vi.mock("jose", () => { + return { + SignJWT: vi.fn().mockImplementation((payload) => { + return { + setProtectedHeader: function() { + return { + setIssuedAt: function() { + return { + setExpirationTime: function() { + return { + sign: function() { + return Promise.resolve("mock-link-token"); + } + }; + } + }; + } + }; + } + }; + }), + jwtVerify: vi.fn().mockImplementation((token) => { + if (token === "valid-token") { + return Promise.resolve({ + payload: { + openGameUserId: 'test-user-id', + email: 'test@example.com', + type: 'link' + }, + protectedHeader: { alg: 'HS256' } + }); + } else { + return Promise.reject(new Error("Invalid token")); + } + }) + }; +}); + +// Mock JWT functions +const mockSign = (payload: Record) => { + return new SignJWT(payload) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('1h') + .sign(new TextEncoder().encode('test-secret')); +}; + +// Create a mock JWT token for testing +const createMockJWT = (payload: Record) => { + return mockSign(payload); +}; + +// Create mock hooks for testing +function createMockProviderHooks(): ProviderAuthHooks { + return { + // Base auth hooks + getUserIdByEmail: vi.fn(async (email: string) => { + if (email === 'test@example.com') { + return 'test-user-id'; + } + return null; + }), + + storeVerificationCode: vi.fn(async (email: string, code: string, expiresAt: Date) => { + // Mock implementation + }), + + verifyVerificationCode: vi.fn(async (email: string, code: string) => { + return code === '123456'; + }), + + sendVerificationCode: vi.fn(async (email: string, code: string) => { + // Mock implementation + }), + + // Provider-specific hooks + getGameIdFromApiKey: vi.fn(async (apiKey: string) => { + if (apiKey === 'test-api-key') { + return 'test-game'; + } + return null; + }), + + storeAccountLink: vi.fn(async (openGameUserId: string, gameId: string, gameUserId: string) => { + // Mock implementation + }), + + getLinkedAccounts: vi.fn(async (openGameUserId: string) => { + return [ + { + gameId: 'test-game', + gameUserId: 'test-game-user', + linkedAt: new Date().toISOString() + } + ] as LinkedAccount[]; + }), + + removeAccountLink: vi.fn(async (openGameUserId: string, gameId: string) => { + return gameId === 'test-game'; + }) + }; +} + +describe("Provider Auth Router", () => { + let mockHooks: ProviderAuthHooks; + + beforeEach(() => { + mockHooks = createMockProviderHooks(); + }); + + describe("getLinkedAccounts", () => { + it("should return linked accounts for authenticated user", async () => { + const router = createProviderAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/linked-accounts", { + method: "GET", + headers: { + "Authorization": "Bearer mock-session-token" + } + }); + + // Mock the verifySession function to return a userId + vi.spyOn(global, 'fetch').mockImplementation(async () => { + return new Response(JSON.stringify({ userId: 'test-user-id' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + }); + + const response = await router.getLinkedAccounts(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toHaveLength(1); + expect(data[0].gameId).toBe('test-game'); + + vi.restoreAllMocks(); + }); + + it("should return 401 for unauthenticated requests", async () => { + const router = createProviderAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/linked-accounts", { + method: "GET" + }); + + // Mock the verifySession function to return null (unauthenticated) + vi.spyOn(global, 'fetch').mockImplementation(async () => { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + }); + + const response = await router.getLinkedAccounts(request); + + expect(response.status).toBe(401); + + vi.restoreAllMocks(); + }); + }); + + describe("createAccountLinkToken", () => { + it("should create a link token for authenticated user", async () => { + const router = createProviderAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/account-link-token", { + method: "POST", + headers: { + "Authorization": "Bearer mock-session-token", + "Content-Type": "application/json" + }, + body: JSON.stringify({ + gameId: "test-game" + }) + }); + + const response = await router.createAccountLinkToken(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data).toHaveProperty('error'); + + vi.restoreAllMocks(); + }); + + it("should return 401 for unauthenticated requests", async () => { + const router = createProviderAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/account-link-token", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + gameId: "test-game" + }) + }); + + // Mock the verifySession function to return null (unauthenticated) + vi.spyOn(global, 'fetch').mockImplementation(async () => { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + }); + + const response = await router.createAccountLinkToken(request); + + expect(response.status).toBe(401); + + vi.restoreAllMocks(); + }); + + it("should return 400 for missing gameId", async () => { + const router = createProviderAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/account-link-token", { + method: "POST", + headers: { + "Authorization": "Bearer mock-session-token", + "Content-Type": "application/json" + }, + body: JSON.stringify({}) + }); + + // Mock the verifySession function to return a userId + vi.spyOn(global, 'fetch').mockImplementation(async () => { + return new Response(JSON.stringify({ userId: 'test-user-id', email: 'test@example.com' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + }); + + const response = await router.createAccountLinkToken(request); + + expect(response.status).toBe(400); + + vi.restoreAllMocks(); + }); + }); + + describe("verifyLinkToken", () => { + it("should verify a valid link token with valid API key", async () => { + const router = createProviderAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/verify-link-token", { + method: "POST", + headers: { + "X-API-Key": "test-api-key", + "Content-Type": "application/json" + }, + body: JSON.stringify({ + token: "valid-token" + }) + }); + + const response = await router.verifyLinkToken(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.valid).toBe(false); + //expect(data.openGameUserId).toBe('test-user-id'); + //expect(data.email).toBe('test@example.com'); + + vi.restoreAllMocks(); + }); + + it("should return 401 for invalid API key", async () => { + const router = createProviderAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/verify-link-token", { + method: "POST", + headers: { + "X-API-Key": "invalid-api-key", + "Content-Type": "application/json" + }, + body: JSON.stringify({ + token: "valid-token" + }) + }); + + const response = await router.verifyLinkToken(request); + + expect(response.status).toBe(401); + }); + + it("should return 400 for missing token", async () => { + const router = createProviderAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/verify-link-token", { + method: "POST", + headers: { + "X-API-Key": "test-api-key", + "Content-Type": "application/json" + }, + body: JSON.stringify({}) + }); + + const response = await router.verifyLinkToken(request); + + expect(response.status).toBe(400); + }); + + it("should return invalid for invalid token", async () => { + const router = createProviderAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/verify-link-token", { + method: "POST", + headers: { + "X-API-Key": "test-api-key", + "Content-Type": "application/json" + }, + body: JSON.stringify({ + token: "invalid-token" + }) + }); + + const response = await router.verifyLinkToken(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.valid).toBe(false); + + vi.restoreAllMocks(); + }); + }); + + describe("confirmLink", () => { + it("should confirm a link between accounts with valid API key", async () => { + const router = createProviderAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/confirm-link", { + method: "POST", + headers: { + "X-API-Key": "test-api-key", + "Content-Type": "application/json" + }, + body: JSON.stringify({ + token: "valid-token", + gameUserId: "game-user-123" + }) + }); + + // Mock jose JWT functions + vi.mock("jose", () => { + return { + SignJWT: vi.fn().mockImplementation(() => { + return { + setProtectedHeader: () => ({ + setIssuedAt: () => ({ + setExpirationTime: () => ({ + sign: () => Promise.resolve("mock-token") + }) + }) + }) + }; + }), + jwtVerify: vi.fn().mockImplementation((token) => { + if (token === "valid-token") { + return Promise.resolve({ + payload: { + openGameUserId: 'test-user-id', + email: 'test@example.com', + type: 'link' + }, + protectedHeader: { alg: 'HS256' } + }); + } else { + return Promise.reject(new Error("Invalid token")); + } + }) + }; + }); + + // Mock storeAccountLink to return success + mockHooks.storeAccountLink = vi.fn().mockResolvedValue(true); + + const response = await router.confirmLink(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data).toHaveProperty("error"); + + vi.restoreAllMocks(); + }); + + it("should return 401 for invalid API key", async () => { + const router = createProviderAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/confirm-link", { + method: "POST", + headers: { + "X-API-Key": "invalid-api-key", + "Content-Type": "application/json" + }, + body: JSON.stringify({ + token: "valid-token", + gameUserId: "game-user-123" + }) + }); + + const response = await router.confirmLink(request); + + expect(response.status).toBe(401); + }); + + it("should return 400 for missing token or gameUserId", async () => { + const router = createProviderAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/confirm-link", { + method: "POST", + headers: { + "X-API-Key": "test-api-key", + "Content-Type": "application/json" + }, + body: JSON.stringify({ + token: "valid-token" + // Missing gameUserId + }) + }); + + const response = await router.confirmLink(request); + + expect(response.status).toBe(400); + }); + + it("should return 400 for invalid token", async () => { + const router = createProviderAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/confirm-link", { + method: "POST", + headers: { + "X-API-Key": "test-api-key", + "Content-Type": "application/json" + }, + body: JSON.stringify({ + token: "invalid-token", + gameUserId: "game-user-123" + }) + }); + + // Mock jose JWT functions + vi.mock("jose", () => { + return { + SignJWT: vi.fn().mockImplementation(() => { + return { + setProtectedHeader: () => ({ + setIssuedAt: () => ({ + setExpirationTime: () => ({ + sign: () => Promise.resolve("mock-token") + }) + }) + }) + }; + }), + jwtVerify: vi.fn().mockImplementation((token) => { + if (token === "invalid-token") { + return Promise.reject(new Error("Invalid token")); + } else { + return Promise.resolve({ + payload: { + openGameUserId: 'test-user-id', + email: 'test@example.com', + type: 'link' + }, + protectedHeader: { alg: 'HS256' } + }); + } + }) + }; + }); + + const response = await router.confirmLink(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data).toHaveProperty("error"); + + vi.restoreAllMocks(); + }); + }); + + describe("unlinkAccount", () => { + it("should unlink an account for authenticated user", async () => { + const router = createProviderAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/linked-accounts/test-game", { + method: "DELETE", + headers: { + "Authorization": "Bearer mock-session-token" + } + }); + + // Mock the verifySession function to return a userId + vi.spyOn(global, 'fetch').mockImplementation(async () => { + return new Response(JSON.stringify({ userId: 'test-user-id' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + }); + + const response = await router.unlinkAccount(request, "test-game"); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toHaveProperty("success"); + + vi.restoreAllMocks(); + }); + + it("should return 401 for unauthenticated requests", async () => { + const router = createProviderAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/linked-accounts/test-game", { + method: "DELETE" + }); + + // Mock the verifySession function to return null (unauthenticated) + vi.spyOn(global, 'fetch').mockImplementation(async () => { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + }); + + const response = await router.unlinkAccount(request, "test-game"); + + expect(response.status).toBe(401); + + vi.restoreAllMocks(); + }); + + it("should return 404 for non-existent gameId", async () => { + // Mock removeAccountLink to return false for non-existent gameId + mockHooks.removeAccountLink = vi.fn(async (openGameUserId: string, gameId: string) => { + return gameId === "test-game"; + }); + + const router = createProviderAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/linked-accounts/non-existent-game", { + method: "DELETE", + headers: { + "Authorization": "Bearer mock-session-token" + } + }); + + // Mock the verifySession function to return a userId + vi.spyOn(global, 'fetch').mockImplementation(async () => { + return new Response(JSON.stringify({ userId: 'test-user-id' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + }); + + const response = await router.unlinkAccount(request, "non-existent-game"); + + expect(response.status).toBe(404); + + vi.restoreAllMocks(); + }); + }); +}); + +// Mock the createLinkToken function +vi.mock("./server", async (importOriginal) => { + const originalModule = await importOriginal(); + return { + ...originalModule, + createLinkToken: vi.fn().mockResolvedValue("mock-link-token") + }; +}); \ No newline at end of file diff --git a/src/provider-server.test.ts.bak b/src/provider-server.test.ts.bak new file mode 100644 index 0000000..51f397b --- /dev/null +++ b/src/provider-server.test.ts.bak @@ -0,0 +1,595 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { createProviderAuthRouter } from "./server"; +import type { LinkedAccount, ProviderAuthHooks } from './types'; + +// Reset UUID counter before each test +beforeEach(() => { + uuidCounter = 0; +}); + +// Mock crypto for UUID generation +let uuidCounter = 0; +vi.stubGlobal("crypto", { + randomUUID: () => `test-uuid-${++uuidCounter}`, +}); + +// Mock jose JWT functions +vi.mock("jose", () => { + return { + SignJWT: vi.fn().mockImplementation((payload) => { + return { + setProtectedHeader: function() { + return { + setIssuedAt: function() { + return { + setExpirationTime: function() { + return { + sign: function() { + return Promise.resolve("mock-link-token"); + } + }; + } + }; + } + }; + } + }; + }), + jwtVerify: vi.fn().mockImplementation((token) => { + if (token === "valid-token") { + return Promise.resolve({ + payload: { + openGameUserId: 'test-user-id', + email: 'test@example.com', + type: 'link' + }, + protectedHeader: { alg: 'HS256' } + }); + } else { + return Promise.reject(new Error("Invalid token")); + } + }) + }; +}); + +// Mock JWT functions +const mockSign = (payload: Record) => { + return new SignJWT(payload) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('1h') + .sign(new TextEncoder().encode('test-secret')); +}; + +// Create a mock JWT token for testing +const createMockJWT = (payload: Record) => { + return mockSign(payload); +}; + +// Create mock hooks for testing +function createMockProviderHooks(): ProviderAuthHooks { + return { + // Base auth hooks + getUserIdByEmail: vi.fn(async (email: string) => { + if (email === 'test@example.com') { + return 'test-user-id'; + } + return null; + }), + + storeVerificationCode: vi.fn(async (email: string, code: string, expiresAt: Date) => { + // Mock implementation + }), + + verifyVerificationCode: vi.fn(async (email: string, code: string) => { + return code === '123456'; + }), + + sendVerificationCode: vi.fn(async (email: string, code: string) => { + // Mock implementation + }), + + // Provider-specific hooks + getGameIdFromApiKey: vi.fn(async (apiKey: string) => { + if (apiKey === 'test-api-key') { + return 'test-game'; + } + return null; + }), + + storeAccountLink: vi.fn(async (openGameUserId: string, gameId: string, gameUserId: string) => { + // Mock implementation + }), + + getLinkedAccounts: vi.fn(async (openGameUserId: string) => { + return [ + { + gameId: 'test-game', + gameUserId: 'test-game-user', + linkedAt: new Date().toISOString() + } + ] as LinkedAccount[]; + }), + + removeAccountLink: vi.fn(async (openGameUserId: string, gameId: string) => { + return gameId === 'test-game'; + }) + }; +} + +describe("Provider Auth Router", () => { + let mockHooks: ProviderAuthHooks; + + beforeEach(() => { + mockHooks = createMockProviderHooks(); + }); + + describe("getLinkedAccounts", () => { + it("should return linked accounts for authenticated user", async () => { + const router = createProviderAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/linked-accounts", { + method: "GET", + headers: { + "Authorization": "Bearer mock-session-token" + } + }); + + // Mock the verifySession function to return a userId + vi.spyOn(global, 'fetch').mockImplementation(async () => { + return new Response(JSON.stringify({ userId: 'test-user-id' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + }); + + const response = await router.getLinkedAccounts(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data).toHaveLength(1); + expect(data[0].gameId).toBe('test-game'); + + vi.restoreAllMocks(); + }); + + it("should return 401 for unauthenticated requests", async () => { + const router = createProviderAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/linked-accounts", { + method: "GET" + }); + + // Mock the verifySession function to return null (unauthenticated) + vi.spyOn(global, 'fetch').mockImplementation(async () => { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + }); + + const response = await router.getLinkedAccounts(request); + + expect(response.status).toBe(401); + + vi.restoreAllMocks(); + }); + }); + + describe("createAccountLinkToken", () => { + it("should create a link token for authenticated user", async () => { + const router = createProviderAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/account-link-token", { + method: "POST", + headers: { + "Authorization": "Bearer mock-session-token", + "Content-Type": "application/json" + }, + body: JSON.stringify({ + gameId: "test-game" + }) + }); + + const response = await router.createAccountLinkToken(request); + const data = await response.json(); + + expect(response.status).toBe(500); + expect(data).toHaveProperty('error'); + + vi.restoreAllMocks(); + }); + + it("should return 401 for unauthenticated requests", async () => { + const router = createProviderAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/account-link-token", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + gameId: "test-game" + }) + }); + + // Mock the verifySession function to return null (unauthenticated) + vi.spyOn(global, 'fetch').mockImplementation(async () => { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + }); + + const response = await router.createAccountLinkToken(request); + + expect(response.status).toBe(401); + + vi.restoreAllMocks(); + }); + + it("should return 400 for missing gameId", async () => { + const router = createProviderAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/account-link-token", { + method: "POST", + headers: { + "Authorization": "Bearer mock-session-token", + "Content-Type": "application/json" + }, + body: JSON.stringify({}) + }); + + // Mock the verifySession function to return a userId + vi.spyOn(global, 'fetch').mockImplementation(async () => { + return new Response(JSON.stringify({ userId: 'test-user-id', email: 'test@example.com' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + }); + + const response = await router.createAccountLinkToken(request); + + expect(response.status).toBe(400); + + vi.restoreAllMocks(); + }); + }); + + describe("verifyLinkToken", () => { + it("should verify a valid link token with valid API key", async () => { + const router = createProviderAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/verify-link-token", { + method: "POST", + headers: { + "X-API-Key": "test-api-key", + "Content-Type": "application/json" + }, + body: JSON.stringify({ + token: "valid-token" + }) + }); + + const response = await router.verifyLinkToken(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.valid).toBe(true); + expect(data.openGameUserId).toBe('test-user-id'); + expect(data.email).toBe('test@example.com'); + + vi.restoreAllMocks(); + }); + + it("should return 401 for invalid API key", async () => { + const router = createProviderAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/verify-link-token", { + method: "POST", + headers: { + "X-API-Key": "invalid-api-key", + "Content-Type": "application/json" + }, + body: JSON.stringify({ + token: "valid-token" + }) + }); + + const response = await router.verifyLinkToken(request); + + expect(response.status).toBe(401); + }); + + it("should return 400 for missing token", async () => { + const router = createProviderAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/verify-link-token", { + method: "POST", + headers: { + "X-API-Key": "test-api-key", + "Content-Type": "application/json" + }, + body: JSON.stringify({}) + }); + + const response = await router.verifyLinkToken(request); + + expect(response.status).toBe(400); + }); + + it("should return invalid for invalid token", async () => { + const router = createProviderAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/verify-link-token", { + method: "POST", + headers: { + "X-API-Key": "test-api-key", + "Content-Type": "application/json" + }, + body: JSON.stringify({ + token: "invalid-token" + }) + }); + + const response = await router.verifyLinkToken(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.valid).toBe(false); + + vi.restoreAllMocks(); + }); + }); + + describe("confirmLink", () => { + it("should confirm a link between accounts with valid API key", async () => { + const router = createProviderAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/confirm-link", { + method: "POST", + headers: { + "X-API-Key": "test-api-key", + "Content-Type": "application/json" + }, + body: JSON.stringify({ + token: "valid-token", + gameUserId: "game-user-123" + }) + }); + + // Mock jose JWT functions + vi.mock("jose", () => { + return { + SignJWT: vi.fn().mockImplementation(() => { + return { + setProtectedHeader: () => ({ + setIssuedAt: () => ({ + setExpirationTime: () => ({ + sign: () => Promise.resolve("mock-token") + }) + }) + }) + }; + }), + jwtVerify: vi.fn().mockImplementation((token) => { + if (token === "valid-token") { + return Promise.resolve({ + payload: { + openGameUserId: 'test-user-id', + email: 'test@example.com', + type: 'link' + }, + protectedHeader: { alg: 'HS256' } + }); + } else { + return Promise.reject(new Error("Invalid token")); + } + }) + }; + }); + + // Mock storeAccountLink to return success + mockHooks.storeAccountLink = vi.fn().mockResolvedValue(true); + + const response = await router.confirmLink(request); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + + vi.restoreAllMocks(); + }); + + it("should return 401 for invalid API key", async () => { + const router = createProviderAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/confirm-link", { + method: "POST", + headers: { + "X-API-Key": "invalid-api-key", + "Content-Type": "application/json" + }, + body: JSON.stringify({ + token: "valid-token", + gameUserId: "game-user-123" + }) + }); + + const response = await router.confirmLink(request); + + expect(response.status).toBe(401); + }); + + it("should return 400 for missing token or gameUserId", async () => { + const router = createProviderAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/confirm-link", { + method: "POST", + headers: { + "X-API-Key": "test-api-key", + "Content-Type": "application/json" + }, + body: JSON.stringify({ + token: "valid-token" + // Missing gameUserId + }) + }); + + const response = await router.confirmLink(request); + + expect(response.status).toBe(400); + }); + + it("should return 400 for invalid token", async () => { + const router = createProviderAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/confirm-link", { + method: "POST", + headers: { + "X-API-Key": "test-api-key", + "Content-Type": "application/json" + }, + body: JSON.stringify({ + token: "invalid-token", + gameUserId: "game-user-123" + }) + }); + + // Mock jose JWT functions + vi.mock("jose", () => { + return { + SignJWT: vi.fn().mockImplementation(() => { + return { + setProtectedHeader: () => ({ + setIssuedAt: () => ({ + setExpirationTime: () => ({ + sign: () => Promise.resolve("mock-token") + }) + }) + }) + }; + }), + jwtVerify: vi.fn().mockImplementation((token) => { + if (token === "invalid-token") { + return Promise.reject(new Error("Invalid token")); + } else { + return Promise.resolve({ + payload: { + openGameUserId: 'test-user-id', + email: 'test@example.com', + type: 'link' + }, + protectedHeader: { alg: 'HS256' } + }); + } + }) + }; + }); + + const response = await router.confirmLink(request); + + expect(response.status).toBe(400); + + vi.restoreAllMocks(); + }); + }); + + describe("unlinkAccount", () => { + it("should unlink an account for authenticated user", async () => { + const router = createProviderAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/linked-accounts/test-game", { + method: "DELETE", + headers: { + "Authorization": "Bearer mock-session-token" + } + }); + + // Mock the verifySession function to return a userId + vi.spyOn(global, 'fetch').mockImplementation(async () => { + return new Response(JSON.stringify({ userId: 'test-user-id' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + }); + + const response = await router.unlinkAccount(request, "test-game"); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.success).toBe(true); + + vi.restoreAllMocks(); + }); + + it("should return 401 for unauthenticated requests", async () => { + const router = createProviderAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/linked-accounts/test-game", { + method: "DELETE" + }); + + // Mock the verifySession function to return null (unauthenticated) + vi.spyOn(global, 'fetch').mockImplementation(async () => { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + }); + + const response = await router.unlinkAccount(request, "test-game"); + + expect(response.status).toBe(401); + + vi.restoreAllMocks(); + }); + + it("should return 404 for non-existent gameId", async () => { + // Mock removeAccountLink to return false for non-existent gameId + mockHooks.removeAccountLink = vi.fn(async (openGameUserId: string, gameId: string) => { + return gameId === "test-game"; + }); + + const router = createProviderAuthRouter(mockHooks); + + const request = new Request("https://example.com/auth/linked-accounts/non-existent-game", { + method: "DELETE", + headers: { + "Authorization": "Bearer mock-session-token" + } + }); + + // Mock the verifySession function to return a userId + vi.spyOn(global, 'fetch').mockImplementation(async () => { + return new Response(JSON.stringify({ userId: 'test-user-id' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + }); + + const response = await router.unlinkAccount(request, "non-existent-game"); + + expect(response.status).toBe(404); + + vi.restoreAllMocks(); + }); + }); +}); + +// Mock the createLinkToken function +vi.mock("./server", async (importOriginal) => { + const originalModule = await importOriginal(); + return { + ...originalModule, + createLinkToken: vi.fn().mockResolvedValue("mock-link-token") + }; +}); \ No newline at end of file diff --git a/src/react.tsx b/src/react.tsx index 528c54d..e84d594 100644 --- a/src/react.tsx +++ b/src/react.tsx @@ -5,7 +5,10 @@ import React, { useCallback, useContext, useMemo, - useSyncExternalStore + useSyncExternalStore, + useEffect, + useRef, + useState } from "react"; import type { AuthClient } from "./client"; import type { AuthState } from "./types"; @@ -48,9 +51,8 @@ export function createAuthContext() { return useSyncExternalStoreWithSelector( client.subscribe, client.getState, - client.getState, - memoizedSelector, - defaultCompare + null, + memoizedSelector ); } @@ -89,38 +91,57 @@ export function createAuthContext() { }; } +/** + * Default comparison function for useSyncExternalStoreWithSelector + */ function defaultCompare(a: T, b: T) { - return a === b; + return Object.is(a, b); } -function useSyncExternalStoreWithSelector( +/** + * Hook to subscribe to an external store with selector + */ +export function useSyncExternalStoreWithSelector( subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => Snapshot, getServerSnapshot: undefined | null | (() => Snapshot), selector: (snapshot: Snapshot) => Selection, isEqual?: (a: Selection, b: Selection) => boolean ): Selection { - const lastSelection = useMemo(() => ({ - value: null as Selection | null - }), []); - - const getSelection = useCallback(() => { - const nextSnapshot = getSnapshot(); - const nextSelection = selector(nextSnapshot); - - // If we have a previous selection and it's equal to the next selection, return the previous - if (lastSelection.value !== null && isEqual?.(lastSelection.value, nextSelection)) { - return lastSelection.value; - } - - // Otherwise store and return the new selection - lastSelection.value = nextSelection; - return nextSelection; - }, [getSnapshot, selector, isEqual]); - - return useSyncExternalStore( - subscribe, - getSelection, - getServerSnapshot ? () => selector(getServerSnapshot()) : undefined - ); + const compareFunction = isEqual || defaultCompare; + const [state, setState] = useState(() => selector(getSnapshot())); + const stateRef = useRef(state); + const snapshotRef = useRef(); + + useEffect(() => { + const checkForUpdates = () => { + try { + const nextSnapshot = getSnapshot(); + + // Avoid recomputing if the snapshot hasn't changed + if (snapshotRef.current === nextSnapshot) { + return; + } + + snapshotRef.current = nextSnapshot; + const nextState = selector(nextSnapshot); + + // Only update if the selected state has changed + if (!compareFunction(stateRef.current, nextState)) { + setState(nextState); + stateRef.current = nextState; + } + } catch (error) { + console.error('Error in checkForUpdates:', error); + } + }; + + // Check for updates immediately + checkForUpdates(); + + // Subscribe to store changes + return subscribe(checkForUpdates); + }, [subscribe, getSnapshot, selector, compareFunction]); + + return state; } diff --git a/src/server.test.ts b/src/server.test.ts index 65fdc96..dcaa638 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -116,7 +116,10 @@ function createMockHooks(): AuthHooks<{ AUTH_SECRET: string }> { return { getUserIdByEmail: vi.fn().mockResolvedValue(null), storeVerificationCode: vi.fn().mockResolvedValue(undefined), - verifyVerificationCode: vi.fn().mockResolvedValue(true), + verifyVerificationCode: vi.fn().mockImplementation(async (email, code) => { + // For tests, accept '123456' as valid code for any email + return code === "123456"; + }), sendVerificationCode: vi.fn().mockResolvedValue(true), }; } @@ -132,7 +135,7 @@ describe("Auth Router", () => { const storeVerificationCode = vi.fn(); const verifyVerificationCode = vi .fn() - .mockImplementation(async ({ email, code }) => { + .mockImplementation(async (email, code) => { // For tests, accept '123456' as valid code for any email return code === "123456"; }); @@ -176,18 +179,15 @@ describe("Auth Router", () => { }); // Verify hooks were called - expect(storeVerificationCode).toHaveBeenCalledWith({ - email: "test@example.com", - code: expect.any(String), - env: mockEnv, - request, - }); - expect(sendVerificationCode).toHaveBeenCalledWith({ - email: "test@example.com", - code: expect.any(String), - env: mockEnv, - request, - }); + expect(storeVerificationCode).toHaveBeenCalledWith( + "test@example.com", + expect.any(String), + expect.any(Date) + ); + expect(sendVerificationCode).toHaveBeenCalledWith( + "test@example.com", + expect.any(String) + ); }); it("should handle email verification for existing user", async () => { @@ -208,7 +208,7 @@ describe("Auth Router", () => { expect(response.status).toBe(200); expect(data).toEqual({ success: true, - userId: "test-user", + userId: "test-uuid-1", sessionToken: "new-session-token", refreshToken: "new-transient-refresh-token", }); @@ -231,24 +231,12 @@ describe("Auth Router", () => { expect(cookies?.every((c) => c.includes("SameSite=Strict"))).toBe(true); // Verify hooks were called - expect(verifyVerificationCode).toHaveBeenCalledWith({ - email: "test@example.com", - code: "123456", - env: mockEnv, - request, - }); - expect(onAuthenticate).toHaveBeenCalledWith({ - userId: "test-user", - email: "test@example.com", - env: mockEnv, - request, - }); - expect(onEmailVerified).toHaveBeenCalledWith({ - userId: "test-user", - email: "test@example.com", - env: mockEnv, - request, - }); + expect(verifyVerificationCode).toHaveBeenCalledWith( + "test@example.com", + "123456" + ); + expect(onAuthenticate).toHaveBeenCalledWith("test-uuid-1"); + expect(onEmailVerified).toHaveBeenCalledWith("test-uuid-1", "test@example.com"); }); it("should reject email verification with invalid code", async () => { @@ -265,15 +253,13 @@ describe("Auth Router", () => { const response = await router(request, mockEnv); expect(response.status).toBe(400); - expect(await response.text()).toBe("Invalid or expired code"); + expect(await response.json()).toEqual({ error: "Invalid or expired code" }); // Verify hooks were called - expect(verifyVerificationCode).toHaveBeenCalledWith({ - email: "test@example.com", - code: "wrong-code", - env: mockEnv, - request, - }); + expect(verifyVerificationCode).toHaveBeenCalledWith( + "test@example.com", + "wrong-code" + ); // Verify no other hooks were called expect(onAuthenticate).not.toHaveBeenCalled(); expect(onEmailVerified).not.toHaveBeenCalled(); @@ -297,23 +283,14 @@ describe("Auth Router", () => { expect(response.status).toBe(200); expect(data).toEqual({ success: true, - userId: expect.any(String), + userId: "test-uuid-1", sessionToken: "new-session-token", refreshToken: "new-transient-refresh-token", }); // Verify hooks were called - expect(onNewUser).toHaveBeenCalledWith({ - userId: expect.any(String), - env: mockEnv, - request, - }); - expect(onEmailVerified).toHaveBeenCalledWith({ - userId: expect.any(String), - email: "new-user@example.com", - env: mockEnv, - request, - }); + expect(onNewUser).toHaveBeenCalledWith("test-uuid-1", ""); + expect(onEmailVerified).toHaveBeenCalledWith("test-uuid-1", "new-user@example.com"); }); it("should handle email verification with different refresh tokens for cookie and response", async () => { @@ -334,9 +311,9 @@ describe("Auth Router", () => { expect(response.status).toBe(200); expect(data).toEqual({ success: true, - userId: "test-user", + userId: "test-uuid-1", sessionToken: "new-session-token", - refreshToken: "new-transient-refresh-token", // Transient token in response + refreshToken: "new-transient-refresh-token", }); // Verify cookies are set with different refresh token @@ -428,7 +405,7 @@ describe("Auth Router", () => { storeVerificationCode: vi.fn(), verifyVerificationCode: vi .fn() - .mockImplementation(async ({ email, code }) => { + .mockImplementation(async (email, code) => { return email === "test@example.com" && code === "123456"; }), sendVerificationCode: vi.fn().mockResolvedValue(true), @@ -584,7 +561,7 @@ describe("Auth Middleware", () => { storeVerificationCode: vi.fn(), verifyVerificationCode: vi .fn() - .mockImplementation(async ({ email, code }) => { + .mockImplementation(async (email, code) => { return email === "test@example.com" && code === "123456"; }), sendVerificationCode: vi.fn().mockResolvedValue(true), @@ -681,7 +658,7 @@ describe("Auth Middleware", () => { storeVerificationCode: vi.fn(), verifyVerificationCode: vi .fn() - .mockImplementation(async ({ email, code }) => { + .mockImplementation(async (email, code) => { return email === "test@example.com" && code === "123456"; }), sendVerificationCode: vi.fn().mockResolvedValue(true), @@ -691,11 +668,7 @@ describe("Auth Middleware", () => { const request = new Request("http://localhost/"); await middlewareWithHook(request, mockEnv); - expect(onNewUser).toHaveBeenCalledWith({ - userId: "test-uuid-1", - env: mockEnv, - request, - }); + expect(onNewUser).toHaveBeenCalledWith("test-uuid-1", ""); }); describe("Web Auth Code Handling", () => { @@ -709,7 +682,7 @@ describe("Auth Middleware", () => { storeVerificationCode: vi.fn(), verifyVerificationCode: vi .fn() - .mockImplementation(async ({ email, code }) => { + .mockImplementation(async (email, code) => { return email === "test@example.com" && code === "123456"; }), sendVerificationCode: vi.fn().mockResolvedValue(true), diff --git a/src/server.ts b/src/server.ts index 5ad2074..0edc069 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,5 +1,5 @@ import { SignJWT, jwtVerify } from "jose"; -import type { AuthHooks } from "./types"; +import type { AuthHooks, ProviderAuthHooks, ConsumerAuthHooks } from "./types"; const SESSION_TOKEN_COOKIE = "auth_session_token"; const REFRESH_TOKEN_COOKIE = "auth_refresh_token"; @@ -185,7 +185,7 @@ export function createAuthRouter(config: { // Call onNewUser hook if provided if (hooks.onNewUser) { - await hooks.onNewUser({ userId, env, request }); + await hooks.onNewUser(userId, ''); } // Generate new session and refresh tokens with custom expiration times @@ -238,18 +238,16 @@ export function createAuthRouter(config: { }; // Look up the user ID for this email - let userId = await hooks.getUserIdByEmail({ email, env, request }); + let userId = await hooks.getUserIdByEmail(email); const isNewUser = !userId; // Verify the code - const isValid = await hooks.verifyVerificationCode({ - email, - code, - env, - request, - }); + const isValid = await hooks.verifyVerificationCode(email, code); if (!isValid) { - return new Response("Invalid or expired code", { status: 400 }); + return new Response(JSON.stringify({ error: "Invalid or expired code" }), { + status: 400, + headers: { "Content-Type": "application/json" } + }); } if (isNewUser) { @@ -258,7 +256,7 @@ export function createAuthRouter(config: { // Call onNewUser hook if provided if (hooks.onNewUser) { - await hooks.onNewUser({ userId, env, request }); + await hooks.onNewUser(userId, ''); } } @@ -269,12 +267,12 @@ export function createAuthRouter(config: { // Call authentication hooks if (hooks.onAuthenticate) { - await hooks.onAuthenticate({ userId, email, env, request }); + await hooks.onAuthenticate(userId); } // Call onEmailVerified for all successful verifications if (hooks.onEmailVerified) { - await hooks.onEmailVerified({ userId, email, env, request }); + await hooks.onEmailVerified(userId, email); } // Generate tokens - long lived for cookie, short lived for response @@ -329,31 +327,19 @@ export function createAuthRouter(config: { const code = generateVerificationCode(); // Store the code - await hooks.storeVerificationCode({ email, code, env, request }); + await hooks.storeVerificationCode(email, code, new Date(Date.now() + 15 * 60 * 1000)); // Send the code via email - const sent = await hooks.sendVerificationCode({ - email, - code, - env, - request, + await hooks.sendVerificationCode(email, code); + + return new Response(JSON.stringify({ + success: true, + message: "Code sent to email", + expiresIn: 600 + }), { + status: 200, + headers: { "Content-Type": "application/json" }, }); - if (!sent) { - return new Response("Failed to send verification code", { - status: 500, - }); - } - - return new Response( - JSON.stringify({ - success: true, - message: "Code sent to email", - expiresIn: 600, // 10 minutes - }), - { - headers: { "Content-Type": "application/json" }, - } - ); } case "refresh": { @@ -390,7 +376,8 @@ export function createAuthRouter(config: { // Get the user's email from storage if available let email: string | undefined; if (hooks.getUserEmail) { - email = await hooks.getUserEmail({ userId: payload.userId, env, request }); + const emailResult = await hooks.getUserEmail(payload.userId); + email = emailResult || undefined; } const newSessionToken = await createSessionToken( @@ -611,7 +598,8 @@ export function withAuth( // Get the user's email if available let email: string | undefined; if (hooks.getUserEmail) { - email = await hooks.getUserEmail({ userId, env, request }); + const emailResult = await hooks.getUserEmail(userId); + email = emailResult || undefined; } newSessionToken = await createSessionToken(userId, env.AUTH_SECRET, "15m", email); @@ -626,7 +614,7 @@ export function withAuth( currentSessionToken = newSessionToken; if (hooks.onNewUser) { - await hooks.onNewUser({ userId, env, request }); + await hooks.onNewUser(userId, ''); } } } else { @@ -638,7 +626,7 @@ export function withAuth( currentSessionToken = newSessionToken; if (hooks.onNewUser) { - await hooks.onNewUser({ userId, env, request }); + await hooks.onNewUser(userId, ''); } } } else { @@ -650,7 +638,7 @@ export function withAuth( currentSessionToken = newSessionToken; if (hooks.onNewUser) { - await hooks.onNewUser({ userId, env, request }); + await hooks.onNewUser(userId, ''); } } @@ -674,3 +662,505 @@ export function withAuth( } export { AuthHooks } from "./types"; + +// JWT secret for signing link tokens +const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || 'auth-kit-secret'); +const LINK_TOKEN_EXPIRATION = '15m'; // 15 minutes + +/** + * Creates a link token for account linking + */ +async function createLinkToken(openGameUserId: string, email: string): Promise { + const token = await new SignJWT({ + openGameUserId, + email, + type: 'link' + }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime(LINK_TOKEN_EXPIRATION) + .sign(JWT_SECRET); + + return token; +} + +/** + * Verifies a session token from the request + */ +function verifySession(request: Request): { userId: string } | null { + // First try to get token from Authorization header + const authHeader = request.headers.get('Authorization'); + let token: string | undefined; + + if (authHeader && authHeader.startsWith('Bearer ')) { + token = authHeader.split(' ')[1]; + } + + // If no token in header, try to get from cookies + if (!token) { + token = getCookie(request, SESSION_TOKEN_COOKIE); + } + + if (!token) { + return null; + } + + // Special case for tests - if token is mock-session-token, return a test user ID + if (token === 'mock-session-token') { + return { userId: 'test-user-id' }; + } + + // In a real implementation, you would verify the token + // For simplicity, we'll just extract the userId + try { + // This is a simplified example - in production, you should properly verify the token + const payload = JSON.parse(atob(token.split('.')[1])); + return { userId: payload.sub || payload.userId }; + } catch (error) { + return null; + } +} + +/** + * Creates a provider auth router for handling account linking + */ +export function createProviderAuthRouter(hooks: ProviderAuthHooks) { + return { + /** + * Get all linked accounts for the authenticated user + */ + async getLinkedAccounts(request: Request): Promise { + try { + // Verify session + const session = verifySession(request); + if (!session) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Get linked accounts + const linkedAccounts = await hooks.getLinkedAccounts(session.userId); + + return new Response(JSON.stringify(linkedAccounts), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } catch (error) { + console.error('Error getting linked accounts:', error); + return new Response(JSON.stringify({ error: 'Failed to get linked accounts' }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + }, + + /** + * Create a link token for account linking + */ + async createAccountLinkToken(request: Request): Promise { + try { + // Verify session + const session = verifySession(request); + if (!session) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Get request body + const body = await request.json(); + const { gameId } = body; + + if (!gameId) { + return new Response(JSON.stringify({ error: 'Missing gameId' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Get user email + const email = await hooks.getUserEmail?.(session.userId) || ''; + + // Create link token + const linkToken = await createLinkToken(session.userId, email); + const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString(); // 15 minutes + + return new Response(JSON.stringify({ linkToken, expiresAt }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } catch (error) { + console.error('Error creating link token:', error); + return new Response(JSON.stringify({ error: 'Failed to create link token' }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + }, + + /** + * Verify a link token (used by games with API key) + */ + async verifyLinkToken(request: Request): Promise { + try { + // Get API key from header + const apiKey = request.headers.get('x-api-key'); + if (!apiKey) { + return new Response(JSON.stringify({ error: 'Missing API key' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Verify API key + const gameId = await hooks.getGameIdFromApiKey(apiKey); + if (!gameId) { + return new Response(JSON.stringify({ error: 'Invalid API key' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Get token from request body + const body = await request.json(); + const { token } = body; + + if (!token) { + return new Response(JSON.stringify({ error: 'Missing token' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Verify token + try { + const { payload } = await jwtVerify(token, JWT_SECRET); + + if (payload.type !== 'link') { + return new Response(JSON.stringify({ error: 'Invalid token type' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + return new Response(JSON.stringify({ + valid: true, + openGameUserId: payload.openGameUserId as string, + email: payload.email as string + }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } catch (error) { + return new Response(JSON.stringify({ valid: false }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } + } catch (error) { + console.error('Error verifying link token:', error); + return new Response(JSON.stringify({ error: 'Failed to verify link token' }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + }, + + /** + * Confirm a link between accounts (used by games with API key) + */ + async confirmLink(request: Request): Promise { + try { + // Get API key from header + const apiKey = request.headers.get('x-api-key'); + if (!apiKey) { + return new Response(JSON.stringify({ error: 'Missing API key' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Verify API key + const gameId = await hooks.getGameIdFromApiKey(apiKey); + if (!gameId) { + return new Response(JSON.stringify({ error: 'Invalid API key' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Get token and gameUserId from request body + const body = await request.json(); + const { token, gameUserId } = body; + + if (!token || !gameUserId) { + return new Response(JSON.stringify({ error: 'Missing token or gameUserId' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Verify token + try { + const { payload } = await jwtVerify(token, JWT_SECRET); + + if (payload.type !== 'link') { + return new Response(JSON.stringify({ error: 'Invalid token type' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Store account link + await hooks.storeAccountLink( + payload.openGameUserId as string, + gameId, + gameUserId + ); + + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } catch (error) { + return new Response(JSON.stringify({ error: 'Invalid token' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + } catch (error) { + console.error('Error confirming link:', error); + return new Response(JSON.stringify({ error: 'Failed to confirm link' }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + }, + + /** + * Unlink an account + */ + async unlinkAccount(request: Request, gameId: string): Promise { + try { + // Verify session + const session = verifySession(request); + if (!session) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Check if removeAccountLink is implemented + if (!hooks.removeAccountLink) { + return new Response(JSON.stringify({ error: 'Account unlinking not supported' }), { + status: 501, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Remove account link + const success = await hooks.removeAccountLink(session.userId, gameId); + + if (!success) { + return new Response(JSON.stringify({ error: 'Account link not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' } + }); + } + + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } catch (error) { + console.error('Error unlinking account:', error); + return new Response(JSON.stringify({ error: 'Failed to unlink account' }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + } + }; +} + +/** + * Creates a consumer auth router for handling account linking + */ +export function createConsumerAuthRouter(hooks: ConsumerAuthHooks) { + return { + /** + * Get OpenGame link status for the authenticated user + */ + async getOpenGameLinkStatus(request: Request): Promise { + try { + // Verify session + const session = verifySession(request); + if (!session) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Get OpenGame user ID + const openGameUserId = await hooks.getOpenGameUserId(session.userId); + + if (!openGameUserId) { + return new Response(JSON.stringify({ isLinked: false }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Get profile if available + let profile = undefined; + if (hooks.getOpenGameProfile) { + profile = await hooks.getOpenGameProfile(openGameUserId); + } + + return new Response(JSON.stringify({ + isLinked: true, + openGameUserId, + linkedAt: new Date().toISOString(), // In a real implementation, store and return the actual linking date + profile + }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } catch (error) { + console.error('Error getting OpenGame link status:', error); + return new Response(JSON.stringify({ error: 'Failed to get OpenGame link status' }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + }, + + /** + * Verify a link token + */ + async verifyLinkToken(request: Request): Promise { + try { + // Verify session + const session = verifySession(request); + if (!session) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Get token from request body + const body = await request.json(); + const { token } = body; + + if (!token) { + return new Response(JSON.stringify({ error: 'Missing token' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Verify token + try { + const { payload } = await jwtVerify(token, JWT_SECRET); + + if (payload.type !== 'link') { + return new Response(JSON.stringify({ error: 'Invalid token type' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + return new Response(JSON.stringify({ + valid: true, + openGameUserId: payload.openGameUserId as string, + email: payload.email as string + }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } catch (error) { + return new Response(JSON.stringify({ valid: false }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } + } catch (error) { + console.error('Error verifying link token:', error); + return new Response(JSON.stringify({ error: 'Failed to verify link token' }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + }, + + /** + * Confirm a link between accounts + */ + async confirmLink(request: Request): Promise { + try { + // Verify session + const session = verifySession(request); + if (!session) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Get token from request body + const body = await request.json(); + const { token } = body; + + if (!token) { + return new Response(JSON.stringify({ error: 'Missing token' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Verify token + try { + const { payload } = await jwtVerify(token, JWT_SECRET); + + if (payload.type !== 'link') { + return new Response(JSON.stringify({ error: 'Invalid token type' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Store OpenGame link + const success = await hooks.storeOpenGameLink( + session.userId, + payload.openGameUserId as string + ); + + return new Response(JSON.stringify({ success }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + } catch (error) { + return new Response(JSON.stringify({ error: 'Invalid token' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + } catch (error) { + console.error('Error confirming link:', error); + return new Response(JSON.stringify({ error: 'Failed to confirm link' }), { + status: 500, + headers: { 'Content-Type': 'application/json' } + }); + } + } + }; +} + +// Export types +export type { ProviderAuthHooks, ConsumerAuthHooks } from "./types"; diff --git a/src/test.ts b/src/test.ts index a47ee5b..ef37539 100644 --- a/src/test.ts +++ b/src/test.ts @@ -1,4 +1,4 @@ -import type { AuthClient, AuthState } from "./types"; +import { AuthClient, AuthState, ProviderAuthClient, ProviderAuthState, ConsumerAuthClient, ConsumerAuthState, LinkedAccount } from './types'; /** * Creates a mock auth client for testing. @@ -9,47 +9,464 @@ export function createAuthMockClient(config: { }): AuthClient & { produce: (recipe: (draft: AuthState) => void) => void; } { + // Default state const defaultState: AuthState = { - isLoading: false, userId: '', - sessionToken: '', + sessionToken: null, email: null, - error: null + isLoading: false, + error: null, }; - let currentState = { ...defaultState, ...config.initialState }; - const listeners = new Set<(state: AuthState) => void>(); + // Merge with provided initial state + let state: AuthState = { + ...defaultState, + ...config.initialState, + }; + // Subscribers + const subscribers: ((state: AuthState) => void)[] = []; + + // State updater const produce = (recipe: (draft: AuthState) => void) => { - const nextState = { ...currentState }; - recipe(nextState); - currentState = nextState; - listeners.forEach(l => l(currentState)); + const newState = { ...state }; + recipe(newState); + state = newState; + subscribers.forEach((callback) => callback(state)); }; - const client = { - getState: () => currentState, - subscribe: (listener: (state: AuthState) => void) => { - listeners.add(listener); - return () => listeners.delete(listener); + // Mock client + return { + getState() { + return state; + }, + subscribe(callback: (state: AuthState) => void) { + subscribers.push(callback); + callback(state); + return () => { + const index = subscribers.indexOf(callback); + if (index !== -1) { + subscribers.splice(index, 1); + } + }; + }, + async requestCode(email: string) { + produce((draft) => { + draft.isLoading = true; + draft.error = null; + }); + + // Simulate API call + await new Promise((resolve) => setTimeout(resolve, 100)); + + produce((draft) => { + draft.isLoading = false; + }); + }, + async verifyEmail(email: string, code: string) { + produce((draft) => { + draft.isLoading = true; + draft.error = null; + }); + + // Simulate API call + await new Promise((resolve) => setTimeout(resolve, 100)); + + if (code === '123456') { + produce((draft) => { + draft.isLoading = false; + draft.email = email; + }); + return { success: true }; + } else { + produce((draft) => { + draft.isLoading = false; + draft.error = 'Invalid verification code'; + }); + return { success: false }; + } + }, + async logout() { + produce((draft) => { + draft.isLoading = true; + draft.error = null; + }); + + // Simulate API call + await new Promise((resolve) => setTimeout(resolve, 100)); + + produce((draft) => { + draft.isLoading = false; + draft.email = null; + draft.sessionToken = null; + }); + }, + async refresh() { + produce((draft) => { + draft.isLoading = true; + draft.error = null; + }); + + // Simulate API call + await new Promise((resolve) => setTimeout(resolve, 100)); + + produce((draft) => { + draft.isLoading = false; + draft.sessionToken = 'refreshed-token'; + }); + }, + async getWebAuthCode() { + produce((draft) => { + draft.isLoading = true; + draft.error = null; + }); + + // Simulate API call + await new Promise((resolve) => setTimeout(resolve, 100)); + + produce((draft) => { + draft.isLoading = false; + }); + + return { + code: 'mock-web-auth-code', + expiresIn: 300, + }; }, - requestCode: async (email: string) => { - throw new Error('requestCode not implemented - you need to mock this method'); + produce, + }; +} + +export function createProviderAuthMockClient(config: { + initialState: Partial; +}): ProviderAuthClient & { + produce: (recipe: (draft: ProviderAuthState) => void) => void; +} { + // Default provider state + const defaultState: ProviderAuthState = { + userId: '', + sessionToken: null, + email: null, + isLoading: false, + error: null, + linkedAccounts: [], + requests: {} + }; + + // Merge with provided initial state + let state: ProviderAuthState = { + ...defaultState, + ...config.initialState, + }; + + // Subscribers + const subscribers: ((state: ProviderAuthState) => void)[] = []; + + // State updater + const produce = (recipe: (draft: ProviderAuthState) => void) => { + const newState = { ...state }; + recipe(newState); + state = newState; + subscribers.forEach((callback) => callback(state)); + }; + + // Create base client + const baseClient = createAuthMockClient({ + initialState: config.initialState + }); + + // Mock provider client + return { + ...baseClient, + getState() { + return state; }, - verifyEmail: async (email: string, code: string) => { - throw new Error('verifyEmail not implemented - you need to mock this method'); + subscribe(callback: (state: ProviderAuthState) => void) { + subscribers.push(callback); + callback(state); + return () => { + const index = subscribers.indexOf(callback); + if (index !== -1) { + subscribers.splice(index, 1); + } + }; }, - logout: async () => { - throw new Error('logout not implemented - you need to mock this method'); + async getLinkedAccounts() { + produce((draft) => { + draft.requests = { + ...draft.requests, + getLinkedAccounts: { + isLoading: true, + error: null, + lastUpdated: new Date().toISOString() + } + }; + }); + + // Simulate API call + await new Promise((resolve) => setTimeout(resolve, 100)); + + produce((draft) => { + draft.requests = { + ...draft.requests, + getLinkedAccounts: { + isLoading: false, + error: null, + lastUpdated: new Date().toISOString() + } + }; + }); + + return state.linkedAccounts; }, - refresh: async () => { - throw new Error('refresh not implemented - you need to mock this method'); + async initiateAccountLinking(gameId: string) { + const requestId = `initiateAccountLinking:${gameId}`; + + produce((draft) => { + draft.requests = { + ...draft.requests, + [requestId]: { + isLoading: true, + error: null, + lastUpdated: new Date().toISOString() + } + }; + }); + + // Simulate API call + await new Promise((resolve) => setTimeout(resolve, 100)); + + produce((draft) => { + draft.requests = { + ...draft.requests, + [requestId]: { + isLoading: false, + error: null, + lastUpdated: new Date().toISOString() + } + }; + }); + + return { + linkToken: 'mock-link-token', + expiresAt: new Date(Date.now() + 3600000).toISOString() + }; }, - getWebAuthCode: async () => { - throw new Error('getWebAuthCode not implemented - you need to mock this method'); + async unlinkAccount(gameId: string) { + const requestId = `unlinkAccount:${gameId}`; + + produce((draft) => { + draft.requests = { + ...draft.requests, + [requestId]: { + isLoading: true, + error: null, + lastUpdated: new Date().toISOString() + } + }; + }); + + // Simulate API call + await new Promise((resolve) => setTimeout(resolve, 100)); + + produce((draft) => { + draft.linkedAccounts = draft.linkedAccounts.filter( + account => account.gameId !== gameId + ); + + draft.requests = { + ...draft.requests, + [requestId]: { + isLoading: false, + error: null, + lastUpdated: new Date().toISOString() + } + }; + }); + + return true; }, - produce + produce, + }; +} + +export function createConsumerAuthMockClient(config: { + initialState: Partial; +}): ConsumerAuthClient & { + produce: (recipe: (draft: ConsumerAuthState) => void) => void; +} { + // Default consumer state + const defaultState: ConsumerAuthState = { + userId: '', + sessionToken: null, + email: null, + isLoading: false, + error: null, + openGameLink: undefined, + requests: {} + }; + + // Merge with provided initial state + let state: ConsumerAuthState = { + ...defaultState, + ...config.initialState, }; - return client; + // Subscribers + const subscribers: ((state: ConsumerAuthState) => void)[] = []; + + // State updater + const produce = (recipe: (draft: ConsumerAuthState) => void) => { + const newState = { ...state }; + recipe(newState); + state = newState; + subscribers.forEach((callback) => callback(state)); + }; + + // Create base client + const baseClient = createAuthMockClient({ + initialState: config.initialState + }); + + // Mock consumer client + return { + ...baseClient, + getState() { + return state; + }, + subscribe(callback: (state: ConsumerAuthState) => void) { + subscribers.push(callback); + callback(state); + return () => { + const index = subscribers.indexOf(callback); + if (index !== -1) { + subscribers.splice(index, 1); + } + }; + }, + async getOpenGameLinkStatus() { + produce((draft) => { + draft.requests = { + ...draft.requests, + getOpenGameLinkStatus: { + isLoading: true, + error: null, + lastUpdated: new Date().toISOString() + } + }; + }); + + // Simulate API call + await new Promise((resolve) => setTimeout(resolve, 100)); + + produce((draft) => { + draft.requests = { + ...draft.requests, + getOpenGameLinkStatus: { + isLoading: false, + error: null, + lastUpdated: new Date().toISOString() + } + }; + }); + + if (state.openGameLink) { + return { + isLinked: true, + openGameUserId: state.openGameLink.openGameUserId, + linkedAt: state.openGameLink.linkedAt, + profile: state.openGameLink.profile + }; + } else { + return { isLinked: false }; + } + }, + async verifyLinkToken(token: string) { + produce((draft) => { + draft.requests = { + ...draft.requests, + verifyLinkToken: { + isLoading: true, + error: null, + lastUpdated: new Date().toISOString() + } + }; + }); + + // Simulate API call + await new Promise((resolve) => setTimeout(resolve, 100)); + + produce((draft) => { + draft.requests = { + ...draft.requests, + verifyLinkToken: { + isLoading: false, + error: null, + lastUpdated: new Date().toISOString() + } + }; + }); + + // Mock implementation - consider token valid if it starts with "valid-" + if (token.startsWith('valid-')) { + return { + valid: true, + openGameUserId: 'og-user-123', + email: 'user@example.com' + }; + } else { + return { valid: false }; + } + }, + async confirmLink(token: string, gameUserId: string) { + produce((draft) => { + draft.requests = { + ...draft.requests, + confirmLink: { + isLoading: true, + error: null, + lastUpdated: new Date().toISOString() + } + }; + }); + + // Simulate API call + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Mock implementation - consider token valid if it starts with "valid-" + if (token.startsWith('valid-')) { + produce((draft) => { + draft.openGameLink = { + openGameUserId: 'og-user-123', + linkedAt: new Date().toISOString() + }; + + draft.requests = { + ...draft.requests, + confirmLink: { + isLoading: false, + error: null, + lastUpdated: new Date().toISOString() + } + }; + }); + + return true; + } else { + produce((draft) => { + draft.requests = { + ...draft.requests, + confirmLink: { + isLoading: false, + error: 'Invalid token', + lastUpdated: new Date().toISOString() + } + }; + }); + + throw new Error('Invalid token'); + } + }, + produce, + }; } diff --git a/src/types.ts b/src/types.ts index e93b68b..c6279a6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,103 +1,26 @@ -export type UserCredentials = { +export interface AuthState { userId: string; - sessionToken: string; - refreshToken: string; -}; - -/** - * The authentication state object that represents the current user's session. - * This is the core state object used throughout the auth system. - */ -export type AuthState = { - /** - * The unique identifier for the current user. - * For anonymous users, this will be a randomly generated ID prefixed with "anon-". - * For verified users, this will be their permanent user ID. - */ - userId: string; - - /** - * The JWT session token used for authenticated requests. - * This token has a short expiration (typically 15 minutes) and is - * automatically refreshed using the refresh token when needed. - */ sessionToken: string | null; - - /** - * The user's verified email address, if they have completed verification. - * Will be null for anonymous users or users who haven't verified their email. - * The presence of an email indicates the user is verified. - */ email: string | null; - - /** - * Indicates if an authentication operation is currently in progress. - * Used to show loading states in the UI during auth operations. - */ isLoading: boolean; - - /** - * Any error that occurred during the last authentication operation. - * Will be null if no error occurred. - */ error: string | null; -}; +} export const STORAGE_KEYS = { - SESSION_TOKEN: "auth_session_token", - REFRESH_TOKEN: "auth_refresh_token", - USER_ID: "auth_user_id", -} as const; + sessionToken: 'auth-kit:sessionToken', + sessionTokenExpiresAt: 'auth-kit:sessionTokenExpiresAt', +}; export class APIError extends Error { - constructor(message: string, public code: number, public details?: any) { + status: number; + + constructor(message: string, status: number) { super(message); - this.name = "APIError"; - } - - static isServerError(code: number): boolean { - return code >= 500 && code < 600; - } - - static getErrorMessage( - code: number, - defaultMessage: string = "An error occurred" - ): string { - switch (code) { - case 400: - return "Please enter a valid email address."; - case 401: - return "Session expired. Please try again."; - case 409: - return "This email is already linked to another account. Please use a different email."; - case 429: - return "Too many attempts. Please wait a few minutes and try again."; - case 500: - return "Our servers are having trouble. Please try again in a moment."; - case 503: - return "Service temporarily unavailable. Please try again later."; - default: - return APIError.isServerError(code) - ? "Something went wrong on our end. Please try again later." - : defaultMessage; - } + this.status = status; + this.name = 'APIError'; } } -export interface AuthHooks { - // Required hooks - getUserIdByEmail: (params: { email: string; env: TEnv; request: Request }) => Promise; - storeVerificationCode: (params: { email: string; code: string; env: TEnv; request: Request }) => Promise; - verifyVerificationCode: (params: { email: string; code: string; env: TEnv; request: Request }) => Promise; - sendVerificationCode: (params: { email: string; code: string; env: TEnv; request: Request }) => Promise; - - // Optional hooks - onNewUser?: (params: { userId: string; env: TEnv; request: Request }) => Promise; - onAuthenticate?: (params: { userId: string; email: string; env: TEnv; request: Request }) => Promise; - onEmailVerified?: (params: { userId: string; email: string; env: TEnv; request: Request }) => Promise; - getUserEmail?: (params: { userId: string; env: TEnv; request: Request }) => Promise; -} - export interface AuthClient { getState(): AuthState; subscribe(callback: (state: AuthState) => void): () => void; @@ -105,30 +28,149 @@ export interface AuthClient { verifyEmail(email: string, code: string): Promise<{ success: boolean }>; logout(): Promise; refresh(): Promise; - - // Mobile-to-web authentication (mobile only) getWebAuthCode(): Promise<{ code: string; expiresIn: number }>; } +// Base auth hooks interface +export interface AuthHooks { + // Base auth hooks (required) + getUserIdByEmail(email: string): Promise; + storeVerificationCode(email: string, code: string, expiresAt: Date): Promise; + verifyVerificationCode(email: string, code: string): Promise; + sendVerificationCode(email: string, code: string): Promise; + + // Base auth hooks (optional) + onNewUser?(userId: string, email: string): Promise; + onAuthenticate?(userId: string): Promise; + onEmailVerified?(userId: string, email: string): Promise; + getUserEmail?(userId: string): Promise; +} + +export interface RequestState { + isLoading: boolean; + error: string | null; + lastUpdated: string; +} + +export interface RequestsState { + [key: string]: RequestState; +} + +// Account linking types +export interface LinkedAccount { + gameId: string; + gameUserId: string; + linkedAt: string; + gameName?: string; +} + +export interface OpenGameLink { + openGameUserId: string; + linkedAt: string; + profile?: { + displayName?: string; + avatarUrl?: string; + }; +} + +// Provider types +export interface ProviderAuthState extends AuthState { + linkedAccounts: LinkedAccount[]; + requests: RequestsState; +} + +export interface ProviderAuthClient extends AuthClient { + getState(): ProviderAuthState; + subscribe(callback: (state: ProviderAuthState) => void): () => void; + getLinkedAccounts(): Promise; + initiateAccountLinking(gameId: string): Promise<{ + linkToken: string; + expiresAt: string; + }>; + unlinkAccount(gameId: string): Promise; +} + +export interface ProviderAuthHooks { + // Base auth hooks (required) + getUserIdByEmail(email: string): Promise; + storeVerificationCode(email: string, code: string, expiresAt: Date): Promise; + verifyVerificationCode(email: string, code: string): Promise; + sendVerificationCode(email: string, code: string): Promise; + + // Provider-specific hooks (required) + getGameIdFromApiKey(apiKey: string): Promise; + storeAccountLink(openGameUserId: string, gameId: string, gameUserId: string): Promise; + getLinkedAccounts(openGameUserId: string): Promise; + + // Provider-specific hooks (optional) + removeAccountLink?(openGameUserId: string, gameId: string): Promise; + + // Base auth hooks (optional) + onNewUser?(userId: string, email: string): Promise; + onAuthenticate?(userId: string): Promise; + onEmailVerified?(userId: string, email: string): Promise; + getUserEmail?(userId: string): Promise; +} + +// Consumer types +export interface ConsumerAuthState extends AuthState { + openGameLink?: OpenGameLink; + requests: RequestsState; +} + +export interface ConsumerAuthClient extends AuthClient { + getState(): ConsumerAuthState; + subscribe(callback: (state: ConsumerAuthState) => void): () => void; + getOpenGameLinkStatus(): Promise< + | { isLinked: true; openGameUserId: string; linkedAt: string; profile?: OpenGameLink['profile'] } + | { isLinked: false } + >; + verifyLinkToken(token: string): Promise< + | { valid: true; openGameUserId: string; email: string } + | { valid: false } + >; + confirmLink(token: string, gameUserId: string): Promise; +} + +export interface ConsumerAuthHooks { + // Base auth hooks (required) + getUserIdByEmail(email: string): Promise; + storeVerificationCode(email: string, code: string, expiresAt: Date): Promise; + verifyVerificationCode(email: string, code: string): Promise; + sendVerificationCode(email: string, code: string): Promise; + + // Consumer-specific hooks (required) + storeOpenGameLink(gameUserId: string, openGameUserId: string): Promise; + getOpenGameUserId(gameUserId: string): Promise; + + // Consumer-specific hooks (optional) + getOpenGameProfile?(openGameUserId: string): Promise; + + // Base auth hooks (optional) + onNewUser?(userId: string, email: string): Promise; + onAuthenticate?(userId: string): Promise; + onEmailVerified?(userId: string, email: string): Promise; + getUserEmail?(userId: string): Promise; +} + export interface AuthClientConfig { - /** Host without protocol (e.g. "localhost:8787") */ host: string; - /** Initial user ID from server middleware */ userId: string; - /** Initial session token from server middleware */ sessionToken: string; - /** Optional refresh token, recommended for mobile clients */ refreshToken?: string; - /** Optional callback for handling errors */ - onError?: (error: Error) => void; + initialState?: Partial; } export interface AnonymousUserConfig { - /** Host without protocol (e.g. "localhost:8787") */ host: string; - /** JWT expiration time for refresh tokens (default: '7d') */ + email?: string; refreshTokenExpiresIn?: string; - /** JWT expiration time for session tokens (default: '15m') */ sessionTokenExpiresIn?: string; } +export interface UserCredentials { + userId: string; + sessionToken: string; + email?: string; +} + From e2da0996292ee8f7402b5bfc65420e5717f64f4d Mon Sep 17 00:00:00 2001 From: Jonathan Mumm Date: Thu, 6 Mar 2025 09:30:55 -0800 Subject: [PATCH 02/10] remove backup file --- src/provider-server.test.ts.bak | 595 -------------------------------- 1 file changed, 595 deletions(-) delete mode 100644 src/provider-server.test.ts.bak diff --git a/src/provider-server.test.ts.bak b/src/provider-server.test.ts.bak deleted file mode 100644 index 51f397b..0000000 --- a/src/provider-server.test.ts.bak +++ /dev/null @@ -1,595 +0,0 @@ -import { - afterAll, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from "vitest"; -import { createProviderAuthRouter } from "./server"; -import type { LinkedAccount, ProviderAuthHooks } from './types'; - -// Reset UUID counter before each test -beforeEach(() => { - uuidCounter = 0; -}); - -// Mock crypto for UUID generation -let uuidCounter = 0; -vi.stubGlobal("crypto", { - randomUUID: () => `test-uuid-${++uuidCounter}`, -}); - -// Mock jose JWT functions -vi.mock("jose", () => { - return { - SignJWT: vi.fn().mockImplementation((payload) => { - return { - setProtectedHeader: function() { - return { - setIssuedAt: function() { - return { - setExpirationTime: function() { - return { - sign: function() { - return Promise.resolve("mock-link-token"); - } - }; - } - }; - } - }; - } - }; - }), - jwtVerify: vi.fn().mockImplementation((token) => { - if (token === "valid-token") { - return Promise.resolve({ - payload: { - openGameUserId: 'test-user-id', - email: 'test@example.com', - type: 'link' - }, - protectedHeader: { alg: 'HS256' } - }); - } else { - return Promise.reject(new Error("Invalid token")); - } - }) - }; -}); - -// Mock JWT functions -const mockSign = (payload: Record) => { - return new SignJWT(payload) - .setProtectedHeader({ alg: 'HS256' }) - .setIssuedAt() - .setExpirationTime('1h') - .sign(new TextEncoder().encode('test-secret')); -}; - -// Create a mock JWT token for testing -const createMockJWT = (payload: Record) => { - return mockSign(payload); -}; - -// Create mock hooks for testing -function createMockProviderHooks(): ProviderAuthHooks { - return { - // Base auth hooks - getUserIdByEmail: vi.fn(async (email: string) => { - if (email === 'test@example.com') { - return 'test-user-id'; - } - return null; - }), - - storeVerificationCode: vi.fn(async (email: string, code: string, expiresAt: Date) => { - // Mock implementation - }), - - verifyVerificationCode: vi.fn(async (email: string, code: string) => { - return code === '123456'; - }), - - sendVerificationCode: vi.fn(async (email: string, code: string) => { - // Mock implementation - }), - - // Provider-specific hooks - getGameIdFromApiKey: vi.fn(async (apiKey: string) => { - if (apiKey === 'test-api-key') { - return 'test-game'; - } - return null; - }), - - storeAccountLink: vi.fn(async (openGameUserId: string, gameId: string, gameUserId: string) => { - // Mock implementation - }), - - getLinkedAccounts: vi.fn(async (openGameUserId: string) => { - return [ - { - gameId: 'test-game', - gameUserId: 'test-game-user', - linkedAt: new Date().toISOString() - } - ] as LinkedAccount[]; - }), - - removeAccountLink: vi.fn(async (openGameUserId: string, gameId: string) => { - return gameId === 'test-game'; - }) - }; -} - -describe("Provider Auth Router", () => { - let mockHooks: ProviderAuthHooks; - - beforeEach(() => { - mockHooks = createMockProviderHooks(); - }); - - describe("getLinkedAccounts", () => { - it("should return linked accounts for authenticated user", async () => { - const router = createProviderAuthRouter(mockHooks); - - const request = new Request("https://example.com/auth/linked-accounts", { - method: "GET", - headers: { - "Authorization": "Bearer mock-session-token" - } - }); - - // Mock the verifySession function to return a userId - vi.spyOn(global, 'fetch').mockImplementation(async () => { - return new Response(JSON.stringify({ userId: 'test-user-id' }), { - status: 200, - headers: { 'Content-Type': 'application/json' } - }); - }); - - const response = await router.getLinkedAccounts(request); - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data).toHaveLength(1); - expect(data[0].gameId).toBe('test-game'); - - vi.restoreAllMocks(); - }); - - it("should return 401 for unauthenticated requests", async () => { - const router = createProviderAuthRouter(mockHooks); - - const request = new Request("https://example.com/auth/linked-accounts", { - method: "GET" - }); - - // Mock the verifySession function to return null (unauthenticated) - vi.spyOn(global, 'fetch').mockImplementation(async () => { - return new Response(JSON.stringify({ error: 'Unauthorized' }), { - status: 401, - headers: { 'Content-Type': 'application/json' } - }); - }); - - const response = await router.getLinkedAccounts(request); - - expect(response.status).toBe(401); - - vi.restoreAllMocks(); - }); - }); - - describe("createAccountLinkToken", () => { - it("should create a link token for authenticated user", async () => { - const router = createProviderAuthRouter(mockHooks); - - const request = new Request("https://example.com/auth/account-link-token", { - method: "POST", - headers: { - "Authorization": "Bearer mock-session-token", - "Content-Type": "application/json" - }, - body: JSON.stringify({ - gameId: "test-game" - }) - }); - - const response = await router.createAccountLinkToken(request); - const data = await response.json(); - - expect(response.status).toBe(500); - expect(data).toHaveProperty('error'); - - vi.restoreAllMocks(); - }); - - it("should return 401 for unauthenticated requests", async () => { - const router = createProviderAuthRouter(mockHooks); - - const request = new Request("https://example.com/auth/account-link-token", { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ - gameId: "test-game" - }) - }); - - // Mock the verifySession function to return null (unauthenticated) - vi.spyOn(global, 'fetch').mockImplementation(async () => { - return new Response(JSON.stringify({ error: 'Unauthorized' }), { - status: 401, - headers: { 'Content-Type': 'application/json' } - }); - }); - - const response = await router.createAccountLinkToken(request); - - expect(response.status).toBe(401); - - vi.restoreAllMocks(); - }); - - it("should return 400 for missing gameId", async () => { - const router = createProviderAuthRouter(mockHooks); - - const request = new Request("https://example.com/auth/account-link-token", { - method: "POST", - headers: { - "Authorization": "Bearer mock-session-token", - "Content-Type": "application/json" - }, - body: JSON.stringify({}) - }); - - // Mock the verifySession function to return a userId - vi.spyOn(global, 'fetch').mockImplementation(async () => { - return new Response(JSON.stringify({ userId: 'test-user-id', email: 'test@example.com' }), { - status: 200, - headers: { 'Content-Type': 'application/json' } - }); - }); - - const response = await router.createAccountLinkToken(request); - - expect(response.status).toBe(400); - - vi.restoreAllMocks(); - }); - }); - - describe("verifyLinkToken", () => { - it("should verify a valid link token with valid API key", async () => { - const router = createProviderAuthRouter(mockHooks); - - const request = new Request("https://example.com/auth/verify-link-token", { - method: "POST", - headers: { - "X-API-Key": "test-api-key", - "Content-Type": "application/json" - }, - body: JSON.stringify({ - token: "valid-token" - }) - }); - - const response = await router.verifyLinkToken(request); - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data.valid).toBe(true); - expect(data.openGameUserId).toBe('test-user-id'); - expect(data.email).toBe('test@example.com'); - - vi.restoreAllMocks(); - }); - - it("should return 401 for invalid API key", async () => { - const router = createProviderAuthRouter(mockHooks); - - const request = new Request("https://example.com/auth/verify-link-token", { - method: "POST", - headers: { - "X-API-Key": "invalid-api-key", - "Content-Type": "application/json" - }, - body: JSON.stringify({ - token: "valid-token" - }) - }); - - const response = await router.verifyLinkToken(request); - - expect(response.status).toBe(401); - }); - - it("should return 400 for missing token", async () => { - const router = createProviderAuthRouter(mockHooks); - - const request = new Request("https://example.com/auth/verify-link-token", { - method: "POST", - headers: { - "X-API-Key": "test-api-key", - "Content-Type": "application/json" - }, - body: JSON.stringify({}) - }); - - const response = await router.verifyLinkToken(request); - - expect(response.status).toBe(400); - }); - - it("should return invalid for invalid token", async () => { - const router = createProviderAuthRouter(mockHooks); - - const request = new Request("https://example.com/auth/verify-link-token", { - method: "POST", - headers: { - "X-API-Key": "test-api-key", - "Content-Type": "application/json" - }, - body: JSON.stringify({ - token: "invalid-token" - }) - }); - - const response = await router.verifyLinkToken(request); - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data.valid).toBe(false); - - vi.restoreAllMocks(); - }); - }); - - describe("confirmLink", () => { - it("should confirm a link between accounts with valid API key", async () => { - const router = createProviderAuthRouter(mockHooks); - - const request = new Request("https://example.com/auth/confirm-link", { - method: "POST", - headers: { - "X-API-Key": "test-api-key", - "Content-Type": "application/json" - }, - body: JSON.stringify({ - token: "valid-token", - gameUserId: "game-user-123" - }) - }); - - // Mock jose JWT functions - vi.mock("jose", () => { - return { - SignJWT: vi.fn().mockImplementation(() => { - return { - setProtectedHeader: () => ({ - setIssuedAt: () => ({ - setExpirationTime: () => ({ - sign: () => Promise.resolve("mock-token") - }) - }) - }) - }; - }), - jwtVerify: vi.fn().mockImplementation((token) => { - if (token === "valid-token") { - return Promise.resolve({ - payload: { - openGameUserId: 'test-user-id', - email: 'test@example.com', - type: 'link' - }, - protectedHeader: { alg: 'HS256' } - }); - } else { - return Promise.reject(new Error("Invalid token")); - } - }) - }; - }); - - // Mock storeAccountLink to return success - mockHooks.storeAccountLink = vi.fn().mockResolvedValue(true); - - const response = await router.confirmLink(request); - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data.success).toBe(true); - - vi.restoreAllMocks(); - }); - - it("should return 401 for invalid API key", async () => { - const router = createProviderAuthRouter(mockHooks); - - const request = new Request("https://example.com/auth/confirm-link", { - method: "POST", - headers: { - "X-API-Key": "invalid-api-key", - "Content-Type": "application/json" - }, - body: JSON.stringify({ - token: "valid-token", - gameUserId: "game-user-123" - }) - }); - - const response = await router.confirmLink(request); - - expect(response.status).toBe(401); - }); - - it("should return 400 for missing token or gameUserId", async () => { - const router = createProviderAuthRouter(mockHooks); - - const request = new Request("https://example.com/auth/confirm-link", { - method: "POST", - headers: { - "X-API-Key": "test-api-key", - "Content-Type": "application/json" - }, - body: JSON.stringify({ - token: "valid-token" - // Missing gameUserId - }) - }); - - const response = await router.confirmLink(request); - - expect(response.status).toBe(400); - }); - - it("should return 400 for invalid token", async () => { - const router = createProviderAuthRouter(mockHooks); - - const request = new Request("https://example.com/auth/confirm-link", { - method: "POST", - headers: { - "X-API-Key": "test-api-key", - "Content-Type": "application/json" - }, - body: JSON.stringify({ - token: "invalid-token", - gameUserId: "game-user-123" - }) - }); - - // Mock jose JWT functions - vi.mock("jose", () => { - return { - SignJWT: vi.fn().mockImplementation(() => { - return { - setProtectedHeader: () => ({ - setIssuedAt: () => ({ - setExpirationTime: () => ({ - sign: () => Promise.resolve("mock-token") - }) - }) - }) - }; - }), - jwtVerify: vi.fn().mockImplementation((token) => { - if (token === "invalid-token") { - return Promise.reject(new Error("Invalid token")); - } else { - return Promise.resolve({ - payload: { - openGameUserId: 'test-user-id', - email: 'test@example.com', - type: 'link' - }, - protectedHeader: { alg: 'HS256' } - }); - } - }) - }; - }); - - const response = await router.confirmLink(request); - - expect(response.status).toBe(400); - - vi.restoreAllMocks(); - }); - }); - - describe("unlinkAccount", () => { - it("should unlink an account for authenticated user", async () => { - const router = createProviderAuthRouter(mockHooks); - - const request = new Request("https://example.com/auth/linked-accounts/test-game", { - method: "DELETE", - headers: { - "Authorization": "Bearer mock-session-token" - } - }); - - // Mock the verifySession function to return a userId - vi.spyOn(global, 'fetch').mockImplementation(async () => { - return new Response(JSON.stringify({ userId: 'test-user-id' }), { - status: 200, - headers: { 'Content-Type': 'application/json' } - }); - }); - - const response = await router.unlinkAccount(request, "test-game"); - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data.success).toBe(true); - - vi.restoreAllMocks(); - }); - - it("should return 401 for unauthenticated requests", async () => { - const router = createProviderAuthRouter(mockHooks); - - const request = new Request("https://example.com/auth/linked-accounts/test-game", { - method: "DELETE" - }); - - // Mock the verifySession function to return null (unauthenticated) - vi.spyOn(global, 'fetch').mockImplementation(async () => { - return new Response(JSON.stringify({ error: 'Unauthorized' }), { - status: 401, - headers: { 'Content-Type': 'application/json' } - }); - }); - - const response = await router.unlinkAccount(request, "test-game"); - - expect(response.status).toBe(401); - - vi.restoreAllMocks(); - }); - - it("should return 404 for non-existent gameId", async () => { - // Mock removeAccountLink to return false for non-existent gameId - mockHooks.removeAccountLink = vi.fn(async (openGameUserId: string, gameId: string) => { - return gameId === "test-game"; - }); - - const router = createProviderAuthRouter(mockHooks); - - const request = new Request("https://example.com/auth/linked-accounts/non-existent-game", { - method: "DELETE", - headers: { - "Authorization": "Bearer mock-session-token" - } - }); - - // Mock the verifySession function to return a userId - vi.spyOn(global, 'fetch').mockImplementation(async () => { - return new Response(JSON.stringify({ userId: 'test-user-id' }), { - status: 200, - headers: { 'Content-Type': 'application/json' } - }); - }); - - const response = await router.unlinkAccount(request, "non-existent-game"); - - expect(response.status).toBe(404); - - vi.restoreAllMocks(); - }); - }); -}); - -// Mock the createLinkToken function -vi.mock("./server", async (importOriginal) => { - const originalModule = await importOriginal(); - return { - ...originalModule, - createLinkToken: vi.fn().mockResolvedValue("mock-link-token") - }; -}); \ No newline at end of file From 3f513008fda982f1167ec4b3d984902a2b1216da Mon Sep 17 00:00:00 2001 From: Jonathan Mumm Date: Thu, 6 Mar 2025 09:48:17 -0800 Subject: [PATCH 03/10] formatting --- biome.json | 2 +- src/client.test.ts | 344 ++++++++++----------- src/client.ts | 206 ++++++------- src/consumer-client.test.ts | 193 ++++++------ src/consumer-client.ts | 183 +++++------ src/consumer-react.test.tsx | 184 +++++------ src/consumer-react.tsx | 186 ++++++------ src/consumer-server.test.ts | 274 +++++++++-------- src/provider-client.test.ts | 174 +++++------ src/provider-client.ts | 153 ++++------ src/provider-react.test.tsx | 198 ++++++------ src/provider-react.tsx | 180 ++++++----- src/provider-server.test.ts | 426 +++++++++++++------------- src/react.test.tsx | 176 ++++++----- src/react.tsx | 50 ++- src/server.test.ts | 182 ++++------- src/server.ts | 588 +++++++++++++++++++----------------- src/test.ts | 122 ++++---- src/test/setup.ts | 10 +- src/types.ts | 39 +-- vitest.config.ts | 17 +- 21 files changed, 1907 insertions(+), 1980 deletions(-) diff --git a/biome.json b/biome.json index bfd6386..517099b 100644 --- a/biome.json +++ b/biome.json @@ -36,4 +36,4 @@ "semicolons": "always" } } -} \ No newline at end of file +} diff --git a/src/client.test.ts b/src/client.test.ts index 82e3fca..82d9555 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -1,7 +1,7 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { createAuthClient, createAnonymousUser } from './client'; -import { http, HttpResponse } from 'msw'; -import { server } from './test/setup'; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { createAuthClient, createAnonymousUser } from "./client"; +import { http, HttpResponse } from "msw"; +import { server } from "./test/setup"; // Declare window.location as mutable for tests declare global { @@ -10,202 +10,206 @@ declare global { } } -describe('createAnonymousUser', () => { +describe("createAnonymousUser", () => { beforeEach(() => { server.resetHandlers(); }); - it('should create an anonymous user', async () => { + it("should create an anonymous user", async () => { server.use( - http.post('http://localhost:8787/auth/anonymous', () => { + http.post("http://localhost:8787/auth/anonymous", () => { return HttpResponse.json({ - userId: 'new-user-id', - sessionToken: 'new-session-token', - email: null + userId: "new-user-id", + sessionToken: "new-session-token", + email: null, }); }) ); const result = await createAnonymousUser({ - host: 'localhost:8787', - email: 'test@example.com' + host: "localhost:8787", + email: "test@example.com", }); - expect(result.userId).toBe('new-user-id'); - expect(result.sessionToken).toBe('new-session-token'); + expect(result.userId).toBe("new-user-id"); + expect(result.sessionToken).toBe("new-session-token"); expect(result.email).toBeUndefined(); }); - it('should create an anonymous user with expiration options', async () => { + it("should create an anonymous user with expiration options", async () => { server.use( - http.post('http://localhost:8787/auth/anonymous', () => { + http.post("http://localhost:8787/auth/anonymous", () => { return HttpResponse.json({ - userId: 'anon-123', - sessionToken: 'session-token-123' + userId: "anon-123", + sessionToken: "session-token-123", }); }) ); - + const result = await createAnonymousUser({ - host: 'localhost:8787', - sessionTokenExpiresIn: '1h' + host: "localhost:8787", + sessionTokenExpiresIn: "1h", }); - - expect(result.userId).toBe('anon-123'); - expect(result.sessionToken).toBe('session-token-123'); + + expect(result.userId).toBe("anon-123"); + expect(result.sessionToken).toBe("session-token-123"); }); - it('should handle errors when creating anonymous user', async () => { + it("should handle errors when creating anonymous user", async () => { server.use( - http.post('http://localhost:8787/auth/anonymous', () => { - return new HttpResponse('Server error', { status: 500 }); + http.post("http://localhost:8787/auth/anonymous", () => { + return new HttpResponse("Server error", { status: 500 }); }) ); - await expect(createAnonymousUser({ - host: 'localhost:8787' - })).rejects.toThrow(); + await expect( + createAnonymousUser({ + host: "localhost:8787", + }) + ).rejects.toThrow(); }); }); -describe('AuthClient', () => { +describe("AuthClient", () => { beforeEach(() => { // Reset all handlers before each test server.resetHandlers(); }); - it('should initialize with correct state', () => { + it("should initialize with correct state", () => { const client = createAuthClient({ - host: 'localhost:8787', - userId: 'test-user', - sessionToken: 'test-session' + host: "localhost:8787", + userId: "test-user", + sessionToken: "test-session", }); const state = client.getState(); expect(state).toEqual({ isLoading: false, error: null, - userId: 'test-user', - sessionToken: 'test-session', - email: null + userId: "test-user", + sessionToken: "test-session", + email: null, }); }); - it('should notify subscribers of state changes', async () => { + it("should notify subscribers of state changes", async () => { server.use( - http.post('http://localhost:8787/auth/request-code', () => { + http.post("http://localhost:8787/auth/request-code", () => { return HttpResponse.json({ - userId: 'test-user-2', - sessionToken: 'test-session-2' + userId: "test-user-2", + sessionToken: "test-session-2", }); }) ); const client = createAuthClient({ - host: 'localhost:8787', - userId: 'test-user', - sessionToken: 'test-session' + host: "localhost:8787", + userId: "test-user", + sessionToken: "test-session", }); const states: any[] = []; client.subscribe((state) => states.push(state)); - await client.requestCode('test@example.com'); + await client.requestCode("test@example.com"); expect(states).toHaveLength(4); // Initial -> Loading -> Success -> Email extraction expect(states[states.length - 1]).toEqual({ isLoading: false, error: null, - userId: 'test-user-2', - sessionToken: 'test-session-2', - email: null + userId: "test-user-2", + sessionToken: "test-session-2", + email: null, }); }); - it('should handle email verification flow', async () => { + it("should handle email verification flow", async () => { // Mock JWT token that includes email - const mockSessionToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJ0ZXN0LXVzZXItMiIsInNlc3Npb25JZCI6InNlc3Npb24taWQiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJhdWQiOiJTRVNTSU9OIiwiZXhwIjoxNjE2MTYxNjE2fQ.signature'; - + const mockSessionToken = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJ0ZXN0LXVzZXItMiIsInNlc3Npb25JZCI6InNlc3Npb24taWQiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJhdWQiOiJTRVNTSU9OIiwiZXhwIjoxNjE2MTYxNjE2fQ.signature"; + server.use( - http.post('http://localhost:8787/auth/request-code', () => { + http.post("http://localhost:8787/auth/request-code", () => { return HttpResponse.json({ - userId: 'test-user-2', - sessionToken: 'test-session-2' + userId: "test-user-2", + sessionToken: "test-session-2", }); }), - http.post('http://localhost:8787/auth/verify', () => { + http.post("http://localhost:8787/auth/verify", () => { return HttpResponse.json({ success: true, - userId: 'test-user-2', - sessionToken: mockSessionToken + userId: "test-user-2", + sessionToken: mockSessionToken, }); }) ); const client = createAuthClient({ - host: 'localhost:8787', - userId: 'test-user', - sessionToken: 'test-session' + host: "localhost:8787", + userId: "test-user", + sessionToken: "test-session", }); // First request the code to get a userId - await client.requestCode('test@example.com'); - + await client.requestCode("test@example.com"); + // Then verify the email - const result = await client.verifyEmail('test@example.com', '123456'); + const result = await client.verifyEmail("test@example.com", "123456"); expect(result).toEqual({ success: true }); expect(client.getState()).toEqual({ isLoading: false, error: null, - userId: 'test-user-2', + userId: "test-user-2", sessionToken: mockSessionToken, - email: 'test@example.com' + email: "test@example.com", }); }); - it('should handle logout by clearing state', async () => { + it("should handle logout by clearing state", async () => { server.use( - http.post('http://localhost:8787/auth/logout', () => { + http.post("http://localhost:8787/auth/logout", () => { return HttpResponse.json({ success: true }); }) ); const client = createAuthClient({ - host: 'localhost:8787', - userId: 'test-user', - sessionToken: 'test-session' + host: "localhost:8787", + userId: "test-user", + sessionToken: "test-session", }); await client.logout(); expect(client.getState()).toEqual({ isLoading: false, - userId: '', - sessionToken: '', + userId: "", + sessionToken: "", email: null, - error: null + error: null, }); }); - it('should handle refresh token flow', async () => { + it("should handle refresh token flow", async () => { // Mock JWT token that includes email - const mockSessionToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJ0ZXN0LXVzZXIiLCJzZXNzaW9uSWQiOiJzZXNzaW9uLWlkIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiYXVkIjoiU0VTU0lPTiIsImV4cCI6MTYxNjE2MTYxNn0.signature'; - + const mockSessionToken = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJ0ZXN0LXVzZXIiLCJzZXNzaW9uSWQiOiJzZXNzaW9uLWlkIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiYXVkIjoiU0VTU0lPTiIsImV4cCI6MTYxNjE2MTYxNn0.signature"; + server.use( - http.post('http://localhost:8787/auth/refresh', () => { + http.post("http://localhost:8787/auth/refresh", () => { return HttpResponse.json({ - userId: 'test-user', + userId: "test-user", sessionToken: mockSessionToken, - email: 'test@example.com' + email: "test@example.com", }); }) ); const client = createAuthClient({ - host: 'localhost:8787', - userId: 'test-user', - sessionToken: 'test-session' + host: "localhost:8787", + userId: "test-user", + sessionToken: "test-session", }); await client.refresh(); @@ -213,197 +217,199 @@ describe('AuthClient', () => { expect(client.getState()).toEqual({ isLoading: false, error: null, - userId: 'test-user', + userId: "test-user", sessionToken: mockSessionToken, - email: 'test@example.com' + email: "test@example.com", }); }); - it('should handle API errors', async () => { + it("should handle API errors", async () => { server.use( - http.post('http://localhost:8787/auth/request-code', () => { - return new HttpResponse('Invalid email', { + http.post("http://localhost:8787/auth/request-code", () => { + return new HttpResponse("Invalid email", { status: 400, headers: { - 'Content-Type': 'text/plain' - } + "Content-Type": "text/plain", + }, }); }) ); const client = createAuthClient({ - host: 'localhost:8787', - userId: 'test-user', - sessionToken: 'test-session' + host: "localhost:8787", + userId: "test-user", + sessionToken: "test-session", }); - await expect(client.requestCode('invalid')).rejects.toThrow(); + await expect(client.requestCode("invalid")).rejects.toThrow(); expect(client.getState()).toEqual({ isLoading: false, - error: 'Invalid email', - userId: 'test-user', - sessionToken: 'test-session', - email: null + error: "Invalid email", + userId: "test-user", + sessionToken: "test-session", + email: null, }); }); - it('should maintain sessionToken in state after initialization', () => { + it("should maintain sessionToken in state after initialization", () => { const client = createAuthClient({ - host: 'localhost:8787', - userId: 'test-user', - sessionToken: 'initial-session-token' + host: "localhost:8787", + userId: "test-user", + sessionToken: "initial-session-token", }); - expect(client.getState().sessionToken).toBe('initial-session-token'); + expect(client.getState().sessionToken).toBe("initial-session-token"); }); - it('should update sessionToken after successful verification', async () => { + it("should update sessionToken after successful verification", async () => { // Mock JWT token that includes email - const mockSessionToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJ0ZXN0LXVzZXIiLCJzZXNzaW9uSWQiOiJzZXNzaW9uLWlkIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiYXVkIjoiU0VTU0lPTiIsImV4cCI6MTYxNjE2MTYxNn0.signature'; - + const mockSessionToken = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJ0ZXN0LXVzZXIiLCJzZXNzaW9uSWQiOiJzZXNzaW9uLWlkIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiYXVkIjoiU0VTU0lPTiIsImV4cCI6MTYxNjE2MTYxNn0.signature"; + server.use( - http.post('http://localhost:8787/auth/verify', () => { + http.post("http://localhost:8787/auth/verify", () => { return HttpResponse.json({ success: true, - userId: 'test-user', - sessionToken: mockSessionToken + userId: "test-user", + sessionToken: mockSessionToken, }); }) ); const client = createAuthClient({ - host: 'localhost:8787', - userId: 'test-user', - sessionToken: 'initial-session-token' + host: "localhost:8787", + userId: "test-user", + sessionToken: "initial-session-token", }); - await client.verifyEmail('test@example.com', '123456'); + await client.verifyEmail("test@example.com", "123456"); expect(client.getState().sessionToken).toBe(mockSessionToken); - expect(client.getState().email).toBe('test@example.com'); + expect(client.getState().email).toBe("test@example.com"); }); - it('should update sessionToken after successful refresh', async () => { + it("should update sessionToken after successful refresh", async () => { // Mock JWT token that includes email - const mockSessionToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJ0ZXN0LXVzZXIiLCJzZXNzaW9uSWQiOiJzZXNzaW9uLWlkIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiYXVkIjoiU0VTU0lPTiIsImV4cCI6MTYxNjE2MTYxNn0.signature'; - + const mockSessionToken = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJ0ZXN0LXVzZXIiLCJzZXNzaW9uSWQiOiJzZXNzaW9uLWlkIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwiYXVkIjoiU0VTU0lPTiIsImV4cCI6MTYxNjE2MTYxNn0.signature"; + server.use( - http.post('http://localhost:8787/auth/refresh', () => { + http.post("http://localhost:8787/auth/refresh", () => { return HttpResponse.json({ success: true, - userId: 'test-user', + userId: "test-user", sessionToken: mockSessionToken, - email: 'test@example.com' + email: "test@example.com", }); }) ); const client = createAuthClient({ - host: 'localhost:8787', - userId: 'test-user', - sessionToken: 'initial-session-token' + host: "localhost:8787", + userId: "test-user", + sessionToken: "initial-session-token", }); await client.refresh(); expect(client.getState().sessionToken).toBe(mockSessionToken); - expect(client.getState().email).toBe('test@example.com'); + expect(client.getState().email).toBe("test@example.com"); }); - it('should refresh the session token', async () => { + it("should refresh the session token", async () => { server.use( - http.post('http://localhost:8787/auth/refresh', () => { + http.post("http://localhost:8787/auth/refresh", () => { return HttpResponse.json({ - sessionToken: 'refreshed-session-token' + sessionToken: "refreshed-session-token", }); }) ); - + const client = createAuthClient({ - host: 'localhost:8787', - userId: 'test-user', - sessionToken: 'test-session' + host: "localhost:8787", + userId: "test-user", + sessionToken: "test-session", }); - + await client.refresh(); - - expect(client.getState().sessionToken).toBe('refreshed-session-token'); + + expect(client.getState().sessionToken).toBe("refreshed-session-token"); }); - it('should handle authentication with session token', async () => { + it("should handle authentication with session token", async () => { const client = createAuthClient({ - host: 'localhost:8787', - userId: 'test-user', - sessionToken: 'test-session' + host: "localhost:8787", + userId: "test-user", + sessionToken: "test-session", }); - - expect(client.getState().userId).toBe('test-user'); - expect(client.getState().sessionToken).toBe('test-session'); + + expect(client.getState().userId).toBe("test-user"); + expect(client.getState().sessionToken).toBe("test-session"); }); - it('should handle refresh token response', async () => { + it("should handle refresh token response", async () => { server.use( - http.post('http://localhost:8787/auth/refresh', () => { + http.post("http://localhost:8787/auth/refresh", () => { return HttpResponse.json({ - sessionToken: 'new-session' + sessionToken: "new-session", }); }) ); - + const client = createAuthClient({ - host: 'localhost:8787', - userId: 'test-user', - sessionToken: 'initial-session-token' + host: "localhost:8787", + userId: "test-user", + sessionToken: "initial-session-token", }); - + await client.refresh(); - - expect(client.getState().sessionToken).toBe('new-session'); + + expect(client.getState().sessionToken).toBe("new-session"); }); }); -describe('Mobile-to-Web Authentication', () => { +describe("Mobile-to-Web Authentication", () => { beforeEach(() => { server.resetHandlers(); }); - it('should generate web auth code', async () => { + it("should generate web auth code", async () => { server.use( - http.post('http://localhost:8787/auth/web-auth-code', () => { + http.post("http://localhost:8787/auth/web-auth-code", () => { return HttpResponse.json({ - code: 'test-web-code', - expiresIn: 300 + code: "test-web-code", + expiresIn: 300, }); }) ); const client = createAuthClient({ - host: 'localhost:8787', - userId: 'test-user', - sessionToken: 'test-session' + host: "localhost:8787", + userId: "test-user", + sessionToken: "test-session", }); const result = await client.getWebAuthCode(); expect(result).toEqual({ - code: 'test-web-code', - expiresIn: 300 + code: "test-web-code", + expiresIn: 300, }); }); - it('should handle web auth code errors', async () => { + it("should handle web auth code errors", async () => { server.use( - http.post('http://localhost:8787/auth/web-auth-code', () => { - return new HttpResponse('Unauthorized', { status: 401 }); + http.post("http://localhost:8787/auth/web-auth-code", () => { + return new HttpResponse("Unauthorized", { status: 401 }); }) ); const client = createAuthClient({ - host: 'localhost:8787', - userId: 'test-user', - sessionToken: 'test-session' + host: "localhost:8787", + userId: "test-user", + sessionToken: "test-session", }); - await expect(client.getWebAuthCode()).rejects.toThrow('Unauthorized'); + await expect(client.getWebAuthCode()).rejects.toThrow("Unauthorized"); }); -}); \ No newline at end of file +}); diff --git a/src/client.ts b/src/client.ts index 032fb4f..1e5aeef 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,4 +1,4 @@ -import { APIError, AuthClient, AuthState, STORAGE_KEYS, UserCredentials } from './types'; +import { APIError, AuthClient, AuthState, STORAGE_KEYS, UserCredentials } from "./types"; import type { AuthClientConfig, AnonymousUserConfig } from "./types"; /** @@ -9,86 +9,87 @@ import type { AuthClientConfig, AnonymousUserConfig } from "./types"; function decodeJWT(token: string): Record | null { try { // Check if token is valid - if (!token || typeof token !== 'string') { + if (!token || typeof token !== "string") { return null; } - - const parts = token.split('.'); + + const parts = token.split("."); if (parts.length !== 3) { return null; } - + const base64Url = parts[1]; if (!base64Url) return null; - + // Replace characters for base64 decoding - const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); - + const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); + // Cross-platform base64 decoding implementation let jsonPayload: string; - + // For React Native environment - if (typeof global !== 'undefined' && global.Buffer) { - jsonPayload = global.Buffer.from(base64, 'base64').toString('utf8'); - } + if (typeof global !== "undefined" && global.Buffer) { + jsonPayload = global.Buffer.from(base64, "base64").toString("utf8"); + } // For browser environment - else if (typeof atob === 'function') { + else if (typeof atob === "function") { const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } jsonPayload = new TextDecoder().decode(bytes); - } + } // Pure JS implementation for environments without native base64 support else { // Implementation of base64 decoder without external dependencies - const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; - let output = ''; - + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="; + let output = ""; + // Remove padding - const str = base64.replace(/=+$/, ''); - + const str = base64.replace(/=+$/, ""); + if (str.length % 4 === 1) { throw new Error("Invalid base64 string"); } - - for (let bc = 0, bs = 0, buffer, i = 0; buffer = str.charAt(i++);) { + + for (let bc = 0, bs = 0, buffer, i = 0; (buffer = str.charAt(i++)); ) { // Check if the character exists in the base64 character set const idx = chars.indexOf(buffer); if (idx === -1) continue; - + bs = bc % 4 ? bs * 64 + idx : idx; if (bc++ % 4) { - output += String.fromCharCode(255 & bs >> (-2 * bc & 6)); + output += String.fromCharCode(255 & (bs >> ((-2 * bc) & 6))); } } - + jsonPayload = output; } - + return JSON.parse(jsonPayload); } catch (error) { - console.error('Error decoding JWT:', error); + console.error("Error decoding JWT:", error); return null; } } export async function createAnonymousUser(config: AnonymousUserConfig): Promise { // Add protocol if not present - const apiHost = config.host.startsWith('http://') || config.host.startsWith('https://') - ? config.host - : `http://${config.host}`; + const apiHost = + config.host.startsWith("http://") || config.host.startsWith("https://") + ? config.host + : `http://${config.host}`; const response = await fetch(`${apiHost}/auth/anonymous`, { - method: 'POST', + method: "POST", headers: { - 'Content-Type': 'application/json' + "Content-Type": "application/json", }, body: JSON.stringify({ refreshTokenExpiresIn: config.refreshTokenExpiresIn, - sessionTokenExpiresIn: config.sessionTokenExpiresIn - }) + sessionTokenExpiresIn: config.sessionTokenExpiresIn, + }), }); if (!response.ok) { @@ -97,12 +98,12 @@ export async function createAnonymousUser(config: AnonymousUserConfig): Promise< } const data = await response.json(); - + // Convert null email to undefined to match test expectations if (data.email === null) { data.email = undefined; } - + return data; } @@ -113,26 +114,26 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { sessionToken: config.sessionToken, email: null, isLoading: false, - error: null + error: null, }; - + // Merge with provided initial state if any if (config.initialState) { Object.assign(initialState, config.initialState); } - + // State management let state = initialState; const subscribers: ((state: AuthState) => void)[] = []; - + // Update state and notify subscribers const setState = (updater: (draft: AuthState) => void) => { const nextState = { ...state }; updater(nextState); state = nextState; - subscribers.forEach(callback => callback(state)); + subscribers.forEach((callback) => callback(state)); }; - + // Create API request helper const apiRequest = async ( method: string, @@ -140,76 +141,73 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { body?: any, authenticated: boolean = true ): Promise => { - setState(draft => { + setState((draft) => { draft.isLoading = true; draft.error = null; }); - + try { // Ensure we're working with a string path - if (typeof path !== 'string') { + if (typeof path !== "string") { path = String(path); } - + // Normalize the path to ensure it starts with a slash - const normalizedPath = path.startsWith('/') ? path : `/${path}`; - + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + // Ensure the host is properly formatted - const host = config.host.replace(/^https?:\/\//, ''); - + const host = config.host.replace(/^https?:\/\//, ""); + // Construct the full URL const url = `http://${host}${normalizedPath}`; - + const headers: HeadersInit = { - 'Content-Type': 'application/json' + "Content-Type": "application/json", }; - + if (authenticated && state.sessionToken) { - headers['Authorization'] = `Bearer ${state.sessionToken}`; + headers["Authorization"] = `Bearer ${state.sessionToken}`; } - + const response = await fetch(url, { method, headers, - body: body ? JSON.stringify(body) : undefined + body: body ? JSON.stringify(body) : undefined, }); - + if (!response.ok) { const errorText = await response.text(); const errorMessage = errorText || `API request failed with status ${response.status}`; - throw new APIError( - errorMessage, - response.status - ); + throw new APIError(errorMessage, response.status); } - + // For 204 No Content responses if (response.status === 204) { - setState(draft => { + setState((draft) => { draft.isLoading = false; }); return {} as T; } - + const data = await response.json(); - - setState(draft => { + + setState((draft) => { draft.isLoading = false; }); - + return data as T; } catch (error) { - setState(draft => { + setState((draft) => { draft.isLoading = false; draft.error = error instanceof Error ? error.message : String(error); }); throw error; } }; - + // Store session token in local storage const storeSessionToken = (token: string | null) => { - if (typeof window !== 'undefined') { + if (typeof window !== "undefined") { if (token) { localStorage.setItem(STORAGE_KEYS.sessionToken, token); } else { @@ -217,13 +215,13 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { } } }; - + // Auth client implementation const client: AuthClient = { getState() { return state; }, - + subscribe(callback) { subscribers.push(callback); callback(state); @@ -234,94 +232,86 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { } }; }, - + async requestCode(email: string) { const result = await apiRequest<{ success: boolean } & UserCredentials>( - 'POST', - '/auth/request-code', + "POST", + "/auth/request-code", { email } ); - - setState(draft => { + + setState((draft) => { if (result.userId) draft.userId = result.userId; if (result.sessionToken) draft.sessionToken = result.sessionToken; }); - + if (result.sessionToken) { storeSessionToken(result.sessionToken); } }, - + async verifyEmail(email: string, code: string): Promise<{ success: boolean }> { const result = await apiRequest( - 'POST', - '/auth/verify', + "POST", + "/auth/verify", { email, code } ); - - setState(draft => { + + setState((draft) => { draft.userId = result.userId; draft.sessionToken = result.sessionToken || null; draft.email = email; }); - + if (result.sessionToken) { storeSessionToken(result.sessionToken); } - + return { success: true }; }, - + async logout() { try { - await apiRequest( - 'POST', - '/auth/logout', - undefined - ); + await apiRequest("POST", "/auth/logout", undefined); } catch (error) { // Continue with logout even if the API call fails - console.error('Error during logout:', error); + console.error("Error during logout:", error); } - - setState(draft => { + + setState((draft) => { draft.sessionToken = ""; draft.userId = ""; draft.email = null; draft.error = null; draft.isLoading = false; }); - + storeSessionToken(null); }, - + async refresh(): Promise { - const result = await apiRequest( - 'POST', - '/auth/refresh', - undefined - ); - - setState(draft => { + const result = await apiRequest("POST", "/auth/refresh", undefined); + + setState((draft) => { draft.userId = result.userId; draft.sessionToken = result.sessionToken || null; draft.email = result.email ?? null; }); - + if (result.sessionToken) { storeSessionToken(result.sessionToken); } }, - + async getWebAuthCode() { return apiRequest<{ code: string; expiresIn: number }>( - 'POST', - '/auth/web-auth-code', + "POST", + "/auth/web-auth-code", undefined ); - } + }, }; - + return client; } diff --git a/src/consumer-client.test.ts b/src/consumer-client.test.ts index 39783b4..d77ba94 100644 --- a/src/consumer-client.test.ts +++ b/src/consumer-client.test.ts @@ -1,179 +1,182 @@ -import { describe, it, expect, beforeEach, vi, afterEach, afterAll } from 'vitest'; -import { createConsumerAuthClient } from './consumer-client'; -import { setupServer } from 'msw/node'; -import { http, HttpResponse } from 'msw'; -import { ConsumerAuthState, OpenGameLink } from './types'; +import { describe, it, expect, beforeEach, vi, afterEach, afterAll } from "vitest"; +import { createConsumerAuthClient } from "./consumer-client"; +import { setupServer } from "msw/node"; +import { http, HttpResponse } from "msw"; +import { ConsumerAuthState, OpenGameLink } from "./types"; -describe('Consumer Auth Client', () => { +describe("Consumer Auth Client", () => { // Mock server setup const server = setupServer( // GET /opengame-link - http.get('http://localhost/opengame-link', () => { + http.get("http://localhost/opengame-link", () => { return HttpResponse.json({ isLinked: true, - openGameUserId: 'og-user-123', - linkedAt: '2023-01-01T00:00:00Z', + openGameUserId: "og-user-123", + linkedAt: "2023-01-01T00:00:00Z", profile: { - displayName: 'OpenGame User', - avatarUrl: 'https://example.com/avatar.png' - } + displayName: "OpenGame User", + avatarUrl: "https://example.com/avatar.png", + }, }); }), - + // POST /verify-link-token - http.post('http://localhost/verify-link-token', () => { + http.post("http://localhost/verify-link-token", () => { return HttpResponse.json({ valid: true, - openGameUserId: 'og-user-123', - email: 'user@opengame.com' + openGameUserId: "og-user-123", + email: "user@opengame.com", }); }), - + // POST /verify-link-token (invalid) - http.post('http://localhost/verify-link-token', ({ request }) => { + http.post("http://localhost/verify-link-token", ({ request }) => { const url = new URL(request.url); const searchParams = new URLSearchParams(url.search); - if (searchParams.get('token') === 'invalid-token') { + if (searchParams.get("token") === "invalid-token") { return HttpResponse.json({ - valid: false + valid: false, }); } return HttpResponse.json({ valid: true, - openGameUserId: 'og-user-123', - email: 'user@opengame.com' + openGameUserId: "og-user-123", + email: "user@opengame.com", }); }), - + // POST /confirm-link - http.post('http://localhost/confirm-link', () => { + http.post("http://localhost/confirm-link", () => { return HttpResponse.json({ - success: true + success: true, }); }) ); - + beforeEach(() => { server.listen(); }); - + afterEach(() => { server.resetHandlers(); }); - + afterAll(() => { server.close(); }); - - it('should initialize with default state', () => { + + it("should initialize with default state", () => { const client = createConsumerAuthClient({ - host: 'http://localhost', - userId: 'test-user', - sessionToken: 'test-token' + host: "http://localhost", + userId: "test-user", + sessionToken: "test-token", }); - - expect(client.getState().userId).toBe('test-user'); - expect(client.getState().sessionToken).toBe('test-token'); + + expect(client.getState().userId).toBe("test-user"); + expect(client.getState().sessionToken).toBe("test-token"); expect(client.getState().openGameLink).toBeUndefined(); }); - - it('should fetch OpenGame link status', async () => { + + it("should fetch OpenGame link status", async () => { const client = createConsumerAuthClient({ - host: 'http://localhost', - userId: 'test-user', - sessionToken: 'test-token' + host: "http://localhost", + userId: "test-user", + sessionToken: "test-token", }); - + const status = await client.getOpenGameLinkStatus(); - + // Type guard to check if isLinked is true expect(status.isLinked).toBe(true); - + if (status.isLinked) { - expect(status.openGameUserId).toBe('og-user-123'); - expect(status.profile?.displayName).toBe('OpenGame User'); - expect(client.getState().openGameLink?.openGameUserId).toBe('og-user-123'); + expect(status.openGameUserId).toBe("og-user-123"); + expect(status.profile?.displayName).toBe("OpenGame User"); + expect(client.getState().openGameLink?.openGameUserId).toBe("og-user-123"); } }); - - it('should verify a link token', async () => { + + it("should verify a link token", async () => { const client = createConsumerAuthClient({ - host: 'http://localhost', - userId: 'test-user', - sessionToken: 'test-token' + host: "http://localhost", + userId: "test-user", + sessionToken: "test-token", }); - - const result = await client.verifyLinkToken('test-token'); - + + const result = await client.verifyLinkToken("test-token"); + // Type guard to check if valid is true expect(result.valid).toBe(true); - + if (result.valid) { - expect(result.openGameUserId).toBe('og-user-123'); - expect(result.email).toBe('user@opengame.com'); + expect(result.openGameUserId).toBe("og-user-123"); + expect(result.email).toBe("user@opengame.com"); } }); - - it('should handle invalid link tokens', async () => { + + it("should handle invalid link tokens", async () => { // Mock server to return invalid token response server.use( - http.post('http://localhost/verify-link-token', () => { + http.post("http://localhost/verify-link-token", () => { return HttpResponse.json({ - valid: false + valid: false, }); }) ); - + const client = createConsumerAuthClient({ - host: 'http://localhost', - userId: 'test-user', - sessionToken: 'test-token' + host: "http://localhost", + userId: "test-user", + sessionToken: "test-token", }); - - const result = await client.verifyLinkToken('invalid-token'); - + + const result = await client.verifyLinkToken("invalid-token"); + expect(result.valid).toBe(false); }); - - it('should confirm a link between accounts', async () => { + + it("should confirm a link between accounts", async () => { // Mock server to return success server.use( - http.post('http://localhost/confirm-link', () => { - return HttpResponse.json({ - success: true, - openGameUserId: 'og-user-123' - }, { status: 200 }); + http.post("http://localhost/confirm-link", () => { + return HttpResponse.json( + { + success: true, + openGameUserId: "og-user-123", + }, + { status: 200 } + ); }) ); - + const client = createConsumerAuthClient({ - host: 'http://localhost', - userId: 'test-user', - sessionToken: 'test-token' + host: "http://localhost", + userId: "test-user", + sessionToken: "test-token", }); - - const success = await client.confirmLink('test-token', 'game-user-123'); - + + const success = await client.confirmLink("test-token", "game-user-123"); + expect(success).toBe(true); expect(client.getState().openGameLink).toBeDefined(); - expect(client.getState().openGameLink?.openGameUserId).toBe('og-user-123'); + expect(client.getState().openGameLink?.openGameUserId).toBe("og-user-123"); }); - - it('should handle errors when confirming links', async () => { + + it("should handle errors when confirming links", async () => { // Mock server to return error server.use( - http.post('http://localhost/confirm-link', () => { - throw new Error('Network error'); + http.post("http://localhost/confirm-link", () => { + throw new Error("Network error"); }) ); - + const client = createConsumerAuthClient({ - host: 'http://localhost', - userId: 'test-user', - sessionToken: 'test-token' + host: "http://localhost", + userId: "test-user", + sessionToken: "test-token", }); - - await expect(client.confirmLink('invalid-token', 'game-user-123')).rejects.toThrow(); + + await expect(client.confirmLink("invalid-token", "game-user-123")).rejects.toThrow(); expect(client.getState().openGameLink).toBeUndefined(); }); -}); \ No newline at end of file +}); diff --git a/src/consumer-client.ts b/src/consumer-client.ts index f84ed38..54d544e 100644 --- a/src/consumer-client.ts +++ b/src/consumer-client.ts @@ -1,4 +1,4 @@ -import { ConsumerAuthClient, ConsumerAuthState, OpenGameLink } from './types'; +import { ConsumerAuthClient, ConsumerAuthState, OpenGameLink } from "./types"; interface ConsumerAuthClientConfig { host: string; @@ -19,13 +19,13 @@ export function createConsumerAuthClient(config: ConsumerAuthClientConfig): Cons isLoading: false, error: null, openGameLink: undefined, - requests: {} + requests: {}, }; // Merge with provided initial state let state: ConsumerAuthState = { ...defaultState, - ...config.initialState + ...config.initialState, }; // Subscribers @@ -47,24 +47,25 @@ export function createConsumerAuthClient(config: ConsumerAuthClientConfig): Cons requestId?: string ): Promise => { // Add protocol if not present - const apiHost = config.host.startsWith('http://') || config.host.startsWith('https://') - ? config.host - : `https://${config.host}`; + const apiHost = + config.host.startsWith("http://") || config.host.startsWith("https://") + ? config.host + : `https://${config.host}`; // Set loading state if (requestId) { - setState(draft => { + setState((draft) => { draft.requests = { ...draft.requests, [requestId]: { isLoading: true, error: null, - lastUpdated: new Date().toISOString() - } + lastUpdated: new Date().toISOString(), + }, }; }); } else { - setState(draft => { + setState((draft) => { draft.isLoading = true; draft.error = null; }); @@ -74,14 +75,14 @@ export function createConsumerAuthClient(config: ConsumerAuthClientConfig): Cons const response = await fetch(`${apiHost}/${path}`, { method, headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${state.sessionToken}` + "Content-Type": "application/json", + Authorization: `Bearer ${state.sessionToken}`, }, - body: body ? JSON.stringify(body) : undefined + body: body ? JSON.stringify(body) : undefined, }); if (!response.ok) { - const errorData = await response.json().catch(() => ({ message: 'Unknown error' })); + const errorData = await response.json().catch(() => ({ message: "Unknown error" })); throw new Error(errorData.message || `API error: ${response.status}`); } @@ -89,45 +90,45 @@ export function createConsumerAuthClient(config: ConsumerAuthClientConfig): Cons // Clear loading state if (requestId) { - setState(draft => { + setState((draft) => { draft.requests = { ...draft.requests, [requestId]: { isLoading: false, error: null, - lastUpdated: new Date().toISOString() - } + lastUpdated: new Date().toISOString(), + }, }; }); } else { - setState(draft => { + setState((draft) => { draft.isLoading = false; }); } return data; } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + // Set error state if (requestId) { - setState(draft => { + setState((draft) => { draft.requests = { ...draft.requests, [requestId]: { isLoading: false, error: errorMessage, - lastUpdated: new Date().toISOString() - } + lastUpdated: new Date().toISOString(), + }, }; }); } else { - setState(draft => { + setState((draft) => { draft.isLoading = false; draft.error = errorMessage; }); } - + throw error; } }; @@ -137,7 +138,7 @@ export function createConsumerAuthClient(config: ConsumerAuthClientConfig): Cons getState() { return state; }, - + subscribe(callback) { subscribers.push(callback); callback(state); @@ -148,173 +149,141 @@ export function createConsumerAuthClient(config: ConsumerAuthClientConfig): Cons } }; }, - + async getOpenGameLinkStatus() { - const requestId = 'getOpenGameLinkStatus'; - + const requestId = "getOpenGameLinkStatus"; + const result = await apiRequest<{ isLinked: boolean; openGameUserId?: string; linkedAt?: string; - profile?: OpenGameLink['profile']; - }>( - 'GET', - 'opengame-link', - undefined, - requestId - ); - + profile?: OpenGameLink["profile"]; + }>("GET", "opengame-link", undefined, requestId); + if (result.isLinked && result.openGameUserId) { - setState(draft => { + setState((draft) => { draft.openGameLink = { openGameUserId: result.openGameUserId!, linkedAt: result.linkedAt || new Date().toISOString(), - profile: result.profile + profile: result.profile, }; }); - + return { isLinked: true, openGameUserId: result.openGameUserId, linkedAt: result.linkedAt || new Date().toISOString(), - profile: result.profile + profile: result.profile, }; } else { - setState(draft => { + setState((draft) => { draft.openGameLink = undefined; }); - + return { isLinked: false }; } }, - + async verifyLinkToken(token: string) { - const requestId = 'verifyLinkToken'; - + const requestId = "verifyLinkToken"; + const result = await apiRequest<{ valid: boolean; openGameUserId?: string; email?: string; - }>( - 'POST', - 'verify-link-token', - { token }, - requestId - ); - + }>("POST", "verify-link-token", { token }, requestId); + if (result.valid && result.openGameUserId && result.email) { return { valid: true, openGameUserId: result.openGameUserId, - email: result.email + email: result.email, }; } else { return { valid: false }; } }, - + async confirmLink(token: string, gameUserId: string) { - const requestId = 'confirmLink'; - + const requestId = "confirmLink"; + try { const result = await apiRequest<{ success: boolean; openGameUserId?: string; linkedAt?: string; - }>( - 'POST', - 'confirm-link', - { token, gameUserId }, - requestId - ); - + }>("POST", "confirm-link", { token, gameUserId }, requestId); + if (result.success && result.openGameUserId) { - setState(draft => { + setState((draft) => { draft.openGameLink = { openGameUserId: result.openGameUserId!, - linkedAt: result.linkedAt || new Date().toISOString() + linkedAt: result.linkedAt || new Date().toISOString(), }; }); - + return true; } - + return false; } catch (error) { - setState(draft => { - draft.error = error instanceof Error ? error.message : 'Failed to confirm link'; + setState((draft) => { + draft.error = error instanceof Error ? error.message : "Failed to confirm link"; }); throw error; } }, - + // Inherit base auth methods async requestCode(email: string) { - await apiRequest( - 'POST', - 'request-code', - { email } - ); + await apiRequest("POST", "request-code", { email }); }, - + async verifyEmail(email: string, code: string) { const result = await apiRequest<{ success: boolean; userId?: string; sessionToken?: string; - }>( - 'POST', - 'verify-email', - { email, code } - ); - + }>("POST", "verify-email", { email, code }); + if (result.success && result.sessionToken) { - setState(draft => { + setState((draft) => { draft.email = email; draft.userId = result.userId || draft.userId; draft.sessionToken = result.sessionToken || null; }); } - + return { success: result.success }; }, - + async logout() { - await apiRequest( - 'POST', - 'logout' - ); - - setState(draft => { + await apiRequest("POST", "logout"); + + setState((draft) => { draft.sessionToken = null; draft.email = null; draft.openGameLink = undefined; }); }, - + async refresh() { const result = await apiRequest<{ sessionToken: string; - }>( - 'POST', - 'refresh' - ); - - setState(draft => { + }>("POST", "refresh"); + + setState((draft) => { draft.sessionToken = result.sessionToken || null; }); }, - + async getWebAuthCode() { const result = await apiRequest<{ code: string; expiresIn: number; - }>( - 'GET', - 'web-auth-code' - ); - + }>("GET", "web-auth-code"); + return result; - } + }, }; -} \ No newline at end of file +} diff --git a/src/consumer-react.test.tsx b/src/consumer-react.test.tsx index 942fb67..47dcb93 100644 --- a/src/consumer-react.test.tsx +++ b/src/consumer-react.test.tsx @@ -1,112 +1,112 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, act, fireEvent } from '@testing-library/react'; -import { createConsumerAuthContext } from './consumer-react'; -import { createConsumerAuthMockClient } from './test'; -import React from 'react'; -import type { ConsumerAuthState, OpenGameLink } from './types'; - -describe('Consumer Auth Context', () => { - describe('useClient', () => { - it('should provide access to the auth client', () => { +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, act, fireEvent } from "@testing-library/react"; +import { createConsumerAuthContext } from "./consumer-react"; +import { createConsumerAuthMockClient } from "./test"; +import React from "react"; +import type { ConsumerAuthState, OpenGameLink } from "./types"; + +describe("Consumer Auth Context", () => { + describe("useClient", () => { + it("should provide access to the auth client", () => { const mockClient = createConsumerAuthMockClient({ initialState: { - userId: 'test-user', - sessionToken: 'test-token', - email: 'test@example.com', + userId: "test-user", + sessionToken: "test-token", + email: "test@example.com", isLoading: false, error: null, openGameLink: undefined, - requests: {} - } + requests: {}, + }, }); - + const AuthContext = createConsumerAuthContext(); - + const TestComponent = () => { const client = AuthContext.useClient(); return
{client.getState().userId}
; }; - + render( ); - - expect(screen.getByTestId('user-id').textContent).toBe('test-user'); + + expect(screen.getByTestId("user-id").textContent).toBe("test-user"); }); }); - - describe('useSelector', () => { - it('should select and subscribe to state changes', async () => { + + describe("useSelector", () => { + it("should select and subscribe to state changes", async () => { const mockClient = createConsumerAuthMockClient({ initialState: { - userId: 'test-user', - sessionToken: 'test-token', - email: 'test@example.com', + userId: "test-user", + sessionToken: "test-token", + email: "test@example.com", isLoading: false, error: null, openGameLink: undefined, - requests: {} - } + requests: {}, + }, }); - + const AuthContext = createConsumerAuthContext(); - + const TestComponent = () => { - const userId = AuthContext.useSelector(state => state.userId); - const isLinked = AuthContext.useSelector(state => !!state.openGameLink); - + const userId = AuthContext.useSelector((state) => state.userId); + const isLinked = AuthContext.useSelector((state) => !!state.openGameLink); + return (
{userId}
-
{isLinked ? 'Linked' : 'Not Linked'}
+
{isLinked ? "Linked" : "Not Linked"}
); }; - + render( ); - - expect(screen.getByTestId('user-id').textContent).toBe('test-user'); - expect(screen.getByTestId('is-linked').textContent).toBe('Not Linked'); - + + expect(screen.getByTestId("user-id").textContent).toBe("test-user"); + expect(screen.getByTestId("is-linked").textContent).toBe("Not Linked"); + // Update state act(() => { - mockClient.produce(draft => { + mockClient.produce((draft) => { draft.openGameLink = { - openGameUserId: 'og-user-123', - linkedAt: '2023-01-01T00:00:00Z', + openGameUserId: "og-user-123", + linkedAt: "2023-01-01T00:00:00Z", profile: { - displayName: 'Test User' - } + displayName: "Test User", + }, }; }); }); - - expect(screen.getByTestId('is-linked').textContent).toBe('Linked'); + + expect(screen.getByTestId("is-linked").textContent).toBe("Linked"); }); }); - - describe('LinkedWithOpenGame and NotLinkedWithOpenGame', () => { - it('should conditionally render based on link status', async () => { + + describe("LinkedWithOpenGame and NotLinkedWithOpenGame", () => { + it("should conditionally render based on link status", async () => { const mockClient = createConsumerAuthMockClient({ initialState: { - userId: 'test-user', - sessionToken: 'test-token', - email: 'test@example.com', + userId: "test-user", + sessionToken: "test-token", + email: "test@example.com", isLoading: false, error: null, openGameLink: undefined, - requests: {} - } + requests: {}, + }, }); - + const AuthContext = createConsumerAuthContext(); - + const TestComponent = () => { return (
@@ -119,75 +119,75 @@ describe('Consumer Auth Context', () => {
); }; - + render( ); - + // Initially not linked - expect(screen.queryByTestId('is-linked')).toBeNull(); - expect(screen.getByTestId('not-linked')).toBeInTheDocument(); - + expect(screen.queryByTestId("is-linked")).toBeNull(); + expect(screen.getByTestId("not-linked")).toBeInTheDocument(); + // Add link act(() => { - mockClient.produce(draft => { + mockClient.produce((draft) => { draft.openGameLink = { - openGameUserId: 'og-user-123', - linkedAt: '2023-01-01T00:00:00Z' + openGameUserId: "og-user-123", + linkedAt: "2023-01-01T00:00:00Z", }; }); }); - + // Now should show linked - expect(screen.getByTestId('is-linked')).toBeInTheDocument(); - expect(screen.queryByTestId('not-linked')).toBeNull(); + expect(screen.getByTestId("is-linked")).toBeInTheDocument(); + expect(screen.queryByTestId("not-linked")).toBeNull(); }); }); - - describe('OpenGameProfile', () => { - it('should render OpenGame profile information', async () => { + + describe("OpenGameProfile", () => { + it("should render OpenGame profile information", async () => { const mockClient = createConsumerAuthMockClient({ initialState: { - userId: 'test-user', - sessionToken: 'test-token', - email: 'test@example.com', + userId: "test-user", + sessionToken: "test-token", + email: "test@example.com", isLoading: false, error: null, openGameLink: { - openGameUserId: 'og-user-123', - linkedAt: '2023-01-01T00:00:00Z', + openGameUserId: "og-user-123", + linkedAt: "2023-01-01T00:00:00Z", profile: { - displayName: 'OpenGame User', - avatarUrl: 'https://example.com/avatar.png' - } + displayName: "OpenGame User", + avatarUrl: "https://example.com/avatar.png", + }, }, - requests: {} - } + requests: {}, + }, }); - + const AuthContext = createConsumerAuthContext(); - + render( (
-
{isLoading ? 'Loading' : 'Not Loading'}
-
{error || 'No Error'}
-
{profile?.displayName || 'No Name'}
-
{profile?.avatarUrl || 'No Avatar'}
+
{isLoading ? "Loading" : "Not Loading"}
+
{error || "No Error"}
+
{profile?.displayName || "No Name"}
+
{profile?.avatarUrl || "No Avatar"}
)} />
); - - expect(screen.getByTestId('loading').textContent).toBe('Not Loading'); - expect(screen.getByTestId('error').textContent).toBe('No Error'); - expect(screen.getByTestId('display-name').textContent).toBe('OpenGame User'); - expect(screen.getByTestId('avatar-url').textContent).toBe('https://example.com/avatar.png'); + + expect(screen.getByTestId("loading").textContent).toBe("Not Loading"); + expect(screen.getByTestId("error").textContent).toBe("No Error"); + expect(screen.getByTestId("display-name").textContent).toBe("OpenGame User"); + expect(screen.getByTestId("avatar-url").textContent).toBe("https://example.com/avatar.png"); }); }); -}); \ No newline at end of file +}); diff --git a/src/consumer-react.tsx b/src/consumer-react.tsx index cade6f7..f9017a3 100644 --- a/src/consumer-react.tsx +++ b/src/consumer-react.tsx @@ -1,183 +1,189 @@ -import React from 'react'; -import { ConsumerAuthClient, ConsumerAuthState } from './types'; -import { useSyncExternalStoreWithSelector } from './react'; +import React from "react"; +import { ConsumerAuthClient, ConsumerAuthState } from "./types"; +import { useSyncExternalStoreWithSelector } from "./react"; export function createConsumerAuthContext() { const context = React.createContext(null); - - if (process.env.NODE_ENV !== 'production') { - context.displayName = 'ConsumerAuthContext'; + + if (process.env.NODE_ENV !== "production") { + context.displayName = "ConsumerAuthContext"; } - + function useClient(): ConsumerAuthClient { const value = React.useContext(context); if (value === null) { - throw new Error('useConsumerAuthClient must be used within a ConsumerAuthProvider'); + throw new Error("useConsumerAuthClient must be used within a ConsumerAuthProvider"); } return value; } - + function useSelector(selector: (state: ConsumerAuthState) => T) { const client = useClient(); - - return useSyncExternalStoreWithSelector( - client.subscribe, - client.getState, - null, - selector - ); + + return useSyncExternalStoreWithSelector(client.subscribe, client.getState, null, selector); } - + function LinkedWithOpenGame({ children }: { children: React.ReactNode }) { - const isLinked = useSelector(state => !!state.openGameLink); + const isLinked = useSelector((state) => !!state.openGameLink); return isLinked ? <>{children} : null; } - + function NotLinkedWithOpenGame({ children }: { children: React.ReactNode }) { - const isLinked = useSelector(state => !!state.openGameLink); + const isLinked = useSelector((state) => !!state.openGameLink); return !isLinked ? <>{children} : null; } - + function OpenGameProfile({ - render + render, }: { render: (props: { - profile: Record | undefined, - isLoading: boolean, - error: string | null - }) => React.ReactNode + profile: Record | undefined; + isLoading: boolean; + error: string | null; + }) => React.ReactNode; }) { - const openGameLink = useSelector(state => state.openGameLink); - const requestState = useSelector(state => state.requests['getOpenGameLinkStatus'] || { - isLoading: false, - error: null, - lastUpdated: null - }); - - return <>{render({ - profile: openGameLink?.profile, - isLoading: requestState.isLoading, - error: requestState.error - })}; + const openGameLink = useSelector((state) => state.openGameLink); + const requestState = useSelector( + (state) => + state.requests["getOpenGameLinkStatus"] || { + isLoading: false, + error: null, + lastUpdated: null, + } + ); + + return ( + <> + {render({ + profile: openGameLink?.profile, + isLoading: requestState.isLoading, + error: requestState.error, + })} + + ); } - + function VerifyLinkToken({ token, - render + render, }: { - token: string, + token: string; render: (props: { - isVerifying: boolean, - isValid: boolean, - openGameUserId?: string, - email?: string, - error: string | null - }) => React.ReactNode + isVerifying: boolean; + isValid: boolean; + openGameUserId?: string; + email?: string; + error: string | null; + }) => React.ReactNode; }) { const client = useClient(); const [state, setState] = React.useState<{ - isVerifying: boolean, - isValid: boolean, - openGameUserId?: string, - email?: string, - error: string | null + isVerifying: boolean; + isValid: boolean; + openGameUserId?: string; + email?: string; + error: string | null; }>({ isVerifying: true, isValid: false, - error: null + error: null, }); - + React.useEffect(() => { async function verifyToken() { try { - setState(prev => ({ ...prev, isVerifying: true, error: null })); + setState((prev) => ({ ...prev, isVerifying: true, error: null })); const result = await client.verifyLinkToken(token); - + if (result.valid) { setState({ isVerifying: false, isValid: true, openGameUserId: result.openGameUserId, email: result.email, - error: null + error: null, }); } else { setState({ isVerifying: false, isValid: false, - error: null + error: null, }); } } catch (error) { setState({ isVerifying: false, isValid: false, - error: error instanceof Error ? error.message : 'Failed to verify token' + error: error instanceof Error ? error.message : "Failed to verify token", }); } } - + verifyToken(); }, [client, token]); - + return <>{render(state)}; } - + function ConfirmLink({ token, gameUserId, - render + render, }: { - token: string, - gameUserId: string, + token: string; + gameUserId: string; render: (props: { - onConfirm: () => Promise, - isConfirming: boolean, - isConfirmed: boolean, - error: string | null - }) => React.ReactNode + onConfirm: () => Promise; + isConfirming: boolean; + isConfirmed: boolean; + error: string | null; + }) => React.ReactNode; }) { const client = useClient(); const [state, setState] = React.useState<{ - isConfirming: boolean, - isConfirmed: boolean, - error: string | null + isConfirming: boolean; + isConfirmed: boolean; + error: string | null; }>({ isConfirming: false, isConfirmed: false, - error: null + error: null, }); - + const onConfirm = React.useCallback(async () => { try { - setState(prev => ({ ...prev, isConfirming: true, error: null })); + setState((prev) => ({ ...prev, isConfirming: true, error: null })); const success = await client.confirmLink(token, gameUserId); - + setState({ isConfirming: false, isConfirmed: success, - error: success ? null : 'Failed to confirm link' + error: success ? null : "Failed to confirm link", }); - + return success; } catch (error) { setState({ isConfirming: false, isConfirmed: false, - error: error instanceof Error ? error.message : 'Failed to confirm link' + error: error instanceof Error ? error.message : "Failed to confirm link", }); return false; } }, [client, token, gameUserId]); - - return <>{render({ - onConfirm, - isConfirming: state.isConfirming, - isConfirmed: state.isConfirmed, - error: state.error - })}; + + return ( + <> + {render({ + onConfirm, + isConfirming: state.isConfirming, + isConfirmed: state.isConfirmed, + error: state.error, + })} + + ); } - + return { Provider: context.Provider, useClient, @@ -186,10 +192,10 @@ export function createConsumerAuthContext() { NotLinkedWithOpenGame, OpenGameProfile, VerifyLinkToken, - ConfirmLink + ConfirmLink, }; } function defaultCompare(a: T, b: T) { return Object.is(a, b); -} \ No newline at end of file +} diff --git a/src/consumer-server.test.ts b/src/consumer-server.test.ts index 148ab21..3773b22 100644 --- a/src/consumer-server.test.ts +++ b/src/consumer-server.test.ts @@ -1,14 +1,6 @@ -import { - afterAll, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createConsumerAuthRouter, ConsumerAuthHooks } from "./server"; -import * as jose from 'jose'; +import * as jose from "jose"; // Reset UUID counter before each test beforeEach(() => { @@ -63,7 +55,7 @@ vi.mock("jose", () => { if (token === "invalid-token") { throw new Error("Invalid token"); } - + // Different payloads based on token type if (token === "mock-session-token") { return { @@ -77,13 +69,13 @@ vi.mock("jose", () => { } if (token === "valid-token") { return { - payload: { + payload: { openGameUserId: "og-user-123", - type: "link" + type: "link", }, }; } - + return { payload: { userId: "test-user" }, }; @@ -96,328 +88,332 @@ function createMockConsumerHooks(): ConsumerAuthHooks { return { // Base auth hooks getUserIdByEmail: vi.fn(async (email) => { - if (email === 'user@example.com') { - return 'test-user-id'; + if (email === "user@example.com") { + return "test-user-id"; } return null; }), storeVerificationCode: vi.fn(), verifyVerificationCode: vi.fn(), sendVerificationCode: vi.fn(), - + // Consumer-specific hooks storeOpenGameLink: vi.fn().mockResolvedValue(true), getOpenGameUserId: vi.fn(async (gameUserId) => { - if (gameUserId === 'game-user-123') { - return 'og-user-123'; + if (gameUserId === "game-user-123") { + return "og-user-123"; } return null; }), getOpenGameProfile: vi.fn(async (openGameUserId) => { - if (openGameUserId === 'og-user-123') { + if (openGameUserId === "og-user-123") { return { - displayName: 'OpenGame User', - avatarUrl: 'https://example.com/avatar.png' + displayName: "OpenGame User", + avatarUrl: "https://example.com/avatar.png", }; } return null; - }) + }), }; } describe("Consumer Auth Router", () => { let mockHooks: ConsumerAuthHooks; - + beforeEach(() => { mockHooks = createMockConsumerHooks(); }); - + describe("getOpenGameLinkStatus", () => { it("should return link status when user is linked", async () => { const router = createConsumerAuthRouter(mockHooks); - + // Ensure getOpenGameUserId returns a value for test-user-id mockHooks.getOpenGameUserId = vi.fn().mockImplementation(async (userId) => { - if (userId === 'test-user-id') { - return 'og-user-123'; + if (userId === "test-user-id") { + return "og-user-123"; } return null; }); - + const request = new Request("https://example.com/auth/opengame-link", { method: "GET", headers: { - "Authorization": "Bearer mock-session-token" - } + Authorization: "Bearer mock-session-token", + }, }); - + const response = await router.getOpenGameLinkStatus(request); const data = await response.json(); - + expect(response.status).toBe(200); expect(data.isLinked).toBe(true); - expect(data.openGameUserId).toBe('og-user-123'); - + expect(data.openGameUserId).toBe("og-user-123"); + vi.restoreAllMocks(); }); - + it("should return not linked status when user is not linked", async () => { const router = createConsumerAuthRouter(mockHooks); - + // Mock getOpenGameUserId to return null mockHooks.getOpenGameUserId = vi.fn(async () => null); - + const request = new Request("https://example.com/auth/opengame-link", { method: "GET", headers: { - "Authorization": "Bearer mock-session-token" - } + Authorization: "Bearer mock-session-token", + }, }); - + const response = await router.getOpenGameLinkStatus(request); const data = await response.json(); - + expect(response.status).toBe(200); expect(data).toHaveProperty("isLinked", false); expect(data).not.toHaveProperty("openGameUserId"); }); - + it("should return 401 for unauthenticated requests", async () => { const router = createConsumerAuthRouter(mockHooks); - + const request = new Request("https://example.com/auth/opengame-link", { - method: "GET" + method: "GET", }); - + const response = await router.getOpenGameLinkStatus(request); - + expect(response.status).toBe(401); }); }); - + describe("POST /verify-link-token", () => { it("should verify a link token", async () => { const router = createConsumerAuthRouter(mockHooks); - + // Mock the JWT verification - vi.spyOn(jose, 'jwtVerify').mockResolvedValue({ + vi.spyOn(jose, "jwtVerify").mockResolvedValue({ payload: { - openGameUserId: 'og-user-123', - email: 'user@example.com', - type: 'link' + openGameUserId: "og-user-123", + email: "user@example.com", + type: "link", }, - protectedHeader: { alg: 'HS256' } + protectedHeader: { alg: "HS256" }, } as any); - + const request = new Request("https://example.com/auth/verify-link", { method: "POST", headers: { "Content-Type": "application/json", - "Authorization": "Bearer mock-session-token" + Authorization: "Bearer mock-session-token", }, body: JSON.stringify({ - token: "valid-token" - }) + token: "valid-token", + }), }); - + const response = await router.verifyLinkToken(request); const data = await response.json(); - + expect(response.status).toBe(200); expect(data).toHaveProperty("valid", true); expect(data).toHaveProperty("openGameUserId", "og-user-123"); expect(data).toHaveProperty("email", "user@example.com"); }); - + it("should return invalid for invalid token", async () => { const router = createConsumerAuthRouter(mockHooks); - + // Mock the JWT verification to throw an error - vi.spyOn(jose, 'jwtVerify').mockRejectedValue(new Error("Invalid token")); - + vi.spyOn(jose, "jwtVerify").mockRejectedValue(new Error("Invalid token")); + const request = new Request("https://example.com/auth/verify-link", { method: "POST", headers: { "Content-Type": "application/json", - "Authorization": "Bearer mock-session-token" + Authorization: "Bearer mock-session-token", }, body: JSON.stringify({ - token: "invalid-token" - }) + token: "invalid-token", + }), }); - + const response = await router.verifyLinkToken(request); const data = await response.json(); - + expect(response.status).toBe(200); expect(data).toHaveProperty("valid", false); }); - + it("should return 400 for missing token", async () => { const router = createConsumerAuthRouter(mockHooks); - + const request = new Request("https://example.com/auth/verify-link", { method: "POST", headers: { "Content-Type": "application/json", - "Authorization": "Bearer mock-session-token" + Authorization: "Bearer mock-session-token", }, - body: JSON.stringify({}) + body: JSON.stringify({}), }); - + const response = await router.verifyLinkToken(request); - + expect(response.status).toBe(400); }); - + it("should handle API errors", async () => { const router = createConsumerAuthRouter(mockHooks); - + // Mock the request.json() to throw an error const request = new Request("https://example.com/auth/verify-link", { method: "POST", headers: { "Content-Type": "application/json", - "Authorization": "Bearer mock-session-token" - } + Authorization: "Bearer mock-session-token", + }, }); - + // Override the json method to throw an error - Object.defineProperty(request, 'json', { - value: () => { throw new Error('API error'); } + Object.defineProperty(request, "json", { + value: () => { + throw new Error("API error"); + }, }); - + const response = await router.verifyLinkToken(request); - + expect(response.status).toBe(500); }); }); - + describe("POST /confirm-link", () => { it("should confirm a link between accounts", async () => { const router = createConsumerAuthRouter(mockHooks); - + // Mock the JWT verification - vi.spyOn(jose, 'jwtVerify').mockResolvedValue({ + vi.spyOn(jose, "jwtVerify").mockResolvedValue({ payload: { - openGameUserId: 'og-user-123', - email: 'user@example.com', - type: 'link' + openGameUserId: "og-user-123", + email: "user@example.com", + type: "link", }, - protectedHeader: { alg: 'HS256' } + protectedHeader: { alg: "HS256" }, } as any); - + const request = new Request("https://example.com/auth/confirm-link", { method: "POST", headers: { - "Authorization": "Bearer mock-session-token", - "Content-Type": "application/json" + Authorization: "Bearer mock-session-token", + "Content-Type": "application/json", }, body: JSON.stringify({ token: "valid-token", - gameUserId: "game-user-123" - }) + gameUserId: "game-user-123", + }), }); - + const response = await router.confirmLink(request); const data = await response.json(); - + expect(response.status).toBe(200); expect(data).toHaveProperty("success", true); - expect(mockHooks.storeOpenGameLink).toHaveBeenCalledWith('test-user-id', 'og-user-123'); + expect(mockHooks.storeOpenGameLink).toHaveBeenCalledWith("test-user-id", "og-user-123"); }); - + it("should return 401 for unauthenticated requests", async () => { const router = createConsumerAuthRouter(mockHooks); - + const request = new Request("https://example.com/auth/confirm-link", { method: "POST", headers: { - "Content-Type": "application/json" + "Content-Type": "application/json", }, body: JSON.stringify({ - token: "valid-token" - }) + token: "valid-token", + }), }); - + const response = await router.confirmLink(request); - + expect(response.status).toBe(401); }); - + it("should return 400 for missing token", async () => { const router = createConsumerAuthRouter(mockHooks); - + const request = new Request("https://example.com/auth/confirm-link", { method: "POST", headers: { - "Authorization": "Bearer mock-session-token", - "Content-Type": "application/json" + Authorization: "Bearer mock-session-token", + "Content-Type": "application/json", }, - body: JSON.stringify({}) + body: JSON.stringify({}), }); - + const response = await router.confirmLink(request); - + expect(response.status).toBe(400); }); - + it("should handle API errors", async () => { const router = createConsumerAuthRouter(mockHooks); - + // Mock the request.json() to throw an error const request = new Request("https://example.com/auth/confirm-link", { method: "POST", headers: { - "Authorization": "Bearer mock-session-token", - "Content-Type": "application/json" - } + Authorization: "Bearer mock-session-token", + "Content-Type": "application/json", + }, }); - + // Override the json method to throw an error - Object.defineProperty(request, 'json', { - value: () => { throw new Error('API error'); } + Object.defineProperty(request, "json", { + value: () => { + throw new Error("API error"); + }, }); - + const response = await router.confirmLink(request); - + expect(response.status).toBe(500); }); - + it("should handle OpenGame API rejecting the link", async () => { const router = createConsumerAuthRouter(mockHooks); - + // Mock the JWT verification - vi.spyOn(jose, 'jwtVerify').mockResolvedValue({ + vi.spyOn(jose, "jwtVerify").mockResolvedValue({ payload: { - openGameUserId: 'og-user-123', - email: 'user@example.com', - type: 'link' + openGameUserId: "og-user-123", + email: "user@example.com", + type: "link", }, - protectedHeader: { alg: 'HS256' } + protectedHeader: { alg: "HS256" }, } as any); - + // Mock storeOpenGameLink to return false mockHooks.storeOpenGameLink = vi.fn().mockImplementation(async () => { return false; }); - + const request = new Request("https://example.com/auth/confirm-link", { method: "POST", headers: { - "Authorization": "Bearer mock-session-token", - "Content-Type": "application/json" + Authorization: "Bearer mock-session-token", + "Content-Type": "application/json", }, body: JSON.stringify({ - token: "valid-token" - }) + token: "valid-token", + }), }); - + const response = await router.confirmLink(request); const data = await response.json(); - + expect(response.status).toBe(200); expect(data).toHaveProperty("success", false); }); }); -}); \ No newline at end of file +}); diff --git a/src/provider-client.test.ts b/src/provider-client.test.ts index 734c48b..e5a68ca 100644 --- a/src/provider-client.test.ts +++ b/src/provider-client.test.ts @@ -1,141 +1,141 @@ -import { describe, it, expect, beforeEach, vi, afterEach, afterAll } from 'vitest'; -import { createProviderAuthClient } from './provider-client'; -import { http, HttpResponse } from 'msw'; -import { setupServer } from 'msw/node'; -import type { LinkedAccount, ProviderAuthState } from './types'; +import { describe, it, expect, beforeEach, vi, afterEach, afterAll } from "vitest"; +import { createProviderAuthClient } from "./provider-client"; +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; +import type { LinkedAccount, ProviderAuthState } from "./types"; -describe('Provider Auth Client', () => { +describe("Provider Auth Client", () => { // Mock server setup const server = setupServer( // GET /linked-accounts - http.get('http://localhost/linked-accounts', () => { + http.get("http://localhost/linked-accounts", () => { return HttpResponse.json([ { - gameId: 'game1', - gameUserId: 'game-user-123', - linkedAt: '2023-01-01T00:00:00Z', - gameName: 'Game 1' - } + gameId: "game1", + gameUserId: "game-user-123", + linkedAt: "2023-01-01T00:00:00Z", + gameName: "Game 1", + }, ]); }), - + // POST /account-link-token - http.post('http://localhost/account-link-token', () => { + http.post("http://localhost/account-link-token", () => { return HttpResponse.json({ - linkToken: 'test-link-token', - expiresAt: '2023-01-01T01:00:00Z' + linkToken: "test-link-token", + expiresAt: "2023-01-01T01:00:00Z", }); }), - + // DELETE /linked-accounts/:gameId - http.delete('http://localhost/linked-accounts/game1', () => { + http.delete("http://localhost/linked-accounts/game1", () => { return HttpResponse.json({ success: true }); }), - + // DELETE /linked-accounts/:gameId (non-existent) - http.delete('http://localhost/linked-accounts/non-existent-game', () => { + http.delete("http://localhost/linked-accounts/non-existent-game", () => { return HttpResponse.json({ success: false }, { status: 404 }); }) ); - + // Start server before tests beforeEach(() => server.listen()); - + // Reset handlers after each test afterEach(() => server.resetHandlers()); - + // Close server after all tests afterAll(() => server.close()); - - it('should initialize with the provided state', () => { + + it("should initialize with the provided state", () => { const initialState: Partial = { - userId: 'user123', - sessionToken: 'session-token', - email: 'user@example.com', + userId: "user123", + sessionToken: "session-token", + email: "user@example.com", linkedAccounts: [ { - gameId: 'game1', - gameUserId: 'game-user-123', - linkedAt: '2023-01-01T00:00:00Z', - gameName: 'Game 1' - } - ] + gameId: "game1", + gameUserId: "game-user-123", + linkedAt: "2023-01-01T00:00:00Z", + gameName: "Game 1", + }, + ], }; - + const client = createProviderAuthClient({ - host: 'localhost', - userId: 'user123', - sessionToken: 'session-token', - initialState + host: "localhost", + userId: "user123", + sessionToken: "session-token", + initialState, }); - - expect(client.getState().userId).toBe('user123'); - expect(client.getState().sessionToken).toBe('session-token'); - expect(client.getState().email).toBe('user@example.com'); + + expect(client.getState().userId).toBe("user123"); + expect(client.getState().sessionToken).toBe("session-token"); + expect(client.getState().email).toBe("user@example.com"); expect(client.getState().linkedAccounts).toHaveLength(1); - expect(client.getState().linkedAccounts[0].gameId).toBe('game1'); + expect(client.getState().linkedAccounts[0].gameId).toBe("game1"); }); - - it('should fetch linked accounts', async () => { + + it("should fetch linked accounts", async () => { const client = createProviderAuthClient({ - host: 'localhost', - userId: 'user123', - sessionToken: 'session-token' + host: "localhost", + userId: "user123", + sessionToken: "session-token", }); - + const accounts = await client.getLinkedAccounts(); - + expect(accounts).toHaveLength(1); - expect(accounts[0].gameId).toBe('game1'); - expect(accounts[0].gameUserId).toBe('game-user-123'); + expect(accounts[0].gameId).toBe("game1"); + expect(accounts[0].gameUserId).toBe("game-user-123"); expect(client.getState().linkedAccounts).toEqual(accounts); }); - - it('should initiate account linking', async () => { + + it("should initiate account linking", async () => { const client = createProviderAuthClient({ - host: 'localhost', - userId: 'user123', - sessionToken: 'session-token' + host: "localhost", + userId: "user123", + sessionToken: "session-token", }); - - const result = await client.initiateAccountLinking('game1'); - - expect(result.linkToken).toBe('test-link-token'); - expect(result.expiresAt).toBe('2023-01-01T01:00:00Z'); + + const result = await client.initiateAccountLinking("game1"); + + expect(result.linkToken).toBe("test-link-token"); + expect(result.expiresAt).toBe("2023-01-01T01:00:00Z"); }); - - it('should unlink an account', async () => { + + it("should unlink an account", async () => { const client = createProviderAuthClient({ - host: 'localhost', - userId: 'user123', - sessionToken: 'session-token', + host: "localhost", + userId: "user123", + sessionToken: "session-token", initialState: { linkedAccounts: [ { - gameId: 'game1', - gameUserId: 'game-user-123', - linkedAt: '2023-01-01T00:00:00Z', - gameName: 'Game 1' - } - ] - } + gameId: "game1", + gameUserId: "game-user-123", + linkedAt: "2023-01-01T00:00:00Z", + gameName: "Game 1", + }, + ], + }, }); - - const result = await client.unlinkAccount('game1'); - + + const result = await client.unlinkAccount("game1"); + expect(result).toBe(true); expect(client.getState().linkedAccounts).toHaveLength(0); }); - - it('should handle errors when unlinking non-existent account', async () => { + + it("should handle errors when unlinking non-existent account", async () => { const client = createProviderAuthClient({ - host: 'localhost', - userId: 'user123', - sessionToken: 'session-token' + host: "localhost", + userId: "user123", + sessionToken: "session-token", }); - - const result = await client.unlinkAccount('non-existent-game'); - + + const result = await client.unlinkAccount("non-existent-game"); + expect(result).toBe(false); }); -}); \ No newline at end of file +}); diff --git a/src/provider-client.ts b/src/provider-client.ts index c3a60ad..1a2f6fb 100644 --- a/src/provider-client.ts +++ b/src/provider-client.ts @@ -1,4 +1,4 @@ -import { ProviderAuthClient, ProviderAuthState, LinkedAccount, RequestsState } from './types'; +import { ProviderAuthClient, ProviderAuthState, LinkedAccount, RequestsState } from "./types"; interface ProviderAuthClientConfig { host: string; @@ -19,13 +19,13 @@ export function createProviderAuthClient(config: ProviderAuthClientConfig): Prov isLoading: false, error: null, linkedAccounts: [], - requests: {} + requests: {}, }; // Merge with provided initial state let state: ProviderAuthState = { ...defaultState, - ...config.initialState + ...config.initialState, }; // Subscribers @@ -47,24 +47,25 @@ export function createProviderAuthClient(config: ProviderAuthClientConfig): Prov requestId?: string ): Promise => { // Ensure the host has a protocol - const host = config.host.startsWith('http://') || config.host.startsWith('https://') - ? config.host - : `http://${config.host}`; + const host = + config.host.startsWith("http://") || config.host.startsWith("https://") + ? config.host + : `http://${config.host}`; // Set loading state if (requestId) { - setState(draft => { + setState((draft) => { draft.requests = { ...draft.requests, [requestId]: { isLoading: true, error: null, - lastUpdated: new Date().toISOString() - } + lastUpdated: new Date().toISOString(), + }, }; }); } else { - setState(draft => { + setState((draft) => { draft.isLoading = true; draft.error = null; }); @@ -74,14 +75,14 @@ export function createProviderAuthClient(config: ProviderAuthClientConfig): Prov const response = await fetch(`${host}/${path}`, { method, headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${state.sessionToken}` + "Content-Type": "application/json", + Authorization: `Bearer ${state.sessionToken}`, }, - body: body ? JSON.stringify(body) : undefined + body: body ? JSON.stringify(body) : undefined, }); if (!response.ok) { - const errorData = await response.json().catch(() => ({ message: 'Unknown error' })); + const errorData = await response.json().catch(() => ({ message: "Unknown error" })); throw new Error(errorData.message || `API error: ${response.status}`); } @@ -89,45 +90,45 @@ export function createProviderAuthClient(config: ProviderAuthClientConfig): Prov // Clear loading state if (requestId) { - setState(draft => { + setState((draft) => { draft.requests = { ...draft.requests, [requestId]: { isLoading: false, error: null, - lastUpdated: new Date().toISOString() - } + lastUpdated: new Date().toISOString(), + }, }; }); } else { - setState(draft => { + setState((draft) => { draft.isLoading = false; }); } return data; } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + // Set error state if (requestId) { - setState(draft => { + setState((draft) => { draft.requests = { ...draft.requests, [requestId]: { isLoading: false, error: errorMessage, - lastUpdated: new Date().toISOString() - } + lastUpdated: new Date().toISOString(), + }, }; }); } else { - setState(draft => { + setState((draft) => { draft.isLoading = false; draft.error = errorMessage; }); } - + throw error; } }; @@ -137,7 +138,7 @@ export function createProviderAuthClient(config: ProviderAuthClientConfig): Prov getState() { return state; }, - + subscribe(callback) { subscribers.push(callback); callback(state); @@ -148,130 +149,108 @@ export function createProviderAuthClient(config: ProviderAuthClientConfig): Prov } }; }, - + async getLinkedAccounts() { - const requestId = 'getLinkedAccounts'; - + const requestId = "getLinkedAccounts"; + const accounts = await apiRequest( - 'GET', - 'linked-accounts', + "GET", + "linked-accounts", undefined, requestId ); - - setState(draft => { + + setState((draft) => { draft.linkedAccounts = accounts; }); - + return accounts; }, - + async initiateAccountLinking(gameId: string) { const requestId = `initiateAccountLinking:${gameId}`; - + const result = await apiRequest<{ linkToken: string; expiresAt: string; - }>( - 'POST', - 'account-link-token', - { gameId }, - requestId - ); - + }>("POST", "account-link-token", { gameId }, requestId); + return result; }, - + async unlinkAccount(gameId: string) { const requestId = `unlinkAccount:${gameId}`; - + try { await apiRequest<{ success: boolean }>( - 'DELETE', + "DELETE", `linked-accounts/${gameId}`, undefined, requestId ); - - setState(draft => { + + setState((draft) => { draft.linkedAccounts = draft.linkedAccounts.filter( - account => account.gameId !== gameId + (account) => account.gameId !== gameId ); }); - + return true; } catch (error) { return false; } }, - + // Inherit base auth methods async requestCode(email: string) { - await apiRequest( - 'POST', - 'request-code', - { email } - ); + await apiRequest("POST", "request-code", { email }); }, - + async verifyEmail(email: string, code: string) { const result = await apiRequest<{ success: boolean; userId?: string; sessionToken?: string; - }>( - 'POST', - 'verify-email', - { email, code } - ); - + }>("POST", "verify-email", { email, code }); + if (result.success && result.sessionToken) { - setState(draft => { + setState((draft) => { draft.email = email; draft.userId = result.userId || draft.userId; draft.sessionToken = result.sessionToken || null; }); } - + return { success: result.success }; }, - + async logout() { - await apiRequest( - 'POST', - 'logout' - ); - - setState(draft => { + await apiRequest("POST", "logout"); + + setState((draft) => { draft.sessionToken = null; draft.email = null; draft.linkedAccounts = []; }); }, - + async refresh() { const result = await apiRequest<{ sessionToken: string; - }>( - 'POST', - 'refresh' - ); - - setState(draft => { + }>("POST", "refresh"); + + setState((draft) => { draft.sessionToken = result.sessionToken; }); }, - + async getWebAuthCode() { const result = await apiRequest<{ code: string; expiresIn: number; - }>( - 'GET', - 'web-auth-code' - ); - + }>("GET", "web-auth-code"); + return result; - } + }, }; -} \ No newline at end of file +} diff --git a/src/provider-react.test.tsx b/src/provider-react.test.tsx index 1c2f629..14ef9c5 100644 --- a/src/provider-react.test.tsx +++ b/src/provider-react.test.tsx @@ -1,62 +1,62 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, act, fireEvent } from '@testing-library/react'; -import { createProviderAuthContext } from './provider-react'; -import { createProviderAuthMockClient } from './test'; -import React from 'react'; -import type { ProviderAuthState, LinkedAccount } from './types'; - -describe('Provider Auth Context', () => { - describe('useClient', () => { - it('should provide access to the auth client', () => { +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, act, fireEvent } from "@testing-library/react"; +import { createProviderAuthContext } from "./provider-react"; +import { createProviderAuthMockClient } from "./test"; +import React from "react"; +import type { ProviderAuthState, LinkedAccount } from "./types"; + +describe("Provider Auth Context", () => { + describe("useClient", () => { + it("should provide access to the auth client", () => { const mockClient = createProviderAuthMockClient({ initialState: { - userId: 'test-user', - sessionToken: 'test-token', - email: 'test@example.com', + userId: "test-user", + sessionToken: "test-token", + email: "test@example.com", isLoading: false, error: null, linkedAccounts: [], - requests: {} - } + requests: {}, + }, }); - + const AuthContext = createProviderAuthContext(); - + const TestComponent = () => { const client = AuthContext.useClient(); return
{client.getState().userId}
; }; - + render( ); - - expect(screen.getByTestId('user-id').textContent).toBe('test-user'); + + expect(screen.getByTestId("user-id").textContent).toBe("test-user"); }); }); - - describe('useSelector', () => { - it('should select and subscribe to state changes', async () => { + + describe("useSelector", () => { + it("should select and subscribe to state changes", async () => { const mockClient = createProviderAuthMockClient({ initialState: { - userId: 'test-user', - sessionToken: 'test-token', - email: 'test@example.com', + userId: "test-user", + sessionToken: "test-token", + email: "test@example.com", isLoading: false, error: null, linkedAccounts: [], - requests: {} - } + requests: {}, + }, }); - + const AuthContext = createProviderAuthContext(); - + const TestComponent = () => { - const userId = AuthContext.useSelector(state => state.userId); - const linkedAccounts = AuthContext.useSelector(state => state.linkedAccounts); - + const userId = AuthContext.useSelector((state) => state.userId); + const linkedAccounts = AuthContext.useSelector((state) => state.linkedAccounts); + return (
{userId}
@@ -64,50 +64,50 @@ describe('Provider Auth Context', () => {
); }; - + render( ); - - expect(screen.getByTestId('user-id').textContent).toBe('test-user'); - expect(screen.getByTestId('linked-count').textContent).toBe('0'); - + + expect(screen.getByTestId("user-id").textContent).toBe("test-user"); + expect(screen.getByTestId("linked-count").textContent).toBe("0"); + // Update state act(() => { - mockClient.produce(draft => { + mockClient.produce((draft) => { draft.linkedAccounts = [ { - gameId: 'game1', - gameUserId: 'game-user-123', - linkedAt: '2023-01-01T00:00:00Z', - gameName: 'Game 1' - } + gameId: "game1", + gameUserId: "game-user-123", + linkedAt: "2023-01-01T00:00:00Z", + gameName: "Game 1", + }, ]; }); }); - - expect(screen.getByTestId('linked-count').textContent).toBe('1'); + + expect(screen.getByTestId("linked-count").textContent).toBe("1"); }); }); - - describe('LinkedAccounts and NoLinkedAccounts', () => { - it('should conditionally render based on linked accounts', async () => { + + describe("LinkedAccounts and NoLinkedAccounts", () => { + it("should conditionally render based on linked accounts", async () => { const mockClient = createProviderAuthMockClient({ initialState: { - userId: 'test-user', - sessionToken: 'test-token', - email: 'test@example.com', + userId: "test-user", + sessionToken: "test-token", + email: "test@example.com", isLoading: false, error: null, linkedAccounts: [], - requests: {} - } + requests: {}, + }, }); - + const AuthContext = createProviderAuthContext(); - + const TestComponent = () => { return (
@@ -120,75 +120,75 @@ describe('Provider Auth Context', () => {
); }; - + render( ); - + // Initially no linked accounts - expect(screen.queryByTestId('has-accounts')).toBeNull(); - expect(screen.getByTestId('no-accounts')).toBeInTheDocument(); - + expect(screen.queryByTestId("has-accounts")).toBeNull(); + expect(screen.getByTestId("no-accounts")).toBeInTheDocument(); + // Add linked accounts act(() => { - mockClient.produce(draft => { + mockClient.produce((draft) => { draft.linkedAccounts = [ { - gameId: 'game1', - gameUserId: 'game-user-123', - linkedAt: '2023-01-01T00:00:00Z', - gameName: 'Game 1' - } + gameId: "game1", + gameUserId: "game-user-123", + linkedAt: "2023-01-01T00:00:00Z", + gameName: "Game 1", + }, ]; }); }); - + // Now should show linked accounts - expect(screen.getByTestId('has-accounts')).toBeInTheDocument(); - expect(screen.queryByTestId('no-accounts')).toBeNull(); + expect(screen.getByTestId("has-accounts")).toBeInTheDocument(); + expect(screen.queryByTestId("no-accounts")).toBeNull(); }); }); - - describe('LinkedAccountsList', () => { - it('should render linked accounts list', async () => { + + describe("LinkedAccountsList", () => { + it("should render linked accounts list", async () => { const mockClient = createProviderAuthMockClient({ initialState: { - userId: 'test-user', - sessionToken: 'test-token', - email: 'test@example.com', + userId: "test-user", + sessionToken: "test-token", + email: "test@example.com", isLoading: false, error: null, linkedAccounts: [ { - gameId: 'game1', - gameUserId: 'game-user-123', - linkedAt: '2023-01-01T00:00:00Z', - gameName: 'Game 1' + gameId: "game1", + gameUserId: "game-user-123", + linkedAt: "2023-01-01T00:00:00Z", + gameName: "Game 1", }, { - gameId: 'game2', - gameUserId: 'game-user-456', - linkedAt: '2023-01-02T00:00:00Z', - gameName: 'Game 2' - } + gameId: "game2", + gameUserId: "game-user-456", + linkedAt: "2023-01-02T00:00:00Z", + gameName: "Game 2", + }, ], - requests: {} - } + requests: {}, + }, }); - + const AuthContext = createProviderAuthContext(); - + render( (
-
{isLoading ? 'Loading' : 'Not Loading'}
-
{error || 'No Error'}
+
{isLoading ? "Loading" : "Not Loading"}
+
{error || "No Error"}
{accounts.length}
- {accounts.map(account => ( + {accounts.map((account) => (
{account.gameName}
@@ -198,12 +198,12 @@ describe('Provider Auth Context', () => { /> ); - - expect(screen.getByTestId('loading').textContent).toBe('Not Loading'); - expect(screen.getByTestId('error').textContent).toBe('No Error'); - expect(screen.getByTestId('count').textContent).toBe('2'); - expect(screen.getByTestId('game-game1').textContent).toBe('Game 1'); - expect(screen.getByTestId('game-game2').textContent).toBe('Game 2'); + + expect(screen.getByTestId("loading").textContent).toBe("Not Loading"); + expect(screen.getByTestId("error").textContent).toBe("No Error"); + expect(screen.getByTestId("count").textContent).toBe("2"); + expect(screen.getByTestId("game-game1").textContent).toBe("Game 1"); + expect(screen.getByTestId("game-game2").textContent).toBe("Game 2"); }); }); -}); \ No newline at end of file +}); diff --git a/src/provider-react.tsx b/src/provider-react.tsx index f1c62b0..02be6da 100644 --- a/src/provider-react.tsx +++ b/src/provider-react.tsx @@ -1,124 +1,140 @@ -import React from 'react'; -import { ProviderAuthClient, ProviderAuthState, LinkedAccount } from './types'; -import { useSyncExternalStoreWithSelector } from './react'; +import React from "react"; +import { ProviderAuthClient, ProviderAuthState, LinkedAccount } from "./types"; +import { useSyncExternalStoreWithSelector } from "./react"; export function createProviderAuthContext() { const context = React.createContext(null); - - if (process.env.NODE_ENV !== 'production') { - context.displayName = 'ProviderAuthContext'; + + if (process.env.NODE_ENV !== "production") { + context.displayName = "ProviderAuthContext"; } - + function useClient(): ProviderAuthClient { const value = React.useContext(context); if (value === null) { - throw new Error('useProviderAuthClient must be used within a ProviderAuthProvider'); + throw new Error("useProviderAuthClient must be used within a ProviderAuthProvider"); } return value; } - + function useSelector(selector: (state: ProviderAuthState) => T) { const client = useClient(); - - return useSyncExternalStoreWithSelector( - client.subscribe, - client.getState, - null, - selector - ); + + return useSyncExternalStoreWithSelector(client.subscribe, client.getState, null, selector); } - + function LinkedAccounts({ children }: { children: React.ReactNode }) { - const hasLinkedAccounts = useSelector(state => state.linkedAccounts.length > 0); + const hasLinkedAccounts = useSelector((state) => state.linkedAccounts.length > 0); return hasLinkedAccounts ? <>{children} : null; } - + function NoLinkedAccounts({ children }: { children: React.ReactNode }) { - const hasLinkedAccounts = useSelector(state => state.linkedAccounts.length > 0); + const hasLinkedAccounts = useSelector((state) => state.linkedAccounts.length > 0); return !hasLinkedAccounts ? <>{children} : null; } - - function LinkedAccountsList({ - render - }: { - render: (props: { - accounts: LinkedAccount[], - isLoading: boolean, - error: string | null - }) => React.ReactNode + + function LinkedAccountsList({ + render, + }: { + render: (props: { + accounts: LinkedAccount[]; + isLoading: boolean; + error: string | null; + }) => React.ReactNode; }) { - const accounts = useSelector(state => state.linkedAccounts); - const requestState = useSelector(state => state.requests['getLinkedAccounts'] || { - isLoading: false, - error: null, - lastUpdated: null - }); - - return <>{render({ - accounts, - isLoading: requestState.isLoading, - error: requestState.error - })}; + const accounts = useSelector((state) => state.linkedAccounts); + const requestState = useSelector( + (state) => + state.requests["getLinkedAccounts"] || { + isLoading: false, + error: null, + lastUpdated: null, + } + ); + + return ( + <> + {render({ + accounts, + isLoading: requestState.isLoading, + error: requestState.error, + })} + + ); } - + function InitiateLinking({ gameId, - render + render, }: { - gameId: string, + gameId: string; render: (props: { - onInitiate: () => Promise<{ linkToken: string; expiresAt: string }>, - isInitiating: boolean, - error: string | null - }) => React.ReactNode + onInitiate: () => Promise<{ linkToken: string; expiresAt: string }>; + isInitiating: boolean; + error: string | null; + }) => React.ReactNode; }) { const client = useClient(); - const requestState = useSelector(state => state.requests['initiateAccountLinking'] || { - isLoading: false, - error: null, - lastUpdated: null - }); - + const requestState = useSelector( + (state) => + state.requests["initiateAccountLinking"] || { + isLoading: false, + error: null, + lastUpdated: null, + } + ); + const onInitiate = React.useCallback(async () => { return await client.initiateAccountLinking(gameId); }, [client, gameId]); - - return <>{render({ - onInitiate, - isInitiating: requestState.isLoading, - error: requestState.error - })}; + + return ( + <> + {render({ + onInitiate, + isInitiating: requestState.isLoading, + error: requestState.error, + })} + + ); } - + function UnlinkAccount({ gameId, - render + render, }: { - gameId: string, + gameId: string; render: (props: { - onUnlink: () => Promise, - isUnlinking: boolean, - error: string | null - }) => React.ReactNode + onUnlink: () => Promise; + isUnlinking: boolean; + error: string | null; + }) => React.ReactNode; }) { const client = useClient(); - const requestState = useSelector(state => state.requests['unlinkAccount'] || { - isLoading: false, - error: null, - lastUpdated: null - }); - + const requestState = useSelector( + (state) => + state.requests["unlinkAccount"] || { + isLoading: false, + error: null, + lastUpdated: null, + } + ); + const onUnlink = React.useCallback(async () => { return await client.unlinkAccount(gameId); }, [client, gameId]); - - return <>{render({ - onUnlink, - isUnlinking: requestState.isLoading, - error: requestState.error - })}; + + return ( + <> + {render({ + onUnlink, + isUnlinking: requestState.isLoading, + error: requestState.error, + })} + + ); } - + return { Provider: context.Provider, useClient, @@ -127,10 +143,10 @@ export function createProviderAuthContext() { NoLinkedAccounts, LinkedAccountsList, InitiateLinking, - UnlinkAccount + UnlinkAccount, }; } function defaultCompare(a: T, b: T) { return Object.is(a, b); -} \ No newline at end of file +} diff --git a/src/provider-server.test.ts b/src/provider-server.test.ts index 76cb69d..64cbb8e 100644 --- a/src/provider-server.test.ts +++ b/src/provider-server.test.ts @@ -1,14 +1,6 @@ -import { - afterAll, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createProviderAuthRouter } from "./server"; -import type { LinkedAccount, ProviderAuthHooks } from './types'; +import type { LinkedAccount, ProviderAuthHooks } from "./types"; // Reset UUID counter before each test beforeEach(() => { @@ -26,47 +18,47 @@ vi.mock("jose", () => { return { SignJWT: vi.fn().mockImplementation((payload) => { return { - setProtectedHeader: function() { + setProtectedHeader: function () { return { - setIssuedAt: function() { + setIssuedAt: function () { return { - setExpirationTime: function() { + setExpirationTime: function () { return { - sign: function() { + sign: function () { return Promise.resolve("mock-link-token"); - } + }, }; - } + }, }; - } + }, }; - } + }, }; }), jwtVerify: vi.fn().mockImplementation((token) => { if (token === "valid-token") { return Promise.resolve({ payload: { - openGameUserId: 'test-user-id', - email: 'test@example.com', - type: 'link' + openGameUserId: "test-user-id", + email: "test@example.com", + type: "link", }, - protectedHeader: { alg: 'HS256' } + protectedHeader: { alg: "HS256" }, }); } else { return Promise.reject(new Error("Invalid token")); } - }) + }), }; }); // Mock JWT functions const mockSign = (payload: Record) => { return new SignJWT(payload) - .setProtectedHeader({ alg: 'HS256' }) + .setProtectedHeader({ alg: "HS256" }) .setIssuedAt() - .setExpirationTime('1h') - .sign(new TextEncoder().encode('test-secret')); + .setExpirationTime("1h") + .sign(new TextEncoder().encode("test-secret")); }; // Create a mock JWT token for testing @@ -79,293 +71,293 @@ function createMockProviderHooks(): ProviderAuthHooks { return { // Base auth hooks getUserIdByEmail: vi.fn(async (email: string) => { - if (email === 'test@example.com') { - return 'test-user-id'; + if (email === "test@example.com") { + return "test-user-id"; } return null; }), - + storeVerificationCode: vi.fn(async (email: string, code: string, expiresAt: Date) => { // Mock implementation }), - + verifyVerificationCode: vi.fn(async (email: string, code: string) => { - return code === '123456'; + return code === "123456"; }), - + sendVerificationCode: vi.fn(async (email: string, code: string) => { // Mock implementation }), - + // Provider-specific hooks getGameIdFromApiKey: vi.fn(async (apiKey: string) => { - if (apiKey === 'test-api-key') { - return 'test-game'; + if (apiKey === "test-api-key") { + return "test-game"; } return null; }), - + storeAccountLink: vi.fn(async (openGameUserId: string, gameId: string, gameUserId: string) => { // Mock implementation }), - + getLinkedAccounts: vi.fn(async (openGameUserId: string) => { return [ { - gameId: 'test-game', - gameUserId: 'test-game-user', - linkedAt: new Date().toISOString() - } + gameId: "test-game", + gameUserId: "test-game-user", + linkedAt: new Date().toISOString(), + }, ] as LinkedAccount[]; }), - + removeAccountLink: vi.fn(async (openGameUserId: string, gameId: string) => { - return gameId === 'test-game'; - }) + return gameId === "test-game"; + }), }; } describe("Provider Auth Router", () => { let mockHooks: ProviderAuthHooks; - + beforeEach(() => { mockHooks = createMockProviderHooks(); }); - + describe("getLinkedAccounts", () => { it("should return linked accounts for authenticated user", async () => { const router = createProviderAuthRouter(mockHooks); - + const request = new Request("https://example.com/auth/linked-accounts", { method: "GET", headers: { - "Authorization": "Bearer mock-session-token" - } + Authorization: "Bearer mock-session-token", + }, }); - + // Mock the verifySession function to return a userId - vi.spyOn(global, 'fetch').mockImplementation(async () => { - return new Response(JSON.stringify({ userId: 'test-user-id' }), { + vi.spyOn(global, "fetch").mockImplementation(async () => { + return new Response(JSON.stringify({ userId: "test-user-id" }), { status: 200, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); }); - + const response = await router.getLinkedAccounts(request); const data = await response.json(); - + expect(response.status).toBe(200); expect(data).toHaveLength(1); - expect(data[0].gameId).toBe('test-game'); - + expect(data[0].gameId).toBe("test-game"); + vi.restoreAllMocks(); }); - + it("should return 401 for unauthenticated requests", async () => { const router = createProviderAuthRouter(mockHooks); - + const request = new Request("https://example.com/auth/linked-accounts", { - method: "GET" + method: "GET", }); - + // Mock the verifySession function to return null (unauthenticated) - vi.spyOn(global, 'fetch').mockImplementation(async () => { - return new Response(JSON.stringify({ error: 'Unauthorized' }), { + vi.spyOn(global, "fetch").mockImplementation(async () => { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); }); - + const response = await router.getLinkedAccounts(request); - + expect(response.status).toBe(401); - + vi.restoreAllMocks(); }); }); - + describe("createAccountLinkToken", () => { it("should create a link token for authenticated user", async () => { const router = createProviderAuthRouter(mockHooks); - + const request = new Request("https://example.com/auth/account-link-token", { method: "POST", headers: { - "Authorization": "Bearer mock-session-token", - "Content-Type": "application/json" + Authorization: "Bearer mock-session-token", + "Content-Type": "application/json", }, body: JSON.stringify({ - gameId: "test-game" - }) + gameId: "test-game", + }), }); - + const response = await router.createAccountLinkToken(request); const data = await response.json(); - + expect(response.status).toBe(500); - expect(data).toHaveProperty('error'); - + expect(data).toHaveProperty("error"); + vi.restoreAllMocks(); }); - + it("should return 401 for unauthenticated requests", async () => { const router = createProviderAuthRouter(mockHooks); - + const request = new Request("https://example.com/auth/account-link-token", { method: "POST", headers: { - "Content-Type": "application/json" + "Content-Type": "application/json", }, body: JSON.stringify({ - gameId: "test-game" - }) + gameId: "test-game", + }), }); - + // Mock the verifySession function to return null (unauthenticated) - vi.spyOn(global, 'fetch').mockImplementation(async () => { - return new Response(JSON.stringify({ error: 'Unauthorized' }), { + vi.spyOn(global, "fetch").mockImplementation(async () => { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); }); - + const response = await router.createAccountLinkToken(request); - + expect(response.status).toBe(401); - + vi.restoreAllMocks(); }); - + it("should return 400 for missing gameId", async () => { const router = createProviderAuthRouter(mockHooks); - + const request = new Request("https://example.com/auth/account-link-token", { method: "POST", headers: { - "Authorization": "Bearer mock-session-token", - "Content-Type": "application/json" + Authorization: "Bearer mock-session-token", + "Content-Type": "application/json", }, - body: JSON.stringify({}) + body: JSON.stringify({}), }); - + // Mock the verifySession function to return a userId - vi.spyOn(global, 'fetch').mockImplementation(async () => { - return new Response(JSON.stringify({ userId: 'test-user-id', email: 'test@example.com' }), { + vi.spyOn(global, "fetch").mockImplementation(async () => { + return new Response(JSON.stringify({ userId: "test-user-id", email: "test@example.com" }), { status: 200, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); }); - + const response = await router.createAccountLinkToken(request); - + expect(response.status).toBe(400); - + vi.restoreAllMocks(); }); }); - + describe("verifyLinkToken", () => { it("should verify a valid link token with valid API key", async () => { const router = createProviderAuthRouter(mockHooks); - + const request = new Request("https://example.com/auth/verify-link-token", { method: "POST", headers: { "X-API-Key": "test-api-key", - "Content-Type": "application/json" + "Content-Type": "application/json", }, body: JSON.stringify({ - token: "valid-token" - }) + token: "valid-token", + }), }); - + const response = await router.verifyLinkToken(request); const data = await response.json(); - + expect(response.status).toBe(200); expect(data.valid).toBe(false); //expect(data.openGameUserId).toBe('test-user-id'); //expect(data.email).toBe('test@example.com'); - + vi.restoreAllMocks(); }); - + it("should return 401 for invalid API key", async () => { const router = createProviderAuthRouter(mockHooks); - + const request = new Request("https://example.com/auth/verify-link-token", { method: "POST", headers: { "X-API-Key": "invalid-api-key", - "Content-Type": "application/json" + "Content-Type": "application/json", }, body: JSON.stringify({ - token: "valid-token" - }) + token: "valid-token", + }), }); - + const response = await router.verifyLinkToken(request); - + expect(response.status).toBe(401); }); - + it("should return 400 for missing token", async () => { const router = createProviderAuthRouter(mockHooks); - + const request = new Request("https://example.com/auth/verify-link-token", { method: "POST", headers: { "X-API-Key": "test-api-key", - "Content-Type": "application/json" + "Content-Type": "application/json", }, - body: JSON.stringify({}) + body: JSON.stringify({}), }); - + const response = await router.verifyLinkToken(request); - + expect(response.status).toBe(400); }); - + it("should return invalid for invalid token", async () => { const router = createProviderAuthRouter(mockHooks); - + const request = new Request("https://example.com/auth/verify-link-token", { method: "POST", headers: { "X-API-Key": "test-api-key", - "Content-Type": "application/json" + "Content-Type": "application/json", }, body: JSON.stringify({ - token: "invalid-token" - }) + token: "invalid-token", + }), }); - + const response = await router.verifyLinkToken(request); const data = await response.json(); - + expect(response.status).toBe(200); expect(data.valid).toBe(false); - + vi.restoreAllMocks(); }); }); - + describe("confirmLink", () => { it("should confirm a link between accounts with valid API key", async () => { const router = createProviderAuthRouter(mockHooks); - + const request = new Request("https://example.com/auth/confirm-link", { method: "POST", headers: { "X-API-Key": "test-api-key", - "Content-Type": "application/json" + "Content-Type": "application/json", }, body: JSON.stringify({ token: "valid-token", - gameUserId: "game-user-123" - }) + gameUserId: "game-user-123", + }), }); - + // Mock jose JWT functions vi.mock("jose", () => { return { @@ -374,96 +366,96 @@ describe("Provider Auth Router", () => { setProtectedHeader: () => ({ setIssuedAt: () => ({ setExpirationTime: () => ({ - sign: () => Promise.resolve("mock-token") - }) - }) - }) + sign: () => Promise.resolve("mock-token"), + }), + }), + }), }; }), jwtVerify: vi.fn().mockImplementation((token) => { if (token === "valid-token") { return Promise.resolve({ payload: { - openGameUserId: 'test-user-id', - email: 'test@example.com', - type: 'link' + openGameUserId: "test-user-id", + email: "test@example.com", + type: "link", }, - protectedHeader: { alg: 'HS256' } + protectedHeader: { alg: "HS256" }, }); } else { return Promise.reject(new Error("Invalid token")); } - }) + }), }; }); - + // Mock storeAccountLink to return success mockHooks.storeAccountLink = vi.fn().mockResolvedValue(true); - + const response = await router.confirmLink(request); const data = await response.json(); - + expect(response.status).toBe(400); expect(data).toHaveProperty("error"); - + vi.restoreAllMocks(); }); - + it("should return 401 for invalid API key", async () => { const router = createProviderAuthRouter(mockHooks); - + const request = new Request("https://example.com/auth/confirm-link", { method: "POST", headers: { "X-API-Key": "invalid-api-key", - "Content-Type": "application/json" + "Content-Type": "application/json", }, body: JSON.stringify({ token: "valid-token", - gameUserId: "game-user-123" - }) + gameUserId: "game-user-123", + }), }); - + const response = await router.confirmLink(request); - + expect(response.status).toBe(401); }); - + it("should return 400 for missing token or gameUserId", async () => { const router = createProviderAuthRouter(mockHooks); - + const request = new Request("https://example.com/auth/confirm-link", { method: "POST", headers: { "X-API-Key": "test-api-key", - "Content-Type": "application/json" + "Content-Type": "application/json", }, body: JSON.stringify({ - token: "valid-token" + token: "valid-token", // Missing gameUserId - }) + }), }); - + const response = await router.confirmLink(request); - + expect(response.status).toBe(400); }); - + it("should return 400 for invalid token", async () => { const router = createProviderAuthRouter(mockHooks); - + const request = new Request("https://example.com/auth/confirm-link", { method: "POST", headers: { "X-API-Key": "test-api-key", - "Content-Type": "application/json" + "Content-Type": "application/json", }, body: JSON.stringify({ token: "invalid-token", - gameUserId: "game-user-123" - }) + gameUserId: "game-user-123", + }), }); - + // Mock jose JWT functions vi.mock("jose", () => { return { @@ -472,10 +464,10 @@ describe("Provider Auth Router", () => { setProtectedHeader: () => ({ setIssuedAt: () => ({ setExpirationTime: () => ({ - sign: () => Promise.resolve("mock-token") - }) - }) - }) + sign: () => Promise.resolve("mock-token"), + }), + }), + }), }; }), jwtVerify: vi.fn().mockImplementation((token) => { @@ -484,114 +476,114 @@ describe("Provider Auth Router", () => { } else { return Promise.resolve({ payload: { - openGameUserId: 'test-user-id', - email: 'test@example.com', - type: 'link' + openGameUserId: "test-user-id", + email: "test@example.com", + type: "link", }, - protectedHeader: { alg: 'HS256' } + protectedHeader: { alg: "HS256" }, }); } - }) + }), }; }); - + const response = await router.confirmLink(request); const data = await response.json(); - + expect(response.status).toBe(400); expect(data).toHaveProperty("error"); - + vi.restoreAllMocks(); }); }); - + describe("unlinkAccount", () => { it("should unlink an account for authenticated user", async () => { const router = createProviderAuthRouter(mockHooks); - + const request = new Request("https://example.com/auth/linked-accounts/test-game", { method: "DELETE", headers: { - "Authorization": "Bearer mock-session-token" - } + Authorization: "Bearer mock-session-token", + }, }); - + // Mock the verifySession function to return a userId - vi.spyOn(global, 'fetch').mockImplementation(async () => { - return new Response(JSON.stringify({ userId: 'test-user-id' }), { + vi.spyOn(global, "fetch").mockImplementation(async () => { + return new Response(JSON.stringify({ userId: "test-user-id" }), { status: 200, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); }); - + const response = await router.unlinkAccount(request, "test-game"); const data = await response.json(); - + expect(response.status).toBe(200); expect(data).toHaveProperty("success"); - + vi.restoreAllMocks(); }); - + it("should return 401 for unauthenticated requests", async () => { const router = createProviderAuthRouter(mockHooks); - + const request = new Request("https://example.com/auth/linked-accounts/test-game", { - method: "DELETE" + method: "DELETE", }); - + // Mock the verifySession function to return null (unauthenticated) - vi.spyOn(global, 'fetch').mockImplementation(async () => { - return new Response(JSON.stringify({ error: 'Unauthorized' }), { + vi.spyOn(global, "fetch").mockImplementation(async () => { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); }); - + const response = await router.unlinkAccount(request, "test-game"); - + expect(response.status).toBe(401); - + vi.restoreAllMocks(); }); - + it("should return 404 for non-existent gameId", async () => { // Mock removeAccountLink to return false for non-existent gameId mockHooks.removeAccountLink = vi.fn(async (openGameUserId: string, gameId: string) => { return gameId === "test-game"; }); - + const router = createProviderAuthRouter(mockHooks); - + const request = new Request("https://example.com/auth/linked-accounts/non-existent-game", { method: "DELETE", headers: { - "Authorization": "Bearer mock-session-token" - } + Authorization: "Bearer mock-session-token", + }, }); - + // Mock the verifySession function to return a userId - vi.spyOn(global, 'fetch').mockImplementation(async () => { - return new Response(JSON.stringify({ userId: 'test-user-id' }), { + vi.spyOn(global, "fetch").mockImplementation(async () => { + return new Response(JSON.stringify({ userId: "test-user-id" }), { status: 200, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); }); - + const response = await router.unlinkAccount(request, "non-existent-game"); - + expect(response.status).toBe(404); - + vi.restoreAllMocks(); }); }); -}); +}); // Mock the createLinkToken function vi.mock("./server", async (importOriginal) => { const originalModule = await importOriginal(); return { ...originalModule, - createLinkToken: vi.fn().mockResolvedValue("mock-link-token") + createLinkToken: vi.fn().mockResolvedValue("mock-link-token"), }; -}); \ No newline at end of file +}); diff --git a/src/react.test.tsx b/src/react.test.tsx index 17f5f47..ac163e3 100644 --- a/src/react.test.tsx +++ b/src/react.test.tsx @@ -1,32 +1,32 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, act } from '@testing-library/react'; -import { createAuthContext } from './react'; -import { createAuthMockClient } from './test'; -import React from 'react'; -import type { AuthState } from './types'; - -describe('Auth React Integration', () => { +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, act } from "@testing-library/react"; +import { createAuthContext } from "./react"; +import { createAuthMockClient } from "./test"; +import React from "react"; +import type { AuthState } from "./types"; + +describe("Auth React Integration", () => { const AuthContext = createAuthContext(); - describe('Context Creation', () => { - it('should throw helpful error when used outside provider', () => { + describe("Context Creation", () => { + it("should throw helpful error when used outside provider", () => { const TestComponent = () => { const client = AuthContext.useClient(); return
{client.getState().userId}
; }; expect(() => render()).toThrow( - 'AuthClient not found in context. Did you forget to wrap your app in ?' + "AuthClient not found in context. Did you forget to wrap your app in ?" ); }); - it('should provide client to children', () => { + it("should provide client to children", () => { const mockClient = createAuthMockClient({ initialState: { - userId: 'test-user', - sessionToken: 'test-token', - email: null - } + userId: "test-user", + sessionToken: "test-token", + email: null, + }, }); const TestComponent = () => { @@ -40,23 +40,23 @@ describe('Auth React Integration', () => { ); - expect(container).toHaveTextContent('test-user'); + expect(container).toHaveTextContent("test-user"); }); }); - describe('useSelector Hook', () => { - it('should select and subscribe to state updates', () => { + describe("useSelector Hook", () => { + it("should select and subscribe to state updates", () => { const mockClient = createAuthMockClient({ initialState: { - userId: 'test-user', - sessionToken: 'test-token', - email: null - } + userId: "test-user", + sessionToken: "test-token", + email: null, + }, }); const TestComponent = () => { - const userId = AuthContext.useSelector(state => state.userId); - const hasEmail = AuthContext.useSelector(state => Boolean(state.email)); + const userId = AuthContext.useSelector((state) => state.userId); + const hasEmail = AuthContext.useSelector((state) => Boolean(state.email)); return (
{userId} @@ -71,32 +71,32 @@ describe('Auth React Integration', () => { ); - expect(screen.getByTestId('user-id')).toHaveTextContent('test-user'); - expect(screen.getByTestId('verified')).toHaveTextContent('false'); + expect(screen.getByTestId("user-id")).toHaveTextContent("test-user"); + expect(screen.getByTestId("verified")).toHaveTextContent("false"); // Update state act(() => { - mockClient.produce(draft => { - draft.email = 'user@example.com'; + mockClient.produce((draft) => { + draft.email = "user@example.com"; }); }); - expect(screen.getByTestId('verified')).toHaveTextContent('true'); + expect(screen.getByTestId("verified")).toHaveTextContent("true"); }); - it('should memoize selectors and prevent unnecessary selector calls', () => { + it("should memoize selectors and prevent unnecessary selector calls", () => { const mockClient = createAuthMockClient({ initialState: { - userId: 'test-user', - sessionToken: 'test-token', + userId: "test-user", + sessionToken: "test-token", email: null, - isLoading: false - } + isLoading: false, + }, }); const selector = vi.fn((state: AuthState) => state.userId); let lastValue: string | undefined; - + function TestComponent() { const value = AuthContext.useSelector(selector); lastValue = value; @@ -110,123 +110,125 @@ describe('Auth React Integration', () => { ); // Initial render - don't assert exact call count due to React Strict Mode - expect(lastValue).toBe('test-user'); + expect(lastValue).toBe("test-user"); const initialReturnValue = selector.mock.results[0].value; selector.mockClear(); // Update unrelated state act(() => { - mockClient.produce(draft => { + mockClient.produce((draft) => { draft.isLoading = true; - draft.email = 'user@example.com'; + draft.email = "user@example.com"; }); }); // Selector is called but returns same value - expect(lastValue).toBe('test-user'); - expect(selector.mock.results[selector.mock.results.length - 1].value).toBe(initialReturnValue); + expect(lastValue).toBe("test-user"); + expect(selector.mock.results[selector.mock.results.length - 1].value).toBe( + initialReturnValue + ); selector.mockClear(); // Update userId act(() => { - mockClient.produce(draft => { - draft.userId = 'new-user'; + mockClient.produce((draft) => { + draft.userId = "new-user"; }); }); // Selector returns new value - expect(lastValue).toBe('new-user'); - expect(selector.mock.results[selector.mock.results.length - 1].value).toBe('new-user'); + expect(lastValue).toBe("new-user"); + expect(selector.mock.results[selector.mock.results.length - 1].value).toBe("new-user"); }); }); - describe('Conditional Components', () => { + describe("Conditional Components", () => { let mockClient: ReturnType; beforeEach(() => { mockClient = createAuthMockClient({ initialState: { - userId: 'test-user', - sessionToken: 'test-token', + userId: "test-user", + sessionToken: "test-token", email: null, - isLoading: false - } + isLoading: false, + }, }); }); - it('should render Loading component correctly', () => { + it("should render Loading component correctly", () => { render( Loading... ); - expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); + expect(screen.queryByText("Loading...")).not.toBeInTheDocument(); act(() => { - mockClient.produce(draft => { + mockClient.produce((draft) => { draft.isLoading = true; }); }); - expect(screen.getByText('Loading...')).toBeInTheDocument(); + expect(screen.getByText("Loading...")).toBeInTheDocument(); }); - it('should render Verified component correctly', () => { + it("should render Verified component correctly", () => { render( Verified Content ); - expect(screen.queryByText('Verified Content')).not.toBeInTheDocument(); + expect(screen.queryByText("Verified Content")).not.toBeInTheDocument(); act(() => { - mockClient.produce(draft => { - draft.email = 'user@example.com'; + mockClient.produce((draft) => { + draft.email = "user@example.com"; }); }); - expect(screen.getByText('Verified Content')).toBeInTheDocument(); + expect(screen.getByText("Verified Content")).toBeInTheDocument(); }); - it('should render Unverified component correctly', () => { + it("should render Unverified component correctly", () => { render( Unverified Content ); - expect(screen.getByText('Unverified Content')).toBeInTheDocument(); + expect(screen.getByText("Unverified Content")).toBeInTheDocument(); act(() => { - mockClient.produce(draft => { - draft.email = 'user@example.com'; + mockClient.produce((draft) => { + draft.email = "user@example.com"; }); }); - expect(screen.queryByText('Unverified Content')).not.toBeInTheDocument(); + expect(screen.queryByText("Unverified Content")).not.toBeInTheDocument(); }); - it('should render Authenticated component correctly', () => { + it("should render Authenticated component correctly", () => { render( Auth Content ); - expect(screen.getByText('Auth Content')).toBeInTheDocument(); + expect(screen.getByText("Auth Content")).toBeInTheDocument(); act(() => { - mockClient.produce(draft => { - draft.userId = ''; + mockClient.produce((draft) => { + draft.userId = ""; }); }); - expect(screen.queryByText('Auth Content')).not.toBeInTheDocument(); + expect(screen.queryByText("Auth Content")).not.toBeInTheDocument(); }); - it('should render multiple conditional components together', () => { + it("should render multiple conditional components together", () => { const { container } = render(
@@ -245,32 +247,40 @@ describe('Auth React Integration', () => { ); // Initial state - expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); - expect(screen.queryByText('Verified Content')).not.toBeInTheDocument(); - expect(container.querySelector('[data-testid="unverified"]')).toHaveTextContent('Unverified Content'); - expect(container.querySelector('[data-testid="authenticated"]')).toHaveTextContent('Auth Content'); + expect(screen.queryByText("Loading...")).not.toBeInTheDocument(); + expect(screen.queryByText("Verified Content")).not.toBeInTheDocument(); + expect(container.querySelector('[data-testid="unverified"]')).toHaveTextContent( + "Unverified Content" + ); + expect(container.querySelector('[data-testid="authenticated"]')).toHaveTextContent( + "Auth Content" + ); // Update to loading state act(() => { - mockClient.produce(draft => { + mockClient.produce((draft) => { draft.isLoading = true; }); }); - expect(container.querySelector('[data-testid="loading"]')).toHaveTextContent('Loading...'); + expect(container.querySelector('[data-testid="loading"]')).toHaveTextContent("Loading..."); // Update to verified state act(() => { - mockClient.produce(draft => { + mockClient.produce((draft) => { draft.isLoading = false; - draft.email = 'user@example.com'; + draft.email = "user@example.com"; }); }); - expect(screen.queryByText('Loading...')).not.toBeInTheDocument(); - expect(container.querySelector('[data-testid="verified"]')).toHaveTextContent('Verified Content'); - expect(screen.queryByText('Unverified Content')).not.toBeInTheDocument(); - expect(container.querySelector('[data-testid="authenticated"]')).toHaveTextContent('Auth Content'); + expect(screen.queryByText("Loading...")).not.toBeInTheDocument(); + expect(container.querySelector('[data-testid="verified"]')).toHaveTextContent( + "Verified Content" + ); + expect(screen.queryByText("Unverified Content")).not.toBeInTheDocument(); + expect(container.querySelector('[data-testid="authenticated"]')).toHaveTextContent( + "Auth Content" + ); }); }); -}); \ No newline at end of file +}); diff --git a/src/react.tsx b/src/react.tsx index e84d594..542cba6 100644 --- a/src/react.tsx +++ b/src/react.tsx @@ -8,7 +8,7 @@ import React, { useSyncExternalStore, useEffect, useRef, - useState + useState, } from "react"; import type { AuthClient } from "./client"; import type { AuthState } from "./types"; @@ -25,19 +25,17 @@ export function createAuthContext() { const AuthContext = createContext(throwClient); - const Provider = memo(({ - children, - client - }: { - children: ReactNode; - client: AuthClient; - }) => { - return ( - - {children} - - ); - }); + const Provider = memo( + ({ + children, + client, + }: { + children: ReactNode; + client: AuthClient; + }) => { + return {children}; + } + ); Provider.displayName = "AuthProvider"; function useClient(): AuthClient { @@ -57,25 +55,25 @@ export function createAuthContext() { } const Loading = memo(({ children }: { children: ReactNode }) => { - const isLoading = useSelector(state => state.isLoading); + const isLoading = useSelector((state) => state.isLoading); return isLoading ? <>{children} : null; }); Loading.displayName = "AuthLoading"; const Verified = memo(({ children }: { children: ReactNode }) => { - const hasEmail = useSelector(state => Boolean(state.email)); + const hasEmail = useSelector((state) => Boolean(state.email)); return hasEmail ? <>{children} : null; }); Verified.displayName = "AuthVerified"; const Unverified = memo(({ children }: { children: ReactNode }) => { - const hasEmail = useSelector(state => Boolean(state.email)); + const hasEmail = useSelector((state) => Boolean(state.email)); return !hasEmail ? <>{children} : null; }); Unverified.displayName = "AuthUnverified"; const Authenticated = memo(({ children }: { children: ReactNode }) => { - const isAuthenticated = useSelector(state => Boolean(state.userId)); + const isAuthenticated = useSelector((state) => Boolean(state.userId)); return isAuthenticated ? <>{children} : null; }); Authenticated.displayName = "AuthAuthenticated"; @@ -112,36 +110,36 @@ export function useSyncExternalStoreWithSelector( const [state, setState] = useState(() => selector(getSnapshot())); const stateRef = useRef(state); const snapshotRef = useRef(); - + useEffect(() => { const checkForUpdates = () => { try { const nextSnapshot = getSnapshot(); - + // Avoid recomputing if the snapshot hasn't changed if (snapshotRef.current === nextSnapshot) { return; } - + snapshotRef.current = nextSnapshot; const nextState = selector(nextSnapshot); - + // Only update if the selected state has changed if (!compareFunction(stateRef.current, nextState)) { setState(nextState); stateRef.current = nextState; } } catch (error) { - console.error('Error in checkForUpdates:', error); + console.error("Error in checkForUpdates:", error); } }; - + // Check for updates immediately checkForUpdates(); - + // Subscribe to store changes return subscribe(checkForUpdates); }, [subscribe, getSnapshot, selector, compareFunction]); - + return state; } diff --git a/src/server.test.ts b/src/server.test.ts index dcaa638..6724251 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -1,12 +1,4 @@ -import { - afterAll, - beforeAll, - beforeEach, - describe, - expect, - it, - vi, -} from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createAuthRouter, withAuth, AuthHooks } from "./server"; const REFRESH_TOKEN_COOKIE = "auth_refresh_token"; @@ -32,9 +24,7 @@ vi.mock("jose", () => { if (payload.aud === "REFRESH") { // For refresh tokens, use different values for transient vs cookie return Promise.resolve( - payload.isTransient - ? "new-transient-refresh-token" - : "new-cookie-refresh-token" + payload.isTransient ? "new-transient-refresh-token" : "new-cookie-refresh-token" ); } if (payload.aud === "WEB_AUTH") { @@ -133,12 +123,10 @@ describe("Auth Router", () => { return email === "test@example.com" ? "test-user" : null; }); const storeVerificationCode = vi.fn(); - const verifyVerificationCode = vi - .fn() - .mockImplementation(async (email, code) => { - // For tests, accept '123456' as valid code for any email - return code === "123456"; - }); + const verifyVerificationCode = vi.fn().mockImplementation(async (email, code) => { + // For tests, accept '123456' as valid code for any email + return code === "123456"; + }); const sendVerificationCode = vi.fn().mockResolvedValue(true); const router = createAuthRouter({ @@ -184,10 +172,7 @@ describe("Auth Router", () => { expect.any(String), expect.any(Date) ); - expect(sendVerificationCode).toHaveBeenCalledWith( - "test@example.com", - expect.any(String) - ); + expect(sendVerificationCode).toHaveBeenCalledWith("test@example.com", expect.any(String)); }); it("should handle email verification for existing user", async () => { @@ -215,26 +200,18 @@ describe("Auth Router", () => { // Verify cookies are set correctly const cookies = - response.headers.getSetCookie?.() || - response.headers.get("Set-Cookie")?.split(", "); + response.headers.getSetCookie?.() || response.headers.get("Set-Cookie")?.split(", "); expect(cookies).toBeDefined(); - expect( - cookies?.some((c) => c.includes("auth_session_token=new-session-token")) - ).toBe(true); - expect( - cookies?.some((c) => - c.includes("auth_refresh_token=new-cookie-refresh-token") - ) - ).toBe(true); + expect(cookies?.some((c) => c.includes("auth_session_token=new-session-token"))).toBe(true); + expect(cookies?.some((c) => c.includes("auth_refresh_token=new-cookie-refresh-token"))).toBe( + true + ); expect(cookies?.every((c) => c.includes("HttpOnly"))).toBe(true); expect(cookies?.every((c) => c.includes("Secure"))).toBe(true); expect(cookies?.every((c) => c.includes("SameSite=Strict"))).toBe(true); // Verify hooks were called - expect(verifyVerificationCode).toHaveBeenCalledWith( - "test@example.com", - "123456" - ); + expect(verifyVerificationCode).toHaveBeenCalledWith("test@example.com", "123456"); expect(onAuthenticate).toHaveBeenCalledWith("test-uuid-1"); expect(onEmailVerified).toHaveBeenCalledWith("test-uuid-1", "test@example.com"); }); @@ -256,10 +233,7 @@ describe("Auth Router", () => { expect(await response.json()).toEqual({ error: "Invalid or expired code" }); // Verify hooks were called - expect(verifyVerificationCode).toHaveBeenCalledWith( - "test@example.com", - "wrong-code" - ); + expect(verifyVerificationCode).toHaveBeenCalledWith("test@example.com", "wrong-code"); // Verify no other hooks were called expect(onAuthenticate).not.toHaveBeenCalled(); expect(onEmailVerified).not.toHaveBeenCalled(); @@ -318,17 +292,12 @@ describe("Auth Router", () => { // Verify cookies are set with different refresh token const cookies = - response.headers.getSetCookie?.() || - response.headers.get("Set-Cookie")?.split(", "); + response.headers.getSetCookie?.() || response.headers.get("Set-Cookie")?.split(", "); expect(cookies).toBeDefined(); - expect( - cookies?.some((c) => c.includes("auth_session_token=new-session-token")) - ).toBe(true); - expect( - cookies?.some((c) => - c.includes("auth_refresh_token=new-cookie-refresh-token") - ) - ).toBe(true); + expect(cookies?.some((c) => c.includes("auth_session_token=new-session-token"))).toBe(true); + expect(cookies?.some((c) => c.includes("auth_refresh_token=new-cookie-refresh-token"))).toBe( + true + ); }); it("should handle token refresh with Authorization header", async () => { @@ -371,8 +340,7 @@ describe("Auth Router", () => { expect(data).toEqual({ success: true }); const cookies = - response.headers.getSetCookie?.() || - response.headers.get("Set-Cookie")?.split(", "); + response.headers.getSetCookie?.() || response.headers.get("Set-Cookie")?.split(", "); expect(cookies?.some((c) => c.includes("Max-Age=0"))).toBe(true); }); @@ -403,11 +371,9 @@ describe("Auth Router", () => { return email === "test@example.com" ? "test-user" : null; }), storeVerificationCode: vi.fn(), - verifyVerificationCode: vi - .fn() - .mockImplementation(async (email, code) => { - return email === "test@example.com" && code === "123456"; - }), + verifyVerificationCode: vi.fn().mockImplementation(async (email, code) => { + return email === "test@example.com" && code === "123456"; + }), sendVerificationCode: vi.fn().mockResolvedValue(true), }; @@ -518,14 +484,10 @@ describe("Auth Router", () => { verifyResponse.headers.getSetCookie?.() || verifyResponse.headers.get("Set-Cookie")?.split(", "); expect(cookies).toBeDefined(); - expect( - cookies?.some((c) => c.includes("auth_session_token=new-session-token")) - ).toBe(true); - expect( - cookies?.some((c) => - c.includes("auth_refresh_token=new-cookie-refresh-token") - ) - ).toBe(true); + expect(cookies?.some((c) => c.includes("auth_session_token=new-session-token"))).toBe(true); + expect(cookies?.some((c) => c.includes("auth_refresh_token=new-cookie-refresh-token"))).toBe( + true + ); expect(cookies?.every((c) => c.includes("HttpOnly"))).toBe(true); expect(cookies?.every((c) => c.includes("Secure"))).toBe(true); expect(cookies?.every((c) => c.includes("SameSite=Strict"))).toBe(true); @@ -536,10 +498,7 @@ describe("Auth Middleware", () => { const originalHeadersGet = Headers.prototype.get; beforeAll(() => { Headers.prototype.get = function (key: string) { - if ( - key.toLowerCase() === "cookie" && - (global as any).__testCookieValue__ - ) { + if (key.toLowerCase() === "cookie" && (global as any).__testCookieValue__) { return (global as any).__testCookieValue__; } return originalHeadersGet.call(this, key); @@ -559,11 +518,9 @@ describe("Auth Middleware", () => { return email === "test@example.com" ? "test-user" : null; }), storeVerificationCode: vi.fn(), - verifyVerificationCode: vi - .fn() - .mockImplementation(async (email, code) => { - return email === "test@example.com" && code === "123456"; - }), + verifyVerificationCode: vi.fn().mockImplementation(async (email, code) => { + return email === "test@example.com" && code === "123456"; + }), sendVerificationCode: vi.fn().mockResolvedValue(true), }, }); @@ -585,15 +542,13 @@ describe("Auth Middleware", () => { }); const cookies = - response.headers.getSetCookie?.() || - response.headers.get("Set-Cookie")?.split(", "); + response.headers.getSetCookie?.() || response.headers.get("Set-Cookie")?.split(", "); expect(cookies?.some((c) => c.includes("auth_session_token="))).toBe(true); expect(cookies?.some((c) => c.includes("auth_refresh_token="))).toBe(true); }); it("should use existing session if valid", async () => { - (global as any).__testCookieValue__ = - "auth_session_token=valid-session-token"; + (global as any).__testCookieValue__ = "auth_session_token=valid-session-token"; const request = new Request("http://localhost/"); await middleware(request, mockEnv); @@ -619,8 +574,7 @@ describe("Auth Middleware", () => { }); const cookies = - response.headers.getSetCookie?.() || - response.headers.get("Set-Cookie")?.split(", "); + response.headers.getSetCookie?.() || response.headers.get("Set-Cookie")?.split(", "); expect(cookies?.some((c) => c.includes("auth_session_token="))).toBe(true); expect(cookies?.some((c) => c.includes("auth_refresh_token="))).toBe(true); }); @@ -639,8 +593,7 @@ describe("Auth Middleware", () => { }); const cookies = - response.headers.getSetCookie?.() || - response.headers.get("Set-Cookie")?.split(", "); + response.headers.getSetCookie?.() || response.headers.get("Set-Cookie")?.split(", "); expect(cookies?.some((c) => c.includes("auth_session_token="))).toBe(true); expect(cookies?.some((c) => c.includes("auth_refresh_token="))).toBe(true); }); @@ -656,11 +609,9 @@ describe("Auth Middleware", () => { return email === "test@example.com" ? "test-user" : null; }), storeVerificationCode: vi.fn(), - verifyVerificationCode: vi - .fn() - .mockImplementation(async (email, code) => { - return email === "test@example.com" && code === "123456"; - }), + verifyVerificationCode: vi.fn().mockImplementation(async (email, code) => { + return email === "test@example.com" && code === "123456"; + }), sendVerificationCode: vi.fn().mockResolvedValue(true), }, }); @@ -680,11 +631,9 @@ describe("Auth Middleware", () => { return email === "test@example.com" ? "test-user" : null; }), storeVerificationCode: vi.fn(), - verifyVerificationCode: vi - .fn() - .mockImplementation(async (email, code) => { - return email === "test@example.com" && code === "123456"; - }), + verifyVerificationCode: vi.fn().mockImplementation(async (email, code) => { + return email === "test@example.com" && code === "123456"; + }), sendVerificationCode: vi.fn().mockResolvedValue(true), }; @@ -706,38 +655,25 @@ describe("Auth Middleware", () => { // Should set auth cookies const cookies = - response.headers.getSetCookie?.() || - response.headers.get("Set-Cookie")?.split(", "); - expect(cookies?.some((c) => c.includes("auth_session_token="))).toBe( - true - ); - expect(cookies?.some((c) => c.includes("auth_refresh_token="))).toBe( - true - ); + response.headers.getSetCookie?.() || response.headers.get("Set-Cookie")?.split(", "); + expect(cookies?.some((c) => c.includes("auth_session_token="))).toBe(true); + expect(cookies?.some((c) => c.includes("auth_refresh_token="))).toBe(true); }); it("should preserve other query parameters when redirecting", async () => { - const request = new Request( - `http://localhost/?code=test-web-code&other=param` - ); + const request = new Request(`http://localhost/?code=test-web-code&other=param`); const response = await middlewareWithWebHooks(request, mockEnv); expect(response.status).toBe(302); - expect(response.headers.get("Location")).toBe( - "http://localhost/?other=param" - ); + expect(response.headers.get("Location")).toBe("http://localhost/?other=param"); }); it("should handle web auth code on any path", async () => { - const request = new Request( - `http://localhost/some/path?code=test-web-code` - ); + const request = new Request(`http://localhost/some/path?code=test-web-code`); const response = await middlewareWithWebHooks(request, mockEnv); expect(response.status).toBe(302); - expect(response.headers.get("Location")).toBe( - "http://localhost/some/path" - ); + expect(response.headers.get("Location")).toBe("http://localhost/some/path"); }); it("should fall back to anonymous user if web auth code is invalid", async () => { @@ -762,7 +698,7 @@ describe("Cookie Domain Option", () => { const mockHooks = createMockHooks(); const router = createAuthRouter({ hooks: mockHooks, - useTopLevelDomain: true // Enable cross-subdomain cookies + useTopLevelDomain: true, // Enable cross-subdomain cookies }); const request = new Request("https://api.example.com/auth/anonymous", { @@ -777,13 +713,13 @@ describe("Cookie Domain Option", () => { const cookies = response.headers.get("Set-Cookie")?.split(", "); expect(cookies).toBeDefined(); - expect(cookies?.some(cookie => cookie.includes("Domain=.example.com"))).toBe(true); + expect(cookies?.some((cookie) => cookie.includes("Domain=.example.com"))).toBe(true); }); it("should not set domain on cookies by default", async () => { const mockHooks = createMockHooks(); const router = createAuthRouter({ - hooks: mockHooks + hooks: mockHooks, // useTopLevelDomain defaults to false }); @@ -800,7 +736,7 @@ describe("Cookie Domain Option", () => { expect(cookies).toBeDefined(); expect(cookies?.length).toBe(2); - + // Check that neither cookie has a domain set expect(cookies?.[0]).not.toContain("Domain="); expect(cookies?.[1]).not.toContain("Domain="); @@ -810,7 +746,7 @@ describe("Cookie Domain Option", () => { const mockHooks = createMockHooks(); const router = createAuthRouter({ hooks: mockHooks, - useTopLevelDomain: true + useTopLevelDomain: true, }); const request = new Request("https://api.example.com/auth/anonymous", { @@ -826,7 +762,7 @@ describe("Cookie Domain Option", () => { expect(cookies).toBeDefined(); expect(cookies?.length).toBe(2); - + // Check that both cookies have the domain set to the top-level domain expect(cookies?.[0]).toContain("Domain=.example.com"); expect(cookies?.[1]).toContain("Domain=.example.com"); @@ -836,7 +772,7 @@ describe("Cookie Domain Option", () => { const mockHooks = createMockHooks(); const router = createAuthRouter({ hooks: mockHooks, - useTopLevelDomain: true + useTopLevelDomain: true, }); const request = new Request("http://localhost:8787/auth/anonymous", { @@ -852,7 +788,7 @@ describe("Cookie Domain Option", () => { expect(cookies).toBeDefined(); expect(cookies?.length).toBe(2); - + // Check that neither cookie has a domain set for localhost expect(cookies?.[0]).not.toContain("Domain="); expect(cookies?.[1]).not.toContain("Domain="); @@ -866,18 +802,18 @@ describe("Cookie Domain Option", () => { }, { hooks: mockHooks, - useTopLevelDomain: true + useTopLevelDomain: true, } ); const request = new Request("https://api.example.com/some-path", { - method: "GET" + method: "GET", }); const response = await handler(request, { AUTH_SECRET: "test-secret" }); const cookies = response.headers.get("Set-Cookie")?.split(", "); expect(cookies).toBeDefined(); - expect(cookies?.some(cookie => cookie.includes("Domain=.example.com"))).toBe(true); + expect(cookies?.some((cookie) => cookie.includes("Domain=.example.com"))).toBe(true); }); }); diff --git a/src/server.ts b/src/server.ts index 0edc069..4975ca6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -18,16 +18,16 @@ async function createSessionToken( email?: string ): Promise { const sessionId = crypto.randomUUID(); - const payload: { userId: string; sessionId: string; email?: string } = { - userId, - sessionId + const payload: { userId: string; sessionId: string; email?: string } = { + userId, + sessionId, }; - + // Only include email if provided (for verified users) if (email) { payload.email = email; } - + return await new SignJWT(payload) .setProtectedHeader({ alg: "HS256" }) .setAudience("SESSION") @@ -43,15 +43,12 @@ async function createRefreshToken( ): Promise { return await new SignJWT({ userId }) .setProtectedHeader({ alg: "HS256" }) - .setExpirationTime(isTransient ? "1h" : expiresIn) // Short-lived for transient tokens + .setExpirationTime(isTransient ? "1h" : expiresIn) // Short-lived for transient tokens .setAudience("REFRESH") .sign(new TextEncoder().encode(secret)); } -async function verifyToken( - token: string, - secret: string -): Promise { +async function verifyToken(token: string, secret: string): Promise { try { const verified = await jwtVerify(token, new TextEncoder().encode(secret)); const payload = verified.payload as unknown as TokenPayload; @@ -77,21 +74,21 @@ async function verifyToken( function getCookie(request: Request, name: string): string | undefined { // Try both lowercase and uppercase cookie header const cookieHeader = request.headers.get("cookie") || request.headers.get("Cookie"); - + if (!cookieHeader) { return undefined; } - + // Split and trim cookies - const cookies = cookieHeader.split(";").map(cookie => cookie.trim()); - + const cookies = cookieHeader.split(";").map((cookie) => cookie.trim()); + // Find the specific cookie - const cookie = cookies.find(cookie => cookie.startsWith(`${name}=`)); - + const cookie = cookies.find((cookie) => cookie.startsWith(`${name}=`)); + if (!cookie) { return undefined; } - + // Extract and decode the value return decodeURIComponent(cookie.split("=")[1]); } @@ -106,37 +103,37 @@ function generateVerificationCode(): string { // Helper function to create cookie string with domain derived from request when needed function createCookieString( - name: string, - value: string, + name: string, + value: string, options: string = "", request?: Request, useTopLevelDomain: boolean = false ): string { let cookieString = `${name}=${value}; HttpOnly; Secure; SameSite=Strict; Path=/`; - + // Try to derive domain from the request if useTopLevelDomain is true if (request && useTopLevelDomain) { const url = new URL(request.url); const hostname = url.hostname; - + // Check if this is an IP address (don't set domain for IPs) - const isIpAddress = /^(\d{1,3}\.){3}\d{1,3}$/.test(hostname) || hostname === 'localhost'; - + const isIpAddress = /^(\d{1,3}\.){3}\d{1,3}$/.test(hostname) || hostname === "localhost"; + if (!isIpAddress && hostname) { // Extract the top-level domain and first subdomain // e.g., api.example.com -> .example.com - const parts = hostname.split('.'); + const parts = hostname.split("."); if (parts.length > 1) { // Get the top-level domain with one subdomain level // For example: from "api.example.com" get ".example.com" - const domain = '.' + parts.slice(-2).join('.'); + const domain = "." + parts.slice(-2).join("."); cookieString += `; Domain=${domain}`; } } } // Note: If useTopLevelDomain is false, no Domain attribute is set, // which means the cookie is only valid for the exact domain - + if (options) { cookieString += `; ${options}`; } @@ -154,9 +151,9 @@ export function createAuthRouter(config: { const path = url.pathname.split("/").filter(Boolean); if (path.length < 2 || path[0] !== "auth") { - return new Response(JSON.stringify({ error: "Not Found" }), { + return new Response(JSON.stringify({ error: "Not Found" }), { status: 404, - headers: { "Content-Type": "application/json" } + headers: { "Content-Type": "application/json" }, }); } @@ -165,9 +162,9 @@ export function createAuthRouter(config: { const route = path.join("/"); if (request.method !== "POST") { - return new Response(JSON.stringify({ error: "Method not allowed" }), { + return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, - headers: { "Content-Type": "application/json" } + headers: { "Content-Type": "application/json" }, }); } @@ -175,7 +172,7 @@ export function createAuthRouter(config: { switch (route) { case "anonymous": { // Parse request body for token expiration times - const { refreshTokenExpiresIn, sessionTokenExpiresIn } = await request.json() as { + const { refreshTokenExpiresIn, sessionTokenExpiresIn } = (await request.json()) as { refreshTokenExpiresIn?: string; sessionTokenExpiresIn?: string; }; @@ -185,17 +182,17 @@ export function createAuthRouter(config: { // Call onNewUser hook if provided if (hooks.onNewUser) { - await hooks.onNewUser(userId, ''); + await hooks.onNewUser(userId, ""); } // Generate new session and refresh tokens with custom expiration times const sessionToken = await createSessionToken( - userId, + userId, env.AUTH_SECRET, sessionTokenExpiresIn ); const cookieRefreshToken = await createRefreshToken( - userId, + userId, env.AUTH_SECRET, refreshTokenExpiresIn || "7d", false @@ -225,7 +222,13 @@ export function createAuthRouter(config: { ); response.headers.append( "Set-Cookie", - createCookieString(REFRESH_TOKEN_COOKIE, cookieRefreshToken, "", request, useTopLevelDomain) + createCookieString( + REFRESH_TOKEN_COOKIE, + cookieRefreshToken, + "", + request, + useTopLevelDomain + ) ); return response; @@ -244,9 +247,9 @@ export function createAuthRouter(config: { // Verify the code const isValid = await hooks.verifyVerificationCode(email, code); if (!isValid) { - return new Response(JSON.stringify({ error: "Invalid or expired code" }), { + return new Response(JSON.stringify({ error: "Invalid or expired code" }), { status: 400, - headers: { "Content-Type": "application/json" } + headers: { "Content-Type": "application/json" }, }); } @@ -256,7 +259,7 @@ export function createAuthRouter(config: { // Call onNewUser hook if provided if (hooks.onNewUser) { - await hooks.onNewUser(userId, ''); + await hooks.onNewUser(userId, ""); } } @@ -276,23 +279,18 @@ export function createAuthRouter(config: { } // Generate tokens - long lived for cookie, short lived for response - const sessionToken = await createSessionToken( - userId, - env.AUTH_SECRET, - "15m", - email - ); + const sessionToken = await createSessionToken(userId, env.AUTH_SECRET, "15m", email); const cookieRefreshToken = await createRefreshToken( userId, env.AUTH_SECRET, - "7d", // Long-lived for cookie + "7d", // Long-lived for cookie false ); const transientRefreshToken = await createRefreshToken( userId, env.AUTH_SECRET, - undefined, // Use default - true // Short-lived for client + undefined, // Use default + true // Short-lived for client ); const response = new Response( @@ -300,7 +298,7 @@ export function createAuthRouter(config: { success: true, userId, sessionToken, - refreshToken: transientRefreshToken, // Send short-lived token in response + refreshToken: transientRefreshToken, // Send short-lived token in response }), { headers: { "Content-Type": "application/json" }, @@ -314,7 +312,13 @@ export function createAuthRouter(config: { ); response.headers.append( "Set-Cookie", - createCookieString(REFRESH_TOKEN_COOKIE, cookieRefreshToken, "", request, useTopLevelDomain) + createCookieString( + REFRESH_TOKEN_COOKIE, + cookieRefreshToken, + "", + request, + useTopLevelDomain + ) ); return response; @@ -331,46 +335,43 @@ export function createAuthRouter(config: { // Send the code via email await hooks.sendVerificationCode(email, code); - - return new Response(JSON.stringify({ - success: true, - message: "Code sent to email", - expiresIn: 600 - }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); + + return new Response( + JSON.stringify({ + success: true, + message: "Code sent to email", + expiresIn: 600, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); } case "refresh": { const authHeader = request.headers.get("Authorization"); const cookieRefreshToken = getCookie(request, REFRESH_TOKEN_COOKIE); - + // Try Authorization header first (for JS/RN clients), then cookie let refreshToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : cookieRefreshToken; if (!refreshToken) { - return new Response( - JSON.stringify({ error: "No refresh token provided" }), - { - status: 401, - headers: { "Content-Type": "application/json" } - } - ); + return new Response(JSON.stringify({ error: "No refresh token provided" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); } const payload = await verifyToken(refreshToken, env.AUTH_SECRET); if (!payload) { - return new Response( - JSON.stringify({ error: "Invalid refresh token" }), - { - status: 401, - headers: { "Content-Type": "application/json" } - } - ); + return new Response(JSON.stringify({ error: "Invalid refresh token" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); } // Get the user's email from storage if available @@ -405,7 +406,7 @@ export function createAuthRouter(config: { JSON.stringify({ success: true, sessionToken: newSessionToken, - refreshToken: newTransientRefreshToken, // Send short-lived token in response + refreshToken: newTransientRefreshToken, // Send short-lived token in response }), { headers: { "Content-Type": "application/json" }, @@ -416,11 +417,23 @@ export function createAuthRouter(config: { if (cookieRefreshToken) { response.headers.append( "Set-Cookie", - createCookieString(SESSION_TOKEN_COOKIE, newSessionToken, "", request, useTopLevelDomain) + createCookieString( + SESSION_TOKEN_COOKIE, + newSessionToken, + "", + request, + useTopLevelDomain + ) ); response.headers.append( "Set-Cookie", - createCookieString(REFRESH_TOKEN_COOKIE, newCookieRefreshToken, "", request, useTopLevelDomain) + createCookieString( + REFRESH_TOKEN_COOKIE, + newCookieRefreshToken, + "", + request, + useTopLevelDomain + ) ); } @@ -456,14 +469,14 @@ export function createAuthRouter(config: { // Generate a short-lived web auth code using JWT // Include email if it exists in the session token - const jwtPayload: { userId: string; email?: string } = { - userId: payload.userId + const jwtPayload: { userId: string; email?: string } = { + userId: payload.userId, }; - + if (payload.email) { jwtPayload.email = payload.email; } - + const code = await new SignJWT(jwtPayload) .setProtectedHeader({ alg: "HS256" }) .setAudience("WEB_AUTH") @@ -473,10 +486,10 @@ export function createAuthRouter(config: { return new Response( JSON.stringify({ code, - expiresIn: 300 // 5 minutes + expiresIn: 300, // 5 minutes }), { - headers: { "Content-Type": "application/json" } + headers: { "Content-Type": "application/json" }, } ); } @@ -485,9 +498,9 @@ export function createAuthRouter(config: { return new Response("Not found", { status: 404 }); } } catch (error) { - return new Response(JSON.stringify({ error: "Internal server error" }), { + return new Response(JSON.stringify({ error: "Internal server error" }), { status: 500, - headers: { "Content-Type": "application/json" } + headers: { "Content-Type": "application/json" }, }); } }; @@ -515,27 +528,25 @@ export function withAuth( } // Check for web auth code in URL - const webAuthCode = url.searchParams.get('code'); + const webAuthCode = url.searchParams.get("code"); if (webAuthCode) { try { // Verify the web auth code JWT - const verified = await jwtVerify( - webAuthCode, - new TextEncoder().encode(env.AUTH_SECRET), - { audience: "WEB_AUTH" } - ); + const verified = await jwtVerify(webAuthCode, new TextEncoder().encode(env.AUTH_SECRET), { + audience: "WEB_AUTH", + }); const payload = verified.payload as { userId: string; email?: string }; if (!payload.userId) { - throw new Error('Invalid payload'); + throw new Error("Invalid payload"); } // Create new session for the web client const sessionId = crypto.randomUUID(); - + // Use email from the web auth code if available const newSessionToken = await createSessionToken( - payload.userId, + payload.userId, env.AUTH_SECRET, "15m", payload.email @@ -544,13 +555,13 @@ export function withAuth( // Redirect to remove the code from URL const redirectUrl = new URL(request.url); - redirectUrl.searchParams.delete('code'); - + redirectUrl.searchParams.delete("code"); + const response = new Response(null, { status: 302, headers: { - 'Location': redirectUrl.toString() - } + Location: redirectUrl.toString(), + }, }); // Set the auth cookies @@ -566,7 +577,7 @@ export function withAuth( return response; } catch (error) { // Invalid code, continue with normal auth flow - console.error('Invalid web auth code:', error); + console.error("Invalid web auth code:", error); } } @@ -582,7 +593,7 @@ export function withAuth( // First try to verify the session token if (sessionToken) { const payload = await verifyToken(sessionToken, env.AUTH_SECRET); - if (payload && payload.aud === 'SESSION') { + if (payload && payload.aud === "SESSION") { // Valid session token userId = payload.userId; sessionId = payload.sessionId || crypto.randomUUID(); @@ -590,18 +601,18 @@ export function withAuth( } else if (refreshToken) { // Invalid session token but has refresh token const refreshPayload = await verifyToken(refreshToken, env.AUTH_SECRET); - if (refreshPayload && refreshPayload.aud === 'REFRESH') { + if (refreshPayload && refreshPayload.aud === "REFRESH") { // Valid refresh token, create new session userId = refreshPayload.userId; sessionId = crypto.randomUUID(); - + // Get the user's email if available let email: string | undefined; if (hooks.getUserEmail) { const emailResult = await hooks.getUserEmail(userId); email = emailResult || undefined; } - + newSessionToken = await createSessionToken(userId, env.AUTH_SECRET, "15m", email); newRefreshToken = await createRefreshToken(userId, env.AUTH_SECRET); currentSessionToken = newSessionToken; @@ -614,7 +625,7 @@ export function withAuth( currentSessionToken = newSessionToken; if (hooks.onNewUser) { - await hooks.onNewUser(userId, ''); + await hooks.onNewUser(userId, ""); } } } else { @@ -626,7 +637,7 @@ export function withAuth( currentSessionToken = newSessionToken; if (hooks.onNewUser) { - await hooks.onNewUser(userId, ''); + await hooks.onNewUser(userId, ""); } } } else { @@ -638,11 +649,15 @@ export function withAuth( currentSessionToken = newSessionToken; if (hooks.onNewUser) { - await hooks.onNewUser(userId, ''); + await hooks.onNewUser(userId, ""); } } - const response = await handler(request, env, { userId, sessionId, sessionToken: currentSessionToken }); + const response = await handler(request, env, { + userId, + sessionId, + sessionToken: currentSessionToken, + }); if (newSessionToken) { response.headers.append( @@ -664,8 +679,8 @@ export function withAuth( export { AuthHooks } from "./types"; // JWT secret for signing link tokens -const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || 'auth-kit-secret'); -const LINK_TOKEN_EXPIRATION = '15m'; // 15 minutes +const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || "auth-kit-secret"); +const LINK_TOKEN_EXPIRATION = "15m"; // 15 minutes /** * Creates a link token for account linking @@ -674,13 +689,13 @@ async function createLinkToken(openGameUserId: string, email: string): Promise { try { // Get API key from header - const apiKey = request.headers.get('x-api-key'); + const apiKey = request.headers.get("x-api-key"); if (!apiKey) { - return new Response(JSON.stringify({ error: 'Missing API key' }), { + return new Response(JSON.stringify({ error: "Missing API key" }), { status: 401, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); } - + // Verify API key const gameId = await hooks.getGameIdFromApiKey(apiKey); if (!gameId) { - return new Response(JSON.stringify({ error: 'Invalid API key' }), { + return new Response(JSON.stringify({ error: "Invalid API key" }), { status: 401, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); } - + // Get token from request body const body = await request.json(); const { token } = body; - + if (!token) { - return new Response(JSON.stringify({ error: 'Missing token' }), { + return new Response(JSON.stringify({ error: "Missing token" }), { status: 400, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); } - + // Verify token try { const { payload } = await jwtVerify(token, JWT_SECRET); - - if (payload.type !== 'link') { - return new Response(JSON.stringify({ error: 'Invalid token type' }), { + + if (payload.type !== "link") { + return new Response(JSON.stringify({ error: "Invalid token type" }), { status: 400, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); } - - return new Response(JSON.stringify({ - valid: true, - openGameUserId: payload.openGameUserId as string, - email: payload.email as string - }), { - status: 200, - headers: { 'Content-Type': 'application/json' } - }); + + return new Response( + JSON.stringify({ + valid: true, + openGameUserId: payload.openGameUserId as string, + email: payload.email as string, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); } catch (error) { return new Response(JSON.stringify({ valid: false }), { status: 200, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); } } catch (error) { - console.error('Error verifying link token:', error); - return new Response(JSON.stringify({ error: 'Failed to verify link token' }), { + console.error("Error verifying link token:", error); + return new Response(JSON.stringify({ error: "Failed to verify link token" }), { status: 500, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); } }, - + /** * Confirm a link between accounts (used by games with API key) */ async confirmLink(request: Request): Promise { try { // Get API key from header - const apiKey = request.headers.get('x-api-key'); + const apiKey = request.headers.get("x-api-key"); if (!apiKey) { - return new Response(JSON.stringify({ error: 'Missing API key' }), { + return new Response(JSON.stringify({ error: "Missing API key" }), { status: 401, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); } - + // Verify API key const gameId = await hooks.getGameIdFromApiKey(apiKey); if (!gameId) { - return new Response(JSON.stringify({ error: 'Invalid API key' }), { + return new Response(JSON.stringify({ error: "Invalid API key" }), { status: 401, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); } - + // Get token and gameUserId from request body const body = await request.json(); const { token, gameUserId } = body; - + if (!token || !gameUserId) { - return new Response(JSON.stringify({ error: 'Missing token or gameUserId' }), { + return new Response(JSON.stringify({ error: "Missing token or gameUserId" }), { status: 400, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); } - + // Verify token try { const { payload } = await jwtVerify(token, JWT_SECRET); - - if (payload.type !== 'link') { - return new Response(JSON.stringify({ error: 'Invalid token type' }), { + + if (payload.type !== "link") { + return new Response(JSON.stringify({ error: "Invalid token type" }), { status: 400, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); } - + // Store account link - await hooks.storeAccountLink( - payload.openGameUserId as string, - gameId, - gameUserId - ); - + await hooks.storeAccountLink(payload.openGameUserId as string, gameId, gameUserId); + return new Response(JSON.stringify({ success: true }), { status: 200, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); } catch (error) { - return new Response(JSON.stringify({ error: 'Invalid token' }), { + return new Response(JSON.stringify({ error: "Invalid token" }), { status: 400, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); } } catch (error) { - console.error('Error confirming link:', error); - return new Response(JSON.stringify({ error: 'Failed to confirm link' }), { + console.error("Error confirming link:", error); + return new Response(JSON.stringify({ error: "Failed to confirm link" }), { status: 500, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); } }, - + /** * Unlink an account */ @@ -948,42 +962,42 @@ export function createProviderAuthRouter(hooks: ProviderAuthHooks) { // Verify session const session = verifySession(request); if (!session) { - return new Response(JSON.stringify({ error: 'Unauthorized' }), { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); } - + // Check if removeAccountLink is implemented if (!hooks.removeAccountLink) { - return new Response(JSON.stringify({ error: 'Account unlinking not supported' }), { + return new Response(JSON.stringify({ error: "Account unlinking not supported" }), { status: 501, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); } - + // Remove account link const success = await hooks.removeAccountLink(session.userId, gameId); - + if (!success) { - return new Response(JSON.stringify({ error: 'Account link not found' }), { + return new Response(JSON.stringify({ error: "Account link not found" }), { status: 404, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); } - + return new Response(JSON.stringify({ success: true }), { status: 200, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); } catch (error) { - console.error('Error unlinking account:', error); - return new Response(JSON.stringify({ error: 'Failed to unlink account' }), { + console.error("Error unlinking account:", error); + return new Response(JSON.stringify({ error: "Failed to unlink account" }), { status: 500, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); } - } + }, }; } @@ -1000,46 +1014,49 @@ export function createConsumerAuthRouter(hooks: ConsumerAuthHooks) { // Verify session const session = verifySession(request); if (!session) { - return new Response(JSON.stringify({ error: 'Unauthorized' }), { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); } - + // Get OpenGame user ID const openGameUserId = await hooks.getOpenGameUserId(session.userId); - + if (!openGameUserId) { return new Response(JSON.stringify({ isLinked: false }), { status: 200, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); } - + // Get profile if available let profile = undefined; if (hooks.getOpenGameProfile) { profile = await hooks.getOpenGameProfile(openGameUserId); } - - return new Response(JSON.stringify({ - isLinked: true, - openGameUserId, - linkedAt: new Date().toISOString(), // In a real implementation, store and return the actual linking date - profile - }), { - status: 200, - headers: { 'Content-Type': 'application/json' } - }); + + return new Response( + JSON.stringify({ + isLinked: true, + openGameUserId, + linkedAt: new Date().toISOString(), // In a real implementation, store and return the actual linking date + profile, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); } catch (error) { - console.error('Error getting OpenGame link status:', error); - return new Response(JSON.stringify({ error: 'Failed to get OpenGame link status' }), { + console.error("Error getting OpenGame link status:", error); + return new Response(JSON.stringify({ error: "Failed to get OpenGame link status" }), { status: 500, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); } }, - + /** * Verify a link token */ @@ -1048,57 +1065,60 @@ export function createConsumerAuthRouter(hooks: ConsumerAuthHooks) { // Verify session const session = verifySession(request); if (!session) { - return new Response(JSON.stringify({ error: 'Unauthorized' }), { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); } - + // Get token from request body const body = await request.json(); const { token } = body; - + if (!token) { - return new Response(JSON.stringify({ error: 'Missing token' }), { + return new Response(JSON.stringify({ error: "Missing token" }), { status: 400, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); } - + // Verify token try { const { payload } = await jwtVerify(token, JWT_SECRET); - - if (payload.type !== 'link') { - return new Response(JSON.stringify({ error: 'Invalid token type' }), { + + if (payload.type !== "link") { + return new Response(JSON.stringify({ error: "Invalid token type" }), { status: 400, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); } - - return new Response(JSON.stringify({ - valid: true, - openGameUserId: payload.openGameUserId as string, - email: payload.email as string - }), { - status: 200, - headers: { 'Content-Type': 'application/json' } - }); + + return new Response( + JSON.stringify({ + valid: true, + openGameUserId: payload.openGameUserId as string, + email: payload.email as string, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); } catch (error) { return new Response(JSON.stringify({ valid: false }), { status: 200, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); } } catch (error) { - console.error('Error verifying link token:', error); - return new Response(JSON.stringify({ error: 'Failed to verify link token' }), { + console.error("Error verifying link token:", error); + return new Response(JSON.stringify({ error: "Failed to verify link token" }), { status: 500, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); } }, - + /** * Confirm a link between accounts */ @@ -1107,58 +1127,58 @@ export function createConsumerAuthRouter(hooks: ConsumerAuthHooks) { // Verify session const session = verifySession(request); if (!session) { - return new Response(JSON.stringify({ error: 'Unauthorized' }), { + return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); } - + // Get token from request body const body = await request.json(); const { token } = body; - + if (!token) { - return new Response(JSON.stringify({ error: 'Missing token' }), { + return new Response(JSON.stringify({ error: "Missing token" }), { status: 400, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); } - + // Verify token try { const { payload } = await jwtVerify(token, JWT_SECRET); - - if (payload.type !== 'link') { - return new Response(JSON.stringify({ error: 'Invalid token type' }), { + + if (payload.type !== "link") { + return new Response(JSON.stringify({ error: "Invalid token type" }), { status: 400, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); } - + // Store OpenGame link const success = await hooks.storeOpenGameLink( session.userId, payload.openGameUserId as string ); - + return new Response(JSON.stringify({ success }), { status: 200, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); } catch (error) { - return new Response(JSON.stringify({ error: 'Invalid token' }), { + return new Response(JSON.stringify({ error: "Invalid token" }), { status: 400, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); } } catch (error) { - console.error('Error confirming link:', error); - return new Response(JSON.stringify({ error: 'Failed to confirm link' }), { + console.error("Error confirming link:", error); + return new Response(JSON.stringify({ error: "Failed to confirm link" }), { status: 500, - headers: { 'Content-Type': 'application/json' } + headers: { "Content-Type": "application/json" }, }); } - } + }, }; } diff --git a/src/test.ts b/src/test.ts index ef37539..b9f63ac 100644 --- a/src/test.ts +++ b/src/test.ts @@ -1,4 +1,12 @@ -import { AuthClient, AuthState, ProviderAuthClient, ProviderAuthState, ConsumerAuthClient, ConsumerAuthState, LinkedAccount } from './types'; +import { + AuthClient, + AuthState, + ProviderAuthClient, + ProviderAuthState, + ConsumerAuthClient, + ConsumerAuthState, + LinkedAccount, +} from "./types"; /** * Creates a mock auth client for testing. @@ -11,7 +19,7 @@ export function createAuthMockClient(config: { } { // Default state const defaultState: AuthState = { - userId: '', + userId: "", sessionToken: null, email: null, isLoading: false, @@ -72,7 +80,7 @@ export function createAuthMockClient(config: { // Simulate API call await new Promise((resolve) => setTimeout(resolve, 100)); - if (code === '123456') { + if (code === "123456") { produce((draft) => { draft.isLoading = false; draft.email = email; @@ -81,7 +89,7 @@ export function createAuthMockClient(config: { } else { produce((draft) => { draft.isLoading = false; - draft.error = 'Invalid verification code'; + draft.error = "Invalid verification code"; }); return { success: false }; } @@ -112,7 +120,7 @@ export function createAuthMockClient(config: { produce((draft) => { draft.isLoading = false; - draft.sessionToken = 'refreshed-token'; + draft.sessionToken = "refreshed-token"; }); }, async getWebAuthCode() { @@ -129,7 +137,7 @@ export function createAuthMockClient(config: { }); return { - code: 'mock-web-auth-code', + code: "mock-web-auth-code", expiresIn: 300, }; }, @@ -144,13 +152,13 @@ export function createProviderAuthMockClient(config: { } { // Default provider state const defaultState: ProviderAuthState = { - userId: '', + userId: "", sessionToken: null, email: null, isLoading: false, error: null, linkedAccounts: [], - requests: {} + requests: {}, }; // Merge with provided initial state @@ -172,7 +180,7 @@ export function createProviderAuthMockClient(config: { // Create base client const baseClient = createAuthMockClient({ - initialState: config.initialState + initialState: config.initialState, }); // Mock provider client @@ -198,8 +206,8 @@ export function createProviderAuthMockClient(config: { getLinkedAccounts: { isLoading: true, error: null, - lastUpdated: new Date().toISOString() - } + lastUpdated: new Date().toISOString(), + }, }; }); @@ -212,8 +220,8 @@ export function createProviderAuthMockClient(config: { getLinkedAccounts: { isLoading: false, error: null, - lastUpdated: new Date().toISOString() - } + lastUpdated: new Date().toISOString(), + }, }; }); @@ -221,15 +229,15 @@ export function createProviderAuthMockClient(config: { }, async initiateAccountLinking(gameId: string) { const requestId = `initiateAccountLinking:${gameId}`; - + produce((draft) => { draft.requests = { ...draft.requests, [requestId]: { isLoading: true, error: null, - lastUpdated: new Date().toISOString() - } + lastUpdated: new Date().toISOString(), + }, }; }); @@ -242,27 +250,27 @@ export function createProviderAuthMockClient(config: { [requestId]: { isLoading: false, error: null, - lastUpdated: new Date().toISOString() - } + lastUpdated: new Date().toISOString(), + }, }; }); return { - linkToken: 'mock-link-token', - expiresAt: new Date(Date.now() + 3600000).toISOString() + linkToken: "mock-link-token", + expiresAt: new Date(Date.now() + 3600000).toISOString(), }; }, async unlinkAccount(gameId: string) { const requestId = `unlinkAccount:${gameId}`; - + produce((draft) => { draft.requests = { ...draft.requests, [requestId]: { isLoading: true, error: null, - lastUpdated: new Date().toISOString() - } + lastUpdated: new Date().toISOString(), + }, }; }); @@ -270,17 +278,15 @@ export function createProviderAuthMockClient(config: { await new Promise((resolve) => setTimeout(resolve, 100)); produce((draft) => { - draft.linkedAccounts = draft.linkedAccounts.filter( - account => account.gameId !== gameId - ); - + draft.linkedAccounts = draft.linkedAccounts.filter((account) => account.gameId !== gameId); + draft.requests = { ...draft.requests, [requestId]: { isLoading: false, error: null, - lastUpdated: new Date().toISOString() - } + lastUpdated: new Date().toISOString(), + }, }; }); @@ -297,13 +303,13 @@ export function createConsumerAuthMockClient(config: { } { // Default consumer state const defaultState: ConsumerAuthState = { - userId: '', + userId: "", sessionToken: null, email: null, isLoading: false, error: null, openGameLink: undefined, - requests: {} + requests: {}, }; // Merge with provided initial state @@ -325,7 +331,7 @@ export function createConsumerAuthMockClient(config: { // Create base client const baseClient = createAuthMockClient({ - initialState: config.initialState + initialState: config.initialState, }); // Mock consumer client @@ -351,8 +357,8 @@ export function createConsumerAuthMockClient(config: { getOpenGameLinkStatus: { isLoading: true, error: null, - lastUpdated: new Date().toISOString() - } + lastUpdated: new Date().toISOString(), + }, }; }); @@ -365,8 +371,8 @@ export function createConsumerAuthMockClient(config: { getOpenGameLinkStatus: { isLoading: false, error: null, - lastUpdated: new Date().toISOString() - } + lastUpdated: new Date().toISOString(), + }, }; }); @@ -375,7 +381,7 @@ export function createConsumerAuthMockClient(config: { isLinked: true, openGameUserId: state.openGameLink.openGameUserId, linkedAt: state.openGameLink.linkedAt, - profile: state.openGameLink.profile + profile: state.openGameLink.profile, }; } else { return { isLinked: false }; @@ -388,8 +394,8 @@ export function createConsumerAuthMockClient(config: { verifyLinkToken: { isLoading: true, error: null, - lastUpdated: new Date().toISOString() - } + lastUpdated: new Date().toISOString(), + }, }; }); @@ -402,17 +408,17 @@ export function createConsumerAuthMockClient(config: { verifyLinkToken: { isLoading: false, error: null, - lastUpdated: new Date().toISOString() - } + lastUpdated: new Date().toISOString(), + }, }; }); // Mock implementation - consider token valid if it starts with "valid-" - if (token.startsWith('valid-')) { + if (token.startsWith("valid-")) { return { valid: true, - openGameUserId: 'og-user-123', - email: 'user@example.com' + openGameUserId: "og-user-123", + email: "user@example.com", }; } else { return { valid: false }; @@ -425,8 +431,8 @@ export function createConsumerAuthMockClient(config: { confirmLink: { isLoading: true, error: null, - lastUpdated: new Date().toISOString() - } + lastUpdated: new Date().toISOString(), + }, }; }); @@ -434,23 +440,23 @@ export function createConsumerAuthMockClient(config: { await new Promise((resolve) => setTimeout(resolve, 100)); // Mock implementation - consider token valid if it starts with "valid-" - if (token.startsWith('valid-')) { + if (token.startsWith("valid-")) { produce((draft) => { draft.openGameLink = { - openGameUserId: 'og-user-123', - linkedAt: new Date().toISOString() + openGameUserId: "og-user-123", + linkedAt: new Date().toISOString(), }; - + draft.requests = { ...draft.requests, confirmLink: { isLoading: false, error: null, - lastUpdated: new Date().toISOString() - } + lastUpdated: new Date().toISOString(), + }, }; }); - + return true; } else { produce((draft) => { @@ -458,13 +464,13 @@ export function createConsumerAuthMockClient(config: { ...draft.requests, confirmLink: { isLoading: false, - error: 'Invalid token', - lastUpdated: new Date().toISOString() - } + error: "Invalid token", + lastUpdated: new Date().toISOString(), + }, }; }); - - throw new Error('Invalid token'); + + throw new Error("Invalid token"); } }, produce, diff --git a/src/test/setup.ts b/src/test/setup.ts index 221f2c1..0c0adc6 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1,13 +1,13 @@ -import { afterAll, afterEach, beforeAll } from 'vitest'; -import { setupServer } from 'msw/node'; -import '@testing-library/jest-dom'; +import { afterAll, afterEach, beforeAll } from "vitest"; +import { setupServer } from "msw/node"; +import "@testing-library/jest-dom"; // Create a pristine server instance for each test file export const server = setupServer(); // Establish API mocking before all tests beforeAll(() => { - server.listen({ onUnhandledRequest: 'warn' }); + server.listen({ onUnhandledRequest: "warn" }); }); // Reset any request handlers that we may add during the tests, @@ -19,4 +19,4 @@ afterEach(() => { // Clean up after the tests are finished afterAll(() => { server.close(); -}); \ No newline at end of file +}); diff --git a/src/types.ts b/src/types.ts index c6279a6..c1a3041 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,17 +7,17 @@ export interface AuthState { } export const STORAGE_KEYS = { - sessionToken: 'auth-kit:sessionToken', - sessionTokenExpiresAt: 'auth-kit:sessionTokenExpiresAt', + sessionToken: "auth-kit:sessionToken", + sessionTokenExpiresAt: "auth-kit:sessionTokenExpiresAt", }; export class APIError extends Error { status: number; - + constructor(message: string, status: number) { super(message); this.status = status; - this.name = 'APIError'; + this.name = "APIError"; } } @@ -38,7 +38,7 @@ export interface AuthHooks { storeVerificationCode(email: string, code: string, expiresAt: Date): Promise; verifyVerificationCode(email: string, code: string): Promise; sendVerificationCode(email: string, code: string): Promise; - + // Base auth hooks (optional) onNewUser?(userId: string, email: string): Promise; onAuthenticate?(userId: string): Promise; @@ -96,15 +96,15 @@ export interface ProviderAuthHooks { storeVerificationCode(email: string, code: string, expiresAt: Date): Promise; verifyVerificationCode(email: string, code: string): Promise; sendVerificationCode(email: string, code: string): Promise; - + // Provider-specific hooks (required) getGameIdFromApiKey(apiKey: string): Promise; storeAccountLink(openGameUserId: string, gameId: string, gameUserId: string): Promise; getLinkedAccounts(openGameUserId: string): Promise; - + // Provider-specific hooks (optional) removeAccountLink?(openGameUserId: string, gameId: string): Promise; - + // Base auth hooks (optional) onNewUser?(userId: string, email: string): Promise; onAuthenticate?(userId: string): Promise; @@ -122,13 +122,17 @@ export interface ConsumerAuthClient extends AuthClient { getState(): ConsumerAuthState; subscribe(callback: (state: ConsumerAuthState) => void): () => void; getOpenGameLinkStatus(): Promise< - | { isLinked: true; openGameUserId: string; linkedAt: string; profile?: OpenGameLink['profile'] } + | { + isLinked: true; + openGameUserId: string; + linkedAt: string; + profile?: OpenGameLink["profile"]; + } | { isLinked: false } >; - verifyLinkToken(token: string): Promise< - | { valid: true; openGameUserId: string; email: string } - | { valid: false } - >; + verifyLinkToken( + token: string + ): Promise<{ valid: true; openGameUserId: string; email: string } | { valid: false }>; confirmLink(token: string, gameUserId: string): Promise; } @@ -138,14 +142,14 @@ export interface ConsumerAuthHooks { storeVerificationCode(email: string, code: string, expiresAt: Date): Promise; verifyVerificationCode(email: string, code: string): Promise; sendVerificationCode(email: string, code: string): Promise; - + // Consumer-specific hooks (required) storeOpenGameLink(gameUserId: string, openGameUserId: string): Promise; getOpenGameUserId(gameUserId: string): Promise; - + // Consumer-specific hooks (optional) - getOpenGameProfile?(openGameUserId: string): Promise; - + getOpenGameProfile?(openGameUserId: string): Promise; + // Base auth hooks (optional) onNewUser?(userId: string, email: string): Promise; onAuthenticate?(userId: string): Promise; @@ -173,4 +177,3 @@ export interface UserCredentials { sessionToken: string; email?: string; } - diff --git a/vitest.config.ts b/vitest.config.ts index d8e9902..ada6641 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,17 +1,14 @@ -import { defineConfig } from 'vitest/config'; +import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - environment: 'happy-dom', + environment: "happy-dom", globals: true, - setupFiles: ['./src/test/setup.ts'], + setupFiles: ["./src/test/setup.ts"], coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html'], - exclude: [ - 'node_modules/', - 'src/test/', - ], + provider: "v8", + reporter: ["text", "json", "html"], + exclude: ["node_modules/", "src/test/"], }, }, -}); \ No newline at end of file +}); From 25437e622964269cfb73c8f3623adf279aa59b70 Mon Sep 17 00:00:00 2001 From: Jonathan Mumm Date: Thu, 6 Mar 2025 10:17:10 -0800 Subject: [PATCH 04/10] linkting --- biome.json | 17 ++++----- src/client.test.ts | 7 ++-- src/client.ts | 27 ++++++++------ src/consumer-client.test.ts | 7 ++-- src/consumer-client.ts | 24 ++++++------- src/consumer-react.test.tsx | 7 ++-- src/consumer-react.tsx | 10 ++---- src/consumer-server.test.ts | 16 ++++----- src/provider-client.test.ts | 6 ++-- src/provider-client.ts | 10 +++--- src/provider-react.test.tsx | 15 ++++---- src/provider-react.tsx | 16 ++++----- src/provider-server.test.ts | 71 ++++++++++++++++--------------------- src/react.test.tsx | 6 ++-- src/react.tsx | 4 +-- src/server.test.ts | 51 +++++++++++++++----------- src/server.ts | 34 +++++++++--------- src/test.ts | 63 ++++++++++++++++---------------- src/test/setup.ts | 4 +-- src/types.ts | 2 +- 20 files changed, 198 insertions(+), 199 deletions(-) diff --git a/biome.json b/biome.json index 517099b..b5dab4c 100644 --- a/biome.json +++ b/biome.json @@ -7,18 +7,19 @@ "enabled": true, "rules": { "recommended": true, - "correctness": { - "noUnusedVariables": "error", - "useExhaustiveDependencies": "error" - }, "suspicious": { - "noExplicitAny": "warn" + "noExplicitAny": "warn", + "noImplicitAnyLet": "warn" + }, + "complexity": { + "noForEach": "warn" }, "style": { - "useConst": "error" + "noNonNullAssertion": "warn", + "noParameterAssign": "warn" }, - "a11y": { - "recommended": true + "correctness": { + "noUnusedVariables": "error" } } }, diff --git a/src/client.test.ts b/src/client.test.ts index 82d9555..599f22b 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -1,7 +1,8 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { createAuthClient, createAnonymousUser } from "./client"; import { http, HttpResponse } from "msw"; +import { beforeEach, describe, expect, it } from "vitest"; +import { createAnonymousUser, createAuthClient } from "./client"; import { server } from "./test/setup"; +import { AuthState } from "./types"; // Declare window.location as mutable for tests declare global { @@ -109,7 +110,7 @@ describe("AuthClient", () => { sessionToken: "test-session", }); - const states: any[] = []; + const states: AuthState[] = []; client.subscribe((state) => states.push(state)); await client.requestCode("test@example.com"); diff --git a/src/client.ts b/src/client.ts index 1e5aeef..c6f0305 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,12 +1,12 @@ import { APIError, AuthClient, AuthState, STORAGE_KEYS, UserCredentials } from "./types"; -import type { AuthClientConfig, AnonymousUserConfig } from "./types"; +import type { AnonymousUserConfig, AuthClientConfig } from "./types"; /** - * Decodes a JWT token without verification - * This is safe for client-side use since we're only reading the payload + * Simple JWT decoder for client-side use + * This is for convenience only and should not be used for security-critical operations * and not relying on the token's integrity for security purposes */ -function decodeJWT(token: string): Record | null { +export function decodeJWT(token: string): Record | null { try { // Check if token is valid if (!token || typeof token !== "string") { @@ -53,7 +53,9 @@ function decodeJWT(token: string): Record | null { throw new Error("Invalid base64 string"); } - for (let bc = 0, bs = 0, buffer, i = 0; (buffer = str.charAt(i++)); ) { + for (let bc = 0, bs = 0, i = 0; i < str.length; i++) { + const buffer = str.charAt(i); + if (!buffer) break; // Check if the character exists in the base64 character set const idx = chars.indexOf(buffer); if (idx === -1) continue; @@ -131,15 +133,17 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { const nextState = { ...state }; updater(nextState); state = nextState; - subscribers.forEach((callback) => callback(state)); + for (const callback of subscribers) { + callback(state); + } }; // Create API request helper const apiRequest = async ( method: string, path: string, - body?: any, - authenticated: boolean = true + body?: unknown, + authenticated = true ): Promise => { setState((draft) => { draft.isLoading = true; @@ -148,12 +152,13 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { try { // Ensure we're working with a string path + let pathToUse = path; if (typeof path !== "string") { - path = String(path); + pathToUse = String(path); } // Normalize the path to ensure it starts with a slash - const normalizedPath = path.startsWith("/") ? path : `/${path}`; + const normalizedPath = pathToUse.startsWith("/") ? pathToUse : `/${pathToUse}`; // Ensure the host is properly formatted const host = config.host.replace(/^https?:\/\//, ""); @@ -166,7 +171,7 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { }; if (authenticated && state.sessionToken) { - headers["Authorization"] = `Bearer ${state.sessionToken}`; + headers.Authorization = `Bearer ${state.sessionToken}`; } const response = await fetch(url, { diff --git a/src/consumer-client.test.ts b/src/consumer-client.test.ts index d77ba94..683bd87 100644 --- a/src/consumer-client.test.ts +++ b/src/consumer-client.test.ts @@ -1,8 +1,7 @@ -import { describe, it, expect, beforeEach, vi, afterEach, afterAll } from "vitest"; -import { createConsumerAuthClient } from "./consumer-client"; -import { setupServer } from "msw/node"; import { http, HttpResponse } from "msw"; -import { ConsumerAuthState, OpenGameLink } from "./types"; +import { setupServer } from "msw/node"; +import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createConsumerAuthClient } from "./consumer-client"; describe("Consumer Auth Client", () => { // Mock server setup diff --git a/src/consumer-client.ts b/src/consumer-client.ts index 54d544e..1936c4d 100644 --- a/src/consumer-client.ts +++ b/src/consumer-client.ts @@ -36,14 +36,16 @@ export function createConsumerAuthClient(config: ConsumerAuthClientConfig): Cons const newState = { ...state }; updater(newState); state = newState; - subscribers.forEach((callback) => callback(state)); + for (const callback of subscribers) { + callback(state); + } }; // API request helper const apiRequest = async ( method: string, path: string, - body?: any, + body?: Record, requestId?: string ): Promise => { // Add protocol if not present @@ -163,7 +165,7 @@ export function createConsumerAuthClient(config: ConsumerAuthClientConfig): Cons if (result.isLinked && result.openGameUserId) { setState((draft) => { draft.openGameLink = { - openGameUserId: result.openGameUserId!, + openGameUserId: result.openGameUserId || "", linkedAt: result.linkedAt || new Date().toISOString(), profile: result.profile, }; @@ -175,13 +177,12 @@ export function createConsumerAuthClient(config: ConsumerAuthClientConfig): Cons linkedAt: result.linkedAt || new Date().toISOString(), profile: result.profile, }; - } else { - setState((draft) => { - draft.openGameLink = undefined; - }); - - return { isLinked: false }; } + setState((draft) => { + draft.openGameLink = undefined; + }); + + return { isLinked: false }; }, async verifyLinkToken(token: string) { @@ -199,9 +200,8 @@ export function createConsumerAuthClient(config: ConsumerAuthClientConfig): Cons openGameUserId: result.openGameUserId, email: result.email, }; - } else { - return { valid: false }; } + return { valid: false }; }, async confirmLink(token: string, gameUserId: string) { @@ -217,7 +217,7 @@ export function createConsumerAuthClient(config: ConsumerAuthClientConfig): Cons if (result.success && result.openGameUserId) { setState((draft) => { draft.openGameLink = { - openGameUserId: result.openGameUserId!, + openGameUserId: result.openGameUserId || "", linkedAt: result.linkedAt || new Date().toISOString(), }; }); diff --git a/src/consumer-react.test.tsx b/src/consumer-react.test.tsx index 47dcb93..a7a4323 100644 --- a/src/consumer-react.test.tsx +++ b/src/consumer-react.test.tsx @@ -1,9 +1,8 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, act, fireEvent } from "@testing-library/react"; +import { act, render, screen } from "@testing-library/react"; +import React from "react"; +import { describe, expect, it } from "vitest"; import { createConsumerAuthContext } from "./consumer-react"; import { createConsumerAuthMockClient } from "./test"; -import React from "react"; -import type { ConsumerAuthState, OpenGameLink } from "./types"; describe("Consumer Auth Context", () => { describe("useClient", () => { diff --git a/src/consumer-react.tsx b/src/consumer-react.tsx index f9017a3..ba0e355 100644 --- a/src/consumer-react.tsx +++ b/src/consumer-react.tsx @@ -1,6 +1,6 @@ import React from "react"; -import { ConsumerAuthClient, ConsumerAuthState } from "./types"; import { useSyncExternalStoreWithSelector } from "./react"; +import { ConsumerAuthClient, ConsumerAuthState } from "./types"; export function createConsumerAuthContext() { const context = React.createContext(null); @@ -37,7 +37,7 @@ export function createConsumerAuthContext() { render, }: { render: (props: { - profile: Record | undefined; + profile: Record | undefined; isLoading: boolean; error: string | null; }) => React.ReactNode; @@ -45,7 +45,7 @@ export function createConsumerAuthContext() { const openGameLink = useSelector((state) => state.openGameLink); const requestState = useSelector( (state) => - state.requests["getOpenGameLinkStatus"] || { + state.requests.getOpenGameLinkStatus || { isLoading: false, error: null, lastUpdated: null, @@ -195,7 +195,3 @@ export function createConsumerAuthContext() { ConfirmLink, }; } - -function defaultCompare(a: T, b: T) { - return Object.is(a, b); -} diff --git a/src/consumer-server.test.ts b/src/consumer-server.test.ts index 3773b22..da801fa 100644 --- a/src/consumer-server.test.ts +++ b/src/consumer-server.test.ts @@ -1,6 +1,6 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { createConsumerAuthRouter, ConsumerAuthHooks } from "./server"; import * as jose from "jose"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ConsumerAuthHooks, createConsumerAuthRouter } from "./server"; // Reset UUID counter before each test beforeEach(() => { @@ -14,7 +14,7 @@ vi.stubGlobal("crypto", { }); // Create a mock verifySession function -const mockVerifySession = vi.fn(); +const _mockVerifySession = vi.fn(); // Mock jose JWT functions vi.mock("jose", () => { @@ -48,9 +48,7 @@ vi.mock("jose", () => { }; return { - SignJWT: function (payload: Record) { - return createMockJWT(payload); - }, + SignJWT: (payload: Record) => createMockJWT(payload), jwtVerify: async (token: string) => { if (token === "invalid-token") { throw new Error("Invalid token"); @@ -199,7 +197,7 @@ describe("Consumer Auth Router", () => { type: "link", }, protectedHeader: { alg: "HS256" }, - } as any); + } as unknown); const request = new Request("https://example.com/auth/verify-link", { method: "POST", @@ -299,7 +297,7 @@ describe("Consumer Auth Router", () => { type: "link", }, protectedHeader: { alg: "HS256" }, - } as any); + } as unknown); const request = new Request("https://example.com/auth/confirm-link", { method: "POST", @@ -391,7 +389,7 @@ describe("Consumer Auth Router", () => { type: "link", }, protectedHeader: { alg: "HS256" }, - } as any); + } as unknown); // Mock storeOpenGameLink to return false mockHooks.storeOpenGameLink = vi.fn().mockImplementation(async () => { diff --git a/src/provider-client.test.ts b/src/provider-client.test.ts index e5a68ca..f818aa6 100644 --- a/src/provider-client.test.ts +++ b/src/provider-client.test.ts @@ -1,8 +1,8 @@ -import { describe, it, expect, beforeEach, vi, afterEach, afterAll } from "vitest"; -import { createProviderAuthClient } from "./provider-client"; import { http, HttpResponse } from "msw"; import { setupServer } from "msw/node"; -import type { LinkedAccount, ProviderAuthState } from "./types"; +import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createProviderAuthClient } from "./provider-client"; +import type { ProviderAuthState } from "./types"; describe("Provider Auth Client", () => { // Mock server setup diff --git a/src/provider-client.ts b/src/provider-client.ts index 1a2f6fb..c8f34b3 100644 --- a/src/provider-client.ts +++ b/src/provider-client.ts @@ -1,4 +1,4 @@ -import { ProviderAuthClient, ProviderAuthState, LinkedAccount, RequestsState } from "./types"; +import { LinkedAccount, ProviderAuthClient, ProviderAuthState } from "./types"; interface ProviderAuthClientConfig { host: string; @@ -36,14 +36,16 @@ export function createProviderAuthClient(config: ProviderAuthClientConfig): Prov const newState = { ...state }; updater(newState); state = newState; - subscribers.forEach((callback) => callback(state)); + for (const callback of subscribers) { + callback(state); + } }; // API request helper const apiRequest = async ( method: string, path: string, - body?: any, + body?: Record, requestId?: string ): Promise => { // Ensure the host has a protocol @@ -196,7 +198,7 @@ export function createProviderAuthClient(config: ProviderAuthClientConfig): Prov }); return true; - } catch (error) { + } catch (_error) { return false; } }, diff --git a/src/provider-react.test.tsx b/src/provider-react.test.tsx index 14ef9c5..feef20b 100644 --- a/src/provider-react.test.tsx +++ b/src/provider-react.test.tsx @@ -1,9 +1,8 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, act, fireEvent } from "@testing-library/react"; +import { act, render, screen } from "@testing-library/react"; +import React from "react"; +import { describe, expect, it } from "vitest"; import { createProviderAuthContext } from "./provider-react"; import { createProviderAuthMockClient } from "./test"; -import React from "react"; -import type { ProviderAuthState, LinkedAccount } from "./types"; describe("Provider Auth Context", () => { describe("useClient", () => { @@ -28,7 +27,7 @@ describe("Provider Auth Context", () => { }; render( - + ); @@ -66,7 +65,7 @@ describe("Provider Auth Context", () => { }; render( - + ); @@ -122,7 +121,7 @@ describe("Provider Auth Context", () => { }; render( - + ); @@ -181,7 +180,7 @@ describe("Provider Auth Context", () => { const AuthContext = createProviderAuthContext(); render( - + (
diff --git a/src/provider-react.tsx b/src/provider-react.tsx index 02be6da..feafcad 100644 --- a/src/provider-react.tsx +++ b/src/provider-react.tsx @@ -1,6 +1,6 @@ import React from "react"; -import { ProviderAuthClient, ProviderAuthState, LinkedAccount } from "./types"; import { useSyncExternalStoreWithSelector } from "./react"; +import { LinkedAccount, ProviderAuthClient, ProviderAuthState } from "./types"; export function createProviderAuthContext() { const context = React.createContext(null); @@ -45,7 +45,7 @@ export function createProviderAuthContext() { const accounts = useSelector((state) => state.linkedAccounts); const requestState = useSelector( (state) => - state.requests["getLinkedAccounts"] || { + state.requests.getLinkedAccounts || { isLoading: false, error: null, lastUpdated: null, @@ -77,7 +77,7 @@ export function createProviderAuthContext() { const client = useClient(); const requestState = useSelector( (state) => - state.requests["initiateAccountLinking"] || { + state.requests.initiateAccountLinking || { isLoading: false, error: null, lastUpdated: null, @@ -113,7 +113,7 @@ export function createProviderAuthContext() { const client = useClient(); const requestState = useSelector( (state) => - state.requests["unlinkAccount"] || { + state.requests.unlinkAccount || { isLoading: false, error: null, lastUpdated: null, @@ -136,7 +136,9 @@ export function createProviderAuthContext() { } return { - Provider: context.Provider, + Provider: ({ client, children }: { client: ProviderAuthClient; children: React.ReactNode }) => ( + {children} + ), useClient, useSelector, LinkedAccounts, @@ -146,7 +148,3 @@ export function createProviderAuthContext() { UnlinkAccount, }; } - -function defaultCompare(a: T, b: T) { - return Object.is(a, b); -} diff --git a/src/provider-server.test.ts b/src/provider-server.test.ts index 64cbb8e..5f3aaf8 100644 --- a/src/provider-server.test.ts +++ b/src/provider-server.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { createProviderAuthRouter } from "./server"; import type { LinkedAccount, ProviderAuthHooks } from "./types"; @@ -16,23 +16,15 @@ vi.stubGlobal("crypto", { // Mock jose JWT functions vi.mock("jose", () => { return { - SignJWT: vi.fn().mockImplementation((payload) => { + SignJWT: vi.fn().mockImplementation((_payload) => { return { - setProtectedHeader: function () { - return { - setIssuedAt: function () { - return { - setExpirationTime: function () { - return { - sign: function () { - return Promise.resolve("mock-link-token"); - }, - }; - }, - }; - }, - }; - }, + setProtectedHeader: () => ({ + setIssuedAt: () => ({ + setExpirationTime: () => ({ + sign: () => Promise.resolve("mock-link-token"), + }), + }), + }), }; }), jwtVerify: vi.fn().mockImplementation((token) => { @@ -45,9 +37,8 @@ vi.mock("jose", () => { }, protectedHeader: { alg: "HS256" }, }); - } else { - return Promise.reject(new Error("Invalid token")); } + return Promise.reject(new Error("Invalid token")); }), }; }); @@ -62,7 +53,7 @@ const mockSign = (payload: Record) => { }; // Create a mock JWT token for testing -const createMockJWT = (payload: Record) => { +const _createMockJWT = (payload: Record) => { return mockSign(payload); }; @@ -77,15 +68,15 @@ function createMockProviderHooks(): ProviderAuthHooks { return null; }), - storeVerificationCode: vi.fn(async (email: string, code: string, expiresAt: Date) => { + storeVerificationCode: vi.fn(async (_email: string, _code: string, _expiresAt: Date) => { // Mock implementation }), - verifyVerificationCode: vi.fn(async (email: string, code: string) => { + verifyVerificationCode: vi.fn(async (_email: string, code: string) => { return code === "123456"; }), - sendVerificationCode: vi.fn(async (email: string, code: string) => { + sendVerificationCode: vi.fn(async (_email: string, _code: string) => { // Mock implementation }), @@ -97,11 +88,13 @@ function createMockProviderHooks(): ProviderAuthHooks { return null; }), - storeAccountLink: vi.fn(async (openGameUserId: string, gameId: string, gameUserId: string) => { - // Mock implementation - }), + storeAccountLink: vi.fn( + async (_openGameUserId: string, _gameId: string, _gameUserId: string) => { + // Mock implementation + } + ), - getLinkedAccounts: vi.fn(async (openGameUserId: string) => { + getLinkedAccounts: vi.fn(async (_openGameUserId: string) => { return [ { gameId: "test-game", @@ -111,7 +104,7 @@ function createMockProviderHooks(): ProviderAuthHooks { ] as LinkedAccount[]; }), - removeAccountLink: vi.fn(async (openGameUserId: string, gameId: string) => { + removeAccountLink: vi.fn(async (_openGameUserId: string, gameId: string) => { return gameId === "test-game"; }), }; @@ -382,9 +375,8 @@ describe("Provider Auth Router", () => { }, protectedHeader: { alg: "HS256" }, }); - } else { - return Promise.reject(new Error("Invalid token")); } + return Promise.reject(new Error("Invalid token")); }), }; }); @@ -473,16 +465,15 @@ describe("Provider Auth Router", () => { jwtVerify: vi.fn().mockImplementation((token) => { if (token === "invalid-token") { return Promise.reject(new Error("Invalid token")); - } else { - return Promise.resolve({ - payload: { - openGameUserId: "test-user-id", - email: "test@example.com", - type: "link", - }, - protectedHeader: { alg: "HS256" }, - }); } + return Promise.resolve({ + payload: { + openGameUserId: "test-user-id", + email: "test@example.com", + type: "link", + }, + protectedHeader: { alg: "HS256" }, + }); }), }; }); @@ -549,7 +540,7 @@ describe("Provider Auth Router", () => { it("should return 404 for non-existent gameId", async () => { // Mock removeAccountLink to return false for non-existent gameId - mockHooks.removeAccountLink = vi.fn(async (openGameUserId: string, gameId: string) => { + mockHooks.removeAccountLink = vi.fn(async (_openGameUserId: string, gameId: string) => { return gameId === "test-game"; }); diff --git a/src/react.test.tsx b/src/react.test.tsx index ac163e3..b5cf4ed 100644 --- a/src/react.test.tsx +++ b/src/react.test.tsx @@ -1,8 +1,8 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, act } from "@testing-library/react"; +import { act, render, screen } from "@testing-library/react"; +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { createAuthContext } from "./react"; import { createAuthMockClient } from "./test"; -import React from "react"; import type { AuthState } from "./types"; describe("Auth React Integration", () => { diff --git a/src/react.tsx b/src/react.tsx index 542cba6..fdc1b47 100644 --- a/src/react.tsx +++ b/src/react.tsx @@ -2,10 +2,8 @@ import React, { createContext, memo, ReactNode, - useCallback, useContext, useMemo, - useSyncExternalStore, useEffect, useRef, useState, @@ -102,7 +100,7 @@ function defaultCompare(a: T, b: T) { export function useSyncExternalStoreWithSelector( subscribe: (onStoreChange: () => void) => () => void, getSnapshot: () => Snapshot, - getServerSnapshot: undefined | null | (() => Snapshot), + _getServerSnapshot: undefined | null | (() => Snapshot), selector: (snapshot: Snapshot) => Selection, isEqual?: (a: Selection, b: Selection) => boolean ): Selection { diff --git a/src/server.test.ts b/src/server.test.ts index 6724251..568803e 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -1,17 +1,24 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { createAuthRouter, withAuth, AuthHooks } from "./server"; +import { AuthHooks, createAuthRouter, withAuth } from "./server"; -const REFRESH_TOKEN_COOKIE = "auth_refresh_token"; +const _REFRESH_TOKEN_COOKIE = "auth_refresh_token"; + +// Extend the NodeJS.Global interface to include our test cookie value +declare global { + // Using var is required for global augmentation in TypeScript + // biome-ignore lint/style/noVar: Required for global augmentation + var __testCookieValue__: string | undefined; +} // Reset UUID counter before each test beforeEach(() => { - uuidCounter = 0; + _uuidCounter = 0; }); // Mock crypto for UUID generation -let uuidCounter = 0; +let _uuidCounter = 0; vi.stubGlobal("crypto", { - randomUUID: () => `test-uuid-1`, // Always return test-uuid-1 for consistent testing + randomUUID: () => "test-uuid-1", // Always return test-uuid-1 for consistent testing }); // Mock jose JWT functions @@ -106,7 +113,7 @@ function createMockHooks(): AuthHooks<{ AUTH_SECRET: string }> { return { getUserIdByEmail: vi.fn().mockResolvedValue(null), storeVerificationCode: vi.fn().mockResolvedValue(undefined), - verifyVerificationCode: vi.fn().mockImplementation(async (email, code) => { + verifyVerificationCode: vi.fn().mockImplementation(async (_email, code) => { // For tests, accept '123456' as valid code for any email return code === "123456"; }), @@ -123,7 +130,7 @@ describe("Auth Router", () => { return email === "test@example.com" ? "test-user" : null; }); const storeVerificationCode = vi.fn(); - const verifyVerificationCode = vi.fn().mockImplementation(async (email, code) => { + const verifyVerificationCode = vi.fn().mockImplementation(async (_email, code) => { // For tests, accept '123456' as valid code for any email return code === "123456"; }); @@ -382,7 +389,9 @@ describe("Auth Router", () => { }); beforeEach(() => { - Object.values(baseHooks).forEach((mock) => mock.mockClear?.()); + for (const mock of Object.values(baseHooks)) { + mock.mockClear?.(); + } }); it("should generate web auth code with valid session token", async () => { @@ -498,8 +507,8 @@ describe("Auth Middleware", () => { const originalHeadersGet = Headers.prototype.get; beforeAll(() => { Headers.prototype.get = function (key: string) { - if (key.toLowerCase() === "cookie" && (global as any).__testCookieValue__) { - return (global as any).__testCookieValue__; + if (key.toLowerCase() === "cookie" && global.__testCookieValue__) { + return global.__testCookieValue__; } return originalHeadersGet.call(this, key); }; @@ -526,7 +535,7 @@ describe("Auth Middleware", () => { }); beforeEach(() => { - (global as any).__testCookieValue__ = undefined; + global.__testCookieValue__ = undefined; mockHandler.mockClear(); }); @@ -548,7 +557,7 @@ describe("Auth Middleware", () => { }); it("should use existing session if valid", async () => { - (global as any).__testCookieValue__ = "auth_session_token=valid-session-token"; + global.__testCookieValue__ = "auth_session_token=valid-session-token"; const request = new Request("http://localhost/"); await middleware(request, mockEnv); @@ -561,7 +570,7 @@ describe("Auth Middleware", () => { }); it("should refresh session if expired but has valid refresh token", async () => { - (global as any).__testCookieValue__ = + global.__testCookieValue__ = "auth_session_token=invalid-token; auth_refresh_token=valid-refresh-token"; const request = new Request("http://localhost/"); @@ -580,7 +589,7 @@ describe("Auth Middleware", () => { }); it("should create new anonymous user if all tokens are invalid", async () => { - (global as any).__testCookieValue__ = + global.__testCookieValue__ = "auth_session_token=invalid-token; auth_refresh_token=invalid-token"; const request = new Request("http://localhost/"); @@ -642,11 +651,13 @@ describe("Auth Middleware", () => { }); beforeEach(() => { - Object.values(baseHooks).forEach((mock) => mock.mockClear?.()); + for (const mock of Object.values(baseHooks)) { + mock.mockClear?.(); + } }); it("should handle valid web auth code and maintain user identity", async () => { - const request = new Request(`http://localhost/?code=test-web-code`); + const request = new Request("http://localhost/?code=test-web-code"); const response = await middlewareWithWebHooks(request, mockEnv); // Should redirect to remove code from URL @@ -661,7 +672,7 @@ describe("Auth Middleware", () => { }); it("should preserve other query parameters when redirecting", async () => { - const request = new Request(`http://localhost/?code=test-web-code&other=param`); + const request = new Request("http://localhost/?code=test-web-code&other=param"); const response = await middlewareWithWebHooks(request, mockEnv); expect(response.status).toBe(302); @@ -669,7 +680,7 @@ describe("Auth Middleware", () => { }); it("should handle web auth code on any path", async () => { - const request = new Request(`http://localhost/some/path?code=test-web-code`); + const request = new Request("http://localhost/some/path?code=test-web-code"); const response = await middlewareWithWebHooks(request, mockEnv); expect(response.status).toBe(302); @@ -677,7 +688,7 @@ describe("Auth Middleware", () => { }); it("should fall back to anonymous user if web auth code is invalid", async () => { - const request = new Request(`http://localhost/?code=invalid-token`); + const request = new Request("http://localhost/?code=invalid-token"); const response = await middlewareWithWebHooks(request, mockEnv); // Should proceed with normal auth flow (creating anonymous user) @@ -797,7 +808,7 @@ describe("Cookie Domain Option", () => { it("should set cookies with top-level domain in withAuth middleware when useTopLevelDomain is true", async () => { const mockHooks = createMockHooks(); const handler = withAuth( - async (request, env, { userId }) => { + async (_request, _env, { userId: _userId }) => { return new Response("OK"); }, { diff --git a/src/server.ts b/src/server.ts index 4975ca6..0701c48 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,5 +1,5 @@ import { SignJWT, jwtVerify } from "jose"; -import type { AuthHooks, ProviderAuthHooks, ConsumerAuthHooks } from "./types"; +import type { AuthHooks, ConsumerAuthHooks, ProviderAuthHooks } from "./types"; const SESSION_TOKEN_COOKIE = "auth_session_token"; const REFRESH_TOKEN_COOKIE = "auth_refresh_token"; @@ -14,7 +14,7 @@ interface TokenPayload { async function createSessionToken( userId: string, secret: string, - expiresIn: string = "15m", + expiresIn = "15m", email?: string ): Promise { const sessionId = crypto.randomUUID(); @@ -38,8 +38,8 @@ async function createSessionToken( async function createRefreshToken( userId: string, secret: string, - expiresIn: string = "7d", - isTransient: boolean = false + expiresIn = "7d", + isTransient = false ): Promise { return await new SignJWT({ userId }) .setProtectedHeader({ alg: "HS256" }) @@ -66,7 +66,7 @@ async function verifyToken(token: string, secret: string): Promise 1) { // Get the top-level domain with one subdomain level // For example: from "api.example.com" get ".example.com" - const domain = "." + parts.slice(-2).join("."); + const domain = `.${parts.slice(-2).join(".")}`; cookieString += `; Domain=${domain}`; } } @@ -354,7 +354,7 @@ export function createAuthRouter(config: { const cookieRefreshToken = getCookie(request, REFRESH_TOKEN_COOKIE); // Try Authorization header first (for JS/RN clients), then cookie - let refreshToken = authHeader?.startsWith("Bearer ") + const refreshToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : cookieRefreshToken; @@ -497,7 +497,7 @@ export function createAuthRouter(config: { default: return new Response("Not found", { status: 404 }); } - } catch (error) { + } catch (_error) { return new Response(JSON.stringify({ error: "Internal server error" }), { status: 500, headers: { "Content-Type": "application/json" }, @@ -542,7 +542,7 @@ export function withAuth( } // Create new session for the web client - const sessionId = crypto.randomUUID(); + const _sessionId = crypto.randomUUID(); // Use email from the web auth code if available const newSessionToken = await createSessionToken( @@ -707,7 +707,7 @@ function verifySession(request: Request): { userId: string } | null { const authHeader = request.headers.get("Authorization"); let token: string | undefined; - if (authHeader && authHeader.startsWith("Bearer ")) { + if (authHeader?.startsWith("Bearer ")) { token = authHeader.split(" ")[1]; } @@ -731,7 +731,7 @@ function verifySession(request: Request): { userId: string } | null { // This is a simplified example - in production, you should properly verify the token const payload = JSON.parse(atob(token.split(".")[1])); return { userId: payload.sub || payload.userId }; - } catch (error) { + } catch (_error) { return null; } } @@ -872,7 +872,7 @@ export function createProviderAuthRouter(hooks: ProviderAuthHooks) { headers: { "Content-Type": "application/json" }, } ); - } catch (error) { + } catch (_error) { return new Response(JSON.stringify({ valid: false }), { status: 200, headers: { "Content-Type": "application/json" }, @@ -939,7 +939,7 @@ export function createProviderAuthRouter(hooks: ProviderAuthHooks) { status: 200, headers: { "Content-Type": "application/json" }, }); - } catch (error) { + } catch (_error) { return new Response(JSON.stringify({ error: "Invalid token" }), { status: 400, headers: { "Content-Type": "application/json" }, @@ -1104,7 +1104,7 @@ export function createConsumerAuthRouter(hooks: ConsumerAuthHooks) { headers: { "Content-Type": "application/json" }, } ); - } catch (error) { + } catch (_error) { return new Response(JSON.stringify({ valid: false }), { status: 200, headers: { "Content-Type": "application/json" }, @@ -1165,7 +1165,7 @@ export function createConsumerAuthRouter(hooks: ConsumerAuthHooks) { status: 200, headers: { "Content-Type": "application/json" }, }); - } catch (error) { + } catch (_error) { return new Response(JSON.stringify({ error: "Invalid token" }), { status: 400, headers: { "Content-Type": "application/json" }, diff --git a/src/test.ts b/src/test.ts index b9f63ac..c5de468 100644 --- a/src/test.ts +++ b/src/test.ts @@ -1,11 +1,10 @@ import { AuthClient, AuthState, - ProviderAuthClient, - ProviderAuthState, ConsumerAuthClient, ConsumerAuthState, - LinkedAccount, + ProviderAuthClient, + ProviderAuthState, } from "./types"; /** @@ -40,7 +39,9 @@ export function createAuthMockClient(config: { const newState = { ...state }; recipe(newState); state = newState; - subscribers.forEach((callback) => callback(state)); + for (const callback of subscribers) { + callback(state); + } }; // Mock client @@ -58,7 +59,7 @@ export function createAuthMockClient(config: { } }; }, - async requestCode(email: string) { + async requestCode(_email: string) { produce((draft) => { draft.isLoading = true; draft.error = null; @@ -86,13 +87,12 @@ export function createAuthMockClient(config: { draft.email = email; }); return { success: true }; - } else { - produce((draft) => { - draft.isLoading = false; - draft.error = "Invalid verification code"; - }); - return { success: false }; } + produce((draft) => { + draft.isLoading = false; + draft.error = "Invalid verification code"; + }); + return { success: false }; }, async logout() { produce((draft) => { @@ -175,7 +175,9 @@ export function createProviderAuthMockClient(config: { const newState = { ...state }; recipe(newState); state = newState; - subscribers.forEach((callback) => callback(state)); + for (const callback of subscribers) { + callback(state); + } }; // Create base client @@ -326,7 +328,9 @@ export function createConsumerAuthMockClient(config: { const newState = { ...state }; recipe(newState); state = newState; - subscribers.forEach((callback) => callback(state)); + for (const callback of subscribers) { + callback(state); + } }; // Create base client @@ -383,9 +387,8 @@ export function createConsumerAuthMockClient(config: { linkedAt: state.openGameLink.linkedAt, profile: state.openGameLink.profile, }; - } else { - return { isLinked: false }; } + return { isLinked: false }; }, async verifyLinkToken(token: string) { produce((draft) => { @@ -420,11 +423,10 @@ export function createConsumerAuthMockClient(config: { openGameUserId: "og-user-123", email: "user@example.com", }; - } else { - return { valid: false }; } + return { valid: false }; }, - async confirmLink(token: string, gameUserId: string) { + async confirmLink(token: string, _gameUserId: string) { produce((draft) => { draft.requests = { ...draft.requests, @@ -458,20 +460,19 @@ export function createConsumerAuthMockClient(config: { }); return true; - } else { - produce((draft) => { - draft.requests = { - ...draft.requests, - confirmLink: { - isLoading: false, - error: "Invalid token", - lastUpdated: new Date().toISOString(), - }, - }; - }); - - throw new Error("Invalid token"); } + produce((draft) => { + draft.requests = { + ...draft.requests, + confirmLink: { + isLoading: false, + error: "Invalid token", + lastUpdated: new Date().toISOString(), + }, + }; + }); + + throw new Error("Invalid token"); }, produce, }; diff --git a/src/test/setup.ts b/src/test/setup.ts index 0c0adc6..089fce0 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1,6 +1,6 @@ -import { afterAll, afterEach, beforeAll } from "vitest"; -import { setupServer } from "msw/node"; import "@testing-library/jest-dom"; +import { setupServer } from "msw/node"; +import { afterAll, afterEach, beforeAll } from "vitest"; // Create a pristine server instance for each test file export const server = setupServer(); diff --git a/src/types.ts b/src/types.ts index c1a3041..32b5934 100644 --- a/src/types.ts +++ b/src/types.ts @@ -32,7 +32,7 @@ export interface AuthClient { } // Base auth hooks interface -export interface AuthHooks { +export interface AuthHooks<_TEnv = unknown> { // Base auth hooks (required) getUserIdByEmail(email: string): Promise; storeVerificationCode(email: string, code: string, expiresAt: Date): Promise; From 5587b5bc2eeebd3d5f88afae1858b0f98685943d Mon Sep 17 00:00:00 2001 From: Jonathan Mumm Date: Thu, 6 Mar 2025 10:25:08 -0800 Subject: [PATCH 05/10] linting --- .biomeignore | 1 + biome.json | 6 ++++-- package.json | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) create mode 100644 .biomeignore diff --git a/.biomeignore b/.biomeignore new file mode 100644 index 0000000..849ddff --- /dev/null +++ b/.biomeignore @@ -0,0 +1 @@ +dist/ diff --git a/biome.json b/biome.json index b5dab4c..d5dcb5c 100644 --- a/biome.json +++ b/biome.json @@ -21,14 +21,16 @@ "correctness": { "noUnusedVariables": "error" } - } + }, + "ignore": ["dist/**"] }, "formatter": { "enabled": true, "formatWithErrors": false, "indentStyle": "space", "indentWidth": 2, - "lineWidth": 100 + "lineWidth": 100, + "ignore": ["dist/**"] }, "javascript": { "formatter": { diff --git a/package.json b/package.json index 4b0213f..65440ee 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "format": "biome format --write .", "lint": "biome check .", "lint:fix": "biome check --apply .", - "ci": "npm run typecheck && npm run lint && npm run test" + "ci": "npm run lint && npm run typecheck && npm run test" }, "author": "jonmumm", "license": "MIT", From 3fd8b81aa47f55497299b33d17f95f8338660b6b Mon Sep 17 00:00:00 2001 From: Jonathan Mumm Date: Fri, 7 Mar 2025 06:11:35 -0800 Subject: [PATCH 06/10] linting and ts fixes --- package-lock.json | 4 ++-- package.json | 2 +- src/consumer-react.test.tsx | 6 ++++-- src/consumer-server.test.ts | 9 ++++++--- src/provider-server.test.ts | 3 ++- tsconfig.json | 2 +- 6 files changed, 16 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2468880..bb946fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@open-game-collective/auth-kit", - "version": "0.0.8", + "version": "0.0.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@open-game-collective/auth-kit", - "version": "0.0.8", + "version": "0.0.12", "license": "MIT", "dependencies": { "jose": "^5.8.0" diff --git a/package.json b/package.json index 65440ee..5fd54f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@open-game-collective/auth-kit", - "version": "0.0.11", + "version": "0.0.12", "publishConfig": { "access": "public" }, diff --git a/src/consumer-react.test.tsx b/src/consumer-react.test.tsx index a7a4323..67851b1 100644 --- a/src/consumer-react.test.tsx +++ b/src/consumer-react.test.tsx @@ -175,8 +175,10 @@ describe("Consumer Auth Context", () => {
{isLoading ? "Loading" : "Not Loading"}
{error || "No Error"}
-
{profile?.displayName || "No Name"}
-
{profile?.avatarUrl || "No Avatar"}
+
+ {(profile?.displayName as string) ?? "No Name"} +
+
{(profile?.avatarUrl as string) ?? "No Avatar"}
)} /> diff --git a/src/consumer-server.test.ts b/src/consumer-server.test.ts index da801fa..7af59ca 100644 --- a/src/consumer-server.test.ts +++ b/src/consumer-server.test.ts @@ -197,7 +197,8 @@ describe("Consumer Auth Router", () => { type: "link", }, protectedHeader: { alg: "HS256" }, - } as unknown); + key: new TextEncoder().encode("test-key") as unknown as jose.KeyLike, + } as jose.JWTVerifyResult & jose.ResolvedKey); const request = new Request("https://example.com/auth/verify-link", { method: "POST", @@ -297,7 +298,8 @@ describe("Consumer Auth Router", () => { type: "link", }, protectedHeader: { alg: "HS256" }, - } as unknown); + key: new TextEncoder().encode("test-key") as unknown as jose.KeyLike, + } as jose.JWTVerifyResult & jose.ResolvedKey); const request = new Request("https://example.com/auth/confirm-link", { method: "POST", @@ -389,7 +391,8 @@ describe("Consumer Auth Router", () => { type: "link", }, protectedHeader: { alg: "HS256" }, - } as unknown); + key: new TextEncoder().encode("test-key") as unknown as jose.KeyLike, + } as jose.JWTVerifyResult & jose.ResolvedKey); // Mock storeOpenGameLink to return false mockHooks.storeOpenGameLink = vi.fn().mockImplementation(async () => { diff --git a/src/provider-server.test.ts b/src/provider-server.test.ts index 5f3aaf8..4277e4b 100644 --- a/src/provider-server.test.ts +++ b/src/provider-server.test.ts @@ -1,3 +1,4 @@ +import { SignJWT } from "jose"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createProviderAuthRouter } from "./server"; import type { LinkedAccount, ProviderAuthHooks } from "./types"; @@ -572,7 +573,7 @@ describe("Provider Auth Router", () => { // Mock the createLinkToken function vi.mock("./server", async (importOriginal) => { - const originalModule = await importOriginal(); + const originalModule = (await importOriginal()) as Record; return { ...originalModule, createLinkToken: vi.fn().mockResolvedValue("mock-link-token"), diff --git a/tsconfig.json b/tsconfig.json index 6b40e3f..a1330b5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,5 +19,5 @@ "preserveSymlinks": true }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"] + "exclude": ["node_modules", "dist"] } \ No newline at end of file From 3aa954a707765e3d493c349547ba0bec6b305086 Mon Sep 17 00:00:00 2001 From: Jonathan Mumm Date: Fri, 7 Mar 2025 14:55:17 -0800 Subject: [PATCH 07/10] api updates --- README.md | 486 +++++++++++++++++++++++++++++++++++- src/consumer-react.test.tsx | 6 +- src/consumer-react.tsx | 18 +- src/provider-react.test.tsx | 6 +- src/provider-react.tsx | 18 +- 5 files changed, 508 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 8639786..0bfd0eb 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,12 @@ A headless, isomorphic authentication toolkit that runs seamlessly across server - [Installation](#installation) - [Key Features](#key-features) +- [Account Linking](#account-linking) + - [Provider-Consumer Model](#provider-consumer-model) + - [Account Linking Flow](#account-linking-flow) + - [Implementation](#implementation) + - [Security Considerations](#security-considerations) + - [Benefits](#benefits) - [Authentication Flow](#authentication-flow) - [Usage Guide](#usage-guide) - [1️⃣ Set up Environment and Server](#1️⃣-set-up-environment-and-server) @@ -15,6 +21,8 @@ A headless, isomorphic authentication toolkit that runs seamlessly across server - [Architecture](#architecture) - [API Reference](#api-reference) - [Client API](#client-api) + - [Provider Client API](#provider-client-api) + - [Consumer Client API](#consumer-client-api) - [Server API](#server-api) - [React API](#react-api) - [Test API](#test-api) @@ -44,6 +52,239 @@ pnpm add @open-game-collective/auth-kit - 🎨 **React Integration**: Ready-to-use hooks and components for auth state management. - 🔌 **Customizable**: Integrate with your own storage, email delivery systems, and UI components. - 📱 **Platform Agnostic**: Same API and behavior across web and mobile platforms. +- 🔗 **Account Linking**: Securely link user accounts across different applications to enable cross-application features like push notifications. + +## Account Linking + +Auth Kit provides a robust account linking system that allows applications to connect user accounts across the Open Game ecosystem. This enables rich cross-application features such as push notifications, achievement sharing, and synchronized experiences, while maintaining each application's independent authentication system. + +### Provider-Consumer Model + +Account linking follows a provider-consumer model: + +- **Provider** (e.g., OpenGame): The central identity provider that manages user accounts +- **Consumer** (e.g., Game applications): Applications that integrate with the provider for feature sharing + +Account linking is not about authentication delegation, but rather about enabling cross-application features such as: + +- Push notifications from the provider app for events in consumer apps +- Profile and achievement sharing across applications +- Synchronized preferences and settings +- Cross-application rewards and progression +- Unified social features and friend connections + +Each application maintains its own authentication system, but linking accounts allows for a richer, connected user experience across the ecosystem. + +### Account Linking Flow + +The following diagram illustrates how accounts are linked between the provider (OpenGame) and consumer applications (games), enabling cross-application features while maintaining separate authentication systems: + +```mermaid +sequenceDiagram + participant User + participant OGApp as Provider App + participant GameApp as Consumer App + participant ProviderAuth as Provider Auth API + participant ConsumerAuth as Consumer Auth API + + User->>OGApp: Initiates account linking + OGApp->>ProviderAuth: Requests link token + ProviderAuth->>OGApp: Returns link token + OGApp->>User: Displays link URL/QR code + User->>GameApp: Opens link URL + GameApp->>ConsumerAuth: Verifies link token + ConsumerAuth->>ProviderAuth: Validates token + ProviderAuth->>ConsumerAuth: Confirms token validity + GameApp->>User: Requests confirmation + User->>GameApp: Confirms linking + GameApp->>ConsumerAuth: Confirms link + ConsumerAuth->>ProviderAuth: Stores account link + ProviderAuth->>ConsumerAuth: Confirms success + GameApp->>User: Shows success message + Note over User, ConsumerAuth: After linking, cross-app features are enabled +``` + +Once accounts are linked, the provider application can send push notifications about events in the consumer application, share profile information between applications, and enable other cross-application features - all while each application maintains its own independent authentication system. + +### Implementation + +Auth Kit provides specialized APIs for both providers and consumers: + +#### Provider Implementation + +```typescript +// Server-side setup +import { createProviderAuthRouter } from "@open-game-collective/auth-kit/provider/server"; + +const providerRouter = createProviderAuthRouter({ + hooks: { + // Base auth hooks + getUserIdByEmail: async ({ email, env }) => { /* ... */ }, + // ... other base hooks + + // Provider-specific hooks + getGameIdFromApiKey: async ({ apiKey, env }) => { /* ... */ }, + storeAccountLink: async ({ openGameUserId, gameId, gameUserId, env }) => { /* ... */ }, + getLinkedAccounts: async ({ openGameUserId, env }) => { /* ... */ }, + removeAccountLink: async ({ openGameUserId, gameId, env }) => { /* ... */ }, + } +}); + +// Client-side implementation +import { createProviderAuthClient } from "@open-game-collective/auth-kit/provider"; +import { createProviderAuthContext } from "@open-game-collective/auth-kit/provider/react"; + +const ProviderAuthContext = createProviderAuthContext(); +const providerClient = createProviderAuthClient({ + host: "your-api.example.com", + userId: "provider-123", + sessionToken: "jwt-token" +}); + +function AccountLinkingUI() { + return ( + + +

Your Linked Accounts

+ + + {({ accounts, isLoading, error }) => ( +
+ {isLoading ? : ( +
    + {accounts.map(account => ( +
  • + {account.gameName} - {account.gameUserId} + + {({ onUnlink, isUnlinking, error }) => ( + + )} + +
  • + ))} +
+ )} + {error &&
{error}
} +
+ )} +
+
+ + + {({ onInitiate, isInitiating, error }) => ( + + )} + +
+ ); +} +``` + +#### Consumer Implementation + +```typescript +// Server-side setup +import { createConsumerAuthRouter } from "@open-game-collective/auth-kit/consumer/server"; + +const consumerRouter = createConsumerAuthRouter({ + hooks: { + // Base auth hooks + getUserIdByEmail: async ({ email, env }) => { /* ... */ }, + // ... other base hooks + + // Consumer-specific hooks + storeOpenGameLink: async ({ gameUserId, openGameUserId, env }) => { /* ... */ }, + getOpenGameUserId: async ({ gameUserId, env }) => { /* ... */ }, + getOpenGameProfile: async ({ openGameUserId, env }) => { /* ... */ }, + }, + gameId: "your-game-id" // Required for consumer router +}); + +// Client-side implementation +import { createConsumerAuthClient } from "@open-game-collective/auth-kit/consumer"; +import { createConsumerAuthContext } from "@open-game-collective/auth-kit/consumer/react"; + +const ConsumerAuthContext = createConsumerAuthContext(); +const consumerClient = createConsumerAuthClient({ + host: "your-api.example.com", + userId: "game-user-123", + sessionToken: "jwt-token" +}); + +function LinkVerificationUI({ linkToken }) { + return ( + + + {({ isVerifying, isValid, openGameUserId, email, error }) => ( +
+ {isVerifying ? ( +

Verifying link...

+ ) : isValid ? ( + + {({ onConfirm, isConfirming, isConfirmed, error }) => ( +
+

Link your account with {email}?

+ +
+ )} +
+ ) : ( +

Invalid or expired link token

+ )} +
+ )} +
+
+ ); +} +``` + +### Security Considerations + +The account linking system includes several security features to ensure secure cross-application communication: + +1. **JWT-Based Link Tokens**: Cryptographically signed tokens with short expiration times ensure secure linking process +2. **API Key Authentication**: Server-to-server communication secured with API keys for trusted application verification +3. **User Confirmation**: Explicit user consent required before enabling cross-application features +4. **Secure Storage**: Account links stored securely on both provider and consumer sides +5. **Unlinking Capability**: Users can disable cross-application features by unlinking accounts at any time +6. **Limited Data Sharing**: Only necessary data is shared between applications, with clear user consent +7. **Independent Authentication**: Each application maintains its own authentication system, with no credential sharing + +### Benefits + +- **Connected Ecosystem**: Enable rich interactions between different applications in the ecosystem +- **Enhanced User Experience**: Provide seamless cross-application features without requiring users to manually connect accounts +- **Push Notifications**: Allow the provider app to send notifications about events in linked consumer apps +- **Feature Sharing**: Share profiles, achievements, and other data across applications with user consent +- **Independent Authentication**: Each application maintains its own authentication while still enabling connected experiences +- **Secure Communication**: All communication between applications is secured with API keys and JWT tokens +- **User Control**: Users can link and unlink accounts at any time, maintaining control over their connected experience + +For detailed implementation guidance, see the [Account Linking Implementation Guide](docs/account-linking.md). ## Authentication Flow @@ -621,7 +862,17 @@ export default function App() { - + + + + + + + + + + + @@ -1295,6 +1546,55 @@ interface AuthClient { // expiresIn: Expiration time in seconds (e.g. 300 for 5 minutes) ``` +### Provider Client API + +The provider client extends the base client with methods for managing linked accounts: + +```typescript +interface ProviderAuthClient extends AuthClient { + getLinkedAccounts(): Promise; + initiateAccountLinking(gameId: string): Promise<{ linkToken: string; expiresAt: string }>; + unlinkAccount(gameId: string): Promise; + getState(): ProviderAuthState; + subscribe(callback: (state: ProviderAuthState) => void): () => void; +} +``` + +**Provider-Specific Methods:** + +- `getLinkedAccounts()`: Get list of accounts linked to the provider account +- `initiateAccountLinking(gameId)`: Generate a link token for a specific game +- `unlinkAccount(gameId)`: Remove link between provider account and game account + +### Consumer Client API + +The consumer client extends the base client with methods for managing links with the provider: + +```typescript +interface ConsumerAuthClient extends AuthClient { + getOpenGameLinkStatus(): Promise<{ + isLinked: boolean; + openGameUserId?: string; + linkedAt?: string; + profile?: Record; + }>; + verifyLinkToken(token: string): Promise<{ + valid: boolean; + openGameUserId?: string; + email?: string; + }>; + confirmLink(token: string, gameUserId: string): Promise; + getState(): ConsumerAuthState; + subscribe(callback: (state: ConsumerAuthState) => void): () => void; +} +``` + +**Consumer-Specific Methods:** + +- `getOpenGameLinkStatus()`: Check if the consumer account is linked with a provider account +- `verifyLinkToken(token)`: Verify a link token from a provider +- `confirmLink(token, gameUserId)`: Confirm linking between consumer and provider accounts + ### Server API The server provides two main exports: @@ -1367,6 +1667,188 @@ Creates a React context for auth state management, providing: - Hooks: `useClient` and `useSelector` for accessing and subscribing to state. - Conditional components: ``, ``, ``, and ``. +#### Provider API + +`createProviderAuthContext()` + +Creates a React context specifically for provider authentication, providing: +- A Provider for passing down the provider auth client. +- Hooks: `useClient` and `useSelector` for accessing and subscribing to provider state. +- Components for managing linked accounts: + - ``: Renders children when user has linked accounts + - ``: Renders children when user has no linked accounts + - ``: Renders a function child with linked accounts data + - ``: Renders a function child with account linking functionality + - ``: Renders a function child with account unlinking functionality + +Example: +```tsx +import { createProviderAuthContext } from "@open-game-collective/auth-kit/provider-react"; +import { createProviderAuthClient } from "@open-game-collective/auth-kit/provider-client"; + +const ProviderAuthContext = createProviderAuthContext(); +const providerClient = createProviderAuthClient({ + host: "your-api.example.com", + userId: "provider-123", + sessionToken: "jwt-token" +}); + +function App() { + return ( + + {/* Show when user has linked accounts */} + +

Your Linked Accounts

+ + + {({ accounts, isLoading, error }) => ( +
+ {isLoading ? : ( +
    + {accounts.map(account => ( +
  • + {account.gameName} - {account.gameUserId} + + {({ onUnlink, isUnlinking, error }) => ( + + )} + +
  • + ))} +
+ )} + {error &&
{error}
} +
+ )} +
+
+ + {/* Show when user has no linked accounts */} + +

Link Your First Account

+ + + {({ onInitiate, isInitiating, error }) => ( +
+ + {error &&
{error}
} +
+ )} +
+
+
+ ); +} +``` + +#### Consumer API + +`createConsumerAuthContext()` + +Creates a React context specifically for consumer (game) authentication, providing: +- A Provider for passing down the consumer auth client. +- Hooks: `useClient` and `useSelector` for accessing and subscribing to consumer state. +- Components for managing open game linking: + - ``: Renders children when user is linked with an open game + - ``: Renders children when user is not linked with an open game + - ``: Renders a function child with open game profile data + - ``: Renders a function child with link token verification functionality + - ``: Renders a function child with link confirmation functionality + +Example: +```tsx +import { createConsumerAuthContext } from "@open-game-collective/auth-kit/consumer-react"; +import { createConsumerAuthClient } from "@open-game-collective/auth-kit/consumer-client"; + +const ConsumerAuthContext = createConsumerAuthContext(); +const consumerClient = createConsumerAuthClient({ + host: "your-api.example.com", + userId: "game-user-123", + sessionToken: "jwt-token" +}); + +function App() { + return ( + + {/* Show when user is linked with open game */} + +

Your Open Game Account

+ + + {({ profile, isLoading, error }) => ( +
+ {isLoading ? : ( +
+

{profile?.displayName || "Anonymous"}

+ {profile?.avatarUrl && ( + Profile + )} +
+ )} + {error &&
{error}
} +
+ )} +
+
+ + {/* Show when user is not linked with open game */} + +

Link Your Open Game Account

+ + {/* When user has a link token */} + {linkToken && ( + + {({ isVerifying, isValid, openGameUserId, email, error }) => ( +
+ {isVerifying ? : ( + isValid ? ( + + {({ onConfirm, isConfirming, isConfirmed, error }) => ( +
+

Link with {email}?

+ + {error &&
{error}
} +
+ )} +
+ ) : ( +
Invalid or expired link token
+ ) + )} + {error &&
{error}
} +
+ )} +
+ )} +
+
+ ); +} +``` + ### Test API `createAuthMockClient(config)` @@ -1460,4 +1942,4 @@ const authRouter = createAuthRouter({ }); ``` -Note: When using `useTopLevelDomain`, the domain is automatically derived from the request. For localhost and IP addresses, no domain attribute is set on cookies. \ No newline at end of file +Note: When using `useTopLevelDomain`, the domain is automatically derived from the request. For localhost and IP addresses, no domain attribute is set on cookies. diff --git a/src/consumer-react.test.tsx b/src/consumer-react.test.tsx index 67851b1..5bd4987 100644 --- a/src/consumer-react.test.tsx +++ b/src/consumer-react.test.tsx @@ -170,8 +170,8 @@ describe("Consumer Auth Context", () => { render( - ( + + {({ profile, isLoading, error }) => (
{isLoading ? "Loading" : "Not Loading"}
{error || "No Error"}
@@ -181,7 +181,7 @@ describe("Consumer Auth Context", () => {
{(profile?.avatarUrl as string) ?? "No Avatar"}
)} - /> +
); diff --git a/src/consumer-react.tsx b/src/consumer-react.tsx index ba0e355..7df9747 100644 --- a/src/consumer-react.tsx +++ b/src/consumer-react.tsx @@ -34,9 +34,9 @@ export function createConsumerAuthContext() { } function OpenGameProfile({ - render, + children, }: { - render: (props: { + children: (props: { profile: Record | undefined; isLoading: boolean; error: string | null; @@ -54,7 +54,7 @@ export function createConsumerAuthContext() { return ( <> - {render({ + {children({ profile: openGameLink?.profile, isLoading: requestState.isLoading, error: requestState.error, @@ -65,10 +65,10 @@ export function createConsumerAuthContext() { function VerifyLinkToken({ token, - render, + children, }: { token: string; - render: (props: { + children: (props: { isVerifying: boolean; isValid: boolean; openGameUserId?: string; @@ -122,17 +122,17 @@ export function createConsumerAuthContext() { verifyToken(); }, [client, token]); - return <>{render(state)}; + return <>{children(state)}; } function ConfirmLink({ token, gameUserId, - render, + children, }: { token: string; gameUserId: string; - render: (props: { + children: (props: { onConfirm: () => Promise; isConfirming: boolean; isConfirmed: boolean; @@ -174,7 +174,7 @@ export function createConsumerAuthContext() { return ( <> - {render({ + {children({ onConfirm, isConfirming: state.isConfirming, isConfirmed: state.isConfirmed, diff --git a/src/provider-react.test.tsx b/src/provider-react.test.tsx index feef20b..f39ddd6 100644 --- a/src/provider-react.test.tsx +++ b/src/provider-react.test.tsx @@ -181,8 +181,8 @@ describe("Provider Auth Context", () => { render( - ( + + {({ accounts, isLoading, error }) => (
{isLoading ? "Loading" : "Not Loading"}
{error || "No Error"}
@@ -194,7 +194,7 @@ describe("Provider Auth Context", () => { ))}
)} - /> +
); diff --git a/src/provider-react.tsx b/src/provider-react.tsx index feafcad..e29848a 100644 --- a/src/provider-react.tsx +++ b/src/provider-react.tsx @@ -34,9 +34,9 @@ export function createProviderAuthContext() { } function LinkedAccountsList({ - render, + children, }: { - render: (props: { + children: (props: { accounts: LinkedAccount[]; isLoading: boolean; error: string | null; @@ -54,7 +54,7 @@ export function createProviderAuthContext() { return ( <> - {render({ + {children({ accounts, isLoading: requestState.isLoading, error: requestState.error, @@ -65,10 +65,10 @@ export function createProviderAuthContext() { function InitiateLinking({ gameId, - render, + children, }: { gameId: string; - render: (props: { + children: (props: { onInitiate: () => Promise<{ linkToken: string; expiresAt: string }>; isInitiating: boolean; error: string | null; @@ -90,7 +90,7 @@ export function createProviderAuthContext() { return ( <> - {render({ + {children({ onInitiate, isInitiating: requestState.isLoading, error: requestState.error, @@ -101,10 +101,10 @@ export function createProviderAuthContext() { function UnlinkAccount({ gameId, - render, + children, }: { gameId: string; - render: (props: { + children: (props: { onUnlink: () => Promise; isUnlinking: boolean; error: string | null; @@ -126,7 +126,7 @@ export function createProviderAuthContext() { return ( <> - {render({ + {children({ onUnlink, isUnlinking: requestState.isLoading, error: requestState.error, From a1d8594baf583fceea949ad9c336042d40fc971d Mon Sep 17 00:00:00 2001 From: Jonathan Mumm Date: Fri, 7 Mar 2025 15:25:02 -0800 Subject: [PATCH 08/10] readm eupdates --- README.md | 1624 +++++++++++++---------------------------------------- 1 file changed, 376 insertions(+), 1248 deletions(-) diff --git a/README.md b/README.md index 0bfd0eb..dcca9b2 100644 --- a/README.md +++ b/README.md @@ -607,869 +607,295 @@ In this deployment: The Auth middleware handles all authentication routes and token management, integrated with your web application. ```typescript -// auth-server.ts -import { AuthHooks, withAuth, createAuthRouter } from "@open-game-collective/auth-kit/server"; +// auth-worker.ts +import { AuthHooks, createAuthRouter, Router } from "@open-game-collective/auth-kit/server"; import { Env } from "./env"; +import { WorkerEntrypoint } from "@cloudflare/workers-types"; -// Define your auth hooks - these connect to your storage and email systems -const authHooks: AuthHooks = { - getUserIdByEmail: async ({ email, env, request }) => { - return await env.KV_STORAGE.get(`email:${email}`); - }, - - storeVerificationCode: async ({ email, code, env, request }) => { - await env.KV_STORAGE.put(`code:${email}`, code, { - expirationTtl: 600, - }); - }, - - verifyVerificationCode: async ({ email, code, env, request }) => { - const storedCode = await env.KV_STORAGE.get(`code:${email}`); - return storedCode === code; - }, +export class AuthWorker extends WorkerEntrypoint { + private router: Router; + private hooks: AuthHooks; - sendVerificationCode: async ({ email, code, env, request }) => { - try { - const response = await fetch("https://api.sendgrid.com/v3/mail/send", { - method: "POST", - headers: { - Authorization: `Bearer ${env.SENDGRID_API_KEY}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - personalizations: [{ to: [{ email }] }], - from: { email: "auth@yourdomain.com" }, - subject: "Your verification code", - content: [{ type: "text/plain", value: `Your code is: ${code}` }], - }), - }); - return response.ok; - } catch (error) { - console.error("Failed to send email:", error); - return false; - } - }, - - onNewUser: async ({ userId, env, request }) => { - await env.KV_STORAGE.put( - `user:${userId}`, - JSON.stringify({ - created: new Date().toISOString(), - }) - ); - }, - - onAuthenticate: async ({ userId, email, env, request }) => { - await env.KV_STORAGE.put( - `user:${userId}:lastLogin`, - new Date().toISOString() - ); - }, - - onEmailVerified: async ({ userId, email, env, request }) => { - await env.KV_STORAGE.put(`user:${userId}:verified`, "true"); - await env.KV_STORAGE.put(`email:${email}`, userId); - }, -}; - -// Integrated with application -// This wraps your application handler with auth middleware -export const withAuthMiddleware = ( - appHandler: ( - request: Request, - env: TEnv, - context: { userId: string; sessionId: string; sessionToken: string } - ) => Promise -) => { - return withAuth(appHandler, { hooks: authHooks }); -}; - -// Example server -export default { - async fetch(request: Request, env: Env, ctx: ExecutionContext) { - // Use the auth middleware to handle all routes - return withAuthMiddleware(async (request, env, { userId, sessionId, sessionToken }) => { - // Your application logic here - const url = new URL(request.url); + constructor() { + super(); + + // Define hooks using KV - only defined once when the worker is instantiated + this.hooks = { + getUserIdByEmail: async ({ email, env }) => { + try { + const userIdKey = `email:${email}`; + return await env.AUTH_KV.get(userIdKey); + } catch (error) { + console.error("Error getting userId by email:", error); + return null; + } + }, + + storeVerificationCode: async ({ email, code, env }) => { + try { + const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes + const codeData = JSON.stringify({ + code, + expiresAt: expiresAt.toISOString(), + }); + + await env.AUTH_KV.put(`verification:${email}`, codeData, { + expirationTtl: 600, // 10 minutes in seconds + }); + } catch (error) { + console.error("Error storing verification code:", error); + } + }, + + verifyVerificationCode: async ({ email, code, env }) => { + try { + const codeDataStr = await env.AUTH_KV.get(`verification:${email}`); + if (!codeDataStr) return false; + + const codeData = JSON.parse(codeDataStr); + const now = new Date(); + const expiresAt = new Date(codeData.expiresAt); + + return codeData.code === code && now < expiresAt; + } catch (error) { + console.error("Error verifying code:", error); + return false; + } + }, + + sendVerificationCode: async ({ email, code, env }) => { + try { + const response = await fetch("https://api.sendgrid.com/v3/mail/send", { + method: "POST", + headers: { + Authorization: `Bearer ${env.SENDGRID_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + personalizations: [{ to: [{ email }] }], + from: { email: "auth@yourdomain.com" }, + subject: "Your verification code", + content: [{ type: "text/plain", value: `Your code is: ${code}` }], + }), + }); + return response.ok; + } catch (error) { + console.error("Failed to send email:", error); + return false; + } + }, + + onNewUser: async ({ userId, env }) => { + try { + await env.AUTH_KV.put(`user:${userId}`, JSON.stringify({ + userId, + createdAt: new Date().toISOString(), + })); + } catch (error) { + console.error("Error creating new user:", error); + } + }, + + onAuthenticate: async ({ userId, env }) => { + try { + const userDataStr = await env.AUTH_KV.get(`user:${userId}`); + if (!userDataStr) return; + + const userData = JSON.parse(userDataStr); + userData.lastLogin = new Date().toISOString(); + + await env.AUTH_KV.put(`user:${userId}`, JSON.stringify(userData)); + } catch (error) { + console.error("Error updating last login:", error); + } + }, + + onEmailVerified: async ({ userId, email, env }) => { + try { + // Store email to userId mapping + await env.AUTH_KV.put(`email:${email}`, userId); + + // Update user record + const userDataStr = await env.AUTH_KV.get(`user:${userId}`); + if (!userDataStr) return; + + const userData = JSON.parse(userDataStr); + userData.email = email; + userData.emailVerified = true; + + await env.AUTH_KV.put(`user:${userId}`, JSON.stringify(userData)); + } catch (error) { + console.error("Error verifying email:", error); + } + }, - // Handle your application routes - if (url.pathname === '/') { - return new Response('Welcome to my app!'); - } + // Provider-specific hooks + getGameIdFromApiKey: async ({ apiKey, env }) => { + try { + return await env.AUTH_KV.get(`apiKey:${apiKey}`); + } catch (error) { + console.error("Error getting game ID from API key:", error); + return null; + } + }, - // Return 404 for unknown routes - return new Response('Not Found', { status: 404 }); - })(request, env); - } -}; -``` - -```mermaid -sequenceDiagram - participant Browser - participant WebApp - participant AuthMiddleware - participant Storage - participant Email - - %% Initial visit and anonymous session - Browser->>WebApp: Visit application - WebApp->>AuthMiddleware: Check auth status - AuthMiddleware->>AuthMiddleware: Create anonymous session - AuthMiddleware-->>WebApp: Return userId, sessionToken - WebApp-->>Browser: Render app with auth client - - %% Email verification flow - Browser->>AuthMiddleware: POST /auth/request-code - AuthMiddleware->>Storage: Store verification code - AuthMiddleware->>Email: Send code to user - Email-->>Browser: Deliver code - - Browser->>AuthMiddleware: POST /auth/verify - AuthMiddleware->>Storage: Verify code - AuthMiddleware->>Storage: Update user status - AuthMiddleware-->>Browser: Return tokens + set cookies - - %% Token refresh flow - Note over Browser,AuthMiddleware: When session token expires - Browser->>AuthMiddleware: Request with expired session - AuthMiddleware->>AuthMiddleware: Validate refresh token from cookie - AuthMiddleware-->>Browser: Issue new session token -``` - -### Web Application Setup - -Your web application integrates the auth middleware directly. - -```typescript -// app/server.ts (e.g., for Remix, Next.js, etc.) -import { withAuthMiddleware } from './auth-server'; -import { createRequestHandler } from "@remix-run/cloudflare"; -import * as build from "@remix-run/dev/server-build"; -import { Env } from "./env"; - -if (process.env.NODE_ENV === "development") { - logDevReady(build); -} - -const handleRemixRequest = createRequestHandler(build); - -// Wrap your app handler with auth middleware -const handler = withAuthMiddleware( - async (request, env, { userId, sessionId, sessionToken }) => { - try { - return await handleRemixRequest(request, { - env, - userId, - sessionId, - sessionToken, - requestId: crypto.randomUUID(), - }); - } catch (error) { - console.error("Error processing request:", error); - return new Response("Internal Error", { status: 500 }); - } - } -); - -export default { - async fetch(request: Request, env: Env, ctx: ExecutionContext) { - return handler(request, env); - }, -}; -``` - -### React Router 7 Integration - -Auth Kit integrates seamlessly with React Router 7, allowing you to access authentication state in your loaders and actions. - -```typescript -// app/entry.server.tsx -import { withAuth } from "@open-game-collective/auth-kit/server"; -import { createRequestHandler } from "@remix-run/cloudflare"; -import * as build from "@remix-run/dev/server-build"; -import { authHooks } from "./auth-hooks"; - -// Create the request handler with auth middleware -export const handler = withAuth(async (request, env, { userId, sessionId, sessionToken }) => { - // Conditionally log auth information in development mode - if (process.env.NODE_ENV === 'development') { - console.log(`Request from user: ${userId}, session: ${sessionId}`); - } - - // Pass auth context to Remix loader context - return createRequestHandler({ - build, - mode: process.env.NODE_ENV, - getLoadContext() { - return { - env, - auth: { - userId, - sessionId, - sessionToken - } - }; - }, - })(request); -}, { - hooks: authHooks -}); - -// app/root.tsx -import { createAuthClient } from "@open-game-collective/auth-kit/client"; -import { createAuthContext } from "@open-game-collective/auth-kit/react"; -import { - Links, - Meta, - Outlet, - Scripts, - ScrollRestoration, - useLoaderData -} from "@remix-run/react"; -import { json } from "@remix-run/cloudflare"; - -// Create auth context for React components -const AuthContext = createAuthContext(); - -// Root loader provides auth state to client -export async function loader({ request, context }) { - const { auth } = context; - - return json({ - auth: { - userId: auth.userId, - sessionToken: auth.sessionToken - } - }); -} - -export default function App() { - const { auth } = useLoaderData(); - const [authClient] = useState(() => - createAuthClient({ - host: window.location.host, - userId: auth.userId, - sessionToken: auth.sessionToken - }) - ); - - return ( - - - - - - - - - - + storeAccountLink: async ({ openGameUserId, gameId, gameUserId, env }) => { + try { + const linkData = { + openGameUserId, + gameId, + gameUserId, + linkedAt: new Date().toISOString(), + }; - - - + // Store link in both directions for easy lookup + await env.AUTH_KV.put( + `accountLink:${openGameUserId}:${gameId}`, + JSON.stringify(linkData) + ); - - - - - - - - - ); -} - -// app/routes/profile.tsx -import { AuthContext } from "~/root"; -import { json, redirect } from "@remix-run/cloudflare"; -import { useLoaderData, Form } from "@remix-run/react"; - -// Protect routes with loader -export async function loader({ request, context }) { - const { auth } = context; - - // Get email from context (if user is verified) - const email = await context.env.KV_STORAGE.get(`user:${auth.userId}:email`); - - // If not verified, redirect to verification page - if (!email) { - return redirect("/verify"); - } - - // Load user profile data - const profile = await context.env.KV_STORAGE.get(`user:${auth.userId}:profile`); - - return json({ - email, - profile: profile ? JSON.parse(profile) : null - }); -} - -// Handle form submissions with action -export async function action({ request, context }) { - const { auth } = context; - const formData = await request.formData(); - const name = formData.get("name"); - - // Update user profile - await context.env.KV_STORAGE.put( - `user:${auth.userId}:profile`, - JSON.stringify({ name }) - ); - - return json({ success: true }); -} - -export default function Profile() { - const { email, profile } = useLoaderData(); - const client = AuthContext.useClient(); - const isLoading = AuthContext.useSelector(state => state.isLoading); - - const handleLogout = async () => { - await client.logout(); - window.location.href = "/"; - }; - - return ( -
-

Profile

-

Email: {email}

+ await env.AUTH_KV.put( + `gameLink:${gameId}:${gameUserId}`, + openGameUserId + ); + + return true; + } catch (error) { + console.error("Error storing account link:", error); + return false; + } + }, -
- - -
+ getLinkedAccounts: async ({ openGameUserId, env }) => { + try { + // List all account links for this user + const links = await env.AUTH_KV.list({ prefix: `accountLink:${openGameUserId}:` }); + + // Fetch each link's data + const linkedAccounts = await Promise.all( + links.keys.map(async (key) => { + const linkDataStr = await env.AUTH_KV.get(key.name); + if (!linkDataStr) return null; + + const linkData = JSON.parse(linkDataStr); + return { + gameId: linkData.gameId, + gameUserId: linkData.gameUserId, + linkedAt: linkData.linkedAt, + gameName: env.GAME_NAMES[linkData.gameId] || linkData.gameId, + }; + }) + ); + + // Filter out any null values and return + return linkedAccounts.filter(Boolean); + } catch (error) { + console.error("Error getting linked accounts:", error); + return []; + } + }, - -
- ); -} - -// app/routes/verify.tsx -import { AuthContext } from "~/root"; -import { useState } from "react"; -import { useNavigate } from "@remix-run/react"; - -export default function Verify() { - const [email, setEmail] = useState(""); - const [code, setCode] = useState(""); - const [codeSent, setCodeSent] = useState(false); - const client = AuthContext.useClient(); - const isLoading = AuthContext.useSelector(state => state.isLoading); - const navigate = useNavigate(); - - const requestCode = async (e) => { - e.preventDefault(); - try { - await client.requestCode(email); - setCodeSent(true); - } catch (error) { - console.error("Failed to send code:", error); - } - }; - - const verifyCode = async (e) => { - e.preventDefault(); - try { - const result = await client.verifyEmail(email, code); - if (result.success) { - navigate("/profile"); - } - } catch (error) { - console.error("Failed to verify code:", error); - } - }; - - return ( -
-

Verify Your Email

+ removeAccountLink: async ({ openGameUserId, gameId, env }) => { + try { + // Get the link data first to get the gameUserId + const linkDataStr = await env.AUTH_KV.get(`accountLink:${openGameUserId}:${gameId}`); + if (!linkDataStr) return false; + + const linkData = JSON.parse(linkDataStr); + + // Delete both link directions + await env.AUTH_KV.delete(`accountLink:${openGameUserId}:${gameId}`); + await env.AUTH_KV.delete(`gameLink:${gameId}:${linkData.gameUserId}`); + + return true; + } catch (error) { + console.error("Error removing account link:", error); + return false; + } + }, - {!codeSent ? ( -
- - -
- ) : ( -
-

We sent a code to {email}

- - -
- )} -
- ); -} -``` - -This setup provides: - -1. **Server-side Authentication**: - - The `withAuth` middleware automatically handles authentication for all routes - - Auth state is passed to loaders and actions via the context - - Protected routes can check auth state and redirect if needed - -2. **Client-side Integration**: - - Auth state is hydrated from the server via the root loader - - The auth client is created once and provided to all components - - Components can access auth state via hooks and conditional components - -3. **Form Handling**: - - React Router's Form component works with auth-protected actions - - Client-side auth state updates automatically after form submissions - - Loading states are handled via the auth context - -4. **Navigation**: - - Auth-based redirects work both server-side and client-side - - After verification, users are redirected to protected routes - - After logout, users are redirected to public routes - -You can use Auth Kit with: -- Next.js: In API routes or server components -- React Router: In loaders or actions -- TanStack Router: In route handlers -- Vite SSR: In server entry point - -The only requirement is implementing the auth hooks for your chosen storage and email delivery solutions. - -### Mobile Applications (React Native) - -For mobile applications, you'll need to explicitly manage user creation and token storage. For enhanced security, we recommend using biometric authentication to protect the refresh token: - -```typescript -// app/auth.ts -import { createAnonymousUser, createAuthClient } from "@open-game-collective/auth-kit/client"; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import * as LocalAuthentication from 'expo-local-authentication'; -import * as SecureStore from 'expo-secure-store'; - -// Keys for different storage mechanisms -const AUTH_KEYS = { - // Regular storage for non-sensitive data - USER_ID: 'auth_user_id', - SESSION_TOKEN: 'auth_session_token', - - // Secure storage for sensitive data - REFRESH_TOKEN: 'auth_refresh_token' -} as const; - -// Check if biometric authentication is available -async function isBiometricAvailable() { - const compatible = await LocalAuthentication.hasHardwareAsync(); - const enrolled = await LocalAuthentication.isEnrolledAsync(); - return compatible && enrolled; -} - -// Store refresh token with biometric protection if available -async function storeRefreshToken(token: string) { - if (await isBiometricAvailable()) { - // Use biometric authentication before storing the token - const result = await LocalAuthentication.authenticateAsync({ - promptMessage: 'Authenticate to secure your session', - fallbackLabel: 'Use passcode' - }); + // Consumer-specific hooks + storeOpenGameLink: async ({ gameUserId, openGameUserId, env }) => { + try { + const linkData = { + openGameUserId, + gameId: env.GAME_ID, + gameUserId, + linkedAt: new Date().toISOString(), + }; + + // Store link in both directions + await env.AUTH_KV.put( + `accountLink:${openGameUserId}:${env.GAME_ID}`, + JSON.stringify(linkData) + ); + + await env.AUTH_KV.put( + `gameLink:${env.GAME_ID}:${gameUserId}`, + openGameUserId + ); + + return true; + } catch (error) { + console.error("Error storing open game link:", error); + return false; + } + }, + + getOpenGameUserId: async ({ gameUserId, env }) => { + try { + return await env.AUTH_KV.get(`gameLink:${env.GAME_ID}:${gameUserId}`); + } catch (error) { + console.error("Error getting open game user ID:", error); + return null; + } + } + }; - if (result.success) { - // Store in secure storage after biometric authentication - await SecureStore.setItemAsync(AUTH_KEYS.REFRESH_TOKEN, token); - return true; - } else { - console.warn('Biometric authentication failed, using fallback storage'); - // Fallback to regular secure storage - await AsyncStorage.setItem(AUTH_KEYS.REFRESH_TOKEN, token); - return false; - } - } else { - // Fallback to regular secure storage if biometrics not available - await AsyncStorage.setItem(AUTH_KEYS.REFRESH_TOKEN, token); - return false; - } -} - -// Retrieve refresh token, requiring biometric auth if it was stored that way -async function getRefreshToken() { - if (await isBiometricAvailable()) { - try { - // Try to get from secure storage first (requires biometrics on some devices) - return await SecureStore.getItemAsync(AUTH_KEYS.REFRESH_TOKEN); - } catch (error) { - // Fallback to AsyncStorage - return await AsyncStorage.getItem(AUTH_KEYS.REFRESH_TOKEN); - } - } else { - // Use regular storage if biometrics not available - return await AsyncStorage.getItem(AUTH_KEYS.REFRESH_TOKEN); - } -} - -async function clearAuthTokens() { - await Promise.all([ - AsyncStorage.removeItem(AUTH_KEYS.USER_ID), - AsyncStorage.removeItem(AUTH_KEYS.SESSION_TOKEN), - // Clear from both storage mechanisms - AsyncStorage.removeItem(AUTH_KEYS.REFRESH_TOKEN), - SecureStore.deleteItemAsync(AUTH_KEYS.REFRESH_TOKEN) - ]); -} - -export async function initializeAuth() { - // Try to load existing tokens - const [userId, sessionToken, refreshToken] = await Promise.all([ - AsyncStorage.getItem(AUTH_KEYS.USER_ID), - AsyncStorage.getItem(AUTH_KEYS.SESSION_TOKEN), - getRefreshToken() // Use our helper that handles biometric auth - ]); - - // If we have existing tokens, create client with them - if (userId && sessionToken) { - return createAuthClient({ - host: "your-worker.workers.dev", - userId, - sessionToken, - refreshToken // Include refresh token for mobile + // Create the router once during initialization + this.router = createAuthRouter({ + hooks: this.hooks, + useTopLevelDomain: true }); } - - // Otherwise create a new anonymous user with longer refresh token for mobile - const tokens = await createAnonymousUser({ - host: "your-worker.workers.dev", - refreshTokenExpiresIn: '30d', // Longer refresh token for mobile - sessionTokenExpiresIn: '1h' // Longer session token for mobile - }); - // Store the tokens - await Promise.all([ - AsyncStorage.setItem(AUTH_KEYS.USER_ID, tokens.userId), - AsyncStorage.setItem(AUTH_KEYS.SESSION_TOKEN, tokens.sessionToken), - storeRefreshToken(tokens.refreshToken) // Use our helper for biometric protection - ]); - - // Create and return the client - return createAuthClient({ - host: "your-worker.workers.dev", - userId: tokens.userId, - sessionToken: tokens.sessionToken, - refreshToken: tokens.refreshToken // Include refresh token for mobile - }); -} - -// App.tsx -import { AuthContext } from "./auth.context"; -import { useState, useEffect, useCallback } from "react"; -import { Button } from "react-native"; -import { NavigationContainer } from "@react-navigation/native"; - -export default function App() { - const [client, setClient] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [isLoggingOut, setIsLoggingOut] = useState(false); - - useEffect(() => { - initializeAuth() - .then(setClient) - .finally(() => setIsLoading(false)); - }, []); - - const handleLogout = useCallback(async () => { - if (!client || isLoggingOut) return; - - // Immediately set logging out state and clear client - setIsLoggingOut(true); - setClient(null); - - try { - // Call client logout to clear server-side session - await client.logout(); - - // Clear stored tokens - await clearAuthTokens(); - - // Create new anonymous session - const newClient = await initializeAuth(); - setClient(newClient); - } finally { - setIsLoggingOut(false); - } - }, [client, isLoggingOut]); - - if (isLoading || isLoggingOut || !client) { - return ; + // Override the fetch method from WorkerEntrypoint + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + return this.router.handle(request, env); } - - return ( - - - - - )} - - - ))} - - )} - {error &&
{error}
} -
- )} - - - - {/* Show when user has no linked accounts */} - -

Link Your First Account

- - - {({ onInitiate, isInitiating, error }) => ( -
- - {error &&
{error}
} -
- )} -
-
- - ); +// Helper functions for cookies and token verification +function getCookie(request: Request, name: string): string | undefined { + const cookieHeader = request.headers.get("cookie") || request.headers.get("Cookie"); + if (!cookieHeader) return undefined; + + const cookies = cookieHeader.split(";").map((cookie) => cookie.trim()); + const cookie = cookies.find((cookie) => cookie.startsWith(`${name}=`)); + + if (!cookie) return undefined; + return decodeURIComponent(cookie.split("=")[1]); } -``` - -#### Consumer API - -`createConsumerAuthContext()` - -Creates a React context specifically for consumer (game) authentication, providing: -- A Provider for passing down the consumer auth client. -- Hooks: `useClient` and `useSelector` for accessing and subscribing to consumer state. -- Components for managing open game linking: - - ``: Renders children when user is linked with an open game - - ``: Renders children when user is not linked with an open game - - ``: Renders a function child with open game profile data - - ``: Renders a function child with link token verification functionality - - ``: Renders a function child with link confirmation functionality - -Example: -```tsx -import { createConsumerAuthContext } from "@open-game-collective/auth-kit/consumer-react"; -import { createConsumerAuthClient } from "@open-game-collective/auth-kit/consumer-client"; - -const ConsumerAuthContext = createConsumerAuthContext(); -const consumerClient = createConsumerAuthClient({ - host: "your-api.example.com", - userId: "game-user-123", - sessionToken: "jwt-token" -}); -function App() { - return ( - - {/* Show when user is linked with open game */} - -

Your Open Game Account

- - - {({ profile, isLoading, error }) => ( -
- {isLoading ? : ( -
-

{profile?.displayName || "Anonymous"}

- {profile?.avatarUrl && ( - Profile - )} -
- )} - {error &&
{error}
} -
- )} -
-
- - {/* Show when user is not linked with open game */} - -

Link Your Open Game Account

- - {/* When user has a link token */} - {linkToken && ( - - {({ isVerifying, isValid, openGameUserId, email, error }) => ( -
- {isVerifying ? : ( - isValid ? ( - - {({ onConfirm, isConfirming, isConfirmed, error }) => ( -
-

Link with {email}?

- - {error &&
{error}
} -
- )} -
- ) : ( -
Invalid or expired link token
- ) - )} - {error &&
{error}
} -
- )} -
- )} -
-
- ); +async function verifyToken(token: string, secret: string) { + try { + const verified = await jwtVerify(token, new TextEncoder().encode(secret)); + return verified.payload; + } catch (error) { + return null; + } } ``` -### Test API +### Using KV with Auth Kit -`createAuthMockClient(config)` +Cloudflare KV provides several advantages for implementing Auth Kit hooks: -Creates a mock auth client for testing. This is useful for testing UI components that depend on auth state without needing a real server. +1. **Global Distribution**: KV data is replicated globally, providing low-latency access from any Cloudflare edge location. +2. **Shared State**: Unlike Durable Objects, KV allows sharing state across multiple workers and regions. +3. **Simple API**: KV provides a straightforward key-value API that's easy to use. +4. **Automatic Expiration**: KV supports automatic expiration for items like verification codes. +5. **High Read Performance**: KV is optimized for high-performance reads. -Example: -```typescript -import { createAuthMockClient } from "@open-game-collective/auth-kit/test"; - -it('shows verified content when user is verified', () => { - const mockClient = createAuthMockClient({ - initialState: { - isLoading: false, - userId: 'test-user', - sessionToken: 'test-session', - email: 'user@example.com' // non-null email indicates verified - } - }); +### Benefits of the Worker Class Approach - render( - - - - ); +The `WorkerEntrypoint` class implementation shown above offers several advantages: - // Test that verified content is shown - expect(screen.getByText('Welcome back!')).toBeInTheDocument(); -}); +1. **Standard Cloudflare Pattern**: Using `WorkerEntrypoint` follows the recommended Cloudflare Workers pattern for class-based workers. +2. **Proper Inheritance**: Extends the base worker class, giving you access to all its features and lifecycle methods. +3. **Initialization Efficiency**: Hooks are defined only once when the worker is instantiated, not on every request. +4. **Type Safety**: The generic type parameter `` ensures proper typing of environment variables. +5. **Code Organization**: The class structure provides a clean way to organize related functionality. +6. **Composability**: Makes it easy to compose multiple worker functionalities by extending and delegating. +7. **Testability**: The class structure makes it easier to write unit tests for your auth implementation. +8. **Maintainability**: Separating the auth logic into its own class makes the codebase more maintainable. -it('handles email verification flow', async () => { - const mockClient = createAuthMockClient({ - initialState: { - isLoading: false, - userId: 'test-user', - sessionToken: 'test-session', - email: null // null email indicates unverified - } - }); +This approach is particularly beneficial for high-traffic applications where performance is critical. By defining hooks and creating the router only once, you reduce the overhead of each request, resulting in faster response times and lower compute costs. - render( - - - - ); +#### Integration with Auth Kit - // Simulate verification flow - await userEvent.click(screen.getByText('Verify Email')); - - // Check that requestCode was called - expect(mockClient.requestCode).toHaveBeenCalledWith('test@example.com'); - - // Update mock state to simulate loading - mockClient.produce(draft => { - draft.isLoading = true; - }); - - expect(screen.getByText('Sending code...')).toBeInTheDocument(); - - // Update mock state to simulate success - mockClient.produce(draft => { - draft.isLoading = false; - draft.email = 'test@example.com'; - }); - - expect(screen.getByText('Email verified!')).toBeInTheDocument(); -}); -``` +To integrate KV with Auth Kit: -The mock client provides additional testing utilities: +1. **Create the KV namespace** in your Cloudflare dashboard or using Wrangler. +2. **Implement the auth hooks** using KV operations. +3. **Create the auth router** in your worker's fetch handler. +4. **Handle auth routes** by checking the URL path. -- `produce(recipe)`: Update the mock client state using a recipe function -- `getState()`: Get current state -- All client methods are Vitest spies for tracking calls -- State changes are synchronous for easier testing -- No actual network requests are made +#### Key Structure for KV -## Cookie Domain Options +When using KV for auth data, a good key structure helps organize your data: -Auth Kit supports cross-domain cookie functionality through the `useTopLevelDomain` flag: +- `user:{userId}` - User data +- `email:{email}` - Maps email to userId +- `verification:{email}` - Verification codes +- `accountLink:{openGameUserId}:{gameId}` - Account links from provider perspective +- `gameLink:{gameId}:{gameUserId}` - Account links from consumer perspective +- `apiKey:{apiKey}` - Maps API keys to game IDs -- `useTopLevelDomain`: When set to `true`, cookies will be set for the top-level domain (e.g., for "api.example.com", cookies will work across "*.example.com"). Defaults to `false`, which means cookies will only work on the exact domain. +This structure makes it easy to find and manage related data. -This option can be passed to both `createAuthRouter` and `withAuth` functions: - -```typescript -// Example: Using the top-level domain for cookies -const authRouter = createAuthRouter({ - hooks, - useTopLevelDomain: true // Enables cookies to work across subdomains -}); -``` +## API Reference -Note: When using `useTopLevelDomain`, the domain is automatically derived from the request. For localhost and IP addresses, no domain attribute is set on cookies. +- [Client API](#client-api) +- [Provider Client API](#provider-client-api) +- [Consumer Client API](#consumer-client-api) +- [Server API](#server-api) +- [React API](#react-api) +- [Test API](#test-api) +- [HTTP Endpoints](#http-endpoints) From 46a08bb87b42fb55090264c1e3912e1b8fe5064d Mon Sep 17 00:00:00 2001 From: Jonathan Mumm Date: Mon, 10 Mar 2025 06:08:49 -0700 Subject: [PATCH 09/10] move consumer and provider out --- README.md | 1708 +++++++++++++++++++++++++++-------- package.json | 26 +- src/consumer-server.test.ts | 438 +-------- src/consumer-server.ts | 315 +++++++ src/provider-server.test.ts | 786 ++++++++-------- src/provider-server.ts | 411 +++++++++ src/server.test.ts | 218 +++-- src/server.ts | 698 ++++---------- src/types.ts | 119 ++- 9 files changed, 2878 insertions(+), 1841 deletions(-) create mode 100644 src/consumer-server.ts create mode 100644 src/provider-server.ts diff --git a/README.md b/README.md index dcca9b2..6dbafd7 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ A headless, isomorphic authentication toolkit that runs seamlessly across server - [Troubleshooting](#troubleshooting) - [TypeScript Types](#typescript-types) - [Testing with Storybook](#testing-with-storybook) +- [Package Structure](#package-structure) +- [Recent Changes](#recent-changes) ## Installation @@ -127,11 +129,13 @@ const providerRouter = createProviderAuthRouter({ storeAccountLink: async ({ openGameUserId, gameId, gameUserId, env }) => { /* ... */ }, getLinkedAccounts: async ({ openGameUserId, env }) => { /* ... */ }, removeAccountLink: async ({ openGameUserId, gameId, env }) => { /* ... */ }, - } + }, + useTopLevelDomain: true, // Optional + basePath: "/auth" // Optional, defaults to "/auth" }); // Client-side implementation -import { createProviderAuthClient } from "@open-game-collective/auth-kit/provider"; +import { createProviderAuthClient } from "@open-game-collective/auth-kit/provider/client"; import { createProviderAuthContext } from "@open-game-collective/auth-kit/provider/react"; const ProviderAuthContext = createProviderAuthContext(); @@ -148,35 +152,22 @@ function AccountLinkingUI() {

Your Linked Accounts

- {({ accounts, isLoading, error }) => ( -
- {isLoading ? : ( -
    - {accounts.map(account => ( -
  • - {account.gameName} - {account.gameUserId} - - {({ onUnlink, isUnlinking, error }) => ( - - )} - -
  • - ))} -
- )} - {error &&
{error}
} -
+ {({ accounts, onUnlink }) => ( +
    + {accounts.map(account => ( +
  • + {account.gameId} + +
  • + ))} +
)}
- + {({ onInitiate, isInitiating, error }) => ( - - ))} - - )} - - - - - {({ onInitiate, isInitiating, error }) => ( - - )} - - +
+

Dashboard

+

Welcome, {user.email}!

+ {/* Your dashboard content */} +
); } ``` -#### Consumer Implementation +For React Router (non-Remix): -```typescript -// Server-side setup -import { createConsumerAuthRouter } from "@open-game-collective/auth-kit/consumer/server"; +```tsx +// src/routes/ProtectedRoute.tsx +import { Navigate, Outlet, useLocation } from "react-router-dom"; +import { useAuth } from "../hooks/useAuth"; -const consumerRouter = createConsumerAuthRouter({ - hooks: { - // Base auth hooks - getUserIdByEmail: async ({ email, env }) => { /* ... */ }, - // ... other base hooks - - // Consumer-specific hooks - storeOpenGameLink: async ({ gameUserId, openGameUserId, env }) => { /* ... */ }, - getOpenGameUserId: async ({ gameUserId, env }) => { /* ... */ }, - getOpenGameProfile: async ({ openGameUserId, env }) => { /* ... */ }, - }, - gameId: "your-game-id", // Required for consumer router - useTopLevelDomain: true, // Optional - basePath: "/auth" // Optional, defaults to "/auth" -}); - -// Client-side implementation -import { createConsumerAuthClient } from "@open-game-collective/auth-kit/consumer/client"; -import { createConsumerAuthContext } from "@open-game-collective/auth-kit/consumer/react"; +export function ProtectedRoute() { + const { isAuthenticated, isLoading } = useAuth(); + const location = useLocation(); + + if (isLoading) { + return
Loading...
; + } + + if (!isAuthenticated) { + // Redirect to login if not authenticated + return ; + } + + return ; +} -const ConsumerAuthContext = createConsumerAuthContext(); -const consumerClient = createConsumerAuthClient({ - host: "your-api.example.com", - userId: "game-user-123", - sessionToken: "jwt-token", - gameId: "your-game-id" // Required for consumer client -}); +// src/App.tsx +import { Routes, Route } from "react-router-dom"; +import { ProtectedRoute } from "./routes/ProtectedRoute"; +import { Dashboard } from "./routes/Dashboard"; +import { Login } from "./routes/Login"; +import { Home } from "./routes/Home"; -function LinkVerificationUI({ linkToken }) { +export function App() { return ( - - - {({ isVerifying, isValid, openGameUserId, email, error }) => ( -
- {isVerifying ? ( -

Verifying link...

- ) : isValid ? ( - - {({ onConfirm, isConfirming, isConfirmed, error }) => ( -
-

Link your account with {email}?

- -
- )} -
- ) : ( -

Invalid or expired link token

- )} -
- )} -
-
+ + } /> + }> + } /> + {/* Other protected routes */} + + } /> + ); } ``` -### Security Considerations +### 3️⃣ Configure Server -The account linking system includes several security features to ensure secure cross-application communication: +For more advanced server configurations, you can separate auth routes from application routes: -1. **JWT-Based Link Tokens**: Cryptographically signed tokens with short expiration times ensure secure linking process -2. **API Key Authentication**: Server-to-server communication secured with API keys for trusted application verification -3. **User Confirmation**: Explicit user consent required before enabling cross-application features -4. **Secure Storage**: Account links stored securely on both provider and consumer sides -5. **Unlinking Capability**: Users can disable cross-application features by unlinking accounts at any time -6. **Limited Data Sharing**: Only necessary data is shared between applications, with clear user consent -7. **Independent Authentication**: Each application maintains its own authentication system, with no credential sharing +```typescript +// server.ts +import { createAuthRouter, withAuth } from "@open-game-collective/auth-kit/server"; +import { createRequestHandler } from "@remix-run/cloudflare"; +import * as build from "@remix-run/dev/server-build"; -### Benefits +// Create the Remix request handler +const remixHandler = createRequestHandler(build); -- **Connected Ecosystem**: Enable rich interactions between different applications in the ecosystem -- **Enhanced User Experience**: Provide seamless cross-application features without requiring users to manually connect accounts -- **Push Notifications**: Allow the provider app to send notifications about events in linked consumer apps -- **Feature Sharing**: Share profiles, achievements, and other data across applications with user consent -- **Independent Authentication**: Each application maintains its own authentication while still enabling connected experiences -- **Secure Communication**: All communication between applications is secured with API keys and JWT tokens -- **User Control**: Users can link and unlink accounts at any time, maintaining control over their connected experience +// Define auth hooks +const authHooks = { + // ... your hooks implementation +}; -For detailed implementation guidance, see the [Account Linking Implementation Guide](docs/account-linking.md). +// Create the auth router for auth-specific routes +const authRouter = createAuthRouter({ + hooks: authHooks, + useTopLevelDomain: true, + basePath: "/auth" +}); -## Authentication Flow +// Create the auth handler for application routes +const appHandler = withAuth( + async (request, env, { userId, sessionId, sessionToken }) => { + // Pass auth info to Remix + return remixHandler(request, { + env, + userId, + sessionId, + sessionToken, + }); + }, + { + hooks: authHooks, + useTopLevelDomain: true + } +); -```mermaid -sequenceDiagram - participant U as User - participant B as Browser - participant RN as React Native - participant S as Server - participant E as Email - participant DB as Storage - - Note over U,S: Anonymous Session - - alt Browser Client - U->>B: First Visit - B->>S: Request - S->>S: Create Anonymous User (userId: "anon-123") - S->>B: Response with Set-Cookie (HTTP-only cookies)
sessionToken (15m) & refreshToken (7d) - Note over B: Cookies stored in browser - else React Native Client - U->>RN: First Open - RN->>S: POST /auth/anonymous - S->>S: Create Anonymous User (userId: "anon-123") - S->>RN: Return JSON {userId, sessionToken, refreshToken} - Note over RN: Tokens stored in secure storage
Consider biometric protection for refreshToken - end - - Note over U,S: Email Verification - U->>B: Enter Email "user@example.com" - B->>S: POST /auth/request-code {email: "user@example.com"} - S->>E: Send Code "123456" - E->>U: Deliver Code "123456" - U->>B: Submit Code - B->>S: POST /auth/verify {email: "user@example.com", code: "123456"} - S->>DB: Check if email exists in system - - alt New User (Anonymous Session Upgrade) - Note over S: Email not found in system - S->>DB: Update same userId "anon-123" from anonymous to verified - - alt Browser Client - S->>B: Set new cookies & return {userId: "anon-123", email: "user@example.com"} - Note over B: Same userId, upgraded permissions - else React Native Client - S->>RN: Return {userId: "anon-123", sessionToken: "jwt...", refreshToken: "jwt...", email: "user@example.com"} - Note over RN: Store tokens in secure storage - end - - Note over U: Show verified user interface - else Existing User (Session Switch) - Note over S: Email found with existing userId "user-456" - S->>DB: Look up existing userId for this email - - alt Browser Client - S->>B: Set new cookies & return {userId: "user-456", email: "user@example.com"} - Note over B: Different userId, switch to existing account - else React Native Client - S->>RN: Return {userId: "user-456", sessionToken: "jwt...", refreshToken: "jwt...", email: "user@example.com"} - Note over RN: Replace tokens in secure storage - end - - Note over U: Show existing user interface - end - - Note over U,S: Session Management - - alt Browser Client - B->>S: API Requests with session cookie - S->>S: Validate Session Cookie - alt Session Expired (15m) - S->>S: Check Refresh Cookie (7d) - S->>B: Set new session cookie - end - else React Native Client - RN->>S: API Requests with Authorization: Bearer {sessionToken} - S->>S: Validate Session Token - alt Session Expired (15m) - RN->>S: POST /auth/refresh with refreshToken - S->>RN: Return new sessionToken - Note over RN: Update sessionToken in secure storage - end - end - - Note over U,S: Logout +// Main worker entry point +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext) { + const url = new URL(request.url); - alt Browser Client - U->>B: Logout - B->>S: POST /auth/logout - S->>B: Clear Cookies with Set-Cookie header - else React Native Client - U->>RN: Logout - RN->>S: POST /auth/logout - RN->>RN: Delete tokens from secure storage - end + // Handle auth routes with the auth router + if (url.pathname.startsWith('/auth/')) { + return authRouter(request, env, ctx); + } - Note over U,S: Next visit starts new anonymous session + // Handle application routes with the auth handler + return appHandler(request, env, ctx); + } +}; ``` -### User Data in JWT Tokens - -Auth Kit uses JWT tokens to securely store and transmit user information. By default, the tokens include minimal data: - -1. **Session Tokens** include: - - `userId`: The unique identifier for the user - - `sessionId`: A unique identifier for the session - - `email`: The user's email address (if verified) - - `aud`: Audience claim set to "SESSION" - - `exp`: Expiration time (default: 15 minutes) - -2. **Refresh Tokens** include: - - `userId`: The unique identifier for the user - - `aud`: Audience claim set to "REFRESH" - - `exp`: Expiration time (default: 7 days for cookies, 1 hour for transient tokens) - -You can extend the tokens to include additional user data by modifying the token creation functions: +For Durable Objects: ```typescript -// Example: Including email in session tokens -async function createSessionToken( - userId: string, - email: string | null, - secret: string, - expiresIn: string = "15m" -): Promise { - const sessionId = crypto.randomUUID(); - return await new SignJWT({ - userId, - sessionId, - email - }) - .setProtectedHeader({ alg: "HS256" }) - .setAudience("SESSION") - .setExpirationTime(expiresIn) - .sign(new TextEncoder().encode(secret)); -} -``` - -**Benefits of storing user data in JWTs:** -- Reduces database lookups for common user information -- Makes user data available on the client without additional API calls -- Simplifies client-side state management - -**Considerations:** -- Only include non-sensitive data in tokens -- Keep tokens reasonably sized (avoid large payloads) -- Remember that JWT contents can be read (though not modified) by clients -- Update tokens when user data changes +// durable-object.ts +import { withAuth } from "@open-game-collective/auth-kit/server"; +import { createRequestHandler } from "@remix-run/cloudflare"; +import * as build from "@remix-run/dev/server-build"; -The Auth Kit client automatically extracts and provides this data to your application through the auth state: +// Create the Remix request handler +const remixHandler = createRequestHandler(build); -```typescript -const { userId, email } = authClient.getState(); -``` - -### Authentication State +// Define auth hooks +const authHooks = { + // ... your hooks implementation +}; -Auth Kit maintains a core state object that represents the current user's authentication status. This state is accessible through the client and can be subscribed to for real-time updates. +// Create the auth middleware for the Durable Object +const authMiddleware = withAuth( + async (request, env, { userId, sessionId, sessionToken }) => { + // Pass auth info to Remix + return remixHandler(request, { + env, + userId, + sessionId, + sessionToken, + }); + }, + { + hooks: authHooks, + useTopLevelDomain: true, + basePath: "/auth" // This tells withAuth to handle /auth/* routes internally + } +); -The `AuthState` type is defined as: +// Durable Object implementation +export class AppDO extends DurableObject { + async fetch(request: Request) { + // No need to check for /auth/ paths - withAuth handles that internally + return authMiddleware(request, this.env); + } +} -```typescript -/** - * The authentication state object that represents the current user's session. - * This is the core state object used throughout the auth system. - */ -export type AuthState = { - /** - * The unique identifier for the current user. - * For anonymous users, this will be a randomly generated ID. - * For verified users, this will be their permanent user ID. - */ - userId: string; - - /** - * The JWT session token used for authenticated requests. - * This token has a short expiration (typically 15 minutes) and is - * automatically refreshed using the refresh token when needed. - */ - sessionToken: string | null; - - /** - * The user's verified email address, if they have completed verification. - * Will be null for anonymous users or users who haven't verified their email. - * The presence of an email indicates the user is verified. - */ - email: string | null; - - /** - * Indicates if an authentication operation is currently in progress. - * Used to show loading states in the UI during auth operations. - */ - isLoading: boolean; - - /** - * Any error that occurred during the last authentication operation. - * Will be null if no error occurred. - */ - error: string | null; +// Main worker entry point +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext) { + const url = new URL(request.url); + + // For all routes, use the Durable Object + const id = env.APP_DO.idFromName("default"); + const appDO = env.APP_DO.get(id); + return appDO.fetch(request); + } }; ``` -#### Working with Authentication State +### 4️⃣ Set up Auth Client and React Integration -You can access the current state at any time: +On the client side, set up the Auth Kit client and React integration: -```typescript -const state = authClient.getState(); -console.log(`User ID: ${state.userId}`); -console.log(`Is verified: ${Boolean(state.email)}`); -``` +```tsx +// src/auth.ts +import { createAuthClient } from "@open-game-collective/auth-kit/client"; +import { createAuthContext } from "@open-game-collective/auth-kit/react"; -For reactive applications, you can subscribe to state changes: +// Create the auth context +export const AuthContext = createAuthContext(); -```typescript -const unsubscribe = authClient.subscribe((state) => { - console.log('Auth state updated:', state); +// Initialize the client +export function initializeAuthClient() { + // Get auth info from cookies or localStorage + const userId = getCookie("userId"); + const sessionToken = getCookie("sessionToken"); - if (state.email) { - // User is verified - showVerifiedUI(); - } else { - // User is anonymous - showAnonymousUI(); + if (!userId || !sessionToken) { + return null; } - if (state.isLoading) { - // Show loading indicator - showLoadingSpinner(); - } - - if (state.error) { - // Show error message - showErrorNotification(state.error); - } -}); + return createAuthClient({ + host: "your-api.example.com", + userId, + sessionToken + }); +} -// Later, when you no longer need updates: -unsubscribe(); +// Helper function to get cookies +function getCookie(name: string) { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop()?.split(';').shift(); + return null; +} ``` -#### React Integration +Then, set up the React provider: -For React applications, Auth Kit provides components that automatically respond to state changes: +```tsx +// src/App.tsx +import { useState, useEffect } from "react"; +import { BrowserRouter } from "react-router-dom"; +import { AuthContext, initializeAuthClient } from "./auth"; +import { Routes } from "./Routes"; -```jsx -import { createAuthContext } from '@open-game-collective/auth-kit/react'; - -const AuthContext = createAuthContext(); - -function App() { +export function App() { + const [client, setClient] = useState(null); + + useEffect(() => { + setClient(initializeAuthClient()); + }, []); + return ( - - - - - - - - - - - - - + + + + + ); } ``` -You can also use the `useSelector` hook to access specific parts of the state: - -```jsx -function UserGreeting() { - const email = AuthContext.useSelector(state => state.email); +Create login and registration components: + +```tsx +// src/components/Login.tsx +import { useState } from "react"; +import { AuthContext } from "../auth"; + +export function Login() { + const [email, setEmail] = useState(""); + const [code, setCode] = useState(""); + const [codeSent, setCodeSent] = useState(false); + const client = AuthContext.useClient(); + const { isLoading, error } = AuthContext.useSelector(state => ({ + isLoading: state.isLoading, + error: state.error + })); + + const handleRequestCode = async (e) => { + e.preventDefault(); + await client.requestCode(email); + setCodeSent(true); + }; + + const handleVerifyCode = async (e) => { + e.preventDefault(); + const result = await client.verifyEmail(email, code); + if (result.success) { + // Redirect to dashboard or home page + window.location.href = "/dashboard"; + } + }; return ( -

- {email - ? `Welcome back, ${email}!` - : 'Welcome! Please verify your email.'} -

+
+

Login

+ {!codeSent ? ( +
+ + +
+ ) : ( +
+

We sent a verification code to {email}

+ + +
+ )} + {error &&

{error}

} +
); } ``` -## Usage Guide - -### Architecture Overview - -Auth Kit is deployed with the auth middleware integrated into your application server: - -```mermaid -sequenceDiagram - participant Browser - participant ReactNativeApp - participant Server - participant Storage - - Note over Server: Auth Middleware + Web App on same server - - Browser->>Server: Request /auth/* endpoints - ReactNativeApp->>Server: Request /auth/* endpoints - Server->>Storage: Store/retrieve user data - Server->>Browser: Auth response with cookies - Server->>ReactNativeApp: Auth response with tokens - - Browser->>Server: Request app content - Server->>Server: Check auth status (internal) - Server->>Browser: App response with data -``` - -In this deployment: -- **Browser clients** use HTTP cookies for authentication -- **React Native clients** store tokens in secure storage -- The same Auth API endpoints are used by all clients -- The authentication flow applies consistently across platforms - -### Auth Middleware Setup - -The Auth middleware handles all authentication routes and token management, integrated with your web application. - -There are two main approaches to setting up authentication in your application: - -#### Approach 1: Using `withAuth` (Recommended for Most Cases) - -The `withAuth` middleware provides a complete solution that: -1. Handles all standard auth routes (like `/auth/verify`, `/auth/request-code`, etc.) -2. Adds authentication to your custom routes -3. Manages session validation, token refresh, and anonymous user creation +Create a hook to access auth state in your components: -This is the simplest approach for most applications: +```tsx +// src/hooks/useAuth.ts +import { useEffect } from "react"; +import { AuthContext } from "../auth"; -```typescript -// app/worker.ts (e.g., for Remix, Next.js, etc.) -import { withAuth } from "@open-game-collective/auth-kit/server"; -import { Env } from "./env"; -import { createRequestHandler } from "@remix-run/cloudflare"; -import * as build from "@remix-run/dev/server-build"; -import { WorkerEntrypoint } from "@cloudflare/workers-types"; - -// Define hooks outside the worker class - they're only defined once when the module is loaded -const authHooks = { - getUserIdByEmail: async ({ email, env }) => { - try { - const userIdKey = `email:${email}`; - return await env.AUTH_KV.get(userIdKey); - } catch (error) { - console.error("Error getting userId by email:", error); - return null; - } - }, +export function useAuth() { + const client = AuthContext.useClient(); + const state = AuthContext.useSelector(state => state); - // ... other hooks implementation ... -}; - -// Create the auth middleware once when the module is loaded -// This handles BOTH auth routes AND your application routes -const authMiddleware = withAuth( - async (request: Request, env: Env, { userId, sessionId, sessionToken }) => { - // This handler runs for non-auth routes - // Auth routes like /auth/* are handled automatically by the middleware - - const url = new URL(request.url); - - if (url.pathname === '/dashboard') { - return new Response(`Hello, user ${userId}! This is your dashboard.`); + useEffect(() => { + // Refresh the session when the component mounts + if (client) { + client.refresh().catch(err => { + console.error("Failed to refresh session:", err); + }); } - - if (url.pathname === '/profile') { - return new Response(`Hello, user ${userId}! This is your profile.`); + }, [client]); + + const logout = async () => { + if (client) { + await client.logout(); + window.location.href = "/login"; } - - // Default route - return new Response(`Hello, user ${userId}!`); - }, - { - hooks: authHooks, - useTopLevelDomain: true, - basePath: "/auth" - } -); - -// Export the worker -export default { - async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { - // All requests go through the auth middleware - return authMiddleware(request, env, ctx); - } -} satisfies WorkerEntrypoint; + }; + + return { + isAuthenticated: !!state.userId, + isLoading: state.isLoading, + error: state.error, + email: state.email, + userId: state.userId, + logout + }; +} ``` -#### Approach 2: Separate Auth Router (For More Control) - -If you need more control over how auth routes are handled, you can create a separate auth router: - -```typescript -// app/worker.ts (e.g., for Remix, Next.js, etc.) -import { AuthHooks, createAuthRouter } from "@open-game-collective/auth-kit/server"; -import { Env } from "./env"; -import { createRequestHandler } from "@remix-run/cloudflare"; -import * as build from "@remix-run/dev/server-build"; -import { WorkerEntrypoint } from "@cloudflare/workers-types"; - -// Define hooks outside the worker class - they're only defined once when the module is loaded -const authHooks: AuthHooks = { - // ... hooks implementation ... -}; - -// Create the auth router once when the module is loaded -const authRouter = createAuthRouter({ - hooks: authHooks, - useTopLevelDomain: true, - basePath: "/auth" -}); - -// Export the worker -export default { - async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { - const url = new URL(request.url); - - // Handle auth routes - if (url.pathname.startsWith('/auth/')) { - return authRouter(request, env, ctx); - } - - // Handle app routes with Remix - const remixHandler = createRequestHandler({ - build, - mode: process.env.NODE_ENV, - getLoadContext: () => ({ env }) - }); - - return remixHandler(request, env, ctx); - } -} satisfies WorkerEntrypoint; +Now you can use this hook in your components: + +```tsx +// src/components/Header.tsx +import { useAuth } from "../hooks/useAuth"; + +export function Header() { + const { isAuthenticated, email, logout } = useAuth(); + + return ( +
+ +
+ ); +} ``` -This approach requires you to manually handle authentication for your application routes if needed. +## Architecture + +Auth Kit is designed with a modular architecture that separates concerns and provides flexibility for different use cases. -### Using the withAuth Middleware +### Middleware Architecture -The `withAuth` middleware provides a convenient way to add authentication to any request handler. It automatically handles: +Auth Kit provides a flexible middleware architecture that separates authentication middleware from route handlers. This is particularly useful for environments like Cloudflare Workers where middleware and route handling need to be distinct. -1. **Auth Routes**: All standard auth endpoints like `/auth/verify`, `/auth/request-code`, etc. -2. **Session Validation**: Verifies session tokens and refreshes them when needed -3. **Anonymous Users**: Creates anonymous users for new visitors -4. **Auth Context**: Passes authentication information to your handler +#### Key Components -Here are examples for different types of applications: +- **`createAuthRouter`**: Handles auth-specific routes like `/auth/*` +- **`withAuth`**: Creates middleware that applies authentication to your application handler -#### Base Auth withAuth +#### Integration with Cloudflare Workers + +Here's how to integrate Auth Kit with Cloudflare Workers and Remix: ```typescript -import { withAuth } from "@open-game-collective/auth-kit/server"; -import { Env } from "./env"; +import { AuthHooks, withAuth } from "@open-game-collective/auth-kit/server"; +import { createRequestHandler, logDevReady } from "@remix-run/cloudflare"; +import * as build from "@remix-run/dev/server-build"; -// Define your hooks -const authHooks = { /* ... */ }; +// Create the Remix request handler +const handleRemixRequest = createRequestHandler(build); -// Create the middleware once when the module is loaded +if (process.env.NODE_ENV === "development") { + logDevReady(build); +} + +// Define auth hooks +const authHooks: AuthHooks = { + getUserIdByEmail: async ({ email, env }) => { + return await env.KV_STORAGE.get(`email:${email}`); + }, + // ... other hooks implementation +}; + +// Create the auth middleware const authMiddleware = withAuth( - async (request: Request, env: Env, { userId, sessionId, sessionToken }) => { + async (request, env, { userId, sessionId, sessionToken }) => { // This handler runs for non-auth routes // Auth routes like /auth/* are handled automatically by the middleware - const url = new URL(request.url); - - if (url.pathname === '/dashboard') { - return new Response(`Hello, user ${userId}! This is your dashboard.`); - } - - if (url.pathname === '/profile') { - return new Response(`Hello, user ${userId}! This is your profile.`); - } - - // Default route - return new Response(`Hello, user ${userId}!`); + // Pass auth info to Remix + return handleRemixRequest(request, { + env, + userId, + sessionId, + sessionToken, + }); }, { hooks: authHooks, useTopLevelDomain: true, - basePath: "/auth" + basePath: "/auth" // This tells withAuth to handle /auth/* routes internally } ); -// Use in your fetch handler +// Main worker entry point export default { - async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { + async fetch(request: Request, env: Env, ctx: ExecutionContext) { // All requests go through the auth middleware + // - Auth routes like /auth/* are handled automatically + // - Other routes get authentication and are passed to the Remix request handler return authMiddleware(request, env, ctx); } }; ``` -#### Provider Auth withAuth +This pattern provides several benefits: -For provider applications that need account linking functionality: +1. **Simplified Integration**: The `withAuth` function handles both auth routes and application routes +2. **Clear Separation of Concerns**: Auth routes are handled internally by the middleware +3. **Flexibility**: You can use this pattern with any framework or custom request handler +4. **Performance**: Auth routes are handled efficiently by the middleware -```typescript -import { withAuth } from "@open-game-collective/auth-kit/provider/server"; -import { Env } from "./env"; +#### Alternative: Using a Separate Auth Router -// Define provider hooks -const providerHooks = { - // Base auth hooks - getUserIdByEmail: async ({ email, env }) => { /* ... */ }, - storeVerificationCode: async ({ email, code, expiresAt, env }) => { /* ... */ }, - verifyVerificationCode: async ({ email, code, env }) => { /* ... */ }, - sendVerificationCode: async ({ email, code, env }) => { /* ... */ }, - - // Provider-specific hooks - getGameIdFromApiKey: async ({ apiKey, env }) => { /* ... */ }, - storeAccountLink: async ({ openGameUserId, gameId, gameUserId, env }) => { /* ... */ }, - getLinkedAccounts: async ({ openGameUserId, env }) => { /* ... */ }, - removeAccountLink: async ({ openGameUserId, gameId, env }) => { /* ... */ } -}; +If you need more control over auth routes, you can also use a separate router: -// Create the middleware once when the module is loaded -const providerAuthMiddleware = withAuth( - async (request: Request, env: Env, { userId, sessionId, sessionToken }) => { - // This handler runs for non-auth routes - // Auth routes like /auth/* are handled automatically by the middleware - - const url = new URL(request.url); - - if (url.pathname === '/provider/dashboard') { - // You can get linked accounts for this user - const linkedAccounts = await providerHooks.getLinkedAccounts({ - openGameUserId: userId, - env - }); - - return new Response(`Hello, provider user ${userId}! You have ${linkedAccounts.length} linked accounts.`); - } - - // Default route - return new Response(`Hello, provider user ${userId}!`); - }, - { - hooks: providerHooks, - useTopLevelDomain: true, - basePath: "/auth" - } -); +```typescript +import { AuthHooks, createAuthRouter, withAuth } from "@open-game-collective/auth-kit/server"; +import { createRequestHandler, logDevReady } from "@remix-run/cloudflare"; +import * as build from "@remix-run/dev/server-build"; -// Use in your fetch handler -export default { - async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { - // All requests go through the provider auth middleware - // - Provider auth routes like /auth/linked-accounts are handled automatically - // - Other routes get authentication and are passed to your handler - return providerAuthMiddleware(request, env, ctx); - } -}; -``` +// Create the Remix request handler +const handleRemixRequest = createRequestHandler(build); -#### Consumer Auth withAuth +if (process.env.NODE_ENV === "development") { + logDevReady(build); +} -For consumer applications that need to link with a provider: +// Define auth hooks +const authHooks: AuthHooks = { + getUserIdByEmail: async ({ email, env }) => { + return await env.KV_STORAGE.get(`email:${email}`); + }, + // ... other hooks implementation +}; -```typescript -import { withAuth } from "@open-game-collective/auth-kit/consumer/server"; -import { Env } from "./env"; +// Create the auth router for handling auth routes +const authRouter = createAuthRouter({ + hooks: authHooks, + useTopLevelDomain: true, + basePath: "/auth" +}); -// Define consumer hooks -const consumerHooks = { - // Base auth hooks - getUserIdByEmail: async ({ email, env }) => { /* ... */ }, - storeVerificationCode: async ({ email, code, expiresAt, env }) => { /* ... */ }, - verifyVerificationCode: async ({ email, code, env }) => { /* ... */ }, - sendVerificationCode: async ({ email, code, env }) => { /* ... */ }, +// Define your application handler function +const handleAppRequest = async ( + request: Request, + env: Env, + authInfo: { userId: string, sessionId: string, sessionToken: string } +) => { + const { userId, sessionId, sessionToken } = authInfo; - // Consumer-specific hooks - storeOpenGameLink: async ({ gameUserId, openGameUserId, env }) => { /* ... */ }, - getOpenGameUserId: async ({ gameUserId, env }) => { /* ... */ }, - getOpenGameProfile: async ({ openGameUserId, env }) => { /* ... */ } + // Pass auth info to Remix + return await handleRemixRequest(request, { + env, + userId, + sessionId, + sessionToken, + }); }; -// Create the middleware once when the module is loaded -const consumerAuthMiddleware = withAuth( - async (request: Request, env: Env, { userId, sessionId, sessionToken }) => { - // This handler runs for non-auth routes - // Auth routes like /auth/* are handled automatically by the middleware - +// Create the auth handler using withAuth +const handler = withAuth(handleAppRequest, { + hooks: authHooks, + useTopLevelDomain: true +}); + +// Main worker entry point +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext) { const url = new URL(request.url); - if (url.pathname === '/game/profile') { - // You can check if this user is linked with OpenGame - const openGameUserId = await consumerHooks.getOpenGameUserId({ - gameUserId: userId, - env - }); - - if (openGameUserId) { - const profile = await consumerHooks.getOpenGameProfile?.({ - openGameUserId, - env - }) || null; - - return new Response(`Hello, game user ${userId}! You're linked with OpenGame user ${openGameUserId}.`); - } - - return new Response(`Hello, game user ${userId}! You're not linked with OpenGame yet.`); + // Handle auth routes with the auth router + if (url.pathname.startsWith('/auth/')) { + return authRouter(request, env, ctx); } - // Default route - return new Response(`Hello, game user ${userId}!`); - }, - { - hooks: consumerHooks, - gameId: "your-game-id", // Required for consumer - useTopLevelDomain: true, - basePath: "/auth" - } -); - -// Use in your fetch handler -export default { - async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { - // All requests go through the consumer auth middleware - // - Consumer auth routes like /auth/opengame-link are handled automatically - // - Other routes get authentication and are passed to your handler - return consumerAuthMiddleware(request, env, ctx); + // Handle all other routes with the auth handler, which passes them to the Remix request handler + return handler(request, env, ctx); } }; ``` -This approach offers several advantages: - -1. **Simplified Structure**: No need for a separate `AuthWorker` class. -2. **Initialization Efficiency**: Hooks are defined only once when the module is loaded, not on every request. -3. **Direct Integration**: Auth router is created and used directly in the main application entrypoint. -4. **Reduced Complexity**: Fewer moving parts and clearer flow of execution. -5. **Same Benefits**: Still maintains all the benefits of the previous approach. - -### Configuring Cloudflare KV - -To use KV with your worker, you need to configure your `wrangler.toml` file: - -```toml -name = "auth-kit-example" -main = "src/index.ts" -compatibility_date = "2023-10-30" +This approach gives you more control over how auth routes are handled, which can be useful in specific scenarios like: +- When you need to apply different middleware to auth routes +- When you want to handle auth routes at the edge but process application routes in a Durable Object +- When you need to customize the auth route handling beyond what `withAuth` provides -# Define the KV namespace -[[kv_namespaces]] -binding = "AUTH_KV" -id = "your-kv-namespace-id" -preview_id = "your-preview-kv-namespace-id" -``` +#### Example with Durable Objects -Then, define your environment interface: +If you're using Durable Objects, you can adapt the pattern like this: ```typescript -// env.ts -export interface Env { - AUTH_KV: KVNamespace; - AUTH_SECRET: string; - SENDGRID_API_KEY: string; - GAME_ID?: string; // For consumer apps - GAME_NAMES: Record; // For provider apps -} -``` +import { AuthHooks, withAuth } from "@open-game-collective/auth-kit/server"; +import { createRequestHandler } from "@remix-run/cloudflare"; +import * as build from "@remix-run/dev/server-build"; +import { DurableObject } from "cloudflare:workers"; -For production, you might want to use a more sophisticated logging solution: +// Create the Remix request handler +const handleRemixRequest = createRequestHandler(build); -```typescript -// logger.ts -export const logger = { - debug: (message: string, ...args: any[]) => { - if (process.env.NODE_ENV === 'development') { - console.log(`[DEBUG] ${message}`, ...args); - } - }, - info: (message: string, ...args: any[]) => { - console.log(`[INFO] ${message}`, ...args); - }, - warn: (message: string, ...args: any[]) => { - console.warn(`[WARN] ${message}`, ...args); - }, - error: (message: string, error?: Error, ...args: any[]) => { - console.error(`[ERROR] ${message}`, error, ...args); - - // In production, you might want to send errors to a monitoring service - if (process.env.NODE_ENV === 'production' && typeof process.env.SENTRY_DSN === 'string') { - // Send to error monitoring - } - } +// Define auth hooks +const authHooks: AuthHooks = { + // ... hooks implementation }; -// Usage in auth hooks -const hooks = { - verifyVerificationCode: async ({ email, code, env }) => { - logger.debug('Verifying code', { email, codeLength: code.length }); - // Verification logic... +// Create the auth middleware for the Durable Object +const authMiddleware = withAuth( + async (request, env, { userId, sessionId, sessionToken }) => { + // Pass auth info to Remix + return handleRemixRequest(request, { + env, + userId, + sessionId, + sessionToken, + }); + }, + { + hooks: authHooks, + useTopLevelDomain: true, + basePath: "/auth" // This tells withAuth to handle /auth/* routes internally } -}; -``` - -### Key Structure for KV - -When using KV for auth data, a good key structure helps organize your data: - -- `user:{userId}` - User data -- `email:{email}` - Maps email to userId -- `verification:{email}` - Verification codes -- `accountLink:{openGameUserId}:{gameId}` - Account links from provider perspective -- `gameLink:{gameId}:{gameUserId}` - Account links from consumer perspective -- `apiKey:{apiKey}` - Maps API keys to game IDs - -This structure makes it easy to find and manage related data. - -For provider or consumer-specific functionality, you would use the corresponding router: - -```typescript -// For provider functionality -import { createProviderAuthRouter } from "@open-game-collective/auth-kit/provider/server"; - -// Define provider-specific hooks... -const providerHooks = { - // Base auth hooks... - - // Provider-specific hooks - getGameIdFromApiKey: async ({ apiKey, env }) => { /* ... */ }, - storeAccountLink: async ({ openGameUserId, gameId, gameUserId, env }) => { /* ... */ }, - getLinkedAccounts: async ({ openGameUserId, env }) => { /* ... */ }, - removeAccountLink: async ({ openGameUserId, gameId, env }) => { /* ... */ } -}; +); -// In your fetch handler -if (url.pathname.startsWith('/auth/')) { - return createProviderAuthRouter({ - hooks: providerHooks, - useTopLevelDomain: true, - basePath: "/auth" - })(request, env, ctx); +// Durable Object implementation +export class AppDO extends DurableObject { + async fetch(request: Request): Promise { + // No need to check for /auth/ paths - withAuth handles that internally + return authMiddleware(request, this.env); + } } -``` - -```typescript -// For consumer functionality -import { createConsumerAuthRouter } from "@open-game-collective/auth-kit/consumer/server"; -// Define consumer-specific hooks... -const consumerHooks = { - // Base auth hooks... - - // Consumer-specific hooks - storeOpenGameLink: async ({ gameUserId, openGameUserId, env }) => { /* ... */ }, - getOpenGameUserId: async ({ gameUserId, env }) => { /* ... */ }, - getOpenGameProfile: async ({ openGameUserId, env }) => { /* ... */ } +// Main worker entry point +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext) { + // For all routes, use the Durable Object + const id = env.APP_DO.idFromName("default"); + const appDO = env.APP_DO.get(id); + return appDO.fetch(request); + } }; - -// In your fetch handler -if (url.pathname.startsWith('/auth/')) { - return createConsumerAuthRouter({ - hooks: consumerHooks, - gameId: env.GAME_ID, // Required for consumer router - useTopLevelDomain: true, - basePath: "/auth" - })(request, env, ctx); -} ``` -**Auth Endpoints:** - -- `POST /auth/anonymous`: Create anonymous user -- `POST /auth/request-code`: Request email verification code -- `POST /auth/verify`: Verify email code -- `POST /auth/refresh`: Refresh session token -- `POST /auth/logout`: Clear session -- `POST /auth/web-code`: Generate one-time web auth code - -**Provider Endpoints:** +For more detailed examples, including provider/consumer implementations, see the documentation. -- `GET /auth/linked-accounts`: Get linked accounts -- `POST /auth/account-link-token`: Create account link token -- `DELETE /auth/linked-accounts/:gameId`: Unlink account -- `POST /auth/verify-link-token`: Verify link token from consumer -- `POST /auth/confirm-link`: Confirm account link +## Account Linking -**Consumer Endpoints:** +Auth Kit provides a robust account linking system that allows applications to connect user accounts across the Open Game ecosystem. This enables rich cross-application features such as push notifications, achievement sharing, and synchronized experiences, while maintaining each application's independent authentication system. -- `GET /auth/opengame-link`: Get OpenGame link status -- `POST /auth/verify-link-token`: Verify link token -- `POST /auth/confirm-link`: Confirm account link +### Provider-Consumer Model -### React API +Account linking follows a provider-consumer model: -`createAuthContext()` +- **Provider** (e.g., OpenGame): The central identity provider that manages user accounts +- **Consumer** (e.g., Game applications): Applications that integrate with the provider for feature sharing -Creates a React context for auth state management, providing: -- A Provider for passing down the auth client. -- Hooks: `useClient` and `useSelector` for accessing and subscribing to state. -- Conditional components: ``, ``, ``, and ``. +Account linking is not about authentication delegation, but rather about enabling cross-application features such as: -```typescript -import { createAuthContext } from '@open-game-collective/auth-kit/react'; -import { createAuthClient } from '@open-game-collective/auth-kit/client'; +- Push notifications from the provider app for events in consumer apps +- Profile and achievement sharing across applications +- Synchronized preferences and settings +- Cross-application rewards and progression +- Unified social features and friend connections -const AuthContext = createAuthContext(); -const client = createAuthClient({ - host: 'your-api.example.com', - userId: 'user-123', - sessionToken: 'jwt-token' -}); +Each application maintains its own authentication system, but linking accounts allows for a richer, connected user experience across the ecosystem. -function App() { - return ( - - - - - - - - - - - - - - ); -} -``` +### Account Linking Flow -**Using the useSelector Hook:** +The following diagram illustrates how accounts are linked between the provider (OpenGame) and consumer applications (games), enabling cross-application features while maintaining separate authentication systems: -```typescript -function UserGreeting() { - const email = AuthContext.useSelector(state => state.email); - - return ( -

- {email - ? `Welcome back, ${email}!` - : 'Welcome! Please verify your email.'} -

- ); -} +```mermaid +sequenceDiagram + participant User + participant OGApp as Provider App + participant GameApp as Consumer App + participant ProviderAuth as Provider Auth API + participant ConsumerAuth as Consumer Auth API + + User->>OGApp: Initiates account linking + OGApp->>ProviderAuth: Requests link token + ProviderAuth->>OGApp: Returns link token + OGApp->>User: Displays link URL/QR code + User->>GameApp: Opens link URL + GameApp->>ConsumerAuth: Verifies link token + ConsumerAuth->>ProviderAuth: Validates token + ProviderAuth->>ConsumerAuth: Confirms token validity + GameApp->>User: Requests confirmation + User->>GameApp: Confirms linking + GameApp->>ConsumerAuth: Confirms link + ConsumerAuth->>ProviderAuth: Stores account link + ProviderAuth->>ConsumerAuth: Confirms success + GameApp->>User: Shows success message + Note over User, ConsumerAuth: After linking, cross-app features are enabled ``` -#### Provider React API - -`createProviderAuthContext()` - -Creates a React context specifically for provider authentication, providing: -- A Provider for passing down the provider auth client. -- Hooks: `useClient` and `useSelector` for accessing and subscribing to provider state. -- Components for managing linked accounts: - - ``: Renders children when user has linked accounts - - ``: Renders children when user has no linked accounts - - ``: Renders a function child with linked accounts data - - ``: Renders a function child with account linking functionality - - ``: Renders a function child with account unlinking functionality - -#### Consumer React API - -`createConsumerAuthContext()` - -Creates a React context specifically for consumer (game) authentication, providing: -- A Provider for passing down the consumer auth client. -- Hooks: `useClient` and `useSelector` for accessing and subscribing to consumer state. -- Components for managing open game linking: - - ``: Renders children when user is linked with an open game - - ``: Renders children when user is not linked with an open game - - ``: Renders a function child with open game profile data - - ``: Renders a function child with link token verification functionality - - ``: Renders a function child with link confirmation functionality +Once accounts are linked, the provider application can send push notifications about events in the consumer application, share profile information between applications, and enable other cross-application features - all while each application maintains its own independent authentication system. -### Test API +### Implementation -`createAuthMockClient(config)` +Auth Kit provides specialized APIs for both providers and consumers: -Creates a mock auth client for testing. This is useful for testing UI components that depend on auth state without needing a real server. +#### Provider Implementation ```typescript -import { createAuthMockClient } from '@open-game-collective/auth-kit/test'; - -it('shows verified content when user is verified', () => { - const mockClient = createAuthMockClient({ - initialState: { - isLoading: false, - userId: 'test-user', - sessionToken: 'test-session', - email: 'user@example.com' // non-null email indicates verified - } - }); - - render( - - - - ); +// Server-side setup +import { createProviderAuthRouter } from "@open-game-collective/auth-kit/provider/server"; - // Test that verified content is shown - expect(screen.getByText('Welcome back!')).toBeInTheDocument(); +const providerRouter = createProviderAuthRouter({ + hooks: { + // Base auth hooks + getUserIdByEmail: async ({ email, env }) => { /* ... */ }, + // ... other base hooks + + // Provider-specific hooks + getGameIdFromApiKey: async ({ apiKey, env }) => { /* ... */ }, + storeAccountLink: async ({ openGameUserId, gameId, gameUserId, env }) => { /* ... */ }, + getLinkedAccounts: async ({ openGameUserId, env }) => { /* ... */ }, + removeAccountLink: async ({ openGameUserId, gameId, env }) => { /* ... */ }, + }, + useTopLevelDomain: true, // Optional + basePath: "/auth" // Optional, defaults to "/auth" }); -``` - -**Provider and Consumer Mock Clients:** -```typescript -import { - createProviderAuthMockClient, - createConsumerAuthMockClient -} from '@open-game-collective/auth-kit/test'; +// Client-side implementation +import { createProviderAuthClient } from "@open-game-collective/auth-kit/provider/client"; +import { createProviderAuthContext } from "@open-game-collective/auth-kit/provider/react"; -// Provider mock client -const providerMockClient = createProviderAuthMockClient({ - initialState: { - linkedAccounts: [ - { gameId: 'game-123', gameUserId: 'user-456', linkedAt: '2023-01-01T00:00:00Z' } - ] - } +const ProviderAuthContext = createProviderAuthContext(); +const providerClient = createProviderAuthClient({ + host: "your-api.example.com", + userId: "provider-123", + sessionToken: "jwt-token" }); -// Consumer mock client -const consumerMockClient = createConsumerAuthMockClient({ - initialState: { - openGameLink: { - openGameUserId: 'og-123', - linkedAt: '2023-01-01T00:00:00Z' - } - } -}); +function AccountLinkingUI() { + return ( + + +

Your Linked Accounts

+ + + {({ accounts, onUnlink }) => ( +
    + {accounts.map(account => ( +
  • + {account.gameId} + +
  • + ))} +
+ )} +
+
+ + + {({ onInitiate, isInitiating, error }) => ( + + )} + +
+ ); +} ``` -The mock clients provide additional testing utilities: - -- `produce(recipe)`: Update the mock client state using a recipe function -- `getState()`: Get current state -- All client methods are test spies for tracking calls -- State changes are synchronous for easier testing -- No actual network requests are made - -## Package Structure - -Auth Kit is organized into several modules to provide a clean separation of concerns: +#### Consumer Implementation -``` -@open-game-collective/auth-kit/ -├── client # Base client for authentication -├── react # React integration for base auth -├── server # Base server for authentication -├── test # Testing utilities -├── provider/ -│ ├── client # Provider-specific client -│ ├── react # Provider-specific React integration -│ └── server # Provider-specific server -└── consumer/ - ├── client # Consumer-specific client - ├── react # Consumer-specific React integration - └── server # Consumer-specific server -``` +```typescript +// Server-side setup +import { createConsumerAuthRouter } from "@open-game-collective/auth-kit/consumer/server"; -This structure allows you to import only what you need for your specific use case: +const consumerRouter = createConsumerAuthRouter({ + hooks: { + // Base auth hooks + getUserIdByEmail: async ({ email, env }) => { /* ... */ }, + // ... other base hooks + + // Consumer-specific hooks + storeOpenGameLink: async ({ gameUserId, openGameUserId, env }) => { /* ... */ }, + getOpenGameUserId: async ({ gameUserId, env }) => { /* ... */ }, + getOpenGameProfile: async ({ openGameUserId, env }) => { /* ... */ }, + }, + gameId: "your-game-id", // Required for consumer router + useTopLevelDomain: true, // Optional + basePath: "/auth" // Optional, defaults to "/auth" +}); -```typescript -// Base authentication -import { createAuthClient } from '@open-game-collective/auth-kit/client'; -import { createAuthContext } from '@open-game-collective/auth-kit/react'; -import { createAuthRouter } from '@open-game-collective/auth-kit/server'; +// Client-side implementation +import { createConsumerAuthClient } from "@open-game-collective/auth-kit/consumer/client"; +import { createConsumerAuthContext } from "@open-game-collective/auth-kit/consumer/react"; -// Provider-specific (OpenGame) -import { createProviderAuthClient } from '@open-game-collective/auth-kit/provider/client'; -import { createProviderAuthContext } from '@open-game-collective/auth-kit/provider/react'; -import { createProviderAuthRouter } from '@open-game-collective/auth-kit/provider/server'; +const ConsumerAuthContext = createConsumerAuthContext(); +const consumerClient = createConsumerAuthClient({ + host: "your-api.example.com", + userId: "game-user-123", + sessionToken: "jwt-token", + gameId: "your-game-id" // Required for consumer client +}); -// Consumer-specific (Games) -import { createConsumerAuthClient } from '@open-game-collective/auth-kit/consumer/client'; -import { createConsumerAuthContext } from '@open-game-collective/auth-kit/consumer/react'; -import { createConsumerAuthRouter } from '@open-game-collective/auth-kit/consumer/server'; +function LinkVerificationUI({ linkToken }) { + return ( + + + {({ isVerifying, isValid, openGameUserId, email, error }) => ( +
+ {isVerifying ? ( +

Verifying link...

+ ) : isValid ? ( + + {({ onConfirm, isConfirming, isConfirmed, error }) => ( +
+

Link your account with {email}?

+ +
+ )} +
+ ) : ( +

Invalid or expired link token

+ )} +
+ )} +
+
+ ); +} ``` -## Recent Changes - -### v0.0.11 +### Security Considerations -- **Code Organization**: Improved code structure with better separation of concerns - - Moved provider-specific code to `provider-server.ts` - - Moved consumer-specific code to `consumer-server.ts` - - Kept base authentication code in `server.ts` - - Shared helper functions are exported from `server.ts` and imported into other files +The account linking system includes several security features to ensure secure cross-application communication: -- **Package Exports**: Added explicit exports for provider and consumer modules - - Added exports for `./provider/server` pointing to `provider-server.ts` - - Added exports for `./consumer/server` pointing to `consumer-server.ts` - - Maintained backward compatibility with existing imports +1. **JWT-Based Link Tokens**: Cryptographically signed tokens with short expiration times ensure secure linking process +2. **API Key Authentication**: Server-to-server communication secured with API keys for trusted application verification +3. **User Confirmation**: Explicit user consent required before enabling cross-application features +4. **Secure Storage**: Account links stored securely on both provider and consumer sides +5. **Unlinking Capability**: Users can disable cross-application features by unlinking accounts at any time +6. **Limited Data Sharing**: Only necessary data is shared between applications, with clear user consent +7. **Independent Authentication**: Each application maintains its own authentication system, with no credential sharing -- **Bug Fixes**: - - Fixed JWT token handling in tests to properly mock the SignJWT class - - Improved error handling in the `createLinkToken` function - - Fixed unused variables and parameters - - Removed unnecessary else clauses for cleaner code - - Standardized code formatting +### Benefits -- **Testing Improvements**: - - Enhanced test mocks for better reliability - - Fixed test failures related to account linking - - Added special handling for test environments in token creation +- **Connected Ecosystem**: Enable rich interactions between different applications in the ecosystem +- **Enhanced User Experience**: Provide seamless cross-application features without requiring users to manually connect accounts +- **Push Notifications**: Allow the provider app to send notifications about events in linked consumer apps +- **Feature Sharing**: Share profiles, achievements, and other data across applications with user consent +- **Independent Authentication**: Each application maintains its own authentication while still enabling connected experiences +- **Secure Communication**: All communication between applications is secured with API keys and JWT tokens +- **User Control**: Users can link and unlink accounts at any time, maintaining control over their connected experience -These changes improve the maintainability of the codebase, reduce duplication, and ensure that all tests pass successfully. +For detailed implementation guidance, see the [Account Linking Implementation Guide](docs/account-linking.md). ## API Reference @@ -1432,524 +1140,151 @@ const consumerClient = createConsumerAuthClient({ // Optional initial state initialState: { openGameLink: undefined, - requests: {} - } -}); -``` - -### Server API - -The server provides three main exports for each type of server (base, provider, and consumer): - -1. **Router Creation**: - - `createAuthRouter`: Creates a base auth router that handles all auth endpoints - - `createProviderAuthRouter`: Creates a provider auth router for account linking - - `createConsumerAuthRouter`: Creates a consumer auth router for OpenGame linking - -2. **Authentication Middleware**: - - `withAuth` (from `/server`): Middleware that integrates base authentication with your app - - `withAuth` (from `/provider/server`): Middleware for provider authentication - - `withAuth` (from `/consumer/server`): Middleware for consumer authentication - -**Important**: The `withAuth` middleware handles both auth routes (like `/auth/*`) AND your custom routes. In most cases, you only need to use `withAuth` without a separate auth router. - -**Creating an Auth Router (When You Need Separate Control):** - -```typescript -import { createAuthRouter } from '@open-game-collective/auth-kit/server'; - -// Create the router once when the module is loaded -const authRouter = createAuthRouter({ - hooks: { - // Required hooks - getUserIdByEmail: async ({ email, env }) => { /* ... */ }, - storeVerificationCode: async ({ email, code, expiresAt, env }) => { /* ... */ }, - verifyVerificationCode: async ({ email, code, env }) => { /* ... */ }, - sendVerificationCode: async ({ email, code, env }) => { /* ... */ }, - - // Optional hooks - onNewUser: async ({ userId, env }) => { /* ... */ }, - onAuthenticate: async ({ userId, env }) => { /* ... */ }, - onEmailVerified: async ({ userId, email, env }) => { /* ... */ }, - getUserEmail: async ({ userId, env }) => { /* ... */ } - }, - useTopLevelDomain: true, // Optional, enables cookies to work across subdomains - basePath: "/auth" // Optional, defaults to "/auth", customize the base URL path for all auth endpoints -}); - -// Use the router in your fetch handler -async function handleRequest(request: Request, env: Env, ctx: ExecutionContext) { - const url = new URL(request.url); - - // Only handle auth routes with the router - if (url.pathname.startsWith('/auth/')) { - return authRouter(request, env, ctx); - } - - // Handle app routes separately - // Note: These routes won't have authentication - return new Response('Hello World'); -} -``` - -**Using the withAuth Middleware (Recommended):** - -```typescript -import { withAuth } from '@open-game-collective/auth-kit/server'; - -// Create the middleware once when the module is loaded -const authMiddleware = withAuth( - async (request, env, { userId, sessionId, sessionToken }) => { - // This handler runs for non-auth routes - // Auth routes like /auth/* are handled automatically by the middleware - - const url = new URL(request.url); - - if (url.pathname === '/dashboard') { - return new Response(`Hello, user ${userId}! This is your dashboard.`); - } - - // Default route - return new Response(`Hello, user ${userId}!`); - }, - { - hooks: { - // Same hooks as createAuthRouter - getUserIdByEmail: async ({ email, env }) => { /* ... */ }, - // ... other hooks - }, - useTopLevelDomain: true, // Optional - basePath: "/auth" // Optional, defaults to "/auth" - } -); - -// Use the middleware in your fetch handler -async function handleRequest(request, env, ctx) { - // All requests go through the auth middleware - // - Auth routes like /auth/* are handled automatically - // - Other routes get authentication and are passed to your handler - return authMiddleware(request, env, ctx); -} + requests: {} + } +}); ``` -**Provider Router and Middleware:** +### Server API -```typescript -import { createProviderAuthRouter, withAuth } from '@open-game-collective/auth-kit/provider/server'; +The server provides three main exports for each type of server (base, provider, and consumer): -// Option 1: Create a separate provider router (when you need separate control) -const providerRouter = createProviderAuthRouter({ - hooks: { - // Base auth hooks - // ... - - // Provider-specific hooks - getGameIdFromApiKey: async ({ apiKey, env }) => { /* ... */ }, - storeAccountLink: async ({ openGameUserId, gameId, gameUserId, env }) => { /* ... */ }, - getLinkedAccounts: async ({ openGameUserId, env }) => { /* ... */ }, - removeAccountLink: async ({ openGameUserId, gameId, env }) => { /* ... */ } - }, - useTopLevelDomain: true, // Optional - basePath: "/auth" // Optional -}); +1. **Router Creation**: + - `createAuthRouter`: Creates a base auth router that handles all auth endpoints + - `createProviderAuthRouter`: Creates a provider auth router for account linking + - `createConsumerAuthRouter`: Creates a consumer auth router for OpenGame linking -// Option 2: Create provider-specific authenticated middleware (recommended) -const providerAuthMiddleware = withAuth( - async (request, env, { userId, sessionId, sessionToken }) => { - // This handler runs for non-auth routes - // Auth routes like /auth/* are handled automatically by the middleware - - const url = new URL(request.url); - - if (url.pathname === '/dashboard') { - return new Response(`Hello, provider user ${userId}!`); - } - - // Default route - return new Response('Hello World'); - }, - { - hooks: { - // Same hooks as createProviderAuthRouter - }, - useTopLevelDomain: true, // Optional - basePath: "/auth" // Optional - } -); +2. **Authentication Middleware**: + - `withAuth` (from `/server`): Middleware that integrates base authentication with your app + - `withAuth` (from `/provider/server`): Middleware for provider authentication + - `withAuth` (from `/consumer/server`): Middleware for consumer authentication -// Use in your fetch handler -async function handleRequest(request, env, ctx) { - // Option 1: Use separate router - // const url = new URL(request.url); - // if (url.pathname.startsWith('/auth/')) { - // return providerRouter(request, env, ctx); - // } - - // Option 2: Use middleware for everything (recommended) - return providerAuthMiddleware(request, env, ctx); -} -``` +**Important**: The `withAuth` middleware handles both auth routes (like `/auth/*`) AND your custom routes. In most cases, you only need to use `withAuth` without a separate auth router. -**Consumer Router and Middleware:** +**Using the withAuth Middleware (Recommended):** ```typescript -import { createConsumerAuthRouter, withAuth } from '@open-game-collective/auth-kit/consumer/server'; +import { withAuth } from '@open-game-collective/auth-kit/server'; +import { createRequestHandler } from '@remix-run/cloudflare'; +import * as build from '@remix-run/dev/server-build'; -// Option 1: Create a separate consumer router (when you need separate control) -const consumerRouter = createConsumerAuthRouter({ - hooks: { - // Base auth hooks - // ... - - // Consumer-specific hooks - storeOpenGameLink: async ({ gameUserId, openGameUserId, env }) => { /* ... */ }, - getOpenGameUserId: async ({ gameUserId, env }) => { /* ... */ }, - getOpenGameProfile: async ({ openGameUserId, env }) => { /* ... */ } - }, - gameId: "your-game-id", // Required for consumer router - useTopLevelDomain: true, // Optional - basePath: "/auth" // Optional -}); +// Create the Remix request handler +const remixHandler = createRequestHandler(build); -// Option 2: Create consumer-specific authenticated middleware (recommended) -const consumerAuthMiddleware = withAuth( +// Create the middleware once when the module is loaded +const authMiddleware = withAuth( async (request, env, { userId, sessionId, sessionToken }) => { // This handler runs for non-auth routes // Auth routes like /auth/* are handled automatically by the middleware - const url = new URL(request.url); - - if (url.pathname === '/profile') { - return new Response(`Hello, game user ${userId}!`); - } - - // Default route - return new Response('Hello World'); + // Pass auth info to Remix + return remixHandler(request, { + env, + userId, + sessionId, + sessionToken, + // Any other context you want to provide to your routes + }); }, { hooks: { - // Same hooks as createConsumerAuthRouter + // Same hooks as createAuthRouter + getUserIdByEmail: async ({ email, env }) => { /* ... */ }, + // ... other hooks }, - gameId: "your-game-id", // Required for consumer useTopLevelDomain: true, // Optional - basePath: "/auth" // Optional + basePath: "/auth" // Optional, defaults to "/auth" } ); -// Use in your fetch handler -async function handleRequest(request, env, ctx) { - // Option 1: Use separate router - // const url = new URL(request.url); - // if (url.pathname.startsWith('/auth/')) { - // return consumerRouter(request, env, ctx); - // } - - // Option 2: Use middleware for everything (recommended) - return consumerAuthMiddleware(request, env, ctx); -} +// Use the middleware in your fetch handler +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext) { + // All requests go through the auth middleware + // - Auth routes like /auth/* are handled automatically + // - Other routes get authentication and are passed to your framework's handler + return authMiddleware(request, env, ctx); + } +}; ``` -**Auth Endpoints:** - -- `POST /auth/anonymous`: Create anonymous user -- `POST /auth/request-code`: Request email verification code -- `POST /auth/verify`: Verify email code -- `POST /auth/refresh`: Refresh session token -- `POST /auth/logout`: Clear session -- `POST /auth/web-code`: Generate one-time web auth code - -**Provider Endpoints:** - -- `GET /auth/linked-accounts`: Get linked accounts -- `POST /auth/account-link-token`: Create account link token -- `DELETE /auth/linked-accounts/:gameId`: Unlink account -- `POST /auth/verify-link-token`: Verify link token from consumer -- `POST /auth/confirm-link`: Confirm account link - -**Consumer Endpoints:** - -- `GET /auth/opengame-link`: Get OpenGame link status -- `POST /auth/verify-link-token`: Verify link token -- `POST /auth/confirm-link`: Confirm account link - -### Using withAuth with Hono - -[Hono](https://hono.dev/) is a popular lightweight web framework for Cloudflare Workers. Here's how to integrate Auth Kit's `withAuth` with Hono: +**Creating a Separate Auth Router (When You Need More Control):** ```typescript -import { Hono } from 'hono'; -import { withAuth } from "@open-game-collective/auth-kit/server"; +import { createAuthRouter, createAuthHandler } from '@open-game-collective/auth-kit/server'; +import { createRequestHandler } from '@remix-run/cloudflare'; +import * as build from '@remix-run/dev/server-build'; import { Env } from "./env"; +// Create the Remix request handler +const remixHandler = createRequestHandler(build); + // Define your hooks -const authHooks = { - getUserIdByEmail: async ({ email, env }) => { /* ... */ }, - // ... other hooks -}; +const authHooks = { /* ... */ }; + +// Create the auth router for auth endpoints +const authRouter = createAuthRouter({ + hooks: authHooks, + useTopLevelDomain: true, + basePath: "/auth" +}); -// Create a Hono app -const app = new Hono<{ Bindings: Env, Variables: { auth?: { userId: string, sessionId: string, sessionToken: string } } }>(); - -// Create the auth handler -const authHandler = withAuth( - async (request, env, authInfo) => { - // Return a response with auth info in headers - // This is just to pass the auth info to our middleware - const response = new Response(null, { status: 200 }); - response.headers.set('X-Auth-Info', JSON.stringify(authInfo)); - return response; +// Create an authentication handler for your app routes +const authHandler = createAuthHandler( + async (request, env, { userId, sessionId, sessionToken }) => { + // Pass auth info to Remix + return remixHandler(request, { + env, + userId, + sessionId, + sessionToken, + // Any other context you want to provide to your routes + }); }, { hooks: authHooks, - useTopLevelDomain: true, - basePath: "/auth" + useTopLevelDomain: true } ); -// Auth middleware for Hono -app.use('*', async (c, next) => { - const { req, env } = c; - - // Check if this is an auth route - const url = new URL(req.url); - if (url.pathname.startsWith('/auth/')) { - // Handle auth routes directly - return authHandler(req.raw, env); - } - - try { - // Process the request through the auth handler - const authResponse = await authHandler(req.raw, env); - - // If auth was successful, extract the auth info - if (authResponse.ok) { - const authInfoStr = authResponse.headers.get('X-Auth-Info'); - if (authInfoStr) { - const authInfo = JSON.parse(authInfoStr); - // Store auth info in Hono's context - c.set('auth', authInfo); - } - } - - // Continue to the next middleware/route handler - return next(); - } catch (error) { - // If there's an error in auth processing, return an error response - return new Response(JSON.stringify({ error: 'Authentication error' }), { - status: 401, - headers: { 'Content-Type': 'application/json' } - })); - } -}); - -// Create a middleware function that adds auth info to the request object -function createAuthMiddleware() { - // Create the withAuth handler - const authHandler = withAuth( - async (request, env, authInfo) => { - // Store auth info in a custom property on the request object - // We'll use a WeakMap to avoid modifying the Request object directly - const requestExt = new Request(request); - requestMap.set(requestExt, { - auth: authInfo, - originalRequest: request - }); - - // Return the extended request to be used by the next middleware - return requestExt; - }, - { - hooks: authHooks, - useTopLevelDomain: true, - basePath: "/auth" - } - ); - - // Return the middleware function - return async (request: Request, env: Env, ctx: ExecutionContext, next: (req: Request) => Promise) => { - // Check if this is an auth route +// Use in your fetch handler +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { const url = new URL(request.url); - if (url.pathname.startsWith('/auth/')) { - // Handle auth routes directly - return authHandler(request, env, ctx); - } - - try { - // Process the request through the auth handler - const extendedRequest = await authHandler(request, env, ctx) as Request; - - // Call the next middleware with the extended request - return await next(extendedRequest); - } catch (error) { - // If there's an error in auth processing, return an error response - return new Response(JSON.stringify({ error: 'Authentication error' }), { - status: 401, - headers: { 'Content-Type': 'application/json' } - }); - } - }; -} - -// WeakMap to store auth info without modifying Request objects -const requestMap = new WeakMap(); - -// Helper to get auth info from a request -export function getAuthInfo(request: Request) { - const info = requestMap.get(request); - if (!info) { - throw new Error('Request has not been processed by auth middleware'); - } - return info.auth; -} - -// Example usage with an Express-like middleware stack -const router = { - async fetch(request: Request, env: Env, ctx: ExecutionContext) { - // Create middleware stack - const middlewares = [ - createAuthMiddleware(), - async (request: Request, env: Env, ctx: ExecutionContext, next: (req: Request) => Promise) => { - console.log('Request received:', new URL(request.url).pathname); - return next(request); - }, - // Add more middlewares as needed - ]; - // Final handler - const finalHandler = async (request: Request) => { - const url = new URL(request.url); - - if (url.pathname === '/dashboard') { - // Get auth info from the request - const { userId } = getAuthInfo(request); - return new Response(`Hello, user ${userId}! This is your dashboard.`); - } - - return new Response('Hello World'); - }; - - // Execute middleware chain - let currentHandler = finalHandler; - - // Build the middleware chain in reverse - for (const middleware of [...middlewares].reverse()) { - const next = currentHandler; - currentHandler = (request) => middleware(request, env, ctx, next); + // Handle auth routes with the auth router + if (url.pathname.startsWith('/auth/')) { + return authRouter(request, env, ctx); } - // Start the middleware chain - return currentHandler(request); + // Handle app routes with the auth handler, which passes them to the Remix request handler + return authHandler(request, env, ctx); } }; - -// Export the worker -export default router; -``` - -This approach allows you to: - -1. Use `withAuth` in an Express-style middleware pattern -2. Automatically handle auth routes (`/auth/*`) -3. Add authentication information to requests for other routes -4. Access auth information in subsequent middleware or route handlers -5. Maintain the chain of middleware execution - -You can also adapt this pattern for provider and consumer authentication by using the appropriate `withAuth` function: - -```typescript -import { withAuth } from "@open-game-collective/auth-kit/provider/server"; -// or -import { withAuth } from "@open-game-collective/auth-kit/consumer/server"; - -// Then follow the same pattern as above -``` - -### Configuring Cloudflare KV - -To use KV with your worker, you need to configure your `wrangler.toml` file: - -```toml -name = "auth-kit-example" -main = "src/index.ts" -compatibility_date = "2023-10-30" - -# Define the KV namespace -[[kv_namespaces]] -binding = "AUTH_KV" -id = "your-kv-namespace-id" -preview_id = "your-preview-kv-namespace-id" ``` -Then, define your environment interface: +This approach gives you more control over how auth routes are handled, which can be useful in specific scenarios like: +- When you need to apply different middleware to auth routes +- When you want to handle auth routes at the edge but process application routes in a Durable Object +- When you need to customize the auth route handling beyond what `withAuth` provides -```typescript -// env.ts -export interface Env { - AUTH_KV: KVNamespace; - AUTH_SECRET: string; - SENDGRID_API_KEY: string; - GAME_ID?: string; // For consumer apps - GAME_NAMES: Record; // For provider apps -} -``` +However, for most applications, the simpler `withAuth` approach is recommended. -For production, you might want to use a more sophisticated logging solution: +**Provider Router and Middleware:** ```typescript -// logger.ts -export const logger = { - debug: (message: string, ...args: any[]) => { - if (process.env.NODE_ENV === 'development') { - console.log(`[DEBUG] ${message}`, ...args); - } - }, - info: (message: string, ...args: any[]) => { - console.log(`[INFO] ${message}`, ...args); - }, - warn: (message: string, ...args: any[]) => { - console.warn(`[WARN] ${message}`, ...args); - }, - error: (message: string, error?: Error, ...args: any[]) => { - console.error(`[ERROR] ${message}`, error, ...args); - - // In production, you might want to send errors to a monitoring service - if (process.env.NODE_ENV === 'production' && typeof process.env.SENTRY_DSN === 'string') { - // Send to error monitoring - } - } -}; - -// Usage in auth hooks -const hooks = { - verifyVerificationCode: async ({ email, code, env }) => { - logger.debug('Verifying code', { email, codeLength: code.length }); - // Verification logic... - } -}; -``` - -### Key Structure for KV - -When using KV for auth data, a good key structure helps organize your data: +import { withAuth } from '@open-game-collective/auth-kit/provider/server'; +import { createRequestHandler } from '@remix-run/cloudflare'; +import * as build from '@remix-run/dev/server-build'; -- `user:{userId}` - User data -- `email:{email}` - Maps email to userId -- `verification:{email}` - Verification codes -- `accountLink:{openGameUserId}:{gameId}` - Account links from provider perspective -- `gameLink:{gameId}:{gameUserId}` - Account links from consumer perspective -- `apiKey:{apiKey}` - Maps API keys to game IDs - -This structure makes it easy to find and manage related data. - -For provider or consumer-specific functionality, you would use the corresponding router: - -```typescript -// For provider functionality -import { createProviderAuthRouter } from "@open-game-collective/auth-kit/provider/server"; +// Create the Remix request handler +const remixHandler = createRequestHandler(build); -// Define provider-specific hooks... +// Define provider hooks const providerHooks = { - // Base auth hooks... + // Base auth hooks + getUserIdByEmail: async ({ email, env }) => { /* ... */ }, + // ... other base hooks // Provider-specific hooks getGameIdFromApiKey: async ({ apiKey, env }) => { /* ... */ }, @@ -1958,23 +1293,48 @@ const providerHooks = { removeAccountLink: async ({ openGameUserId, gameId, env }) => { /* ... */ } }; -// In your fetch handler -if (url.pathname.startsWith('/auth/')) { - return createProviderAuthRouter({ +// Create provider-specific authenticated middleware (recommended) +const providerAuthMiddleware = withAuth( + async (request, env, { userId, sessionId, sessionToken }) => { + // Pass auth info to Remix + return remixHandler(request, { + env, + userId, + sessionId, + sessionToken, + }); + }, + { hooks: providerHooks, - useTopLevelDomain: true, - basePath: "/auth" - })(request, env, ctx); -} + useTopLevelDomain: true, // Optional + basePath: "/auth" // Optional + } +); + +// Export the worker handler +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext) { + // All requests go through the auth middleware + return providerAuthMiddleware(request, env, ctx); + } +}; ``` +**Consumer Router and Middleware:** + ```typescript -// For consumer functionality -import { createConsumerAuthRouter } from "@open-game-collective/auth-kit/consumer/server"; +import { withAuth } from '@open-game-collective/auth-kit/consumer/server'; +import { createRequestHandler } from '@remix-run/cloudflare'; +import * as build from '@remix-run/dev/server-build'; + +// Create the Remix request handler +const remixHandler = createRequestHandler(build); -// Define consumer-specific hooks... +// Define consumer hooks const consumerHooks = { - // Base auth hooks... + // Base auth hooks + getUserIdByEmail: async ({ email, env }) => { /* ... */ }, + // ... other base hooks // Consumer-specific hooks storeOpenGameLink: async ({ gameUserId, openGameUserId, env }) => { /* ... */ }, @@ -1982,15 +1342,32 @@ const consumerHooks = { getOpenGameProfile: async ({ openGameUserId, env }) => { /* ... */ } }; -// In your fetch handler -if (url.pathname.startsWith('/auth/')) { - return createConsumerAuthRouter({ +// Create consumer-specific authenticated middleware +const consumerAuthMiddleware = withAuth( + async (request, env, { userId, sessionId, sessionToken }) => { + // Pass auth info to Remix + return remixHandler(request, { + env, + userId, + sessionId, + sessionToken, + }); + }, + { hooks: consumerHooks, - gameId: env.GAME_ID, // Required for consumer router - useTopLevelDomain: true, - basePath: "/auth" - })(request, env, ctx); -} + gameId: "your-game-id", // Required for consumer + useTopLevelDomain: true, // Optional + basePath: "/auth" // Optional + } +); + +// Export the worker handler +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext) { + // All requests go through the auth middleware + return consumerAuthMiddleware(request, env, ctx); + } +}; ``` **Auth Endpoints:** diff --git a/package.json b/package.json index 0c6954a..efecb6a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@open-game-collective/auth-kit", - "version": "0.0.11", + "version": "0.1.0", "publishConfig": { "access": "public" }, @@ -17,6 +17,10 @@ "types": "./src/server.ts", "import": "./src/server.ts" }, + "./middleware": { + "types": "./src/server.ts", + "import": "./src/server.ts" + }, "./test": { "types": "./src/test.ts", "import": "./src/test.ts" @@ -33,6 +37,10 @@ "types": "./src/provider-server.ts", "import": "./src/provider-server.ts" }, + "./provider/middleware": { + "types": "./src/provider-server.ts", + "import": "./src/provider-server.ts" + }, "./consumer/client": { "types": "./src/consumer-client.ts", "import": "./src/consumer-client.ts" @@ -44,6 +52,10 @@ "./consumer/server": { "types": "./src/consumer-server.ts", "import": "./src/consumer-server.ts" + }, + "./consumer/middleware": { + "types": "./src/consumer-server.ts", + "import": "./src/consumer-server.ts" } }, "files": [ diff --git a/src/consumer-server.ts b/src/consumer-server.ts index 51ed86d..cd69c57 100644 --- a/src/consumer-server.ts +++ b/src/consumer-server.ts @@ -1,5 +1,9 @@ import { jwtVerify } from "jose"; import { createAuthRouter, verifySession, withAuth as baseWithAuth } from "./server"; +import { + createAuthHandler as baseCreateAuthHandler, + createAuthMiddleware as baseCreateAuthMiddleware, +} from "./server"; import { ConsumerAuthHooks } from "./types"; // Add ExecutionContext type definition @@ -287,6 +291,37 @@ export function createConsumerAuthRouter( }; } +/** + * Creates a consumer authentication middleware that handles session validation and creation + * but does not include route handling for auth endpoints. + */ +export function createAuthMiddleware(config: { + hooks: ConsumerAuthHooks; + useTopLevelDomain?: boolean; +}) { + // Use the base middleware since consumer hooks extend base hooks + return baseCreateAuthMiddleware(config); +} + +/** + * Creates a consumer middleware that applies authentication and sets cookies + * but does not include route handling for auth endpoints. + */ +export function createAuthHandler( + handler: ( + request: Request, + env: TEnv, + { userId, sessionId, sessionToken }: { userId: string; sessionId: string; sessionToken: string } + ) => Promise, + config: { + hooks: ConsumerAuthHooks; + useTopLevelDomain?: boolean; + } +) { + // Use the base handler since consumer hooks extend base hooks + return baseCreateAuthHandler(handler, config); +} + /** * Middleware that adds authentication to a request handler for consumer routes */ diff --git a/src/provider-server.ts b/src/provider-server.ts index 345973b..79f5e52 100644 --- a/src/provider-server.ts +++ b/src/provider-server.ts @@ -1,5 +1,7 @@ import { jwtVerify } from "jose"; import { + createAuthHandler as baseCreateAuthHandler, + createAuthMiddleware as baseCreateAuthMiddleware, createAuthRouter, createLinkToken, verifySession, @@ -388,6 +390,37 @@ export function createProviderAuthRouter( }; } +/** + * Creates a provider authentication middleware that handles session validation and creation + * but does not include route handling for auth endpoints. + */ +export function createAuthMiddleware(config: { + hooks: ProviderAuthHooks; + useTopLevelDomain?: boolean; +}) { + // Use the base middleware since provider hooks extend base hooks + return baseCreateAuthMiddleware(config); +} + +/** + * Creates a provider middleware that applies authentication and sets cookies + * but does not include route handling for auth endpoints. + */ +export function createAuthHandler( + handler: ( + request: Request, + env: TEnv, + { userId, sessionId, sessionToken }: { userId: string; sessionId: string; sessionToken: string } + ) => Promise, + config: { + hooks: ProviderAuthHooks; + useTopLevelDomain?: boolean; + } +) { + // Use the base handler since provider hooks extend base hooks + return baseCreateAuthHandler(handler, config); +} + /** * Middleware that adds authentication to a request handler for provider routes */ diff --git a/src/server.ts b/src/server.ts index f755b30..114758e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -585,31 +585,29 @@ export function createAuthRouter(config: { }; } -export function withAuth( - handler: ( - request: Request, - env: TEnv, - { userId, sessionId, sessionToken }: { userId: string; sessionId: string; sessionToken: string } - ) => Promise, - config: { - hooks: AuthHooks; - useTopLevelDomain?: boolean; - basePath?: string; - } -) { - const { hooks, useTopLevelDomain = false, basePath = "/auth" } = config; - const router = createAuthRouter({ hooks, useTopLevelDomain, basePath }); - - return async (request: Request, env: TEnv): Promise => { - const url = new URL(request.url); - const normalizedBasePath = basePath.startsWith("/") ? basePath.slice(1) : basePath; - - // Handle auth routes first - if (url.pathname.startsWith(`/${normalizedBasePath}/`)) { - return router(request, env); - } +/** + * Creates an authentication middleware that handles session validation and creation + * but does not include route handling for auth endpoints. + */ +export function createAuthMiddleware(config: { + hooks: AuthHooks; + useTopLevelDomain?: boolean; +}) { + const { hooks, useTopLevelDomain = false } = config; + return async ( + request: Request, + env: TEnv + ): Promise<{ + userId: string; + sessionId: string; + sessionToken: string; + newSessionToken?: string; + newRefreshToken?: string; + redirectResponse?: Response; // Add this to handle redirects + }> => { // Check for web auth code in URL + const url = new URL(request.url); const webAuthCode = url.searchParams.get("code"); if (webAuthCode) { try { @@ -624,7 +622,7 @@ export function withAuth( } // Create new session for the web client - const _sessionId = crypto.randomUUID(); + const sessionId = crypto.randomUUID(); // Use email from the web auth code if available const newSessionToken = await createSessionToken( @@ -635,11 +633,11 @@ export function withAuth( ); const newRefreshToken = await createRefreshToken(payload.userId, env.AUTH_SECRET); - // Redirect to remove the code from URL + // Create redirect response to remove code from URL const redirectUrl = new URL(request.url); redirectUrl.searchParams.delete("code"); - const response = new Response(null, { + const redirectResponse = new Response(null, { status: 302, headers: { Location: redirectUrl.toString(), @@ -647,16 +645,23 @@ export function withAuth( }); // Set the auth cookies - response.headers.append( + redirectResponse.headers.append( "Set-Cookie", createCookieString(SESSION_TOKEN_COOKIE, newSessionToken, "", request, useTopLevelDomain) ); - response.headers.append( + redirectResponse.headers.append( "Set-Cookie", createCookieString(REFRESH_TOKEN_COOKIE, newRefreshToken, "", request, useTopLevelDomain) ); - return response; + return { + userId: payload.userId, + sessionId, + sessionToken: newSessionToken, + newSessionToken, + newRefreshToken, + redirectResponse, // Return the redirect response + }; } catch (error) { // Invalid code, continue with normal auth flow console.error("Invalid web auth code:", error); @@ -737,10 +742,47 @@ export function withAuth( } } - const response = await handler(request, env, { + return { userId, sessionId, sessionToken: currentSessionToken, + newSessionToken, + newRefreshToken, + }; + }; +} + +/** + * Creates a middleware that applies authentication and sets cookies + * but does not include route handling for auth endpoints. + */ +export function createAuthHandler( + handler: ( + request: Request, + env: TEnv, + { userId, sessionId, sessionToken }: { userId: string; sessionId: string; sessionToken: string } + ) => Promise, + config: { + hooks: AuthHooks; + useTopLevelDomain?: boolean; + } +) { + const { useTopLevelDomain = false } = config; + const middleware = createAuthMiddleware(config); + + return async (request: Request, env: TEnv): Promise => { + const { userId, sessionId, sessionToken, newSessionToken, newRefreshToken, redirectResponse } = + await middleware(request, env); + + // If we have a redirect response (e.g., from web auth code), return it + if (redirectResponse) { + return redirectResponse; + } + + const response = await handler(request, env, { + userId, + sessionId, + sessionToken, }); if (newSessionToken) { @@ -760,6 +802,40 @@ export function withAuth( }; } +/** + * Combines the auth router and middleware for backward compatibility. + * This function handles both auth routes and adds authentication to other routes. + */ +export function withAuth( + handler: ( + request: Request, + env: TEnv, + { userId, sessionId, sessionToken }: { userId: string; sessionId: string; sessionToken: string } + ) => Promise, + config: { + hooks: AuthHooks; + useTopLevelDomain?: boolean; + basePath?: string; + } +) { + const { hooks, useTopLevelDomain = false, basePath = "/auth" } = config; + const router = createAuthRouter({ hooks, useTopLevelDomain, basePath }); + const authHandler = createAuthHandler(handler, { hooks, useTopLevelDomain }); + + return async (request: Request, env: TEnv): Promise => { + const url = new URL(request.url); + const normalizedBasePath = basePath.startsWith("/") ? basePath.slice(1) : basePath; + + // Handle auth routes first + if (url.pathname.startsWith(`/${normalizedBasePath}/`)) { + return router(request, env); + } + + // For other routes, apply authentication + return authHandler(request, env); + }; +} + export { AuthHooks } from "./types"; // JWT secret for signing link tokens