11import { Prisma } from "@/generated/prisma/client" ;
2+ import { usersCrudHandlers } from "@/app/api/latest/users/crud" ;
23import { generateAccessTokenFromRefreshTokenIfValid } from "@/lib/tokens" ;
3- import { globalPrismaClient } from "@/prisma-client" ;
4+ import { getPrismaClientForTenancy , getPrismaSchemaForTenancy , globalPrismaClient , sqlQuoteIdent } from "@/prisma-client" ;
45import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler" ;
5- import { adaptSchema , clientOrHigherAuthTypeSchema , yupBoolean , yupNumber , yupObject , yupString } from "@stackframe/stack-shared/dist/schema-fields" ;
6+ import { Tenancy } from "@/lib/tenancies" ;
7+ import { KnownErrors } from "@stackframe/stack-shared" ;
8+ import { adaptSchema , clientOrHigherAuthTypeSchema , yupBoolean , yupNumber , yupObject , yupString , yupUnion } from "@stackframe/stack-shared/dist/schema-fields" ;
69import { StatusError } from "@stackframe/stack-shared/dist/utils/errors" ;
10+ import type { InferType } from "yup" ;
711
812type CliSessionState = "anonymous" | "none" ;
913
14+ const postCliAuthCompleteRequestSchema = yupObject ( {
15+ auth : yupObject ( {
16+ type : clientOrHigherAuthTypeSchema ,
17+ tenancy : adaptSchema . defined ( ) ,
18+ } ) . defined ( ) ,
19+ body : yupObject ( {
20+ login_code : yupString ( ) . defined ( ) ,
21+ mode : yupString ( ) . oneOf ( [ "check" , "claim-anon-session" , "complete" ] ) . default ( "complete" ) ,
22+ refresh_token : yupString ( ) . optional ( ) ,
23+ } ) . defined ( ) ,
24+ } ) ;
25+
26+ const postCliAuthCompleteResponseSchema = yupUnion (
27+ yupObject ( {
28+ statusCode : yupNumber ( ) . oneOf ( [ 200 ] ) . defined ( ) ,
29+ bodyType : yupString ( ) . oneOf ( [ "json" ] ) . defined ( ) ,
30+ body : yupObject ( {
31+ cli_session_state : yupString ( ) . oneOf ( [ "anonymous" , "none" ] ) . defined ( ) ,
32+ } ) . defined ( ) ,
33+ } ) . defined ( ) ,
34+ yupObject ( {
35+ statusCode : yupNumber ( ) . oneOf ( [ 200 ] ) . defined ( ) ,
36+ bodyType : yupString ( ) . oneOf ( [ "json" ] ) . defined ( ) ,
37+ body : yupObject ( {
38+ access_token : yupString ( ) . defined ( ) ,
39+ refresh_token : yupString ( ) . defined ( ) ,
40+ } ) . defined ( ) ,
41+ } ) . defined ( ) ,
42+ yupObject ( {
43+ statusCode : yupNumber ( ) . oneOf ( [ 200 ] ) . defined ( ) ,
44+ bodyType : yupString ( ) . oneOf ( [ "json" ] ) . defined ( ) ,
45+ body : yupObject ( {
46+ success : yupBoolean ( ) . oneOf ( [ true ] ) . defined ( ) ,
47+ } ) . defined ( ) ,
48+ } ) . defined ( ) ,
49+ ) . defined ( ) ;
50+
51+ type PostCliAuthCompleteRequest = InferType < typeof postCliAuthCompleteRequestSchema > ;
52+ type PostCliAuthCompleteResponse = InferType < typeof postCliAuthCompleteResponseSchema > ;
53+
54+ function cliAuthCompleteCheckResponse ( cliSessionState : CliSessionState ) : PostCliAuthCompleteResponse {
55+ return {
56+ statusCode : 200 ,
57+ bodyType : "json" ,
58+ body : {
59+ cli_session_state : cliSessionState ,
60+ } ,
61+ } ;
62+ }
63+
64+ function cliAuthCompleteClaimResponse ( accessToken : string , refreshToken : string ) : PostCliAuthCompleteResponse {
65+ return {
66+ statusCode : 200 ,
67+ bodyType : "json" ,
68+ body : {
69+ access_token : accessToken ,
70+ refresh_token : refreshToken ,
71+ } ,
72+ } ;
73+ }
74+
75+ function cliAuthCompleteSuccessResponse ( ) : PostCliAuthCompleteResponse {
76+ return {
77+ statusCode : 200 ,
78+ bodyType : "json" ,
79+ body : {
80+ success : true ,
81+ } ,
82+ } ;
83+ }
84+
1085type CliAuthAttemptRow = {
1186 id : string ,
1287 tenancyId : string ,
@@ -24,28 +99,27 @@ type RefreshTokenRow = {
2499 expiresAt : Date | null ,
25100} ;
26101
27- async function getPendingCliAuthAttempt ( tenancyId : string , loginCode : string ) {
28- const rows = await globalPrismaClient . $queryRaw < CliAuthAttemptRow [ ] > ( Prisma . sql `
102+ async function getPendingCliAuthAttempt ( tenancy : Tenancy , loginCode : string ) {
103+ // CliAuthAttempt lives in the tenancy's source-of-truth DB, consistent with cli/poll/route.tsx.
104+ const prisma = await getPrismaClientForTenancy ( tenancy ) ;
105+ const schema = await getPrismaSchemaForTenancy ( tenancy ) ;
106+ const rows = await prisma . $queryRaw < CliAuthAttemptRow [ ] > ( Prisma . sql `
29107 SELECT
30108 "id",
31109 "tenancyId",
32110 "refreshToken",
33111 "anonRefreshToken",
34112 "expiresAt",
35113 "usedAt"
36- FROM "CliAuthAttempt"
37- WHERE "loginCode" = ${ loginCode }
114+ FROM ${ sqlQuoteIdent ( schema ) } ."CliAuthAttempt"
115+ WHERE "tenancyId" = ${ tenancy . id } ::UUID
116+ AND "loginCode" = ${ loginCode }
38117 LIMIT 1
39118 ` ) ;
40- const cliAuth = rows [ 0 ] ;
41-
42- if ( ! cliAuth ) {
119+ if ( rows . length === 0 ) {
43120 throw new StatusError ( 400 , "Invalid login code or the code has expired" ) ;
44121 }
45-
46- if ( cliAuth . tenancyId !== tenancyId ) {
47- throw new StatusError ( 400 , "Project ID mismatch; please ensure that you are using the correct app url." ) ;
48- }
122+ const cliAuth = rows [ 0 ] ;
49123
50124 if ( cliAuth . refreshToken !== null || cliAuth . usedAt !== null || cliAuth . expiresAt < new Date ( ) ) {
51125 throw new StatusError ( 400 , "Invalid login code or the code has expired" ) ;
@@ -55,6 +129,7 @@ async function getPendingCliAuthAttempt(tenancyId: string, loginCode: string) {
55129}
56130
57131async function getRefreshTokenSession ( tenancyId : string , refreshToken : string ) {
132+ // ProjectUserRefreshToken lives in the global DB (see tokens.tsx and oauth/model.tsx).
58133 const rows = await globalPrismaClient . $queryRaw < RefreshTokenRow [ ] > ( Prisma . sql `
59134 SELECT
60135 "id",
@@ -66,11 +141,10 @@ async function getRefreshTokenSession(tenancyId: string, refreshToken: string) {
66141 WHERE "refreshToken" = ${ refreshToken }
67142 LIMIT 1
68143 ` ) ;
69- const refreshTokenObj = rows [ 0 ] ;
70-
71- if ( ! refreshTokenObj ) {
144+ if ( rows . length === 0 ) {
72145 return null ;
73146 }
147+ const refreshTokenObj = rows [ 0 ] ;
74148
75149 if ( refreshTokenObj . tenancyId !== tenancyId ) {
76150 throw new StatusError ( 400 , "Refresh token does not belong to this project" ) ;
@@ -83,84 +157,89 @@ async function getRefreshTokenSession(tenancyId: string, refreshToken: string) {
83157 return refreshTokenObj ;
84158}
85159
86- async function getCliAnonymousSession ( tenancyId : string , anonRefreshToken : string | null ) {
160+ async function getCliAnonymousSession ( tenancy : Tenancy , anonRefreshToken : string | null ) {
87161 if ( anonRefreshToken === null ) {
88162 return null ;
89163 }
90164
91- const refreshTokenObj = await getRefreshTokenSession ( tenancyId , anonRefreshToken ) ;
165+ const refreshTokenObj = await getRefreshTokenSession ( tenancy . id , anonRefreshToken ) ;
92166 if ( ! refreshTokenObj ) {
93167 return null ;
94168 }
95169
96- const userRows = await globalPrismaClient . $queryRaw < { projectUserId : string , isAnonymous : boolean } [ ] > ( Prisma . sql `
97- SELECT "projectUserId", "isAnonymous"
98- FROM "ProjectUser"
99- WHERE "tenancyId" = ${ tenancyId } ::UUID
100- AND "projectUserId" = ${ refreshTokenObj . projectUserId } ::UUID
101- LIMIT 1
102- ` ) ;
103- const user = userRows [ 0 ] ;
170+ // ProjectUser lives in the tenancy's source-of-truth DB, not global.
171+ // Use the CRUD handler which is topology-aware (matches tokens.tsx:206).
172+ let user ;
173+ try {
174+ user = await usersCrudHandlers . adminRead ( {
175+ tenancy,
176+ user_id : refreshTokenObj . projectUserId ,
177+ allowedErrorTypes : [ KnownErrors . UserNotFound ] ,
178+ } ) ;
179+ } catch ( error ) {
180+ if ( error instanceof KnownErrors . UserNotFound ) {
181+ return null ;
182+ }
183+ throw error ;
184+ }
104185
105- if ( ! user ?. isAnonymous ) {
186+ if ( ! user . is_anonymous ) {
106187 return null ;
107188 }
108189
109190 return {
110191 refreshTokenObj,
111- userId : user . projectUserId ,
192+ userId : user . id ,
112193 } ;
113194}
114195
115- export const POST = createSmartRouteHandler ( {
196+ export const POST = createSmartRouteHandler < PostCliAuthCompleteRequest , PostCliAuthCompleteResponse > ( {
116197 metadata : {
117198 summary : "Complete CLI authentication" ,
118199 description : "Inspect, claim, or complete a CLI authentication session" ,
119200 tags : [ "CLI Authentication" ] ,
120201 } ,
121- request : yupObject ( {
122- auth : yupObject ( {
123- type : clientOrHigherAuthTypeSchema ,
124- tenancy : adaptSchema . defined ( ) ,
125- } ) . defined ( ) ,
126- body : yupObject ( {
127- login_code : yupString ( ) . defined ( ) ,
128- mode : yupString ( ) . oneOf ( [ "check" , "claim-anon-session" , "complete" ] ) . default ( "complete" ) ,
129- refresh_token : yupString ( ) . optional ( ) ,
130- } ) . defined ( ) ,
131- } ) ,
132- response : yupObject ( {
133- statusCode : yupNumber ( ) . oneOf ( [ 200 ] ) . defined ( ) ,
134- bodyType : yupString ( ) . oneOf ( [ "json" ] ) . defined ( ) ,
135- body : yupObject ( {
136- success : yupBoolean ( ) . optional ( ) ,
137- cli_session_state : yupString ( ) . oneOf ( [ "anonymous" , "none" ] ) . optional ( ) ,
138- access_token : yupString ( ) . optional ( ) ,
139- refresh_token : yupString ( ) . optional ( ) ,
140- } ) . defined ( ) ,
141- } ) ,
202+ request : postCliAuthCompleteRequestSchema ,
203+ response : postCliAuthCompleteResponseSchema ,
142204 async handler ( { auth : { tenancy } , body : { login_code, mode, refresh_token } } ) {
143- const cliAuth = await getPendingCliAuthAttempt ( tenancy . id , login_code ) ;
205+ const cliAuth = await getPendingCliAuthAttempt ( tenancy , login_code ) ;
144206
145207 if ( mode === "check" ) {
146- const cliAnonymousSession = await getCliAnonymousSession ( tenancy . id , cliAuth . anonRefreshToken ) ;
208+ const cliAnonymousSession = await getCliAnonymousSession ( tenancy , cliAuth . anonRefreshToken ) ;
147209 const cliSessionState : CliSessionState = cliAnonymousSession ? "anonymous" : "none" ;
148210
149- return {
150- statusCode : 200 ,
151- bodyType : "json" as const ,
152- body : {
153- cli_session_state : cliSessionState ,
154- } ,
155- } ;
211+ return cliAuthCompleteCheckResponse ( cliSessionState ) ;
156212 }
157213
158214 if ( mode === "claim-anon-session" ) {
159- const cliAnonymousSession = await getCliAnonymousSession ( tenancy . id , cliAuth . anonRefreshToken ) ;
215+ const cliAnonymousSession = await getCliAnonymousSession ( tenancy , cliAuth . anonRefreshToken ) ;
160216 if ( ! cliAnonymousSession ) {
161217 throw new StatusError ( 400 , "No anonymous session associated with this code" ) ;
162218 }
163219
220+ // Atomically consume the anon session (one-shot): null out anonRefreshToken
221+ // on the CliAuthAttempt row so subsequent claim-anon-session calls cannot
222+ // replay and re-retrieve the anon user's refresh token.
223+ const prisma = await getPrismaClientForTenancy ( tenancy ) ;
224+ const schema = await getPrismaSchemaForTenancy ( tenancy ) ;
225+ const consumed = await prisma . $queryRaw < { id : string } [ ] > ( Prisma . sql `
226+ UPDATE ${ sqlQuoteIdent ( schema ) } ."CliAuthAttempt"
227+ SET
228+ "anonRefreshToken" = NULL,
229+ "updatedAt" = NOW()
230+ WHERE "tenancyId" = ${ tenancy . id } ::UUID
231+ AND "id" = ${ cliAuth . id } ::UUID
232+ AND "anonRefreshToken" = ${ cliAuth . anonRefreshToken }
233+ AND "refreshToken" IS NULL
234+ AND "usedAt" IS NULL
235+ AND "expiresAt" > NOW()
236+ RETURNING "id"
237+ ` ) ;
238+
239+ if ( consumed . length === 0 ) {
240+ throw new StatusError ( 400 , "No anonymous session associated with this code" ) ;
241+ }
242+
164243 const accessToken = await generateAccessTokenFromRefreshTokenIfValid ( {
165244 tenancy,
166245 refreshTokenObj : cliAnonymousSession . refreshTokenObj ,
@@ -170,14 +249,7 @@ export const POST = createSmartRouteHandler({
170249 throw new StatusError ( 400 , "Anonymous session is no longer valid" ) ;
171250 }
172251
173- return {
174- statusCode : 200 ,
175- bodyType : "json" as const ,
176- body : {
177- access_token : accessToken ,
178- refresh_token : cliAnonymousSession . refreshTokenObj . refreshToken ,
179- } ,
180- } ;
252+ return cliAuthCompleteClaimResponse ( accessToken , cliAnonymousSession . refreshTokenObj . refreshToken ) ;
181253 }
182254
183255 if ( ! refresh_token ) {
@@ -194,8 +266,10 @@ export const POST = createSmartRouteHandler({
194266 // the anonymous user into the authenticated user (that was a security risk).
195267 // The anonymous user is left untouched and will simply be orphaned from
196268 // this CLI flow.
197- const claimed = await globalPrismaClient . $queryRaw < { id : string } [ ] > ( Prisma . sql `
198- UPDATE "CliAuthAttempt"
269+ const prisma = await getPrismaClientForTenancy ( tenancy ) ;
270+ const schema = await getPrismaSchemaForTenancy ( tenancy ) ;
271+ const claimed = await prisma . $queryRaw < { id : string } [ ] > ( Prisma . sql `
272+ UPDATE ${ sqlQuoteIdent ( schema ) } ."CliAuthAttempt"
199273 SET
200274 "refreshToken" = ${ refresh_token } ,
201275 "anonRefreshToken" = NULL,
@@ -212,12 +286,6 @@ export const POST = createSmartRouteHandler({
212286 throw new StatusError ( 400 , "Invalid login code or the code has expired" ) ;
213287 }
214288
215- return {
216- statusCode : 200 ,
217- bodyType : "json" as const ,
218- body : {
219- success : true ,
220- } ,
221- } ;
289+ return cliAuthCompleteSuccessResponse ( ) ;
222290 } ,
223291} ) ;
0 commit comments