This commit is contained in:
R R D Perera 2026-09-11 13:11:52 -04:00 committed by GitHub
commit bfb5ff89d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 196 additions and 17 deletions

View File

@ -0,0 +1,89 @@
import { SESSION_EXPIRED_MESSAGE } from "@/server/api/auth-constants";
import {
isSessionExpiredError,
shouldRedirectOnAuthError,
} from "@/utils/auth-error";
import { TRPCClientError } from "@trpc/client";
import { describe, expect, test } from "vitest";
const makeSessionExpiredError = () => {
const error = new TRPCClientError(SESSION_EXPIRED_MESSAGE);
(error as any).data = { code: "UNAUTHORIZED" };
return error;
};
// UNAUTHORIZED, but from a permission/role check on a still-valid session.
const makePermissionDeniedError = () => {
const error = new TRPCClientError("You don't have access to this project");
(error as any).data = { code: "UNAUTHORIZED" };
return error;
};
const makeForbiddenError = () => {
const error = new TRPCClientError("Forbidden");
(error as any).data = { code: "FORBIDDEN" };
return error;
};
describe("isSessionExpiredError", () => {
test("returns true for an UNAUTHORIZED error tagged as session-expired", () => {
expect(isSessionExpiredError(makeSessionExpiredError())).toBe(true);
});
// Regression for #4310: the API reuses UNAUTHORIZED for permission denials
// on authenticated users; those must NOT be treated as session expiry.
test("returns false for a permission-denied UNAUTHORIZED error", () => {
expect(isSessionExpiredError(makePermissionDeniedError())).toBe(false);
});
test("returns false for a different code", () => {
expect(isSessionExpiredError(makeForbiddenError())).toBe(false);
});
test("returns false for a plain Error / null / undefined", () => {
expect(isSessionExpiredError(new Error(SESSION_EXPIRED_MESSAGE))).toBe(false);
expect(isSessionExpiredError(null)).toBe(false);
expect(isSessionExpiredError(undefined)).toBe(false);
});
});
describe("shouldRedirectOnAuthError", () => {
test("returns false on public paths even with a session-expired error", () => {
const error = makeSessionExpiredError();
expect(shouldRedirectOnAuthError("/", error)).toBe(false);
expect(shouldRedirectOnAuthError("/register", error)).toBe(false);
expect(shouldRedirectOnAuthError("/invitation/abc", error)).toBe(false);
expect(shouldRedirectOnAuthError("/accept-invitation/abc", error)).toBe(
false,
);
expect(shouldRedirectOnAuthError("/reset-password", error)).toBe(false);
expect(shouldRedirectOnAuthError("/send-reset-password", error)).toBe(false);
});
test("returns true for a protected path with a session-expired error", () => {
expect(
shouldRedirectOnAuthError(
"/dashboard/project/abc",
makeSessionExpiredError(),
),
).toBe(true);
});
test("returns false for a protected path with a permission-denied error", () => {
expect(
shouldRedirectOnAuthError(
"/dashboard/project/abc",
makePermissionDeniedError(),
),
).toBe(false);
});
test("returns false for a protected path with a non-auth error", () => {
expect(
shouldRedirectOnAuthError("/dashboard/project/abc", makeForbiddenError()),
).toBe(false);
expect(
shouldRedirectOnAuthError("/dashboard/project/abc", new Error("nope")),
).toBe(false);
});
});

View File

@ -0,0 +1,12 @@
/**
* Sentinel message attached to UNAUTHORIZED errors that specifically mean
* "there is no valid session" (i.e. the session expired or is missing), as
* opposed to the many UNAUTHORIZED errors that represent an authenticated user
* lacking a role/resource permission.
*
* The client (utils/auth-error.ts) keys the "redirect to login" behaviour off
* this exact message so that permission denials do NOT bounce logged-in users
* off their page. Keep this file dependency-free so it can be imported from
* both server and client code.
*/
export const SESSION_EXPIRED_MESSAGE = "SESSION_EXPIRED";

View File

