Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 43 additions & 24 deletions apps/backend/src/app/api/latest/auth/cli/poll/route.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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"
`);
Comment thread
mantrakp04 marked this conversation as resolved.

if (claimed.length === 0) {
return createResponse('used');
}

return createResponse('success', cliAuth.refreshToken);
return createResponse('success', claimed[0].refreshToken);
},
});
7 changes: 3 additions & 4 deletions apps/backend/src/app/api/latest/auth/cli/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({}),
}),
Expand All @@ -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"
Expand All @@ -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");
}

Expand Down
Loading