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
JavaScript
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