|
| 1 | +# ADR-0033: Suppress Passkey Detection in the Login WebView |
| 2 | + |
| 3 | +## Status |
| 4 | + |
| 5 | +Accepted |
| 6 | + |
| 7 | +## Context |
| 8 | + |
| 9 | +Kaset signs users in by loading `accounts.google.com` in a `WKWebView` and |
| 10 | +capturing the resulting Google session cookies. When an account has passkeys, |
| 11 | +Google's sign-in page offers passkey authentication, and that ceremony can |
| 12 | +never succeed inside Kaset's WebView (issue #291): |
| 13 | + |
| 14 | +- `WKWebView` exposes the WebAuthn API (`window.PublicKeyCredential`), and |
| 15 | + `PublicKeyCredential.getClientCapabilities()` even reports |
| 16 | + `hybridTransport: true`, so Google's page treats the WebView as a |
| 17 | + passkey-capable browser. |
| 18 | +- However, the system authorizes passkey ceremonies only for apps that hold |
| 19 | + the restricted `com.apple.developer.web-browser.public-key-credential` |
| 20 | + entitlement, or for relying parties covered by the app's |
| 21 | + `webcredentials` Associated Domains. Every `navigator.credentials.get()` |
| 22 | + call from Kaset is rejected almost instantly with `NotAllowedError`, with |
| 23 | + no system UI and no Bluetooth activity. |
| 24 | +- Google interprets that failure as a broken cross-device (hybrid) attempt |
| 25 | + and renders the "Something went wrong — check that Bluetooth is on and the |
| 26 | + devices are close together" error screen. The advice is unsatisfiable; the |
| 27 | + only escape is "Try another way" and a manual password entry. |
| 28 | + |
| 29 | +Neither supported path to real in-WebView passkeys is available to Kaset: |
| 30 | + |
| 31 | +- The browser entitlement is an Apple-managed capability granted after review |
| 32 | + only to apps that act as general-purpose web browsers (URL field, search, |
| 33 | + bookmarks), requested by the Account Holder of an organization developer |
| 34 | + account, and it must be authorized by an embedded provisioning profile. |
| 35 | + A YouTube Music client does not meet the criteria, and the profile |
| 36 | + requirement is incompatible with Kaset's ad-hoc and Apple Development |
| 37 | + signing fallbacks. |
| 38 | +- Associated Domains would require Google to list Kaset's app identifier in |
| 39 | + the `google.com` AASA file, which will not happen. |
| 40 | + |
| 41 | +Moving login to the system browser (or `ASWebAuthenticationSession`) does not |
| 42 | +fit either: Kaset needs the Google session cookies to land in its own |
| 43 | +`WKWebsiteDataStore`, an authentication session only returns a callback URL, |
| 44 | +and importing cookies from another browser is both fragile (Google invalidates |
| 45 | +sessions reused across client fingerprints) and indistinguishable from |
| 46 | +credential-stealing behavior. |
| 47 | + |
| 48 | +Shipping WKWebView apps in the same situation (for example the Nook browser |
| 49 | +while awaiting the entitlement, and several Claude/Google wrapper apps) |
| 50 | +converge on the same mitigation: hide WebAuthn from the sign-in page so the |
| 51 | +site never offers a flow that cannot succeed. |
| 52 | + |
| 53 | +## Decision |
| 54 | + |
| 55 | +1. **Hide WebAuthn capability signals in the login and session-switch |
| 56 | + WebViews.** `WebKitManager.createSessionSwitchWebViewConfiguration()` |
| 57 | + attaches a `WKUserScript` (`LoginPasskeySuppression`), injected at document |
| 58 | + start into all frames, that undefines `window.PublicKeyCredential` and |
| 59 | + rejects `navigator.credentials.get`/`create` calls requesting a |
| 60 | + `publicKey` credential with `NotAllowedError`. Google's sign-in page |
| 61 | + feature-detects WebAuthn before offering passkeys, so it routes the |
| 62 | + account through password (and other non-WebAuthn) challenges instead of |
| 63 | + the dead-end hybrid flow. |
| 64 | +2. **Scope the suppression to the authentication surface only.** The script |
| 65 | + is added solely to the configuration used by the login sheet and hidden |
| 66 | + account-switch navigations. Those WebViews load Google sign-in pages plus |
| 67 | + the youtube.com/music.youtube.com redirects that complete the flow, none |
| 68 | + of which need WebAuthn. Playback WebViews use different configurations |
| 69 | + and are unaffected. |
| 70 | +3. **Keep the login sheet note, updated to describe the actual behavior** |
| 71 | + ("Passkey sign-in is not available in this window. Google will ask for |
| 72 | + your password or another sign-in method instead.") so users are not |
| 73 | + surprised when their passkey is not offered. |
| 74 | + |
| 75 | +## Consequences |
| 76 | + |
| 77 | +- Passkey-enabled Google accounts can sign in without hitting the |
| 78 | + unrecoverable "Something went wrong" screen; Google falls back to password |
| 79 | + sign-in on its own instead of requiring users to discover "Try another |
| 80 | + way". |
| 81 | +- Passkeys still cannot be used to sign in to Kaset. That is a platform |
| 82 | + restriction, not a regression: the ceremony has never been able to succeed |
| 83 | + in the embedded WebView. If Apple ever offers a viable path (for example a |
| 84 | + less restrictive entitlement), the suppression script and this ADR should |
| 85 | + be revisited. |
| 86 | +- Accounts restricted to passkey-only sign-in (for example by a Workspace |
| 87 | + policy that disallows passwords) still cannot log in, but they could not |
| 88 | + before either; with suppression Google at least presents its other |
| 89 | + verification options rather than the Bluetooth error loop. |
| 90 | +- The suppression script depends on Google feature-detecting WebAuthn. If a |
| 91 | + page calls the API anyway, the second layer (rejecting `publicKey` |
| 92 | + requests promptly with `NotAllowedError`) fails the ceremony immediately |
| 93 | + and deterministically instead of leaving the outcome to the platform; the |
| 94 | + page may still render an error screen, but sign-in stays recoverable |
| 95 | + through its non-WebAuthn fallback. |
| 96 | +- `LoginPasskeySuppressionTests` guards both directions: a plain WKWebView |
| 97 | + must still expose `PublicKeyCredential` (if WebKit changes this, the shim |
| 98 | + is obsolete), and the session-switch configuration must hide it and reject |
| 99 | + passkey requests. |
0 commit comments