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" ;
24import { 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" ;
46import { 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+
687export 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} ) ;
0 commit comments