Azure Functions Easy authの実装と仕組みを理解する

Azure

App ServiceにはEasy Authと呼ばれる組み込みの認証認可機能があります。これを利用することで、コードを変更せずに認証認可機能をアプリに追加できます。

Azure Portalから数クリックで簡単に設定できるのも素晴らしい点です。しかし、実際どのような仕組みでどのようなサービスが連携して成り立っているのか興味を持ちました。そこで、Azure FunctionsでEasy Authを有効化し、実際の動作を1つずつ確認することで理解を深めていきたいと思います。

前提

  • Azure FunctionsでAPIを実装して動作確認を行います(Functionsの作成の説明は省略します)。
  • IDプロバイダーとしてEntra IDを利用します。
  • APIの実行はアプリとします(ユーザーではない)

最終的なフローと構成図

Easy Authを有効化して作成されるリソースと、最終的なフローは以下の通りとなります。以降はこの図を参考に進めていきます。

Easy Authの有効化

設定手順

まずは、Azure FunctionsでEasy Authを有効化します。Azure Functionsを作成したら「Authentication」から「Add identity provider」をクリックします。

今回は冒頭で触れましたが、Entra IDを用いますので、identity providerには「Microsoft」を選択します。それ以外はデフォルトのままとします。

以上でEasy Authの設定は完了です。名前の通り簡単な設定でした。これでAzure Functionsで実装したAPIやホームページ(https://<Functions名>.azurewebsites.net)にブラウザからアクセスするとEntra IDへのログインを求められます。APIの場合は、実行すると「401 Unauthorized」のステータスコードと共に、「You do not have permission to view this directory or page.」というメッセージが返却されます。

アプリケーション

Easy Authを有効化すると、裏側ではEntra IDにアプリケーションがFunctions名と同名で作成されます。「Entra ID」→「App registrations」→「Owned applications」にアプリが確認できると思います。アプリ名をクリックすると以下のような情報が確認できます。

Azure FunctionsがアプリとしてEntra IDに作成されています。後ほどこのアプリの情報を利用します。図ではアプリ(Functions)がこれに該当します。

クライアント用のアプリ登録

先ほどはアクセス先であるAzure Funtionsのアプリが作成されましたが、ここではアクセス元となるクライアント用のアプリをEntra IDに作成します。図ではアプリ(クライアント)になります。

Entra IDのApp registrationsから「New registration」をクリックして新規作成します。名前を入力したらあとはデフォルトのまま作成します。

作成できたら、「Certificates & secrets」からシークレットを1つ追加します。後程のAzure Functionsへのアクセスはこのシークレットを使います。シークレット追加直後、シークレットValueが表示されますので、忘れずにメモしておきます。IDは後からでも確認できますが、Valueは一度ページを離れてしまうと確認できなくなってしまいます。

動作確認①

さっそくEasy Authを有効化したAzure Functionsに対してリクエストを送信してみましょう。流れとしては、まずEntra IDに対してクライアント側のアプリのシークレットを使いアクセストークンを要求します。(フロー①、②)アクセストークンを取得できたらそれをAuthorizationヘッダーに指定してリクエストを送信します(フロー③)。

それではアクセストークンを取得します。まずclient_idには「クライアント用のアプリ登録」で作成したアプリのアプリケーションIDを指定します。同じくclient_secretには「クライアント用のアプリ登録」で作成したシークレットのvalueを指定します。

続いてscopeはAzure FunctionsでEasy Authを有効化したときに同時に作成されたアプリのscopeに「/.default」を追加した値を指定します。scopeはEntra IDのアプリから、「Expose an API」のページで確認できます。デフォルトでは「api://application_id」となっていると思います。

POST https://login.microsoftonline.com/<tenatn id>/oauth2/v2.0/token
Content-Type: application/x-www-form-urlencoded

client_id=<appication id of client application>
&scope=<application id url of azure functions>/.default
&client_secret=<secret value of client application>
&grant_type=client_credentials

上記のリクエストを送信するとアクセストークンが取得できます。そしてそのアクセストークンをヘッダーに指定してAzure Functionsにリクエストを送信します(フロー③)。

GET https://func-dev-entraid.azurewebsites.net/api/Test1
Authorization: Bearer <access token>

これで問題なくAzure Functionsで実装したAPIを実行できます(裏ではフロー④~⑥が行われる)。

アプリロールによるクライアントの制限

現在の設定では、同じEntra ID上に登録されているアプリであれば、どのアプリでも同じようにアクセストークンを取得してAPIを実行できます。

そこで、アプリロールを作成しクライアントのアプリに明示的に割り当てます。そしてAzure FunctionsのAPIの内部でロールが付与されているかを確認することで、特定のクライアントのみにアクセスを制限することができます。ではさっそくアプリロールを設定してきます。設定の流れは以下のようになります。

  • Azure Functionsのアプリでアプリロールを作成する
  • クライアントのアプリにアプリロールを付与する
  • API内部の実装としてロールを確認する処理を追加する

アプリロールの作成

アプリロールはAzure Functions用のアプリ内に作成します。Entra IDから該当のアプリを選び、そこから「App roles」をクリックします。そして追加をクリックします。「Value」については「API.Execute」としていますが、自由に設定して大丈夫です。また「Allowed member types」は「Applications」とします。今回はユーザーが伴わないアプリ間の連携について考えているためです。

クライアントアプリへのアプリロールの付与

続いて、先ほど作成した「API.Execute」をクライアント用のアプリに付与します。クライアントアプリを選択して、以下の順でAzure Functions用のアプリを選択します。

アプリを選択したら以下のように「Application permissions」を選択すると「API.Execute」が表示されるので選択して追加します。

追加できたら最後に「Grant admin consent for 既定のディレクトリ」をクリックして許可します。管理者の許可が必要な設定にしていますので、permissionを追加しただけではだめで明示的に許可が必要になります。

付与されているアプリロールの確認

アプリロールが付与されたクライアントで再度アクセストークンを取得し、それをヘッダーに指定してAzure Functionsへリクエストを送信します。すると複数のヘッダーが自動的に追加されます。その中で「X-MS-CLIENT-PRINCIPAL」にアプリロールが含まれます。ソースコードではこのヘッダーを解析して、適切なロールが付与されているかどうかを確認します(フロー⑦)。

以下のコードでは、9~22行目あたりでヘッダーの内容を確認して、想定するロールが含まれているかを確認しています。

        [Function("Test1")]
        public async Task<HttpResponseData> Run([HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequestData req)
        {

            var headers = new Headers();
            var responseCode = HttpStatusCode.Unauthorized;
            var responseMessage = "ロールがありません";

            if (req.Headers.Contains("x-ms-client-principal"))
            {
                var principal = req.Headers.GetValues("x-ms-client-principal").First();
                var decodedPrincipal = Convert.FromBase64String(principal);
                var jsonPrincipal = Encoding.UTF8.GetString(decodedPrincipal);
                headers.Principal = JsonSerializer.Deserialize<ClientPrincipal>(jsonPrincipal);

            }

            if (headers.Principal.Claims.Any(x => x.Type == "roles" && x.Value == "API.Execute"))
            {
                responseMessage = "OK";
                responseCode = HttpStatusCode.OK;
            }

            var response = req.CreateResponse(responseCode);
            response.Headers.Add("Content-Type", "text/plain; charset=utf-8");
            response.WriteString(responseMessage);
            return response;
        }


        public class Headers
        {
            public List<Header> HeaderList { get; set; } = new List<Header>();
            public ClientPrincipal Principal { get; set; }
        }

        public class Header
        {

            public Header() { }
            public string Key { get; set; } 
            public string Value { get; set; } 
        }

        public class ClientPrincipalClaim
        {
            [JsonPropertyName("typ")]
            public string Type { get; set; } 
            [JsonPropertyName("val")]
            public string Value { get; set; } 
        }

        public class ClientPrincipal
        {
            [JsonPropertyName("auth_typ")]
            public string IdentityProvider { get; set; } 
            [JsonPropertyName("name_typ")]
            public string NameClaimType { get; set; }
            [JsonPropertyName("role_typ")]
            public string RoleClaimType { get; set; }
            [JsonPropertyName("claims")]
            public IEnumerable<ClientPrincipalClaim> Claims { get; set; }
        }

基本的に以下のドキュメントを参考にしていますので、その他詳細は以下をご確認ください。

以下は実際のヘッダー情報の抜粋です。以下のようにPrincipalのclaimsの中にrolesが含まれています。

{
  "HeaderList": [
    {
      "Key": "Accept-Encoding",
      "Value": "gzip"
    },
  (略)
  ],
  "Principal": {
    "auth_typ": "aad",
    "name_typ": "appid",
    "role_typ": "http://schemas.microsoft.com/ws/2008/06/identity/claims/role",
    "claims": [
      {
        "typ": "aud",
        "val": "api://appilication id"
      },
  (略)
      {
        "typ": "roles",
        "val": "API.Execute"
      },
  (略)
      {
        "typ": "ver",
        "val": "1.0"
      }
    ]
  }
}

動作確認②

再度アクセストークンを取得し直して、Azure Functionsにリクエストを送信すると、以下のように成功します。

クライアントのアプリからアプリロールを削除したり、管理者の同意を取り消すとリクエスト情報にロールが含まれなくなりますので、失敗します。

最後に

Easy Authは、たったの数クリックでコードを変更することなく認証機能を追加できる非常に便利な仕組みです。クラウドでは利用者が頑張らなくても簡単に実装できる仕組みがたくさん備わっています。それでもやはり裏側はどうなっているのか気になってしまいます。

今回、1つずつ動作を確認することで理解を深められたと思います。要件に応じてはアプリロールなどを使用してセキュリティの強化などのカスタマイズもできます。その場合はソースコードの変更が伴いますが、カスタマイズ性もあり改めていい機能だなと思います。

コメント