11
6

More than 1 year has passed since last update.

Azure Static Web Apps のカスタム認証機能で Azure AD B2C 認証する

Last updated at Posted at 2023-01-04

Azure Static Web Apps には組み込みの認証機能が付いており、デフォルトでは Azure AD や Github、Twitter といった認証プロバイダーを利用することができます。
この認証機能は「マネージド認証」と呼ばれていますが、他にも「カスタム認証」という機能もあり、OpenID Connectをサポートする任意のカスタムプロバイダーを構成することもできます。
今回はこのカスタム認証を利用して Azure AD B2C 認証を試していきたいと思います。

前提条件

  • Azure AD B2C テナント が作成済みであること(この記事では作成手順は割愛します)
  • Static Web Apps(以降「SWA」と表記)のアプリがデプロイ済みであること

※本記事に登場するアプリ、ソースコードは、以下の記事で作成したものとなります
 (読まなくても本記事のみで要点は把握できると思います・・・多分)

構成は以下の通り。

  • フロントエンド
    • Vite + React
    • Typescript
  • バックエンド(API)
    • Azure Functions(SWA のマネージド関数)
    • Node
    • Typescript

作業の流れ

カスタム認証を構成するための大まかな手順は以下の通りです。
沢山ありますが、順を追ってやっていけば、そんなに難しくはないと思います。

  1. Azure AD B2C でアプリの登録を行う
  2. 登録したアプリの認証設定を行う
  3. 登録したアプリのシークレットを作成する
  4. Azure AD B2C の認証確認用ユーザーを作成する
  5. Azure AD B2C でユーザーフローの設定を行う
  6. SWA のホスティングプランを Standard に変更する
  7. 作成したシークレットとアプリの ID を SWA のアプリケーション構成に登録する
  8. SWA の構成ファイルに認証プロバイダーとルーティングの設定を追加する
  9. Azure へデプロイ

SWA のデプロイ先 URL 確認

Azure Portal にサインイン(SWA がデプロイされているディレクトリを選択)し、静的 Web アプリの概要ページに記載されている URL をメモしておきます。
image.png

Azure AD B2C アプリの登録

Azure Portal にサインイン(B2C テナントのディレクトリを選択)し、「B2C」等で検索して Azure AD B2C の詳細ページへ移動します。

アプリの登録 > 新規登録
image.png

必要な情報を入力して、登録

  • アプリケーションの表示名
    • 任意の名前
  • サポートされているカウントの種類
    • 「任意の ID プロバイダーまたは組織ディレクトリ内のアカウント(ユーザーフローを使用したユーザーの認証用)」をチェック
  • リダイレクト URI
    • 「Web」を選択
    • <先ほどメモした SWA のデプロイ先URL>/.auth/login/b2c/callback
      ※「b2c」は後ほど設定する認証プロバイダーの識別名(任意の名前で OK)

image.png

アプリケーション ID の確認

アプリの登録が完了するとアプリの概要ページへ移動します。
後で使用するので、概要ページの「アプリケーション(クライアント)ID」をメモしておきます。
image.png

アプリの認証設定

サイドメニューの 認証 をクリック。
必要な情報を入力して 保存

  • フロントチャネルのログアウト URL
    • <SWA のデプロイ先URL>/.auth/logout
  • 承認エンドポイントによって発行してほしいトークンを選択してください
    • 「ID トークン(暗黙的およびハイブリッドフローに使用)」をチェック

image.png

アプリのシークレット作成

サイドメニューの 証明書とシークレット > 新しいクライアントシークレット > 説明 を入力 > 追加
有効期限は必要に応じて変更してください(デフォルトでは 6 か月)
image.png

後で使用するので、「」の方をメモしておいてください。
(一度ページ遷移すると値がマスクされて見えなくなってしまうので注意)
image.png

Azure AD B2C の認証確認用ユーザー作成

