232 lines
		
	
	
		
			7.5 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
		
		
			
		
	
	
			232 lines
		
	
	
		
			7.5 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
|  | /** | ||
|  |  * SMTP Client Authentication Handler | ||
|  |  * Authentication mechanisms implementation | ||
|  |  */ | ||
|  | 
 | ||
|  | import { AUTH_METHODS } from './constants.ts'; | ||
|  | import type {  | ||
|  |   ISmtpConnection,  | ||
|  |   ISmtpAuthOptions,  | ||
|  |   ISmtpClientOptions, | ||
|  |   ISmtpResponse, | ||
|  |   IOAuth2Options | ||
|  | } from './interfaces.ts'; | ||
|  | import {  | ||
|  |   encodeAuthPlain,  | ||
|  |   encodeAuthLogin,  | ||
|  |   generateOAuth2String, | ||
|  |   isSuccessCode  | ||
|  | } from './utils/helpers.ts'; | ||
|  | import { logAuthentication, logDebug } from './utils/logging.ts'; | ||
|  | import type { CommandHandler } from './command-handler.ts'; | ||
|  | 
 | ||
|  | 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<void> { | ||
|  |     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<void> { | ||
|  |     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<void> { | ||
|  |     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<void> { | ||
|  |     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>): 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<string> { | ||
|  |     // 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; | ||
|  |   } | ||
|  | } |