Azure FunctionsでAzureADB2Cから発行されたトークンの検証を行う

## 1. はじめに

[前回](https://qiita.com/tamo_breaker/items/0dfc82426b7842617ada)と[前々回](https://qiita.com/tamo_breaker/items/38eae9e5a24fade35a36)までで、Azure FunctionsにGraphQL APIを実装してきました。この時点でGraphQL APIはエンドポイントとスキーマが分かれば誰でも操作できるオープンなAPIになっています。

ID管理サービスであるAzureAD B2Cでユーザ認証を行い、Azure Functionsで認証結果を検証する処理を用意し、認証付きWebAPIにしていきたいと思います。

Azure Functionsで認証を有効化する方法は、以下２つのいずれかになると思います。
①App Service認証を利用する方法
Azure FunctionsでApp Service認証を有効化して、認証に関する処理をAzureに任せる。
(Linuxの場合はホスティングプランはAppServiceのP1V2以上であること、Free/Basicプランでは認証が機能しない模様。Winでは従量課金でもAppService認証を使えるようなのですが、Pythonコードを現時点でデプロイできない・・・。)
②JWTを独自APIで検証する方法
AzureAD B2Cで認証したIDトークンを何らかの方法でBearerトークンとしてAuthorizationヘッダーに付与してAPIアクセスし、Azure Functions側ではアプリ内でIDトークンを取得・検証する。


今回は②の方法を確認しました。AzureAD B2Cでユーザ認証を行うとIDトークンが発行されるので認証後にAuthorizationヘッダーにBearerトークンとして付与し、アプリ側でトークンを取得してPyJWTで検証するという方法にしています。他にうまいやり方があれば模索していきたいと思います。

なお認証処理自体は今回は用意していないので、AzureAD B2Cのユーザフロー画面でユーザーフローを実行してトークンを発行して動作を確認するか、チュートリアルの[サンプルSPAアプリ](https://docs.microsoft.com/ja-jp/azure/active-directory-b2c/active-directory-b2c-tutorials-spa)を流用しています。

AWS(Amplify)では②の挙動になっているようで、Amplifyで認証方式をCognitoとした場合にAPIアクセス時にAuthorizationヘッダーにBearerトークンとしてIDトークンがセットされているようです。(チュートリアルのサンプルSPAアプリも同様の動き)


## 2. ID管理サービス(AzureAD B2C)
AzureAD B2C(Azure Active Directory B2C)は、一般向けユーザに対するWebアプリ/モバイルアプリの認証・認可・ユーザ管理を実現するためのID管理サービスです。
AzureではAzureADというサービスがありますがこちらは組織向け(社内アプリ等)のID管理に特化していますが、AzureAD B2CではECサイトなどの一般向けユーザのID管理に特化しています。[AWSのCognito](https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/what-is-amazon-cognito.html)が同種/比較対象となるID管理サービスでしょうか。

AzureAD B2CではID管理としてローカルアカウントで持つことができる他、Google/Microsoftアカウント/Amazonなどの外部のIDプロバイダーと連携することができます。

設定などは[公式ドキュメント](https://docs.microsoft.com/ja-jp/azure/active-directory-b2c/)が充実しています。ドキュメントのチュートリアルに記載された内容に従っていくと一通り設定は完了します。(チュートリアル、ドキュメントは結構充実しているのですが、うまく読んでいかないとハマりそうな気がします）

### 2.1. AzureAD B2Cディレクトリ
AzureAD B2Cの利用には新規にディレクトリを作成し、さらにサブスクリプションと紐付けておく必要があります。
既存ディレクトリにAzureAD B2Cを作成することはできないので、AzureAD B2C専用のディレクトリを作成しそこで運用管理していく必要があるようです。(Azure FunctionsやCosmosDBなどのリソースをそのディレクトリに展開するか別ディレクトリに展開するかは検討。すでにリソースを展開しておりディレクトリを集約する場合はお引越しが必要)

手順は公式ドキュメントの「[チュートリアル:Azure Active Directory B2C テナントの作成](https://docs.microsoft.com/ja-jp/azure/active-directory-b2c/tutorial-create-tenant)」や「[Azure サブスクリプションを Azure Active Directory B2C テナントにリンクする](https://docs.microsoft.com/ja-jp/azure/active-directory-b2c/active-directory-b2c-how-to-enable-billing)」が参考になると思います。(サブスクリプションも別途作成します。)

### 2.2. アプリケーション登録
AzureAD B2Cの利用にあたり、アプリケーション登録とユーザフロー定義が必要になります。
アプリケーションの登録は「[チュートリアル:Azure Active Directory B2C にアプリケーションを登録する](https://docs.microsoft.com/ja-jp/azure/active-directory-b2c/tutorial-register-applications)」を参照し作成します。

![aadb2c11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280717/303d51e6-0c69-c840-a0ad-40e5157ca7f3.png)

応答URLは認証処理が完了したらリダイレクトするURLになります。
```https://jwt.ms```にしているとIDトークン、クレーム情報を表示するWebページにリダイレクトされます。チュートリアルのSPAアプリなどをローカルで確認する場合には```http://localhost:6420```などとします。

また作成時にアプリケーションIDが発行されるので確認しメモしておきます。(JWTの検証の際にアプリケーションIDがaudとして埋め込まれているのでこの値を使うか、トークンをデコードするとクレームにaud属性が入っているのでこの値を使うかどちらか。)

（一部黒文字で隠していますが、SPAとしてアプリを提供するとアプリケーションIDや認証URL・ドメイン名などはJavaScriptコード内に記述されるので一般に参照できるものとなります。）

### 2.3. サインアップとサインインユーザーのユーザフロー定義
続いてユーザフロー定義ですが、こちらも[チュートリアル:Azure Active Directory B2C 内にユーザー フローを作成する](https://docs.microsoft.com/ja-jp/azure/active-directory-b2c/tutorial-create-user-flows)を参照しサクッと作成できます。
ユーザー属性と要求で、表示名やメールアドレスを要求として返すようにしておくと、この値を使った検証や処理も行うことができるようになります。

### 2.4. 認証テスト
ここまで設定が完了するとローカルアカウントによるユーザ認証が可能になります。認証用のアプリケーションを作成していなくても、AzureAD B2Cのユーザーフロー画面から簡易的に認証テストが実施可能です。

1.AzureAD B2Cのユーザーフロー画面で「ユーザーフローを実行します」を選択.
![aadb2c01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280717/1489ddfe-7f58-859e-dfec-19cd5a3ad665.png)

2.アプリケーションやドメインなどの情報が表示されますが、「ユーザーフローを実行します」を選択。
![aadb2c02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280717/e61d4ad5-7762-5c4e-2c60-c3a5575ee25b.png)

3.ログイン画面が別ウィンドウで表示されるので、ユーザ名とパスワードを入力してサインインする。(ローカルアカウントがない場合はAzureAD B2Cのポータルか「Sign up now」からアカウントを作成)
![aadb2c03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280717/b67aec04-5bcd-2f5a-ef9c-edda440cd458.png)


4.サインインが成功すると、アプリケーション登録時に設定した応答URLにリダイレクトされるようです。応答URLを```https://jwt.ms```にしていると、トークンとトークンのデコード結果を表示してくれます。


## 3. アプリケーション(Azure Functions)でのトークン検証

AzureAD B2Cで認証するとJWTでIDトークンを取得できるので、このトークンを使ってAPIにアクセスしアプリケーション側で検証などを行います。応答URLをSPAアプリなどの特定のページにし、そこでIDトークンをヘッダーに付与した上でAzure Functionsにアクセスするという流れになると思います。

Pythonアプリでのトークンの検証には[PyJWT](https://pyjwt.readthedocs.io/en/latest/index.html)を利用しています。


Azure FunctionsでPyJWTを用いたトークンの検証を行うので、requirements.txtに以下2つを追加のうえ、検証用コードを用意しデプロイします。
（AzureAD B2Cの認証によって発行されたトークンはRSA SHA256で署名されているので、[PyJWTのドキュメント](https://pyjwt.readthedocs.io/en/latest/installation.html#cryptographic-dependencies-optional)記載の通りcryptographyもインストールします。）

```
pyjwt
cryptography
```

Azure Functionsのアプリコードはこちらになります。

```__init__.py
import azure.functions as func
import jwt
from jwt.algorithms import RSAAlgorithm
import json
from urllib import request
import ssl

def main(req: func.HttpRequest) -> func.HttpResponse:
    decodedToken = None

    authHeader = req.headers.get('Authorization').split('Bearer')[1].strip()
    if authHeader:
        jwtClaims = jwt.decode(authHeader, verify=False)
        jwks_uri = jwtClaims.get('iss') + 'discovery/keys?p=' + jwtClaims.get('tfp')
        res = request.urlopen(jwks_uri, context=ssl.SSLContext(ssl.PROTOCOL_TLSv1_1))
        key_json = json.loads(res.read()).get('keys')[0]
        public_key = RSAAlgorithm.from_jwk(json.dumps(key_json))

        aud = 'AzureAD_B2C_ApplicationID'
        issuer = 'Issuer_URL'

        ecodedToken = jwt.decode(authHeader, public_key, audience=aud, issuer=issuer, algorithms=['RS256'])

    if decodedToken:
        return func.HttpResponse(f"{decodedToken}\n")
    else:
        return func.HttpResponse(
             "AuthorizationError\n",
             status_code=403
        )

```


Pythonでのデコードには[decode](https://pyjwt.readthedocs.io/en/latest/usage.html#encoding-decoding-tokens-with-rs256-rsa)により検証とデコードを同時に実施できます。
一旦検証なしでデコードしてJWKSのURLを確認、公開鍵を取得しています。

注意が必要なのは[こちら](https://github.com/jpadilla/pyjwt/issues/359)や[こちら](https://github.com/jpadilla/pyjwt/issues/291)のissueにもあるのですが、decodeメソッドへの公開鍵の指定はRSAAlgorithm.from_jwkメソッドで取得したもの利用する必要があります。(トークンに含まれる公開鍵をそのまま利用すると、"ValueError: Could not deserialize key data."が発生します。)
このfrom_jwkメソッドに与える値(JSON Web Key/JWKS)はAzureAD B2Cでは、```トークンのクレームに含まれるissの値とdiscovery/keys?p=とトークンのクレームに含まれるtfpの値を連結したURL```で取得します。

またdecodeメソッドにaudienceを指定しないとInvalidAudienceErrorが発生します。audienceは、AuzreAD B2CのアプリケーションIDをハードコードするか、トークンのクレームに含まれるaudの値のどちらかを使います。decodeメソッドでのaudienceの指定は[PyJWTのドキュメント](https://pyjwt.readthedocs.io/en/latest/usage.html#audience-claim-aud)を参照。

audienceと合わせてissuerの検証もします。issuerの値はサインイン用ユーザフローのプロパティ画面の発行者(iss)要求から確認できます。(```https://<AzureAD B2Cドメイン名>/<テナントID>/v2.0/```)

デコードするとクレーム情報を取得できるのですが、その情報は[Azure Active Directory B2C のトークンの概要](https://docs.microsoft.com/ja-jp/azure/active-directory-b2c/active-directory-b2c-reference-tokens)にも掲載されています。またAzureADのユーザフローを定義する際にクレームに追加の属性を付与することも可能です。



## 4. チュートリアルのアプリで動作確認する
公式ドキュメントのチュートリアルにサンプルのSPAアプリがあるのでそれを流用しました。[チュートリアル:Azure Active Directory B2C を使用してシングルページ アプリケーションで認証を有効にする](https://docs.microsoft.com/ja-jp/azure/active-directory-b2c/active-directory-b2c-tutorials-spa)を参考。

```
git clone https://github.com/Azure-Samples/active-directory-b2c-javascript-msal-singlepageapp.git
```

サンプルを取得したらindex.htmlのapplicationConfigを修正します。
ここにAzureAD B2CのアプリケーションID、認証URL、Azure FunctionsのWebAPIのURLなどを記載します。

```
        // The current application coordinates were pre-registered in a B2C tenant.
        var applicationConfig = {
            clientID: 'AzureAD_B2C_アプリケーションID',
            authority: "https://<AzureADB2Cドメイン名>.b2clogin.com/tfp/<AzureADB2Cドメイン名>.onmicrosoft.com/<サインインワークフロー名>",
            b2cScopes: ['AzureAD_B2C_アプリケーションID'],
            webApi: 'https://<azurefunctions_functionapp名>.azurewebsites.net/api/httptrigger',
        };
```

修正したらNode.jsを起動し、```http://localhost:6420```にブラウザでアクセスします。

```
cd active-directory-b2c-javascript-msal-singlepageapp
npm install
node server.js
```

サンプルのWebページが表示されるので、Loginを行うと、Call Web APIボタンが出るので、これを押すとAPIを叩き実行結果を画面に表示してくれるという簡単なサンプルアプリのようです。
![aadb2c21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280717/2300a186-cab6-7e24-74f7-046e0fb2eee5.png)
![aadb2c22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280717/9d3e9613-a985-4259-7fb0-80e4f0dfec3a.png)
![aadb2c23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280717/8155999e-fd03-7900-1629-b8dc0a8f8d10.png)

同一生成元ポリシーがあるのでAzure Functions側にCORSの設定が必要になります。Azure FunctionsのFunction Appの設定(プラットフォーム機能)に移りCORSにて、応答URLに記載したURL(```http://localhost:6420```)を追加します。
SPAのアプリをCDNで配布する場合もおそらく同様に設定が必要と思います。
![aadb2c24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280717/e99979b2-8fc0-d477-e4f2-67f4207e7ce3.png)



##参考情報
[Azure Active Directory B2C のドキュメント](https://docs.microsoft.com/ja-jp/azure/active-directory-b2c/)
[PyJWT](https://pyjwt.readthedocs.io/en/latest/index.html)

### チュートリアル関連
[チュートリアル:Azure Active Directory B2C を使用してシングルページ アプリケーションで認証を有効にする](https://docs.microsoft.com/ja-jp/azure/active-directory-b2c/active-directory-b2c-tutorials-spa)
[チュートリアル:Azure Active Directory B2C テナントの作成](https://docs.microsoft.com/ja-jp/azure/active-directory-b2c/tutorial-create-tenant)
[Azure サブスクリプションを Azure Active Directory B2C テナントにリンクする](https://docs.microsoft.com/ja-jp/azure/active-directory-b2c/active-directory-b2c-how-to-enable-billing)
[チュートリアル:Azure Active Directory B2C にアプリケーションを登録する](https://docs.microsoft.com/ja-jp/azure/active-directory-b2c/tutorial-register-applications)
[チュートリアル:Azure Active Directory B2C 内にユーザー フローを作成する](https://docs.microsoft.com/ja-jp/azure/active-directory-b2c/tutorial-create-user-flows)