OAuth を使って Exchange Online に SMTP で接続する

Exchange Online の基本認証無効化に向けて、SMTP でも OAuth がサポートされるようになりました。開発者向けの情報は以下のページに記載されています。
https://docs.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth

C# で実装する場合の基本的な内容を作成したので、以下にサンプル コードを紹介します。コード自体に対する説明は特にないので、上記の開発者向けページと併せて読んでいただければと思います。必要最低限の内容のみを実装しているため、本格的に実装を行うには Microsoft Identity Platform や SMTP の知識が必要になります。C# のコンソール アプリケーションとなっており、認証ライブラリとして MSAL を使用しています。認証フローは、わざわざ今から Authorization Code Flow を使用する SMTP クライアントを作成するメリットがないため Device Authorization Grant Flow にしています。この場合の Azure AD へのアプリケーションの登録方法も最後に紹介します。

なお、SMTP を使用して Exchange Online へ接続しなければならない明確な要件が無いのであれば Microsoft Graph への移行を検討すべきです。

サンプル コード

MSAL (Microsoft.Identity.Client) バージョン 4.13.0 にて動作確認をしています。

using Microsoft.Identity.Client;
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading.Tasks;

namespace SmtpOAuthDemo
{
    class Program
    {
        private const string ClientId = "<CLIENT ID OF YOUR APP>";
        private const string Authority = "https://login.microsoftonline.com/organizations";
        private static readonly string[] Scopes = new string[] { "https://outlook.office365.com/SMTP.Send" };

        private const string RecipientSmtpAddress = "user01@contoso.com";

        static void Main(string[] args)
        {
            var tokenResult = GetATokenForGraph().Result;
            string token = tokenResult.AccessToken;
            string user = tokenResult.Account.Username;
            string XOAUTH2 = Base64Encode($"user={user}\u0001auth=Bearer {token}\u0001\u0001");
            string fromAddress = user;
            // In case of sending as someone else, replace the value of fromAddress variable with the user's email address.

            using (ExoSmtpClient client = new ExoSmtpClient())
            {
                // Receive the greating.
                var response = client.Receive();

                if (!response.StartsWith("220"))
                {
                    throw new Exception("Unexpected response received.");
                }

                client.Send($"EHLO {Dns.GetHostName()}");
                response = client.Receive();

                if (!response.Contains("STARTTLS"))
                {
                    throw new Exception("STARTTLS is not supported");
                }

                client.Send("STARTTLS");
                response = client.Receive();

                if (!response.StartsWith("220"))
                {
                    throw new Exception("Unexpected response received.");
                }

                client.UpgradeToSsl();

                client.Send($"EHLO {Dns.GetHostName()}");
                response = client.Receive();

                if (!response.Contains("XOAUTH2"))
                {
                    throw new Exception("OAuth is not supported");
                }

                client.Send("AUTH XOAUTH2");
                response = client.Receive();

                if (!response.StartsWith("334"))
                {
                    throw new Exception("Unexpected response received.");
                }

                client.Send(XOAUTH2);
                response = client.Receive();

                if (!response.StartsWith("235"))
                {
                    throw new Exception("Authentication failure");
                }

                client.Send($"MAIL FROM: {fromAddress}");
                response = client.Receive();

                if (!response.StartsWith("250"))
                {
                    throw new Exception("Unexpected response received.");
                }

                client.Send($"RCPT TO: {RecipientSmtpAddress}");
                response = client.Receive();

                if (!response.StartsWith("250"))
                {
                    throw new Exception("Unexpected response received.");
                }

                client.Send("DATA");
                response = client.Receive();

                if (!response.StartsWith("354"))
                {
                    throw new Exception("Unexpected response received.");
                }

                client.Send($"From: {fromAddress}");
                client.Send($"To: {RecipientSmtpAddress}");
                client.Send("Subject: Test Mail");
                client.Send("");
                client.Send("Test message.");
                client.Send(".");

                response = client.Receive();

                if (!response.StartsWith("250"))
                {
                    throw new Exception("Unexpected response received.");
                }

                client.Send("QUIT");
            }

            Console.ReadLine();
        }

