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
30 changes: 28 additions & 2 deletions web/app/handler/cli-auth-confirmation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type CliAuthIdentityMessages = {
emailUnavailable: string;
organization: string;
personalAccount: string;
switchAccountButton: string;
};

export function CliAuthConfirmation({ fullPage = true, identityMessages }: {
Expand All @@ -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 (
<MessageCard {...cardProps} fullPage={fullPage}>
Expand All @@ -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",
Expand All @@ -48,6 +72,7 @@ function cliAuthMessage(cliAuth: CliAuthConfirmationState) {
title: "Authorization Failed",
primaryButtonText: "Try Again",
primaryAction: cliAuth.retry,
...accountSwitch,
children: <p className="text-red-600">{"Failed to authorize the CLI application. Please try again."}</p>,
};
}
Expand All @@ -70,6 +95,7 @@ function cliAuthMessage(cliAuth: CliAuthConfirmationState) {
title: "Authorize CLI Application",
primaryButtonText: cliAuth.isLoading ? "Authorizing..." : "Authorize",
primaryAction: cliAuth.authorize,
...accountSwitch,
children: <>
<p>{"A command line application is requesting access to your account. Click the button below to authorize it."}</p>
<p className="text-red-600">{"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."}</p>
Expand Down
26 changes: 25 additions & 1 deletion web/app/handler/sign-out-and-sign-in/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down Expand Up @@ -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)));
Expand Down
3 changes: 2 additions & 1 deletion web/messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -3870,6 +3870,7 @@
"email": "البريد الإلكتروني",
"emailUnavailable": "البريد الإلكتروني غير متاح",
"organization": "المؤسسة",
"personalAccount": "حساب شخصي"
"personalAccount": "حساب شخصي",
"switchAccountButton": "استخدام حساب مختلف"
}
}
3 changes: 2 additions & 1 deletion web/messages/bs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
3 changes: 2 additions & 1 deletion web/messages/da.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
3 changes: 2 additions & 1 deletion web/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
3 changes: 2 additions & 1 deletion web/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -5533,6 +5533,7 @@
"email": "Email",
"emailUnavailable": "email unavailable",
"organization": "Organization",
"personalAccount": "personal account"
"personalAccount": "personal account",
"switchAccountButton": "Use a different account"
}
}
3 changes: 2 additions & 1 deletion web/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
3 changes: 2 additions & 1 deletion web/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
3 changes: 2 additions & 1 deletion web/messages/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -3873,6 +3873,7 @@
"email": "Email",
"emailUnavailable": "email non disponibile",
"organization": "Organizzazione",
"personalAccount": "account personale"
"personalAccount": "account personale",
"switchAccountButton": "Usa un altro account"
}
}
3 changes: 2 additions & 1 deletion web/messages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -5443,6 +5443,7 @@
"email": "メールアドレス",
"emailUnavailable": "メールアドレスを取得できません",
"organization": "組織",
"personalAccount": "個人アカウント"
"personalAccount": "個人アカウント",
"switchAccountButton": "別のアカウントを使用"
}
}
3 changes: 2 additions & 1 deletion web/messages/km.json
Original file line number Diff line number Diff line change
Expand Up @@ -3871,6 +3871,7 @@
"email": "អ៊ីមែល",
"emailUnavailable": "មិនមានអ៊ីមែល",
"organization": "ស្ថាប័ន",
"personalAccount": "គណនីផ្ទាល់ខ្លួន"
"personalAccount": "គណនីផ្ទាល់ខ្លួន",
"switchAccountButton": "ប្រើគណនីផ្សេងទៀត"
}
}
3 changes: 2 additions & 1 deletion web/messages/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -3870,6 +3870,7 @@
"email": "이메일",
"emailUnavailable": "이메일을 확인할 수 없음",
"organization": "조직",
"personalAccount": "개인 계정"
"personalAccount": "개인 계정",
"switchAccountButton": "다른 계정 사용"
}
}
3 changes: 2 additions & 1 deletion web/messages/no.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
3 changes: 2 additions & 1 deletion web/messages/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
3 changes: 2 additions & 1 deletion web/messages/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
3 changes: 2 additions & 1 deletion web/messages/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -3873,6 +3873,7 @@
"email": "Электронная почта",
"emailUnavailable": "адрес электронной почты недоступен",
"organization": "Организация",
"personalAccount": "личный аккаунт"
"personalAccount": "личный аккаунт",
"switchAccountButton": "Использовать другой аккаунт"
}
}
3 changes: 2 additions & 1 deletion web/messages/th.json
Original file line number Diff line number Diff line change
Expand Up @@ -3878,6 +3878,7 @@
"email": "อีเมล",
"emailUnavailable": "ไม่พบอีเมล",
"organization": "องค์กร",
"personalAccount": "บัญชีส่วนตัว"
"personalAccount": "บัญชีส่วนตัว",
"switchAccountButton": "ใช้บัญชีอื่น"
}
}
3 changes: 2 additions & 1 deletion web/messages/tr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
3 changes: 2 additions & 1 deletion web/messages/uk.json
Original file line number Diff line number Diff line change
Expand Up @@ -3947,6 +3947,7 @@
"email": "Електронна пошта",
"emailUnavailable": "адреса електронної пошти недоступна",
"organization": "Організація",
"personalAccount": "особистий обліковий запис"
"personalAccount": "особистий обліковий запис",
"switchAccountButton": "Використати інший обліковий запис"
}
}
3 changes: 2 additions & 1 deletion web/messages/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -3873,6 +3873,7 @@
"email": "电子邮件",
"emailUnavailable": "电子邮件地址不可用",
"organization": "组织",
"personalAccount": "个人账户"
"personalAccount": "个人账户",
"switchAccountButton": "使用其他账号"
}
}
3 changes: 2 additions & 1 deletion web/messages/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -3872,6 +3872,7 @@
"email": "電子郵件",
"emailUnavailable": "無法取得電子郵件地址",
"organization": "組織",
"personalAccount": "個人帳戶"
"personalAccount": "個人帳戶",
"switchAccountButton": "使用其他帳號"
}
}
35 changes: 34 additions & 1 deletion web/tests/after-sign-in-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
29 changes: 27 additions & 2 deletions web/tests/cli-auth-confirmation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}) => <main><h1>{title}</h1>{children}{primaryButtonText && <button type="button">{primaryButtonText}</button>}</main>,
secondaryButtonText?: string;
}) => <main><h1>{title}</h1>{children}{secondaryButtonText && <button type="button">{secondaryButtonText}</button>}{primaryButtonText && <button type="button">{primaryButtonText}</button>}</main>,
}));

const { CliAuthConfirmation } = await import("../app/handler/cli-auth-confirmation");
Expand Down Expand Up @@ -54,6 +55,30 @@ describe("CLI authorization account identity", () => {
expect(html.indexOf("alex@example.com")).toBeLessThan(html.indexOf('<button type="button">Authorize</button>'));
});

test("offers a different-account sign-in that preserves the CLI login code", () => {
const html = renderToStaticMarkup(<CliAuthConfirmation identityMessages={en.cliAuthIdentity} />);
expect(html).toContain("<button type=\"button\">Use a different account</button>");
});

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(<CliAuthConfirmation identityMessages={en.cliAuthIdentity} />);
user = { primaryEmail: "blair@example.com", selectedTeam: null };
Expand Down
Loading