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 で接続するを参照してください。
アプリの登録
- https://portal.azure.com/ にアクセスして、Azure ポータルにサインインします。
- Azure Active Directory ブレードに移動します。
- [管理] – [アプリの登録] – [新規登録] をクリックします。
- 以下のように設定し、[登録] をクリックします。
- 名前 : 任意のアプリケーションの名前を指定します (例 : SmtpTest02)
- サポートされているアカウントの種類 : 任意の組織ディレクトリ内のアカウント (任意の Azure AD ディレクトリ – マルチテナント)
- リダイレクト URI : ドロップダウンから [パブリック クライアント/ネイティブ (モバイルとデスクトップ)] を選択して、値を https://login.microsoftonline.com/common/oauth2/nativeclient にします
- [概要] – [基本] – [アプリケーション (クライアント) ID] の値を控えておきます。
- [管理] – [API のアクセス許可] をクリックします。
- [アクセス許可の追加] をクリックします。
- [所属する組織で使用している API] から [Office 365 Exchange Online]をクリックします。
- [アプリケーションの許可] をクリックします。
- [SMTP] – [SMTP.SendAsApp] を選択し、[アクセス許可の追加] をクリックします。
- [構成されたアクセス許可] に既定で含まれていた [Microsoft Graph] – [User.Read] の右側の [・・・] から [アクセス許可の削除] をクリックします。
- [はい、削除します] をクリックします。
- [<テナント名>] に管理者の同意を与えます] をクリックします。
- [はい] をクリックします。
- [管理] – [証明書とシークレット] をクリックします。
- [クライアント シークレット] – [新しいクライアント シークレット] をクリックします。
- [説明] に任意の説明を入力し、[追加] をクリックします。
- 追加されたクライアント シークレットの値を控えておきます。
- Azure Active Directory ブレードのトップに移動します。
- [管理] – [エンタープライズ アプリケーション] をクリックします。
- 一覧から先ほど登録したアプリを探して [オブジェクト ID] の値を控えておきます。
Exchange Online の設定
- Exchange Online の管理者権限で PowerShell で Exchange Online に接続します。
- 以下のようにコマンドを実行します。
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 - さらに以下のようにコマンドを実行して、アプリが接続するメールボックスに対するフル アクセス権を設定します。
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;
}
}
}