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

IMAP でも 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 や IMAP の知識が必要になります。C# のコンソール アプリケーションとなっており、認証ライブラリとして MSAL を使用しています。

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

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

アプリの登録

  1. https://portal.azure.com/ にアクセスして、Azure ポータルにサインインします。
  2. Azure Active Directory ブレードに移動します。
  3. [管理] – [アプリの登録] – [新規登録] をクリックします。
  4. 以下のように設定し、[登録] をクリックします。
    • 名前 : 任意のアプリケーションの名前を指定します (例 : ImapTest02)
    • サポートされているアカウントの種類 : 任意の組織ディレクトリ内のアカウント (任意の Azure AD ディレクトリ – マルチテナント)
    • リダイレクト URI : ドロップダウンから [パブリック クライアント/ネイティブ (モバイルとデスクトップ)] を選択して、値を https://login.microsoftonline.com/common/oauth2/nativeclient にします
  5. [概要] – [基本] – [アプリケーション (クライアント) ID] の値を控えておきます。
  6. [管理] – [API のアクセス許可] をクリックします。
  7. [アクセス許可の追加] をクリックします。
  8. [所属する組織で使用している API] から [Office 365 Exchange Online]をクリックします。
  9. [アプリケーションの許可] をクリックします。
  10. [IMAP] – [IMAP.AccessAsApp] を選択し、[アクセス許可の追加] をクリックします。
  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 “ImapTest02” | 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 にて動作確認をしています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
using Microsoft.Identity.Client;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Cryptography.X509Certificates;
using System.Text;
 
namespace ImapOAuthCcfDemo
{
    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 SmtpAddress = "<接続先メールボックスの SMTP アドレス (アプリがフル アクセス権を与えられているメールボックス)>";
  
        static void Main(string[] args)
        {
            var tokenResult = GetToken().Result;
            string token = tokenResult.AccessToken;
            string XOAUTH2 = Base64Encode($"user={SmtpAddress}\u0001auth=Bearer {token}\u0001\u0001");
 
            using (ExoImapClient client = new())
            {
                // 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.");
                }
 
                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> 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 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 && 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;
        }
    }
}