-
-
Notifications
You must be signed in to change notification settings - Fork 1k
Add passkey, WebAuthn, and FIDO2 support to browser pane #2660
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
007ed0b
81f352b
7b86496
a767c32
6458077
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,119 @@ | |
| "sourceLanguage": "en", | ||
| "version": "1.0", | ||
| "strings": { | ||
| "NSBluetoothAlwaysUsageDescription": { | ||
| "extractionState": "manual", | ||
| "localizations": { | ||
| "en": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "cmux uses Bluetooth for cross-device passkey sign-in in embedded browser authentication flows." | ||
| } | ||
| }, | ||
| "ja": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "cmux は内蔵ブラウザの認証フローでクロスデバイスのパスキーサインインに Bluetooth を使用します。" | ||
| } | ||
| }, | ||
| "zh-Hans": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "cmux 在嵌入式浏览器身份验证流程中使用蓝牙进行跨设备通行密钥登录。" | ||
| } | ||
| }, | ||
| "zh-Hant": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "cmux 在內嵌瀏覽器身份驗證流程中使用藍牙進行跨裝置密碼金鑰登入。" | ||
| } | ||
| }, | ||
| "ko": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "cmux는 내장 브라우저 인증 흐름에서 기기 간 패스키 로그인에 Bluetooth를 사용합니다." | ||
| } | ||
| }, | ||
| "de": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "cmux verwendet Bluetooth für die geräteübergreifende Passkey-Anmeldung in den Authentifizierungsabläufen des integrierten Browsers." | ||
| } | ||
| }, | ||
| "es": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "cmux usa Bluetooth para el inicio de sesión con claves de acceso entre dispositivos en los flujos de autenticación del navegador integrado." | ||
| } | ||
| }, | ||
| "fr": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "cmux utilise Bluetooth pour la connexion par clé d'accès entre appareils dans les flux d'authentification du navigateur intégré." | ||
| } | ||
| }, | ||
| "it": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "cmux usa il Bluetooth per l'accesso con passkey tra dispositivi nei flussi di autenticazione del browser integrato." | ||
| } | ||
| }, | ||
| "da": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "cmux bruger Bluetooth til adgangsnøgle-login på tværs af enheder i den integrerede browsers godkendelsesforløb." | ||
| } | ||
| }, | ||
| "pl": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "cmux używa Bluetooth do logowania za pomocą kluczy dostępu między urządzeniami w procesach uwierzytelniania wbudowanej przeglądarki." | ||
| } | ||
| }, | ||
| "ru": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "cmux использует Bluetooth для входа с помощью ключей доступа между устройствами в потоках аутентификации встроенного браузера." | ||
| } | ||
| }, | ||
| "bs": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "cmux koristi Bluetooth za prijavu pomoću pristupnog ključa između uređaja u tokovima autentifikacije ugrađenog preglednika." | ||
| } | ||
| }, | ||
| "ar": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "يستخدم cmux البلوتوث لتسجيل الدخول باستخدام مفاتيح المرور عبر الأجهزة في تدفقات المصادقة بالمتصفح المدمج." | ||
| } | ||
| }, | ||
| "nb": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "cmux bruker Bluetooth for tilgangsnøkkel-pålogging på tvers av enheter i den innebygde nettleserens autentiseringsflyter." | ||
| } | ||
| }, | ||
| "pt-BR": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "O cmux usa Bluetooth para login com chaves de acesso entre dispositivos nos fluxos de autenticação do navegador integrado." | ||
| } | ||
| }, | ||
| "th": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "cmux ใช้บลูทูธสำหรับการลงชื่อเข้าใช้ด้วย passkey ข้ามอุปกรณ์ในขั้นตอนการตรวจสอบสิทธิ์ของเบราว์เซอร์ในตัว" | ||
| } | ||
| }, | ||
| "tr": { | ||
| "stringUnit": { | ||
| "state": "translated", | ||
| "value": "cmux, gömülü tarayıcının kimlik doğrulama akışlarında cihazlar arası geçiş anahtarı oturum açma için Bluetooth kullanır." | ||
| } | ||
| } | ||
| } | ||
|
Comment on lines
+5
to
+116
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This new key only includes
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }, | ||
| "NSCameraUsageDescription": { | ||
| "extractionState": "manual", | ||
| "localizations": { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| import Foundation | ||
| import Combine | ||
| import AuthenticationServices | ||
| import WebKit | ||
| import AppKit | ||
| import Bonsplit | ||
|
|
@@ -1728,6 +1729,164 @@ final class BrowserPortalAnchorView: NSView { | |
| } | ||
| } | ||
|
|
||
| // WKWebView handles WebAuthn itself for browser apps; this support code only | ||
| // determines when cmux should request browser passkey access from macOS. | ||
| enum BrowserPasskeyAuthorizationSupport { | ||
| static let remoteLoopbackAliasHost = "cmux-loopback.localtest.me" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Duplicate loopback alias host constant risks future driftLow Severity The magic string Additional Locations (1)Reviewed by Cursor Bugbot for commit 6458077. Configure here. |
||
|
|
||
| static func isPotentiallyTrustworthy(url: URL?) -> Bool { | ||
| guard let url, | ||
| let scheme = url.scheme?.lowercased() else { | ||
| return false | ||
| } | ||
| if scheme == "https" { | ||
| return true | ||
| } | ||
| guard scheme == "http", | ||
| let host = url.host else { | ||
| return false | ||
| } | ||
| return isLoopbackHost(host) | ||
| } | ||
|
|
||
| static func shouldRequestAuthorization( | ||
| for url: URL?, | ||
| authorizationState: ASAuthorizationWebBrowserPublicKeyCredentialManager.AuthorizationState, | ||
| hasPendingRequest: Bool, | ||
| didPromptThisSession: Bool | ||
| ) -> Bool { | ||
| guard isPotentiallyTrustworthy(url: url) else { return false } | ||
| guard authorizationState == .notDetermined else { return false } | ||
| guard !hasPendingRequest else { return false } | ||
| guard !didPromptThisSession else { return false } | ||
| return true | ||
| } | ||
|
|
||
| /// Returns a host/origin string suitable for debug logs without leaking | ||
| /// query parameters, paths, OIDC state, or auth codes. | ||
| static func redactedURLDescription(for url: URL?) -> String { | ||
| guard let url else { return "nil" } | ||
| let scheme = url.scheme?.lowercased() ?? "?" | ||
| let host = url.host ?? "?" | ||
| return "\(scheme)://\(host)" | ||
| } | ||
|
|
||
| private static func isLoopbackHost(_ host: String) -> Bool { | ||
| let normalized = normalizedHost(host) | ||
| let octets = normalized.split(separator: ".", omittingEmptySubsequences: false) | ||
| let is127Loopback = octets.count == 4 | ||
| && octets[0] == "127" | ||
| && octets.allSatisfy { component in | ||
| guard let value = Int(component) else { return false } | ||
| return (0...255).contains(value) | ||
| } | ||
|
|
||
| return normalized == "localhost" | ||
| || normalized.hasSuffix(".localhost") | ||
| || normalized == "::1" | ||
| || is127Loopback | ||
| || normalized == remoteLoopbackAliasHost | ||
| || normalized.hasSuffix("." + remoteLoopbackAliasHost) | ||
| } | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| private static func normalizedHost(_ host: String) -> String { | ||
| var normalized = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() | ||
| if normalized.hasPrefix("[") && normalized.hasSuffix("]") { | ||
| normalized.removeFirst() | ||
| normalized.removeLast() | ||
| } | ||
| if normalized.hasSuffix(".") { | ||
| normalized.removeLast() | ||
| } | ||
| return normalized | ||
| } | ||
| } | ||
|
|
||
| private extension ASAuthorizationWebBrowserPublicKeyCredentialManager.AuthorizationState { | ||
| var cmuxDebugName: String { | ||
| switch self { | ||
| case .authorized: | ||
| return "authorized" | ||
| case .denied: | ||
| return "denied" | ||
| case .notDetermined: | ||
| return "notDetermined" | ||
| @unknown default: | ||
| return "unknown" | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @MainActor | ||
| final class BrowserPasskeyAuthorizationManager { | ||
| static let shared = BrowserPasskeyAuthorizationManager() | ||
|
|
||
| private let credentialManager = ASAuthorizationWebBrowserPublicKeyCredentialManager() | ||
| private var authorizationTask: Task<ASAuthorizationWebBrowserPublicKeyCredentialManager.AuthorizationState, Never>? | ||
| private var didPromptThisSession = false | ||
|
|
||
| private init() {} | ||
|
|
||
| func requestAuthorizationIfNeeded(for url: URL?, source: String) { | ||
| // Skip the system passkey authorization prompt when running under | ||
| // automated UI tests; the dialog blocks the main thread and breaks | ||
| // unrelated keyboard regressions on CI. | ||
| if SessionRestorePolicy.isRunningUnderAutomatedTests() { | ||
| return | ||
| } | ||
|
|
||
| let state = credentialManager.authorizationStateForPlatformCredentials | ||
| let redactedURL = BrowserPasskeyAuthorizationSupport.redactedURLDescription(for: url) | ||
| guard BrowserPasskeyAuthorizationSupport.shouldRequestAuthorization( | ||
| for: url, | ||
| authorizationState: state, | ||
| hasPendingRequest: authorizationTask != nil, | ||
| didPromptThisSession: didPromptThisSession | ||
| ) else { | ||
| #if DEBUG | ||
| dlog( | ||
| "browser.passkey.authorization.skip " + | ||
| "source=\(source) origin=\(redactedURL) state=\(state.cmuxDebugName) " + | ||
| "pending=\(authorizationTask == nil ? 0 : 1) prompted=\(didPromptThisSession ? 1 : 0)" | ||
| ) | ||
| #endif | ||
| return | ||
| } | ||
|
|
||
| didPromptThisSession = true | ||
| #if DEBUG | ||
| dlog( | ||
| "browser.passkey.authorization.begin " + | ||
| "source=\(source) origin=\(redactedURL) state=\(state.cmuxDebugName)" | ||
| ) | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| #endif | ||
|
|
||
| let task = Task<ASAuthorizationWebBrowserPublicKeyCredentialManager.AuthorizationState, Never> { [credentialManager] in | ||
| await withCheckedContinuation { continuation in | ||
| credentialManager.requestAuthorizationForPublicKeyCredentials { authorizationState in | ||
| continuation.resume(returning: authorizationState) | ||
| } | ||
| } | ||
| } | ||
| authorizationTask = task | ||
|
|
||
| Task { @MainActor [weak self] in | ||
| guard let self else { return } | ||
| let result = await task.value | ||
| self.authorizationTask = nil | ||
| if result == .notDetermined { | ||
| self.didPromptThisSession = false | ||
| } | ||
| #if DEBUG | ||
| dlog( | ||
| "browser.passkey.authorization.end " + | ||
| "source=\(source) origin=\(redactedURL) result=\(result.cmuxDebugName)" | ||
| ) | ||
| #endif | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @MainActor | ||
| final class BrowserPanel: Panel, ObservableObject { | ||
| private static let remoteLoopbackProxyAliasHost = "cmux-loopback.localtest.me" | ||
|
|
@@ -6189,6 +6348,13 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { | |
| return | ||
| } | ||
|
|
||
| if navigationAction.targetFrame?.isMainFrame != false { | ||
| BrowserPasskeyAuthorizationManager.shared.requestAuthorizationIfNeeded( | ||
| for: navigationAction.request.url, | ||
| source: "browser.navigation.mainFrame" | ||
| ) | ||
| } | ||
|
|
||
| #if DEBUG | ||
| let targetURL = navigationAction.request.url?.absoluteString ?? "nil" | ||
| dlog("browser.nav.decidePolicy.action kind=allow url=\(targetURL)") | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| <?xml version="1.0" encoding="UTF-8"?> | ||
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||
| <plist version="1.0"> | ||
| <dict> | ||
| <key>com.apple.security.cs.disable-library-validation</key> | ||
| <true/> | ||
| <key>com.apple.security.cs.allow-unsigned-executable-memory</key> | ||
| <true/> | ||
| <key>com.apple.security.cs.allow-jit</key> | ||
| <true/> | ||
| <key>com.apple.security.device.camera</key> | ||
| <true/> | ||
| <key>com.apple.security.device.audio-input</key> | ||
| <true/> | ||
| <key>com.apple.security.automation.apple-events</key> | ||
| <true/> | ||
| </dict> | ||
| </plist> |


Uh oh!
There was an error while loading. Please reload this page.