import { getStatusCode, getErrorText, isServerRefusal } from '../tools.js'; import { createImapError } from '../errors.js'; // The OAuth SASL payloads are framed with \x01 (RFC 7628 section 3.1, and the XOAUTH2 format // copies it), so a control character in a value ends the field early and the rest of the value // becomes fields of its own, auth= included. RFC 7628 allows only VCHAR, SP, HTAB, CR and LF in // a value, and none of the controls has a place in a username or a bearer token. const RE_CONTROL_CHARS = /[\x00-\x1f\x7f]/; /** * Refuses a username or token that could not be framed as one SASL field. * * @param value - The value about to be placed in the payload * @param what - What the value is, for the error message * @throws If the value contains a control character */ function assertSaslValue(value, what) { if (RE_CONTROL_CHARS.test(value)) { throw createImapError(`The ${what} contains control characters`, 'InvalidStringValue'); } } /** * Escapes a value for the authzid of a GS2 header (RFC 5801 section 4): "," and "=" delimit * the header, so they are written as =2C and =3D. "=" goes first, so the "=" the escaping * itself introduces is not escaped again. * * @param value - The saslname * @returns The escaped saslname */ function escapeSaslName(value) { return value.replace(/[=]/g, '=3D').replace(/,/g, '=2C'); } /** * Handles authentication errors by enriching the error object with server response details. * * @param err - The original authentication error * @param errorResponse - Optional OAuth error response from the server * @returns The enriched error; the caller is expected to throw it */ async function handleAuthError(err, errorResponse) { let errorCode = getStatusCode(err.response); if (errorCode) { err.serverResponseCode = errorCode; } // Only a tagged NO/BAD is the server refusing the credentials, see login.ts if (isServerRefusal(err)) { err.authenticationFailed = true; } err.response = await getErrorText(err.response); if (errorResponse) { err.oauthError = errorResponse; } return err; } /** * Authenticates using OAuth (OAUTHBEARER or XOAUTH2). * * @param connection - IMAP connection instance * @param username - The username to authenticate with * @param accessToken - The OAuth2 access token * @returns The authenticated username * @throws {Error} If authentication fails */ async function authOauth(connection, username, accessToken) { let oauthbearer; let command; let breaker; assertSaslValue(username, 'username'); assertSaslValue(accessToken, 'access token'); if (connection.capabilities.has('AUTH=OAUTHBEARER')) { // OAUTHBEARER payload per RFC 7628: fields separated by \x01 (SASL GS2 framing). // Format: "n,a=," \x01 "host=..." \x01 "port=..." \x01 "auth=Bearer " \x01 \x01 // The trailing empty strings produce the required double-\x01 terminator. // Both fields must describe the connection actually in use. The port was hardcoded to 993, // so an OAuth2 server reached over 143/STARTTLS advertised a payload that did not match, and // `servername` is set to false for a bare-IP host, which rendered as a literal "host=false". // A server validating either field rejects with status `invalid_request` - a permanent // failure that refreshing the access token can never clear. oauthbearer = [ `n,a=${escapeSaslName(username)},`, `host=${connection.servername || connection.host}`, `port=${connection.port}`, `auth=Bearer ${accessToken}`, '', '' ].join('\x01'); command = 'OAUTHBEARER'; // "AQ==" is base64 for \x01, sent as the error continuation to abort the SASL exchange breaker = 'AQ=='; } else if (connection.capabilities.has('AUTH=XOAUTH') || connection.capabilities.has('AUTH=XOAUTH2')) { // XOAUTH2 payload (Google-specific): simpler format, also \x01-delimited. // Format: "user=" \x01 "auth=Bearer " \x01 \x01 oauthbearer = [`user=${username}`, `auth=Bearer ${accessToken}`, '', ''].join('\x01'); command = 'XOAUTH2'; // Empty breaker: XOAUTH2 expects an empty response to abort the SASL exchange breaker = ''; } let encoded = Buffer.from(oauthbearer).toString('base64'); // Without SASL-IR the payload may not ride on the command line (RFC 4959 section 3): it is // sent as the answer to the first, empty continuation request instead let payloadPending = !connection.capabilities.has('SASL-IR'); let errorResponse = false; try { let attributes = [{ type: 'ATOM', value: command }]; if (!payloadPending) { attributes.push({ type: 'ATOM', value: encoded, sensitive: true }); } let response = await connection.exec('AUTHENTICATE', attributes, { // Server sends a "+" continuation if auth fails, with a base64 JSON error payload. // We decode it for diagnostics, then send the breaker to terminate the exchange. onPlusTag: async (resp) => { if (payloadPending) { payloadPending = false; connection.log.debug({ src: 'c', msg: '(* value hidden *)', comment: `Encoded response for AUTH=${command}`, cid: connection.id }); connection.write(encoded); return; } if (resp.attributes && resp.attributes[0] && resp.attributes[0].type === 'TEXT') { try { errorResponse = JSON.parse(Buffer.from(resp.attributes[0].value, 'base64').toString()); } catch (err) { connection.log.debug({ msg: 'Failed to parse OAuth error response', errorResponse: resp.attributes[0].value, err, cid: connection.id }); } } connection.log.debug({ src: 'c', msg: breaker, comment: `Error response for ${command}`, cid: connection.id }); connection.write(breaker); } }); response.next(); connection.authCapabilities.set(`AUTH=${command}`, true); return username; } catch (err) { throw await handleAuthError(err, errorResponse); } } /** * Authenticates using the SASL LOGIN mechanism. * * @param connection - IMAP connection instance * @param username - The username to authenticate with * @param password - The password to authenticate with * @returns The authenticated username * @throws {Error} If authentication fails */ async function authLogin(connection, username, password) { let errorResponse = false; try { // SASL LOGIN is a challenge-response mechanism: the server sends base64-encoded // prompts ("Username:" and "Password:") and the client responds with base64-encoded values. let usernameSent = false; let response = await connection.exec('AUTHENTICATE', [{ type: 'ATOM', value: 'LOGIN' }], { onPlusTag: async (resp) => { // Decode the server's base64 challenge to determine what it's asking for. // Strip trailing colons and null bytes (\x00) that some servers append to the prompt. let question = resp.attributes && resp.attributes[0] && resp.attributes[0].type === 'TEXT' ? Buffer.from(resp.attributes[0].value, 'base64') .toString() .toLowerCase() .replace(/[:\x00]*$/, '') : ''; // Some servers send an empty first challenge, which by SASL LOGIN convention asks for the username if (question === 'username' || question === 'user name' || (!question && !usernameSent)) { let encodedUsername = Buffer.from(username).toString('base64'); connection.log.debug({ src: 'c', msg: encodedUsername, comment: `Encoded username for AUTH=LOGIN`, cid: connection.id }); connection.write(encodedUsername); usernameSent = true; } else if (question === 'password') { connection.log.debug({ src: 'c', msg: '(* value hidden *)', comment: `Encoded password for AUTH=LOGIN`, cid: connection.id }); connection.write(Buffer.from(password).toString('base64')); } else { // Cancel the exchange (RFC 9051 section 6.2.2), so the server fails the command // with a tagged BAD instead of waiting for an answer that never comes connection.log.warn({ msg: 'Unknown AUTH=LOGIN challenge, cancelling', question, cid: connection.id }); connection.write('*'); } } }); response.next(); connection.authCapabilities.set(`AUTH=LOGIN`, true); return username; } catch (err) { throw await handleAuthError(err, errorResponse); } } /** * Authenticates using the SASL PLAIN mechanism. * * @param connection - IMAP connection instance * @param username - The authentication identity (authcid) * @param password - The password to authenticate with * @param authzid - Optional authorization identity to impersonate * @returns The authorized identity (authzid if provided, otherwise username) * @throws {Error} If authentication fails */ async function authPlain(connection, username, password, authzid) { let errorResponse = false; try { let response = await connection.exec('AUTHENTICATE', [{ type: 'ATOM', value: 'PLAIN' }], { onPlusTag: async () => { // SASL PLAIN format: [authzid]\x00authcid\x00password // authzid: authorization identity (who to impersonate) // authcid: authentication identity (who is authenticating) let authzidValue = authzid || ''; let encodedResponse = Buffer.from([authzidValue, username, password].join('\x00')).toString('base64'); let loggedResponse = Buffer.from([authzidValue, username, '(* value hidden *)'].join('\x00')).toString('base64'); connection.log.debug({ src: 'c', msg: loggedResponse, comment: `Encoded response for AUTH=PLAIN${authzid ? ' with authzid' : ''}`, cid: connection.id }); connection.write(encodedResponse); } }); response.next(); connection.authCapabilities.set(`AUTH=PLAIN`, true); // Return the identity we're authorized as (authzid if provided, otherwise username) return authzid || username; } catch (err) { throw await handleAuthError(err, errorResponse); } } /** * Authenticates user using the best available method. * * @param connection - IMAP connection instance * @param username - The username to authenticate with * @param credentials - Authentication credentials * @returns The authenticated username, or undefined if already authenticated * @throws {Error} If no supported authentication mechanism is available or if authentication fails */ export default async function authenticate(connection, username, { accessToken, password, loginMethod, authzid }) { if (connection.state !== connection.states.NOT_AUTHENTICATED) { // nothing to do here return; } // Authentication method selection order: // 1. OAuth (OAUTHBEARER > XOAUTH2), preferred when an accessToken is provided, // as it avoids transmitting passwords entirely. // 2. SASL PLAIN, preferred over LOGIN because it supports authzid (impersonation) // and sends credentials in a single round trip. // 3. SASL LOGIN, the fallback; an older challenge-response mechanism (two round trips). // If loginMethod is explicitly set, it overrides the automatic capability-based selection. if (accessToken) { // AUTH=OAUTHBEARER and AUTH=XOAUTH in the context of OAuth2 or very similar so we can handle these together if (connection.capabilities.has('AUTH=OAUTHBEARER') || connection.capabilities.has('AUTH=XOAUTH') || connection.capabilities.has('AUTH=XOAUTH2')) { return await authOauth(connection, username, accessToken); } } if (password) { if ((!loginMethod && connection.capabilities.has('AUTH=PLAIN')) || loginMethod === 'AUTH=PLAIN') { return await authPlain(connection, username, password, authzid); } if ((!loginMethod && connection.capabilities.has('AUTH=LOGIN')) || loginMethod === 'AUTH=LOGIN') { return await authLogin(connection, username, password); } } throw new Error('Unsupported authentication mechanism'); }