Azure ADのアカウント自動処理をCloud Function + Secret Managerでやりたい

# 背景

* 退職とかで不要になったAzure ADアカウントをGraph API + Cloud Functionで自動処理したい
* 最初はアプリケーションアクセスでやってたけど、**管理者ロールがついているアカウントの場合、アプリケーションアクセスだと動かない　→　OAuthにする必要がある（管理者ロールを操作できる権限を持った管理者アカウントで委任してやる必要がある）**
    * Azure AD Loginした場合にローカルPCの管理者権限を与える「Azure AD 参加済みデバイスのローカル管理者」ロールとかでも問題になる。~~そのぐらい例外にしてくれないかな~~
* **Graph APIの[refresh tokenの有効期限90日しかない](https://docs.microsoft.com/ja-jp/azure/active-directory/develop/refresh-tokens)んかい。**ローテートの自動化の処理が必要だ。
* というわけで、Cloud Function + Secret ManagerでGraph APIのrefresh tokenの更新処理をかける仕組みを検討。色々動作検証するのが大変だったのでメモがてら残す。


※記事執筆時点(2021年9月)の手順。

# 実行環境
* Google Cloud Platform
    * Cloud Function
        * Node.js
            * Secret Manager Node.js SDK (サービスアカウント)
            * Graph API (OAuth 2.0)
    * Secret Manager
* Azure AD
    * Enterprise Mobility + Security E3 ライセンス (Azure AD Premium P1)

※Secret ManagerはSecretへのアクセスや、アクティブなバージョンごとに課金されるので注意。[課金体系はこちら](https://cloud.google.com/secret-manager/pricing)。

# やること解説
1. Graph API叩くための下準備
    1. Azure AD Portalで「アプリの登録」を行う
    1. 認可処理をしてrefresh tokenを取得
1. Secret Managerの準備
    1. Secret Mangerにrefresh tokenを登録
    1. Secret Manager APIの有効化
    1. サービスアカウントの作成と権限付与
1. Cloud Functionの準備
    1. SDKの準備と関数の作成
    1. デプロイ & サービスアカウントの紐付け


## 1. Graph API叩くための下準備

[公式のレファレンス](https://docs.microsoft.com/ja-jp/graph/auth-v2-user)も参照。

##### 1-1. Azure AD Portalで「アプリの登録」を行う
* Azureポータルから、「すべてのサービス」→「ID」→「Azure Active Directory」→「アプリの登録」と移動
* 「新規登録」→各種パラメータを入力して「登録」
    * 「名前」は適当
    * 「サポートされているアカウントの種類」は原則「この組織ディレクトリに含まれるアカウント」(社内限定の処理ならこれ以外を選ぶ理由はない)
    * リダイレクトURIは一旦省略
* 作成されたアプリに移動して、いくつか追加の設定を実施
    * リダイレクトURIの追加 (※あとでrefresh token取得するのに使う)
        * 「認証」→「プラットフォーム構成」から「プラットフォームを追加」
        * 「モバイルアプリケーションとデスクトップアプリケーション」を選ぶ
        * `https://login.microsoftonline.com/common/oauth2/nativeclient`をチェックして「構成」をクリック
    * APIのアクセス許可を追加 (※Graph APIでアカウント処理を行うのに必要な権限を追加する)
        * 「APIのアクセス許可」→「アクセス許可の追加」
        * 「Microsoft Graph」→「委任されたアクセス許可」を選択
        * アクセス許可を追加する
            * `offline_access` (必須)
            * `Directory.AccessAsUser.All` (今回の要件=管理者ロール持ってるアカウント処理する場合必要)
                * その他必要に応じて追加(基本的に↑がついてると認可を出したユーザーと同じ権限で操作ができるので特に困らないはず)
        * 「APIのアクセス許可」ページに戻って、「〜〜〜〜(テナント名)に管理者の同意を与えます」を押して有効化する
* トークン取得に使う情報を控えておく
    * アプリの「概要」に戻り、「基本」エリアの `アプリケーション (クライアント) ID` と `ディレクトリ (テナント) ID` をコピーしておく

##### 1-2. 認可処理をしてrefresh tokenを取得
* 何でもいいのでHTTPリクエストを送れる環境を準備しておく(Postmanが楽かな？ Curl等でも。)
* OAuthの承認を行う
    * **ブラウザで(シークレットウィンドウが良い)、以下のURLにアクセス**

```
https://login.microsoftonline.com/{ディレクトリ(テナント)ID}/oauth2/v2.0/authorize?
client_id={アプリケーション(クライアント)ID}
&response_type=code
&redirect_uri=https%3A%2F%2Flogin.microsoftonline.com%2Fcommon%2Foauth2%2Fnativeclient
&response_mode=query
&scope=offline_access%20directory.accessasuser.all
&state=12345

※パラメータは適宜作成したアプリに合わせて変更すること
※見やすいように改行しているが、ブラウザでアクセスする際には1行で入力すること
※scopeについては、別のscopeを入れてるなら%20(=エンコード前だとスペース)を挟んで追加する
※stateはなくてもいいし適当な値に変えても良い
※ちなみに、ディレクトリ(テナント)IDは、テナントのデフォルトドメインでも代用できる(例： contoso.onmicrosoft.com)
```

* ブラウザで上記URLにアクセスすると、ログイン要求を求められるので、管理者ロールを操作できる権限を持ったアカウントで認証を行う。(個人のアカウントではなく、この自動化に使うためのシステムアカウントを払い出して、それに必要なロールをつけたもので認可を与えるのが望ましい。特権ロール管理者とユーザー管理者がついてればいけそう)
    * その後、アクセス許可要求のページが表示されるので、同意にチェックを入れて「承諾」を押す
    * 真っ白なページにリダイレクトされるはずなので、ここで**ブラウザのURL欄をコピーする**
        * コピーしたURLは以下のような形式になっているはず。 `code` のパラメータが必要になる。

```
https://login.microsoftonline.com/common/oauth2/nativeclient?
code=XXXX(...中略...)XXX
&state=12345
&session_state=YYYY....
```

* リフレッシュトークンを取得する
    * 今度はPostman等で、以下のリクエストを送る

```
URL: https://login.microsoftonline.com/{ディレクトリ(テナント)ID}/oauth2/v2.0/token
Method: POST
Header: {
  Content-Type: application/x-www-form-urlencoded
}

body: {
  client_id: {アプリケーション(クライアント)ID},
  scope: offline_access directory.accessasuser.all,
  code: {さっき取得した XXX(...中略...)XXX の部分をコピペ},
  redirect_uri: https://login.microsoftonline.com/common/oauth2/nativeclient,
  grant_type: authorization_code
}

※scopeやredirect_uriなどは先ほどcodeを取得する際に使ったものと同じものを使う
```

* 以下のようなレスポンスが返ってくればOK

```
{
    "token_type": "Bearer",
    "scope": "Directory.AccessAsUser.All",
    "expires_in": 3599,
    "access_token": "ZZZZZZ...",
    "refresh_token": "ZZZZZZZ..."
}

※scopeにoffline_accessを入れてないと、ここでrefresh_tokenが返ってこない
```

* 得られたリフレッシュトークンを控えておく。

これでGraph API叩くための下準備は完了。

## 2. Secret Managerの準備
公式は[この辺](https://cloud.google.com/secret-manager/docs/quickstart?hl=ja)参照
##### 2-1. Secret Mangerにrefresh tokenを登録
* GCPの該当のプロジェクトのWebポータルに移動
* Secret Manager操作の前準備として、まずIAMを確認しておく
    * 「IAMと管理」→「IAM」で自分のアカウントに「**Secret Manager管理者(roles/secretmanager.admin
)**」が付与されていることを確認。(付与されてなかったら追加しておく)
        * Secretの作成・管理そのものや、後述のサービスアカウントへの権限管理などに必要
* 「セキュリティ」→「Secret Manager」に移動
* 「シークレットを作成」→　名前を適当に入力し、値に先ほど得られたGraph API用のリフレッシュトークンを入力して「シークレットを作成」を押す。(※オプションは全部チェックなしでOK)
* 出来上がったシークレットの詳細ページに移動して、バージョン:1 の右側の操作ボタン→「Secretの値を表示」で先ほどのリフレッシュトークンが追加されていることを確認。

##### 2-2. Secret Manager APIの有効化
* GCPのWebポータルで、「APIとサービス」→「ライブラリ」に移動
* 「Secret Manager API」を検索　→　有効化する　(※後のSDKの利用のために必要) 

##### 2-3. サービスアカウントの作成と権限付与
* SDK利用時に使うサービスアカウントを準備する
* GCPのWebポータルで、「IAMと管理」→「サービスアカウント」に移動
* 適当に名前をつけてサービスアカウントを作成する。
    * この際、アクセス許可などは特にいじらなくてOK
* 作成できたら、メールアドレスを控えておく
* 「セキュリティ」→「Secret Manager」→先ほど作成したリフレッシュトークンを保管しているSecretに移動
* 「権限」タブで、「追加」を押す
    * メンバーに、先ほど作成したサービスアカウントのメールアドレスを入力(サジェストが出てくるのでそれをクリック)
    * ロールは、「Secret Manager」→「Secret Manager管理者」を指定する
        * ここは、新トークンを追加していくだけなら「Secret Manager」→「Secret Managerのシークレットアクセサー」+「Secret Managerのバージョンの追加」でOK。アクティブなバージョン数で課金が入るため、今回は削除もするために「Secret Manager管理者」の権限を足す。
        * なお、IAMのページからではなく、シークレット個別の権限を付与してるので、このサービスアカウントはあくまで「このSecretに対してのみ」「Secret Manager管理者」の権限を持つ状態になる。別のSecretを作った場合は、個別に付与して上げる必要がある。

## 3. Cloud Functionの準備

以下、Node.js利用前提で記載。
Secret Manager SDKの[インストール](https://cloud.google.com/secret-manager/docs/reference/libraries?hl=ja#client-libraries-install-nodejs)と[詳細なリファレンス](https://cloud.google.com/nodejs/docs/reference/secret-manager/latest)はこの辺参照。
[Graph APIのSDK](https://github.com/microsoftgraph/msgraph-sdk-javascript)もあったけど、~~いろいろ入れる必要がある割にそんな楽になってなさそうなので~~今回はそんな複雑な処理をするわけじゃないので、Graph API側はピュアにAPI叩く方法で作成。

##### 3-1. SDKの準備と関数の作成

* ローカルでSDK試す場合は下記のコマンドでインストール

```
npm install @google-cloud/secret-manager
```

* Cloud Function向けに関数等を準備する。構成は以下。

```
sample_function/
 ┣ index.js      ---> Function本体
 ┣ package.json  ---> SDK等使うライブラリの指定
 ┗ .env.yaml     ---> Cloud Functionの変数を指定
```

* とりあえずテスト用の各ファイルの中身を抜粋

```index.js
const {SecretManagerServiceClient} = require('@google-cloud/secret-manager');
const fetch = require('node-fetch');
const querystring = require('querystring');

exports.testFunc = async (req, res) => {
    // Cloud Functionの環境変数読み取り
    project_id = process.env.project_id;
    secret_name = process.env.secret_name;

    // Secret ManagerからRefresh Tokenを読み込み
    const client = new SecretManagerServiceClient();
    const name = `projects/${project_id}/secrets/${secret_name}/versions/latest`;
    const [ version ] = await client.accessSecretVersion({name: name});
    const refresh_token = version.payload.data.toString();

    // Refresh Tokenを使ってAccess Tokenを取得
    const response_accessToken = await fetch(
        `https://login.microsoftonline.com/${process.env.tenant}/oauth2/v2.0/token`,
        {
            method: 'POST',
            headers: {
                'Content-type': 'application/x-www-form-urlencoded'
            },
            body: querystring.stringify({
                client_id: process.env.client_id, // tenantとかclient_idも秘匿性高い情報なので環境変数ではなくSecret Managerに格納した方がよい。今回はサンプルなので楽する
                scope: process.env.scope,
                refresh_token: refresh_token,
                redirect_uri: process.env.redirect_uri,
                grant_type: 'refresh_token'
            })
        }
    );
    const response_accessToken_data = await response_accessToken.json();
    console.log(response_accessToken_data)
    const access_token = response_accessToken_data.access_token;
    const new_refresh_token = response_accessToken_data.refresh_token;

    // Access TokenでGraph API叩く ※サンプルは全ユーザー取得。これやるだけならアプリケーションアクセスだけでもいけるけど、あくまで例として
    const response_getuser = await fetch(
        `https://graph.microsoft.com/v1.0/users`,
        {
            method: 'GET',
            headers: {
                'Authorization': `Bearer ${access_token}`,
                'Content-Type': 'application/json'
            }
        }
    );
    const response_getuser_json = await response_getuser.json();
    console.log(response_getuser_json.value[0])

    // Secret Managerに格納しているRefresh TokenをRenew(正確には新バージョンとして追加)する
    const new_key = Buffer.from(new_refresh_token, 'utf-8')
    const [ Version_after ] = await client.addSecretVersion({
        parent: `projects/${project_id}/secrets/${secret_name}`,
        payload: {
            data: new_key
        }
    })
    console.log(`Added secret version ${Version_after.name}`)

    // 最後に、古いバージョンのRefresh Tokenを破棄する
    const [ Deleted_version ] = await client.destroySecretVersion({
        name: version.name
    })
    console.log(`Deleted secret version ${Deleted_version.name}`)

    return res.status(204).send();
}
```
※エラーハンドリングとか一切入れてないので、流用する場合はその辺修正してください。

```package.json
{
  "name": "sample_secretManager",
  "version": "0.0.1",
  "dependencies": {
    "@google-cloud/secret-manager": "^3.10.0",
    "node-fetch": "^2.6.0",
    "querystring": "^0.2.0"
  }
}
```
nameとかは適当

```.env.yaml
project_id: "{自分のプロジェクト名}"
secret_name: "{Graph APIのリフレッシュトークンを格納したSecret名}"
tenant: "{ディレクトリ(テナント)ID}"
client_id: "{アプリケーション(クライアント)ID}"
scope: "offline_access directory.accessasuser.all"
redirect_uri: "https://login.microsoftonline.com/common/oauth2/nativeclient"
```
ディレクトリ(テナント)IDとかアプリケーション(クライアント)IDはRefresh Tokenと同様にSecret Managerで管理したほうが安全。
scope、redirect_uriも含め、もともと指定していたものと同じものを指定する。


##### 3-2. デプロイ & サービスアカウントの紐付け

gcloudでデプロイ。gcloud自体の準備とかprojectへの紐付けとかは、Qiitaなどにまとまってるページがあるのでそれらを参照ください。
※[過去に書いた自分用メモ](https://qiita.com/marony/items/54ae8229297694bd4efa)もおいときます。

```
gcloud functions deploy {function名}
--entry-point=testFunc
--trigger-http
--runtime nodejs14
--service-account={2-3で作成したサービスアカウントのメールアドレス}
--env-vars-file=.env.yaml
--allow-unauthenticated //テストで外部からコールするために許可
```

※ここで指定したサービスアカウントの権限で、Secret ManagerのAPI操作を行うので必ず権限をもたせたサービスアカウントを指定すること。

最後にpostmanなりcurlなりでデプロイしたfunctionのURLにPOSTして、ログが出ていること、キーがrenewされていることなどが確認できればOK。

※実用上は、pub/sub使うなりして処理したいアカウント情報をこのFunctionに流して、ライセンスや権限剥奪なりアカウントの削除なりを行う形になりそう。


## 追記(2021/09/10)
という記事を作った直後に[Cloud FunctionsとSecret Managerが今後Native統合されていく](https://cloud.google.com/blog/ja/products/serverless/cloud-functions-integrates-with-google-secret-manager)、という情報を発見。

ただ、初期段階ではSecretへのアクセスのみ許可で、バージョン追加の自動化はサポートされていない様子？（[参考](https://cloud.google.com/functions/docs/configuring/secrets)）
であれば、こういうローテート自動化したいようなケースについてはしばらくはコーディング必要なままなのかも。