Client Credential Flow を使って Exchange Online に SMTP で接続する

SMTP でも Client Credential Flow がサポートされるようになりました。開発者向けの情報は以下のページに記載されています。
https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth

アプリの登録や Exchange Online での準備、そして C# で実装する場合の基本的な内容を紹介します。コード自体に対する説明は特にないので、上記の開発者向けページと併せて読んでいただければと思います。必要最低限の内容のみを実装しているため、本格的に実装を行うには Microsoft Identity Platform や SMTP の知識が必要になります。C# のコンソール アプリケーションとなっており、認証ライブラリとして MSAL を使用しています。

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

Device Authentication Grant Flow を使用する場合は OAuth を使って Exchange Online に SMTP で接続するを参照してください。

アプリの登録

  1. https://portal.azure.com/ にアクセスして、Azure ポータルにサインインします。
  2. Azure Active Directory ブレードに移動します。
  3. [管理] – [アプリの登録] – [新規登録] をクリックします。
  4. 以下のように設定し、[登録] をクリックします。
    • 名前 : 任意のアプリケーションの名前を指定します (例 : SmtpTest02)
    • サポートされているアカウントの種類 : 任意の組織ディレクトリ内のアカウント (任意の Azure AD ディレクトリ – マルチテナント)
    • リダイレクト URI : ドロップダウンから [パブリック クライアント/ネイティブ (モバイルとデスクトップ)] を選択して、値を https://login.microsoftonline.com/common/oauth2/nativeclient にします
  5. [概要] – [基本] – [アプリケーション (クライアント) ID] の値を控えておきます。
  6. [管理] – [API のアクセス許可] をクリックします。
  7. [アクセス許可の追加] をクリックします。
  8. [所属する組織で使用している API] から [Office 365 Exchange Online]をクリックします。
  9. [アプリケーションの許可] をクリックします。
  10. [SMTP] – [SMTP.SendAsApp] を選択し、[アクセス許可の追加] をクリックします。
  11. [構成されたアクセス許可] に既定で含まれていた [Microsoft Graph] – [User.Read] の右側の [・・・] から [アクセス許可の削除] をクリックします。
  12. [はい、削除します] をクリックします。
  13. [<テナント名>] に管理者の同意を与えます] をクリックします。
  14. [はい] をクリックします。
  15. [管理] – [証明書とシークレット] をクリックします。
  16. [クライアント シークレット] – [新しいクライアント シークレット] をクリックします。
  17. [説明] に任意の説明を入力し、[追加] をクリックします。
  18. 追加されたクライアント シークレットの値を控えておきます。
  19. Azure Active Directory ブレードのトップに移動します。
  20. [管理] – [エンタープライズ アプリケーション] をクリックします。
  21. 一覧から先ほど登録したアプリを探して [オブジェクト ID] の値を控えておきます。

Exchange Online の設定

  1. Exchange Online の管理者権限で PowerShell で Exchange Online に接続します。
  2. 以下のようにコマンドを実行します。
    New-ServicePrincipal -AppId <控えておいたアプリケーション (クライアント) ID> -ObjectId <控えておいたオブジェクト ID> -DisplayName <登録したアプリの名前> | fl

    実行例)
    New-ServicePrincipal -AppId 05cd6963-153d-437f-92c7-2ddbc195d595 -ObjectId 8e1edc86-3a23-4afa-99d9-0cc0794b123d -DisplayName “SmtpTest02” | fl
  3. さらに以下のようにコマンドを実行して、アプリが接続するメールボックスに対するフル アクセス権を設定します。
    Add-MailboxPermission -Identity <アクセス先のメールボックス> -User <手順 2 の出力結果にある New-ServicePrincipal コマンド実行後に表示された Identity の値> -AccessRights FullAccess

    実行例)
    Add-MailboxPermission -Identity ExoUser01 -User 8e1edc86-3a23-4afa-99d9-0cc0794b123d -AccessRights FullAccess

サンプル コード

.NET 7.0 環境で MSAL (Microsoft.Identity.Client) バージョン 4.54.1 にて動作確認をしています。

using Microsoft.Identity.Client;
using System.Net.Security;
using System.Net.Sockets;
using System.Net;
using System.Security.Cryptography.X509Certificates;
using System.Text;

namespace SmtpOAuthCcfDemo
{
    internal class Program
    {
        private const string ClientId = "<控えておいたアプリケーション (クライアント) ID>";
        private const string Secret = "<控えておいたクライアント シークレット>";
        private const string TenantId = "<接続先のテナント名 (contoso.onmicrosoft.com など)>";
        private static readonly string[] Scopes = new string[] { "https://outlook.office365.com/.default" };

        private const string SenderSmtpAddress = "<差出人の SMTP アドレス (アプリがフル アクセス権を与えられているメールボックス)>";
        private const string RecipientSmtpAddress = "<宛先の SMTP アドレス>";

        static void Main(string[] args)
        {
            var tokenResult = GetToken().Result;
            string token = tokenResult.AccessToken;
            string XOAUTH2 = Base64Encode($"user={SenderSmtpAddress}\u0001auth=Bearer {token}\u0001\u0001");

            using (ExoSmtpClient client = new())
            {
                // 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: {SenderSmtpAddress}");
                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: {SenderSmtpAddress}");
                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> GetToken()
        {
            var cca = ConfidentialClientApplicationBuilder
                .Create(ClientId)
                .WithClientSecret(Secret)
                .Build();

            return await cca.AcquireTokenForClient(Scopes)
                .WithAuthority(AzureCloudInstance.AzurePublic, TenantId)
                .ExecuteAsync();
        }

        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 && stringBuilder[stringBuilder.Length - 2] == '\r' && 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;
        }
    }
}