Exchange Online PowerShell リモート接続のポータブル対応と完全無人自動化

## 概要

Exchange Online の設定を自動化するには [Exchange Online PowerShell](https://docs.microsoft.com/ja-jp/powershell/exchange/exchange-online/exchange-online-powershell) のリモート接続が利用できます。しかしながら、ドキュメントで説明されている方法には次のような問題があります。

- Windows の PowerShell でしか利用できない
  - macOS や Linux の PowerShell Core では動かない
  - GUI のサインイン画面を出すモジュールに依存しているため？
- 多要素認証を使用する場合、無人実行環境での自動化ができない
  - 毎回サインインを要求されるため管理自動化タスクの CI ができない

今回、これらの問題を解決した PowerShell Core によるポータブルなスクリプトを書いてみたので紹介します。

## 利用法

### スクリプトの実行

スクリプトは GitLab の [exops1demo プロジェクト](https://gitlab.com/yaegashi/exops1demo)にあります。 [ExchangeOnlineAutomate.ps1](https://gitlab.com/yaegashi/exops1demo/-/blob/master/ExchangeOnlineAutomate.ps1) が本体です。 Windows PowerShell や macOS/Linux などで動作する PowerShell Core で利用できます。

スクリプトでは [PartnerCenter](https://github.com/microsoft/Partner-Center-PowerShell) というモジュールで Azure AD の認証を扱っています。このモジュールは [PowerShell Gallery で公開されている](https://www.powershellgallery.com/packages/PartnerCenter)ので、次のコマンドで事前にインストールしてください。

```pwsh:pwsh
Install-Module PartnerCenter
```

単純に ExchangeOnlineAutomate.ps1 を実行すると、次のようにデバイスコード認証のサインインを要求してきます。

```terminal:ターミナル
WARNING: To sign in, use a web browser to open the page https://microsoft.com/devicelogin and enter the code B6UD2PR7H to authenticate.
```

これに従って Exchange Online のユーザーがサインインすると、そのユーザーとして Exchange Online サーバに接続し、Exchange Online PowerShell コマンドレットをインポートして実行するという流れになっています。

### 認証トークンの永続化

さらにこのスクリプトには認証トークンを Azure Blob Storage に永続化する機能があります。これを使うと認証が完全無人自動化でき、CI での実行が実現できます。

トークンを永続化するにはスクリプトに `-TokenUri` パラメータで SAS つきの Blob URI を渡す必要があります。一度手動のサインインに成功すれば、次の起動からはこの URI に永続化したトークンを使って認証・接続するようになります。

SAS つき Blob URI については、無効化が必要になったときのために、[保存されているアクセスポリシー](https://docs.microsoft.com/ja-jp/rest/api/storageservices/define-stored-access-policy)を利用して生成することを強くお勧めします。 Azure CLI では次の例のようにすれば生成できます。

```terminal:ターミナル
$ az storage container policy create --account-name l0wstorage --account-key xxxxxxxx --container-name tokens --name exops1demo-policy --permission crw --expiry 2100-01-01T00:00:00Z
$ az storage blob generate-sas --account-name l0wstorage --account-key xxxxxxxx --container-name tokens --name exops1demo.json --policy-name exops1demo-policy --full-uri -o tsv
https://l0wstorage.blob.core.windows.net/tokens/exops1demo.json?sr=b&si=exops1demo-policy&sig=f6XvzAkrbaRhcPZgW6SOGNhWffSHSuqFYJMjD9AoRz0%3D&sv=2018-11-09
```

### CI の実現

[.gitlab-ci.yml](https://gitlab.com/yaegashi/exops1demo/-/blob/master/.gitlab-ci.yml) は GitLab CI の設定例です。プロジェクトの CI/CD 変数 TokenUri に前述の SAS つき Blob URI を設定して実行しています。

次の実行ログを見ると CI でスクリプトがどのように動作するかを確認できます。

- 初回の実行: https://gitlab.com/yaegashi/exops1demo/-/jobs/526931295
  - [35行目](https://gitlab.com/yaegashi/exops1demo/-/jobs/526931295#L35)で一時停止してサインインを要求しています。別のブラウザでサインインを完了すると実行再開します。
- 二回目以降: https://gitlab.com/yaegashi/exops1demo/-/jobs/526931675
  - 永続化したトークンで認証しているのでサインインを要求することなく最後まで実行しています。

**注意事項: この種の CI を公開のプロジェクトでは実行しないでください。**ちょうど上記のビルドログのように SAS つき URI などのセキュリティ情報が漏洩する危険があります。万が一漏洩した場合は直ちに次の対応を行ってください。

- ユーザーの既存のリフレッシュトークンを無効化する。これはユーザーのパスワードを変更するのが最も簡単です。詳細については[ドキュメント](https://docs.microsoft.com/ja-jp/azure/active-directory/develop/access-tokens#revocation)を参照してください。
- ストレージアカウントのアクセスポリシーやアクセスキーを無効化して、新しい SAS つき URI を発行する。

## 実装の説明

### Exchange Online PowerShell への接続

次は [ExchangeOnlineAutomate.ps1](https://gitlab.com/yaegashi/exops1demo/-/blob/master/ExchangeOnlineAutomate.ps1) のサーバ接続部分を簡略化したものです。

```powershell:pwsh
$AppId = "a0c73c16-a7e3-4564-9a95-2bdf47383716"
$Scopes = @("https://outlook.office365.com/.default")
$RemoteConfig = "Microsoft.Exchange"
$RemoteUri = "https://ps.outlook.com/powershell-liveid?BasicAuthToOAuthConversion=true"

$token = New-PartnerAccessToken -ApplicationId $AppId -Scopes $Scopes -UseDeviceAuthentication
$pass = ConvertTo-SecureString "Bearer $($token.AccessToken)" -AsPlainText -Force
$cred = New-Object System.Management.Automation.PSCredential($token.Account.Username, $pass)
$session = New-PSSession -ConfigurationName $RemoteConfig -ConnectionUri $RemoteUri -Credential $cred -Authentication Basic -AllowRedirection
Import-PSSession -AllowClobber -Session $session

# これ以降 Exchange Online PowerShell コマンドレットが利用可能
Get-Mailbox | Format-Table
```

まず `$AppId` は Exchange Online PowerShell のアプリケーション ID です。これに対して [New-PartnerAccessToken](https://docs.microsoft.com/en-us/powershell/module/partnercenter/new-partneraccesstoken) で OAuth2 トークン `$token` を取得します。 `-UseDeviceAuthentication` を指定すると[デバイスコード認証](https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-device-code)を行います。

ユーザーのサインインが成功すると `$token` には次のように認証で得られた情報がすべて入ってきます。

```terminal:ターミナル
PS /Users/yaegashi/git/exops1demo> $token                                                                             

RefreshToken            : OAQABAAAAAAAm-06blBE1TpVMil8KPQ41vUSEVoU9OMB0nHU_w5KJvmPxMO_epJkpIejVfp7LKF5vPhfFyXt
                          ....
AccessToken             : eyJ0eXAiOiJKV1QiLCJub25jZSI6IjdmaFZDR1ZyLWJoS1lJZXhqQXdsVmlyRXM0amJRcTYxSmVWXzZPa3NM
                          ....
IsExtendedLifeTimeToken : False
UniqueId                : d2a07c12-3806-4f0b-9f86-c39d88de1c83
ExpiresOn               : 2020/04/24 0:52:48 +00:00
ExtendedExpiresOn       : 2020/04/24 0:52:48 +00:00
TenantId                : bcd388dd-3247-4137-9a8d-a55a4b2de147
Account                 : Account username: admin@l0wdev.onmicrosoft.com environment login.windows.net home ac
                          count id: AccountId: d2a07c12-3806-4f0b-9f86-c39d88de1c83.bcd388dd-3247-4137-9a8d-a5
                          5a4b2de147
IdToken                 : eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IkN0VHVoTUptRDVNN0RMZHpEMnYyeDNRS1NSWSJ9
                          ....
Scopes                  : {https://outlook.office365.com/.default, https://outlook.office365.com/adminapi.acce
                          ssasuser.all, https://outlook.office365.com/ffopowershell.accessasuser.all, https://
                          outlook.office365.com/remotepowershell.accessasuser.all}
CorrelationId           : 4456c6c7-e559-443f-97d2-5aefbdf3dfc2
User                    : 
```

アクセストークン (`AccessToken`) を使って Exchange Online サーバにリモート接続します。それには https://ps.outlook.com/powershell-liveid?BasicAuthToOAuthConversion=true という興味深いクエリがついた URL に対して、次のような Basic 認証で接続します。

|設定|値|
|---|---|
|ユーザー|接続するユーザーの UPN (`$token.Account.UserName`)|
|パスワード|`Bearer ` + アクセストークン (`$token.AccessToken`)|

最後に `Import-PSSession` でセッションをインポートすると、 Exchange Online PowerShell のコマンドレットが次の行から使用できるようになります。

### 認証トークンのリフレッシュ

アクセストークンの有効期間は 1 時間です。有効期間が過ぎると使えなくなりますので、認証をやりなおすか、リフレッシュトークンを使ってアクセストークンを更新する必要があります。アクセストークンの更新にも New-PartnerAccessToken コマンドレットが利用できます。

```powershell:pwsh
$token = New-PartnerAccessToken -ApplicationId $AppId -Scopes $Scopes -RefreshToken $token.RefreshToken
```

リフレッシュトークンを有効利用するには、それをどこかで永続化する必要があります。一番簡単なのは `$token` を JSON などにシリアライズして保存することです。

保存先としては、たとえば Azure Blob Storage のようなオブジェクトストレージを使うと、CI システムの一時的な実行環境からでも利用できるようになります。書き込み可能な SAS つきの Blob URI (`$TokenUri`) を用意して、次のようにすれば JSON 形式にしてアップロードできます。

```powershell:pwsh
$TokenUri = "https://l0wstorage.blob.core.windows.net/tokens/exops1demo.json?sr=b&si=exops1demo-policy&sig=f6XvzAkrbaRhcPZgW6SOGNhWffSHSuqFYJMjD9AoRz0%3D&sv=2018-11-09"
$token | ConvertTo-Json | Invoke-RestMethod -Uri $TokenUri -Method PUT -ContentType application/json -Headers @{"x-ms-blob-type" = "BlockBlob" }
```

## まとめ

この記事では、次の機能を持つスクリプト ExchangeOnlineAutomate.ps1 と GitLab CI による実行デモを紹介しました。

- macOS や Linux の PowerShell Core でも動作するポータブルな Exchange Online PowerShell リモート接続
- PartnerCenter モジュールによる Azure AD OAuth2 認証操作 (デバイス認証許可フロー、トークンのリフレッシュ)
- Azure Blob Storage による OAuth2 トークンの永続化

今後の改良としては、次のようなものが考えられると思います。

- Exchange Online PowerShell リモート接続や、OAuth2 トークン永続化の機能をモジュール化して PowerShell Gallery で公開する
- Azure Blob Storage 以外でも OAuth2 トークンの永続化ができるようにする。 OneDrive や SharePoint が使えると Office 365 で完結するのでうれしい。

特に PartnerCenter モジュールはポータブルでオープンソースで Azure AD 認証のトークンを直接扱えるとても便利なモジュールだと思いました。これを活用すれば様々な管理タスクについて PowerShell Core による自動化や CI が実現できそうです。

今回は久々に PowerShell でプログラミングしてみました。いろいろ拙いところがあると思いますので、お気づきの点はご指摘いただけると大変うれしく思います。

## 謝辞

この記事は次のブログ記事を参考にして書きました。ありがとうございました。

- [Hacking your way around Modern authentication and the PowerShell modules for Office 365](https://www.michev.info/Blog/Post/1771/hacking-your-way-around-modern-authentication-and-the-powershell-modules-for-office-365)
  - 謎の `BasicAuthToOAuthConversion=true` についてこの記事で知りました。
- [Connect to Exchange Online automated when MFA is enabled (Using the SecureApp Model)](https://www.cyberdrain.com/connect-to-exchange-online-automated-when-mfa-is-enabled-using-the-secureapp-model/)
  - PartnerCenter モジュールの存在をこの記事で知りました。