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 ( + + <> + + {t("Icon")} + ( + field.onChange(url)} + onError={(err) => setError("avatarUrl", { message: err })} + model={{ + id: oauthClient?.id, + avatarUrl: field.value, + initial: getValues().name[0], + }} + borderRadius={0} + /> + )} + /> + + + + ( + { + field.onChange(event.target.value.split("\n")); + }} + required + /> + )} + /> + {isCloudHosted && ( + + )} + > + + + + {oauthClient + ? formState.isSubmitting + ? `${t("Saving")}…` + : t("Save") + : formState.isSubmitting + ? `${t("Creating")}…` + : t("Create")} + + + + ); +}); 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 ( + <> + + + + + > + ); +} + +export default observer(OAuthClientMenu); diff --git a/app/menus/OrganizationMenu.tsx b/app/menus/TeamMenu.tsx similarity index 91% rename from app/menus/OrganizationMenu.tsx rename to app/menus/TeamMenu.tsx index 1113476a71..6010ce7f7a 100644 --- a/app/menus/OrganizationMenu.tsx +++ b/app/menus/TeamMenu.tsx @@ -10,7 +10,7 @@ import { } from "~/actions/definitions/navigation"; import { createTeam, - createTeamsList, + switchTeamsList, desktopLoginTeam, } from "~/actions/definitions/teams"; import useActionContext from "~/hooks/useActionContext"; @@ -22,7 +22,7 @@ type Props = { children?: React.ReactNode; }; -const OrganizationMenu: React.FC = ({ children }: Props) => { +const TeamMenu: React.FC = ({ children }: Props) => { const menu = useMenuState({ unstable_offset: [4, -4], placement: "bottom-start", @@ -44,7 +44,7 @@ const OrganizationMenu: React.FC = ({ children }: Props) => { // menu is not cached at all. const actions = React.useMemo( () => [ - ...createTeamsList(context), + ...switchTeamsList(context), createTeam, desktopLoginTeam, separator(), @@ -64,4 +64,4 @@ const OrganizationMenu: React.FC = ({ children }: Props) => { ); }; -export default observer(OrganizationMenu); +export default observer(TeamMenu); diff --git a/app/models/oauth/OAuthAuthentication.ts b/app/models/oauth/OAuthAuthentication.ts new file mode 100644 index 0000000000..c982befcfe --- /dev/null +++ b/app/models/oauth/OAuthAuthentication.ts @@ -0,0 +1,39 @@ +import { action, observable } from "mobx"; +import { client } from "~/utils/ApiClient"; +import User from "../User"; +import ParanoidModel from "../base/ParanoidModel"; +import Field from "../decorators/Field"; +import Relation from "../decorators/Relation"; +import OAuthClient from "./OAuthClient"; + +class OAuthAuthentication extends ParanoidModel { + static modelName = "OAuthAuthentication"; + + /** A list of scopes that this authentication has access to */ + @Field + @observable + scope: string[]; + + @Relation(() => User) + user: User; + + userId: string; + + oauthClient: Pick; + + oauthClientId: string; + + lastActiveAt: string; + + @action + public async deleteAll() { + await client.post(`/${this.store.apiEndpoint}.delete`, { + oauthClientId: this.oauthClientId, + scope: this.scope, + }); + + return this.store.remove(this.id); + } +} + +export default OAuthAuthentication; diff --git a/app/models/oauth/OAuthClient.ts b/app/models/oauth/OAuthClient.ts new file mode 100644 index 0000000000..656824c4e1 --- /dev/null +++ b/app/models/oauth/OAuthClient.ts @@ -0,0 +1,92 @@ +import invariant from "invariant"; +import { observable, runInAction } from "mobx"; +import queryString from "query-string"; +import env from "~/env"; +import { client } from "~/utils/ApiClient"; +import User from "../User"; +import ParanoidModel from "../base/ParanoidModel"; +import Field from "../decorators/Field"; +import Relation from "../decorators/Relation"; + +class OAuthClient extends ParanoidModel { + static modelName = "OAuthClient"; + + /** The human-readable name of this app */ + @Field + @observable + name: string; + + /** A short description of this app */ + @Field + @observable + description: string | null; + + /** The name of the developer of this app */ + @Field + @observable + developerName: string | null; + + /** The URL of the developer of this app */ + @Field + @observable + developerUrl: string | null; + + /** The URL of the avatar of the developer of this app */ + @Field + @observable + avatarUrl: string | null; + + /** The public identifier of this app */ + @Field + clientId: string; + + /** The secret key used to authenticate this app */ + @Field + @observable + clientSecret: string; + + /** Whether this app is published (available to other workspaces) */ + @Field + @observable + published: boolean; + + /** A list of valid redirect URIs for this app */ + @Field + @observable + redirectUris: string[]; + + @Relation(() => User) + createdBy: User; + + createdById: string; + + // instance methods + + public async rotateClientSecret() { + const res = await client.post("/oauthClients.rotate_secret", { + id: this.id, + }); + invariant(res.data, "Failed to rotate client secret"); + + runInAction("OAuthClient#rotateSecret", () => { + this.clientSecret = res.data.clientSecret; + }); + } + + public get initial() { + return this.name[0]; + } + + public get authorizationUrl(): string { + const params = { + client_id: this.clientId, + redirect_uri: this.redirectUris[0], + response_type: "code", + scope: "read", + }; + + return `${env.URL}/oauth/authorize?${queryString.stringify(params)}`; + } +} + +export default OAuthClient; diff --git a/app/routes/authenticated.tsx b/app/routes/authenticated.tsx index 30f407fe9e..d778dc09f7 100644 --- a/app/routes/authenticated.tsx +++ b/app/routes/authenticated.tsx @@ -42,55 +42,77 @@ const RedirectDocument = ({ /> ); +/** + * The authenticated routes are all the routes of the application that require + * the user to be logged in. + */ function AuthenticatedRoutes() { const team = useCurrentTeam(); const can = usePolicy(team); return ( - - - - - - } - > - - {can.createDocument && ( - - )} - {can.createDocument && ( - - )} - {can.createDocument && ( - - )} - - - - - - - - - - - - - - - - - - - - - + + + + + + + } + > + + {can.createDocument && ( + + )} + {can.createDocument && ( + + )} + {can.createDocument && ( + + )} + + + + + + + + + + + + + + + + + + + + + + ); } diff --git a/app/routes/index.tsx b/app/routes/index.tsx index 3f90c125b8..a508966a4f 100644 --- a/app/routes/index.tsx +++ b/app/routes/index.tsx @@ -6,14 +6,15 @@ import FullscreenLoading from "~/components/FullscreenLoading"; import Route from "~/components/ProfiledRoute"; import env from "~/env"; import useQueryNotices from "~/hooks/useQueryNotices"; -import lazyWithRetry from "~/utils/lazyWithRetry"; +import lazy from "~/utils/lazyWithRetry"; import { matchDocumentSlug as slug } from "~/utils/routeHelpers"; -const Authenticated = lazyWithRetry(() => import("~/components/Authenticated")); -const AuthenticatedRoutes = lazyWithRetry(() => import("./authenticated")); -const SharedDocument = lazyWithRetry(() => import("~/scenes/Document/Shared")); -const Login = lazyWithRetry(() => import("~/scenes/Login")); -const Logout = lazyWithRetry(() => import("~/scenes/Logout")); +const Authenticated = lazy(() => import("~/components/Authenticated")); +const AuthenticatedRoutes = lazy(() => import("./authenticated")); +const SharedDocument = lazy(() => import("~/scenes/Document/Shared")); +const Login = lazy(() => import("~/scenes/Login")); +const Logout = lazy(() => import("~/scenes/Logout")); +const OAuthAuthorize = lazy(() => import("~/scenes/Login/OAuthAuthorize")); export default function Routes() { useQueryNotices(); @@ -43,6 +44,7 @@ export default function Routes() { + diff --git a/app/routes/settings.tsx b/app/routes/settings.tsx index 8a5d54b68a..f32664acb3 100644 --- a/app/routes/settings.tsx +++ b/app/routes/settings.tsx @@ -7,6 +7,7 @@ import useSettingsConfig from "~/hooks/useSettingsConfig"; import lazy from "~/utils/lazyWithRetry"; import { matchDocumentSlug, settingsPath } from "~/utils/routeHelpers"; +const Application = lazy(() => import("~/scenes/Settings/Application")); const Document = lazy(() => import("~/scenes/Document")); export default function SettingsRoutes() { @@ -22,6 +23,12 @@ export default function SettingsRoutes() { component={config.component} /> ))} + {/* TODO: Refactor these exceptions into config? */} + React.ReactNode; + onBack?: () => void; }; -function Login({ children }: Props) { +function Login({ children, onBack }: Props) { const location = useLocation(); const query = useQuery(); const notice = query.get("notice"); @@ -110,9 +111,9 @@ function Login({ children }: Props) { if (error) { return ( - + - + {t("Error")} @@ -142,9 +143,9 @@ function Login({ children }: Props) { if (isCloudHosted && isCustomDomain && !config.name) { return ( - + - + {t("Almost there")}… @@ -160,17 +161,10 @@ function Login({ children }: Props) { if (Desktop.isElectron() && notice === "domain-required") { return ( - + - + {t("Choose workspace")} {t( @@ -206,8 +200,8 @@ function Login({ children }: Props) { if (emailLinkSentTo) { return ( - - + + {t("Check your email")} @@ -241,10 +235,10 @@ function Login({ children }: Props) { return ( - + - + @@ -336,14 +330,6 @@ const CheckEmailIcon = styled(EmailIcon)` margin-bottom: -1.5em; `; -const Background = styled(Fade)` - width: 100vw; - height: 100%; - background: ${s("background")}; - display: flex; - ${draggableOnDesktop()} -`; - const Logo = styled.div` margin-bottom: -4px; `; @@ -389,12 +375,4 @@ const Or = styled.hr` } `; -const Centered = styled(Flex)` - user-select: none; - width: 90vw; - height: 100%; - max-width: 320px; - margin: 0 auto; -`; - export default observer(Login); diff --git a/app/scenes/Login/OAuthAuthorize.tsx b/app/scenes/Login/OAuthAuthorize.tsx new file mode 100644 index 0000000000..e4257bfcaf --- /dev/null +++ b/app/scenes/Login/OAuthAuthorize.tsx @@ -0,0 +1,260 @@ +import React from "react"; +import { Trans, useTranslation } from "react-i18next"; +import styled from "styled-components"; +import Flex from "@shared/components/Flex"; +import { s } from "@shared/styles"; +import { parseDomain } from "@shared/utils/domains"; +import type OAuthClient from "~/models/oauth/OAuthClient"; +import ButtonLarge from "~/components/ButtonLarge"; +import ChangeLanguage from "~/components/ChangeLanguage"; +import Heading from "~/components/Heading"; +import LoadingIndicator from "~/components/LoadingIndicator"; +import PageTitle from "~/components/PageTitle"; +import Text from "~/components/Text"; +import env from "~/env"; +import useCurrentTeam from "~/hooks/useCurrentTeam"; +import { useLoggedInSessions } from "~/hooks/useLoggedInSessions"; +import useQuery from "~/hooks/useQuery"; +import useRequest from "~/hooks/useRequest"; +import { client } from "~/utils/ApiClient"; +import { BadRequestError, NotFoundError } from "~/utils/errors"; +import isCloudHosted from "~/utils/isCloudHosted"; +import { detectLanguage } from "~/utils/language"; +import Login from "./Login"; +import { OAuthScopeHelper } from "./OAuthScopeHelper"; +import { Background } from "./components/Background"; +import { Centered } from "./components/Centered"; +import { ConnectHeader } from "./components/ConnectHeader"; +import { TeamSwitcher } from "./components/TeamSwitcher"; + +export default function OAuthAuthorize() { + const team = useCurrentTeam({ rejectOnEmpty: false }); + const sessions = useLoggedInSessions(); + + // We're self-hosted or on a team subdomain already, just show the authorize screen. + if (team) { + return ; + } + + // Cloud hosted and on root domain – show the workspace switcher. + const isAppRoot = + parseDomain(window.location.hostname).host === parseDomain(env.URL).host; + const hasLoggedInSessions = Object.keys(sessions).length > 0; + if (isCloudHosted && hasLoggedInSessions && isAppRoot) { + return ; + } + + return ; +} + +/** + * Authorize component is responsible for handling the OAuth authorization process. + * It retrieves the OAuth client information, displays the authorization request, + * and allows the user to either authorize or cancel the request. + */ +function Authorize() { + const team = useCurrentTeam(); + const params = useQuery(); + const { t } = useTranslation(); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const timeoutRef = React.useRef(); + const { + client_id: clientId, + redirect_uri: redirectUri, + response_type: responseType, + code_challenge: codeChallenge, + code_challenge_method: codeChallengeMethod, + state, + scope, + } = Object.fromEntries(params); + const [scopes] = React.useState(() => scope?.split(" ") ?? []); + const { error: clientError, data: response } = useRequest<{ + data: OAuthClient; + }>(() => client.post("/oauthClients.info", { clientId, redirectUri }), true); + + const handleCancel = () => { + if (redirectUri && !clientError) { + const url = new URL(redirectUri); + url.searchParams.set("error", "access_denied"); + window.location.href = url.toString(); + return; + } + if (window.history.length) { + window.history.back(); + } else { + window.location.href = "/"; + } + }; + + const handleSubmit = () => { + setIsSubmitting(true); + timeoutRef.current = window.setTimeout(() => setIsSubmitting(false), 5000); + }; + + React.useEffect( + () => () => { + timeoutRef.current && window.clearTimeout(timeoutRef.current); + }, + [] + ); + + const missingParams = [ + !clientId && "client_id", + !redirectUri && "redirect_uri", + !responseType && "response_type", + !scope && "scope", + !state && "state", + ].filter(Boolean); + + if (missingParams.length || clientError) { + return ( + + + {t("An error occurred")} + {clientError instanceof NotFoundError ? ( + + {t( + "The OAuth client could not be found, please check the provided client ID" + )} + {clientId} + + ) : clientError instanceof BadRequestError ? ( + + {t( + "The OAuth client could not be loaded, please check the redirect URI is valid" + )} + {redirectUri} + + ) : ( + + {t("Required OAuth parameters are missing")} + + {missingParams.map((param) => ( + <> + {param} + + > + ))} + + + )} + + + ); + } + + if (!response) { + return ; + } + + const { name, developerName, developerUrl } = response.data; + + return ( + + + + + + + {t(`{{ appName }} wants to access {{ teamName }}`, { + appName: name, + teamName: team.name, + })} + + {developerName && ( + + + ) : ( + + ), + }} + /> + + )} + + {t( + "{{ appName }} will be able to access your account and perform the following actions", + { + appName: name, + } + )} + : + + + {OAuthScopeHelper.normalizeScopes(scopes, t).map((item) => ( + + {item} + + ))} + + + + + + + + {codeChallenge && ( + + )} + {codeChallengeMethod && ( + + )} + + + {t("Cancel")} + + + {t("Authorize")} + + + + + + ); +} + +const Button = styled(ButtonLarge)` + width: calc(50% - 4px); +`; + +const StyledHeading = styled(Heading).attrs({ + as: "h2", + centered: true, +})` + margin-top: 0; +`; + +const Pre = styled.pre` + background: ${s("backgroundSecondary")}; + padding: 16px; + border-radius: 4px; + font-size: 12px; + white-space: pre-wrap; +`; diff --git a/app/scenes/Login/OAuthScopeHelper.ts b/app/scenes/Login/OAuthScopeHelper.ts new file mode 100644 index 0000000000..598d4ef004 --- /dev/null +++ b/app/scenes/Login/OAuthScopeHelper.ts @@ -0,0 +1,56 @@ +import type { TFunction } from "i18next"; +import capitalize from "lodash/capitalize"; +import uniq from "lodash/uniq"; +import { Scope } from "@shared/types"; + +export class OAuthScopeHelper { + public static normalizeScopes(scopes: string[], t: TFunction): string[] { + const methodToReadable = { + list: t("read"), + info: t("read"), + read: t("read"), + write: t("write"), + create: t("write"), + update: t("write"), + delete: t("write"), + "*": t("read and write"), + }; + + const translatedNamespaces = { + apiKeys: t("API keys"), + attachments: t("attachments"), + collections: t("collections"), + comments: t("comments"), + documents: t("documents"), + events: t("events"), + groups: t("groups"), + integrations: t("integrations"), + notifications: t("notifications"), + reactions: t("reactions"), + pins: t("pins"), + shares: t("shares"), + users: t("users"), + teams: t("teams"), + "*": t("workspace"), + }; + + const normalizedScopes = scopes.map((scope) => { + if (scope === Scope.Read) { + return t("Read all data"); + } + if (scope === Scope.Write) { + return t("Write all data"); + } + + const [namespace, method] = scope.replace("/api/", "").split(/[:\.]/g); + const readableMethod = + methodToReadable[method as keyof typeof methodToReadable] ?? method; + const translatedNamespace = + translatedNamespaces[namespace as keyof typeof translatedNamespaces] ?? + namespace; + return capitalize(`${readableMethod} ${translatedNamespace}`); + }); + + return uniq(normalizedScopes); + } +} diff --git a/app/scenes/Login/components/BackButton.tsx b/app/scenes/Login/components/BackButton.tsx index 28ce8aa6b2..477ce6992f 100644 --- a/app/scenes/Login/components/BackButton.tsx +++ b/app/scenes/Login/components/BackButton.tsx @@ -10,12 +10,21 @@ import isCloudHosted from "~/utils/isCloudHosted"; type Props = { config?: Config; + onBack?: () => void; }; -export default function BackButton({ config }: Props) { +export function BackButton({ onBack, config }: Props) { const { t } = useTranslation(); const isSubdomain = !!config?.hostname; + if (onBack) { + return ( + + {t("Back")} + + ); + } + if (!isCloudHosted || parseDomain(window.location.origin).custom) { return null; } diff --git a/app/scenes/Login/components/Background.tsx b/app/scenes/Login/components/Background.tsx new file mode 100644 index 0000000000..532d6e9841 --- /dev/null +++ b/app/scenes/Login/components/Background.tsx @@ -0,0 +1,12 @@ +import styled from "styled-components"; +import { s } from "@shared/styles"; +import Fade from "~/components/Fade"; +import { draggableOnDesktop } from "~/styles"; + +export const Background = styled(Fade)` + width: 100vw; + height: 100%; + background: ${s("background")}; + display: flex; + ${draggableOnDesktop()} +`; diff --git a/app/scenes/Login/components/Centered.tsx b/app/scenes/Login/components/Centered.tsx new file mode 100644 index 0000000000..4c84301af1 --- /dev/null +++ b/app/scenes/Login/components/Centered.tsx @@ -0,0 +1,15 @@ +import styled from "styled-components"; +import Flex from "@shared/components/Flex"; + +export const Centered = styled(Flex).attrs({ + align: "center", + justify: "center", + column: true, + auto: true, +})` + user-select: none; + width: 90vw; + height: 100%; + max-width: 320px; + margin: 0 auto; +`; diff --git a/app/scenes/Login/components/ConnectHeader.tsx b/app/scenes/Login/components/ConnectHeader.tsx new file mode 100644 index 0000000000..4e7bb6d97c --- /dev/null +++ b/app/scenes/Login/components/ConnectHeader.tsx @@ -0,0 +1,40 @@ +import { MoreIcon } from "outline-icons"; +import * as React from "react"; +import Flex from "@shared/components/Flex"; +import Text from "@shared/components/Text"; +import type Team from "~/models/Team"; +import type OAuthClient from "~/models/oauth/OAuthClient"; +import { Avatar } from "~/components/Avatar"; +import { AvatarSize, AvatarVariant } from "~/components/Avatar/Avatar"; + +type Props = { + team: Team; + oauthClient: OAuthClient; +}; + +export function ConnectHeader({ team, oauthClient }: Props) { + return ( + + + + + + + + + + ); +} diff --git a/app/scenes/Login/components/Notices.tsx b/app/scenes/Login/components/Notices.tsx index 0a752b3f37..a434e7b1a6 100644 --- a/app/scenes/Login/components/Notices.tsx +++ b/app/scenes/Login/components/Notices.tsx @@ -123,7 +123,7 @@ function Message({ notice }: { notice: string }) { } } -export default function Notices() { +export function Notices() { const query = useQuery(); const notice = query.get("notice"); diff --git a/app/scenes/Login/components/TeamSwitcher.tsx b/app/scenes/Login/components/TeamSwitcher.tsx new file mode 100644 index 0000000000..a0007089ef --- /dev/null +++ b/app/scenes/Login/components/TeamSwitcher.tsx @@ -0,0 +1,109 @@ +import { ArrowIcon } from "outline-icons"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import styled from "styled-components"; +import Text from "@shared/components/Text"; +import { s } from "@shared/styles"; +import { AvatarSize } from "~/components/Avatar"; +import Avatar, { AvatarVariant } from "~/components/Avatar/Avatar"; +import ChangeLanguage from "~/components/ChangeLanguage"; +import Heading from "~/components/Heading"; +import OutlineIcon from "~/components/Icons/OutlineIcon"; +import env from "~/env"; +import type { Sessions } from "~/hooks/useLoggedInSessions"; +import { detectLanguage } from "~/utils/language"; +import Login from "../Login"; +import { Background } from "./Background"; +import { Centered } from "./Centered"; + +type Props = { sessions: Sessions }; + +export function TeamSwitcher({ sessions }: Props) { + const { t } = useTranslation(); + const [showLogin, setShowLogin] = React.useState(false); + const url = new URL(window.location.href); + const appName = env.APP_NAME; + + if (showLogin) { + return setShowLogin(false)} />; + } + + return ( + + + + + + {t("Choose a workspace")} + + {t( + "Choose an {{ appName }} workspace or login to continue connecting this app", + { appName } + )} + . + + {Object.keys(sessions)?.map((teamId) => { + const session = sessions[teamId]; + const location = session.url + url.pathname + url.search; + return ( + + + {session.name} + + + ); + })} + setShowLogin(true)}> + + {t("Login to workspace")} + + + + ); +} + +const StyledArrowIcon = styled(ArrowIcon)` + position: absolute; + transition: all 0.2s ease-in-out; + opacity: 0; + right: 12px; +`; + +const TeamLink = styled.a` + position: relative; + left: -8px; + right: -8px; + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + margin: 4px; + border-radius: 8px; + width: 100%; + color: ${s("text")}; + font-weight: ${s("fontWeightMedium")}; + + &:hover { + background: ${s("listItemHoverBackground")}; + + ${StyledArrowIcon} { + opacity: 1; + right: 8px; + } + } +`; + +const StyledHeading = styled(Heading).attrs({ + as: "h2", + centered: true, +})` + margin-top: 0; +`; diff --git a/app/scenes/Login/index.ts b/app/scenes/Login/index.ts new file mode 100644 index 0000000000..04230a1bd9 --- /dev/null +++ b/app/scenes/Login/index.ts @@ -0,0 +1,3 @@ +import Login from "./Login"; + +export default Login; diff --git a/app/scenes/Settings/APIAndApps.tsx b/app/scenes/Settings/APIAndApps.tsx new file mode 100644 index 0000000000..1ec53c1adb --- /dev/null +++ b/app/scenes/Settings/APIAndApps.tsx @@ -0,0 +1,107 @@ +import { observer } from "mobx-react"; +import { PadlockIcon } from "outline-icons"; +import * as React from "react"; +import { useTranslation, Trans } from "react-i18next"; +import ApiKey from "~/models/ApiKey"; +import OAuthAuthentication from "~/models/oauth/OAuthAuthentication"; +import { Action } from "~/components/Actions"; +import Button from "~/components/Button"; +import Heading from "~/components/Heading"; +import PaginatedList from "~/components/PaginatedList"; +import Scene from "~/components/Scene"; +import Text from "~/components/Text"; +import { createApiKey } from "~/actions/definitions/apiKeys"; +import env from "~/env"; +import useActionContext from "~/hooks/useActionContext"; +import useCurrentTeam from "~/hooks/useCurrentTeam"; +import useCurrentUser from "~/hooks/useCurrentUser"; +import usePolicy from "~/hooks/usePolicy"; +import useStores from "~/hooks/useStores"; +import ApiKeyListItem from "./components/ApiKeyListItem"; +import OAuthAuthenticationListItem from "./components/OAuthAuthenticationListItem"; + +function APIAndApps() { + const team = useCurrentTeam(); + const user = useCurrentUser(); + const { t } = useTranslation(); + const { apiKeys, oauthAuthentications } = useStores(); + const can = usePolicy(team); + const context = useActionContext(); + const appName = env.APP_NAME; + + return ( + } + actions={ + <> + {can.createApiKey && ( + + + + )} + > + } + > + {t("API & Apps")} + {t("API keys")} + {can.createApiKey ? ( + + + ), + }} + /> + + ) : ( + + {t("API keys have been disabled by an admin for your account")} + + )} + + fetch={apiKeys.fetchPage} + items={apiKeys.personalApiKeys} + options={{ userId: user.id }} + renderItem={(apiKey) => ( + + )} + /> + + {t("Application access")} + + {t( + "Manage which third-party and internal applications have been granted access to your {{ appName }} account.", + { appName } + )} + + > + } + renderItem={(oauthAuthentication: OAuthAuthentication) => ( + + )} + /> + + ); +} + +export default observer(APIAndApps); diff --git a/app/scenes/Settings/ApiKeys.tsx b/app/scenes/Settings/ApiKeys.tsx index b9989d7fff..09e73f9500 100644 --- a/app/scenes/Settings/ApiKeys.tsx +++ b/app/scenes/Settings/ApiKeys.tsx @@ -61,7 +61,6 @@ function ApiKeys() { fetch={apiKeys.fetchPage} items={apiKeys.orderedData} - heading={{t("All")}} renderItem={(apiKey) => ( )} diff --git a/app/scenes/Settings/Application.tsx b/app/scenes/Settings/Application.tsx new file mode 100644 index 0000000000..4aaf1f471c --- /dev/null +++ b/app/scenes/Settings/Application.tsx @@ -0,0 +1,325 @@ +import { observer } from "mobx-react"; +import { CopyIcon, InternetIcon, ReplaceIcon } from "outline-icons"; +import * as React from "react"; +import { Controller, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { useParams } from "react-router-dom"; +import { toast } from "sonner"; +import { OAuthClientValidation } from "@shared/validations"; +import OAuthClient from "~/models/oauth/OAuthClient"; +import Breadcrumb from "~/components/Breadcrumb"; +import Button from "~/components/Button"; +import ConfirmationDialog from "~/components/ConfirmationDialog"; +import ContentEditable from "~/components/ContentEditable"; +import Heading from "~/components/Heading"; +import Input from "~/components/Input"; +import LoadingIndicator from "~/components/LoadingIndicator"; +import NudeButton from "~/components/NudeButton"; +import { FormData } from "~/components/OAuthClient/OAuthClientForm"; +import Scene from "~/components/Scene"; +import Switch from "~/components/Switch"; +import Tooltip from "~/components/Tooltip"; +import useRequest from "~/hooks/useRequest"; +import useStores from "~/hooks/useStores"; +import OAuthClientMenu from "~/menus/OAuthClientMenu"; +import isCloudHosted from "~/utils/isCloudHosted"; +import { settingsPath } from "~/utils/routeHelpers"; +import { ActionRow } from "./components/ActionRow"; +import { CopyButton } from "./components/CopyButton"; +import ImageInput from "./components/ImageInput"; +import SettingRow from "./components/SettingRow"; + +type Props = { + oauthClient: OAuthClient; +}; + +const LoadingState = observer(function LoadingState() { + const { id } = useParams<{ id: string }>(); + const { oauthClients } = useStores(); + const oauthClient = oauthClients.get(id); + const { request } = useRequest(() => oauthClients.fetch(id)); + + React.useEffect(() => { + if (!oauthClient) { + void request(); + } + }, [oauthClient]); + + if (!oauthClient) { + return ; + } + + return ; +}); + +const Application = observer(function Application({ oauthClient }: Props) { + const { t } = useTranslation(); + const { dialogs } = useStores(); + + const { + register, + handleSubmit: formHandleSubmit, + formState, + getValues, + setError, + control, + } = useForm({ + mode: "all", + defaultValues: { + name: oauthClient.name ?? "", + developerName: oauthClient.developerName ?? "", + developerUrl: oauthClient.developerUrl ?? "", + description: oauthClient.description ?? "", + avatarUrl: oauthClient.avatarUrl ?? "", + redirectUris: oauthClient.redirectUris ?? [], + published: oauthClient.published ?? false, + }, + }); + + const handleSubmit = React.useCallback( + async (data: FormData) => { + try { + await oauthClient.save(data); + toast.success( + oauthClient.published + ? t("Application published") + : t("Application updated") + ); + } catch (error) { + toast.error(error.message); + } + }, + [oauthClient, t] + ); + + const handleRotateSecret = React.useCallback(async () => { + const onDelete = async () => { + try { + await oauthClient.rotateClientSecret(); + toast.success(t("Client secret rotated")); + } catch (err) { + toast.error(err.message); + } + }; + + dialogs.openModal({ + title: t("Rotate secret"), + content: ( + + {t( + "Rotating the client secret will invalidate the current secret. Make sure to update any applications using these credentials." + )} + + ), + }); + }, [t, dialogs, oauthClient]); + + return ( + , + }, + ]} + /> + } + actions={} + > + + + ( + + )} + /> + + + + ( + field.onChange(url)} + onError={(err) => setError("avatarUrl", { message: err })} + model={{ + id: oauthClient.id, + avatarUrl: field.value, + initial: getValues().name[0], + }} + borderRadius={0} + /> + )} + /> + + + + + + + + + + + {isCloudHosted && ( + + + + )} + + {t("Credentials")} + + + } + /> + + + + + + + + + + + } + /> + + + + ( + { + field.onChange(event.target.value.split("\n")); + }} + required + /> + )} + /> + + + + + + + + + + {formState.isSubmitting ? `${t("Saving")}…` : t("Save")} + + + + + ); +}); + +export default LoadingState; diff --git a/app/scenes/Settings/PersonalApiKeys.tsx b/app/scenes/Settings/Applications.tsx similarity index 53% rename from app/scenes/Settings/PersonalApiKeys.tsx rename to app/scenes/Settings/Applications.tsx index 32bb83d0c6..36a68a0b75 100644 --- a/app/scenes/Settings/PersonalApiKeys.tsx +++ b/app/scenes/Settings/Applications.tsx @@ -1,42 +1,40 @@ import { observer } from "mobx-react"; -import { CodeIcon } from "outline-icons"; +import { InternetIcon } from "outline-icons"; import * as React from "react"; import { useTranslation, Trans } from "react-i18next"; -import ApiKey from "~/models/ApiKey"; +import OAuthClient from "~/models/oauth/OAuthClient"; import { Action } from "~/components/Actions"; import Button from "~/components/Button"; import Heading from "~/components/Heading"; import PaginatedList from "~/components/PaginatedList"; import Scene from "~/components/Scene"; import Text from "~/components/Text"; -import { createApiKey } from "~/actions/definitions/apiKeys"; +import { createOAuthClient } from "~/actions/definitions/oauthClients"; import useActionContext from "~/hooks/useActionContext"; import useCurrentTeam from "~/hooks/useCurrentTeam"; -import useCurrentUser from "~/hooks/useCurrentUser"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; -import ApiKeyListItem from "./components/ApiKeyListItem"; +import OAuthClientListItem from "./components/OAuthClientListItem"; -function PersonalApiKeys() { +function Applications() { const team = useCurrentTeam(); - const user = useCurrentUser(); const { t } = useTranslation(); - const { apiKeys } = useStores(); + const { oauthClients } = useStores(); const can = usePolicy(team); const context = useActionContext(); return ( } + title={t("Applications")} + icon={} actions={ <> - {can.createApiKey && ( + {can.createOAuthClient && ( @@ -44,12 +42,10 @@ function PersonalApiKeys() { > } > - {t("API")} + {t("Applications")} - - fetch={apiKeys.fetchPage} - items={apiKeys.personalApiKeys} - options={{ userId: user.id }} - heading={{t("Personal keys")}} - renderItem={(apiKey) => ( - + + fetch={oauthClients.fetchPage} + items={oauthClients.orderedData} + renderItem={(oauthClient) => ( + )} /> ); } -export default observer(PersonalApiKeys); +export default observer(Applications); diff --git a/app/scenes/Settings/Details.tsx b/app/scenes/Settings/Details.tsx index 1d9bb69f40..ba3b4449a0 100644 --- a/app/scenes/Settings/Details.tsx +++ b/app/scenes/Settings/Details.tsx @@ -108,7 +108,16 @@ function Details() { toast.error(err.message); } }, - [team, name, subdomain, defaultCollectionId, publicBranding, customTheme, t] + [ + tocPosition, + team, + name, + subdomain, + defaultCollectionId, + publicBranding, + customTheme, + t, + ] ); const handleNameChange = React.useCallback( diff --git a/app/scenes/Settings/components/CopyButton.tsx b/app/scenes/Settings/components/CopyButton.tsx new file mode 100644 index 0000000000..7334d19471 --- /dev/null +++ b/app/scenes/Settings/components/CopyButton.tsx @@ -0,0 +1,50 @@ +import { LinkIcon } from "outline-icons"; +import * as React from "react"; +import { toast } from "sonner"; +import CopyToClipboard from "~/components/CopyToClipboard"; +import NudeButton from "~/components/NudeButton"; +import Tooltip from "~/components/Tooltip"; + +type Props = { + /** The value to be copied */ + value: string; + /** The message to show when the value is copied */ + success: string; + /** The tooltip message */ + tooltip: string; + /** An optional icon */ + icon?: React.ReactNode; +}; + +/** + * A button that copies a value to the clipboard when clicked and shows a + * single icon. + */ +export function CopyButton({ + value, + success, + tooltip, + icon = , +}: Props) { + const timeout = React.useRef>(); + + const handleCopied = React.useCallback(() => { + timeout.current = setTimeout(() => { + toast.message(success); + }, 100); + + return () => { + if (timeout.current) { + clearTimeout(timeout.current); + } + }; + }, [success]); + + return ( + + + {icon} + + + ); +} diff --git a/app/scenes/Settings/components/ImageInput.tsx b/app/scenes/Settings/components/ImageInput.tsx index 267c4b1e6f..add17927d1 100644 --- a/app/scenes/Settings/components/ImageInput.tsx +++ b/app/scenes/Settings/components/ImageInput.tsx @@ -1,8 +1,10 @@ +import { EditIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; import styled from "styled-components"; import { s } from "@shared/styles"; import { Avatar, AvatarSize, IAvatar } from "~/components/Avatar"; +import { AvatarVariant } from "~/components/Avatar/Avatar"; import Button from "~/components/Button"; import Flex from "~/components/Flex"; import ImageUpload, { Props as ImageUploadProps } from "./ImageUpload"; @@ -17,10 +19,18 @@ export default function ImageInput({ model, onSuccess, ...rest }: Props) { return ( - - + + - {t("Upload")} + @@ -38,10 +48,6 @@ const avatarStyles = ` height: ${AvatarSize.Upload}px; `; -const StyledAvatar = styled(Avatar)` - border-radius: 8px; -`; - const ImageBox = styled(Flex)` ${avatarStyles}; position: relative; diff --git a/app/scenes/Settings/components/OAuthAuthenticationListItem.tsx b/app/scenes/Settings/components/OAuthAuthenticationListItem.tsx new file mode 100644 index 0000000000..170ec830c4 --- /dev/null +++ b/app/scenes/Settings/components/OAuthAuthenticationListItem.tsx @@ -0,0 +1,61 @@ +import { observer } from "mobx-react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import OAuthAuthentication from "~/models/oauth/OAuthAuthentication"; +import { OAuthScopeHelper } from "~/scenes/Login/OAuthScopeHelper"; +import { Avatar, AvatarSize } from "~/components/Avatar"; +import { AvatarVariant } from "~/components/Avatar/Avatar"; +import ListItem from "~/components/List/Item"; +import Text from "~/components/Text"; +import Time from "~/components/Time"; +import OAuthAuthenticationMenu from "~/menus/OAuthAuthenticationMenu"; + +type Props = { + /** The OAuthAuthentication to display */ + oauthAuthentication: OAuthAuthentication; +}; + +const OAuthAuthenticationListItem = ({ oauthAuthentication }: Props) => { + const { t } = useTranslation(); + + const subtitle = ( + <> + + {oauthAuthentication.lastActiveAt ? ( + <> + {t("Last active")}{" "} + + > + ) : ( + t("Never used") + )}{" "} + ·{" "} + + + {OAuthScopeHelper.normalizeScopes(oauthAuthentication.scope, t).join( + ", " + )} + + > + ); + + return ( + + } + title={oauthAuthentication.oauthClient.name} + subtitle={subtitle} + actions={ + + } + /> + ); +}; + +export default observer(OAuthAuthenticationListItem); diff --git a/app/scenes/Settings/components/OAuthClientDeleteDialog.tsx b/app/scenes/Settings/components/OAuthClientDeleteDialog.tsx new file mode 100644 index 0000000000..b84ae082ca --- /dev/null +++ b/app/scenes/Settings/components/OAuthClientDeleteDialog.tsx @@ -0,0 +1,37 @@ +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import OAuthClient from "~/models/oauth/OAuthClient"; +import ConfirmationDialog from "~/components/ConfirmationDialog"; + +type Props = { + oauthClient: OAuthClient; + onSubmit: () => void; +}; + +export default function OAuthClientDeleteDialog({ + oauthClient, + onSubmit, +}: Props) { + const { t } = useTranslation(); + + const handleSubmit = async () => { + await oauthClient.delete(); + onSubmit(); + }; + + return ( + + {t( + "Are you sure you want to delete the {{ appName }} application? This cannot be undone.", + { + appName: oauthClient.name, + } + )} + + ); +} diff --git a/app/scenes/Settings/components/OAuthClientListItem.tsx b/app/scenes/Settings/components/OAuthClientListItem.tsx new file mode 100644 index 0000000000..910bbbca34 --- /dev/null +++ b/app/scenes/Settings/components/OAuthClientListItem.tsx @@ -0,0 +1,55 @@ +import { observer } from "mobx-react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import OAuthClient from "~/models/oauth/OAuthClient"; +import { Avatar, AvatarSize } from "~/components/Avatar"; +import { AvatarVariant } from "~/components/Avatar/Avatar"; +import ListItem from "~/components/List/Item"; +import Text from "~/components/Text"; +import Time from "~/components/Time"; +import useCurrentUser from "~/hooks/useCurrentUser"; +import OAuthClientMenu from "~/menus/OAuthClientMenu"; +import { settingsPath } from "~/utils/routeHelpers"; + +type Props = { + oauthClient: OAuthClient; +}; + +const OAuthClientListItem = ({ oauthClient }: Props) => { + const { t } = useTranslation(); + const user = useCurrentUser(); + + const subtitle = ( + <> + + {t(`Created`)} {" "} + {oauthClient.createdById === user.id + ? "" + : t(`by {{ name }}`, { name: user.name })} + + > + ); + + return ( + + } + title={ + + {oauthClient.name} + + } + subtitle={subtitle} + actions={} + /> + ); +}; + +export default observer(OAuthClientListItem); diff --git a/app/scenes/Settings/components/SettingRow.tsx b/app/scenes/Settings/components/SettingRow.tsx index de67a5db49..0b661bb4ab 100644 --- a/app/scenes/Settings/components/SettingRow.tsx +++ b/app/scenes/Settings/components/SettingRow.tsx @@ -39,7 +39,7 @@ const Column = styled.div` flex: 1; &:first-child { - min-width: 70%; + min-width: 65%; } &:last-child { diff --git a/app/stores/OAuthAuthenticationsStore.ts b/app/stores/OAuthAuthenticationsStore.ts new file mode 100644 index 0000000000..ba30209326 --- /dev/null +++ b/app/stores/OAuthAuthenticationsStore.ts @@ -0,0 +1,11 @@ +import OAuthAuthentication from "~/models/oauth/OAuthAuthentication"; +import RootStore from "./RootStore"; +import Store from "./base/Store"; + +export default class OAuthAuthenticationsStore extends Store { + apiEndpoint = "oauthAuthentications"; + + constructor(rootStore: RootStore) { + super(rootStore, OAuthAuthentication); + } +} diff --git a/app/stores/OAuthClientsStore.ts b/app/stores/OAuthClientsStore.ts new file mode 100644 index 0000000000..a1fd075c95 --- /dev/null +++ b/app/stores/OAuthClientsStore.ts @@ -0,0 +1,11 @@ +import OAuthClient from "~/models/oauth/OAuthClient"; +import RootStore from "./RootStore"; +import Store from "./base/Store"; + +export default class OAuthClientsStore extends Store { + apiEndpoint = "oauthClients"; + + constructor(rootStore: RootStore) { + super(rootStore, OAuthClient); + } +} diff --git a/app/stores/RootStore.ts b/app/stores/RootStore.ts index 32b80b0162..224254af8e 100644 --- a/app/stores/RootStore.ts +++ b/app/stores/RootStore.ts @@ -18,6 +18,8 @@ import ImportsStore from "./ImportsStore"; import IntegrationsStore from "./IntegrationsStore"; import MembershipsStore from "./MembershipsStore"; import NotificationsStore from "./NotificationsStore"; +import OAuthAuthenticationsStore from "./OAuthAuthenticationsStore"; +import OAuthClientsStore from "./OAuthClientsStore"; import PinsStore from "./PinsStore"; import PoliciesStore from "./PoliciesStore"; import RevisionsStore from "./RevisionsStore"; @@ -49,6 +51,8 @@ export default class RootStore { integrations: IntegrationsStore; memberships: MembershipsStore; notifications: NotificationsStore; + oauthAuthentications: OAuthAuthenticationsStore; + oauthClients: OAuthClientsStore; presence: DocumentPresenceStore; pins: PinsStore; policies: PoliciesStore; @@ -80,6 +84,8 @@ export default class RootStore { this.registerStore(IntegrationsStore); this.registerStore(MembershipsStore); this.registerStore(NotificationsStore); + this.registerStore(OAuthAuthenticationsStore, "oauthAuthentications"); + this.registerStore(OAuthClientsStore, "oauthClients"); this.registerStore(PinsStore); this.registerStore(PoliciesStore); this.registerStore(RevisionsStore); @@ -110,8 +116,9 @@ export default class RootStore { */ public getStoreForModelName(modelName: string) { const storeName = this.getStoreNameForModelName(modelName); + invariant(storeName, `No store found for model name "${modelName}"`); + const store = this[storeName]; - invariant(store, `No store found for model name "${modelName}"`); return store as RootStore[K]; } @@ -139,10 +146,24 @@ export default class RootStore { // @ts-expect-error TS thinks we are instantiating an abstract class. const store = new StoreClass(this); const storeName = name ?? this.getStoreNameForModelName(store.modelName); + invariant(storeName, `No store found for model name "${store.modelName}"`); + this[storeName] = store; } private getStoreNameForModelName(modelName: string) { - return pluralize(lowerFirst(modelName)) as keyof RootStore; + for (const key of Object.keys(this)) { + const store = this[key as keyof RootStore]; + if (store && "modelName" in store && store.modelName === modelName) { + return key as keyof RootStore; + } + } + + const storeName = pluralize(lowerFirst(modelName)) as keyof RootStore; + if (storeName) { + return storeName; + } + + return undefined; } } diff --git a/app/utils/routeHelpers.ts b/app/utils/routeHelpers.ts index 7e915194d2..c4f8911996 100644 --- a/app/utils/routeHelpers.ts +++ b/app/utils/routeHelpers.ts @@ -27,8 +27,8 @@ export function trashPath(): string { return "/trash"; } -export function settingsPath(section?: string): string { - return "/settings" + (section ? `/${section}` : ""); +export function settingsPath(...args: string[]): string { + return "/settings" + (args.length > 0 ? `/${args.join("/")}` : ""); } export function commentPath(document: Document, comment: Comment): string { diff --git a/package.json b/package.json index 55385e8b4f..65e3e11bb7 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "@joplin/turndown-plugin-gfm": "^1.0.49", "@juggle/resize-observer": "^3.4.0", "@linear/sdk": "^39.0.0", + "@node-oauth/oauth2-server": "^5.2.0", "@notionhq/client": "^2.3.0", "@octokit/auth-app": "^6.1.3", "@outlinewiki/koa-passport": "^4.2.1", diff --git a/plugins/webhooks/server/api/webhookSubscriptions.ts b/plugins/webhooks/server/api/webhookSubscriptions.ts index 00f3da7aaf..7be68a0fa5 100644 --- a/plugins/webhooks/server/api/webhookSubscriptions.ts +++ b/plugins/webhooks/server/api/webhookSubscriptions.ts @@ -8,7 +8,7 @@ import validate from "@server/middlewares/validate"; import { WebhookSubscription } from "@server/models"; import { authorize } from "@server/policies"; import pagination from "@server/routes/api/middlewares/pagination"; -import { APIContext } from "@server/types"; +import { APIContext, AuthenticationType } from "@server/types"; import presentWebhookSubscription from "../presenters/webhookSubscription"; import * as T from "./schema"; @@ -41,7 +41,10 @@ router.post( router.post( "webhookSubscriptions.create", - auth({ role: UserRole.Admin }), + auth({ + role: UserRole.Admin, + type: [AuthenticationType.API, AuthenticationType.APP], + }), validate(T.WebhookSubscriptionsCreateSchema), transaction(), async (ctx: APIContext) => { @@ -68,7 +71,10 @@ router.post( router.post( "webhookSubscriptions.delete", - auth({ role: UserRole.Admin }), + auth({ + role: UserRole.Admin, + type: [AuthenticationType.API, AuthenticationType.APP], + }), validate(T.WebhookSubscriptionsDeleteSchema), transaction(), async (ctx: APIContext) => { @@ -94,7 +100,10 @@ router.post( router.post( "webhookSubscriptions.update", - auth({ role: UserRole.Admin }), + auth({ + role: UserRole.Admin, + type: [AuthenticationType.API, AuthenticationType.APP], + }), validate(T.WebhookSubscriptionsUpdateSchema), transaction(), async (ctx: APIContext) => { diff --git a/plugins/webhooks/server/tasks/DeliverWebhookTask.ts b/plugins/webhooks/server/tasks/DeliverWebhookTask.ts index a23eca19a1..418ddab1e2 100644 --- a/plugins/webhooks/server/tasks/DeliverWebhookTask.ts +++ b/plugins/webhooks/server/tasks/DeliverWebhookTask.ts @@ -237,6 +237,11 @@ export default class DeliverWebhookTask extends BaseTask { case "imports.delete": // Ignored return; + case "oauthClients.create": + case "oauthClients.update": + case "oauthClients.delete": + // Ignored + return; default: assertUnreachable(event); } diff --git a/server/env.ts b/server/env.ts index 34f926029f..9a4a0da1d3 100644 --- a/server/env.ts +++ b/server/env.ts @@ -15,6 +15,7 @@ import { } from "class-validator"; import uniq from "lodash/uniq"; import { languages } from "@shared/i18n"; +import { Day, Hour } from "@shared/utils/time"; import { CannotUseWith, CannotUseWithout } from "@server/utils/validators"; import Deprecated from "./models/decorators/Deprecated"; import { getArg } from "./utils/args"; @@ -609,6 +610,31 @@ export class Environment { public MAXIMUM_EXPORT_SIZE = this.toOptionalNumber(environment.MAXIMUM_EXPORT_SIZE) ?? os.totalmem(); + /** + * The number of seconds access tokens issue by the OAuth provider are valid. + */ + @IsNumber() + public OAUTH_PROVIDER_ACCESS_TOKEN_LIFETIME = + this.toOptionalNumber(environment.OAUTH_PROVIDER_ACCESS_TOKEN_LIFETIME) ?? + Hour.seconds; + + /** + * The number of seconds refresh tokens issue by the OAuth provider are valid. + */ + @IsNumber() + public OAUTH_PROVIDER_REFRESH_TOKEN_LIFETIME = + this.toOptionalNumber(environment.OAUTH_PROVIDER_REFRESH_TOKEN_LIFETIME) ?? + 30 * Day.seconds; + + /** + * The number of seconds authorization codes issue by the OAuth provider are valid. + */ + @IsNumber() + public OAUTH_PROVIDER_AUTHORIZATION_CODE_LIFETIME = + this.toOptionalNumber( + environment.OAUTH_PROVIDER_AUTHORIZATION_CODE_LIFETIME + ) ?? 300; + /** * Enable unsafe-inline in script-src CSP directive */ diff --git a/server/middlewares/authentication.test.ts b/server/middlewares/authentication.test.ts index 24c187db55..e5cd020c33 100644 --- a/server/middlewares/authentication.test.ts +++ b/server/middlewares/authentication.test.ts @@ -1,16 +1,19 @@ import { DefaultState } from "koa"; import randomstring from "randomstring"; +import { Scope } from "@shared/types"; import { buildUser, buildTeam, buildAdmin, buildApiKey, + buildOAuthAuthentication, } from "@server/test/factories"; +import { AuthenticationType } from "@server/types"; import auth from "./authentication"; describe("Authentication middleware", () => { - describe("with JWT", () => { - it("should authenticate with correct token", async () => { + describe("with session JWT", () => { + it("should authenticate with correct session token", async () => { const state = {} as DefaultState; const user = await buildUser(); const authMiddleware = auth(); @@ -28,7 +31,7 @@ describe("Authentication middleware", () => { expect(state.auth.user.id).toEqual(user.id); }); - it("should return error with invalid token", async () => { + it("should return error with invalid session token", async () => { const state = {} as DefaultState; const user = await buildUser(); const authMiddleware = auth(); @@ -49,7 +52,32 @@ describe("Authentication middleware", () => { expect(e.message).toBe("Invalid token"); } }); + + it("should return error if AuthenticationType mismatches", async () => { + const state = {} as DefaultState; + const user = await buildUser(); + const authMiddleware = auth({ + type: AuthenticationType.API, + }); + + try { + await authMiddleware( + { + // @ts-expect-error mock request + request: { + get: jest.fn(() => `Bearer ${user.getJwtToken()}`), + }, + state, + cache: {}, + }, + jest.fn() + ); + } catch (e) { + expect(e.message).toBe("Invalid authentication type"); + } + }); }); + describe("with API key", () => { it("should authenticate user with valid API key", async () => { const state = {} as DefaultState; @@ -91,6 +119,90 @@ describe("Authentication middleware", () => { }); }); + describe("with OAuth access token", () => { + it("should authenticate user with valid OAuth access token", async () => { + const state = {} as DefaultState; + const user = await buildUser(); + const authMiddleware = auth(); + const authentication = await buildOAuthAuthentication({ + user, + scope: [Scope.Read], + }); + + await authMiddleware( + { + // @ts-expect-error mock request + request: { + url: "/api/users.info", + get: jest.fn(() => `Bearer ${authentication.accessToken}`), + }, + state, + cache: {}, + }, + jest.fn() + ); + expect(state.auth.user.id).toEqual(user.id); + }); + + it("should return error with invalid scope", async () => { + const state = {} as DefaultState; + const user = await buildUser(); + const authMiddleware = auth(); + const authentication = await buildOAuthAuthentication({ + user, + scope: [Scope.Read], + }); + + try { + await authMiddleware( + { + // @ts-expect-error mock request + request: { + url: "/api/documents.create", + get: jest.fn(() => `Bearer ${authentication.accessToken}`), + }, + state, + cache: {}, + }, + jest.fn() + ); + } catch (e) { + expect(e.message).toContain("does not have access to this resource"); + } + }); + + it("should return error with OAuth access token in body", async () => { + const state = {} as DefaultState; + const user = await buildUser(); + const authMiddleware = auth(); + const authentication = await buildOAuthAuthentication({ + user, + scope: [Scope.Read], + }); + try { + await authMiddleware( + { + request: { + url: "/api/users.info", + // @ts-expect-error mock request + get: jest.fn(() => null), + body: { + token: authentication.accessToken, + }, + }, + state, + cache: {}, + }, + jest.fn() + ); + } catch (e) { + expect(e.message).toContain( + "must be passed in the Authorization header" + ); + } + }); + }); + it("should return error message if no auth token is available", async () => { const state = {} as DefaultState; const authMiddleware = auth(); diff --git a/server/middlewares/authentication.ts b/server/middlewares/authentication.ts index bc3fa6c3bc..b8a8588b33 100644 --- a/server/middlewares/authentication.ts +++ b/server/middlewares/authentication.ts @@ -7,7 +7,7 @@ import tracer, { addTags, getRootSpanFromRequestContext, } from "@server/logging/tracer"; -import { User, Team, ApiKey } from "@server/models"; +import { User, Team, ApiKey, OAuthAuthentication } from "@server/models"; import { AppContext, AuthenticationType } from "@server/types"; import { getUserForJWT } from "@server/utils/jwt"; import { @@ -20,7 +20,7 @@ type AuthenticationOptions = { /** Role requuired to access the route. */ role?: UserRole; /** Type of authentication required to access the route. */ - type?: AuthenticationType; + type?: AuthenticationType | AuthenticationType[]; /** Authentication is parsed, but optional. */ optional?: boolean; }; @@ -65,7 +65,50 @@ export default function auth(options: AuthenticationOptions = {}) { let user: User | null; let type: AuthenticationType; - if (ApiKey.match(String(token))) { + if (OAuthAuthentication.match(String(token))) { + if (!authorizationHeader) { + throw AuthenticationError( + "OAuth access token must be passed in the Authorization header" + ); + } + + type = AuthenticationType.OAUTH; + + let authentication; + try { + authentication = await OAuthAuthentication.findByAccessToken(token, { + rejectOnEmpty: true, + }); + } catch (err) { + throw AuthenticationError("Invalid access token"); + } + if (!authentication) { + throw AuthenticationError("Invalid access token"); + } + if (authentication.accessTokenExpiresAt < new Date()) { + throw AuthenticationError("Access token is expired"); + } + if (!authentication.canAccess(ctx.request.url)) { + throw AuthenticationError( + "Access token does not have access to this resource" + ); + } + + user = await User.findByPk(authentication.userId, { + include: [ + { + model: Team, + as: "team", + required: true, + }, + ], + }); + if (!user) { + throw AuthenticationError("Invalid access token"); + } + + await authentication.updateActiveAt(); + } else if (ApiKey.match(String(token))) { type = AuthenticationType.API; let apiKey; @@ -125,7 +168,12 @@ export default function auth(options: AuthenticationOptions = {}) { throw AuthorizationError(`${capitalize(options.role)} role required`); } - if (options.type && type !== options.type) { + if ( + options.type && + (Array.isArray(options.type) + ? !options.type.includes(type) + : type !== options.type) + ) { throw AuthorizationError(`Invalid authentication type`); } diff --git a/server/routes/api/middlewares/apiTracer.ts b/server/middlewares/requestTracer.ts similarity index 81% rename from server/routes/api/middlewares/apiTracer.ts rename to server/middlewares/requestTracer.ts index 8d41dca22c..437437064a 100644 --- a/server/routes/api/middlewares/apiTracer.ts +++ b/server/middlewares/requestTracer.ts @@ -1,8 +1,8 @@ import { Context, Next } from "koa"; import { addTags, getRootSpanFromRequestContext } from "@server/logging/tracer"; -export default function apiTracer() { - return async function apiTracerMiddleware(ctx: Context, next: Next) { +export default function requestTracer() { + return async function requestTracerMiddleware(ctx: Context, next: Next) { const params = ctx.request.body ?? ctx.request.query; for (const key in params) { diff --git a/server/migrations/20250331231413-add-oauth-server-models.js b/server/migrations/20250331231413-add-oauth-server-models.js new file mode 100644 index 0000000000..f8af70ed96 --- /dev/null +++ b/server/migrations/20250331231413-add-oauth-server-models.js @@ -0,0 +1,212 @@ +"use strict"; + +/** @type {import("sequelize-cli").Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.createTable("oauth_clients", { + id: { + type: Sequelize.UUID, + primaryKey: true, + allowNull: false + }, + name: { + type: Sequelize.STRING, + allowNull: false + }, + description: { + type: Sequelize.STRING, + allowNull: true + }, + developerName: { + type: Sequelize.STRING, + allowNull: true + }, + developerUrl: { + type: Sequelize.STRING, + allowNull: true + }, + avatarUrl: { + type: Sequelize.STRING, + allowNull: true + }, + clientId: { + type: Sequelize.STRING, + allowNull: false, + unique: true, + }, + clientSecret: { + type: Sequelize.BLOB, + allowNull: false + }, + published: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false + }, + teamId: { + type: Sequelize.UUID, + references: { + model: "teams", + }, + allowNull: false, + onDelete: "cascade" + }, + createdById: { + type: Sequelize.UUID, + references: { + model: "users", + }, + allowNull: false + }, + redirectUris: { + type: Sequelize.ARRAY(Sequelize.STRING), + allowNull: false, + defaultValue: [] + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true + } + }, { + transaction + }); + + await queryInterface.createTable("oauth_authorization_codes", { + id: { + type: Sequelize.UUID, + primaryKey: true, + allowNull: false + }, + authorizationCodeHash: { + type: Sequelize.STRING, + allowNull: false + }, + codeChallenge: { + type: Sequelize.STRING, + allowNull: true + }, + codeChallengeMethod: { + type: Sequelize.STRING, + allowNull: true + }, + scope: { + type: Sequelize.ARRAY(Sequelize.STRING), + allowNull: false + }, + oauthClientId: { + type: Sequelize.UUID, + references: { + model: "oauth_clients", + }, + onDelete: "cascade", + allowNull: false + }, + userId: { + type: Sequelize.UUID, + references: { + model: "users", + }, + onDelete: "cascade", + allowNull: false + }, + redirectUri: { + type: Sequelize.STRING, + allowNull: false + }, + expiresAt: { + type: Sequelize.DATE, + allowNull: false + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false + } + }, { + transaction + }); + + await queryInterface.createTable("oauth_authentications", { + id: { + type: Sequelize.UUID, + primaryKey: true, + allowNull: false + }, + accessTokenHash: { + type: Sequelize.STRING, + allowNull: false, + unique: true + }, + accessTokenExpiresAt: { + type: Sequelize.DATE, + allowNull: false + }, + refreshTokenHash: { + type: Sequelize.STRING, + allowNull: false, + unique: true + }, + refreshTokenExpiresAt: { + type: Sequelize.DATE, + allowNull: false + }, + lastActiveAt: { + type: Sequelize.DATE, + allowNull: true + }, + scope: { + type: Sequelize.ARRAY(Sequelize.STRING), + allowNull: false + }, + oauthClientId: { + type: Sequelize.UUID, + references: { + model: "oauth_clients", + }, + onDelete: "cascade", + allowNull: false + }, + userId: { + type: Sequelize.UUID, + references: { + model: "users", + }, + onDelete: "cascade", + allowNull: false + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true + } + }, { + transaction + }); + + await queryInterface.addIndex("oauth_clients", ["teamId"], { transaction }); + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.dropTable("oauth_authentications", { transaction }); + await queryInterface.dropTable("oauth_authorization_codes", { transaction }); + await queryInterface.dropTable("oauth_clients", { transaction }); + }); + } +}; diff --git a/server/models/ApiKey.ts b/server/models/ApiKey.ts index f52079087f..2f0980a708 100644 --- a/server/models/ApiKey.ts +++ b/server/models/ApiKey.ts @@ -1,4 +1,3 @@ -import crypto from "crypto"; import { Matches } from "class-validator"; import { subMinutes } from "date-fns"; import randomstring from "randomstring"; @@ -16,10 +15,12 @@ import { BeforeSave, } from "sequelize-typescript"; import { ApiKeyValidation } from "@shared/validations"; +import { hash } from "@server/utils/crypto"; import User from "./User"; import ParanoidModel from "./base/ParanoidModel"; import { SkipChangeset } from "./decorators/Changeset"; import Fix from "./decorators/Fix"; +import AuthenticationHelper from "./helpers/AuthenticationHelper"; import Length from "./validators/Length"; @Table({ tableName: "apiKeys", modelName: "apiKey" }) @@ -41,7 +42,7 @@ class ApiKey extends ParanoidModel< @Column name: string; - /** A space-separated list of scopes that this API key has access to */ + /** A list of scopes that this API key has access to */ @Matches(/[\/\.\w\s]*/, { each: true, }) @@ -96,7 +97,7 @@ class ApiKey extends ParanoidModel< if (!model.hash) { const secret = `${ApiKey.prefix}${randomstring.generate(38)}`; model.value = model.secret || secret; - model.hash = this.hash(model.value); + model.hash = hash(model.value); } } @@ -109,18 +110,8 @@ class ApiKey extends ParanoidModel< } /** - * Generates a hashed API key for the given input key. - * - * @param key The input string to hash - * @returns The hashed API key - */ - public static hash(key: string) { - return crypto.createHash("sha256").update(key).digest("hex"); - } - - /** - * Validates that the input touch could be an API key, this does not check - * that the key exists in the database. + * Validates that the input text _could_ be an API key, this does not check + * that the key actually exists in the database. * * @param text The text to validate * @returns True if likely an API key @@ -140,7 +131,7 @@ class ApiKey extends ParanoidModel< public static findByToken(input: string) { return this.findOne({ where: { - [Op.or]: [{ secret: input }, { hash: this.hash(input) }], + [Op.or]: [{ secret: input }, { hash: hash(input) }], }, }); } @@ -174,22 +165,7 @@ class ApiKey extends ParanoidModel< return true; } - // strip any query string from the path - path = path.split("?")[0]; - - const resource = path.split("/").pop() ?? ""; - const [namespace, method] = resource.split("."); - - return this.scope.some((scope) => { - const [scopeNamespace, scopeMethod] = scope - .replace("/api/", "") - .split("."); - return ( - scope.startsWith("/api/") && - (namespace === scopeNamespace || scopeNamespace === "*") && - (method === scopeMethod || scopeMethod === "*") - ); - }); + return AuthenticationHelper.canAccess(path, this.scope); }; } diff --git a/server/models/helpers/AuthenticationHelper.test.ts b/server/models/helpers/AuthenticationHelper.test.ts new file mode 100644 index 0000000000..b700eef3b7 --- /dev/null +++ b/server/models/helpers/AuthenticationHelper.test.ts @@ -0,0 +1,126 @@ +import AuthenticationHelper from "./AuthenticationHelper"; + +describe("AuthenticationHelper", () => { + const canAccess = AuthenticationHelper.canAccess; + + describe("canAccess", () => { + describe("api scopes", () => { + it("should account for query string", async () => { + const scopes = ["/api/documents.info"]; + + expect(canAccess("/api/documents.info?foo=bar", scopes)).toBe(true); + }); + + it("should return false if no matching scope", async () => { + const scopes = ["/api/documents.info"]; + + expect(canAccess("/api/documents.info", scopes)).toBe(true); + expect(canAccess("/api/collections.create", scopes)).toBe(false); + expect(canAccess("/api/apiKeys.list", scopes)).toBe(false); + }); + + it("should allow wildcard methods", async () => { + const scopes = ["/api/documents.*"]; + + expect(canAccess("/api/documents.info", scopes)).toBe(true); + expect(canAccess("/api/documents.create", scopes)).toBe(true); + expect(canAccess("/api/collections.create", scopes)).toBe(false); + }); + + it("should allow wildcard namespaces", async () => { + const scopes = ["/api/*.info"]; + + expect(canAccess("/api/documents.info", scopes)).toBe(true); + expect(canAccess("/api/documents.create", scopes)).toBe(false); + expect(canAccess("/api/collections.create", scopes)).toBe(false); + }); + + it("should allow wildcard namespaces", async () => { + const scopes = ["/api/*.info"]; + + expect(canAccess("/api/documents.info", scopes)).toBe(true); + expect(canAccess("/api/documents.create", scopes)).toBe(false); + }); + }); + + describe("namespaced access scopes", () => { + it("read", async () => { + const scopes = ["documents:read"]; + + expect(canAccess("/api/documents.info?foo=bar", scopes)).toBe(true); + expect(canAccess("/api/documents.info", scopes)).toBe(true); + expect(canAccess("/api/documents.list", scopes)).toBe(true); + expect(canAccess("/api/documents.create", scopes)).toBe(false); + expect(canAccess("/api/documents.update", scopes)).toBe(false); + expect(canAccess("/api/users.info", scopes)).toBe(false); + expect(canAccess("/api/users.create", scopes)).toBe(false); + }); + + it("write", async () => { + const scopes = ["documents:write"]; + + expect(canAccess("/api/documents.info?foo=bar", scopes)).toBe(true); + expect(canAccess("/api/documents.info", scopes)).toBe(true); + expect(canAccess("/api/documents.list", scopes)).toBe(true); + expect(canAccess("/api/documents.create", scopes)).toBe(true); + expect(canAccess("/api/documents.update", scopes)).toBe(true); + expect(canAccess("/api/users.info", scopes)).toBe(false); + expect(canAccess("/api/users.create", scopes)).toBe(false); + }); + + it("create", async () => { + const scopes = ["documents:create"]; + + expect(canAccess("/api/documents.create", scopes)).toBe(true); + expect(canAccess("/api/documents.info?foo=bar", scopes)).toBe(false); + expect(canAccess("/api/documents.info", scopes)).toBe(false); + expect(canAccess("/api/documents.list", scopes)).toBe(false); + expect(canAccess("/api/documents.update", scopes)).toBe(false); + expect(canAccess("/api/users.info", scopes)).toBe(false); + expect(canAccess("/api/users.create", scopes)).toBe(false); + }); + }); + + describe("global access scopes", () => { + it("read", async () => { + const scopes = ["read"]; + + expect(canAccess("/api/documents.info?foo=bar", scopes)).toBe(true); + expect(canAccess("/api/documents.info", scopes)).toBe(true); + expect(canAccess("/api/documents.list", scopes)).toBe(true); + expect(canAccess("/api/users.info", scopes)).toBe(true); + expect(canAccess("/api/groups.info", scopes)).toBe(true); + expect(canAccess("/api/collections.list", scopes)).toBe(true); + expect(canAccess("/api/documents.create", scopes)).toBe(false); + expect(canAccess("/api/documents.update", scopes)).toBe(false); + expect(canAccess("/api/users.create", scopes)).toBe(false); + }); + + it("write", async () => { + const scopes = ["write"]; + + expect(canAccess("/api/documents.info?foo=bar", scopes)).toBe(true); + expect(canAccess("/api/documents.info", scopes)).toBe(true); + expect(canAccess("/api/documents.list", scopes)).toBe(true); + expect(canAccess("/api/users.info", scopes)).toBe(true); + expect(canAccess("/api/groups.info", scopes)).toBe(true); + expect(canAccess("/api/documents.create", scopes)).toBe(true); + expect(canAccess("/api/documents.update", scopes)).toBe(true); + expect(canAccess("/api/users.info", scopes)).toBe(true); + expect(canAccess("/api/users.create", scopes)).toBe(true); + }); + + it("create", async () => { + const scopes = ["create"]; + + expect(canAccess("/api/documents.create", scopes)).toBe(true); + expect(canAccess("/api/users.create", scopes)).toBe(true); + expect(canAccess("/api/documents.info?foo=bar", scopes)).toBe(false); + expect(canAccess("/api/documents.info", scopes)).toBe(false); + expect(canAccess("/api/documents.list", scopes)).toBe(false); + expect(canAccess("/api/documents.update", scopes)).toBe(false); + expect(canAccess("/api/users.info", scopes)).toBe(false); + }); + }); + }); +}); diff --git a/server/models/helpers/AuthenticationHelper.ts b/server/models/helpers/AuthenticationHelper.ts index 6e85bbd80a..3a4234ea1f 100644 --- a/server/models/helpers/AuthenticationHelper.ts +++ b/server/models/helpers/AuthenticationHelper.ts @@ -1,10 +1,27 @@ /* eslint-disable @typescript-eslint/no-var-requires */ import find from "lodash/find"; +import { Scope } from "@shared/types"; import env from "@server/env"; import Team from "@server/models/Team"; import { Hook, PluginManager } from "@server/utils/PluginManager"; export default class AuthenticationHelper { + /** + * The mapping of method names to their scopes, anything not listed here + * defaults to `Scope.Write`. + * + * - `documents.create` -> `Scope.Create` + * - `documents.list` -> `Scope.Read` + * - `documents.info` -> `Scope.Read` + */ + private static methodToScope = { + create: Scope.Create, + list: Scope.Read, + info: Scope.Read, + search: Scope.Read, + documents: Scope.Read, + }; + /** * Returns the enabled authentication provider configurations for the current * installation. @@ -52,4 +69,45 @@ export default class AuthenticationHelper { ); }); } + + /** + * Returns whether the given path can be accessed with any of the scopes. We + * support scopes in the formats of: + * + * - `/api/namespace.method` + * - `namespace:scope` + * - `scope` + * + * @param path The path to check + * @param scopes The scopes to check + * @returns True if the path can be accessed + */ + public static canAccess = (path: string, scopes: string[]) => { + // strip any query string, this is never used as part of scope matching + path = path.split("?")[0]; + + const resource = path.split("/").pop() ?? ""; + const [namespace, method] = resource.split("."); + + return scopes.some((scope) => { + const [scopeNamespace, scopeMethod] = scope.match(/[:\.]/g) + ? scope.replace("/api/", "").split(/[:\.]/g) + : ["*", scope]; + const isRouteScope = scope.startsWith("/api/"); + + if (isRouteScope) { + return ( + (namespace === scopeNamespace || scopeNamespace === "*") && + (method === scopeMethod || scopeMethod === "*") + ); + } + + return ( + (namespace === scopeNamespace || scopeNamespace === "*") && + (scopeMethod === Scope.Write || + this.methodToScope[method as keyof typeof this.methodToScope] === + scopeMethod) + ); + }); + }; } diff --git a/server/models/index.ts b/server/models/index.ts index a7a773f2d6..486e3c8b4f 100644 --- a/server/models/index.ts +++ b/server/models/index.ts @@ -34,6 +34,12 @@ export { default as IntegrationAuthentication } from "./IntegrationAuthenticatio export { default as Notification } from "./Notification"; +export { default as OAuthAuthentication } from "./oauth/OAuthAuthentication"; + +export { default as OAuthAuthorizationCode } from "./oauth/OAuthAuthorizationCode"; + +export { default as OAuthClient } from "./oauth/OAuthClient"; + export { default as Pin } from "./Pin"; export { default as Reaction } from "./Reaction"; diff --git a/server/models/oauth/OAuthAuthentication.ts b/server/models/oauth/OAuthAuthentication.ts new file mode 100644 index 0000000000..ae8e647ad7 --- /dev/null +++ b/server/models/oauth/OAuthAuthentication.ts @@ -0,0 +1,212 @@ +import { Matches } from "class-validator"; +import { subMinutes } from "date-fns"; +import { + FindOptions, + InferAttributes, + InferCreationAttributes, + NonNullFindOptions, +} from "sequelize"; +import { + Column, + DataType, + BelongsTo, + ForeignKey, + Table, + IsDate, + Unique, +} from "sequelize-typescript"; +import env from "@server/env"; +import User from "@server/models/User"; +import ParanoidModel from "@server/models/base/ParanoidModel"; +import { SkipChangeset } from "@server/models/decorators/Changeset"; +import Fix from "@server/models/decorators/Fix"; +import AuthenticationHelper from "@server/models/helpers/AuthenticationHelper"; +import { hash } from "@server/utils/crypto"; +import OAuthClient from "./OAuthClient"; + +@Table({ + tableName: "oauth_authentications", + modelName: "oauth_authentication", +}) +@Fix +class OAuthAuthentication extends ParanoidModel< + InferAttributes, + Partial> +> { + static eventNamespace = "oauthAuthentications"; + + /** The lifetime of an access token in seconds. */ + public static accessTokenLifetime = env.OAUTH_PROVIDER_ACCESS_TOKEN_LIFETIME; + + /** The lifetime of a refresh token in seconds. */ + public static refreshTokenLifetime = + env.OAUTH_PROVIDER_REFRESH_TOKEN_LIFETIME; + + /** A recognizable prefix for access tokens. */ + public static accessTokenPrefix = "ol_at_"; + + /** A recognizable prefix for refresh tokens. */ + public static refreshTokenPrefix = "ol_rt_"; + + @Unique + @Column + @SkipChangeset + accessTokenHash: string; + + /** The cached plain text access token. Only available during creation. */ + @Column(DataType.VIRTUAL) + accessToken: string | null; + + @IsDate + @Column + accessTokenExpiresAt: Date; + + @Unique + @Column + @SkipChangeset + refreshTokenHash: string; + + /** The cached plain text refresh token. Only available during creation. */ + @Column(DataType.VIRTUAL) + refreshToken: string | null; + + @IsDate + @Column + refreshTokenExpiresAt: Date; + + /** A list of scopes that this authentication has access to */ + @Matches(/[\/\.\w\s]*/, { + each: true, + }) + @Column(DataType.ARRAY(DataType.STRING)) + scope: string[]; + + @IsDate + @Column + @SkipChangeset + lastActiveAt: Date; + + // associations + + @BelongsTo(() => OAuthClient, "oauthClientId") + oauthClient: OAuthClient; + + @ForeignKey(() => OAuthClient) + @Column(DataType.UUID) + oauthClientId: string; + + @BelongsTo(() => User, "userId") + user: User; + + @ForeignKey(() => User) + @Column(DataType.UUID) + userId: string; + + // methods + + updateActiveAt = async () => { + const fiveMinutesAgo = subMinutes(new Date(), 5); + + // ensure this is updated only every few minutes otherwise + // we'll be constantly writing to the DB as API requests happen + if (!this.lastActiveAt || this.lastActiveAt < fiveMinutesAgo) { + this.lastActiveAt = new Date(); + } + + return this.save({ silent: true }); + }; + + // instance methods + + /** Checks if the authentication has access to the given path */ + canAccess = (path: string) => { + // Special case for the revoke endpoint, which is always allowed + if (path === "/revoke") { + return true; + } + + return AuthenticationHelper.canAccess(path, this.scope); + }; + + // static methods + + /** + * Validates that the input text _could_ be an OAuth token, this does not check + * that the key actually exists in the database. + * + * @param text The text to validate + * @returns True if likely an OAuth token + */ + public static match(text: string) { + return !!text.startsWith(this.accessTokenPrefix); + } + + /** + * Validates that the input text _could_ be an OAuth refresh token, this does + * not check that the key actually exists in the database. + * + * @param text The text to validate + * @returns True if likely an OAuth refresh token + */ + public static matchRefreshToken(text: string) { + return !!text.startsWith(this.refreshTokenPrefix); + } + + /** + * Finds an OAuthAuthentication by the given access token, including the + * associated user. + * + * @param input The access token to search for + * @param options The options to pass to the find method + * @returns The OAuthAuthentication if found + */ + static findByAccessToken( + input: string, + options?: + | FindOptions + | NonNullFindOptions + ): Promise { + return this.findOne({ + where: { + accessTokenHash: hash(input), + }, + include: [ + { + association: "user", + required: true, + }, + ], + ...options, + }); + } + + /** + * Finds an OAuthAuthentication by the given refresh token, including the + * associated user. + * + * @param input The refresh token to search for + * @param options The options to pass to the find method + * @returns The OAuthAuthentication if found + */ + public static findByRefreshToken( + input: string, + options?: + | FindOptions + | NonNullFindOptions + ) { + return this.findOne({ + where: { + refreshTokenHash: hash(input), + }, + include: [ + { + association: "user", + required: true, + }, + ], + ...options, + }); + } +} + +export default OAuthAuthentication; diff --git a/server/models/oauth/OAuthAuthorizationCode.ts b/server/models/oauth/OAuthAuthorizationCode.ts new file mode 100644 index 0000000000..d6c742759a --- /dev/null +++ b/server/models/oauth/OAuthAuthorizationCode.ts @@ -0,0 +1,104 @@ +import { Matches } from "class-validator"; +import { InferAttributes, InferCreationAttributes } from "sequelize"; +import { + Column, + DataType, + BelongsTo, + ForeignKey, + Table, + Length, +} from "sequelize-typescript"; +import { OAuthClientValidation } from "@shared/validations"; +import env from "@server/env"; +import User from "@server/models/User"; +import IdModel from "@server/models/base/IdModel"; +import { SkipChangeset } from "@server/models/decorators/Changeset"; +import Fix from "@server/models/decorators/Fix"; +import { hash } from "@server/utils/crypto"; +import OAuthClient from "./OAuthClient"; + +@Table({ + tableName: "oauth_authorization_codes", + modelName: "oauth_authorization_code", + updatedAt: false, +}) +@Fix +class OAuthAuthorizationCode extends IdModel< + InferAttributes, + Partial> +> { + static eventNamespace = "oauthAuthorizationCodes"; + + /** The lifetime of an authorization code in seconds. */ + public static authorizationCodeLifetime = + env.OAUTH_PROVIDER_AUTHORIZATION_CODE_LIFETIME; + + /** A recognizable prefix for authorization codes. */ + public static authorizationCodePrefix = "ol_ac_"; + + @Column + @SkipChangeset + authorizationCodeHash: string; + + @Column + @SkipChangeset + codeChallenge?: string; + + @Column + @SkipChangeset + codeChallengeMethod?: string; + + /** A list of scopes that this authorization code has access to */ + @Matches(/[\/\.\w\s]*/, { + each: true, + }) + @Column(DataType.ARRAY(DataType.STRING)) + scope: string[]; + + @Length({ max: OAuthClientValidation.maxRedirectUriLength }) + @Column + redirectUri: string; + + @Column(DataType.DATE) + expiresAt: Date; + + // associations + + @BelongsTo(() => OAuthClient, "oauthClientId") + oauthClient: OAuthClient; + + @ForeignKey(() => OAuthClient) + @Column(DataType.UUID) + oauthClientId: string; + + @BelongsTo(() => User, "userId") + user: User; + + @ForeignKey(() => User) + @Column(DataType.UUID) + userId: string; + + /** + * Finds an OAuthAuthorizationCode by the given code. + * + * @param input The code to search for + * @returns The OAuthAuthentication if found + */ + public static findByCode(input: string) { + const authorizationCodeHash = hash(input); + + return this.findOne({ + where: { + authorizationCodeHash, + }, + include: [ + { + association: "user", + required: true, + }, + ], + }); + } +} + +export default OAuthAuthorizationCode; diff --git a/server/models/oauth/OAuthClient.ts b/server/models/oauth/OAuthClient.ts new file mode 100644 index 0000000000..533ce75470 --- /dev/null +++ b/server/models/oauth/OAuthClient.ts @@ -0,0 +1,159 @@ +import { + ArrayMaxSize, + ArrayMinSize, + ArrayNotEmpty, + ArrayUnique, + IsUrl, +} from "class-validator"; +import rs from "randomstring"; +import { InferAttributes, InferCreationAttributes } from "sequelize"; +import { + Column, + DataType, + BelongsTo, + ForeignKey, + Table, + Length, + BeforeCreate, + AllowNull, +} from "sequelize-typescript"; +import { OAuthClientValidation } from "@shared/validations"; +import Team from "@server/models/Team"; +import User from "@server/models/User"; +import ParanoidModel from "@server/models/base/ParanoidModel"; +import Encrypted from "@server/models/decorators/Encrypted"; +import Fix from "@server/models/decorators/Fix"; +import IsUrlOrRelativePath from "@server/models/validators/IsUrlOrRelativePath"; +import NotContainsUrl from "@server/models/validators/NotContainsUrl"; + +@Table({ + tableName: "oauth_clients", + modelName: "oauth_client", +}) +@Fix +class OAuthClient extends ParanoidModel< + InferAttributes, + Partial> +> { + static eventNamespace = "oauthClients"; + + public static clientSecretPrefix = "ol_sk_"; + + @NotContainsUrl + @Length({ max: OAuthClientValidation.maxNameLength }) + @Column + name: string; + + @AllowNull + @NotContainsUrl + @Length({ max: OAuthClientValidation.maxDescriptionLength }) + @Column + description: string | null; + + @AllowNull + @NotContainsUrl + @Length({ max: OAuthClientValidation.maxDeveloperNameLength }) + @Column + developerName: string | null; + + @AllowNull + @IsUrlOrRelativePath + @Length({ max: OAuthClientValidation.maxDeveloperUrlLength }) + @Column + developerUrl: string | null; + + @AllowNull + @IsUrlOrRelativePath + @Length({ max: OAuthClientValidation.maxAvatarUrlLength }) + @Column + avatarUrl: string | null; + + @Column + clientId: string; + + @Column(DataType.BLOB) + @Encrypted + clientSecret: string; + + @Column + published: boolean; + + @ArrayNotEmpty() + @ArrayUnique() + @ArrayMinSize(1) + @ArrayMaxSize(10) + @IsUrl( + { + require_tld: false, + allow_underscores: true, + }, + { + each: true, + } + ) + @Column(DataType.ARRAY(DataType.STRING)) + redirectUris: string[]; + + // associations + + @BelongsTo(() => Team, "teamId") + team: Team; + + @ForeignKey(() => Team) + @Column(DataType.UUID) + teamId: string; + + @BelongsTo(() => User, "createdById") + createdBy: User; + + @ForeignKey(() => User) + @Column(DataType.UUID) + createdById: string; + + // instance methods + + /** + * Rotate the client secret value. Does not persist to database. + */ + public rotateClientSecret() { + this.clientSecret = OAuthClient.generateNewClientSecret(); + } + + // hooks + + @BeforeCreate + public static async generateCredentials(model: OAuthClient) { + model.clientId = OAuthClient.generateNewClientId(); + model.clientSecret = OAuthClient.generateNewClientSecret(); + } + + // static methods + + /** + * Find an OAuthClient by it's public `clientId` + * + * @param clientId The public clientId of the OAuthClient + * @returns The OAuthClient or null if not found + */ + public static async findByClientId(clientId: string) { + return this.findOne({ + where: { + clientId, + }, + }); + } + + private static generateNewClientId(): string { + return rs.generate({ + length: 20, + charset: "alphanumeric", + capitalization: "lowercase", + }); + } + + private static generateNewClientSecret(): string { + return `${OAuthClient.clientSecretPrefix}${rs.generate(32)}`; + } +} + +export default OAuthClient; diff --git a/server/policies/index.ts b/server/policies/index.ts index ebcf04f9ab..f4f0467c63 100644 --- a/server/policies/index.ts +++ b/server/policies/index.ts @@ -11,6 +11,9 @@ import "./document"; import "./fileOperation"; import "./import"; import "./integration"; +import "./notification"; +import "./oauthClient"; +import "./oauthAuthentication"; import "./pins"; import "./reaction"; import "./revision"; @@ -22,5 +25,4 @@ import "./user"; import "./team"; import "./group"; import "./webhookSubscription"; -import "./notification"; import "./userMembership"; diff --git a/server/policies/oauthAuthentication.ts b/server/policies/oauthAuthentication.ts new file mode 100644 index 0000000000..b33621b4cd --- /dev/null +++ b/server/policies/oauthAuthentication.ts @@ -0,0 +1,14 @@ +import { Team, User, OAuthAuthentication } from "@server/models"; +import { allow } from "./cancan"; +import { isTeamModel } from "./utils"; + +allow(User, "listOAuthAuthentications", Team, (actor, team) => + isTeamModel(actor, team) +); + +allow( + User, + ["read", "delete"], + OAuthAuthentication, + (actor, oauthAuthentication) => actor?.id === oauthAuthentication?.userId +); diff --git a/server/policies/oauthClient.ts b/server/policies/oauthClient.ts new file mode 100644 index 0000000000..d1341ee008 --- /dev/null +++ b/server/policies/oauthClient.ts @@ -0,0 +1,19 @@ +import { Team, User, OAuthClient } from "@server/models"; +import { allow } from "./cancan"; +import { or, isTeamModel, isTeamMutable, and, isTeamAdmin } from "./utils"; + +allow(User, "createOAuthClient", Team, (actor, team) => + and(isTeamModel(actor, team), isTeamMutable(actor), actor.isAdmin) +); + +allow(User, "listOAuthClients", Team, (actor, team) => + isTeamAdmin(actor, team) +); + +allow(User, "read", OAuthClient, (actor, oauthClient) => + or(isTeamModel(actor, oauthClient), !!oauthClient?.published) +); + +allow(User, ["update", "delete"], OAuthClient, (actor, oauthClient) => + and(isTeamModel(actor, oauthClient), isTeamMutable(actor), actor.isAdmin) +); diff --git a/server/presenters/index.ts b/server/presenters/index.ts index 563e3657e5..f3fb962ce9 100644 --- a/server/presenters/index.ts +++ b/server/presenters/index.ts @@ -13,6 +13,7 @@ import presentGroupUser from "./groupUser"; import presentImport from "./import"; import presentIntegration from "./integration"; import presentMembership from "./membership"; +import presentOAuthClient, { presentPublishedOAuthClient } from "./oauthClient"; import presentPin from "./pin"; import presentPolicies from "./policy"; import presentProviderConfig from "./providerConfig"; @@ -43,6 +44,8 @@ export { presentImport, presentIntegration, presentMembership, + presentOAuthClient, + presentPublishedOAuthClient, presentPublicTeam, presentPin, presentPolicies, diff --git a/server/presenters/oauthAuthentication.ts b/server/presenters/oauthAuthentication.ts new file mode 100644 index 0000000000..9266a6a1ad --- /dev/null +++ b/server/presenters/oauthAuthentication.ts @@ -0,0 +1,16 @@ +import { OAuthAuthentication } from "@server/models"; +import { presentPublishedOAuthClient } from "./oauthClient"; + +export default function presentOAuthAuthentication( + oauthAuthentication: OAuthAuthentication +) { + return { + id: oauthAuthentication.id, + userId: oauthAuthentication.userId, + oauthClientId: oauthAuthentication.oauthClientId, + oauthClient: presentPublishedOAuthClient(oauthAuthentication.oauthClient), + scope: oauthAuthentication.scope, + lastActiveAt: oauthAuthentication.lastActiveAt, + createdAt: oauthAuthentication.createdAt, + }; +} diff --git a/server/presenters/oauthClient.ts b/server/presenters/oauthClient.ts new file mode 100644 index 0000000000..200c8ea588 --- /dev/null +++ b/server/presenters/oauthClient.ts @@ -0,0 +1,42 @@ +import { OAuthClient } from "@server/models"; + +/** + * Presents the OAuth client to the user. + * + * @param oauthClient The OAuth client to present + */ +export default function presentOAuthClient(oauthClient: OAuthClient) { + return { + id: oauthClient.id, + name: oauthClient.name, + description: oauthClient.description, + developerName: oauthClient.developerName, + developerUrl: oauthClient.developerUrl, + avatarUrl: oauthClient.avatarUrl, + clientId: oauthClient.clientId, + clientSecret: oauthClient.clientSecret, + redirectUris: oauthClient.redirectUris, + published: oauthClient.published, + createdAt: oauthClient.createdAt, + updatedAt: oauthClient.updatedAt, + }; +} + +/** + * Important: This function is used to present the OAuth client to users + * that are NOT in the same workspace as the client. Be very careful about + * what you expose here. + * + * @param oauthClient The OAuth client to present + */ +export function presentPublishedOAuthClient(oauthClient: OAuthClient) { + return { + name: oauthClient.name, + description: oauthClient.description, + developerName: oauthClient.developerName, + developerUrl: oauthClient.developerUrl, + avatarUrl: oauthClient.avatarUrl, + clientId: oauthClient.clientId, + published: oauthClient.published, + }; +} diff --git a/server/queues/processors/OAuthClientDeletedProcessor.ts b/server/queues/processors/OAuthClientDeletedProcessor.ts new file mode 100644 index 0000000000..f1363b4225 --- /dev/null +++ b/server/queues/processors/OAuthClientDeletedProcessor.ts @@ -0,0 +1,15 @@ +import { OAuthAuthentication } from "@server/models"; +import { OAuthClientEvent, Event as TEvent } from "@server/types"; +import BaseProcessor from "./BaseProcessor"; + +export default class OAuthClientDeletedProcessor extends BaseProcessor { + static applicableEvents: TEvent["name"][] = ["oauthClients.delete"]; + + async perform(event: OAuthClientEvent) { + await OAuthAuthentication.destroy({ + where: { + oauthClientId: event.modelId, + }, + }); + } +} diff --git a/server/queues/processors/OAuthClientUnpublishedProcessor.ts b/server/queues/processors/OAuthClientUnpublishedProcessor.ts new file mode 100644 index 0000000000..7a6a2fd1fd --- /dev/null +++ b/server/queues/processors/OAuthClientUnpublishedProcessor.ts @@ -0,0 +1,36 @@ +import { Op } from "sequelize"; +import { OAuthAuthentication, OAuthClient, User } from "@server/models"; +import { OAuthClientEvent, Event as TEvent } from "@server/types"; +import BaseProcessor from "./BaseProcessor"; + +export default class OAuthClientUnpublishedProcessor extends BaseProcessor { + static applicableEvents: TEvent["name"][] = ["oauthClients.update"]; + + async perform(event: OAuthClientEvent) { + if ( + event.changes?.previous.published === true && + event.changes.attributes.published === false + ) { + const oauthClient = await OAuthClient.findByPk(event.modelId, { + rejectOnEmpty: true, + }); + const users = await User.findAll({ + attributes: ["id"], + where: { + teamId: oauthClient.teamId, + }, + }); + const userIds = users.map((user) => user.id); + + // Revoke access for all users except any that are in the same team + await OAuthAuthentication.destroy({ + where: { + oauthClientId: event.modelId, + userId: { + [Op.notIn]: userIds, + }, + }, + }); + } + } +} diff --git a/server/queues/processors/UserDeletedProcessor.ts b/server/queues/processors/UserDeletedProcessor.ts index 79bbb8b6d4..b3d5dbabe2 100644 --- a/server/queues/processors/UserDeletedProcessor.ts +++ b/server/queues/processors/UserDeletedProcessor.ts @@ -1,6 +1,7 @@ import { ApiKey, GroupUser, + OAuthAuthentication, Star, Subscription, UserAuthentication, @@ -46,6 +47,12 @@ export default class UserDeletedProcessor extends BaseProcessor { }, transaction, }); + await OAuthAuthentication.destroy({ + where: { + userId: event.userId, + }, + transaction, + }); await Star.destroy({ where: { userId: event.userId, diff --git a/server/queues/processors/UserSuspendedProcessor.ts b/server/queues/processors/UserSuspendedProcessor.ts new file mode 100644 index 0000000000..e77b396cf7 --- /dev/null +++ b/server/queues/processors/UserSuspendedProcessor.ts @@ -0,0 +1,14 @@ +import { OAuthAuthentication } from "@server/models"; +import { Event as TEvent, UserEvent } from "@server/types"; +import BaseProcessor from "./BaseProcessor"; + +export default class UserSuspendedProcessor extends BaseProcessor { + static applicableEvents: TEvent["name"][] = ["users.suspend"]; + + async perform(event: UserEvent) { + // Remove all OAuth authentications for this user. + await OAuthAuthentication.destroy({ + where: { userId: event.userId }, + }); + } +} diff --git a/server/queues/tasks/CleanupOAuthAuthorizationCodeTask.test.ts b/server/queues/tasks/CleanupOAuthAuthorizationCodeTask.test.ts new file mode 100644 index 0000000000..f9fc17ae97 --- /dev/null +++ b/server/queues/tasks/CleanupOAuthAuthorizationCodeTask.test.ts @@ -0,0 +1,37 @@ +import { subMonths } from "date-fns"; +import { OAuthAuthorizationCode } from "@server/models"; +import { buildOAuthAuthorizationCode } from "@server/test/factories"; +import CleanupOAuthAuthorizationCodeTask from "./CleanupOAuthAuthorizationCodeTask"; + +const codeExists = async (code: OAuthAuthorizationCode) => { + const found = await OAuthAuthorizationCode.findByPk(code.id); + return !!found; +}; + +describe("CleanupOAuthAuthorizationCodeTask", () => { + it("should delete authorization codes expired more than one month ago", async () => { + const brandNewCode = await buildOAuthAuthorizationCode({ + expiresAt: new Date(), + }); + const oldCode = await buildOAuthAuthorizationCode({ + expiresAt: subMonths(new Date(), 2), + }); + + const task = new CleanupOAuthAuthorizationCodeTask(); + await task.perform(); + + expect(await codeExists(brandNewCode)).toBe(true); + expect(await codeExists(oldCode)).toBe(false); + }); + + it("should not delete codes that expired less than one month ago", async () => { + const recentCode = await buildOAuthAuthorizationCode({ + expiresAt: new Date(), + }); + + const task = new CleanupOAuthAuthorizationCodeTask(); + await task.perform(); + + expect(await codeExists(recentCode)).toBe(true); + }); +}); diff --git a/server/queues/tasks/CleanupOAuthAuthorizationCodeTask.ts b/server/queues/tasks/CleanupOAuthAuthorizationCodeTask.ts new file mode 100644 index 0000000000..572c8c1ec6 --- /dev/null +++ b/server/queues/tasks/CleanupOAuthAuthorizationCodeTask.ts @@ -0,0 +1,33 @@ +import { subMonths } from "date-fns"; +import { Op } from "sequelize"; +import Logger from "@server/logging/Logger"; +import { OAuthAuthorizationCode } from "@server/models"; +import BaseTask, { TaskPriority, TaskSchedule } from "./BaseTask"; + +type Props = Record; + +export default class CleanupOAuthAuthorizationCodeTask extends BaseTask { + static cron = TaskSchedule.Day; + + public async perform() { + Logger.info( + "task", + `Deleting OAuth authorization codes older than one month…` + ); + const count = await OAuthAuthorizationCode.destroy({ + where: { + expiresAt: { + [Op.lt]: subMonths(new Date(), 1), + }, + }, + }); + Logger.info("task", `${count} expired OAuth authorization codes deleted.`); + } + + public get options() { + return { + attempts: 1, + priority: TaskPriority.Background, + }; + } +} diff --git a/server/routes/api/apiKeys/apiKeys.ts b/server/routes/api/apiKeys/apiKeys.ts index a1ae85db68..c9a551261e 100644 --- a/server/routes/api/apiKeys/apiKeys.ts +++ b/server/routes/api/apiKeys/apiKeys.ts @@ -15,7 +15,10 @@ const router = new Router(); router.post( "apiKeys.create", - auth({ role: UserRole.Member, type: AuthenticationType.APP }), + auth({ + role: UserRole.Member, + type: AuthenticationType.APP, + }), validate(T.APIKeysCreateSchema), transaction(), async (ctx: APIContext) => { @@ -90,7 +93,10 @@ router.post( router.post( "apiKeys.delete", - auth({ role: UserRole.Member }), + auth({ + role: UserRole.Member, + type: AuthenticationType.APP, + }), validate(T.APIKeysDeleteSchema), transaction(), async (ctx: APIContext) => { diff --git a/server/routes/api/index.ts b/server/routes/api/index.ts index de12822684..583e23267b 100644 --- a/server/routes/api/index.ts +++ b/server/routes/api/index.ts @@ -5,6 +5,7 @@ import userAgent, { UserAgentContext } from "koa-useragent"; import env from "@server/env"; import { NotFoundError } from "@server/errors"; import coalesceBody from "@server/middlewares/coaleseBody"; +import requestTracer from "@server/middlewares/requestTracer"; import { AppState, AppContext } from "@server/types"; import { Hook, PluginManager } from "@server/utils/PluginManager"; import apiKeys from "./apiKeys"; @@ -25,9 +26,10 @@ import installation from "./installation"; import integrations from "./integrations"; import apiErrorHandler from "./middlewares/apiErrorHandler"; import apiResponse from "./middlewares/apiResponse"; -import apiTracer from "./middlewares/apiTracer"; import editor from "./middlewares/editor"; import notifications from "./notifications"; +import oauthAuthentications from "./oauthAuthentications"; +import oauthClients from "./oauthClients"; import pins from "./pins"; import reactions from "./reactions"; import revisions from "./revisions"; @@ -60,7 +62,7 @@ api.use( ); api.use(coalesceBody()); api.use(userAgent); -api.use(apiTracer()); +api.use(requestTracer()); api.use(apiResponse()); api.use(apiErrorHandler()); api.use(editor()); @@ -90,6 +92,8 @@ router.use("/", suggestions.routes()); router.use("/", teams.routes()); router.use("/", integrations.routes()); router.use("/", notifications.routes()); +router.use("/", oauthAuthentications.routes()); +router.use("/", oauthClients.routes()); router.use("/", attachments.routes()); router.use("/", cron.routes()); router.use("/", groups.routes()); diff --git a/server/routes/api/oauthAuthentications/__snapshots__/oauthAuthentications.test.ts.snap b/server/routes/api/oauthAuthentications/__snapshots__/oauthAuthentications.test.ts.snap new file mode 100644 index 0000000000..b18e9a4571 --- /dev/null +++ b/server/routes/api/oauthAuthentications/__snapshots__/oauthAuthentications.test.ts.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`oauthAuthentications.delete should require authentication 1`] = ` +{ + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; + +exports[`oauthAuthentications.list should require authentication 1`] = ` +{ + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; diff --git a/server/routes/api/oauthAuthentications/index.ts b/server/routes/api/oauthAuthentications/index.ts new file mode 100644 index 0000000000..71e4bcfa95 --- /dev/null +++ b/server/routes/api/oauthAuthentications/index.ts @@ -0,0 +1 @@ +export { default } from "./oauthAuthentications"; diff --git a/server/routes/api/oauthAuthentications/oauthAuthentications.test.ts b/server/routes/api/oauthAuthentications/oauthAuthentications.test.ts new file mode 100644 index 0000000000..e8a6edc566 --- /dev/null +++ b/server/routes/api/oauthAuthentications/oauthAuthentications.test.ts @@ -0,0 +1,194 @@ +import { OAuthClient, OAuthAuthentication } from "@server/models"; +import { + buildOAuthAuthentication, + buildTeam, + buildUser, +} from "@server/test/factories"; +import { getTestServer } from "@server/test/support"; + +const server = getTestServer(); + +describe("oauthAuthentications.list", () => { + it("should require authentication", async () => { + const res = await server.post("/api/oauthAuthentications.list"); + const body = await res.json(); + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); + + it("should return list of oauth authentications for user", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const oauthClient = await OAuthClient.create({ + teamId: team.id, + createdById: user.id, + name: "Test Client", + redirectUris: ["https://example.com/callback"], + }); + + await buildOAuthAuthentication({ + oauthClientId: oauthClient.id, + user, + scope: ["read"], + }); + + const res = await server.post("/api/oauthAuthentications.list", { + body: { + token: user.getJwtToken(), + }, + }); + + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(1); + expect(body.data[0].id).toBeDefined(); + expect(body.data[0].oauthClient.name).toEqual("Test Client"); + expect(body.policies).toBeDefined(); + }); + + it("should only return authentications for requesting user", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const anotherUser = await buildUser({ teamId: team.id }); + const oauthClient = await OAuthClient.create({ + teamId: team.id, + createdById: user.id, + name: "Test Client", + redirectUris: ["https://example.com/callback"], + }); + + await buildOAuthAuthentication({ + oauthClientId: oauthClient.id, + user: anotherUser, + scope: ["read"], + }); + + const res = await server.post("/api/oauthAuthentications.list", { + body: { + token: user.getJwtToken(), + }, + }); + + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(0); + }); +}); + +describe("oauthAuthentications.delete", () => { + it("should require authentication", async () => { + const res = await server.post("/api/oauthAuthentications.delete"); + const body = await res.json(); + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); + + it("should delete all authentications for a client without scope", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const oauthClient = await OAuthClient.create({ + teamId: team.id, + createdById: user.id, + name: "Test Client", + redirectUris: ["https://example.com/callback"], + }); + + await buildOAuthAuthentication({ + oauthClientId: oauthClient.id, + user, + scope: ["read"], + }); + + const res = await server.post("/api/oauthAuthentications.delete", { + body: { + token: user.getJwtToken(), + oauthClientId: oauthClient.id, + }, + }); + + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.success).toBe(true); + + const auths = await OAuthAuthentication.findAll({ + where: { + userId: user.id, + oauthClientId: oauthClient.id, + }, + }); + expect(auths.length).toEqual(0); + }); + + it("should delete matching authentications for a client with scope", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const oauthClient = await OAuthClient.create({ + teamId: team.id, + createdById: user.id, + name: "Test Client", + redirectUris: ["https://example.com/callback"], + }); + + await buildOAuthAuthentication({ + oauthClientId: oauthClient.id, + user, + scope: ["read"], + }); + await buildOAuthAuthentication({ + oauthClientId: oauthClient.id, + user, + scope: ["write"], + }); + + const res = await server.post("/api/oauthAuthentications.delete", { + body: { + token: user.getJwtToken(), + oauthClientId: oauthClient.id, + scope: ["read"], + }, + }); + + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.success).toBe(true); + + const auths = await OAuthAuthentication.findAll({ + where: { + userId: user.id, + oauthClientId: oauthClient.id, + }, + }); + expect(auths.length).toEqual(1); + expect(auths[0].scope[0]).toEqual("write"); + }); + + it("should only delete authentications for requesting user", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + const anotherUser = await buildUser({ teamId: team.id }); + const oauthClient = await OAuthClient.create({ + teamId: team.id, + createdById: user.id, + name: "Test Client", + redirectUris: ["https://example.com/callback"], + }); + + const otherAuth = await buildOAuthAuthentication({ + oauthClientId: oauthClient.id, + user: anotherUser, + scope: ["read"], + }); + + await server.post("/api/oauthAuthentications.delete", { + body: { + token: user.getJwtToken(), + oauthClientId: oauthClient.id, + scope: "read", + }, + }); + + // Verify other user's auth still exists + const auth = await OAuthAuthentication.findByPk(otherAuth.id); + expect(auth).not.toBeNull(); + }); +}); diff --git a/server/routes/api/oauthAuthentications/oauthAuthentications.ts b/server/routes/api/oauthAuthentications/oauthAuthentications.ts new file mode 100644 index 0000000000..ca7c697482 --- /dev/null +++ b/server/routes/api/oauthAuthentications/oauthAuthentications.ts @@ -0,0 +1,90 @@ +import Router from "koa-router"; +import { QueryTypes } from "sequelize"; +import auth from "@server/middlewares/authentication"; +import { rateLimiter } from "@server/middlewares/rateLimiter"; +import { transaction } from "@server/middlewares/transaction"; +import validate from "@server/middlewares/validate"; +import { OAuthAuthentication } from "@server/models"; +import { authorize } from "@server/policies"; +import { presentPolicies } from "@server/presenters"; +import presentOAuthAuthentication from "@server/presenters/oauthAuthentication"; +import { sequelize } from "@server/storage/database"; +import { APIContext } from "@server/types"; +import { RateLimiterStrategy } from "@server/utils/RateLimiter"; +import pagination from "../middlewares/pagination"; +import * as T from "./schema"; + +const router = new Router(); + +router.post( + "oauthAuthentications.list", + auth(), + pagination(), + validate(T.OAuthAuthenticationsListSchema), + async (ctx: APIContext) => { + const { user } = ctx.state.auth; + + const oauthAuthentications = await sequelize.query( + ` + SELECT DISTINCT ON (oa."oauthClientId", oa."scope") + oa.*, + oc.id AS "oauthClient.id", + oc.name AS "oauthClient.name", + oc."avatarUrl" AS "oauthClient.avatarUrl", + oc."clientId" AS "oauthClient.clientId" + FROM oauth_authentications oa + INNER JOIN oauth_clients oc ON oc.id = oa."oauthClientId" + WHERE oa."userId" = :userId + AND oa."deletedAt" IS NULL + ORDER BY oa."oauthClientId", oa."scope", oa."lastActiveAt", oa."createdAt" DESC + LIMIT :limit OFFSET :offset + `, + { + replacements: { + userId: user.id, + limit: ctx.state.pagination.limit, + offset: ctx.state.pagination.offset, + }, + type: QueryTypes.SELECT, + nest: true, + } + ); + + ctx.body = { + pagination: { ...ctx.state.pagination }, + data: oauthAuthentications.map(presentOAuthAuthentication), + policies: presentPolicies(user, oauthAuthentications), + }; + } +); + +router.post( + "oauthAuthentications.delete", + rateLimiter(RateLimiterStrategy.TwentyFivePerMinute), + auth(), + validate(T.OAuthAuthenticationsDeleteSchema), + transaction(), + async (ctx: APIContext) => { + const { user } = ctx.state.auth; + const { oauthClientId, scope } = ctx.input.body; + const oauthAuthentications = await OAuthAuthentication.findAll({ + where: { + userId: user.id, + oauthClientId, + ...(scope ? { scope } : {}), + }, + transaction: ctx.state.transaction, + }); + + for (const oauthAuthentication of oauthAuthentications) { + authorize(user, "delete", oauthAuthentication); + await oauthAuthentication.destroyWithCtx(ctx); + } + + ctx.body = { + success: true, + }; + } +); + +export default router; diff --git a/server/routes/api/oauthAuthentications/schema.ts b/server/routes/api/oauthAuthentications/schema.ts new file mode 100644 index 0000000000..9dce6369f9 --- /dev/null +++ b/server/routes/api/oauthAuthentications/schema.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; +import { BaseSchema } from "@server/routes/api/schema"; + +export const OAuthAuthenticationsListSchema = BaseSchema.extend({ + body: z.object({}), +}); + +export type OAuthAuthenticationsListReq = z.infer< + typeof OAuthAuthenticationsListSchema +>; + +export const OAuthAuthenticationsDeleteSchema = BaseSchema.extend({ + body: z.object({ + oauthClientId: z.string(), + scope: z.array(z.string()).optional(), + }), +}); + +export type OAuthAuthenticationsDeleteReq = z.infer< + typeof OAuthAuthenticationsDeleteSchema +>; diff --git a/server/routes/api/oauthClients/__snapshots__/oauthClients.test.ts.snap b/server/routes/api/oauthClients/__snapshots__/oauthClients.test.ts.snap new file mode 100644 index 0000000000..eb84ac42ec --- /dev/null +++ b/server/routes/api/oauthClients/__snapshots__/oauthClients.test.ts.snap @@ -0,0 +1,55 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`oauthClients.create should require authentication 1`] = ` +{ + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; + +exports[`oauthClients.delete should require authentication 1`] = ` +{ + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; + +exports[`oauthClients.info should require authentication 1`] = ` +{ + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; + +exports[`oauthClients.list should require authentication 1`] = ` +{ + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; + +exports[`oauthClients.rotate_secret should require authentication 1`] = ` +{ + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; + +exports[`oauthclients.update should require authentication 1`] = ` +{ + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; diff --git a/server/routes/api/oauthClients/index.ts b/server/routes/api/oauthClients/index.ts new file mode 100644 index 0000000000..1428e5aa23 --- /dev/null +++ b/server/routes/api/oauthClients/index.ts @@ -0,0 +1 @@ +export { default } from "./oauthClients"; diff --git a/server/routes/api/oauthClients/oauthClients.test.ts b/server/routes/api/oauthClients/oauthClients.test.ts new file mode 100644 index 0000000000..e68bb48226 --- /dev/null +++ b/server/routes/api/oauthClients/oauthClients.test.ts @@ -0,0 +1,326 @@ +import { OAuthClient } from "@server/models"; +import { buildTeam, buildUser, buildAdmin } from "@server/test/factories"; +import { getTestServer } from "@server/test/support"; + +const server = getTestServer(); + +describe("oauthClients.list", () => { + it("should require authentication", async () => { + const res = await server.post("/api/oauthClients.list"); + const body = await res.json(); + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); + + it("should return all clients for admin", async () => { + const team = await buildTeam(); + const another = await buildTeam(); + const admin = await buildAdmin({ teamId: team.id }); + + await OAuthClient.create({ + teamId: another.id, + createdById: admin.id, + name: "Another Client", + redirectUris: ["https://example.com/callback"], + published: true, + }); + + await OAuthClient.create({ + teamId: team.id, + createdById: admin.id, + name: "Published Client", + redirectUris: ["https://example.com/callback"], + published: true, + }); + + await OAuthClient.create({ + teamId: team.id, + createdById: admin.id, + name: "Unpublished Client", + redirectUris: ["https://example.com/callback"], + published: false, + }); + + const res = await server.post("/api/oauthClients.list", { + body: { + token: admin.getJwtToken(), + }, + }); + + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(2); + expect(body.data.map((c: { name: string }) => c.name).sort()).toEqual([ + "Published Client", + "Unpublished Client", + ]); + expect(body.data[0].id).toBeDefined(); + expect(body.data[0].redirectUris).toBeDefined(); + }); +}); + +describe("oauthClients.info", () => { + it("should require authentication", async () => { + const res = await server.post("/api/oauthClients.info"); + const body = await res.json(); + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); + + it("should return information about an OAuth client when authorized", async () => { + const team = await buildTeam(); + const user = await buildUser({ teamId: team.id }); + + const client = await OAuthClient.create({ + teamId: team.id, + createdById: user.id, + name: "Test Client", + redirectUris: ["https://example.com/callback"], + }); + + const res = await server.post("/api/oauthClients.info", { + body: { + token: user.getJwtToken(), + id: client.id, + }, + }); + + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.id).toBeDefined(); + expect(body.data.name).toEqual("Test Client"); + expect(body.data.published).toBeFalsy(); + expect(body.data.redirectUris).toEqual(["https://example.com/callback"]); + }); + + it("should return information about an OAuth client when published", async () => { + const team = await buildTeam(); + const admin = await buildAdmin({ teamId: team.id }); + const user = await buildUser(); + + const client = await OAuthClient.create({ + teamId: team.id, + createdById: admin.id, + name: "Test Client", + redirectUris: ["https://example.com/callback"], + published: true, + }); + + const res = await server.post("/api/oauthClients.info", { + body: { + token: user.getJwtToken(), + id: client.id, + }, + }); + + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.name).toEqual("Test Client"); + expect(body.data.published).toBeTruthy(); + expect(body.data.id).toBeUndefined(); + expect(body.data.redirectUris).toBeUndefined(); + }); + + it("should allow querying by clientId", async () => { + const team = await buildTeam(); + const admin = await buildAdmin({ teamId: team.id }); + const user = await buildUser(); + + const client = await OAuthClient.create({ + teamId: team.id, + createdById: admin.id, + name: "Test Client", + redirectUris: ["https://example.com/callback"], + published: true, + }); + + const res = await server.post("/api/oauthClients.info", { + body: { + token: user.getJwtToken(), + clientId: client.clientId, + }, + }); + + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.name).toEqual("Test Client"); + expect(body.data.published).toBeTruthy(); + expect(body.data.id).toBeUndefined(); + expect(body.data.redirectUris).toBeUndefined(); + }); + + it("should validate redirectUri parameter", async () => { + const team = await buildTeam(); + const admin = await buildAdmin({ teamId: team.id }); + const user = await buildUser(); + + const client = await OAuthClient.create({ + teamId: team.id, + createdById: admin.id, + name: "Test Client", + redirectUris: [ + "https://example.com/callback", + "https://another.com/callback", + ], + published: true, + }); + + // Test with valid redirectUri + const validRes = await server.post("/api/oauthClients.info", { + body: { + token: user.getJwtToken(), + clientId: client.clientId, + redirectUri: "https://example.com/callback", + }, + }); + + const validBody = await validRes.json(); + expect(validRes.status).toEqual(200); + expect(validBody.data.name).toEqual("Test Client"); + + // Test with invalid redirectUri + const invalidRes = await server.post("/api/oauthClients.info", { + body: { + token: user.getJwtToken(), + clientId: client.clientId, + redirectUri: "https://malicious.com/callback", + }, + }); + expect(invalidRes.status).toEqual(400); + }); +}); + +describe("oauthClients.create", () => { + it("should require authentication", async () => { + const res = await server.post("/api/oauthClients.create"); + const body = await res.json(); + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); + + it("should create a new OAuth client", async () => { + const team = await buildTeam(); + const admin = await buildAdmin({ teamId: team.id }); + + const res = await server.post("/api/oauthClients.create", { + body: { + token: admin.getJwtToken(), + name: "Test Client", + redirectUris: ["https://example.com/callback"], + }, + }); + + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.id).toBeDefined(); + expect(body.data.name).toEqual("Test Client"); + expect(body.data.redirectUris).toEqual(["https://example.com/callback"]); + }); +}); + +describe("oauthclients.update", () => { + it("should require authentication", async () => { + const res = await server.post("/api/oauthClients.update"); + const body = await res.json(); + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); + + it("should allow updating an OAuth client", async () => { + const team = await buildTeam(); + const admin = await buildAdmin({ teamId: team.id }); + + const client = await OAuthClient.create({ + teamId: team.id, + createdById: admin.id, + name: "Test Client", + redirectUris: ["https://example.com/callback"], + published: true, + }); + + const res = await server.post("/api/oauthClients.update", { + body: { + token: admin.getJwtToken(), + id: client.id, + published: false, + name: "Renamed", + }, + }); + + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.name).toEqual("Renamed"); + expect(body.data.published).toBeFalsy(); + }); +}); + +describe("oauthClients.rotate_secret", () => { + it("should require authentication", async () => { + const res = await server.post("/api/oauthClients.rotate_secret"); + const body = await res.json(); + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); + + it("should rotate the client secret", async () => { + const team = await buildTeam(); + const admin = await buildAdmin({ teamId: team.id }); + + const client = await OAuthClient.create({ + teamId: team.id, + createdById: admin.id, + name: "Test Client", + redirectUris: ["https://example.com/callback"], + }); + + const originalSecret = client.clientSecret; + + const res = await server.post("/api/oauthClients.rotate_secret", { + body: { + token: admin.getJwtToken(), + id: client.id, + }, + }); + + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.id).toBeDefined(); + expect(body.data.clientSecret).toBeDefined(); + expect(body.data.clientSecret).not.toEqual(originalSecret); + }); +}); + +describe("oauthClients.delete", () => { + it("should require authentication", async () => { + const res = await server.post("/api/oauthClients.delete"); + const body = await res.json(); + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); + + it("should delete an OAuth client", async () => { + const team = await buildTeam(); + const admin = await buildAdmin({ teamId: team.id }); + + const client = await OAuthClient.create({ + teamId: team.id, + createdById: admin.id, + name: "Test Client", + redirectUris: ["https://example.com/callback"], + }); + + const res = await server.post("/api/oauthClients.delete", { + body: { + token: admin.getJwtToken(), + id: client.id, + }, + }); + + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.success).toBe(true); + + const deletedClient = await OAuthClient.findByPk(client.id); + expect(deletedClient).toBeNull(); + }); +}); diff --git a/server/routes/api/oauthClients/oauthClients.ts b/server/routes/api/oauthClients/oauthClients.ts new file mode 100644 index 0000000000..09caa0665b --- /dev/null +++ b/server/routes/api/oauthClients/oauthClients.ts @@ -0,0 +1,181 @@ +import Router from "koa-router"; +import { UserRole } from "@shared/types"; +import { ValidationError } from "@server/errors"; +import auth from "@server/middlewares/authentication"; +import { rateLimiter } from "@server/middlewares/rateLimiter"; +import { transaction } from "@server/middlewares/transaction"; +import validate from "@server/middlewares/validate"; +import { OAuthClient } from "@server/models"; +import { authorize } from "@server/policies"; +import { + presentPolicies, + presentOAuthClient, + presentPublishedOAuthClient, +} from "@server/presenters"; +import { APIContext } from "@server/types"; +import { RateLimiterStrategy } from "@server/utils/RateLimiter"; +import pagination from "../middlewares/pagination"; +import * as T from "./schema"; + +const router = new Router(); + +router.post( + "oauthClients.list", + auth({ role: UserRole.Admin }), + pagination(), + validate(T.OAuthClientsListSchema), + async (ctx: APIContext) => { + const { user } = ctx.state.auth; + const where = { teamId: user.teamId }; + + const [oauthClients, total] = await Promise.all([ + OAuthClient.findAll({ + where, + order: [["createdAt", "DESC"]], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }), + OAuthClient.count({ where }), + ]); + + ctx.body = { + pagination: { ...ctx.state.pagination, total }, + data: oauthClients.map(presentOAuthClient), + policies: presentPolicies(user, oauthClients), + }; + } +); +router.post( + "oauthClients.info", + auth(), + validate(T.OAuthClientsInfoSchema), + async (ctx: APIContext) => { + const { id, clientId, redirectUri } = ctx.input.body; + const { user } = ctx.state.auth; + + const oauthClient = await OAuthClient.findOne({ + where: clientId ? { clientId } : { id }, + rejectOnEmpty: true, + }); + authorize(user, "read", oauthClient); + + if (redirectUri && !oauthClient.redirectUris.includes(redirectUri)) { + throw ValidationError("redirect_uri is invalid"); + } + + const isInternalApp = oauthClient.teamId === user.teamId; + + ctx.body = { + data: isInternalApp + ? presentOAuthClient(oauthClient) + : presentPublishedOAuthClient(oauthClient), + policies: isInternalApp ? presentPolicies(user, [oauthClient]) : [], + }; + } +); + +router.post( + "oauthClients.create", + rateLimiter(RateLimiterStrategy.FivePerHour), + auth({ role: UserRole.Admin }), + validate(T.OAuthClientsCreateSchema), + transaction(), + async (ctx: APIContext) => { + const input = ctx.input.body; + const { user } = ctx.state.auth; + + authorize(user, "createOAuthClient", user.team); + + const oauthClient = await OAuthClient.createWithCtx(ctx, { + ...input, + teamId: user.teamId, + createdById: user.id, + }); + + ctx.body = { + data: presentOAuthClient(oauthClient), + policies: presentPolicies(user, [oauthClient]), + }; + } +); + +router.post( + "oauthClients.update", + auth({ role: UserRole.Admin }), + validate(T.OAuthClientsUpdateSchema), + transaction(), + async (ctx: APIContext) => { + const { id, ...input } = ctx.input.body; + const { user } = ctx.state.auth; + const { transaction } = ctx.state; + + const oauthClient = await OAuthClient.findByPk(id, { + transaction, + lock: transaction.LOCK.UPDATE, + rejectOnEmpty: true, + }); + authorize(user, "update", oauthClient); + + await oauthClient.updateWithCtx(ctx, input); + + ctx.body = { + data: presentOAuthClient(oauthClient), + policies: presentPolicies(user, [oauthClient]), + }; + } +); + +router.post( + "oauthClients.rotate_secret", + rateLimiter(RateLimiterStrategy.FivePerHour), + auth({ role: UserRole.Admin }), + validate(T.OAuthClientsRotateSecretSchema), + transaction(), + async (ctx: APIContext) => { + const { id } = ctx.input.body; + const { user } = ctx.state.auth; + const { transaction } = ctx.state; + + const oauthClient = await OAuthClient.findByPk(id, { + transaction, + lock: transaction.LOCK.UPDATE, + rejectOnEmpty: true, + }); + authorize(user, "update", oauthClient); + + oauthClient.rotateClientSecret(); + await oauthClient.saveWithCtx(ctx); + + ctx.body = { + data: presentOAuthClient(oauthClient), + policies: presentPolicies(user, [oauthClient]), + }; + } +); + +router.post( + "oauthClients.delete", + auth({ role: UserRole.Admin }), + validate(T.OAuthClientsDeleteSchema), + transaction(), + async (ctx: APIContext) => { + const { id } = ctx.input.body as { id: string }; + const { user } = ctx.state.auth; + const { transaction } = ctx.state; + + const oauthClient = await OAuthClient.findByPk(id, { + transaction, + lock: transaction.LOCK.UPDATE, + rejectOnEmpty: true, + }); + authorize(user, "delete", oauthClient); + + await oauthClient.destroyWithCtx(ctx); + + ctx.body = { + success: true, + }; + } +); + +export default router; diff --git a/server/routes/api/oauthClients/schema.ts b/server/routes/api/oauthClients/schema.ts new file mode 100644 index 0000000000..5c834e4e74 --- /dev/null +++ b/server/routes/api/oauthClients/schema.ts @@ -0,0 +1,128 @@ +import { z } from "zod"; +import { OAuthClientValidation } from "@shared/validations"; +import { BaseSchema } from "@server/routes/api/schema"; + +export const OAuthClientsInfoSchema = BaseSchema.extend({ + body: z + .object({ + /** OAuth client id */ + id: z.string().uuid().optional(), + + /** OAuth clientId */ + clientId: z.string().optional(), + + redirectUri: z.string().optional(), + }) + .refine((data) => data.id || data.clientId, { + message: "Either id or clientId is required", + }), +}); + +export type OAuthClientsInfoReq = z.infer; + +export const OAuthClientsCreateSchema = BaseSchema.extend({ + body: z.object({ + /** OAuth client name */ + name: z.string(), + + /** OAuth client description */ + description: z.string().nullish(), + + /** OAuth client developer name */ + developerName: z.string().nullish(), + + /** OAuth client developer url */ + developerUrl: z.string().nullish(), + + /** OAuth client avatar url */ + avatarUrl: z.string().nullish(), + + /** OAuth client redirect uri */ + redirectUris: z + .array(z.string().url()) + .min(1, { message: "At least one redirect uri is required" }) + .max(10, { message: "A maximum of 10 redirect uris are allowed" }) + .refine( + (uris) => + uris.every( + (uri) => uri.length <= OAuthClientValidation.maxRedirectUriLength + ), + { + message: `Redirect uri must be less than ${OAuthClientValidation.maxRedirectUriLength} characters`, + } + ), + + /** OAuth client published */ + published: z.boolean().default(false), + }), +}); + +export type OAuthClientsCreateReq = z.infer; + +export const OAuthClientsUpdateSchema = BaseSchema.extend({ + body: z.object({ + id: z.string().uuid(), + + /** OAuth client name */ + name: z.string().optional(), + + /** OAuth client description */ + description: z.string().nullish(), + + /** OAuth client developer name */ + developerName: z.string().nullish(), + + /** OAuth client developer url */ + developerUrl: z.string().nullish(), + + /** OAuth client avatar url */ + avatarUrl: z.string().nullish(), + + /** OAuth client redirect uri */ + redirectUris: z + .array(z.string().url()) + .min(1, { message: "At least one redirect uri is required" }) + .max(10, { message: "A maximum of 10 redirect uris are allowed" }) + .refine( + (uris) => + uris.every( + (uri) => uri.length <= OAuthClientValidation.maxRedirectUriLength + ), + { + message: `Redirect uri must be less than ${OAuthClientValidation.maxRedirectUriLength} characters`, + } + ) + .optional(), + + /** OAuth client published */ + published: z.boolean().optional(), + }), +}); + +export type OAuthClientsUpdateReq = z.infer; + +export const OAuthClientsDeleteSchema = BaseSchema.extend({ + body: z.object({ + /** OAuth client id */ + id: z.string().uuid(), + }), +}); + +export type OAuthClientsDeleteReq = z.infer; + +export const OAuthClientsRotateSecretSchema = BaseSchema.extend({ + body: z.object({ + /** OAuth client id */ + id: z.string().uuid(), + }), +}); + +export type OAuthClientsRotateSecretReq = z.infer< + typeof OAuthClientsRotateSecretSchema +>; + +export const OAuthClientsListSchema = BaseSchema.extend({ + body: z.object({}), +}); + +export type OAuthClientsListReq = z.infer; diff --git a/server/routes/oauth/index.test.ts b/server/routes/oauth/index.test.ts new file mode 100644 index 0000000000..a997226c14 --- /dev/null +++ b/server/routes/oauth/index.test.ts @@ -0,0 +1,47 @@ +import { Scope } from "@shared/types"; +import { OAuthAuthentication } from "@server/models"; +import { buildOAuthAuthentication, buildUser } from "@server/test/factories"; +import { getTestServer } from "@server/test/support"; + +const server = getTestServer(); + +describe("#oauth.revoke", () => { + it("should revoke access token", async () => { + const user = await buildUser(); + const auth = await buildOAuthAuthentication({ user, scope: [Scope.Read] }); + + const res = await server.post("/oauth/revoke", { + body: { + token: auth.accessToken, + }, + }); + expect(res.status).toEqual(200); + + const found = await OAuthAuthentication.findByPk(auth.id); + expect(found).toBeNull(); + }); + + it("should revoke refresh token", async () => { + const user = await buildUser(); + const auth = await buildOAuthAuthentication({ user, scope: [Scope.Read] }); + + const res = await server.post("/oauth/revoke", { + body: { + token: auth.refreshToken, + }, + }); + expect(res.status).toEqual(200); + + const found = await OAuthAuthentication.findByPk(auth.id); + expect(found).toBeNull(); + }); + + it("should not error with invalid token", async () => { + const res = await server.post("/oauth/revoke", { + body: { + token: "invalid-token", + }, + }); + expect(res.status).toEqual(200); + }); +}); diff --git a/server/routes/oauth/index.ts b/server/routes/oauth/index.ts new file mode 100644 index 0000000000..ed0b5f07e0 --- /dev/null +++ b/server/routes/oauth/index.ts @@ -0,0 +1,132 @@ +import OAuth2Server from "@node-oauth/oauth2-server"; +import Koa from "koa"; +import bodyParser from "koa-body"; +import Router from "koa-router"; +import { ValidationError } from "@server/errors"; +import auth from "@server/middlewares/authentication"; +import { rateLimiter } from "@server/middlewares/rateLimiter"; +import requestTracer from "@server/middlewares/requestTracer"; +import { transaction } from "@server/middlewares/transaction"; +import validate from "@server/middlewares/validate"; +import { OAuthAuthorizationCode, OAuthClient } from "@server/models"; +import OAuthAuthentication from "@server/models/oauth/OAuthAuthentication"; +import { authorize } from "@server/policies"; +import { APIContext } from "@server/types"; +import { RateLimiterStrategy } from "@server/utils/RateLimiter"; +import { OAuthInterface } from "@server/utils/oauth/OAuthInterface"; +import oauthErrorHandler from "./middlewares/oauthErrorHandler"; +import * as T from "./schema"; + +const app = new Koa(); +const router = new Router(); +const oauth = new OAuth2Server({ + model: OAuthInterface, +}); + +router.post( + "/authorize", + rateLimiter(RateLimiterStrategy.OneHundredPerHour), + auth(), + async (ctx) => { + const { user } = ctx.state.auth; + const clientId = ctx.request.body.client_id; + if (!clientId) { + throw ValidationError("Missing client_id"); + } + + const client = await OAuthClient.findByClientId(clientId); + authorize(user, "read", client); + + // Note: These objects are mutated by the OAuth2Server library + const request = new OAuth2Server.Request(ctx.request); + const response = new OAuth2Server.Response(ctx.response); + + const authorizationCode = await oauth.authorize(request, response, { + // Require state to prevent CSRF attacks + allowEmptyState: false, + authorizationCodeLifetime: + OAuthAuthorizationCode.authorizationCodeLifetime, + authenticateHandler: { + // Fetch the current user from the request, so the library knows + // which user is authorizing the client. + handle: async () => user, + }, + }); + + // In the case of a redirect, the response will be always be a redirect + // to the redirect_uri with the authorization code as a query parameter. + if (response.status === 302 && response.headers?.location) { + const location = response.headers.location; + delete response.headers.location; + ctx.set(response.headers); + ctx.redirect(location); + return; + } + + ctx.body = { code: authorizationCode }; + } +); + +router.post( + "/token", + rateLimiter(RateLimiterStrategy.OneHundredPerHour), + async (ctx) => { + // Note: These objects are mutated by the OAuth2Server library + const request = new OAuth2Server.Request(ctx.request); + const response = new OAuth2Server.Response(ctx.response); + const token = await oauth.token(request, response, { + accessTokenLifetime: OAuthAuthentication.accessTokenLifetime, + refreshTokenLifetime: OAuthAuthentication.refreshTokenLifetime, + }); + + if (response.headers) { + ctx.set(response.headers); + } + + ctx.body = { + access_token: token.accessToken, + refresh_token: token.refreshToken, + // OAuth2 spec says that the expires_in should be in seconds. + expires_in: token.accessTokenExpiresAt + ? Math.round((token.accessTokenExpiresAt.getTime() - Date.now()) / 1000) + : undefined, + token_type: "Bearer", + // OAuth2 spec says that the scope should be a space-separated list. + scope: token.scope?.join(" "), + }; + } +); + +router.post( + "/revoke", + rateLimiter(RateLimiterStrategy.OneHundredPerHour), + validate(T.TokenRevokeSchema), + transaction(), + async (ctx: APIContext) => { + const { token } = ctx.input.body; + + if (OAuthAuthentication.match(token)) { + const accessToken = await OAuthAuthentication.findByAccessToken(token); + await accessToken?.destroyWithCtx(ctx); + } + + if (OAuthAuthentication.matchRefreshToken(token)) { + const refreshToken = await OAuthAuthentication.findByRefreshToken(token); + await refreshToken?.destroyWithCtx(ctx); + } + + // https://datatracker.ietf.org/doc/html/rfc7009#section-2.2 + // Note: invalid tokens do not cause an error response since the client + // cannot handle such an error in a reasonable way + ctx.body = { + success: true, + }; + } +); + +app.use(requestTracer()); +app.use(oauthErrorHandler()); +app.use(bodyParser()); +app.use(router.routes()); + +export default app; diff --git a/server/routes/oauth/middlewares/oauthErrorHandler.ts b/server/routes/oauth/middlewares/oauthErrorHandler.ts new file mode 100644 index 0000000000..820afe08a2 --- /dev/null +++ b/server/routes/oauth/middlewares/oauthErrorHandler.ts @@ -0,0 +1,41 @@ +import { Context, Next } from "koa"; +import { + ValidationError as SequelizeValidationError, + EmptyResultError as SequelizeEmptyResultError, +} from "sequelize"; + +/** + * To adhere to the OAuth 2.0 specification, errors from the /token and /authorize routes + * follow the snake_case convention with `error` and `error_description` keys, rather than + * our standard error format. + */ +export default function oauthErrorHandler() { + return async function oauthErrorHandlerMiddleware(ctx: Context, next: Next) { + try { + await next(); + } catch (err) { + if (err instanceof SequelizeEmptyResultError) { + ctx.status = 404; + ctx.body = { + error: "invalid_request", + error_description: "Resource not found", + }; + return; + } + if (err instanceof SequelizeValidationError) { + ctx.status = 400; + ctx.body = { + error: "invalid_request", + error_description: err.errors[0].message, + }; + return; + } + + ctx.status = err.code || 500; + ctx.body = { + error: err.name, + error_description: err.message, + }; + } + }; +} diff --git a/server/routes/oauth/schema.ts b/server/routes/oauth/schema.ts new file mode 100644 index 0000000000..3e9d10af5a --- /dev/null +++ b/server/routes/oauth/schema.ts @@ -0,0 +1,11 @@ +import z from "zod"; +import { BaseSchema } from "../api/schema"; + +export const TokenRevokeSchema = BaseSchema.extend({ + body: z.object({ + token: z.string(), + token_type_hint: z.string().optional(), + }), +}); + +export type TokenRevokeReq = z.infer; diff --git a/server/scripts/20240930113921-hash-api-keys.ts b/server/scripts/20240930113921-hash-api-keys.ts index 41e5caabc7..c0dccc2434 100644 --- a/server/scripts/20240930113921-hash-api-keys.ts +++ b/server/scripts/20240930113921-hash-api-keys.ts @@ -2,6 +2,7 @@ import "./bootstrap"; import { Transaction } from "sequelize"; import { ApiKey } from "@server/models"; import { sequelize } from "@server/storage/database"; +import { hash } from "@server/utils/crypto"; let page = parseInt(process.argv[2], 10); page = Number.isNaN(page) ? 0 : page; @@ -25,7 +26,7 @@ export default async function main(exit = false, limit = 100) { if (!apiKey.hash) { console.log(`Migrating ${apiKey.id}…`); apiKey.value = apiKey.secret; - apiKey.hash = ApiKey.hash(apiKey.secret); + apiKey.hash = hash(apiKey.secret); // @ts-expect-error secret is deprecated apiKey.secret = null; await apiKey.save({ transaction }); diff --git a/server/services/web.ts b/server/services/web.ts index 18c6d41923..f75eeb5700 100644 --- a/server/services/web.ts +++ b/server/services/web.ts @@ -22,6 +22,7 @@ import { initI18n } from "@server/utils/i18n"; import routes from "../routes"; import api from "../routes/api"; import auth from "../routes/auth"; +import oauth from "../routes/oauth"; // Construct scripts CSP based on services in use by this installation const defaultSrc = ["'self'"]; @@ -74,6 +75,8 @@ export default function init(app: Koa = new Koa(), server?: Server) { } app.use(compress()); + + app.use(mount("/oauth", oauth)); app.use(mount("/auth", auth)); app.use(mount("/api", api)); diff --git a/server/test/factories.ts b/server/test/factories.ts index 921c75a9fc..f215227bec 100644 --- a/server/test/factories.ts +++ b/server/test/factories.ts @@ -43,8 +43,14 @@ import { Pin, Comment, Import, + OAuthAuthorizationCode, + OAuthClient, + AuthenticationProvider, + OAuthAuthentication, } from "@server/models"; import AttachmentHelper from "@server/models/helpers/AttachmentHelper"; +import { hash } from "@server/utils/crypto"; +import { OAuthInterface } from "@server/utils/oauth/OAuthInterface"; export async function buildApiKey(overrides: Partial = {}) { if (!overrides.userId) { @@ -137,7 +143,11 @@ export async function buildSubscription(overrides: Partial = {}) { }); } -export function buildTeam(overrides: Record = {}) { +export function buildTeam( + overrides: Omit, "authenticationProviders"> & { + authenticationProviders?: Partial[]; + } = {} +) { return Team.create( { name: faker.company.name(), @@ -691,6 +701,106 @@ export async function buildPin(overrides: Partial = {}): Promise { return Pin.create(overrides); } +export async function buildOAuthClient(overrides: Partial = {}) { + if (!overrides.teamId) { + const team = await buildTeam(); + overrides.teamId = team.id; + } + + if (!overrides.createdById) { + const user = await buildUser({ + teamId: overrides.teamId, + }); + overrides.createdById = user.id; + } + + return OAuthClient.create({ + name: faker.company.name(), + description: faker.lorem.paragraph(), + redirectUris: ["https://example.com/oauth/callback"], + published: true, + ...overrides, + }); +} + +export async function buildOAuthAuthorizationCode( + overrides: Partial = {} +) { + if (!overrides.userId) { + const user = await buildUser(); + overrides.userId = user.id; + } + + if (!overrides.expiresAt) { + overrides.expiresAt = new Date(); + } + + const code = randomstring.generate(32); + + let client; + if (overrides.oauthClientId) { + client = await OAuthClient.findByPk(overrides.oauthClientId, { + rejectOnEmpty: true, + }); + } else { + client = await buildOAuthClient(); + overrides.oauthClientId = client.id; + } + + return OAuthAuthorizationCode.create({ + authorizationCodeHash: hash(code), + scope: ["read"], + redirectUri: client.redirectUris[0], + ...overrides, + }); +} + +export async function buildOAuthAuthentication({ + oauthClientId, + user, + scope, +}: { + oauthClientId?: string; + user: User; + scope: string[]; +}) { + const oauthClient = oauthClientId + ? await OAuthClient.findByPk(oauthClientId, { rejectOnEmpty: true }) + : await buildOAuthClient({ + teamId: user.teamId, + }); + const oauthInterfaceClient = { + id: oauthClient.clientId, + grants: ["authorization_code"], + redirectUris: ["https://example.com/oauth/callback"], + }; + const oauthInterfaceUser = { + id: user.id, + }; + + const accessToken = await OAuthInterface.generateAccessToken( + oauthInterfaceClient, + oauthInterfaceUser, + scope + ); + const refreshToken = await OAuthInterface.generateRefreshToken( + oauthInterfaceClient, + oauthInterfaceUser, + scope + ); + return OAuthAuthentication.create({ + userId: user.id, + oauthClientId: oauthClient.id, + accessToken, + accessTokenHash: hash(accessToken), + accessTokenExpiresAt: new Date(Date.now() + 1000 * 60 * 60), + refreshToken, + refreshTokenHash: hash(refreshToken), + refreshTokenExpiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), + scope, + }); +} + export function buildProseMirrorDoc(content: DeepPartial[]) { return Node.fromJSON(schema, { type: "doc", diff --git a/server/types.ts b/server/types.ts index ca0734ba53..f63405ca9d 100644 --- a/server/types.ts +++ b/server/types.ts @@ -37,11 +37,13 @@ import type { Share, GroupMembership, Import, + OAuthClient, } from "./models"; export enum AuthenticationType { API = "api", APP = "app", + OAUTH = "oauth", } export type AuthenticationResult = AccountProvisionerResult & { @@ -50,7 +52,7 @@ export type AuthenticationResult = AccountProvisionerResult & { export type Authentication = { user: User; - token?: string; + token: string; type?: AuthenticationType; }; @@ -469,6 +471,11 @@ export type NotificationEvent = BaseEvent & { membershipId?: string; }; +export type OAuthClientEvent = BaseEvent & { + name: "oauthClients.create" | "oauthClients.update" | "oauthClients.delete"; + modelId: string; +}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any export type ImportEvent = BaseEvent> & { name: @@ -504,6 +511,7 @@ export type Event = | ViewEvent | WebhookSubscriptionEvent | NotificationEvent + | OAuthClientEvent | EmptyTrashEvent | ImportEvent; diff --git a/server/utils/crypto.ts b/server/utils/crypto.ts index 9bfebbe445..59187f4da5 100644 --- a/server/utils/crypto.ts +++ b/server/utils/crypto.ts @@ -17,3 +17,13 @@ export function safeEqual(a?: string, b?: string) { return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)); } + +/** + * Hash a string using SHA-256. + * + * @param input The input string to hash + * @returns The hashed input + */ +export function hash(input: string) { + return crypto.createHash("sha256").update(input).digest("hex"); +} diff --git a/server/utils/oauth/OAuthInterface.test.ts b/server/utils/oauth/OAuthInterface.test.ts new file mode 100644 index 0000000000..053e673ccc --- /dev/null +++ b/server/utils/oauth/OAuthInterface.test.ts @@ -0,0 +1,104 @@ +import { v4 } from "uuid"; +import { Scope } from "@shared/types"; +import { OAuthInterface } from "./OAuthInterface"; + +describe("OAuthInterface", () => { + const user = { + id: v4(), + }; + const client = { + id: v4(), + grants: ["authorization_code", "refresh_token"], + redirectUris: ["https://example.com/callback"], + }; + + describe("#validateRedirectUri", () => { + it("should return true for valid redirect URI", async () => { + const redirectUri = "https://example.com/callback"; + const result = await OAuthInterface.validateRedirectUri( + redirectUri, + client + ); + expect(result).toBe(true); + }); + it("should return false for invalid redirect URI", async () => { + const redirectUri = "invalid_uri"; + const result = await OAuthInterface.validateRedirectUri( + redirectUri, + client + ); + expect(result).toBe(false); + }); + + it("should return false for URI with fragment", async () => { + const redirectUri = "https://example.com/callback#fragment"; + const result = await OAuthInterface.validateRedirectUri( + redirectUri, + client + ); + expect(result).toBe(false); + }); + }); + + describe("#validateScope", () => { + it("should return empty array for empty scope", async () => { + const result = await OAuthInterface.validateScope(user, client, []); + expect(result).toEqual([]); + }); + + it("should return empty array for empty scope", async () => { + const result = await OAuthInterface.validateScope( + user, + client, + undefined + ); + expect(result).toEqual([]); + }); + + it("should allow valid global scopes", async () => { + const scope = [Scope.Read, Scope.Write]; + const result = await OAuthInterface.validateScope(user, client, scope); + expect(result).toEqual(scope); + }); + + it("should allow route scopes", async () => { + const scope = [ + "/api/documents.info", + "/api/documents.create", + "/api/documents.update", + "/api/documents.delete", + ]; + const result = await OAuthInterface.validateScope(user, client, scope); + expect(result).toEqual(scope); + }); + + it("should allow scopes with colon and valid prefix", async () => { + const scope = [ + "documents:read", + "documents:write", + "collections:read", + "collections:write", + ]; + const result = await OAuthInterface.validateScope(user, client, scope); + expect(result).toEqual(scope); + }); + + it("should reject invalid route scopes", async () => { + const scope = ["invalid.scope.periods"]; + const result = await OAuthInterface.validateScope(user, client, scope); + expect(result).toBe(false); + }); + + it("should reject invalid access scopes", async () => { + const scope = ["documents:invalid"]; + const result = await OAuthInterface.validateScope(user, client, scope); + expect(result).toBe(false); + }); + + it("should reject malformed access scopes", async () => { + const scope = ["documents::read"]; + const result = await OAuthInterface.validateScope(user, client, scope); + expect(result).toBe(false); + }); + }); +}); diff --git a/server/utils/oauth/OAuthInterface.ts b/server/utils/oauth/OAuthInterface.ts new file mode 100644 index 0000000000..f4aa7f12e6 --- /dev/null +++ b/server/utils/oauth/OAuthInterface.ts @@ -0,0 +1,294 @@ +import crypto from "crypto"; +import { + RefreshTokenModel, + AuthorizationCodeModel, +} from "@node-oauth/oauth2-server"; +import { Required } from "utility-types"; +import { Scope } from "@shared/types"; +import { isUrl } from "@shared/utils/urls"; +import { + OAuthClient, + OAuthAuthentication, + OAuthAuthorizationCode, +} from "@server/models"; +import { hash, safeEqual } from "@server/utils/crypto"; + +/** + * Additional configuration for the OAuthInterface, not part of the + * OAuth2Server library. + */ +interface Config { + grants: string[]; +} + +/** + * This interface is used by the OAuth2Server library to handle OAuth2 + * authentication and authorization flows. See the library's documentation: + * + * https://node-oauthoauth2-server.readthedocs.io/en/master/model/overview.html + */ +export const OAuthInterface: RefreshTokenModel & + Required< + AuthorizationCodeModel, + | "validateScope" + | "validateRedirectUri" + | "generateAccessToken" + | "generateRefreshToken" + | "generateAuthorizationCode" + > & + Config = { + /** Supported grant types */ + grants: ["authorization_code", "refresh_token"], + + async generateAccessToken() { + return `${OAuthAuthentication.accessTokenPrefix}${crypto + .randomBytes(32) + .toString("hex")}`; + }, + + async generateRefreshToken() { + return `${OAuthAuthentication.refreshTokenPrefix}${crypto + .randomBytes(32) + .toString("hex")}`; + }, + + async generateAuthorizationCode() { + return `${OAuthAuthorizationCode.authorizationCodePrefix}${crypto + .randomBytes(32) + .toString("hex")}`; + }, + + async getAccessToken(accessToken: string) { + const authentication = await OAuthAuthentication.findByAccessToken( + accessToken + ); + if (!authentication) { + return false; + } + + return { + accessToken, + accessTokenExpiresAt: authentication.accessTokenExpiresAt, + scope: authentication.scope, + client: { + id: authentication.oauthClientId, + grants: this.grants, + }, + user: authentication.user, + }; + }, + + async getRefreshToken(refreshToken: string) { + const authentication = await OAuthAuthentication.findByRefreshToken( + refreshToken + ); + if (!authentication) { + return false; + } + + return { + refreshToken, + refreshTokenExpiresAt: authentication.refreshTokenExpiresAt, + scope: authentication.scope, + client: { + id: authentication.oauthClientId, + grants: this.grants, + }, + user: authentication.user, + }; + }, + + async getAuthorizationCode(authorizationCode) { + const code = await OAuthAuthorizationCode.findByCode(authorizationCode); + if (!code) { + return false; + } + + const oauthClient = await OAuthClient.findByPk(code.oauthClientId); + if (!oauthClient) { + return false; + } + + return { + authorizationCode, + expiresAt: code.expiresAt, + scope: code.scope, + redirectUri: code.redirectUri, + codeChallenge: code.codeChallenge, + codeChallengeMethod: code.codeChallengeMethod, + client: { + id: oauthClient.clientId, + grants: this.grants, + }, + user: code.user, + }; + }, + + async getClient(clientId: string, clientSecret?: string) { + const client = await OAuthClient.findByClientId(clientId); + if (!client) { + return false; + } + + if (clientSecret && !safeEqual(client.clientSecret, clientSecret)) { + return false; + } + + return { + id: client.clientId, + redirectUris: client.redirectUris, + databaseId: client.id, + grants: this.grants, + }; + }, + + async saveToken(token, client, user) { + const { + accessToken, + refreshToken, + accessTokenExpiresAt, + refreshTokenExpiresAt, + } = token; + const accessTokenHash = hash(accessToken); + const refreshTokenHash = refreshToken ? hash(refreshToken) : undefined; + + await OAuthAuthentication.create({ + accessTokenHash, + refreshTokenHash, + accessTokenExpiresAt, + refreshTokenExpiresAt, + scope: token.scope, + oauthClientId: client.databaseId, + userId: user.id, + }); + + return { + accessToken, + accessTokenExpiresAt, + refreshToken, + refreshTokenExpiresAt, + scope: token.scope, + client: { + id: client.id, + grants: this.grants, + }, + user, + }; + }, + + async saveAuthorizationCode(code, client, user) { + const { + authorizationCode, + expiresAt, + redirectUri, + scope, + codeChallenge, + codeChallengeMethod, + } = code; + + const authCode = await OAuthAuthorizationCode.create({ + authorizationCodeHash: hash(authorizationCode), + expiresAt, + scope, + redirectUri, + codeChallenge, + codeChallengeMethod, + oauthClientId: client.databaseId, + userId: user.id, + }); + + return { + authorizationCode, + expiresAt, + scope, + redirectUri, + client: { + id: client.id, + grants: this.grants, + }, + user: authCode.user, + }; + }, + + async revokeToken(token) { + const auth = await OAuthAuthentication.findByRefreshToken( + token.refreshToken + ); + if (auth) { + await auth.destroy(); + return true; + } + return false; + }, + + async revokeAuthorizationCode(code) { + const authCode = await OAuthAuthorizationCode.findByCode( + code.authorizationCode + ); + if (authCode) { + await authCode.destroy(); + return true; + } + return false; + }, + + /** + * Ensure the redirect URI is not plain HTTP. Custom protocols are allowed. + * + * @param uri The redirect URI to validate. + * @returns True if the URI is valid, false otherwise. + */ + async validateRedirectUri(uri, client) { + if (uri.includes("#") || uri.includes("*")) { + return false; + } + if (!client.redirectUris?.includes(uri)) { + return false; + } + if (!isUrl(uri, { requireHttps: true })) { + return false; + } + + return true; + }, + + /** + * Invoked to check if the requested scope is valid for a particular + * client/user combination. + * + * @param scope The requested scopes. + * @returns The scopes if valid, false otherwise. + */ + async validateScope(user, client, scope) { + if (!scope?.length) { + return []; + } + + const scopes = Array.isArray(scope) ? scope : [scope]; + const validAccessScopes = Object.values(Scope); + + return scopes.some((s: string) => { + if (validAccessScopes.includes(s as Scope)) { + return true; + } + + const periodCount = (s.match(/\./g) || []).length; + const colonCount = (s.match(/:/g) || []).length; + + if (periodCount === 1 && colonCount === 0) { + return true; + } + + if ( + colonCount === 1 && + validAccessScopes.includes(s.split(":")[1] as Scope) + ) { + return true; + } + + return false; + }) + ? scopes + : false; + }, +}; diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 9e7704fc14..958f1417f5 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -120,6 +120,8 @@ "Log out": "Log out", "Mark notifications as read": "Mark notifications as read", "Archive all notifications": "Archive all notifications", + "New App": "New App", + "New Application": "New Application", "Restore revision": "Restore revision", "Link copied": "Link copied", "Dark": "Dark", @@ -308,6 +310,13 @@ "Unknown": "Unknown", "Mark all as read": "Mark all as read", "You're all caught up": "You're all caught up", + "Icon": "Icon", + "My App": "My App", + "Tagline": "Tagline", + "A short description": "A short description", + "Callback URLs": "Callback URLs", + "Published": "Published", + "Allow this app to be installed by other workspaces": "Allow this app to be installed by other workspaces", "{{ username }} reacted with {{ emoji }}": "{{ username }} reacted with {{ emoji }}", "{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}": "{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}", "{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}": "{{ firstUsername }} and {{ count }} other reacted with {{ emoji }}", @@ -395,7 +404,6 @@ "Star document": "Star document", "Template created, go ahead and customize it": "Template created, go ahead and customize it", "Creating a template from {{titleWithDefault}} is a non-destructive action – we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.": "Creating a template from {{titleWithDefault}} is a non-destructive action – we'll make a copy of the document and turn it into a template that can be used as a starting point for new documents.", - "Published": "Published", "Enable other members to use the template immediately": "Enable other members to use the template immediately", "Location": "Location", "Admins can manage the workspace and access billing.": "Admins can manage the workspace and access billing.", @@ -512,12 +520,14 @@ "Unsubscribed from document": "Unsubscribed from document", "Unsubscribed from collection": "Unsubscribed from collection", "Account": "Account", - "API Keys": "API Keys", + "API & Apps": "API & Apps", "Details": "Details", "Security": "Security", "Features": "Features", "Members": "Members", "Groups": "Groups", + "API Keys": "API Keys", + "Applications": "Applications", "Shared Links": "Shared Links", "Import": "Import", "Integrations": "Integrations", @@ -551,6 +561,10 @@ "New child document": "New child document", "Save in workspace": "Save in workspace", "Notification settings": "Notification settings", + "Revoke {{ appName }}": "Revoke {{ appName }}", + "Revoking": "Revoking", + "Are you sure you want to revoke access?": "Are you sure you want to revoke access?", + "Delete app": "Delete app", "Revision options": "Revision options", "Share link revoked": "Share link revoked", "Share link copied": "Share link copied", @@ -805,6 +819,8 @@ "Authentication failed – this login method was disabled by a workspace admin.": "Authentication failed – this login method was disabled by a workspace admin.", "The workspace you are trying to join requires an invite before you can create an account.<1>1>Please request an invite from your workspace admin and try again.": "The workspace you are trying to join requires an invite before you can create an account.<1>1>Please request an invite from your workspace admin and try again.", "Sorry, an unknown error occurred.": "Sorry, an unknown error occurred.", + "Choose a workspace": "Choose a workspace", + "Choose an {{ appName }} workspace or login to continue connecting this app": "Choose an {{ appName }} workspace or login to continue connecting this app", "Login": "Login", "Error": "Error", "Failed to load configuration.": "Failed to load configuration.", @@ -822,6 +838,34 @@ "You signed in with {{ authProviderName }} last time.": "You signed in with {{ authProviderName }} last time.", "Or": "Or", "Already have an account? Go to <1>login1>.": "Already have an account? Go to <1>login1>.", + "An error occurred": "An error occurred", + "The OAuth client could not be found, please check the provided client ID": "The OAuth client could not be found, please check the provided client ID", + "The OAuth client could not be loaded, please check the redirect URI is valid": "The OAuth client could not be loaded, please check the redirect URI is valid", + "Required OAuth parameters are missing": "Required OAuth parameters are missing", + "Authorize": "Authorize", + "{{ appName }} wants to access {{ teamName }}": "{{ appName }} wants to access {{ teamName }}", + "By {{ developerName }}": "By {{ developerName }}", + "{{ appName }} will be able to access your account and perform the following actions": "{{ appName }} will be able to access your account and perform the following actions", + "read": "read", + "write": "write", + "read and write": "read and write", + "API keys": "API keys", + "attachments": "attachments", + "collections": "collections", + "comments": "comments", + "documents": "documents", + "events": "events", + "groups": "groups", + "integrations": "integrations", + "notifications": "notifications", + "reactions": "reactions", + "pins": "pins", + "shares": "shares", + "users": "users", + "teams": "teams", + "workspace": "workspace", + "Read all data": "Read all data", + "Write all data": "Write all data", "Any collection": "Any collection", "All time": "All time", "Past day": "Past day", @@ -837,15 +881,38 @@ "Something went wrong": "Something went wrong", "Please try again or contact support if the problem persists": "Please try again or contact support if the problem persists", "No documents found for your search filters.": "No documents found for your search filters.", + "Create personal API keys to authenticate with the API and programatically control\n your workspace's data. For more details see the developer documentation.": "Create personal API keys to authenticate with the API and programatically control\n your workspace's data. For more details see the developer documentation.", + "{t(\"API keys have been disabled by an admin for your account\")}": "{t(\"API keys have been disabled by an admin for your account\")}", + "API keys have been disabled by an admin for your account": "API keys have been disabled by an admin for your account", + "Application access": "Application access", + "Manage which third-party and internal applications have been granted access to your {{ appName }} account.": "Manage which third-party and internal applications have been granted access to your {{ appName }} account.", "API": "API", "API keys can be used to authenticate with the API and programatically control\n your workspace's data. For more details see the developer documentation.": "API keys can be used to authenticate with the API and programatically control\n your workspace's data. For more details see the developer documentation.", + "Application published": "Application published", + "Application updated": "Application updated", + "Client secret rotated": "Client secret rotated", + "Rotate secret": "Rotate secret", + "Rotating the client secret will invalidate the current secret. Make sure to update any applications using these credentials.": "Rotating the client secret will invalidate the current secret. Make sure to update any applications using these credentials.", + "Displayed to users when authorizing": "Displayed to users when authorizing", + "Developer information shown to users when authorizing": "Developer information shown to users when authorizing", + "Developer name": "Developer name", + "Developer URL": "Developer URL", + "Allow users from other workspaces to authorize this app": "Allow users from other workspaces to authorize this app", + "Credentials": "Credentials", + "OAuth client ID": "OAuth client ID", + "The public identifier for this app": "The public identifier for this app", + "OAuth client secret": "OAuth client secret", + "Store this value securely, do not expose it publicly": "Store this value securely, do not expose it publicly", + "Where users are redirected after authorizing this app": "Where users are redirected after authorizing this app", + "Authorization URL": "Authorization URL", + "Where users are redirected to authorize this app": "Where users are redirected to authorize this app", + "Applications allow you to build internal or public integrations with Outline and provide secure access via OAuth. For more details see the developer documentation.": "Applications allow you to build internal or public integrations with Outline and provide secure access via OAuth. For more details see the developer documentation.", "by {{ name }}": "by {{ name }}", "Last used": "Last used", "No expiry": "No expiry", "Restricted scope": "Restricted scope", "API key copied to clipboard": "API key copied to clipboard", "Copied": "Copied", - "Revoking": "Revoking", "Are you sure you want to revoke the {{ tokenName }} token?": "Are you sure you want to revoke the {{ tokenName }} token?", "Disconnect integration": "Disconnect integration", "Connected": "Connected", @@ -892,7 +959,7 @@ "No people matching your search": "No people matching your search", "No people left to add": "No people left to add", "Date created": "Date created", - "Upload": "Upload", + "Crop Image": "Crop Image", "Crop image": "Crop image", "Uploading": "Uploading", "How does this work?": "How does this work?", @@ -909,6 +976,8 @@ "Drag and drop the zip file from the Markdown export option in {{appName}}, or click to upload": "Drag and drop the zip file from the Markdown export option in {{appName}}, or click to upload", "Last active": "Last active", "Guest": "Guest", + "Never used": "Never used", + "Are you sure you want to delete the {{ appName }} application? This cannot be undone.": "Are you sure you want to delete the {{ appName }} application? This cannot be undone.", "Shared by": "Shared by", "Date shared": "Date shared", "Last accessed": "Last accessed", @@ -993,8 +1062,6 @@ "Unsubscription successful. Your notification settings were updated": "Unsubscription successful. Your notification settings were updated", "Manage when and where you receive email notifications.": "Manage when and where you receive email notifications.", "The email integration is currently disabled. Please set the associated environment variables and restart the server to enable notifications.": "The email integration is currently disabled. Please set the associated environment variables and restart the server to enable notifications.", - "Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n For more details see the developer documentation.": "Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n For more details see the developer documentation.", - "Personal keys": "Personal keys", "Preferences saved": "Preferences saved", "Delete account": "Delete account", "Manage settings that affect your personal experience.": "Manage settings that affect your personal experience.", diff --git a/shared/types.ts b/shared/types.ts index bf08671624..c1346ccc37 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -5,6 +5,15 @@ export enum UserRole { Guest = "guest", } +/** + * Scopes for OAuth and API keys. + */ +export enum Scope { + Read = "read", + Write = "write", + Create = "create", +} + export type DateFilter = "day" | "week" | "month" | "year"; export enum StatusFilter { diff --git a/shared/utils/urls.ts b/shared/utils/urls.ts index 96d0d256c8..31e35559f1 100644 --- a/shared/utils/urls.ts +++ b/shared/utils/urls.ts @@ -98,7 +98,15 @@ export function isCollectionUrl(url: string) { * @param options Parsing options. * @returns True if a url, false otherwise. */ -export function isUrl(text: string, options?: { requireHostname: boolean }) { +export function isUrl( + text: string, + options?: { + /** Require the url to have a hostname. */ + requireHostname?: boolean; + /** Require the url not to use HTTP, custom protocols are ok. */ + requireHttps?: boolean; + } +) { if (text.match(/\n/)) { return false; } @@ -113,6 +121,9 @@ export function isUrl(text: string, options?: { requireHostname: boolean }) { if (url.hostname) { return true; } + if (options?.requireHttps && url.protocol === "http:") { + return false; + } return ( url.protocol !== "" && diff --git a/shared/validations.ts b/shared/validations.ts index ac0f5ac73e..c35c1610ee 100644 --- a/shared/validations.ts +++ b/shared/validations.ts @@ -56,6 +56,26 @@ export const ImportValidation = { maxNameLength: 100, }; +export const OAuthClientValidation = { + /** The maximum length of the OAuth client name */ + maxNameLength: 100, + + /** The maximum length of the OAuth client description */ + maxDescriptionLength: 1000, + + /** The maximum length of the OAuth client developer name */ + maxDeveloperNameLength: 100, + + /** The maximum length of the OAuth client developer URL */ + maxDeveloperUrlLength: 1000, + + /** The maximum length of the OAuth client avatar URL */ + maxAvatarUrlLength: 1000, + + /** The maximum length of an OAuth client redirect URI */ + maxRedirectUriLength: 1000, +}; + export const RevisionValidation = { minNameLength: 1, maxNameLength: 255, diff --git a/yarn.lock b/yarn.lock index f820f25cf6..3e08adac8b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2867,6 +2867,20 @@ resolved "https://registry.yarnpkg.com/@nicolo-ribaudo/chokidar-2/-/chokidar-2-2.1.8-no-fsevents.3.tgz#323d72dd25103d0c4fbdce89dadf574a787b1f9b" integrity "sha1-Mj1y3SUQPQxPvc6J2t9XSnh7H5s= sha512-s88O1aVtXftvp5bCPB7WnmXc5IwOZZ7YPuwNPt+GtOOXpPvad1LfbmjYv+qII7zP6RU2QGnqve27dnLycEnyEQ==" +"@node-oauth/formats@1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@node-oauth/formats/-/formats-1.0.0.tgz#6525478802180199ecf6ea3208fc5e1b683031be" + integrity sha512-DwSbLtdC8zC5B5gTJkFzJj5s9vr9SGzOgQvV9nH7tUVuMSScg0EswAczhjIapOmH3Y8AyP7C4Jv7b8+QJObWZA== + +"@node-oauth/oauth2-server@^5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@node-oauth/oauth2-server/-/oauth2-server-5.2.0.tgz#9bfabf1f5d24387c919e30a4c5fa3ff0c408cbfa" + integrity sha512-tbw0aHPk1Pu/HmQlll4unYd+VHwoagbAmUBLys5g6hDh9khcKzTmE77Z0myMG5a66w3Yk3xBwCRPX9a7M+HTqA== + dependencies: + "@node-oauth/formats" "1.0.0" + basic-auth "2.0.1" + type-is "1.6.18" + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -6522,6 +6536,13 @@ base64url@3.x.x: resolved "https://registry.yarnpkg.com/base64url/-/base64url-3.0.1.tgz#6399d572e2bc3f90a9a8b22d5dbb0a32d33f788d" integrity "sha1-Y5nVcuK8P5CpqLItXbsKMtM/eI0= sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==" +basic-auth@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/basic-auth/-/basic-auth-2.0.1.tgz#b998279bf47ce38344b4f3cf916d4679bbf51e3a" + integrity sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg== + dependencies: + safe-buffer "5.1.2" + before-after-hook@^2.2.0: version "2.2.3" resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.2.3.tgz#c51e809c81a4e354084422b9b26bad88249c517c" @@ -15330,7 +15351,7 @@ type-fest@^4.0.0: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.20.1.tgz#d97bb1e923bf524e5b4b43421d586760fb2ee8be" integrity sha512-R6wDsVsoS9xYOpy8vgeBlqpdOyzJ12HNfQhC/aAKWM3YoCV9TtunJzh/QpkMgeDhkoynDcw5f1y+qF9yc/HHyg== -type-is@^1.6.16, type-is@^1.6.18: +type-is@1.6.18, type-is@^1.6.16, type-is@^1.6.18: version "1.6.18" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" integrity "sha1-TlUs0F3wlGfcvE73Od6J8s83wTE= sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="
{clientId}
{redirectUri}
+ {missingParams.map((param) => ( + <> + {param} + + > + ))} +