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 ? ( - - - - ) : restrictToAdmins ? ( - }> - - {t("API key creation is restricted to admins by your workspace administrator.")} + + + + {t("API keys")} + + + {t("Authorized apps")} + + + + + + + + , + }} + /> - - ) : null} - + {canCreate && ( + + )} + - + {!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.")} + + )} + + + + + +
+ ); +} 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)':