しばやん雑記

Azure とメイドさんが大好きなフリーランスのプログラマーのブログ

複数の Azure AD テナントに対応した Service Principal を作成して Terraform から利用する

App Service と Azure AD B2C というように Azure AD テナントが分かれている場合には、いつも通りの Service Principal を使った方法だと、当然ながらどちらかのテナントしか操作できません。

以前書いたように Azure AD B2C 側のアプリケーションを管理しつつ、App Service Authentication の設定を追加する際には同じ Terraform 定義内で行う必要があります。

MSA や組織アカウントで Azure CLI にログインしている場合は、特に気にすることなく Terraform Provider がテナント単位でいい感じに扱ってくれますが、Service Principal ではそうもいきません。

ちなみに同一 Azure AD 内の複数サブスクリプションへの対応は module を使って分離する、サブスクリプション別に Provider を定義して別名を付けて管理するなど、いくつか対応方法があります。以前書いたこともあるので、興味がある方はこちらを参照してください。

Azure AD テナントが複数になるということは、必要となる Service Principal が大きな問題になって来ます。単純に考えれば、テナント別に Service Principal を作成して設定すれば解決しますが、管理が複雑になりますし値を環境変数で渡せなくなるので不便です。

実は知らなかったのですが、1 つの Service Principal を複数テナント向けに構成することが出来るようです。これを使って複数の Azure AD テナントに対応しつつ、シンプルな認証情報の管理を実現したいと思います。

複数テナントに対応した Service Principal の作成方法はドキュメントが異常に少ないですが、以下の AKS から ACR にアクセスするドキュメントが最適でした。手順はほぼこのドキュメントの通りです。

正直なところ割と手間がかかる作業になります。Azure AD の知識が無いと苦労しそうです。

自動化が難しい処理もあるので、最初だけと思って手動で頑張るしかないと思っていますが、実はもっと簡単に実現できる気もしています。

複数テナントに対応した Service Principal を作成する

これまで Service Principal というか Azure AD アプリケーションを作成する際は、殆どのケースで Single tenant を選んでいると思いますが、複数テナント向けの場合は Multitenant を選んで作成します。

この Account types の設定以外は Single tenant 向けと同じです。複数テナントで利用する際に別テナント側で許可を与えるという作業が追加で必要になります。

ドキュメントには Redirect URIs に https://www.microsoft.com を指定するように書いてありますが、特に意味があるものではなく OAuth 2 の同意画面を出すために必要というだけです。認可コードを使ってトークンを取る必要すらないので、いつも通り jwt.ms でも問題ないです。

Azure AD アプリケーションを作成したら、以下の URL にアクセスして同意画面を出します。

https://login.microsoftonline.com/<B2C Tenant ID>/oauth2/authorize?client_id=<Application ID>&response_type=code&redirect_uri=https://www.microsoft.com

この時に組織の代理として同意する必要があるので、Azure AD B2C のテナントに Global Administrator として登録されたアカウントを使うのが無難です。

Azure Portal から作成すると自動的に API Permissions に User.Read が追加されるので問題ないですが、同意画面がエラーで表示されない場合は User.Read が Delegated で追加されているか確認します。

承諾ボタンを押すと指定した URL に認可コード付きでリダイレクトされますが、前述したようにトークンが必要なわけではないので同意が終わればブラウザごと閉じて良いです。

同意後に Azure AD B2C テナントの Enterprise Applications を確認すると、Azure AD テナント側で作成したアプリケーションが追加されていることが確認できます。

後はこの Service Principal に対して Azure AD B2C を操作するための権限を追加すれば良いです。

ただし Service Principal に対しては Azure Portal から権限を追加することが出来ないので、Azure CLI や PowerShell を使う必要があります。

追加した Service Principal に Graph API の権限を追加する

当然ながら、この時点では Service Principal には権限がほぼ付いていません。Azure リソースの場合は IAM 設定から簡単に追加できますが、Azure AD の権限追加は少し手間がかかります。

具体的な設定方法は以下のドキュメントの通りなので省略しますが、Managed Identity に対して Graph API のアクセス権限を追加するのと同じ手順です。

Azure CLI や PowerShell を使って必要な Application.ReadWrite.AllDirectory.ReadWrite.All を追加すると、Azure Portal から以下のように確認出来ます。

既に管理者として同意が与えられた状態なので、更にボタンを押す必要はありません。

これで Azure AD テナント以下にあるサブスクリプションを操作しつつ、Azure AD B2C のアプリケーションも操作可能な Service Principal が完成です。

後は実際に試すわけですが、以下のような簡単な Terraform 定義を使って確認します。注意点としては Tenant ID をそれぞれの Provider 単位で明示的に指定する必要があることです。

terraform {
  required_providers {
    azuread = "~> 2.0"
    azurerm = "~> 2.0"
  }
}

provider "azuread" {
  tenant_id = "<Azure AD B2C Tenant ID>"
}

provider "azurerm" {
  tenant_id = "<Azure AD Tenant ID>"

  features {}
}

resource "azuread_application" "default" {
  display_name     = "Cross Tenant Test"
  sign_in_audience = "AzureADandPersonalMicrosoftAccount"

  api {
    requested_access_token_version = 2
  }
}

resource "azurerm_resource_group" "default" {
  name     = "rg-crosstenant-test"
  location = "westus2"
}

作成した Service Principal の情報はこれまで通りで、環境変数 ARM_CLIENT_ID / ARM_CLIENT_SECRET / ARM_SUBSCRIPTION_ID として渡してあげるだけです。当然ながら ARM_TENANT_ID は必要ないです。

環境変数を追加した後に terraform apply を実行すると、Azure AD と Azure AD B2C それぞれに対してリソース作成に成功するのが確認出来るはずです。

GitHub Actions や Azure Pipelines で実行する場合もローカルで動かす場合と同じです。環境変数に作成した Service Principal の情報を追加すれば問題なく動作します。

特に GitHub Actions で利用する場合は Client Secret ではなく Federated credentials を使いたいところですが、Terraform Provider 側の対応が必要になるので時間がかかりそうです。

App Service Authentication と Azure AD B2C の組み合わせでログアウトを正しく実装する

少し前に App Service Authentication が OpenID Connect に対応したので、様々な IdP を利用することが出来るようになりました。Azure AD B2C も OIDC 対応によって正式利用が可能になった IdP の一つです。

ログアウト後のリダイレクト先を指定する

App Service Authentication から使う時にはログインは当然ながら確認すると思いますが、案外確認が漏れがちなのがログアウトです。ここで単純に /.auth/logout にリダイレクトするだけでは、以下のような懐かしさすらある画面が表示されてしまいます。

普通の Web アプリケーションであれば、ログアウトした後は独自のログアウトしたことを知らせる画面や、ログイン画面にリダイレクトするのが一般的なので遷移先を post_logout_redirect_uri クエリパラメータで /.auth/logout に渡して対応します。

この時 post_logout_redirect_uri には相対パスか "Allowed external redirect URLs" に追加した外部 URL を URL エンコードして渡してあげます。この辺りのカスタマイズ方法は公式ドキュメントに記載があります。

ログアウトを行った後にログイン画面を表示するためには、認証が必須になっているパスにリダイレクトすればよいのでルート / にでも飛ばせば解決しそうです。単純に以下のような URL で実現できます。

https://***.azurewebsites.net/.auth/logout?post_logout_redirect_uri=%2f

実際に上の URL を使ってログアウトを行ってみると、見た目上はログインしたままで正しく動作しません。

これは Azure AD B2C 側のセッションが生きている場合には、ルートにリダイレクトした後にログインが行われてしまうため発生します。リダイレクトを確認すると動作がよく分かります。

ルートにリダイレクトした後に Azure AD B2C へ更にリダイレクトされて、そのままログインが完了してしまっています。よって正しくログアウトを行うには Azure AD B2C からもログアウトする必要があります。

同時に Azure AD B2C からもログアウトする

