Skip to content

Commit 261d892

Browse files
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>
1 parent 68ae6d1 commit 261d892

2 files changed

Lines changed: 46 additions & 28 deletions

File tree

apps/backend/src/app/api/latest/auth/cli/poll/route.tsx

Lines changed: 43 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1-
import { getPrismaClientForTenancy } from "@/prisma-client";
1+
import { Prisma } from "@/generated/prisma/client";
2+
import { getPrismaClientForTenancy, getPrismaSchemaForTenancy, sqlQuoteIdent } from "@/prisma-client";
23
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
34
import { KnownErrors } from "@stackframe/stack-shared";
45
import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
56

7+
type CliAuthAttemptRow = {
8+
id: string,
9+
refreshToken: string | null,
10+
expiresAt: Date,
11+
usedAt: Date | null,
12+
};
13+
614
// Helper function to create response
715
const createResponse = (status: 'waiting' | 'success' | 'expired' | 'used', refreshToken?: string) => ({
816
statusCode: status === 'success' ? 201 : 200,
@@ -38,44 +46,55 @@ export const POST = createSmartRouteHandler({
3846
}),
3947
async handler({ auth: { tenancy }, body: { polling_code } }) {
4048
const prisma = await getPrismaClientForTenancy(tenancy);
49+
const schema = await getPrismaSchemaForTenancy(tenancy);
4150

42-
// Find the CLI auth attempt
43-
const cliAuth = await prisma.cliAuthAttempt.findFirst({
44-
where: {
45-
tenancyId: tenancy.id,
46-
pollingCode: polling_code,
47-
},
48-
});
51+
const cliAuthRows = await prisma.$queryRaw<CliAuthAttemptRow[]>(Prisma.sql`
52+
SELECT
53+
"id",
54+
"refreshToken",
55+
"expiresAt",
56+
"usedAt"
57+
FROM ${sqlQuoteIdent(schema)}."CliAuthAttempt"
58+
WHERE "tenancyId" = ${tenancy.id}::UUID
59+
AND "pollingCode" = ${polling_code}
60+
LIMIT 1
61+
`);
4962

50-
if (!cliAuth) {
63+
if (cliAuthRows.length === 0) {
5164
throw new KnownErrors.InvalidPollingCodeError();
5265
}
66+
const cliAuth = cliAuthRows[0];
5367

5468
if (cliAuth.expiresAt < new Date()) {
5569
return createResponse('expired');
5670
}
5771

58-
if (cliAuth.usedAt) {
72+
if (cliAuth.usedAt !== null) {
5973
return createResponse('used');
6074
}
6175

62-
if (!cliAuth.refreshToken) {
76+
if (cliAuth.refreshToken === null) {
6377
return createResponse('waiting');
6478
}
6579

66-
// Mark as used
67-
await prisma.cliAuthAttempt.update({
68-
where: {
69-
tenancyId_id: {
70-
tenancyId: tenancy.id,
71-
id: cliAuth.id,
72-
},
73-
},
74-
data: {
75-
usedAt: new Date(),
76-
},
77-
});
80+
// Atomically mark as used, claiming the row only if no one else has.
81+
// This prevents a TOCTOU race where two concurrent polls could both
82+
// read usedAt = null and both receive the same refresh token.
83+
const claimed = await prisma.$queryRaw<{ refreshToken: string }[]>(Prisma.sql`
84+
UPDATE ${sqlQuoteIdent(schema)}."CliAuthAttempt"
85+
SET
86+
"usedAt" = NOW(),
87+
"updatedAt" = NOW()
88+
WHERE "tenancyId" = ${tenancy.id}::UUID
89+
AND "id" = ${cliAuth.id}::UUID
90+
AND "usedAt" IS NULL
91+
RETURNING "refreshToken"
92+
`);
93+
94+
if (claimed.length === 0) {
95+
return createResponse('used');
96+
}
7897

79-
return createResponse('success', cliAuth.refreshToken);
98+
return createResponse('success', claimed[0].refreshToken);
8099
},
81100
});

apps/backend/src/app/api/latest/auth/cli/route.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export const POST = createSmartRouteHandler({
2525
tenancy: adaptSchema.defined(),
2626
}).defined(),
2727
body: yupObject({
28-
expires_in_millis: yupNumber().max(1000 * 60 * 60 * 24).default(1000 * 60 * 120), // Default: 2 hours, max: 24 hours
28+
expires_in_millis: yupNumber().max(1000 * 60 * 15).default(1000 * 60 * 2), // Default: 2 minutes, max: 15 minutes
2929
anon_refresh_token: yupString().optional(),
3030
}).default({}),
3131
}),
@@ -41,8 +41,7 @@ export const POST = createSmartRouteHandler({
4141
async handler({ auth: { tenancy }, body: { expires_in_millis, anon_refresh_token } }) {
4242
let anonRefreshToken: string | null = null;
4343

44-
if (anon_refresh_token) {
45-
// ProjectUserRefreshToken lives in the global DB (see tokens.tsx and oauth/model.tsx).
44+
if (anon_refresh_token != null) {
4645
const refreshTokenRows = await globalPrismaClient.$queryRaw<RefreshTokenRow[]>(Prisma.sql`
4746
SELECT "tenancyId", "projectUserId", "expiresAt"
4847
FROM "ProjectUserRefreshToken"
@@ -58,7 +57,7 @@ export const POST = createSmartRouteHandler({
5857
throw new StatusError(400, "Anon refresh token does not belong to this project");
5958
}
6059

61-
if (refreshTokenObj.expiresAt && refreshTokenObj.expiresAt < new Date()) {
60+
if (refreshTokenObj.expiresAt != null && refreshTokenObj.expiresAt < new Date()) {
6261
throw new StatusError(400, "The provided anon refresh token has expired");
6362
}
6463

0 commit comments

Comments
 (0)