From e56de8eb6cb03952189cebc40caa538793a7b6b9 Mon Sep 17 00:00:00 2001
From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com>
Date: Wed, 26 Aug 2026 12:55:30 +0100
Subject: [PATCH] feat(ee): MCP OAuth (#2432)
* feat: mcp oauth
* fix: small refactor
* fix: cleanup consent
---
.../public/locales/en-US/translation.json | 30 +-
apps/client/src/App.tsx | 7 +
.../src/ee/ai/components/mcp-settings.tsx | 60 +++-
.../src/ee/api-key/pages/user-api-keys.tsx | 119 +++++--
apps/client/src/ee/features.ts | 2 +
.../components/authorized-apps-panel.tsx | 19 +
.../oauth/components/oauth-grants-table.tsx | 178 ++++++++++
.../src/ee/oauth/pages/oauth-consent.tsx | 333 ++++++++++++++++++
.../src/ee/oauth/queries/oauth-query.ts | 45 +++
.../src/ee/oauth/services/oauth-service.ts | 36 ++
apps/client/src/ee/oauth/types/oauth.types.ts | 24 ++
.../workspace/types/workspace.types.ts | 2 +
apps/client/src/lib/api-client.ts | 2 +
apps/server/package.json | 1 +
.../decorators/oauth-scope.decorator.ts | 8 +
.../require-session-auth.decorator.ts | 6 +
apps/server/src/common/events/audit-events.ts | 7 +
.../src/common/events/event.contants.ts | 2 +
apps/server/src/common/features.ts | 2 +
.../src/common/guards/jwt-auth.guard.spec.ts | 186 ++++++++++
.../src/common/guards/jwt-auth.guard.ts | 43 +++
.../common/helpers/security-headers.spec.ts | 66 ++++
.../src/common/helpers/security-headers.ts | 16 +
.../core/attachment/attachment.controller.ts | 3 +
apps/server/src/core/auth/auth.controller.ts | 4 +-
apps/server/src/core/auth/dto/jwt-payload.ts | 12 +
.../src/core/auth/services/auth.service.ts | 19 +
.../src/core/auth/strategies/jwt.strategy.ts | 61 +++-
.../src/core/comment/comment.controller.ts | 4 +
apps/server/src/core/page/page.controller.ts | 9 +
.../src/core/search/search.controller.ts | 3 +
.../server/src/core/space/space.controller.ts | 5 +
apps/server/src/core/user/user.controller.ts | 2 +
.../controllers/workspace.controller.ts | 3 +
.../workspace/dto/update-workspace.dto.ts | 4 +
.../workspace/services/workspace.service.ts | 30 +-
.../migrations/20260825T022612-oauth.ts | 88 +++++
apps/server/src/database/types/db.d.ts | 61 ++++
.../server/src/database/types/entity.types.ts | 11 +
apps/server/src/ee | 2 +-
.../integrations/throttle/throttle.module.ts | 11 +-
.../integrations/throttle/throttler-names.ts | 12 +
apps/server/src/main.ts | 48 ++-
pnpm-lock.yaml | 15 +
44 files changed, 1530 insertions(+), 71 deletions(-)
create mode 100644 apps/client/src/ee/oauth/components/authorized-apps-panel.tsx
create mode 100644 apps/client/src/ee/oauth/components/oauth-grants-table.tsx
create mode 100644 apps/client/src/ee/oauth/pages/oauth-consent.tsx
create mode 100644 apps/client/src/ee/oauth/queries/oauth-query.ts
create mode 100644 apps/client/src/ee/oauth/services/oauth-service.ts
create mode 100644 apps/client/src/ee/oauth/types/oauth.types.ts
create mode 100644 apps/server/src/common/decorators/oauth-scope.decorator.ts
create mode 100644 apps/server/src/common/decorators/require-session-auth.decorator.ts
create mode 100644 apps/server/src/common/guards/jwt-auth.guard.spec.ts
create mode 100644 apps/server/src/common/helpers/security-headers.spec.ts
create mode 100644 apps/server/src/database/migrations/20260825T022612-oauth.ts
diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index 098fb45dd..86a97d352 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -707,9 +707,11 @@
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.",
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
"MCP Server URL": "MCP Server URL",
- "Use your API key for authentication. You can manage API keys in your account settings.": "Use your API key for authentication. You can manage API keys in your account settings.",
+ "Connect AI assistants with your Docmost account via OAuth.": "Connect AI assistants with your Docmost account via OAuth.",
+ "Require OAuth": "Require OAuth",
+ "AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.",
+ "Toggle require OAuth for MCP": "Toggle require OAuth for MCP",
"Supported tools": "Supported tools",
- "Your workspace has MCP enabled. Use your API key to connect AI assistants.": "Your workspace has MCP enabled. Use your API key to connect AI assistants.",
"MCP server URL:": "MCP server URL:",
"Learn more": "Learn more",
"Manage API keys for all users in the workspace. View the API documentation for usage details.": "Manage API keys for all users in the workspace. View the API documentation for usage details.",
@@ -1304,6 +1306,30 @@
"No attachments on this page yet.": "No attachments on this page yet.",
"Uploaded by {{name}}": "Uploaded by {{name}}",
"Download {{name}}": "Download {{name}}",
+ "Access revoked": "Access revoked",
+ "Authorize application": "Authorize application",
+ "{{name}} wants to access {{workspace}}": "{{name}} wants to access {{workspace}}",
+ "Not you? Switch account": "Not you? Switch account",
+ "This application will be able to:": "This application will be able to:",
+ "Write": "Write",
+ "Invalid authorization request": "Invalid authorization request",
+ "Authorize": "Authorize",
+ "Application": "Application",
+ "Permissions": "Permissions",
+ "Authorized": "Authorized",
+ "Revoke access": "Revoke access",
+ "Revoke access for {{name}}": "Revoke access for {{name}}",
+ "Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.",
+ "Something went wrong. Please try again.": "Something went wrong. Please try again.",
+ "Remove {{name}}": "Remove {{name}}",
+ "Make sure you trust this application before authorizing it.": "Make sure you trust this application before authorizing it.",
+ "You will be redirected to": "You will be redirected to",
+ "View content without making changes.": "View content without making changes.",
+ "Create and modify content.": "Create and modify content.",
+ "Applications and AI assistants you have authorized to access your account.": "Applications and AI assistants you have authorized to access your account.",
+ "Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.",
+ "Authorized apps": "Authorized apps",
+ "No authorized apps yet.": "No authorized apps yet.",
"Workspace knowledge only": "Workspace knowledge only",
"Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.": "Restrict AI Chat to answering from your workspace pages and uploaded files only. It will not use outside knowledge.",
"Toggle workspace knowledge only": "Toggle workspace knowledge only",
diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx
index c90593e58..4207c3006 100644
--- a/apps/client/src/App.tsx
+++ b/apps/client/src/App.tsx
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
import { useTrackOrigin } from "@/hooks/use-track-origin";
+
const SetupWorkspace = lazy(() => import("@/pages/auth/setup-workspace.tsx"));
const LoginPage = lazy(() => import("@/pages/auth/login"));
const Home = lazy(() => import("@/pages/dashboard/home"));
@@ -73,6 +74,7 @@ const FavoritesPage = lazy(() => import("@/pages/favorites/favorites-page"));
const AiChat = lazy(() => import("@/ee/ai-chat/pages/ai-chat.tsx"));
const VerifyEmail = lazy(() => import("@/ee/pages/verify-email.tsx"));
const LabelPage = lazy(() => import("@/pages/label/label-page"));
+const OAuthConsent = lazy(() => import("@/ee/oauth/pages/oauth-consent.tsx"));
export default function App() {
const { t } = useTranslation();
@@ -95,6 +97,7 @@ export default function App() {
} />
} />
} />
+ } />
{!isCloud() && (
} />
@@ -148,6 +151,10 @@ export default function App() {
element={ }
/>
} />
+ }
+ />
} />
} />
} />
diff --git a/apps/client/src/ee/ai/components/mcp-settings.tsx b/apps/client/src/ee/ai/components/mcp-settings.tsx
index e7cc2234b..eeb7ad47e 100644
--- a/apps/client/src/ee/ai/components/mcp-settings.tsx
+++ b/apps/client/src/ee/ai/components/mcp-settings.tsx
@@ -1,5 +1,6 @@
import {
Anchor,
+ Badge,
Group,
List,
Text,
@@ -107,11 +108,11 @@ export default function McpSettings() {
- {t(
- "Use your API key for authentication. You can manage API keys in your account settings.",
- )}
+ {t("Connect AI assistants with your Docmost account via OAuth.")}
+
+
{t("Supported tools")}
@@ -154,3 +155,56 @@ export default function McpSettings() {
);
}
+
+function McpOauthOnlySetting() {
+ const { t } = useTranslation();
+ const [workspace, setWorkspace] = useAtom(workspaceAtom);
+ const [checked, setChecked] = useState(workspace?.settings?.ai?.mcpOauthOnly);
+ const hasAccess = useHasFeature(Feature.MCP_CONTROLS);
+ const upgradeLabel = useUpgradeLabel();
+
+ const handleChange = async (event: React.ChangeEvent) => {
+ const value = event.currentTarget.checked;
+ try {
+ const updatedWorkspace = await updateWorkspace({ mcpOauthOnly: value });
+ setChecked(value);
+ setWorkspace(updatedWorkspace);
+ } catch (err) {
+ notifications.show({
+ message: err?.response?.data?.message,
+ color: "red",
+ });
+ }
+ };
+
+ return (
+
+
+
+
+ {t("Require OAuth")}
+
+ {!hasAccess && (
+
+ {t("Enterprise")}
+
+ )}
+
+
+ {t(
+ "AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.",
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/client/src/ee/api-key/pages/user-api-keys.tsx b/apps/client/src/ee/api-key/pages/user-api-keys.tsx
index 2b2572200..7bec5995a 100644
--- a/apps/client/src/ee/api-key/pages/user-api-keys.tsx
+++ b/apps/client/src/ee/api-key/pages/user-api-keys.tsx
@@ -1,5 +1,5 @@
import React, { useState } from "react";
-import { Anchor, Alert, Button, Group, Space, Text } from "@mantine/core";
+import { Anchor, Alert, Button, Group, Space, Tabs, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { Trans, useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title";
@@ -17,6 +17,8 @@ import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import useUserRole from "@/hooks/use-user-role.tsx";
import { DocumentTitle } from "@/components/ui/document-title.tsx";
+import { useLocation, useNavigate } from "react-router-dom";
+import { AuthorizedAppsPanel } from "@/ee/oauth/components/authorized-apps-panel.tsx";
export default function UserApiKeys() {
const { t } = useTranslation();
@@ -29,6 +31,11 @@ export default function UserApiKeys() {
const { data, isLoading } = useGetApiKeysQuery({ cursor });
const [workspace] = useAtom(workspaceAtom);
const { isAdmin } = useUserRole();
+ const location = useLocation();
+ const navigate = useNavigate();
+ const activeTab = location.pathname.endsWith("/authorized-apps")
+ ? "authorized-apps"
+ : "api-keys";
const mcpEnabled = workspace?.settings?.ai?.mcp === true;
const restrictToAdmins = workspace?.settings?.api?.restrictToAdmins === true;
const canCreate = !restrictToAdmins || isAdmin;
@@ -47,26 +54,27 @@ export default function UserApiKeys() {
setRevokeModalOpened(true);
};
+ const handleTabChange = (value: string | null) => {
+ navigate(
+ value === "authorized-apps"
+ ? "/settings/account/api-keys/authorized-apps"
+ : "/settings/account/api-keys",
+ );
+ };
+
return (
<>
-
+
-
- ,
- }}
- />
-
-
- {mcpEnabled && canCreate && (
+ {mcpEnabled && (
}>
{t(
- "Your workspace has MCP enabled. Use your API key to connect AI assistants.",
+ "Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.",
)}{" "}
)}
- {canCreate ? (
-
- setCreateModalOpened(true)}>
- {t("Create API Key")}
-
-
- ) : restrictToAdmins ? (
- }>
-
- {t("API key creation is restricted to admins by your workspace administrator.")}
+
+
+
+ {t("API keys")}
+
+
+ {t("Authorized apps")}
+
+
+
+
+
+
+
+ ,
+ }}
+ />
-
- ) : null}
-
+ {canCreate && (
+ setCreateModalOpened(true)}
+ style={{ flexShrink: 0 }}
+ >
+ {t("Create API Key")}
+
+ )}
+
-
+ {!canCreate && restrictToAdmins && (
+ }>
+
+ {t("API key creation is restricted to admins by your workspace administrator.")}
+
+
+ )}
- {data?.items.length > 0 && (
- goNext(data?.meta?.nextCursor)}
- onPrev={goPrev}
+
- )}
+
+
+
+ {data?.items.length > 0 && (
+ goNext(data?.meta?.nextCursor)}
+ onPrev={goPrev}
+ />
+ )}
+
+
+
+
+
+
+
+
+ {t("Applications and AI assistants you have authorized to access your account.")}
+
+
+
+ >
+ );
+}
diff --git a/apps/client/src/ee/oauth/components/oauth-grants-table.tsx b/apps/client/src/ee/oauth/components/oauth-grants-table.tsx
new file mode 100644
index 000000000..fa48aa1de
--- /dev/null
+++ b/apps/client/src/ee/oauth/components/oauth-grants-table.tsx
@@ -0,0 +1,178 @@
+import { ActionIcon, Badge, Group, Skeleton, Table, Text, Tooltip } from "@mantine/core";
+import { IconTrash } from "@tabler/icons-react";
+import { modals } from "@mantine/modals";
+import { useTranslation } from "react-i18next";
+import NoTableResults from "@/components/common/no-table-results";
+import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts";
+import { IOAuthGrant } from "@/ee/oauth/types/oauth.types";
+import { useRevokeOAuthGrantMutation } from "@/ee/oauth/queries/oauth-query";
+
+// Callback hosts identify the app; the full URL belongs on the consent screen.
+function callbackHosts(redirectUris: string[] = []): string[] {
+ const hosts = redirectUris.map((uri) => {
+ try {
+ const url = new URL(uri);
+ return url.protocol === "http:" || url.protocol === "https:"
+ ? url.host
+ : url.protocol;
+ } catch {
+ return uri;
+ }
+ });
+ return Array.from(new Set(hosts));
+}
+
+type OAuthGrantsTableProps = {
+ grants: IOAuthGrant[];
+ isLoading?: boolean;
+};
+
+function TableSkeleton() {
+ return (
+ <>
+ {Array.from({ length: 3 }).map((_, i) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+ >
+ );
+}
+
+export function OAuthGrantsTable({ grants, isLoading }: OAuthGrantsTableProps) {
+ const { t } = useTranslation();
+ const locale = useDateFnsLocale();
+ const revokeMutation = useRevokeOAuthGrantMutation();
+
+ const scopeMeta: Record = {
+ read: { color: "blue", label: t("Read") },
+ write: { color: "orange", label: t("Write") },
+ };
+
+ const formatDate = (date: string | null) => {
+ if (!date) return t("Never");
+ return formatLocalized(date, "MMM dd, yyyy", "PP", locale);
+ };
+
+ const openRevokeModal = (grant: IOAuthGrant) =>
+ modals.openConfirmModal({
+ title: t("Revoke access"),
+ centered: true,
+ children: (
+
+ {t(
+ "Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.",
+ { name: grant.clientName },
+ )}
+
+ ),
+ labels: { confirm: t("Revoke access"), cancel: t("Cancel") },
+ confirmProps: { color: "red" },
+ onConfirm: () => revokeMutation.mutate(grant.id),
+ });
+
+ return (
+
+
+
+
+ {t("Application")}
+ {t("Permissions")}
+ {t("Authorized")}
+ {t("Last used")}
+
+
+
+
+
+ {isLoading ? (
+
+ ) : grants.length > 0 ? (
+ grants.map((grant) => (
+
+
+
+ {grant.clientName}
+
+
+ {callbackHosts(grant.redirectUris).join(", ")}
+
+
+
+
+
+ {grant.scopes.map((scope) => {
+ const meta = scopeMeta[scope];
+ if (!meta) return null;
+ return (
+
+ {meta.label}
+
+ );
+ })}
+
+
+
+
+
+ {formatDate(grant.createdAt)}
+
+
+
+
+
+ {formatDate(grant.lastUsedAt)}
+
+
+
+
+
+ openRevokeModal(grant)}
+ >
+
+
+
+
+
+ ))
+ ) : (
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/client/src/ee/oauth/pages/oauth-consent.tsx b/apps/client/src/ee/oauth/pages/oauth-consent.tsx
new file mode 100644
index 000000000..a19b60829
--- /dev/null
+++ b/apps/client/src/ee/oauth/pages/oauth-consent.tsx
@@ -0,0 +1,333 @@
+import React, { useEffect, useMemo, useState } from "react";
+import {
+ Alert,
+ Anchor,
+ Box,
+ Button,
+ Center,
+ Checkbox,
+ Container,
+ Divider,
+ Group,
+ Loader,
+ Paper,
+ Stack,
+ Text,
+ ThemeIcon,
+ Title,
+} from "@mantine/core";
+import { notifications } from "@mantine/notifications";
+import {
+ IconAlertTriangle,
+ IconEye,
+ IconPencil,
+} from "@tabler/icons-react";
+import { useQuery } from "@tanstack/react-query";
+import { useAtom } from "jotai";
+import { RESET } from "jotai/utils";
+import { useTranslation } from "react-i18next";
+import { useLocation, useSearchParams } from "react-router-dom";
+import { AuthLayout } from "@/features/auth/components/auth-layout.tsx";
+import classes from "@/features/auth/components/auth.module.css";
+import { DocumentTitle } from "@/components/ui/document-title.tsx";
+import { UserInfo } from "@/components/common/user-info.tsx";
+import useCurrentUser from "@/features/user/hooks/use-current-user";
+import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
+import { logout } from "@/features/auth/services/auth-service";
+import { ICurrentUser } from "@/features/user/types/user.types";
+import APP_ROUTE from "@/lib/app-route.ts";
+import {
+ approveOAuthAuthorization,
+ getOAuthAuthorizeInfo,
+} from "@/ee/oauth/services/oauth-service";
+import {
+ IApproveAuthorizationPayload,
+ IAuthorizeParams,
+ IOAuthAuthorizeInfo,
+} from "@/ee/oauth/types/oauth.types";
+
+function loginRedirectUrl(pathname: string, search: string): string {
+ return `${APP_ROUTE.AUTH.LOGIN}?redirect=${encodeURIComponent(pathname + search)}`;
+}
+
+function errorStatus(error: any): number | undefined {
+ return error?.response?.status;
+}
+
+function errorText(error: any): string | undefined {
+ const data = error?.response?.data;
+ if (typeof data?.error_description === "string") return data.error_description;
+ if (Array.isArray(data?.message)) return data.message.join(", ");
+ return data?.message;
+}
+
+const OAUTH_PARAM_NAMES = [
+ "response_type",
+ "client_id",
+ "redirect_uri",
+ "state",
+ "code_challenge",
+ "code_challenge_method",
+ "scope",
+ "resource",
+];
+
+function pickOAuthParams(params: IAuthorizeParams): IAuthorizeParams {
+ const picked: IAuthorizeParams = {};
+ for (const name of OAUTH_PARAM_NAMES) {
+ if (params[name] !== undefined) {
+ picked[name] = params[name];
+ }
+ }
+ return picked;
+}
+
+export default function OAuthConsent() {
+ const { t } = useTranslation();
+ const location = useLocation();
+ const [searchParams] = useSearchParams();
+
+ const params = useMemo(
+ () => Object.fromEntries(searchParams.entries()),
+ [searchParams],
+ );
+
+ const currentUserQuery = useCurrentUser();
+ const infoQuery = useQuery({
+ queryKey: ["oauth-authorize-info", params],
+ queryFn: () => getOAuthAuthorizeInfo(params),
+ });
+
+ const isUnauthenticated =
+ errorStatus(currentUserQuery.error) === 401 ||
+ errorStatus(infoQuery.error) === 401;
+
+ useEffect(() => {
+ if (isUnauthenticated) {
+ window.location.replace(
+ loginRedirectUrl(location.pathname, location.search),
+ );
+ }
+ }, [isUnauthenticated, location.pathname, location.search]);
+
+ const isLoading =
+ isUnauthenticated || currentUserQuery.isLoading || infoQuery.isLoading;
+
+ return (
+
+
+
+
+ {isLoading ? (
+
+
+
+ ) : infoQuery.data && currentUserQuery.data ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
+
+function InvalidRequestCard({ description }: { description?: string }) {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
+ {t("Invalid authorization request")}
+
+ {description && (
+
+ {description}
+
+ )}
+
+ );
+}
+
+type ConsentCardProps = {
+ info: IOAuthAuthorizeInfo;
+ currentUser: ICurrentUser;
+ params: IAuthorizeParams;
+};
+
+function ConsentCard({ info, currentUser, params }: ConsentCardProps) {
+ const { t } = useTranslation();
+ const location = useLocation();
+ const [, setCurrentUser] = useAtom(currentUserAtom);
+ const [approvedScopes, setApprovedScopes] = useState(
+ info.scopes.filter((scope) => scope === "read" || scope === "write"),
+ );
+ const [submitting, setSubmitting] = useState<"approve" | "deny" | null>(null);
+
+ const scopeRows = [
+ {
+ scope: "read",
+ icon: ,
+ label: t("Read"),
+ description: t("View data in your workspace without making changes."),
+ },
+ {
+ scope: "write",
+ icon: ,
+ label: t("Write"),
+ description: t("Create and modify data in your workspace."),
+ },
+ ];
+
+ function toggleScope(scope: string, checked: boolean) {
+ setApprovedScopes((prev) =>
+ checked ? [...prev, scope] : prev.filter((item) => item !== scope),
+ );
+ }
+
+ async function submitDecision(approved: boolean) {
+ setSubmitting(approved ? "approve" : "deny");
+ const oauthParams = pickOAuthParams(params);
+ const payload: IApproveAuthorizationPayload = approved
+ ? { ...oauthParams, approved: true, approvedScopes }
+ : { ...oauthParams, approved: false };
+
+ try {
+ const res = await approveOAuthAuthorization(payload);
+ window.location.replace(res.redirectUrl);
+ } catch (err) {
+ if (errorStatus(err) === 401) {
+ window.location.replace(
+ loginRedirectUrl(location.pathname, location.search),
+ );
+ return;
+ }
+ setSubmitting(null);
+ notifications.show({
+ message: errorText(err) || t("Something went wrong. Please try again."),
+ color: "red",
+ });
+ }
+ }
+
+ async function switchAccount() {
+ if (submitting !== null) {
+ return;
+ }
+ setCurrentUser(RESET);
+ try {
+ await logout();
+ } finally {
+ window.location.replace(
+ loginRedirectUrl(location.pathname, location.search),
+ );
+ }
+ }
+
+ return (
+
+
+ {t("{{name}} wants to access {{workspace}}", {
+ name: info.clientName,
+ workspace: currentUser.workspace.name,
+ })}
+
+
+
+
+
+
+ {t("Not you? Switch account")}
+
+
+
+
+
+
+
+
+ {t("This application will be able to:")}
+
+ {scopeRows.map((row) => {
+ const requested = info.scopes.includes(row.scope);
+ return (
+
+ toggleScope(row.scope, event.currentTarget.checked)
+ }
+ label={
+
+ {row.icon}
+
+ {row.label}
+
+
+ }
+ description={row.description}
+ />
+ );
+ })}
+
+
+
+
+ {t("You will be redirected to")}
+
+
+ {info.redirectUri}
+
+
+
+ {!info.verified && (
+ }
+ >
+ {t("Make sure you trust this application before authorizing it.")}
+
+ )}
+
+
+ submitDecision(false)}
+ loading={submitting === "deny"}
+ disabled={submitting === "approve"}
+ >
+ {t("Cancel")}
+
+ submitDecision(true)}
+ loading={submitting === "approve"}
+ disabled={approvedScopes.length === 0 || submitting === "deny"}
+ >
+ {t("Authorize")}
+
+
+
+ );
+}
diff --git a/apps/client/src/ee/oauth/queries/oauth-query.ts b/apps/client/src/ee/oauth/queries/oauth-query.ts
new file mode 100644
index 000000000..2942017f2
--- /dev/null
+++ b/apps/client/src/ee/oauth/queries/oauth-query.ts
@@ -0,0 +1,45 @@
+import {
+ useMutation,
+ useQuery,
+ useQueryClient,
+ UseQueryResult,
+} from "@tanstack/react-query";
+import {
+ getOAuthGrants,
+ revokeOAuthGrant,
+} from "@/ee/oauth/services/oauth-service";
+import { IOAuthGrant } from "@/ee/oauth/types/oauth.types";
+import { notifications } from "@mantine/notifications";
+import { useTranslation } from "react-i18next";
+
+export function useOAuthGrantsQuery(): UseQueryResult {
+ return useQuery({
+ queryKey: ["oauth-grants"],
+ queryFn: () => getOAuthGrants(),
+ staleTime: 0,
+ gcTime: 0,
+ });
+}
+
+export function useRevokeOAuthGrantMutation() {
+ const queryClient = useQueryClient();
+ const { t } = useTranslation();
+
+ return useMutation({
+ mutationFn: (grantId) => revokeOAuthGrant(grantId),
+ onSuccess: () => {
+ notifications.show({ message: t("Access revoked") });
+ queryClient.invalidateQueries({
+ predicate: (item) =>
+ ["oauth-grants"].includes(item.queryKey[0] as string),
+ });
+ },
+ onError: (error) => {
+ const errorMessage = error["response"]?.data?.message;
+ notifications.show({
+ message: errorMessage || t("Something went wrong. Please try again."),
+ color: "red",
+ });
+ },
+ });
+}
diff --git a/apps/client/src/ee/oauth/services/oauth-service.ts b/apps/client/src/ee/oauth/services/oauth-service.ts
new file mode 100644
index 000000000..076974060
--- /dev/null
+++ b/apps/client/src/ee/oauth/services/oauth-service.ts
@@ -0,0 +1,36 @@
+import api from "@/lib/api-client";
+import {
+ IApproveAuthorizationPayload,
+ IAuthorizeParams,
+ IOAuthAuthorizeInfo,
+ IOAuthGrant,
+} from "@/ee/oauth/types/oauth.types";
+
+export async function getOAuthAuthorizeInfo(
+ params: IAuthorizeParams,
+): Promise {
+ const req = await api.post(
+ "/oauth/authorize-info",
+ params,
+ );
+ return req.data;
+}
+
+export async function approveOAuthAuthorization(
+ payload: IApproveAuthorizationPayload,
+): Promise<{ redirectUrl: string }> {
+ const req = await api.post<{ redirectUrl: string }>(
+ "/oauth/authorize",
+ payload,
+ );
+ return req.data;
+}
+
+export async function getOAuthGrants(): Promise {
+ const req = await api.post("/oauth/grants", {});
+ return req.data;
+}
+
+export async function revokeOAuthGrant(grantId: string): Promise {
+ await api.post("/oauth/grants/revoke", { grantId });
+}
diff --git a/apps/client/src/ee/oauth/types/oauth.types.ts b/apps/client/src/ee/oauth/types/oauth.types.ts
new file mode 100644
index 000000000..3fe7f98ff
--- /dev/null
+++ b/apps/client/src/ee/oauth/types/oauth.types.ts
@@ -0,0 +1,24 @@
+export interface IOAuthAuthorizeInfo {
+ clientName: string;
+ redirectUri: string;
+ scopes: string[];
+ clientCreatedAt: string;
+ verified: boolean;
+}
+
+export interface IOAuthGrant {
+ id: string;
+ clientName: string;
+ redirectUris: string[];
+ scopes: string[];
+ createdAt: string;
+ lastUsedAt: string | null;
+}
+
+export type IAuthorizeParams = Record;
+
+export type IApproveAuthorizationPayload = {
+ [param: string]: unknown;
+ approved: boolean;
+ approvedScopes?: string[];
+};
diff --git a/apps/client/src/features/workspace/types/workspace.types.ts b/apps/client/src/features/workspace/types/workspace.types.ts
index c2f1eca46..1722370ef 100644
--- a/apps/client/src/features/workspace/types/workspace.types.ts
+++ b/apps/client/src/features/workspace/types/workspace.types.ts
@@ -27,6 +27,7 @@ export interface IWorkspace {
mcpEnabled?: boolean;
aiChatReadOnly?: boolean;
aiChatWorkspaceKnowledgeOnly?: boolean;
+ mcpOauthOnly?: boolean;
trashRetentionDays?: number;
restrictApiToAdmins?: boolean;
allowMemberTemplates?: boolean;
@@ -52,6 +53,7 @@ export interface IWorkspaceAiSettings {
search?: boolean;
generative?: boolean;
mcp?: boolean;
+ mcpOauthOnly?: boolean;
chat?: boolean;
chatReadOnly?: boolean;
chatWorkspaceKnowledgeOnly?: boolean;
diff --git a/apps/client/src/lib/api-client.ts b/apps/client/src/lib/api-client.ts
index 61368646d..c39b70a1e 100644
--- a/apps/client/src/lib/api-client.ts
+++ b/apps/client/src/lib/api-client.ts
@@ -76,6 +76,8 @@ function redirectToLogin() {
APP_ROUTE.AUTH.MFA_CHALLENGE,
APP_ROUTE.AUTH.MFA_SETUP_REQUIRED,
"/invites",
+ // the oauth consent page redirects to login itself, preserving its query string
+ "/oauth/consent",
];
if (!exemptPaths.some((path) => window.location.pathname.startsWith(path))) {
const redirectTo = window.location.pathname;
diff --git a/apps/server/package.json b/apps/server/package.json
index 94463aacc..c0a9ec88d 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -43,6 +43,7 @@
"@fastify/cookie": "11.1.2",
"@fastify/multipart": "10.1.1",
"@fastify/static": "10.1.3",
+ "@jmondi/oauth2-server": "^4.3.7",
"@keyv/redis": "5.1.6",
"@langchain/core": "1.1.46",
"@langchain/textsplitters": "1.0.1",
diff --git a/apps/server/src/common/decorators/oauth-scope.decorator.ts b/apps/server/src/common/decorators/oauth-scope.decorator.ts
new file mode 100644
index 000000000..060cc3404
--- /dev/null
+++ b/apps/server/src/common/decorators/oauth-scope.decorator.ts
@@ -0,0 +1,8 @@
+import { SetMetadata } from '@nestjs/common';
+
+export const OAUTH_SCOPE_KEY = 'oauthScope';
+
+export type OAuthRouteScope = 'read' | 'write';
+
+export const OAuthScope = (scope: OAuthRouteScope) =>
+ SetMetadata(OAUTH_SCOPE_KEY, scope);
diff --git a/apps/server/src/common/decorators/require-session-auth.decorator.ts b/apps/server/src/common/decorators/require-session-auth.decorator.ts
new file mode 100644
index 000000000..b5498ad74
--- /dev/null
+++ b/apps/server/src/common/decorators/require-session-auth.decorator.ts
@@ -0,0 +1,6 @@
+import { SetMetadata } from '@nestjs/common';
+
+export const REQUIRE_SESSION_AUTH_KEY = 'requireSessionAuth';
+
+export const RequireSessionAuth = () =>
+ SetMetadata(REQUIRE_SESSION_AUTH_KEY, true);
diff --git a/apps/server/src/common/events/audit-events.ts b/apps/server/src/common/events/audit-events.ts
index d8be76f83..24ca2af5d 100644
--- a/apps/server/src/common/events/audit-events.ts
+++ b/apps/server/src/common/events/audit-events.ts
@@ -23,6 +23,11 @@ export const AuditEvent = {
API_KEY_UPDATED: 'api_key.updated',
API_KEY_DELETED: 'api_key.deleted',
+ // OAuth
+ OAUTH_CLIENT_REGISTERED: 'oauth_client.registered',
+ OAUTH_GRANT_CREATED: 'oauth_grant.created',
+ OAUTH_GRANT_REVOKED: 'oauth_grant.revoked',
+
// SCIM Tokens
SCIM_TOKEN_CREATED: 'scim_token.created',
SCIM_TOKEN_UPDATED: 'scim_token.updated',
@@ -124,6 +129,8 @@ export const AuditResource = {
COMMENT: 'comment',
SHARE: 'share',
API_KEY: 'api_key',
+ OAUTH_CLIENT: 'oauth_client',
+ OAUTH_GRANT: 'oauth_grant',
SCIM_TOKEN: 'scim_token',
SSO_PROVIDER: 'sso_provider',
WORKSPACE_INVITATION: 'workspace_invitation',
diff --git a/apps/server/src/common/events/event.contants.ts b/apps/server/src/common/events/event.contants.ts
index 3a0ecba17..b52a3b722 100644
--- a/apps/server/src/common/events/event.contants.ts
+++ b/apps/server/src/common/events/event.contants.ts
@@ -12,6 +12,8 @@ export enum EventName {
SPACE_UPDATED = 'space.updated',
SPACE_DELETED = 'space.deleted',
+ USER_PASSWORD_RESET = 'user.password.reset',
+
WORKSPACE_CREATED = 'workspace.created',
WORKSPACE_UPDATED = 'workspace.updated',
WORKSPACE_DELETED = 'workspace.deleted',
diff --git a/apps/server/src/common/features.ts b/apps/server/src/common/features.ts
index 6adc447bd..fcb21d35a 100644
--- a/apps/server/src/common/features.ts
+++ b/apps/server/src/common/features.ts
@@ -23,7 +23,9 @@ export const Feature = {
PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx',
BASES: 'bases',
+ OAUTH: 'oauth',
AI_CONTROLS: 'ai:controls',
+ MCP_CONTROLS: 'mcp:controls',
} as const;
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
diff --git a/apps/server/src/common/guards/jwt-auth.guard.spec.ts b/apps/server/src/common/guards/jwt-auth.guard.spec.ts
new file mode 100644
index 000000000..113ab78f5
--- /dev/null
+++ b/apps/server/src/common/guards/jwt-auth.guard.spec.ts
@@ -0,0 +1,186 @@
+import {
+ ExecutionContext,
+ ForbiddenException,
+ UnauthorizedException,
+} from '@nestjs/common';
+import { JwtAuthGuard } from './jwt-auth.guard';
+import { OAUTH_SCOPE_KEY } from '../decorators/oauth-scope.decorator';
+import { REQUIRE_SESSION_AUTH_KEY } from '../decorators/require-session-auth.decorator';
+import { JwtType } from '../../core/auth/dto/jwt-payload';
+
+const handlerSentinel = () => 'handler';
+const classSentinel = class Controller {};
+
+function createCtx(): ExecutionContext {
+ return {
+ getHandler: () => handlerSentinel,
+ getClass: () => classSentinel,
+ } as any;
+}
+
+function createGuard(scopeMetadata?: unknown, requireSession?: boolean) {
+ const reflector = {
+ getAllAndOverride: jest.fn((key: string) =>
+ key === REQUIRE_SESSION_AUTH_KEY ? requireSession : scopeMetadata,
+ ),
+ } as any;
+ const environmentService = {
+ isCloud: jest.fn().mockReturnValue(false),
+ } as any;
+ const guard = new JwtAuthGuard(reflector, environmentService);
+ return { guard, reflector };
+}
+
+function oauthUser(scopes: string[]) {
+ return {
+ user: { id: 'user_1' },
+ workspace: { id: 'ws_1' },
+ oauth: { grantId: 'grant_1', scopes },
+ };
+}
+
+describe('JwtAuthGuard.handleRequest', () => {
+ it('rethrows the strategy error', () => {
+ const { guard } = createGuard();
+ const err = new UnauthorizedException('bad token');
+
+ expect(() => guard.handleRequest(err, null, null, createCtx())).toThrow(err);
+ });
+
+ it('throws UnauthorizedException when there is no user', () => {
+ const { guard } = createGuard();
+
+ expect(() => guard.handleRequest(null, null, null, createCtx())).toThrow(
+ UnauthorizedException,
+ );
+ });
+
+ it('returns a non-oauth user untouched without consulting scope metadata', () => {
+ const { guard, reflector } = createGuard();
+ const user = { user: { id: 'user_1' }, workspace: { id: 'ws_1' } };
+
+ expect(guard.handleRequest(null, user, null, createCtx())).toBe(user);
+ expect(reflector.getAllAndOverride).not.toHaveBeenCalledWith(
+ OAUTH_SCOPE_KEY,
+ expect.anything(),
+ );
+ });
+
+ it('forbids an oauth user on a route without scope metadata', () => {
+ const { guard, reflector } = createGuard(undefined);
+
+ expect(() =>
+ guard.handleRequest(null, oauthUser(['read', 'write']), null, createCtx()),
+ ).toThrow(ForbiddenException);
+ expect(reflector.getAllAndOverride).toHaveBeenCalledWith(OAUTH_SCOPE_KEY, [
+ handlerSentinel,
+ classSentinel,
+ ]);
+ });
+
+ it('passes read scope on a read route', () => {
+ const { guard } = createGuard('read');
+ const user = oauthUser(['read']);
+
+ expect(guard.handleRequest(null, user, null, createCtx())).toBe(user);
+ });
+
+ it('forbids read scope on a write route with insufficient_scope', () => {
+ const { guard } = createGuard('write');
+
+ expect(() =>
+ guard.handleRequest(null, oauthUser(['read']), null, createCtx()),
+ ).toThrow('insufficient_scope');
+ });
+
+ it('passes write scope on a read route', () => {
+ const { guard } = createGuard('read');
+ const user = oauthUser(['write']);
+
+ expect(guard.handleRequest(null, user, null, createCtx())).toBe(user);
+ });
+
+ it('passes write scope on a write route', () => {
+ const { guard } = createGuard('write');
+ const user = oauthUser(['write']);
+
+ expect(guard.handleRequest(null, user, null, createCtx())).toBe(user);
+ });
+
+ describe('session-only routes', () => {
+ const sessionUser = {
+ user: { id: 'user_1' },
+ workspace: { id: 'ws_1' },
+ authType: JwtType.ACCESS,
+ };
+
+ it('allows a signed-in session', () => {
+ const { guard } = createGuard(undefined, true);
+
+ expect(guard.handleRequest(null, sessionUser, null, createCtx())).toBe(
+ sessionUser,
+ );
+ });
+
+ it('forbids an api key', () => {
+ const { guard } = createGuard(undefined, true);
+ const apiKeyUser = {
+ user: { id: 'user_1' },
+ workspace: { id: 'ws_1' },
+ authType: JwtType.API_KEY,
+ };
+
+ expect(() =>
+ guard.handleRequest(null, apiKeyUser, null, createCtx()),
+ ).toThrow('This action requires an interactive user session');
+ });
+
+ it('forbids an oauth token even when it carries write scope', () => {
+ const { guard } = createGuard('write', true);
+ const user = { ...oauthUser(['write']), authType: JwtType.OAUTH_ACCESS };
+
+ expect(() => guard.handleRequest(null, user, null, createCtx())).toThrow(
+ 'This action requires an interactive user session',
+ );
+ });
+
+ it('leaves api keys working on routes without the marker', () => {
+ const { guard } = createGuard(undefined, undefined);
+ const apiKeyUser = {
+ user: { id: 'user_1' },
+ workspace: { id: 'ws_1' },
+ authType: JwtType.API_KEY,
+ };
+
+ expect(guard.handleRequest(null, apiKeyUser, null, createCtx())).toBe(
+ apiKeyUser,
+ );
+ });
+ });
+
+ it('lets handler metadata override class metadata', () => {
+ const metadataByTarget = new Map([
+ [handlerSentinel, 'write'],
+ [classSentinel, 'read'],
+ ]);
+ const reflector = {
+ getAllAndOverride: jest.fn((key: string, targets: unknown[]) => {
+ if (key === REQUIRE_SESSION_AUTH_KEY) {
+ return undefined;
+ }
+ for (const target of targets) {
+ if (metadataByTarget.has(target)) {
+ return metadataByTarget.get(target);
+ }
+ }
+ return undefined;
+ }),
+ } as any;
+ const environmentService = { isCloud: jest.fn().mockReturnValue(false) } as any;
+ const guard = new JwtAuthGuard(reflector, environmentService);
+
+ expect(() =>
+ guard.handleRequest(null, oauthUser(['read']), null, createCtx()),
+ ).toThrow('insufficient_scope');
+ });
+});
diff --git a/apps/server/src/common/guards/jwt-auth.guard.ts b/apps/server/src/common/guards/jwt-auth.guard.ts
index 0508ed4ef..92a51402e 100644
--- a/apps/server/src/common/guards/jwt-auth.guard.ts
+++ b/apps/server/src/common/guards/jwt-auth.guard.ts
@@ -1,16 +1,26 @@
import {
ExecutionContext,
+ ForbiddenException,
Injectable,
+ Logger,
UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
+import {
+ OAUTH_SCOPE_KEY,
+ OAuthRouteScope,
+} from '../decorators/oauth-scope.decorator';
+import { REQUIRE_SESSION_AUTH_KEY } from '../decorators/require-session-auth.decorator';
+import { JwtType } from '../../core/auth/dto/jwt-payload';
import { Reflector } from '@nestjs/core';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { addDays } from 'date-fns';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
+ private logger = new Logger('JwtAuthGuard');
+
constructor(
private reflector: Reflector,
private environmentService: EnvironmentService,
@@ -36,6 +46,39 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
throw err || new UnauthorizedException();
}
+ const requiresSession = this.reflector.getAllAndOverride(
+ REQUIRE_SESSION_AUTH_KEY,
+ [ctx.getHandler(), ctx.getClass()],
+ );
+ if (requiresSession && user.authType !== JwtType.ACCESS) {
+ this.logger.debug(
+ `session-only endpoint ${ctx.getClass()?.name}.${ctx.getHandler()?.name} refused authType ${user.authType}`,
+ );
+ throw new ForbiddenException(
+ 'This action requires an interactive user session',
+ );
+ }
+
+ if (user.oauth) {
+ const required = this.reflector.getAllAndOverride<
+ OAuthRouteScope | undefined
+ >(OAUTH_SCOPE_KEY, [ctx.getHandler(), ctx.getClass()]);
+ if (!required) {
+ this.logger.warn(
+ `oauth scope check: no @OAuthScope metadata on ${ctx.getClass()?.name}.${ctx.getHandler()?.name}`,
+ );
+ throw new ForbiddenException('OAuth tokens cannot access this endpoint');
+ }
+ const scopes: string[] = user.oauth.scopes ?? [];
+ const satisfied =
+ required === 'read'
+ ? scopes.includes('read') || scopes.includes('write')
+ : scopes.includes('write');
+ if (!satisfied) {
+ throw new ForbiddenException('insufficient_scope');
+ }
+ }
+
this.setJoinedWorkspacesCookie(user, ctx);
return user;
}
diff --git a/apps/server/src/common/helpers/security-headers.spec.ts b/apps/server/src/common/helpers/security-headers.spec.ts
new file mode 100644
index 000000000..cf098b839
--- /dev/null
+++ b/apps/server/src/common/helpers/security-headers.spec.ts
@@ -0,0 +1,66 @@
+import {
+ resolveFrameHeader,
+ resolveFrameHeadersForPath,
+ SecurityHeader,
+} from './security-headers';
+
+describe('resolveFrameHeader', () => {
+ it('denies framing with X-Frame-Options when embedding is off', () => {
+ expect(resolveFrameHeader(false, [])).toEqual({
+ name: 'X-Frame-Options',
+ value: 'SAMEORIGIN',
+ });
+ });
+
+ it('returns null when embedding is on but no origins are configured', () => {
+ expect(resolveFrameHeader(true, [])).toBeNull();
+ });
+
+ it('emits a frame-ancestors CSP for the allowed origins', () => {
+ expect(resolveFrameHeader(true, ['https://a.example', 'https://b.example']))
+ .toEqual({
+ name: 'Content-Security-Policy',
+ value: "frame-ancestors 'self' https://a.example https://b.example",
+ });
+ });
+});
+
+describe('resolveFrameHeadersForPath', () => {
+ const configured: SecurityHeader = {
+ name: 'Content-Security-Policy',
+ value: "frame-ancestors 'self' https://a.example",
+ };
+
+ it.each(['/oauth/consent', '/oauth/consent/nested'])(
+ 'force-denies %s regardless of configured header',
+ (path) => {
+ expect(resolveFrameHeadersForPath(path, configured)).toEqual([
+ { name: 'X-Frame-Options', value: 'DENY' },
+ { name: 'Content-Security-Policy', value: "frame-ancestors 'none'" },
+ ]);
+ },
+ );
+
+ it('force-denies consent even when the global header is absent', () => {
+ expect(resolveFrameHeadersForPath('/oauth/consent', null)).toEqual([
+ { name: 'X-Frame-Options', value: 'DENY' },
+ { name: 'Content-Security-Policy', value: "frame-ancestors 'none'" },
+ ]);
+ });
+
+ it('does not match an unrelated path that merely contains the prefix', () => {
+ expect(
+ resolveFrameHeadersForPath('/oauth/consenting-adults', configured),
+ ).toEqual([configured]);
+ });
+
+ it('passes the configured header through for other paths', () => {
+ expect(resolveFrameHeadersForPath('/home', configured)).toEqual([
+ configured,
+ ]);
+ });
+
+ it('returns nothing for other paths when no header is configured', () => {
+ expect(resolveFrameHeadersForPath('/home', null)).toEqual([]);
+ });
+});
diff --git a/apps/server/src/common/helpers/security-headers.ts b/apps/server/src/common/helpers/security-headers.ts
index 931300e2d..235c9258f 100644
--- a/apps/server/src/common/helpers/security-headers.ts
+++ b/apps/server/src/common/helpers/security-headers.ts
@@ -17,3 +17,19 @@ export function resolveFrameHeader(
value: `frame-ancestors 'self' ${allowedOrigins.join(' ')}`,
};
}
+
+// Deny OAuth consent in iframe
+export const OAUTH_CONSENT_PATH = '/oauth/consent';
+
+export function resolveFrameHeadersForPath(
+ path: string,
+ configuredHeader: SecurityHeader | null,
+): SecurityHeader[] {
+ if (path === OAUTH_CONSENT_PATH || path.startsWith(`${OAUTH_CONSENT_PATH}/`)) {
+ return [
+ { name: 'X-Frame-Options', value: 'DENY' },
+ { name: 'Content-Security-Policy', value: "frame-ancestors 'none'" },
+ ];
+ }
+ return configuredHeader ? [configuredHeader] : [];
+}
diff --git a/apps/server/src/core/attachment/attachment.controller.ts b/apps/server/src/core/attachment/attachment.controller.ts
index 57d4124c3..13e92901f 100644
--- a/apps/server/src/core/attachment/attachment.controller.ts
+++ b/apps/server/src/core/attachment/attachment.controller.ts
@@ -24,6 +24,7 @@ import * as bytes from 'bytes';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
+import { OAuthScope } from '../../common/decorators/oauth-scope.decorator';
import { Attachment, User, Workspace } from '@docmost/db/types/entity.types';
import { StorageService } from '../../integrations/storage/storage.service';
import {
@@ -175,6 +176,7 @@ export class AttachmentController {
@UseGuards(JwtAuthGuard)
@Get('/files/:fileId/:fileName')
+ @OAuthScope('read')
async getFile(
@Req() req: FastifyRequest,
@Res() res: FastifyReply,
@@ -427,6 +429,7 @@ export class AttachmentController {
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('pages/attachments')
+ @OAuthScope('read')
async getPageAttachments(
@Body() dto: PageIdDto,
@Body() pagination: PaginationOptions,
diff --git a/apps/server/src/core/auth/auth.controller.ts b/apps/server/src/core/auth/auth.controller.ts
index 89bb9e1b0..08b7a6303 100644
--- a/apps/server/src/core/auth/auth.controller.ts
+++ b/apps/server/src/core/auth/auth.controller.ts
@@ -12,7 +12,7 @@ import {
} from '@nestjs/common';
import { SkipThrottle, ThrottlerGuard } from '@nestjs/throttler';
import {
- AI_CHAT_THROTTLER,
+ ALL_NAMED_THROTTLERS_SKIPPED,
AUTH_THROTTLER,
} from '../../integrations/throttle/throttler-names';
import { LoginDto } from './dto/login.dto';
@@ -38,7 +38,7 @@ import {
IAuditService,
} from '../../integrations/audit/audit.service';
-@SkipThrottle({ [AI_CHAT_THROTTLER]: true })
+@SkipThrottle({ ...ALL_NAMED_THROTTLERS_SKIPPED, [AUTH_THROTTLER]: false })
@UseGuards(ThrottlerGuard)
@Controller('auth')
export class AuthController {
diff --git a/apps/server/src/core/auth/dto/jwt-payload.ts b/apps/server/src/core/auth/dto/jwt-payload.ts
index b3ccda709..4c249ccb1 100644
--- a/apps/server/src/core/auth/dto/jwt-payload.ts
+++ b/apps/server/src/core/auth/dto/jwt-payload.ts
@@ -7,6 +7,7 @@ export enum JwtType {
API_KEY = 'api_key',
PDF_RENDER = 'pdf_render',
PDF_EXPORT_DOWNLOAD = 'pdf_export_download',
+ OAUTH_ACCESS = 'oauth_access',
}
export type JwtPayload = {
sub: string;
@@ -59,3 +60,14 @@ export type JwtPdfExportDownloadPayload = {
workspaceId: string;
type: 'pdf_export_download';
};
+
+export type JwtOAuthPayload = {
+ sub: string;
+ workspaceId: string;
+ grantId: string;
+ scope: string;
+ aud: string;
+ iss: string;
+ jti: string;
+ type: JwtType.OAUTH_ACCESS;
+};
diff --git a/apps/server/src/core/auth/services/auth.service.ts b/apps/server/src/core/auth/services/auth.service.ts
index bfd8e1a08..45148931e 100644
--- a/apps/server/src/core/auth/services/auth.service.ts
+++ b/apps/server/src/core/auth/services/auth.service.ts
@@ -2,6 +2,7 @@ import {
BadRequestException,
Inject,
Injectable,
+ Logger,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
@@ -40,9 +41,13 @@ import {
IAuditService,
} from '../../../integrations/audit/audit.service';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
+import { EventEmitter2 } from '@nestjs/event-emitter';
+import { EventName } from '../../../common/events/event.contants';
@Injectable()
export class AuthService {
+ private readonly logger = new Logger(AuthService.name);
+
constructor(
private signupService: SignupService,
private tokenService: TokenService,
@@ -53,6 +58,7 @@ export class AuthService {
private mailService: MailService,
private domainService: DomainService,
private environmentService: EnvironmentService,
+ private eventEmitter: EventEmitter2,
@InjectKysely() private readonly db: KyselyDB,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
@@ -261,6 +267,19 @@ export class AuthService {
await this.userSessionRepo.deleteByUserId(user.id, workspace.id);
+ // A failed revocation must not block the reset itself; log loudly instead.
+ try {
+ await this.eventEmitter.emitAsync(EventName.USER_PASSWORD_RESET, {
+ userId: user.id,
+ workspaceId: workspace.id,
+ });
+ } catch (err) {
+ this.logger.error(
+ `failed to revoke oauth grants for user ${user.id} after password reset`,
+ err,
+ );
+ }
+
this.auditService.setActorId(user.id);
this.auditService.log({
event: AuditEvent.USER_PASSWORD_RESET,
diff --git a/apps/server/src/core/auth/strategies/jwt.strategy.ts b/apps/server/src/core/auth/strategies/jwt.strategy.ts
index d861eddf0..c74fa3e0c 100644
--- a/apps/server/src/core/auth/strategies/jwt.strategy.ts
+++ b/apps/server/src/core/auth/strategies/jwt.strategy.ts
@@ -2,7 +2,12 @@ import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
-import { JwtApiKeyPayload, JwtPayload, JwtType } from '../dto/jwt-payload';
+import {
+ JwtApiKeyPayload,
+ JwtOAuthPayload,
+ JwtPayload,
+ JwtType,
+} from '../dto/jwt-payload';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
@@ -33,7 +38,10 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
});
}
- async validate(req: any, payload: JwtPayload | JwtApiKeyPayload) {
+ async validate(
+ req: any,
+ payload: JwtPayload | JwtApiKeyPayload | JwtOAuthPayload,
+ ) {
if (!payload.workspaceId) {
throw new UnauthorizedException();
}
@@ -42,8 +50,21 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
throw new UnauthorizedException('Workspace does not match');
}
+ // authType lets guards tell an interactive session from a programmatic credential.
if (payload.type === JwtType.API_KEY) {
- return this.validateApiKey(req, payload as JwtApiKeyPayload);
+ const authResult = await this.validateApiKey(
+ req,
+ payload as JwtApiKeyPayload,
+ );
+ return { ...authResult, authType: JwtType.API_KEY };
+ }
+
+ if (payload.type === JwtType.OAUTH_ACCESS) {
+ const authResult = await this.validateOAuthToken(
+ req,
+ payload as JwtOAuthPayload,
+ );
+ return { ...authResult, authType: JwtType.OAUTH_ACCESS };
}
if (payload.type !== JwtType.ACCESS) {
@@ -71,7 +92,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
this.sessionActivityService.trackActivity(sessionId, payload.sub, payload.workspaceId);
}
- return { user, workspace };
+ return { user, workspace, authType: JwtType.ACCESS };
}
private async validateApiKey(req: any, payload: JwtApiKeyPayload) {
@@ -99,4 +120,36 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
throw new UnauthorizedException('Enterprise API Key module missing');
}
+
+ private async validateOAuthToken(req: any, payload: JwtOAuthPayload) {
+ let OAuthStrategyModule: any;
+ let isOAuthModuleReady = false;
+
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ OAuthStrategyModule = require('./../../../ee/oauth/services/oauth-strategy.service');
+ isOAuthModuleReady = true;
+ } catch (err) {
+ this.logger.debug(
+ 'OAuth module requested but enterprise module not bundled in this build',
+ );
+ isOAuthModuleReady = false;
+ }
+
+ if (isOAuthModuleReady) {
+ const OAuthStrategyService = this.moduleRef.get(
+ OAuthStrategyModule.OAuthStrategyService,
+ {
+ strict: false,
+ },
+ );
+
+ return OAuthStrategyService.validateOAuthToken(payload, {
+ workspaceId: req.raw.workspaceId,
+ host: req.raw.headers?.host ?? req.headers?.host,
+ });
+ }
+
+ throw new UnauthorizedException('Enterprise OAuth module missing');
+ }
}
diff --git a/apps/server/src/core/comment/comment.controller.ts b/apps/server/src/core/comment/comment.controller.ts
index 132fc6e77..3902c5846 100644
--- a/apps/server/src/core/comment/comment.controller.ts
+++ b/apps/server/src/core/comment/comment.controller.ts
@@ -16,6 +16,7 @@ import { PageIdDto, CommentIdDto } from './dto/comments.input';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
+import { OAuthScope } from '../../common/decorators/oauth-scope.decorator';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { User, Workspace } from '@docmost/db/types/entity.types';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
@@ -48,6 +49,7 @@ export class CommentController {
@HttpCode(HttpStatus.OK)
@Post('create')
+ @OAuthScope('write')
async create(
@Body() createCommentDto: CreateCommentDto,
@AuthUser() user: User,
@@ -84,6 +86,7 @@ export class CommentController {
@HttpCode(HttpStatus.OK)
@Post('/')
+ @OAuthScope('read')
async findPageComments(
@Body() input: PageIdDto,
@Body()
@@ -125,6 +128,7 @@ export class CommentController {
@HttpCode(HttpStatus.OK)
@Post('update')
+ @OAuthScope('write')
async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) {
const comment = await this.commentRepo.findById(dto.commentId, {
includeCreator: true,
diff --git a/apps/server/src/core/page/page.controller.ts b/apps/server/src/core/page/page.controller.ts
index 773774ea7..397eed259 100644
--- a/apps/server/src/core/page/page.controller.ts
+++ b/apps/server/src/core/page/page.controller.ts
@@ -26,6 +26,7 @@ import { PageHistoryService } from './services/page-history.service';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
+import { OAuthScope } from '../../common/decorators/oauth-scope.decorator';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { Page, User, Workspace } from '@docmost/db/types/entity.types';
import { SidebarPageDto } from './dto/sidebar-page.dto';
@@ -69,6 +70,7 @@ export class PageController {
@HttpCode(HttpStatus.OK)
@Post('/info')
+ @OAuthScope('read')
async getPage(@Body() dto: PageInfoDto, @AuthUser() user: User) {
const page = await this.pageRepo.findById(dto.pageId, {
includeSpace: true,
@@ -199,6 +201,7 @@ export class PageController {
@HttpCode(HttpStatus.OK)
@Post('create')
+ @OAuthScope('write')
async create(
@Body() createPageDto: CreatePageDto,
@AuthUser() user: User,
@@ -269,6 +272,7 @@ export class PageController {
@HttpCode(HttpStatus.OK)
@Post('update')
+ @OAuthScope('write')
async update(@Body() updatePageDto: UpdatePageDto, @AuthUser() user: User) {
const page = await this.pageRepo.findById(updatePageDto.pageId);
@@ -413,6 +417,7 @@ export class PageController {
@HttpCode(HttpStatus.OK)
@Post('recent')
+ @OAuthScope('read')
async getRecentPages(
@Body() recentPageDto: RecentPageDto,
@Body() pagination: PaginationOptions,
@@ -527,6 +532,7 @@ export class PageController {
@HttpCode(HttpStatus.OK)
@Post('/sidebar-pages')
+ @OAuthScope('read')
async getSidebarPages(
@Body() dto: SidebarPageDto,
@Body() pagination: PaginationOptions,
@@ -569,6 +575,7 @@ export class PageController {
@HttpCode(HttpStatus.OK)
@Post('move-to-space')
+ @OAuthScope('write')
async movePageToSpace(
@Body() dto: MovePageToSpaceDto,
@AuthUser() user: User,
@@ -622,6 +629,7 @@ export class PageController {
@HttpCode(HttpStatus.OK)
@Post('duplicate')
+ @OAuthScope('write')
async duplicatePage(@Body() dto: DuplicatePageDto, @AuthUser() user: User) {
const copiedPage = await this.pageRepo.findById(dto.pageId);
if (!copiedPage) {
@@ -706,6 +714,7 @@ export class PageController {
@HttpCode(HttpStatus.OK)
@Post('move')
+ @OAuthScope('write')
async movePage(@Body() dto: MovePageDto, @AuthUser() user: User) {
const movedPage = await this.pageRepo.findById(dto.pageId);
if (!movedPage) {
diff --git a/apps/server/src/core/search/search.controller.ts b/apps/server/src/core/search/search.controller.ts
index c968c3443..e060d8a8c 100644
--- a/apps/server/src/core/search/search.controller.ts
+++ b/apps/server/src/core/search/search.controller.ts
@@ -17,6 +17,7 @@ import {
} from './dto/search.dto';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
+import { OAuthScope } from '../../common/decorators/oauth-scope.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import {
@@ -42,6 +43,7 @@ export class SearchController {
@HttpCode(HttpStatus.OK)
@Post()
+ @OAuthScope('read')
async pageSearch(
@Body() searchDto: SearchDTO,
@AuthUser() user: User,
@@ -75,6 +77,7 @@ export class SearchController {
@HttpCode(HttpStatus.OK)
@Post('suggest')
+ @OAuthScope('read')
async searchSuggestions(
@Body() dto: SearchSuggestionDTO,
@AuthUser() user: User,
diff --git a/apps/server/src/core/space/space.controller.ts b/apps/server/src/core/space/space.controller.ts
index 74dfebf30..7ba8509aa 100644
--- a/apps/server/src/core/space/space.controller.ts
+++ b/apps/server/src/core/space/space.controller.ts
@@ -13,6 +13,7 @@ import { SpaceService } from './services/space.service';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
+import { OAuthScope } from '../../common/decorators/oauth-scope.decorator';
import { SpaceIdDto } from './dto/space-id.dto';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { SpaceMemberService } from './services/space-member.service';
@@ -48,6 +49,7 @@ export class SpaceController {
@HttpCode(HttpStatus.OK)
@Post('/')
+ @OAuthScope('read')
async getWorkspaceSpaces(
@Body()
pagination: PaginationOptions,
@@ -92,6 +94,7 @@ export class SpaceController {
@HttpCode(HttpStatus.OK)
@Post('info')
+ @OAuthScope('read')
async getSpaceInfo(
@Body() spaceIdDto: SpaceIdDto,
@AuthUser() user: User,
@@ -129,6 +132,7 @@ export class SpaceController {
@HttpCode(HttpStatus.OK)
@Post('create')
+ @OAuthScope('write')
createSpace(
@Body() createSpaceDto: CreateSpaceDto,
@AuthUser() user: User,
@@ -145,6 +149,7 @@ export class SpaceController {
@HttpCode(HttpStatus.OK)
@Post('update')
+ @OAuthScope('write')
async updateSpace(
@Body() updateSpaceDto: UpdateSpaceDto,
@AuthUser() user: User,
diff --git a/apps/server/src/core/user/user.controller.ts b/apps/server/src/core/user/user.controller.ts
index 8d51ce6b7..3184f2277 100644
--- a/apps/server/src/core/user/user.controller.ts
+++ b/apps/server/src/core/user/user.controller.ts
@@ -10,6 +10,7 @@ import { UserService } from './user.service';
import { UpdateUserDto } from './dto/update-user.dto';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
+import { OAuthScope } from '../../common/decorators/oauth-scope.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
@@ -24,6 +25,7 @@ export class UserController {
@HttpCode(HttpStatus.OK)
@Post('me')
+ @OAuthScope('read')
async getUserInfo(
@AuthUser() authUser: User,
@AuthWorkspace() workspace: Workspace,
diff --git a/apps/server/src/core/workspace/controllers/workspace.controller.ts b/apps/server/src/core/workspace/controllers/workspace.controller.ts
index f12499982..dd33ae39b 100644
--- a/apps/server/src/core/workspace/controllers/workspace.controller.ts
+++ b/apps/server/src/core/workspace/controllers/workspace.controller.ts
@@ -24,6 +24,7 @@ import {
RevokeInviteDto,
} from '../dto/invitation.dto';
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
+import { OAuthScope } from '../../../common/decorators/oauth-scope.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.factory';
import {
@@ -58,6 +59,7 @@ export class WorkspaceController {
@HttpCode(HttpStatus.OK)
@Post('/info')
+ @OAuthScope('read')
async getWorkspace(@AuthWorkspace() workspace: Workspace) {
return this.workspaceService.getWorkspaceInfo(workspace.id);
}
@@ -113,6 +115,7 @@ export class WorkspaceController {
@HttpCode(HttpStatus.OK)
@Post('members')
+ @OAuthScope('read')
async getWorkspaceMembers(
@Body()
pagination: PaginationOptions,
diff --git a/apps/server/src/core/workspace/dto/update-workspace.dto.ts b/apps/server/src/core/workspace/dto/update-workspace.dto.ts
index 63b143142..55b19283d 100644
--- a/apps/server/src/core/workspace/dto/update-workspace.dto.ts
+++ b/apps/server/src/core/workspace/dto/update-workspace.dto.ts
@@ -76,4 +76,8 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional()
@IsBoolean()
aiChatWorkspaceKnowledgeOnly: boolean;
+
+ @IsOptional()
+ @IsBoolean()
+ mcpOauthOnly: boolean;
}
diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts
index e443bc0df..efb10a647 100644
--- a/apps/server/src/core/workspace/services/workspace.service.ts
+++ b/apps/server/src/core/workspace/services/workspace.service.ts
@@ -336,7 +336,8 @@ export class WorkspaceService {
typeof updateWorkspaceDto.isScimEnabled !== 'undefined' ||
typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined' ||
typeof updateWorkspaceDto.aiChatReadOnly !== 'undefined' ||
- typeof updateWorkspaceDto.aiChatWorkspaceKnowledgeOnly !== 'undefined'
+ typeof updateWorkspaceDto.aiChatWorkspaceKnowledgeOnly !== 'undefined' ||
+ typeof updateWorkspaceDto.mcpOauthOnly !== 'undefined'
) {
const ws = await this.db
.selectFrom('workspaces')
@@ -391,6 +392,18 @@ export class WorkspaceService {
}
}
+ if (typeof updateWorkspaceDto.mcpOauthOnly !== 'undefined') {
+ if (
+ !this.licenseCheckService.hasFeature(
+ ws.licenseKey,
+ Feature.MCP_CONTROLS,
+ ws.plan,
+ )
+ ) {
+ throw new ForbiddenException('This feature requires a valid license');
+ }
+ }
+
if (
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
@@ -561,6 +574,20 @@ export class WorkspaceService {
);
}
+ if (typeof updateWorkspaceDto.mcpOauthOnly !== 'undefined') {
+ const prev = settingsBefore?.ai?.mcpOauthOnly ?? false;
+ if (prev !== updateWorkspaceDto.mcpOauthOnly) {
+ before.mcpOauthOnly = prev;
+ after.mcpOauthOnly = updateWorkspaceDto.mcpOauthOnly;
+ }
+ await this.workspaceRepo.updateAiSettings(
+ workspaceId,
+ 'mcpOauthOnly',
+ updateWorkspaceDto.mcpOauthOnly,
+ trx,
+ );
+ }
+
if (typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined') {
const prev = settingsBefore?.spaces?.allowPersonal ?? false;
if (prev !== updateWorkspaceDto.allowPersonalSpaces) {
@@ -600,6 +627,7 @@ export class WorkspaceService {
delete updateWorkspaceDto.defaultPageEditMode;
delete updateWorkspaceDto.aiChatReadOnly;
delete updateWorkspaceDto.aiChatWorkspaceKnowledgeOnly;
+ delete updateWorkspaceDto.mcpOauthOnly;
await this.workspaceRepo.updateWorkspace(
updateWorkspaceDto,
diff --git a/apps/server/src/database/migrations/20260825T022612-oauth.ts b/apps/server/src/database/migrations/20260825T022612-oauth.ts
new file mode 100644
index 000000000..0ba5d5937
--- /dev/null
+++ b/apps/server/src/database/migrations/20260825T022612-oauth.ts
@@ -0,0 +1,88 @@
+import { Kysely, sql } from 'kysely';
+
+export async function up(db: Kysely): Promise {
+ await db.schema
+ .createTable('oauth_clients')
+ .addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`gen_uuid_v7()`))
+ .addColumn('name', 'text', (col) => col.notNull())
+ .addColumn('redirect_uris', 'jsonb', (col) => col.notNull())
+ .addColumn('client_uri', 'text')
+ .addColumn('logo_uri', 'text')
+ .addColumn('grant_types', 'jsonb', (col) => col.notNull())
+ .addColumn('scopes', 'jsonb', (col) => col.notNull())
+ .addColumn('token_endpoint_auth_method', 'text', (col) => col.notNull().defaultTo('none'))
+ .addColumn('secret_hash', 'text')
+ .addColumn('is_dynamic', 'boolean', (col) => col.notNull().defaultTo(true))
+ .addColumn('workspace_id', 'uuid', (col) => col.notNull().references('workspaces.id').onDelete('cascade'))
+ .addColumn('created_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`))
+ .addColumn('updated_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`))
+ .addColumn('deleted_at', 'timestamptz')
+ .execute();
+ await db.schema.createIndex('oauth_clients_workspace_id_idx').on('oauth_clients').column('workspace_id').execute();
+
+ await db.schema
+ .createTable('oauth_authorization_codes')
+ .addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`gen_uuid_v7()`))
+ .addColumn('code_hash', 'text', (col) => col.notNull().unique())
+ .addColumn('client_id', 'uuid', (col) => col.notNull().references('oauth_clients.id').onDelete('cascade'))
+ .addColumn('user_id', 'uuid', (col) => col.notNull().references('users.id').onDelete('cascade'))
+ .addColumn('workspace_id', 'uuid', (col) => col.notNull().references('workspaces.id').onDelete('cascade'))
+ .addColumn('scopes', 'jsonb', (col) => col.notNull())
+ .addColumn('redirect_uri', 'text', (col) => col.notNull())
+ .addColumn('code_challenge', 'text')
+ .addColumn('code_challenge_method', 'text')
+ .addColumn('expires_at', 'timestamptz', (col) => col.notNull())
+ .addColumn('consumed_at', 'timestamptz')
+ .addColumn('created_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`))
+ .execute();
+
+ await db.schema.createIndex('oauth_authorization_codes_expires_at_idx').on('oauth_authorization_codes').column('expires_at').execute();
+ await db.schema.createIndex('oauth_authorization_codes_consumed_at_idx').on('oauth_authorization_codes').column('consumed_at').execute();
+ await db.schema.createIndex('oauth_authorization_codes_client_id_idx').on('oauth_authorization_codes').column('client_id').execute();
+ await db.schema.createIndex('oauth_authorization_codes_user_id_idx').on('oauth_authorization_codes').column('user_id').execute();
+ await db.schema.createIndex('oauth_authorization_codes_workspace_id_idx').on('oauth_authorization_codes').column('workspace_id').execute();
+
+ await db.schema
+ .createTable('oauth_grants')
+ .addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`gen_uuid_v7()`))
+ .addColumn('user_id', 'uuid', (col) => col.notNull().references('users.id').onDelete('cascade'))
+ .addColumn('client_id', 'uuid', (col) => col.notNull().references('oauth_clients.id').onDelete('cascade'))
+ .addColumn('workspace_id', 'uuid', (col) => col.notNull().references('workspaces.id').onDelete('cascade'))
+ .addColumn('scopes', 'jsonb', (col) => col.notNull())
+ .addColumn('created_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`))
+ .addColumn('updated_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`))
+ .addColumn('last_used_at', 'timestamptz')
+ .addColumn('revoked_at', 'timestamptz')
+ .addUniqueConstraint('oauth_grants_user_client_unique', ['user_id', 'client_id'])
+ .execute();
+
+ // The user_id/client_id unique constraint cannot serve client-side FK lookups.
+ await db.schema.createIndex('oauth_grants_client_id_idx').on('oauth_grants').column('client_id').execute();
+
+ await db.schema
+ .createTable('oauth_tokens')
+ .addColumn('id', 'uuid', (col) => col.primaryKey().defaultTo(sql`gen_uuid_v7()`))
+ .addColumn('grant_id', 'uuid', (col) => col.notNull().references('oauth_grants.id').onDelete('cascade'))
+ .addColumn('workspace_id', 'uuid', (col) => col.notNull().references('workspaces.id').onDelete('cascade'))
+ .addColumn('access_token_jti', 'text', (col) => col.notNull())
+ .addColumn('refresh_token_hash', 'text', (col) => col.unique())
+ .addColumn('scopes', 'jsonb', (col) => col.notNull())
+ .addColumn('access_expires_at', 'timestamptz', (col) => col.notNull())
+ .addColumn('refresh_expires_at', 'timestamptz')
+ .addColumn('revoked_at', 'timestamptz')
+ .addColumn('created_at', 'timestamptz', (col) => col.notNull().defaultTo(sql`now()`))
+ .execute();
+ await db.schema.createIndex('oauth_tokens_grant_id_idx').on('oauth_tokens').column('grant_id').execute();
+ await db.schema.createIndex('oauth_tokens_access_token_jti_idx').on('oauth_tokens').columns(['workspace_id', 'access_token_jti']).execute();
+ // One index per branch of the cleanup sweep's OR so it can bitmap-or them.
+ await db.schema.createIndex('oauth_tokens_access_expires_at_idx').on('oauth_tokens').column('access_expires_at').execute();
+ await db.schema.createIndex('oauth_tokens_refresh_expires_at_idx').on('oauth_tokens').column('refresh_expires_at').execute();
+ await db.schema.createIndex('oauth_tokens_revoked_at_idx').on('oauth_tokens').column('revoked_at').execute();
+}
+
+export async function down(db: Kysely): Promise {
+ await db.schema.dropTable('oauth_tokens').execute();
+ await db.schema.dropTable('oauth_grants').execute();
+ await db.schema.dropTable('oauth_authorization_codes').execute();
+ await db.schema.dropTable('oauth_clients').execute();
+}
diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts
index 4756c2636..25060c71a 100644
--- a/apps/server/src/database/types/db.d.ts
+++ b/apps/server/src/database/types/db.d.ts
@@ -636,6 +636,63 @@ export interface UserSessions {
createdAt: Generated;
}
+export interface OauthAuthorizationCodes {
+ clientId: string;
+ codeChallenge: string | null;
+ codeChallengeMethod: string | null;
+ codeHash: string;
+ consumedAt: Timestamp | null;
+ createdAt: Generated;
+ expiresAt: Timestamp;
+ id: Generated;
+ redirectUri: string;
+ scopes: Json;
+ userId: string;
+ workspaceId: string;
+}
+
+export interface OauthClients {
+ clientUri: string | null;
+ createdAt: Generated;
+ deletedAt: Timestamp | null;
+ grantTypes: Json;
+ id: Generated;
+ isDynamic: Generated;
+ logoUri: string | null;
+ name: string;
+ redirectUris: Json;
+ scopes: Json;
+ secretHash: string | null;
+ tokenEndpointAuthMethod: Generated;
+ updatedAt: Generated;
+ workspaceId: string;
+}
+
+export interface OauthGrants {
+ clientId: string;
+ createdAt: Generated;
+ id: Generated;
+ lastUsedAt: Timestamp | null;
+ revokedAt: Timestamp | null;
+ scopes: Json;
+ updatedAt: Generated;
+ userId: string;
+ workspaceId: string;
+}
+
+export interface OauthTokens {
+ accessExpiresAt: Timestamp;
+ accessTokenJti: string;
+ createdAt: Generated;
+ grantId: string;
+ id: Generated;
+ refreshExpiresAt: Timestamp | null;
+ refreshTokenHash: string | null;
+ revokedAt: Timestamp | null;
+ scopes: Json;
+ workspaceId: string;
+}
+
export interface DB {
aiChats: AiChats;
aiChatMessages: AiChatMessages;
@@ -677,4 +734,8 @@ export interface DB {
watchers: Watchers;
workspaceInvitations: WorkspaceInvitations;
workspaces: Workspaces;
+ oauthAuthorizationCodes: OauthAuthorizationCodes;
+ oauthClients: OauthClients;
+ oauthGrants: OauthGrants;
+ oauthTokens: OauthTokens;
}
diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts
index e8aa0572c..c7d2af0a0 100644
--- a/apps/server/src/database/types/entity.types.ts
+++ b/apps/server/src/database/types/entity.types.ts
@@ -40,6 +40,10 @@ import {
Watchers,
Audit as _Audit,
Templates,
+ OauthClients,
+ OauthAuthorizationCodes,
+ OauthGrants,
+ OauthTokens,
} from './db';
import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
@@ -184,6 +188,13 @@ export type ScimToken = Selectable;
export type InsertableScimToken = Insertable;
export type UpdatableScimToken = Updateable>;
+// OAuth
+export type OAuthClient = Selectable;
+export type InsertableOAuthClient = Insertable;
+export type OAuthGrant = Selectable;
+export type OAuthAuthorizationCode = Selectable;
+export type OAuthToken = Selectable;
+
// Page Embedding
export type PageEmbedding = Selectable;
export type InsertablePageEmbedding = Insertable;
diff --git a/apps/server/src/ee b/apps/server/src/ee
index 4c837b763..acae98e81 160000
--- a/apps/server/src/ee
+++ b/apps/server/src/ee
@@ -1 +1 @@
-Subproject commit 4c837b76355bd5cc25b974f91a4bee3624290989
+Subproject commit acae98e81903e0331ee5791bfeac044d582f3107
diff --git a/apps/server/src/integrations/throttle/throttle.module.ts b/apps/server/src/integrations/throttle/throttle.module.ts
index 827f671c1..e22eddc75 100644
--- a/apps/server/src/integrations/throttle/throttle.module.ts
+++ b/apps/server/src/integrations/throttle/throttle.module.ts
@@ -4,7 +4,13 @@ import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis'
import { EnvironmentService } from '../environment/environment.service';
import { EnvironmentModule } from '../environment/environment.module';
import { createRetryStrategy, parseRedisUrl } from '../../common/helpers';
-import { AUTH_THROTTLER, AI_CHAT_THROTTLER } from './throttler-names';
+import {
+ AUTH_THROTTLER,
+ AI_CHAT_THROTTLER,
+ OAUTH_REGISTER_THROTTLER,
+ OAUTH_TOKEN_THROTTLER,
+ OAUTH_AUTHORIZE_THROTTLER,
+} from './throttler-names';
import Redis from 'ioredis';
@Module({
@@ -18,6 +24,9 @@ import Redis from 'ioredis';
throttlers: [
{ name: AUTH_THROTTLER, ttl: 60_000, limit: 10 },
{ name: AI_CHAT_THROTTLER, ttl: 60_000, limit: 25 },
+ { name: OAUTH_REGISTER_THROTTLER, ttl: 3_600_000, limit: 10 },
+ { name: OAUTH_TOKEN_THROTTLER, ttl: 60_000, limit: 60 },
+ { name: OAUTH_AUTHORIZE_THROTTLER, ttl: 60_000, limit: 30 },
],
errorMessage: 'Too many requests',
storage: new ThrottlerStorageRedisService(
diff --git a/apps/server/src/integrations/throttle/throttler-names.ts b/apps/server/src/integrations/throttle/throttler-names.ts
index 388ba29d7..898982976 100644
--- a/apps/server/src/integrations/throttle/throttler-names.ts
+++ b/apps/server/src/integrations/throttle/throttler-names.ts
@@ -1,2 +1,14 @@
export const AUTH_THROTTLER = 'auth';
export const AI_CHAT_THROTTLER = 'ai-chat';
+export const OAUTH_REGISTER_THROTTLER = 'oauth-register';
+export const OAUTH_TOKEN_THROTTLER = 'oauth-token';
+export const OAUTH_AUTHORIZE_THROTTLER = 'oauth-authorize';
+
+// Every named throttler must appear here; spread it in @SkipThrottle and re-enable per name with false.
+export const ALL_NAMED_THROTTLERS_SKIPPED: Record = {
+ [AUTH_THROTTLER]: true,
+ [AI_CHAT_THROTTLER]: true,
+ [OAUTH_REGISTER_THROTTLER]: true,
+ [OAUTH_TOKEN_THROTTLER]: true,
+ [OAUTH_AUTHORIZE_THROTTLER]: true,
+};
diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
index 1c2ccebf1..b6db64627 100644
--- a/apps/server/src/main.ts
+++ b/apps/server/src/main.ts
@@ -13,7 +13,10 @@ import fastifyCookie from '@fastify/cookie';
import fastifyIp from 'fastify-ip';
import { InternalLogFilter } from './common/logger/internal-log-filter';
import { EnvironmentService } from './integrations/environment/environment.service';
-import { resolveFrameHeader } from './common/helpers';
+import {
+ resolveFrameHeader,
+ resolveFrameHeadersForPath,
+} from './common/helpers';
async function bootstrap() {
const app = await NestFactory.create(
@@ -39,7 +42,14 @@ async function bootstrap() {
app.useLogger(app.get(PinoLogger));
app.setGlobalPrefix('api', {
- exclude: ['robots.txt', 'share/:shareId/p/:pageSlug', 'mcp'],
+ exclude: [
+ 'robots.txt',
+ 'share/:shareId/p/:pageSlug',
+ 'mcp',
+ '.well-known/oauth-authorization-server',
+ '.well-known/oauth-protected-resource',
+ '.well-known/oauth-protected-resource/mcp',
+ ],
});
const reflector = app.get(Reflector);
@@ -57,22 +67,24 @@ async function bootstrap() {
environmentService.isIframeEmbedAllowed(),
environmentService.getIframeAllowedOrigins(),
);
- if (frameHeader) {
- // Skipped routes:
- // /api/files/ - attachment controller sets its own CSP we'd overwrite
- // /share/ 0 public share pages are safe to embed
- const frameHeaderSkippedPrefixes = ['/api/files/', '/share/'];
- app
- .getHttpAdapter()
- .getInstance()
- .addHook('onSend', (req, reply, payload, done) => {
- if (frameHeaderSkippedPrefixes.some((p) => req.url.startsWith(p))) {
- return done(null, payload);
- }
- reply.header(frameHeader.name, frameHeader.value);
- done(null, payload);
- });
- }
+ // Skipped routes:
+ // /api/files/ - attachment controller sets its own CSP we'd overwrite
+ // /share/ - public share pages are safe to embed
+ const frameHeaderSkippedPrefixes = ['/api/files/', '/share/'];
+ app
+ .getHttpAdapter()
+ .getInstance()
+ .addHook('onSend', (req, reply, payload, done) => {
+ if (frameHeaderSkippedPrefixes.some((p) => req.url.startsWith(p))) {
+ return done(null, payload);
+ }
+ const path = req.url.split('?')[0];
+ // Force-denies the oauth consent screen even when the global frame header is absent.
+ for (const header of resolveFrameHeadersForPath(path, frameHeader)) {
+ reply.header(header.name, header.value);
+ }
+ done(null, payload);
+ });
app
.getHttpAdapter()
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 60e6662b7..109214fe8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -527,6 +527,9 @@ importers:
'@fastify/static':
specifier: 10.1.3
version: 10.1.3
+ '@jmondi/oauth2-server':
+ specifier: ^4.3.7
+ version: 4.3.7
'@keyv/redis':
specifier: 5.1.6
version: 5.1.6(keyv@5.6.0)
@@ -2525,6 +2528,10 @@ packages:
resolution: {integrity: sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
+ '@jmondi/oauth2-server@4.3.7':
+ resolution: {integrity: sha512-Mo0HHIB/TVRETVSxzvmyclfNPn0HnvSyrLoqvrtlk0z3oWMUr1CFRc7msDNQm6vRJIbRGnCbNKuAwXiNaS6xfw==}
+ engines: {node: '>=16'}
+
'@joplin/turndown-plugin-gfm@1.0.64':
resolution: {integrity: sha512-8GJ7f9OenE3zkSVII5B6qzIkvgF7C/a20gaASEjM6jWPLPJFFQ2nQ3Ou/kXH1mPUTs9dC9VYs8QXVPvZabKXBQ==}
@@ -4994,6 +5001,7 @@ packages:
'@xmldom/xmldom@0.8.13':
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
engines: {node: '>=10.0.0'}
+ deprecated: this version has critical issues, please update to the latest version
'@xtuc/ieee754@1.2.0':
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
@@ -6322,6 +6330,7 @@ packages:
eslint@9.28.0:
resolution: {integrity: sha512-ocgh41VhRlf9+fVpe7QKzwLj9c92fDiqOj8Y3Sd4/ZmVA4Btx4PlUYPq4pp9JDyupkf1upbEXecxL2mwNV7jPQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
hasBin: true
peerDependencies:
jiti: '*'
@@ -12411,6 +12420,12 @@ snapshots:
'@types/yargs': 17.0.35
chalk: 4.1.2
+ '@jmondi/oauth2-server@4.3.7':
+ dependencies:
+ jsonwebtoken: 9.0.3
+ ms: 2.1.3
+ uri-js: 4.4.1
+
'@joplin/turndown-plugin-gfm@1.0.64': {}
'@joplin/turndown@4.0.82(supports-color@7.2.0)':