目指す動作のためには App Service Authentication からログアウトした際に Azure AD B2C からもログアウトする必要がありますが、ドキュメントにひっそりと書いてあるように Azure AD と Google アカウントの場合は、サーバーサイドでログアウトを行ってくれるようです。

  • For Azure Active Directory and Google, performs a server-side sign-out on the identity provider.
Customize sign-ins and sign-outs - Azure App Service | Microsoft Docs

残念ながら OpenID Connect を使っている場合は対応していませんし、そもそも OIDC 側のセッション管理に関する仕様がドラフトのようなので、App Service Authentication が対応するには時間がかかりそうです。*1

とはいえ既に Azure AD / Azure AD B2C には必要なエンドポイントは実装されていました。以下のドキュメントによると OIDC のメタデータに含まれている end_session_endpoint が該当するようです。

このエンドポイントは Azure AD B2C のユーザーフロー単位に提供されるメタデータにも含まれています。

Azure AD B2C のドキュメントにも end_session_endpoint に関する詳細が記載されています。パラメータがいくつか必要そうに見えますが、今回はとりあえず post_logout_redirect_uri のみ指定すれば良いです。

ここで重要になるのは App Service Authentication のログアウトを先に行ってから、Azure AD B2C のログアウトを行うという点です。このフローを実現するためには post_logout_redirect_uri でのリダイレクトを組み合わせる必要があります。

リダイレクトが多いので複雑ですが、分解すると以下のようなログアウト開始 URL を作成します。

# 1. App Service Authentication のログアウトを行った後に任意の URL にリダイレクトする URL
https://***.azurewebsites.net/.auth/logout?post_logout_redirect_uri=...

# 2. Azure AD B2C のログアウトを行った後に App Service のルートにリダイレクトする URL
https://***.b2clogin.com/***.onmicrosoft.com/oauth2/v2.0/logout?p=b2c_1_signupsignin&post_logout_redirect_uri=https%3a%2f%2f***.azurewebsites.net

# 3. 1 と 2 を組み合わせて App Service Authentication => Azure AD B2C => App Service を実現
https://***.azurewebsites.net/.auth/logout?post_logout_redirect_uri=https%3a%2f%2f***.b2clogin.com%2f***.onmicrosoft.com%2foauth2%2fv2.0%2flogout%3fp%3db2c_1_signupsignin%26post_logout_redirect_uri%3dhttps%253a%252f%252f***.azurewebsites.net

App Service Authentication のログアウト後にリダイレクトする URL は、オープンリダイレクト防止のために明示的に許可する必要があります。

これは Azure AD B2C の end_session_endpoint を Azure Portal から設定しておけば良いです。クエリパラメータを省いた部分のみで問題ないようです。

設定後に作成したログアウト開始 URL を実行すると、App Service Authentication のログアウトが行われた後に Azure AD B2C のログアウトも実行され、最後に App Service に戻ってくることが確認できます。

App Service 自体に認証がかかっているので、更にログイン画面へリダイレクトされています。

これで App Service Authentication と Azure AD B2C のログアウトを同時に行えるようになりました。必要な URL は少し複雑ですが、コードから作成するのは非常に簡単なはずです。

一応は目標とした動作は実現できるようになりました。最後に Azure AD B2C 側に残ったオープンリダイレクトの問題を修正して終わりにします。

Azure AD B2C ログアウト時のリダイレクト先を制限する

数回試した範囲では Azure AD B2C のログアウトエンドポイントに指定する post_logout_redirect_uri は、デフォルトでは指定されたリダイレクト先の検証を行わず、何でも受け付けてしまうようです。

このままの挙動ではオープンリダイレクトの問題が残ってしまっているので対応します。以下のドキュメントの最後に書いてある部分がそれに該当します。

要するに id_token_hint パラメータにログアウトするユーザーが保持していた id_token を渡すことで、発行したアプリケーションのリダイレクト許可リストを使って検証を行うという仕組みです。

ログアウト時に id_token を必須にするオプションはユーザーフローの設定内にあります。

この設定を有効にして id_token を必須にすると、これまで動作していたログアウト URL は以下のようなエラーが出るため動かなくなります。エラーメッセージにもあるように id_token_hint が必要になります。

パラメータとして id_token_hint を追加すればこのエラーは消えますが、代わりにリダイレクト URL が許可されていないというエラーになるので、最終的にリダイレクトさせたい URL を追加しておきます。

設定場所は見て分かるように、ログイン時のコールバック URL を指定する場所と同じです。

これで一通りの設定が完了したので、最後に一連のログアウトに必要な URL の生成を行う簡単なサンプルコードを紹介しておきます。

App Service Authentication は Token Store を有効化すると id_token/.auth/me から取得できるので、それを使って一連のログアウト処理に必要なデータを用意します。

// App Service Authentication / Azure AD B2C それぞれのログアウトエンドポイント
const endSessionEndpoint = "https://***.b2clogin.com/***.onmicrosoft.com/oauth2/v2.0/logout?p=b2c_1_signupsignin";
const logoutEndpoint = window.location.origin + "/.auth/logout";

// ログイン中ユーザーの id_token を取得する(失敗したらログアウト済み)
const response = await fetch("/.auth/me");
const userInfo = await response.json();

// リダイレクト先の URL を組み立てていく
const postLogoutRedirectUri = window.location.origin
const startLogoutAzureADB2CUri = endSessionEndpoint + "&post_logout_redirect_uri=" + encodeURIComponent(postLogoutRedirectUri) + "&id_token_hint=" + userInfo[0].id_token;
const startLogoutUri = logoutEndpoint + "?post_logout_redirect_uri=" + encodeURIComponent(startLogoutAzureADB2CUri);

window.location = startLogoutUri;

コード自体はリダイレクト先を組み立てては順次 post_logout_redirect_uri に URL エンコードして設定していくだけです。今回は id_token_hint が付いている点だけが異なっています。

スクリーンショットは出しませんが、このコードをログイン中の App Service 内で実行すると App Service Authentication と Azure AD B2C の両方に対して、安全な形でログアウトが行われます。

ちなみに MSAL.js v2 を使って直接 Azure AD B2C を利用している場合でも、ログアウト時に id_token を指定すれば同じ挙動になるようです。是非とも App Service でも公式対応を期待したいです。

GitHub Actions の OpenID Connect と Azure AD を利用して Client Secret の管理を不要にする

激しく今更ですが、GitHub Actions の OpenID Connect 対応を利用して Azure リソースや Azure AD 自身へのアクセスを Client Secret 無しで行ってみます。

ぶっちゃけ全ては watahani さんのブログを読んでもらえれば良いのですが、少し時間が経過したこともあり若干扱いやすくなっているのと、Terraform との連携も確認しておきたいのが今回書いた理由です。

公式ドキュメントがアップデートされているので、この通りに Workflow を書いて Azure AD 側で Federated credentials の設定を追加すれば終わります。とても簡単ですね。

以前はベータ版の Azure CLI をインストールする必要がありましたが、現在はその必要はなくなっています。Workflow には azure/login だけ書けば済むのでスッキリしました。

ちゃんと azure/login のドキュメントもアップデートされているので、参考にしてください。

Azure Portal から選択できる Azure AD の Federated credentials の利用シナリオも、GitHub Actions 以外にも Kubernetes からの利用やカスタム設定が増えていました。Terraform Cloud が OpenID Connect に対応すると楽できるはずですが、現状は GitHub Actions での利用が中心でしょう。

Federated credentials の追加は GitHub Actions の場合はリポジトリの情報を追加するだけです。

これまでの Client Secret のように有効期限という概念が存在しないので安全に利用できます。今回 Entity type は Branch にしていますが、実際には Environment を使うことが多くなる気がします。

Azure AD である以上 Federated credentials を利用しても Client Id や Tenant Id からは逃れられないので、実際の Workflow は以下のようになります。

name: Publish

on:
  push:
    branches: [ master ]

permissions:
  id-token: write
  contents: read

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2

    - name: Azure Login
      uses: azure/login@v1
      with:
        client-id: ${{ secrets.AZURE_CLIENT_ID }}
        tenant-id: ${{ secrets.AZURE_TENANT_ID }}
        subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

