Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,12 @@ func resolveTerminalOpenURLTarget(_ rawValue: String) -> TerminalOpenURLTarget?
if let parsed = URL(string: trimmed),
let scheme = parsed.scheme?.lowercased() {
if scheme == "http" || scheme == "https" {
if browserIsOAuthFlowURL(parsed) {
#if DEBUG
dlog("link.resolve result=external(oauth) url=\(parsed)")
#endif
return .external(parsed)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
guard BrowserInsecureHTTPSettings.normalizeHost(parsed.host ?? "") != nil else {
#if DEBUG
dlog("link.resolve result=external(invalidHost) url=\(parsed)")
Expand All @@ -305,6 +311,12 @@ func resolveTerminalOpenURLTarget(_ rawValue: String) -> TerminalOpenURLTarget?
}

if let webURL = resolveBrowserNavigableURL(trimmed) {
if browserIsOAuthFlowURL(webURL) {
#if DEBUG
dlog("link.resolve result=external(bareHost-oauth) url=\(webURL)")
#endif
return .external(webURL)
}
guard BrowserInsecureHTTPSettings.normalizeHost(webURL.host ?? "") != nil else {
#if DEBUG
dlog("link.resolve result=external(bareHost-invalidHost) url=\(webURL)")
Expand Down
59 changes: 59 additions & 0 deletions Sources/Panels/BrowserPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,49 @@ func browserShouldOpenURLExternally(_ url: URL) -> Bool {
return !browserEmbeddedNavigationSchemes.contains(scheme)
}

/// OAuth and SSO flows must open in the system browser because identity providers
/// (Google, Microsoft, GitHub, Apple) block or degrade OAuth in embedded WebViews.
/// Google explicitly disallows it: https://developers.google.com/identity/protocols/oauth2/policies#browsers
func browserIsOAuthFlowURL(_ url: URL) -> Bool {
// Only intercept web URLs — file:// and other schemes should not be affected.
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
return false
}

let host = url.host?.lowercased() ?? ""
let pathSegments = Set(url.path.lowercased().split(separator: "/").map(String.init))

// Standard OAuth authorize/callback endpoints — match as a path segment,
// not a substring, so "/docs/oauth" or "/oauth-settings" won't trigger.
if pathSegments.contains("oauth") || pathSegments.contains("oauth2") {
return true
}

// Google sign-in (the redirect target after /oauth/authorize)
if host == "accounts.google.com" && (url.path.hasPrefix("/signin") || url.path.hasPrefix("/o/oauth2")) {
return true
}

// Microsoft identity platform — boundary-aware match to avoid
// "evillogin.microsoftonline.com" false positives.
if host == "login.microsoftonline.com" || host.hasSuffix(".login.microsoftonline.com")
|| host == "login.live.com" {
return true
}

// GitHub OAuth
if host == "github.com" && url.path.lowercased().hasPrefix("/login/oauth") {
return true
}

// Apple ID
if host == "appleid.apple.com" && url.path.lowercased().hasPrefix("/auth") {
return true
}

return false
}

enum BrowserUserAgentSettings {
// Force a Safari UA. Some WebKit builds return a minimal UA without Version/Safari tokens,
// and some installs may have legacy Chrome UA overrides. Both can cause Google to serve
Expand Down Expand Up @@ -3957,6 +4000,22 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
return
}

// OAuth/SSO flows must open in the system browser. Identity providers like
// Google block OAuth in embedded WebViews, causing the flow to hang.
if let url = navigationAction.request.url,
navigationAction.targetFrame?.isMainFrame != false,
browserIsOAuthFlowURL(url) {
let opened = NSWorkspace.shared.open(url)
if !opened {
NSLog("BrowserPanel OAuth external navigation failed to open URL: %@", url.absoluteString)
}
#if DEBUG
dlog("browser.navigation.oauth source=navDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)")
#endif
decisionHandler(.cancel)
return
Comment on lines +4008 to +4016

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The adjacent deeplink-handling block (lines 4010–4016) logs an NSLog error when NSWorkspace.shared.open(url) returns false. The OAuth block should do the same for consistency and to help diagnose cases where the system browser couldn't be launched.

Suggested change
let opened = NSWorkspace.shared.open(url)
#if DEBUG
dlog("browser.navigation.oauth source=navDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)")
#endif
decisionHandler(.cancel)
return
if let url = navigationAction.request.url,
navigationAction.targetFrame?.isMainFrame != false,
browserIsOAuthFlowURL(url) {
let opened = NSWorkspace.shared.open(url)
if !opened {
NSLog("BrowserPanel OAuth navigation failed to open URL in system browser: %@", url.absoluteString)
}
#if DEBUG
dlog("browser.navigation.oauth source=navDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)")
#endif
decisionHandler(.cancel)
return
}

Comment on lines +4005 to +4016

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Don’t silently cancel when the external handoff fails.

This path computes opened but cancels the WebView navigation even when NSWorkspace.shared.open(url) returns false. That leaves the user with a dead click and no diagnostic. Please at least mirror the external-link path below by logging the failure, and ideally surface an error/fallback.

Suggested fix
         if let url = navigationAction.request.url,
            navigationAction.targetFrame?.isMainFrame != false,
            browserIsOAuthFlowURL(url) {
             let opened = NSWorkspace.shared.open(url)
+            if !opened {
+                NSLog("BrowserPanel OAuth navigation failed to open URL externally: %@", url.absoluteString)
+            }
             `#if` DEBUG
             dlog("browser.navigation.oauth source=navDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)")
             `#endif`
             decisionHandler(.cancel)
             return
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if let url = navigationAction.request.url,
navigationAction.targetFrame?.isMainFrame != false,
browserIsOAuthFlowURL(url) {
let opened = NSWorkspace.shared.open(url)
#if DEBUG
dlog("browser.navigation.oauth source=navDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)")
#endif
decisionHandler(.cancel)
return
if let url = navigationAction.request.url,
navigationAction.targetFrame?.isMainFrame != false,
browserIsOAuthFlowURL(url) {
let opened = NSWorkspace.shared.open(url)
if !opened {
NSLog("BrowserPanel OAuth navigation failed to open URL externally: %@", url.absoluteString)
}
`#if` DEBUG
dlog("browser.navigation.oauth source=navDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)")
`#endif`
decisionHandler(.cancel)
return
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Panels/BrowserPanel.swift` around lines 3997 - 4005, The OAuth
handoff block currently calls NSWorkspace.shared.open(url) and cancels
navigation regardless of the returned Bool; update the branch that checks
browserIsOAuthFlowURL(url) to handle a failed handoff by logging the failure
(use dlog with opened value and url like the external-link path does) and
surface a fallback instead of silently cancelling (e.g., present an alert/error
to the user or allow the WebView to load the URL as a fallback); ensure you
still call decisionHandler(.cancel) only when the open succeeded, and on failure
invoke the same error-handling path used elsewhere in this file.

}

// WebKit cannot open app-specific deeplinks (discord://, slack://, zoommtg://, etc.).
// Hand these off to macOS so the owning app can handle them.
if let url = navigationAction.request.url,
Expand Down