Skip to content

Commit a5d4171

Browse files
NestDreamLi Guosozercan
authored andcommitted
fix(auth): route Google passkey sign-in to a working fallback (sozercan#291) (sozercan#453)
Co-authored-by: Li Guo <liguoamz@amazon.com> Co-authored-by: Sertac Ozercan <sozercan@gmail.com> Signed-off-by: Sertac Ozercan <sozercan@gmail.com>
1 parent df31303 commit a5d4171

6 files changed

Lines changed: 331 additions & 5 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import WebKit
2+
3+
// MARK: - LoginPasskeySuppression
4+
5+
/// Hides WebAuthn passkey support from Google sign-in pages loaded in the
6+
/// login and session-switch WebViews.
7+
///
8+
/// WKWebView exposes `window.PublicKeyCredential`, but without the restricted
9+
/// `com.apple.developer.web-browser.public-key-credential` entitlement (granted
10+
/// by Apple only to general-purpose browsers) the system rejects every passkey
11+
/// ceremony before showing any UI. Google's sign-in page detects the API,
12+
/// offers the passkey flow, and dead-ends on a "Something went wrong" error
13+
/// instead of falling back to a password. Removing the API up front makes
14+
/// Google treat the WebView like a browser without WebAuthn support and route
15+
/// sign-in through flows that can actually succeed. See ADR-0033.
16+
enum LoginPasskeySuppression {
17+
/// JavaScript that removes WebAuthn capability signals before page scripts run.
18+
///
19+
/// Two layers, each independently guarded so a WebKit change can never
20+
/// break the sign-in page itself:
21+
/// 1. Undefines `window.PublicKeyCredential`, which well-behaved relying
22+
/// parties (including Google) feature-detect before offering passkeys.
23+
/// 2. Rejects any `navigator.credentials.get/create` call that still asks
24+
/// for a `publicKey` credential with `NotAllowedError`, the same error
25+
/// the platform would eventually produce, but without the misleading
26+
/// cross-device error screen.
27+
static let scriptSource = """
28+
(function () {
29+
"use strict";
30+
try {
31+
delete window.PublicKeyCredential;
32+
Object.defineProperty(window, "PublicKeyCredential", {
33+
value: undefined,
34+
writable: false,
35+
configurable: false,
36+
});
37+
} catch (error) {}
38+
try {
39+
var credentials = window.navigator && window.navigator.credentials;
40+
if (!credentials) {
41+
return;
42+
}
43+
var prototype = Object.getPrototypeOf(credentials);
44+
var wrap = function (original) {
45+
if (typeof original !== "function") {
46+
return original;
47+
}
48+
return function (options) {
49+
if (options && options.publicKey) {
50+
return Promise.reject(new DOMException(
51+
"Passkeys are not available in this app.",
52+
"NotAllowedError"
53+
));
54+
}
55+
return original.apply(this, arguments);
56+
};
57+
};
58+
prototype.get = wrap(prototype.get);
59+
prototype.create = wrap(prototype.create);
60+
} catch (error) {}
61+
})();
62+
"""
63+
64+
/// Builds the user script for the login/session-switch configuration.
65+
///
66+
/// Injected at document start so it runs before Google's capability
67+
/// detection, and into all frames because parts of the sign-in flow render
68+
/// in iframes.
69+
@MainActor
70+
static func makeUserScript() -> WKUserScript {
71+
WKUserScript(
72+
source: self.scriptSource,
73+
injectionTime: .atDocumentStart,
74+
forMainFrameOnly: false
75+
)
76+
}
77+
}

Sources/Kaset/Services/WebKit/WebKitManager.swift

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -346,13 +346,17 @@ final class WebKitManager: NSObject, WebKitManagerProtocol {
346346
#endif
347347
}
348348

349-
/// Creates the minimal WebView configuration used for hidden account-switch
350-
/// navigations. It deliberately shares only the website data store (cookies)
351-
/// and does not attach the app's `WKWebExtensionController`, so enabled
352-
/// extensions/content scripts cannot observe credential-bearing signin URLs.
349+
/// Creates the minimal WebView configuration used for the login sheet and
350+
/// hidden account-switch navigations. It deliberately shares only the
351+
/// website data store (cookies) and does not attach the app's
352+
/// `WKWebExtensionController`, so enabled extensions/content scripts cannot
353+
/// observe credential-bearing signin URLs. It also suppresses WebAuthn
354+
/// passkey detection so Google's sign-in falls back to flows that can
355+
/// succeed in an embedded WebView (ADR-0033).
353356
func createSessionSwitchWebViewConfiguration() -> WKWebViewConfiguration {
354357
let configuration = WKWebViewConfiguration()
355358
configuration.websiteDataStore = self.dataStore
359+
configuration.userContentController.addUserScript(LoginPasskeySuppression.makeUserScript())
356360
return configuration
357361
}
358362

Sources/Kaset/Views/LoginSheet.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ struct LoginSheet: View {
180180
}
181181
}
182182