Azure リソースにアクセスする場合にはサブスクリプションが必ず存在しているので、同時に Subscription Id も指定する必要があるのは変わりません。

実際に Workflow を実行してみると GitHub Actions が提供する id_token を使っていることが分かります。

定期的に Client Secret をローテートさせる必要が消滅したのは大きなメリットです。

デプロイなどで Azure リソースを操作する場合には Azure CLI でログインしてしまえば、後は公式で用意されたデプロイ用などの Action が利用できるので簡単です。

Federated credentials を使った場合でも Azure への認証情報は一時的に保存されるようなので、Azure CLI を使ってログインしておけば Terraform CLI 経由で問題なく利用出来ます。

Azure Provider: Authenticating via the Azure CLI | Guides | hashicorp/azurerm | Terraform Registry

Terraform Provider 側での OpenID Connect 対応も検討されているようなので、実現すると複数 Azure AD テナントに跨るリソースのデプロイが楽になる予感です。

最近は Azure AD 周りの Terraform 化ブームが来ているので、サブスクリプションを持たない Azure AD B2C に対しても問題なく動作するか確認しておきました。

Azure CLI や azure/login はデフォルトではサブスクリプションを持たないテナントへのログインをエラーにしてしまうので、Azure AD B2C をターゲットにする場合は allow-no-subscriptions を追加します。

これでサブスクリプションを持たないテナントに対しても Azure CLI ベースでアクセスが出来ます。

当然ながら Terraform CLI からも認証情報を利用できるので、Terraform Provider for Azure AD を使っている場合でも動作します。一般的には Terraform の利用には高い権限が必要になるので、出来るだけ Federated credentials を使って安全に運用できるようにしておきたいです。

Azure AD B2C のカスタムポリシー利用に必要なアプリケーションを Terraform を使って登録する

最近の Azure AD B2C は組み込みユーザーフローの機能がかなり増えているので、ある程度のことなら Azure Portal からの設定で実現できますが、結局は IEF を使ったカスタムポリシーが必要になるケースも多いです。

Azure AD B2C のカスタムポリシーは触ったことがある人ならわかると思いますが、XML ベースの難解な定義の集合体になっています。確実にバージョン管理と CI/CD を行っておきたいですが、Azure Portal からアップロードする運用になっていることも多いのではないかと思います。

将来的にそのあたりを Terraform Provider for Azure AD が綺麗に解決してくれそうなので、今回はカスタムポリシーを利用する際に必要な IdentityExperienceFrameworkProxyIdentityExperienceFramework という 2 つのアプリケーションを登録し、自動化の準備をしておきます。

カスタムポリシーを使う上で必要になるのでセットアップツールも用意されていますが、全体を Terraform で管理することを踏まえると上手くモジュール化し、再利用可能な形にしておくのが正解だと思います。

IEF 用アプリケーションと言っても、登録方法は通常のアプリケーションとほぼ同じです。前回書いたエントリでもほぼ同じことをしているので参照してください。

単純に Azure AD B2C を利用するアプリケーションとは異なり、API の公開とその API に対する許可の付与が絡み合っているので、Azure Portal で作業する場合は結構ミスが多くなります。

Terraform 化すると Application ID を反対にしてしまうといったありがちなミスを減らすことが出来ます。

共通で利用するリソースを定義

まずは 2 つのアプリケーションの両方で利用するリソースを定義しておきます。

Microsoft Graph のサービスプリンシパルは毎回使うのでお馴染みですが、今回は Azure AD B2C のテナント名をローカル変数として用意しておきます。

terraform {
  required_providers {
    azuread = "~> 2.0"
  }
}

data "azuread_domains" "default" {
  only_initial = true
}

data "azuread_application_published_app_ids" "well_known" {}

data "azuread_service_principal" "msgraph" {
  application_id = data.azuread_application_published_app_ids.well_known.result.MicrosoftGraph
}

locals {
  domain_name = trimsuffix(data.azuread_domains.default.domains.0.domain_name, ".onmicrosoft.com")
}

本来ならテナント名が azuread_domains のプロパティで取れると楽なのですが、ドメイン名という形でのみ取得可能なので trimsuffix を使ってテナント名を切り出しています。

確認はしていないのですが、カスタムドメインを当てた場合には挙動が変わる可能性もあります。

IdentityExperienceFramework を定義

2 つのアプリケーション間には API の依存関係が存在するため、先に IdentityExperienceFramework から定義していきます。こちらは API を公開する側になります。

注意点としては sign_in_audienceAzureADMyOrg を指定することと、identifier_uris に指定する値です。Azure Portal から設定する場合は Application ID が使われますが、Terraform では事前にテナント内でユニークな値を指定する必要があります。今回は適当な値を指定しています。

resource "random_uuid" "user_impersonation" {}

resource "azuread_application" "identity_experience_framework" {
  display_name     = "IdentityExperienceFramework"
  sign_in_audience = "AzureADMyOrg"
  identifier_uris = ["https://${local.domain_name}.onmicrosoft.com/identity-experience-framework"]

  api {
    requested_access_token_version = 2

    oauth2_permission_scope {
      admin_consent_description  = "Allow the application to access IdentityExperienceFramework on behalf of the signed-in user."
      admin_consent_display_name = "Access IdentityExperienceFramework"
      enabled                    = true
      id                         = random_uuid.user_impersonation.result
      type                       = "User"
      value                      = "user_impersonation"
    }
  }

  required_resource_access {
    resource_app_id = data.azuread_service_principal.msgraph.application_id

    resource_access {
      id   = data.azuread_service_principal.msgraph.oauth2_permission_scope_ids["openid"]
      type = "Scope"
    }

    resource_access {
      id   = data.azuread_service_principal.msgraph.oauth2_permission_scope_ids["offline_access"]
      type = "Scope"
    }
  }

  web {
    redirect_uris = ["https://${local.domain_name}.b2clogin.com/${local.domain_name}.onmicrosoft.com"]
  }
}

resource "azuread_service_principal" "identity_experience_framework" {
  application_id = azuread_application.identity_experience_framework.application_id
}

resource "azuread_service_principal_delegated_permission_grant" "identity_experience_framework" {
  service_principal_object_id          = azuread_service_principal.identity_experience_framework.object_id
  resource_service_principal_object_id = data.azuread_service_principal.msgraph.object_id
  claim_values                         = ["openid", "offline_access"]
}

それ以外は前回作成した Azure AD B2C のアプリケーションとほぼ変わりません。

redirect_uris はチュートリアルで指定されたフォーマットで指定すれば良いです。実際のところ URI であればなんでも良さそうですが、ドキュメントの通りに従っておくことにします。

この定義を使って terraform apply を実行すると、以下のようにアプリケーションが作成されます。

f:id:shiba-yan:20220305145349p:plain

必要な API が公開されていることや、管理者の許可が付与されていることも確認出来るはずです。

ProxyIdentityExperienceFramework を定義

次に API を利用する側となる ProxyIdentityExperienceFramework を定義していきます。こちらはパブリッククライアントとして定義するので、これまでとは少し違います。

ポイントは fallback_public_client_enabledtrue を指定するのと、public_client ブロックを追加して redirect_urismyapp://auth を指定することです。

resource "azuread_application" "proxy_identity_experience_framework" {
  display_name                   = "ProxyIdentityExperienceFramework"
  sign_in_audience               = "AzureADMyOrg"
  fallback_public_client_enabled = true

  required_resource_access {
    resource_app_id = data.azuread_service_principal.msgraph.application_id

    resource_access {
      id   = data.azuread_service_principal.msgraph.oauth2_permission_scope_ids["openid"]
      type = "Scope"
    }

    resource_access {
      id   = data.azuread_service_principal.msgraph.oauth2_permission_scope_ids["offline_access"]
      type = "Scope"
    }
  }

  required_resource_access {
    resource_app_id = azuread_application.identity_experience_framework.application_id

    resource_access {
      id   = azuread_application.identity_experience_framework.oauth2_permission_scope_ids["user_impersonation"]
      type = "Scope"
    }
  }

  public_client {
    redirect_uris = ["myapp://auth"]
  }
}

