From 261d8923d41edad8b08b72f3d5a1e389977442d3 Mon Sep 17 00:00:00 2001 From: Mantra <87142457+mantrakp04@users.noreply.github.com> Date: Fri, 8 May 2026 11:00:03 -0700 Subject: [PATCH] stack-cli: support self-hosted URLs and tighten CLI auth polling (#1419) ## Summary - **Self-hosted CLI**: read `STACK_API_URL` / `STACK_DASHBOARD_URL` from env in `stack-cli` so the published CLI can talk to self-hosted Stack Auth installs without a custom build. The existing `STACK_CLI_PUBLISHABLE_CLIENT_KEY` override is kept as-is. - **Docker example**: surface the three CLI-relevant vars in `docker/server/.env.example` so self-host operators see them. - **Tighter polling-code TTL**: default `2h -> 2min`, max `24h -> 15min` for the CLI auth polling code. The code is only valid while a user is actively waiting in `stack login`, so a tight window limits the blast radius of a leaked code. - **Raw-SQL poll handler**: convert `apps/backend/src/app/api/latest/auth/cli/poll/route.tsx` from `prisma.cliAuthAttempt.*` to raw SQL targeted at the tenancy source-of-truth schema, matching the pattern already used by the initiate handler in `apps/backend/src/app/api/latest/auth/cli/route.tsx`. ## Test plan - [ ] `pnpm typecheck` - [ ] `pnpm lint` - [ ] `pnpm test run` (focus on CLI-auth tests if any) - [ ] Manual: `stack login` against a local backend - polling code now expires after ~2 minutes by default - `waiting` / `success` / `used` / `expired` branches still return correct status codes and bodies - [ ] Manual: published `stack-cli` against a self-hosted backend with `STACK_API_URL` / `STACK_DASHBOARD_URL` set, end-to-end login Made with [Cursor](https://cursor.com) ## Summary by CodeRabbit * **Improvements** * More robust CLI authentication polling with atomic database updates to prevent races; returns explicit statuses (waiting/expired/used/success) and provides the refresh token on success. * **Changes** * Default CLI auth token TTL reduced to 2 minutes and capped at 15 minutes. * Anonymous refresh token is considered present only when not null; null expiry is treated as not-expired. --------- Co-authored-by: Cursor --- .../app/api/latest/auth/cli/poll/route.tsx | 67 ++++++++++++------- .../src/app/api/latest/auth/cli/route.tsx | 7 +- 2 files changed, 46 insertions(+), 28 deletions(-) diff --git a/apps/backend/src/app/api/latest/auth/cli/poll/route.tsx b/apps/backend/src/app/api/latest/auth/cli/poll/route.tsx index 90c5b41af..387c196cf 100644 --- a/apps/backend/src/app/api/latest/auth/cli/poll/route.tsx +++ b/apps/backend/src/app/api/latest/auth/cli/poll/route.tsx @@ -1,8 +1,16 @@ -import { getPrismaClientForTenancy } from "@/prisma-client"; +import { Prisma } from "@/generated/prisma/client"; +import { getPrismaClientForTenancy, getPrismaSchemaForTenancy, sqlQuoteIdent } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared"; import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +type CliAuthAttemptRow = { + id: string, + refreshToken: string | null, + expiresAt: Date, + usedAt: Date | null, +}; + // Helper function to create response const createResponse = (status: 'waiting' | 'success' | 'expired' | 'used', refreshToken?: string) => ({ statusCode: status === 'success' ? 201 : 200, @@ -38,44 +46,55 @@ export const POST = createSmartRouteHandler({ }), async handler({ auth: { tenancy }, body: { polling_code } }) { const prisma = await getPrismaClientForTenancy(tenancy); + const schema = await getPrismaSchemaForTenancy(tenancy); - // Find the CLI auth attempt - const cliAuth = await prisma.cliAuthAttempt.findFirst({ - where: { - tenancyId: tenancy.id, - pollingCode: polling_code, - }, - }); + const cliAuthRows = await prisma.$queryRaw(Prisma.sql` + SELECT + "id", + "refreshToken", + "expiresAt", + "usedAt" + FROM ${sqlQuoteIdent(schema)}."CliAuthAttempt" + WHERE "tenancyId" = ${tenancy.id}::UUID + AND "pollingCode" = ${polling_code} + LIMIT 1 + `); - if (!cliAuth) { + if (cliAuthRows.length === 0) { throw new KnownErrors.InvalidPollingCodeError(); } + const cliAuth = cliAuthRows[0]; if (cliAuth.expiresAt < new Date()) { return createResponse('expired'); } - if (cliAuth.usedAt) { + if (cliAuth.usedAt !== null) { return createResponse('used'); } - if (!cliAuth.refreshToken) { + if (cliAuth.refreshToken === null) { return createResponse('waiting'); } - // Mark as used - await prisma.cliAuthAttempt.update({ - where: { - tenancyId_id: { - tenancyId: tenancy.id, - id: cliAuth.id, - }, - }, - data: { - usedAt: new Date(), - }, - }); + // Atomically mark as used, claiming the row only if no one else has. + // This prevents a TOCTOU race where two concurrent polls could both + // read usedAt = null and both receive the same refresh token. + const claimed = await prisma.$queryRaw<{ refreshToken: string }[]>(Prisma.sql` + UPDATE ${sqlQuoteIdent(schema)}."CliAuthAttempt" + SET + "usedAt" = NOW(), + "updatedAt" = NOW() + WHERE "tenancyId" = ${tenancy.id}::UUID + AND "id" = ${cliAuth.id}::UUID + AND "usedAt" IS NULL + RETURNING "refreshToken" + `); - return createResponse('success', cliAuth.refreshToken); + if (claimed.length === 0) { + return createResponse('used'); + } + + return createResponse('success', claimed[0].refreshToken); }, }); diff --git a/apps/backend/src/app/api/latest/auth/cli/route.tsx b/apps/backend/src/app/api/latest/auth/cli/route.tsx index fb869535f..5d122c11d 100644 --- a/apps/backend/src/app/api/latest/auth/cli/route.tsx +++ b/apps/backend/src/app/api/latest/auth/cli/route.tsx @@ -25,7 +25,7 @@ export const POST = createSmartRouteHandler({ tenancy: adaptSchema.defined(), }).defined(), body: yupObject({ - expires_in_millis: yupNumber().max(1000 * 60 * 60 * 24).default(1000 * 60 * 120), // Default: 2 hours, max: 24 hours + expires_in_millis: yupNumber().max(1000 * 60 * 15).default(1000 * 60 * 2), // Default: 2 minutes, max: 15 minutes anon_refresh_token: yupString().optional(), }).default({}), }), @@ -41,8 +41,7 @@ export const POST = createSmartRouteHandler({ async handler({ auth: { tenancy }, body: { expires_in_millis, anon_refresh_token } }) { let anonRefreshToken: string | null = null; - if (anon_refresh_token) { - // ProjectUserRefreshToken lives in the global DB (see tokens.tsx and oauth/model.tsx). + if (anon_refresh_token != null) { const refreshTokenRows = await globalPrismaClient.$queryRaw(Prisma.sql` SELECT "tenancyId", "projectUserId", "expiresAt" FROM "ProjectUserRefreshToken" @@ -58,7 +57,7 @@ export const POST = createSmartRouteHandler({ throw new StatusError(400, "Anon refresh token does not belong to this project"); } - if (refreshTokenObj.expiresAt && refreshTokenObj.expiresAt < new Date()) { + if (refreshTokenObj.expiresAt != null && refreshTokenObj.expiresAt < new Date()) { throw new StatusError(400, "The provided anon refresh token has expired"); }