Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
feat: add anonRefreshToken to CLI auth flow and enhance session manag…
…ement

- Updated the CliAuthAttempt model to include anonRefreshToken.
- Added migration to support the new anonRefreshToken field.
- Enhanced CLI authentication routes to handle anonymous user sessions, including validation and merging of anonymous user data into authenticated sessions.
- Updated tests to cover new functionality for anonymous sessions and refresh token handling.
  • Loading branch information
mantrakp04 committed Apr 1, 2026
commit c44075cae7a7bff88879b07b9f307ed7041fc346
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE "CliAuthAttempt"
ADD COLUMN "anonRefreshToken" TEXT;
11 changes: 6 additions & 5 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -1057,11 +1057,12 @@ model CliAuthAttempt {
tenancyId String @db.Uuid

id String @default(uuid()) @db.Uuid
pollingCode String @unique
loginCode String @unique
refreshToken String?
expiresAt DateTime
usedAt DateTime?
pollingCode String @unique
loginCode String @unique
refreshToken String?
anonRefreshToken String?
expiresAt DateTime
usedAt DateTime?

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Expand Down
204 changes: 176 additions & 28 deletions apps/backend/src/app/api/latest/auth/cli/complete/route.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,93 @@
import { getPrismaClientForTenancy } from "@/prisma-client";
import { generateAccessTokenFromRefreshTokenIfValid } from "@/lib/tokens";
import { mergeAnonymousUserIntoAuthenticatedUser } from "@/lib/user-merge";
import { globalPrismaClient, getPrismaClientForTenancy, retryTransaction } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { adaptSchema, clientOrHigherAuthTypeSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";

type CliSessionState = "anonymous" | "none";

async function getPendingCliAuthAttempt(tenancyId: string, loginCode: string) {
const cliAuth = await globalPrismaClient.cliAuthAttempt.findUnique({
where: {
loginCode,
},
});

if (!cliAuth) {
throw new StatusError(400, "Invalid login code or the code has expired");
}

if (cliAuth.tenancyId !== tenancyId) {
throw new StatusError(400, "Project ID mismatch; please ensure that you are using the correct app url.");
}

if (cliAuth.refreshToken !== null || cliAuth.usedAt !== null || cliAuth.expiresAt < new Date()) {
throw new StatusError(400, "Invalid login code or the code has expired");
}
Comment thread
mantrakp04 marked this conversation as resolved.

return cliAuth;
}

async function getRefreshTokenSession(tenancyId: string, refreshToken: string) {
const refreshTokenObj = await globalPrismaClient.projectUserRefreshToken.findUnique({
where: {
refreshToken,
},
});

if (!refreshTokenObj) {
return null;
}

if (refreshTokenObj.tenancyId !== tenancyId) {
throw new StatusError(400, "Refresh token does not belong to this project");
}

if (refreshTokenObj.expiresAt !== null && refreshTokenObj.expiresAt < new Date()) {
return null;
}

return refreshTokenObj;
}

async function getCliAnonymousSession(tenancyId: string, anonRefreshToken: string | null) {
if (anonRefreshToken === null) {
return null;
}

const refreshTokenObj = await getRefreshTokenSession(tenancyId, anonRefreshToken);
if (!refreshTokenObj) {
return null;
}

const user = await globalPrismaClient.projectUser.findUnique({
where: {
tenancyId_projectUserId: {
tenancyId,
projectUserId: refreshTokenObj.projectUserId,
},
},
select: {
projectUserId: true,
isAnonymous: true,
},
});

if (!user?.isAnonymous) {
return null;
}

return {
refreshTokenObj,
userId: user.projectUserId,
};
}

export const POST = createSmartRouteHandler({
metadata: {
summary: "Complete CLI authentication",
description: "Set the refresh token for a CLI authentication session using the login code",
description: "Inspect, claim, or complete a CLI authentication session",
tags: ["CLI Authentication"],
},
request: yupObject({
Expand All @@ -16,51 +97,118 @@ export const POST = createSmartRouteHandler({
}).defined(),
body: yupObject({
login_code: yupString().defined(),
refresh_token: yupString().defined(),
mode: yupString().oneOf(["check", "claim-anon-session", "complete"]).default("complete"),
refresh_token: yupString().optional(),
}).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["success"]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
success: yupBoolean().optional(),
cli_session_state: yupString().oneOf(["anonymous", "none"]).optional(),
access_token: yupString().optional(),
refresh_token: yupString().optional(),
Comment thread
mantrakp04 marked this conversation as resolved.
Outdated
}).defined(),
}),
async handler({ auth: { tenancy }, body: { login_code, refresh_token } }) {
async handler({ auth: { tenancy }, body: { login_code, mode, refresh_token } }) {
const prisma = await getPrismaClientForTenancy(tenancy);
const cliAuth = await getPendingCliAuthAttempt(tenancy.id, login_code);
const cliAnonymousSession = await getCliAnonymousSession(tenancy.id, cliAuth.anonRefreshToken);

// Find the CLI auth attempt
const cliAuth = await prisma.cliAuthAttempt.findUnique({
where: {
loginCode: login_code,
refreshToken: null,
expiresAt: {
gt: new Date(),
if (mode === "check") {
const cliSessionState: CliSessionState = cliAnonymousSession ? "anonymous" : "none";

return {
statusCode: 200,
bodyType: "json" as const,
body: {
cli_session_state: cliSessionState,
},
},
});
};
}

if (mode === "claim-anon-session") {
if (!cliAnonymousSession) {
throw new StatusError(400, "No anonymous session associated with this code");
}

const accessToken = await generateAccessTokenFromRefreshTokenIfValid({
tenancy,
refreshTokenObj: cliAnonymousSession.refreshTokenObj,
});

if (!accessToken) {
throw new StatusError(400, "Anonymous session is no longer valid");
}

return {
statusCode: 200,
bodyType: "json" as const,
body: {
access_token: accessToken,
refresh_token: cliAnonymousSession.refreshTokenObj.refreshToken,
},
};
}

if (!cliAuth) {
throw new StatusError(400, "Invalid login code or the code has expired");
if (!refresh_token) {
throw new StatusError(400, "refresh_token is required when mode is 'complete'");
}
Comment thread
mantrakp04 marked this conversation as resolved.

if (cliAuth.tenancyId !== tenancy.id) {
throw new StatusError(400, "Project ID mismatch; please ensure that you are using the correct app url.");
const browserRefreshTokenSession = await getRefreshTokenSession(tenancy.id, refresh_token);
if (!browserRefreshTokenSession) {
throw new StatusError(400, "Invalid refresh token");
}

// Update with refresh token
await prisma.cliAuthAttempt.update({
where: {
tenancyId_id: {
await retryTransaction(prisma, async (tx) => {
// Re-verify the CLI auth attempt is still pending inside the transaction
// to prevent race conditions with concurrent complete requests
const freshCliAuth = await tx.cliAuthAttempt.findFirst({
where: {
tenancyId: tenancy.id,
id: cliAuth.id,
refreshToken: null,
usedAt: null,
expiresAt: { gt: new Date() },
},
},
data: {
refreshToken: refresh_token,
},
});

if (!freshCliAuth) {
throw new StatusError(400, "Invalid login code or the code has expired");
}

if (
cliAnonymousSession
&& cliAnonymousSession.userId !== browserRefreshTokenSession.projectUserId
) {
await mergeAnonymousUserIntoAuthenticatedUser(tx, {
tenancy,
anonymousUserId: cliAnonymousSession.userId,
authenticatedUserId: browserRefreshTokenSession.projectUserId,
});
}

await tx.cliAuthAttempt.update({
where: {
tenancyId_id: {
tenancyId: tenancy.id,
id: freshCliAuth.id,
},
},
data: {
refreshToken: refresh_token,
anonRefreshToken: null,
},
});
});

return {
statusCode: 200,
bodyType: "success",
bodyType: "json" as const,
body: {
success: true,
},
};
},
});
45 changes: 42 additions & 3 deletions apps/backend/src/app/api/latest/auth/cli/route.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { getPrismaClientForTenancy } from "@/prisma-client";
import { globalPrismaClient, getPrismaClientForTenancy } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { adaptSchema, clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";

export const POST = createSmartRouteHandler({
metadata: {
Expand All @@ -16,6 +17,7 @@ export const POST = createSmartRouteHandler({
}).defined(),
body: yupObject({
expires_in_millis: yupNumber().max(1000 * 60 * 60 * 24).default(1000 * 60 * 120), // Default: 2 hours, max: 24 hours
anon_refresh_token: yupString().optional(),
Comment thread
mantrakp04 marked this conversation as resolved.
}).default({}),
}),
response: yupObject({
Expand All @@ -27,19 +29,56 @@ export const POST = createSmartRouteHandler({
expires_at: yupString().defined(),
}).defined(),
}),
async handler({ auth: { tenancy }, body: { expires_in_millis } }) {
async handler({ auth: { tenancy }, body: { expires_in_millis, anon_refresh_token } }) {
let anonRefreshToken: string | null = null;

if (anon_refresh_token) {
const refreshTokenObj = await globalPrismaClient.projectUserRefreshToken.findUnique({
where: {
refreshToken: anon_refresh_token,
},
});

if (!refreshTokenObj) {
throw new StatusError(400, "Invalid anon refresh token");
}

if (refreshTokenObj.tenancyId !== tenancy.id) {
throw new StatusError(400, "Anon refresh token does not belong to this project");
}

if (refreshTokenObj.expiresAt && refreshTokenObj.expiresAt < new Date()) {
throw new StatusError(400, "The provided anon refresh token has expired");
}

const user = await globalPrismaClient.projectUser.findUnique({
where: {
tenancyId_projectUserId: {
tenancyId: tenancy.id,
projectUserId: refreshTokenObj.projectUserId,
},
},
});

if (!user?.isAnonymous) {
throw new StatusError(400, "The provided refresh token does not belong to an anonymous user");
Comment thread
mantrakp04 marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

anonRefreshToken = anon_refresh_token;
}

const pollingCode = generateSecureRandomString();
const loginCode = generateSecureRandomString();
const expiresAt = new Date(Date.now() + expires_in_millis);

// Create a new CLI auth attempt
const prisma = await getPrismaClientForTenancy(tenancy);
const cliAuth = await prisma.cliAuthAttempt.create({
data: {
tenancyId: tenancy.id,
pollingCode,
loginCode,
expiresAt,
anonRefreshToken,
},
});

Expand Down
Loading
Loading