mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
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:
parent
68ae6d1f1c
commit
261d8923d4
@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user