resource "azuread_service_principal" "proxy_identity_experience_framework" {
  application_id = azuread_application.proxy_identity_experience_framework.application_id
}

resource "azuread_service_principal_delegated_permission_grant" "proxy_identity_experience_framework_msgraph" {
  service_principal_object_id          = azuread_service_principal.proxy_identity_experience_framework.object_id
  resource_service_principal_object_id = data.azuread_service_principal.msgraph.object_id
  claim_values                         = ["openid", "offline_access"]
}

resource "azuread_service_principal_delegated_permission_grant" "proxy_identity_experience_framework_ief" {
  service_principal_object_id          = azuread_service_principal.proxy_identity_experience_framework.object_id
  resource_service_principal_object_id = azuread_service_principal.identity_experience_framework.object_id
  claim_values                         = ["user_impersonation"]
}

このアプリケーションは IdentityExperienceFramework が公開している API を必要とするので、required_resource_access ブロックと azuread_service_principal_delegated_permission_grant リソースの追加が必要になります。

そしてこの定義に対して terraform apply を実行すると、管理者の許可が付与された状態で作成されます。

f:id:shiba-yan:20220305145406p:plain

定義は少し長くなり、リソースも複数出てきますがカスタムポリシーを利用する場合の全てで同じ定義が利用できるので、正確な定義をモジュール化して使いまわしが出来るというメリットは大きいです。

カスタムポリシーから利用する

最後に作成した 2 つのアプリケーションを利用して、実際にカスタムポリシーを作成して動作するか確認しておきます。カスタムポリシー自体はチュートリアルにもある starterpack を使いました。

ポリシーキーの作成は行えないので TokenSigningKeyContainerTokenEncryptionKeyContainer は手動で作成しましたが、将来的には Terraform から作成できるはずです。

アップロード前に starterpack に用意された XML に対して、Application ID とテナント名の修正を行っておきます。正直なところ手動だとミスが起こりがち作業ですが、ポリシーのアップロードに Terraform が対応すれば templatefile 関数を使って、この辺りの修正も自動化出来るようになります。

修正した XML ファイルを Azure Portal からアップロードしていきます。エラーが出なければ問題ないです。

f:id:shiba-yan:20220305144639p:plain

設定に問題が無ければ、アップロードした B2C_1A_SIGNUP_SIGNIN を選んでユーザーフローを実行すると、ログインから id_token の取得まで成功するはずです。

割と設定項目が多いので、手動で設定すると大体 1 度ははまるのですが、Terraform を使ってコード化することで確実にセットアップできるようになりました。Azure AD は IaC との相性が凄く良いと感じています。

Terraform Provider for Azure AD を使って Azure AD B2C で利用するアプリケーションを管理する

未だ個人的な Terraform Provider for Azure AD ブームが続いているので、今回は Azure AD B2C の管理を目的に利用してみます。Azure サブスクリプションが紐づく Azure AD テナントとは異なり、サブスクリプションは持たず独立したテナントとなるので、扱いは少し工夫する必要があります。

ちなみに Azure AD B2C 向けで使われる一部の Graph API がまだベータ版だからか、カスタムポリシーなどの Azure AD B2C のコア機能と呼べるものには未対応となっています。

Azure AD B2C への対応はかなり注目されていて、Azure AD B2C のように独立したテナントや設定項目が多いリソース程、Terraform などを使ってコードで管理してデプロイも自動化したいはずなので、対応を楽しみに待ち続けたいと思います。

ポリシーキーやカスタムポリシーを含め Terraform で一括管理出来るようになると、ポリシーのアップロードミスやキーの有効期限問題など一気に解決できるはずです。

現状では Terraform Provider for Azure AD で行えるのは Azure AD B2C のアプリケーション登録ぐらいになりますので、まずはこの辺りの設定方法から押さえておきたいと思います。方針としては Azure AD の時とほぼ同じなので、以前書いた以下のエントリを参考にしてください。

大きな違いとしては sign_in_audience として AzureADandPersonalMicrosoftAccount を指定することと、明示的に openidoffline_access のスコープを追加することぐらいになります。同時にサービスプリンシパルを作成しておくのを忘れないようにします。

実際に用意した Azure AD B2C に対して jwt.ms に Implicit flow を利用して、発行した id_token 付きでリダイレクトするアプリケーション定義が以下になります。

terraform {
  required_providers {
    azuread = "~> 2.0"
  }
}

data "azuread_client_config" "current" {}

data "azuread_application_published_app_ids" "well_known" {}

data "azuread_service_principal" "msgraph" {
  application_id = data.azuread_application_published_app_ids.well_known.result.MicrosoftGraph
}

resource "azuread_application" "jwt_ms" {
  display_name     = "jwt.ms"
  sign_in_audience = "AzureADandPersonalMicrosoftAccount"
  owners           = [data.azuread_client_config.current.object_id]

  api {
    requested_access_token_version = 2
  }

  required_resource_access {
    resource_app_id = data.azuread_service_principal.msgraph.application_id

    resource_access {
      id   = data.azuread_service_principal.msgraph.oauth2_permission_scope_ids["openid"]
      type = "Scope"
    }

    resource_access {
      id   = data.azuread_service_principal.msgraph.oauth2_permission_scope_ids["offline_access"]
      type = "Scope"
    }
  }

  web {
    redirect_uris = ["https://jwt.ms/"]

    implicit_grant {
      access_token_issuance_enabled = true
      id_token_issuance_enabled     = true
    }
  }
}

resource "azuread_service_principal" "jwt_ms" {
  application_id = azuread_application.jwt_ms.application_id
}

この時 AzureADandPersonalMicrosoftAccount を設定すると、requested_access_token_version に対して 2 を設定する必要があるようです。未設定の場合は実行時にエラーとなってしまいます。

必要なリソース定義はこれだけなので、次は terraform apply を実行して Azure AD B2C 上にアプリケーションを作成してみるのですが、Azure CLI はデフォルトではアカウントの親テナントに対してログインしまいます。Azure Portal のようにログイン後にテナントを切り替える機能はありません。

そのため azure login 時に --tenant オプションと --allow-no-subscriptions オプションを渡すことで、Azure サブスクリプションを持たないテナントに対してログインを行います。

az login --tenant ***.onmicrosoft.com --allow-no-subscriptions

これで特定のテナントかつ Azure サブスクリプションを持たない場合にも Azure CLI でログインできるので、Terraform から何も設定する必要なく対象に出来ます。

ログイン後は terraform apply を実行すると Azure AD B2C に jwt_ms という名前のアプリケーションが作成されるので、そのまま適当に追加したユーザーフローでログインを試してみると、以下のようにあまり見覚えのないエラーになってしまいます。

f:id:shiba-yan:20220226021217p:plain

エラーメッセージを見ると大体理由は分かると思いますが、これは openidoffline_access に対して管理者の同意が付与されていないことが原因です。

実際に作成されたアプリケーションを確認すると Status は空のままです。

f:id:shiba-yan:20220226021229p:plain

Azure AD の場合はユーザー単位で許可を行えましたが、Azure AD B2C の場合はこれらのスコープに対して事前に管理者の同意を付与しておく必要があるようです。

Azure Portal では "Grant admin consent for ***" ボタンを押すだけで良かったですが、Terraform で行うためには azuread_service_principal_delegated_permission_grant リソースを追加し、必要な権限を持つユーザーとして実行する必要があります。

azuread_service_principal_delegated_permission_grant | Resources | hashicorp/azuread | Terraform Registry

このリソースはサービスプリンシパルに対して行うので作成は必須です。そして許可を付与するのもサービスプリンシパル単位になるので、場合によっては複数個定義する必要があります。

resource "azuread_service_principal_delegated_permission_grant" "jwt_ms" {
  service_principal_object_id          = azuread_service_principal.jwt_ms.object_id
  resource_service_principal_object_id = data.azuread_service_principal.msgraph.object_id
  claim_values                         = ["openid", "offline_access"]
}

