Skip to content

Commit 4a6a2e1

Browse files
committed
fix: improve user validation and error handling in CLI auth flow
- Updated user validation logic to throw an error if the user is not found for the provided refresh token. - Enhanced error handling for anonymous user merges to skip the merge if the user has been upgraded concurrently. - Adjusted CLI auth demo page to correctly handle anonymous user state and refresh tokens. - Updated tests to reflect changes in user validation and session management.
1 parent c44075c commit 4a6a2e1

File tree

8 files changed

+42
-32
lines changed

8 files changed

+42
-32
lines changed

apps/backend/src/app/api/latest/auth/cli/route.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,11 @@ export const POST = createSmartRouteHandler({
6060
},
6161
});
6262

63-
if (!user?.isAnonymous) {
63+
if (!user) {
64+
throw new StatusError(400, "User not found for provided refresh token");
65+
}
66+
67+
if (!user.isAnonymous) {
6468
throw new StatusError(400, "The provided refresh token does not belong to an anonymous user");
6569
}
6670

apps/backend/src/lib/user-merge.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ export async function mergeAnonymousUserIntoAuthenticatedUser(
6969
}
7070

7171
if (!anonymousUser.isAnonymous) {
72-
throw new StackAssertionError("Expected the CLI source user to still be anonymous during merge", options);
72+
// User was upgraded concurrently; treat as already authenticated, skip merge
73+
return;
7374
}
7475

7576
const [anonymousContactChannelCount, anonymousAuthMethodCount, anonymousOauthAccountCount] = await Promise.all([

apps/e2e/tests/js/restricted-users.test.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,6 @@ describe("restricted user SDK filtering", () => {
141141
expect(userWithAnonymousFallback!.isRestricted).toBe(true);
142142
expect(userWithAnonymousFallback!.isAnonymous).toBe(false);
143143
});
144-
145144
});
146145

147146
describe("server app getUser with includeRestricted option", () => {
@@ -247,7 +246,6 @@ describe("restricted user SDK filtering", () => {
247246
serverApp.getUser({ or: "anonymous", includeRestricted: false })
248247
).rejects.toThrow("Cannot use { or: 'anonymous' } with { includeRestricted: false }");
249248
});
250-
251249
});
252250

