diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 03e10c53e..e93010c68 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -706,9 +706,8 @@ "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 with your Docmost account via OAuth when your client supports it, or use an API key from your account settings.": "Connect with your Docmost account via OAuth when your client supports it, or use an API key from your account settings.", "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.", @@ -1302,5 +1301,36 @@ "Error loading attachments.": "Error loading attachments.", "No attachments on this page yet.": "No attachments on this page yet.", "Uploaded by {{name}}": "Uploaded by {{name}}", - "Download {{name}}": "Download {{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.", + "Trusted applications": "Trusted applications", + "Remove {{name}}": "Remove {{name}}", + "This origin is already trusted.": "This origin is already trusted.", + "Trusted application name": "Trusted application name", + "Trusted application origin": "Trusted application origin", + "I recognize this application and want to continue": "I recognize this application and want to continue", + "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.", + "This application is not on your workspace's trusted list. Authorize only if you recognize it.": "This application is not on your workspace's trusted list. Authorize only if you recognize it.", + "Applications with these callback origins are trusted. Members will not see a warning when authorizing them.": "Applications with these callback origins are trusted. Members will not see a warning when authorizing them.", + "Enter the app's callback origin, e.g. https://app.yourcompany.com": "Enter the app's callback origin, e.g. https://app.yourcompany.com", + "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, or with an API key.": "Your workspace has MCP enabled. Connect AI assistants with your Docmost account, or with an API key.", + "Authorized apps": "Authorized apps", + "No authorized apps yet.": "No authorized apps yet." } diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index ab291ffea..8d12779cc 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -47,6 +47,7 @@ import FavoritesPage from "@/pages/favorites/favorites-page"; import AiChat from "@/ee/ai-chat/pages/ai-chat.tsx"; import VerifyEmail from "@/ee/pages/verify-email.tsx"; import LabelPage from "@/pages/label/label-page"; +import OAuthConsent from "@/ee/oauth/pages/oauth-consent.tsx"; export default function App() { const { t } = useTranslation(); @@ -63,6 +64,7 @@ export default function App() { } /> } /> } /> + } /> {!isCloud() && ( } /> @@ -116,6 +118,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..102da44e3 100644 --- a/apps/client/src/ee/ai/components/mcp-settings.tsx +++ b/apps/client/src/ee/ai/components/mcp-settings.tsx @@ -1,7 +1,9 @@ import { Anchor, + Button, Group, List, + Table, Text, Switch, TextInput, @@ -15,14 +17,33 @@ import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; import React, { useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts"; +import { ITrustedOAuthClient } from "@/features/workspace/types/workspace.types.ts"; import { notifications } from "@mantine/notifications"; import { useHasFeature } from "@/ee/hooks/use-feature"; import { Feature } from "@/ee/features"; import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label"; import { getAppUrl } from "@/lib/config.ts"; -import { IconCheck, IconCopy, IconInfoCircle } from "@tabler/icons-react"; +import { + IconCheck, + IconCopy, + IconInfoCircle, + IconTrash, +} from "@tabler/icons-react"; import { CopyButton } from "@/components/common/copy-button.tsx"; +// Mirrors the server rule: an exact https origin, tolerating only a trailing slash. +function parseTrustedOrigin(value: string): string | null { + const input = value.trim().toLowerCase(); + try { + const url = new URL(input); + if (url.protocol !== "https:") return null; + if (input !== url.origin && input !== `${url.origin}/`) return null; + return url.origin; + } catch { + return null; + } +} + export default function McpSettings() { const { t } = useTranslation(); const [workspace, setWorkspace] = useAtom(workspaceAtom); @@ -30,7 +51,14 @@ export default function McpSettings() { const hasAccess = useHasFeature(Feature.MCP); const upgradeLabel = useUpgradeLabel(); + const [newClientName, setNewClientName] = useState(""); + const [newClientOrigin, setNewClientOrigin] = useState(""); + const mcpUrl = `${getAppUrl()}/mcp`; + const storedTrustedClients = workspace?.trustedOauthClients; + const trustedClients = Array.isArray(storedTrustedClients) + ? storedTrustedClients + : []; const handleChange = async (event: React.ChangeEvent) => { const value = event.currentTarget.checked; @@ -46,6 +74,51 @@ export default function McpSettings() { } }; + const saveTrustedClients = async (next: ITrustedOAuthClient[]) => { + try { + const updatedWorkspace = await updateWorkspace({ + trustedOauthClients: next, + }); + setWorkspace(updatedWorkspace); + return true; + } catch (err) { + notifications.show({ + message: err?.response?.data?.message, + color: "red", + }); + return false; + } + }; + + const handleAddTrustedClient = async () => { + const name = newClientName.trim(); + const origin = parseTrustedOrigin(newClientOrigin); + if (!origin) { + notifications.show({ + message: t("Enter the app's callback origin, e.g. https://app.yourcompany.com"), + color: "red", + }); + return; + } + if (trustedClients.some((client) => client.origin.toLowerCase() === origin)) { + notifications.show({ + message: t("This origin is already trusted."), + color: "red", + }); + return; + } + if (await saveTrustedClients([...trustedClients, { origin, name }])) { + setNewClientName(""); + setNewClientOrigin(""); + } + }; + + const handleRemoveTrustedClient = (origin: string) => { + void saveTrustedClients( + trustedClients.filter((client) => client.origin !== origin), + ); + }; + return ( {!hasAccess && ( @@ -108,7 +181,7 @@ export default function McpSettings() { {t( - "Use your API key for authentication. You can manage API keys in your account settings.", + "Connect with your Docmost account via OAuth when your client supports it, or use an API key from your account settings.", )} @@ -149,6 +222,81 @@ export default function McpSettings() { + +
+ + {t("Trusted applications")} + + + {t( + "Applications with these callback origins are trusted. Members will not see a warning when authorizing them.", + )} + + + {trustedClients.length > 0 && ( + + + {trustedClients.map((client) => ( + + + + {client.name} + + + + + {client.origin} + + + + + handleRemoveTrustedClient(client.origin) + } + > + + + + + ))} + +
+ )} + + + + setNewClientName(event.currentTarget.value) + } + placeholder={t("Name")} + aria-label={t("Trusted application name")} + maxLength={64} + style={{ flex: 1 }} + /> + + setNewClientOrigin(event.currentTarget.value) + } + placeholder="https://app.yourcompany.com" + aria-label={t("Trusted application origin")} + style={{ flex: 2 }} + /> + + +
)}
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..941bdf7c2 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, or with an API key.", )}{" "} )} - {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..688691940 --- /dev/null +++ b/apps/client/src/ee/oauth/pages/oauth-consent.tsx @@ -0,0 +1,353 @@ +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, + IconInfoCircle, + 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 [acknowledged, setAcknowledged] = useState(false); + + 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( + "This application is not on your workspace's trusted list. Authorize only if you recognize it.", + )} + + + setAcknowledged(event.currentTarget.checked) + } + label={t("I recognize this application and want to continue")} + /> + + + )} + + + + + +
+ ); +} 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 dd7124dfb..656111286 100644 --- a/apps/client/src/features/workspace/types/workspace.types.ts +++ b/apps/client/src/features/workspace/types/workspace.types.ts @@ -31,6 +31,7 @@ export interface IWorkspace { allowPersonalSpaces?: boolean; defaultPageEditMode?: string; isScimEnabled?: boolean; + trustedOauthClients?: ITrustedOAuthClient[]; } export interface IWorkspaceSettings { @@ -42,6 +43,11 @@ export interface IWorkspaceSettings { defaultPageEditMode?: string; } +export interface ITrustedOAuthClient { + origin: string; + name: string; +} + export interface IWorkspaceApiSettings { restrictToAdmins?: 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 cb32657a7..bfb04c2da 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -43,6 +43,7 @@ "@fastify/cookie": "11.0.2", "@fastify/multipart": "10.0.0", "@fastify/static": "10.1.2", + "@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 2a889fd08..878732bc1 100644 --- a/apps/server/src/common/features.ts +++ b/apps/server/src/common/features.ts @@ -23,6 +23,7 @@ export const Feature = { PERSONAL_SPACES: 'spaces:personal', DOCX_EXPORT: 'export:docx', BASES: 'bases', + OAUTH: 'oauth', } 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..36ec852d0 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.warn( + `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..f92f950a5 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) { @@ -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 22458848b..2a19b336b 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() @@ -120,6 +123,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 87e969e16..fbadae180 100644 --- a/apps/server/src/core/workspace/dto/update-workspace.dto.ts +++ b/apps/server/src/core/workspace/dto/update-workspace.dto.ts @@ -1,15 +1,32 @@ import { PartialType } from '@nestjs/mapped-types'; +import { Type } from 'class-transformer'; import { CreateWorkspaceDto } from './create-workspace.dto'; +import { TrustedOAuthClient } from '../workspace.util'; import { + ArrayMaxSize, IsArray, IsBoolean, IsIn, IsInt, + IsNotEmpty, IsOptional, IsString, + MaxLength, Min, + ValidateNested, } from 'class-validator'; +export class TrustedOAuthClientDto { + @IsString() + @IsNotEmpty() + origin: string; + + @IsString() + @IsNotEmpty() + @MaxLength(64) + name: string; +} + export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) { @IsOptional() @IsArray() @@ -68,4 +85,11 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) { @IsString() @IsIn(['read', 'edit']) defaultPageEditMode: string; + + @IsOptional() + @IsArray() + @ArrayMaxSize(50) + @ValidateNested({ each: true }) + @Type(() => TrustedOAuthClientDto) + trustedOauthClients?: TrustedOAuthClient[]; } diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts index 77ccb90c9..dca48adfa 100644 --- a/apps/server/src/core/workspace/services/workspace.service.ts +++ b/apps/server/src/core/workspace/services/workspace.service.ts @@ -30,7 +30,10 @@ import { DomainService } from '../../../integrations/environment/domain.service' import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { addDays } from 'date-fns'; import { DISALLOWED_HOSTNAMES, WorkspaceStatus } from '../workspace.constants'; -import { isAdminActingOnOwner } from '../workspace.util'; +import { + isAdminActingOnOwner, + normalizeTrustedOAuthClients, +} from '../workspace.util'; import { v4 } from 'uuid'; import { InjectQueue } from '@nestjs/bullmq'; import { QueueJob, QueueName } from '../../../integrations/queue/constants'; @@ -314,6 +317,12 @@ export class WorkspaceService { .filter(Boolean); } + if (typeof updateWorkspaceDto.trustedOauthClients !== 'undefined') { + updateWorkspaceDto.trustedOauthClients = normalizeTrustedOAuthClients( + updateWorkspaceDto.trustedOauthClients, + ); + } + if (updateWorkspaceDto.hostname) { const hostname = updateWorkspaceDto.hostname; if (DISALLOWED_HOSTNAMES.includes(hostname)) { @@ -334,7 +343,8 @@ export class WorkspaceService { typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' || typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined' || typeof updateWorkspaceDto.isScimEnabled !== 'undefined' || - typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined' + typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined' || + typeof updateWorkspaceDto.trustedOauthClients !== 'undefined' ) { const ws = await this.db .selectFrom('workspaces') @@ -362,6 +372,18 @@ export class WorkspaceService { } } + if (typeof updateWorkspaceDto.trustedOauthClients !== 'undefined') { + if ( + !this.licenseCheckService.hasFeature( + ws.licenseKey, + Feature.OAUTH, + ws.plan, + ) + ) { + throw new ForbiddenException('This feature requires a valid license'); + } + } + if (typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined') { if ( !this.licenseCheckService.hasFeature( @@ -589,6 +611,7 @@ export class WorkspaceService { 'enforceMfa', 'emailDomains', 'isScimEnabled', + 'trustedOauthClients', ], updateWorkspaceDto, workspaceBefore, diff --git a/apps/server/src/core/workspace/workspace.util.spec.ts b/apps/server/src/core/workspace/workspace.util.spec.ts new file mode 100644 index 000000000..7edefd0a2 --- /dev/null +++ b/apps/server/src/core/workspace/workspace.util.spec.ts @@ -0,0 +1,49 @@ +import { BadRequestException } from '@nestjs/common'; +import { normalizeTrustedOAuthClients } from './workspace.util'; + +describe('normalizeTrustedOAuthClients', () => { + it('lowercases origins and trims names', () => { + expect( + normalizeTrustedOAuthClients([ + { origin: 'https://mcp.acme.com', name: ' Acme MCP ' }, + ]), + ).toEqual([{ origin: 'https://mcp.acme.com', name: 'Acme MCP' }]); + }); + + it('dedupes origins case-insensitively with the last entry winning', () => { + expect( + normalizeTrustedOAuthClients([ + { origin: 'https://mcp.acme.com', name: 'First' }, + { origin: 'https://mcp.acme.com', name: 'Second' }, + ]), + ).toEqual([{ origin: 'https://mcp.acme.com', name: 'Second' }]); + }); + + it.each([ + ['http origin', 'http://mcp.acme.com'], + ['trailing slash', 'https://mcp.acme.com/'], + ['path suffix', 'https://mcp.acme.com/oauth'], + ['uppercase host', 'https://MCP.acme.com'], + ['not a url', 'mcp.acme.com'], + ])('rejects %s naming the origin', (_label, origin) => { + expect(() => + normalizeTrustedOAuthClients([{ origin, name: 'Acme MCP' }]), + ).toThrow(BadRequestException); + expect(() => + normalizeTrustedOAuthClients([{ origin, name: 'Acme MCP' }]), + ).toThrow(origin); + }); + + it.each([ + ['blank', ' '], + ['too long', 'x'.repeat(65)], + ])('rejects a %s name', (_label, name) => { + expect(() => + normalizeTrustedOAuthClients([{ origin: 'https://mcp.acme.com', name }]), + ).toThrow(BadRequestException); + }); + + it('returns an empty array for no entries', () => { + expect(normalizeTrustedOAuthClients([])).toEqual([]); + }); +}); diff --git a/apps/server/src/core/workspace/workspace.util.ts b/apps/server/src/core/workspace/workspace.util.ts index 0d385d04e..d265c74c1 100644 --- a/apps/server/src/core/workspace/workspace.util.ts +++ b/apps/server/src/core/workspace/workspace.util.ts @@ -1,3 +1,4 @@ +import { BadRequestException } from '@nestjs/common'; import { UserRole } from '../../common/helpers/types/permission'; export function isAdminActingOnOwner( @@ -7,6 +8,39 @@ export function isAdminActingOnOwner( return authUserRole === UserRole.ADMIN && targetRole === UserRole.OWNER; } +export type TrustedOAuthClient = { origin: string; name: string }; + +// Origins must be exact https origins; duplicates collapse case-insensitively, last entry wins. +export function normalizeTrustedOAuthClients( + entries: { origin: string; name: string }[], +): TrustedOAuthClient[] { + const byOrigin = new Map(); + for (const entry of entries) { + const name = entry.name.trim(); + if (name.length < 1 || name.length > 64) { + throw new BadRequestException( + `Invalid trusted application name for origin: ${entry.origin}`, + ); + } + let parsed: URL; + try { + parsed = new URL(entry.origin); + } catch { + throw new BadRequestException( + `Invalid trusted application origin: ${entry.origin}`, + ); + } + if (parsed.protocol !== 'https:' || parsed.origin !== entry.origin) { + throw new BadRequestException( + `Trusted application origin must be an exact https origin: ${entry.origin}`, + ); + } + const origin = entry.origin.toLowerCase(); + byOrigin.set(origin, { origin, name }); + } + return Array.from(byOrigin.values()); +} + export type PageEditMode = 'read' | 'edit'; export function getWorkspaceDefaultPageEditMode( diff --git a/apps/server/src/database/migrations/20260816T190412-oauth.ts b/apps/server/src/database/migrations/20260816T190412-oauth.ts new file mode 100644 index 000000000..21d135522 --- /dev/null +++ b/apps/server/src/database/migrations/20260816T190412-oauth.ts @@ -0,0 +1,96 @@ +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(); + + await db.schema + .alterTable('workspaces') + .addColumn('trusted_oauth_clients', 'jsonb', (col) => col.defaultTo(sql`'[]'::jsonb`)) + .execute(); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE workspaces DROP COLUMN IF EXISTS trusted_oauth_clients`.execute( + db, + ); + 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/repos/workspace/workspace.repo.ts b/apps/server/src/database/repos/workspace/workspace.repo.ts index d0f9b5cee..1e1113cae 100644 --- a/apps/server/src/database/repos/workspace/workspace.repo.ts +++ b/apps/server/src/database/repos/workspace/workspace.repo.ts @@ -20,6 +20,7 @@ export class WorkspaceRepo { 'hostname', 'customDomain', 'settings', + 'trustedOauthClients', 'defaultRole', 'emailDomains', 'defaultSpaceId', diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts index 4756c2636..aff0682d1 100644 --- a/apps/server/src/database/types/db.d.ts +++ b/apps/server/src/database/types/db.d.ts @@ -460,6 +460,7 @@ export interface Workspaces { status: string | null; stripeCustomerId: string | null; trialEndAt: Timestamp | null; + trustedOauthClients: Generated; updatedAt: Generated; } @@ -636,6 +637,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 +735,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 22a5f200d..ae7f6e475 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 22a5f200dfb7d042e88c6a1397568da5bdf9477b +Subproject commit ae7f6e475a59d714fcc52e74faa7533ed15458af diff --git a/apps/server/src/integrations/throttle/throttle.module.ts b/apps/server/src/integrations/throttle/throttle.module.ts index 42dd0ec4a..b4e145d44 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 { 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 e26d0a45d..0c285feea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -524,6 +524,9 @@ importers: '@fastify/static': specifier: 10.1.2 version: 10.1.2 + '@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) @@ -2519,6 +2522,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==} @@ -5698,6 +5705,7 @@ packages: cron-parser@4.9.0: resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} engines: {node: '>=12.0.0'} + deprecated: v4 is no longer maintained, upgrade to v5 cron@4.4.0: resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==} @@ -12429,6 +12437,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)':