今回のスコープは両方とも Microsoft Graph になるので、このように 1 つだけで済みます。

追加した定義に対して terraform apply を実行すると、以下のように管理者の同意が付与されます。

f:id:shiba-yan:20220226021237p:plain

ユーザーとしてログインしている場合は高確率で Global Administrator なので問題ないですが、CI で実行する場合には追加する権限が変わってくるので注意が必要です。CI に関しては別のエントリでまとめます。

もう一度ユーザーフローから作成されたアプリケーションを使ってログインを行うと、今度は正しく id_token が発行されて jwt.ms で確認出来るようになります。

f:id:shiba-yan:20220226021404p:plain

これでログインが行えるアプリケーションを Azure AD B2C に Terraform で作成できましたが、実際には App Service や Static Web Apps に設定してログインできる状態にしなくてはなりません。

この時の問題として Azure AD B2C は Azure サブスクリプションを持つ Azure AD とは別になるので、簡単には Terraform から Azure AD B2C と App Service の同時デプロイが行えないのですが、現実問題としてそこの統合は必須なので別のエントリで方法をまとめます。

Azure AD と App Service Authentication を使って Web App と Web API を保護する

Azure AD 認証と App Service Authentication を組み合わせて、Web App へのログインを行いつつ同時に発行されるアクセストークンを利用して、別に用意された API を実行したいというシナリオが存在します。

Microsoft Graph API を呼び出す場合はアプリケーションに許可するスコープを追加すればよいですが、自前で用意した API の場合にはまず API を公開するという設定が必要になります。

公式ドキュメントでも Web App から Web API を呼び出すシナリオが紹介されていますが、Azure AD にアプリケーションを登録するあたりは若干省略されていてわかりにくいです。

最近は Azure AD の設定を Azure Portal からではなく Terraform で全て行うブームが個人的に来ているので、今回も Terraform を使って Azure AD と App Service の設定まで行ってしまいます。

作成した Terraform 定義は長いので GitHub で公開しています。フロントエンドの Web App には App Service を、バックエンドの Web API には Azure Functions を利用して、その両方を Azure AD と App Service Authentication を使って保護しています。

https://github.com/shibayan/terraform-azuread-web-app-call-api

実際に触ってみると Azure Portal はアプリケーションを登録する際に、色々と追加の設定を行っていることが分かります。Terraform を使った場合はそういった設定は行わないので、全て明示的に設定する必要がありますが、個人的には逆に理解がしやすかったです。

ここから先はサンプル定義の Azure AD のアプリケーション設定周りを中心に説明をしておきます。

Web API の Azure AD アプリケーション設定

バックエンドの Web API に対して設定する Azure AD アプリケーションから説明します。この Azure AD アプリケーションは API を公開するので、OAuth 2 のスコープを公開することになります。

Web App ではそのスコープを指定することで、ユーザーに対してアクセス許可の確認を行います。ここで重要なのは identifier_urisoauth2_permission_scope の 2 つになります。

resource "random_uuid" "user_impersonation" {}

resource "azuread_application" "backend" {
  display_name    = "Backend example"
  identifier_uris = ["api://terraform-backend"]
  owners          = [data.azuread_client_config.current.object_id]

  api {
    requested_access_token_version = 2

    # user_impersonation スコープを公開する
    oauth2_permission_scope {
      admin_consent_description  = "Allow the application to access Backend example on behalf of the signed-in user."
      admin_consent_display_name = "Access Backend example"
      enabled                    = true
      id                         = random_uuid.user_impersonation.result
      type                       = "User"
      user_consent_description   = "Allow the application to access Backend example on your behalf."
      user_consent_display_name  = "Access Backend example"
      value                      = "user_impersonation"
    }
  }
}

# Application ID に紐づいた Service Principal は必須
resource "azuread_service_principal" "backend" {
  application_id = azuread_application.backend.application_id
}

今回のように Azure AD アプリケーションでスコープを公開する場合は、テナント内でユニークな Uri が必要になります。Azure Portal から設定する場合は大体 api://<application_id> という形式になりますが、Terraform の場合は作成時に Application ID が決まらないので、独自にユニークな文字列を振るのが安全です。

Azure AD のアプリケーション登録周りは用語が色々出てきて分かりにくいので、まずは以下の記事を一通り読んでおくことをお勧めします。実際 Enterprise Application とか名前からは意味が分からないと思います。

Azure Portal を使ってアプリケーションを作成すると user_impersonation と Service Principal は自動的に作成されますが、Terraform ではこのように明示的に定義する必要があります。

この定義を使ってリソースを実際に作成すると、以下のように Azure Portal から Application ID とスコープ付きで作成されているのが確認できます。

アプリケーションと同時に Service Principal も作成されていますが、スクリーンショットは省略します。

バックエンドの Web API 向け Azure AD アプリケーションの設定はこれで終わりになるので、次はフロントエンドの Azure AD アプリケーションの設定をしていきます。

Web App の Azure AD アプリケーション設定

バックエンドに比べるとフロントエンドの Web App 向け設定は、以下のように許可するスコープとして先ほど作成した user_impersonation を追加するだけなのでシンプルです。

追加するにはアクセス先の Application ID とスコープの ID が必要になるので、モジュール化する際には上手く値を渡せるようにする必要があります。

resource "azuread_application" "frontend" {
  display_name = "Frontend example"
  owners       = [data.azuread_client_config.current.object_id]

  required_resource_access {
    resource_app_id = azuread_application.backend.application_id

    resource_access {
      id   = azuread_application.backend.oauth2_permission_scope_ids["user_impersonation"]
      type = "Scope"
    }
  }

  web {
    redirect_uris = ["https://***.azurewebsites.net/.auth/login/aad/callback"]

    implicit_grant {
      access_token_issuance_enabled = false
      id_token_issuance_enabled     = true
    }
  }
}

Web App では実際にユーザーにログインさせる必要があるので web ブロックを追加して、リダイレクト URI などの設定を追加しています。

この定義を使ってリソースを作成すると、バックエンドの API permissions が追加された状態になります。

たったこれだけでフロントエンドの Web App 向け設定は完了です。最後に App Service Authentication の設定を修正して、バックエンドに向けのアクセストークンを取得するようにすれば完了です。

App Service Authentication から利用する

App Service Authentication の修正が必要になるのは、実際にユーザーがログインするフロントエンド Web App です。具体的には additional_login_params で今回作成したスコープを追加するだけです。

resource "azurerm_app_service" "frontend" {
  name                       = "app-terraform-frontend"
  # 本題と関係のないプロパティは省略

  auth_settings {
    enabled                       = true
    token_store_enabled           = true
    default_provider              = "AzureActiveDirectory"
    unauthenticated_client_action = "RedirectToLoginPage"
    issuer                        = "https://login.microsoftonline.com/${data.azuread_client_config.current.tenant_id}/v2.0"

    # バックエンド向けのアクセストークンを要求する
    additional_login_params = {
      "scope" = "openid profile email offline_access api://terraform-backend/user_impersonation"
    }

    active_directory {
      client_id     = azuread_application.frontend.application_id
      client_secret = azuread_application_password.frontend.value
    }
  }
}

この修正をデプロイすると以下のようにログイン時に表示されるアクセス許可の確認画面で、バックエンド Web API へのアクセス許可を与えるかを追加で聞かれるようになります。

承諾してログイン処理を完了すると、アクセストークンを App Service Authentication の /.auth/me エンドポイントや、サーバーサイドの場合は HTTP リクエストヘッダーから取得できるようになります。

以下では実際に /.auth/me エンドポイントを叩いてアクセストークンを確認しています。

本来であればフロントエンドのアプリケーションからバックエンドの Web API を叩くのですが、今回はアクセストークンを使って Postman でバックエンドの Web API を呼び出せるか確認します。

まずは Bearer Token 無しでリクエストを投げた場合です。401 が返されてリクエストは通りません。

次に /.auth/me エンドポイントを叩いて取得したアクセストークンを付けて呼び出してみます。今度は問題なくバックエンドにデプロイした Azure Functions が実行されていることが確認できます。

