feat(ee): MCP OAuth (#2432)

* feat: mcp oauth

* fix: small refactor

* fix: cleanup consent
This commit is contained in:
Philip Okugbe
2026-08-26 12:55:30 +01:00
committed by GitHub
parent bda802e5df
commit e56de8eb6c
44 changed files with 1530 additions and 71 deletions
@@ -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.", "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 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", "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", "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:", "MCP server URL:": "MCP server URL:",
"Learn more": "Learn more", "Learn more": "Learn more",
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.", "Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.",
@@ -1304,6 +1306,30 @@
"No attachments on this page yet.": "No attachments on this page yet.", "No attachments on this page yet.": "No attachments on this page yet.",
"Uploaded by {{name}}": "Uploaded by {{name}}", "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.",
"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", "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.", "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", "Toggle workspace knowledge only": "Toggle workspace knowledge only",
+7
View File
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx"; import { useRedirectToCloudSelect } from "@/ee/hooks/use-redirect-to-cloud-select.tsx";
import { useTrackOrigin } from "@/hooks/use-track-origin"; import { useTrackOrigin } from "@/hooks/use-track-origin";
const SetupWorkspace = lazy(() => import("@/pages/auth/setup-workspace.tsx")); const SetupWorkspace = lazy(() => import("@/pages/auth/setup-workspace.tsx"));
const LoginPage = lazy(() => import("@/pages/auth/login")); const LoginPage = lazy(() => import("@/pages/auth/login"));
const Home = lazy(() => import("@/pages/dashboard/home")); 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 AiChat = lazy(() => import("@/ee/ai-chat/pages/ai-chat.tsx"));
const VerifyEmail = lazy(() => import("@/ee/pages/verify-email.tsx")); const VerifyEmail = lazy(() => import("@/ee/pages/verify-email.tsx"));
const LabelPage = lazy(() => import("@/pages/label/label-page")); const LabelPage = lazy(() => import("@/pages/label/label-page"));
const OAuthConsent = lazy(() => import("@/ee/oauth/pages/oauth-consent.tsx"));
export default function App() { export default function App() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -95,6 +97,7 @@ export default function App() {
<Route path={"/password-reset"} element={<PasswordReset />} /> <Route path={"/password-reset"} element={<PasswordReset />} />
<Route path={"/login/mfa"} element={<MfaChallengePage />} /> <Route path={"/login/mfa"} element={<MfaChallengePage />} />
<Route path={"/login/mfa/setup"} element={<MfaSetupRequiredPage />} /> <Route path={"/login/mfa/setup"} element={<MfaSetupRequiredPage />} />
<Route path={"/oauth/consent"} element={<OAuthConsent />} />
{!isCloud() && ( {!isCloud() && (
<Route path={"/setup/register"} element={<SetupWorkspace />} /> <Route path={"/setup/register"} element={<SetupWorkspace />} />
@@ -148,6 +151,10 @@ export default function App() {
element={<AccountPreferences />} element={<AccountPreferences />}
/> />
<Route path={"account/api-keys"} element={<UserApiKeys />} /> <Route path={"account/api-keys"} element={<UserApiKeys />} />
<Route
path={"account/api-keys/authorized-apps"}
element={<UserApiKeys />}
/>
<Route path={"workspace"} element={<WorkspaceSettings />} /> <Route path={"workspace"} element={<WorkspaceSettings />} />
<Route path={"members"} element={<WorkspaceMembers />} /> <Route path={"members"} element={<WorkspaceMembers />} />
<Route path={"api-keys"} element={<WorkspaceApiKeys />} /> <Route path={"api-keys"} element={<WorkspaceApiKeys />} />
@@ -1,5 +1,6 @@
import { import {
Anchor, Anchor,
Badge,
Group, Group,
List, List,
Text, Text,
@@ -107,11 +108,11 @@ export default function McpSettings() {
</CopyButton> </CopyButton>
</Group> </Group>
<Text size="sm" c="dimmed" mt="xs"> <Text size="sm" c="dimmed" mt="xs">
{t( {t("Connect AI assistants with your Docmost account via OAuth.")}
"Use your API key for authentication. You can manage API keys in your account settings.",
)}
</Text> </Text>
<McpOauthOnlySetting />
<div> <div>
<Text size="sm" fw={500} mt="md" mb={4}> <Text size="sm" fw={500} mt="md" mb={4}>
{t("Supported tools")} {t("Supported tools")}
@@ -154,3 +155,56 @@ export default function McpSettings() {
</Stack> </Stack>
); );
} }
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<HTMLInputElement>) => {
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 (
<Group justify="space-between" wrap="nowrap" gap="xl" mt="md">
<div>
<Group gap="xs" align="center">
<Text size="sm" fw={500}>
{t("Require OAuth")}
</Text>
{!hasAccess && (
<Badge variant="light" size="sm" radius="sm">
{t("Enterprise")}
</Badge>
)}
</Group>
<Text size="sm" c="dimmed">
{t(
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.",
)}
</Text>
</div>
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle require OAuth for MCP")}
/>
</Tooltip>
</Group>
);
}
@@ -1,5 +1,5 @@
import React, { useState } from "react"; 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 { IconInfoCircle } from "@tabler/icons-react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import SettingsTitle from "@/components/settings/settings-title"; 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 { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { DocumentTitle } from "@/components/ui/document-title.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() { export default function UserApiKeys() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -29,6 +31,11 @@ export default function UserApiKeys() {
const { data, isLoading } = useGetApiKeysQuery({ cursor }); const { data, isLoading } = useGetApiKeysQuery({ cursor });
const [workspace] = useAtom(workspaceAtom); const [workspace] = useAtom(workspaceAtom);
const { isAdmin } = useUserRole(); 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 mcpEnabled = workspace?.settings?.ai?.mcp === true;
const restrictToAdmins = workspace?.settings?.api?.restrictToAdmins === true; const restrictToAdmins = workspace?.settings?.api?.restrictToAdmins === true;
const canCreate = !restrictToAdmins || isAdmin; const canCreate = !restrictToAdmins || isAdmin;
@@ -47,26 +54,27 @@ export default function UserApiKeys() {
setRevokeModalOpened(true); setRevokeModalOpened(true);
}; };
const handleTabChange = (value: string | null) => {
navigate(
value === "authorized-apps"
? "/settings/account/api-keys/authorized-apps"
: "/settings/account/api-keys",
);
};
return ( return (
<> <>
<DocumentTitle title={t("API keys")} /> <DocumentTitle
title={activeTab === "authorized-apps" ? t("Authorized apps") : t("API keys")}
/>
<SettingsTitle title={t("API keys")} /> <SettingsTitle title={t("API keys")} />
<Text size="sm" c="dimmed" mb="md"> {mcpEnabled && (
<Trans
i18nKey="View the <anchor>API documentation</anchor> for usage details."
components={{
anchor: <Anchor href="https://docmost.com/api-docs" target="_blank" size="sm" />,
}}
/>
</Text>
{mcpEnabled && canCreate && (
<Alert variant="light" color="blue" mb="md" p="sm" icon={<IconInfoCircle />}> <Alert variant="light" color="blue" mb="md" p="sm" icon={<IconInfoCircle />}>
<Text size="sm"> <Text size="sm">
{t( {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.",
)}{" "} )}{" "}
<Anchor <Anchor
href="https://docmost.com/docs/user-guide/mcp" href="https://docmost.com/docs/user-guide/mcp"
@@ -85,37 +93,70 @@ export default function UserApiKeys() {
</Alert> </Alert>
)} )}
{canCreate ? ( <Tabs color="dark" value={activeTab} onChange={handleTabChange}>
<Group justify="flex-end" mb="md"> <Tabs.List>
<Button onClick={() => setCreateModalOpened(true)}> <Tabs.Tab fw={500} value="api-keys">
{t("Create API Key")} {t("API keys")}
</Button> </Tabs.Tab>
</Group> <Tabs.Tab fw={500} value="authorized-apps">
) : restrictToAdmins ? ( {t("Authorized apps")}
<Alert variant="light" color="yellow" mb="md" p="sm" icon={<IconInfoCircle />}> </Tabs.Tab>
<Text size="sm"> </Tabs.List>
{t("API key creation is restricted to admins by your workspace administrator.")}
<Tabs.Panel value="api-keys" pt="md">
<Group justify="space-between" align="center" mb="md">
<Text size="sm" c="dimmed">
<Trans
i18nKey="View the <anchor>API documentation</anchor> for usage details."
components={{
anchor: <Anchor href="https://docmost.com/api-docs" target="_blank" size="sm" />,
}}
/>
</Text> </Text>
</Alert>
) : null}
<ApiKeyTable {canCreate && (
apiKeys={data?.items || []} <Button
isLoading={isLoading} onClick={() => setCreateModalOpened(true)}
onUpdate={handleUpdate} style={{ flexShrink: 0 }}
onRevoke={handleRevoke} >
/> {t("Create API Key")}
</Button>
)}
</Group>
<Space h="md" /> {!canCreate && restrictToAdmins && (
<Alert variant="light" color="yellow" mb="md" p="sm" icon={<IconInfoCircle />}>
<Text size="sm">
{t("API key creation is restricted to admins by your workspace administrator.")}
</Text>
</Alert>
)}
{data?.items.length > 0 && ( <ApiKeyTable
<Paginate apiKeys={data?.items || []}
hasPrevPage={data?.meta?.hasPrevPage} isLoading={isLoading}
hasNextPage={data?.meta?.hasNextPage} onUpdate={handleUpdate}
onNext={() => goNext(data?.meta?.nextCursor)} onRevoke={handleRevoke}
onPrev={goPrev}
/> />
)}
<Space h="md" />
{data?.items.length > 0 && (
<Paginate
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
/>
)}
</Tabs.Panel>
<Tabs.Panel value="authorized-apps" pt="md">
<AuthorizedAppsPanel />
</Tabs.Panel>
</Tabs>
<CreateApiKeyModal <CreateApiKeyModal
opened={createModalOpened} opened={createModalOpened}
+2
View File
@@ -22,5 +22,7 @@ export const Feature = {
PERSONAL_SPACES: 'spaces:personal', PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx', DOCX_EXPORT: 'export:docx',
BASES: 'bases', BASES: 'bases',
OAUTH: 'oauth',
AI_CONTROLS: 'ai:controls', AI_CONTROLS: 'ai:controls',
MCP_CONTROLS: 'mcp:controls',
} as const; } as const;
@@ -0,0 +1,19 @@
import { Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { OAuthGrantsTable } from "@/ee/oauth/components/oauth-grants-table";
import { useOAuthGrantsQuery } from "@/ee/oauth/queries/oauth-query";
export function AuthorizedAppsPanel() {
const { t } = useTranslation();
const { data, isLoading } = useOAuthGrantsQuery();
return (
<>
<Text size="sm" c="dimmed" mb="md">
{t("Applications and AI assistants you have authorized to access your account.")}
</Text>
<OAuthGrantsTable grants={data || []} isLoading={isLoading} />
</>
);
}
@@ -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) => (
<Table.Tr key={i}>
<Table.Td>
<Skeleton height={14} width={140} />
</Table.Td>
<Table.Td>
<Group gap={4}>
<Skeleton height={20} width={50} />
<Skeleton height={20} width={55} />
</Group>
</Table.Td>
<Table.Td>
<Skeleton height={14} width={90} />
</Table.Td>
<Table.Td>
<Skeleton height={14} width={90} />
</Table.Td>
<Table.Td>
<Skeleton height={28} width={28} circle />
</Table.Td>
</Table.Tr>
))}
</>
);
}
export function OAuthGrantsTable({ grants, isLoading }: OAuthGrantsTableProps) {
const { t } = useTranslation();
const locale = useDateFnsLocale();
const revokeMutation = useRevokeOAuthGrantMutation();
const scopeMeta: Record<string, { color: string; label: string }> = {
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: (
<Text size="sm">
{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 },
)}
</Text>
),
labels: { confirm: t("Revoke access"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => revokeMutation.mutate(grant.id),
});
return (
<Table.ScrollContainer minWidth={600}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Application")}</Table.Th>
<Table.Th>{t("Permissions")}</Table.Th>
<Table.Th>{t("Authorized")}</Table.Th>
<Table.Th>{t("Last used")}</Table.Th>
<Table.Th aria-label={t("Action")} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isLoading ? (
<TableSkeleton />
) : grants.length > 0 ? (
grants.map((grant) => (
<Table.Tr key={grant.id}>
<Table.Td>
<Text fz="sm" fw={500}>
{grant.clientName}
</Text>
<Text
fz="xs"
c="dimmed"
title={grant.redirectUris?.join("\n")}
style={{ overflowWrap: "anywhere" }}
>
{callbackHosts(grant.redirectUris).join(", ")}
</Text>
</Table.Td>
<Table.Td>
<Group gap={4}>
{grant.scopes.map((scope) => {
const meta = scopeMeta[scope];
if (!meta) return null;
return (
<Badge
key={scope}
variant="light"
color={meta.color}
size="sm"
>
{meta.label}
</Badge>
);
})}
</Group>
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(grant.createdAt)}
</Text>
</Table.Td>
<Table.Td>
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
{formatDate(grant.lastUsedAt)}
</Text>
</Table.Td>
<Table.Td>
<Tooltip label={t("Revoke access")}>
<ActionIcon
variant="subtle"
color="red"
aria-label={t("Revoke access for {{name}}", {
name: grant.clientName,
})}
onClick={() => openRevokeModal(grant)}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</Table.Td>
</Table.Tr>
))
) : (
<NoTableResults colSpan={5} text={t("No authorized apps yet.")} />
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
@@ -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<IAuthorizeParams>(
() => 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 (
<AuthLayout>
<DocumentTitle title={t("Authorize application")} />
<Container size={460} className={classes.container}>
<Box p="xl">
{isLoading ? (
<Center mih={200}>
<Loader />
</Center>
) : infoQuery.data && currentUserQuery.data ? (
<ConsentCard
info={infoQuery.data}
currentUser={currentUserQuery.data}
params={params}
/>
) : (
<InvalidRequestCard
description={errorText(infoQuery.error ?? currentUserQuery.error)}
/>
)}
</Box>
</Container>
</AuthLayout>
);
}
function InvalidRequestCard({ description }: { description?: string }) {
const { t } = useTranslation();
return (
<Stack align="center" gap="sm">
<ThemeIcon size={48} radius="xl" variant="light" color="red">
<IconAlertTriangle size={26} stroke={1.5} />
</ThemeIcon>
<Title order={3} ta="center" fw={600}>
{t("Invalid authorization request")}
</Title>
{description && (
<Text size="sm" c="dimmed" ta="center">
{description}
</Text>
)}
</Stack>
);
}
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<string[]>(
info.scopes.filter((scope) => scope === "read" || scope === "write"),
);
const [submitting, setSubmitting] = useState<"approve" | "deny" | null>(null);
const scopeRows = [
{
scope: "read",
icon: <IconEye size={16} stroke={1.5} />,
label: t("Read"),
description: t("View data in your workspace without making changes."),
},
{
scope: "write",
icon: <IconPencil size={16} stroke={1.5} />,
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 (
<Stack gap="lg">
<Title order={3} ta="center" fw={600}>
{t("{{name}} wants to access {{workspace}}", {
name: info.clientName,
workspace: currentUser.workspace.name,
})}
</Title>
<Paper withBorder radius="md" p="sm">
<Group justify="space-between" wrap="nowrap">
<UserInfo user={currentUser.user} />
<Anchor
component="button"
type="button"
size="xs"
c="dimmed"
disabled={submitting !== null}
onClick={switchAccount}
style={{ whiteSpace: "nowrap" }}
>
{t("Not you? Switch account")}
</Anchor>
</Group>
</Paper>
<Divider />
<Stack gap="sm">
<Text size="sm" fw={500}>
{t("This application will be able to:")}
</Text>
{scopeRows.map((row) => {
const requested = info.scopes.includes(row.scope);
return (
<Checkbox
key={row.scope}
size="sm"
checked={approvedScopes.includes(row.scope)}
disabled={!requested || submitting !== null}
onChange={(event) =>
toggleScope(row.scope, event.currentTarget.checked)
}
label={
<Group gap={6} wrap="nowrap">
{row.icon}
<Text size="sm" fw={500}>
{row.label}
</Text>
</Group>
}
description={row.description}
/>
);
})}
</Stack>
<div>
<Text size="xs" c="dimmed">
{t("You will be redirected to")}
</Text>
<Text size="xs" ff="monospace" style={{ overflowWrap: "anywhere" }}>
{info.redirectUri}
</Text>
</div>
{!info.verified && (
<Alert
variant="light"
color="yellow"
py="xs"
icon={<IconAlertTriangle size={16} />}
>
{t("Make sure you trust this application before authorizing it.")}
</Alert>
)}
<Group grow>
<Button
variant="default"
onClick={() => submitDecision(false)}
loading={submitting === "deny"}
disabled={submitting === "approve"}
>
{t("Cancel")}
</Button>
<Button
onClick={() => submitDecision(true)}
loading={submitting === "approve"}
disabled={approvedScopes.length === 0 || submitting === "deny"}
>
{t("Authorize")}
</Button>
</Group>
</Stack>
);
}
@@ -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<IOAuthGrant[], Error> {
return useQuery({
queryKey: ["oauth-grants"],
queryFn: () => getOAuthGrants(),
staleTime: 0,
gcTime: 0,
});
}
export function useRevokeOAuthGrantMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, string>({
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",
});
},
});
}
@@ -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<IOAuthAuthorizeInfo> {
const req = await api.post<IOAuthAuthorizeInfo>(
"/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<IOAuthGrant[]> {
const req = await api.post<IOAuthGrant[]>("/oauth/grants", {});
return req.data;
}
export async function revokeOAuthGrant(grantId: string): Promise<void> {
await api.post("/oauth/grants/revoke", { grantId });
}
@@ -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<string, string>;
export type IApproveAuthorizationPayload = {
[param: string]: unknown;
approved: boolean;
approvedScopes?: string[];
};
@@ -27,6 +27,7 @@ export interface IWorkspace {
mcpEnabled?: boolean; mcpEnabled?: boolean;
aiChatReadOnly?: boolean; aiChatReadOnly?: boolean;
aiChatWorkspaceKnowledgeOnly?: boolean; aiChatWorkspaceKnowledgeOnly?: boolean;
mcpOauthOnly?: boolean;
trashRetentionDays?: number; trashRetentionDays?: number;
restrictApiToAdmins?: boolean; restrictApiToAdmins?: boolean;
allowMemberTemplates?: boolean; allowMemberTemplates?: boolean;
@@ -52,6 +53,7 @@ export interface IWorkspaceAiSettings {
search?: boolean; search?: boolean;
generative?: boolean; generative?: boolean;
mcp?: boolean; mcp?: boolean;
mcpOauthOnly?: boolean;
chat?: boolean; chat?: boolean;
chatReadOnly?: boolean; chatReadOnly?: boolean;
chatWorkspaceKnowledgeOnly?: boolean; chatWorkspaceKnowledgeOnly?: boolean;
+2
View File
@@ -76,6 +76,8 @@ function redirectToLogin() {
APP_ROUTE.AUTH.MFA_CHALLENGE, APP_ROUTE.AUTH.MFA_CHALLENGE,
APP_ROUTE.AUTH.MFA_SETUP_REQUIRED, APP_ROUTE.AUTH.MFA_SETUP_REQUIRED,
"/invites", "/invites",
// the oauth consent page redirects to login itself, preserving its query string
"/oauth/consent",
]; ];
if (!exemptPaths.some((path) => window.location.pathname.startsWith(path))) { if (!exemptPaths.some((path) => window.location.pathname.startsWith(path))) {
const redirectTo = window.location.pathname; const redirectTo = window.location.pathname;
+1
View File
@@ -43,6 +43,7 @@
"@fastify/cookie": "11.1.2", "@fastify/cookie": "11.1.2",
"@fastify/multipart": "10.1.1", "@fastify/multipart": "10.1.1",
"@fastify/static": "10.1.3", "@fastify/static": "10.1.3",
"@jmondi/oauth2-server": "^4.3.7",
"@keyv/redis": "5.1.6", "@keyv/redis": "5.1.6",
"@langchain/core": "1.1.46", "@langchain/core": "1.1.46",
"@langchain/textsplitters": "1.0.1", "@langchain/textsplitters": "1.0.1",
@@ -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);
@@ -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);
@@ -23,6 +23,11 @@ export const AuditEvent = {
API_KEY_UPDATED: 'api_key.updated', API_KEY_UPDATED: 'api_key.updated',
API_KEY_DELETED: 'api_key.deleted', 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 Tokens
SCIM_TOKEN_CREATED: 'scim_token.created', SCIM_TOKEN_CREATED: 'scim_token.created',
SCIM_TOKEN_UPDATED: 'scim_token.updated', SCIM_TOKEN_UPDATED: 'scim_token.updated',
@@ -124,6 +129,8 @@ export const AuditResource = {
COMMENT: 'comment', COMMENT: 'comment',
SHARE: 'share', SHARE: 'share',
API_KEY: 'api_key', API_KEY: 'api_key',
OAUTH_CLIENT: 'oauth_client',
OAUTH_GRANT: 'oauth_grant',
SCIM_TOKEN: 'scim_token', SCIM_TOKEN: 'scim_token',
SSO_PROVIDER: 'sso_provider', SSO_PROVIDER: 'sso_provider',
WORKSPACE_INVITATION: 'workspace_invitation', WORKSPACE_INVITATION: 'workspace_invitation',
@@ -12,6 +12,8 @@ export enum EventName {
SPACE_UPDATED = 'space.updated', SPACE_UPDATED = 'space.updated',
SPACE_DELETED = 'space.deleted', SPACE_DELETED = 'space.deleted',
USER_PASSWORD_RESET = 'user.password.reset',
WORKSPACE_CREATED = 'workspace.created', WORKSPACE_CREATED = 'workspace.created',
WORKSPACE_UPDATED = 'workspace.updated', WORKSPACE_UPDATED = 'workspace.updated',
WORKSPACE_DELETED = 'workspace.deleted', WORKSPACE_DELETED = 'workspace.deleted',
+2
View File
@@ -23,7 +23,9 @@ export const Feature = {
PERSONAL_SPACES: 'spaces:personal', PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx', DOCX_EXPORT: 'export:docx',
BASES: 'bases', BASES: 'bases',
OAUTH: 'oauth',
AI_CONTROLS: 'ai:controls', AI_CONTROLS: 'ai:controls',
MCP_CONTROLS: 'mcp:controls',
} as const; } as const;
export type FeatureKey = (typeof Feature)[keyof typeof Feature]; export type FeatureKey = (typeof Feature)[keyof typeof Feature];
@@ -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<unknown, string>([
[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');
});
});
@@ -1,16 +1,26 @@
import { import {
ExecutionContext, ExecutionContext,
ForbiddenException,
Injectable, Injectable,
Logger,
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; 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 { Reflector } from '@nestjs/core';
import { EnvironmentService } from '../../integrations/environment/environment.service'; import { EnvironmentService } from '../../integrations/environment/environment.service';
import { addDays } from 'date-fns'; import { addDays } from 'date-fns';
@Injectable() @Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') { export class JwtAuthGuard extends AuthGuard('jwt') {
private logger = new Logger('JwtAuthGuard');
constructor( constructor(
private reflector: Reflector, private reflector: Reflector,
private environmentService: EnvironmentService, private environmentService: EnvironmentService,
@@ -36,6 +46,39 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
throw err || new UnauthorizedException(); throw err || new UnauthorizedException();
} }
const requiresSession = this.reflector.getAllAndOverride<boolean>(
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); this.setJoinedWorkspacesCookie(user, ctx);
return user; return user;
} }
@@ -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([]);
});
});
@@ -17,3 +17,19 @@ export function resolveFrameHeader(
value: `frame-ancestors 'self' ${allowedOrigins.join(' ')}`, 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] : [];
}
@@ -24,6 +24,7 @@ import * as bytes from 'bytes';
import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; 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 { Attachment, User, Workspace } from '@docmost/db/types/entity.types';
import { StorageService } from '../../integrations/storage/storage.service'; import { StorageService } from '../../integrations/storage/storage.service';
import { import {
@@ -175,6 +176,7 @@ export class AttachmentController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Get('/files/:fileId/:fileName') @Get('/files/:fileId/:fileName')
@OAuthScope('read')
async getFile( async getFile(
@Req() req: FastifyRequest, @Req() req: FastifyRequest,
@Res() res: FastifyReply, @Res() res: FastifyReply,
@@ -427,6 +429,7 @@ export class AttachmentController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('pages/attachments') @Post('pages/attachments')
@OAuthScope('read')
async getPageAttachments( async getPageAttachments(
@Body() dto: PageIdDto, @Body() dto: PageIdDto,
@Body() pagination: PaginationOptions, @Body() pagination: PaginationOptions,
+2 -2
View File
@@ -12,7 +12,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { SkipThrottle, ThrottlerGuard } from '@nestjs/throttler'; import { SkipThrottle, ThrottlerGuard } from '@nestjs/throttler';
import { import {
AI_CHAT_THROTTLER, ALL_NAMED_THROTTLERS_SKIPPED,
AUTH_THROTTLER, AUTH_THROTTLER,
} from '../../integrations/throttle/throttler-names'; } from '../../integrations/throttle/throttler-names';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
@@ -38,7 +38,7 @@ import {
IAuditService, IAuditService,
} from '../../integrations/audit/audit.service'; } from '../../integrations/audit/audit.service';
@SkipThrottle({ [AI_CHAT_THROTTLER]: true }) @SkipThrottle({ ...ALL_NAMED_THROTTLERS_SKIPPED, [AUTH_THROTTLER]: false })
@UseGuards(ThrottlerGuard) @UseGuards(ThrottlerGuard)
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
@@ -7,6 +7,7 @@ export enum JwtType {
API_KEY = 'api_key', API_KEY = 'api_key',
PDF_RENDER = 'pdf_render', PDF_RENDER = 'pdf_render',
PDF_EXPORT_DOWNLOAD = 'pdf_export_download', PDF_EXPORT_DOWNLOAD = 'pdf_export_download',
OAUTH_ACCESS = 'oauth_access',
} }
export type JwtPayload = { export type JwtPayload = {
sub: string; sub: string;
@@ -59,3 +60,14 @@ export type JwtPdfExportDownloadPayload = {
workspaceId: string; workspaceId: string;
type: 'pdf_export_download'; 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;
};
@@ -2,6 +2,7 @@ import {
BadRequestException, BadRequestException,
Inject, Inject,
Injectable, Injectable,
Logger,
NotFoundException, NotFoundException,
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
@@ -40,9 +41,13 @@ import {
IAuditService, IAuditService,
} from '../../../integrations/audit/audit.service'; } from '../../../integrations/audit/audit.service';
import { EnvironmentService } from '../../../integrations/environment/environment.service'; import { EnvironmentService } from '../../../integrations/environment/environment.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { EventName } from '../../../common/events/event.contants';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor( constructor(
private signupService: SignupService, private signupService: SignupService,
private tokenService: TokenService, private tokenService: TokenService,
@@ -53,6 +58,7 @@ export class AuthService {
private mailService: MailService, private mailService: MailService,
private domainService: DomainService, private domainService: DomainService,
private environmentService: EnvironmentService, private environmentService: EnvironmentService,
private eventEmitter: EventEmitter2,
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {} ) {}
@@ -261,6 +267,19 @@ export class AuthService {
await this.userSessionRepo.deleteByUserId(user.id, workspace.id); 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.setActorId(user.id);
this.auditService.log({ this.auditService.log({
event: AuditEvent.USER_PASSWORD_RESET, event: AuditEvent.USER_PASSWORD_RESET,
@@ -2,7 +2,12 @@ import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt'; import { Strategy } from 'passport-jwt';
import { EnvironmentService } from '../../../integrations/environment/environment.service'; 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 { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo'; import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.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) { if (!payload.workspaceId) {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
@@ -42,8 +50,21 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
throw new UnauthorizedException('Workspace does not match'); throw new UnauthorizedException('Workspace does not match');
} }
// authType lets guards tell an interactive session from a programmatic credential.
if (payload.type === JwtType.API_KEY) { 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) { if (payload.type !== JwtType.ACCESS) {
@@ -71,7 +92,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
this.sessionActivityService.trackActivity(sessionId, payload.sub, payload.workspaceId); this.sessionActivityService.trackActivity(sessionId, payload.sub, payload.workspaceId);
} }
return { user, workspace }; return { user, workspace, authType: JwtType.ACCESS };
} }
private async validateApiKey(req: any, payload: JwtApiKeyPayload) { 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'); 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');
}
} }
@@ -16,6 +16,7 @@ import { PageIdDto, CommentIdDto } from './dto/comments.input';
import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; 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 { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { User, Workspace } from '@docmost/db/types/entity.types'; import { User, Workspace } from '@docmost/db/types/entity.types';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory'; import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
@@ -48,6 +49,7 @@ export class CommentController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('create') @Post('create')
@OAuthScope('write')
async create( async create(
@Body() createCommentDto: CreateCommentDto, @Body() createCommentDto: CreateCommentDto,
@AuthUser() user: User, @AuthUser() user: User,
@@ -84,6 +86,7 @@ export class CommentController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('/') @Post('/')
@OAuthScope('read')
async findPageComments( async findPageComments(
@Body() input: PageIdDto, @Body() input: PageIdDto,
@Body() @Body()
@@ -125,6 +128,7 @@ export class CommentController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('update') @Post('update')
@OAuthScope('write')
async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) { async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) {
const comment = await this.commentRepo.findById(dto.commentId, { const comment = await this.commentRepo.findById(dto.commentId, {
includeCreator: true, includeCreator: true,
@@ -26,6 +26,7 @@ import { PageHistoryService } from './services/page-history.service';
import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; 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 { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { Page, User, Workspace } from '@docmost/db/types/entity.types'; import { Page, User, Workspace } from '@docmost/db/types/entity.types';
import { SidebarPageDto } from './dto/sidebar-page.dto'; import { SidebarPageDto } from './dto/sidebar-page.dto';
@@ -69,6 +70,7 @@ export class PageController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('/info') @Post('/info')
@OAuthScope('read')
async getPage(@Body() dto: PageInfoDto, @AuthUser() user: User) { async getPage(@Body() dto: PageInfoDto, @AuthUser() user: User) {
const page = await this.pageRepo.findById(dto.pageId, { const page = await this.pageRepo.findById(dto.pageId, {
includeSpace: true, includeSpace: true,
@@ -199,6 +201,7 @@ export class PageController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('create') @Post('create')
@OAuthScope('write')
async create( async create(
@Body() createPageDto: CreatePageDto, @Body() createPageDto: CreatePageDto,
@AuthUser() user: User, @AuthUser() user: User,
@@ -269,6 +272,7 @@ export class PageController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('update') @Post('update')
@OAuthScope('write')
async update(@Body() updatePageDto: UpdatePageDto, @AuthUser() user: User) { async update(@Body() updatePageDto: UpdatePageDto, @AuthUser() user: User) {
const page = await this.pageRepo.findById(updatePageDto.pageId); const page = await this.pageRepo.findById(updatePageDto.pageId);
@@ -413,6 +417,7 @@ export class PageController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('recent') @Post('recent')
@OAuthScope('read')
async getRecentPages( async getRecentPages(
@Body() recentPageDto: RecentPageDto, @Body() recentPageDto: RecentPageDto,
@Body() pagination: PaginationOptions, @Body() pagination: PaginationOptions,
@@ -527,6 +532,7 @@ export class PageController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('/sidebar-pages') @Post('/sidebar-pages')
@OAuthScope('read')
async getSidebarPages( async getSidebarPages(
@Body() dto: SidebarPageDto, @Body() dto: SidebarPageDto,
@Body() pagination: PaginationOptions, @Body() pagination: PaginationOptions,
@@ -569,6 +575,7 @@ export class PageController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('move-to-space') @Post('move-to-space')
@OAuthScope('write')
async movePageToSpace( async movePageToSpace(
@Body() dto: MovePageToSpaceDto, @Body() dto: MovePageToSpaceDto,
@AuthUser() user: User, @AuthUser() user: User,
@@ -622,6 +629,7 @@ export class PageController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('duplicate') @Post('duplicate')
@OAuthScope('write')
async duplicatePage(@Body() dto: DuplicatePageDto, @AuthUser() user: User) { async duplicatePage(@Body() dto: DuplicatePageDto, @AuthUser() user: User) {
const copiedPage = await this.pageRepo.findById(dto.pageId); const copiedPage = await this.pageRepo.findById(dto.pageId);
if (!copiedPage) { if (!copiedPage) {
@@ -706,6 +714,7 @@ export class PageController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('move') @Post('move')
@OAuthScope('write')
async movePage(@Body() dto: MovePageDto, @AuthUser() user: User) { async movePage(@Body() dto: MovePageDto, @AuthUser() user: User) {
const movedPage = await this.pageRepo.findById(dto.pageId); const movedPage = await this.pageRepo.findById(dto.pageId);
if (!movedPage) { if (!movedPage) {
@@ -17,6 +17,7 @@ import {
} from './dto/search.dto'; } from './dto/search.dto';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; 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 { User, Workspace } from '@docmost/db/types/entity.types';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory'; import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import { import {
@@ -42,6 +43,7 @@ export class SearchController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post() @Post()
@OAuthScope('read')
async pageSearch( async pageSearch(
@Body() searchDto: SearchDTO, @Body() searchDto: SearchDTO,
@AuthUser() user: User, @AuthUser() user: User,
@@ -75,6 +77,7 @@ export class SearchController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('suggest') @Post('suggest')
@OAuthScope('read')
async searchSuggestions( async searchSuggestions(
@Body() dto: SearchSuggestionDTO, @Body() dto: SearchSuggestionDTO,
@AuthUser() user: User, @AuthUser() user: User,
@@ -13,6 +13,7 @@ import { SpaceService } from './services/space.service';
import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { OAuthScope } from '../../common/decorators/oauth-scope.decorator';
import { SpaceIdDto } from './dto/space-id.dto'; import { SpaceIdDto } from './dto/space-id.dto';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { SpaceMemberService } from './services/space-member.service'; import { SpaceMemberService } from './services/space-member.service';
@@ -48,6 +49,7 @@ export class SpaceController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('/') @Post('/')
@OAuthScope('read')
async getWorkspaceSpaces( async getWorkspaceSpaces(
@Body() @Body()
pagination: PaginationOptions, pagination: PaginationOptions,
@@ -92,6 +94,7 @@ export class SpaceController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('info') @Post('info')
@OAuthScope('read')
async getSpaceInfo( async getSpaceInfo(
@Body() spaceIdDto: SpaceIdDto, @Body() spaceIdDto: SpaceIdDto,
@AuthUser() user: User, @AuthUser() user: User,
@@ -129,6 +132,7 @@ export class SpaceController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('create') @Post('create')
@OAuthScope('write')
createSpace( createSpace(
@Body() createSpaceDto: CreateSpaceDto, @Body() createSpaceDto: CreateSpaceDto,
@AuthUser() user: User, @AuthUser() user: User,
@@ -145,6 +149,7 @@ export class SpaceController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('update') @Post('update')
@OAuthScope('write')
async updateSpace( async updateSpace(
@Body() updateSpaceDto: UpdateSpaceDto, @Body() updateSpaceDto: UpdateSpaceDto,
@AuthUser() user: User, @AuthUser() user: User,
@@ -10,6 +10,7 @@ import { UserService } from './user.service';
import { UpdateUserDto } from './dto/update-user.dto'; import { UpdateUserDto } from './dto/update-user.dto';
import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; 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 { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types'; import { User, Workspace } from '@docmost/db/types/entity.types';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo'; import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
@@ -24,6 +25,7 @@ export class UserController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('me') @Post('me')
@OAuthScope('read')
async getUserInfo( async getUserInfo(
@AuthUser() authUser: User, @AuthUser() authUser: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
@@ -24,6 +24,7 @@ import {
RevokeInviteDto, RevokeInviteDto,
} from '../dto/invitation.dto'; } from '../dto/invitation.dto';
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard'; 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 { User, Workspace } from '@docmost/db/types/entity.types';
import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.factory'; import WorkspaceAbilityFactory from '../../casl/abilities/workspace-ability.factory';
import { import {
@@ -58,6 +59,7 @@ export class WorkspaceController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('/info') @Post('/info')
@OAuthScope('read')
async getWorkspace(@AuthWorkspace() workspace: Workspace) { async getWorkspace(@AuthWorkspace() workspace: Workspace) {
return this.workspaceService.getWorkspaceInfo(workspace.id); return this.workspaceService.getWorkspaceInfo(workspace.id);
} }
@@ -113,6 +115,7 @@ export class WorkspaceController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('members') @Post('members')
@OAuthScope('read')
async getWorkspaceMembers( async getWorkspaceMembers(
@Body() @Body()
pagination: PaginationOptions, pagination: PaginationOptions,
@@ -76,4 +76,8 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
aiChatWorkspaceKnowledgeOnly: boolean; aiChatWorkspaceKnowledgeOnly: boolean;
@IsOptional()
@IsBoolean()
mcpOauthOnly: boolean;
} }
@@ -336,7 +336,8 @@ export class WorkspaceService {
typeof updateWorkspaceDto.isScimEnabled !== 'undefined' || typeof updateWorkspaceDto.isScimEnabled !== 'undefined' ||
typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined' || typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined' ||
typeof updateWorkspaceDto.aiChatReadOnly !== 'undefined' || typeof updateWorkspaceDto.aiChatReadOnly !== 'undefined' ||
typeof updateWorkspaceDto.aiChatWorkspaceKnowledgeOnly !== 'undefined' typeof updateWorkspaceDto.aiChatWorkspaceKnowledgeOnly !== 'undefined' ||
typeof updateWorkspaceDto.mcpOauthOnly !== 'undefined'
) { ) {
const ws = await this.db const ws = await this.db
.selectFrom('workspaces') .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 ( if (
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' || typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
typeof updateWorkspaceDto.trashRetentionDays !== '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') { if (typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined') {
const prev = settingsBefore?.spaces?.allowPersonal ?? false; const prev = settingsBefore?.spaces?.allowPersonal ?? false;
if (prev !== updateWorkspaceDto.allowPersonalSpaces) { if (prev !== updateWorkspaceDto.allowPersonalSpaces) {
@@ -600,6 +627,7 @@ export class WorkspaceService {
delete updateWorkspaceDto.defaultPageEditMode; delete updateWorkspaceDto.defaultPageEditMode;
delete updateWorkspaceDto.aiChatReadOnly; delete updateWorkspaceDto.aiChatReadOnly;
delete updateWorkspaceDto.aiChatWorkspaceKnowledgeOnly; delete updateWorkspaceDto.aiChatWorkspaceKnowledgeOnly;
delete updateWorkspaceDto.mcpOauthOnly;
await this.workspaceRepo.updateWorkspace( await this.workspaceRepo.updateWorkspace(
updateWorkspaceDto, updateWorkspaceDto,
@@ -0,0 +1,88 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
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();
}
+61
View File
@@ -636,6 +636,63 @@ export interface UserSessions {
createdAt: Generated<Timestamp>; createdAt: Generated<Timestamp>;
} }
export interface OauthAuthorizationCodes {
clientId: string;
codeChallenge: string | null;
codeChallengeMethod: string | null;
codeHash: string;
consumedAt: Timestamp | null;
createdAt: Generated<Timestamp>;
expiresAt: Timestamp;
id: Generated<string>;
redirectUri: string;
scopes: Json;
userId: string;
workspaceId: string;
}
export interface OauthClients {
clientUri: string | null;
createdAt: Generated<Timestamp>;
deletedAt: Timestamp | null;
grantTypes: Json;
id: Generated<string>;
isDynamic: Generated<boolean>;
logoUri: string | null;
name: string;
redirectUris: Json;
scopes: Json;
secretHash: string | null;
tokenEndpointAuthMethod: Generated<string>;
updatedAt: Generated<Timestamp>;
workspaceId: string;
}
export interface OauthGrants {
clientId: string;
createdAt: Generated<Timestamp>;
id: Generated<string>;
lastUsedAt: Timestamp | null;
revokedAt: Timestamp | null;
scopes: Json;
updatedAt: Generated<Timestamp>;
userId: string;
workspaceId: string;
}
export interface OauthTokens {
accessExpiresAt: Timestamp;
accessTokenJti: string;
createdAt: Generated<Timestamp>;
grantId: string;
id: Generated<string>;
refreshExpiresAt: Timestamp | null;
refreshTokenHash: string | null;
revokedAt: Timestamp | null;
scopes: Json;
workspaceId: string;
}
export interface DB { export interface DB {
aiChats: AiChats; aiChats: AiChats;
aiChatMessages: AiChatMessages; aiChatMessages: AiChatMessages;
@@ -677,4 +734,8 @@ export interface DB {
watchers: Watchers; watchers: Watchers;
workspaceInvitations: WorkspaceInvitations; workspaceInvitations: WorkspaceInvitations;
workspaces: Workspaces; workspaces: Workspaces;
oauthAuthorizationCodes: OauthAuthorizationCodes;
oauthClients: OauthClients;
oauthGrants: OauthGrants;
oauthTokens: OauthTokens;
} }
@@ -40,6 +40,10 @@ import {
Watchers, Watchers,
Audit as _Audit, Audit as _Audit,
Templates, Templates,
OauthClients,
OauthAuthorizationCodes,
OauthGrants,
OauthTokens,
} from './db'; } from './db';
import { PageEmbeddings } from '@docmost/db/types/embeddings.types'; import { PageEmbeddings } from '@docmost/db/types/embeddings.types';
@@ -184,6 +188,13 @@ export type ScimToken = Selectable<ScimTokens>;
export type InsertableScimToken = Insertable<ScimTokens>; export type InsertableScimToken = Insertable<ScimTokens>;
export type UpdatableScimToken = Updateable<Omit<ScimTokens, 'id'>>; export type UpdatableScimToken = Updateable<Omit<ScimTokens, 'id'>>;
// OAuth
export type OAuthClient = Selectable<OauthClients>;
export type InsertableOAuthClient = Insertable<OauthClients>;
export type OAuthGrant = Selectable<OauthGrants>;
export type OAuthAuthorizationCode = Selectable<OauthAuthorizationCodes>;
export type OAuthToken = Selectable<OauthTokens>;
// Page Embedding // Page Embedding
export type PageEmbedding = Selectable<PageEmbeddings>; export type PageEmbedding = Selectable<PageEmbeddings>;
export type InsertablePageEmbedding = Insertable<PageEmbeddings>; export type InsertablePageEmbedding = Insertable<PageEmbeddings>;
@@ -4,7 +4,13 @@ import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis'
import { EnvironmentService } from '../environment/environment.service'; import { EnvironmentService } from '../environment/environment.service';
import { EnvironmentModule } from '../environment/environment.module'; import { EnvironmentModule } from '../environment/environment.module';
import { createRetryStrategy, parseRedisUrl } from '../../common/helpers'; 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'; import Redis from 'ioredis';
@Module({ @Module({
@@ -18,6 +24,9 @@ import Redis from 'ioredis';
throttlers: [ throttlers: [
{ name: AUTH_THROTTLER, ttl: 60_000, limit: 10 }, { name: AUTH_THROTTLER, ttl: 60_000, limit: 10 },
{ name: AI_CHAT_THROTTLER, ttl: 60_000, limit: 25 }, { 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', errorMessage: 'Too many requests',
storage: new ThrottlerStorageRedisService( storage: new ThrottlerStorageRedisService(
@@ -1,2 +1,14 @@
export const AUTH_THROTTLER = 'auth'; export const AUTH_THROTTLER = 'auth';
export const AI_CHAT_THROTTLER = 'ai-chat'; 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<string, boolean> = {
[AUTH_THROTTLER]: true,
[AI_CHAT_THROTTLER]: true,
[OAUTH_REGISTER_THROTTLER]: true,
[OAUTH_TOKEN_THROTTLER]: true,
[OAUTH_AUTHORIZE_THROTTLER]: true,
};
+30 -18
View File
@@ -13,7 +13,10 @@ import fastifyCookie from '@fastify/cookie';
import fastifyIp from 'fastify-ip'; import fastifyIp from 'fastify-ip';
import { InternalLogFilter } from './common/logger/internal-log-filter'; import { InternalLogFilter } from './common/logger/internal-log-filter';
import { EnvironmentService } from './integrations/environment/environment.service'; import { EnvironmentService } from './integrations/environment/environment.service';
import { resolveFrameHeader } from './common/helpers'; import {
resolveFrameHeader,
resolveFrameHeadersForPath,
} from './common/helpers';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>( const app = await NestFactory.create<NestFastifyApplication>(
@@ -39,7 +42,14 @@ async function bootstrap() {
app.useLogger(app.get(PinoLogger)); app.useLogger(app.get(PinoLogger));
app.setGlobalPrefix('api', { 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); const reflector = app.get(Reflector);
@@ -57,22 +67,24 @@ async function bootstrap() {
environmentService.isIframeEmbedAllowed(), environmentService.isIframeEmbedAllowed(),
environmentService.getIframeAllowedOrigins(), environmentService.getIframeAllowedOrigins(),
); );
if (frameHeader) { // Skipped routes:
// Skipped routes: // /api/files/ - attachment controller sets its own CSP we'd overwrite
// /api/files/ - attachment controller sets its own CSP we'd overwrite // /share/ - public share pages are safe to embed
// /share/ 0 public share pages are safe to embed const frameHeaderSkippedPrefixes = ['/api/files/', '/share/'];
const frameHeaderSkippedPrefixes = ['/api/files/', '/share/']; app
app .getHttpAdapter()
.getHttpAdapter() .getInstance()
.getInstance() .addHook('onSend', (req, reply, payload, done) => {
.addHook('onSend', (req, reply, payload, done) => { if (frameHeaderSkippedPrefixes.some((p) => req.url.startsWith(p))) {
if (frameHeaderSkippedPrefixes.some((p) => req.url.startsWith(p))) { return done(null, payload);
return done(null, payload); }
} const path = req.url.split('?')[0];
reply.header(frameHeader.name, frameHeader.value); // Force-denies the oauth consent screen even when the global frame header is absent.
done(null, payload); for (const header of resolveFrameHeadersForPath(path, frameHeader)) {
}); reply.header(header.name, header.value);
} }
done(null, payload);
});
app app
.getHttpAdapter() .getHttpAdapter()
+15
View File
@@ -527,6 +527,9 @@ importers:
'@fastify/static': '@fastify/static':
specifier: 10.1.3 specifier: 10.1.3
version: 10.1.3 version: 10.1.3
'@jmondi/oauth2-server':
specifier: ^4.3.7
version: 4.3.7
'@keyv/redis': '@keyv/redis':
specifier: 5.1.6 specifier: 5.1.6
version: 5.1.6(keyv@5.6.0) version: 5.1.6(keyv@5.6.0)
@@ -2525,6 +2528,10 @@ packages:
resolution: {integrity: sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==} resolution: {integrity: sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} 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': '@joplin/turndown-plugin-gfm@1.0.64':
resolution: {integrity: sha512-8GJ7f9OenE3zkSVII5B6qzIkvgF7C/a20gaASEjM6jWPLPJFFQ2nQ3Ou/kXH1mPUTs9dC9VYs8QXVPvZabKXBQ==} resolution: {integrity: sha512-8GJ7f9OenE3zkSVII5B6qzIkvgF7C/a20gaASEjM6jWPLPJFFQ2nQ3Ou/kXH1mPUTs9dC9VYs8QXVPvZabKXBQ==}
@@ -4994,6 +5001,7 @@ packages:
'@xmldom/xmldom@0.8.13': '@xmldom/xmldom@0.8.13':
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
deprecated: this version has critical issues, please update to the latest version
'@xtuc/ieee754@1.2.0': '@xtuc/ieee754@1.2.0':
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
@@ -6322,6 +6330,7 @@ packages:
eslint@9.28.0: eslint@9.28.0:
resolution: {integrity: sha512-ocgh41VhRlf9+fVpe7QKzwLj9c92fDiqOj8Y3Sd4/ZmVA4Btx4PlUYPq4pp9JDyupkf1upbEXecxL2mwNV7jPQ==} resolution: {integrity: sha512-ocgh41VhRlf9+fVpe7QKzwLj9c92fDiqOj8Y3Sd4/ZmVA4Btx4PlUYPq4pp9JDyupkf1upbEXecxL2mwNV7jPQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} 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 hasBin: true
peerDependencies: peerDependencies:
jiti: '*' jiti: '*'
@@ -12411,6 +12420,12 @@ snapshots:
'@types/yargs': 17.0.35 '@types/yargs': 17.0.35
chalk: 4.1.2 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-plugin-gfm@1.0.64': {}
'@joplin/turndown@4.0.82(supports-color@7.2.0)': '@joplin/turndown@4.0.82(supports-color@7.2.0)':