OIDC のブラウザ連携は Auth Tab を使おう
Auth Tab がリリースされたことはご存知でしょうか?
OIDC の実装において Custom Tab を利用することがデファクトだったと思いますが、Custom Tabs が PiP に対応してきたりして方向性が異なってきたため無理が生じてきたようです。そこで認証に特価した Auth Tab が登場しました。
ここでは Custom Tabs と比較した Auth Tab の利用方法やメリットをご紹介します。
OAuth 2.0 for Native Apps
まずは RFC で定められている OIDC における Android アプリの実装についておさらいします。
Apps can initiate an authorization request in the browser, without the user leaving the app, through the Android Custom Tab feature, which implements the in-app browser tab pattern.
To receive the authorization response, private-use URI schemes are broadly supported through Android Implicit Intents. Claimed "https" scheme redirect URIs through Android App Links are available on Android 6.0 and above.
https://datatracker.ietf.org/doc/html/rfc8252#appendix-B.2
App-claimed "https" scheme redirect URIs have some advantages compared to other native app redirect options in that the identity of the destination app is guaranteed to the authorization server by the operating system. For this reason, native apps SHOULD use them over the other options where possible.
https://datatracker.ietf.org/doc/html/rfc8252#section-7.2
認証リクエストの開始には CustomTabs を利用し、認可レスポンスの受け取り (redirect_uri) には https scheme(AppLinks) を利用しましょうと書いてあります。
DeepLink について
Android における DeepLink は以下のようなフローを辿りブラウザからアプリに遷移することが可能です。
graph LR
Start(["ユーザーがリンクをクリック<br/>Web, SNS, メールなど"])
OS_Intent["Android OS が Intent を発行<br/>ACTION_VIEW"]
Check_Scheme{"リンクのスキームは?"}
Check_AppLinks{"App Links の検証<br/>assetlinks.json"}
Check_Apps{"対応するアプリは?"}
Open_App(["対象のアプリが開く"])
App_Chooser["アプリ選択ダイアログ<br/>App Chooser が表示"]
Open_Browser(["Web ブラウザで開く"])
Fail(["遷移失敗"])
Start --> OS_Intent
OS_Intent --> Check_Scheme
%% HTTP/HTTPS の場合: verified か否かの二択。チューザは出ない
Check_Scheme -->|"http / https"| Check_AppLinks
Check_AppLinks -->|"検証成功 (Verified)"| Open_App
Check_AppLinks -->|"未設定 / 検証失敗"| Open_Browser
%% カスタムスキームの場合: アプリの有無・数で分岐
Check_Scheme -->|"カスタムスキーム<br/>例: myapp://"| Check_Apps
Check_Apps -->|"1つのみ"| Open_App
Check_Apps -->|"複数"| App_Chooser
Check_Apps -->|"なし"| Fail
App_Chooser -->|"アプリを選択"| Open_App
認可レスポンスの受け取りで Android において利用可能な DeepLink の実装方式には AppLinks と Custom URL Scheme があります。
| 方式 | scheme | 補足 |
|---|---|---|
| AppLinks | https://example.com | Web ドメインの所有者の検証を行う |
| Custom URL Scheme | myapp://example.com | セキュリティ上の懸念から推奨されていない |
Custom URL Scheme は AndroidManifest に定義するだけで簡単に実装でき scheme の一意性が保証されないため他のアプリがジャック可能という脆弱性を持っています。それに比べて AppLinks では assetlinks.json を通してドメインの所有権を検証するためよりセキュアにweb <-> アプリ間の疎通が可能です。
しかし AppLinks はブラウザの実装に大きく依存します。リンクを読み込む際に web としてそのまま HTML をロードするのか OS に処理を委譲するのかをブラウザが実装する必要があり、必ずしもアプリで AppLinks が受け取れるわけではありません。なのでフォールバック手段として Custom URL Scheme を完全に廃止することは難しそうです。
また iOS の DeepLink の方式としては AppLinks と同等の Universal Link が利用されています。
Custom Tabs における DeepLink の挙動
さらにやっかいなことに Custom Tabs で開いたページから DeepLink でコールバックを受け取る際、API level によって AppLinks が受け取れないことがあります。
| API level | AppLinks | Custom URL Scheme |
|---|---|---|
| ≧ 31 | ○ | ○ |
| < 31 | × | ○ |
※ 設定を操作すれば回避は可能
Android 12 から Web Intent の仕様が厳格化され verified であれば強制的にアプリで開き、そうでない場合はブラウザで開こうとします。逆に Android 12 未満の場合は Intent Chooser (ResolverActivity) が表示されます。
しかし、Custom Tabs の実装では https の場合 Chooser が表示されないようになっています。
if (shouldAvoidShowingDisambiguationPrompt(
isExternalProtocol, intentTargetUrl, resolvingInfos, resolveActivity)) {
return OverrideUrlLoadingResult.forNoOverride();
}
private boolean shouldAvoidShowingDisambiguationPrompt(...) {
if (isExternalProtocol) return false;
if (!mDelegate.shouldAvoidDisambiguationDialog(intentTargetUrl)) return false;
ResolveInfo resolveActivity = resolveActivitySupplier.get();
if (resolveActivity == null) return true;
return resolvesToChooser(resolveActivity, resolvingInfosSupplier.get());
}
public boolean shouldAvoidDisambiguationDialog(GURL intentDataUrl) {
// Don't show the disambiguation dialog if Chrome could handle the intent.
return UrlUtilities.isAcceptedScheme(intentDataUrl);
}
/**
* Checks if the intent is resolving to the ResolverActivity, which means the {@code
* resolvingInfos} do not contain the result of {@code resolveActivity}.
*/
public static boolean resolvesToChooser(
ResolveInfo resolveActivity, List<ResolveInfo> resolvingInfos) {
return !resolversSubsetOf(Arrays.asList(resolveActivity), resolvingInfos);
}
これらは Custom Tabs の Intent 解決の判定コードです。
UrlUtilities.isAcceptedScheme は Chrome が描画できる scheme のホワイトリストになっており、当然 AppLinks である https は true です。
そして resolvesToChooser で intent-filter が設定された Intent が受け取り可能な候補 (resolvingInfos) の中にデフォルトで開く対象(startActivity した時に開かれる Intent)が存在するかをチェックしています。 この時 Android 12 前後で受け取る resolveActivity の実態が変わります。
| API level | 検証済み | 未検証 |
|---|---|---|
| ≧ 31 | リンクを処理するアプリの Activity | ブラウザの Activity |
| < 31 | ResolverActivity | ResolverActivity (そもそも検証がない) |
ResolverActivity は intent-filter を設定していないためチェックは false です。そうすると forNoOverride が返り Intent は発行されずタブ内に留まりコールバックされません。
要は曖昧な暗黙的 Intent を発行するくらいなら何もしないのです。
回避策としては以下
- Custom URL Scheme との併用
- 設定のデフォルトで開くを操作する
Custom URL Scheme の場合はホワイトリストに入っていないので isAcceptedScheme が false となりこのチェックの限りではなくなるため受け取れるということになります。
もしくは端末の設定から デフォルトで開く を直接操作すれば resolveActivity がアプリの Activity に差し変わるので AppLinks で受け取ることもできますが、その操作を慣れていないユーザーに求めるのは酷だと思います。
[!info] CustomTabsSession でがんばる さらに前段で packageName の解決を行っており、
CustomTabsIntent.Builder.setSessionをセットすると Binder により呼び出し元が保証されるためそもそも上記の Intent 解決のロジックに入りません。 ※ ただし Chrome 140 以降が対象です。それ以前の Chrome では判定条件が異なり受け取れません。140 からGoogle アプリ用に用意されていたであろう迂回経路が全アプリを対象に切り替わったようです。
ちなみに iOS における Universal Link にも同様の OS ver による受け取りの挙動差異があるようです。
また Custom Tabs はジェスチャーのないリダイレクトを阻止することもあり、それが原因で AppLinks ではコールバックを受け取れない場合もあります。なので AppLinks が失敗しても問題ないように Custom Url Scheme にフォールバックするためのボタンを置いた HTML を用意しておくと安心です。
Auth Tab
そのあたりのツラミを解消するために Auth Tab があります。
https://developer.chrome.com/docs/android/custom-tabs/guide-auth-tab?hl=ja
実装はこんな感じです。コールバックを ActivityResult で受け取れるようになっています。
class LoginActivity : AppCompatActivity() {
private val launcher = AuthTabIntent.registerActivityResultLauncher(this) {
when (it.resultCode) {
AuthTabIntent.RESULT_OK -> handleCallback(it.resultUri) // コールバックの受け取り
AuthTabIntent.RESULT_CANCELED -> // ユーザー操作などにより閉じられた場合
AuthTabIntent.RESULT_VERIFICATION_FAILED -> // ドメイン検証に失敗
}
}
fun launchAuthorization(uri: Uri) {
AuthTabIntent
.Builder()
.build()
.launch(launcher, uri, "host", "path")
}
}
Auth Tab による起動は呼び出し元の Activity が確定しているため Custom Tabs が行っていた Intent の解決を行わずに直接呼び出し元の Activity にコールバックを返します。なので普通に launch するだけでも API level に関係なく https scheme の redirect_uri で受け取ることが可能です。
また Auth Tab はデフォルトで PiP が無効です。Custom Tabs では setInitialActivityHeightPx(Int.MAX_VALUE) とかで実質無効化してがんばっていたと思いますが必要なくなります。
その他にもメニューがかなり簡素になっておりユーザーの取れる操作が制限され安心して認証フローに専念できます。
Auth Tab のコールバックは Android の DeepLink を満たしていない
Android における DeepLink のパラダイムは 特定の Uri をトリガーとして OS が Intent のシステムを介してアプリを判定し遷移させる機能 です。
https://developer.android.com/training/app-links?hl=ja
ですが Auth Tab はコールバックに Intent を発行しません。ただ呼び出し元の Activity に Result を返すのみで単純にアプリ内のルーティングと同じです。 なので Web 全体の広義の意味で DeepLink ではあるかもしれませんが、Android においては DeepLink の定義を満たしていません。もちろん Intent が介入しないためジャックの危険性もなくよりセキュアに遷移が可能です。
ちなみにドメイン検証のため assetlinks.json は必要ですが Android DeepLink ではないため Manifest に intent-filter の記述も必要ありません。
ただし完全に頼り切ることは難しい
結局のところブラウザの実装によるため Auth Tab の処理をブラウザがサポートしているかが必要となってきます。 そのため Custom Tabs へのフォールバックはどうしても必要となってきます。幸い Auth Tab をサポートしているかは判定できるので分岐させることは可能です。
fun launchAuthorization(uri: Uri) {
val isAuthTabSupported = CustomTabsClient.getPackageName(this, null)?.let { packageName ->
CustomTabsClient.isAuthTabSupported(this, packageName)
} ?: false
when (isAuthTabSupported) {
true -> authTabIntent.launch()
false -> customTabIntent.launch()
}
}
AuthTabIntent でそのまま起動してもブラウザがサポートしていなければ Custom Tabs に勝手にフォールバックされるので問題ありませんが、Cancel などで何か処理を挟んでいる場合 onNewIntent と result はどちらも実行され呼び出し順は担保されないので正しくコールバックされたのに Cancel の処理が実行されることがあったりします。あまりオススメはしません。
最後に
DeepLink は人類には早すぎた
実際には OEM によってはセキュリティアプリのせいで AppLinks を無視したり、特定のブラウザでは正しく AppLinks によるコールバックが受け取れず onNewIntent ではなく Intent が空の onCreate が常に呼び出されたりします。onCreate で Auth Tab を起動していようものなら無限リダイレクト編に突入します。
特に Android 界隈はデバイスやブラウザの掛け合わせで魔境と化しているため全てを救うことは難しいですね。。。
どうしてサービスの基盤である id 周りの仕様にこんな動作の保証されていない不安定な技術を必要としたのか...単純に API 公開してネイティブ画面使った方が安全なのでは... と思いますが IdP の性質上様々なプラットフォームから呼ばれることを考慮すると仕方ないのかなとも思います。
せめてベンダーがちゃんとガードレールを敷いてくれれば...