複数アプリケーションが関係するという Azure Portal だと分かりにくかった設定が、全て Terraform で管理することでそれぞれの依存関係が明確になり、圧倒的に分かりやすくなったと感じています。

若干 Service Principal の辺りがハマりがちなポイントにはなりますが、基本的にセットで作ると考えておけば問題はないです。もうちょっと Terraform Provider for Azure AD ブームは続きます。

Azure AD における Client Secret の期限切れ問題を Terraform でスマートに解決する

Azure AD にアプリケーションや Service Principal を作成すると大体必要になる Client Secret ですが、有効期限を持っているので期限が切れる前に新しい Client Secret を生成する必要があります。

昔は無期限で作成することも出来ましたが、今はセキュリティ上の理由によって最長でも 2 年、推奨は 6 か月となっています。期限を持っていることによって困るのが、App Service Authentication などに設定している値を定期的に更新する必要があることです。

しかし Terraform を使って全て構成する場合には Client Secret を time_rotating によって、一定の期間で再生成することが出来ます。前回紹介した定義でも利用しています。

テストのために有効期限を短くすると、以下のように Terraform によって再生成されることが確認できます。

明示的に terraform apply を実行する必要はありますが、毎回 Azure Portal や Azure CLI を使って Client Secret を再生成して、アプリケーションに設定することを考えると圧倒的に楽で安全です。

Terraform Provider for Azure AD で time_rotating が利用できるリソースは以下の 2 つです。Azure AD B2C への対応が行われると Policy Key 向けリソースが増えそうです。

Azure AD 周りも全て Terraform で管理することによって、Client Secret は良い感じに再生成することが出来ますが、この再生成時の挙動が非常に重要になって来ます。

先に Client Secret が削除されてしまうと、新しい値が生成されてアプリケーションに再設定されるまでの間だけ正しく動作しないことになります。例えば以下のような定義を用意して実行します。

# 確認のために 1 時間で作り直すようにしている
resource "time_rotating" "test" {
  rotation_hours = 1
}

resource "azuread_application_password" "test" {
  application_object_id = azuread_application.test.object_id
  end_date_relative     = "2h"

  rotate_when_changed = {
    rotation = time_rotating.test.id
  }
}

実際には Client Secret は App Service に設定しているのですが、本題とあまり関係ないので省略しています。

Client Secret を作成してから 1 時間後には再生成が行われるのですが、その時の terraform apply の実行ログが以下のようになります。先に Client Secret の削除が行われるので、再生成されるまでの間はアプリケーションの動作に影響が出てしまいます。

azuread_application_password.test: Destroying... [id=.../password/...]
azuread_application_password.test: Still destroying... [id=.../password/..., 10s elapsed]
azuread_application_password.test: Still destroying... [id=.../password/..., 20s elapsed]
azuread_application_password.test: Destruction complete after 20s
time_rotating.test: Creating...
time_rotating.test: Creation complete after 0s [id=2022-02-23T06:25:33Z]
azuread_application_password.test: Creating...
azuread_application_password.test: Creation complete after 5s [id=.../password/...]
azurerm_app_service.test: Modifying... [id=/subscriptions/.../resourceGroups/rg-rotate-test/providers/Microsoft.Web/sites/app-rotate-test]
azurerm_app_service.test: Still modifying... [id=/subscriptions/.../resourceGroups/rg-rotate-test/providers/Microsoft.Web/sites/app-rotate-test, 10s elapsed]
azurerm_app_service.test: Modifications complete after 16s [id=/subscriptions/.../resourceGroups/rg-rotate-test/providers/Microsoft.Web/sites/app-rotate-test]

Apply complete! Resources: 2 added, 1 changed, 1 destroyed.

先に新しい値を作成し、必要なリソースに再設定してから削除するために、Terraform のリソースに組み込みで用意されている lifecycle ブロックの create_before_destroy プロパティを設定します。

このプロパティを設定すると、名前の通り削除する前に新しいリソースを作成してくれます。

実際に以下のように create_before_destroyazuread_application_password に追加して、再度 Client Secret の再生成時の挙動を確認しておきます。

# 確認のために 1 時間で作り直すようにしている
resource "time_rotating" "test" {
  rotation_hours = 1
}

resource "azuread_application_password" "test" {
  application_object_id = azuread_application.test.object_id
  end_date_relative     = "2h"

  rotate_when_changed = {
    rotation = time_rotating.test.id
  }

  # 既存の Client Secret を削除する前に新しいものを作成するようにする
  lifecycle {
    create_before_destroy = true
  }
}

この定義で terraform apply を実行したログは以下のようになります。

意図した通りに新しい Client Secret を作成し、App Service に設定した後に古い Client Secret を削除していることが確認できます。この順序だと terraform apply の実行中でもアプリケーションに影響しません。

time_rotating.test: Creating...
time_rotating.test: Creation complete after 0s [id=2022-02-22T17:09:22Z]
azuread_application_password.test: Creating...
azuread_application_password.test: Creation complete after 5s [id=.../password/...]
azurerm_app_service.test: Modifying... [id=/subscriptions/.../resourceGroups/rg-rotate-test/providers/Microsoft.Web/sites/app-rotate-test]
azurerm_app_service.test: Still modifying... [id=/subscriptions/.../resourceGroups/rg-rotate-test/providers/Microsoft.Web/sites/app-rotate-test, 10s elapsed]
azurerm_app_service.test: Modifications complete after 15s [id=/subscriptions/.../resourceGroups/rg-rotate-test/providers/Microsoft.Web/sites/app-rotate-test]
azuread_application_password.test (deposed object 34e1bf83): Destroying... [id=.../password/...]
azuread_application_password.test: Still destroying... [id=.../password/..., 10s elapsed]
azuread_application_password.test: Still destroying... [id=.../password/..., 20s elapsed]
azuread_application_password.test: Destruction complete after 21s

Apply complete! Resources: 2 added, 1 changed, 1 destroyed.

ぶっちゃけ Deployment Slot を使ったリリースの場合は、多少のダウンタイムが発生しても影響ないのですが、Terraform で Deployment Slot の Swap を行うのは結構面倒なので避けています。

ここまでの結果を踏まえると Azure AD の Client Secret を Terraform で作成する場合の最適な定義は、以下のようなものになるはずです。Client Secret 自体は 180 日の期限にしつつ time_rotating は余裕を持った期限にして、Client Secret の期限ギリギリ更新になるのを避けます。

# Client Secret を作り直すタイミングは余裕をみて期限切れ 30 日前にしている
resource "time_rotating" "test" {
  rotation_days = 150
}

# 推奨に従い 180 日で期限が切れる Client Secret を作成する
resource "azuread_application_password" "test" {
  application_object_id = azuread_application.test.object_id
  end_date_relative     = "4320h"

  rotate_when_changed = {
    rotation = time_rotating.test.id
  }

  lifecycle {
    create_before_destroy = true
  }
}

多くの方が Client Secret の期限切れ問題に悩まされてきたと思いますが、Terraform Provider for Azure AD を使うとスマートに解決できるのでかなりおすすめです。

これまで正直 Azure AD に対する苦手意識というか、分かりにくさしか感じていなかったのですが、Terraform を使ってコードに落とし込むことでかなり理解が進んでいます。

Managed Identity への Microsoft Graph アクセス権の付与を Terraform Azure AD Provider で行う

前回は Terraform Provider for Azure AD を使って Azure AD アプリケーションの登録を行って、App Service Authentication への設定まで全て自動化してみましたが、今回は App Service で有効化された Managed Identity に対して特定の API アクセス権を追加してみます。

Managed Identity は実体が Service Principal なので、Terraform Provider for Azure AD でも Object ID ベースで扱えるはずです。シナリオとしては公式ドキュメントにもあるように Graph API のアクセス権を付与します。

ユーザーとして Graph API を呼び出すのではなく、アプリケーションとして呼び出すのがミソです。Service Principal を使えば比較的簡単にアクセス権を付与出来ますが、Managed Identity の場合は方法が異なります。

