diff --git a/web/app/handler/cli-auth-confirmation.tsx b/web/app/handler/cli-auth-confirmation.tsx index bd9a23f65827..86842bb221b7 100644 --- a/web/app/handler/cli-auth-confirmation.tsx +++ b/web/app/handler/cli-auth-confirmation.tsx @@ -7,6 +7,7 @@ export type CliAuthIdentityMessages = { emailUnavailable: string; organization: string; personalAccount: string; + switchAccountButton: string; }; export function CliAuthConfirmation({ fullPage = true, identityMessages }: { @@ -17,7 +18,7 @@ export function CliAuthConfirmation({ fullPage = true, identityMessages }: { const user = useUser({ includeRestricted: true }); const email = user?.primaryEmail ?? identityMessages.emailUnavailable; const organization = user?.selectedTeam?.displayName ?? identityMessages.personalAccount; - const { children, ...cardProps } = cliAuthMessage(cliAuth); + const { children, ...cardProps } = cliAuthMessage(cliAuth, identityMessages.switchAccountButton); return ( @@ -36,7 +37,30 @@ export function CliAuthConfirmation({ fullPage = true, identityMessages }: { ); } -function cliAuthMessage(cliAuth: CliAuthConfirmationState) { +export function cliAuthSwitchAccountHref(loginCode: string): string { + const confirmation = new URL("/handler/cli-auth-confirm", "https://cmux.com"); + confirmation.searchParams.set("login_code", loginCode); + + const signIn = new URL("/handler/sign-in", "https://cmux.com"); + signIn.searchParams.set("after_auth_return_to", `${confirmation.pathname}${confirmation.search}`); + + const signOut = new URL("/handler/sign-out-and-sign-in", "https://cmux.com"); + signOut.searchParams.set("after_auth_return_to", `${signIn.pathname}${signIn.search}`); + return `${signOut.pathname}${signOut.search}`; +} + +function switchAccountProps(loginCode: string | null, label: string) { + if (!loginCode) return {}; + const href = cliAuthSwitchAccountHref(loginCode); + return { + secondaryButtonText: label, + secondaryAction: () => window.location.assign(href), + }; +} + +function cliAuthMessage(cliAuth: CliAuthConfirmationState, switchAccountButton: string) { + const accountSwitch = switchAccountProps(cliAuth.loginCode, switchAccountButton); + if (cliAuth.status === "success") { return { title: "Signed in to coderouter", @@ -48,6 +72,7 @@ function cliAuthMessage(cliAuth: CliAuthConfirmationState) { title: "Authorization Failed", primaryButtonText: "Try Again", primaryAction: cliAuth.retry, + ...accountSwitch, children:

{"Failed to authorize the CLI application. Please try again."}

, }; } @@ -70,6 +95,7 @@ function cliAuthMessage(cliAuth: CliAuthConfirmationState) { title: "Authorize CLI Application", primaryButtonText: cliAuth.isLoading ? "Authorizing..." : "Authorize", primaryAction: cliAuth.authorize, + ...accountSwitch, children: <>

{"A command line application is requesting access to your account. Click the button below to authorize it."}

{"WARNING: Make sure you trust the command line application, as it will gain access to your account. If you did not initiate this request, you can close this page and ignore it. We will never send you this link via email or any other means."}

diff --git a/web/app/handler/sign-out-and-sign-in/route.ts b/web/app/handler/sign-out-and-sign-in/route.ts index 70e8142733e8..2a7d45160d43 100644 --- a/web/app/handler/sign-out-and-sign-in/route.ts +++ b/web/app/handler/sign-out-and-sign-in/route.ts @@ -37,6 +37,27 @@ function validatedNativeSignInTarget(request: NextRequest): string | null { return `${target.pathname}${target.search}${target.hash}`; } +function validatedCliSignInTarget(request: NextRequest): string | null { + const target = sameOriginURL(request.nextUrl.searchParams.get("after_auth_return_to"), request); + if (!target || target.pathname !== "/handler/sign-in") return null; + if (!onlySearchParams(target, ["after_auth_return_to"])) return null; + + const confirmation = sameOriginURL(target.searchParams.get("after_auth_return_to"), request); + const loginCode = confirmation?.searchParams.get("login_code"); + if ( + !confirmation || + confirmation.pathname !== "/handler/cli-auth-confirm" || + !onlySearchParams(confirmation, ["login_code"]) || + !loginCode || + loginCode.length > 256 || + !/^[a-zA-Z0-9_-]+$/.test(loginCode) + ) { + return null; + } + + return `${target.pathname}${target.search}`; +} + function onlySearchParams(url: URL, allowed: readonly string[]): boolean { const keys = [...url.searchParams.keys()].sort(); return keys.length === allowed.length && keys.every((key, index) => key === allowed[index]); @@ -140,7 +161,10 @@ function isNextRedirectError(error: unknown): boolean { export function makeSignOutAndSignInHandler(dependencies: SignOutAndSignInDependencies) { return async function GET(request: NextRequest) { - const target = validatedNativeSignInTarget(request) ?? validatedPublicationSignInTarget(request); + const target = + validatedNativeSignInTarget(request) ?? + validatedPublicationSignInTarget(request) ?? + validatedCliSignInTarget(request); if (!target || !canStartSignOut(request)) return NextResponse.redirect(new URL("/", requestOrigin(request))); const response = NextResponse.redirect(new URL(target, requestOrigin(request))); diff --git a/web/messages/ar.json b/web/messages/ar.json index e752afea0489..32f81467bc62 100644 --- a/web/messages/ar.json +++ b/web/messages/ar.json @@ -3870,6 +3870,7 @@ "email": "البريد الإلكتروني", "emailUnavailable": "البريد الإلكتروني غير متاح", "organization": "المؤسسة", - "personalAccount": "حساب شخصي" + "personalAccount": "حساب شخصي", + "switchAccountButton": "استخدام حساب مختلف" } } diff --git a/web/messages/bs.json b/web/messages/bs.json index 281245e4f38e..0aa5414458fd 100644 --- a/web/messages/bs.json +++ b/web/messages/bs.json @@ -3873,6 +3873,7 @@ "email": "E-mail", "emailUnavailable": "e-mail nije dostupan", "organization": "Organizacija", - "personalAccount": "lični račun" + "personalAccount": "lični račun", + "switchAccountButton": "Koristi drugi račun" } } diff --git a/web/messages/da.json b/web/messages/da.json index 3f72c29d1bb5..ba928319df6b 100644 --- a/web/messages/da.json +++ b/web/messages/da.json @@ -3870,6 +3870,7 @@ "email": "E-mail", "emailUnavailable": "e-mail ikke tilgængelig", "organization": "Organisation", - "personalAccount": "personlig konto" + "personalAccount": "personlig konto", + "switchAccountButton": "Brug en anden konto" } } diff --git a/web/messages/de.json b/web/messages/de.json index 847a7f452d21..6a341c85b1b9 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -3873,6 +3873,7 @@ "email": "E-Mail", "emailUnavailable": "E-Mail nicht verfügbar", "organization": "Organisation", - "personalAccount": "persönliches Konto" + "personalAccount": "persönliches Konto", + "switchAccountButton": "Anderes Konto verwenden" } } diff --git a/web/messages/en.json b/web/messages/en.json index 9ebadd180ffe..5fc8cec54946 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -5533,6 +5533,7 @@ "email": "Email", "emailUnavailable": "email unavailable", "organization": "Organization", - "personalAccount": "personal account" + "personalAccount": "personal account", + "switchAccountButton": "Use a different account" } } diff --git a/web/messages/es.json b/web/messages/es.json index 5494b90cbc88..abdadb1efb17 100644 --- a/web/messages/es.json +++ b/web/messages/es.json @@ -3872,6 +3872,7 @@ "email": "Correo electrónico", "emailUnavailable": "correo electrónico no disponible", "organization": "Organización", - "personalAccount": "cuenta personal" + "personalAccount": "cuenta personal", + "switchAccountButton": "Usar otra cuenta" } } diff --git a/web/messages/fr.json b/web/messages/fr.json index a95d28f4034c..fa8c6f30616f 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -3875,6 +3875,7 @@ "email": "Adresse e-mail", "emailUnavailable": "adresse e-mail indisponible", "organization": "Organisation", - "personalAccount": "compte personnel" + "personalAccount": "compte personnel", + "switchAccountButton": "Utiliser un autre compte" } } diff --git a/web/messages/it.json b/web/messages/it.json index fc801b841abe..e0cef65f0ab6 100644 --- a/web/messages/it.json +++ b/web/messages/it.json @@ -3873,6 +3873,7 @@ "email": "Email", "emailUnavailable": "email non disponibile", "organization": "Organizzazione", - "personalAccount": "account personale" + "personalAccount": "account personale", + "switchAccountButton": "Usa un altro account" } } diff --git a/web/messages/ja.json b/web/messages/ja.json index 6fc9097cde5f..3c88edb90bb0 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -5443,6 +5443,7 @@ "email": "メールアドレス", "emailUnavailable": "メールアドレスを取得できません", "organization": "組織", - "personalAccount": "個人アカウント" + "personalAccount": "個人アカウント", + "switchAccountButton": "別のアカウントを使用" } } diff --git a/web/messages/km.json b/web/messages/km.json index 31a3078742a7..b84ad5274c88 100644 --- a/web/messages/km.json +++ b/web/messages/km.json @@ -3871,6 +3871,7 @@ "email": "អ៊ីមែល", "emailUnavailable": "មិនមានអ៊ីមែល", "organization": "ស្ថាប័ន", - "personalAccount": "គណនីផ្ទាល់ខ្លួន" + "personalAccount": "គណនីផ្ទាល់ខ្លួន", + "switchAccountButton": "ប្រើគណនីផ្សេងទៀត" } } diff --git a/web/messages/ko.json b/web/messages/ko.json index 18de18038c73..7d8c97fb4faa 100644 --- a/web/messages/ko.json +++ b/web/messages/ko.json @@ -3870,6 +3870,7 @@ "email": "이메일", "emailUnavailable": "이메일을 확인할 수 없음", "organization": "조직", - "personalAccount": "개인 계정" + "personalAccount": "개인 계정", + "switchAccountButton": "다른 계정 사용" } } diff --git a/web/messages/no.json b/web/messages/no.json index 0b681c43ffec..e1e39c82bec5 100644 --- a/web/messages/no.json +++ b/web/messages/no.json @@ -3870,6 +3870,7 @@ "email": "E-post", "emailUnavailable": "e-post ikke tilgjengelig", "organization": "Organisasjon", - "personalAccount": "personlig konto" + "personalAccount": "personlig konto", + "switchAccountButton": "Bruk en annen konto" } } diff --git a/web/messages/pl.json b/web/messages/pl.json index aa25f85473b6..8aa462aebb29 100644 --- a/web/messages/pl.json +++ b/web/messages/pl.json @@ -3873,6 +3873,7 @@ "email": "E-mail", "emailUnavailable": "adres e-mail niedostępny", "organization": "Organizacja", - "personalAccount": "konto osobiste" + "personalAccount": "konto osobiste", + "switchAccountButton": "Użyj innego konta" } } diff --git a/web/messages/pt-BR.json b/web/messages/pt-BR.json index 208e6334c914..f34faacaf526 100644 --- a/web/messages/pt-BR.json +++ b/web/messages/pt-BR.json @@ -3873,6 +3873,7 @@ "email": "E-mail", "emailUnavailable": "e-mail indisponível", "organization": "Organização", - "personalAccount": "conta pessoal" + "personalAccount": "conta pessoal", + "switchAccountButton": "Usar outra conta" } } diff --git a/web/messages/ru.json b/web/messages/ru.json index 59f977f1d1c1..f7cb1a89b854 100644 --- a/web/messages/ru.json +++ b/web/messages/ru.json @@ -3873,6 +3873,7 @@ "email": "Электронная почта", "emailUnavailable": "адрес электронной почты недоступен", "organization": "Организация", - "personalAccount": "личный аккаунт" + "personalAccount": "личный аккаунт", + "switchAccountButton": "Использовать другой аккаунт" } } diff --git a/web/messages/th.json b/web/messages/th.json index 30b31f0fd07a..85d6b00bc90b 100644 --- a/web/messages/th.json +++ b/web/messages/th.json @@ -3878,6 +3878,7 @@ "email": "อีเมล", "emailUnavailable": "ไม่พบอีเมล", "organization": "องค์กร", - "personalAccount": "บัญชีส่วนตัว" + "personalAccount": "บัญชีส่วนตัว", + "switchAccountButton": "ใช้บัญชีอื่น" } } diff --git a/web/messages/tr.json b/web/messages/tr.json index 57d4a293a1ba..eaa2d0ee287c 100644 --- a/web/messages/tr.json +++ b/web/messages/tr.json @@ -3871,6 +3871,7 @@ "email": "E-posta", "emailUnavailable": "e-posta kullanılamıyor", "organization": "Kuruluş", - "personalAccount": "kişisel hesap" + "personalAccount": "kişisel hesap", + "switchAccountButton": "Farklı bir hesap kullan" } } diff --git a/web/messages/uk.json b/web/messages/uk.json index a9fd328828a1..fe735423fdf9 100644 --- a/web/messages/uk.json +++ b/web/messages/uk.json @@ -3947,6 +3947,7 @@ "email": "Електронна пошта", "emailUnavailable": "адреса електронної пошти недоступна", "organization": "Організація", - "personalAccount": "особистий обліковий запис" + "personalAccount": "особистий обліковий запис", + "switchAccountButton": "Використати інший обліковий запис" } } diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 4f893b291320..67b50bb4f76a 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -3873,6 +3873,7 @@ "email": "电子邮件", "emailUnavailable": "电子邮件地址不可用", "organization": "组织", - "personalAccount": "个人账户" + "personalAccount": "个人账户", + "switchAccountButton": "使用其他账号" } } diff --git a/web/messages/zh-TW.json b/web/messages/zh-TW.json index 377bb4630597..e25afe360690 100644 --- a/web/messages/zh-TW.json +++ b/web/messages/zh-TW.json @@ -3872,6 +3872,7 @@ "email": "電子郵件", "emailUnavailable": "無法取得電子郵件地址", "organization": "組織", - "personalAccount": "個人帳戶" + "personalAccount": "個人帳戶", + "switchAccountButton": "使用其他帳號" } } diff --git a/web/tests/after-sign-in-route.test.ts b/web/tests/after-sign-in-route.test.ts index ac6b8c693d52..7457c13f8ef0 100644 --- a/web/tests/after-sign-in-route.test.ts +++ b/web/tests/after-sign-in-route.test.ts @@ -543,7 +543,40 @@ describe("sign out and sign back in", () => { expect(signOut).toHaveBeenCalledWith({ redirectUrl: `https://cmux.test${signIn}` }); expect(response.status).toBe(307); expect(response.headers.get("location")).toBe(`https://cmux.test${signIn}`); - expect(response.headers.get("set-cookie")).toContain("stack-access=;"); + const setCookie = response.headers.get("set-cookie"); + expect(setCookie).toMatch(/(?:^|,\s*)stack-access=;[^,]*Max-Age=0/i); + expect(setCookie).toContain("stack-refresh-test-project=;"); + }); + + test("signs out and redirects into sign-in for CLI authorization", async () => { + const confirmation = "/handler/cli-auth-confirm?login_code=test-login-code"; + const signIn = `/handler/sign-in?after_auth_return_to=${encodeURIComponent(confirmation)}`; + + const response = await GET(switchRequest(signIn)); + + expect(signOut).toHaveBeenCalledWith({ redirectUrl: `https://cmux.test${signIn}` }); + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe(`https://cmux.test${signIn}`); + expect(response.headers.get("set-cookie")).toMatch( + /(?:^|,\s*)stack-access=;[^,]*Max-Age=0/i, + ); + }); + + test("rejects CLI sign-in targets that are not one exact authorization code", async () => { + const malformedConfirmations = [ + "/handler/cli-auth-confirm", + "/handler/cli-auth-confirm?login_code=test-login-code&next=%2Fdocs", + "/handler/cli-auth-confirm?login_code=not.valid", + "https://evil.test/handler/cli-auth-confirm?login_code=test-login-code", + ]; + + for (const confirmation of malformedConfirmations) { + const signIn = `/handler/sign-in?after_auth_return_to=${encodeURIComponent(confirmation)}`; + const response = await GET(switchRequest(signIn)); + expect(signOut).not.toHaveBeenCalled(); + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe("https://cmux.test/"); + } }); test("rejects Cloud VM access targets that are not exactly an opaque transaction", async () => { diff --git a/web/tests/cli-auth-confirmation.test.tsx b/web/tests/cli-auth-confirmation.test.tsx index 7f94cbef0c0b..ec037c5278b4 100644 --- a/web/tests/cli-auth-confirmation.test.tsx +++ b/web/tests/cli-auth-confirmation.test.tsx @@ -14,11 +14,12 @@ let user: { mock.module("@stackframe/stack", () => ({ useCliAuthConfirmation: () => auth, useUser: () => user, - MessageCard: ({ title, children, primaryButtonText }: { + MessageCard: ({ title, children, primaryButtonText, secondaryButtonText }: { title: string; children: React.ReactNode; primaryButtonText?: string; - }) =>

{title}

{children}{primaryButtonText && }
, + secondaryButtonText?: string; + }) =>

{title}

{children}{secondaryButtonText && }{primaryButtonText && }
, })); const { CliAuthConfirmation } = await import("../app/handler/cli-auth-confirmation"); @@ -54,6 +55,30 @@ describe("CLI authorization account identity", () => { expect(html.indexOf("alex@example.com")).toBeLessThan(html.indexOf('')); }); + test("offers a different-account sign-in that preserves the CLI login code", () => { + const html = renderToStaticMarkup(); + expect(html).toContain(""); + }); + + test("builds a different-account sign-in that preserves the CLI login code", async () => { + const { cliAuthSwitchAccountHref } = await import("../app/handler/cli-auth-confirmation"); + const switchURL = new URL(cliAuthSwitchAccountHref("test-login-code"), "https://cmux.test"); + expect(switchURL.pathname).toBe("/handler/sign-out-and-sign-in"); + + const signInURL = new URL( + switchURL.searchParams.get("after_auth_return_to")!, + "https://cmux.test", + ); + expect(signInURL.pathname).toBe("/handler/sign-in"); + + const confirmationURL = new URL( + signInURL.searchParams.get("after_auth_return_to")!, + "https://cmux.test", + ); + expect(confirmationURL.pathname).toBe("/handler/cli-auth-confirm"); + expect(confirmationURL.searchParams.get("login_code")).toBe("test-login-code"); + }); + test("uses the current session account when it changes", () => { renderToStaticMarkup(); user = { primaryEmail: "blair@example.com", selectedTeam: null };