"; };
@@ -1656,6 +1658,7 @@
F6355601A1B2C3D4E5F60718 /* SSHStartupSignalLifecycleTests.swift */,
F6120001A1B2C3D4E5F60718 /* WorkspaceSSHFishShellTests.swift */,
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */,
+ A36170100000000000000002 /* AuthManagerExternalBrowserSignInTests.swift */,
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */,
F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */,
F1C1AA20B7E84D10A1C10001 /* InactivePaneFirstClickFocusTests.swift */,
@@ -2446,6 +2449,7 @@
C34670010000000000000001 /* AppDelegateRenameShortcutContextTests.swift in Sources */,
F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */,
A11EAA000000000000000000 /* AppearanceSettingsTests.swift in Sources */,
+ A36170100000000000000001 /* AuthManagerExternalBrowserSignInTests.swift in Sources */,
D3622000A1B2C3D4E5F60718 /* BrowserArrowKeyForwardingTests.swift in Sources */,
E12E88F82733EC42F32C36A3 /* BrowserConfigTests.swift in Sources */,
A5008381 /* BrowserFindJavaScriptTests.swift in Sources */,
diff --git a/cmuxTests/AuthManagerExternalBrowserSignInTests.swift b/cmuxTests/AuthManagerExternalBrowserSignInTests.swift
new file mode 100644
index 0000000000..9548a51a2e
--- /dev/null
+++ b/cmuxTests/AuthManagerExternalBrowserSignInTests.swift
@@ -0,0 +1,185 @@
+import Foundation
+import Testing
+import CMUXAuthCore
+
+#if canImport(cmux_DEV)
+@testable import cmux_DEV
+#elseif canImport(cmux)
+@testable import cmux
+#endif
+
+@MainActor
+@Suite
+struct AuthManagerExternalBrowserSignInTests {
+ @Test
+ func beginSignInOpensSignInURLThroughInjectedBrowserOpener() async throws {
+ let suiteName = "AuthManagerExternalBrowserSignInTests.\(UUID().uuidString)"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ var openedURL: URL?
+ let manager = AuthManager(
+ client: AuthManagerExternalBrowserSignInTestClient(),
+ tokenStore: AuthManagerExternalBrowserSignInTestTokenStore(),
+ settingsStore: AuthSettingsStore(userDefaults: defaults),
+ urlOpener: { url in
+ openedURL = url
+ }
+ )
+ await manager.awaitBootstrapped()
+
+ manager.beginSignIn()
+ let isLoadingAfterBegin = manager.isLoading
+ await manager.signOut()
+
+ let url = try #require(openedURL)
+ #expect(url.path == "/handler/sign-in")
+
+ let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false))
+ let afterAuthReturnTo = try #require(
+ components.queryItems?.first { $0.name == "after_auth_return_to" }?.value
+ )
+ #expect(afterAuthReturnTo.contains("native_app_return_to="))
+ #expect(afterAuthReturnTo.contains("auth-callback"))
+ #expect(afterAuthReturnTo.contains("state="))
+ #expect(isLoadingAfterBegin)
+ }
+
+ @Test
+ func beginSignInTimesOutWhenBrowserCallbackDoesNotReturn() async throws {
+ let suiteName = "AuthManagerExternalBrowserSignInTests.\(UUID().uuidString)"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let manager = AuthManager(
+ client: AuthManagerExternalBrowserSignInTestClient(),
+ tokenStore: AuthManagerExternalBrowserSignInTestTokenStore(),
+ settingsStore: AuthSettingsStore(userDefaults: defaults),
+ urlOpener: { _ in }
+ )
+ await manager.awaitBootstrapped()
+
+ manager.beginSignIn(timeout: 0)
+ try await Task.sleep(nanoseconds: 20_000_000)
+
+ #expect(!manager.isLoading)
+ #expect(manager.userFacingSignInErrorMessage == AuthManagerError.signInTimedOut.userFacingMessage)
+ }
+
+ @Test
+ func stateBearingCallbackAfterTimeoutIsIgnored() async throws {
+ let suiteName = "AuthManagerExternalBrowserSignInTests.\(UUID().uuidString)"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ var openedURL: URL?
+ let tokenStore = AuthManagerExternalBrowserSignInTestTokenStore()
+ let manager = AuthManager(
+ client: AuthManagerExternalBrowserSignInTestClient(),
+ tokenStore: tokenStore,
+ settingsStore: AuthSettingsStore(userDefaults: defaults),
+ urlOpener: { url in
+ openedURL = url
+ }
+ )
+ await manager.awaitBootstrapped()
+
+ manager.beginSignIn(timeout: 0)
+ let signInURL = try #require(openedURL)
+ let state = try #require(callbackState(fromSignInURL: signInURL))
+ try await Task.sleep(nanoseconds: 20_000_000)
+
+ let staleCallbackURL = try #require(
+ URL(string: "cmux://auth-callback?stack_refresh=refresh&stack_access=access&state=\(state)")
+ )
+ try await manager.handleCallbackURL(staleCallbackURL)
+
+ #expect(!manager.isAuthenticated)
+ #expect(await tokenStore.getStoredAccessToken() == nil)
+ #expect(await tokenStore.getStoredRefreshToken() == nil)
+ }
+
+ @Test
+ func statelessCallbackWithoutActiveAttemptIsIgnored() async throws {
+ let suiteName = "AuthManagerExternalBrowserSignInTests.\(UUID().uuidString)"
+ let defaults = try #require(UserDefaults(suiteName: suiteName))
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+
+ let tokenStore = AuthManagerExternalBrowserSignInTestTokenStore()
+ let manager = AuthManager(
+ client: AuthManagerExternalBrowserSignInTestClient(),
+ tokenStore: tokenStore,
+ settingsStore: AuthSettingsStore(userDefaults: defaults),
+ urlOpener: { _ in }
+ )
+ await manager.awaitBootstrapped()
+
+ let callbackURL = try #require(
+ URL(string: "cmux://auth-callback?stack_refresh=refresh&stack_access=access")
+ )
+ try await manager.handleCallbackURL(callbackURL)
+
+ #expect(!manager.isAuthenticated)
+ #expect(await tokenStore.getStoredAccessToken() == nil)
+ #expect(await tokenStore.getStoredRefreshToken() == nil)
+ }
+
+ private func callbackState(fromSignInURL url: URL) -> String? {
+ let signInComponents = URLComponents(url: url, resolvingAgainstBaseURL: false)
+ let afterAuthReturnTo = signInComponents?.queryItems?
+ .first { $0.name == "after_auth_return_to" }?
+ .value
+ let afterAuthComponents = afterAuthReturnTo.flatMap {
+ URLComponents(string: $0)
+ }
+ let nativeReturnTo = afterAuthComponents?.queryItems?
+ .first { $0.name == "native_app_return_to" }?
+ .value
+ let nativeComponents = nativeReturnTo.flatMap {
+ URLComponents(string: $0)
+ }
+ return nativeComponents?.queryItems?
+ .first { $0.name == "state" }?
+ .value
+ }
+}
+
+private struct AuthManagerExternalBrowserSignInTestClient: AuthClientProtocol {
+ func currentUser() async throws -> CMUXAuthUser? { nil }
+ func listTeams() async throws -> [AuthTeamSummary] { [] }
+ func currentAccessToken() async throws -> String? { nil }
+ func signOut() async throws {}
+}
+
+private actor AuthManagerExternalBrowserSignInTestTokenStore: StackAuthTokenStoreProtocol {
+ private var accessToken: String?
+ private var refreshToken: String?
+
+ func getStoredAccessToken() async -> String? {
+ accessToken
+ }
+
+ func getStoredRefreshToken() async -> String? {
+ refreshToken
+ }
+
+ func setTokens(accessToken: String?, refreshToken: String?) async {
+ self.accessToken = accessToken
+ self.refreshToken = refreshToken
+ }
+
+ func clearTokens() async {
+ accessToken = nil
+ refreshToken = nil
+ }
+
+ func compareAndSet(
+ compareRefreshToken: String,
+ newRefreshToken: String?,
+ newAccessToken: String?
+ ) async {
+ guard refreshToken == compareRefreshToken else { return }
+ refreshToken = newRefreshToken
+ accessToken = newAccessToken
+ }
+}
diff --git a/cmuxTests/SocketControlPasswordStoreTests.swift b/cmuxTests/SocketControlPasswordStoreTests.swift
index fef69a4ce9..aad9e46953 100644
--- a/cmuxTests/SocketControlPasswordStoreTests.swift
+++ b/cmuxTests/SocketControlPasswordStoreTests.swift
@@ -307,7 +307,10 @@ final class AuthManagerSignOutTests: XCTestCase {
await manager.awaitBootstrapped()
await tokenStore.suspendNextSetTokens()
- let callbackURL = try XCTUnwrap(URL(string: "cmux://auth-callback?stack_refresh=refresh-after-signout&stack_access=access-after-signout"))
+ let signInState = manager.markBrowserSignInLoadingForTesting(state: UUID().uuidString)
+ let callbackURL = try XCTUnwrap(URL(
+ string: "cmux://auth-callback?stack_refresh=refresh-after-signout&stack_access=access-after-signout&state=\(signInState)"
+ ))
let callbackTask = Task { @MainActor in
try await manager.handleCallbackURL(callbackURL)
}
@@ -354,7 +357,10 @@ final class AuthManagerSignOutTests: XCTestCase {
}
await client.waitForSignOutStarted()
- let callbackURL = try XCTUnwrap(URL(string: "cmux://auth-callback?stack_refresh=new-refresh&stack_access=new-access"))
+ let signInState = manager.markBrowserSignInLoadingForTesting(state: UUID().uuidString)
+ let callbackURL = try XCTUnwrap(URL(
+ string: "cmux://auth-callback?stack_refresh=new-refresh&stack_access=new-access&state=\(signInState)"
+ ))
try await manager.handleCallbackURL(callbackURL)
await client.resumeSignOut()
await signOutTask.value
diff --git a/web/app/handler/after-sign-in/OpenNativeClient.tsx b/web/app/handler/after-sign-in/OpenNativeClient.tsx
index 19c68a27e0..0b44f7273b 100644
--- a/web/app/handler/after-sign-in/OpenNativeClient.tsx
+++ b/web/app/handler/after-sign-in/OpenNativeClient.tsx
@@ -1,6 +1,12 @@
"use client";
+import { useEffect } from "react";
+
export function OpenNativeClient({ href }: { href: string }) {
+ useEffect(() => {
+ window.location.assign(href);
+ }, [href]);
+
return (
value.startsWith(scheme));
+}
+
+export function nativeAuthCallbackForReturnTo(
+ value: string | null | undefined,
+): string | null {
+ const scheme = NATIVE_SCHEMES.find((candidate) =>
+ value?.startsWith(candidate),
+ );
+ if (!scheme) return null;
+
+ const callback = new URL(`${scheme}${NATIVE_AUTH_CALLBACK_TARGET}`);
+ try {
+ const source = new URL(value ?? "");
+ const state = source.searchParams.get("state");
+ if (state) callback.searchParams.set("state", state);
+ } catch {}
+ return callback.toString();
+}
+
+export type NativeHandoffArgs = {
+ refreshToken: string | undefined;
+ accessToken: string | undefined;
+};
+
+export function shouldEmitNativeHandoff(args: NativeHandoffArgs): boolean {
+ return Boolean(args.refreshToken && args.accessToken);
+}
diff --git a/web/app/handler/after-sign-in/page.tsx b/web/app/handler/after-sign-in/page.tsx
index 13fa2d17c6..720f87ceb2 100644
--- a/web/app/handler/after-sign-in/page.tsx
+++ b/web/app/handler/after-sign-in/page.tsx
@@ -3,38 +3,43 @@ import { notFound, redirect } from "next/navigation";
import { stackServerApp } from "../../lib/stack";
import { env } from "../../env";
import { OpenNativeClient } from "./OpenNativeClient";
+import {
+ nativeAuthCallbackForReturnTo,
+ shouldEmitNativeHandoff,
+} from "./native-handoff";
export const dynamic = "force-dynamic";
-const NATIVE_SCHEME = "cmux://";
-const NATIVE_SCHEMES = [NATIVE_SCHEME, "cmux-nightly://", "cmux-dev://"];
-
function findStackCookie(
cookieStore: { getAll: () => { name: string; value: string }[] },
- baseName: string
+ baseName: string,
): string | undefined {
const all = cookieStore.getAll();
for (const prefix of ["__Host-", "__Secure-", ""]) {
const withBranch = all.find(
- (c) => c.name.startsWith(`${prefix}${baseName}--`) && c.value
+ (c) => c.name.startsWith(`${prefix}${baseName}--`) && c.value,
);
if (withBranch) return withBranch.value;
- const exact = all.find(
- (c) => c.name === `${prefix}${baseName}` && c.value
- );
+ const exact = all.find((c) => c.name === `${prefix}${baseName}` && c.value);
if (exact) return exact.value;
}
return undefined;
}
-function decodeAccessCookie(value: string | undefined): { refreshToken?: string; accessToken?: string } {
+function decodeAccessCookie(value: string | undefined): {
+ refreshToken?: string;
+ accessToken?: string;
+} {
if (!value) return {};
const decoded = value.includes("%") ? decodeURIComponent(value) : value;
if (!decoded.startsWith("[")) return { accessToken: decoded };
try {
const arr = JSON.parse(decoded) as unknown[];
if (Array.isArray(arr) && arr.length >= 2) {
- return { refreshToken: arr[0] as string, accessToken: arr[1] as string };
+ return {
+ refreshToken: arr[0] as string,
+ accessToken: arr[1] as string,
+ };
}
} catch {}
return {};
@@ -52,19 +57,18 @@ function decodeRefreshCookie(value: string | undefined): string | undefined {
}
function buildNativeHref(
- baseHref: string | null,
+ baseHref: string,
refreshToken: string | undefined,
- accessCookie: string | undefined
+ accessCookie: string | undefined,
): string | null {
- if (!refreshToken || !accessCookie) return baseHref;
- const href = baseHref ?? `${NATIVE_SCHEME}auth-callback`;
+ if (!refreshToken || !accessCookie) return null;
try {
- const url = new URL(href);
+ const url = new URL(baseHref);
url.searchParams.set("stack_refresh", refreshToken);
url.searchParams.set("stack_access", accessCookie);
return url.toString();
} catch {
- return `${NATIVE_SCHEME}auth-callback?stack_refresh=${encodeURIComponent(refreshToken)}&stack_access=${encodeURIComponent(accessCookie)}`;
+ return null;
}
}
@@ -72,7 +76,9 @@ type Props = {
searchParams?: Promise>;
};
-export default async function AfterSignInPage({ searchParams: searchParamsPromise }: Props) {
+export default async function AfterSignInPage({
+ searchParams: searchParamsPromise,
+}: Props) {
const projectId = env.NEXT_PUBLIC_STACK_PROJECT_ID;
if (!stackServerApp || !projectId) notFound();
@@ -85,13 +91,19 @@ export default async function AfterSignInPage({ searchParams: searchParamsPromis
let refreshToken = parsedAccess.refreshToken ?? parsedRefresh;
let accessToken = parsedAccess.accessToken;
- let accessCookie = rawAccessCookie ? (rawAccessCookie.includes("%") ? decodeURIComponent(rawAccessCookie) : rawAccessCookie) : undefined;
+ let accessCookie = rawAccessCookie
+ ? rawAccessCookie.includes("%")
+ ? decodeURIComponent(rawAccessCookie)
+ : rawAccessCookie
+ : undefined;
// Create a fresh session to get valid tokens for the native app
try {
const user = await stackServerApp.getUser({ or: "return-null" });
if (user) {
- const session = await user.createSession({ expiresInMillis: 30 * 24 * 60 * 60 * 1000 });
+ const session = await user.createSession({
+ expiresInMillis: 30 * 24 * 60 * 60 * 1000,
+ });
const tokens = await session.getTokens();
if (tokens.refreshToken) refreshToken = tokens.refreshToken;
if (tokens.accessToken) accessToken = tokens.accessToken;
@@ -105,37 +117,32 @@ export default async function AfterSignInPage({ searchParams: searchParamsPromis
}
const searchParams = await searchParamsPromise;
- const nativeReturnTo = typeof searchParams?.native_app_return_to === "string"
- ? searchParams.native_app_return_to
- : null;
+ const nativeReturnTo =
+ typeof searchParams?.native_app_return_to === "string"
+ ? searchParams.native_app_return_to
+ : null;
// Native app deep link. Only emit the handoff when both tokens are
// available; otherwise the OpenNativeClient would launch cmux with an empty
// auth payload, which would produce a spurious "not signed in" flash.
+ const nativeCallbackHref = nativeAuthCallbackForReturnTo(nativeReturnTo);
if (
- refreshToken &&
- accessCookie &&
- nativeReturnTo !== null &&
- NATIVE_SCHEMES.some((scheme) => nativeReturnTo.startsWith(scheme))
+ shouldEmitNativeHandoff({ refreshToken, accessToken }) &&
+ nativeCallbackHref
) {
- const href = buildNativeHref(nativeReturnTo, refreshToken, accessCookie);
+ const href = buildNativeHref(nativeCallbackHref, refreshToken, accessCookie);
if (href) return ;
}
// Web redirect (relative paths only). Reject protocol-relative paths like
// "//evil.com" that Next.js would treat as external redirects.
- const afterAuth = typeof searchParams?.after_auth_return_to === "string"
- ? searchParams.after_auth_return_to
- : null;
+ const afterAuth =
+ typeof searchParams?.after_auth_return_to === "string"
+ ? searchParams.after_auth_return_to
+ : null;
if (afterAuth && afterAuth.startsWith("/") && !afterAuth.startsWith("//")) {
redirect(afterAuth);
}
- // Fallback: try native app only when we actually have tokens to hand off.
- if (refreshToken && accessCookie) {
- const fallback = buildNativeHref(null, refreshToken, accessCookie);
- if (fallback) return ;
- }
-
redirect("/");
}
diff --git a/web/tests/native-handoff.test.ts b/web/tests/native-handoff.test.ts
new file mode 100644
index 0000000000..e34045c6d0
--- /dev/null
+++ b/web/tests/native-handoff.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, test } from "bun:test";
+import {
+ isNativeReturnScheme,
+ nativeAuthCallbackForReturnTo,
+ shouldEmitNativeHandoff,
+} from "../app/handler/after-sign-in/native-handoff";
+
+describe("native-handoff", () => {
+ describe("isNativeReturnScheme", () => {
+ test("returns true for cmux native schemes", () => {
+ expect(isNativeReturnScheme("cmux://auth-callback")).toBe(true);
+ expect(isNativeReturnScheme("cmux-nightly://auth-callback")).toBe(true);
+ expect(isNativeReturnScheme("cmux-dev://auth-callback")).toBe(true);
+ });
+
+ test("returns false for non-native schemes", () => {
+ expect(isNativeReturnScheme("https://cmux.com")).toBe(false);
+ expect(isNativeReturnScheme("cmuxapp://auth-callback")).toBe(false);
+ expect(isNativeReturnScheme(null)).toBe(false);
+ expect(isNativeReturnScheme(undefined)).toBe(false);
+ expect(isNativeReturnScheme("")).toBe(false);
+ });
+ });
+
+ describe("nativeAuthCallbackForReturnTo", () => {
+ test("coerces native return targets to auth-callback", () => {
+ expect(nativeAuthCallbackForReturnTo("cmux://workspace/123")).toBe(
+ "cmux://auth-callback",
+ );
+ expect(
+ nativeAuthCallbackForReturnTo("cmux-nightly://workspace/123"),
+ ).toBe("cmux-nightly://auth-callback");
+ expect(nativeAuthCallbackForReturnTo("cmux-dev://workspace/123")).toBe(
+ "cmux-dev://auth-callback",
+ );
+ });
+
+ test("preserves the callback state", () => {
+ expect(
+ nativeAuthCallbackForReturnTo("cmux-dev://auth-callback?state=abc123"),
+ ).toBe("cmux-dev://auth-callback?state=abc123");
+ });
+
+ test("returns null for non-native return targets", () => {
+ expect(nativeAuthCallbackForReturnTo("https://cmux.com")).toBe(null);
+ expect(nativeAuthCallbackForReturnTo(null)).toBe(null);
+ expect(nativeAuthCallbackForReturnTo(undefined)).toBe(null);
+ });
+ });
+
+ describe("shouldEmitNativeHandoff", () => {
+ test("returns true when both tokens are present", () => {
+ expect(
+ shouldEmitNativeHandoff({
+ refreshToken: "refresh",
+ accessToken: "access",
+ }),
+ ).toBe(true);
+ });
+
+ test("returns false when refreshToken is missing", () => {
+ expect(
+ shouldEmitNativeHandoff({
+ refreshToken: undefined,
+ accessToken: "access",
+ }),
+ ).toBe(false);
+ });
+
+ test("returns false when accessToken is empty string", () => {
+ expect(
+ shouldEmitNativeHandoff({
+ refreshToken: "refresh",
+ accessToken: "",
+ }),
+ ).toBe(false);
+ });
+
+ test("returns false when accessToken is undefined", () => {
+ expect(
+ shouldEmitNativeHandoff({
+ refreshToken: "refresh",
+ accessToken: undefined,
+ }),
+ ).toBe(false);
+ });
+ });
+});