Azure AD B2C のページで、サイドメニューの ユーザー > 新しいユーザー へ移動し、必要な情報を入力して 作成
image.png

  • テンプレートの選択
    • 「Azure AD B2C ユーザーの作成
  • サインイン方法(任意の値を入力)
    • 「ユーザー名」
    • 「値」
  • サインイン時に必要となるため パスワード をメモしておく
    image.png
    image.png

Azure AD B2C ユーザーフローの設定

ユーザーフローの作成

「Azure AD B2C」のページへ戻り、サイドメニューの ユーザーフロー > 新しいユーザーフロー > サインイン > 推奨 > 作成 の順に移動。
必要な情報を入力して、 作成
image.png
image.png

今回は以下のように入力しました。

  • 名前
    • 任意のユーザーフロー名
  • ID プロバイダー
    • 「User ID signin」を選択
  • 多要素認証
    • 方法の種類
      • 「メール」を選択(既定値)
    • MFA の強制
      • 「オフ」を選択(既定値)
  • アプリケーション要求
    • 「表示名」をチェック

image.png

言語の設定(オプション)

デフォルトではサインイン画面の言語が英語になっているため、日本語に変更します。
(英語のままで良い人はスキップしてください)
作成したユーザーフローが一覧に表示されるので、クリックして詳細画面へ移動します。
image.png

サイドメニュの 言語 > 言語のカスタマイズを有効化 をクリック。
image.png

日本語 をクリックし、「有効」と「規定」を「はい」に設定して 保存
image.png

ユーザーフローの設定情報取得用の URL を確認

後で使用するため、ユーザーフローを実行しますをクリックして表示された URL をメモしておきます。

2023/07/14 追記
この URL (openid-configuration) にクエリ文字列を付けるのは規約違反のようなので、
以下のような URL にした方が良いみたいです。
https://<aad b2c tenant fqdn>/<b2c domain or guid>/<policy or user flow id>/v2.0/.well-known/openid-configuration
[参考] https://blog.azure.moe/2023/06/30/azure-ad-b2c%e3%81%aeopenid-configuration/

image.png

Azure AD B2C からのサインアウト用 URL を確認

SWA の認証機能についての記事で、サインアウトについてはさらっとしか触れられておらず結構ハマったのですが、完全にサインアウトするには、

  • SWA からのサインアウト
  • Azure AD B2C からのサインアウト

の両方を実施する必要があります。
どちらも所定の URL にアクセス(リダイレクト)することでサインアウトすることができます。

2023/06/23 追記
App Service / Static Web APps / Container Apps の Easy Auth が OpenID Connect の PR-Initiated Logout をサポートしたことにより、「SWA からのサインアウト」のみで「Azure AD B2C からのサインアウト」も同時に実施してくれるようになったようです。
以下の記事で詳しく取り上げられています。
https://blog.shibayan.jp/entry/20230609/1686302005

まず SWA からのサインアウト ですが、こちらは アプリの認証設定 で設定した「フロントチャネルのログアウト URL」にアクセスすることで実施することができます。

Azure AD B2C からのサインアウト については ユーザーフローの設定情報取得用の URL を確認 でメモした URL にアクセスして表示される、end_session_endpoint にアクセスすることで実施することができます。
end_session_endpoint は後で使用するのでメモしておいてください。

{
  "issuer": "https://<your tenant>.b2clogin.com/<your tenant id>/v2.0/",
  "authorization_endpoint": "https://<your tenant>.b2clogin.com/<your tenant>.onmicrosoft.com/oauth2/v2.0/authorize?p=b2c_1_swa_signin",
  "token_endpoint": "https://<your tenant>.b2clogin.com/<your tenant>.onmicrosoft.com/oauth2/v2.0/token?p=b2c_1_swa_signin",
  "end_session_endpoint": "https://<your tenant>.b2clogin.com/<your tenant>.onmicrosoft.com/oauth2/v2.0/logout?p=b2c_1_swa_signin",
  "jwks_uri": "https://<your tenant>.b2clogin.com/<your tenant>.onmicrosoft.com/discovery/v2.0/keys?p=b2c_1_swa_signin",
  ...
}

