/** * SMTP Client Authentication Handler * Authentication mechanisms implementation */ import { AUTH_METHODS } from './constants.js'; import type { ISmtpConnection, ISmtpAuthOptions, ISmtpClientOptions, ISmtpResponse, IOAuth2Options } from './interfaces.js'; import { encodeAuthPlain, encodeAuthLogin, generateOAuth2String, isSuccessCode } from './utils/helpers.js'; import { logAuthentication, logDebug } from './utils/logging.js'; import type { CommandHandler } from './command-handler.js'; export class AuthHandler { private options: ISmtpClientOptions; private commandHandler: CommandHandler; constructor(options: ISmtpClientOptions, commandHandler: CommandHandler) { this.options = options; this.commandHandler = commandHandler; } /** * Authenticate using the configured method */ public async authenticate(connection: ISmtpConnection): Promise { if (!this.options.auth) { logDebug('No authentication configured', this.options); return; } const authOptions = this.options.auth; const capabilities = connection.capabilities; if (!capabilities || capabilities.authMethods.size === 0) { throw new Error('Server does not support authentication'); } // Determine authentication method const method = this.selectAuthMethod(authOptions, capabilities.authMethods); logAuthentication('start', method, this.options); try { switch (method) { case AUTH_METHODS.PLAIN: await this.authenticatePlain(connection, authOptions); break; case AUTH_METHODS.LOGIN: await this.authenticateLogin(connection, authOptions); break; case AUTH_METHODS.OAUTH2: await this.authenticateOAuth2(connection, authOptions); break; default: throw new Error(`Unsupported authentication method: ${method}`); } logAuthentication('success', method, this.options); } catch (error) { logAuthentication('failure', method, this.options, { error }); throw error; } } /** * Authenticate using AUTH PLAIN */ private async authenticatePlain(connection: ISmtpConnection, auth: ISmtpAuthOptions): Promise { if (!auth.user || !auth.pass) { throw new Error('Username and password required for PLAIN authentication'); } const credentials = encodeAuthPlain(auth.user, auth.pass); const response = await this.commandHandler.sendAuth(connection, AUTH_METHODS.PLAIN, credentials); if (!isSuccessCode(response.code)) { throw new Error(`PLAIN authentication failed: ${response.message}`); } } /** * Authenticate using AUTH LOGIN */ private async authenticateLogin(connection: ISmtpConnection, auth: ISmtpAuthOptions): Promise { if (!auth.user || !auth.pass) { throw new Error('Username and password required for LOGIN authentication'); } // Step 1: Send AUTH LOGIN let response = await this.commandHandler.sendAuth(connection, AUTH_METHODS.LOGIN); if (response.code !== 334) { throw new Error(`LOGIN authentication initiation failed: ${response.message}`); } // Step 2: Send username const encodedUser = encodeAuthLogin(auth.user); response = await this.commandHandler.sendCommand(connection, encodedUser); if (response.code !== 334) { throw new Error(`LOGIN username failed: ${response.message}`); } // Step 3: Send password const encodedPass = encodeAuthLogin(auth.pass); response = await this.commandHandler.sendCommand(connection, encodedPass); if (!isSuccessCode(response.code)) { throw new Error(`LOGIN password failed: ${response.message}`); } } /** * Authenticate using OAuth2 */ private async authenticateOAuth2(connection: ISmtpConnection, auth: ISmtpAuthOptions): Promise { if (!auth.oauth2) { throw new Error('OAuth2 configuration required for OAUTH2 authentication'); } let accessToken = auth.oauth2.accessToken; // Refresh token if needed if (!accessToken || this.isTokenExpired(auth.oauth2)) { accessToken = await this.refreshOAuth2Token(auth.oauth2); } const authString = generateOAuth2String(auth.oauth2.user, accessToken); const response = await this.commandHandler.sendAuth(connection, AUTH_METHODS.OAUTH2, authString); if (!isSuccessCode(response.code)) { throw new Error(`OAUTH2 authentication failed: ${response.message}`); } } /** * Select appropriate authentication method */ private selectAuthMethod(auth: ISmtpAuthOptions, serverMethods: Set): string { // If method is explicitly specified, use it if (auth.method && auth.method !== 'AUTO') { const method = auth.method === 'OAUTH2' ? AUTH_METHODS.OAUTH2 : auth.method; if (serverMethods.has(method)) { return method; } throw new Error(`Requested authentication method ${auth.method} not supported by server`); } // Auto-select based on available credentials and server support if (auth.oauth2 && serverMethods.has(AUTH_METHODS.OAUTH2)) { return AUTH_METHODS.OAUTH2; } if (auth.user && auth.pass) { // Prefer PLAIN over LOGIN for simplicity if (serverMethods.has(AUTH_METHODS.PLAIN)) { return AUTH_METHODS.PLAIN; } if (serverMethods.has(AUTH_METHODS.LOGIN)) { return AUTH_METHODS.LOGIN; } } throw new Error('No compatible authentication method found'); } /** * Check if OAuth2 token is expired */ private isTokenExpired(oauth2: IOAuth2Options): boolean { if (!oauth2.expires) { return false; // No expiry information, assume valid } const now = Date.now(); const buffer = 300000; // 5 minutes buffer return oauth2.expires < (now + buffer); } /** * Refresh OAuth2 access token */ private async refreshOAuth2Token(oauth2: IOAuth2Options): Promise { // This is a simplified implementation // In a real implementation, you would make an HTTP request to the OAuth2 provider logDebug('OAuth2 token refresh required', this.options); if (!oauth2.refreshToken) { throw new Error('Refresh token required for OAuth2 token refresh'); } // TODO: Implement actual OAuth2 token refresh // For now, throw an error to indicate this needs to be implemented throw new Error('OAuth2 token refresh not implemented. Please provide a valid access token.'); } /** * Validate authentication configuration */ public validateAuthConfig(auth: ISmtpAuthOptions): string[] { const errors: string[] = []; if (auth.method === 'OAUTH2' || auth.oauth2) { if (!auth.oauth2) { errors.push('OAuth2 configuration required when using OAUTH2 method'); } else { if (!auth.oauth2.user) errors.push('OAuth2 user required'); if (!auth.oauth2.clientId) errors.push('OAuth2 clientId required'); if (!auth.oauth2.clientSecret) errors.push('OAuth2 clientSecret required'); if (!auth.oauth2.refreshToken && !auth.oauth2.accessToken) { errors.push('OAuth2 refreshToken or accessToken required'); } } } else if (auth.method === 'PLAIN' || auth.method === 'LOGIN' || (!auth.method && (auth.user || auth.pass))) { if (!auth.user) errors.push('Username required for basic authentication'); if (!auth.pass) errors.push('Password required for basic authentication'); } return errors; } }