以前に Managed Identity を使って Graph API を呼び出すエントリは書いたことがありますが、この時は Azure CLI や PowerShell を使ってアクセス権を付与する必要があり、ぶっちゃけかなり面倒でした。

アクセス権の付与は ARM Template や Bicep では表現できないですが、Terraform では Azure AD Provider と組み合わせることで簡単に実現できます。

まずは Azure AD にセキュリティグループが存在していることを確認します。サンプルでは Managed Identity を使ってグループの一覧を取得するつもりなので、存在していなければ困ります。

これまで PowerShell などを使っていた App Role の割り当てですが、Terraform Provider for Azure AD には任意のプリンシパルに対して App Role を割り当てる azuread_app_role_assignment リソースが用意されているので、これを使って App Service と同時に作成された Managed Identity に対してロールを割り当てます。

azuread_app_role_assignment | Resources | hashicorp/azuread | Terraform Registry

Managed Identity を有効化した App Service は出力として Principal ID が用意されているので、その値と割り当てたいロールの ID を azuread_app_role_assignment に渡すだけで完了します。

一部のリソースは省略していますが、App Service の Managed Identity に対して Microsoft Graph の Group.Read.All ロールを割り当てる Terraform 定義は以下のようになります。

terraform {
  required_providers {
    azurerm = {
      version = "> 2.0"
    }
    azuread = {
      version = "> 2.0"
    }
  }
}

provider "azurerm" {
  features {}
}

data "azuread_application_published_app_ids" "well_known" {}

data "azuread_service_principal" "msgraph" {
  application_id = data.azuread_application_published_app_ids.well_known.result.MicrosoftGraph
}

resource "azurerm_function_app" "test" {
  name                       = "func-terraform-mi-test"
  resource_group_name        = azurerm_resource_group.test.name
  location                   = azurerm_resource_group.test.location
  app_service_plan_id        = azurerm_app_service_plan.test.id
  storage_account_name       = azurerm_storage_account.test.name
  storage_account_access_key = azurerm_storage_account.test.primary_access_key

  version                 = "~4"
  enable_builtin_logging  = false
  https_only              = true

  site_config {
    http2_enabled            = true
    dotnet_framework_version = "v6.0"
  }

  identity {
    type = "SystemAssigned"
  }

  app_settings = {
    "APPINSIGHTS_INSTRUMENTATIONKEY" = azurerm_application_insights.test.instrumentation_key
    "FUNCTIONS_WORKER_RUNTIME"       = "dotnet"
  }
}

resource "azuread_app_role_assignment" "test" {
  app_role_id         = data.azuread_service_principal.msgraph.app_role_ids["Group.Read.All"]
  principal_object_id = azurerm_function_app.test.identity[0].principal_id
  resource_object_id  = data.azuread_service_principal.msgraph.object_id
}

Azure AD は至る所で GUID ベースの ID を指定する必要がありますが、そのあたりを Terraform Provider for Azure AD では azuread_application_published_app_idsazuread_service_principal で上手く扱えるようになっているのがかなり便利です。GUID を定数で指定する必要が全くないことが分かると思います。

この定義に対して terraform apply を実行すると、App Service と同時に作成された Managed Identity に対して Graph API のロールが割り当てられていることが確認できます。

ちなみに Managed Identity は Azure AD の Enterprise Application から確認出来ます。このロール確認画面からは追加・削除が出来ないのが非常に不便な原因となっています。

これだけの定義で Managed Identity で Graph API が実行できるようになっているので、後はアプリケーションをデプロイして動作を確認します。必要な SDK は Microsoft.GraphAzure.Identity だけで、Azure Functions の場合は以下のようなシンプルなコードで行えます。

