-
Notifications
You must be signed in to change notification settings - Fork 513
feat: add anonRefreshToken to CLI auth flow and enhance session management #1303
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
c44075c
feat: add anonRefreshToken to CLI auth flow and enhance session manag…
mantrakp04 4a6a2e1
fix: improve user validation and error handling in CLI auth flow
mantrakp04 267d4ef
refactor: streamline CLI authentication flow and remove user merge fu…
mantrakp04 754ad22
refactor: enhance CLI authentication flow with improved session manag…
mantrakp04 28003a2
Merge branch 'dev' into anon-cli-auth
mantrakp04 be4174b
Merge branch 'dev' into anon-cli-auth
mantrakp04 3ad9a6e
Merge branch 'dev' into anon-cli-auth
mantrakp04 a7c564b
Refactor database migrations and update import statements
mantrakp04 1a0659b
Refactor error handling in OAuth callback route
mantrakp04 67ad0c1
Merge branch 'dev' into anon-cli-auth
mantrakp04 3d48f37
Enhance OAuth callback error handling and streamline account linking …
mantrakp04 475bdf3
Add new migrations for anon refresh token and signup email index
mantrakp04 dd8eb88
Merge branch 'dev' into anon-cli-auth
mantrakp04 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
2 changes: 2 additions & 0 deletions
2
...backend/prisma/migrations/20260331000000_add_anon_refresh_token_to_cli_auth/migration.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| ALTER TABLE "CliAuthAttempt" | ||
| ADD COLUMN "anonRefreshToken" TEXT; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
319 changes: 271 additions & 48 deletions
319
apps/backend/src/app/api/latest/auth/cli/complete/route.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,66 +1,289 @@ | ||
| import { getPrismaClientForTenancy } from "@/prisma-client"; | ||
| import { usersCrudHandlers } from "@/app/api/latest/users/crud"; | ||
| import { Prisma } from "@/generated/prisma/client"; | ||
| import { Tenancy } from "@/lib/tenancies"; | ||
| import { generateAccessTokenFromRefreshTokenIfValid } from "@/lib/tokens"; | ||
| import { getPrismaClientForTenancy, getPrismaSchemaForTenancy, globalPrismaClient, sqlQuoteIdent } from "@/prisma-client"; | ||
| import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; | ||
| import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; | ||
| import { KnownErrors } from "@stackframe/stack-shared"; | ||
| import { adaptSchema, clientOrHigherAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields"; | ||
| import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; | ||
| import type { InferType } from "yup"; | ||
|
|
||
| export const POST = createSmartRouteHandler({ | ||
| metadata: { | ||
| summary: "Complete CLI authentication", | ||
| description: "Set the refresh token for a CLI authentication session using the login code", | ||
| tags: ["CLI Authentication"], | ||
| }, | ||
| request: yupObject({ | ||
| auth: yupObject({ | ||
| type: clientOrHigherAuthTypeSchema, | ||
| tenancy: adaptSchema.defined(), | ||
| type CliSessionState = "anonymous" | "none"; | ||
|
|
||
| const postCliAuthCompleteRequestSchema = yupObject({ | ||
| auth: yupObject({ | ||
| type: clientOrHigherAuthTypeSchema, | ||
| tenancy: adaptSchema.defined(), | ||
| }).defined(), | ||
| body: yupObject({ | ||
| login_code: yupString().defined(), | ||
| mode: yupString().oneOf(["check", "claim-anon-session", "complete"]).default("complete"), | ||
| refresh_token: yupString().optional(), | ||
| }).defined(), | ||
| }); | ||
|
|
||
| const postCliAuthCompleteResponseSchema = yupUnion( | ||
| yupObject({ | ||
| statusCode: yupNumber().oneOf([200]).defined(), | ||
| bodyType: yupString().oneOf(["json"]).defined(), | ||
| body: yupObject({ | ||
| cli_session_state: yupString().oneOf(["anonymous", "none"]).defined(), | ||
| }).defined(), | ||
| }).defined(), | ||
| yupObject({ | ||
| statusCode: yupNumber().oneOf([200]).defined(), | ||
| bodyType: yupString().oneOf(["json"]).defined(), | ||
| body: yupObject({ | ||
| login_code: yupString().defined(), | ||
| access_token: yupString().defined(), | ||
| refresh_token: yupString().defined(), | ||
| }).defined(), | ||
| }), | ||
| response: yupObject({ | ||
| }).defined(), | ||
| yupObject({ | ||
| statusCode: yupNumber().oneOf([200]).defined(), | ||
| bodyType: yupString().oneOf(["success"]).defined(), | ||
| }), | ||
| async handler({ auth: { tenancy }, body: { login_code, refresh_token } }) { | ||
| const prisma = await getPrismaClientForTenancy(tenancy); | ||
| bodyType: yupString().oneOf(["json"]).defined(), | ||
| body: yupObject({ | ||
| success: yupBoolean().oneOf([true]).defined(), | ||
| }).defined(), | ||
| }).defined(), | ||
| ).defined(); | ||
|
|
||
| type PostCliAuthCompleteRequest = InferType<typeof postCliAuthCompleteRequestSchema>; | ||
| type PostCliAuthCompleteResponse = InferType<typeof postCliAuthCompleteResponseSchema>; | ||
|
|
||
| function cliAuthCompleteCheckResponse(cliSessionState: CliSessionState): PostCliAuthCompleteResponse { | ||
| return { | ||
| statusCode: 200, | ||
| bodyType: "json", | ||
| body: { | ||
| cli_session_state: cliSessionState, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| function cliAuthCompleteClaimResponse(accessToken: string, refreshToken: string): PostCliAuthCompleteResponse { | ||
| return { | ||
| statusCode: 200, | ||
| bodyType: "json", | ||
| body: { | ||
| access_token: accessToken, | ||
| refresh_token: refreshToken, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| function cliAuthCompleteSuccessResponse(): PostCliAuthCompleteResponse { | ||
| return { | ||
| statusCode: 200, | ||
| bodyType: "json", | ||
| body: { | ||
| success: true, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| type CliAuthAttemptRow = { | ||
| id: string, | ||
| tenancyId: string, | ||
| refreshToken: string | null, | ||
| anonRefreshToken: string | null, | ||
| expiresAt: Date, | ||
| usedAt: Date | null, | ||
| }; | ||
|
|
||
| type RefreshTokenRow = { | ||
| id: string, | ||
| tenancyId: string, | ||
| projectUserId: string, | ||
| refreshToken: string, | ||
| expiresAt: Date | null, | ||
| }; | ||
|
|
||
| async function getPendingCliAuthAttempt(tenancy: Tenancy, loginCode: string) { | ||
| // CliAuthAttempt lives in the tenancy's source-of-truth DB, consistent with cli/poll/route.tsx. | ||
| const prisma = await getPrismaClientForTenancy(tenancy); | ||
| const schema = await getPrismaSchemaForTenancy(tenancy); | ||
| const rows = await prisma.$queryRaw<CliAuthAttemptRow[]>(Prisma.sql` | ||
| SELECT | ||
| "id", | ||
| "tenancyId", | ||
| "refreshToken", | ||
| "anonRefreshToken", | ||
| "expiresAt", | ||
| "usedAt" | ||
| FROM ${sqlQuoteIdent(schema)}."CliAuthAttempt" | ||
| WHERE "tenancyId" = ${tenancy.id}::UUID | ||
| AND "loginCode" = ${loginCode} | ||
| LIMIT 1 | ||
| `); | ||
| if (rows.length === 0) { | ||
| throw new StatusError(400, "Invalid login code or the code has expired"); | ||
| } | ||
| const cliAuth = rows[0]; | ||
|
|
||
| if (cliAuth.refreshToken !== null || cliAuth.usedAt !== null || cliAuth.expiresAt < new Date()) { | ||
| throw new StatusError(400, "Invalid login code or the code has expired"); | ||
| } | ||
|
|
||
| return cliAuth; | ||
| } | ||
|
|
||
| async function getRefreshTokenSession(tenancyId: string, refreshToken: string) { | ||
| // ProjectUserRefreshToken lives in the global DB (see tokens.tsx and oauth/model.tsx). | ||
| const rows = await globalPrismaClient.$queryRaw<RefreshTokenRow[]>(Prisma.sql` | ||
| SELECT | ||
| "id", | ||
| "tenancyId", | ||
| "projectUserId", | ||
| "refreshToken", | ||
| "expiresAt" | ||
| FROM "ProjectUserRefreshToken" | ||
| WHERE "refreshToken" = ${refreshToken} | ||
| LIMIT 1 | ||
| `); | ||
| if (rows.length === 0) { | ||
| return null; | ||
| } | ||
| const refreshTokenObj = rows[0]; | ||
|
|
||
| if (refreshTokenObj.tenancyId !== tenancyId) { | ||
| throw new StatusError(400, "Refresh token does not belong to this project"); | ||
| } | ||
|
|
||
| if (refreshTokenObj.expiresAt !== null && refreshTokenObj.expiresAt < new Date()) { | ||
| return null; | ||
| } | ||
|
|
||
| return refreshTokenObj; | ||
| } | ||
|
|
||
| // Find the CLI auth attempt | ||
| const cliAuth = await prisma.cliAuthAttempt.findUnique({ | ||
| where: { | ||
| loginCode: login_code, | ||
| refreshToken: null, | ||
| expiresAt: { | ||
| gt: new Date(), | ||
| }, | ||
| }, | ||
| async function getCliAnonymousSession(tenancy: Tenancy, anonRefreshToken: string | null) { | ||
| if (anonRefreshToken === null) { | ||
| return null; | ||
| } | ||
|
|
||
| const refreshTokenObj = await getRefreshTokenSession(tenancy.id, anonRefreshToken); | ||
| if (!refreshTokenObj) { | ||
| return null; | ||
| } | ||
|
|
||
| // ProjectUser lives in the tenancy's source-of-truth DB, not global. | ||
| // Use the CRUD handler which is topology-aware (matches tokens.tsx:206). | ||
| let user; | ||
| try { | ||
| user = await usersCrudHandlers.adminRead({ | ||
| tenancy, | ||
| user_id: refreshTokenObj.projectUserId, | ||
| allowedErrorTypes: [KnownErrors.UserNotFound], | ||
| }); | ||
| } catch (error) { | ||
| if (error instanceof KnownErrors.UserNotFound) { | ||
| return null; | ||
| } | ||
| throw error; | ||
| } | ||
|
|
||
| if (!cliAuth) { | ||
| throw new StatusError(400, "Invalid login code or the code has expired"); | ||
| if (!user.is_anonymous) { | ||
| return null; | ||
| } | ||
|
|
||
| return { | ||
| refreshTokenObj, | ||
| userId: user.id, | ||
| }; | ||
| } | ||
|
|
||
| export const POST = createSmartRouteHandler<PostCliAuthCompleteRequest, PostCliAuthCompleteResponse>({ | ||
| metadata: { | ||
| summary: "Complete CLI authentication", | ||
| description: "Inspect, claim, or complete a CLI authentication session", | ||
| tags: ["CLI Authentication"], | ||
| }, | ||
| request: postCliAuthCompleteRequestSchema, | ||
| response: postCliAuthCompleteResponseSchema, | ||
| async handler({ auth: { tenancy }, body: { login_code, mode, refresh_token } }) { | ||
| const cliAuth = await getPendingCliAuthAttempt(tenancy, login_code); | ||
| const prisma = await getPrismaClientForTenancy(tenancy); | ||
| const schema = await getPrismaSchemaForTenancy(tenancy); | ||
|
|
||
| if (mode === "check") { | ||
| const cliAnonymousSession = await getCliAnonymousSession(tenancy, cliAuth.anonRefreshToken); | ||
| const cliSessionState: CliSessionState = cliAnonymousSession ? "anonymous" : "none"; | ||
|
|
||
| return cliAuthCompleteCheckResponse(cliSessionState); | ||
| } | ||
|
|
||
| if (cliAuth.tenancyId !== tenancy.id) { | ||
| throw new StatusError(400, "Project ID mismatch; please ensure that you are using the correct app url."); | ||
| if (mode === "claim-anon-session") { | ||
| const cliAnonymousSession = await getCliAnonymousSession(tenancy, cliAuth.anonRefreshToken); | ||
| if (!cliAnonymousSession) { | ||
| throw new StatusError(400, "No anonymous session associated with this code"); | ||
| } | ||
|
|
||
| // Atomically consume the anon session (one-shot): null out anonRefreshToken | ||
| // on the CliAuthAttempt row so subsequent claim-anon-session calls cannot | ||
| // replay and re-retrieve the anon user's refresh token. | ||
| const consumed = await prisma.$queryRaw<{ id: string }[]>(Prisma.sql` | ||
| UPDATE ${sqlQuoteIdent(schema)}."CliAuthAttempt" | ||
| SET | ||
| "anonRefreshToken" = NULL, | ||
| "updatedAt" = NOW() | ||
| WHERE "tenancyId" = ${tenancy.id}::UUID | ||
| AND "id" = ${cliAuth.id}::UUID | ||
| AND "anonRefreshToken" = ${cliAuth.anonRefreshToken} | ||
| AND "refreshToken" IS NULL | ||
| AND "usedAt" IS NULL | ||
| AND "expiresAt" > NOW() | ||
| RETURNING "id" | ||
| `); | ||
|
|
||
| if (consumed.length === 0) { | ||
| throw new StatusError(400, "No anonymous session associated with this code"); | ||
| } | ||
|
|
||
| const accessToken = await generateAccessTokenFromRefreshTokenIfValid({ | ||
| tenancy, | ||
| refreshTokenObj: cliAnonymousSession.refreshTokenObj, | ||
| }); | ||
|
|
||
| if (!accessToken) { | ||
| throw new StatusError(400, "Anonymous session is no longer valid"); | ||
| } | ||
|
|
||
| return cliAuthCompleteClaimResponse(accessToken, cliAnonymousSession.refreshTokenObj.refreshToken); | ||
| } | ||
|
|
||
| // Update with refresh token | ||
| await prisma.cliAuthAttempt.update({ | ||
| where: { | ||
| tenancyId_id: { | ||
| tenancyId: tenancy.id, | ||
| id: cliAuth.id, | ||
| }, | ||
| }, | ||
| data: { | ||
| refreshToken: refresh_token, | ||
| }, | ||
| }); | ||
| if (!refresh_token) { | ||
| throw new StatusError(400, "refresh_token is required when mode is 'complete'"); | ||
| } | ||
|
mantrakp04 marked this conversation as resolved.
|
||
|
|
||
| const browserRefreshTokenSession = await getRefreshTokenSession(tenancy.id, refresh_token); | ||
| if (!browserRefreshTokenSession) { | ||
| throw new StatusError(400, "Invalid refresh token"); | ||
| } | ||
|
|
||
| // Atomically claim the pending CLI auth attempt. Any anonymous session | ||
| // attached to this attempt is intentionally ignored — we do NOT merge | ||
| // the anonymous user into the authenticated user (that was a security risk). | ||
| // The anonymous user is left untouched and will simply be orphaned from | ||
| // this CLI flow. | ||
| const claimed = await prisma.$queryRaw<{ id: string }[]>(Prisma.sql` | ||
| UPDATE ${sqlQuoteIdent(schema)}."CliAuthAttempt" | ||
| SET | ||
| "refreshToken" = ${refresh_token}, | ||
| "anonRefreshToken" = NULL, | ||
| "updatedAt" = NOW() | ||
| WHERE "tenancyId" = ${tenancy.id}::UUID | ||
| AND "id" = ${cliAuth.id}::UUID | ||
| AND "refreshToken" IS NULL | ||
| AND "usedAt" IS NULL | ||
| AND "expiresAt" > NOW() | ||
| RETURNING "id" | ||
| `); | ||
|
|
||
| if (claimed.length === 0) { | ||
| throw new StatusError(400, "Invalid login code or the code has expired"); | ||
| } | ||
|
|
||
| return { | ||
| statusCode: 200, | ||
| bodyType: "success", | ||
| }; | ||
| return cliAuthCompleteSuccessResponse(); | ||
| }, | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.