        static async Task<AuthenticationResult> GetATokenForGraph()
        {
            IPublicClientApplication pca = PublicClientApplicationBuilder
                .Create(ClientId)
                .WithAuthority(Authority)
                .WithDefaultRedirectUri()
                .Build();

            var accounts = await pca.GetAccountsAsync();

            try
            {
                return await pca.AcquireTokenSilent(Scopes, accounts.FirstOrDefault()).ExecuteAsync();
            }
            catch (MsalUiRequiredException)
            {
                return await AcquireByDeviceCodeAsync(pca);
            }
        }

        private static async Task<AuthenticationResult> AcquireByDeviceCodeAsync(IPublicClientApplication pca)
        {
            try
            {
                var result = await pca.AcquireTokenWithDeviceCode(Scopes,
                    deviceCodeResult =>
                    {
                        Console.WriteLine(deviceCodeResult.Message);
                        return Task.FromResult(0);
                    }).ExecuteAsync();

                return result;
            }
            catch (Exception ex)
            {
                throw new Exception("Authentication failure", ex);
            }
        }

        private static string Base64Encode(string plainText)
        {
            var plainTextBytes = Encoding.UTF8.GetBytes(plainText);
            return Convert.ToBase64String(plainTextBytes);
        }
    }

    public class ExoSmtpClient : TcpClient
    {
        private Stream stream;
        private readonly string host = "outlook.office365.com";
        private byte[] receiveBuffer = new byte[1024];

        public ExoSmtpClient()
            : base("outlook.office365.com", 587)
        {
            stream = GetStream();
        }

        public void UpgradeToSsl()
        {
            var sslStream = new SslStream(stream, false, ValidateRemoteCertificate);
            sslStream.AuthenticateAsClient(host);
            stream = sslStream;
        }

        private static bool ValidateRemoteCertificate(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors)
        {
            return true;
        }

        public void Send(string command)
        {
            var commandBytes = Encoding.ASCII.GetBytes(command + Environment.NewLine);
            stream.Write(commandBytes, 0, commandBytes.Length);

            Console.WriteLine($"C:\t{command}");
        }

        public string Receive()
        {
            var stringBuilder = new StringBuilder();

            for (; ; )
            {
                var temp = stream.Read(receiveBuffer, 0, receiveBuffer.Length);
                stringBuilder.Append(Encoding.ASCII.GetString(receiveBuffer, 0, temp));

                if (0 < Available)
                {
                    continue;
                }

                if (2 <= stringBuilder.Length &amp;&amp; stringBuilder[stringBuilder.Length - 2] == '\r' &amp;&amp; stringBuilder[stringBuilder.Length - 1] == '\n')
                {
                    break;
                }
            }

            var response = stringBuilder.ToString();

            Console.WriteLine($"S:\t{stringBuilder.Replace("\r\n", "\r\n\t").ToString(0, stringBuilder.Length - 1)}");

            return response;
        }
    }
}

アプリの登録

  1. https://portal.azure.com/ にアクセスして、Azure Active Directory 管理センターにサインインします。
  2. Azure Active Directory ブレードに移動します。
  3. [管理] – [アプリの登録] – [新規登録] をクリックします。
  4. 以下のように設定し、[登録] をクリックします。
    • 名前 : 任意のアプリケーションの名前を指定します (例 : SmtpTest01)
    • サポートされているアカウントの種類 : 任意の組織ディレクトリ内のアカウント (任意の Azure AD ディレクトリ – マルチテナント)
    • リダイレクト URI : ドロップダウンから [パブリック クライアント/ネイティブ (モバイルとデスクトップ)] を選択して、値を https://login.microsoftonline.com/common/oauth2/nativeclient にします
  5. [管理] – [認証] をクリックします。
  6. [アプリケーションは、パブリック クライアントとして扱います。] を [はい] にします。
  7. [保存] をクリックします。