diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 439fc327d557..700c1661f861 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -204,7 +204,6 @@ 527 CLI/CLISocketPathResolver.swift 526 Packages/macOS/CmuxSettings/Sources/CmuxSettings/SocketControl/SocketControlSettings.swift 524 CLI/CMUXCLI+AutoNaming.swift -524 Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/HostBrowserSignInFlowTests.swift 522 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AutomationSection.swift 520 CLI/CMUXCLI+AmpExtension.swift 520 cmuxTests/MainWindowVisibilityControllerTests.swift diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/BrowserSignIn/ASWebBrowserAuthSessionFactory.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/BrowserSignIn/ASWebBrowserAuthSessionFactory.swift index 746328515c20..d3b1f529e881 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/BrowserSignIn/ASWebBrowserAuthSessionFactory.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/BrowserSignIn/ASWebBrowserAuthSessionFactory.swift @@ -18,7 +18,7 @@ public final class ASWebBrowserAuthSessionFactory: HostBrowserAuthSessionFactory public func makeSession( signInURL: URL, callbackScheme: String, - completion: @escaping @MainActor (URL?) -> Void + completion: @escaping @MainActor (HostBrowserAuthSessionResult) -> Void ) -> any HostBrowserAuthSession { log.log("auth.webauth.makeSession signInURL=\(signInURL.absoluteString) callbackScheme=\(callbackScheme)") let session = ASWebAuthenticationSession( @@ -41,10 +41,11 @@ public final class ASWebBrowserAuthSessionFactory: HostBrowserAuthSessionFactory /// boundary when that off-main delivery happens. This bridge carries no /// isolation assumption and hops to the main actor itself. nonisolated func sessionCompletionBridge( - completion: @escaping @MainActor (URL?) -> Void + completion: @escaping @MainActor (HostBrowserAuthSessionResult) -> Void ) -> @Sendable (URL?, (any Error)?) -> Void { let log = self.log return { callbackURL, error in + let result = self.sessionResult(callbackURL: callbackURL, error: error) Task { @MainActor in if let error { let nsError = error as NSError @@ -53,10 +54,39 @@ public final class ASWebBrowserAuthSessionFactory: HostBrowserAuthSessionFactory } else { log.log("auth.webauth.completion error=nil callback=\(callbackURL == nil ? "nil" : "present")") } - completion(callbackURL) + completion(result) } } } + + nonisolated private func sessionResult( + callbackURL: URL?, + error: (any Error)? + ) -> HostBrowserAuthSessionResult { + if let callbackURL { + return .callback(callbackURL) + } + guard let error else { + return .failed(reason: "missing_callback") + } + let nsError = error as NSError + if nsError.domain == ASWebAuthenticationSessionError.errorDomain { + switch nsError.code { + case ASWebAuthenticationSessionError.Code.canceledLogin.rawValue: + return .cancelled(reason: "canceled_login") + case ASWebAuthenticationSessionError.Code.presentationContextInvalid.rawValue: + return .failed(reason: "presentation_context_invalid") + case ASWebAuthenticationSessionError.Code.presentationContextNotProvided.rawValue: + return .failed(reason: "presentation_context_not_provided") + default: + return .failed(reason: "aswebauthentication_\(nsError.code)") + } + } + if nsError.domain == NSURLErrorDomain { + return .failed(reason: "network_\(nsError.code)") + } + return .failed(reason: "error_\(nsError.code)") + } } /// Wraps one `ASWebAuthenticationSession` as a ``HostBrowserAuthSession``. diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/BrowserSignIn/HostBrowserAuthSession.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/BrowserSignIn/HostBrowserAuthSession.swift index dfcfd330b489..48acaf7c3b29 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/BrowserSignIn/HostBrowserAuthSession.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/BrowserSignIn/HostBrowserAuthSession.swift @@ -21,10 +21,10 @@ public protocol HostBrowserAuthSessionFactory { /// - signInURL: The hosted sign-in page URL. /// - callbackScheme: The custom scheme the callback redirect uses. /// - completion: Delivered exactly once on the main actor with the - /// callback URL, or `nil` on error/cancellation. + /// browser session's terminal result. func makeSession( signInURL: URL, callbackScheme: String, - completion: @escaping @MainActor (URL?) -> Void + completion: @escaping @MainActor (HostBrowserAuthSessionResult) -> Void ) -> any HostBrowserAuthSession } diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/BrowserSignIn/HostBrowserAuthSessionResult.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/BrowserSignIn/HostBrowserAuthSessionResult.swift new file mode 100644 index 000000000000..7b6959e0c19b --- /dev/null +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/BrowserSignIn/HostBrowserAuthSessionResult.swift @@ -0,0 +1,11 @@ +public import Foundation + +/// The terminal result of one hosted-browser auth attempt. +public enum HostBrowserAuthSessionResult: Equatable, Sendable { + /// The hosted page redirected to the app's auth callback URL. + case callback(URL) + /// The user or app deliberately cancelled the browser session. + case cancelled(reason: String) + /// The browser session failed before returning a usable callback. + case failed(reason: String) +} diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/BrowserSignIn/HostBrowserSignInFlow.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/BrowserSignIn/HostBrowserSignInFlow.swift index 16ef71b96d54..817ec804335e 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/BrowserSignIn/HostBrowserSignInFlow.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/BrowserSignIn/HostBrowserSignInFlow.swift @@ -1,30 +1,23 @@ public import Foundation public import Observation +import os -/// The macOS hosted-browser sign-in flow. -/// -/// Drives one `ASWebAuthenticationSession` attempt at a time against the cmux -/// web app's hosted sign-in page, then seeds the callback tokens into the -/// injected token store and publishes the session through the shared -/// ``AuthCoordinator`` (`completeExternalSignIn()`). Also handles auth -/// callback URLs that arrive through the app's URL scheme outside a popup. -/// Owns the attempt/sign-out race guards so a late browser callback can never -/// resurrect a session the user just signed out of. +/// macOS hosted-browser sign-in flow, including external URL callbacks and +/// attempt/sign-out race guards. @MainActor @Observable public final class HostBrowserSignInFlow { /// Whether a browser sign-in attempt (popup + completion) is in flight. public private(set) var isSigningIn = false - /// Whether the in-flight sign-in attempt has been waiting on the hosted - /// (Safari-backed) `ASWebAuthenticationSession` longer than - /// ``slowSignInThreshold`` without delivering a callback. The Settings - /// account UI watches this to offer an "open sign-in in your default - /// browser" fallback when the system sign-in window hangs (issue #6015), - /// instead of leaving the user on an indefinite spinner. Resets to `false` - /// whenever an attempt completes, is cancelled, or is replaced. + /// Whether the in-flight popup has waited long enough for the UI to offer + /// the default-browser fallback instead of an indefinite spinner. public private(set) var signInIsSlow = false + /// Display-safe failure from the most recent hosted-browser sign-in + /// attempt. `nil` for a fresh attempt and for deliberate cancellation. + public private(set) var lastFailure: AuthError? + private let coordinator: AuthCoordinator private let tokenStore: any StackAuthTokenStoreProtocol private let sessionFactory: any HostBrowserAuthSessionFactory @@ -37,7 +30,7 @@ public final class HostBrowserSignInFlow { private let log = AuthDebugLog() @ObservationIgnored private var activeSession: (any HostBrowserAuthSession)? - @ObservationIgnored private var activeSessionContinuation: CheckedContinuation? + @ObservationIgnored private var activeSessionContinuation: CheckedContinuation? @ObservationIgnored private var activeSessionContinuationAttemptID: UInt64? @ObservationIgnored private var activeAttemptTimeoutTask: Task? @ObservationIgnored private var slowSignInHintTask: Task? @@ -49,21 +42,6 @@ public final class HostBrowserSignInFlow { @ObservationIgnored private var signOutGeneration: UInt64 = 0 /// Creates the flow. - /// - Parameters: - /// - coordinator: The shared auth coordinator that owns session state. - /// - tokenStore: The store the `StackClientApp` reads tokens from; the - /// callback tokens are seeded here. - /// - sessionFactory: Browser-session seam (production: - /// ``ASWebBrowserAuthSessionFactory``). - /// - callbackRouter: Recognizes/parses auth callback URLs. - /// - makeSignInURL: Builds the hosted sign-in URL per attempt (the - /// composition root derives it from its environment table). - /// - callbackScheme: The custom callback scheme for the popup. - /// - clock: Drives the sign-in deadline; tests inject a virtual clock. - /// - browserAttemptTimeout: Cancels abandoned external-browser attempts. - /// - slowSignInThreshold: How long an attempt may wait on the hosted - /// browser before ``signInIsSlow`` flips to surface the manual - /// default-browser fallback. `0` disables the hint. public init( coordinator: AuthCoordinator, tokenStore: any StackAuthTokenStoreProtocol, @@ -101,23 +79,15 @@ public final class HostBrowserSignInFlow { return makeSignInURL(state) } - /// The hosted sign-in URL for the in-flight attempt, to open in the user's - /// real default browser when the hosted-browser popup hangs (issue #6015). - /// Reuses the active attempt's callback state, so the resulting - /// `cmux://auth-callback` deep link routes back into the in-flight attempt - /// through ``handleCallbackURL(_:)`` instead of being rejected as a - /// stateful callback with no matching attempt. `nil` when no attempt is in - /// flight. + /// Sign-in URL for the active attempt, reused by the default-browser fallback + /// so the callback still routes to this in-flight attempt. public var activeAttemptSignInURL: URL? { guard let activeCallbackState else { return nil } pendingFallbackCallbackState = activeCallbackState return makeSignInURL(activeCallbackState) } - /// Run a browser sign-in attempt with a deadline, for the socket - /// `auth.begin_sign_in` command. Returns whether the app ended signed in - /// before the deadline; the popup itself stays up past the deadline so the - /// user can still finish. + /// Run a browser sign-in attempt with a deadline for `auth.begin_sign_in`. public func signIn(timeout: TimeInterval) async -> Bool { log.log("auth.browser.signIn.request timeoutMs=\(Int(timeout * 1000)) signedIn=\(coordinator.isAuthenticated) signingIn=\(isSigningIn)") if coordinator.isAuthenticated { @@ -138,8 +108,9 @@ public final class HostBrowserSignInFlow { if let attemptID = activeAttemptID, activeSessionContinuation != nil, callbackRouter.isAuthCallbackURL(url) { - guard callbackState(from: url) == activeCallbackState else { + guard authCallbackState(from: url) == activeCallbackState else { log.log("auth.callback.external.reject reason=stateMismatch attempt=\(attemptID)") + lastFailure = .invalidCallback return false } log.log("auth.callback.external.routeToActive attempt=\(attemptID)") @@ -147,21 +118,20 @@ public final class HostBrowserSignInFlow { cancelSlowSignInHint() let signedIn = await completeCallback(url: url, attemptID: attemptID) resumeActiveSessionContinuation( - returning: nil, + returning: .cancelled(reason: "external_callback"), reason: "externalCallback", expectedAttemptID: attemptID ) return signedIn } - if callbackRouter.isAuthCallbackURL(url), callbackState(from: url) == nil { + if callbackRouter.isAuthCallbackURL(url), authCallbackState(from: url) == nil { log.log("auth.callback.external.routeToFallback") return await completeCallback(url: url, attemptID: nil) } if callbackRouter.isAuthCallbackURL(url), - let state = callbackState(from: url), + let state = authCallbackState(from: url), state == pendingFallbackCallbackState { log.log("auth.callback.external.routeToIssuedFallback") - pendingFallbackCallbackState = nil return await completeCallback(url: url, attemptID: nil, acceptedExternalState: state) } log.log("auth.callback.external.reject reason=noActiveAttempt") @@ -173,19 +143,15 @@ public final class HostBrowserSignInFlow { public func signOut() async { log.log("auth.browser.signOut.begin signingIn=\(isSigningIn) activeAttempt=\(activeAttemptID.map(String.init) ?? "nil") generation=\(signOutGeneration)") signOutGeneration &+= 1 + lastFailure = nil cancelActiveAttempt() await coordinator.signOut() log.log("auth.browser.signOut.end generation=\(signOutGeneration)") } - /// Sign out with a deadline, for the socket `auth.sign_out` command. The - /// sign-out itself always runs to completion in the background; the - /// deadline only caps how long the socket caller can hang on the network - /// revoke round trip. + /// Sign out with a socket deadline while sign-out continues in background. public func signOut(timeout: TimeInterval) async { - // Strong capture on purpose: the user asked to sign out, so the task - // must keep the flow alive until the sign-out completes even if the - // socket caller stops waiting at the deadline. + // Strong capture keeps user-requested sign-out alive past caller timeout. let attempt = Task { @MainActor in await self.signOut() return true @@ -193,32 +159,36 @@ public final class HostBrowserSignInFlow { _ = await awaitWithDeadline(attempt, timeout: timeout) } - /// Await `attempt`, resolving `false` at the deadline while the attempt - /// keeps running in the background. - /// - /// Deadline, not polling: bounded + cancellable (cancelled as soon as the - /// attempt resolves), virtual-clock testable via the injected clock. + /// Await `attempt`, resolving `false` at the deadline without cancelling it. private func awaitWithDeadline(_ attempt: Task, timeout: TimeInterval) async -> Bool { - // Clamp before converting so an oversized Double can't overflow. let clamped = max(0, min(timeout, 24 * 60 * 60)) let clock = self.clock - let deadlineTask = Task { try await clock.sleep(for: .seconds(clamped)) } - return await withCheckedContinuation { (continuation: CheckedContinuation) in - let once = ResumeOnceFlag() - Task { @MainActor in + let stream = AsyncStream(bufferingPolicy: .bufferingOldest(1)) { continuation in + let deadlineTask = Task { + do { + try await clock.sleep(for: .seconds(clamped)) + } catch { + return + } + guard !Task.isCancelled else { return } + continuation.yield(false) + continuation.finish() + } + let attemptWaitTask = Task { let result = await attempt.value + continuation.yield(result) + continuation.finish() deadlineTask.cancel() - guard !once.fired else { return } - once.fired = true - continuation.resume(returning: result) } - Task { @MainActor in - try? await deadlineTask.value - guard !once.fired else { return } - once.fired = true - continuation.resume(returning: false) + continuation.onTermination = { @Sendable _ in + deadlineTask.cancel() + attemptWaitTask.cancel() } } + for await result in stream { + return result + } + return false } // MARK: - Attempt lifecycle @@ -228,6 +198,7 @@ public final class HostBrowserSignInFlow { log.log("auth.browser.attempt.replace previous=\(activeAttemptID)") } cancelActiveAttempt() + lastFailure = nil nextAttemptID &+= 1 let attemptID = nextAttemptID let callbackState = pendingManualCallbackState ?? makeCallbackState() @@ -235,25 +206,34 @@ public final class HostBrowserSignInFlow { activeAttemptID = attemptID activeCallbackState = callbackState isSigningIn = true - log.log("auth.browser.attempt.start id=\(attemptID) generation=\(signOutGeneration) state=\(redactedState(callbackState))") + log.log("auth.browser.attempt.start id=\(attemptID) generation=\(signOutGeneration) state=\(redactedAuthState(callbackState))") scheduleAttemptTimeout(attemptID) scheduleSlowSignInHint(attemptID) return Task { @MainActor [weak self] in guard let self else { return false } defer { self.finishAttempt(attemptID) } guard self.activeAttemptID == attemptID else { return false } - guard let callbackURL = await self.runBrowserSession(attemptID: attemptID) else { - self.log.log("auth.browser.attempt.noCallback id=\(attemptID) signedIn=\(self.coordinator.isAuthenticated)") + guard let result = await self.runBrowserSession(attemptID: attemptID) else { + self.log.log("auth.browser.attempt.noResult id=\(attemptID) signedIn=\(self.coordinator.isAuthenticated)") + return self.coordinator.isAuthenticated + } + switch result { + case let .callback(callbackURL): + guard self.activeAttemptID == attemptID else { return false } + self.cancelAttemptTimeout() + self.cancelSlowSignInHint() + return await self.completeCallback(url: callbackURL, attemptID: attemptID) + case let .cancelled(reason): + self.log.log("auth.browser.attempt.cancelled id=\(attemptID) reason=\(reason) signedIn=\(self.coordinator.isAuthenticated)") + return self.coordinator.isAuthenticated + case let .failed(reason): + self.recordBrowserSessionFailure(reason: reason, attemptID: attemptID) return self.coordinator.isAuthenticated } - guard self.activeAttemptID == attemptID else { return false } - self.cancelAttemptTimeout() - self.cancelSlowSignInHint() - return await self.completeCallback(url: callbackURL, attemptID: attemptID) } } - private func runBrowserSession(attemptID: UInt64) async -> URL? { + private func runBrowserSession(attemptID: UInt64) async -> HostBrowserAuthSessionResult? { await withCheckedContinuation { continuation in activeSessionContinuation = continuation activeSessionContinuationAttemptID = attemptID @@ -264,16 +244,14 @@ public final class HostBrowserSignInFlow { let session = sessionFactory.makeSession( signInURL: signInURL, callbackScheme: scheme - ) { url in - // The factory delivers the completion exactly once (including - // after cancel()), so this resume cannot double-fire. - self.log.log("auth.browser.session.completion id=\(attemptID) \(url.map(authCallbackSummary) ?? "url=nil")") - if let url, !self.callbackRouter.isAuthCallbackURL(url) { - self.log.log("auth.browser.session.completion.ignored id=\(attemptID) reason=nonAuthCallback \(authCallbackSummary(url))") + ) { result in + self.log.log("auth.browser.session.completion id=\(attemptID) \(self.sessionResultSummary(result))") + if case let .callback(url) = result, !self.callbackRouter.isAuthCallbackURL(url) { + self.log.log("auth.browser.session.completion.ignored id=\(attemptID) reason=nonAuthCallback \(self.authCallbackSummary(url))") return } self.resumeActiveSessionContinuation( - returning: url, + returning: result, reason: "sessionCompletion", expectedAttemptID: attemptID ) @@ -283,7 +261,7 @@ public final class HostBrowserSignInFlow { guard started else { log.log("auth.webauth: session.start() returned false") resumeActiveSessionContinuation( - returning: nil, + returning: .failed(reason: "start_returned_false"), reason: "startFailed", expectedAttemptID: attemptID ) @@ -302,7 +280,7 @@ public final class HostBrowserSignInFlow { guard activeAttemptID == attemptID else { return } log.log("auth.browser.attempt.finish id=\(attemptID)") resumeActiveSessionContinuation( - returning: nil, + returning: .cancelled(reason: "finish_attempt"), reason: "finishAttempt", expectedAttemptID: attemptID ) @@ -318,7 +296,10 @@ public final class HostBrowserSignInFlow { if let activeAttemptID { log.log("auth.browser.attempt.cancel id=\(activeAttemptID)") } - resumeActiveSessionContinuation(returning: nil, reason: "cancelAttempt") + resumeActiveSessionContinuation( + returning: .cancelled(reason: "attempt_cancelled"), + reason: "cancelAttempt" + ) cancelAttemptTimeout() cancelSlowSignInHint() activeAttemptID = nil @@ -341,6 +322,7 @@ public final class HostBrowserSignInFlow { try? await clock.sleep(for: .seconds(timeout)) guard !Task.isCancelled, let self, self.activeAttemptID == attemptID else { return } self.log.log("auth.browser.attempt.timeout id=\(attemptID)") + self.lastFailure = .timedOut self.cancelActiveAttempt() } } @@ -350,10 +332,7 @@ public final class HostBrowserSignInFlow { activeAttemptTimeoutTask = nil } - /// After ``slowSignInThreshold`` of an attempt still waiting on the hosted - /// browser, flip ``signInIsSlow`` so the account UI can offer the manual - /// default-browser fallback. Non-destructive: the popup keeps running, so a - /// user who is simply taking their time can still finish in it. + /// Flip on the non-destructive default-browser fallback after a slow popup. private func scheduleSlowSignInHint(_ attemptID: UInt64) { slowSignInHintTask?.cancel() guard slowSignInThreshold > 0 else { @@ -376,6 +355,13 @@ public final class HostBrowserSignInFlow { signInIsSlow = false } + private func recordBrowserSessionFailure(reason: String, attemptID: UInt64) { + log.log("auth.browser.attempt.failed id=\(attemptID) reason=\(reason) signedIn=\(coordinator.isAuthenticated)") + if !coordinator.isAuthenticated { + lastFailure = .browserSignInFailed(reason) + } + } + // MARK: - Callback completion /// Seed the callback tokens and publish the session through the shared @@ -384,15 +370,18 @@ public final class HostBrowserSignInFlow { log.log("auth.callback.complete.begin attempt=\(attemptID.map(String.init) ?? "external") \(authCallbackSummary(url))") guard let payload = callbackRouter.callbackPayload(from: url) else { log.log("auth.callback rejected: invalid payload") + lastFailure = .invalidCallback return false } if let attemptID { - guard callbackState(from: url) == activeCallbackState else { + guard authCallbackState(from: url) == activeCallbackState else { log.log("auth.callback rejected: state mismatch attempt=\(attemptID)") + lastFailure = .invalidCallback return false } - } else if let state = callbackState(from: url), state != acceptedExternalState { + } else if let state = authCallbackState(from: url), state != acceptedExternalState { log.log("auth.callback rejected: stateful external callback without active attempt") + lastFailure = .invalidCallback return false } let generation = signOutGeneration @@ -400,8 +389,7 @@ public final class HostBrowserSignInFlow { await tokenStore.seed(accessToken: payload.accessToken, refreshToken: payload.refreshToken) guard signOutGeneration == generation, attemptID == nil || activeAttemptID == attemptID else { - // A sign-out (or a newer attempt) raced the callback; drop the - // seeded tokens instead of resurrecting the session. + // Drop raced tokens instead of resurrecting a signed-out/replaced session. log.log("auth.callback.tokens.clear attempt=\(attemptID.map(String.init) ?? "external") reason=raced generation=\(signOutGeneration) active=\(activeAttemptID.map(String.init) ?? "nil")") await tokenStore.clearTokensIfCurrent( accessToken: payload.accessToken, @@ -414,21 +402,17 @@ public final class HostBrowserSignInFlow { try await coordinator.completeExternalSignIn() } catch { log.log("auth.callback completion failed: \(error)") - // No flow-side seed clear here, deliberately. When a sign-out - // raced the validation round trip, the seeds were already in the - // store when the coordinator's local-first clear ran (they are - // seeded before `completeExternalSignIn`), so the coordinator's - // clear owns wiping them. Clearing here instead RACES that - // sign-out: the flow bumps `signOutGeneration` before the - // coordinator captures the teardown credentials with raw store - // reads, so a clear from this catch can empty the store inside - // the capture window and silently strip the best-effort server - // teardown (push unregister, session revocation) of its - // credentials. A coordinator-level cancellation without a - // sign-out (a concurrent publish) must not clear either: in - // production the published session is typically authenticated by - // these very tokens (same shared store), and clearing them would - // strand it. + guard signOutGeneration == generation, + attemptID == nil || activeAttemptID == attemptID + else { + return false + } + let displaySafe = AuthError(displaySafe: error) ?? .serverError(0, "auth_failed") + if displaySafe != .cancelled { + lastFailure = displaySafe + } + // Do not clear seeds here. The coordinator owns raced sign-out + // teardown credentials and concurrent publish cancellation. return false } log.log("auth.callback.coordinator.complete.end attempt=\(attemptID.map(String.init) ?? "external") signedIn=\(coordinator.isAuthenticated)") @@ -443,14 +427,15 @@ public final class HostBrowserSignInFlow { ) return false } - if callbackState(from: url) == pendingFallbackCallbackState { + if authCallbackState(from: url) == pendingFallbackCallbackState { pendingFallbackCallbackState = nil } + lastFailure = nil return true } private func resumeActiveSessionContinuation( - returning url: URL?, + returning result: HostBrowserAuthSessionResult?, reason: String, expectedAttemptID: UInt64? = nil ) { @@ -462,38 +447,44 @@ public final class HostBrowserSignInFlow { } activeSessionContinuation = nil activeSessionContinuationAttemptID = nil - log.log("auth.browser.session.resume reason=\(reason) \(url.map(authCallbackSummary) ?? "url=nil")") - continuation.resume(returning: url) + log.log("auth.browser.session.resume reason=\(reason) \(result.map(sessionResultSummary) ?? "result=nil")") + continuation.resume(returning: result) } private func makeCallbackState() -> String { UUID().uuidString.lowercased() } - private func callbackState(from url: URL) -> String? { + private func authCallbackState(from url: URL) -> String? { URLComponents(url: url, resolvingAgainstBaseURL: false)? .queryItems? .first(where: { $0.name == "cmux_auth_state" })? .value } - private func redactedState(_ state: String) -> String { + private func redactedAuthState(_ state: String) -> String { "\(state.prefix(8))..." } -} -/// MainActor-confined once-guard for racing continuation resumes. -@MainActor -private final class ResumeOnceFlag { - var fired = false -} + private func authCallbackSummary(_ url: URL) -> String { + let scheme = url.scheme ?? "nil" + let target = url.host ?? url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .map(\.name) + .joined(separator: ",") ?? "" + return "scheme=\(scheme) target=\(target.isEmpty ? "nil" : target) queryKeys=\(queryItems.isEmpty ? "none" : queryItems)" + } + + private func sessionResultSummary(_ result: HostBrowserAuthSessionResult) -> String { + switch result { + case let .callback(url): + return "result=callback \(authCallbackSummary(url))" + case let .cancelled(reason): + return "result=cancelled reason=\(reason)" + case let .failed(reason): + return "result=failed reason=\(reason)" + } + } -private func authCallbackSummary(_ url: URL) -> String { - let scheme = url.scheme ?? "nil" - let target = url.host ?? url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) - let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)? - .queryItems? - .map(\.name) - .joined(separator: ",") ?? "" - return "scheme=\(scheme) target=\(target.isEmpty ? "nil" : target) queryKeys=\(queryItems.isEmpty ? "none" : queryItems)" } diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthError.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthError.swift index 50635da445d3..5742aa5216be 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthError.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthError.swift @@ -20,6 +20,12 @@ public enum AuthError: Error, LocalizedError, Equatable, Sendable { case serverError(Int, String) /// The supplied magic-link / OTP code was missing or rejected. case invalidCode + /// The hosted sign-in callback returned to the app but did not contain a + /// token payload or did not match the active attempt. + case invalidCallback + /// The OS/browser auth session closed before a valid callback reached cmux. + /// Carries a stable diagnostic reason for logs/tests; UI copy stays generic. + case browserSignInFailed(String) /// The session is no longer valid and the user must sign in again. case unauthorized /// A generic credential failure (e.g. wrong email/password). @@ -60,6 +66,18 @@ public enum AuthError: Error, LocalizedError, Equatable, Sendable { defaultValue: "Invalid code. Please try again.", bundle: .main ) + case .invalidCallback: + return String( + localized: "auth.error.invalid_callback", + defaultValue: "The sign-in callback was invalid. Try signing in again.", + bundle: .main + ) + case .browserSignInFailed: + return String( + localized: "auth.error.browser_sign_in_failed", + defaultValue: "Could not complete browser sign-in. Try again or open sign-in in your browser.", + bundle: .main + ) case .unauthorized: return String( localized: "auth.error.unauthorized", diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/ASWebBrowserAuthSessionFactoryTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/ASWebBrowserAuthSessionFactoryTests.swift index de6a40a0ca9c..918940660770 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/ASWebBrowserAuthSessionFactoryTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/ASWebBrowserAuthSessionFactoryTests.swift @@ -1,3 +1,4 @@ +import AuthenticationServices import Foundation import Testing @testable import CmuxAuthRuntime @@ -13,30 +14,50 @@ import Testing @Test func bridgeDeliveredOffMainCompletesOnMainActor() async { let factory = ASWebBrowserAuthSessionFactory(anchor: FakeAnchor()) let url = URL(string: "cmux-dev://auth-callback?stack_refresh=r&stack_access=a")! - let received: URL? = await withCheckedContinuation { continuation in - let bridge = factory.sessionCompletionBridge { callbackURL in + let received: HostBrowserAuthSessionResult = await withCheckedContinuation { continuation in + let bridge = factory.sessionCompletionBridge { result in // @MainActor closure: reaching here off-main would trap. - continuation.resume(returning: callbackURL) + continuation.resume(returning: result) } // Simulate the OS delivering the completion on a non-main queue. DispatchQueue.global(qos: .userInitiated).async { bridge(url, nil) } } - #expect(received == url) + #expect(received == .callback(url)) } - @Test func bridgeDeliversNilOnOffMainCancellation() async { - struct CancelledError: Error {} + @Test func bridgeClassifiesOffMainCancellation() async { let factory = ASWebBrowserAuthSessionFactory(anchor: FakeAnchor()) - let received: URL? = await withCheckedContinuation { continuation in - let bridge = factory.sessionCompletionBridge { callbackURL in - continuation.resume(returning: callbackURL) + let received: HostBrowserAuthSessionResult = await withCheckedContinuation { continuation in + let bridge = factory.sessionCompletionBridge { result in + continuation.resume(returning: result) } DispatchQueue.global(qos: .utility).async { - bridge(nil, CancelledError()) + let error = NSError( + domain: ASWebAuthenticationSessionError.errorDomain, + code: ASWebAuthenticationSessionError.Code.canceledLogin.rawValue + ) + bridge(nil, error) } } - #expect(received == nil) + #expect(received == .cancelled(reason: "canceled_login")) + } + + @Test func bridgeClassifiesOffMainPresentationFailure() async { + let factory = ASWebBrowserAuthSessionFactory(anchor: FakeAnchor()) + let received: HostBrowserAuthSessionResult = await withCheckedContinuation { continuation in + let bridge = factory.sessionCompletionBridge { result in + continuation.resume(returning: result) + } + DispatchQueue.global(qos: .utility).async { + let error = NSError( + domain: ASWebAuthenticationSessionError.errorDomain, + code: ASWebAuthenticationSessionError.Code.presentationContextInvalid.rawValue + ) + bridge(nil, error) + } + } + #expect(received == .failed(reason: "presentation_context_invalid")) } } diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/HostBrowserSignInFlowFailureTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/HostBrowserSignInFlowFailureTests.swift new file mode 100644 index 000000000000..933157255d14 --- /dev/null +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/HostBrowserSignInFlowFailureTests.swift @@ -0,0 +1,135 @@ +import CMUXAuthCore +import Foundation +import StackAuth +import Testing +@testable import CmuxAuthRuntime + +@MainActor +@Suite struct HostBrowserSignInFlowFailureTests { + @Test func invalidCallbackPayloadIsRejected() async { + let harness = HostBrowserSignInFlowHarness(user: CMUXAuthUser(id: "u1", primaryEmail: nil, displayName: nil)) + + let attempt = Task { await harness.flow.signIn(timeout: 60) } + await harness.waitForSession() + harness.factory.sessions[0].deliver(URL(string: "cmux-dev://auth-callback?other=1&cmux_auth_state=\(harness.callbackState(harness.factory.sessions[0]))")!) + + #expect(await attempt.value == false) + #expect(harness.coordinator.isAuthenticated == false) + #expect(await harness.tokenStore.getStoredRefreshToken() == nil) + #expect(harness.flow.lastFailure == .invalidCallback) + } + + @Test func externalCallbackStateMismatchRecordsInvalidCallbackFailure() async { + let harness = HostBrowserSignInFlowHarness(user: CMUXAuthUser(id: "u1", primaryEmail: nil, displayName: nil)) + + let attempt = Task { await harness.flow.signIn(timeout: 60) } + await harness.waitForSession() + + let result = await harness.flow.handleCallbackURL(harness.callbackURL(state: "other-state")) + + #expect(result == false) + #expect(harness.flow.isSigningIn) + #expect(harness.coordinator.isAuthenticated == false) + #expect(harness.flow.lastFailure == .invalidCallback) + + harness.factory.sessions[0].cancel() + #expect(await attempt.value == false) + } + + @Test func callbackTokensThatDoNotValidateRecordUnauthorizedFailure() async { + let harness = HostBrowserSignInFlowHarness(user: nil) + + let attempt = Task { await harness.flow.signIn(timeout: 60) } + await harness.waitForSession() + harness.factory.sessions[0].deliver(harness.callbackURL(state: harness.callbackState(harness.factory.sessions[0]))) + + #expect(await attempt.value == false) + #expect(harness.coordinator.isAuthenticated == false) + #expect(await harness.tokenStore.getStoredRefreshToken() == "refresh-1") + #expect(await harness.tokenStore.getStoredAccessToken() == "access-1") + #expect(harness.flow.lastFailure == .unauthorized) + } + + @Test func callbackValidationDisplayUnsafeErrorRecordsGenericServerFailure() async { + let harness = HostBrowserSignInFlowHarness( + user: CMUXAuthUser(id: "u1", primaryEmail: nil, displayName: nil) + ) + await harness.client.setCurrentUserError(StackAuthError(code: "RATE_LIMIT", message: "try later")) + + let attempt = Task { await harness.flow.signIn(timeout: 60) } + await harness.waitForSession() + harness.factory.sessions[0].deliver( + harness.callbackURL(state: harness.callbackState(harness.factory.sessions[0])) + ) + + #expect(await attempt.value == false) + #expect(harness.coordinator.isAuthenticated == false) + #expect(harness.flow.lastFailure == .serverError(0, "auth_failed")) + } + + @Test func signOutBeforeValidationErrorDoesNotLeaveFailure() async { + let harness = HostBrowserSignInFlowHarness( + user: CMUXAuthUser(id: "u1", primaryEmail: nil, displayName: nil) + ) + await harness.client.closeUserGate() + + let attempt = Task { await harness.flow.signIn(timeout: 60) } + await harness.waitForSession() + harness.factory.sessions[0].deliver( + harness.callbackURL(state: harness.callbackState(harness.factory.sessions[0])) + ) + await harness.waitForPendingUserRequest() + + await harness.client.setCurrentUserError(AuthError.networkError) + await harness.flow.signOut() + await harness.client.openUserGate() + + #expect(await attempt.value == false) + #expect(harness.coordinator.isAuthenticated == false) + #expect(await harness.tokenStore.getStoredRefreshToken() == nil) + #expect(await harness.tokenStore.getStoredAccessToken() == nil) + #expect(harness.flow.lastFailure == nil) + } + + @Test func browserSessionStartFailureRecordsDiagnosticFailure() async { + let harness = HostBrowserSignInFlowHarness() + harness.factory.nextStartResult = false + + let result = await harness.flow.signIn(timeout: 60) + + #expect(result == false) + #expect(harness.coordinator.isAuthenticated == false) + #expect(harness.flow.isSigningIn == false) + #expect(harness.flow.lastFailure == .browserSignInFailed("start_returned_false")) + } + + @Test func browserSessionCompletionFailureRecordsDiagnosticFailure() async { + let harness = HostBrowserSignInFlowHarness() + + let attempt = Task { await harness.flow.signIn(timeout: 60) } + await harness.waitForSession() + harness.factory.sessions[0].deliver(.failed(reason: "presentation_context_invalid")) + + #expect(await attempt.value == false) + #expect(harness.coordinator.isAuthenticated == false) + #expect(harness.flow.isSigningIn == false) + #expect(harness.flow.lastFailure == .browserSignInFailed("presentation_context_invalid")) + } + + @Test func abandonedBrowserAttemptTimesOut() async throws { + let clock = ManualTestClock() + let harness = HostBrowserSignInFlowHarness(browserAttemptTimeout: 1, clock: clock) + + harness.flow.beginSignIn() + await harness.waitForSession() + await clock.waitUntilSleepers(count: 2) + + clock.advance(by: .seconds(1)) + + await harness.waitForCondition { harness.flow.isSigningIn == false } + #expect(harness.factory.sessions[0].cancelled) + #expect(harness.flow.isSigningIn == false) + #expect(harness.coordinator.isAuthenticated == false) + #expect(harness.flow.lastFailure == .timedOut) + } +} diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/HostBrowserSignInFlowFakes.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/HostBrowserSignInFlowFakes.swift index 2b8bb59bea3a..487769196f39 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/HostBrowserSignInFlowFakes.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/HostBrowserSignInFlowFakes.swift @@ -14,6 +14,7 @@ actor FlowFakeAuthClient: AuthClient { private var user: CMUXAuthUser? private let store: FlowInMemoryTokenStore private(set) var pendingUserRequests = 0 + private var currentUserError: (any Error)? private var userGateClosed = false private var userGateWaiters: [CheckedContinuation] = [] private var storedAccessGateArmed = false @@ -26,6 +27,8 @@ actor FlowFakeAuthClient: AuthClient { self.store = store } + func setCurrentUserError(_ error: (any Error)?) { currentUserError = error } + func closeUserGate() { userGateClosed = true } func openUserGate() { @@ -59,6 +62,9 @@ actor FlowFakeAuthClient: AuthClient { await withCheckedContinuation { userGateWaiters.append($0) } pendingUserRequests -= 1 } + if let currentUserError { + throw currentUserError + } return user } @@ -133,14 +139,20 @@ actor FlowInMemoryTokenStore: StackAuthTokenStoreProtocol { @MainActor final class FakeBrowserAuthSessionFactory: HostBrowserAuthSessionFactory { private(set) var sessions: [FakeBrowserAuthSession] = [] + var nextStartResult = true func makeSession( signInURL: URL, callbackScheme: String, - completion: @escaping @MainActor (URL?) -> Void + completion: @escaping @MainActor (HostBrowserAuthSessionResult) -> Void ) -> any HostBrowserAuthSession { - let session = FakeBrowserAuthSession(signInURL: signInURL, completion: completion) + let session = FakeBrowserAuthSession( + signInURL: signInURL, + startResult: nextStartResult, + completion: completion + ) sessions.append(session) + nextStartResult = true return session } } @@ -150,27 +162,37 @@ final class FakeBrowserAuthSessionFactory: HostBrowserAuthSessionFactory { final class FakeBrowserAuthSession: HostBrowserAuthSession { let signInURL: URL var deliverCancelCompletion = true - private let completion: @MainActor (URL?) -> Void + private let startResult: Bool + private let completion: @MainActor (HostBrowserAuthSessionResult) -> Void private var completed = false private(set) var cancelled = false - init(signInURL: URL, completion: @escaping @MainActor (URL?) -> Void) { + init( + signInURL: URL, + startResult: Bool, + completion: @escaping @MainActor (HostBrowserAuthSessionResult) -> Void + ) { self.signInURL = signInURL + self.startResult = startResult self.completion = completion } - func start() -> Bool { true } + func start() -> Bool { startResult } func cancel() { cancelled = true if deliverCancelCompletion { - deliver(nil) + deliver(.cancelled(reason: "fake_cancel")) } } - func deliver(_ url: URL?) { + func deliver(_ url: URL) { + deliver(.callback(url)) + } + + func deliver(_ result: HostBrowserAuthSessionResult) { guard !completed else { return } completed = true - completion(url) + completion(result) } } diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/HostBrowserSignInFlowTestSupport.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/HostBrowserSignInFlowTestSupport.swift new file mode 100644 index 000000000000..de8d4aaa753d --- /dev/null +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/HostBrowserSignInFlowTestSupport.swift @@ -0,0 +1,102 @@ +import CMUXAuthCore +import Foundation +@testable import CmuxAuthRuntime + +@MainActor +struct HostBrowserSignInFlowHarness { + let flow: HostBrowserSignInFlow + let coordinator: AuthCoordinator + let client: FlowFakeAuthClient + let tokenStore: FlowInMemoryTokenStore + let factory: FakeBrowserAuthSessionFactory + + init( + user: CMUXAuthUser? = nil, + browserAttemptTimeout: TimeInterval = 5 * 60, + slowSignInThreshold: TimeInterval = 30, + clock: (any Clock)? = nil + ) { + let store = FakeKeyValueStore() + // The fake client reads and clears the SAME token store the flow seeds, + // like production. Split stores hide seed/capture/clear races. + let tokenStore = FlowInMemoryTokenStore() + let client = FlowFakeAuthClient(user: user, store: tokenStore) + let coordinator = AuthCoordinator( + client: client, + sessionCache: CMUXAuthSessionCache(keyValueStore: store, key: "has_tokens"), + userCache: CMUXAuthIdentityStore(keyValueStore: store, key: "cached_user"), + teamSelection: CMUXAuthTeamSelectionStore(keyValueStore: store, key: "selected_team"), + anchor: FakeAnchor(), + config: .test, + launch: .plain() + ) + let factory = FakeBrowserAuthSessionFactory() + self.flow = HostBrowserSignInFlow( + coordinator: coordinator, + tokenStore: tokenStore, + sessionFactory: factory, + callbackRouter: AuthCallbackRouter(), + makeSignInURL: { URL(string: "https://example.test/handler/sign-in?cmux_auth_state=\($0)")! }, + callbackScheme: { "cmux-dev" }, + clock: clock ?? ContinuousClock(), + browserAttemptTimeout: browserAttemptTimeout, + slowSignInThreshold: slowSignInThreshold + ) + self.coordinator = coordinator + self.client = client + self.tokenStore = tokenStore + self.factory = factory + } + + func callbackURL(state: String) -> URL { + URL(string: "cmux-dev://auth-callback?stack_refresh=refresh-1&stack_access=access-1&cmux_auth_state=\(state)")! + } + + func fallbackCallbackURL() -> URL { + URL(string: "cmux-dev://auth-callback?stack_refresh=refresh-1&stack_access=access-1")! + } + + func callbackState(_ session: FakeBrowserAuthSession) -> String { + URLComponents(url: session.signInURL, resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == "cmux_auth_state" })? + .value ?? "" + } + + func waitForSession(count: Int = 1, timeout: Duration = .seconds(2)) async { + // The attempt task runs on the same main actor; yielding lets it reach + // the browser-session continuation deterministically. + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while factory.sessions.count < count { + if clock.now >= deadline { + preconditionFailure( + "Timed out waiting for \(count) host-browser session(s); got \(factory.sessions.count)" + ) + } + await Task.yield() + } + } + + func waitForCondition(timeout: Duration = .seconds(2), until condition: @MainActor () -> Bool) async { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while !condition() { + if clock.now >= deadline { + preconditionFailure("Timed out waiting for host-browser condition") + } + await Task.yield() + } + } + + func waitForPendingUserRequest(timeout: Duration = .seconds(2)) async { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while await client.pendingUserRequests == 0 { + if clock.now >= deadline { + preconditionFailure("Timed out waiting for a pending user request") + } + await Task.yield() + } + } +} diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/HostBrowserSignInFlowTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/HostBrowserSignInFlowTests.swift index caf39ec96ea2..04d0394e6090 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/HostBrowserSignInFlowTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/HostBrowserSignInFlowTests.swift @@ -8,94 +8,13 @@ import Testing /// the sign-out-vs-callback race guards, deadlines, and attempt cancellation. @MainActor @Suite struct HostBrowserSignInFlowTests { - private struct Harness { - let flow: HostBrowserSignInFlow - let coordinator: AuthCoordinator - let client: FlowFakeAuthClient - let tokenStore: FlowInMemoryTokenStore - let factory: FakeBrowserAuthSessionFactory - } - - private func makeHarness( - user: CMUXAuthUser? = nil, - browserAttemptTimeout: TimeInterval = 5 * 60, - slowSignInThreshold: TimeInterval = 30, - clock: (any Clock)? = nil - ) -> Harness { - let store = FakeKeyValueStore() - // The fake client reads and clears the SAME token store the flow - // seeds, like production (StackAuthClient wraps the StackClientApp - // built over the store the callback seeds into). Split stores would - // hide races between the flow's seed handling and the coordinator's - // capture/clear sequence. - let tokenStore = FlowInMemoryTokenStore() - let client = FlowFakeAuthClient(user: user, store: tokenStore) - let coordinator = AuthCoordinator( - client: client, - sessionCache: CMUXAuthSessionCache(keyValueStore: store, key: "has_tokens"), - userCache: CMUXAuthIdentityStore(keyValueStore: store, key: "cached_user"), - teamSelection: CMUXAuthTeamSelectionStore(keyValueStore: store, key: "selected_team"), - anchor: FakeAnchor(), - config: .test, - launch: .plain() - ) - let factory = FakeBrowserAuthSessionFactory() - let flow = HostBrowserSignInFlow( - coordinator: coordinator, - tokenStore: tokenStore, - sessionFactory: factory, - callbackRouter: AuthCallbackRouter(), - makeSignInURL: { URL(string: "https://example.test/handler/sign-in?cmux_auth_state=\($0)")! }, - callbackScheme: { "cmux-dev" }, - clock: clock ?? ContinuousClock(), - browserAttemptTimeout: browserAttemptTimeout, - slowSignInThreshold: slowSignInThreshold - ) - return Harness(flow: flow, coordinator: coordinator, client: client, tokenStore: tokenStore, factory: factory) - } - - private func callbackURL(state: String) -> URL { - URL(string: "cmux-dev://auth-callback?stack_refresh=refresh-1&stack_access=access-1&cmux_auth_state=\(state)")! - } - - private func fallbackCallbackURL() -> URL { - URL(string: "cmux-dev://auth-callback?stack_refresh=refresh-1&stack_access=access-1")! - } - - private func callbackState(_ session: FakeBrowserAuthSession) -> String { - URLComponents(url: session.signInURL, resolvingAgainstBaseURL: false)? - .queryItems? - .first(where: { $0.name == "cmux_auth_state" })? - .value ?? "" - } - - private func waitForSession(_ factory: FakeBrowserAuthSessionFactory, count: Int = 1) async { - // The attempt task runs on the same main actor; yielding lets it reach - // the browser-session continuation deterministically. - while factory.sessions.count < count { - await Task.yield() - } - } - - /// Yield to the main actor until `condition` holds. The work that flips the - /// condition (e.g. the attempt-timeout task resuming after the virtual clock - /// advances) runs on this same actor, so plain `Task.yield()` drains it with - /// no wall-clock dependence. Loops forever rather than failing on its own; - /// a stuck test surfaces as the suite-level timeout, matching the existing - /// `waitForSession` / `while harness.flow.isSigningIn` helpers in this file. - private func wait(until condition: @MainActor () -> Bool) async { - while !condition() { - await Task.yield() - } - } - @Test func browserCallbackSignsInAndSeedsTokens() async throws { let user = CMUXAuthUser(id: "u1", primaryEmail: "a@b.com", displayName: "A") - let harness = makeHarness(user: user) + let harness = HostBrowserSignInFlowHarness(user: user) let attempt = Task { await harness.flow.signIn(timeout: 60) } - await waitForSession(harness.factory) - harness.factory.sessions[0].deliver(callbackURL(state: callbackState(harness.factory.sessions[0]))) + await harness.waitForSession() + harness.factory.sessions[0].deliver(harness.callbackURL(state: harness.callbackState(harness.factory.sessions[0]))) #expect(await attempt.value) #expect(harness.coordinator.isAuthenticated) @@ -105,31 +24,19 @@ import Testing #expect(harness.flow.isSigningIn == false) } - @Test func invalidCallbackPayloadIsRejected() async { - let harness = makeHarness(user: CMUXAuthUser(id: "u1", primaryEmail: nil, displayName: nil)) - - let attempt = Task { await harness.flow.signIn(timeout: 60) } - await waitForSession(harness.factory) - harness.factory.sessions[0].deliver(URL(string: "cmux-dev://auth-callback?other=1&cmux_auth_state=\(callbackState(harness.factory.sessions[0]))")!) - - #expect(await attempt.value == false) - #expect(harness.coordinator.isAuthenticated == false) - #expect(await harness.tokenStore.getStoredRefreshToken() == nil) - } - @Test func nonAuthBrowserCompletionWaitsForExternalCallback() async { let user = CMUXAuthUser(id: "u1", primaryEmail: "a@b.com", displayName: "A") - let harness = makeHarness(user: user) + let harness = HostBrowserSignInFlowHarness(user: user) let attempt = Task { await harness.flow.signIn(timeout: 60) } - await waitForSession(harness.factory) + await harness.waitForSession() harness.factory.sessions[0].deliver(URL(string: "https://example.test/handler/sign-in?after_auth_return_to=1")!) await Task.yield() #expect(harness.flow.isSigningIn) #expect(harness.coordinator.isAuthenticated == false) - let callbackResult = await harness.flow.handleCallbackURL(callbackURL(state: callbackState(harness.factory.sessions[0]))) + let callbackResult = await harness.flow.handleCallbackURL(harness.callbackURL(state: harness.callbackState(harness.factory.sessions[0]))) #expect(callbackResult) #expect(await attempt.value) @@ -141,21 +48,22 @@ import Testing } @Test func cancelledPopupResolvesFalse() async { - let harness = makeHarness() + let harness = HostBrowserSignInFlowHarness() let attempt = Task { await harness.flow.signIn(timeout: 60) } - await waitForSession(harness.factory) + await harness.waitForSession() harness.factory.sessions[0].cancel() #expect(await attempt.value == false) #expect(harness.flow.isSigningIn == false) + #expect(harness.flow.lastFailure == nil) } @Test func signOutCancelsActivePopup() async { - let harness = makeHarness() + let harness = HostBrowserSignInFlowHarness() harness.flow.beginSignIn() - await waitForSession(harness.factory) + await harness.waitForSession() await harness.flow.signOut() #expect(harness.factory.sessions[0].cancelled) @@ -164,12 +72,12 @@ import Testing } @Test func newAttemptCancelsPreviousPopup() async { - let harness = makeHarness() + let harness = HostBrowserSignInFlowHarness() harness.flow.beginSignIn() - await waitForSession(harness.factory) + await harness.waitForSession() harness.flow.beginSignIn() - await waitForSession(harness.factory, count: 2) + await harness.waitForSession(count: 2) #expect(harness.factory.sessions[0].cancelled) #expect(harness.factory.sessions[1].cancelled == false) @@ -178,22 +86,22 @@ import Testing @Test func staleSessionCompletionCannotResumeNewAttempt() async { let user = CMUXAuthUser(id: "u1", primaryEmail: "a@b.com", displayName: "A") - let harness = makeHarness(user: user) + let harness = HostBrowserSignInFlowHarness(user: user) harness.flow.beginSignIn() - await waitForSession(harness.factory) + await harness.waitForSession() let staleSession = harness.factory.sessions[0] staleSession.deliverCancelCompletion = false let secondAttempt = Task { await harness.flow.signIn(timeout: 60) } - await waitForSession(harness.factory, count: 2) - staleSession.deliver(callbackURL(state: callbackState(staleSession))) + await harness.waitForSession(count: 2) + staleSession.deliver(harness.callbackURL(state: harness.callbackState(staleSession))) await Task.yield() #expect(harness.flow.isSigningIn) #expect(harness.coordinator.isAuthenticated == false) - harness.factory.sessions[1].deliver(callbackURL(state: callbackState(harness.factory.sessions[1]))) + harness.factory.sessions[1].deliver(harness.callbackURL(state: harness.callbackState(harness.factory.sessions[1]))) #expect(await secondAttempt.value) #expect(harness.coordinator.isAuthenticated) @@ -206,17 +114,17 @@ import Testing // wall-clock window. beginSignIn parks two sleepers on this clock: the // attempt timeout (1s here) and the slow-sign-in hint (30s default). let clock = ManualTestClock() - let harness = makeHarness(browserAttemptTimeout: 1, clock: clock) + let harness = HostBrowserSignInFlowHarness(browserAttemptTimeout: 1, clock: clock) harness.flow.beginSignIn() - await waitForSession(harness.factory) + await harness.waitForSession() await clock.waitUntilSleepers(count: 2) // Advance past the attempt timeout but well under the 30s slow-hint // threshold, so only the abandoned-attempt timeout fires. clock.advance(by: .seconds(1)) - await wait { harness.flow.isSigningIn == false } + await harness.waitForCondition { harness.flow.isSigningIn == false } #expect(harness.factory.sessions[0].cancelled) #expect(harness.flow.isSigningIn == false) #expect(harness.coordinator.isAuthenticated == false) @@ -234,34 +142,36 @@ import Testing // wall-clock window. beginSignIn parks two sleepers on this clock: the // attempt timeout (default 5min) and the slow-sign-in hint (1s here). let clock = ManualTestClock() - let harness = makeHarness(slowSignInThreshold: 1, clock: clock) + let harness = HostBrowserSignInFlowHarness(slowSignInThreshold: 1, clock: clock) #expect(harness.flow.signInIsSlow == false) harness.flow.beginSignIn() - await waitForSession(harness.factory) + await harness.waitForSession() await clock.waitUntilSleepers(count: 2) // Advance past the slow-sign-in threshold but well under the 5min // attempt timeout, so only the slow-sign-in hint fires. clock.advance(by: .seconds(1)) - await wait { harness.flow.signInIsSlow } + await harness.waitForCondition { harness.flow.signInIsSlow } #expect(harness.flow.signInIsSlow) // Resolving the attempt clears the slow flag so a later sign-in starts // from a clean slate. harness.factory.sessions[0].cancel() - await wait { harness.flow.signInIsSlow == false && harness.flow.isSigningIn == false } + await harness.waitForCondition { + harness.flow.signInIsSlow == false && harness.flow.isSigningIn == false + } #expect(!harness.flow.signInIsSlow) #expect(!harness.flow.isSigningIn) } @Test func activeAttemptSignInURLCarriesActiveAttemptState() async { - let harness = makeHarness() + let harness = HostBrowserSignInFlowHarness() #expect(harness.flow.activeAttemptSignInURL == nil) harness.flow.beginSignIn() - await waitForSession(harness.factory) + await harness.waitForSession() let fallbackURL = harness.flow.activeAttemptSignInURL #expect(fallbackURL != nil) @@ -274,15 +184,15 @@ import Testing .first(where: { $0.name == "cmux_auth_state" })? .value } - #expect(fallbackState == callbackState(harness.factory.sessions[0])) + #expect(fallbackState == harness.callbackState(harness.factory.sessions[0])) } @Test func issuedFallbackCallbackSurvivesPopupCancellation() async throws { let user = CMUXAuthUser(id: "u1", primaryEmail: "a@b.com", displayName: "A") - let harness = makeHarness(user: user) + let harness = HostBrowserSignInFlowHarness(user: user) harness.flow.beginSignIn() - await waitForSession(harness.factory) + await harness.waitForSession() let fallbackURL = try #require(harness.flow.activeAttemptSignInURL) let fallbackState = try #require(URLComponents(url: fallbackURL, resolvingAgainstBaseURL: false)? .queryItems? @@ -290,11 +200,9 @@ import Testing .value) harness.factory.sessions[0].cancel() - while harness.flow.isSigningIn { - await Task.yield() - } + await harness.waitForCondition { harness.flow.isSigningIn == false } - let callbackResult = await harness.flow.handleCallbackURL(callbackURL(state: fallbackState)) + let callbackResult = await harness.flow.handleCallbackURL(harness.callbackURL(state: fallbackState)) #expect(callbackResult) #expect(harness.coordinator.isAuthenticated) @@ -303,12 +211,42 @@ import Testing #expect(await harness.tokenStore.getStoredAccessToken() == "access-1") } + @Test func issuedFallbackCallbackStateSurvivesFailedRetryAndClearsFailureOnSuccess() async throws { + let user = CMUXAuthUser(id: "u1", primaryEmail: "a@b.com", displayName: "A") + let harness = HostBrowserSignInFlowHarness(user: user) + + harness.flow.beginSignIn() + await harness.waitForSession() + let fallbackURL = try #require(harness.flow.activeAttemptSignInURL) + let fallbackState = try #require(URLComponents(url: fallbackURL, resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == "cmux_auth_state" })? + .value) + + harness.factory.sessions[0].cancel() + await harness.waitForCondition { harness.flow.isSigningIn == false } + + let failedRetry = URL(string: "cmux-dev://auth-callback?other=1&cmux_auth_state=\(fallbackState)")! + #expect(await harness.flow.handleCallbackURL(failedRetry) == false) + #expect(harness.flow.lastFailure == .invalidCallback) + #expect(harness.coordinator.isAuthenticated == false) + + let validRetry = await harness.flow.handleCallbackURL(harness.callbackURL(state: fallbackState)) + + #expect(validRetry) + #expect(harness.coordinator.isAuthenticated) + #expect(harness.coordinator.currentUser == user) + #expect(harness.flow.lastFailure == nil) + #expect(await harness.tokenStore.getStoredRefreshToken() == "refresh-1") + #expect(await harness.tokenStore.getStoredAccessToken() == "access-1") + } + @Test func issuedFallbackCallbackAfterSignOutIsRejected() async throws { let user = CMUXAuthUser(id: "u1", primaryEmail: "a@b.com", displayName: "A") - let harness = makeHarness(user: user) + let harness = HostBrowserSignInFlowHarness(user: user) harness.flow.beginSignIn() - await waitForSession(harness.factory) + await harness.waitForSession() let fallbackURL = try #require(harness.flow.activeAttemptSignInURL) let fallbackState = try #require(URLComponents(url: fallbackURL, resolvingAgainstBaseURL: false)? .queryItems? @@ -316,12 +254,10 @@ import Testing .value) harness.factory.sessions[0].cancel() - while harness.flow.isSigningIn { - await Task.yield() - } + await harness.waitForCondition { harness.flow.isSigningIn == false } await harness.flow.signOut() - let callbackResult = await harness.flow.handleCallbackURL(callbackURL(state: fallbackState)) + let callbackResult = await harness.flow.handleCallbackURL(harness.callbackURL(state: fallbackState)) #expect(callbackResult == false) #expect(harness.coordinator.isAuthenticated == false) @@ -331,16 +267,14 @@ import Testing @Test func signOutDuringCallbackValidationWins() async { let user = CMUXAuthUser(id: "u1", primaryEmail: "a@b.com", displayName: "A") - let harness = makeHarness(user: user) + let harness = HostBrowserSignInFlowHarness(user: user) await harness.client.closeUserGate() let attempt = Task { await harness.flow.signIn(timeout: 60) } - await waitForSession(harness.factory) - harness.factory.sessions[0].deliver(callbackURL(state: callbackState(harness.factory.sessions[0]))) + await harness.waitForSession() + harness.factory.sessions[0].deliver(harness.callbackURL(state: harness.callbackState(harness.factory.sessions[0]))) // Wait until the completion path is blocked inside the user fetch. - while await harness.client.pendingUserRequests == 0 { - await Task.yield() - } + await harness.waitForPendingUserRequest() await harness.flow.signOut() await harness.client.openUserGate() @@ -361,15 +295,13 @@ import Testing // (the callback path already cancelled the timeout before parking). let clock = ManualTestClock() let user = CMUXAuthUser(id: "u1", primaryEmail: "a@b.com", displayName: "A") - let harness = makeHarness(user: user, browserAttemptTimeout: 1, clock: clock) + let harness = HostBrowserSignInFlowHarness(user: user, browserAttemptTimeout: 1, clock: clock) await harness.client.closeUserGate() let attempt = Task { await harness.flow.signIn(timeout: 60) } - await waitForSession(harness.factory) - harness.factory.sessions[0].deliver(callbackURL(state: callbackState(harness.factory.sessions[0]))) - while await harness.client.pendingUserRequests == 0 { - await Task.yield() - } + await harness.waitForSession() + harness.factory.sessions[0].deliver(harness.callbackURL(state: harness.callbackState(harness.factory.sessions[0]))) + await harness.waitForPendingUserRequest() // Past the 1s attempt timeout, but well under both the 30s slow-hint // threshold and the 60s caller deadline, so only the abandoned-attempt @@ -386,7 +318,7 @@ import Testing @Test func deadlineResolvesFalseWhilePopupStaysUp() async { let user = CMUXAuthUser(id: "u1", primaryEmail: "a@b.com", displayName: "A") - let harness = makeHarness(user: user) + let harness = HostBrowserSignInFlowHarness(user: user) let result = await harness.flow.signIn(timeout: 0.05) #expect(result == false) @@ -394,20 +326,18 @@ import Testing #expect(harness.factory.sessions[0].cancelled == false) // The user can still finish in the popup after the caller's deadline. - harness.factory.sessions[0].deliver(callbackURL(state: callbackState(harness.factory.sessions[0]))) - while harness.coordinator.isAuthenticated == false { - await Task.yield() - } + harness.factory.sessions[0].deliver(harness.callbackURL(state: harness.callbackState(harness.factory.sessions[0]))) + await harness.waitForCondition { harness.coordinator.isAuthenticated } #expect(harness.coordinator.currentUser == user) } @Test func lateCallbackAfterSignOutIsRejected() async { let user = CMUXAuthUser(id: "u1", primaryEmail: "a@b.com", displayName: "A") - let harness = makeHarness(user: user) + let harness = HostBrowserSignInFlowHarness(user: user) harness.flow.beginSignIn() - await waitForSession(harness.factory) - let staleCallback = callbackURL(state: callbackState(harness.factory.sessions[0])) + await harness.waitForSession() + let staleCallback = harness.callbackURL(state: harness.callbackState(harness.factory.sessions[0])) await harness.flow.signOut() let result = await harness.flow.handleCallbackURL(staleCallback) @@ -416,15 +346,16 @@ import Testing #expect(harness.coordinator.isAuthenticated == false) #expect(await harness.tokenStore.getStoredRefreshToken() == nil) #expect(await harness.tokenStore.getStoredAccessToken() == nil) + #expect(harness.flow.lastFailure == nil) } @Test func mismatchedCallbackStateIsRejected() async { let user = CMUXAuthUser(id: "u1", primaryEmail: "a@b.com", displayName: "A") - let harness = makeHarness(user: user) + let harness = HostBrowserSignInFlowHarness(user: user) let attempt = Task { await harness.flow.signIn(timeout: 60) } - await waitForSession(harness.factory) - harness.factory.sessions[0].deliver(callbackURL(state: "stale-state")) + await harness.waitForSession() + harness.factory.sessions[0].deliver(harness.callbackURL(state: "stale-state")) #expect(await attempt.value == false) #expect(harness.coordinator.isAuthenticated == false) @@ -433,18 +364,18 @@ import Testing @Test func staleExternalCallbackDoesNotCancelActiveAttempt() async { let user = CMUXAuthUser(id: "u1", primaryEmail: "a@b.com", displayName: "A") - let harness = makeHarness(user: user) + let harness = HostBrowserSignInFlowHarness(user: user) let attempt = Task { await harness.flow.signIn(timeout: 60) } - await waitForSession(harness.factory) + await harness.waitForSession() - let staleResult = await harness.flow.handleCallbackURL(callbackURL(state: "stale-state")) + let staleResult = await harness.flow.handleCallbackURL(harness.callbackURL(state: "stale-state")) #expect(staleResult == false) #expect(harness.flow.isSigningIn) #expect(harness.coordinator.isAuthenticated == false) - harness.factory.sessions[0].deliver(callbackURL(state: callbackState(harness.factory.sessions[0]))) + harness.factory.sessions[0].deliver(harness.callbackURL(state: harness.callbackState(harness.factory.sessions[0]))) #expect(await attempt.value) #expect(harness.coordinator.isAuthenticated) @@ -454,9 +385,9 @@ import Testing @Test func fallbackExternalCallbackWithoutActiveAttemptSignsIn() async { let user = CMUXAuthUser(id: "u1", primaryEmail: "a@b.com", displayName: "A") - let harness = makeHarness(user: user) + let harness = HostBrowserSignInFlowHarness(user: user) - let result = await harness.flow.handleCallbackURL(fallbackCallbackURL()) + let result = await harness.flow.handleCallbackURL(harness.fallbackCallbackURL()) #expect(result) #expect(harness.coordinator.isAuthenticated) @@ -467,14 +398,15 @@ import Testing @Test func statefulExternalCallbackWithoutActiveAttemptIsRejected() async { let user = CMUXAuthUser(id: "u1", primaryEmail: "a@b.com", displayName: "A") - let harness = makeHarness(user: user) + let harness = HostBrowserSignInFlowHarness(user: user) - let result = await harness.flow.handleCallbackURL(callbackURL(state: "stale-state")) + let result = await harness.flow.handleCallbackURL(harness.callbackURL(state: "stale-state")) #expect(result == false) #expect(harness.coordinator.isAuthenticated == false) #expect(await harness.tokenStore.getStoredRefreshToken() == nil) #expect(await harness.tokenStore.getStoredAccessToken() == nil) + #expect(harness.flow.lastFailure == nil) } @Test func signOutDuringCallbackValidationStillRevokesWithCapturedCredentials() async { @@ -487,15 +419,13 @@ import Testing // device is online. The coordinator owns the local clear AFTER the // capture; the flow must not clear the shared store underneath it. let user = CMUXAuthUser(id: "u1", primaryEmail: "a@b.com", displayName: "A") - let harness = makeHarness(user: user) + let harness = HostBrowserSignInFlowHarness(user: user) await harness.client.closeUserGate() let attempt = Task { await harness.flow.signIn(timeout: 60) } - await waitForSession(harness.factory) - harness.factory.sessions[0].deliver(callbackURL(state: callbackState(harness.factory.sessions[0]))) - while await harness.client.pendingUserRequests == 0 { - await Task.yield() - } + await harness.waitForSession() + harness.factory.sessions[0].deliver(harness.callbackURL(state: harness.callbackState(harness.factory.sessions[0]))) + await harness.waitForPendingUserRequest() // Sign-out parks inside its credential capture, before its local // clear. diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index d96fba15c5dc..0be2c1ab318d 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -303,6 +303,131 @@ } } }, + "auth.error.browser_sign_in_failed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not complete browser sign-in. Try again or open sign-in in your browser." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザでのサインインを完了できませんでした。もう一度試すか、ブラウザでサインインを開いてください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法完成浏览器登录。请重试或在浏览器中打开登录。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法完成瀏覽器登入。請重試或在瀏覽器中開啟登入。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저 로그인을 완료할 수 없습니다. 다시 시도하거나 브라우저에서 로그인을 여세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Browser-Anmeldung konnte nicht abgeschlossen werden. Versuche es erneut oder öffne die Anmeldung in deinem Browser." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo completar el inicio de sesión en el navegador. Inténtalo de nuevo o abre el inicio de sesión en tu navegador." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de terminer la connexion dans le navigateur. Réessayez ou ouvrez la connexion dans votre navigateur." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Non è stato possibile completare l'accesso nel browser. Riprova o apri l'accesso nel browser." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Browser-login kunne ikke fuldføres. Prøv igen, eller åbn login i din browser." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie można ukończyć logowania w przeglądarce. Spróbuj ponownie albo otwórz logowanie w przeglądarce." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось завершить вход в браузере. Попробуйте снова или откройте вход в браузере." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nije moguće dovršiti prijavu u pregledniku. Pokušajte ponovo ili otvorite prijavu u pregledniku." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّر إكمال تسجيل الدخول في المتصفح. حاول مرة أخرى أو افتح تسجيل الدخول في متصفحك." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke fullføre pålogging i nettleseren. Prøv igjen, eller åpne påloggingen i nettleseren." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível concluir o login no navegador. Tente novamente ou abra o login no navegador." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่สามารถลงชื่อเข้าใช้ในเบราว์เซอร์ได้ ลองอีกครั้งหรือเปิดการลงชื่อเข้าใช้ในเบราว์เซอร์ของคุณ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcıda oturum açma tamamlanamadı. Yeniden deneyin veya oturum açmayı tarayıcınızda açın." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Не вдалося завершити вхід у браузері. Спробуйте ще раз або відкрийте вхід у браузері." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "មិនអាចបញ្ចប់ការចូលគណនីក្នុងកម្មវិធីរុករកបានទេ។ សូមព្យាយាមម្តងទៀត ឬបើកការចូលគណនីក្នុងកម្មវិធីរុករករបស់អ្នក។" + } + } + } + }, "auth.error.invalid_code_short": { "extractionState": "manual", "localizations": { @@ -320,6 +445,131 @@ } } }, + "auth.error.invalid_callback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The sign-in callback was invalid. Try signing in again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サインインコールバックが無効です。もう一度サインインしてください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "登录回调无效。请重新登录。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "登入回呼無效。請重新登入。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "로그인 콜백이 유효하지 않습니다. 다시 로그인해 보세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Anmelde-Callback war ungültig. Versuche, dich erneut anzumelden." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La devolución de llamada de inicio de sesión no era válida. Intenta iniciar sesión de nuevo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le rappel de connexion était invalide. Essayez de vous reconnecter." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Il callback di accesso non era valido. Prova ad accedere di nuovo." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Login-callbacket var ugyldigt. Prøv at logge ind igen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wywołanie zwrotne logowania było nieprawidłowe. Spróbuj zalogować się ponownie." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обратный вызов входа недействителен. Попробуйте войти снова." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Povratni poziv za prijavu nije važeći. Pokušajte se ponovo prijaviti." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "كانت معاودة الاتصال بتسجيل الدخول غير صالحة. جرّب تسجيل الدخول مرة أخرى." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Påloggingskallet var ugyldig. Prøv å logge på igjen." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O callback de login era inválido. Tente entrar novamente." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คอลแบ็กการลงชื่อเข้าใช้ไม่ถูกต้อง ลองลงชื่อเข้าใช้อีกครั้ง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Oturum açma geri çağrısı geçersizdi. Yeniden oturum açmayı deneyin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Зворотний виклик входу недійсний. Спробуйте ввійти ще раз." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ការហៅត្រឡប់ការចូលគណនីមិនត្រឹមត្រូវ។ សូមព្យាយាមចូលគណនីម្ដងទៀត។" + } + } + } + }, "auth.error.network_error": { "extractionState": "manual", "localizations": { @@ -196284,6 +196534,381 @@ } } }, + "mobile.pairing.signIn.connecting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Connecting…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "接続しています…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在连接…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在連接…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "연결 중…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbindung wird hergestellt…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Conectando…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Connexion…" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Connessione…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Forbinder…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Łączenie…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подключение…" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Povezivanje…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ الاتصال…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kobler til…" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Conectando…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังเชื่อมต่อ…" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bağlanıyor…" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Підключення…" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កំពុងភ្ជាប់…" + } + } + } + }, + "mobile.pairing.signIn.slowHint": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The system sign-in window may stop responding. If nothing happens, open sign-in in your default browser instead." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "システムのサインインウィンドウが応答しなくなることがあります。何も起きない場合は、デフォルトのブラウザでサインインを開いてください。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "系统登录窗口可能会停止响应。如果没有任何反应,请改用默认浏览器打开登录。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "系統登入視窗可能會停止回應。如果沒有任何反應,請改用預設瀏覽器開啟登入。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시스템 로그인 창이 응답하지 않을 수 있습니다. 아무 반응이 없으면 기본 브라우저에서 로그인을 열어 주세요." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das System-Anmeldefenster reagiert möglicherweise nicht mehr. Wenn nichts passiert, öffne die Anmeldung stattdessen in deinem Standardbrowser." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La ventana de inicio de sesión del sistema puede dejar de responder. Si no sucede nada, abre el inicio de sesión en tu navegador predeterminado." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La fenêtre de connexion du système peut ne plus répondre. Si rien ne se passe, ouvrez plutôt la connexion dans votre navigateur par défaut." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La finestra di accesso di sistema potrebbe smettere di rispondere. Se non succede nulla, apri l’accesso nel browser predefinito." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Systemets loginvindue kan holde op med at svare. Hvis der ikke sker noget, skal du i stedet åbne login i din standardbrowser." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Systemowe okno logowania może przestać odpowiadać. Jeśli nic się nie dzieje, otwórz logowanie w domyślnej przeglądarce." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Системное окно входа может перестать отвечать. Если ничего не происходит, откройте вход в браузере по умолчанию." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Sistemski prozor za prijavu može prestati odgovarati. Ako se ništa ne dogodi, otvorite prijavu u zadanom pregledniku." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "قد تتوقف نافذة تسجيل الدخول في النظام عن الاستجابة. إذا لم يحدث شيء، فافتح تسجيل الدخول في المتصفح الافتراضي بدلاً من ذلك." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Systemets påloggingsvindu kan slutte å svare. Hvis ingenting skjer, åpner du påloggingen i standardnettleseren i stedet." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "A janela de login do sistema pode parar de responder. Se nada acontecer, abra o login no navegador padrão." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "หน้าต่างลงชื่อเข้าของระบบอาจหยุดตอบสนอง หากไม่มีอะไรเกิดขึ้น ให้เปิดการลงชื่อเข้าในเบราว์เซอร์เริ่มต้นแทน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sistem oturum açma penceresi yanıt vermeyi durdurabilir. Hiçbir şey olmazsa oturum açmayı varsayılan tarayıcınızda açın." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Системне вікно входу може перестати відповідати. Якщо нічого не відбувається, відкрийте вхід у браузері за умовчанням." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បង្អួចចូលគណនីរបស់ប្រព័ន្ធអាចឈប់ឆ្លើយតប។ ប្រសិនបើគ្មានអ្វីកើតឡើង សូមបើកការចូលគណនីក្នុងកម្មវិធីរុករកលំនាំដើមរបស់អ្នកជំនួសវិញ។" + } + } + } + }, + "mobile.pairing.signIn.openInBrowser": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open in Browser" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザで開く" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在浏览器中打开" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在瀏覽器中打開" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저에서 열기" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Im Browser öffnen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir en el navegador" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir dans le navigateur" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Apri nel browser" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Åbn i browser" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Otwórz w przeglądarce" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть в браузере" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Otvori u pregledniku" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح في المتصفح" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Åpne i nettleser" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir no navegador" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เปิดในเบราว์เซอร์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcıda Aç" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Відкрити у браузері" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បើកក្នុងកម្មវិធីរុករក" + } + } + } + }, "mobile.pairing.connected.title": { "extractionState": "manual", "localizations": { diff --git a/Sources/Mobile/Pairing/MobilePairingView.swift b/Sources/Mobile/Pairing/MobilePairingView.swift index a497cc975cf0..27d1812e97fa 100644 --- a/Sources/Mobile/Pairing/MobilePairingView.swift +++ b/Sources/Mobile/Pairing/MobilePairingView.swift @@ -141,11 +141,7 @@ struct MobilePairingView: View { private var content: some View { switch model.state { case .loading: - centered { - ProgressView().controlSize(.small) - Text(String(localized: "mobile.pairing.checking", defaultValue: "Checking…")) - .foregroundStyle(.secondary) - } + loadingContent case .signedOut: signedOut case .preparing: @@ -197,6 +193,13 @@ struct MobilePairingView: View { .multilineTextAlignment(.center) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) + if let lastFailure = browserSignIn?.lastFailure?.errorDescription, !lastFailure.isEmpty { + Text(lastFailure) + .font(.caption) + .multilineTextAlignment(.center) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + } Button(String(localized: "mobile.pairing.signIn.button", defaultValue: "Sign In")) { model.signIn() } @@ -205,6 +208,54 @@ struct MobilePairingView: View { .frame(maxWidth: .infinity, minHeight: 200) } + @ViewBuilder + private var loadingContent: some View { + if browserSignIn?.isSigningIn == true { + VStack(spacing: 12) { + HStack(spacing: 10) { + ProgressView().controlSize(.small) + Text(String(localized: "mobile.pairing.signIn.connecting", defaultValue: "Connecting…")) + .foregroundStyle(.secondary) + } + if browserSignIn?.signInIsSlow == true { + slowSignInFallback + } + } + .frame(maxWidth: .infinity, minHeight: 200) + } else { + centered { + ProgressView().controlSize(.small) + Text(String(localized: "mobile.pairing.checking", defaultValue: "Checking…")) + .foregroundStyle(.secondary) + } + } + } + + private var slowSignInFallback: some View { + VStack(spacing: 8) { + Text(String( + localized: "mobile.pairing.signIn.slowHint", + defaultValue: "The system sign-in window may stop responding. If nothing happens, open sign-in in your default browser instead." + )) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + Button { + guard let url = browserSignIn?.activeAttemptSignInURL else { return } + NSWorkspace.shared.open(url) + } label: { + Text(String( + localized: "mobile.pairing.signIn.openInBrowser", + defaultValue: "Open in Browser" + )) + } + .controlSize(.small) + } + .frame(maxWidth: 360) + } + private func failure(message: String) -> some View { VStack(spacing: 12) { Image(systemName: "exclamationmark.triangle") diff --git a/cmuxTests/AuthEnvironmentTests.swift b/cmuxTests/AuthEnvironmentTests.swift index 7e4a38654b9b..c83e978d53f0 100644 --- a/cmuxTests/AuthEnvironmentTests.swift +++ b/cmuxTests/AuthEnvironmentTests.swift @@ -57,6 +57,40 @@ struct AuthEnvironmentTests { assertNativeSignInURL(url) } + @Test("tagged debug sign-in URL uses local origin and tag callback scheme") + func taggedDebugSignInURLUsesLocalOriginAndTagCallbackScheme() throws { + let url = AuthEnvironment.signInURL( + callbackState: "state-1", + environment: [ + "CMUX_TAG": "pair-auth", + "CMUX_PORT": "4123", + ], + bundleIdentifier: "com.cmuxterm.app.debug.pair-auth" + ) + + #expect(url.scheme == "http") + #expect(url.host == "localhost") + #expect(url.port == 4123) + #expect(url.path == "/handler/native-sign-in") + + let afterAuthReturnTo = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == "after_auth_return_to" })? + .value) + let afterSignInURL = try #require(URL(string: afterAuthReturnTo)) + #expect(afterSignInURL.scheme == "http") + #expect(afterSignInURL.host == "localhost") + #expect(afterSignInURL.port == 4123) + + let nativeReturnTo = try #require(URLComponents(url: afterSignInURL, resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == "native_app_return_to" })? + .value) + let nativeCallbackURL = try #require(URL(string: nativeReturnTo)) + #expect(nativeCallbackURL.scheme == "cmux-dev-pair-auth") + #expect(nativeCallbackURL.host == "auth-callback") + } + @Test("sign-in URL ignores locale-like environment values") func signInURLIgnoresLocaleLikeEnvironmentValues() { let englishURL = AuthEnvironment.signInURL( diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index a7fb7961b298..132cd61bdb1e 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -103,6 +103,23 @@ } } }, + "auth.error.browser_sign_in_failed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not complete browser sign-in. Try again or open sign-in in your browser." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザでのサインインを完了できませんでした。もう一度試すか、ブラウザでサインインを開いてください。" + } + } + } + }, "auth.error.invalid_code_short": { "extractionState": "manual", "localizations": { @@ -120,6 +137,23 @@ } } }, + "auth.error.invalid_callback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The sign-in callback was invalid. Try signing in again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サインインコールバックが無効です。もう一度サインインしてください。" + } + } + } + }, "auth.error.invalid_email": { "extractionState": "manual", "localizations": {