Skip to content

Implement Account Linking Functionality#2

Open
jonmumm wants to merge 10 commits intomainfrom
linking
Open

Implement Account Linking Functionality#2
jonmumm wants to merge 10 commits intomainfrom
linking

Conversation

@jonmumm
Copy link
Member

@jonmumm jonmumm commented Mar 6, 2025

Implement Account Linking Functionality with Cloudflare KV

Overview

This PR implements account linking functionality for Auth Kit, enabling secure cross-application authentication between OpenGame (provider) and consumer applications. The implementation follows the design outlined in the Account Linking Implementation Plan and introduces role-specific client and server components for both provider and consumer applications.

Key Features

  • Provider/Consumer Architecture: Distinct roles for identity providers (OpenGame) and consumers (game applications)
  • Account Linking Flow: Complete implementation of the linking flow described in the sequence diagram
  • API Key Authentication: Server-to-server authentication using API keys
  • Role-Specific Components: Specialized client implementations, server routers, and React components
  • Backward Compatibility: Maintains compatibility with existing Auth Kit implementations
  • Cloudflare KV Integration: Leverages Cloudflare KV for globally distributed, shared state storage

Implementation Details

New Types and Interfaces

  • Added LinkedAccount, LinkToken, and other types for account linking
  • Created role-specific state interfaces: ProviderAuthState and ConsumerAuthState
  • Defined specialized client interfaces: ProviderAuthClient and ConsumerAuthClient
  • Extended hook interfaces with role-specific methods

Server-Side Changes

  • Implemented createProviderAuthRouter and createConsumerAuthRouter functions
  • Added JWT token handling for link tokens
  • Implemented API key validation for server-to-server communication
  • Added endpoints for creating, verifying, and confirming link tokens

Client-Side Changes

  • Created createProviderAuthClient and createConsumerAuthClient functions
  • Implemented methods for account linking operations
  • Added state management for linked accounts and link status

React Integration

  • Added role-specific context providers and hooks
  • Created specialized components for displaying linked account status
  • Implemented UI components for the linking flow

Hook Implementation Examples with Cloudflare KV and Zod

Below are examples of how to implement the Auth Kit hooks using Cloudflare KV, with proper class-based Worker structure and Zod for type-safe parsing of data.

Zod Schemas for KV Data

// src/schemas.ts
import { z } from 'zod';

// Define schemas for KV data
export const UserSchema = z.object({
  userId: z.string(),
  email: z.string().email().nullable(),
  createdAt: z.string(),
  lastLogin: z.string().nullable(),
  emailVerifiedAt: z.string().nullable()
});

export const VerificationCodeSchema = z.object({
  email: z.string().email(),
  code: z.string(),
  expiresAt: z.string()
});

export const ApiKeySchema = z.object({
  apiKey: z.string(),
  gameId: z.string(),
  createdAt: z.string(),
  rotatedAt: z.string().nullable()
});

export const LinkedAccountSchema = z.object({
  openGameUserId: z.string(),
  gameId: z.string(),
  gameUserId: z.string(),
  linkedAt: z.string()
});

export const OpenGameLinkSchema = z.object({
  gameUserId: z.string(),
  openGameUserId: z.string(),
  linkedAt: z.string()
});

export const OpenGameProfileSchema = z.object({
  openGameUserId: z.string(),
  profile: z.record(z.unknown()),
  cachedAt: z.string(),
  expiresAt: z.string()
});

// Export types derived from schemas
export type User = z.infer<typeof UserSchema>;
export type VerificationCode = z.infer<typeof VerificationCodeSchema>;
export type ApiKey = z.infer<typeof ApiKeySchema>;
export type LinkedAccount = z.infer<typeof LinkedAccountSchema>;
export type OpenGameLink = z.infer<typeof OpenGameLinkSchema>;
export type OpenGameProfile = z.infer<typeof OpenGameProfileSchema>;

Environment Type Definition

// src/types.ts
export interface Env {
  // KV namespaces
  AUTH_KV: KVNamespace;
  
  // Secrets and configuration
  AUTH_SECRET: string;
  GAME_ID?: string;
  OPENGAME_API_KEY?: string;
  
  // Other bindings
  WORKER_DOMAIN: string;
}

Worker Implementation with KV and Zod

