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:
Tom Moor
2025-05-03 19:40:18 -04:00
committed by GitHub
parent fd3c21d28b
commit a06671e8ce
99 changed files with 5115 additions and 221 deletions

View 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} />,
});
},
});

View File

@@ -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({

View File

@@ -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;

View File

@@ -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

View 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>
);
});

View 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} />;
});

View File

@@ -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

View File

@@ -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;

View 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") || "{}");
}

View File

@@ -59,7 +59,7 @@ export default function useRequest<T = unknown>(
if (makeRequestOnMount) {
void request();
}
}, [request, makeRequestOnMount]);
}, []);
return { data, loading, loaded, error, request };
}

View File

@@ -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"),

View 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);

View 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);

View File

@@ -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);

View 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;

View 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;

View File

@@ -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>
);
}

View File

@@ -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} />

View File

@@ -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}`}

View File

@@ -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);

View 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;
`;

View 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);
}
}

View File

@@ -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;
}

View 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()}
`;

View 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;
`;

View 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>
);
}

View File

@@ -123,7 +123,7 @@ function Message({ notice }: { notice: string }) {
}
}
export default function Notices() {
export function Notices() {
const query = useQuery();
const notice = query.get("notice");

View 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;
`;

View File

@@ -0,0 +1,3 @@
import Login from "./Login";
export default Login;

View 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);

View File

@@ -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} />
)}

View 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;

View File

@@ -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);

View File

@@ -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(

View 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>
);
}

View File

@@ -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;

View File

@@ -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")
)}{" "}
&middot;{" "}
</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);

View 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>
);
}

View 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);

View File

@@ -39,7 +39,7 @@ const Column = styled.div`
flex: 1;
&:first-child {
min-width: 70%;
min-width: 65%;
}
&:last-child {

View 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);
}
}

View 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);
}
}

View File

@@ -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;
}
}

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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>) => {

View File

@@ -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);
}

View File

@@ -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
*/

View File

@@ -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();

View File

@@ -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`);
}

View File

@@ -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) {

View 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 });
});
}
};

View File

@@ -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);
};
}

View 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);
});
});
});
});

View File

@@ -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)
);
});
};
}

View File

@@ -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";

View 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;

View 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;

View 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;

View File

@@ -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";

View 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
);

View 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)
);

View File

@@ -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,

View 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,
};
}

View 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,
};
}

View 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,
},
});
}
}

View 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,
},
},
});
}
}
}

View File

@@ -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,

View 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 },
});
}
}

View File

@@ -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);
});
});

View 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,
};
}
}

View File

@@ -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>) => {

View File

@@ -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());

View File

@@ -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,
}
`;

View File

@@ -0,0 +1 @@
export { default } from "./oauthAuthentications";

View File

@@ -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();
});
});

View File

@@ -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;

View 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
>;

View File

@@ -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,
}
`;

View File

@@ -0,0 +1 @@
export { default } from "./oauthClients";

View 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();
});
});

View 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;

View 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>;

View 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);
});
});

View 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;

View 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,
};
}
};
}

View 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>;

View File

@@ -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 });

View File

@@ -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));

View File

@@ -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",

View File

@@ -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;

View File

@@ -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");
}

View 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);
});
});
});

View 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;
},
};

View File

@@ -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.",

View File

@@ -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 {

View File

@@ -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 !== "" &&

View File

@@ -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,

View File

@@ -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=="