dcrouter/ts/mail/delivery/smtpclient/auth-handler.ts
2025-05-22 10:18:02 +00:00

232 lines
7.5 KiB
TypeScript

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