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)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Mantra 2026-05-08 11:00:03 -07:00 committed by GitHub
parent 68ae6d1f1c
commit 261d8923d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 46 additions and 28 deletions

View File

@ -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<CliAuthAttemptRow[]>(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);
},
});

View File

@ -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<RefreshTokenRow[]>(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");
}