Azure AD B2C からのサインアウトを実施しないと、Azure AD B2C のシングル サインオン セッションが残ったままとなるため、例えばサインアウト後にサインインページを表示させたい場合に、以下の様な挙動となりうまくいきません。

  1. SWA からサインアウト
  2. サインイン用の URL にリダイレクト
  3. Azure AD B2C 上のセッション情報を照会
  4. Azure AD B2C 上にセッションが存在するため、サインイン済みと判断されてサインイン後のページへ遷移

※「Azure AD B2C からのサインアウト用 URL」は以降「B2C サインアウト URL」と表記します

SWA のホスティングプラン変更

カスタム認証機能を有効にするためには、SWA のホスティングプランを Standard に変更する必要があります。
(Free プランでは残念ながら機能しません・・・諦めましょう 😢)

腹をくくったら、以下の手順でホスティングプランを変更してやりましょう。
静的 Web アプリ > <デプロイしたアプリ名> > ホスティングプラン > ✔Standard > 保存
image.png

SWA のアプリケーション構成

SWA から Azure AD B2C のユーザーフローにアクセスできるようにするため、Azure AD B2C で作成したアプリの アプリケーション ID と シークレット を、SWA のアプリケーション構成に登録します。

構成 > 追加 で名前・値を入力して OK
以下の値を登録します。

名前
AAD_B2C_ClientID アプリケーション ID の確認 でメモしたアプリケーション(クライアント)ID
AAD_B2C_Client_Secret アプリのシークレット作成 でメモしたシークレットの値

最後に 保存 ボタンをクリックするのを忘れずに。

image.png

認証プロバイダーとルーティングの設定を追加

ここからはローカルでの作業です。
SWA の構成ファイルに認証プロバイダーとルーティングの設定を追加します。
まずは最終的な形を載せておきます。後で分割して説明していきます。

staticwebapp.config.json
{
  "$schema": "https://json.schemastore.org/staticwebapp.config.json",
+  "auth": {
+    "identityProviders": {
+      "customOpenIdConnectProviders": {
+        "b2c": {
+          "registration": {
+            "clientIdSettingName": "AAD_B2C_ClientID",
+            "clientCredential": {
+              "clientSecretSettingName": "AAD_B2C_Client_Secret"
+            },
+            "openIdConnectConfiguration": {
+              "wellKnownOpenIdConfiguration": "<ユーザーフローの設定情報取得用URL>"
+            }
+          },
+          "login": {
+            "nameClaimType": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
+            "scopes": ["openid", "profile"]
+          }
+        }
+      }
+    }
+  },
+  "routes": [
+    {
+      "route": "/login",
+      "redirect": "/.auth/login/b2c"
+    },
+    {
+      "route": "/logout",
+      "redirect": "/.auth/logout?post_logout_redirect_uri=%2Flogout-b2c"
+    },
+    {
+      "route": "/logout-b2c",
+      "redirect": "<B2CサインアウトURL>&post_logout_redirect_uri=<SWAのデプロイ先URL>%2Flogin"
+    },
+    {
+      "route": "/*",
+      "allowedRoles": ["authenticated"]
+    }
+  ],
+  "responseOverrides": {
+    "401": {
+      "statusCode": 302,
+      "redirect": "/login"
+    }
+  },
  "platform": {
    "apiRuntime": "node:18"
  }
}

認証プロバイダーの設定

