From a06671e8cea9faafaaaea66ff2eb45fb7873993e Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 3 May 2025 19:40:18 -0400 Subject: [PATCH] OAuth provider (#8884) This PR contains the necessary work to make Outline an OAuth provider including: - OAuth app registration - OAuth app management - Private / public apps (Public in cloud only) - Full OAuth 2.0 spec compatible authentication flow - Granular scopes - User token management screen in settings - Associated API endpoints for programatic access --- app/actions/definitions/oauthClients.tsx | 25 ++ app/actions/definitions/teams.tsx | 4 +- app/components/Avatar/Avatar.tsx | 31 +- app/components/Avatar/Initials.tsx | 2 - .../OAuthClient/OAuthClientForm.tsx | 142 ++++++++ app/components/OAuthClient/OAuthClientNew.tsx | 33 ++ app/components/Sidebar/App.tsx | 6 +- app/components/TeamLogo.ts | 5 +- app/hooks/useLoggedInSessions.ts | 14 + app/hooks/useRequest.ts | 2 +- app/hooks/useSettingsConfig.ts | 22 +- app/menus/OAuthAuthenticationMenu.tsx | 57 +++ app/menus/OAuthClientMenu.tsx | 68 ++++ .../{OrganizationMenu.tsx => TeamMenu.tsx} | 8 +- app/models/oauth/OAuthAuthentication.ts | 39 +++ app/models/oauth/OAuthClient.ts | 92 +++++ app/routes/authenticated.tsx | 110 +++--- app/routes/index.tsx | 14 +- app/routes/settings.tsx | 7 + app/scenes/Login/{index.tsx => Login.tsx} | 54 +-- app/scenes/Login/OAuthAuthorize.tsx | 260 ++++++++++++++ app/scenes/Login/OAuthScopeHelper.ts | 56 +++ app/scenes/Login/components/BackButton.tsx | 11 +- app/scenes/Login/components/Background.tsx | 12 + app/scenes/Login/components/Centered.tsx | 15 + app/scenes/Login/components/ConnectHeader.tsx | 40 +++ app/scenes/Login/components/Notices.tsx | 2 +- app/scenes/Login/components/TeamSwitcher.tsx | 109 ++++++ app/scenes/Login/index.ts | 3 + app/scenes/Settings/APIAndApps.tsx | 107 ++++++ app/scenes/Settings/ApiKeys.tsx | 1 - app/scenes/Settings/Application.tsx | 325 +++++++++++++++++ .../{PersonalApiKeys.tsx => Applications.tsx} | 44 +-- app/scenes/Settings/Details.tsx | 11 +- app/scenes/Settings/components/CopyButton.tsx | 50 +++ app/scenes/Settings/components/ImageInput.tsx | 20 +- .../OAuthAuthenticationListItem.tsx | 61 ++++ .../components/OAuthClientDeleteDialog.tsx | 37 ++ .../components/OAuthClientListItem.tsx | 55 +++ app/scenes/Settings/components/SettingRow.tsx | 2 +- app/stores/OAuthAuthenticationsStore.ts | 11 + app/stores/OAuthClientsStore.ts | 11 + app/stores/RootStore.ts | 25 +- app/utils/routeHelpers.ts | 4 +- package.json | 1 + .../server/api/webhookSubscriptions.ts | 17 +- .../server/tasks/DeliverWebhookTask.ts | 5 + server/env.ts | 26 ++ server/middlewares/authentication.test.ts | 118 ++++++- server/middlewares/authentication.ts | 56 ++- .../requestTracer.ts} | 4 +- .../20250331231413-add-oauth-server-models.js | 212 ++++++++++++ server/models/ApiKey.ts | 40 +-- .../helpers/AuthenticationHelper.test.ts | 126 +++++++ server/models/helpers/AuthenticationHelper.ts | 58 ++++ server/models/index.ts | 6 + server/models/oauth/OAuthAuthentication.ts | 212 ++++++++++++ server/models/oauth/OAuthAuthorizationCode.ts | 104 ++++++ server/models/oauth/OAuthClient.ts | 159 +++++++++ server/policies/index.ts | 4 +- server/policies/oauthAuthentication.ts | 14 + server/policies/oauthClient.ts | 19 + server/presenters/index.ts | 3 + server/presenters/oauthAuthentication.ts | 16 + server/presenters/oauthClient.ts | 42 +++ .../processors/OAuthClientDeletedProcessor.ts | 15 + .../OAuthClientUnpublishedProcessor.ts | 36 ++ .../queues/processors/UserDeletedProcessor.ts | 7 + .../processors/UserSuspendedProcessor.ts | 14 + .../CleanupOAuthAuthorizationCodeTask.test.ts | 37 ++ .../CleanupOAuthAuthorizationCodeTask.ts | 33 ++ server/routes/api/apiKeys/apiKeys.ts | 10 +- server/routes/api/index.ts | 8 +- .../oauthAuthentications.test.ts.snap | 19 + .../routes/api/oauthAuthentications/index.ts | 1 + .../oauthAuthentications.test.ts | 194 +++++++++++ .../oauthAuthentications.ts | 90 +++++ .../routes/api/oauthAuthentications/schema.ts | 21 ++ .../__snapshots__/oauthClients.test.ts.snap | 55 +++ server/routes/api/oauthClients/index.ts | 1 + .../api/oauthClients/oauthClients.test.ts | 326 ++++++++++++++++++ .../routes/api/oauthClients/oauthClients.ts | 181 ++++++++++ server/routes/api/oauthClients/schema.ts | 128 +++++++ server/routes/oauth/index.test.ts | 47 +++ server/routes/oauth/index.ts | 132 +++++++ .../oauth/middlewares/oauthErrorHandler.ts | 41 +++ server/routes/oauth/schema.ts | 11 + .../scripts/20240930113921-hash-api-keys.ts | 3 +- server/services/web.ts | 3 + server/test/factories.ts | 112 +++++- server/types.ts | 10 +- server/utils/crypto.ts | 10 + server/utils/oauth/OAuthInterface.test.ts | 104 ++++++ server/utils/oauth/OAuthInterface.ts | 294 ++++++++++++++++ shared/i18n/locales/en_US/translation.json | 79 ++++- shared/types.ts | 9 + shared/utils/urls.ts | 13 +- shared/validations.ts | 20 ++ yarn.lock | 23 +- 99 files changed, 5115 insertions(+), 221 deletions(-) create mode 100644 app/actions/definitions/oauthClients.tsx create mode 100644 app/components/OAuthClient/OAuthClientForm.tsx create mode 100644 app/components/OAuthClient/OAuthClientNew.tsx create mode 100644 app/hooks/useLoggedInSessions.ts create mode 100644 app/menus/OAuthAuthenticationMenu.tsx create mode 100644 app/menus/OAuthClientMenu.tsx rename app/menus/{OrganizationMenu.tsx => TeamMenu.tsx} (91%) create mode 100644 app/models/oauth/OAuthAuthentication.ts create mode 100644 app/models/oauth/OAuthClient.ts rename app/scenes/Login/{index.tsx => Login.tsx} (90%) create mode 100644 app/scenes/Login/OAuthAuthorize.tsx create mode 100644 app/scenes/Login/OAuthScopeHelper.ts create mode 100644 app/scenes/Login/components/Background.tsx create mode 100644 app/scenes/Login/components/Centered.tsx create mode 100644 app/scenes/Login/components/ConnectHeader.tsx create mode 100644 app/scenes/Login/components/TeamSwitcher.tsx create mode 100644 app/scenes/Login/index.ts create mode 100644 app/scenes/Settings/APIAndApps.tsx create mode 100644 app/scenes/Settings/Application.tsx rename app/scenes/Settings/{PersonalApiKeys.tsx => Applications.tsx} (53%) create mode 100644 app/scenes/Settings/components/CopyButton.tsx create mode 100644 app/scenes/Settings/components/OAuthAuthenticationListItem.tsx create mode 100644 app/scenes/Settings/components/OAuthClientDeleteDialog.tsx create mode 100644 app/scenes/Settings/components/OAuthClientListItem.tsx create mode 100644 app/stores/OAuthAuthenticationsStore.ts create mode 100644 app/stores/OAuthClientsStore.ts rename server/{routes/api/middlewares/apiTracer.ts => middlewares/requestTracer.ts} (81%) create mode 100644 server/migrations/20250331231413-add-oauth-server-models.js create mode 100644 server/models/helpers/AuthenticationHelper.test.ts create mode 100644 server/models/oauth/OAuthAuthentication.ts create mode 100644 server/models/oauth/OAuthAuthorizationCode.ts create mode 100644 server/models/oauth/OAuthClient.ts create mode 100644 server/policies/oauthAuthentication.ts create mode 100644 server/policies/oauthClient.ts create mode 100644 server/presenters/oauthAuthentication.ts create mode 100644 server/presenters/oauthClient.ts create mode 100644 server/queues/processors/OAuthClientDeletedProcessor.ts create mode 100644 server/queues/processors/OAuthClientUnpublishedProcessor.ts create mode 100644 server/queues/processors/UserSuspendedProcessor.ts create mode 100644 server/queues/tasks/CleanupOAuthAuthorizationCodeTask.test.ts create mode 100644 server/queues/tasks/CleanupOAuthAuthorizationCodeTask.ts create mode 100644 server/routes/api/oauthAuthentications/__snapshots__/oauthAuthentications.test.ts.snap create mode 100644 server/routes/api/oauthAuthentications/index.ts create mode 100644 server/routes/api/oauthAuthentications/oauthAuthentications.test.ts create mode 100644 server/routes/api/oauthAuthentications/oauthAuthentications.ts create mode 100644 server/routes/api/oauthAuthentications/schema.ts create mode 100644 server/routes/api/oauthClients/__snapshots__/oauthClients.test.ts.snap create mode 100644 server/routes/api/oauthClients/index.ts create mode 100644 server/routes/api/oauthClients/oauthClients.test.ts create mode 100644 server/routes/api/oauthClients/oauthClients.ts create mode 100644 server/routes/api/oauthClients/schema.ts create mode 100644 server/routes/oauth/index.test.ts create mode 100644 server/routes/oauth/index.ts create mode 100644 server/routes/oauth/middlewares/oauthErrorHandler.ts create mode 100644 server/routes/oauth/schema.ts create mode 100644 server/utils/oauth/OAuthInterface.test.ts create mode 100644 server/utils/oauth/OAuthInterface.ts diff --git a/app/actions/definitions/oauthClients.tsx b/app/actions/definitions/oauthClients.tsx new file mode 100644 index 0000000000..76eba98ff3 --- /dev/null +++ b/app/actions/definitions/oauthClients.tsx @@ -0,0 +1,25 @@ +import { PlusIcon } from "outline-icons"; +import * as React from "react"; +import stores from "~/stores"; +import { OAuthClientNew } from "~/components/OAuthClient/OAuthClientNew"; +import { createAction } from ".."; +import { SettingsSection } from "../sections"; + +export const createOAuthClient = createAction({ + name: ({ t }) => t("New App"), + analyticsName: "New App", + section: SettingsSection, + icon: , + keywords: "create", + visible: () => + stores.policies.abilities(stores.auth.team?.id || "").createOAuthClient, + perform: ({ t, event }) => { + event?.preventDefault(); + event?.stopPropagation(); + + stores.dialogs.openModal({ + title: t("New Application"), + content: , + }); + }, +}); diff --git a/app/actions/definitions/teams.tsx b/app/actions/definitions/teams.tsx index a9847551d7..02be788fd6 100644 --- a/app/actions/definitions/teams.tsx +++ b/app/actions/definitions/teams.tsx @@ -11,7 +11,7 @@ import { ActionContext } from "~/types"; import Desktop from "~/utils/Desktop"; import { TeamSection } from "../sections"; -export const createTeamsList = ({ stores }: { stores: RootStore }) => +export const switchTeamsList = ({ stores }: { stores: RootStore }) => stores.auth.availableTeams?.map((session) => ({ id: `switch-${session.id}`, name: session.name, @@ -44,7 +44,7 @@ export const switchTeam = createAction({ section: TeamSection, visible: ({ stores }) => !!stores.auth.availableTeams && stores.auth.availableTeams?.length > 1, - children: createTeamsList, + children: switchTeamsList, }); export const createTeam = createAction({ diff --git a/app/components/Avatar/Avatar.tsx b/app/components/Avatar/Avatar.tsx index 731e132e9c..e8b730a90e 100644 --- a/app/components/Avatar/Avatar.tsx +++ b/app/components/Avatar/Avatar.tsx @@ -13,6 +13,11 @@ export enum AvatarSize { Upload = 64, } +export enum AvatarVariant { + Round = "round", + Square = "square", +} + export interface IAvatar { avatarUrl: string | null; color?: string; @@ -23,6 +28,8 @@ export interface IAvatar { type Props = { /** The size of the avatar */ size: AvatarSize; + /** The variant of the avatar */ + variant?: AvatarVariant; /** The source of the avatar image, if not passing a model. */ src?: string; /** The avatar model, if not passing a source. */ @@ -38,14 +45,14 @@ type Props = { }; function Avatar(props: Props) { - const { model, style, ...rest } = props; + const { model, style, variant = AvatarVariant.Round, ...rest } = props; const src = props.src || model?.avatarUrl; const [error, handleError] = useBoolean(false); return ( - + {src && !error ? ( - + ) : model ? ( {model.initial} @@ -61,19 +68,19 @@ Avatar.defaultProps = { size: AvatarSize.Medium, }; -const Relative = styled.div` +const Relative = styled.div<{ $variant: AvatarVariant; $size: AvatarSize }>` position: relative; user-select: none; flex-shrink: 0; -`; - -const CircleImg = styled.img<{ size: number }>` - display: block; - width: ${(props) => props.size}px; - height: ${(props) => props.size}px; - border-radius: 50%; - flex-shrink: 0; + border-radius: ${(props) => + props.$variant === AvatarVariant.Round ? "50%" : `${props.$size / 8}px`}; overflow: hidden; `; +const Image = styled.img<{ size: number }>` + display: block; + width: ${(props) => props.size}px; + height: ${(props) => props.size}px; +`; + export default Avatar; diff --git a/app/components/Avatar/Initials.tsx b/app/components/Avatar/Initials.tsx index 775e380e0c..e6d51b9be6 100644 --- a/app/components/Avatar/Initials.tsx +++ b/app/components/Avatar/Initials.tsx @@ -13,7 +13,6 @@ const Initials = styled(Flex)<{ }>` align-items: center; justify-content: center; - border-radius: 50%; width: 100%; height: 100%; color: ${(props) => @@ -23,7 +22,6 @@ const Initials = styled(Flex)<{ background-color: ${(props) => props.color ?? props.theme.textTertiary}; width: ${(props) => props.size}px; height: ${(props) => props.size}px; - border-radius: 50%; flex-shrink: 0; // adjust font size down for each additional character diff --git a/app/components/OAuthClient/OAuthClientForm.tsx b/app/components/OAuthClient/OAuthClientForm.tsx new file mode 100644 index 0000000000..921c71c707 --- /dev/null +++ b/app/components/OAuthClient/OAuthClientForm.tsx @@ -0,0 +1,142 @@ +import { observer } from "mobx-react"; +import * as React from "react"; +import { Controller, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { OAuthClientValidation } from "@shared/validations"; +import OAuthClient from "~/models/oauth/OAuthClient"; +import ImageInput from "~/scenes/Settings/components/ImageInput"; +import Button from "~/components/Button"; +import Flex from "~/components/Flex"; +import Input, { LabelText } from "~/components/Input"; +import isCloudHosted from "~/utils/isCloudHosted"; +import Switch from "../Switch"; + +export interface FormData { + name: string; + developerName: string; + developerUrl: string; + description: string; + avatarUrl: string; + redirectUris: string[]; + published: boolean; +} + +export const OAuthClientForm = observer(function OAuthClientForm_({ + handleSubmit, + oauthClient, +}: { + handleSubmit: (data: FormData) => void; + oauthClient?: OAuthClient; +}) { + const { t } = useTranslation(); + + const { + register, + handleSubmit: formHandleSubmit, + formState, + getValues, + setFocus, + setError, + control, + } = useForm({ + mode: "all", + defaultValues: { + name: oauthClient?.name ?? "", + description: oauthClient?.description ?? "", + avatarUrl: oauthClient?.avatarUrl ?? "", + redirectUris: oauthClient?.redirectUris ?? [], + published: oauthClient?.published ?? false, + }, + }); + + React.useEffect(() => { + setTimeout(() => setFocus("name", { shouldSelect: true }), 100); + }, [setFocus]); + + return ( +
+ <> + + + + ( + { + field.onChange(event.target.value.split("\n")); + }} + required + /> + )} + /> + {isCloudHosted && ( + + )} + + + + + + + ); +}); diff --git a/app/components/OAuthClient/OAuthClientNew.tsx b/app/components/OAuthClient/OAuthClientNew.tsx new file mode 100644 index 0000000000..eeae134dac --- /dev/null +++ b/app/components/OAuthClient/OAuthClientNew.tsx @@ -0,0 +1,33 @@ +import { observer } from "mobx-react"; +import * as React from "react"; +import { useHistory } from "react-router-dom"; +import { toast } from "sonner"; +import useStores from "~/hooks/useStores"; +import { settingsPath } from "~/utils/routeHelpers"; +import { OAuthClientForm, FormData } from "./OAuthClientForm"; + +type Props = { + onSubmit: () => void; +}; + +export const OAuthClientNew = observer(function OAuthClientNew_({ + onSubmit, +}: Props) { + const { oauthClients } = useStores(); + const history = useHistory(); + + const handleSubmit = React.useCallback( + async (data: FormData) => { + try { + const oauthClient = await oauthClients.save(data); + onSubmit?.(); + history.push(settingsPath("applications", oauthClient.id)); + } catch (error) { + toast.error(error.message); + } + }, + [oauthClients, history, onSubmit] + ); + + return ; +}); diff --git a/app/components/Sidebar/App.tsx b/app/components/Sidebar/App.tsx index 1f89d2210c..633e3781fb 100644 --- a/app/components/Sidebar/App.tsx +++ b/app/components/Sidebar/App.tsx @@ -12,7 +12,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam"; import useCurrentUser from "~/hooks/useCurrentUser"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; -import OrganizationMenu from "~/menus/OrganizationMenu"; +import TeamMenu from "~/menus/TeamMenu"; import { homePath, searchPath } from "~/utils/routeHelpers"; import TeamLogo from "../TeamLogo"; import Tooltip from "../Tooltip"; @@ -62,7 +62,7 @@ function AppSidebar() { - + {(props: SidebarButtonProps) => ( )} - +
; + +export function useLoggedInSessions(): Sessions { + return JSON.parse(getCookie("sessions") || "{}"); +} diff --git a/app/hooks/useRequest.ts b/app/hooks/useRequest.ts index e5b6eed59a..4c40b81c68 100644 --- a/app/hooks/useRequest.ts +++ b/app/hooks/useRequest.ts @@ -59,7 +59,7 @@ export default function useRequest( if (makeRequestOnMount) { void request(); } - }, [request, makeRequestOnMount]); + }, []); return { data, loading, loaded, error, request }; } diff --git a/app/hooks/useSettingsConfig.ts b/app/hooks/useSettingsConfig.ts index e564103dc7..ece9989b0e 100644 --- a/app/hooks/useSettingsConfig.ts +++ b/app/hooks/useSettingsConfig.ts @@ -13,6 +13,7 @@ import { ImportIcon, ShapesIcon, Icon, + InternetIcon, } from "outline-icons"; import React, { ComponentProps } from "react"; import { useTranslation } from "react-i18next"; @@ -28,7 +29,8 @@ import useCurrentUser from "./useCurrentUser"; import usePolicy from "./usePolicy"; const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys")); -const PersonalApiKeys = lazy(() => import("~/scenes/Settings/PersonalApiKeys")); +const Applications = lazy(() => import("~/scenes/Settings/Applications")); +const APIAndApps = lazy(() => import("~/scenes/Settings/APIAndApps")); const Details = lazy(() => import("~/scenes/Settings/Details")); const Export = lazy(() => import("~/scenes/Settings/Export")); const Features = lazy(() => import("~/scenes/Settings/Features")); @@ -86,12 +88,12 @@ const useSettingsConfig = () => { icon: EmailIcon, }, { - name: t("API Keys"), - path: settingsPath("personal-api-keys"), - component: PersonalApiKeys, - enabled: can.createApiKey && !can.listApiKeys, + name: t("API & Apps"), + path: settingsPath("api-and-apps"), + component: APIAndApps, + enabled: true, group: t("Account"), - icon: CodeIcon, + icon: PadlockIcon, }, // Workspace { @@ -150,6 +152,14 @@ const useSettingsConfig = () => { group: t("Workspace"), icon: CodeIcon, }, + { + name: t("Applications"), + path: settingsPath("applications"), + component: Applications, + enabled: can.listOAuthClients, + group: t("Workspace"), + icon: InternetIcon, + }, { name: t("Shared Links"), path: settingsPath("shares"), diff --git a/app/menus/OAuthAuthenticationMenu.tsx b/app/menus/OAuthAuthenticationMenu.tsx new file mode 100644 index 0000000000..8b826655c7 --- /dev/null +++ b/app/menus/OAuthAuthenticationMenu.tsx @@ -0,0 +1,57 @@ +import { observer } from "mobx-react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { useMenuState } from "reakit/Menu"; +import OAuthAuthentication from "~/models/oauth/OAuthAuthentication"; +import ConfirmationDialog from "~/components/ConfirmationDialog"; +import ContextMenu from "~/components/ContextMenu"; +import MenuItem from "~/components/ContextMenu/MenuItem"; +import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton"; +import useStores from "~/hooks/useStores"; + +type Props = { + /** The OAuthAuthentication to associate with the menu */ + oauthAuthentication: OAuthAuthentication; +}; + +function OAuthAuthenticationMenu({ oauthAuthentication }: Props) { + const menu = useMenuState({ + modal: true, + }); + const { dialogs } = useStores(); + const { t } = useTranslation(); + + const handleRevoke = React.useCallback(() => { + dialogs.openModal({ + title: t("Revoke {{ appName }}", { + appName: oauthAuthentication.oauthClient.name, + }), + content: ( + { + await oauthAuthentication.deleteAll(); + dialogs.closeAllModals(); + }} + submitText={t("Revoke")} + savingText={`${t("Revoking")}…`} + danger + > + {t("Are you sure you want to revoke access?")} + + ), + }); + }, [t, dialogs, oauthAuthentication]); + + return ( + <> + + + + {t("Revoke")} + + + + ); +} + +export default observer(OAuthAuthenticationMenu); diff --git a/app/menus/OAuthClientMenu.tsx b/app/menus/OAuthClientMenu.tsx new file mode 100644 index 0000000000..e2177c3b8e --- /dev/null +++ b/app/menus/OAuthClientMenu.tsx @@ -0,0 +1,68 @@ +import { observer } from "mobx-react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { useMenuState } from "reakit/Menu"; +import OAuthClient from "~/models/oauth/OAuthClient"; +import OAuthClientDeleteDialog from "~/scenes/Settings/components/OAuthClientDeleteDialog"; +import ContextMenu from "~/components/ContextMenu"; +import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton"; +import Template from "~/components/ContextMenu/Template"; +import useStores from "~/hooks/useStores"; +import { settingsPath } from "~/utils/routeHelpers"; + +type Props = { + /** The oauthClient to associate with the menu */ + oauthClient: OAuthClient; + /** Whether to show the edit button */ + showEdit?: boolean; +}; + +function OAuthClientMenu({ oauthClient, showEdit }: Props) { + const menu = useMenuState({ + modal: true, + }); + const { dialogs } = useStores(); + const { t } = useTranslation(); + + const handleDelete = React.useCallback(() => { + dialogs.openModal({ + title: t("Delete app"), + content: ( + + ), + }); + }, [t, dialogs, oauthClient]); + + return ( + <> + + +