public class Function1
{
    [FunctionName("Function1")]
    public async Task<IActionResult> Run(
        [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req,
        ILogger log)
    {
        var graphServiceClient = new GraphServiceClient(new DefaultAzureCredential());

        var groups = await graphServiceClient.Groups.Request().GetAsync();

        return new OkObjectResult(groups.Select(x => new { x.Id, x.DisplayName }));
    }
}

このテストコードを Azure Functions にデプロイしてから、実行してみると Graph API からセキュリティグループの一覧が取得できていることが確認できます。Azure Functions には Service Principal など設定していませんが、問題なくアクセス出来ています。

まだ軽く触った程度ですが Terraform Provider for Azure AD を使うとかなり楽で良い感じです。

これまでは全て Azure CLI + 自分の MSA で入った環境で Terraform を実行してきましたが、GitHub Actions や Terraform Cloud で動かす場合には Service Principal が必要になります。

設定手順は Terraform Provider for Azure AD の公式ドキュメントに記載されています。

Configuring a User or Service Principal to manage Azure Active Directory | Guides | hashicorp/azuread | Terraform Registry

基本は公式ドキュメントを読めばよいですが、スクリーンショットがあった方が嬉しいと思うので、簡単にですが必要な Service Principal の作成方法を書いておきます。

Service Principal 自体はこれまで通り Azure CLI で作成するのが簡単です。

az ad sp create-for-rbac --name TerraformForAzureAD

Azure CLI で作成した Service Principal は Azure AD の App registrations から確認出来るはずなので、作成した Service Principal を選択して API permissions を開きます。

ここからは以下のように Microsoft Graph の Application permissions を追加すれば完了です。

必要な Graph API のロール名は、Terraform リソースの公式ドキュメントに記載されているので親切です。

この後は必要に応じて Azure RBAC の設定を Service Principal に追加すれば、Terraform Provider for Azure と Azure AD の両方が同時に利用できる Service Principal が完成します。

Terraform Azure AD Provider を使って App Service の作成と同時に Azure AD 認証を有効化する

個人的に App Service / Azure Functions で頻繁に使う機能 1,2 位を争うのが App Service Authentication で、Azure サブスクリプションが紐づいている Azure AD テナントのユーザーのみアクセス許可したい場合には、コードの修正なしで Azure Portal から有効化出来てとても便利です。

Azure Portal から設定する場合は自動的に Azure AD にアプリケーションを作成してくれるので手間がかかりませんが、Terraform や ARM Template を使う場合には別途作成が必要になるので結構面倒です。

やはり手作業は行いたくないので、今回は Terraform Provider for Azure AD を組み合わせて、App Service と同時に Azure AD アプリケーションを作成することで、手作業を完全に無くしてみます。去年リリースされたバージョン 2.0 から Microsoft Graph にも対応しているので安心です。

基本的な書き方は Terraform Provider for Azure と変わらないので、今回のケースのように複数 Provider を利用する場合には Terraform の良さが出ますね。

以前は廃止扱いになっていた気がしたのですが、今はアクティブに開発されているので、そういった意味でも安心して使えそうです。秩序ある Azure AD の管理には必須だと思います。

今回作成する Azure AD のアプリケーションは設定項目が多く、識別子も GUID で Object ID や Application ID さらには Tenant ID と複数出てくるので、出来れば依存関係はコードで表現したい部分です。

一応公式ドキュメントに App Service Authentication での利用向け Azure AD アプリケーション作成方法が紹介されているので、これをベースに Terraform で定義していきます。ただし不要そうな部分は削っています。

Expose API 周りは単なる OpenID Connect としての利用なら不要だと思っているので削りました。API として公開している場合のみ設定すればよいという認識です。

以下が実際に作成した Azure AD アプリケーションの Terraform 定義です。App Service Authentication では Client Secret が必要なので azuread_application_password を使って作成しています。

terraform {
  required_providers {
    azuread = {
      version = "> 2.0"
    }
  }
}

resource "azuread_application" "test" {
  display_name = "Terraform example"

  web {
    redirect_uris = ["https://***.azurewebsites.net/.auth/login/aad/callback"]

    implicit_grant {
      access_token_issuance_enabled = false
      id_token_issuance_enabled     = true
    }
  }
}

resource "time_rotating" "test" {
  rotation_days = 180
}

resource "azuread_application_password" "test" {
  application_object_id = azuread_application.test.object_id

  rotate_when_changed = {
    rotation = time_rotating.test.id
  }
}

正直 redirect_uris に決め打ちでホスト名を指定しないといけないのがイケてないのですが、認証周りの設定が App Service から分離されたリソースになっていないため、循環参照になってしまうので仕方ないです。

Azure AD で各 Secret を作成する場合には有効期限が悩みどころですが、Terraform を使うと time_rotating リソースと rotate_when_changed の組み合わせで、指定した期間でローテート可能なので便利そうです。

この定義に対して terraform apply を実行すると数秒で Azure AD アプリケーションが作成されます。

今回利用した azuread_application には多くのプロパティが用意されていて、機能的にはほぼ網羅されているようなので詳しくはドキュメントを参照してください。

azuread_application | Resources | hashicorp/azuread | Terraform Registry

Azure AD アプリケーションを作成して Client ID と Client Secret が用意出来れば、後は App Service を作成して auth_settings に設定してあげるだけで完了です。Terraform は Auth Settings V2 に対応していないので、Azure Portal からは確認できないのが割と不便ですが、問題なく動作はします。

data "azuread_client_config" "current" {}

resource "azurerm_resource_group" "test" {
  name     = "rg-terraform-test"
  location = "westus2"
}

resource "azurerm_app_service_plan" "test" {
  name                = "plan-terraform-test"
  resource_group_name = azurerm_resource_group.test.name
  location            = azurerm_resource_group.test.location

  sku {
    tier = "Standard"
    size = "S1"
  }
}

resource "azurerm_app_service" "test" {
  name                = "app-terraform-test"
  resource_group_name = azurerm_resource_group.test.name
  location            = azurerm_resource_group.test.location
  app_service_plan_id = azurerm_app_service_plan.test.id

  client_affinity_enabled = false
  https_only              = true

  site_config {
    default_documents = ["hostingstart.html"]
  }

  auth_settings {
    enabled                       = true
    token_store_enabled           = true
    default_provider              = "AzureActiveDirectory"
    unauthenticated_client_action = "RedirectToLoginPage"
    issuer                        = "https://login.microsoftonline.com/${data.azuread_client_config.current.tenant_id}/v2.0"

    active_directory {
      client_id     = azuread_application.test.application_id
      client_secret = azuread_application_password.test.value
    }
  }
}

現在の Azure AD で利用されている issuer についてはプロパティなどで取得できなかったので、ここに関しては Tenant ID から適当に URL を組み立てることで対応します。

追加した Terraform 定義に対して更に terraform apply を実行すると App Service が作成されます。ブラウザからアクセスしてみると、以下のような見慣れた Azure AD の確認画面が表示されるはずです。

更に承諾ボタンをクリックすると、ログイン処理が継続して App Service のデフォルトページが表示されます。Terraform Provider for Azure AD を組み合わせることで Azure Portal や Azure CLI を使わずに、Terraform のみで Azure AD 認証付きの App Service が作成できました。

必要なリソースは全て Terraform で管理されているので、削除する場合も terraform destroy を実行すれば Azure AD アプリケーションを含め全てが削除されるので、ゴミが残ることなく安心です。

個人的には Client Secret のローテートをスマートに解決できそうなので気に入りました。今回は利用していませんがセキュリティグループの管理と割り当てはコード化とレビュー付きで行いたいので、どこかのタイミングで本番運用に持っていきたいと思っています。

Azure AD B2C が標準で TOTP (Authenticator App) を使った多要素認証に対応したので試した

これまでも Azure AD B2C ではカスタムポリシーと外部 API を用意すれば、Microsoft Authenticator や Authy などの TOTP に対応したアプリケーションを使った多要素認証が使えていましたが、標準で TOTP に対応したので試しました。ちなみにプレビューです。

カスタムポリシーと外部 API を使った方法はオルターブースさんのブログで紹介されています。

サンプルコードは一通り GitHub で提供されているのですが、やっぱりそれなりに手間がかかってしまうので標準で使えた方が圧倒的に便利です。なので今後は SMS やメールを使った方法より TOTP が Azure AD B2C での標準になるのではないかと思っています。

プレビュー公開なのでローカライズ周りが未整備という感がありますが、特に問題なく試せました。

ユーザーフローから利用する

ぶっちゃけ Azure Portal から試した方が早いのですが、TOTP 向けにドキュメントも更新されています。

サンプルで提供されていた TOTP はカスタムポリシー必須でしたが、標準で対応したのでユーザーフローでも MFA の手段として TOTP が選べるようになっています。MFA enforcement を Always on にしておくと、常に MFA が要求されるようになるのでテストには最適です。

f:id:shiba-yan:20211226184552p:plain

TOTP の方が SMS やメールよりもセキュリティを高めることが出来るのと、SMS のように追加費用が発生しないのが良いです。最近だと MFA 向けにスマホには Authenticator App が何かしら入っていることが多いと思うので、そういう意味でも抵抗は少なそうです。

この状態でログインフローを進めると、よく見る QR コードをスキャンする画面が表示されます。スマホの Authenticator App で QR コードを読み取ればセットアップ完了です。

f:id:shiba-yan:20211226184601p:plain

Authenticator App を持っていない人向けにアプリのダウンロードリンクも表示されますが、ダウンロードされるのは Microsoft Authenticator となります。カスタムポリシーを使えばこの辺りのカスタマイズは出来そうな感じですが、ローカライズに必要な ID などが公開されていないので試していません。

自分は TOTP 向けに Authy を使っているので以下のようになりました。ちゃんと OTP が表示されています。

f:id:shiba-yan:20211226195613j:plain:w450

一度デバイスを登録してしまえば、次回からのログインフローでは OTP を入力する画面が表示されるようになります。若干味気ない画面にはなりますが、動作自体は問題ありません。

f:id:shiba-yan:20211226184608p:plain

ここで Authenticator App が生成した 6 桁の OTP を入力すればログインが完了します。

登録情報は User の中にある Authentication methods から確認できます。この中に Software OATH token というのが見つかれば、それが登録された MFA 情報になります。

f:id:shiba-yan:20220115214602p:plain

削除すれば当然ながら MFA 情報がリセットされるので、再セットアップが必要になります。ユーザーが自ら MFA 情報をリセットする方法は用意されていないので、対応フローを考慮しておく必要はありそうです。

カスタムポリシー (IEF) から利用する

組み込みの TOTP はカスタムポリシーからも利用できます。サンプルは GitHub で公開されているので、これを利用するとカスタマイズが可能です。以前の TOTP サンプルからの移行用ポリシーも含まれています。

こちらも一応ドキュメントは追記されているのですが、TOTP のカスタムポリシーに関することはほぼ無いので、基本的には GitHub にあるサンプルを見てもらうのが一番良いはずです。

唯一 DisplayControl に関しては TOTP に関するドキュメントが整備されています。ドキュメントにもあるように登録用の QR コード表示を行う画面は 3 分割されたコントロールの集合体になっているので、カスタマイズが柔軟に行えそうです。

ただしローカライズに必要な情報は何処にも公開されていないようなので、暫くは英語のみでの対応になってしまいそうです。Azure Portal から言語リソースをダウンロードしてみましたが、SMS を使うもの用でした。

実際に試してカスタマイズを行っておきたいのは、TOTP の URI に含まれるラベルや発行者の情報かなと思います。デフォルトだと Azure AD B2C の URL が含まれる長ったらしい名前になってしまうので、サービス名など分かりやすいものにしておきたいです。

登録した MFA 情報を Graph API で削除する

TOTP を有効化した後にデバイスを失って OTP が入力できなくなった場合に、何らかの方法でリセットをサービス管理者側で行う必要が出てきますが、Azure Portal からリセットするのはリスキーなので Graph API を使ってリセットを行えるようにしておくのが運用上良さそうです。

Graph API を使うと Authentication methods を削除できるので、これを使うと簡単に実装出来ます。

とはいえやはり Microsoft Account や Azure AD のように MFA 情報を複数登録しておいて、それを使ってユーザー自身がリセットできるようにしておきたいです。ユーザーフローでは無理ですが、カスタムポリシーなら実装出来そうな気はします。