staticwebapp.config.json
  "auth": {
    "identityProviders": {
      "customOpenIdConnectProviders": {
        "b2c": {
          "registration": {
            "clientIdSettingName": "AAD_B2C_ClientID",
            "clientCredential": {
              "clientSecretSettingName": "AAD_B2C_Client_Secret"
            },
            "openIdConnectConfiguration": {
              "wellKnownOpenIdConfiguration": "<ユーザーフローの設定情報取得用URL>"
            }
          },
          "login": {
            "nameClaimType": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
            "scopes": ["openid", "profile"]
          }
        }
      }
    }
  }
設定名 設定値 説明
b2c 認証プロバイダーの識別名
clientIdSettingName AAD_B2C_ClientID SWA のアプリケーション構成 で設定した設定名
clientSecretSettingName AAD_B2C_Client_Secret SWA のアプリケーション構成 で設定した設定名
wellKnownOpenIdConfiguration <ユーザーフローの設定情報取得用 URL> ユーザーフローの設定情報取得用の URL を確認 でメモした URL
https://<テナント名>.b2clogin.com/<テナント名>.onmicrosoft.com/v2.0/.well-known/openid-configuration?p=<ユーザーフロー名>

ルーティングの設定

staticwebapp.config.json
  "routes": [
    {
      "route": "/login",
      "redirect": "/.auth/login/b2c"
    },
    {
      "route": "/logout",
      "redirect": "/.auth/logout?post_logout_redirect_uri=%2Flogout-b2c"
    },
    {
      "route": "/logout-b2c",
      "redirect": "<B2CサインアウトURL>&post_logout_redirect_uri=<SWAのデプロイ先URL>%2Flogin"
    },
    {
      "route": "/*",
      "allowedRoles": ["authenticated"]
    }
  ]
パス ルール 説明
/login /.auth/login/b2cへリダイレクト サインイン用のエンドポイント
b2cは認証プロバイダの識別名
/logout /.auth/logout?post_logout_redirect_uri=%2Flogout-b2cへリダイレクト SWA のサインアウト用 URL へリダイレクト。
サインアウト後、B2C サインアウト URL へリダイレクトするためのパスへリダイレクトさせる。(ややこしい!)
/logout-b2c <B2CサインアウトURL>&post_logout_redirect_uri=<SWAのデプロイ先URL>%2Floginへリダイレクト B2C サインアウト URL へリダイレクト。
サインアウト後、サインイン画面へリダイレクトさせる。
※SWA のデプロイ先 URL は URL エンコードされている必要があります
/* 認証済みのユーザーのみアクセス可能 全ての URL を未認証ではアクセスできないように設定

/logout-b2cをわざわざ別ルートとして切り出しているのには理由があります。
B2C サインアウト URL は SWA とは異なる外部 URL なので、オープンリダイレクト攻撃対策のため、通常はリダイレクトできないようになっています。

ただし、上記のように設定ファイル内でリダイレクトの設定をしておくことで、外部 URL にもリダイレクトが可能となります。
その挙動を利用して、サインアウト一旦内部URLにリダイレクトルーティングに従って外部URLにリダイレクト という流れにしています。

Azure App Service で作成したアプリではリダイレクト可能な外部 URL を設定することができるのですが、SWA では同様の設定ができないため、ちょっと強引な感じもしますが、上記の方法で対応しました。
◎ 正規の方法を知っている方がいたら是非教えてください

未認証でアクセスした場合の設定

staticwebapp.config.json
{
  "responseOverrides": {
    "401": {
      "statusCode": 302,
      "redirect": "/login"
    }
  }
}

未認証状態でアクセスするとレスポンスコード401が返されるため、レスポンスコードをハンドリングしてサインイン画面へリダイレクトするようにしています。

サインアウトボタンの実装

サインアウトの確認もしたいので、作成したアプリにサインアウトボタンを追加しておきます。

src/App.tsx
// ...
  return (
    <div className='App'>
      <div>
        <a href='https://vitejs.dev' target='_blank'>
          <img src='/vite.svg' className='logo' alt='Vite logo' />
        </a>
        <a href='https://reactjs.org' target='_blank'>
          <img src={reactLogo} className='logo react' alt='React logo' />
        </a>
      </div>
      <h1>Vite + React</h1>
      <div className='card'>
        <button onClick={() => setCount((count) => count + 1)}>count is {count}</button>
        <p>
          Edit <code>src/App.tsx</code> and save to test HMR
        </p>
      </div>
      <p className='read-the-docs'>Click on the Vite and React logos to learn more</p>
      {data === null ? null : (
        <div>
          {/* API のレスポンス */}
          <p>{JSON.stringify(data)}</p>
        </div>
      )}
