mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
Merge branch 'dev' into promptless/document-dashboard-preview-mode
This commit is contained in:
commit
33f0ae14fd
@ -248,31 +248,32 @@ export async function generateAccessTokenFromRefreshTokenIfValid(options: Refres
|
||||
// Get end user IP info for session tracking and event logging
|
||||
const ipInfo = await getEndUserIpInfoForEvent();
|
||||
|
||||
await Promise.all([
|
||||
prisma.projectUser.update({
|
||||
where: {
|
||||
tenancyId_projectUserId: {
|
||||
tenancyId: options.tenancy.id,
|
||||
projectUserId: options.refreshTokenObj.projectUserId,
|
||||
},
|
||||
},
|
||||
data: withExternalDbSyncUpdate({
|
||||
lastActiveAt: now,
|
||||
}),
|
||||
// updateMany (instead of update) so a concurrent sign-out / session revocation
|
||||
// that deletes the row between the caller's read and this write does not
|
||||
// surface as a P2025 500. Update the refresh-token row first so a revoked
|
||||
// session stops before touching projectUser.lastActiveAt.
|
||||
const refreshTokenUpdate = await globalPrismaClient.projectUserRefreshToken.updateMany({
|
||||
where: {
|
||||
tenancyId: options.tenancy.id,
|
||||
id: options.refreshTokenObj.id,
|
||||
},
|
||||
data: withExternalDbSyncUpdate({
|
||||
lastActiveAt: now,
|
||||
lastActiveAtIpInfo: ipInfo ?? undefined,
|
||||
}),
|
||||
globalPrismaClient.projectUserRefreshToken.update({
|
||||
where: {
|
||||
tenancyId_id: {
|
||||
tenancyId: options.tenancy.id,
|
||||
id: options.refreshTokenObj.id,
|
||||
},
|
||||
},
|
||||
data: withExternalDbSyncUpdate({
|
||||
lastActiveAt: now,
|
||||
lastActiveAtIpInfo: ipInfo ?? undefined,
|
||||
}),
|
||||
});
|
||||
if (refreshTokenUpdate.count === 0) return null;
|
||||
|
||||
const projectUserUpdate = await prisma.projectUser.updateMany({
|
||||
where: {
|
||||
tenancyId: options.tenancy.id,
|
||||
projectUserId: options.refreshTokenObj.projectUserId,
|
||||
},
|
||||
data: withExternalDbSyncUpdate({
|
||||
lastActiveAt: now,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
if (projectUserUpdate.count === 0) return null;
|
||||
|
||||
// Log session activity event (used for metrics, geo info, etc.)
|
||||
await logEvent(
|
||||
|
||||
@ -9,7 +9,7 @@ import { createRefreshTokenObj, decodeAccessToken, generateAccessTokenFromRefres
|
||||
import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client";
|
||||
import { AuthorizationCode, AuthorizationCodeModel, Client, Falsey, RefreshToken, Token, User } from "@node-oauth/oauth2-server";
|
||||
import { KnownErrors } from "@stackframe/stack-shared";
|
||||
import { StackAssertionError, StatusError, captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { StackAssertionError, StatusError, captureError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { getProjectBranchFromClientId } from ".";
|
||||
const PrismaClientKnownRequestError = Prisma.PrismaClientKnownRequestError;
|
||||
|
||||
@ -105,10 +105,16 @@ export class OAuthModel implements AuthorizationCodeModel {
|
||||
|
||||
const refreshTokenObj = await this._getOrCreateRefreshTokenObj(client, user, scope);
|
||||
|
||||
return await generateAccessTokenFromRefreshTokenIfValid({
|
||||
const accessToken = await generateAccessTokenFromRefreshTokenIfValid({
|
||||
tenancy,
|
||||
refreshTokenObj,
|
||||
}) ?? 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
|
||||
});
|
||||
if (!accessToken) {
|
||||
// Either the refresh token became invalid between _getOrCreateRefreshTokenObj and now
|
||||
// (e.g. a concurrent sign-out deleted the row), or the user was deleted mid-flight.
|
||||
throw new KnownErrors.RefreshTokenNotFoundOrExpired();
|
||||
}
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
async _getOrCreateRefreshTokenObj(client: Client, user: User, scope: string[]) {
|
||||
|
||||
@ -0,0 +1,116 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { generatedEmailSuffix, it } from "../../../../../../../helpers";
|
||||
import { Auth, backendContext, createMailbox, niceBackendFetch } from "../../../../../../backend-helpers";
|
||||
|
||||
type RaceFailure = {
|
||||
readonly request: "refresh" | "sign-out",
|
||||
readonly status?: number,
|
||||
readonly body?: unknown,
|
||||
readonly error?: unknown,
|
||||
};
|
||||
|
||||
function collectUnexpectedRaceResponseFailures(options: {
|
||||
readonly refreshResult: PromiseSettledResult<Awaited<ReturnType<typeof niceBackendFetch>>>,
|
||||
readonly signOutResult: PromiseSettledResult<Awaited<ReturnType<typeof niceBackendFetch>>>,
|
||||
}): RaceFailure[] {
|
||||
const failures: RaceFailure[] = [];
|
||||
const results: [RaceFailure["request"], PromiseSettledResult<Awaited<ReturnType<typeof niceBackendFetch>>>][] = [
|
||||
["refresh", options.refreshResult],
|
||||
["sign-out", options.signOutResult],
|
||||
];
|
||||
for (const [request, result] of results) {
|
||||
if (result.status === "rejected") {
|
||||
failures.push({ request, error: result.reason });
|
||||
continue;
|
||||
}
|
||||
if (result.value.status >= 500 || JSON.stringify(result.value.body).includes("P2025")) {
|
||||
failures.push({ request, status: result.value.status, body: result.value.body });
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
// Guards Sentry STACK-BACKEND-146:
|
||||
// PrismaClientKnownRequestError P2025 on projectUserRefreshToken.update()
|
||||
// caused by the refresh endpoint reading the token, then calling update()
|
||||
// after a concurrent sign-out has deleted the row.
|
||||
it("does not 500 when a refresh races with a sign-out of the same session", { timeout: 120_000 }, async ({ expect }) => {
|
||||
// Fire many refresh+signout pairs concurrently to hit the race window
|
||||
// between findFirst(refreshToken) and projectUserRefreshToken.update().
|
||||
const ATTEMPTS = 10;
|
||||
const failures: RaceFailure[] = [];
|
||||
|
||||
for (let i = 0; i < ATTEMPTS; i++) {
|
||||
backendContext.set({
|
||||
mailbox: createMailbox(`refresh-race--${randomUUID()}${generatedEmailSuffix}`),
|
||||
userAuth: null,
|
||||
});
|
||||
await Auth.Password.signUpWithEmail();
|
||||
const rt = backendContext.value.userAuth!.refreshToken!;
|
||||
|
||||
const refreshP = niceBackendFetch("/api/v1/auth/sessions/current/refresh", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
headers: { "x-stack-refresh-token": rt },
|
||||
});
|
||||
const signOutP = niceBackendFetch("/api/v1/auth/sessions/current", {
|
||||
method: "DELETE",
|
||||
accessType: "client",
|
||||
});
|
||||
|
||||
const [refreshResult, signOutResult] = await Promise.allSettled([refreshP, signOutP]);
|
||||
failures.push(...collectUnexpectedRaceResponseFailures({ refreshResult, signOutResult }));
|
||||
|
||||
// Acceptable outcomes:
|
||||
// 200 (refresh won the race)
|
||||
// 401 REFRESH_TOKEN_NOT_FOUND_OR_EXPIRED (sign-out won cleanly)
|
||||
// Bug outcome: 500 with Prisma P2025 bubbling out as an unhandled error.
|
||||
if (refreshResult.status === "fulfilled" && refreshResult.value.status !== 200 && refreshResult.value.status !== 401) {
|
||||
failures.push({ request: "refresh", status: refreshResult.value.status, body: refreshResult.value.body });
|
||||
}
|
||||
}
|
||||
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not 500 when an OAuth refresh-token grant races with a sign-out of the same session", { timeout: 120_000 }, async ({ expect }) => {
|
||||
// The OAuth token endpoint uses the same refresh-token helper as the direct
|
||||
// session refresh endpoint, so keep this regression covered on both callers.
|
||||
const ATTEMPTS = 10;
|
||||
const failures: RaceFailure[] = [];
|
||||
|
||||
for (let i = 0; i < ATTEMPTS; i++) {
|
||||
backendContext.set({
|
||||
mailbox: createMailbox(`oauth-refresh-race--${randomUUID()}${generatedEmailSuffix}`),
|
||||
userAuth: null,
|
||||
});
|
||||
await Auth.Password.signUpWithEmail();
|
||||
const rt = backendContext.value.userAuth!.refreshToken!;
|
||||
const projectKeys = backendContext.value.projectKeys;
|
||||
if (projectKeys === "no-project") throw new Error("No project keys found in the backend context");
|
||||
|
||||
const refreshP = niceBackendFetch("/api/v1/auth/oauth/token", {
|
||||
method: "POST",
|
||||
accessType: "client",
|
||||
body: {
|
||||
grant_type: "refresh_token",
|
||||
client_id: projectKeys.projectId,
|
||||
client_secret: projectKeys.publishableClientKey,
|
||||
refresh_token: rt,
|
||||
},
|
||||
});
|
||||
const signOutP = niceBackendFetch("/api/v1/auth/sessions/current", {
|
||||
method: "DELETE",
|
||||
accessType: "client",
|
||||
});
|
||||
|
||||
const [refreshResult, signOutResult] = await Promise.allSettled([refreshP, signOutP]);
|
||||
failures.push(...collectUnexpectedRaceResponseFailures({ refreshResult, signOutResult }));
|
||||
|
||||
if (refreshResult.status === "fulfilled" && refreshResult.value.status !== 200 && refreshResult.value.status !== 401) {
|
||||
failures.push({ request: "refresh", status: refreshResult.value.status, body: refreshResult.value.body });
|
||||
}
|
||||
}
|
||||
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user