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;
 | |
|   }
 | |
| } |