Flutter WebView ✖️ Firebase SAML認証で困った話
こんにちは、SHIFT DAAE(ダーエ)テクノロジーグループの大矢です。
SHIFT技術ブログに初参加してみます。よろしくお願いします。
さて、今回は私たちが社内で開発中のFlutterアプリにて、WebView経由で社内の認証基盤を使ったSAML認証フローを実装した際困った話と、その解決について書かせていただきます。
※ WebViewのパッケージはwebview_flutterを使っています。
はじめに
まずはじめに、作りたかった認証フローは以下のようなものです。
社内の認証基盤をIdPとして使うためユーザーがIdPを選択することはなく、全員が同じIdPを利用します。
SP(サービスプロバイダ)側のユーザー管理にはFirebase Authentication(以下Firebase Auth)を使っており、最終的にFlutterアプリ(Dartコード)側に返して欲しいトークンはFirebase Authのトークンです。詳しくは後述しますが、SPコールバックページが直接トークンを返さずSPサインインページにリダイレクトしているのはFirebase Authの仕様によるもので、この辺りも若干厄介だったりします。
Firebase Authのサインイン処理を実装したWebページはFirebase Hosting上にデプロイするものとします。
Firebase Authの機能とFirebaseのJavaScript向けライブラリを使ってSAML認証フローを構築する方法は以下に公式ガイドがあり、概ねこちらに沿っています。
当記事で動作確認したバージョン
Flutter v3.16.5
webview_flutter v4.5.0
課題1 SPサインインページからIdPサインインページへの自動リダイレクト
"はじめに"の図を見ていただくとわかる通り、SPサインインページへアクセスされると自動的にIdPサインインページへリダイレクトすることになっています。
通常のWebサイトであれば、ユーザーが「⚪︎⚪︎IdP(GoogleやAppleなど)を使ってサインインする」という旨のボタンをクリックすることなどでサインインフローを開始するのではないかと思いますが、今回の場合Flutterのアプリ画面上で「サインイン」ボタンを既に押している上IdPもひとつしかないので、もう一度WebView上でボタンを押させるのはいただけません。
そのため、SPサインインページが読み込まれるとすぐにIdPサインインページへ自動的にリダイレクトさせる必要があります。それ自体はもちろん簡単ですが、問題はIdPでの認証が終わって返ってきた後です。
Firebase AuthでSAMLの認証プロバイダーを設定してみるとわかりますが、SPコールバックページはFirebaseが自動的に用意してくれます。
(ドメインの変更方法は課題2で後述しますが、パス全体を変えることはできません)
このURLはIdPからのリダイレクトを受け取ったあと、Cookieに必要な情報を詰め込んでサインインページへリダイレクトしてきます。
あとはSPサインインページ内でユーザーのトークンを取得するなりすれば良いのですが、SPサインインページを「読み込まれたら即IdPサインインページへリダイレクト」とだけ思って実装していると、このタイミングでまたIdPサインインページへ飛んでいく無限ループにハマってしまいます。
以上から、この場合SPサインインページは最低限以下のような流れで実装する必要があります。
Firebaseのトークン取得を試みる -> 未ログインであればExceptionがスローされる
ログイン処理を行う
const firebaseConfig = {
// 省略
}
const app = initializeApp(firebaseConfig);
const auth = getAuth(app);
// signInWithRedirectの結果を取得する
getRedirectResult(auth)
.then((result) => {
try {
const token = {
idToken: result.user.getIdToken(),
refreshToken: result.user.refreshToken,
};
// tokenをDartコード側へ返す処理
} catch {
// 未認証のためログインを試行
login(auth);
}
})
.catch((error) => {
// handle error
});
function login(auth) {
const provider = new SAMLAuthProvider('SAML_PROVIDER_ID');
signInWithRedirect(auth, provider);
}
SAMLプロバイダーIDはコンソールから取得します(参考 )
セッションが残っている場合の話は後述しますので一旦置きますが、WebViewが開かれた時点ではサインインしていないはずなのでIdPサインインページにリダイレクトされ、IdPでの認証が終わり戻ってきたらトークンを取得してFlutterに返してくれることになります。
ちなみに、JavascriptからFlutterコードを呼び出す方法については以下記事を参考にさせていただきました。
こちらのコードを参考にサインインページで取得したトークンをDartコード側へ渡すJavascriptChannelを作成しましたが、以下記事の通りのため当記事では解説を割愛します。
課題2 リダイレクトして戻ってきたあとにトークンが取得できない
課題1で説明したように、SPサインインページが読み込まれるとIdPへのサインインが要求され、サインインが成功するとSPコールバックページ、SPサインインページへと順にリダイレクトされます。
次に期待する動作として、Firebaseのトークンが取得できるはずです。
当初、AndroidのWebViewではこの動作が実現しましたが、iOSのWebViewでは再び認証エラーとなってしまい困りました。
結論から言うと、SPサインインページのドメインとコールバックページのドメインが異なっていたことにより、Cookieの読み取りができないためでした。
Google ChromeとSafariでは(記事執筆時点では)ドメインを跨いだCookieの読み書きに対する制限に差があります。Safariではより厳しく、別のドメインで作成されたCookieを読み出すことができません。
Firebase公式ドキュメントでもこの問題に対するガイドが出ており、ここを見ることで多くのパターンが解決すると思われます。
※ ドキュメントに記載がある通り、将来的にはChromeもSafari同様の仕様になるそうです。
では、なぜFirebase Hostingを使ってSAML認証フローを構築したときに、「Cookieの書き込みと読み込みが別ドメインになってしまう」のかですが、私の場合これはかなり凡ミスの類になります。
まずFirebase Hostingではデフォルトのドメインとして、以下の2つが用意されています。
[プロジェクトID].web.app
[プロジェクトID].firebaseapp.com
エイリアスになっているのでどちらを使ってアクセスしても同じページに到達します。
一方で、SAML設定をした時に指定されるコールバックURLは、課題1でも図示した通りfirebaseapp.comの方です。つまり、SPサインインページへweb.appのドメインでアクセスした場合、firebaseapp.com(SPコールバックページ)で書いたCookieをweb.app(SPサインインページ)で読もうとすることになりSafariでは読めなくなるわけです。
上にリンクした公式ドキュメントの通りではありますが、この問題の回避方法は以下のようになります。
デフォルトのドメインを使う場合(コールバックURLにこだわりがない場合)
SPサインインページへのアクセスにfirebaseapp.comのドメインを使うカスタムドメインや、web.appのドメインを使いたい場合
SPサインインページを構成するコード内に指定するfirebaseConfigのauthDomainを変更する
→SPコールバックがhttps://指定したドメイン/__/auth/handlerに返るようになる
私のプロジェクトでは最終的にカスタムドメインを設定したため、2の対応を行なっています。その他、Firebase Hostingを使わない場合などは公式ドキュメントをご覧ください。
課題2までで、未サインイン状態からSPサインインページを開き、IdPサインインを経由してFirebaseのトークンを得ることができました。
当記事での解説は行いませんが、以降はFirebaseのREST APIを利用してFlutterコード内だけでIDトークンを更新することもできます。
課題3 認証後のサインアウト処理
※ 課題3の2は、あくまで私のプロジェクトで採用したIdPを使った際の挙動です。参考にされる際は十分にテストをしてご利用ください。
次の課題は、何らかの理由で再認証をさせたい時にWebViewでサインインページを開き直した時、以前の認証時のセッションをきちんと破棄して再認証させられるかどうかという点です。
なお、UXの観点から再認証時に以前のセッションが残っていれば自動的にトークン取得してよいという切り分けは可能かもしれませんが、当記事では必ずIdPのサインインやり直しが必要(ID、パスワード等の再入力が必要)とします。
この課題の対応には認証の都度、以下の2点の対応が必要です。
WebView上でFirebase Authからサインアウトする
WebView上でIdPのセッションを破棄する
1. WebView上でFirebase Authからサインアウトする
Firebaseの提供するfirebase.auth().setPersistenceメソッド で実行可能です。
ドキュメントの例のようにbrowserSessionPersistenceを指定した場合、タブ(WebView)が閉じられた時にセッションが自動的にクリアされます。
課題1のコードを書き直すと、以下のようになります。
const firebaseConfig = {
// 省略
}
const app = initializeApp(firebaseConfig);
const auth = getAuth(app);
// [追加] setPersistenceでブラウザを閉じたときに自動ログアウトするよう指定
setPersistence(auth, browserSessionPersistence).then(() => {
// signInWithRedirectの結果を取得する
getRedirectResult(auth)
.then((result) => {
try {
const token = {
idToken: result.user.getIdToken(),
refreshToken: result.user.refreshToken,
};
// tokenをDartコード側へ返す処理
} catch {
// 未認証のためログインを試行
login(auth);
}
})
.catch((error) => {
// handle error
});
});
function login(auth) {
const provider = new SAMLAuthProvider("SAML_PROVIDER_ID");
signInWithRedirect(auth, provider);
}
この対応を入れるだけで次回WebViewを開いたときFirebase Authからはサインアウトしているのですが、IdPからのサインインができていない(場合がある)ため、WebViewを開く -> IdPサインインページへリダイレクトされる -> 残存しているIdPのセッションを利用して自動的にサインインする -> (中略) -> Firebaseのトークンが返されるという動きになる可能性(※)があります。
※ 可能性としているのはIdP側のセッションの有効期限を過ぎている場合はFirebase AuthからのサインアウトだけでIdP再認証が必要になることもあり得るからです。
先述の通り要件は「必ずIdPのサインインやり直しが必要(ID、パスワード等の再入力が必要)」ですので、もう一方の対応を行う必要があります。
2. WebView上でIdPのセッションを破棄する
こちらも実装自体は極めて簡単で、Dartコード側でWebViewを開く前に以下のメソッドを実行するだけです。
Cookieに保存されているIdPのセッション情報を破棄します。
WebViewCookieManager().clearCookies();
以上で、前回のサインイン時のセッションを破棄し、IdPへのサインインやり直しを確実に要求することができます。
実はハマったところ
実は当初、少し違う書き方をしていました。
Dart側でWebViewを開くとき、WebViewControllerのインスタンスを作って読み込むURLや先述のJavaScriptChannelの設定などを行う必要がありますが、そのWebViewControllerのメソッドにclearLocalStorage() というものがあります。
メソッドのコメントに/// Clears the local storage used by the WebView.とある通り、Local Storageをクリアするメソッドです。
AndroidのWebViewの場合、先述のWebViewCookieManager().clearCookies()とWebViewController().clearLocalStorage()をどちらもWebViewを開く前に実行すると、Firebase AuthからもIdPからもサインアウトするという挙動でした。
しかし、この実装のままiOSで実行してみると2回目以降は即座にFirebaseのトークンが返される、つまりFirebase Authからのサインアウトができていない挙動になりました。
原因の精査は行なっていませんが、ChromeとSafariでLocal Storageの削除できる範囲が異なるのか、Firebase Authのセッションデータの保存のされ方が異なるのか、何らかの差があるのだと思われます。
Firebaseに慣れている読者の方はこのような実装はそもそもされないと思いますが、セッションを破棄するには先に述べた1,2の対応をする必要があります。
おわりに
以上、今回初めてSHIFT技術ブログに参加させていただきました。
個人的に今回の記事の内容は事前知識や経験がほとんどない中でやってみたもので、手探りの中少し時間をかけて作ったものでした。
おそらく慣れている方、経験豊富な方には簡単な内容だったりより良いやり方もあるものかと思いますが、私同様にFirebase AuthやSAML認証に初めて触れる方の一助となれば幸いです。
最後に、内容が参考になった方はぜひいいねボタンをお願い致します。
\もっと身近にもっとリアルに!DAAE公式Twitter/
お問合せはお気軽に
https://service.shiftinc.jp/contact/
SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/
SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/
SHIFTの導入事例
https://service.shiftinc.jp/case/
お役立ち資料はこちら
https://service.shiftinc.jp/resources/
SHIFTの採用情報はこちら
https://recruit.shiftinc.jp/career/
PHOTO:UnsplashのPankaj Patel