From e0025b286cc606986b647c86098e5dfa8b5aa9a0 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Wed, 6 May 2026 14:34:01 -0700 Subject: [PATCH 1/2] stack-cli: support self-hosted URLs and tighten CLI auth polling - 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. Surface the vars (and the existing STACK_CLI_PUBLISHABLE_CLIENT_KEY) in docker/server/.env.example. - Reduce the CLI auth polling-code TTL: default 2h -> 2min, max 24h -> 15min. The code is only used while a user is actively waiting in `stack login`, so a tight window limits the value of a leak. - Convert the CLI auth poll handler to raw SQL against the tenancy source-of-truth schema, matching the pattern already used by the initiate handler. Co-authored-by: Cursor --- .../app/api/latest/auth/cli/poll/route.tsx | 51 +++++++++++-------- .../src/app/api/latest/auth/cli/route.tsx | 3 +- docker/server/.env.example | 3 ++ packages/stack-cli/src/lib/auth.ts | 4 +- 4 files changed, 37 insertions(+), 24 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 90c5b41aff..d0275ae889 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,18 +46,24 @@ 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'); @@ -64,17 +78,14 @@ export const POST = createSmartRouteHandler({ } // Mark as used - await prisma.cliAuthAttempt.update({ - where: { - tenancyId_id: { - tenancyId: tenancy.id, - id: cliAuth.id, - }, - }, - data: { - usedAt: new Date(), - }, - }); + await prisma.$executeRaw(Prisma.sql` + UPDATE ${sqlQuoteIdent(schema)}."CliAuthAttempt" + SET + "usedAt" = NOW(), + "updatedAt" = NOW() + WHERE "tenancyId" = ${tenancy.id}::UUID + AND "id" = ${cliAuth.id}::UUID + `); return createResponse('success', cliAuth.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 fb869535f5..e8f357726e 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 mins anon_refresh_token: yupString().optional(), }).default({}), }), @@ -42,7 +42,6 @@ export const POST = createSmartRouteHandler({ let anonRefreshToken: string | null = null; if (anon_refresh_token) { - // ProjectUserRefreshToken lives in the global DB (see tokens.tsx and oauth/model.tsx). const refreshTokenRows = await globalPrismaClient.$queryRaw(Prisma.sql` SELECT "tenancyId", "projectUserId", "expiresAt" FROM "ProjectUserRefreshToken" diff --git a/docker/server/.env.example b/docker/server/.env.example index cbba7e67ce..bc8f1e8061 100644 --- a/docker/server/.env.example +++ b/docker/server/.env.example @@ -2,6 +2,9 @@ NEXT_PUBLIC_STACK_API_URL=http://localhost:8102 NEXT_PUBLIC_STACK_DASHBOARD_URL=http://localhost:8101 +STACK_API_URL=http://localhost:8102 +STACK_DASHBOARD_URL=http://localhost:8101 +STACK_CLI_PUBLISHABLE_CLIENT_KEY= STACK_DATABASE_CONNECTION_STRING=postgres://postgres:password@host.docker.internal:8128/stackframe diff --git a/packages/stack-cli/src/lib/auth.ts b/packages/stack-cli/src/lib/auth.ts index a2d2cc081b..dd5de6bd95 100644 --- a/packages/stack-cli/src/lib/auth.ts +++ b/packages/stack-cli/src/lib/auth.ts @@ -1,8 +1,8 @@ import { readConfigValue } from "./config.js"; import { AuthError } from "./errors.js"; -export const DEFAULT_API_URL = "https://api.stack-auth.com"; -export const DEFAULT_DASHBOARD_URL = "https://app.stack-auth.com"; +export const DEFAULT_API_URL = process.env.STACK_API_URL ?? "https://api.stack-auth.com"; +export const DEFAULT_DASHBOARD_URL = process.env.STACK_DASHBOARD_URL ?? "https://app.stack-auth.com"; export const DEFAULT_PUBLISHABLE_CLIENT_KEY = process.env.STACK_CLI_PUBLISHABLE_CLIENT_KEY ?? "pck_9bbqvqsbh0gdb6smk11d71qg4ktc4rz8ya7cc69yndm7g"; type Flags = { From 38a64ffd734fd19da679a91978ce230c2689d10c Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Wed, 6 May 2026 14:35:49 -0700 Subject: [PATCH 2/2] Update max expiration time for CLI auth polling from 15 minutes to 24 hours in route.tsx --- apps/backend/src/app/api/latest/auth/cli/route.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e8f357726e..6ddc28ecd6 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 * 15).default(1000 * 60 * 2), // Default: 2 minutes, max: 15 mins + expires_in_millis: yupNumber().max(1000 * 60 * 60 * 24).default(1000 * 60 * 2), // Default: 2 minutes, max: 24 hours anon_refresh_token: yupString().optional(), }).default({}), }),