232 lines
7.5 KiB
TypeScript
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;
|
|
}
|
|
} |