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

Exchange Online の基本認証無効化に向けて、IMAP でも 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 や IMAP の知識が必要になります。C# のコンソール アプリケーションとなっており、認証ライブラリとして MSAL を使用しています。認証フローは、わざわざ今から Authorization Code Flow を使用する IMAP クライアントを作成するメリットがないため Device Authorization Grant Flow にしています。この場合の Azure AD へのアプリケーションの登録方法も最後に紹介します。

なお、IMAP を使用して 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.Security;
using System.Net.Sockets;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading.Tasks;

namespace ImapClientDemo
{
    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/IMAP.AccessAsUser.All" };

        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");

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

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

                client.Send("C01 CAPABILITY");
                response = client.Receive();

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

                client.Send($"A01 AUTHENTICATE XOAUTH2 {XOAUTH2}");
                response = client.Receive();

                if (!response.Contains("A01 OK"))
                {
                    throw new Exception("Authentication failure");
                }

                // Get the Inbox folder.
                client.Send(string.Format("S01 SELECT \"Inbox\""));
                response = client.Receive();

                if (!response.Contains("S01 OK"))
                {
                    throw new Exception("Unexpected response received.");
                }

                // Download headers of the first message.
                client.Send("F01 FETCH 1 RFC822.HEADER");
                response = client.Receive();

                if (!response.Contains("F01 OK"))
                {
                    throw new Exception("Unexpected response received.");
                }

                client.Send("C02 CLOSE");
                response = client.Receive();

                if (!response.Contains("C02 OK"))
                {
                    throw new Exception("Unexpected response received.");
                }

                client.Send("L01 LOGOUT");
                client.Receive();
            }

            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 ExoImapClient : TcpClient
    {
        private Stream stream;
        private readonly string host = "outlook.office365.com";
        private byte[] receiveBuffer = new byte[1024];

        public ExoImapClient()
            : base("outlook.office365.com", 993)
        {
            stream = GetStream();

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