183-
Text(String(localized: "Note: If passkeys don't work, use \"Try another way\" to sign in with password."))
183+
Text(String(localized: "Passkey sign-in is not available in this window. Google will ask for your password or another sign-in method instead."))
184184
.font(.caption)
185185
.foregroundStyle(.secondary)
186186
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import Foundation
2+
import Testing
3+
import WebKit
4+
@testable import Kaset
5+
6+
// MARK: - LoginPasskeySuppressionTests
7+
8+
/// Tests for LoginPasskeySuppression.
9+
@Suite(.serialized, .tags(.service))
10+
@MainActor
11+
struct LoginPasskeySuppressionTests {
12+
@Test("User script injects at document start into all frames")
13+
func userScriptProperties() {
14+
let script = LoginPasskeySuppression.makeUserScript()
15+
#expect(script.source == LoginPasskeySuppression.scriptSource)
16+
#expect(script.injectionTime == .atDocumentStart)
17+
#expect(script.isForMainFrameOnly == false)
18+
}
19+
20+
@Test("Session switch WebView configuration attaches the suppression script")
21+
func sessionSwitchConfigurationAttachesScript() {
22+
let manager = WebKitManager.makeTestInstance()
23+
let configuration = manager.createSessionSwitchWebViewConfiguration()
24+
let suppressionScript = configuration.userContentController.userScripts
25+
.first { $0.source == LoginPasskeySuppression.scriptSource }
26+
#expect(suppressionScript != nil)
27+
#expect(suppressionScript?.injectionTime == .atDocumentStart)
28+
#expect(suppressionScript?.isForMainFrameOnly == false)
29+
}
30+
31+
@Test("WebAuthn API is exposed in a plain WebView", .timeLimit(.minutes(1)))
32+
func webAuthnExposedWithoutSuppression() async throws {
33+
// Baseline guard: if WKWebView ever stops exposing WebAuthn on its
34+
// own, the suppression script becomes redundant and the passkey
35+
// handling should be revisited.
36+
let configuration = WKWebViewConfiguration()
37+
configuration.websiteDataStore = .nonPersistent()
38+
let webView = WKWebView(frame: .zero, configuration: configuration)
39+
try await Self.loadBlankPage(in: webView)
40+
41+
let credentialType = try await webView.evaluateJavaScript("typeof PublicKeyCredential") as? String
42+
#expect(credentialType == "function")
43+
}
44+
45+
@Test("Session-switch configuration hides WebAuthn from pages", .timeLimit(.minutes(1)))
46+
func suppressionHidesWebAuthn() async throws {
47+
let webView = Self.makeSuppressedWebView()
48+
try await Self.loadBlankPage(in: webView)
49+
50+
let credentialType = try await webView.evaluateJavaScript("typeof PublicKeyCredential") as? String
51+
#expect(credentialType == "undefined")
52+
}
53+
54+
@Test("Passkey credential requests are rejected by the suppression shim", .timeLimit(.minutes(1)))
55+
func passkeyRequestsAreRejected() async throws {
56+
let webView = Self.makeSuppressedWebView()
57+
try await Self.loadBlankPage(in: webView)
58+
59+
let errorDescription = try await webView.callAsyncJavaScript(
60+
"""
61+
try {
62+
await navigator.credentials.get({ publicKey: { challenge: new Uint8Array(16) } });
63+
return "resolved";
64+
} catch (error) {
65+
return error.name + ": " + error.message;
66+
}
67+
""",
68+
contentWorld: .page
69+
) as? String
70+
#expect(errorDescription == "NotAllowedError: Passkeys are not available in this app.")
71+
}
72+
73+
// MARK: - Helpers
74+
75+
private static func makeSuppressedWebView() -> WKWebView {
76+
let manager = WebKitManager.makeTestInstance()
77+
let configuration = manager.createSessionSwitchWebViewConfiguration()
78+
return WKWebView(frame: .zero, configuration: configuration)
79+
}
80+
81+
private static func loadBlankPage(in webView: WKWebView) async throws {
82+
// WebAuthn is only exposed in secure contexts, so give the local page
83+
// an https origin; no network request is made for the page itself.
84+
let waiter = NavigationWaiter()
85+
try await waiter.waitForLoad(
86+
of: webView,
87+
html: "<html><body></body></html>",
88+
baseURL: URL(string: "https://accounts.google.com/")
89+
)
90+
}
91+
}
92+
93+
// MARK: - NavigationWaiter
94+
95+
/// Awaits the completion of a single `loadHTMLString` navigation.
96+
///
97+
/// Cancellation-aware so the suite's `timeLimit` trait can end a stuck
98+
/// navigation instead of suspending the test run indefinitely, and treats
99+
/// WebContent process termination as a failure rather than waiting forever.
100+
@MainActor
101+
private final class NavigationWaiter: NSObject, WKNavigationDelegate {
102+
private struct ContentProcessTerminatedError: Error {}
103+
104+
private var continuation: CheckedContinuation<Void, any Error>?
105+
106+
func waitForLoad(of webView: WKWebView, html: String, baseURL: URL?) async throws {
107+
try await withTaskCancellationHandler {
108+
try await withCheckedThrowingContinuation { continuation in
109+
self.continuation = continuation
110+
webView.navigationDelegate = self
111+
webView.loadHTMLString(html, baseURL: baseURL)
112+
}
113+
} onCancel: {
114+
Task { @MainActor in
115+
self.finish(throwing: CancellationError())
116+
}
117+
}
118+
}
119+
120+
private func finish(throwing error: (any Error)? = nil) {
121+
guard let continuation = self.continuation else { return }
122+
self.continuation = nil
123+
if let error {
124+
continuation.resume(throwing: error)
125+
} else {
126+
continuation.resume()
127+
}
128+
}
129+
130+
func webView(_: WKWebView, didFinish _: WKNavigation!) {
131+
self.finish()
132+
}
133+
134+
func webView(_: WKWebView, didFail _: WKNavigation!, withError error: any Error) {
135+
self.finish(throwing: error)
136+
}
137+
138+
func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError error: any Error) {
139+
self.finish(throwing: error)
140+
}
141+
142+
func webViewWebContentProcessDidTerminate(_: WKWebView) {
143+
self.finish(throwing: ContentProcessTerminatedError())
144+
}
145+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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.

docs/adr/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,4 @@ What becomes easier or more difficult because of this change?
6767
| [0030](0030-account-scoped-favorites.md) | Account-Scoped Favorites Persistence | Accepted |
6868
| [0031](0031-saved-album-library-reconciliation.md) | Saved-Album Library Identity and Reconciliation | Accepted |
6969
| [0032](0032-youtube-ask-gemini.md) | Watch-Scoped YouTube Ask Gemini | Accepted; fixed WEB profile enabled in production |
70+
| [0033](0033-login-passkey-suppression.md) | Suppress Passkey Detection in the Login WebView | Accepted |

0 commit comments

Comments
 (0)