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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@
698 cmuxTests/RestorableAgentHookProviderResumeTests.swift
697 cmuxTests/TerminalNotificationClearAllTests.swift
696 cmuxTests/UpdatePillReleaseVisibilityTests.swift
694 Packages/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift
757 Packages/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift
693 Sources/Panels/BrowserPopupWindowController.swift
691 cmuxTests/TaskManagerResourcesTests.swift
685 Sources/RightSidebarPanelView.swift
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,11 @@ public final class AuthCoordinator {
private let anchor: any AuthPresentationAnchoring
private let config: AuthConfig
private let launch: AuthLaunchOptions
private let timeouts: AuthTimeouts
private let clock: any Clock<Duration>
private let isOnline: @Sendable () async -> Bool
private let onSignedIn: @Sendable () async -> Void
private let log = AuthDebugLog()

private var pendingNonce: String?
private var debugCredentials: CMUXAuthAutoLoginCredentials?
Expand All @@ -81,6 +84,12 @@ public final class AuthCoordinator {
/// - anchor: Presentation anchor provider for OAuth flows.
/// - config: Resolved auth configuration (callback URL, project, API base).
/// - launch: Launch-time priming inputs (UI-test fixtures, dev-auth flag).
/// - timeouts: Per-phase deadlines for the sign-in/session flows. Every
/// phase that holds a loading state is bounded so the UI can never spin
/// forever; a phase that hits its deadline fails with the localized,
/// retryable ``AuthError/timedOut``. Defaults to ``AuthTimeouts/default``.
/// - clock: The clock the phase deadlines sleep on. Injected so tests
/// drive timeouts with virtual time. Defaults to `ContinuousClock`.
/// - isOnline: Connectivity probe; sign-in flows fail fast when offline.
/// Defaults to always-online so tests need not supply it.
/// - onSignedIn: Hook run after a successful sign-in / session restore, for
Expand All @@ -94,6 +103,8 @@ public final class AuthCoordinator {
anchor: any AuthPresentationAnchoring,
config: AuthConfig,
launch: AuthLaunchOptions,
timeouts: AuthTimeouts = .default,
clock: any Clock<Duration> = ContinuousClock(),
isOnline: @escaping @Sendable () async -> Bool = { true },
onSignedIn: @escaping @Sendable () async -> Void = {}
) {
Expand All @@ -104,6 +115,8 @@ public final class AuthCoordinator {
self.anchor = anchor
self.config = config
self.launch = launch
self.timeouts = timeouts
self.clock = clock
self.isOnline = isOnline
self.onSignedIn = onSignedIn
self.selectedTeamID = teamSelection.selectedTeamID
Expand Down Expand Up @@ -280,7 +293,11 @@ public final class AuthCoordinator {

private func validateCachedSession() async {
do {
if let user = try await client.currentUser(throwOnMissing: true) {
let client = self.client
let user = try await runPhase(.validateSession, timeout: timeouts.network) {
try await client.currentUser(throwOnMissing: true)
}
if let user {
await applySignedInUser(user)
return
}
Expand Down Expand Up @@ -340,10 +357,11 @@ public final class AuthCoordinator {
}

do {
let nonce = try await client.sendMagicLinkEmail(
email: email,
callbackURL: config.magicLinkCallbackURL
)
let client = self.client
let callbackURL = config.magicLinkCallbackURL
let nonce = try await runPhase(.sendCode, timeout: timeouts.network) {
try await client.sendMagicLinkEmail(email: email, callbackURL: callbackURL)
}
pendingNonce = nonce
} catch {
throw AuthError(displaySafe: error) ?? error
Expand All @@ -361,7 +379,10 @@ public final class AuthCoordinator {

let fullCode = CMUXAuthMagicLinkCode(code: code, nonce: nonce).composed
do {
try await client.signInWithMagicLink(code: fullCode)
let client = self.client
try await runPhase(.verifyCode, timeout: timeouts.network) {
try await client.signInWithMagicLink(code: fullCode)
}
try await completeSignIn()
} catch {
throw AuthError(displaySafe: error) ?? error
Expand All @@ -376,7 +397,10 @@ public final class AuthCoordinator {
defer { if setLoading { isLoading = false } }

do {
try await client.signInWithCredential(email: email, password: password)
let client = self.client
try await runPhase(.passwordSignIn, timeout: timeouts.network) {
try await client.signInWithCredential(email: email, password: password)
}
try await completeSignIn()
} catch {
throw AuthError(displaySafe: error) ?? error
Expand All @@ -398,15 +422,28 @@ public final class AuthCoordinator {
isLoading = true
defer { isLoading = false }
do {
try await client.signInWithOAuth(provider: provider, anchor: anchor)
// Interactive deadline: ASAuthorizationController (Sign in with
// Apple) and ASWebAuthenticationSession callbacks are not
// guaranteed to fire; without a bound a stuck system sheet left
// the sign-in screen loading forever with no error and no way out.
let client = self.client
let anchor = self.anchor
try await runPhase(.oauth, timeout: timeouts.interactiveFlow) {
try await client.signInWithOAuth(provider: provider, anchor: anchor)
}
try await completeSignIn()
} catch {
log.log("auth.oauth provider=\(provider) failed: \(error)")
throw AuthError(displaySafe: error) ?? error
}
}

private func completeSignIn() async throws {
guard let user = try await client.currentUser(throwOnMissing: true) else {
let client = self.client
let user = try await runPhase(.fetchUser, timeout: timeouts.network) {
try await client.currentUser(throwOnMissing: true)
}
Comment on lines +444 to +445

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Raw error logged at .public privacy via AuthDebugLog

log.log("auth.oauth provider=\(provider) failed: \(error)") routes through AuthDebugLog, which passes the redacted string to logger.log(…, "\(redactedMessage, privacy: .public)"). The redaction patterns strip tokens, JWTs, and email addresses, but not the message field of StackAuthError or OAuthError — those can include server-controlled strings or the verbose Apple SIWA diagnostic messages hardcoded in AppleSignInDelegate (up to ~300 characters of Xcode/developer.apple.com instructions). Using .public means this content is visible to any log collector without the usual privacy: .private scrubbing. The existing error logs in validateCachedSession and tryAutoLogin use authLog.error(…, privacy: .private) for the same reason. Either route this through authLog with privacy: .private, or extend AuthDebugLog.redacted to strip the message field from Stack SDK error representations before the .public sink sees them.

guard let user else {
throw AuthError.unauthorized
}
await applySignedInUser(user)
Expand Down Expand Up @@ -585,14 +622,24 @@ public final class AuthCoordinator {
saveCachedUser(user)
sessionCache.setHasTokens(true)
await refreshTeams()
await onSignedIn()
// Bound the post-sign-in hook (e.g. push token re-upload) too: it runs
// while `isLoading` is still true, so an unbounded hook would hold the
// sign-in spinner after the session is already published. Failure and
// timeout are tolerated; the hook is a side effect, not a gate.
let onSignedIn = self.onSignedIn
_ = try? await runPhase(.postSignIn, timeout: timeouts.network) {
await onSignedIn()
}
}

/// Refresh ``availableTeams`` from the client, tolerating failure so a
/// flaky team fetch never blocks or unwinds a successful sign-in.
private func refreshTeams() async {
do {
let teams = try await client.listTeams()
let client = self.client
let teams = try await runPhase(.listTeams, timeout: timeouts.network) {
try await client.listTeams()
}
availableTeams = teams
selectedTeamID = Self.resolveTeamID(selectedTeamID: selectedTeamID, teams: teams)
} catch {
Expand Down Expand Up @@ -647,6 +694,22 @@ public final class AuthCoordinator {
}
}

/// Race `operation` against the phase deadline on the injected clock.
/// See ``withAuthPhaseTimeout(_:duration:clock:log:operation:)``.
private func runPhase<T: Sendable>(
_ phase: AuthPhase,
timeout: Duration,
_ operation: @escaping @Sendable () async throws -> T
) async throws -> T {
try await withAuthPhaseTimeout(
phase,
duration: timeout,
clock: clock,
log: log,
operation: operation
)
}

private func apply(_ state: CMUXAuthState) {
currentUser = state.currentUser
isAuthenticated = state.isAuthenticated
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,19 @@ extension AuthError {
self = authError
return
}
if error is CancellationError || Task.isCancelled {
// A cancelled flow (the user backed out, or the sheet's task was
// torn down) is not a failure to render. Mapping it to the generic
// server error would flash "Something went wrong" after every
// deliberate cancel. The `Task.isCancelled` arm matters because
// URLSession-backed phases surface task cancellation as
// `URLError(.cancelled)` (or a Stack transport error wrapping it),
// not as `CancellationError`: this mapping always runs in the
// cancelled flow's own catch, so any error caught on a cancelled
// task is the cancellation, not an independent failure.
self = .cancelled
Comment on lines 18 to +30

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Task.isCancelled check swallows all errors on a cancelled task, including real auth failures

The mapping if error is CancellationError || Task.isCancelled { self = .cancelled } is intentional (the PR description explains the URLSession surface), and the two new tests (anyErrorCaughtOnACancelledTaskMapsToCancelled, urlCancellationOnALiveTaskStaysNetworkError) lock the contract well. One narrow edge case worth noting in the comment: if a real auth error (e.g., serverError(401, "auth_failed")) is thrown at the exact moment the task is externally cancelled — cancelled wins and the error is silently dropped as .cancelled. In practice the window is tiny and the user chose to cancel, so the silent outcome is acceptable UX, but this could become surprising if AuthCoordinator is ever called from a context where task cancellation originates from something other than a user action (e.g., app-lifecycle teardown). A brief note in the comment would help the next reader not widen this mapping without understanding the trade-off.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

return
}
if let stackError = error as? any StackAuthErrorProtocol {
switch stackError.code.uppercased() {
case "OAUTH_CANCELLED":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ public enum AuthError: Error, LocalizedError, Equatable, Sendable {
case offline
/// A transport-level network failure (timeout, DNS, TLS, etc.).
case networkError
/// A bounded sign-in phase hit its deadline without completing or failing.
/// Replaces the prior behavior of waiting forever on a call (or a system
/// auth callback) that never resolves.
case timedOut
/// The auth server returned a non-success status. Carries the HTTP status
/// code (or `0` when unknown) and a stable machine-readable reason.
case serverError(Int, String)
Expand Down Expand Up @@ -38,6 +42,12 @@ public enum AuthError: Error, LocalizedError, Equatable, Sendable {
defaultValue: "Network error. Please check your connection.",
bundle: .main
)
case .timedOut:
return String(
localized: "auth.error.timed_out",
defaultValue: "Sign-in timed out. Check your connection and try again.",
bundle: .main
)
case .serverError:
return String(
localized: "auth.error.server_error",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Foundation

/// The sign-in/session phase a deadline applies to. Used as the stable label
/// in timeout diagnostics so a stuck-at-sign-in report names the exact phase.
enum AuthPhase: String, Sendable {
case sendCode = "send_code"
case verifyCode = "verify_code"
case passwordSignIn = "password_sign_in"
case oauth = "oauth"
case fetchUser = "fetch_user"
case validateSession = "validate_session"
case listTeams = "list_teams"
case postSignIn = "post_sign_in"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import Foundation

/// Internal marker distinguishing "the deadline child fired" from errors the
/// operation itself threw (including `CancellationError` when the caller's
/// task is cancelled, which must surface as a cancellation, not a timeout).
private struct AuthPhaseDeadlineExceeded: Error {}

/// Race `operation` against a `duration` deadline on `clock`.
///
/// Structured: whichever side finishes first cancels the other, and the task
/// group joins both children before returning, so neither can outlive the
/// call. The operation must be cancellation-responsive for the bound to be
/// real (URLSession calls are; the interactive OAuth waits are made so by the
/// cancellation gates in the vendored Stack SDK).
///
/// - Parameters:
/// - phase: The phase label for timeout diagnostics.
/// - duration: The deadline.
/// - clock: The clock the deadline sleeps on (virtual in tests).
/// - log: Redacted diagnostics sink; timeouts log the phase and duration.
/// - operation: The bounded work.
/// - Returns: The operation's value when it beats the deadline.
/// - Throws: ``AuthError/timedOut`` at the deadline; otherwise rethrows the
/// operation's error (including `CancellationError` on outer cancellation).
func withAuthPhaseTimeout<T: Sendable>(
_ phase: AuthPhase,
duration: Duration,
clock: any Clock<Duration>,
log: AuthDebugLog,
operation: @escaping @Sendable () async throws -> T
) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask {
try await operation()
}
group.addTask {
try await clock.sleep(for: duration, tolerance: nil)
throw AuthPhaseDeadlineExceeded()
}
// Cancel the loser on every exit path; the group then joins it.
defer { group.cancelAll() }
do {
guard let first = try await group.next() else {
throw AuthError.timedOut
}
Comment on lines +40 to +45

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 nil branch of group.next() is unreachable

With exactly two child tasks added (the operation and the deadline), the first call to group.next() can never return nil — nil signals an empty group, but both children are still pending at that point. The else branch mapping to AuthError.timedOut is dead code. While it doesn't affect correctness (the nil case would map to the same error as a real timeout), it could mislead a future reader into thinking group.next() has some documented empty-return path here.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

return first
} catch is AuthPhaseDeadlineExceeded {
log.log("auth.phase=\(phase.rawValue) timed out after \(duration)")
throw AuthError.timedOut
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import Foundation

/// Per-phase deadlines for the sign-in and session flows the
/// ``AuthCoordinator`` drives.
///
/// Every phase that holds a user-visible loading state is raced against one of
/// these deadlines on the coordinator's injected clock, so no flow can leave
/// the UI spinning forever: a phase that neither completes nor fails by its
/// deadline ends as ``AuthError/timedOut`` (localized, retryable). Tests
/// exercise the deadlines with a virtual clock instead of real waiting.
public struct AuthTimeouts: Sendable, Equatable {
/// Deadline for flows that include interactive system auth UI
/// (`ASAuthorizationController` / `ASWebAuthenticationSession`). Generous,
/// because the user may legitimately be typing a password or completing
/// 2FA inside the sheet; it exists to terminate the "callback never fires"
/// hang, not to rush the user. The sign-in UI offers a cancel affordance
/// for bailing out earlier.
public var interactiveFlow: Duration
/// Deadline for non-interactive backend calls (magic-link send/verify,
/// credential sign-in, user fetch, session validation, team list, the
/// post-sign-in hook). Sized above the Stack SDK's 30s per-request timeout
/// so it only fires when retries stack up or a call wedges.
public var network: Duration

/// The production defaults: 300s interactive, 60s network.
public static let `default` = AuthTimeouts(
interactiveFlow: .seconds(300),
network: .seconds(60)
)

/// Creates a timeout table.
/// - Parameters:
/// - interactiveFlow: Deadline for flows with interactive system UI.
/// - network: Deadline for non-interactive backend calls.
public init(interactiveFlow: Duration, network: Duration) {
self.interactiveFlow = interactiveFlow
self.network = network
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import CMUXAuthCore
import Foundation
import Testing
@testable import CmuxAuthRuntime

@MainActor
@Suite struct AuthCoordinatorCancellationTests {
private func makeCoordinator(client: any AuthClient) -> AuthCoordinator {
let store = FakeKeyValueStore()
return 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()
)
}

@Test func taskCancellationMapsToCancelledNotGenericError() {
// Backing out of a stuck sign-in is not a failure: it must map to
// .cancelled (which the UI silently ignores), not the generic
// "Something went wrong" server error.
#expect(AuthError(displaySafe: CancellationError()) == .cancelled)
}

@Test func cancellingInFlightOAuthSignInThrowsCancelledAndStopsLoading() async {
let client = HangingOAuthAuthClient()
let coordinator = makeCoordinator(client: client)

let signIn = Task { try await coordinator.signInWithApple() }
await client.oauthDidStart()
signIn.cancel()

await #expect(throws: AuthError.cancelled) { try await signIn.value }
#expect(coordinator.isLoading == false)
}
}
Loading
Loading