// src/auth-worker.ts
import { WorkerEntrypoint } from "@cloudflare/workers-types";
import { AuthHooks, createAuthRouter, Router } from "@open-game-collective/auth-kit/server";
import { 
  UserSchema, 
  VerificationCodeSchema, 
  ApiKeySchema, 
  LinkedAccountSchema, 
  OpenGameLinkSchema, 
  OpenGameProfileSchema 
} from './schemas';
import type { Env } from './types';

export class AuthWorker extends WorkerEntrypoint<Env> {
  private router: Router<Env>;
  private hooks: AuthHooks<Env>;

  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}`;
          const userId = await env.AUTH_KV.get(userIdKey);
          return userId;
        } catch (error) {
          console.error("Error getting userId by email:", error);
          return null;
        }
      },

      storeVerificationCode: async ({ email, code, env }) => {
        try {
          const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
          const codeData = JSON.stringify({
            email,
            code,
            expiresAt: expiresAt.toISOString(),
          });
          
          // Parse with Zod to ensure type safety
          VerificationCodeSchema.parse({
            email,
            code,
            expiresAt: expiresAt.toISOString()
          });
          
          await env.AUTH_KV.put(`verification:${email}`, codeData, {
            expirationTtl: 900, // 15 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);
          
          // Parse with Zod to ensure type safety
          const verification = VerificationCodeSchema.parse({
            email,
            code: codeData.code,
            expiresAt: codeData.expiresAt
          });
          
          const now = new Date();
          const expiresAt = new Date(verification.expiresAt);
          
          if (verification.code !== code || now > expiresAt) {
            return false;
          }
          
          // Delete the code to prevent reuse
          await env.AUTH_KV.delete(`verification:${email}`);
          
          return true;
        } catch (error) {
          console.error("Error verifying code:", error);
          return false;
        }
      },

      sendVerificationCode: async ({ email, code, env }) => {
        try {
          // This would typically use an email service
          const response = await fetch(`https://email-worker.${env.WORKER_DOMAIN}/send`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ email, code })
          });
          return response.ok;
        } catch (error) {
          console.error("Failed to send email:", error);
          return false;
        }
      },

      onNewUser: async ({ userId, env }) => {
        try {
          const userData = {
            userId,
            createdAt: new Date().toISOString(),
          };
          
          // Parse with Zod to ensure type safety
          UserSchema.partial().parse(userData);
          
          await env.AUTH_KV.put(`user:${userId}`, JSON.stringify(userData));
        } 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();
          
          // Parse with Zod to ensure type safety
          UserSchema.partial().parse(userData);
          
          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.emailVerifiedAt = new Date().toISOString();
          
          // Parse with Zod to ensure type safety
          UserSchema.partial().parse(userData);
          
          await env.AUTH_KV.put(`user:${userId}`, JSON.stringify(userData));
        } catch (error) {
          console.error("Error verifying email:", error);
        }
      },
      
      // 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;
        }
      },
      
      storeAccountLink: async ({ openGameUserId, gameId, gameUserId, env }) => {
        try {
          const linkData = {
            openGameUserId,
            gameId,
            gameUserId,
            linkedAt: new Date().toISOString(),
          };
          
          // Parse with Zod to ensure type safety
          LinkedAccountSchema.parse(linkData);
          
          // Store link in both directions for easy lookup
          await env.AUTH_KV.put(
            `accountLink:${openGameUserId}:${gameId}`, 
            JSON.stringify(linkData)
          );
          
          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);
              
              // Parse with Zod to ensure type safety
              const linkedAccount = LinkedAccountSchema.parse(linkData);
              
              return {
                gameId: linkedAccount.gameId,
                gameUserId: linkedAccount.gameUserId,
                linkedAt: linkedAccount.linkedAt,
                gameName: env.GAME_NAMES?.[linkedAccount.gameId] || linkedAccount.gameId,
              };
            })
          );
          
          // Filter out any null values and return
          return linkedAccounts.filter(Boolean);
        } catch (error) {
          console.error("Error getting linked accounts:", error);
          return [];
        }
      },
      
      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);
          
          // Parse with Zod to ensure type safety
          const linkedAccount = LinkedAccountSchema.parse(linkData);
          
          // Delete both link directions
          await env.AUTH_KV.delete(`accountLink:${openGameUserId}:${gameId}`);
          await env.AUTH_KV.delete(`gameLink:${gameId}:${linkedAccount.gameUserId}`);
          
          return true;
        } catch (error) {
          console.error("Error removing account link:", error);
          return false;
        }
      },
      
      // Consumer-specific hooks
      storeOpenGameLink: async ({ gameUserId, openGameUserId, env }) => {
        try {
          const linkData = {
            openGameUserId,
            gameId: env.GAME_ID,
            gameUserId,
            linkedAt: new Date().toISOString(),
          };
          
          // Parse with Zod to ensure type safety
          LinkedAccountSchema.parse(linkData);
          
          // 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;
        }
      },
      
      getOpenGameProfile: async ({ openGameUserId, env }) => {
        try {
          const now = new Date();
          
          // Check for cached profile
          const profileKey = `profile:${openGameUserId}`;
          const cachedProfileStr = await env.AUTH_KV.get(profileKey);
          
          if (cachedProfileStr) {
            const cachedProfile = JSON.parse(cachedProfileStr);
            
            // Parse with Zod to ensure type safety
            const profileData = OpenGameProfileSchema.parse(cachedProfile);
            
            // Check if profile is still valid
            if (new Date(profileData.expiresAt) > now) {
              return profileData.profile;
            }
          }
          
          // If no valid cached data, fetch from OpenGame API using API key from env
          if (!env.OPENGAME_API_KEY) {
            console.error("Missing OPENGAME_API_KEY in environment");
            return null;
          }
          
          const response = await fetch(`https://api.opengame.org/auth/users/${openGameUserId}/profile`, {
            headers: {
              'X-API-Key': env.OPENGAME_API_KEY,
              'X-Game-Id': env.GAME_ID || ''
            }
          });
          
          if (!response.ok) return null;
          
          const profile = await response.json();
          
          // Cache the profile
          const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
          
          const profileData = {
            openGameUserId,
            profile,
            cachedAt: now.toISOString(),
            expiresAt: expiresAt.toISOString()
          };
          
          // Parse with Zod to ensure type safety
          OpenGameProfileSchema.parse(profileData);
          
          await env.AUTH_KV.put(profileKey, JSON.stringify(profileData), {
            expirationTtl: 3600 // 1 hour in seconds
          });
          
          return profile;
        } catch (error) {
          console.error("Error getting OpenGame profile:", error);
          return null;
        }
      },
      
      setOpenGameAPIKey: async ({ apiKey, name, env }) => {
        try {
          const apiKeyData = {
            apiKey,
            gameId: env.GAME_ID || '',
            createdAt: new Date().toISOString(),
            rotatedAt: null
          };
          
          // Parse with Zod to ensure type safety
          ApiKeySchema.parse(apiKeyData);
          
          await env.AUTH_KV.put('current_api_key', apiKey);
          await env.AUTH_KV.put('api_key_metadata', JSON.stringify({
            name,
            createdAt: apiKeyData.createdAt
          }));
          
          return true;
        } catch (error) {
          console.error("Error setting OpenGame API key:", error);
          return false;
        }
      },
      
      getAPIKeyStatus: async ({ env }) => {
        try {
          // First check environment variable
          if (env.OPENGAME_API_KEY) {
            return { 
              hasValidKey: true,
              createdAt: "Environment Variable"
            };
          }
          
          // Then check KV
          const apiKey = await env.AUTH_KV.get('current_api_key');
          if (!apiKey) {
            return { hasValidKey: false };
          }
          
          const metadataStr = await env.AUTH_KV.get('api_key_metadata');
          const metadata = metadataStr ? JSON.parse(metadataStr) : {};
          
          return {
            hasValidKey: true,
            createdAt: metadata.createdAt,
            rotatedAt: metadata.rotatedAt
          };
        } catch (error) {
          console.error("Error getting API key status:", error);
          return { hasValidKey: false };
        }
      }
    };
    
    // Create the router once during initialization
    this.router = createAuthRouter({
      hooks: this.hooks,
      useTopLevelDomain: true
    });
  }
  
  // Override the fetch method from WorkerEntrypoint
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    return this.router.handle(request, env);
  }
}