+      <div>
+        <button onClick={() => (window.location.href = '/logout')}>サインアウト</button>
+      </div>
    </div>
  )
}

export default App

Azure へデプロイ

いつも通り Azure へデプロイします。

動作確認

デプロイが完了したら、デプロイ先 URL にアクセスして動作確認します。
サインイン画面が表示されれば成功です!
image.png

この状態で API を叩いてみましょう。
レスポンスコード302となり、API のレスポンスが返ってこないことが確認できます。
image.png

ではサインインしてみます。
Azure AD B2C の認証確認用ユーザー作成 で作成したユーザーのユーザー名/パスワードを入力して、サインイン
image.png

アプリのページが表示されました。
API のレスポンスも表示されているので API にアクセスできていることが確認できます。
image.png

次にサインアウトしてみましょう。
サインアウトをクリックします。
image.png
サインイン画面に遷移しました!
image.png

以上で Azure AD B2C 認証の構築は完了です。お疲れさまでした!

【おまけ】認証情報の取得

サインイン済みの場合、アプリから認証情報を取得することができます。

クライアント側

クライアント側では、/.auth/me にアクセスすることで取得できます。
JSON 形式のデータが返されます。
image.png

API 側

API 側では、リクエストヘッダーから取得することができます。
x-ms-client-principalヘッダーに認証情報を Base64 エンコードした値が入っているので、デコードして使います。

api/swafunc/index.ts
import { AzureFunction, Context, HttpRequest } from '@azure/functions'

const httpTrigger: AzureFunction = async function (context: Context, req: HttpRequest): Promise<void> {
  context.log('HTTP trigger function processed a request.')
  const name = req.query.name || (req.body && req.body.name)
  const responseMessage = name
    ? 'Hello, ' + name + '. This HTTP triggered function executed successfully.'
    : 'This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response.'
+  const clientPrincipal = getClientPrincipal(req)

  context.res = {
    // status: 200, /* Defaults to 200 */
    body: {
      responseMessage,
+      clientPrincipal,
    },
  }
}

+export type ClientPrincipal = {
+  identityProvider: string
+  userDetails: string
+  userId: string
+  userRoles: string[]
+  claims?: {
+    typ: string
+    val: string
+  }[]
+}

+const getClientPrincipal = (req: HttpRequest): ClientPrincipal | null => {
+  // 'x-ms-client-principal'ヘッダーから認証情報取得
+  const clientPrincipalHeader = req.headers['x-ms-client-principal']
+  if (!clientPrincipalHeader) {
+    return null
+  }
+
+  // Base64エンコードされているのでデコードする
+  const clientPrincipal: ClientPrincipal = JSON.parse(Buffer.from(clientPrincipalHeader, 'base64').toString('utf-8'))
+  return clientPrincipal
+}

export default httpTrigger

API でも取得できました。
ただし、ヘッダーには"claims"の値は含まれていないようで、取得できませんね。
image.png

おわりに

認証機能は普通に実装したら大変ですが、Azure 上のリソースの設定と、SWA の構成ファイルだけで実現することができてしまいました。
また、SWA のマネージド関数でAPIを作成すると API 側のアクセス制御も一緒にできてしまうので、とても楽です。
SWA の認証機能は他にも色々できることがあるので、今後試していきたいと思います。

11
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
6