@zestic/oauth-core
Version:
Framework-agnostic OAuth authentication library with support for multiple OAuth flows
232 lines • 9.8 kB
JavaScript
;
/**
* 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