// Export the worker
export default AuthWorker;

Router Setup Examples

Provider Auth Router with KV

// src/provider-worker.ts
import { WorkerEntrypoint } from "@cloudflare/workers-types";
import { createProviderAuthRouter } from '@open-game-collective/auth-kit/server';
import type { Env } from './types';

export default class ProviderWorker extends WorkerEntrypoint<Env> {
  private router;
  
  constructor() {
    super();
    
    // Create the provider auth router
    this.router = createProviderAuthRouter({
      hooks: {
        // Provider hooks implementation using KV
        getUserIdByEmail: async ({ email, env }) => {
          return await env.AUTH_KV.get(`email:${email}`);
        },
        
        // Other hook implementations...
        
        // Provider-specific hooks
        getGameIdFromApiKey: async ({ apiKey, env }) => {
          return await env.AUTH_KV.get(`apiKey:${apiKey}`);
        },
        
        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)
            );
            
            await env.AUTH_KV.put(
              `gameLink:${gameId}:${gameUserId}`, 
              openGameUserId
            );
            
            return true;
          } catch (error) {
            console.error("Error storing account link:", error);
            return false;
          }
        },
        
        // Additional provider hooks...
      },
      useTopLevelDomain: true
    });
  }
  
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    // Handle auth-related requests
    if (new URL(request.url).pathname.startsWith('/auth/')) {
      return await this.router.handle(request, env);
    }
    
    // Handle other requests
    return new Response("Not found", { status: 404 });
  }
}

