Skip to content

Commit c44075c

Browse files
committed
feat: add anonRefreshToken to CLI auth flow and enhance session management
- Updated the CliAuthAttempt model to include anonRefreshToken. - Added migration to support the new anonRefreshToken field. - Enhanced CLI authentication routes to handle anonymous user sessions, including validation and merging of anonymous user data into authenticated sessions. - Updated tests to cover new functionality for anonymous sessions and refresh token handling.
1 parent 300970c commit c44075c

17 files changed

Lines changed: 2046 additions & 100 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ALTER TABLE "CliAuthAttempt"
2+
ADD COLUMN "anonRefreshToken" TEXT;

apps/backend/prisma/schema.prisma

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1057,11 +1057,12 @@ model CliAuthAttempt {
10571057
tenancyId String @db.Uuid
10581058
10591059
id String @default(uuid()) @db.Uuid
1060-
pollingCode String @unique
1061-
loginCode String @unique
1062-
refreshToken String?
1063-
expiresAt DateTime
1064-
usedAt DateTime?
1060+
pollingCode String @unique
1061+
loginCode String @unique
1062+
refreshToken String?
1063+
anonRefreshToken String?
1064+
expiresAt DateTime
1065+
usedAt DateTime?
10651066
10661067
createdAt DateTime @default(now())
10671068
updatedAt DateTime @updatedAt
Lines changed: 176 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,93 @@
1-
import { getPrismaClientForTenancy } from "@/prisma-client";
1+
import { generateAccessTokenFromRefreshTokenIfValid } from "@/lib/tokens";
2+
import { mergeAnonymousUserIntoAuthenticatedUser } from "@/lib/user-merge";
3+
import { globalPrismaClient, getPrismaClientForTenancy, retryTransaction } from "@/prisma-client";
24
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
3-
import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
5+
import { adaptSchema, clientOrHigherAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
46
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
57

8+
type CliSessionState = "anonymous" | "none";
9+
10+
async function getPendingCliAuthAttempt(tenancyId: string, loginCode: string) {
11+
const cliAuth = await globalPrismaClient.cliAuthAttempt.findUnique({
12+
where: {
13+
loginCode,
14+
},
15+
});
16+
17+
if (!cliAuth) {
18+
throw new StatusError(400, "Invalid login code or the code has expired");
19+
}
20+
21+
if (cliAuth.tenancyId !== tenancyId) {
22+
throw new StatusError(400, "Project ID mismatch; please ensure that you are using the correct app url.");
23+
}
24+
25+
if (cliAuth.refreshToken !== null || cliAuth.usedAt !== null || cliAuth.expiresAt < new Date()) {
26+
throw new StatusError(400, "Invalid login code or the code has expired");
27+
}
28+
29+
return cliAuth;
30+
}
31+
32+
async function getRefreshTokenSession(tenancyId: string, refreshToken: string) {
33+
const refreshTokenObj = await globalPrismaClient.projectUserRefreshToken.findUnique({
34+
where: {
35+
refreshToken,
36+
},
37+
});
38+
39+
if (!refreshTokenObj) {
40+
return null;
41+
}
42+
43+
if (refreshTokenObj.tenancyId !== tenancyId) {
44+
throw new StatusError(400, "Refresh token does not belong to this project");
45+
}
46+
47+
if (refreshTokenObj.expiresAt !== null && refreshTokenObj.expiresAt < new Date()) {
48+
return null;
49+
}
50+
51+
return refreshTokenObj;
52+
}
53+
54+
async function getCliAnonymousSession(tenancyId: string, anonRefreshToken: string | null) {
55+
if (anonRefreshToken === null) {
56+
return null;
57+
}
58+
59+
const refreshTokenObj = await getRefreshTokenSession(tenancyId, anonRefreshToken);
60+
if (!refreshTokenObj) {
61+
return null;
62+
}
63+
64+
const user = await globalPrismaClient.projectUser.findUnique({
65+
where: {
66+
tenancyId_projectUserId: {
67+
tenancyId,
68+
projectUserId: refreshTokenObj.projectUserId,
69+
},
70+
},
71+
select: {
72+
projectUserId: true,
73+
isAnonymous: true,
74+
},
75+
});
76+
77+
if (!user?.isAnonymous) {
78+
return null;
79+
}
80+
81+
return {
82+
refreshTokenObj,
83+
userId: user.projectUserId,
84+
};
85+
}
86+
687
export const POST = createSmartRouteHandler({
788
metadata: {
889
summary: "Complete CLI authentication",
9-
description: "Set the refresh token for a CLI authentication session using the login code",
90+
description: "Inspect, claim, or complete a CLI authentication session",
1091
tags: ["CLI Authentication"],
1192
},
1293
request: yupObject({
@@ -16,51 +97,118 @@ export const POST = createSmartRouteHandler({
1697
}).defined(),
1798
body: yupObject({
1899
login_code: yupString().defined(),
19-
refresh_token: yupString().defined(),
100+
mode: yupString().oneOf(["check", "claim-anon-session", "complete"]).default("complete"),
101+
refresh_token: yupString().optional(),
20102
}).defined(),
21103
}),
22104
response: yupObject({
23105
statusCode: yupNumber().oneOf([200]).defined(),
24-
bodyType: yupString().oneOf(["success"]).defined(),
106+
bodyType: yupString().oneOf(["json"]).defined(),
107+
body: yupObject({
108+
success: yupBoolean().optional(),
109+
cli_session_state: yupString().oneOf(["anonymous", "none"]).optional(),
110+
access_token: yupString().optional(),
111+
refresh_token: yupString().optional(),
112+
}).defined(),
25113
}),
26-
async handler({ auth: { tenancy }, body: { login_code, refresh_token } }) {
114+
async handler({ auth: { tenancy }, body: { login_code, mode, refresh_token } }) {
27115
const prisma = await getPrismaClientForTenancy(tenancy);
116+
const cliAuth = await getPendingCliAuthAttempt(tenancy.id, login_code);
117+
const cliAnonymousSession = await getCliAnonymousSession(tenancy.id, cliAuth.anonRefreshToken);
28118

29-
// Find the CLI auth attempt
30-
const cliAuth = await prisma.cliAuthAttempt.findUnique({
31-
where: {
32-
loginCode: login_code,
33-
refreshToken: null,
34-
expiresAt: {
35-
gt: new Date(),
119+
if (mode === "check") {
120+
const cliSessionState: CliSessionState = cliAnonymousSession ? "anonymous" : "none";
121+
122+
return {
123+
statusCode: 200,
124+
bodyType: "json" as const,
125+
body: {
126+
cli_session_state: cliSessionState,
36127
},
37-
},
38-
});
128+
};
129+
}
130+
131+
if (mode === "claim-anon-session") {
132+
if (!cliAnonymousSession) {
133+
throw new StatusError(400, "No anonymous session associated with this code");
134+
}
135+
136+
const accessToken = await generateAccessTokenFromRefreshTokenIfValid({
137+
tenancy,
138+
refreshTokenObj: cliAnonymousSession.refreshTokenObj,
139+
});
140+
141+
if (!accessToken) {
142+
throw new StatusError(400, "Anonymous session is no longer valid");
143+
}
144+
145+
return {
146+
statusCode: 200,
147+
bodyType: "json" as const,
148+
body: {
149+
access_token: accessToken,
150+
refresh_token: cliAnonymousSession.refreshTokenObj.refreshToken,
151+
},
152+
};
153+
}
39154

40-
if (!cliAuth) {
41-
throw new StatusError(400, "Invalid login code or the code has expired");
155+
if (!refresh_token) {
156+
throw new StatusError(400, "refresh_token is required when mode is 'complete'");
42157
}
43158

44-
if (cliAuth.tenancyId !== tenancy.id) {
45-
throw new StatusError(400, "Project ID mismatch; please ensure that you are using the correct app url.");
159+
const browserRefreshTokenSession = await getRefreshTokenSession(tenancy.id, refresh_token);
160+
if (!browserRefreshTokenSession) {
161+
throw new StatusError(400, "Invalid refresh token");
46162
}
47163

48-
// Update with refresh token
49-
await prisma.cliAuthAttempt.update({
50-
where: {
51-
tenancyId_id: {
164+
await retryTransaction(prisma, async (tx) => {
165+
// Re-verify the CLI auth attempt is still pending inside the transaction
166+
// to prevent race conditions with concurrent complete requests
167+
const freshCliAuth = await tx.cliAuthAttempt.findFirst({
168+
where: {
52169
tenancyId: tenancy.id,
53170
id: cliAuth.id,
171+
refreshToken: null,
172+
usedAt: null,
173+
expiresAt: { gt: new Date() },
54174
},
55-
},
56-
data: {
57-
refreshToken: refresh_token,
58-
},
175+
});
176+
177+
if (!freshCliAuth) {
178+
throw new StatusError(400, "Invalid login code or the code has expired");
179+
}
180+
181+
if (
182+
cliAnonymousSession
183+
&& cliAnonymousSession.userId !== browserRefreshTokenSession.projectUserId
184+
) {
185+
await mergeAnonymousUserIntoAuthenticatedUser(tx, {
186+
tenancy,
187+
anonymousUserId: cliAnonymousSession.userId,
188+
authenticatedUserId: browserRefreshTokenSession.projectUserId,
189+
});
190+
}
191+
192+
await tx.cliAuthAttempt.update({
193+
where: {
194+
tenancyId_id: {
195+
tenancyId: tenancy.id,
196+
id: freshCliAuth.id,
197+
},
198+
},
199+
data: {
200+
refreshToken: refresh_token,
201+
anonRefreshToken: null,
202+
},
203+
});
59204
});
60205

61206
return {
62207
statusCode: 200,
63-
bodyType: "success",
208+
bodyType: "json" as const,
209+
body: {
210+
success: true,
211+
},
64212
};
65213
},
66214
});

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

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { getPrismaClientForTenancy } from "@/prisma-client";
1+
import { globalPrismaClient, getPrismaClientForTenancy } from "@/prisma-client";
22
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
33
import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
44
import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto";
5+
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
56

67
export const POST = createSmartRouteHandler({
78
metadata: {
@@ -16,6 +17,7 @@ export const POST = createSmartRouteHandler({
1617
}).defined(),
1718
body: yupObject({
1819
expires_in_millis: yupNumber().max(1000 * 60 * 60 * 24).default(1000 * 60 * 120), // Default: 2 hours, max: 24 hours
20+
anon_refresh_token: yupString().optional(),
1921
}).default({}),
2022
}),
2123
response: yupObject({
@@ -27,19 +29,56 @@ export const POST = createSmartRouteHandler({
2729
expires_at: yupString().defined(),
2830
}).defined(),
2931
}),
30-
async handler({ auth: { tenancy }, body: { expires_in_millis } }) {
32+
async handler({ auth: { tenancy }, body: { expires_in_millis, anon_refresh_token } }) {
33+
let anonRefreshToken: string | null = null;
34+
35+
if (anon_refresh_token) {
36+
const refreshTokenObj = await globalPrismaClient.projectUserRefreshToken.findUnique({
37+
where: {
38+
refreshToken: anon_refresh_token,
39+
},
40+
});
41+
42+
if (!refreshTokenObj) {
43+
throw new StatusError(400, "Invalid anon refresh token");
44+
}
45+
46+
if (refreshTokenObj.tenancyId !== tenancy.id) {
47+
throw new StatusError(400, "Anon refresh token does not belong to this project");
48+
}
49+
50+
if (refreshTokenObj.expiresAt && refreshTokenObj.expiresAt < new Date()) {
51+
throw new StatusError(400, "The provided anon refresh token has expired");
52+
}
53+
54+
const user = await globalPrismaClient.projectUser.findUnique({
55+
where: {
56+
tenancyId_projectUserId: {
57+
tenancyId: tenancy.id,
58+
projectUserId: refreshTokenObj.projectUserId,
59+
},
60+
},
61+
});
62+
63+
if (!user?.isAnonymous) {
64+
throw new StatusError(400, "The provided refresh token does not belong to an anonymous user");
65+
}
66+
67+
anonRefreshToken = anon_refresh_token;
68+
}
69+
3170
const pollingCode = generateSecureRandomString();
3271
const loginCode = generateSecureRandomString();
3372
const expiresAt = new Date(Date.now() + expires_in_millis);
3473

35-
// Create a new CLI auth attempt
3674
const prisma = await getPrismaClientForTenancy(tenancy);
3775
const cliAuth = await prisma.cliAuthAttempt.create({
3876
data: {
3977
tenancyId: tenancy.id,
4078
pollingCode,
4179
loginCode,
4280
expiresAt,
81+
anonRefreshToken,
4382
},
4483
});
4584

0 commit comments

Comments
 (0)