Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
1ed2476
Add native sign-in callback regression tests
azooz2003-bit Jun 19, 2026
7b904eb
Surface native sign-in callback failures
azooz2003-bit Jun 19, 2026
13e4654
Localize native sign-in callback strings
azooz2003-bit Jun 19, 2026
4d323fc
Merge remote-tracking branch 'origin/main' into task-connect-device-a…
azooz2003-bit Jun 19, 2026
ade58d7
Split browser sign-in flow tests
azooz2003-bit Jun 19, 2026
db5af51
Bound host browser sign-in test waits
azooz2003-bit Jun 19, 2026
5873031
Localize iOS invalid auth callbacks
azooz2003-bit Jun 19, 2026
82a73c9
Ignore stale auth callbacks after sign-out
azooz2003-bit Jun 19, 2026
8591f77
Merge remote-tracking branch 'origin/main' into task-connect-device-a…
azooz2003-bit Jun 19, 2026
0c49f5f
Refresh Swift length budget after main merge
azooz2003-bit Jun 19, 2026
5e20598
Add fallback callback retry regression
azooz2003-bit Jun 19, 2026
c3cdbb5
Preserve fallback callback state until sign-in succeeds
azooz2003-bit Jun 19, 2026
d1554ef
Add hosted browser failure message regression
azooz2003-bit Jun 19, 2026
daaf8f3
Use generic hosted browser callback failure
azooz2003-bit Jun 19, 2026
d19aae0
Merge remote-tracking branch 'origin/main' into task-connect-device-a…
azooz2003-bit Jun 19, 2026
1f4e2ee
Refresh Swift length budget after main update
azooz2003-bit Jun 19, 2026
08ce03c
Keep browser sign-in helpers private
azooz2003-bit Jun 20, 2026
c45f703
Merge remote-tracking branch 'origin/main' into task-connect-device-a…
azooz2003-bit Jun 20, 2026
ba5768b
Clean up browser sign-in deadline helper
azooz2003-bit Jun 20, 2026
01cde11
Keep host browser test helpers on harness
azooz2003-bit Jun 20, 2026
7caef00
Localize pairing browser fallback
azooz2003-bit Jun 20, 2026
fe931bc
Add stale sign-out callback failure regression
azooz2003-bit Jun 20, 2026
15dba5d
Ignore stale callback failures after sign-out
azooz2003-bit Jun 20, 2026
1f00653
Surface browser sign-in session failures
azooz2003-bit Jun 20, 2026
5c00324
Bound host browser sign-in test waits
azooz2003-bit Jun 20, 2026
2a000cd
Merge remote-tracking branch 'origin/main' into task-connect-device-a…
azooz2003-bit Jun 20, 2026
a068307
Split host browser auth session result type
azooz2003-bit Jun 20, 2026
b2b321c
Keep browser auth result classification on factory instance
azooz2003-bit Jun 20, 2026
bd4b75f
Merge origin/main into task-connect-device-auth-hang
azooz2003-bit Jun 21, 2026
8245509
Localize browser sign-in failure message
azooz2003-bit Jun 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ public final class HostBrowserSignInFlow {
/// whenever an attempt completes, is cancelled, or is replaced.
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
Expand Down Expand Up @@ -138,8 +142,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)")
Expand All @@ -153,18 +158,21 @@ public final class HostBrowserSignInFlow {
)
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")
if callbackRouter.isAuthCallbackURL(url) {
lastFailure = .invalidCallback
}
return false
}

