446 lines
14 KiB
TypeScript
446 lines
14 KiB
TypeScript
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||
|
import { startTestSmtpServer } from '../../helpers/server.loader.js';
|
||
|
import { createSmtpClient } from '../../helpers/smtp.client.js';
|
||
|
|
||
|
let testServer: any;
|
||
|
|
||
|
tap.test('setup test SMTP server', async () => {
|
||
|
testServer = await startTestSmtpServer({
|
||
|
features: ['AUTH', 'AUTH=XOAUTH2', 'AUTH=OAUTHBEARER']
|
||
|
});
|
||
|
expect(testServer).toBeTruthy();
|
||
|
expect(testServer.port).toBeGreaterThan(0);
|
||
|
});
|
||
|
|
||
|
tap.test('CSEC-02: Check OAuth2 support', async () => {
|
||
|
const smtpClient = createSmtpClient({
|
||
|
host: testServer.hostname,
|
||
|
port: testServer.port,
|
||
|
secure: false,
|
||
|
connectionTimeout: 5000,
|
||
|
debug: true
|
||
|
});
|
||
|
|
||
|
await smtpClient.connect();
|
||
|
|
||
|
// Check EHLO response for OAuth support
|
||
|
const ehloResponse = await smtpClient.sendCommand('EHLO testclient.example.com');
|
||
|
|
||
|
console.log('Checking OAuth2 support in EHLO response...');
|
||
|
|
||
|
const supportsXOAuth2 = ehloResponse.includes('XOAUTH2');
|
||
|
const supportsOAuthBearer = ehloResponse.includes('OAUTHBEARER');
|
||
|
|
||
|
console.log(`XOAUTH2 supported: ${supportsXOAuth2}`);
|
||
|
console.log(`OAUTHBEARER supported: ${supportsOAuthBearer}`);
|
||
|
|
||
|
if (!supportsXOAuth2 && !supportsOAuthBearer) {
|
||
|
console.log('Server does not advertise OAuth2 support');
|
||
|
}
|
||
|
|
||
|
await smtpClient.close();
|
||
|
});
|
||
|
|
||
|
tap.test('CSEC-02: XOAUTH2 authentication flow', async () => {
|
||
|
const smtpClient = createSmtpClient({
|
||
|
host: testServer.hostname,
|
||
|
port: testServer.port,
|
||
|
secure: false,
|
||
|
connectionTimeout: 5000,
|
||
|
debug: true
|
||
|
});
|
||
|
|
||
|
await smtpClient.connect();
|
||
|
await smtpClient.sendCommand('EHLO testclient.example.com');
|
||
|
|
||
|
// Create XOAUTH2 string
|
||
|
// Format: base64("user=" + user + "^Aauth=Bearer " + token + "^A^A")
|
||
|
const user = 'user@example.com';
|
||
|
const accessToken = 'mock-oauth2-access-token';
|
||
|
const authString = `user=${user}\x01auth=Bearer ${accessToken}\x01\x01`;
|
||
|
const base64Auth = Buffer.from(authString).toString('base64');
|
||
|
|
||
|
console.log('\nAttempting XOAUTH2 authentication...');
|
||
|
console.log(`User: ${user}`);
|
||
|
console.log(`Token: ${accessToken.substring(0, 10)}...`);
|
||
|
|
||
|
try {
|
||
|
const authResponse = await smtpClient.sendCommand(`AUTH XOAUTH2 ${base64Auth}`);
|
||
|
|
||
|
if (authResponse.startsWith('235')) {
|
||
|
console.log('XOAUTH2 authentication successful');
|
||
|
expect(authResponse).toInclude('235');
|
||
|
} else if (authResponse.startsWith('334')) {
|
||
|
// Server wants more data or error response
|
||
|
console.log('Server response:', authResponse);
|
||
|
|
||
|
// Send empty response to get error details
|
||
|
const errorResponse = await smtpClient.sendCommand('');
|
||
|
console.log('Error details:', errorResponse);
|
||
|
} else {
|
||
|
console.log('Authentication failed:', authResponse);
|
||
|
}
|
||
|
} catch (error) {
|
||
|
console.log('XOAUTH2 not supported or failed:', error.message);
|
||
|
}
|
||
|
|
||
|
await smtpClient.close();
|
||
|
});
|
||
|
|
||
|
tap.test('CSEC-02: OAUTHBEARER authentication flow', async () => {
|
||
|
const smtpClient = createSmtpClient({
|
||
|
host: testServer.hostname,
|
||
|
port: testServer.port,
|
||
|
secure: false,
|
||
|
connectionTimeout: 5000,
|
||
|
debug: true
|
||
|
});
|
||
|
|
||
|
await smtpClient.connect();
|
||
|
await smtpClient.sendCommand('EHLO testclient.example.com');
|
||
|
|
||
|
// Create OAUTHBEARER string (RFC 7628)
|
||
|
// Format: n,a=user@example.com,^Ahost=server.example.com^Aport=587^Aauth=Bearer token^A^A
|
||
|
const user = 'user@example.com';
|
||
|
const accessToken = 'mock-oauthbearer-access-token';
|
||
|
const authString = `n,a=${user},\x01host=${testServer.hostname}\x01port=${testServer.port}\x01auth=Bearer ${accessToken}\x01\x01`;
|
||
|
const base64Auth = Buffer.from(authString).toString('base64');
|
||
|
|
||
|
console.log('\nAttempting OAUTHBEARER authentication...');
|
||
|
console.log(`User: ${user}`);
|
||
|
console.log(`Host: ${testServer.hostname}`);
|
||
|
console.log(`Port: ${testServer.port}`);
|
||
|
|
||
|
try {
|
||
|
const authResponse = await smtpClient.sendCommand(`AUTH OAUTHBEARER ${base64Auth}`);
|
||
|
|
||
|
if (authResponse.startsWith('235')) {
|
||
|
console.log('OAUTHBEARER authentication successful');
|
||
|
expect(authResponse).toInclude('235');
|
||
|
} else if (authResponse.startsWith('334')) {
|
||
|
// Server wants more data or error response
|
||
|
console.log('Server challenge:', authResponse);
|
||
|
|
||
|
// Decode challenge if present
|
||
|
const challenge = authResponse.substring(4).trim();
|
||
|
if (challenge) {
|
||
|
const decodedChallenge = Buffer.from(challenge, 'base64').toString();
|
||
|
console.log('Decoded challenge:', decodedChallenge);
|
||
|
}
|
||
|
|
||
|
// Send empty response to cancel
|
||
|
await smtpClient.sendCommand('*');
|
||
|
} else {
|
||
|
console.log('Authentication failed:', authResponse);
|
||
|
}
|
||
|
} catch (error) {
|
||
|
console.log('OAUTHBEARER not supported or failed:', error.message);
|
||
|
}
|
||
|
|
||
|
await smtpClient.close();
|
||
|
});
|
||
|
|
||
|
tap.test('CSEC-02: OAuth2 with client configuration', async () => {
|
||
|
// Test client with OAuth2 configuration
|
||
|
const smtpClient = createSmtpClient({
|
||
|
host: testServer.hostname,
|
||
|
port: testServer.port,
|
||
|
secure: false,
|
||
|
auth: {
|
||
|
type: 'oauth2',
|
||
|
user: 'oauth.user@example.com',
|
||
|
clientId: 'client-id-12345',
|
||
|
clientSecret: 'client-secret-67890',
|
||
|
accessToken: 'access-token-abcdef',
|
||
|
refreshToken: 'refresh-token-ghijkl',
|
||
|
expires: Date.now() + 3600000 // 1 hour from now
|
||
|
},
|
||
|
connectionTimeout: 5000,
|
||
|
debug: true
|
||
|
});
|
||
|
|
||
|
try {
|
||
|
await smtpClient.connect();
|
||
|
|
||
|
// Check if client handles OAuth2 auth automatically
|
||
|
const authenticated = await smtpClient.isAuthenticated();
|
||
|
console.log('OAuth2 auto-authentication:', authenticated ? 'Success' : 'Failed');
|
||
|
|
||
|
if (authenticated) {
|
||
|
// Try to send a test email
|
||
|
const result = await smtpClient.verify();
|
||
|
console.log('Connection verified:', result);
|
||
|
}
|
||
|
} catch (error) {
|
||
|
console.log('OAuth2 configuration test:', error.message);
|
||
|
// Expected if server doesn't support OAuth2
|
||
|
}
|
||
|
|
||
|
await smtpClient.close();
|
||
|
});
|
||
|
|
||
|
tap.test('CSEC-02: OAuth2 token refresh simulation', async () => {
|
||
|
const smtpClient = createSmtpClient({
|
||
|
host: testServer.hostname,
|
||
|
port: testServer.port,
|
||
|
secure: false,
|
||
|
connectionTimeout: 5000,
|
||
|
debug: true
|
||
|
});
|
||
|
|
||
|
await smtpClient.connect();
|
||
|
await smtpClient.sendCommand('EHLO testclient.example.com');
|
||
|
|
||
|
// Simulate expired token scenario
|
||
|
const user = 'user@example.com';
|
||
|
const expiredToken = 'expired-access-token';
|
||
|
const authString = `user=${user}\x01auth=Bearer ${expiredToken}\x01\x01`;
|
||
|
const base64Auth = Buffer.from(authString).toString('base64');
|
||
|
|
||
|
console.log('\nSimulating expired token scenario...');
|
||
|
|
||
|
try {
|
||
|
const authResponse = await smtpClient.sendCommand(`AUTH XOAUTH2 ${base64Auth}`);
|
||
|
|
||
|
if (authResponse.startsWith('334')) {
|
||
|
// Server returns error, decode it
|
||
|
const errorBase64 = authResponse.substring(4).trim();
|
||
|
if (errorBase64) {
|
||
|
const errorJson = Buffer.from(errorBase64, 'base64').toString();
|
||
|
console.log('OAuth2 error response:', errorJson);
|
||
|
|
||
|
try {
|
||
|
const error = JSON.parse(errorJson);
|
||
|
if (error.status === '401') {
|
||
|
console.log('Token expired or invalid - would trigger refresh');
|
||
|
|
||
|
// Simulate token refresh
|
||
|
const newToken = 'refreshed-access-token';
|
||
|
const newAuthString = `user=${user}\x01auth=Bearer ${newToken}\x01\x01`;
|
||
|
const newBase64Auth = Buffer.from(newAuthString).toString('base64');
|
||
|
|
||
|
// Cancel current auth
|
||
|
await smtpClient.sendCommand('*');
|
||
|
|
||
|
// Try again with new token
|
||
|
console.log('Retrying with refreshed token...');
|
||
|
const retryResponse = await smtpClient.sendCommand(`AUTH XOAUTH2 ${newBase64Auth}`);
|
||
|
console.log('Retry response:', retryResponse);
|
||
|
}
|
||
|
} catch (e) {
|
||
|
console.log('Error response not JSON:', errorJson);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
} catch (error) {
|
||
|
console.log('Token refresh simulation error:', error.message);
|
||
|
}
|
||
|
|
||
|
await smtpClient.close();
|
||
|
});
|
||
|
|
||
|
tap.test('CSEC-02: OAuth2 scope validation', async () => {
|
||
|
const smtpClient = createSmtpClient({
|
||
|
host: testServer.hostname,
|
||
|
port: testServer.port,
|
||
|
secure: false,
|
||
|
connectionTimeout: 5000,
|
||
|
debug: true
|
||
|
});
|
||
|
|
||
|
await smtpClient.connect();
|
||
|
await smtpClient.sendCommand('EHLO testclient.example.com');
|
||
|
|
||
|
// Test different OAuth2 scopes
|
||
|
const testScopes = [
|
||
|
{ scope: 'https://mail.google.com/', desc: 'Gmail full access' },
|
||
|
{ scope: 'https://outlook.office.com/SMTP.Send', desc: 'Outlook send-only' },
|
||
|
{ scope: 'email', desc: 'Generic email scope' }
|
||
|
];
|
||
|
|
||
|
for (const test of testScopes) {
|
||
|
console.log(`\nTesting OAuth2 with scope: ${test.desc}`);
|
||
|
|
||
|
const user = 'user@example.com';
|
||
|
const token = `token-with-scope-${test.scope.replace(/[^a-z]/gi, '')}`;
|
||
|
|
||
|
// Include scope in auth string (non-standard, for testing)
|
||
|
const authString = `user=${user}\x01auth=Bearer ${token}\x01scope=${test.scope}\x01\x01`;
|
||
|
const base64Auth = Buffer.from(authString).toString('base64');
|
||
|
|
||
|
try {
|
||
|
const response = await smtpClient.sendCommand(`AUTH XOAUTH2 ${base64Auth}`);
|
||
|
console.log(`Response for ${test.desc}: ${response.substring(0, 50)}...`);
|
||
|
|
||
|
if (response.startsWith('334') || response.startsWith('535')) {
|
||
|
// Cancel auth attempt
|
||
|
await smtpClient.sendCommand('*');
|
||
|
}
|
||
|
} catch (error) {
|
||
|
console.log(`Error for ${test.desc}: ${error.message}`);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
await smtpClient.close();
|
||
|
});
|
||
|
|
||
|
tap.test('CSEC-02: OAuth2 provider-specific formats', async () => {
|
||
|
const smtpClient = createSmtpClient({
|
||
|
host: testServer.hostname,
|
||
|
port: testServer.port,
|
||
|
secure: false,
|
||
|
connectionTimeout: 5000,
|
||
|
debug: true
|
||
|
});
|
||
|
|
||
|
await smtpClient.connect();
|
||
|
await smtpClient.sendCommand('EHLO testclient.example.com');
|
||
|
|
||
|
// Test provider-specific OAuth2 formats
|
||
|
const providers = [
|
||
|
{
|
||
|
name: 'Google',
|
||
|
format: (user: string, token: string) =>
|
||
|
`user=${user}\x01auth=Bearer ${token}\x01\x01`
|
||
|
},
|
||
|
{
|
||
|
name: 'Microsoft',
|
||
|
format: (user: string, token: string) =>
|
||
|
`user=${user}\x01auth=Bearer ${token}\x01\x01`
|
||
|
},
|
||
|
{
|
||
|
name: 'Yahoo',
|
||
|
format: (user: string, token: string) =>
|
||
|
`user=${user}\x01auth=Bearer ${token}\x01\x01`
|
||
|
}
|
||
|
];
|
||
|
|
||
|
for (const provider of providers) {
|
||
|
console.log(`\nTesting ${provider.name} OAuth2 format...`);
|
||
|
|
||
|
const user = `test@${provider.name.toLowerCase()}.com`;
|
||
|
const token = `${provider.name.toLowerCase()}-oauth-token`;
|
||
|
const authString = provider.format(user, token);
|
||
|
const base64Auth = Buffer.from(authString).toString('base64');
|
||
|
|
||
|
try {
|
||
|
const response = await smtpClient.sendCommand(`AUTH XOAUTH2 ${base64Auth}`);
|
||
|
console.log(`${provider.name} response: ${response.substring(0, 30)}...`);
|
||
|
|
||
|
if (!response.startsWith('235')) {
|
||
|
// Cancel if not successful
|
||
|
await smtpClient.sendCommand('*');
|
||
|
}
|
||
|
} catch (error) {
|
||
|
console.log(`${provider.name} error: ${error.message}`);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
await smtpClient.close();
|
||
|
});
|
||
|
|
||
|
tap.test('CSEC-02: OAuth2 security considerations', async () => {
|
||
|
const smtpClient = createSmtpClient({
|
||
|
host: testServer.hostname,
|
||
|
port: testServer.port,
|
||
|
secure: false,
|
||
|
connectionTimeout: 5000,
|
||
|
debug: true
|
||
|
});
|
||
|
|
||
|
await smtpClient.connect();
|
||
|
|
||
|
console.log('\nOAuth2 Security Considerations:');
|
||
|
|
||
|
// Check if connection is encrypted
|
||
|
const connectionInfo = smtpClient.getConnectionInfo();
|
||
|
console.log(`Connection encrypted: ${connectionInfo?.secure || false}`);
|
||
|
|
||
|
if (!connectionInfo?.secure) {
|
||
|
console.log('WARNING: OAuth2 over unencrypted connection is insecure!');
|
||
|
}
|
||
|
|
||
|
// Check STARTTLS availability
|
||
|
const ehloResponse = await smtpClient.sendCommand('EHLO testclient.example.com');
|
||
|
const supportsStartTLS = ehloResponse.includes('STARTTLS');
|
||
|
|
||
|
if (supportsStartTLS && !connectionInfo?.secure) {
|
||
|
console.log('STARTTLS available - upgrading connection...');
|
||
|
|
||
|
try {
|
||
|
const starttlsResponse = await smtpClient.sendCommand('STARTTLS');
|
||
|
if (starttlsResponse.startsWith('220')) {
|
||
|
console.log('Connection upgraded to TLS');
|
||
|
// In real implementation, TLS handshake would happen here
|
||
|
}
|
||
|
} catch (error) {
|
||
|
console.log('STARTTLS failed:', error.message);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Test token exposure in logs
|
||
|
const sensitiveToken = 'super-secret-oauth-token-12345';
|
||
|
const safeLogToken = sensitiveToken.substring(0, 10) + '...';
|
||
|
console.log(`Token handling - shown as: ${safeLogToken}`);
|
||
|
|
||
|
await smtpClient.close();
|
||
|
});
|
||
|
|
||
|
tap.test('CSEC-02: OAuth2 error handling', async () => {
|
||
|
const smtpClient = createSmtpClient({
|
||
|
host: testServer.hostname,
|
||
|
port: testServer.port,
|
||
|
secure: false,
|
||
|
connectionTimeout: 5000,
|
||
|
debug: true
|
||
|
});
|
||
|
|
||
|
await smtpClient.connect();
|
||
|
await smtpClient.sendCommand('EHLO testclient.example.com');
|
||
|
|
||
|
// Test various OAuth2 error scenarios
|
||
|
const errorScenarios = [
|
||
|
{
|
||
|
name: 'Invalid token format',
|
||
|
authString: 'invalid-base64-!@#$'
|
||
|
},
|
||
|
{
|
||
|
name: 'Empty token',
|
||
|
authString: Buffer.from('user=test@example.com\x01auth=Bearer \x01\x01').toString('base64')
|
||
|
},
|
||
|
{
|
||
|
name: 'Missing user',
|
||
|
authString: Buffer.from('auth=Bearer token123\x01\x01').toString('base64')
|
||
|
},
|
||
|
{
|
||
|
name: 'Malformed structure',
|
||
|
authString: Buffer.from('user=test@example.com auth=Bearer token').toString('base64')
|
||
|
}
|
||
|
];
|
||
|
|
||
|
for (const scenario of errorScenarios) {
|
||
|
console.log(`\nTesting: ${scenario.name}`);
|
||
|
|
||
|
try {
|
||
|
const response = await smtpClient.sendCommand(`AUTH XOAUTH2 ${scenario.authString}`);
|
||
|
console.log(`Response: ${response}`);
|
||
|
|
||
|
if (response.startsWith('334') || response.startsWith('501') || response.startsWith('535')) {
|
||
|
// Expected error responses
|
||
|
await smtpClient.sendCommand('*'); // Cancel
|
||
|
}
|
||
|
} catch (error) {
|
||
|
console.log(`Error (expected): ${error.message}`);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
await smtpClient.close();
|
||
|
});
|
||
|
|
||
|
tap.test('cleanup test SMTP server', async () => {
|
||
|
if (testServer) {
|
||
|
await testServer.stop();
|
||
|
}
|
||
|
});
|
||
|
|
||
|
export default tap.start();
|