UNPKG

@zestic/oauth-core

Version:

Framework-agnostic OAuth authentication library with support for multiple OAuth flows

232 lines 9.8 kB
"use strict"; /** * Magic Link Service * Handles sending magic links via email with OAuth integration */ Object.defineProperty(exports, "__esModule", { value: true }); exports.MagicLinkService = void 0; exports.createMagicLinkService = createMagicLinkService; const StateValidator_1 = require("../core/StateValidator"); const ErrorHandler_1 = require("../utils/ErrorHandler"); const OAuthTypes_1 = require("../types/OAuthTypes"); class MagicLinkService { constructor(adapters, config) { this.adapters = adapters; this.config = config; this.stateValidator = new StateValidator_1.StateValidator(adapters.storage); } /** * Send a magic link to the specified email address */ async sendMagicLink(input) { try { // Validate input this.validateMagicLinkInput(input); // Check if user exists (optional - depends on your use case) const userExists = await this.adapters.user.userExists(input.email); if (!userExists) { // You might want to handle this differently based on your security requirements // For now, we'll proceed to avoid user enumeration attacks } // Store PKCE challenge for later use in OAuth flow await this.storePKCEChallenge(input); // Store and validate state await this.stateValidator.storeState(input.state); // Generate magic link token const magicLinkToken = await this.generateMagicLinkToken(input); // Store magic link token with expiration await this.storeMagicLinkToken(magicLinkToken); // Build magic link URL const magicLinkUrl = this.buildMagicLinkUrl(magicLinkToken); // Trigger server-side magic link sending via GraphQL const graphqlResult = await this.adapters.graphql.sendMagicLinkMutation(input.email, magicLinkUrl, { subject: 'Your Magic Link', templateData: { email: input.email, magicLinkUrl, expirationMinutes: this.config.expirationMinutes || 15 } }); if (!graphqlResult.success) { return { success: false, message: graphqlResult.message || 'Failed to trigger magic link sending', code: 'GRAPHQL_MUTATION_FAILED' }; } return { success: true, message: 'Magic link sent successfully', code: 'MAGIC_LINK_SENT' }; } catch (error) { if (ErrorHandler_1.ErrorHandler.isOAuthError(error)) { throw error; } throw ErrorHandler_1.ErrorHandler.createError(`Magic link sending failed: ${error instanceof Error ? error.message : String(error)}`, OAuthTypes_1.OAUTH_ERROR_CODES.INVALID_CONFIGURATION, error instanceof Error ? error : undefined); } } /** * Validate magic link input parameters */ validateMagicLinkInput(input) { if (!input.email || typeof input.email !== 'string') { throw ErrorHandler_1.ErrorHandler.handleMissingParameter('email'); } if (!this.isValidEmail(input.email)) { throw ErrorHandler_1.ErrorHandler.createError('Invalid email format', OAuthTypes_1.OAUTH_ERROR_CODES.MISSING_REQUIRED_PARAMETER); } if (!input.codeChallenge || typeof input.codeChallenge !== 'string') { throw ErrorHandler_1.ErrorHandler.handleMissingParameter('codeChallenge'); } if (!input.codeChallengeMethod || typeof input.codeChallengeMethod !== 'string') { throw ErrorHandler_1.ErrorHandler.handleMissingParameter('codeChallengeMethod'); } if (!input.redirectUri || typeof input.redirectUri !== 'string') { throw ErrorHandler_1.ErrorHandler.handleMissingParameter('redirectUri'); } if (!input.state || typeof input.state !== 'string') { throw ErrorHandler_1.ErrorHandler.handleMissingParameter('state'); } // Validate PKCE method if (!['S256', 'plain'].includes(input.codeChallengeMethod)) { throw ErrorHandler_1.ErrorHandler.createError('Invalid code challenge method. Must be S256 or plain', OAuthTypes_1.OAUTH_ERROR_CODES.MISSING_PKCE); } // Validate redirect URI format try { new URL(input.redirectUri); } catch { throw ErrorHandler_1.ErrorHandler.createError('Invalid redirect URI format', OAuthTypes_1.OAUTH_ERROR_CODES.INVALID_CONFIGURATION); } } /** * Generate a magic link token */ async generateMagicLinkToken(input) { // Generate a secure random token const token = await this.generateSecureToken(); const expirationMinutes = this.config.expirationMinutes || 15; const expiresAt = new Date(Date.now() + (expirationMinutes * 60 * 1000)); return { token, email: input.email, expiresAt, state: input.state, codeChallenge: input.codeChallenge, codeChallengeMethod: input.codeChallengeMethod, redirectUri: input.redirectUri }; } /** * Generate a secure random token */ async generateSecureToken() { // Use the PKCE adapter to generate a secure random string const pkceChallenge = await this.adapters.pkce.generateCodeChallenge(); return pkceChallenge.codeVerifier; // Use the code verifier as our token } /** * Store magic link token with expiration */ async storeMagicLinkToken(magicLinkToken) { const tokenKey = `magic_link_token:${magicLinkToken.token}`; const tokenData = JSON.stringify(magicLinkToken); await this.adapters.storage.setItem(tokenKey, tokenData); // Also store by email for potential cleanup/validation const emailKey = `magic_link_email:${magicLinkToken.email}`; await this.adapters.storage.setItem(emailKey, magicLinkToken.token); } /** * Store PKCE challenge for later use in OAuth flow */ async storePKCEChallenge(input) { // Store the PKCE challenge data that will be needed during token exchange await this.adapters.storage.setItem('pkce_challenge', input.codeChallenge); await this.adapters.storage.setItem('pkce_method', input.codeChallengeMethod); await this.adapters.storage.setItem('pkce_state', input.state); await this.adapters.storage.setItem('pkce_redirect_uri', input.redirectUri); } /** * Build the magic link URL */ buildMagicLinkUrl(magicLinkToken) { const url = new URL(this.config.baseUrl); // Add magic link token url.searchParams.set('token', magicLinkToken.token); // Add OAuth parameters url.searchParams.set('state', magicLinkToken.state); url.searchParams.set('redirect_uri', magicLinkToken.redirectUri); url.searchParams.set('flow', 'magic_link'); // Add any custom parameters if (this.config.customParams) { Object.entries(this.config.customParams).forEach(([key, value]) => { url.searchParams.set(key, value); }); } return url.toString(); } /** * Simple email validation - ReDoS safe implementation */ isValidEmail(email) { // Input length validation to prevent ReDoS attacks if (email.length > 254) { return false; } // ReDoS-safe email regex pattern const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; return emailRegex.test(email); } /** * Validate and retrieve magic link token */ async validateMagicLinkToken(token) { try { const tokenKey = `magic_link_token:${token}`; const tokenData = await this.adapters.storage.getItem(tokenKey); if (!tokenData) { return { success: false, error: 'Invalid or expired magic link token', code: 'INVALID_TOKEN' }; } const magicLinkToken = JSON.parse(tokenData); // Convert expiresAt string back to Date object magicLinkToken.expiresAt = new Date(magicLinkToken.expiresAt); // Check if token is expired if (new Date() > magicLinkToken.expiresAt) { // Clean up expired token await this.adapters.storage.removeItem(tokenKey); await this.adapters.storage.removeItem(`magic_link_email:${magicLinkToken.email}`); return { success: false, error: 'Magic link token has expired', code: 'TOKEN_EXPIRED' }; } return { success: true, data: magicLinkToken, message: 'Token is valid' }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error), message: 'Failed to validate magic link token' }; } } } exports.MagicLinkService = MagicLinkService; /** * Factory function to create magic link service */ function createMagicLinkService(adapters, config) { return new MagicLinkService(adapters, config); } //# sourceMappingURL=MagicLinkService.js.map