Expand All @@ -173,6 +181,7 @@ 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)")
Expand Down Expand Up @@ -228,14 +237,15 @@ public final class HostBrowserSignInFlow {
log.log("auth.browser.attempt.replace previous=\(activeAttemptID)")
}
cancelActiveAttempt()
lastFailure = nil
nextAttemptID &+= 1
let attemptID = nextAttemptID
let callbackState = pendingManualCallbackState ?? makeCallbackState()
pendingManualCallbackState = nil
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
Expand Down Expand Up @@ -341,6 +351,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()
}
}
Expand Down Expand Up @@ -384,15 +395,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
Expand All @@ -414,6 +428,10 @@ public final class HostBrowserSignInFlow {
try await coordinator.completeExternalSignIn()
} catch {
log.log("auth.callback completion failed: \(error)")
let displaySafe = AuthError(displaySafe: error) ?? .authFailure
if displaySafe != .cancelled {
lastFailure = displaySafe
}
Comment thread
azooz2003-bit marked this conversation as resolved.
Resolved
// 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
Expand Down Expand Up @@ -443,7 +461,7 @@ public final class HostBrowserSignInFlow {
)
return false
}
if callbackState(from: url) == pendingFallbackCallbackState {
if authCallbackState(from: url) == pendingFallbackCallbackState {
pendingFallbackCallbackState = nil
}
return true
Expand All @@ -470,30 +488,4 @@ public final class HostBrowserSignInFlow {
UUID().uuidString.lowercased()
}

private func callbackState(from url: URL) -> String? {
URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?
.first(where: { $0.name == "cmux_auth_state" })?
.value
}

private func redactedState(_ 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)"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import Foundation

/// MainActor-confined once-guard for racing continuation resumes.
@MainActor
final class ResumeOnceFlag {
var fired = false
}

func authCallbackState(from url: URL) -> String? {
URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?
.first(where: { $0.name == "cmux_auth_state" })?
.value
}

func redactedAuthState(_ state: String) -> String {
"\(state.prefix(8))..."
}

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)"
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ 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 session is no longer valid and the user must sign in again.
case unauthorized
/// A generic credential failure (e.g. wrong email/password).
Expand Down Expand Up @@ -60,6 +63,12 @@ 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 .unauthorized:
return String(
localized: "auth.error.unauthorized",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import CMUXAuthCore
import Foundation
import Testing
@testable import CmuxAuthRuntime

@MainActor
@Suite struct HostBrowserSignInFlowFailureTests {
@Test func invalidCallbackPayloadIsRejected() async {
let harness = makeHostBrowserSignInFlowHarness(user: CMUXAuthUser(id: "u1", primaryEmail: nil, displayName: nil))

let attempt = Task { await harness.flow.signIn(timeout: 60) }
await waitForHostBrowserSession(harness.factory)
harness.factory.sessions[0].deliver(URL(string: "cmux-dev://auth-callback?other=1&cmux_auth_state=\(hostBrowserCallbackState(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 = makeHostBrowserSignInFlowHarness(user: CMUXAuthUser(id: "u1", primaryEmail: nil, displayName: nil))

let attempt = Task { await harness.flow.signIn(timeout: 60) }
await waitForHostBrowserSession(harness.factory)

let result = await harness.flow.handleCallbackURL(hostBrowserCallbackURL(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 = makeHostBrowserSignInFlowHarness(user: nil)

let attempt = Task { await harness.flow.signIn(timeout: 60) }
await waitForHostBrowserSession(harness.factory)
harness.factory.sessions[0].deliver(hostBrowserCallbackURL(state: hostBrowserCallbackState(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 abandonedBrowserAttemptTimesOut() async throws {
let clock = ManualTestClock()
let harness = makeHostBrowserSignInFlowHarness(browserAttemptTimeout: 1, clock: clock)

harness.flow.beginSignIn()
await waitForHostBrowserSession(harness.factory)
await clock.waitUntilSleepers(count: 2)

clock.advance(by: .seconds(1))

await waitForHostBrowserCondition { 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)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
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
}

@MainActor
func makeHostBrowserSignInFlowHarness(
user: CMUXAuthUser? = nil,
browserAttemptTimeout: TimeInterval = 5 * 60,
slowSignInThreshold: TimeInterval = 30,
clock: (any Clock<Duration>)? = nil
) -> HostBrowserSignInFlowHarness {
let store = FakeKeyValueStore()
// The fake client reads and clears the SAME token store the flow seeds, like
// production. Split stores would 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()
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 HostBrowserSignInFlowHarness(
flow: flow,
coordinator: coordinator,
client: client,
tokenStore: tokenStore,
factory: factory
)
}

func hostBrowserCallbackURL(state: String) -> URL {
URL(string: "cmux-dev://auth-callback?stack_refresh=refresh-1&stack_access=access-1&cmux_auth_state=\(state)")!
}

func hostBrowserFallbackCallbackURL() -> URL {
URL(string: "cmux-dev://auth-callback?stack_refresh=refresh-1&stack_access=access-1")!
}

func hostBrowserCallbackState(_ session: FakeBrowserAuthSession) -> String {
URLComponents(url: session.signInURL, resolvingAgainstBaseURL: false)?
.queryItems?
.first(where: { $0.name == "cmux_auth_state" })?
.value ?? ""
}

@MainActor
func waitForHostBrowserSession(_ 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()
}
}

@MainActor
func waitForHostBrowserCondition(until condition: @MainActor () -> Bool) async {
while !condition() {
await Task.yield()
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Loading
Loading