mirror of
https://github.com/outline/outline.git
synced 2025-12-30 23:40:46 -06:00
OAuth provider (#8884)
This PR contains the necessary work to make Outline an OAuth provider including: - OAuth app registration - OAuth app management - Private / public apps (Public in cloud only) - Full OAuth 2.0 spec compatible authentication flow - Granular scopes - User token management screen in settings - Associated API endpoints for programatic access
This commit is contained in:
25
app/actions/definitions/oauthClients.tsx
Normal file
25
app/actions/definitions/oauthClients.tsx
Normal file
@@ -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: <PlusIcon />,
|
||||
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: <OAuthClientNew onSubmit={stores.dialogs.closeAllModals} />,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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 (
|
||||
<Relative style={style}>
|
||||
<Relative style={style} $variant={variant} $size={props.size}>
|
||||
{src && !error ? (
|
||||
<CircleImg onError={handleError} src={src} {...rest} />
|
||||
<Image onError={handleError} src={src} {...rest} />
|
||||
) : model ? (
|
||||
<Initials color={model.color} {...rest}>
|
||||
{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;
|
||||
|
||||
@@ -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
|
||||
|
||||
142
app/components/OAuthClient/OAuthClientForm.tsx
Normal file
142
app/components/OAuthClient/OAuthClientForm.tsx
Normal file
@@ -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<FormData>({
|
||||
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 (
|
||||
<form onSubmit={formHandleSubmit(handleSubmit)}>
|
||||
<>
|
||||
<label style={{ marginBottom: "1em" }}>
|
||||
<LabelText>{t("Icon")}</LabelText>
|
||||
<Controller
|
||||
control={control}
|
||||
name="avatarUrl"
|
||||
render={({ field }) => (
|
||||
<ImageInput
|
||||
onSuccess={(url) => field.onChange(url)}
|
||||
onError={(err) => setError("avatarUrl", { message: err })}
|
||||
model={{
|
||||
id: oauthClient?.id,
|
||||
avatarUrl: field.value,
|
||||
initial: getValues().name[0],
|
||||
}}
|
||||
borderRadius={0}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
label={t("Name")}
|
||||
placeholder={t("My App")}
|
||||
{...register("name", {
|
||||
required: true,
|
||||
maxLength: OAuthClientValidation.maxNameLength,
|
||||
})}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
flex
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
label={t("Tagline")}
|
||||
placeholder={t("A short description")}
|
||||
{...register("description", {
|
||||
maxLength: OAuthClientValidation.maxDescriptionLength,
|
||||
})}
|
||||
flex
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="redirectUris"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
type="textarea"
|
||||
label={t("Callback URLs")}
|
||||
placeholder="https://example.com/callback"
|
||||
ref={field.ref}
|
||||
value={field.value.join("\n")}
|
||||
rows={Math.max(2, field.value.length + 1)}
|
||||
onChange={(event) => {
|
||||
field.onChange(event.target.value.split("\n"));
|
||||
}}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{isCloudHosted && (
|
||||
<Switch
|
||||
{...register("published")}
|
||||
label={t("Published")}
|
||||
note={t("Allow this app to be installed by other workspaces")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
<Flex justify="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={formState.isSubmitting || !formState.isValid}
|
||||
>
|
||||
{oauthClient
|
||||
? formState.isSubmitting
|
||||
? `${t("Saving")}…`
|
||||
: t("Save")
|
||||
: formState.isSubmitting
|
||||
? `${t("Creating")}…`
|
||||
: t("Create")}
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
33
app/components/OAuthClient/OAuthClientNew.tsx
Normal file
33
app/components/OAuthClient/OAuthClientNew.tsx
Normal file
@@ -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 <OAuthClientForm handleSubmit={handleSubmit} />;
|
||||
});
|
||||
@@ -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() {
|
||||
<DndProvider backend={HTML5Backend} options={html5Options}>
|
||||
<DragPlaceholder />
|
||||
|
||||
<OrganizationMenu>
|
||||
<TeamMenu>
|
||||
{(props: SidebarButtonProps) => (
|
||||
<SidebarButton
|
||||
{...props}
|
||||
@@ -91,7 +91,7 @@ function AppSidebar() {
|
||||
</Tooltip>
|
||||
</SidebarButton>
|
||||
)}
|
||||
</OrganizationMenu>
|
||||
</TeamMenu>
|
||||
<Overflow>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import { Avatar } from "./Avatar";
|
||||
import { AvatarVariant } from "./Avatar/Avatar";
|
||||
|
||||
const TeamLogo = styled(Avatar)`
|
||||
const TeamLogo = styled(Avatar).attrs({
|
||||
variant: AvatarVariant.Square,
|
||||
})`
|
||||
border-radius: 4px;
|
||||
box-shadow: inset 0 0 0 1px ${s("divider")};
|
||||
border: 0;
|
||||
|
||||
14
app/hooks/useLoggedInSessions.ts
Normal file
14
app/hooks/useLoggedInSessions.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { getCookie } from "tiny-cookie";
|
||||
|
||||
export type Sessions = Record<
|
||||
string,
|
||||
{
|
||||
name: string;
|
||||
logoUrl: string;
|
||||
url: string;
|
||||
}
|
||||
>;
|
||||
|
||||
export function useLoggedInSessions(): Sessions {
|
||||
return JSON.parse(getCookie("sessions") || "{}");
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export default function useRequest<T = unknown>(
|
||||
if (makeRequestOnMount) {
|
||||
void request();
|
||||
}
|
||||
}, [request, makeRequestOnMount]);
|
||||
}, []);
|
||||
|
||||
return { data, loading, loaded, error, request };
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
57
app/menus/OAuthAuthenticationMenu.tsx
Normal file
57
app/menus/OAuthAuthenticationMenu.tsx
Normal file
@@ -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: (
|
||||
<ConfirmationDialog
|
||||
onSubmit={async () => {
|
||||
await oauthAuthentication.deleteAll();
|
||||
dialogs.closeAllModals();
|
||||
}}
|
||||
submitText={t("Revoke")}
|
||||
savingText={`${t("Revoking")}…`}
|
||||
danger
|
||||
>
|
||||
{t("Are you sure you want to revoke access?")}
|
||||
</ConfirmationDialog>
|
||||
),
|
||||
});
|
||||
}, [t, dialogs, oauthAuthentication]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
|
||||
<ContextMenu {...menu}>
|
||||
<MenuItem {...menu} onClick={handleRevoke} dangerous>
|
||||
{t("Revoke")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(OAuthAuthenticationMenu);
|
||||
68
app/menus/OAuthClientMenu.tsx
Normal file
68
app/menus/OAuthClientMenu.tsx
Normal file
@@ -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: (
|
||||
<OAuthClientDeleteDialog
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
oauthClient={oauthClient}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [t, dialogs, oauthClient]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton aria-label={t("Show menu")} {...menu} />
|
||||
<ContextMenu {...menu}>
|
||||
<Template
|
||||
{...menu}
|
||||
items={[
|
||||
{
|
||||
type: "route",
|
||||
title: `${t("Edit")}…`,
|
||||
visible: showEdit,
|
||||
to: settingsPath("applications", oauthClient.id),
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
dangerous: true,
|
||||
title: `${t("Delete")}…`,
|
||||
onClick: handleDelete,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(OAuthClientMenu);
|
||||
@@ -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);
|
||||
39
app/models/oauth/OAuthAuthentication.ts
Normal file
39
app/models/oauth/OAuthAuthentication.ts
Normal file
@@ -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<OAuthClient, "id" | "name" | "clientId" | "avatarUrl">;
|
||||
|
||||
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;
|
||||
92
app/models/oauth/OAuthClient.ts
Normal file
92
app/models/oauth/OAuthClient.ts
Normal file
@@ -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;
|
||||
@@ -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 (
|
||||
<WebsocketProvider>
|
||||
<AuthenticatedLayout>
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<CenteredContent>
|
||||
<PlaceholderDocument />
|
||||
</CenteredContent>
|
||||
}
|
||||
>
|
||||
<Switch>
|
||||
{can.createDocument && (
|
||||
<Route exact path={draftsPath()} component={Drafts} />
|
||||
)}
|
||||
{can.createDocument && (
|
||||
<Route exact path={archivePath()} component={Archive} />
|
||||
)}
|
||||
{can.createDocument && (
|
||||
<Route exact path={trashPath()} component={Trash} />
|
||||
)}
|
||||
<Route path={`${homePath()}/:tab?`} component={Home} />
|
||||
<Redirect from="/dashboard" to={homePath()} />
|
||||
<Redirect exact from="/starred" to={homePath()} />
|
||||
<Redirect exact from="/templates" to={settingsPath("templates")} />
|
||||
<Redirect exact from="/collections/*" to="/collection/*" />
|
||||
<Route exact path="/collection/:id/new" component={DocumentNew} />
|
||||
<Route exact path="/collection/:id/:tab?" component={Collection} />
|
||||
<Route exact path="/doc/new" component={DocumentNew} />
|
||||
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
|
||||
<Route
|
||||
exact
|
||||
path={`/doc/${slug}/history/:revisionId?`}
|
||||
component={Document}
|
||||
/>
|
||||
<Route exact path={`/doc/${slug}/insights`} component={Document} />
|
||||
<Route exact path={`/doc/${slug}/edit`} component={Document} />
|
||||
<Route path={`/doc/${slug}`} component={Document} />
|
||||
<Route exact path={`${searchPath()}/:query?`} component={Search} />
|
||||
<Route path="/404" component={Error404} />
|
||||
<SettingsRoutes />
|
||||
<Route component={Error404} />
|
||||
</Switch>
|
||||
</React.Suspense>
|
||||
</AuthenticatedLayout>
|
||||
</WebsocketProvider>
|
||||
<Switch>
|
||||
<WebsocketProvider>
|
||||
<AuthenticatedLayout>
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<CenteredContent>
|
||||
<PlaceholderDocument />
|
||||
</CenteredContent>
|
||||
}
|
||||
>
|
||||
<Switch>
|
||||
{can.createDocument && (
|
||||
<Route exact path={draftsPath()} component={Drafts} />
|
||||
)}
|
||||
{can.createDocument && (
|
||||
<Route exact path={archivePath()} component={Archive} />
|
||||
)}
|
||||
{can.createDocument && (
|
||||
<Route exact path={trashPath()} component={Trash} />
|
||||
)}
|
||||
<Route path={`${homePath()}/:tab?`} component={Home} />
|
||||
<Redirect from="/dashboard" to={homePath()} />
|
||||
<Redirect exact from="/starred" to={homePath()} />
|
||||
<Redirect
|
||||
exact
|
||||
from="/templates"
|
||||
to={settingsPath("templates")}
|
||||
/>
|
||||
<Redirect exact from="/collections/*" to="/collection/*" />
|
||||
<Route exact path="/collection/:id/new" component={DocumentNew} />
|
||||
<Route
|
||||
exact
|
||||
path="/collection/:id/:tab?"
|
||||
component={Collection}
|
||||
/>
|
||||
<Route exact path="/doc/new" component={DocumentNew} />
|
||||
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
|
||||
<Route
|
||||
exact
|
||||
path={`/doc/${slug}/history/:revisionId?`}
|
||||
component={Document}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={`/doc/${slug}/insights`}
|
||||
component={Document}
|
||||
/>
|
||||
<Route exact path={`/doc/${slug}/edit`} component={Document} />
|
||||
<Route path={`/doc/${slug}`} component={Document} />
|
||||
<Route
|
||||
exact
|
||||
path={`${searchPath()}/:query?`}
|
||||
component={Search}
|
||||
/>
|
||||
<Route path="/404" component={Error404} />
|
||||
<SettingsRoutes />
|
||||
<Route component={Error404} />
|
||||
</Switch>
|
||||
</React.Suspense>
|
||||
</AuthenticatedLayout>
|
||||
</WebsocketProvider>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
<Route exact path="/create" component={Login} />
|
||||
<Route exact path="/logout" component={Logout} />
|
||||
<Route exact path="/desktop-redirect" component={DesktopRedirect} />
|
||||
<Route exact path="/oauth/authorize" component={OAuthAuthorize} />
|
||||
|
||||
<Redirect exact from="/share/:shareId" to="/s/:shareId" />
|
||||
<Route exact path="/s/:shareId" component={SharedDocument} />
|
||||
|
||||
@@ -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? */}
|
||||
<Route
|
||||
exact
|
||||
path={`${settingsPath("applications")}/:id`}
|
||||
component={Application}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path={`${settingsPath("templates")}/${matchDocumentSlug}`}
|
||||
|
||||
@@ -13,7 +13,6 @@ import { Config } from "~/stores/AuthStore";
|
||||
import { AvatarSize } from "~/components/Avatar";
|
||||
import ButtonLarge from "~/components/ButtonLarge";
|
||||
import ChangeLanguage from "~/components/ChangeLanguage";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
import Heading from "~/components/Heading";
|
||||
import OutlineIcon from "~/components/Icons/OutlineIcon";
|
||||
@@ -30,21 +29,23 @@ import {
|
||||
} from "~/hooks/useLastVisitedPath";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { draggableOnDesktop } from "~/styles";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import { detectLanguage } from "~/utils/language";
|
||||
import { homePath } from "~/utils/routeHelpers";
|
||||
import AuthenticationProvider from "./components/AuthenticationProvider";
|
||||
import BackButton from "./components/BackButton";
|
||||
import Notices from "./components/Notices";
|
||||
import { BackButton } from "./components/BackButton";
|
||||
import { Background } from "./components/Background";
|
||||
import { Centered } from "./components/Centered";
|
||||
import { Notices } from "./components/Notices";
|
||||
import { getRedirectUrl, navigateToSubdomain } from "./urls";
|
||||
|
||||
type Props = {
|
||||
children?: (config?: 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 (
|
||||
<Background>
|
||||
<BackButton />
|
||||
<BackButton onBack={onBack} />
|
||||
<ChangeLanguage locale={detectLanguage()} />
|
||||
<Centered align="center" justify="center" column auto>
|
||||
<Centered>
|
||||
<PageTitle title={t("Login")} />
|
||||
<Heading centered>{t("Error")}</Heading>
|
||||
<Note>
|
||||
@@ -142,9 +143,9 @@ function Login({ children }: Props) {
|
||||
if (isCloudHosted && isCustomDomain && !config.name) {
|
||||
return (
|
||||
<Background>
|
||||
<BackButton config={config} />
|
||||
<BackButton onBack={onBack} config={config} />
|
||||
<ChangeLanguage locale={detectLanguage()} />
|
||||
<Centered align="center" justify="center" column auto>
|
||||
<Centered>
|
||||
<PageTitle title={t("Custom domain setup")} />
|
||||
<Heading centered>{t("Almost there")}…</Heading>
|
||||
<Note>
|
||||
@@ -160,17 +161,10 @@ function Login({ children }: Props) {
|
||||
if (Desktop.isElectron() && notice === "domain-required") {
|
||||
return (
|
||||
<Background>
|
||||
<BackButton config={config} />
|
||||
<BackButton onBack={onBack} config={config} />
|
||||
<ChangeLanguage locale={detectLanguage()} />
|
||||
|
||||
<Centered
|
||||
as="form"
|
||||
onSubmit={handleGoSubdomain}
|
||||
align="center"
|
||||
justify="center"
|
||||
column
|
||||
auto
|
||||
>
|
||||
<Centered as="form" onSubmit={handleGoSubdomain}>
|
||||
<Heading centered>{t("Choose workspace")}</Heading>
|
||||
<Note>
|
||||
{t(
|
||||
@@ -206,8 +200,8 @@ function Login({ children }: Props) {
|
||||
if (emailLinkSentTo) {
|
||||
return (
|
||||
<Background>
|
||||
<BackButton config={config} />
|
||||
<Centered align="center" justify="center" column auto>
|
||||
<BackButton onBack={onBack} config={config} />
|
||||
<Centered>
|
||||
<PageTitle title={t("Check your email")} />
|
||||
<CheckEmailIcon size={38} />
|
||||
<Heading centered>{t("Check your email")}</Heading>
|
||||
@@ -241,10 +235,10 @@ function Login({ children }: Props) {
|
||||
|
||||
return (
|
||||
<Background>
|
||||
<BackButton config={config} />
|
||||
<BackButton onBack={onBack} config={config} />
|
||||
<ChangeLanguage locale={detectLanguage()} />
|
||||
|
||||
<Centered align="center" justify="center" gap={12} column auto>
|
||||
<Centered gap={12}>
|
||||
<PageTitle
|
||||
title={config.name ? `${config.name} – ${t("Login")}` : t("Login")}
|
||||
/>
|
||||
@@ -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);
|
||||
260
app/scenes/Login/OAuthAuthorize.tsx
Normal file
260
app/scenes/Login/OAuthAuthorize.tsx
Normal file
@@ -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 <Authorize />;
|
||||
}
|
||||
|
||||
// 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 <TeamSwitcher sessions={sessions} />;
|
||||
}
|
||||
|
||||
return <Login />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<number>();
|
||||
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 (
|
||||
<Background>
|
||||
<Centered>
|
||||
<StyledHeading>{t("An error occurred")}</StyledHeading>
|
||||
{clientError instanceof NotFoundError ? (
|
||||
<Text as="p" type="secondary">
|
||||
{t(
|
||||
"The OAuth client could not be found, please check the provided client ID"
|
||||
)}
|
||||
<Pre>{clientId}</Pre>
|
||||
</Text>
|
||||
) : clientError instanceof BadRequestError ? (
|
||||
<Text as="p" type="secondary">
|
||||
{t(
|
||||
"The OAuth client could not be loaded, please check the redirect URI is valid"
|
||||
)}
|
||||
<Pre>{redirectUri}</Pre>
|
||||
</Text>
|
||||
) : (
|
||||
<Text as="p" type="secondary">
|
||||
{t("Required OAuth parameters are missing")}
|
||||
<Pre>
|
||||
{missingParams.map((param) => (
|
||||
<>
|
||||
{param}
|
||||
<br />
|
||||
</>
|
||||
))}
|
||||
</Pre>
|
||||
</Text>
|
||||
)}
|
||||
</Centered>
|
||||
</Background>
|
||||
);
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
const { name, developerName, developerUrl } = response.data;
|
||||
|
||||
return (
|
||||
<Background>
|
||||
<ChangeLanguage locale={detectLanguage()} />
|
||||
<PageTitle title={t("Authorize")} />
|
||||
<Centered gap={12}>
|
||||
<ConnectHeader team={team} oauthClient={response.data} />
|
||||
<StyledHeading>
|
||||
{t(`{{ appName }} wants to access {{ teamName }}`, {
|
||||
appName: name,
|
||||
teamName: team.name,
|
||||
})}
|
||||
</StyledHeading>
|
||||
{developerName && (
|
||||
<Text type="secondary" as="p" style={{ marginTop: -12 }}>
|
||||
<Trans
|
||||
defaults="By <em>{{ developerName }}</em>"
|
||||
values={{
|
||||
developerName,
|
||||
}}
|
||||
components={{
|
||||
em: developerUrl ? (
|
||||
<Text
|
||||
as="a"
|
||||
type="secondary"
|
||||
weight="bold"
|
||||
href={developerUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
/>
|
||||
) : (
|
||||
<strong />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
<Text type="tertiary" as="p">
|
||||
{t(
|
||||
"{{ appName }} will be able to access your account and perform the following actions",
|
||||
{
|
||||
appName: name,
|
||||
}
|
||||
)}
|
||||
:
|
||||
</Text>
|
||||
<ul style={{ width: "100%", paddingLeft: "1em", marginTop: 0 }}>
|
||||
{OAuthScopeHelper.normalizeScopes(scopes, t).map((item) => (
|
||||
<li key={item}>
|
||||
<Text type="secondary">{item}</Text>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<form
|
||||
method="POST"
|
||||
action="/oauth/authorize"
|
||||
style={{ width: "100%" }}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<input type="hidden" name="client_id" value={clientId ?? ""} />
|
||||
<input type="hidden" name="redirect_uri" value={redirectUri ?? ""} />
|
||||
<input
|
||||
type="hidden"
|
||||
name="response_type"
|
||||
value={responseType ?? ""}
|
||||
/>
|
||||
<input type="hidden" name="state" value={state ?? ""} />
|
||||
<input type="hidden" name="scope" value={scope ?? ""} />
|
||||
{codeChallenge && (
|
||||
<input type="hidden" name="code_challenge" value={codeChallenge} />
|
||||
)}
|
||||
{codeChallengeMethod && (
|
||||
<input
|
||||
type="hidden"
|
||||
name="code_challenge_method"
|
||||
value={codeChallengeMethod}
|
||||
/>
|
||||
)}
|
||||
<Flex gap={8} justify="space-between">
|
||||
<Button type="button" onClick={handleCancel} neutral>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{t("Authorize")}
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
</Centered>
|
||||
</Background>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
`;
|
||||
56
app/scenes/Login/OAuthScopeHelper.ts
Normal file
56
app/scenes/Login/OAuthScopeHelper.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<Link onClick={onBack}>
|
||||
<BackIcon /> {t("Back")}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isCloudHosted || parseDomain(window.location.origin).custom) {
|
||||
return null;
|
||||
}
|
||||
|
||||
12
app/scenes/Login/components/Background.tsx
Normal file
12
app/scenes/Login/components/Background.tsx
Normal file
@@ -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()}
|
||||
`;
|
||||
15
app/scenes/Login/components/Centered.tsx
Normal file
15
app/scenes/Login/components/Centered.tsx
Normal file
@@ -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;
|
||||
`;
|
||||
40
app/scenes/Login/components/ConnectHeader.tsx
Normal file
40
app/scenes/Login/components/ConnectHeader.tsx
Normal file
@@ -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 (
|
||||
<Text type="tertiary">
|
||||
<Flex gap={12} align="center">
|
||||
<Avatar
|
||||
variant={AvatarVariant.Square}
|
||||
model={{
|
||||
avatarUrl: oauthClient.avatarUrl,
|
||||
initial: oauthClient.name[0],
|
||||
}}
|
||||
size={AvatarSize.XXLarge}
|
||||
alt={oauthClient.name}
|
||||
/>
|
||||
|
||||
<MoreIcon />
|
||||
|
||||
<Avatar
|
||||
variant={AvatarVariant.Square}
|
||||
model={team}
|
||||
size={AvatarSize.XXLarge}
|
||||
alt={team.name}
|
||||
/>
|
||||
</Flex>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -123,7 +123,7 @@ function Message({ notice }: { notice: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
export default function Notices() {
|
||||
export function Notices() {
|
||||
const query = useQuery();
|
||||
const notice = query.get("notice");
|
||||
|
||||
|
||||
109
app/scenes/Login/components/TeamSwitcher.tsx
Normal file
109
app/scenes/Login/components/TeamSwitcher.tsx
Normal file
@@ -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 <Login onBack={() => setShowLogin(false)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Background>
|
||||
<ChangeLanguage locale={detectLanguage()} />
|
||||
<Centered>
|
||||
<OutlineIcon size={AvatarSize.XXLarge} />
|
||||
|
||||
<StyledHeading>{t("Choose a workspace")}</StyledHeading>
|
||||
<Text type="tertiary" as="p">
|
||||
{t(
|
||||
"Choose an {{ appName }} workspace or login to continue connecting this app",
|
||||
{ appName }
|
||||
)}
|
||||
.
|
||||
</Text>
|
||||
{Object.keys(sessions)?.map((teamId) => {
|
||||
const session = sessions[teamId];
|
||||
const location = session.url + url.pathname + url.search;
|
||||
return (
|
||||
<TeamLink href={location} key={session.url}>
|
||||
<Avatar
|
||||
variant={AvatarVariant.Square}
|
||||
model={{
|
||||
avatarUrl: session.logoUrl,
|
||||
initial: session.name[0],
|
||||
}}
|
||||
size={AvatarSize.Large}
|
||||
alt={session.name}
|
||||
/>
|
||||
{session.name}
|
||||
<StyledArrowIcon />
|
||||
</TeamLink>
|
||||
);
|
||||
})}
|
||||
<TeamLink onClick={() => setShowLogin(true)}>
|
||||
<ArrowIcon size={AvatarSize.Large} />
|
||||
{t("Login to workspace")}
|
||||
</TeamLink>
|
||||
</Centered>
|
||||
</Background>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
`;
|
||||
3
app/scenes/Login/index.ts
Normal file
3
app/scenes/Login/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import Login from "./Login";
|
||||
|
||||
export default Login;
|
||||
107
app/scenes/Settings/APIAndApps.tsx
Normal file
107
app/scenes/Settings/APIAndApps.tsx
Normal file
@@ -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 (
|
||||
<Scene
|
||||
title={t("API & Apps")}
|
||||
icon={<PadlockIcon />}
|
||||
actions={
|
||||
<>
|
||||
{can.createApiKey && (
|
||||
<Action>
|
||||
<Button
|
||||
type="submit"
|
||||
value={`${t("New API key")}…`}
|
||||
action={createApiKey}
|
||||
context={context}
|
||||
/>
|
||||
</Action>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Heading>{t("API & Apps")}</Heading>
|
||||
<h2>{t("API keys")}</h2>
|
||||
{can.createApiKey ? (
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Create personal API keys to authenticate with the API and programatically control
|
||||
your workspace's data. For more details see the <em>developer documentation</em>."
|
||||
components={{
|
||||
em: (
|
||||
<a
|
||||
href="https://www.getoutline.com/developers"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
) : (
|
||||
<Trans>
|
||||
{t("API keys have been disabled by an admin for your account")}
|
||||
</Trans>
|
||||
)}
|
||||
<PaginatedList<ApiKey>
|
||||
fetch={apiKeys.fetchPage}
|
||||
items={apiKeys.personalApiKeys}
|
||||
options={{ userId: user.id }}
|
||||
renderItem={(apiKey) => (
|
||||
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
|
||||
)}
|
||||
/>
|
||||
<PaginatedList
|
||||
fetch={oauthAuthentications.fetchPage}
|
||||
items={oauthAuthentications.orderedData}
|
||||
heading={
|
||||
<>
|
||||
<h2>{t("Application access")}</h2>
|
||||
<Text as="p" type="secondary">
|
||||
{t(
|
||||
"Manage which third-party and internal applications have been granted access to your {{ appName }} account.",
|
||||
{ appName }
|
||||
)}
|
||||
</Text>
|
||||
</>
|
||||
}
|
||||
renderItem={(oauthAuthentication: OAuthAuthentication) => (
|
||||
<OAuthAuthenticationListItem
|
||||
key={oauthAuthentication.id}
|
||||
oauthAuthentication={oauthAuthentication}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(APIAndApps);
|
||||
@@ -61,7 +61,6 @@ function ApiKeys() {
|
||||
<PaginatedList<ApiKey>
|
||||
fetch={apiKeys.fetchPage}
|
||||
items={apiKeys.orderedData}
|
||||
heading={<h2>{t("All")}</h2>}
|
||||
renderItem={(apiKey) => (
|
||||
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
|
||||
)}
|
||||
|
||||
325
app/scenes/Settings/Application.tsx
Normal file
325
app/scenes/Settings/Application.tsx
Normal file
@@ -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 <LoadingIndicator />;
|
||||
}
|
||||
|
||||
return <Application oauthClient={oauthClient} />;
|
||||
});
|
||||
|
||||
const Application = observer(function Application({ oauthClient }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit: formHandleSubmit,
|
||||
formState,
|
||||
getValues,
|
||||
setError,
|
||||
control,
|
||||
} = useForm<FormData>({
|
||||
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: (
|
||||
<ConfirmationDialog onSubmit={onDelete} danger>
|
||||
{t(
|
||||
"Rotating the client secret will invalidate the current secret. Make sure to update any applications using these credentials."
|
||||
)}
|
||||
</ConfirmationDialog>
|
||||
),
|
||||
});
|
||||
}, [t, dialogs, oauthClient]);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
title={oauthClient.name}
|
||||
left={
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{
|
||||
type: "route",
|
||||
title: t("Applications"),
|
||||
to: settingsPath("applications"),
|
||||
icon: <InternetIcon />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
actions={<OAuthClientMenu oauthClient={oauthClient} showEdit={false} />}
|
||||
>
|
||||
<form onSubmit={formHandleSubmit(handleSubmit)}>
|
||||
<Heading>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<ContentEditable
|
||||
value={field.value}
|
||||
placeholder={t("Name")}
|
||||
onChange={field.onChange}
|
||||
maxLength={OAuthClientValidation.maxNameLength}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Heading>
|
||||
|
||||
<SettingRow
|
||||
label={t("Icon")}
|
||||
name="avatarUrl"
|
||||
description={t("Displayed to users when authorizing")}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="avatarUrl"
|
||||
render={({ field }) => (
|
||||
<ImageInput
|
||||
onSuccess={(url) => field.onChange(url)}
|
||||
onError={(err) => setError("avatarUrl", { message: err })}
|
||||
model={{
|
||||
id: oauthClient.id,
|
||||
avatarUrl: field.value,
|
||||
initial: getValues().name[0],
|
||||
}}
|
||||
borderRadius={0}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
name="description"
|
||||
label={t("Tagline")}
|
||||
description={t("A short description")}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
{...register("description", {
|
||||
maxLength: OAuthClientValidation.maxDescriptionLength,
|
||||
})}
|
||||
flex
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
name="details"
|
||||
label={t("Details")}
|
||||
description={t(
|
||||
"Developer information shown to users when authorizing"
|
||||
)}
|
||||
border={isCloudHosted}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
label={t("Developer name")}
|
||||
{...register("developerName", {
|
||||
maxLength: OAuthClientValidation.maxDeveloperNameLength,
|
||||
})}
|
||||
flex
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
label={t("Developer URL")}
|
||||
{...register("developerUrl", {
|
||||
maxLength: OAuthClientValidation.maxDeveloperUrlLength,
|
||||
})}
|
||||
flex
|
||||
/>
|
||||
</SettingRow>
|
||||
{isCloudHosted && (
|
||||
<SettingRow
|
||||
name="published"
|
||||
label={t("Published")}
|
||||
description={t(
|
||||
"Allow users from other workspaces to authorize this app"
|
||||
)}
|
||||
border={false}
|
||||
>
|
||||
<Switch id="published" {...register("published")} />
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
<h2>{t("Credentials")}</h2>
|
||||
<SettingRow
|
||||
name="clientId"
|
||||
label={t("OAuth client ID")}
|
||||
description={t("The public identifier for this app")}
|
||||
>
|
||||
<Input id="clientId" value={oauthClient.clientId} readOnly>
|
||||
<CopyButton
|
||||
value={oauthClient.clientId}
|
||||
success={t("Copied to clipboard")}
|
||||
tooltip={t("Copy")}
|
||||
icon={<CopyIcon size={20} />}
|
||||
/>
|
||||
</Input>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
name="clientSecret"
|
||||
label={t("OAuth client secret")}
|
||||
description={t(
|
||||
"Store this value securely, do not expose it publicly"
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
id="clientSecret"
|
||||
type="password"
|
||||
value={oauthClient.clientSecret}
|
||||
readOnly
|
||||
>
|
||||
<Tooltip content={t("Rotate secret")} placement="top">
|
||||
<NudeButton type="button" onClick={handleRotateSecret}>
|
||||
<ReplaceIcon size={20} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
|
||||
<CopyButton
|
||||
value={oauthClient.clientSecret}
|
||||
success={t("Copied to clipboard")}
|
||||
tooltip={t("Copy")}
|
||||
icon={<CopyIcon size={20} />}
|
||||
/>
|
||||
</Input>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
name="redirectUris"
|
||||
label={t("Callback URLs")}
|
||||
description={t(
|
||||
"Where users are redirected after authorizing this app"
|
||||
)}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="redirectUris"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id="redirectUris"
|
||||
type="textarea"
|
||||
placeholder="https://example.com/callback"
|
||||
ref={field.ref}
|
||||
value={field.value.join("\n")}
|
||||
rows={Math.max(2, field.value.length + 1)}
|
||||
onChange={(event) => {
|
||||
field.onChange(event.target.value.split("\n"));
|
||||
}}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
name="authorizationUrl"
|
||||
label={t("Authorization URL")}
|
||||
description={t("Where users are redirected to authorize this app")}
|
||||
border={false}
|
||||
>
|
||||
<Input
|
||||
id="authorizationUrl"
|
||||
value={oauthClient.authorizationUrl}
|
||||
readOnly
|
||||
>
|
||||
<CopyButton
|
||||
value={oauthClient.authorizationUrl}
|
||||
success={t("Copied to clipboard")}
|
||||
tooltip={t("Copy link")}
|
||||
/>
|
||||
</Input>
|
||||
</SettingRow>
|
||||
|
||||
<ActionRow>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={formState.isSubmitting || !formState.isValid}
|
||||
>
|
||||
{formState.isSubmitting ? `${t("Saving")}…` : t("Save")}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
</form>
|
||||
</Scene>
|
||||
);
|
||||
});
|
||||
|
||||
export default LoadingState;
|
||||
@@ -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 (
|
||||
<Scene
|
||||
title={t("API")}
|
||||
icon={<CodeIcon />}
|
||||
title={t("Applications")}
|
||||
icon={<InternetIcon />}
|
||||
actions={
|
||||
<>
|
||||
{can.createApiKey && (
|
||||
{can.createOAuthClient && (
|
||||
<Action>
|
||||
<Button
|
||||
type="submit"
|
||||
value={`${t("New API key")}…`}
|
||||
action={createApiKey}
|
||||
value={`${t("New App")}…`}
|
||||
action={createOAuthClient}
|
||||
context={context}
|
||||
/>
|
||||
</Action>
|
||||
@@ -44,12 +42,10 @@ function PersonalApiKeys() {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Heading>{t("API")}</Heading>
|
||||
<Heading>{t("Applications")}</Heading>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans
|
||||
defaults="Create personal API keys to authenticate with the API and programatically control
|
||||
your workspace's data. API keys have the same permissions as your user account.
|
||||
For more details see the <em>developer documentation</em>."
|
||||
defaults="Applications allow you to build internal or public integrations with Outline and provide secure access via OAuth. For more details see the <em>developer documentation</em>."
|
||||
components={{
|
||||
em: (
|
||||
<a
|
||||
@@ -61,17 +57,15 @@ function PersonalApiKeys() {
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
<PaginatedList<ApiKey>
|
||||
fetch={apiKeys.fetchPage}
|
||||
items={apiKeys.personalApiKeys}
|
||||
options={{ userId: user.id }}
|
||||
heading={<h2>{t("Personal keys")}</h2>}
|
||||
renderItem={(apiKey) => (
|
||||
<ApiKeyListItem key={apiKey.id} apiKey={apiKey} />
|
||||
<PaginatedList<OAuthClient>
|
||||
fetch={oauthClients.fetchPage}
|
||||
items={oauthClients.orderedData}
|
||||
renderItem={(oauthClient) => (
|
||||
<OAuthClientListItem key={oauthClient.id} oauthClient={oauthClient} />
|
||||
)}
|
||||
/>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(PersonalApiKeys);
|
||||
export default observer(Applications);
|
||||
@@ -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(
|
||||
|
||||
50
app/scenes/Settings/components/CopyButton.tsx
Normal file
50
app/scenes/Settings/components/CopyButton.tsx
Normal file
@@ -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 = <LinkIcon size={20} />,
|
||||
}: Props) {
|
||||
const timeout = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const handleCopied = React.useCallback(() => {
|
||||
timeout.current = setTimeout(() => {
|
||||
toast.message(success);
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current);
|
||||
}
|
||||
};
|
||||
}, [success]);
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltip} placement="top">
|
||||
<CopyToClipboard text={value} onCopy={handleCopied}>
|
||||
<NudeButton type="button">{icon}</NudeButton>
|
||||
</CopyToClipboard>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Flex gap={8} justify="space-between">
|
||||
<ImageBox>
|
||||
<ImageUpload onSuccess={onSuccess} {...rest}>
|
||||
<StyledAvatar model={model} size={AvatarSize.Upload} />
|
||||
<ImageUpload
|
||||
onSuccess={onSuccess}
|
||||
submitText={t("Crop Image")}
|
||||
{...rest}
|
||||
>
|
||||
<Avatar
|
||||
model={model}
|
||||
size={AvatarSize.Upload}
|
||||
variant={AvatarVariant.Square}
|
||||
/>
|
||||
<Flex auto align="center" justify="center" className="upload">
|
||||
{t("Upload")}
|
||||
<EditIcon />
|
||||
</Flex>
|
||||
</ImageUpload>
|
||||
</ImageBox>
|
||||
@@ -38,10 +48,6 @@ const avatarStyles = `
|
||||
height: ${AvatarSize.Upload}px;
|
||||
`;
|
||||
|
||||
const StyledAvatar = styled(Avatar)`
|
||||
border-radius: 8px;
|
||||
`;
|
||||
|
||||
const ImageBox = styled(Flex)`
|
||||
${avatarStyles};
|
||||
position: relative;
|
||||
|
||||
@@ -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 = (
|
||||
<>
|
||||
<Text type="tertiary">
|
||||
{oauthAuthentication.lastActiveAt ? (
|
||||
<>
|
||||
{t("Last active")}{" "}
|
||||
<Time dateTime={oauthAuthentication.lastActiveAt} addSuffix />
|
||||
</>
|
||||
) : (
|
||||
t("Never used")
|
||||
)}{" "}
|
||||
·{" "}
|
||||
</Text>
|
||||
<Text type="tertiary" ellipsis>
|
||||
{OAuthScopeHelper.normalizeScopes(oauthAuthentication.scope, t).join(
|
||||
", "
|
||||
)}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={oauthAuthentication.id}
|
||||
image={
|
||||
<Avatar
|
||||
model={oauthAuthentication.oauthClient}
|
||||
size={AvatarSize.Large}
|
||||
variant={AvatarVariant.Square}
|
||||
/>
|
||||
}
|
||||
title={oauthAuthentication.oauthClient.name}
|
||||
subtitle={subtitle}
|
||||
actions={
|
||||
<OAuthAuthenticationMenu oauthAuthentication={oauthAuthentication} />
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(OAuthAuthenticationListItem);
|
||||
37
app/scenes/Settings/components/OAuthClientDeleteDialog.tsx
Normal file
37
app/scenes/Settings/components/OAuthClientDeleteDialog.tsx
Normal file
@@ -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 (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("Delete")}
|
||||
savingText={`${t("Deleting")}…`}
|
||||
danger
|
||||
>
|
||||
{t(
|
||||
"Are you sure you want to delete the {{ appName }} application? This cannot be undone.",
|
||||
{
|
||||
appName: oauthClient.name,
|
||||
}
|
||||
)}
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
55
app/scenes/Settings/components/OAuthClientListItem.tsx
Normal file
55
app/scenes/Settings/components/OAuthClientListItem.tsx
Normal file
@@ -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 = (
|
||||
<>
|
||||
<Text type="tertiary">
|
||||
{t(`Created`)} <Time dateTime={oauthClient.createdAt} addSuffix />{" "}
|
||||
{oauthClient.createdById === user.id
|
||||
? ""
|
||||
: t(`by {{ name }}`, { name: user.name })}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={oauthClient.id}
|
||||
image={
|
||||
<Avatar
|
||||
model={oauthClient}
|
||||
size={AvatarSize.Large}
|
||||
variant={AvatarVariant.Square}
|
||||
/>
|
||||
}
|
||||
title={
|
||||
<Link to={settingsPath("applications", oauthClient.id)}>
|
||||
<Text>{oauthClient.name}</Text>
|
||||
</Link>
|
||||
}
|
||||
subtitle={subtitle}
|
||||
actions={<OAuthClientMenu oauthClient={oauthClient} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(OAuthClientListItem);
|
||||
@@ -39,7 +39,7 @@ const Column = styled.div`
|
||||
flex: 1;
|
||||
|
||||
&:first-child {
|
||||
min-width: 70%;
|
||||
min-width: 65%;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
|
||||
11
app/stores/OAuthAuthenticationsStore.ts
Normal file
11
app/stores/OAuthAuthenticationsStore.ts
Normal file
@@ -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<OAuthAuthentication> {
|
||||
apiEndpoint = "oauthAuthentications";
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore, OAuthAuthentication);
|
||||
}
|
||||
}
|
||||
11
app/stores/OAuthClientsStore.ts
Normal file
11
app/stores/OAuthClientsStore.ts
Normal file
@@ -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<OAuthClient> {
|
||||
apiEndpoint = "oauthClients";
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore, OAuthClient);
|
||||
}
|
||||
}
|
||||
@@ -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<K extends keyof RootStore>(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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<T.WebhookSubscriptionsCreateReq>) => {
|
||||
@@ -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<T.WebhookSubscriptionsDeleteReq>) => {
|
||||
@@ -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<T.WebhookSubscriptionsUpdateReq>) => {
|
||||
|
||||
@@ -237,6 +237,11 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
|
||||
case "imports.delete":
|
||||
// Ignored
|
||||
return;
|
||||
case "oauthClients.create":
|
||||
case "oauthClients.update":
|
||||
case "oauthClients.delete":
|
||||
// Ignored
|
||||
return;
|
||||
default:
|
||||
assertUnreachable(event);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
212
server/migrations/20250331231413-add-oauth-server-models.js
Normal file
212
server/migrations/20250331231413-add-oauth-server-models.js
Normal file
@@ -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 });
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
126
server/models/helpers/AuthenticationHelper.test.ts
Normal file
126
server/models/helpers/AuthenticationHelper.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
212
server/models/oauth/OAuthAuthentication.ts
Normal file
212
server/models/oauth/OAuthAuthentication.ts
Normal file
@@ -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<OAuthAuthentication>,
|
||||
Partial<InferCreationAttributes<OAuthAuthentication>>
|
||||
> {
|
||||
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<OAuthAuthentication>
|
||||
| NonNullFindOptions<OAuthAuthentication>
|
||||
): Promise<OAuthAuthentication | null> {
|
||||
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<OAuthAuthentication>
|
||||
| NonNullFindOptions<OAuthAuthentication>
|
||||
) {
|
||||
return this.findOne({
|
||||
where: {
|
||||
refreshTokenHash: hash(input),
|
||||
},
|
||||
include: [
|
||||
{
|
||||
association: "user",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
...options,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default OAuthAuthentication;
|
||||
104
server/models/oauth/OAuthAuthorizationCode.ts
Normal file
104
server/models/oauth/OAuthAuthorizationCode.ts
Normal file
@@ -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<OAuthAuthorizationCode>,
|
||||
Partial<InferCreationAttributes<OAuthAuthorizationCode>>
|
||||
> {
|
||||
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;
|
||||
159
server/models/oauth/OAuthClient.ts
Normal file
159
server/models/oauth/OAuthClient.ts
Normal file
@@ -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<OAuthClient>,
|
||||
Partial<InferCreationAttributes<OAuthClient>>
|
||||
> {
|
||||
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;
|
||||
@@ -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";
|
||||
|
||||
14
server/policies/oauthAuthentication.ts
Normal file
14
server/policies/oauthAuthentication.ts
Normal file
@@ -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
|
||||
);
|
||||
19
server/policies/oauthClient.ts
Normal file
19
server/policies/oauthClient.ts
Normal file
@@ -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)
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
16
server/presenters/oauthAuthentication.ts
Normal file
16
server/presenters/oauthAuthentication.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
42
server/presenters/oauthClient.ts
Normal file
42
server/presenters/oauthClient.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
15
server/queues/processors/OAuthClientDeletedProcessor.ts
Normal file
15
server/queues/processors/OAuthClientDeletedProcessor.ts
Normal file
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
36
server/queues/processors/OAuthClientUnpublishedProcessor.ts
Normal file
36
server/queues/processors/OAuthClientUnpublishedProcessor.ts
Normal file
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
14
server/queues/processors/UserSuspendedProcessor.ts
Normal file
14
server/queues/processors/UserSuspendedProcessor.ts
Normal file
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
33
server/queues/tasks/CleanupOAuthAuthorizationCodeTask.ts
Normal file
33
server/queues/tasks/CleanupOAuthAuthorizationCodeTask.ts
Normal file
@@ -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<string, never>;
|
||||
|
||||
export default class CleanupOAuthAuthorizationCodeTask extends BaseTask<Props> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<T.APIKeysCreateReq>) => {
|
||||
@@ -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<T.APIKeysDeleteReq>) => {
|
||||
|
||||
@@ -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<BaseContext, UserAgentContext>(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());
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
`;
|
||||
1
server/routes/api/oauthAuthentications/index.ts
Normal file
1
server/routes/api/oauthAuthentications/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "./oauthAuthentications";
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<T.OAuthAuthenticationsListReq>) => {
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
const oauthAuthentications = await sequelize.query<OAuthAuthentication>(
|
||||
`
|
||||
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<T.OAuthAuthenticationsDeleteReq>) => {
|
||||
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;
|
||||
21
server/routes/api/oauthAuthentications/schema.ts
Normal file
21
server/routes/api/oauthAuthentications/schema.ts
Normal file
@@ -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
|
||||
>;
|
||||
@@ -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,
|
||||
}
|
||||
`;
|
||||
1
server/routes/api/oauthClients/index.ts
Normal file
1
server/routes/api/oauthClients/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "./oauthClients";
|
||||
326
server/routes/api/oauthClients/oauthClients.test.ts
Normal file
326
server/routes/api/oauthClients/oauthClients.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
181
server/routes/api/oauthClients/oauthClients.ts
Normal file
181
server/routes/api/oauthClients/oauthClients.ts
Normal file
@@ -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<T.OAuthClientsListReq>) => {
|
||||
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<T.OAuthClientsInfoReq>) => {
|
||||
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<T.OAuthClientsCreateReq>) => {
|
||||
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<T.OAuthClientsUpdateReq>) => {
|
||||
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<T.OAuthClientsRotateSecretReq>) => {
|
||||
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<T.OAuthClientsDeleteReq>) => {
|
||||
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;
|
||||
128
server/routes/api/oauthClients/schema.ts
Normal file
128
server/routes/api/oauthClients/schema.ts
Normal file
@@ -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<typeof OAuthClientsInfoSchema>;
|
||||
|
||||
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<typeof OAuthClientsCreateSchema>;
|
||||
|
||||
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<typeof OAuthClientsUpdateSchema>;
|
||||
|
||||
export const OAuthClientsDeleteSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
/** OAuth client id */
|
||||
id: z.string().uuid(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type OAuthClientsDeleteReq = z.infer<typeof OAuthClientsDeleteSchema>;
|
||||
|
||||
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<typeof OAuthClientsListSchema>;
|
||||
47
server/routes/oauth/index.test.ts
Normal file
47
server/routes/oauth/index.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
132
server/routes/oauth/index.ts
Normal file
132
server/routes/oauth/index.ts
Normal file
@@ -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<T.TokenRevokeReq>) => {
|
||||
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;
|
||||
41
server/routes/oauth/middlewares/oauthErrorHandler.ts
Normal file
41
server/routes/oauth/middlewares/oauthErrorHandler.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
11
server/routes/oauth/schema.ts
Normal file
11
server/routes/oauth/schema.ts
Normal file
@@ -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<typeof TokenRevokeSchema>;
|
||||
@@ -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 });
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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<ApiKey> = {}) {
|
||||
if (!overrides.userId) {
|
||||
@@ -137,7 +143,11 @@ export async function buildSubscription(overrides: Partial<Subscription> = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
export function buildTeam(overrides: Record<string, any> = {}) {
|
||||
export function buildTeam(
|
||||
overrides: Omit<Partial<Team>, "authenticationProviders"> & {
|
||||
authenticationProviders?: Partial<AuthenticationProvider>[];
|
||||
} = {}
|
||||
) {
|
||||
return Team.create(
|
||||
{
|
||||
name: faker.company.name(),
|
||||
@@ -691,6 +701,106 @@ export async function buildPin(overrides: Partial<Pin> = {}): Promise<Pin> {
|
||||
return Pin.create(overrides);
|
||||
}
|
||||
|
||||
export async function buildOAuthClient(overrides: Partial<OAuthClient> = {}) {
|
||||
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<OAuthAuthorizationCode> = {}
|
||||
) {
|
||||
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<ProsemirrorData>[]) {
|
||||
return Node.fromJSON(schema, {
|
||||
type: "doc",
|
||||
|
||||
@@ -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<Notification> & {
|
||||
membershipId?: string;
|
||||
};
|
||||
|
||||
export type OAuthClientEvent = BaseEvent<OAuthClient> & {
|
||||
name: "oauthClients.create" | "oauthClients.update" | "oauthClients.delete";
|
||||
modelId: string;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type ImportEvent = BaseEvent<Import<any>> & {
|
||||
name:
|
||||
@@ -504,6 +511,7 @@ export type Event =
|
||||
| ViewEvent
|
||||
| WebhookSubscriptionEvent
|
||||
| NotificationEvent
|
||||
| OAuthClientEvent
|
||||
| EmptyTrashEvent
|
||||
| ImportEvent;
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
104
server/utils/oauth/OAuthInterface.test.ts
Normal file
104
server/utils/oauth/OAuthInterface.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
294
server/utils/oauth/OAuthInterface.ts
Normal file
294
server/utils/oauth/OAuthInterface.ts
Normal file
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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 <em>{{titleWithDefault}}</em> 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 <em>{{titleWithDefault}}</em> 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>login</1>.": "Already have an account? Go to <1>login</1>.",
|
||||
"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 <em>{{ developerName }}</em>": "By <em>{{ developerName }}</em>",
|
||||
"{{ 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 <em>developer documentation</em>.": "Create personal API keys to authenticate with the API and programatically control\n your workspace's data. For more details see the <em>developer documentation</em>.",
|
||||
"{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 <em>developer documentation</em>.": "API keys can be used to authenticate with the API and programatically control\n your workspace's data. For more details see the <em>developer documentation</em>.",
|
||||
"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 <em>developer documentation</em>.": "Applications allow you to build internal or public integrations with Outline and provide secure access via OAuth. For more details see the <em>developer documentation</em>.",
|
||||
"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 <em>developer documentation</em>.": "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 <em>developer documentation</em>.",
|
||||
"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.",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 !== "" &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
23
yarn.lock
23
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=="
|
||||
|
||||
Reference in New Issue
Block a user