From 641f1c35cab10d872ee1a79e2dba6c7f0e8ca386 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Thu, 28 May 2026 20:44:34 -0700 Subject: [PATCH 01/12] test: cover external browser sign-in opener --- cmux.xcodeproj/project.pbxproj | 4 + ...uthManagerExternalBrowserSignInTests.swift | 85 +++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 cmuxTests/AuthManagerExternalBrowserSignInTests.swift diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index dd5a6a2bf2..d27981d500 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -37,6 +37,7 @@ 36CE99ED050785B5E96B72BB /* AuthCallbackRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61A19A4145F034965110CF87 /* AuthCallbackRouter.swift */; }; D9FEC58D5BACCF76459F1BBE /* AuthEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43430FA5929121E2EAAB3091 /* AuthEnvironment.swift */; }; 350DAC5EBD38642A3E81471A /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 312DE7503B4658DD173121B8 /* AuthManager.swift */; }; + A36170100000000000000001 /* AuthManagerExternalBrowserSignInTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A36170100000000000000002 /* AuthManagerExternalBrowserSignInTests.swift */; }; 698944F99A6ADFBA24A7BFB9 /* AuthSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B1EA8948C5F126FE63CFB4E /* AuthSettingsStore.swift */; }; B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */; }; C0DE35860000000000000001 /* BackgroundWorkspacePrimeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE35860000000000000002 /* BackgroundWorkspacePrimeCoordinator.swift */; }; @@ -631,6 +632,7 @@ 61A19A4145F034965110CF87 /* AuthCallbackRouter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AuthCallbackRouter.swift; sourceTree = ""; }; 43430FA5929121E2EAAB3091 /* AuthEnvironment.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AuthEnvironment.swift; sourceTree = ""; }; 312DE7503B4658DD173121B8 /* AuthManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = ""; }; + A36170100000000000000002 /* AuthManagerExternalBrowserSignInTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManagerExternalBrowserSignInTests.swift; sourceTree = ""; }; 5B1EA8948C5F126FE63CFB4E /* AuthSettingsStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AuthSettingsStore.swift; sourceTree = ""; }; B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutomationSocketUITests.swift; sourceTree = ""; }; C0DE35860000000000000002 /* BackgroundWorkspacePrimeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorkspacePrimeCoordinator.swift; sourceTree = ""; }; @@ -1656,6 +1658,7 @@ F6355601A1B2C3D4E5F60718 /* SSHStartupSignalLifecycleTests.swift */, F6120001A1B2C3D4E5F60718 /* WorkspaceSSHFishShellTests.swift */, F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */, + A36170100000000000000002 /* AuthManagerExternalBrowserSignInTests.swift */, F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */, F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */, F1C1AA20B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift */, @@ -2446,6 +2449,7 @@ C34670010000000000000001 /* AppDelegateRenameShortcutContextTests.swift in Sources */, F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */, A11EAA000000000000000000 /* AppearanceSettingsTests.swift in Sources */, + A36170100000000000000001 /* AuthManagerExternalBrowserSignInTests.swift in Sources */, D3622000A1B2C3D4E5F60718 /* BrowserArrowKeyForwardingTests.swift in Sources */, E12E88F82733EC42F32C36A3 /* BrowserConfigTests.swift in Sources */, A5008381 /* BrowserFindJavaScriptTests.swift in Sources */, diff --git a/cmuxTests/AuthManagerExternalBrowserSignInTests.swift b/cmuxTests/AuthManagerExternalBrowserSignInTests.swift new file mode 100644 index 0000000000..78cf50ce18 --- /dev/null +++ b/cmuxTests/AuthManagerExternalBrowserSignInTests.swift @@ -0,0 +1,85 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite +struct AuthManagerExternalBrowserSignInTests { + @Test + func beginSignInOpensSignInURLThroughInjectedBrowserOpener() async throws { + let suiteName = "AuthManagerExternalBrowserSignInTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + var openedURL: URL? + let manager = AuthManager( + client: AuthManagerExternalBrowserSignInTestClient(), + tokenStore: AuthManagerExternalBrowserSignInTestTokenStore(), + settingsStore: AuthSettingsStore(userDefaults: defaults), + urlOpener: { url in + openedURL = url + } + ) + await manager.awaitBootstrapped() + + manager.beginSignIn() + let isLoadingAfterBegin = manager.isLoading + await manager.signOut() + + let url = try #require(openedURL) + #expect(url.path == "/handler/sign-in") + + let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)) + let afterAuthReturnTo = try #require( + components.queryItems?.first { $0.name == "after_auth_return_to" }?.value + ) + #expect(afterAuthReturnTo.contains("native_app_return_to=")) + #expect(afterAuthReturnTo.contains("auth-callback")) + #expect(isLoadingAfterBegin) + } +} + +private struct AuthManagerExternalBrowserSignInTestClient: AuthClientProtocol { + func currentUser() async throws -> CMUXAuthUser? { nil } + func listTeams() async throws -> [AuthTeamSummary] { [] } + func currentAccessToken() async throws -> String? { nil } + func signOut() async throws {} +} + +private actor AuthManagerExternalBrowserSignInTestTokenStore: StackAuthTokenStoreProtocol { + private var accessToken: String? + private var refreshToken: String? + + func getStoredAccessToken() async -> String? { + accessToken + } + + func getStoredRefreshToken() async -> String? { + refreshToken + } + + func setTokens(accessToken: String?, refreshToken: String?) async { + self.accessToken = accessToken + self.refreshToken = refreshToken + } + + func clearTokens() async { + accessToken = nil + refreshToken = nil + } + + func compareAndSet( + compareRefreshToken: String, + newRefreshToken: String?, + newAccessToken: String? + ) async { + guard refreshToken == compareRefreshToken else { return } + refreshToken = newRefreshToken + accessToken = newAccessToken + } +} From 070ab30b41b7ae201711b253960b0ebca14d824a Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Thu, 28 May 2026 20:50:10 -0700 Subject: [PATCH 02/12] fix: open settings sign-in in default browser --- CHANGELOG.md | 5 + Resources/Info.plist | 13 -- Resources/Localizable.xcstrings | 29 +++ Sources/Auth/AuthCallbackRouter.swift | 17 +- Sources/Auth/AuthEnvironment.swift | 17 +- Sources/Auth/AuthManager.swift | 201 +++++++++--------- Sources/cmuxApp.swift | 44 ++-- ...uthManagerExternalBrowserSignInTests.swift | 1 + .../after-sign-in/OpenNativeClient.tsx | 6 + .../handler/after-sign-in/native-handoff.ts | 36 ++++ web/app/handler/after-sign-in/page.tsx | 89 +++++--- web/tests/native-handoff.test.ts | 88 ++++++++ 12 files changed, 384 insertions(+), 162 deletions(-) create mode 100644 web/app/handler/after-sign-in/native-handoff.ts create mode 100644 web/tests/native-handoff.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d615c76bc3..79e4158324 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to cmux are documented here. +## [Unreleased] + +### Fixed +- Open Settings > Account sign-in in the user's default browser, complete through the cmux auth callback, and guard malformed native auth handoffs ([#3617](https://github.com/manaflow-ai/cmux/issues/3617)) + ## [0.64.10] - 2026-05-23 ### Added diff --git a/Resources/Info.plist b/Resources/Info.plist index 4bea9049b3..e8e8de2401 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -53,19 +53,6 @@ A program running within cmux would like to use AppleScript. CFBundleURLTypes - - CFBundleTypeRole - Viewer - CFBundleURLName - $(PRODUCT_BUNDLE_IDENTIFIER).web - LSHandlerRank - Default - CFBundleURLSchemes - - http - https - - CFBundleTypeRole Viewer diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index e22a3a5e62..178aa18d3d 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -120943,6 +120943,35 @@ } } }, + "settings.account.error.signInTimedOut": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in timed out. Try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サインインがタイムアウトしました。もう一度お試しください。" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Час очікування входу минув. Спробуйте ще раз." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "로그인 시간이 초과되었습니다. 다시 시도하세요." + } + } + } + }, "remote.state.connected.vmNoProxy": { "extractionState": "manual", "localizations": { diff --git a/Sources/Auth/AuthCallbackRouter.swift b/Sources/Auth/AuthCallbackRouter.swift index 311c87690e..6817ff29aa 100644 --- a/Sources/Auth/AuthCallbackRouter.swift +++ b/Sources/Auth/AuthCallbackRouter.swift @@ -3,6 +3,7 @@ import Foundation struct CMUXAuthCallbackPayload: Equatable, Sendable { let refreshToken: String let accessToken: String + let state: String? } enum AuthCallbackRouter { @@ -29,10 +30,19 @@ enum AuthCallbackRouter { return CMUXAuthCallbackPayload( refreshToken: refreshToken, - accessToken: accessToken + accessToken: accessToken, + state: callbackState(in: components) ) } + static func callbackState(from url: URL) -> String? { + guard isAuthCallbackURL(url), + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return nil + } + return callbackState(in: components) + } + private static func isAllowedScheme(_ scheme: String?) -> Bool { guard let normalized = scheme?.lowercased() else { return false } if normalized == "cmux" || normalized == "cmux-nightly" || normalized == "cmux-dev" { @@ -63,6 +73,11 @@ enum AuthCallbackRouter { .value } + private static func callbackState(in components: URLComponents) -> String? { + queryValue(named: "state", in: components)? + .trimmingCharacters(in: .whitespacesAndNewlines) + } + private static func decodeAccessToken(from accessCookie: String) -> String? { guard accessCookie.hasPrefix("[") else { return accessCookie diff --git a/Sources/Auth/AuthEnvironment.swift b/Sources/Auth/AuthEnvironment.swift index 71452faa15..0093873160 100644 --- a/Sources/Auth/AuthEnvironment.swift +++ b/Sources/Auth/AuthEnvironment.swift @@ -26,7 +26,20 @@ enum AuthEnvironment { } static var callbackURL: URL { - URL(string: "\(callbackScheme)://auth-callback")! + authCallbackURL() + } + + static func authCallbackURL(state: String? = nil) -> URL { + var components = URLComponents() + components.scheme = callbackScheme + components.host = "auth-callback" + if let state, + !state.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + components.queryItems = [ + URLQueryItem(name: "state", value: state), + ] + } + return components.url! } static var websiteOrigin: URL { @@ -191,7 +204,7 @@ enum AuthEnvironment { ) } - static func signInURL() -> URL { + static func signInURL(callbackURL: URL = AuthEnvironment.callbackURL) -> URL { // Build the after-sign-in callback URL that includes the native app return scheme. // The after-sign-in handler extracts tokens from the Stack Auth session // and redirects to the native app via the cmux:// callback scheme. diff --git a/Sources/Auth/AuthManager.swift b/Sources/Auth/AuthManager.swift index 842940a294..401ba7eb1b 100644 --- a/Sources/Auth/AuthManager.swift +++ b/Sources/Auth/AuthManager.swift @@ -1,5 +1,4 @@ import AppKit -import AuthenticationServices import CMUXAuthCore import Foundation import os @@ -8,34 +7,11 @@ import StackAuth import Security #endif -private final class AuthPresentationContext: NSObject, ASWebAuthenticationPresentationContextProviding { - static let shared = AuthPresentationContext() - - func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { - // ASWebAuthenticationSession invokes this on whichever thread called - // session.start(). When beginSignIn() fires from the socket command - // dispatch thread (cmux auth login), this callback lands off-main, - // and any NSApp access must hop to main before returning. - if Thread.isMainThread { - return Self.currentAnchor() - } - var result: ASPresentationAnchor = NSWindow() - DispatchQueue.main.sync { - result = Self.currentAnchor() - } - return result - } - - @MainActor - private static func currentAnchor() -> ASPresentationAnchor { - NSApp.keyWindow ?? NSApp.mainWindow ?? (NSApp.windows.first ?? NSWindow()) - } -} - enum AuthManagerError: LocalizedError { case invalidCallback case missingAccessToken case missingRefreshToken + case signInTimedOut var errorDescription: String? { switch self { @@ -54,6 +30,11 @@ enum AuthManagerError: LocalizedError { localized: "settings.account.error.missingRefreshToken", defaultValue: "Account refresh token is unavailable." ) + case .signInTimedOut: + return String( + localized: "settings.account.error.signInTimedOut", + defaultValue: "Sign in timed out. Try again." + ) } } } @@ -159,6 +140,7 @@ final class AuthManager: ObservableObject { @Published private(set) var isLoading = false @Published private(set) var isRestoringSession = false @Published private(set) var didCompleteBrowserSignIn = false + @Published private(set) var lastSignInError: AuthManagerError? @Published var selectedTeamID: String? { didSet { guard selectedTeamID != oldValue else { return } @@ -214,10 +196,11 @@ final class AuthManager: ObservableObject { } private var loginPollTask: Task? - private var webAuthSession: ASWebAuthenticationSession? private var nextBrowserSignInAttemptID: UInt64 = 0 private var activeBrowserSignInAttemptID: UInt64? + private var activeBrowserSignInAttemptState: String? private var signOutCancelledBrowserSignInAttemptID: UInt64? + private var signOutCancelledBrowserSignInAttemptState: String? private var authMutationGeneration: UInt64 = 0 private var currentAuthMutationKind: AuthMutationKind? @@ -229,74 +212,46 @@ final class AuthManager: ObservableObject { #if DEBUG func markBrowserSignInLoadingForTesting() { - _ = startBrowserSignInAttempt() + _ = startBrowserSignInAttempt(state: "test") } #endif func beginSignIn() { + lastSignInError = nil loginPollTask?.cancel() - webAuthSession?.cancel() - webAuthSession = nil - let attemptID = startBrowserSignInAttempt() - - let signInURL = AuthEnvironment.signInURL() - let callbackScheme = AuthEnvironment.callbackScheme - - let session = ASWebAuthenticationSession( - url: signInURL, - callbackURLScheme: callbackScheme - ) { [weak self] callbackURL, error in - Task { @MainActor [weak self] in - guard let self else { return } - guard self.isCurrentBrowserSignInAttempt(attemptID) else { return } - defer { - self.finishBrowserSignInAttempt(attemptID) - } - if let error { - self.authLog("auth.webauth failed: \(error)") - return - } - guard let callbackURL else { return } - let callbackPayload = AuthCallbackRouter.callbackPayload(from: callbackURL) - do { - try await self.handleCallbackURL(callbackURL) - if self.signOutCancelledBrowserSignInAttemptID == attemptID, - self.activeBrowserSignInAttemptID == nil, - let callbackPayload { - let didClear = await self.tokenStore.clearTokensIfCurrent( - accessToken: callbackPayload.accessToken, - refreshToken: callbackPayload.refreshToken - ) - if didClear { - self.clearSessionState(clearSelectedTeam: true) - } - self.signOutCancelledBrowserSignInAttemptID = nil - } - } catch { - self.authLog("auth.webauth callback failed: \(error)") - } - } + let signInState = UUID().uuidString + let callbackURL = AuthEnvironment.authCallbackURL(state: signInState) + let signInURL = AuthEnvironment.signInURL(callbackURL: callbackURL) + let attemptID = startBrowserSignInAttempt(state: signInState) + authLog("auth.browserSignIn begin url=\(Self.redactedURLDescription(signInURL))") + urlOpener(signInURL) + guard isCurrentBrowserSignInAttempt(attemptID) else { + return } - session.presentationContextProvider = AuthPresentationContext.shared - session.prefersEphemeralWebBrowserSession = false + } - if session.start() { - webAuthSession = session - } else { - authLog("auth.webauth: session.start() returned false") - finishBrowserSignInAttempt(attemptID) - } + func markBrowserSignInTimedOut() { + guard let attemptID = activeBrowserSignInAttemptID else { return } + lastSignInError = .signInTimedOut + finishBrowserSignInAttempt(attemptID) } - /// Starts the ASWebAuthenticationSession popup and awaits the user's - /// completion by observing isAuthenticated AND isLoading. Resolves when - /// authenticated, when the sign-in attempt settles unsuccessfully (popup - /// dismissed/cancelled/error), or when the deadline elapses. No polling - /// — the $isAuthenticated / $isLoading AsyncPublishers drive the wait. + /// Opens the sign-in page in the user's default browser and awaits the deep-link callback. + /// Resolves when authenticated, when the sign-in attempt settles unsuccessfully, + /// or when the deadline elapses. No polling — the $isAuthenticated / + /// $isLoading AsyncPublishers drive the wait. func beginSignInAndAwait(timeout: TimeInterval) async -> Bool { if isAuthenticated { return true } beginSignIn() - return await waitForSignInSettled(timeout: timeout) + if !isLoading { return isAuthenticated } + let signedIn = await waitForSignInSettled(timeout: timeout) + if signedIn || isAuthenticated { + return true + } + if isLoading { + markBrowserSignInTimedOut() + } + return isAuthenticated } /// Signs out and awaits the state to flip. signOut() is already async and @@ -319,9 +274,8 @@ final class AuthManager: ObservableObject { } group.addTask { @MainActor [weak self] in guard let self else { return false } - // Wait for isLoading to flip false after we started the - // popup. If authentication hasn't succeeded by then the - // user cancelled/errored and we can resolve early. + // Wait for isLoading to flip false after the browser sign-in starts. + // If authentication hasn't succeeded by then the attempt failed or timed out. for await loading in self.$isLoading.values { if !loading && !self.isAuthenticated { return false } if self.isAuthenticated { return true } @@ -455,8 +409,34 @@ final class AuthManager: ObservableObject { } func handleCallbackURL(_ url: URL) async throws { - guard let payload = AuthCallbackRouter.callbackPayload(from: url) else { - throw AuthManagerError.invalidCallback + let browserSignInAttemptID = activeBrowserSignInAttemptID + let callbackState = AuthCallbackRouter.callbackState(from: url) + let callbackPayload = AuthCallbackRouter.callbackPayload(from: url) + + if let activeState = activeBrowserSignInAttemptState, + callbackState != activeState { + authLog("auth.browserSignIn ignored stale callback state=\(callbackState ?? "nil")") + return + } + + guard let payload = callbackPayload else { + let error = AuthManagerError.invalidCallback + if let browserSignInAttemptID { + lastSignInError = error + finishBrowserSignInAttempt(browserSignInAttemptID) + } + throw error + } + if let cancelledState = signOutCancelledBrowserSignInAttemptState, + payload.state == cancelledState { + signOutCancelledBrowserSignInAttemptID = nil + signOutCancelledBrowserSignInAttemptState = nil + authLog("auth.browserSignIn ignored callback cancelledBySignOut state=\(payload.state ?? "nil")") + return + } + defer { + guard let browserSignInAttemptID else { return } + finishBrowserSignInAttempt(browserSignInAttemptID) } let mutationGeneration = beginAuthMutation(.signIn) @@ -484,6 +464,11 @@ final class AuthManager: ObservableObject { refreshToken: payload.refreshToken ) return + } catch { + if isCurrentAuthMutation(mutationGeneration), browserSignInAttemptID != nil { + lastSignInError = error as? AuthManagerError ?? .invalidCallback + } + throw error } guard await keepAuthMutationIfCurrent( mutationGeneration, @@ -493,6 +478,7 @@ final class AuthManager: ObservableObject { return } didCompleteBrowserSignIn = true + lastSignInError = nil } func seedTokensFromCLI(refreshToken: String, accessToken: String?) async { @@ -520,6 +506,7 @@ final class AuthManager: ObservableObject { lastKnownAccessToken = resolvedAccess do { try await refreshSession() + lastSignInError = nil authLog("seedTokensFromCLI: success user=\(currentUser?.primaryEmail ?? "nil")") } catch { authLog("seedTokensFromCLI: refreshSession failed: \(error)") @@ -577,6 +564,7 @@ final class AuthManager: ObservableObject { } func applySignInResult(_ result: SignInResult) { + lastSignInError = nil // Cache access token for fast synchronous reads lastKnownAccessToken = result.accessToken // Store tokens in keychain (fire-and-forget) @@ -597,6 +585,7 @@ final class AuthManager: ObservableObject { func signInWithCredential(email: String, password: String) async throws { authLog("signInWithCredential: email=\(email)") + lastSignInError = nil isLoading = true defer { isLoading = false } @@ -658,9 +647,11 @@ final class AuthManager: ObservableObject { selectedTeamID = Self.resolveTeamID(selectedTeamID: selectedTeamID, teams: teams) authLog("signInWithCredential: success user=\(user.primaryEmail ?? "nil") teams=\(teams.count) teamID=\(selectedTeamID ?? "nil")") didCompleteBrowserSignIn = true + lastSignInError = nil } func signOut() async { + lastSignInError = nil let signOutGeneration = beginAuthMutation(.signOut) cancelBrowserSignInForSignOut() let accessTokenAtSignOut = await tokenStore.currentAccessToken() @@ -795,6 +786,21 @@ final class AuthManager: ObservableObject { return redacted } + /// Renders URL structure for auth diagnostics without logging token-bearing query values. + nonisolated static func redactedURLDescription(_ url: URL) -> String { + let scheme = url.scheme ?? "nil" + let host = url.host ?? "" + let portPart = url.port.map { ":\($0)" } ?? "" + let path = url.path + let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] + let keysSummary = queryItems + .map { "\($0.name)(\($0.value?.count ?? 0))" } + .joined(separator: ",") + let queryPart = keysSummary.isEmpty ? "" : "?[\(keysSummary)]" + let fragmentPart = url.fragment.flatMap { $0.isEmpty ? nil : " #len=\($0.count)" } ?? "" + return "\(scheme)://\(host)\(portPart)\(path)\(queryPart)\(fragmentPart)" + } + #if DEBUG nonisolated static func redactedAuthLogMessageForTesting(_ message: String) -> String { redactedAuthLogMessage(message) @@ -849,6 +855,7 @@ final class AuthManager: ObservableObject { currentUser = nil isAuthenticated = false didCompleteBrowserSignIn = false + lastSignInError = nil if clearSelectedTeam { selectedTeamID = nil } @@ -892,13 +899,13 @@ final class AuthManager: ObservableObject { return false } - private func startBrowserSignInAttempt() -> UInt64 { + private func startBrowserSignInAttempt(state: String) -> UInt64 { nextBrowserSignInAttemptID &+= 1 let attemptID = nextBrowserSignInAttemptID activeBrowserSignInAttemptID = attemptID - if signOutCancelledBrowserSignInAttemptID == attemptID { - signOutCancelledBrowserSignInAttemptID = nil - } + activeBrowserSignInAttemptState = state + signOutCancelledBrowserSignInAttemptID = nil + signOutCancelledBrowserSignInAttemptState = nil isLoading = true return attemptID } @@ -911,20 +918,21 @@ final class AuthManager: ObservableObject { private func finishBrowserSignInAttempt(_ attemptID: UInt64) { guard activeBrowserSignInAttemptID == attemptID else { return } isLoading = false - webAuthSession = nil activeBrowserSignInAttemptID = nil + activeBrowserSignInAttemptState = nil if signOutCancelledBrowserSignInAttemptID == attemptID { signOutCancelledBrowserSignInAttemptID = nil + signOutCancelledBrowserSignInAttemptState = nil } } private func cancelBrowserSignInForSignOut() { if let attemptID = activeBrowserSignInAttemptID { signOutCancelledBrowserSignInAttemptID = attemptID + signOutCancelledBrowserSignInAttemptState = activeBrowserSignInAttemptState } activeBrowserSignInAttemptID = nil - webAuthSession?.cancel() - webAuthSession = nil + activeBrowserSignInAttemptState = nil isLoading = false } @@ -952,9 +960,8 @@ final class AuthManager: ObservableObject { } // Open in the user's actual default browser. urlsForApplications(toOpen:) // returns candidates in LaunchServices priority order (user's chosen - // default first). Skip cmux itself, since Info.plist advertises http/https - // at LSHandlerRank=Default and otherwise the app could re-open the URL in - // its own embedded WebView. + // default first). Skip cmux itself defensively so old LaunchServices + // registrations cannot route the auth URL back into this app. let ownBundleIDs: Set = { var ids: Set = [] if let id = Bundle.main.bundleIdentifier { ids.insert(id) } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 464e599551..9f19cb9321 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -8363,25 +8363,33 @@ private struct AuthSettingsRow: View { @ObservedObject var authManager: AuthManager var body: some View { - HStack(alignment: .center, spacing: 12) { - VStack(alignment: .leading, spacing: 2) { - Text(titleText) - .font(.system(size: 13, weight: .medium)) - if let subtitle = subtitleText { - Text(subtitle) - .font(.system(size: 11)) - .foregroundColor(.secondary) + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(titleText) + .font(.system(size: 13, weight: .medium)) + if let subtitle = subtitleText { + Text(subtitle) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } } + Spacer(minLength: 12) + if authManager.isLoading || authManager.isRestoringSession { + ProgressView().controlSize(.small) + } + Button(action: buttonAction) { + Text(buttonTitle) + } + .controlSize(.small) + .disabled(buttonIsDisabled) } - Spacer(minLength: 12) - if authManager.isLoading || authManager.isRestoringSession { - ProgressView().controlSize(.small) - } - Button(action: buttonAction) { - Text(buttonTitle) + + if let errorMessage = authManager.lastSignInError?.localizedDescription { + Text(errorMessage) + .font(.system(size: 11)) + .foregroundColor(.red) } - .controlSize(.small) - .disabled(authManager.isLoading || authManager.isRestoringSession) } .padding(.horizontal, 14) .padding(.vertical, 10) @@ -8426,6 +8434,10 @@ private struct AuthSettingsRow: View { ) } + private var buttonIsDisabled: Bool { + authManager.isRestoringSession || (authManager.isAuthenticated && authManager.isLoading) + } + private func buttonAction() { if authManager.isAuthenticated { Task { @MainActor in diff --git a/cmuxTests/AuthManagerExternalBrowserSignInTests.swift b/cmuxTests/AuthManagerExternalBrowserSignInTests.swift index 78cf50ce18..556ea18e92 100644 --- a/cmuxTests/AuthManagerExternalBrowserSignInTests.swift +++ b/cmuxTests/AuthManagerExternalBrowserSignInTests.swift @@ -40,6 +40,7 @@ struct AuthManagerExternalBrowserSignInTests { ) #expect(afterAuthReturnTo.contains("native_app_return_to=")) #expect(afterAuthReturnTo.contains("auth-callback")) + #expect(afterAuthReturnTo.contains("state=")) #expect(isLoadingAfterBegin) } } diff --git a/web/app/handler/after-sign-in/OpenNativeClient.tsx b/web/app/handler/after-sign-in/OpenNativeClient.tsx index 19c68a27e0..0b44f7273b 100644 --- a/web/app/handler/after-sign-in/OpenNativeClient.tsx +++ b/web/app/handler/after-sign-in/OpenNativeClient.tsx @@ -1,6 +1,12 @@ "use client"; +import { useEffect } from "react"; + export function OpenNativeClient({ href }: { href: string }) { + useEffect(() => { + window.location.assign(href); + }, [href]); + return (
value.startsWith(scheme)); +} + +export function nativeAuthCallbackForReturnTo( + value: string | null | undefined, +): string | null { + const scheme = NATIVE_SCHEMES.find((candidate) => + value?.startsWith(candidate), + ); + if (!scheme) return null; + + const callback = new URL(`${scheme}${NATIVE_AUTH_CALLBACK_TARGET}`); + try { + const source = new URL(value ?? ""); + const state = source.searchParams.get("state"); + if (state) callback.searchParams.set("state", state); + } catch {} + return callback.toString(); +} + +export type NativeHandoffArgs = { + refreshToken: string | undefined; + accessToken: string | undefined; +}; + +export function shouldEmitNativeHandoff(args: NativeHandoffArgs): boolean { + return Boolean(args.refreshToken && args.accessToken); +} diff --git a/web/app/handler/after-sign-in/page.tsx b/web/app/handler/after-sign-in/page.tsx index 13fa2d17c6..4b07e65a29 100644 --- a/web/app/handler/after-sign-in/page.tsx +++ b/web/app/handler/after-sign-in/page.tsx @@ -3,38 +3,44 @@ import { notFound, redirect } from "next/navigation"; import { stackServerApp } from "../../lib/stack"; import { env } from "../../env"; import { OpenNativeClient } from "./OpenNativeClient"; +import { + isNativeReturnScheme, + nativeAuthCallbackForReturnTo, + shouldEmitNativeHandoff, +} from "./native-handoff"; export const dynamic = "force-dynamic"; -const NATIVE_SCHEME = "cmux://"; -const NATIVE_SCHEMES = [NATIVE_SCHEME, "cmux-nightly://", "cmux-dev://"]; - function findStackCookie( cookieStore: { getAll: () => { name: string; value: string }[] }, - baseName: string + baseName: string, ): string | undefined { const all = cookieStore.getAll(); for (const prefix of ["__Host-", "__Secure-", ""]) { const withBranch = all.find( - (c) => c.name.startsWith(`${prefix}${baseName}--`) && c.value + (c) => c.name.startsWith(`${prefix}${baseName}--`) && c.value, ); if (withBranch) return withBranch.value; - const exact = all.find( - (c) => c.name === `${prefix}${baseName}` && c.value - ); + const exact = all.find((c) => c.name === `${prefix}${baseName}` && c.value); if (exact) return exact.value; } return undefined; } -function decodeAccessCookie(value: string | undefined): { refreshToken?: string; accessToken?: string } { +function decodeAccessCookie(value: string | undefined): { + refreshToken?: string; + accessToken?: string; +} { if (!value) return {}; const decoded = value.includes("%") ? decodeURIComponent(value) : value; if (!decoded.startsWith("[")) return { accessToken: decoded }; try { const arr = JSON.parse(decoded) as unknown[]; if (Array.isArray(arr) && arr.length >= 2) { - return { refreshToken: arr[0] as string, accessToken: arr[1] as string }; + return { + refreshToken: arr[0] as string, + accessToken: arr[1] as string, + }; } } catch {} return {}; @@ -52,19 +58,18 @@ function decodeRefreshCookie(value: string | undefined): string | undefined { } function buildNativeHref( - baseHref: string | null, + baseHref: string, refreshToken: string | undefined, - accessCookie: string | undefined + accessCookie: string | undefined, ): string | null { - if (!refreshToken || !accessCookie) return baseHref; - const href = baseHref ?? `${NATIVE_SCHEME}auth-callback`; + if (!refreshToken || !accessCookie) return null; try { - const url = new URL(href); + const url = new URL(baseHref); url.searchParams.set("stack_refresh", refreshToken); url.searchParams.set("stack_access", accessCookie); return url.toString(); } catch { - return `${NATIVE_SCHEME}auth-callback?stack_refresh=${encodeURIComponent(refreshToken)}&stack_access=${encodeURIComponent(accessCookie)}`; + return null; } } @@ -72,7 +77,9 @@ type Props = { searchParams?: Promise>; }; -export default async function AfterSignInPage({ searchParams: searchParamsPromise }: Props) { +export default async function AfterSignInPage({ + searchParams: searchParamsPromise, +}: Props) { const projectId = env.NEXT_PUBLIC_STACK_PROJECT_ID; if (!stackServerApp || !projectId) notFound(); @@ -85,13 +92,19 @@ export default async function AfterSignInPage({ searchParams: searchParamsPromis let refreshToken = parsedAccess.refreshToken ?? parsedRefresh; let accessToken = parsedAccess.accessToken; - let accessCookie = rawAccessCookie ? (rawAccessCookie.includes("%") ? decodeURIComponent(rawAccessCookie) : rawAccessCookie) : undefined; + let accessCookie = rawAccessCookie + ? rawAccessCookie.includes("%") + ? decodeURIComponent(rawAccessCookie) + : rawAccessCookie + : undefined; // Create a fresh session to get valid tokens for the native app try { const user = await stackServerApp.getUser({ or: "return-null" }); if (user) { - const session = await user.createSession({ expiresInMillis: 30 * 24 * 60 * 60 * 1000 }); + const session = await user.createSession({ + expiresInMillis: 30 * 24 * 60 * 60 * 1000, + }); const tokens = await session.getTokens(); if (tokens.refreshToken) refreshToken = tokens.refreshToken; if (tokens.accessToken) accessToken = tokens.accessToken; @@ -105,35 +118,45 @@ export default async function AfterSignInPage({ searchParams: searchParamsPromis } const searchParams = await searchParamsPromise; - const nativeReturnTo = typeof searchParams?.native_app_return_to === "string" - ? searchParams.native_app_return_to - : null; + const nativeReturnTo = + typeof searchParams?.native_app_return_to === "string" + ? searchParams.native_app_return_to + : null; // Native app deep link. Only emit the handoff when both tokens are // available; otherwise the OpenNativeClient would launch cmux with an empty // auth payload, which would produce a spurious "not signed in" flash. + const nativeCallbackHref = nativeAuthCallbackForReturnTo(nativeReturnTo); if ( - refreshToken && - accessCookie && - nativeReturnTo !== null && - NATIVE_SCHEMES.some((scheme) => nativeReturnTo.startsWith(scheme)) + shouldEmitNativeHandoff({ refreshToken, accessToken }) && + isNativeReturnScheme(nativeReturnTo) && + nativeCallbackHref ) { - const href = buildNativeHref(nativeReturnTo, refreshToken, accessCookie); + const href = buildNativeHref(nativeCallbackHref, refreshToken, accessCookie); if (href) return ; } // Web redirect (relative paths only). Reject protocol-relative paths like // "//evil.com" that Next.js would treat as external redirects. - const afterAuth = typeof searchParams?.after_auth_return_to === "string" - ? searchParams.after_auth_return_to - : null; + const afterAuth = + typeof searchParams?.after_auth_return_to === "string" + ? searchParams.after_auth_return_to + : null; if (afterAuth && afterAuth.startsWith("/") && !afterAuth.startsWith("//")) { redirect(afterAuth); } - // Fallback: try native app only when we actually have tokens to hand off. - if (refreshToken && accessCookie) { - const fallback = buildNativeHref(null, refreshToken, accessCookie); + // Fallback: try native app only when we can preserve the requested native scheme. + const fallbackReturnTo = nativeAuthCallbackForReturnTo(nativeReturnTo); + if ( + shouldEmitNativeHandoff({ refreshToken, accessToken }) && + fallbackReturnTo + ) { + const fallback = buildNativeHref( + fallbackReturnTo, + refreshToken, + accessCookie, + ); if (fallback) return ; } diff --git a/web/tests/native-handoff.test.ts b/web/tests/native-handoff.test.ts new file mode 100644 index 0000000000..e34045c6d0 --- /dev/null +++ b/web/tests/native-handoff.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; +import { + isNativeReturnScheme, + nativeAuthCallbackForReturnTo, + shouldEmitNativeHandoff, +} from "../app/handler/after-sign-in/native-handoff"; + +describe("native-handoff", () => { + describe("isNativeReturnScheme", () => { + test("returns true for cmux native schemes", () => { + expect(isNativeReturnScheme("cmux://auth-callback")).toBe(true); + expect(isNativeReturnScheme("cmux-nightly://auth-callback")).toBe(true); + expect(isNativeReturnScheme("cmux-dev://auth-callback")).toBe(true); + }); + + test("returns false for non-native schemes", () => { + expect(isNativeReturnScheme("https://cmux.com")).toBe(false); + expect(isNativeReturnScheme("cmuxapp://auth-callback")).toBe(false); + expect(isNativeReturnScheme(null)).toBe(false); + expect(isNativeReturnScheme(undefined)).toBe(false); + expect(isNativeReturnScheme("")).toBe(false); + }); + }); + + describe("nativeAuthCallbackForReturnTo", () => { + test("coerces native return targets to auth-callback", () => { + expect(nativeAuthCallbackForReturnTo("cmux://workspace/123")).toBe( + "cmux://auth-callback", + ); + expect( + nativeAuthCallbackForReturnTo("cmux-nightly://workspace/123"), + ).toBe("cmux-nightly://auth-callback"); + expect(nativeAuthCallbackForReturnTo("cmux-dev://workspace/123")).toBe( + "cmux-dev://auth-callback", + ); + }); + + test("preserves the callback state", () => { + expect( + nativeAuthCallbackForReturnTo("cmux-dev://auth-callback?state=abc123"), + ).toBe("cmux-dev://auth-callback?state=abc123"); + }); + + test("returns null for non-native return targets", () => { + expect(nativeAuthCallbackForReturnTo("https://cmux.com")).toBe(null); + expect(nativeAuthCallbackForReturnTo(null)).toBe(null); + expect(nativeAuthCallbackForReturnTo(undefined)).toBe(null); + }); + }); + + describe("shouldEmitNativeHandoff", () => { + test("returns true when both tokens are present", () => { + expect( + shouldEmitNativeHandoff({ + refreshToken: "refresh", + accessToken: "access", + }), + ).toBe(true); + }); + + test("returns false when refreshToken is missing", () => { + expect( + shouldEmitNativeHandoff({ + refreshToken: undefined, + accessToken: "access", + }), + ).toBe(false); + }); + + test("returns false when accessToken is empty string", () => { + expect( + shouldEmitNativeHandoff({ + refreshToken: "refresh", + accessToken: "", + }), + ).toBe(false); + }); + + test("returns false when accessToken is undefined", () => { + expect( + shouldEmitNativeHandoff({ + refreshToken: "refresh", + accessToken: undefined, + }), + ).toBe(false); + }); + }); +}); From 0b3dff34a6191fd17954bcb9da3daeb1c1566fa8 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Thu, 28 May 2026 20:55:18 -0700 Subject: [PATCH 03/12] Fix auth callback defer compile error --- Sources/Auth/AuthManager.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Sources/Auth/AuthManager.swift b/Sources/Auth/AuthManager.swift index 401ba7eb1b..75f22035cb 100644 --- a/Sources/Auth/AuthManager.swift +++ b/Sources/Auth/AuthManager.swift @@ -435,8 +435,9 @@ final class AuthManager: ObservableObject { return } defer { - guard let browserSignInAttemptID else { return } - finishBrowserSignInAttempt(browserSignInAttemptID) + if let browserSignInAttemptID { + finishBrowserSignInAttempt(browserSignInAttemptID) + } } let mutationGeneration = beginAuthMutation(.signIn) @@ -809,7 +810,7 @@ final class AuthManager: ObservableObject { // ISO8601DateFormatter is expensive to construct (calendar + locale + // time zone). Reuse one instance across the high-frequency authLog path. - private static let logTimestampFormatter: ISO8601DateFormatter = { + private nonisolated static let logTimestampFormatter: ISO8601DateFormatter = { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime] return f From b53968ac7655b26f8ce8b37dac9be5584d27b233 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Thu, 28 May 2026 21:01:39 -0700 Subject: [PATCH 04/12] Fix auth sign-in test build issues --- Sources/Auth/AuthManager.swift | 25 +++++++++++++------ ...uthManagerExternalBrowserSignInTests.swift | 1 + 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/Sources/Auth/AuthManager.swift b/Sources/Auth/AuthManager.swift index 75f22035cb..586e3099b9 100644 --- a/Sources/Auth/AuthManager.swift +++ b/Sources/Auth/AuthManager.swift @@ -741,7 +741,7 @@ final class AuthManager: ObservableObject { let redactedMessage = redactedAuthLogMessage(message) authLogger.log(level: authLogType(for: redactedMessage), "\(redactedMessage, privacy: .public)") #if DEBUG - let line = "[\(Self.logTimestampFormatter.string(from: Date()))] auth: \(redactedMessage)\n" + let line = "[\(Self.logTimestampString())] auth: \(redactedMessage)\n" let path = authDebugLogPath if let handle = FileHandle(forWritingAtPath: path) { handle.seekToEndOfFile() @@ -808,13 +808,22 @@ final class AuthManager: ObservableObject { } #endif - // ISO8601DateFormatter is expensive to construct (calendar + locale + - // time zone). Reuse one instance across the high-frequency authLog path. - private nonisolated static let logTimestampFormatter: ISO8601DateFormatter = { - let f = ISO8601DateFormatter() - f.formatOptions = [.withInternetDateTime] - return f - }() + // ISO8601DateFormatter is expensive to construct and is not Sendable. + // Keep one formatter per thread so DEBUG auth logging stays off the main actor. + private nonisolated static func logTimestampString() -> String { + let key = "cmux.auth.logTimestampFormatter" + let dictionary = Thread.current.threadDictionary + let formatter: ISO8601DateFormatter + if let cachedFormatter = dictionary[key] as? ISO8601DateFormatter { + formatter = cachedFormatter + } else { + let newFormatter = ISO8601DateFormatter() + newFormatter.formatOptions = [.withInternetDateTime] + dictionary[key] = newFormatter + formatter = newFormatter + } + return formatter.string(from: Date()) + } private func authLog(_ message: String) { Self.authLog(message) diff --git a/cmuxTests/AuthManagerExternalBrowserSignInTests.swift b/cmuxTests/AuthManagerExternalBrowserSignInTests.swift index 556ea18e92..eb526cc829 100644 --- a/cmuxTests/AuthManagerExternalBrowserSignInTests.swift +++ b/cmuxTests/AuthManagerExternalBrowserSignInTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing +import CMUXAuthCore #if canImport(cmux_DEV) @testable import cmux_DEV From 02b19e1ee50eeaf6ce20b5070534d773ea104672 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Thu, 28 May 2026 21:22:18 -0700 Subject: [PATCH 05/12] Address auth sign-in review feedback --- CHANGELOG.md | 2 +- Resources/Localizable.xcstrings | 100 ++++++++++++++++++++++++- Sources/Auth/AuthManager.swift | 13 +++- Sources/cmuxApp.swift | 4 +- web/app/handler/after-sign-in/page.tsx | 16 ---- 5 files changed, 110 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79e4158324..21509a8acc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to cmux are documented here. ## [Unreleased] ### Fixed -- Open Settings > Account sign-in in the user's default browser, complete through the cmux auth callback, and guard malformed native auth handoffs ([#3617](https://github.com/manaflow-ai/cmux/issues/3617)) +- Settings sign-in now opens in your default browser and displays localized error messages when sign-in fails ([#3617](https://github.com/manaflow-ai/cmux/issues/3617)) ## [0.64.10] - 2026-05-23 diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 178aa18d3d..6786ce265c 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -120946,22 +120946,64 @@ "settings.account.error.signInTimedOut": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "انتهت مهلة تسجيل الدخول. حاول مرة أخرى." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prijava je istekla. Pokušajte ponovo." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Login fik timeout. Prøv igen." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Anmeldung ist abgelaufen. Versuche es erneut." + } + }, "en": { "stringUnit": { "state": "translated", "value": "Sign in timed out. Try again." } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se agotó el tiempo para iniciar sesión. Inténtalo de nuevo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La connexion a expiré. Réessayez." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Accesso scaduto. Riprova." + } + }, "ja": { "stringUnit": { "state": "translated", "value": "サインインがタイムアウトしました。もう一度お試しください。" } }, - "uk": { + "km": { "stringUnit": { "state": "translated", - "value": "Час очікування входу минув. Спробуйте ще раз." + "value": "ការចូលបានអស់ពេល។ សូមព្យាយាមម្តងទៀត។" } }, "ko": { @@ -120969,6 +121011,60 @@ "state": "translated", "value": "로그인 시간이 초과되었습니다. 다시 시도하세요." } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Innloggingen tidsavbrøt. Prøv igjen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Logowanie przekroczyło limit czasu. Spróbuj ponownie." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O login expirou. Tente novamente." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Время ожидания входа истекло. Попробуйте еще раз." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การลงชื่อเข้าใช้หมดเวลา ลองอีกครั้ง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Oturum açma zaman aşımına uğradı. Tekrar deneyin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Час очікування входу минув. Спробуйте ще раз." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "登录超时。请重试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "登入逾時。請再試一次。" + } } } }, diff --git a/Sources/Auth/AuthManager.swift b/Sources/Auth/AuthManager.swift index 586e3099b9..a663d240cb 100644 --- a/Sources/Auth/AuthManager.swift +++ b/Sources/Auth/AuthManager.swift @@ -14,6 +14,10 @@ enum AuthManagerError: LocalizedError { case signInTimedOut var errorDescription: String? { + userFacingMessage + } + + var userFacingMessage: String { switch self { case .invalidCallback: return String( @@ -152,6 +156,10 @@ final class AuthManager: ObservableObject { Self.resolveTeamID(selectedTeamID: selectedTeamID, teams: availableTeams) } + var userFacingSignInErrorMessage: String? { + lastSignInError?.userFacingMessage + } + let requiresAuthenticationGate = false private let client: any AuthClientProtocol @@ -222,12 +230,9 @@ final class AuthManager: ObservableObject { let signInState = UUID().uuidString let callbackURL = AuthEnvironment.authCallbackURL(state: signInState) let signInURL = AuthEnvironment.signInURL(callbackURL: callbackURL) - let attemptID = startBrowserSignInAttempt(state: signInState) + _ = startBrowserSignInAttempt(state: signInState) authLog("auth.browserSignIn begin url=\(Self.redactedURLDescription(signInURL))") urlOpener(signInURL) - guard isCurrentBrowserSignInAttempt(attemptID) else { - return - } } func markBrowserSignInTimedOut() { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 9f19cb9321..87e97a2280 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -8385,7 +8385,7 @@ private struct AuthSettingsRow: View { .disabled(buttonIsDisabled) } - if let errorMessage = authManager.lastSignInError?.localizedDescription { + if let errorMessage = authManager.userFacingSignInErrorMessage { Text(errorMessage) .font(.system(size: 11)) .foregroundColor(.red) @@ -8435,7 +8435,7 @@ private struct AuthSettingsRow: View { } private var buttonIsDisabled: Bool { - authManager.isRestoringSession || (authManager.isAuthenticated && authManager.isLoading) + authManager.isRestoringSession || authManager.isLoading } private func buttonAction() { diff --git a/web/app/handler/after-sign-in/page.tsx b/web/app/handler/after-sign-in/page.tsx index 4b07e65a29..720f87ceb2 100644 --- a/web/app/handler/after-sign-in/page.tsx +++ b/web/app/handler/after-sign-in/page.tsx @@ -4,7 +4,6 @@ import { stackServerApp } from "../../lib/stack"; import { env } from "../../env"; import { OpenNativeClient } from "./OpenNativeClient"; import { - isNativeReturnScheme, nativeAuthCallbackForReturnTo, shouldEmitNativeHandoff, } from "./native-handoff"; @@ -129,7 +128,6 @@ export default async function AfterSignInPage({ const nativeCallbackHref = nativeAuthCallbackForReturnTo(nativeReturnTo); if ( shouldEmitNativeHandoff({ refreshToken, accessToken }) && - isNativeReturnScheme(nativeReturnTo) && nativeCallbackHref ) { const href = buildNativeHref(nativeCallbackHref, refreshToken, accessCookie); @@ -146,19 +144,5 @@ export default async function AfterSignInPage({ redirect(afterAuth); } - // Fallback: try native app only when we can preserve the requested native scheme. - const fallbackReturnTo = nativeAuthCallbackForReturnTo(nativeReturnTo); - if ( - shouldEmitNativeHandoff({ refreshToken, accessToken }) && - fallbackReturnTo - ) { - const fallback = buildNativeHref( - fallbackReturnTo, - refreshToken, - accessCookie, - ); - if (fallback) return ; - } - redirect("/"); } From 0906d8741d76ab68d67c67a85f60ddc9a33ddbf5 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Thu, 28 May 2026 21:43:56 -0700 Subject: [PATCH 06/12] Handle abandoned browser sign-in attempts --- Sources/Auth/AuthManager.swift | 47 ++++++++---- ...uthManagerExternalBrowserSignInTests.swift | 73 +++++++++++++++++++ 2 files changed, 107 insertions(+), 13 deletions(-) diff --git a/Sources/Auth/AuthManager.swift b/Sources/Auth/AuthManager.swift index a663d240cb..872054e083 100644 --- a/Sources/Auth/AuthManager.swift +++ b/Sources/Auth/AuthManager.swift @@ -204,6 +204,7 @@ final class AuthManager: ObservableObject { } private var loginPollTask: Task? + private var browserSignInTimeoutTask: Task? private var nextBrowserSignInAttemptID: UInt64 = 0 private var activeBrowserSignInAttemptID: UInt64? private var activeBrowserSignInAttemptState: String? @@ -211,6 +212,7 @@ final class AuthManager: ObservableObject { private var signOutCancelledBrowserSignInAttemptState: String? private var authMutationGeneration: UInt64 = 0 private var currentAuthMutationKind: AuthMutationKind? + private static let defaultBrowserSignInTimeout: TimeInterval = 5 * 60 private enum AuthMutationKind { case restore @@ -224,13 +226,14 @@ final class AuthManager: ObservableObject { } #endif - func beginSignIn() { + func beginSignIn(timeout: TimeInterval = Self.defaultBrowserSignInTimeout) { lastSignInError = nil loginPollTask?.cancel() let signInState = UUID().uuidString let callbackURL = AuthEnvironment.authCallbackURL(state: signInState) let signInURL = AuthEnvironment.signInURL(callbackURL: callbackURL) - _ = startBrowserSignInAttempt(state: signInState) + let attemptID = startBrowserSignInAttempt(state: signInState) + scheduleBrowserSignInTimeout(attemptID: attemptID, timeout: timeout) authLog("auth.browserSignIn begin url=\(Self.redactedURLDescription(signInURL))") urlOpener(signInURL) } @@ -247,7 +250,7 @@ final class AuthManager: ObservableObject { /// $isLoading AsyncPublishers drive the wait. func beginSignInAndAwait(timeout: TimeInterval) async -> Bool { if isAuthenticated { return true } - beginSignIn() + beginSignIn(timeout: timeout) if !isLoading { return isAuthenticated } let signedIn = await waitForSignInSettled(timeout: timeout) if signedIn || isAuthenticated { @@ -418,9 +421,16 @@ final class AuthManager: ObservableObject { let callbackState = AuthCallbackRouter.callbackState(from: url) let callbackPayload = AuthCallbackRouter.callbackPayload(from: url) - if let activeState = activeBrowserSignInAttemptState, - callbackState != activeState { - authLog("auth.browserSignIn ignored stale callback state=\(callbackState ?? "nil")") + if let cancelledState = signOutCancelledBrowserSignInAttemptState, + callbackState == cancelledState { + signOutCancelledBrowserSignInAttemptID = nil + signOutCancelledBrowserSignInAttemptState = nil + authLog("auth.browserSignIn ignored callback cancelledBySignOut state=\(callbackState ?? "nil")") + return + } + + guard callbackState == activeBrowserSignInAttemptState else { + authLog("auth.browserSignIn ignored stale callback state=\(callbackState ?? "nil") activeState=\(activeBrowserSignInAttemptState ?? "nil")") return } @@ -432,13 +442,6 @@ final class AuthManager: ObservableObject { } throw error } - if let cancelledState = signOutCancelledBrowserSignInAttemptState, - payload.state == cancelledState { - signOutCancelledBrowserSignInAttemptID = nil - signOutCancelledBrowserSignInAttemptState = nil - authLog("auth.browserSignIn ignored callback cancelledBySignOut state=\(payload.state ?? "nil")") - return - } defer { if let browserSignInAttemptID { finishBrowserSignInAttempt(browserSignInAttemptID) @@ -930,8 +933,24 @@ final class AuthManager: ObservableObject { && signOutCancelledBrowserSignInAttemptID != attemptID } + private func scheduleBrowserSignInTimeout(attemptID: UInt64, timeout: TimeInterval) { + browserSignInTimeoutTask?.cancel() + let maxSeconds: Double = 24 * 60 * 60 + let clamped = max(0, min(timeout, maxSeconds)) + browserSignInTimeoutTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: UInt64(clamped * 1_000_000_000)) + guard !Task.isCancelled, let self, self.isCurrentBrowserSignInAttempt(attemptID) else { + return + } + self.lastSignInError = .signInTimedOut + self.finishBrowserSignInAttempt(attemptID) + } + } + private func finishBrowserSignInAttempt(_ attemptID: UInt64) { guard activeBrowserSignInAttemptID == attemptID else { return } + browserSignInTimeoutTask?.cancel() + browserSignInTimeoutTask = nil isLoading = false activeBrowserSignInAttemptID = nil activeBrowserSignInAttemptState = nil @@ -942,6 +961,8 @@ final class AuthManager: ObservableObject { } private func cancelBrowserSignInForSignOut() { + browserSignInTimeoutTask?.cancel() + browserSignInTimeoutTask = nil if let attemptID = activeBrowserSignInAttemptID { signOutCancelledBrowserSignInAttemptID = attemptID signOutCancelledBrowserSignInAttemptState = activeBrowserSignInAttemptState diff --git a/cmuxTests/AuthManagerExternalBrowserSignInTests.swift b/cmuxTests/AuthManagerExternalBrowserSignInTests.swift index eb526cc829..dbd094d46c 100644 --- a/cmuxTests/AuthManagerExternalBrowserSignInTests.swift +++ b/cmuxTests/AuthManagerExternalBrowserSignInTests.swift @@ -44,6 +44,79 @@ struct AuthManagerExternalBrowserSignInTests { #expect(afterAuthReturnTo.contains("state=")) #expect(isLoadingAfterBegin) } + + @Test + func beginSignInTimesOutWhenBrowserCallbackDoesNotReturn() async throws { + let suiteName = "AuthManagerExternalBrowserSignInTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let manager = AuthManager( + client: AuthManagerExternalBrowserSignInTestClient(), + tokenStore: AuthManagerExternalBrowserSignInTestTokenStore(), + settingsStore: AuthSettingsStore(userDefaults: defaults), + urlOpener: { _ in } + ) + await manager.awaitBootstrapped() + + manager.beginSignIn(timeout: 0) + try await Task.sleep(nanoseconds: 20_000_000) + + #expect(!manager.isLoading) + #expect(manager.userFacingSignInErrorMessage == AuthManagerError.signInTimedOut.userFacingMessage) + } + + @Test + func stateBearingCallbackAfterTimeoutIsIgnored() async throws { + let suiteName = "AuthManagerExternalBrowserSignInTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + var openedURL: URL? + let tokenStore = AuthManagerExternalBrowserSignInTestTokenStore() + let manager = AuthManager( + client: AuthManagerExternalBrowserSignInTestClient(), + tokenStore: tokenStore, + settingsStore: AuthSettingsStore(userDefaults: defaults), + urlOpener: { url in + openedURL = url + } + ) + await manager.awaitBootstrapped() + + manager.beginSignIn(timeout: 0) + let signInURL = try #require(openedURL) + let state = try #require(callbackState(fromSignInURL: signInURL)) + try await Task.sleep(nanoseconds: 20_000_000) + + let staleCallbackURL = try #require( + URL(string: "cmux://auth-callback?stack_refresh=refresh&stack_access=access&state=\(state)") + ) + try await manager.handleCallbackURL(staleCallbackURL) + + #expect(!manager.isAuthenticated) + #expect(await tokenStore.getStoredAccessToken() == nil) + #expect(await tokenStore.getStoredRefreshToken() == nil) + } + + private func callbackState(fromSignInURL url: URL) -> String? { + let signInComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) + let afterAuthReturnTo = signInComponents?.queryItems? + .first { $0.name == "after_auth_return_to" }? + .value + let afterAuthComponents = afterAuthReturnTo.flatMap { + URLComponents(string: $0) + } + let nativeReturnTo = afterAuthComponents?.queryItems? + .first { $0.name == "native_app_return_to" }? + .value + let nativeComponents = nativeReturnTo.flatMap { + URLComponents(string: $0) + } + return nativeComponents?.queryItems? + .first { $0.name == "state" }? + .value + } } private struct AuthManagerExternalBrowserSignInTestClient: AuthClientProtocol { From 541fdf4bdfbd1a4f00db67146eb6aadb8bf3b6da Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Thu, 28 May 2026 21:46:55 -0700 Subject: [PATCH 07/12] Fix auth sign-in timeout default argument --- Sources/Auth/AuthManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Auth/AuthManager.swift b/Sources/Auth/AuthManager.swift index 872054e083..b3d1b58d87 100644 --- a/Sources/Auth/AuthManager.swift +++ b/Sources/Auth/AuthManager.swift @@ -226,7 +226,7 @@ final class AuthManager: ObservableObject { } #endif - func beginSignIn(timeout: TimeInterval = Self.defaultBrowserSignInTimeout) { + func beginSignIn(timeout: TimeInterval = AuthManager.defaultBrowserSignInTimeout) { lastSignInError = nil loginPollTask?.cancel() let signInState = UUID().uuidString From 6775e2ccb0c8791cee1d7d613be22807a60d6d47 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Thu, 28 May 2026 21:52:22 -0700 Subject: [PATCH 08/12] Avoid auth timeout default isolation warning --- Sources/Auth/AuthManager.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/Auth/AuthManager.swift b/Sources/Auth/AuthManager.swift index b3d1b58d87..f452f1b286 100644 --- a/Sources/Auth/AuthManager.swift +++ b/Sources/Auth/AuthManager.swift @@ -212,7 +212,6 @@ final class AuthManager: ObservableObject { private var signOutCancelledBrowserSignInAttemptState: String? private var authMutationGeneration: UInt64 = 0 private var currentAuthMutationKind: AuthMutationKind? - private static let defaultBrowserSignInTimeout: TimeInterval = 5 * 60 private enum AuthMutationKind { case restore @@ -226,7 +225,7 @@ final class AuthManager: ObservableObject { } #endif - func beginSignIn(timeout: TimeInterval = AuthManager.defaultBrowserSignInTimeout) { + func beginSignIn(timeout: TimeInterval = 300) { lastSignInError = nil loginPollTask?.cancel() let signInState = UUID().uuidString From 3cd027f0be536c268b2783d0490ef838333fb8f6 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Thu, 28 May 2026 22:25:27 -0700 Subject: [PATCH 09/12] test: cover stateless idle auth callbacks --- ...uthManagerExternalBrowserSignInTests.swift | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/cmuxTests/AuthManagerExternalBrowserSignInTests.swift b/cmuxTests/AuthManagerExternalBrowserSignInTests.swift index dbd094d46c..9548a51a2e 100644 --- a/cmuxTests/AuthManagerExternalBrowserSignInTests.swift +++ b/cmuxTests/AuthManagerExternalBrowserSignInTests.swift @@ -99,6 +99,31 @@ struct AuthManagerExternalBrowserSignInTests { #expect(await tokenStore.getStoredRefreshToken() == nil) } + @Test + func statelessCallbackWithoutActiveAttemptIsIgnored() async throws { + let suiteName = "AuthManagerExternalBrowserSignInTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let tokenStore = AuthManagerExternalBrowserSignInTestTokenStore() + let manager = AuthManager( + client: AuthManagerExternalBrowserSignInTestClient(), + tokenStore: tokenStore, + settingsStore: AuthSettingsStore(userDefaults: defaults), + urlOpener: { _ in } + ) + await manager.awaitBootstrapped() + + let callbackURL = try #require( + URL(string: "cmux://auth-callback?stack_refresh=refresh&stack_access=access") + ) + try await manager.handleCallbackURL(callbackURL) + + #expect(!manager.isAuthenticated) + #expect(await tokenStore.getStoredAccessToken() == nil) + #expect(await tokenStore.getStoredRefreshToken() == nil) + } + private func callbackState(fromSignInURL url: URL) -> String? { let signInComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) let afterAuthReturnTo = signInComponents?.queryItems? From 8d84d1e0b6ce8b7dd1ca11c9dd1b910bb17ca612 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Thu, 28 May 2026 22:25:47 -0700 Subject: [PATCH 10/12] fix: reject idle stateless auth callbacks --- Sources/Auth/AuthManager.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/Auth/AuthManager.swift b/Sources/Auth/AuthManager.swift index f452f1b286..29470b4a71 100644 --- a/Sources/Auth/AuthManager.swift +++ b/Sources/Auth/AuthManager.swift @@ -428,7 +428,12 @@ final class AuthManager: ObservableObject { return } - guard callbackState == activeBrowserSignInAttemptState else { + guard let activeState = activeBrowserSignInAttemptState else { + authLog("auth.browserSignIn ignored callback without active attempt state=\(callbackState ?? "nil")") + return + } + + guard callbackState == activeState else { authLog("auth.browserSignIn ignored stale callback state=\(callbackState ?? "nil") activeState=\(activeBrowserSignInAttemptState ?? "nil")") return } From 386039ade2f43623588985ee30b4b584025f4247 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Thu, 28 May 2026 22:49:03 -0700 Subject: [PATCH 11/12] test: use stateful auth callbacks in sign-out races --- Sources/Auth/AuthManager.swift | 6 ++++-- cmuxTests/SocketControlPasswordStoreTests.swift | 10 ++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Sources/Auth/AuthManager.swift b/Sources/Auth/AuthManager.swift index 29470b4a71..59472016d6 100644 --- a/Sources/Auth/AuthManager.swift +++ b/Sources/Auth/AuthManager.swift @@ -220,8 +220,10 @@ final class AuthManager: ObservableObject { } #if DEBUG - func markBrowserSignInLoadingForTesting() { - _ = startBrowserSignInAttempt(state: "test") + @discardableResult + func markBrowserSignInLoadingForTesting(state: String = "test") -> String { + _ = startBrowserSignInAttempt(state: state) + return state } #endif diff --git a/cmuxTests/SocketControlPasswordStoreTests.swift b/cmuxTests/SocketControlPasswordStoreTests.swift index fef69a4ce9..aad9e46953 100644 --- a/cmuxTests/SocketControlPasswordStoreTests.swift +++ b/cmuxTests/SocketControlPasswordStoreTests.swift @@ -307,7 +307,10 @@ final class AuthManagerSignOutTests: XCTestCase { await manager.awaitBootstrapped() await tokenStore.suspendNextSetTokens() - let callbackURL = try XCTUnwrap(URL(string: "cmux://auth-callback?stack_refresh=refresh-after-signout&stack_access=access-after-signout")) + let signInState = manager.markBrowserSignInLoadingForTesting(state: UUID().uuidString) + let callbackURL = try XCTUnwrap(URL( + string: "cmux://auth-callback?stack_refresh=refresh-after-signout&stack_access=access-after-signout&state=\(signInState)" + )) let callbackTask = Task { @MainActor in try await manager.handleCallbackURL(callbackURL) } @@ -354,7 +357,10 @@ final class AuthManagerSignOutTests: XCTestCase { } await client.waitForSignOutStarted() - let callbackURL = try XCTUnwrap(URL(string: "cmux://auth-callback?stack_refresh=new-refresh&stack_access=new-access")) + let signInState = manager.markBrowserSignInLoadingForTesting(state: UUID().uuidString) + let callbackURL = try XCTUnwrap(URL( + string: "cmux://auth-callback?stack_refresh=new-refresh&stack_access=new-access&state=\(signInState)" + )) try await manager.handleCallbackURL(callbackURL) await client.resumeSignOut() await signOutTask.value From d3a5769879d4b26c024019cec6432f1aff73aafe Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Thu, 28 May 2026 23:09:59 -0700 Subject: [PATCH 12/12] fix: keep auth loading scoped to browser attempt --- Sources/Auth/AuthManager.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Sources/Auth/AuthManager.swift b/Sources/Auth/AuthManager.swift index 59472016d6..fa65096331 100644 --- a/Sources/Auth/AuthManager.swift +++ b/Sources/Auth/AuthManager.swift @@ -455,9 +455,6 @@ final class AuthManager: ObservableObject { } let mutationGeneration = beginAuthMutation(.signIn) - isLoading = true - defer { isLoading = false } - await tokenStore.seed( accessToken: payload.accessToken, refreshToken: payload.refreshToken