Consumer Auth Router with KV

// src/consumer-worker.ts
import { WorkerEntrypoint } from "@cloudflare/workers-types";
import { createConsumerAuthRouter } from '@open-game-collective/auth-kit/server';
import type { Env } from './types';

export default class ConsumerWorker extends WorkerEntrypoint<Env> {
  private router;
  
  constructor() {
    super();
    
    // Create the consumer auth router
    this.router = createConsumerAuthRouter({
      hooks: {
        // Base auth hooks
        getUserIdByEmail: async ({ email, env }) => {
          return await env.AUTH_KV.get(`email:${email}`);
        },
        
        // Other hook implementations...
        
        // 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;
          }
        },
        
        // Additional consumer hooks...
      },
      gameId: env.GAME_ID || "standardelectric",
      useTopLevelDomain: true
    });
  }
  
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    // Handle auth-related requests
    if (new URL(request.url).pathname.startsWith('/auth/')) {
      return await this.router.handle(request, env);
    }
    
    // Handle other requests
    return new Response("Not found", { status: 404 });
  }
}

Cloudflare KV Configuration

To use Cloudflare KV with Auth Kit, configure your wrangler.toml:

name = "auth-kit-example"
main = "src/index.ts"
compatibility_date = "2024-03-25"
compatibility_flags = ["nodejs_compat"]

# Define the KV namespace
[[kv_namespaces]]
binding = "AUTH_KV"
id = "your-kv-namespace-id"
preview_id = "your-preview-kv-namespace-id"

[vars]
WORKER_DOMAIN = "example.workers.dev"
GAME_ID = "standardelectric"  # For consumer implementations

Benefits of Using Cloudflare KV with Zod

  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, making it ideal for authentication systems.

  3. Type Safety: Zod provides runtime type validation for KV data, ensuring data integrity across distributed systems.

  4. Automatic Expiration: KV supports automatic expiration for items like verification codes and cached profiles.

  5. High Read Performance: KV is optimized for high-performance reads, which is ideal for authentication systems.

  6. Simplicity: KV provides a straightforward key-value API that's easy to use and understand.

  7. Proper Worker Structure: Using WorkerEntrypoint follows the recommended Cloudflare Workers pattern for class-based workers.

API Key Management

For API key management, this implementation supports both environment variables and KV storage:

  1. Environment Variables: Set API keys as secrets in your Cloudflare Worker

    npx wrangler secret put OPENGAME_API_KEY
  2. KV Storage: Store and retrieve API keys using KV

    // Store API key
    await env.AUTH_KV.put('current_api_key', apiKey);
    await env.AUTH_KV.put('api_key_metadata', JSON.stringify({
      name,
      createdAt: new Date().toISOString()
    }));

The implementation prioritizes environment variables for security, falling back to stored keys if needed.

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
  • profile:{openGameUserId} - Cached OpenGame profiles

This structure makes it easy to find and manage related data.

Testing

All tests have been updated and are passing. The implementation includes tests for:

  • Provider and consumer client functionality
  • Server-side request handling
  • React component integration
  • Error handling and edge cases
  • KV storage operations with Zod validation

Next Steps

  • Add more detailed documentation with examples
  • Explore additional features like profile synchronization
  • Implement caching strategies for frequently accessed data
  • Add monitoring and observability for KV usage

Related Documentation

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant