🎄

[Azure AD B2C][Envoy] Azure AD B2C + Envoy で JWT 検証機能を実装する

2023/12/19に公開

こんにちは、本記事は Microsoft Azure Tech Advent Calendar 2023 の 12/19 (火) 分の記事になります🎅

概要

Azure AD B2C が発行するアクセストークンを Envoy の JWT Authentication フィルターを用いて検証する機能を実装してみました🎄

本記事の構成について

本記事ではまず、今回の実装に使う Azure AD B2C および Envoy の説明をしていきます。
その後、Azure AD B2C および Envoy それぞれの設定について紹介し、最後に今回実装した JWT 検証の動作確認をしていきます。

Azure AD B2C とは

Azure AD B2C は、顧客管理を目的とした ID 管理基盤サービス (Identity as a Service (IDaaS)) です。
Azure AD B2C を用いることにより、アプリケーションにおけるユーザー (= 顧客) 管理、Open ID Connect/OAuth2/SAML をベースとした認証認可処理の実装の手間を省くことができます。
Azure AD B2C ではユーザーフローなどの、既定で作成されているユーザーのサインイン/サインアップ/パスワードリセット等の画面を独自で作り込めることに加え、さらに認証機能を作り込みたい方はカスタムポリシーによる認証のフロー自体を自由にカスタマイズするような機能も提供しています。

(参考画像)https://learn.microsoft.com/ja-jp/azure/active-directory-b2c/overview より引用

本記事では Azure AD B2C を認証認可の基盤として活用し、API の認証に必要となるアクセストークンの払い出しおよび検証用 JSON Web Key Set (JWKS) エンドポイントを提供する基盤として利用していきます。

Azure AD B2C では、下記の公開情報に記載の通り、アプリケーションの API 認証で使用されるアクセストークンを払い出してくれますが、アクセストークンの有効性を検証するためのクライアント側の機能は用意されておりません。
そのため、アプリケーション開発者が、アクセストークンの検証ロジックを自ら実装する必要があります。これを後述する Envoy の JWT Authentication フィルターでコードをかかずに実装します。

  • 公開情報 一部引用
    アクセス トークン - API に付与されているアクセス許可を識別するために使用できる要求が含まれる JWT。 アクセス トークンは署名されますが、暗号化されません。 アクセス トークンは、API およびリソース サーバーへのアクセスを提供するために使用されます。 API がアクセス トークンを受け取ったら、トークンが認証済みであることを証明するために、署名を検証する必要があります。 API は、トークンが有効であることを証明するために、いくつかの要求も検証する必要があります。 シナリオの要件に応じて、アプリケーションによって検証される要求は異なりますが、いずれのシナリオでも、アプリケーションによっていくつかの共通の要求検証が行われる必要があります。

https://learn.microsoft.com/ja-jp/azure/active-directory-b2c/tokens-overview#token-types

Envoy とは

Envoy は、Cloud Native Computing Foundation (CNCF) の Graduated Project な OSS として開発・保守が進められている、クラウドネイティブアーキテクチャを支えるネットワークプロキシです。

https://www.envoyproxy.io/

Envoy にはフィルターの機能があり、通信における L3/L4 レベルの Network Filter、L7 レベルの HTTP Filter ごとにフィルターを構成することができます。Envoy のフィルターを構成することにより、アプリケーションの通信の負荷分散やセキュリティ、オブザーバビリティの向上などと言った機能の拡張を図ることができます。

また、マイクロサービスの構築を検討する場合、Envoy のフィルターによってハンドリングできる処理で、様々なビジネスロジック処理をアプリケーションから分離することを可能にしてくれます。

今回の Envoy と Azure AD B2C の検証について

今回の検証では、Envoy における HTTP Filter の設定の一つである JWT Authentication に焦点を当てます。
JWT Authentication では、Envoy がリクエスト内に含まれるアクセストークンを検証し、トークンの有効性を確認することができます。
本来はアプリケーション側で実装が必要となる、Azure AD B2C へのサインイン時に発行されるトークンの検証ロジックを当該 Envoy フィルターで肩代わりします。

検証環境では Envoy を web アプリの手前にプロキシとして配置し、web アプリへのリクエストを受信した際に Envoy が HTTP リクエストの Authorization ヘッダを確認してアクセストークンを抽出し、検証します。検証に成功した場合にはリクエストを web アプリへフォワードし、クライアントへレスポンスを返します。

[Azure Portal] 検証用の Azure AD B2C 環境を構成する

今回の検証では、Azure AD B2C におけるサインアップ サインイン (SUSI) のユーザーフローが発行するアクセストークンを検証できるようにします。本環境の構成にあたり、事前に Azure AD B2C テナントを作成しておく必要があります。

1. アプリケーションを登録する

[Azure Portal] > [Azure AD B2C] > [アプリの登録] へ移動し、新規でアプリケーションを登録します。
リダイレクト URI には今回作成する web アプリのエンドポイント [http://localhost:3000/callback] を指定しておきます。

新規作成後、Envoy の設定やアクセストークンの取得の際にアプリケーションのクライアント ID を利用する (Azure AD B2C が発行する JWT の aud クレームに該当します) ので、記録しておきましょう!

また、アクセストークンの発行には、認可エンドポイントへのリクエスト時に指定できるようにするため、事前に API 用のスコープを作成する必要があります。
[API の公開] へ移動し、下記のように何かしらスコープを作成します。

[API の許可] へ移動し、[API の公開] で作成したスコープを Azure AD B2C におけるアクセス許可として追加し、管理者の同意を与えます。下記にて同意を与えたスコープ [(例)https://yourtenant.onmicrosoft.com/00000000-0000-0000-0000-000000000000/user.write] を記録しておきます。

最後に、[証明書とシークレット] へ移動し、[新しいクライアント シークレット] より新しいシークレットを作成します。
これもアクセストークンを取得する際に利用するので、記録しておきましょう。

記録するものが多いですが..... これにてアプリケーションの登録は完了です。

2. ユーザーフローを作成する

[Azure Portal] > [Azure AD B2C] > [ユーザー フロー] へ移動し、新規でサインアップ サインインのユーザーフローを作成します。

3. 各種エンドポイントの情報を取得する

Envoy の設定およびアクセストークンの取得にあたり、ユーザーフローに関連するエンドポイントの情報が必要となるので、先ほど作成したユーザーフローのディスカバリエンドポイントへアクセスし、事前に設定に利用するエンドポイント情報を記録します。

エンドポイントの種類 対応 URL
ディスカバリ https://<B2C テナントのドメイン>.b2clogin.com/<B2C テナントのドメイン>.onmicrosoft.com/<ユーザーフロー名>/v2.0/.well-known/openid-configuration

ディスカバリエンドポイントで確認しておきたいエンドポイントは下記の通りです:

エンドポイントの種類 対応 URL
JSON Web Key Set (JWKS) https://<B2C テナントのドメイン>.b2clogin.com/<B2C テナントのドメイン>.onmicrosoft.com/<ユーザーフロー名>/discovery/v2.0/keys
トークン https://<B2C テナントのドメイン>.b2clogin.com/<B2C テナントのドメイン>.onmicrosoft.com/<ユーザーフロー名>/oauth2/v2.0/token
認可 https://<B2C テナントのドメイン>.b2clogin.com/<B2C テナントのドメイン>.onmicrosoft.com/<ユーザーフロー名>/oauth2/v2.0/authorize

[実装] コードのディレクトリ構成

ここからは検証環境の実装の節が続きます。
コードのディレクトリ構成はざっくり下記の通りです。

├── compose.yml
├── envoy
│   └── front-envoy.yaml
├── node-app
│   ├── Dockerfile
│   └── src
│	├── package.json
│	└── index.ts
...

[実装] web アプリの作成

まず初めに、クライアントからのリクエストを受ける web アプリを作成します。
今回は簡易的な検証を目的としているため、web アプリフレームワークの Express (Node.js) で シンプルな web API アプリ を以下のように作成します。
先ほど作成したリダイレクト URI も、下記のように作っておきます。(レスポンスで認可コードだけを返すエンドポイントです)

node-app/src/index.ts
app.listen(port, () => {
  console.log(`[server]: Server is running at http://localhost:${port}`);
});

app.get("/test", (req: Request, res: Response) => {
  res.send("Request arrived to web server!🎄");
});

app.get("/callback", (req: Request, res: Response) => {
  res.send(req.query['code']);
});

[実装] Docker Compose による検証環境の構成

検証環境は、Docker Compose で構築していきます。
web アプリの Dockerfile および Docker Compose 用の yaml ファイルは下記の通りです。

node-app/Dockerfile
# Dockerfile
FROM node:18.14.2

WORKDIR /app

COPY package*.json ./
RUN npm install

COPY . .

RUN npm run build

CMD ["npm", "start"]
compose.yaml
version: "3"
services:
  envoy:
    image: envoyproxy/envoy:v<ご利用されたい Envoy Version>-latest
    volumes:
      - ./envoy/front-envoy.yaml:/etc/front-envoy.yaml
    ports:
      - "8080:8080"
      - "9901:9901"
    command: ["-c", "/etc/front-envoy.yaml", "--service-cluster", "front-proxy"]
  demo-app:
    build:
      context: demo-app/
    volumes:
      - ./node-app:/app
    ports:
    - "3000:3000"
  • envoy
    Envoy のコンテナです。ポート 8080 が今回のリクエストの受け口となります。8080 で受け取った HTTP リクエストの Authorization ヘッダの内容が Envoy で検証され、demo-app [web アプリ] へフォワードされます。

  • demo-app
    Express (Node.js) で作成した簡易的な web アプリ (REST) をホストします。
    Node.js でサーバーを立ち上げた際に、既定で利用されるポート 3000 へアクセスできるように ports にて指定しておきます。

これではローカルで立ち上げた際に web アプリにそのままアクセスできるじゃないか!とツッコミが入りそうですが、細かいDocker Network の設定は今回割愛させていただきます!

[実装] Envoy の設定

ここが今回の検証の本体と言っても過言ではありません。
Docker Compose にて記載したポート 8080 をリクエストの受け口とし、Envoy の Listener に対して Http Filter を適用します。
Azure AD B2C のユーザーフロー作成時に確認したエンドポイントを Envoy の設定に記述していきます。

/envoy/front-proxy.yml
admin:
  address:
    socket_address:
      address: 0.0.0.0
      port_value: 9901

static_resources:
  listeners:
  - address:
      socket_address:
        address: 0.0.0.0
        port_value: 8080
    filter_chains:
    - filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          codec_type: AUTO
          stat_prefix: ingress_http
          # Routing Settings for HTTP.
          route_config:
            name: demo_app
            virtual_hosts:
            - name: demo_app_hosts
              domains:
              - '*'
              routes:
              - match:
                  prefix: "/"
                route:
                  cluster: demo-app-service
                  timeout: 15s
          http_filters:
          - name: envoy.filters.http.jwt_authn
            typed_config:
              '@type': type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication
              providers:
                aadb2c:
                  issuer: "https://<B2C テナントのドメイン>.b2clogin.com/<Azure AD B2C に登録したアプリケーションのクライアント ID>/v2.0/"
                  audiences:
                  - "<Azure AD B2C に登録したアプリケーションのクライアント ID>"
                  remote_jwks:
                    http_uri:
                      uri: "https://<B2C テナントのドメイン>.b2clogin.com/<B2C テナントのドメイン>.onmicrosoft.com/<ユーザーフロー名>/discovery/v2.0/keys"
                      cluster: aadb2c
                      timeout: 5s
              rules:
              - match:
                  prefix: /
                requires:
                  provider_name: aadb2c
          - name: envoy.filters.http.router
            typed_config:
              '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router

  clusters:
  - name: demo-app-service
    type: LOGICAL_DNS
    dns_lookup_family: V4_ONLY
    load_assignment:
      cluster_name: demo-app-service
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: demo-app
                port_value: 3000
  - name: aadb2c
    type: LOGICAL_DNS
    dns_lookup_family: V4_ONLY
    load_assignment:
      cluster_name: aadb2c
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: <B2C テナントのドメイン>.b2clogin.com
                port_value: 443
    transport_socket:
      name: envoy.transport_sockets.tls
      typed_config:
        '@type': type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext

検証環境の構成は以上です。
ここからやっと動作検証になります・・・!

動作検証

あらためて、今回の検証内容を再掲します。
検証手順は以下の通りです:
① Azure AD B2C へのサインインによりアクセストークンを取得する
② web アプリ [localhost:3000] の手前にある Envoy [localhost:8080] へ HTTP リクエストを送信し、Authorization ヘッダのアクセストークン (JWT) を検証します。JWT 検証の有効性が確認できたら、Envoy は web アプリへリクエストをフォワードし、クライアントは正常なレスポンスが応答されます。

アクセストークンの取得、および取得したアクセストークンを利用した実際の検証の2節に分けて動作検証を実施します。

アクセストークンの取得

少々手間がかかりますが、下記の公開情報に従い、Azure AD B2C のアクセストークンを取得することができます。

https://learn.microsoft.com/ja-jp/azure/active-directory-b2c/access-tokens

  1. 認可エンドポイント (例) へ下記のパラメータを含めてアクセスし、Azure AD B2C ユーザーでサインインします。(ブラウザなどから URL エンコードを行った上でアクセスします)
パラメータ
client_id [1. アプリケーションを登録する] で作成したアプリケーションのオブジェクト ID
redirect_uri 認可エンドポイントでサインイン後にリダイレクトされる URI
response_type code
scope openid offline_access {[1. アプリケーションを登録する] で追加した API のスコープ}
https://<B2C テナントのドメイン>.b2clogin.com/<B2C テナントのドメイン>.onmicrosoft.com/<ユーザーフロー名>/oauth2/v2.0/authorize
?client_id=00000000-0000-0000-0000-000000001000
&redirect_uri=http://localhost:3000/callback
&response_type=code
&scope=openid offline_access https://yourtenant.onmicrosoft.com/00000000-0000-0000-0000-000000001000/user.write
  1. アプリケーションにて指定したリダイレクト URI に含まれる認可コード (code の値) を抽出します。
http://localhost:3000/callback?code=eyJraWQiOiJjc...
  1. 下記のパラメータを指定してトークンエンドポイントへアクセスし、応答にアクセストークンが含まれるので、これを使って検証していきます。(こちらも適宜 URL エンコードを行った上で curl していきます)
パラメータ
grant_type authorization_code
code 2. で取得した認可コード
response_type [1. アプリケーションを登録する] で作成したアプリケーションのクライアント ID
client_secret [1. アプリケーションを登録する] で作成したアプリケーションのクライアントシークレット
curl -X POST -H 'Host: <tenant-name>.b2clogin.com' -H 'Content-Type: application/x-www-form-urlencoded' https://<B2C テナントのドメイン>.b2clogin.com/<B2C テナントのドメイン>.onmicrosoft.com/<ユーザーフロー名>/oauth2/v2.0/token
?grant_type=authorization_code
&code=eyJraWQiOiJjc....
&client_id=0000-0000-0000-000000001000
&client_secret={secret}

リクエストの検証

[1.アクセストークンの取得] で Azure AD B2C のアクセストークンが発行できたら、今回実装した Envoy のフィルターが正常に動作するかを確認していきます。

有効なトークンをリクエストに含めて送信した際の応答

無事クリスマスツリーが応答されました。

$ curl -H 'Authorization: Bearer <access token>' http://localhost:8080/test -v 
*   Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /test HTTP/1.1
...(省略)...
< HTTP/1.1 200 OK
< x-powered-by: Express
< content-type: text/html; charset=utf-8
< content-length: 34
< etag: W/"22-aSyiaQptXbAhIPnnIoPU0/JC6a8"
< date: Sat, 16 Dec 2023 12:58:45 GMT
< x-envoy-upstream-service-time: 1
< server: envoy
< 
* Connection #0 to host localhost left intact
Request arrived to web server!🎄

Envoy 側のログにも検証成功のログが残っております。

不正なトークンをリクエストに含めて送信した際の応答

ここで少し、変化球を投げてみましょう。
JWT ヘッダの中の署名アルゴリズムを少し変更して、Web アプリへアクセスしてみます。
既定の alg クレーム RS256 を none へ変更し、base64 エンコードしたものを HTTP リクエストの Authorization ヘッダで指定します。

{"alg":"RS256","kid":"<署名キー>","typ":"JWT"}

↓ alg クレームを変更

{"alg":"none","kid":"<署名キー>","typ":"JWT"}
$ curl -H 'Authorization: Bearer <invalid access token>' http://localhost:8080/test -v
*   Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /test HTTP/1.1
...(省略)...
< HTTP/1.1 401 Unauthorized
< www-authenticate: Bearer realm="http://localhost:8080/test", error="invalid_token"
< content-length: 33
< content-type: text/plain
< date: Sat, 16 Dec 2023 13:03:07 GMT
< server: envoy
< 
* Connection #0 to host localhost left intact
Jwt header [alg] is not supported

ちゃんと不正を検知してくれたようです!
Envoy 側のログにも検証失敗のログが残っております。

今回の検証環境について

今回の検証環境構築のサンプルは下記のリポジトリに載せてますので、参考にしてみてください!
https://github.com/hebo4096/aadb2c-envoy-jwt-authentication

まとめ

今回は Envoy の JWT Authentication フィルターを用いて JWT の検証ができるようにしました。やはりアプリケーション側の実装の手間が省けるのが素晴らしいです。

今回紹介したフィルター以外にも、Envoy ではたくさんのフィルターが提供されております。
Azure AD B2C にて発行されたアクセストークンを用いて REST API の認可制御を実装したい場合、Envoy と Open Policy Agent (OPA) の OSS を連携させるような方法なども検討できそうです!

Discussion