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
作業の流れ
カスタム認証を構成するための大まかな手順は以下の通りです。
沢山ありますが、順を追ってやっていけば、そんなに難しくはないと思います。
- Azure AD B2C でアプリの登録を行う
- 登録したアプリの認証設定を行う
- 登録したアプリのシークレットを作成する
- Azure AD B2C の認証確認用ユーザーを作成する
- Azure AD B2C でユーザーフローの設定を行う
- SWA のホスティングプランを Standard に変更する
- 作成したシークレットとアプリの ID を SWA のアプリケーション構成に登録する
- SWA の構成ファイルに認証プロバイダーとルーティングの設定を追加する
- Azure へデプロイ
SWA のデプロイ先 URL 確認
Azure Portal にサインイン(SWA がデプロイされているディレクトリを選択)し、静的 Web アプリの概要ページに記載されている URL をメモしておきます。
Azure AD B2C アプリの登録
Azure Portal にサインイン(B2C テナントのディレクトリを選択)し、「B2C」等で検索して Azure AD B2C
の詳細ページへ移動します。
必要な情報を入力して、登録
- アプリケーションの表示名
- 任意の名前
- サポートされているカウントの種類
- 「任意の ID プロバイダーまたは組織ディレクトリ内のアカウント(ユーザーフローを使用したユーザーの認証用)」をチェック
- リダイレクト URI
- 「Web」を選択
-
<先ほどメモした SWA のデプロイ先URL>/.auth/login/b2c/callback
※「b2c」は後ほど設定する認証プロバイダーの識別名(任意の名前で OK)
アプリケーション ID の確認
アプリの登録が完了するとアプリの概要ページへ移動します。
後で使用するので、概要ページの「アプリケーション(クライアント)ID」をメモしておきます。
アプリの認証設定
サイドメニューの 認証
をクリック。
必要な情報を入力して 保存
- フロントチャネルのログアウト URL
<SWA のデプロイ先URL>/.auth/logout
- 承認エンドポイントによって発行してほしいトークンを選択してください
- 「ID トークン(暗黙的およびハイブリッドフローに使用)」をチェック
アプリのシークレット作成
サイドメニューの 証明書とシークレット
> 新しいクライアントシークレット
> 説明
を入力 > 追加
有効期限は必要に応じて変更してください(デフォルトでは 6 か月)
後で使用するので、「値」の方をメモしておいてください。
(一度ページ遷移すると値がマスクされて見えなくなってしまうので注意)
Azure AD B2C の認証確認用ユーザー作成
Azure AD B2C のページで、サイドメニューの ユーザー
> 新しいユーザー
へ移動し、必要な情報を入力して 作成
Azure AD B2C ユーザーフローの設定
ユーザーフローの作成
「Azure AD B2C」のページへ戻り、サイドメニューの ユーザーフロー
> 新しいユーザーフロー
> サインイン
> 推奨
> 作成
の順に移動。
必要な情報を入力して、 作成
今回は以下のように入力しました。
- 名前
- 任意のユーザーフロー名
- ID プロバイダー
- 「User ID signin」を選択
- 多要素認証
- 方法の種類
- 「メール」を選択(既定値)
- MFA の強制
- 「オフ」を選択(既定値)
- 方法の種類
- アプリケーション要求
- 「表示名」をチェック
言語の設定(オプション)
デフォルトではサインイン画面の言語が英語になっているため、日本語に変更します。
(英語のままで良い人はスキップしてください)
作成したユーザーフローが一覧に表示されるので、クリックして詳細画面へ移動します。
サイドメニュの 言語
> 言語のカスタマイズを有効化
をクリック。
日本語
をクリックし、「有効」と「規定」を「はい」に設定して 保存
ユーザーフローの設定情報取得用の 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/
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 のシングル サインオン セッションが残ったままとなるため、例えばサインアウト後にサインインページを表示させたい場合に、以下の様な挙動となりうまくいきません。
- SWA からサインアウト
- サインイン用の URL にリダイレクト
- Azure AD B2C 上のセッション情報を照会
- Azure AD B2C 上にセッションが存在するため、サインイン済みと判断されてサインイン後のページへ遷移
※「Azure AD B2C からのサインアウト用 URL」は以降「B2C サインアウト URL」と表記します
SWA のホスティングプラン変更
カスタム認証機能を有効にするためには、SWA のホスティングプランを Standard に変更する必要があります。
(Free プランでは残念ながら機能しません・・・諦めましょう 😢)
腹をくくったら、以下の手順でホスティングプランを変更してやりましょう。
静的 Web アプリ
> <デプロイしたアプリ名>
> ホスティングプラン
> ✔Standard
> 保存
SWA のアプリケーション構成
SWA から Azure AD B2C のユーザーフローにアクセスできるようにするため、Azure AD B2C で作成したアプリの アプリケーション ID と シークレット を、SWA のアプリケーション構成に登録します。
構成
> 追加
で名前・値を入力して OK
以下の値を登録します。
名前 | 値 |
---|---|
AAD_B2C_ClientID | アプリケーション ID の確認 でメモしたアプリケーション(クライアント)ID |
AAD_B2C_Client_Secret | アプリのシークレット作成 でメモしたシークレットの値 |
最後に 保存
ボタンをクリックするのを忘れずに。
認証プロバイダーとルーティングの設定を追加
ここからはローカルでの作業です。
SWA の構成ファイルに認証プロバイダーとルーティングの設定を追加します。
まずは最終的な形を載せておきます。後で分割して説明していきます。
{
"$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"
}
}
認証プロバイダーの設定
"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=<ユーザーフロー名> |
ルーティングの設定
"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 では同様の設定ができないため、ちょっと強引な感じもしますが、上記の方法で対応しました。
◎ 正規の方法を知っている方がいたら是非教えてください
未認証でアクセスした場合の設定
{
"responseOverrides": {
"401": {
"statusCode": 302,
"redirect": "/login"
}
}
}
未認証状態でアクセスするとレスポンスコード401
が返されるため、レスポンスコードをハンドリングしてサインイン画面へリダイレクトするようにしています。
サインアウトボタンの実装
サインアウトの確認もしたいので、作成したアプリにサインアウトボタンを追加しておきます。
// ...
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 にアクセスして動作確認します。
サインイン画面が表示されれば成功です!
この状態で API を叩いてみましょう。
レスポンスコード302
となり、API のレスポンスが返ってこないことが確認できます。
ではサインインしてみます。
Azure AD B2C の認証確認用ユーザー作成 で作成したユーザーのユーザー名/パスワードを入力して、サインイン
アプリのページが表示されました。
API のレスポンスも表示されているので API にアクセスできていることが確認できます。
次にサインアウトしてみましょう。
サインアウト
をクリックします。
サインイン画面に遷移しました!
以上で Azure AD B2C 認証の構築は完了です。お疲れさまでした!
【おまけ】認証情報の取得
サインイン済みの場合、アプリから認証情報を取得することができます。
クライアント側
クライアント側では、/.auth/me
にアクセスすることで取得できます。
JSON 形式のデータが返されます。
API 側
API 側では、リクエストヘッダーから取得することができます。
x-ms-client-principal
ヘッダーに認証情報を Base64 エンコードした値が入っているので、デコードして使います。
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"の値は含まれていないようで、取得できませんね。
おわりに
認証機能は普通に実装したら大変ですが、Azure 上のリソースの設定と、SWA の構成ファイルだけで実現することができてしまいました。
また、SWA のマネージド関数でAPIを作成すると API 側のアクセス制御も一緒にできてしまうので、とても楽です。
SWA の認証機能は他にも色々できることがあるので、今後試していきたいと思います。