こんにちは、サイオステクノロジーの佐藤 陽です。
今回はAzure Static Web AppsとAADB2Cの組み合わせにおけるEasyAuthの検証を行いたいと思います。
前回、こちらのブログでAzure App ServiceとAADB2Cの組み合わせにおけるEasyAuthの検証を行いました。
検証の流れは前回と同様に行いますので、是非前回のブログを読んでからこちらもご覧ください!
なお、前回からの繰り返しになりますが
※本記事は独自の検証に基づいた考察であるため、正当性を保証するものではありません、ご了承ください。
また、今回もこちらのブログを非常に参考にさせていただきました。
Abstract
結論から言うと、StaticWebAppsの場合はこんな構成になってました。
また、クライアントプリンシパルデータというオブジェクトが用意されており
このオブジェクトと、各Cookie群を使ってSPAでもセキュアな認証・認可を実現しているようです。
はじめに
StaticWebAppsとは、静的サイトをデプロイするためのAzrureのリソースであり、
ReactやVue.jsなどのSPA(Single Page Application)のアプリを扱う際に利用されます。
またCI/CD機能と非常に親和性の高いPaaSとなっており
こちら
や、こちら
でも紹介していますので、是非ご覧ください!
AppServiceとStaticWebAppsは何が違うの?
AppService | ConfidentialClientApplication |
StaticWebApps(SPA) | PublicClientApplication |
の違いがあります。
あまりここの説明に自信ないのですが、何が違うかというと
ConfidentialClientApplicationであるAppServiceにデプロイするアプリは、サーバー上で動作しています。
そのサーバーに対してリクエストを発行することによりレスポンスが返ってきます。
そのため、クライアントからしたら中身がどう動いているかは分かりえない(=Confidental)です。
一方、PublicClientApplicationであるSPA(StaticWebApps)は、ブラウザでアプリケーションを動作させます。
DevToolの「ソース」項目からソースコードも確認できますね。
また、SPAの他にもスマホのアプリやデスクトップアプリなどもPublicClientApplicationに分類されます
「これらはソースコード見えないじゃないか!」と思われるかもしれませんが
「ソースコードが見える=PublicClientApplication」という訳ではなく
「機密情報を安全に管理できない=PublicClientApplication」ということになります。(おそらく。)
スマホアプリや、デスクトップアプリもバイナリ解析とがゴリゴリやれば機密情報アクセスできる!
ということでこれらもPublicClientApplicationに分類されるようです。
では、その「機密情報とは何か?」というと、今回の記事に関連して言うと認証・認可のフローで扱う情報です。
例えば、ClientSecretでしたり、AccessTokenでしたり、IDTokenが該当します。
つまり、OAuthのフローでSPAがアクセストークンを受け取った場合、SPAを解析することでアクセストークンを取得できてしまうのです。
アクセストークンは外部のAPIを叩くために必要であり、これが見れてしまうのはセキュリティ的に問題ですね。
そのため、SPAでこれらのトークン情報をどう扱うか・どこに保管するか、等に関してはたびたび議論となっています。
この問題に関する言及は、本記事では避けるため、気になる方は調べてみてください。
では本題に戻りますが
そんなトークンの扱いを慎重に行わなければならないSPAを扱うStaticWebAppsのEasyAuth。
どのようにして認証・認可周りを実装しているのでしょうか?
EasyAuth
まずはとにもかくにもEasyAuthの設定を行います。
今回も設定方法に関しては以下のドキュメントを見れば十分かと思うので省略します。
参照:Azure AD B2C を使って Azure Static Web アプリで認証を構成する
AppServiceの時と異なり、Portal上でポチポチするというよりは、staticwebapp.config.jsonファイルを修正する形ですね。
今回、”auth”の項目だけ設定していれば問題ないかと思いますが、スムーズに検証を行うために以下の項目も付け足しました。
内容としては、アプリへのアクセス時、ログインしていなかった場合にログイン画面へリダイレクトするルーティング設定です。
また、後ほど解説します。
{
"routes": [
{
"route": "/*",
"allowedRoles": ["authenticated"]
}
],
"responseOverrides": {
"401": {
"statusCode": 302,
"redirect": "/.auth/login/aadb2c"
}
},
"auth": {
"identityProviders": {
"customOpenIdConnectProviders": {
"aadb2c": {
"registration": {
"clientIdSettingName": "AADB2C_PROVIDER_CLIENT_ID",
"clientCredential": {
"clientSecretSettingName": "AADB2C_PROVIDER_CLIENT_SECRET"
},
"openIdConnectConfiguration": {
"wellKnownOpenIdConfiguration": "https://{tenant-name}.b2clogin.com/{tenant-name}.onmicrosoft.com/{user-flow-name}/v2.0/.well-known/openid-configuration"
}
},
"login": {
"nameClaimType": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier",
"scopes": [],
"loginParameterNames": []
}
}
}
}
}
}
}
検証
では実際に検証してきます。
冒頭にも書きましたが、前回のブログに沿った形で検証していくため、細かい検証方法などは省きます。
前回のブログを読んでからこちらをご覧いただくことをお勧めします。
ログイン
まずは、アプリにアクセスします。
すると、ログイン画面に自動遷移するかと思います。
これは上記したroutesの設定によるもので
アプリのルートへのアクセスは”authenticated”のRoleのみが許可されており、
許可されない場合は、ログインURLである”/.auth/login/aadb2c“へ飛ばされるためです。
ではログインしてみましょう。
ログイン完了したらアプリの方が表示されます。
ここで前回同様
GET /.auth/me
を実行します。
すると、ユーザー情報が取得できているのが確認されます。
ただ前回と少し、形式が違うようです。
この形式はクライアントプリンシパルデータと呼ばれるものだそうで
/.auth/meにGET要求を送信することで、このオブジェクトへの直接アクセスを受け取ることができます。
にも書いてありましたが、どうやらStaticWebAppsはTokenStoreの仕組みを使っていないようです。
確かにAppServiceでは認証設定の場合にTokenStoreに関する設定がありましたが
StaticWebAppsの場合は、staticwebapp.config.jsonにそう言った項目が見られませんでした。
セッション確認
まずアプリセッションに関してですが、以下のようなものが表示されていました。
“AppServiceAuthSession“に関しては、前回のブログでも登場しましたね。
今回はそれに加えて”StaticWebAppsAuthCookie“というものが見られました。
いかにもStaticWebAppに特化したものにみえますね。
また、こちらは名称がSessionではなくCookieとなっている点も異なっています。
次にAADB2Cセッションも確認します。
https://{tenant-name}.b2clogin.com/
へ移動し、DevToolで確認しましょう
こちらはAppServiceと同様のx-ms-cpim-ssoのCookieが確認できました。
動作確認
この3つのセッションもしくはCookieに関して、挙動を確認していきたいと思います。
前回同様、それぞれのCookieを削除し、以下の2点を確認します
- /.auth/meを実行した際のレスポンス
- StaticWebAppsにデプロイしたアプリにログインできるか
StaticWebAppsAuthCookieを削除
DevToolからStaticWebAppsAuthCookieを除外し、/.auth/meを実行します。
StaticWebAppsAuthCookie | なし |
AppServiceAuthSession | あり |
x-ms-cpim-sso | あり |
先ほど取得できていたclaimsPrincipalの値がnullになっていることが確認できます。
claimsPrincipalの値を取得するには、このCookieが必要になるようです。
ここで、再度アプリにアクセスします。
すると、問題なくアプリにアクセス可能となり、StaticWebAppsAuthCookieも復活していることが確認できます。
ここで./auth/meを実行すると、正しくclaimsPrincipalの値も取得できました。
では、AppServiceの時と同様、AADB2CセッションによってCookieが復活するのでしょうか?
参照: Azure AD B2C を使って Azure Web アプリで認証を構成する
AADB2Cのセッションを除外したのちに、再度StaticWebAppsAuthCookieを削除し、動作確認します。
StaticWebAppsAuthCookie | なし |
AppServiceAuthSession | あり |
x-ms-cpim-sso | なし |
すると、先ほどと同様にclaimsprinciaplの値はnullになっています。
ここで再度アプリにアクセスすると、問題なくアプリにアクセス可能となり、StaticWebAppsAuthCookieも復活しています。
もちろん/.auth/meでclaimsprinciaplの値も取得できました。
このことから、例えAADB2Cセッションが無いとしてもStaticWebAppsのアプリにアクセスすることでclaimsPrincipalは再取得できるようです。
AppServiceAuthSessionを削除
再度ログインし直し、全てのCookieが揃っている状態にします。
DevToolからAppServiceAuthSessionを除外し、/.auth/meを実行します。
StaticWebAppsAuthCookie | あり |
AppServiceAuthSession | なし |
x-ms-cpim-sso | あり |
すると、レスポンスは取得できました。
…が、なんか少しレスポンスが先ほどに比べて少ないですね。
claimsPrincipalの値は存在していますが、claimsの項目が存在しないようです。
各Cookieで取得する内容が決まっている…といった感じでしょうか?
- StaticWebAppsAuthCookie:identityProvider, userId, userDetails, userRoles
- AppServiceAuthSession:claims(※StaticWebAppsAuthCookieがある場合に限る)
次にこの状態からアプリをリロードしてみます。
AppServiceAuthSessionは復活しませんでした。
AppServiceの時は、B2CのSSOセッションが残っていればAppServiceAuthSessionが復活したのですが
StaticWebAppsの時の挙動は異なるようです。
もちろん、状況としては変わっていないため、/.auth/meの挙動も変わらずです。
StaticWebAppsAuthCookieとAppServiceAuthSessionを両方削除
再度ログインし直し、全てのCookieが揃っている状態にします。
次に、アプリセッションのクッキーを両方消してみます。
StaticWebAppsAuthCookie | なし |
AppServiceAuthSession | なし |
x-ms-cpim-sso | あり |
/.auth/meを実行すると、claimsPrincipalはnullが返ってきました。
これはStaticWebAppsAuthCookieが無いことから予想できました。
次に、アプリをリロードします。
するとStaicWebAppAuthCookieもAppServiceAuthSessionも再度生成されました。
/.auth/meでclaimsPrincipalもフルで取得できますね。
ここでresponseの中身を見てみると、authtimeの値が前回取得したものから更新されていることに気が付きました。
どうやらこのケースでは再認証が行われたようです。
先ほど載せたAADB2Cのドキュメントに以下の文章がありますが、
ID トークンが有効期限切れになった場合、またはアプリ セッションが無効になった場合、Azure Web アプリは新しい認証要求を開始し、ユーザーを Azure AD B2C にリダイレクトします。 Azure AD B2C SSO セッションがアクティブである場合、Azure AD B2C は、ユーザーに再度サインインを促さずにアクセス トークンを発行します。 Azure AD B2C セッションが有効期限切れまたは無効になった場合、ユーザーは再度サインインするように促されます。
「アプリセッションが無効になった場合」というのは、「StaticWebAppsAuthCookieとAppServiceAuthSessionの両方が無効になった場合」を指すことが推察されます。
また、前回も検証しましたが
Azure AD B2C セッションが有効期限切れまたは無効になった場合、ユーザーは再度サインインするように促されます
とあるように、
AADB2Cのポータル上で設定したセッション有効期限を過ぎたタイミングで、両Cookieを除外しアプリをリロードするとサインイン画面に飛ばされることを確認しました。
AADB2Cセッションを削除
次にAADB2Cセッションの挙動を確認します。
再度ログインし、Cookieが全て揃ってる状態にした後、AADB2Cのセッションを削除します。
StaticWebAppsAuthCookie | あり |
AppServiceAuthSession | あり |
x-ms-cpim-sso | なし |
その後/.auth/meを実行すると、フルで内容が取れることが確認できます。
また、この状態で
- StaticWebAppsAuthCookieを削除し、アプリをリロードするとCookieが再生成されること
- AppServiceAuthSessionを削除し、アプリをリロードしてもCookieが再生成されないこと
も確認できました。
この辺りは、これまで見てきた検証の中で分かっていたことです。
また、AADB2Cセッション存在しない状態でアプリセッションも両方削除します。
StaticWebAppsAuthCookie | なし |
AppServiceAuthSession | なし |
x-ms-cpim-sso | なし |
この状態で、/.auth/meを実行するとnullのレスポンスが返され、
アプリをリロードすると、(configファイルのRoute設定によって)ログイン画面へと遷移しました。
これも、これまでの検証の中である程度見えてきていたかと思います。
1点気になったのが
AppServiceの場合は、ログインしていない状態で/.auth/meを実行したらログイン画面へ遷移しましたが
StaticWebAppsのEasyAuthの場合は、nullが返ってくることも違いますね。
ログアウト
ログイン時の処理に関しては一通り確認できたため、ログアウト処理を見てみたいと思います。
StaticWebAppsの場合でもログインのエンドポイントとして、/.auth/logoutが提供されています。
早速実行してみたいと思います。
ログインし、すべてのCookieが揃っている状態で
GET /.auth/logout
を実行します。
すると、画面的に何も変化がないように見えます。
実はDevToolでCookieの値を見てみると/.auth/logout実行時に、Cookieの値が書き変わっていることが分かります。
つまり、アプリセッションが削除→再生成されているのです。
今回、staticwebapp.config.jsonの設定によって承認されていないユーザはログインページに飛ばされます。
そのため
- /.auth/logoutでアプリセッションが2つとも破棄される
- 未承認状態となりログインページへ飛ばされる
- AADB2Cセッションがまだ残っているため、自動的にログインされる
- アプリセッションが再確立され、承認状態でページが表示される
という一連の流れが行われていると考えられます。
これを実際に確認するため、staticwebapp.config.jsonのRoute設定で、未承認の状態でもアクセスできるよう変更します。
この状態で/.auth/logoutを実行したところ、アプリセッションが両方とも破棄されることが確認できました。
もちろんこの時、claimsPrincipalの値はnullが返ってきます。
また、アプリをリロードすると再度アプリセッションが確立され、再度ログイン状態となることも確認できました。
前回のブログのAppServiceのEasyAuthの流れと同じ感じですね。
なので、AppServiceと同じようにアプリセッションと同時に、AADB2Cセッションも破棄するような流れを実装してみます。
復習になりますが、AADB2CセッションのログアウトURLはこちらになります。
https://{tenant-name}.b2clogin.com/{tenant-name}.onmicrosoft.com/{userflow-name}/oauth2/v2.0/logout?post_logout_redirect_uri={encoded app name}
こちらを実行すると、AADB2Cセッションが破棄されていることが確認できます。
次に、/.auth/logoutのpost_logout_redirect_uriクエリに与えて実行したいと思います。
ただ、AppServiceで設定したようなallowed-external-redirect-urlsの設定項目がありません。
staticwebapp.config.jsonのSchema一覧を見ても見つかりませんでした。
ひとまずそのままを実行してみます。
https://{app-name}.2.azurestaticapps.net/.auth/logout?post_logout_redirect_uri={encoded B2C logout url}
すると、CORSエラーが表示されてしまいます。
このCORSエラーの解消方法なのですが、まだ解決策が見つかっておらず現在調査中です…。
AADB2Cのカスタムドメインとかそのあたりを使えば解消できそうな気はしてますが、
もう少しライトに解消する方法は無いかなと思っています。
まとめ
今回は、前回のブログに引き続きAzure Static Web AppsとAADB2Cの組み合わせにおけるEasyAuthを調査してみました。
AppServiceと似たような感じにも見えますが、細かい部分では仕様が異なっていました。
一番大きな部分でいうと、StaticWebAppsAuthCookieといったCookieの登場でしょうか。
このCookieの登場によりAppServiceAuthSessionの振る舞いも少し前回と異なっていました。
また、SPAがPublicClientApplicationということで、機密情報をアプリ上で持たないようにする必要がありますが
StaticWebAppsの場合は、クライアントプリンシパルデータオブジェクトと、それを取り巻くCookie群を利用してうまくやっているようです。
最後のリダイレクトの部分は煮え切らない状態で終わってしまいましたが、1発でログアウトする方法に関してはまた調べていきたいと思います。
また、冒頭にも書きましたが
独自の検証に基づいた考察であるため、正当性を保証するものではありません。
誤りや、疑問点があった場合にはコメントなどでご指摘いただけると幸いです。