Skip to content

Commit 22678b9

Browse files
committed
fix: refresh-token P2025 race with concurrent sign-out
`generateAccessTokenFromRefreshTokenIfValid` read the refresh-token row upstream and then issued `.update()` on it (plus on `projectUser`). If a concurrent sign-out / session revoke / password change / user delete removed the row between the read and the update, Prisma threw P2025 and the request 500'd (Sentry STACK-BACKEND-146). Switch the two updates to `updateMany` so a missing row is a no-op, then re-check the refresh-token row exists and return null if it doesn't — the refresh route already maps null to RefreshTokenNotFoundOrExpired (401). On the OAuth refresh_token grant path, replace the "ultra-rare race condition" throwErr with the same KnownError so it returns 401 too instead of 500. Adds a regression test that concurrently refreshes and signs out the same session; before the fix it 500s on the first iteration. Fixes https://stackframe-pw.sentry.io/issues/7377768662/
1 parent 0006346 commit 22678b9

3 files changed

Lines changed: 82 additions & 13 deletions

File tree

apps/backend/src/lib/tokens.tsx

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -248,24 +248,24 @@ export async function generateAccessTokenFromRefreshTokenIfValid(options: Refres
248248
// Get end user IP info for session tracking and event logging
249249
const ipInfo = await getEndUserIpInfoForEvent();
250250

251+
// updateMany (instead of update) so a concurrent sign-out / session revocation
252+
// that deletes the row between the caller's read and this write does not
253+
// surface as a P2025 500. We re-check existence below and return null if the
254+
// token was deleted, which the refresh route maps to RefreshTokenNotFoundOrExpired.
251255
await Promise.all([
252-
prisma.projectUser.update({
256+
prisma.projectUser.updateMany({
253257
where: {
254-
tenancyId_projectUserId: {
255-
tenancyId: options.tenancy.id,
256-
projectUserId: options.refreshTokenObj.projectUserId,
257-
},
258+
tenancyId: options.tenancy.id,
259+
projectUserId: options.refreshTokenObj.projectUserId,
258260
},
259261
data: withExternalDbSyncUpdate({
260262
lastActiveAt: now,
261263
}),
262264
}),
263-
globalPrismaClient.projectUserRefreshToken.update({
265+
globalPrismaClient.projectUserRefreshToken.updateMany({
264266
where: {
265-
tenancyId_id: {
266-
tenancyId: options.tenancy.id,
267-
id: options.refreshTokenObj.id,
268-
},
267+
tenancyId: options.tenancy.id,
268+
id: options.refreshTokenObj.id,
269269
},
270270
data: withExternalDbSyncUpdate({
271271
lastActiveAt: now,
@@ -274,6 +274,17 @@ export async function generateAccessTokenFromRefreshTokenIfValid(options: Refres
274274
}),
275275
]);
276276

277+
const stillExists = await globalPrismaClient.projectUserRefreshToken.findUnique({
278+
where: {
279+
tenancyId_id: {
280+
tenancyId: options.tenancy.id,
281+
id: options.refreshTokenObj.id,
282+
},
283+
},
284+
select: { id: true },
285+
});
286+
if (!stillExists) return null;
287+
277288
// Log session activity event (used for metrics, geo info, etc.)
278289
await logEvent(
279290
[SystemEventTypes.SessionActivity],

apps/backend/src/oauth/model.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { createRefreshTokenObj, decodeAccessToken, generateAccessTokenFromRefres
99
import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client";
1010
import { AuthorizationCode, AuthorizationCodeModel, Client, Falsey, RefreshToken, Token, User } from "@node-oauth/oauth2-server";
1111
import { KnownErrors } from "@stackframe/stack-shared";
12-
import { StackAssertionError, StatusError, captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
12+
import { StackAssertionError, StatusError, captureError } from "@stackframe/stack-shared/dist/utils/errors";
1313
import { getProjectBranchFromClientId } from ".";
1414
const PrismaClientKnownRequestError = Prisma.PrismaClientKnownRequestError;
1515

@@ -105,10 +105,16 @@ export class OAuthModel implements AuthorizationCodeModel {
105105

106106
const refreshTokenObj = await this._getOrCreateRefreshTokenObj(client, user, scope);
107107

108-
return await generateAccessTokenFromRefreshTokenIfValid({
108+
const accessToken = await generateAccessTokenFromRefreshTokenIfValid({
109109
tenancy,
110110
refreshTokenObj,
111-
}) ?? throwErr("Get or create refresh token failed; returned refreshTokenObj that's invalid (or maybe it's an ultra-rare race condition and it became invalid in since the function call?)", { refreshTokenObj }); // TODO fix the ultra-rare race condition — although unless we're at gigascale this should basically never happen
111+
});
112+
if (!accessToken) {
113+
// Either the refresh token became invalid between _getOrCreateRefreshTokenObj and now
114+
// (e.g. a concurrent sign-out deleted the row), or the user was deleted mid-flight.
115+
throw new KnownErrors.RefreshTokenNotFoundOrExpired();
116+
}
117+
return accessToken;
112118
}
113119

114120
async _getOrCreateRefreshTokenObj(client: Client, user: User, scope: string[]) {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { randomUUID } from "node:crypto";
2+
import { generatedEmailSuffix } from "../../../../../../../helpers";
3+
import { it } from "../../../../../../../helpers";
4+
import { Auth, backendContext, createMailbox, niceBackendFetch } from "../../../../../../backend-helpers";
5+
6+
// Reproduces Sentry STACK-BACKEND-146:
7+
// PrismaClientKnownRequestError P2025 on projectUserRefreshToken.update()
8+
// caused by the refresh endpoint reading the token, then calling update()
9+
// after a concurrent sign-out has deleted the row.
10+
it("reproduces P2025 when a refresh races with a sign-out of the same session", { timeout: 120_000 }, async ({ expect }) => {
11+
// Fire many refresh+signout pairs concurrently to hit the race window
12+
// between findFirst(refreshToken) and projectUserRefreshToken.update().
13+
const ATTEMPTS = 10;
14+
const crashes: any[] = [];
15+
16+
for (let i = 0; i < ATTEMPTS; i++) {
17+
backendContext.set({
18+
mailbox: createMailbox(`refresh-race--${randomUUID()}${generatedEmailSuffix}`),
19+
userAuth: null,
20+
});
21+
await Auth.Password.signUpWithEmail();
22+
const rt = backendContext.value.userAuth!.refreshToken!;
23+
24+
const refreshP = niceBackendFetch("/api/v1/auth/sessions/current/refresh", {
25+
method: "POST",
26+
accessType: "client",
27+
headers: { "x-stack-refresh-token": rt },
28+
});
29+
const signOutP = niceBackendFetch("/api/v1/auth/sessions/current", {
30+
method: "DELETE",
31+
accessType: "client",
32+
});
33+
34+
const [refreshRes] = await Promise.all([refreshP, signOutP]);
35+
36+
// Acceptable outcomes:
37+
// 200 (refresh won the race)
38+
// 401 REFRESH_TOKEN_NOT_FOUND_OR_EXPIRED (sign-out won cleanly)
39+
// Bug outcome: 500 with Prisma P2025 bubbling out as an unhandled error.
40+
if (refreshRes.status !== 200 && refreshRes.status !== 401) {
41+
crashes.push({ status: refreshRes.status, body: refreshRes.body });
42+
} else if (
43+
typeof refreshRes.body === "object" &&
44+
refreshRes.body !== null &&
45+
JSON.stringify(refreshRes.body).includes("P2025")
46+
) {
47+
crashes.push({ status: refreshRes.status, body: refreshRes.body });
48+
}
49+
}
50+
51+
expect(crashes).toEqual([]);
52+
});

0 commit comments

Comments
 (0)