Skip to content
Open
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
89 changes: 89 additions & 0 deletions apps/dokploy/__test__/utils/auth-error.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
12 changes: 12 additions & 0 deletions apps/dokploy/server/api/auth-constants.ts
Original file line number Diff line number Diff line change
@@ -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";
42 changes: 26 additions & 16 deletions apps/dokploy/server/api/trpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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: {
Expand All @@ -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({
Expand All @@ -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({
Expand All @@ -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" });
}

Expand Down
13 changes: 12 additions & 1 deletion apps/dokploy/utils/api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { MutationCache, QueryCache } from "@tanstack/react-query";
import {
createWSClient,
httpBatchLink,
Expand All @@ -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 "";
Expand Down Expand Up @@ -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,
Expand Down
57 changes: 57 additions & 0 deletions apps/dokploy/utils/auth-error.ts
Original file line number Diff line number Diff line change
@@ -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 = "/";
};