253251
describe("transition from restricted to non-restricted", () => {

examples/demo/src/app/cli-auth-demo/page.tsx

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,13 @@ export default function CliAuthDemoPage() {
181181
isAnonymous: user.isAnonymous,
182182
refreshToken: stored.refreshToken,
183183
});
184+
} else {
185+
const { userId, isAnonymous } = parseAccessTokenUserSnapshot(refreshed.access_token);
186+
setCliState({
187+
userId,
188+
isAnonymous,
189+
refreshToken: stored.refreshToken,
190+
});
184191
}
185192
} else {
186193
saveCliState(null);
@@ -208,13 +215,12 @@ export default function CliAuthDemoPage() {
208215

209216
const doCliAnonSignUp = useCallback(async () => {
210217
log('CLI: Creating anonymous user...');
211-
await cliApp.getUser({ or: 'anonymous' });
212-
const user = await cliApp.getUser({ includeRestricted: true });
213-
const tokens = user ? await user.currentSession.getTokens() : null;
218+
const anonUser = await cliApp.getUser({ or: 'anonymous' });
219+
const tokens = await anonUser.currentSession.getTokens();
214220
const state: CliState = {
215-
userId: user?.id ?? null,
216-
isAnonymous: user?.isAnonymous ?? false,
217-
refreshToken: tokens?.refreshToken ?? null,
221+
userId: anonUser.id,
222+
isAnonymous: anonUser.isAnonymous,
223+
refreshToken: tokens.refreshToken ?? null,
218224
};
219225
setCliState(state);
220226
saveCliState(state.refreshToken);
@@ -237,7 +243,7 @@ export default function CliAuthDemoPage() {
237243
const doBrowserSignOut = useCallback(async () => {
238244
if (browserUser) {
239245
log('Browser: Signing out...');
240-
await browserUser.signOut();
246+
await browserUser.signOut({ redirectUrl: '/cli-auth-demo' });
241247
log('Browser: Signed out');
242248
}
243249
}, [browserUser, log]);
@@ -339,7 +345,7 @@ export default function CliAuthDemoPage() {
339345
return () => clearInterval(id);
340346
}, [phase]);
341347

342-
const confirmUrl = loginCode
348+
const confirmUrl = loginCode && typeof window !== 'undefined'
343349
? `${window.location.origin}/handler/cli-auth-confirm?login_code=${encodeURIComponent(loginCode)}`
344350
: null;
345351

packages/stack-cli/src/commands/login.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { Command } from "commander";
21
import { StackClientApp } from "@stackframe/js";
3-
import { resolveLoginConfig, DEFAULT_PUBLISHABLE_CLIENT_KEY } from "../lib/auth.js";
4-
import { readConfigValue, writeConfigValue, removeConfigValue } from "../lib/config.js";
2+
import { Command } from "commander";
3+
import { DEFAULT_PUBLISHABLE_CLIENT_KEY, resolveLoginConfig } from "../lib/auth.js";
4+
import { readConfigValue, removeConfigValue, writeConfigValue } from "../lib/config.js";
55
import { CliError } from "../lib/errors.js";
66

77
export function registerLoginCommand(program: Command) {

packages/template/src/components-page/cli-auth-confirm.tsx

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client';
22

3-
import { Typography } from "@stackframe/stack-ui";
43
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
4+
import { Typography } from "@stackframe/stack-ui";
55
import { useEffect, useRef, useState } from "react";
66
import { stackAppInternalsSymbol, useStackApp } from "..";
77
import { MessageCard } from "../components/message-cards/message-card";
@@ -12,7 +12,7 @@ function getStackAppInternals(app: unknown) {
1212
}
1313

1414
async function postCliAuthComplete(app: unknown, body: Record<string, unknown>) {
15-
return getStackAppInternals(app).sendRequest("/auth/cli/complete", {
15+
return await getStackAppInternals(app).sendRequest("/auth/cli/complete", {
1616
method: "POST",
1717
headers: { "Content-Type": "application/json" },
1818
body: JSON.stringify(body),
@@ -98,25 +98,26 @@ export function CliAuthConfirmation({ fullPage = true }: { fullPage?: boolean })
9898
window.history.replaceState({}, "", url.toString());
9999

100100
const checkResult = await postCliAuthComplete(app, { login_code: loginCode, mode: "check" });
101-
102-
let cliSessionState: string | null = null;
103-
if (checkResult.ok) {
104-
const checkData = await checkResult.json();
105-
cliSessionState = checkData.cli_session_state ?? null;
101+
if (!checkResult.ok) {
102+
throw new Error(`Failed to verify login code: ${checkResult.status} ${await checkResult.text()}`);
106103
}
104+
const checkData = await checkResult.json();
105+
const cliSessionState: string | null = checkData.cli_session_state ?? null;
107106

108107
if (cliSessionState === "anonymous") {
109108
const claimResult = await postCliAuthComplete(app, { login_code: loginCode, mode: "claim-anon-session" });
110109

111-
if (claimResult.ok) {
112-
const tokens = await claimResult.json();
113-
await getStackAppInternals(app).signInWithTokens({
114-
accessToken: tokens.access_token,
115-
refreshToken: tokens.refresh_token,
116-
});
117-
await app.redirectToSignUp({ replace: true });
118-
return;
110+
if (!claimResult.ok) {
111+
throw new Error(`Failed to claim anonymous session: ${claimResult.status} ${await claimResult.text()}`);
119112
}
113+
114+
const tokens = await claimResult.json();
115+
await getStackAppInternals(app).signInWithTokens({
116+
accessToken: tokens.access_token,
117+
refreshToken: tokens.refresh_token,
118+
});
119+
await app.redirectToSignUp({ replace: true });
120+
return;
120121
}
121122

122123
await app.redirectToSignIn({ replace: true });

packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3005,7 +3005,7 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
30053005
},
30063006
body: JSON.stringify({
30073007
expires_in_millis: options.expiresInMillis,
3008-
...(options.anonRefreshToken ? { anon_refresh_token: options.anonRefreshToken } : {}),
3008+
...(options.anonRefreshToken != null ? { anon_refresh_token: options.anonRefreshToken } : {}),
30093009
}),
30103010
},
30113011
null

sdks/spec/src/apps/client-app.spec.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -855,7 +855,7 @@ Implementation:
855855
4. Poll for completion:
856856
POST /api/v1/auth/cli/poll
857857
Body: { polling_code: string }
858-
Response on pending: { status: "pending" }
858+
Response on pending: { status: "waiting" }
859859
Response on success: { status: "success", refresh_token: string }
860860

861861
Poll every waitTimeMillis until success, error, or maxAttempts reached.

0 commit comments

Comments
 (0)