diff --git a/claude/CLAUDE-KNOWLEDGE.md b/claude/CLAUDE-KNOWLEDGE.md
index 25d8af346f..0a1116d83b 100644
--- a/claude/CLAUDE-KNOWLEDGE.md
+++ b/claude/CLAUDE-KNOWLEDGE.md
@@ -386,3 +386,9 @@ A: Use a strict root `postinstall` script that rewrites only Next `>=16` app-pag
Q: Why can Turbo-pruned Docker builds fail with `Cannot find module /app/scripts/postinstall-patch-next-async-debug-info.mjs` during `pnpm install`?
A: In pruned builder stages, we copy `/app/out/json` and run `pnpm install` before copying `/app/out/full`. The root `package.json` still runs `postinstall: node ./scripts/postinstall-patch-next-async-debug-info.mjs`, but that script is not present yet. Fix by copying `scripts/postinstall-patch-next-async-debug-info.mjs` into the builder stage before `pnpm install` (for all Dockerfiles using the prune pattern).
+
+Q: What is the simple custom-page DX for CLI auth confirmation?
+A: Add `cliAuthConfirm` as a normal handler URL target and expose `useCliAuthConfirmation()` from the template package. Custom pages should consume the hook's `status`, `error`, `isLoading`, `authorize()`, and `retry()` instead of calling `/auth/cli/complete` directly. The hook owns reading `login_code`, preserving `confirmed=true`, claiming anonymous CLI sessions, redirecting through sign-in/sign-up, and completing authorization with the current refresh token.
+
+Q: How should the CLI auth login URL be constructed in template tests?
+A: Do not import the concrete template `_StackClientAppImpl` directly from Vitest just to test `promptCliLogin`; it trips the compile-time client-version sentinel. Put the URL construction in a small helper such as `buildCliAuthConfirmUrl()` and have `promptCliLogin` call that helper. Then unit-test the helper with relative/custom `cliAuthConfirm` targets.
diff --git a/packages/stack-shared/src/interface/handler-urls.ts b/packages/stack-shared/src/interface/handler-urls.ts
index cdb2e11fdf..7ad921cfb4 100644
--- a/packages/stack-shared/src/interface/handler-urls.ts
+++ b/packages/stack-shared/src/interface/handler-urls.ts
@@ -15,6 +15,7 @@ export type HandlerPageUrls = Record<
| "magicLinkCallback"
| "accountSettings"
| "teamInvitation"
+ | "cliAuthConfirm"
| "mfa"
| "error"
| "onboarding",
@@ -45,4 +46,3 @@ export {
type PageVersionEntry,
type PageVersions
} from "./page-component-versions";
-
diff --git a/packages/stack-shared/src/interface/page-component-versions.ts b/packages/stack-shared/src/interface/page-component-versions.ts
index ed9b20e184..23ba0ffa7f 100644
--- a/packages/stack-shared/src/interface/page-component-versions.ts
+++ b/packages/stack-shared/src/interface/page-component-versions.ts
@@ -1419,6 +1419,59 @@ export function getCustomPagePrompts(): Record ;
+ }
+
+ if (cliAuth.status === "success") {
+ return You can close this window and return to the command line. ;
+ }
+
+ if (cliAuth.status === "error") {
+ return (
+
+ {cliAuth.error?.message}
+
+ );
+ }
+
+ return (
+
+ A command line application is requesting access to your account.
+
+ );
+ }
+ `,
+ notes: deindent`
+ - Be explicit about the account being authorized. CLI auth grants a refresh token to the command line application.
+ - The hook owns the protocol details: reading \`login_code\`, preserving confirmed state across redirects, claiming anonymous sessions, and completing authorization.
+ `,
+ versions: {},
+ }),
mfa: createCustomPagePrompt({
key: "mfa",
title: "MFA",
diff --git a/packages/template/src/components-page/cli-auth-confirm.test.tsx b/packages/template/src/components-page/cli-auth-confirm.test.tsx
new file mode 100644
index 0000000000..b35db29bd1
--- /dev/null
+++ b/packages/template/src/components-page/cli-auth-confirm.test.tsx
@@ -0,0 +1,200 @@
+// @vitest-environment jsdom
+
+import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
+import React, { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { StackClientApp } from "../lib/stack-app/apps/interfaces/client-app";
+import { stackAppInternalsSymbol } from "../lib/stack-app/common";
+import { StackContext } from "../providers/stack-context";
+import { useCliAuthConfirmation } from "./cli-auth-confirm";
+
+const previousActEnvironment = Reflect.get(globalThis, "IS_REACT_ACT_ENVIRONMENT");
+
+function responseJson(data: unknown, init?: ResponseInit) {
+ return new Response(JSON.stringify(data), {
+ status: init?.status ?? 200,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+function createAppTestDouble(options: {
+ user: unknown,
+ sendRequest: (path: string, requestOptions: RequestInit) => Promise,
+ signInWithTokens?: (tokens: { accessToken: string, refreshToken: string }) => Promise,
+ redirectToSignIn?: (options: { replace: true }) => Promise,
+ redirectToSignUp?: (options: { replace: true }) => Promise,
+}) {
+ const app = {
+ useUser: () => options.user,
+ redirectToSignIn: options.redirectToSignIn ?? vi.fn(async () => {}),
+ redirectToSignUp: options.redirectToSignUp ?? vi.fn(async () => {}),
+ [stackAppInternalsSymbol]: {
+ sendRequest: options.sendRequest,
+ signInWithTokens: options.signInWithTokens ?? vi.fn(async () => {}),
+ },
+ };
+
+ // This test double intentionally implements only the StackClientApp surface
+ // that useCliAuthConfirmation touches.
+ return app as unknown as StackClientApp;
+}
+
+function HookProbe() {
+ const cliAuth = useCliAuthConfirmation();
+ return (
+ <>
+ {cliAuth.status}
+ {cliAuth.error?.message}
+ runAsynchronously(cliAuth.authorize)}>authorize
+ retry
+ >
+ );
+}
+
+let root: Root | null = null;
+let container: HTMLDivElement | null = null;
+
+async function renderWithApp(app: StackClientApp) {
+ container = document.createElement("div");
+ document.body.append(container);
+ root = createRoot(container);
+ await act(async () => {
+ root?.render(
+
+
+
+ );
+ });
+}
+
+function getByTestId(testId: string): HTMLElement {
+ const element = container?.querySelector(`[data-testid="${testId}"]`);
+ if (!(element instanceof HTMLElement)) {
+ throw new Error(`Could not find test element ${testId}`);
+ }
+ return element;
+}
+
+function getButton(label: string): HTMLButtonElement {
+ const button = [...container?.querySelectorAll("button") ?? []]
+ .find((element) => element.textContent === label);
+ if (!(button instanceof HTMLButtonElement)) {
+ throw new Error(`Could not find button ${label}`);
+ }
+ return button;
+}
+
+describe("useCliAuthConfirmation", () => {
+ beforeEach(() => {
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
+ });
+
+ afterEach(() => {
+ act(() => {
+ root?.unmount();
+ });
+ container?.remove();
+ root = null;
+ container = null;
+ vi.restoreAllMocks();
+ window.history.replaceState({}, "", "/");
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", previousActEnvironment);
+ });
+
+ it("completes CLI auth with the current user's refresh token", async () => {
+ window.history.replaceState({}, "", "/handler/cli-auth-confirm?login_code=login-code");
+ const getTokens = vi.fn(async () => ({ refreshToken: "refresh-token" }));
+ const sendRequest = vi.fn(async (_path: string, _requestOptions: RequestInit) => new Response(null, { status: 200 }));
+ const app = createAppTestDouble({
+ user: { currentSession: { getTokens } },
+ sendRequest,
+ });
+
+ await renderWithApp(app);
+ await act(async () => {
+ getButton("authorize").click();
+ });
+
+ expect(getByTestId("status").textContent).toBe("success");
+ expect(getTokens).toHaveBeenCalledOnce();
+ expect(sendRequest).toHaveBeenCalledOnce();
+ expect(sendRequest.mock.calls[0][0]).toBe("/auth/cli/complete");
+ expect(JSON.parse(String(sendRequest.mock.calls[0][1].body))).toMatchInlineSnapshot(`
+ {
+ "login_code": "login-code",
+ "refresh_token": "refresh-token",
+ }
+ `);
+ });
+
+ it("ignores duplicate authorize clicks before React re-renders", async () => {
+ window.history.replaceState({}, "", "/handler/cli-auth-confirm?login_code=login-code");
+ const getTokens = vi.fn(async () => ({ refreshToken: "refresh-token" }));
+ const sendRequest = vi.fn(async (_path: string, _requestOptions: RequestInit) => new Response(null, { status: 200 }));
+ const app = createAppTestDouble({
+ user: { currentSession: { getTokens } },
+ sendRequest,
+ });
+
+ await renderWithApp(app);
+ await act(async () => {
+ const authorizeButton = getButton("authorize");
+ authorizeButton.click();
+ authorizeButton.click();
+ });
+
+ expect(sendRequest).toHaveBeenCalledOnce();
+ });
+
+ it("claims anonymous CLI sessions before redirecting to sign-up", async () => {
+ window.history.replaceState({}, "", "/handler/cli-auth-confirm?login_code=login-code");
+ const signInWithTokens = vi.fn(async (_tokens: { accessToken: string, refreshToken: string }) => {});
+ const redirectToSignUp = vi.fn(async (_options: { replace: true }) => {});
+ const sendRequest = vi.fn(async (_path: string, _requestOptions: RequestInit) => new Response(null, { status: 200 }))
+ .mockResolvedValueOnce(responseJson({ cli_session_state: "anonymous" }))
+ .mockResolvedValueOnce(responseJson({ access_token: "access-token", refresh_token: "refresh-token" }));
+ const app = createAppTestDouble({
+ user: null,
+ sendRequest,
+ signInWithTokens,
+ redirectToSignUp,
+ });
+
+ await renderWithApp(app);
+ await act(async () => {
+ getButton("authorize").click();
+ });
+
+ expect(redirectToSignUp).toHaveBeenCalledWith({ replace: true });
+ expect(signInWithTokens).toHaveBeenCalledWith({
+ accessToken: "access-token",
+ refreshToken: "refresh-token",
+ });
+ expect(new URL(window.location.href).searchParams.get("confirmed")).toBe("true");
+ expect(sendRequest.mock.calls.map(call => JSON.parse(String(call[1].body)))).toMatchInlineSnapshot(`
+ [
+ {
+ "login_code": "login-code",
+ "mode": "check",
+ },
+ {
+ "login_code": "login-code",
+ "mode": "claim-anon-session",
+ },
+ ]
+ `);
+ });
+
+ it("reports invalid when the login code is missing", async () => {
+ window.history.replaceState({}, "", "/handler/cli-auth-confirm");
+ const app = createAppTestDouble({
+ user: null,
+ sendRequest: vi.fn(async (_path: string, _requestOptions: RequestInit) => new Response(null, { status: 200 })),
+ });
+
+ await renderWithApp(app);
+
+ expect(getByTestId("status").textContent).toBe("invalid");
+ });
+});
diff --git a/packages/template/src/components-page/cli-auth-confirm.tsx b/packages/template/src/components-page/cli-auth-confirm.tsx
index db82ff199e..5042f15447 100644
--- a/packages/template/src/components-page/cli-auth-confirm.tsx
+++ b/packages/template/src/components-page/cli-auth-confirm.tsx
@@ -2,10 +2,12 @@
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import { Typography } from "@stackframe/stack-ui";
-import { useEffect, useRef, useState } from "react";
-import { type StackClientApp, stackAppInternalsSymbol, useStackApp } from "..";
+import { useCallback, useEffect, useRef, useState } from "react";
import { MessageCard } from "../components/message-cards/message-card";
import { useTranslation } from "../lib/translations";
+import { stackAppInternalsSymbol } from "../lib/stack-app/common";
+import type { StackClientApp } from "../lib/stack-app/apps/interfaces/client-app";
+import { useStackApp } from "../lib/hooks";
async function postCliAuthComplete(app: StackClientApp, body: Record) {
return await app[stackAppInternalsSymbol].sendRequest("/auth/cli/complete", {
@@ -32,15 +34,45 @@ function markUrlConfirmed() {
window.history.replaceState({}, "", url.toString());
}
-export function CliAuthConfirmation({ fullPage = true }: { fullPage?: boolean }) {
- const { t } = useTranslation();
+function getError(err: unknown): Error {
+ return err instanceof Error ? err : new Error(String(err));
+}
+
+function getObjectField(data: unknown, fieldName: string): unknown {
+ return typeof data === "object" && data !== null && fieldName in data
+ ? data[fieldName as keyof typeof data]
+ : undefined;
+}
+
+function getStringField(data: unknown, fieldName: string): string | undefined {
+ const value = getObjectField(data, fieldName);
+ return typeof value === "string" ? value : undefined;
+}
+
+export type CliAuthConfirmationStatus =
+ | "idle"
+ | "invalid"
+ | "authorizing"
+ | "redirecting"
+ | "success"
+ | "error";
+
+export type CliAuthConfirmationState = {
+ status: CliAuthConfirmationStatus,
+ loginCode: string | null,
+ error: Error | null,
+ isLoading: boolean,
+ authorize: () => Promise,
+ retry: () => void,
+};
+
+export function useCliAuthConfirmation(): CliAuthConfirmationState {
const app = useStackApp();
const user = app.useUser({ includeRestricted: true });
- const [authorizing, setAuthorizing] = useState(false);
- const [success, setSuccess] = useState(false);
+ const [status, setStatus] = useState>("idle");
const [error, setError] = useState(null);
const autoCompleteRef = useRef(false);
-
+ const authorizeInProgressRef = useRef(false);
const [loginCode] = useState(() => {
if (typeof window === 'undefined') return null;
return new URLSearchParams(window.location.search).get("login_code");
@@ -50,48 +82,55 @@ export function CliAuthConfirmation({ fullPage = true }: { fullPage?: boolean })
return new URLSearchParams(window.location.search).get("confirmed") === "true";
});
+ const completeWithCurrentUser = useCallback(async () => {
+ if (!loginCode) {
+ throw new Error("Missing login code in URL parameters");
+ }
+ if (!user) {
+ throw new Error("Cannot complete CLI authorization without a signed-in user");
+ }
+ const refreshToken = (await user.currentSession.getTokens()).refreshToken;
+ if (!refreshToken) {
+ throw new Error("Could not retrieve session token");
+ }
+ await completeCliAuthWithRefreshToken(app, loginCode, refreshToken);
+ }, [app, loginCode, user]);
+
useEffect(() => {
if (!confirmed || !user || autoCompleteRef.current) {
return;
}
autoCompleteRef.current = true;
runAsynchronouslyWithAlert(async () => {
- setAuthorizing(true);
+ setStatus("authorizing");
try {
- if (!loginCode) {
- throw new Error("Missing login code in URL parameters");
- }
- const refreshToken = (await user.currentSession.getTokens()).refreshToken;
- if (!refreshToken) {
- throw new Error("Could not retrieve session token");
- }
- await completeCliAuthWithRefreshToken(app, loginCode, refreshToken);
- setSuccess(true);
+ await completeWithCurrentUser();
+ setStatus("success");
} catch (err) {
- setError(err as Error);
- } finally {
- setAuthorizing(false);
+ setError(getError(err));
+ setStatus("error");
}
});
- }, [confirmed, user, loginCode, app]);
+ }, [confirmed, user, completeWithCurrentUser]);
- const handleAuthorize = async () => {
- if (authorizing) {
+ const authorize = useCallback(async () => {
+ if (authorizeInProgressRef.current) {
return;
}
- setAuthorizing(true);
+ authorizeInProgressRef.current = true;
+
try {
if (!loginCode) {
- throw new Error("Missing login code in URL parameters");
+ setError(new Error("Missing login code in URL parameters"));
+ setStatus("error");
+ return;
}
+ setError(null);
+ setStatus("authorizing");
if (user) {
- const refreshToken = (await user.currentSession.getTokens()).refreshToken;
- if (!refreshToken) {
- throw new Error("Could not retrieve session token");
- }
- await completeCliAuthWithRefreshToken(app, loginCode, refreshToken);
- setSuccess(true);
+ await completeWithCurrentUser();
+ setStatus("success");
return;
}
@@ -99,8 +138,8 @@ export function CliAuthConfirmation({ fullPage = true }: { fullPage?: boolean })
if (!checkResult.ok) {
throw new Error(`Failed to verify login code: ${checkResult.status} ${await checkResult.text()}`);
}
- const checkData = await checkResult.json();
- const cliSessionState: string | null = checkData.cli_session_state ?? null;
+ const checkData: unknown = await checkResult.json();
+ const cliSessionState = getStringField(checkData, "cli_session_state") ?? null;
if (cliSessionState === "anonymous") {
const claimResult = await postCliAuthComplete(app, { login_code: loginCode, mode: "claim-anon-session" });
@@ -109,30 +148,59 @@ export function CliAuthConfirmation({ fullPage = true }: { fullPage?: boolean })
throw new Error(`Failed to claim anonymous session: ${claimResult.status} ${await claimResult.text()}`);
}
- const tokens = await claimResult.json();
+ const tokens: unknown = await claimResult.json();
+ const accessToken = getStringField(tokens, "access_token");
+ const refreshToken = getStringField(tokens, "refresh_token");
+ if (!accessToken || !refreshToken) {
+ throw new Error("Anonymous CLI session claim did not return tokens");
+ }
await app[stackAppInternalsSymbol].signInWithTokens({
- accessToken: tokens.access_token,
- refreshToken: tokens.refresh_token,
+ accessToken,
+ refreshToken,
});
// Only mark the URL as confirmed once the anon session is actually
// bound to the browser; otherwise a failure above would leave a stale
// confirmed=true in the URL and the auto-complete effect would later
// bind the CLI to whichever user happens to be signed in.
markUrlConfirmed();
+ setStatus("redirecting");
await app.redirectToSignUp({ replace: true });
return;
}
markUrlConfirmed();
+ setStatus("redirecting");
await app.redirectToSignIn({ replace: true });
} catch (err) {
- setError(err as Error);
+ setError(getError(err));
+ setStatus("error");
} finally {
- setAuthorizing(false);
+ authorizeInProgressRef.current = false;
}
+ }, [app, completeWithCurrentUser, loginCode, user]);
+
+ const retry = useCallback(() => {
+ setError(null);
+ autoCompleteRef.current = false;
+ setStatus("idle");
+ }, []);
+
+ const visibleStatus = loginCode == null ? "invalid" : status;
+ return {
+ status: visibleStatus,
+ loginCode,
+ error,
+ isLoading: visibleStatus === "authorizing" || visibleStatus === "redirecting",
+ authorize,
+ retry,
};
+}
- if (success) {
+export function CliAuthConfirmation({ fullPage = true }: { fullPage?: boolean }) {
+ const { t } = useTranslation();
+ const cliAuth = useCliAuthConfirmation();
+
+ if (cliAuth.status === "success") {
return (
@@ -142,28 +210,35 @@ export function CliAuthConfirmation({ fullPage = true }: { fullPage?: boolean })
);
}
- if (error) {
+ if (cliAuth.status === "error") {
return (
{
- setError(null);
- autoCompleteRef.current = false;
- }}
+ primaryAction={cliAuth.retry}
>
{t("Failed to authorize the CLI application:")}
- {error.message}
+ {cliAuth.error?.message}
+
+
+ );
+ }
+
+ if (cliAuth.status === "invalid") {
+ return (
+
+
+ {t("This CLI authorization link is missing a login code. Please return to the command line and start the login process again.")}
);
}
- if (confirmed && authorizing) {
+ if (cliAuth.status === "authorizing" || cliAuth.status === "redirecting") {
return (
@@ -177,8 +252,8 @@ export function CliAuthConfirmation({ fullPage = true }: { fullPage?: boolean })
{t("A command line application is requesting access to your account. Click the button below to authorize it.")}
diff --git a/packages/template/src/components-page/stack-handler-client.tsx b/packages/template/src/components-page/stack-handler-client.tsx
index d7af8fad29..82c28c93e9 100644
--- a/packages/template/src/components-page/stack-handler-client.tsx
+++ b/packages/template/src/components-page/stack-handler-client.tsx
@@ -177,6 +177,7 @@ function renderComponent(props: {
/>;
}
case availablePaths.cliAuthConfirm: {
+ redirectIfNotHandler?.('cliAuthConfirm');
return ): HTMLElement {
{ key: 'emailVerification' as any, label: 'Email verification' },
{ key: 'accountSettings' as any, label: 'Account settings' },
{ key: 'teamInvitation' as any, label: 'Team invitation' },
+ { key: 'cliAuthConfirm' as any, label: 'CLI auth confirmation' },
{ key: 'mfa' as any, label: 'MFA' },
{ key: 'onboarding' as any, label: 'Onboarding' },
{ key: 'error' as any, label: 'Error' },
diff --git a/packages/template/src/index.ts b/packages/template/src/index.ts
index e62361c393..a948d93ac1 100644
--- a/packages/template/src/index.ts
+++ b/packages/template/src/index.ts
@@ -12,7 +12,7 @@ export { StackTheme } from './providers/theme-provider';
export { AccountSettings } from "./components-page/account-settings";
export { AuthPage } from "./components-page/auth-page";
-export { CliAuthConfirmation } from "./components-page/cli-auth-confirm";
+export { CliAuthConfirmation, useCliAuthConfirmation, type CliAuthConfirmationState, type CliAuthConfirmationStatus } from "./components-page/cli-auth-confirm";
export { EmailVerification } from "./components-page/email-verification";
export { ForgotPassword } from "./components-page/forgot-password";
export { PasswordReset } from "./components-page/password-reset";
diff --git a/packages/template/src/lib/hooks.tsx b/packages/template/src/lib/hooks.tsx
index 4a2cc568c1..99e29bfa82 100644
--- a/packages/template/src/lib/hooks.tsx
+++ b/packages/template/src/lib/hooks.tsx
@@ -1,6 +1,6 @@
import { useContext } from "react";
-import { StackContext } from "../providers/stack-provider-client";
-import { GetUserOptions as AppGetUserOptions, CurrentInternalUser, CurrentUser, StackClientApp } from "./stack-app";
+import { StackContext } from "../providers/stack-context";
+import type { GetUserOptions as AppGetUserOptions, CurrentInternalUser, CurrentUser, StackClientApp } from "./stack-app";
type GetUserOptions = AppGetUserOptions & {
projectIdMustMatch?: string,
diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts
index 4422a824c2..1671e16fe3 100644
--- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts
+++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts
@@ -53,7 +53,7 @@ import { NotificationCategory } from "../../notification-categories";
import { TeamPermission } from "../../permissions";
import { AdminOwnedProject, AdminProjectUpdateOptions, Project, adminProjectCreateOptionsToCrud } from "../../projects";
import { EditableTeamMemberProfile, ReceivedTeamInvitation, SentTeamInvitation, Team, TeamCreateOptions, TeamUpdateOptions, TeamUser, teamCreateOptionsToCrud, teamUpdateOptionsToCrud } from "../../teams";
-import { isHostedHandlerUrlForProject, resolveHandlerUrls } from "../../url-targets";
+import { buildCliAuthConfirmUrl, isHostedHandlerUrlForProject, resolveHandlerUrls } from "../../url-targets";
import { ActiveSession, Auth, BaseUser, CurrentUser, InternalUserExtra, OAuthProvider, ProjectCurrentUser, SyncedPartialUser, TokenPartialUser, UserExtra, UserUpdateOptions, userUpdateOptionsToCrud, withUserDestructureGuard } from "../../users";
import { StackClientApp, StackClientAppConstructorOptions, StackClientAppJson } from "../interfaces/client-app";
import { _StackAdminAppImplIncomplete } from "./admin-app-impl";
@@ -2511,6 +2511,7 @@ export class _StackClientAppImplIncomplete> {
@@ -3073,7 +3074,11 @@ export class _StackClientAppImplIncomplete {
afterEach(() => {
@@ -93,6 +93,31 @@ describe("handler URL targets", () => {
expect(urls.signUp).toBe("/sign-up");
expect(urls.signIn).toBe("https://project-id.example-stack-hosted.test/handler/sign-in");
+ expect(urls.cliAuthConfirm).toBe("https://project-id.example-stack-hosted.test/handler/cli-auth-confirm");
+ });
+
+ it("supports custom CLI auth confirmation targets", () => {
+ const cliAuthConfirmPrompt = getPagePrompt("cliAuthConfirm");
+ if (cliAuthConfirmPrompt == null) {
+ throw new Error("Expected cliAuthConfirm prompt metadata to exist");
+ }
+
+ const urls = resolveHandlerUrls({
+ projectId: "project-id",
+ urls: {
+ cliAuthConfirm: { type: "custom", url: "/cli/authorize", version: cliAuthConfirmPrompt.latestVersion },
+ },
+ });
+
+ expect(urls.cliAuthConfirm).toBe("/cli/authorize");
+ });
+
+ it("builds CLI auth login URLs from the resolved confirmation target", () => {
+ expect(buildCliAuthConfirmUrl({
+ cliAuthConfirmUrl: "/cli/authorize",
+ appUrl: "https://app.example.test/base",
+ loginCode: "login-code",
+ })).toBe("https://app.example.test/cli/authorize?login_code=login-code");
});
it("uses default target for unknown /handler/* pages", () => {
diff --git a/packages/template/src/lib/stack-app/url-targets.ts b/packages/template/src/lib/stack-app/url-targets.ts
index a5dffb8741..cb43f1ce34 100644
--- a/packages/template/src/lib/stack-app/url-targets.ts
+++ b/packages/template/src/lib/stack-app/url-targets.ts
@@ -77,6 +77,9 @@ const getHostedPagePathForHandlerName = (handlerName: keyof HandlerUrls): string
case "teamInvitation": {
return "team-invitation";
}
+ case "cliAuthConfirm": {
+ return "cli-auth-confirm";
+ }
case "mfa": {
return "mfa";
}
@@ -306,6 +309,12 @@ export const resolveHandlerUrls = (options: { urls: HandlerUrlOptions | undefine
handlerName: "teamInvitation",
projectId: options.projectId,
}),
+ cliAuthConfirm: resolveUrlTarget({
+ target: configuredUrls?.cliAuthConfirm ?? defaultTarget,
+ fallbackPath: joinHandlerComponentPath(handlerComponentBasePath, "cli-auth-confirm"),
+ handlerName: "cliAuthConfirm",
+ projectId: options.projectId,
+ }),
mfa: resolveUrlTarget({
target: configuredUrls?.mfa ?? defaultTarget,
fallbackPath: joinHandlerComponentPath(handlerComponentBasePath, "mfa"),
@@ -327,6 +336,17 @@ export const resolveHandlerUrls = (options: { urls: HandlerUrlOptions | undefine
};
};
+export const buildCliAuthConfirmUrl = (options: {
+ cliAuthConfirmUrl: string,
+ /** Used as the base URL only when cliAuthConfirmUrl is relative. */
+ appUrl: string,
+ loginCode: string,
+}): string => {
+ const url = new URL(options.cliAuthConfirmUrl, options.appUrl);
+ url.searchParams.set("login_code", options.loginCode);
+ return url.toString();
+};
+
export const resolveUnknownHandlerPathFallbackUrl = (options: {
defaultTarget: DefaultHandlerUrlTarget | undefined,
projectId: string,
diff --git a/packages/template/src/providers/stack-context.tsx b/packages/template/src/providers/stack-context.tsx
new file mode 100644
index 0000000000..7f9cca3028
--- /dev/null
+++ b/packages/template/src/providers/stack-context.tsx
@@ -0,0 +1,8 @@
+"use client";
+
+import React from "react";
+import type { StackClientApp } from "../lib/stack-app/apps/interfaces/client-app";
+
+export const StackContext = React.createContext,
+}>(null);
diff --git a/packages/template/src/providers/stack-provider-client.tsx b/packages/template/src/providers/stack-provider-client.tsx
index 1e423dcaaa..a4615ea96c 100644
--- a/packages/template/src/providers/stack-provider-client.tsx
+++ b/packages/template/src/providers/stack-provider-client.tsx
@@ -3,12 +3,9 @@
import { CurrentUserCrud } from "@stackframe/stack-shared/dist/interface/crud/current-user";
import { globalVar } from "@stackframe/stack-shared/dist/utils/globals";
import React, { useEffect } from "react";
-import { useStackApp } from "..";
+import { useStackApp } from "../lib/hooks";
import { StackClientApp, StackClientAppJson, stackAppInternalsSymbol } from "../lib/stack-app";
-
-export const StackContext = React.createContext,
-}>(null);
+import { StackContext } from "./stack-context";
export function StackProviderClient(props: {
app: StackClientAppJson | StackClientApp,
diff --git a/sdks/spec/src/apps/client-app.spec.md b/sdks/spec/src/apps/client-app.spec.md
index 0c5c5d0ab0..3972a5a98e 100644
--- a/sdks/spec/src/apps/client-app.spec.md
+++ b/sdks/spec/src/apps/client-app.spec.md
@@ -849,7 +849,7 @@ Implementation:
Body: { expires_in_millis?: number, anon_refresh_token?: string }
Response: { polling_code: string, login_code: string }
-2. Build login URL: {appUrl}/handler/cli-auth-confirm?login_code={login_code}
+2. Build login URL from the app's resolved `urls.cliAuthConfirm` target, using `appUrl` as the base URL when the target is relative, and append `login_code={login_code}`.
3. Call promptLink(url, loginCode) if provided, or print login code and URL
4. Poll for completion: