UNPKG

nostr-dm-magiclink-utils

Version:

A comprehensive Nostr utility library for magic link authentication via direct messages, supporting both ESM and CommonJS. Features NIP-01/04 compliant message encryption, multi-relay support, internationalization (i18n) with RTL support, and TypeScript-f

182 lines 8.08 kB
import { createLogger } from '../utils/logger.js'; import { NostrError, NostrErrorCode } from '../types/index.js'; import jwt from 'jsonwebtoken'; import crypto from 'crypto'; /** * Manager for handling magic link authentication * Manages generation, sending, and verification of magic links through Nostr protocol */ export class MagicLinkManager { nostrService; config; logger; /** * Tracks consumed token JTIs to prevent replay attacks. * Maps jti -> expiry timestamp (seconds since epoch). * Expired entries are periodically cleaned up during verification. */ consumedTokens = new Map(); defaultTemplate = { en: 'Click this magic link to login: {{link}}', ar: 'انقر فوق هذا الرابط السحري لتسجيل الدخول: {{link}}', es: 'Haz clic en este enlace mágico para iniciar sesión: {{link}}', fr: 'Cliquez sur ce lien magique pour vous connecter: {{link}}', ja: 'ログインするには、このマジックリンクをクリックしてください:{{link}}', ko: '로그인하려면 이 매직 링크를 클릭하세요: {{link}}', pt: 'Clique neste link mágico para fazer login: {{link}}', ru: 'Нажмите на эту волшебную ссылку, чтобы войти: {{link}}', zh: '点击此魔法链接登录:{{link}}' }; /** * Creates a new instance of MagicLinkManager * @param nostrService - Service for handling Nostr protocol operations * @param config - Configuration for magic link functionality * @param logger - Optional logger instance. If not provided, creates a new logger */ constructor(nostrService, config, logger) { this.nostrService = nostrService; this.config = config; this.logger = logger || createLogger('MagicLinkManager'); } /** * Sends a magic link to a recipient via Nostr direct message * @param options - Options for sending the magic link * @param options.recipientPubkey - Public key of the recipient * @param options.messageOptions - Optional message formatting options * @returns Promise resolving to a response object containing success status and magic link or error */ async sendMagicLink(options) { try { const { recipientPubkey, messageOptions = {} } = options; const token = await this.generateToken(recipientPubkey); const link = `${this.config.verifyUrl}?token=${token}`; const message = this.formatMessage(link, messageOptions); await this.nostrService.sendDirectMessage(recipientPubkey, message); return { success: true }; } catch (error) { const errorDetails = new NostrError('Failed to send magic link', NostrErrorCode.GENERAL_ERROR, error instanceof Error ? error : undefined); throw errorDetails; } } /** * Verifies a magic link token and returns the associated public key * @param token - The token to verify * @returns Promise resolving to the public key if verification succeeds, null otherwise */ async verifyMagicLink(token) { try { // Clean up expired consumed tokens before verification this.cleanupConsumedTokens(); const secret = this.getJwtSecret(); const decoded = jwt.verify(token, secret); if (!decoded || !decoded.pubkey) { throw new Error('Invalid token payload'); } // Enforce single-use: check if this token's jti has already been consumed if (decoded.jti) { if (this.consumedTokens.has(decoded.jti)) { throw new Error('Token already used'); } // Mark token as consumed with its expiry time for cleanup const expiry = decoded.exp || (Math.floor(Date.now() / 1000) + 900); // fallback 15m this.consumedTokens.set(decoded.jti, expiry); } else { // Tokens without jti are rejected — all tokens generated by this service have jti throw new Error('Token missing jti claim'); } this.logger.debug({ pubkey: decoded.pubkey }, 'Token verified successfully'); return decoded.pubkey; } catch (error) { const errorDetails = new NostrError('Failed to verify magic link', NostrErrorCode.GENERAL_ERROR, error instanceof Error ? error : undefined); throw errorDetails; } } /** * Returns the JWT signing secret. * Prefers config.jwtSecret; falls back to config.token (string) for backwards compatibility. * @returns The JWT signing secret string */ getJwtSecret() { if (this.config.jwtSecret) { return this.config.jwtSecret; } // Backwards-compatible fallback: use config.token as the secret if it is a string if (typeof this.config.token === 'string') { return this.config.token; } throw new Error('No JWT secret configured. Set config.jwtSecret or provide config.token as a string.'); } /** * Generates a per-request JWT token for magic link authentication. * Each token contains the recipient's pubkey, a unique jti, and a 15-minute expiration. * @param pubkey - The recipient's public key to embed in the token * @returns Promise resolving to the generated JWT token string * @throws {NostrError} If token generation fails */ async generateToken(pubkey) { try { const secret = this.getJwtSecret(); const jti = crypto.randomBytes(16).toString('hex'); // If config.token is a function, call it and include the result as additional payload data let additionalData = {}; if (typeof this.config.token === 'function') { const tokenData = await this.config.token(); additionalData = { tokenData }; } const token = jwt.sign({ pubkey, jti, ...additionalData, }, secret, { expiresIn: '15m' }); return token; } catch (error) { const errorDetails = new NostrError('Failed to generate token', NostrErrorCode.TOKEN_GENERATION_ERROR, error instanceof Error ? error : undefined); throw errorDetails; } } /** * Removes expired entries from the consumed tokens map. * Called during verification to prevent unbounded memory growth. */ cleanupConsumedTokens() { const now = Math.floor(Date.now() / 1000); for (const [jti, expiry] of this.consumedTokens) { if (expiry <= now) { this.consumedTokens.delete(jti); } } } /** * Formats a message with the given template and variables * @param link - The magic link URL * @param options - Message formatting options * @returns Formatted message string */ formatMessage(link, options = {}) { const { template, variables = {}, textDirection = this.config.defaultTextDirection || 'ltr' } = options; const locale = options.locale || this.config.defaultLocale || 'en'; let message = template || this.defaultTemplate[locale] || this.defaultTemplate.en; const allVariables = { ...variables, link, device: variables.device || '' }; // Replace all variables in the template Object.entries(allVariables).forEach(([key, value]) => { const placeholder = `{{${key}}}`; message = message.replace(placeholder, value || ''); }); // Handle RTL text if needed if (textDirection === 'rtl') { message = `\u200F${message}\u200F`; // Add RLM markers } return message; } } //# sourceMappingURL=magiclink.service.js.map