OIDC のブラウザ連携は 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
認証リクエストの開始には CustomTabs を利用し、認証レスポンスの受け取り (redirect_uri) には AppLinks を利用しましょうと書いてあります。
DeepLink について
認証レスポンスの受け取りで 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 を完全に廃止することは難しいと思います。
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 が表示されないようになっています。
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);
}
private boolean shouldAvoidShowingDisambiguationPrompt(...) {
if (!mDelegate.shouldAvoidDisambiguationDialog(intentTargetUrl)) return false;
ResolveInfo resolveActivity = resolveActivitySupplier.get();
if (resolveActivity == null) return true;
return resolvesToChooser(resolveActivity, resolvingInfosSupplier.get());
}
if (shouldAvoidShowingDisambiguationPrompt(
isExternalProtocol, intentTargetUrl, resolvingInfos, resolveActivity)) {
return OverrideUrlLoadingResult.forNoOverride();
}
UrlUtilities.isAcceptedScheme は CustomTab が自身で解決できる scheme のホワイトリストになっています。当然 AppLinks である https は自前解決できる scheme なので true です。
そして resolvesToChooser で Intent を受け取れる候補にデフォルトで開く対象(startActivity した時に開かれる Intent)が存在するかをチェックしています。
この時 Android 12 前後で受け取る resolveActivity の実態が異なり、Android 12 以降では起動元のアプリの Activity が、未満では ResolverActivity が渡されます。
当然 ResolverActivity は Intent を受け取るわけではないためチェックは false です。そうすると forNoOverride が返り Intent は発行せずタブ内に留まりコールバックされません。
回避策としては以下
- CustomScheme との併用
- CustomTabsSession をがんばる
- 設定のデフォルトで開くを操作する
CustomScheme の場合はリストに入っていないので isAcceptedScheme が false となりこのチェックの限りではなくなるため受け取れるということになります。
また、さらに前段で packageName の解決を行っていますが、CustomTabsClient.setSession で setPackage を行っておりここで info が確定するためそもそも上記の Chooser 表示判定のロジックに入りません。
もしくは端末の設定からデフォルトで開くを直接操作すれば resolveActivity が差し変わるので AppLinks で受け取ることもできますが、その操作を慣れていないユーザーに求めるのは酷だと思います。