@ -19,6 +19,7 @@ import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
import type { Session, User } from "better-auth";
import superjson from "superjson";
import { ZodError } from "zod";
import { SESSION_EXPIRED_MESSAGE } from "./auth-constants";
type Resource = keyof typeof statements;
type ActionOf<R extends Resource> = (typeof statements)[R][number];
@ -160,7 +161,10 @@ export const publicProcedure = t.procedure;
*/
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.session || !ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
throw new TRPCError({
code: "UNAUTHORIZED",
message: SESSION_EXPIRED_MESSAGE,
});
}
return next({
ctx: {
@ -173,11 +177,13 @@ export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
});
export const cliProcedure = t.procedure.use(({ ctx, next }) => {
if (
!ctx.session ||
!ctx.user ||
(ctx.user.role !== "owner" && ctx.user.role !== "admin")
) {
if (!ctx.session || !ctx.user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: SESSION_EXPIRED_MESSAGE,
});
}
if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
@ -191,11 +197,13 @@ export const cliProcedure = t.procedure.use(({ ctx, next }) => {
});
export const adminProcedure = t.procedure.use(({ ctx, next }) => {
if (
!ctx.session ||
!ctx.user ||
(ctx.user.role !== "owner" && ctx.user.role !== "admin")
) {
if (!ctx.session || !ctx.user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: SESSION_EXPIRED_MESSAGE,
});
}
if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
@ -214,11 +222,13 @@ export const adminProcedure = t.procedure.use(({ ctx, next }) => {
* is used in the UI gate and when activating/validating keys.
*/
export const enterpriseProcedure = t.procedure.use(async ({ ctx, next }) => {
if (
!ctx.session ||
!ctx.user ||
(ctx.user.role !== "owner" && ctx.user.role !== "admin")
) {
if (!ctx.session || !ctx.user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: SESSION_EXPIRED_MESSAGE,
});
}
if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
throw new TRPCError({ code: "UNAUTHORIZED" });
}

View File

@ -1,3 +1,4 @@
import { MutationCache, QueryCache } from "@tanstack/react-query";
import {
createWSClient,
httpBatchLink,
@ -9,6 +10,7 @@ import { createTRPCNext } from "@trpc/next";
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
import superjson from "superjson";
import type { AppRouter } from "@/server/api/root";
import { handleAuthError } from "@/utils/auth-error";
const getBaseUrl = () => {
if (typeof window !== "undefined") return "";
@ -71,9 +73,18 @@ const links =
}),
];
const queryClientConfig = {
queryCache: new QueryCache({
onError: (error: unknown) => handleAuthError(error),
}),
mutationCache: new MutationCache({
onError: (error: unknown) => handleAuthError(error),
}),
};
export const api = createTRPCNext<AppRouter>({
config() {
return { links };
return { links, queryClientConfig };
},
ssr: false,
transformer: superjson,

View File

@ -0,0 +1,57 @@
import { SESSION_EXPIRED_MESSAGE } from "@/server/api/auth-constants";
import { TRPCClientError } from "@trpc/client";
const PUBLIC_PATH_PREFIXES = [
"/register",
"/invitation",
"/accept-invitation",
"/reset-password",
"/send-reset-password",
];
/**
* Returns true only for the specific UNAUTHORIZED error that means the session
* is missing/expired (tagged server-side with SESSION_EXPIRED_MESSAGE).
*
* This is deliberately NOT every UNAUTHORIZED error: the API reuses the
* UNAUTHORIZED code for authenticated users who lack a role or resource
* permission. Redirecting those to login would yank logged-in users off their
* page on any permission denial, so we match the sentinel message instead.
*/
export const isSessionExpiredError = (error: unknown): boolean => {
if (!(error instanceof TRPCClientError)) return false;
const code = (error.data as { code?: string } | null | undefined)?.code;
return code === "UNAUTHORIZED" && error.message === SESSION_EXPIRED_MESSAGE;
};
/**
* Decides whether an auth error should trigger a redirect to the login screen.
* Public/unauthenticated pages never redirect, and only a genuine expired
* session (not a permission denial) qualifies.
*/
export const shouldRedirectOnAuthError = (
pathname: string,
error: unknown,
): boolean => {
if (!isSessionExpiredError(error)) return false;
if (pathname === "/") return false;
if (PUBLIC_PATH_PREFIXES.some((prefix) => pathname.startsWith(prefix)))
return false;
return true;
};
let isRedirecting = false;
/**
* Side-effecting handler wired into the global tRPC query/mutation caches.
* Redirects the browser to the login screen exactly once when an expired
* session is detected on a protected page.
*/
export const handleAuthError = (error: unknown): void => {
if (typeof window === "undefined") return;
if (isRedirecting) return;
if (!shouldRedirectOnAuthError(window.location.pathname, error)) return;
isRedirecting = true;
window.location.href = "/";
};