mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
feat: Add authentication provider management (#10997)
* Gemini first-pass * Prevent post-connect login * stash * stash * Add OIDC logo * Separate security page * test * Update icon * test * ui * Add extra guards for disabling auth provider * refactor * test
This commit is contained in:
@@ -142,16 +142,16 @@ yarn sequelize migration:create --name=add-field-to-table
|
||||
- Run tests with Jest:
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
# Run a specific test file (preferred)
|
||||
yarn test path/to/test.spec.ts
|
||||
|
||||
# Run every test (avoid)
|
||||
yarn test
|
||||
|
||||
# Run specific test suites
|
||||
yarn test:app # Frontend tests
|
||||
yarn test:server # Backend tests
|
||||
yarn test:shared # Shared code tests
|
||||
|
||||
# Run specific test file
|
||||
yarn test path/to/test.spec.ts
|
||||
# Run test suites (avoid)
|
||||
yarn test:app # All frontend tests
|
||||
yarn test:server # All backend tests
|
||||
yarn test:shared # All shared code tests
|
||||
```
|
||||
|
||||
- Write unit tests for utilities and business logic in a collocated .test.ts file.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
export const ConnectedIcon = styled.div<{ color?: string }>`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: ${(props) => props.color ?? props.theme.accent};
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
`;
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
UserIcon,
|
||||
GroupIcon,
|
||||
GlobeIcon,
|
||||
ShieldIcon,
|
||||
TeamIcon,
|
||||
BeakerIcon,
|
||||
SettingsIcon,
|
||||
@@ -33,6 +34,7 @@ import useStores from "./useStores";
|
||||
const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys"));
|
||||
const Applications = lazy(() => import("~/scenes/Settings/Applications"));
|
||||
const APIAndApps = lazy(() => import("~/scenes/Settings/APIAndApps"));
|
||||
const Authentication = lazy(() => import("~/scenes/Settings/Authentication"));
|
||||
const Details = lazy(() => import("~/scenes/Settings/Details"));
|
||||
const Export = lazy(() => import("~/scenes/Settings/Export"));
|
||||
const Features = lazy(() => import("~/scenes/Settings/Features"));
|
||||
@@ -120,6 +122,15 @@ const useSettingsConfig = () => {
|
||||
group: t("Workspace"),
|
||||
icon: TeamIcon,
|
||||
},
|
||||
{
|
||||
name: t("Authentication"),
|
||||
path: settingsPath("authentication"),
|
||||
component: Authentication.Component,
|
||||
preload: Authentication.preload,
|
||||
enabled: can.update,
|
||||
group: t("Workspace"),
|
||||
icon: PadlockIcon,
|
||||
},
|
||||
{
|
||||
name: t("Security"),
|
||||
path: settingsPath("security"),
|
||||
@@ -127,7 +138,7 @@ const useSettingsConfig = () => {
|
||||
preload: Security.preload,
|
||||
enabled: can.update,
|
||||
group: t("Workspace"),
|
||||
icon: PadlockIcon,
|
||||
icon: ShieldIcon,
|
||||
},
|
||||
{
|
||||
name: t("Features"),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { computed, observable } from "mobx";
|
||||
import Model from "./base/Model";
|
||||
import Field from "./decorators/Field";
|
||||
import { AfterDelete } from "./decorators/Lifecycle";
|
||||
import type AuthenticationProvidersStore from "~/stores/AuthenticationProvidersStore";
|
||||
|
||||
class AuthenticationProvider extends Model {
|
||||
static modelName = "AuthenticationProvider";
|
||||
@@ -20,6 +22,16 @@ class AuthenticationProvider extends Model {
|
||||
get isActive() {
|
||||
return this.isEnabled && this.isConnected;
|
||||
}
|
||||
|
||||
@AfterDelete
|
||||
static afterDelete(model: AuthenticationProvider) {
|
||||
// Restore a placeholder record to allow re-connection
|
||||
return (model.store as AuthenticationProvidersStore).add({
|
||||
...model,
|
||||
isEnabled: false,
|
||||
isConnected: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default AuthenticationProvider;
|
||||
|
||||
@@ -188,7 +188,7 @@ function Invite({ onSubmit }: Props) {
|
||||
{can.update && (
|
||||
<Trans>
|
||||
As an admin you can also{" "}
|
||||
<Link to="/settings/security">enable email sign-in</Link>.
|
||||
<Link to="/settings/authentication">enable email sign-in</Link>.
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { EmailIcon, PadlockIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Flex from "~/components/Flex";
|
||||
import Heading from "~/components/Heading";
|
||||
import type AuthenticationProvider from "~/models/AuthenticationProvider";
|
||||
import PluginIcon from "~/components/PluginIcon";
|
||||
import Scene from "~/components/Scene";
|
||||
import Switch from "~/components/Switch";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import SettingRow from "./components/SettingRow";
|
||||
import { setPostLoginPath } from "~/hooks/useLastVisitedPath";
|
||||
import { settingsPath } from "~/utils/routeHelpers";
|
||||
import DomainManagement from "./components/DomainManagement";
|
||||
import Button from "~/components/Button";
|
||||
import { ConnectedIcon } from "~/components/Icons/ConnectedIcon";
|
||||
import { useTheme } from "styled-components";
|
||||
|
||||
function Authentication() {
|
||||
const { authenticationProviders, dialogs } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
const {
|
||||
data: providers,
|
||||
loading,
|
||||
request,
|
||||
} = useRequest(authenticationProviders.fetchPage);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!providers && !loading) {
|
||||
void request();
|
||||
}
|
||||
}, [loading, providers, request]);
|
||||
|
||||
const handleGuestSigninChange = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await team.save({ guestSignin: checked });
|
||||
toast.success(t("Settings saved"));
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[team, t]
|
||||
);
|
||||
|
||||
const handleToggleProvider = React.useCallback(
|
||||
async (provider: AuthenticationProvider, isEnabled: boolean) => {
|
||||
try {
|
||||
await provider.save({ isEnabled });
|
||||
toast.success(t("Settings saved"));
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const handleRemoveProvider = React.useCallback(
|
||||
async (provider: AuthenticationProvider) => {
|
||||
dialogs.openModal({
|
||||
title: t("Are you sure?"),
|
||||
content: (
|
||||
<ConfirmationDialog
|
||||
onSubmit={async () => {
|
||||
await provider.delete();
|
||||
toast.success(t("Settings saved"));
|
||||
}}
|
||||
savingText={`${t("Removing")}…`}
|
||||
danger
|
||||
>
|
||||
{t(
|
||||
"Removing this authentication provider will prevent members from signing in with {{ authProvider }}.",
|
||||
{
|
||||
authProvider: provider.displayName,
|
||||
}
|
||||
)}
|
||||
</ConfirmationDialog>
|
||||
),
|
||||
});
|
||||
},
|
||||
[dialogs, t]
|
||||
);
|
||||
|
||||
const handleConnectProvider = React.useCallback((name: string) => {
|
||||
setPostLoginPath(settingsPath("authentication"));
|
||||
window.location.href = `/auth/${name}?host=${window.location.host}`;
|
||||
}, []);
|
||||
|
||||
const showSuccessMessage = React.useMemo(
|
||||
() => () => toast.success(t("Settings saved")),
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<Scene title={t("Authentication")} icon={<PadlockIcon />}>
|
||||
<Heading>{t("Authentication")}</Heading>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans>
|
||||
Manage how members sign-in to your workspace and which authentication
|
||||
providers are enabled.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Heading as="h2">{t("Sign In")}</Heading>
|
||||
|
||||
{authenticationProviders.orderedData.map((provider) => (
|
||||
<SettingRow
|
||||
key={provider.name}
|
||||
label={
|
||||
<Flex gap={8} align="center">
|
||||
<PluginIcon id={provider.name} /> {provider.displayName}
|
||||
</Flex>
|
||||
}
|
||||
name={provider.name}
|
||||
description={
|
||||
provider.isConnected
|
||||
? t("Allow members to sign-in with {{ authProvider }}", {
|
||||
authProvider: provider.displayName,
|
||||
})
|
||||
: t("Connect {{ authProvider }} to allow members to sign-in", {
|
||||
authProvider: provider.displayName,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Flex align="center" gap={12}>
|
||||
{provider.isConnected ? (
|
||||
<Button
|
||||
icon={
|
||||
provider.isEnabled ? (
|
||||
<ConnectedIcon />
|
||||
) : (
|
||||
<ConnectedIcon color={theme.textSecondary} />
|
||||
)
|
||||
}
|
||||
onClick={() =>
|
||||
!provider.isEnabled
|
||||
? handleToggleProvider(provider, true)
|
||||
: handleRemoveProvider(provider)
|
||||
}
|
||||
neutral
|
||||
>
|
||||
{provider.isEnabled ? t("Connected") : t("Disabled")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleConnectProvider(provider.name)}
|
||||
neutral
|
||||
>
|
||||
{t("Connect")}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</SettingRow>
|
||||
))}
|
||||
<SettingRow
|
||||
label={
|
||||
<Flex gap={8} align="center">
|
||||
<EmailIcon /> {t("Email")}
|
||||
</Flex>
|
||||
}
|
||||
name="guestSignin"
|
||||
description={
|
||||
env.EMAIL_ENABLED
|
||||
? t("Allow members to sign-in using their email address")
|
||||
: t("The server must have SMTP configured to enable this setting")
|
||||
}
|
||||
border={false}
|
||||
>
|
||||
<Switch
|
||||
id="guestSignin"
|
||||
checked={team.guestSignin}
|
||||
onChange={handleGuestSigninChange}
|
||||
disabled={!env.EMAIL_ENABLED}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<Heading as="h2">{t("Restrictions")}</Heading>
|
||||
<DomainManagement onSuccess={showSuccessMessage} />
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Authentication);
|
||||
@@ -1,50 +1,37 @@
|
||||
import debounce from "lodash/debounce";
|
||||
import { observer } from "mobx-react";
|
||||
import { CheckboxIcon, EmailIcon, PadlockIcon } from "outline-icons";
|
||||
import { ShieldIcon } from "outline-icons";
|
||||
import { useState } from "react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useTheme } from "styled-components";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Flex from "~/components/Flex";
|
||||
import Heading from "~/components/Heading";
|
||||
import type { Option } from "~/components/InputSelect";
|
||||
import { InputSelect } from "~/components/InputSelect";
|
||||
import PluginIcon from "~/components/PluginIcon";
|
||||
import Scene from "~/components/Scene";
|
||||
import Switch from "~/components/Switch";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import DomainManagement from "./components/DomainManagement";
|
||||
import SettingRow from "./components/SettingRow";
|
||||
|
||||
function Security() {
|
||||
const { authenticationProviders, dialogs } = useStores();
|
||||
const { dialogs } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
|
||||
const [data, setData] = useState({
|
||||
sharing: team.sharing,
|
||||
documentEmbeds: team.documentEmbeds,
|
||||
guestSignin: team.guestSignin,
|
||||
defaultUserRole: team.defaultUserRole,
|
||||
memberCollectionCreate: team.memberCollectionCreate,
|
||||
memberTeamCreate: team.memberTeamCreate,
|
||||
inviteRequired: team.inviteRequired,
|
||||
});
|
||||
|
||||
const {
|
||||
data: providers,
|
||||
loading,
|
||||
request,
|
||||
} = useRequest(authenticationProviders.fetchPage);
|
||||
|
||||
const userRoleOptions: Option[] = React.useMemo(
|
||||
() =>
|
||||
[
|
||||
@@ -62,12 +49,6 @@ function Security() {
|
||||
[t]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!providers && !loading) {
|
||||
void request();
|
||||
}
|
||||
}, [loading, providers, request]);
|
||||
|
||||
const showSuccessMessage = React.useMemo(
|
||||
() =>
|
||||
debounce(() => {
|
||||
@@ -96,13 +77,6 @@ function Security() {
|
||||
[saveData]
|
||||
);
|
||||
|
||||
const handleGuestSigninChange = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
await saveData({ guestSignin: checked });
|
||||
},
|
||||
[saveData]
|
||||
);
|
||||
|
||||
const handleSharingChange = React.useCallback(
|
||||
async (checked: boolean) => {
|
||||
await saveData({ sharing: checked });
|
||||
@@ -200,7 +174,7 @@ function Security() {
|
||||
);
|
||||
|
||||
return (
|
||||
<Scene title={t("Security")} icon={<PadlockIcon />}>
|
||||
<Scene title={t("Security")} icon={<ShieldIcon />}>
|
||||
<Heading>{t("Security")}</Heading>
|
||||
<Text as="p" type="secondary">
|
||||
<Trans>
|
||||
@@ -209,57 +183,7 @@ function Security() {
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<h2>{t("Sign In")}</h2>
|
||||
{authenticationProviders.orderedData
|
||||
// filtering unconnected, until we have ability to connect from this screen
|
||||
.filter((provider) => provider.isConnected)
|
||||
.map((provider) => (
|
||||
<SettingRow
|
||||
key={provider.name}
|
||||
label={
|
||||
<Flex gap={8} align="center">
|
||||
<PluginIcon id={provider.name} /> {provider.displayName}
|
||||
</Flex>
|
||||
}
|
||||
name={provider.name}
|
||||
description={t("Allow members to sign-in with {{ authProvider }}", {
|
||||
authProvider: provider.displayName,
|
||||
})}
|
||||
>
|
||||
<Flex align="center">
|
||||
<CheckboxIcon
|
||||
color={provider.isActive ? theme.accent : undefined}
|
||||
checked={provider.isActive}
|
||||
/>{" "}
|
||||
<Text as="p" type="secondary">
|
||||
{provider.isActive ? t("Connected") : t("Disabled")}
|
||||
</Text>
|
||||
</Flex>
|
||||
</SettingRow>
|
||||
))}
|
||||
<SettingRow
|
||||
label={
|
||||
<Flex gap={8} align="center">
|
||||
<EmailIcon /> {t("Email")}
|
||||
</Flex>
|
||||
}
|
||||
name="guestSignin"
|
||||
description={
|
||||
env.EMAIL_ENABLED
|
||||
? t("Allow members to sign-in using their email address")
|
||||
: t("The server must have SMTP configured to enable this setting")
|
||||
}
|
||||
border={false}
|
||||
>
|
||||
<Switch
|
||||
id="guestSignin"
|
||||
checked={data.guestSignin}
|
||||
onChange={handleGuestSigninChange}
|
||||
disabled={!env.EMAIL_ENABLED}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<h2>{t("Access")}</h2>
|
||||
<Heading as="h2">{t("Invites")}</Heading>
|
||||
<SettingRow
|
||||
label={t("Allow users to send invites")}
|
||||
name={TeamPreference.MembersCanInvite}
|
||||
@@ -307,9 +231,7 @@ function Security() {
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
<DomainManagement onSuccess={showSuccessMessage} />
|
||||
|
||||
<h2>{t("Behavior")}</h2>
|
||||
<Heading as="h2">{t("Behavior")}</Heading>
|
||||
<SettingRow
|
||||
label={t("Public document sharing")}
|
||||
name="sharing"
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import type { Props as ButtonProps } from "~/components/Button";
|
||||
import Button from "~/components/Button";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import { ConnectedIcon } from "~/components/Icons/ConnectedIcon";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -61,21 +60,3 @@ function ConfirmDisconnectDialog({
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
const ConnectedIcon = styled.div`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: ${s("accent")};
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ConfigItem } from "~/hooks/useSettingsConfig";
|
||||
import Button from "../../../components/Button";
|
||||
import Flex from "../../../components/Flex";
|
||||
import Text from "../../../components/Text";
|
||||
import { Status } from "./Status";
|
||||
|
||||
type Props = {
|
||||
integration: ConfigItem;
|
||||
@@ -69,26 +70,3 @@ const Description = styled(Text)`
|
||||
max-width: 100%;
|
||||
color: ${s("textTertiary")};
|
||||
`;
|
||||
|
||||
const Status = styled(Text).attrs({
|
||||
type: "secondary",
|
||||
size: "small",
|
||||
as: "span",
|
||||
})`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
|
||||
background: radial-gradient(
|
||||
circle at center,
|
||||
${s("accent")} 0 33%,
|
||||
transparent 33%
|
||||
);
|
||||
border-radius: 50%;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import Text from "@shared/components/Text";
|
||||
import { s } from "@shared/styles";
|
||||
import styled from "styled-components";
|
||||
|
||||
export const Status = styled(Text).attrs({
|
||||
type: "secondary",
|
||||
size: "small",
|
||||
as: "span",
|
||||
})`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
|
||||
background: radial-gradient(
|
||||
circle at center,
|
||||
${s("accent")} 0 33%,
|
||||
transparent 33%
|
||||
);
|
||||
border-radius: 50%;
|
||||
}
|
||||
`;
|
||||
@@ -1,11 +1,18 @@
|
||||
import orderBy from "lodash/orderBy";
|
||||
import { computed } from "mobx";
|
||||
import AuthenticationProvider from "~/models/AuthenticationProvider";
|
||||
import type RootStore from "./RootStore";
|
||||
import Store, { RPCAction } from "./base/Store";
|
||||
|
||||
export default class AuthenticationProvidersStore extends Store<AuthenticationProvider> {
|
||||
actions = [RPCAction.List, RPCAction.Update];
|
||||
actions = [RPCAction.List, RPCAction.Update, RPCAction.Delete];
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore, AuthenticationProvider);
|
||||
}
|
||||
|
||||
@computed
|
||||
get orderedData(): AuthenticationProvider[] {
|
||||
return orderBy(Array.from(this.data.values()), ["desc", "asc"]);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -182,7 +182,7 @@
|
||||
"node-fetch": "2.7.0",
|
||||
"nodemailer": "^7.0.11",
|
||||
"octokit": "^3.2.2",
|
||||
"outline-icons": "^3.16.0",
|
||||
"outline-icons": "^3.17.0",
|
||||
"oy-vey": "^0.12.1",
|
||||
"pako": "^2.1.0",
|
||||
"passport": "^0.7.0",
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
StateStore,
|
||||
request,
|
||||
getTeamFromContext,
|
||||
getClientFromContext,
|
||||
getClientFromOAuthState,
|
||||
getUserFromOAuthState,
|
||||
} from "@server/utils/passport";
|
||||
import config from "../../plugin.json";
|
||||
import env from "../env";
|
||||
@@ -96,13 +97,19 @@ if (env.AZURE_CLIENT_ID && env.AZURE_CLIENT_SECRET) {
|
||||
}
|
||||
|
||||
const team = await getTeamFromContext(context);
|
||||
const client = getClientFromContext(context);
|
||||
const client = getClientFromOAuthState(context);
|
||||
const user =
|
||||
context.state?.auth?.user ?? (await getUserFromOAuthState(context));
|
||||
|
||||
const domain = parseEmail(email).domain;
|
||||
const subdomain = slugifyDomain(domain);
|
||||
|
||||
const teamName = organization.displayName;
|
||||
const ctx = createContext({ ip: context.ip });
|
||||
const ctx = createContext({
|
||||
ip: context.ip,
|
||||
user,
|
||||
authType: context.state?.auth?.type,
|
||||
});
|
||||
const result = await accountProvisioner(ctx, {
|
||||
team: {
|
||||
teamId: team?.id,
|
||||
|
||||
@@ -21,7 +21,8 @@ import type { AuthenticationResult } from "@server/types";
|
||||
import {
|
||||
StateStore,
|
||||
getTeamFromContext,
|
||||
getClientFromContext,
|
||||
getClientFromOAuthState,
|
||||
getUserFromOAuthState,
|
||||
request,
|
||||
} from "@server/utils/passport";
|
||||
import config from "../../plugin.json";
|
||||
@@ -68,7 +69,7 @@ if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) {
|
||||
) {
|
||||
try {
|
||||
const team = await getTeamFromContext(context);
|
||||
const client = getClientFromContext(context);
|
||||
const client = getClientFromOAuthState(context);
|
||||
/** Fetch the user's profile */
|
||||
const profile: RESTGetAPICurrentUserResult = await request(
|
||||
"GET",
|
||||
@@ -177,11 +178,17 @@ if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) {
|
||||
}
|
||||
}
|
||||
}
|
||||
const user =
|
||||
context.state?.auth?.user ?? (await getUserFromOAuthState(context));
|
||||
|
||||
// if a team can be inferred, we assume the user is only interested in signing into
|
||||
// that team in particular; otherwise, we will do a best effort at finding their account
|
||||
// or provisioning a new one (within AccountProvisioner)
|
||||
const ctx = createContext({ ip: context.ip });
|
||||
const ctx = createContext({
|
||||
ip: context.ip,
|
||||
user,
|
||||
authType: context.state?.auth?.type,
|
||||
});
|
||||
const result = await accountProvisioner(ctx, {
|
||||
team: {
|
||||
teamId: team?.id,
|
||||
|
||||
@@ -17,7 +17,8 @@ import type { AuthenticationResult } from "@server/types";
|
||||
import {
|
||||
StateStore,
|
||||
getTeamFromContext,
|
||||
getClientFromContext,
|
||||
getClientFromOAuthState,
|
||||
getUserFromOAuthState,
|
||||
} from "@server/utils/passport";
|
||||
import config from "../../plugin.json";
|
||||
import env from "../env";
|
||||
@@ -67,7 +68,9 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
|
||||
// "domain" is the Google Workspaces domain
|
||||
const domain = profile._json.hd;
|
||||
const team = await getTeamFromContext(context);
|
||||
const client = getClientFromContext(context);
|
||||
const client = getClientFromOAuthState(context);
|
||||
const user =
|
||||
context.state?.auth?.user ?? (await getUserFromOAuthState(context));
|
||||
|
||||
// No profile domain means personal gmail account
|
||||
// No team implies the request came from the apex domain
|
||||
@@ -109,7 +112,11 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
|
||||
// if a team can be inferred, we assume the user is only interested in signing into
|
||||
// that team in particular; otherwise, we will do a best effort at finding their account
|
||||
// or provisioning a new one (within AccountProvisioner)
|
||||
const ctx = createContext({ ip: context.ip });
|
||||
const ctx = createContext({
|
||||
ip: context.ip,
|
||||
user,
|
||||
authType: context.state?.auth?.type,
|
||||
});
|
||||
const result = await accountProvisioner(ctx, {
|
||||
team: {
|
||||
teamId: team?.id,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
type Props = {
|
||||
/** The size of the icon, 24px is default to match standard icons */
|
||||
size?: number;
|
||||
/** The color of the icon, defaults to the current text color */
|
||||
color?: string;
|
||||
};
|
||||
|
||||
export default function OIDCIcon({ size = 24, color = "currentColor" }: Props) {
|
||||
return (
|
||||
<svg
|
||||
fill={color}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="-8 -8 38 38"
|
||||
version="1.1"
|
||||
>
|
||||
<path d="M14.54 .887L10.91 2.66V20.83C6.76 20.31 3.64 18.05 3.64 15.33C3.64 12.75 6.44 10.58 10.27 9.92V7.61C4.42 8.32 0 11.5 0 15.33C0 19.29 4.74 22.57 10.91 23.11L14.54 21.4V.886M15.18 7.61V9.92C16.61 10.17 17.89 10.62 18.94 11.23L16.97 12.34L24 13.87L23.5 8.66L21.63 9.72C19.89 8.66 17.67 7.91 15.18 7.61Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import config from "../plugin.json";
|
||||
import Icon from "./Icon";
|
||||
|
||||
PluginManager.add([
|
||||
{
|
||||
...config,
|
||||
type: Hook.Icon,
|
||||
value: Icon,
|
||||
},
|
||||
]);
|
||||
@@ -19,7 +19,8 @@ import type { AuthenticationResult } from "@server/types";
|
||||
import {
|
||||
StateStore,
|
||||
getTeamFromContext,
|
||||
getClientFromContext,
|
||||
getClientFromOAuthState,
|
||||
getUserFromOAuthState,
|
||||
request,
|
||||
} from "@server/utils/passport";
|
||||
import config from "../../plugin.json";
|
||||
@@ -121,7 +122,9 @@ export function createOIDCRouter(
|
||||
}
|
||||
|
||||
const team = await getTeamFromContext(context);
|
||||
const client = getClientFromContext(context);
|
||||
const client = getClientFromOAuthState(context);
|
||||
const user =
|
||||
context.state?.auth?.user ?? (await getUserFromOAuthState(context));
|
||||
const { domain } = parseEmail(email);
|
||||
|
||||
// Only a single OIDC provider is supported – find the existing, if any.
|
||||
@@ -187,7 +190,11 @@ export function createOIDCRouter(
|
||||
avatarUrl = null;
|
||||
}
|
||||
|
||||
const ctx = createContext({ ip: context.ip });
|
||||
const ctx = createContext({
|
||||
ip: context.ip,
|
||||
user,
|
||||
authType: context.state?.auth?.type,
|
||||
});
|
||||
const result = await accountProvisioner(ctx, {
|
||||
team: {
|
||||
teamId: team?.id,
|
||||
|
||||
@@ -20,8 +20,9 @@ import { authorize } from "@server/policies";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import type { APIContext, AuthenticationResult } from "@server/types";
|
||||
import {
|
||||
getClientFromContext,
|
||||
getClientFromOAuthState,
|
||||
getTeamFromContext,
|
||||
getUserFromOAuthState,
|
||||
StateStore,
|
||||
} from "@server/utils/passport";
|
||||
import { parseEmail } from "@shared/utils/email";
|
||||
@@ -82,11 +83,17 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
|
||||
) {
|
||||
try {
|
||||
const team = await getTeamFromContext(context);
|
||||
const client = getClientFromContext(context);
|
||||
const client = getClientFromOAuthState(context);
|
||||
const user =
|
||||
context.state?.auth?.user ?? (await getUserFromOAuthState(context));
|
||||
|
||||
const { domain } = parseEmail(profile.user.email);
|
||||
|
||||
const ctx = createContext({ ip: context.ip });
|
||||
const ctx = createContext({
|
||||
ip: context.ip,
|
||||
user,
|
||||
authType: context.state?.auth?.type,
|
||||
});
|
||||
const result = await accountProvisioner(ctx, {
|
||||
team: {
|
||||
teamId: team?.id,
|
||||
|
||||
@@ -431,6 +431,43 @@ describe("accountProvisioner", () => {
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("should allow connecting a new authentication provider while logged in", async () => {
|
||||
const admin = await buildAdmin();
|
||||
const team = admin.team;
|
||||
const ctxWithAdmin = createContext({ ip, user: admin });
|
||||
|
||||
const providerId = faker.internet.domainName();
|
||||
const { user, isNewTeam, isNewUser } = await accountProvisioner(
|
||||
ctxWithAdmin,
|
||||
{
|
||||
user: {
|
||||
name: admin.name,
|
||||
email: admin.email!,
|
||||
},
|
||||
team: {
|
||||
teamId: team.id,
|
||||
subdomain: team.subdomain!,
|
||||
},
|
||||
authenticationProvider: {
|
||||
name: "google",
|
||||
providerId,
|
||||
},
|
||||
authentication: {
|
||||
providerId: randomUUID(),
|
||||
accessToken: "456",
|
||||
scopes: ["read"],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(user.id).toEqual(admin.id);
|
||||
expect(isNewUser).toEqual(false);
|
||||
expect(isNewTeam).toEqual(false);
|
||||
|
||||
const providers = await team.$get("authenticationProviders");
|
||||
expect(providers.find((p) => p.name === "google")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("self hosted", () => {
|
||||
|
||||
@@ -93,6 +93,34 @@ async function accountProvisioner(
|
||||
let result;
|
||||
let emailMatchOnly;
|
||||
|
||||
const actor = ctx.state.auth?.user;
|
||||
|
||||
// If the user is already logged in and is an admin of the team then we
|
||||
// allow them to connect a new authentication provider
|
||||
if (actor && actor.teamId === teamParams.teamId && actor.isAdmin) {
|
||||
const team = actor.team;
|
||||
let authenticationProvider = await AuthenticationProvider.findOne({
|
||||
where: {
|
||||
...authenticationProviderParams,
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!authenticationProvider) {
|
||||
authenticationProvider = await team.$create<AuthenticationProvider>(
|
||||
"authenticationProvider",
|
||||
authenticationProviderParams
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
user: actor,
|
||||
team,
|
||||
isNewUser: false,
|
||||
isNewTeam: false,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
result = await teamProvisioner(ctx, {
|
||||
...teamParams,
|
||||
|
||||
@@ -69,6 +69,7 @@ export default function auth(options: AuthenticationOptions = {}) {
|
||||
}
|
||||
|
||||
Object.defineProperty(ctx, "context", {
|
||||
configurable: true,
|
||||
get() {
|
||||
return {
|
||||
auth: ctx.state.auth,
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { buildTeam } from "@server/test/factories";
|
||||
import { AuthenticationProvider } from "@server/models";
|
||||
import { createContext } from "@server/context";
|
||||
|
||||
describe("AuthenticationProvider", () => {
|
||||
describe("checkCanBeDisabled", () => {
|
||||
it("should allow disabling if email sign-in is enabled", async () => {
|
||||
const team = await buildTeam({
|
||||
guestSignin: true,
|
||||
});
|
||||
const provider = await AuthenticationProvider.create({
|
||||
name: "google",
|
||||
providerId: "google-id",
|
||||
teamId: team.id,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const ctx = createContext({ user: { team } as any });
|
||||
await expect(provider.disable(ctx)).resolves.not.toThrow();
|
||||
expect(provider.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("should allow disabling if another provider is enabled", async () => {
|
||||
const team = await buildTeam({
|
||||
guestSignin: false,
|
||||
});
|
||||
const provider1 = await AuthenticationProvider.create({
|
||||
name: "google",
|
||||
providerId: "google-id",
|
||||
teamId: team.id,
|
||||
enabled: true,
|
||||
});
|
||||
await AuthenticationProvider.create({
|
||||
name: "slack",
|
||||
providerId: "slack-id",
|
||||
teamId: team.id,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const ctx = createContext({ user: { team } as any });
|
||||
await expect(provider1.disable(ctx)).resolves.not.toThrow();
|
||||
expect(provider1.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("should prevent disabling if it is the last enabled method", async () => {
|
||||
const team = await buildTeam({
|
||||
guestSignin: false,
|
||||
});
|
||||
// buildTeam creates a default 'slack' provider, let's disable it first
|
||||
await AuthenticationProvider.update(
|
||||
{ enabled: false },
|
||||
{ where: { teamId: team.id } }
|
||||
);
|
||||
|
||||
const provider = await AuthenticationProvider.create({
|
||||
name: "google",
|
||||
providerId: "google-id",
|
||||
teamId: team.id,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const ctx = createContext({ user: { team } as any });
|
||||
await expect(provider.disable(ctx)).rejects.toThrow(
|
||||
"At least one authentication provider is required"
|
||||
);
|
||||
});
|
||||
|
||||
it("should prevent destruction if it is the last enabled method", async () => {
|
||||
const team = await buildTeam({
|
||||
guestSignin: false,
|
||||
});
|
||||
// Disable existing default providers
|
||||
await AuthenticationProvider.update(
|
||||
{ enabled: false },
|
||||
{ where: { teamId: team.id } }
|
||||
);
|
||||
|
||||
const provider = await AuthenticationProvider.create({
|
||||
name: "google",
|
||||
providerId: "google-id",
|
||||
teamId: team.id,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
await expect(provider.destroy()).rejects.toThrow(
|
||||
"At least one authentication provider is required"
|
||||
);
|
||||
});
|
||||
|
||||
it("should allow destruction if it is not enabled", async () => {
|
||||
const team = await buildTeam({
|
||||
guestSignin: false,
|
||||
});
|
||||
const provider = await AuthenticationProvider.create({
|
||||
name: "google",
|
||||
providerId: "google-id",
|
||||
teamId: team.id,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
await expect(provider.destroy()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { InferAttributes, InferCreationAttributes } from "sequelize";
|
||||
import type {
|
||||
InferAttributes,
|
||||
InferCreationAttributes,
|
||||
Transaction,
|
||||
} from "sequelize";
|
||||
import { Op } from "sequelize";
|
||||
import {
|
||||
BeforeDestroy,
|
||||
BelongsTo,
|
||||
Column,
|
||||
CreatedAt,
|
||||
@@ -25,6 +30,7 @@ import AzureClient from "plugins/azure/server/azure";
|
||||
import GoogleClient from "plugins/google/server/google";
|
||||
import OIDCClient from "plugins/oidc/server/oidc";
|
||||
import type { APIContext } from "@server/types";
|
||||
import type { DestroyOptions } from "sequelize";
|
||||
|
||||
@Scopes(() => ({
|
||||
withUserAuthentication: (userId: string) => ({
|
||||
@@ -109,19 +115,30 @@ class AuthenticationProvider extends Model<
|
||||
}
|
||||
}
|
||||
|
||||
disable: (ctx: APIContext) => Promise<AuthenticationProvider> = async (
|
||||
ctx
|
||||
) => {
|
||||
const { transaction } = ctx.state;
|
||||
if (!transaction) {
|
||||
throw new Error("Transaction required");
|
||||
/**
|
||||
* Check if this provider can be disabled or destroyed.
|
||||
* Throws an error if this is the last enabled authentication provider.
|
||||
*
|
||||
* @param transaction - Database transaction to use for the check.
|
||||
* @throws ValidationError if disabling is not allowed.
|
||||
*/
|
||||
private async checkCanBeDisabled(
|
||||
transaction?: Transaction | null
|
||||
): Promise<void> {
|
||||
// Check if email sign-in is enabled for the team first
|
||||
const team = await Team.findByPk(this.teamId, {
|
||||
transaction,
|
||||
lock: transaction?.LOCK.SHARE,
|
||||
});
|
||||
if (team?.emailSigninEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const otherEnabledProviders = await (
|
||||
this.constructor as typeof AuthenticationProvider
|
||||
).findAll({
|
||||
transaction,
|
||||
lock: transaction.LOCK.SHARE,
|
||||
lock: transaction?.LOCK.SHARE,
|
||||
where: {
|
||||
teamId: this.teamId,
|
||||
enabled: true,
|
||||
@@ -132,15 +149,45 @@ class AuthenticationProvider extends Model<
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (otherEnabledProviders.length >= 1) {
|
||||
return this.updateWithCtx(ctx, {
|
||||
enabled: false,
|
||||
});
|
||||
} else {
|
||||
if (otherEnabledProviders.length === 0) {
|
||||
throw ValidationError("At least one authentication provider is required");
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeDestroy
|
||||
static async checkBeforeDestroy(
|
||||
instance: AuthenticationProvider,
|
||||
options: DestroyOptions
|
||||
) {
|
||||
if (instance.enabled) {
|
||||
await instance.checkCanBeDisabled(options.transaction);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable this authentication provider after ensuring it's allowed.
|
||||
*
|
||||
* @param ctx - API context containing the transaction.
|
||||
* @returns The updated AuthenticationProvider instance.
|
||||
* @throws ValidationError if disabling is not allowed.
|
||||
*/
|
||||
disable: (ctx: APIContext) => Promise<AuthenticationProvider> = async (
|
||||
ctx
|
||||
) => {
|
||||
const { transaction } = ctx.state;
|
||||
await this.checkCanBeDisabled(transaction);
|
||||
|
||||
return this.updateWithCtx(ctx, {
|
||||
enabled: false,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Enable this authentication provider.
|
||||
*
|
||||
* @param ctx - API context containing the transaction.
|
||||
* @returns The updated AuthenticationProvider instance.
|
||||
*/
|
||||
enable: (ctx: APIContext) => Promise<AuthenticationProvider> = async (ctx) =>
|
||||
this.updateWithCtx(ctx, {
|
||||
enabled: true,
|
||||
|
||||
@@ -55,7 +55,9 @@ describe("#authenticationProviders.info", () => {
|
||||
|
||||
describe("#authenticationProviders.update", () => {
|
||||
it("should not allow admins to disable when last authentication provider", async () => {
|
||||
const team = await buildTeam();
|
||||
const team = await buildTeam({
|
||||
guestSignin: false,
|
||||
});
|
||||
const user = await buildAdmin({
|
||||
teamId: team.id,
|
||||
});
|
||||
@@ -150,3 +152,59 @@ describe("#authenticationProviders.list", () => {
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#authenticationProviders.delete", () => {
|
||||
it("should allow admins to delete authentication provider", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildAdmin({
|
||||
teamId: team.id,
|
||||
});
|
||||
const googleProvider = await team.$create("authenticationProvider", {
|
||||
name: "google",
|
||||
providerId: randomUUID(),
|
||||
});
|
||||
const res = await server.post("/api/authenticationProviders.delete", {
|
||||
body: {
|
||||
id: googleProvider.id,
|
||||
token: user.getJwtToken(),
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(200);
|
||||
const count = await team.$count("authenticationProviders", {
|
||||
where: {
|
||||
id: googleProvider.id,
|
||||
},
|
||||
});
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
it("should require authorization", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser();
|
||||
const googleProvider = await team.$create("authenticationProvider", {
|
||||
name: "google",
|
||||
providerId: randomUUID(),
|
||||
});
|
||||
const res = await server.post("/api/authenticationProviders.delete", {
|
||||
body: {
|
||||
id: googleProvider.id,
|
||||
token: user.getJwtToken(),
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(403);
|
||||
});
|
||||
|
||||
it("should require authentication", async () => {
|
||||
const team = await buildTeam();
|
||||
const googleProvider = await team.$create("authenticationProvider", {
|
||||
name: "google",
|
||||
providerId: randomUUID(),
|
||||
});
|
||||
const res = await server.post("/api/authenticationProviders.delete", {
|
||||
body: {
|
||||
id: googleProvider.id,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,6 +64,35 @@ router.post(
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"authenticationProviders.delete",
|
||||
auth({ role: UserRole.Admin }),
|
||||
validate(T.AuthenticationProvidersDeleteSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.AuthenticationProvidersDeleteReq>) => {
|
||||
const { transaction } = ctx.state;
|
||||
const { id } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
const authenticationProvider = await AuthenticationProvider.findByPk(id, {
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
|
||||
authorize(user, "delete", authenticationProvider);
|
||||
|
||||
if (authenticationProvider.enabled) {
|
||||
await authenticationProvider.disable(ctx);
|
||||
}
|
||||
|
||||
await authenticationProvider.destroy({ transaction });
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"authenticationProviders.list",
|
||||
auth({ role: UserRole.Admin }),
|
||||
@@ -91,7 +120,7 @@ router.post(
|
||||
...(row ? presentAuthenticationProvider(row) : {}),
|
||||
};
|
||||
})
|
||||
.sort((a) => (a.isEnabled ? -1 : 1));
|
||||
.sort((a, b) => (a.isEnabled === b.isEnabled ? 0 : a.isEnabled ? -1 : 1));
|
||||
|
||||
ctx.body = {
|
||||
data,
|
||||
|
||||
@@ -25,3 +25,14 @@ export const AuthenticationProvidersUpdateSchema = BaseSchema.extend({
|
||||
export type AuthenticationProvidersUpdateReq = z.infer<
|
||||
typeof AuthenticationProvidersUpdateSchema
|
||||
>;
|
||||
|
||||
export const AuthenticationProvidersDeleteSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
/** Authentication Provider Id */
|
||||
id: z.string().uuid(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type AuthenticationProvidersDeleteReq = z.infer<
|
||||
typeof AuthenticationProvidersDeleteSchema
|
||||
>;
|
||||
|
||||
@@ -21,7 +21,11 @@ void (async () => {
|
||||
for (const provider of AuthenticationHelper.providers) {
|
||||
const resolvedRouter = await provider.value.router;
|
||||
if (resolvedRouter) {
|
||||
router.use("/", resolvedRouter.routes());
|
||||
router.use(
|
||||
"/",
|
||||
authMiddleware({ optional: true }),
|
||||
resolvedRouter.routes()
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -16,9 +16,9 @@ export class MutexLock {
|
||||
*/
|
||||
public static get lock(): Redlock {
|
||||
this.redlock ??= new Redlock([Redis.defaultClient], {
|
||||
retryJitter: 10,
|
||||
retryCount: 20,
|
||||
retryDelay: 200,
|
||||
retryJitter: 100,
|
||||
retryCount: 120,
|
||||
retryDelay: 1000,
|
||||
});
|
||||
|
||||
return this.redlock;
|
||||
|
||||
+81
-11
@@ -12,6 +12,7 @@ import env from "@server/env";
|
||||
import { Team } from "@server/models";
|
||||
import { InternalError, OAuthStateMismatchError } from "../errors";
|
||||
import fetch from "./fetch";
|
||||
import { getUserForJWT } from "./jwt";
|
||||
|
||||
export class StateStore {
|
||||
constructor(private pkce = false) {}
|
||||
@@ -45,7 +46,14 @@ export class StateStore {
|
||||
const clientInput = ctx.query.client?.toString();
|
||||
const client = clientInput === Client.Desktop ? Client.Desktop : Client.Web;
|
||||
const host = ctx.query.host?.toString() || parseDomain(ctx.hostname).host;
|
||||
const state = buildState(host, token, client, codeVerifier);
|
||||
const accessToken = ctx.cookies.get("accessToken");
|
||||
const state = buildState({
|
||||
host,
|
||||
token,
|
||||
client,
|
||||
codeVerifier,
|
||||
accessToken,
|
||||
});
|
||||
|
||||
ctx.cookies.set(this.key, state, {
|
||||
expires: addMinutes(new Date(), 10),
|
||||
@@ -115,27 +123,82 @@ export async function request(
|
||||
}
|
||||
}
|
||||
|
||||
function buildState(
|
||||
host: string,
|
||||
token: string,
|
||||
client?: Client,
|
||||
codeVerifier?: string
|
||||
) {
|
||||
return [host, token, client, codeVerifier].join("|");
|
||||
function buildState({
|
||||
host,
|
||||
token,
|
||||
client,
|
||||
codeVerifier,
|
||||
accessToken,
|
||||
}: {
|
||||
host: string;
|
||||
token: string;
|
||||
client?: Client;
|
||||
codeVerifier?: string;
|
||||
accessToken?: string;
|
||||
}) {
|
||||
return [host, token, client, codeVerifier, accessToken].join("|");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the state string into its components.
|
||||
*
|
||||
* @param state The state string
|
||||
* @returns An object containing the parsed components
|
||||
*/
|
||||
export function parseState(state: string) {
|
||||
const [host, token, client, rawCodeVerifier] = state.split("|");
|
||||
const [host, token, client, rawCodeVerifier, rawAccessToken] =
|
||||
state.split("|");
|
||||
const codeVerifier = rawCodeVerifier ? rawCodeVerifier : undefined;
|
||||
return { host, token, client, codeVerifier };
|
||||
const accessToken = rawAccessToken ? rawAccessToken : undefined;
|
||||
return { host, token, client, codeVerifier, accessToken };
|
||||
}
|
||||
|
||||
export function getClientFromContext(ctx: Context): Client {
|
||||
/**
|
||||
* Returns the client type from the context if available. Used to redirect
|
||||
* the user back to the correct client after the OAuth flow.
|
||||
*
|
||||
* @param ctx The Koa context
|
||||
* @returns The client type, defaults to Client.Web
|
||||
*/
|
||||
export function getClientFromOAuthState(ctx: Context): Client {
|
||||
const state = ctx.cookies.get("state");
|
||||
const client = state ? parseState(state).client : undefined;
|
||||
return client === Client.Desktop ? Client.Desktop : Client.Web;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the access token from the context if available. This is used
|
||||
* to restore the session during the OAuth flow when connecting additional
|
||||
* providers to an existing team.
|
||||
*
|
||||
* @param ctx The Koa context
|
||||
* @returns The access token if available, otherwise undefined
|
||||
*/
|
||||
export function getAccessTokenFromOAuthState(ctx: Context): string | undefined {
|
||||
const state = ctx.cookies.get("state");
|
||||
return state ? parseState(state).accessToken : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user from the context if they are authenticated. This is used
|
||||
* to restore the session during the OAuth flow.
|
||||
*
|
||||
* @param ctx The Koa context
|
||||
* @returns The user if authenticated, otherwise undefined
|
||||
*/
|
||||
export async function getUserFromOAuthState(ctx: Context) {
|
||||
const token = getAccessTokenFromOAuthState(ctx);
|
||||
if (!token) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return await getUserForJWT(token);
|
||||
} catch (_err) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
type TeamFromContextOptions = {
|
||||
/**
|
||||
* Whether to consider the state cookie in the context when determining the team.
|
||||
@@ -145,6 +208,13 @@ type TeamFromContextOptions = {
|
||||
includeStateCookie?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Infers the team from the context based on the hostname or state cookie.
|
||||
*
|
||||
* @param ctx The Koa context
|
||||
* @param options Options for determining the team
|
||||
* @returns The inferred team or undefined if not found
|
||||
*/
|
||||
export async function getTeamFromContext(
|
||||
ctx: Context,
|
||||
options: TeamFromContextOptions = { includeStateCookie: true }
|
||||
|
||||
@@ -617,6 +617,7 @@
|
||||
"Account": "Account",
|
||||
"API & Apps": "API & Apps",
|
||||
"Details": "Details",
|
||||
"Authentication": "Authentication",
|
||||
"Security": "Security",
|
||||
"Features": "Features",
|
||||
"Members": "Members",
|
||||
@@ -1004,6 +1005,19 @@
|
||||
"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>.",
|
||||
"Settings saved": "Settings saved",
|
||||
"Are you sure?": "Are you sure?",
|
||||
"Removing": "Removing",
|
||||
"Removing this authentication provider will prevent members from signing in with {{ authProvider }}.": "Removing this authentication provider will prevent members from signing in with {{ authProvider }}.",
|
||||
"Manage how members sign-in to your workspace and which authentication providers are enabled.": "Manage how members sign-in to your workspace and which authentication providers are enabled.",
|
||||
"Allow members to sign-in with {{ authProvider }}": "Allow members to sign-in with {{ authProvider }}",
|
||||
"Connect {{ authProvider }} to allow members to sign-in": "Connect {{ authProvider }} to allow members to sign-in",
|
||||
"Connected": "Connected",
|
||||
"Disabled": "Disabled",
|
||||
"Connect": "Connect",
|
||||
"Allow members to sign-in using their email address": "Allow members to sign-in using their email address",
|
||||
"The server must have SMTP configured to enable this setting": "The server must have SMTP configured to enable this setting",
|
||||
"Restrictions": "Restrictions",
|
||||
"by {{ name }}": "by {{ name }}",
|
||||
"Last used": "Last used",
|
||||
"No expiry": "No expiry",
|
||||
@@ -1012,7 +1026,6 @@
|
||||
"Copied": "Copied",
|
||||
"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",
|
||||
"Disconnecting": "Disconnecting",
|
||||
"Are you sure you want to disconnect the <em>{{ service }}</em> integration?": "Are you sure you want to disconnect the <em>{{ service }}</em> integration?",
|
||||
"This will stop sending analytics events to the configured instance.": "This will stop sending analytics events to the configured instance.",
|
||||
@@ -1083,7 +1096,6 @@
|
||||
"You can import a zip file that was previously exported from an Outline installation – collections, documents, and images will be imported. In Outline, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.": "You can import a zip file that was previously exported from an Outline installation – collections, documents, and images will be imported. In Outline, open <em>Export</em> in the Settings sidebar and click on <em>Export Data</em>.",
|
||||
"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",
|
||||
"Configure": "Configure",
|
||||
"Connect": "Connect",
|
||||
"Last active": "Last active",
|
||||
"Role": "Role",
|
||||
"Guest": "Guest",
|
||||
@@ -1104,7 +1116,6 @@
|
||||
"Custom emojis can be used throughout your workspace in documents, comments, and reactions.": "Custom emojis can be used throughout your workspace in documents, comments, and reactions.",
|
||||
"Left": "Left",
|
||||
"Right": "Right",
|
||||
"Settings saved": "Settings saved",
|
||||
"Logo updated": "Logo updated",
|
||||
"Unable to upload new logo": "Unable to upload new logo",
|
||||
"Delete workspace": "Delete workspace",
|
||||
@@ -1209,11 +1220,7 @@
|
||||
"Are you sure you want to require invites?": "Are you sure you want to require invites?",
|
||||
"New users will first need to be invited to create an account. <em>Default role</em> and <em>Allowed domains</em> will no longer apply.": "New users will first need to be invited to create an account. <em>Default role</em> and <em>Allowed domains</em> will no longer apply.",
|
||||
"Settings that impact the access, security, and content of your workspace.": "Settings that impact the access, security, and content of your workspace.",
|
||||
"Allow members to sign-in with {{ authProvider }}": "Allow members to sign-in with {{ authProvider }}",
|
||||
"Disabled": "Disabled",
|
||||
"Allow members to sign-in using their email address": "Allow members to sign-in using their email address",
|
||||
"The server must have SMTP configured to enable this setting": "The server must have SMTP configured to enable this setting",
|
||||
"Access": "Access",
|
||||
"Invites": "Invites",
|
||||
"Allow users to send invites": "Allow users to send invites",
|
||||
"Allow editors to invite other people to the workspace": "Allow editors to invite other people to the workspace",
|
||||
"Require invites": "Require invites",
|
||||
|
||||
@@ -12133,10 +12133,10 @@ os-tmpdir@~1.0.2:
|
||||
resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
|
||||
integrity "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g=="
|
||||
|
||||
outline-icons@^3.16.0:
|
||||
version "3.16.0"
|
||||
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-3.16.0.tgz#bc42017fc86d7e5378e66626a6a241a343b87fa7"
|
||||
integrity sha512-w/D1kGCRQSDqIUtFwpsmdmF3GBsOe8fmph7ikyjsoM5wSWA7BJGotDscy/1OnSWdIqnJ41EyGKLDrskZBo27ug==
|
||||
outline-icons@^3.17.0:
|
||||
version "3.17.0"
|
||||
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-3.17.0.tgz#6a7cbfaebd89168d4acc81119a21827dd25eeb90"
|
||||
integrity sha512-H6p2MqJAqi6bkjX1ZIYy9Qw/1mYEz2akm44t5jg7ncaWdBC8ey0Tcbc4oKN6mtjQPCjm8km+qd5dXy3tsZstMA==
|
||||
|
||||
own-keys@^1.0.0:
|
||||
version "1.0.1"
|
||||
|
||||
Reference in New Issue
Block a user