From 21a1257d0652cb3d043c99b13db5884f3206708d Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sun, 19 Feb 2023 22:52:08 -0500 Subject: [PATCH] chore: Move remaining auth methods to plugins (#4900) * Move Google, Email, and Azure to plugins * Move OIDC provider, remove old loading code * Move AuthLogo to plugin * AuthLogo -> PluginIcon * Lazy load plugin settings --- app/components/AuthLogo/GoogleLogo.tsx | 26 ------ app/components/AuthLogo/MicrosoftLogo.tsx | 43 ---------- app/components/AuthLogo/index.tsx | 49 ------------ app/components/PluginIcon.tsx | 35 ++++++++ app/hooks/useSettingsConfig.ts | 13 +-- app/scenes/Login/AuthenticationProvider.tsx | 4 +- app/scenes/Settings/Security.tsx | 4 +- app/utils/PluginLoader.ts | 51 ++++++++++++ app/utils/plugins.ts | 43 ---------- .../AUTHENTICATION_PROVIDERS.md | 11 +-- plugins/azure/client/Icon.tsx | 45 +++++++++++ plugins/azure/plugin.json | 5 ++ plugins/azure/server/.babelrc | 3 + .../azure/server/auth}/azure.ts | 5 -- plugins/email/plugin.json | 4 + plugins/email/server/.babelrc | 3 + .../email/server/auth}/email.test.ts | 0 .../email/server/auth}/email.ts | 5 -- plugins/google/client/Icon.tsx | 26 ++++++ plugins/google/plugin.json | 5 ++ plugins/google/server/.babelrc | 3 + .../google/server/auth}/google.ts | 14 ++-- plugins/oidc/plugin.json | 12 +++ plugins/oidc/server/.babelrc | 3 + .../oidc/server/auth}/oidc.ts | 24 +++--- plugins/slack/client/Icon.tsx | 8 +- plugins/slack/client/Settings.tsx | 4 +- server/models/helpers/AuthenticationHelper.ts | 74 ++++++++++++++--- server/presenters/providerConfig.ts | 2 +- .../authenticationProviders.ts | 4 +- server/routes/auth/index.ts | 8 +- server/routes/auth/providers/index.ts | 80 ------------------- 32 files changed, 302 insertions(+), 314 deletions(-) delete mode 100644 app/components/AuthLogo/GoogleLogo.tsx delete mode 100644 app/components/AuthLogo/MicrosoftLogo.tsx delete mode 100644 app/components/AuthLogo/index.tsx create mode 100644 app/components/PluginIcon.tsx create mode 100644 app/utils/PluginLoader.ts delete mode 100644 app/utils/plugins.ts rename server/routes/auth/providers/README.md => docs/AUTHENTICATION_PROVIDERS.md (50%) create mode 100644 plugins/azure/client/Icon.tsx create mode 100644 plugins/azure/plugin.json create mode 100644 plugins/azure/server/.babelrc rename {server/routes/auth/providers => plugins/azure/server/auth}/azure.ts (98%) create mode 100644 plugins/email/plugin.json create mode 100644 plugins/email/server/.babelrc rename {server/routes/auth/providers => plugins/email/server/auth}/email.test.ts (100%) rename {server/routes/auth/providers => plugins/email/server/auth}/email.ts (98%) create mode 100644 plugins/google/client/Icon.tsx create mode 100644 plugins/google/plugin.json create mode 100644 plugins/google/server/.babelrc rename {server/routes/auth/providers => plugins/google/server/auth}/google.ts (95%) create mode 100644 plugins/oidc/plugin.json create mode 100644 plugins/oidc/server/.babelrc rename {server/routes/auth/providers => plugins/oidc/server/auth}/oidc.ts (90%) delete mode 100644 server/routes/auth/providers/index.ts diff --git a/app/components/AuthLogo/GoogleLogo.tsx b/app/components/AuthLogo/GoogleLogo.tsx deleted file mode 100644 index 4854512b9..000000000 --- a/app/components/AuthLogo/GoogleLogo.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import * as React from "react"; - -type Props = { - size?: number; - fill?: string; - className?: string; -}; - -function GoogleLogo({ size = 34, fill = "#FFF", className }: Props) { - return ( - - - - - - ); -} - -export default GoogleLogo; diff --git a/app/components/AuthLogo/MicrosoftLogo.tsx b/app/components/AuthLogo/MicrosoftLogo.tsx deleted file mode 100644 index 393b52488..000000000 --- a/app/components/AuthLogo/MicrosoftLogo.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import * as React from "react"; - -type Props = { - size?: number; - fill?: string; - className?: string; -}; - -function MicrosoftLogo({ size = 34, fill = "#FFF", className }: Props) { - return ( - - - - - - - ); -} - -export default MicrosoftLogo; diff --git a/app/components/AuthLogo/index.tsx b/app/components/AuthLogo/index.tsx deleted file mode 100644 index c1bf8fda0..000000000 --- a/app/components/AuthLogo/index.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import * as React from "react"; -import styled from "styled-components"; -import GoogleLogo from "./GoogleLogo"; -import MicrosoftLogo from "./MicrosoftLogo"; -import SlackLogo from "./SlackLogo"; - -type Props = { - providerName: string; - size?: number; - color?: string; -}; - -function AuthLogo({ providerName, color, size = 16 }: Props) { - switch (providerName) { - case "slack": - return ( - - - - ); - - case "google": - return ( - - - - ); - - case "azure": - return ( - - - - ); - - default: - return null; - } -} - -const Logo = styled.div` - display: flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; -`; - -export default AuthLogo; diff --git a/app/components/PluginIcon.tsx b/app/components/PluginIcon.tsx new file mode 100644 index 000000000..53dec09ae --- /dev/null +++ b/app/components/PluginIcon.tsx @@ -0,0 +1,35 @@ +import * as React from "react"; +import styled from "styled-components"; +import PluginLoader from "~/utils/PluginLoader"; + +type Props = { + id: string; + size?: number; + color?: string; +}; + +function PluginIcon({ id, color, size = 24 }: Props) { + const plugin = PluginLoader.plugins[id]; + const Icon = plugin?.icon; + + if (Icon) { + return ( + + + + ); + } + + return null; +} + +const Wrapper = styled.div` + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 24px; + height: 24px; +`; + +export default PluginIcon; diff --git a/app/hooks/useSettingsConfig.ts b/app/hooks/useSettingsConfig.ts index 24e0928a0..e8258a314 100644 --- a/app/hooks/useSettingsConfig.ts +++ b/app/hooks/useSettingsConfig.ts @@ -34,8 +34,8 @@ import Tokens from "~/scenes/Settings/Tokens"; import Zapier from "~/scenes/Settings/Zapier"; import GoogleIcon from "~/components/Icons/GoogleIcon"; import ZapierIcon from "~/components/Icons/ZapierIcon"; +import PluginLoader from "~/utils/PluginLoader"; import isCloudHosted from "~/utils/isCloudHosted"; -import { loadPlugins } from "~/utils/plugins"; import { accountPreferencesPath } from "~/utils/routeHelpers"; import useCurrentTeam from "./useCurrentTeam"; import usePolicy from "./usePolicy"; @@ -160,7 +160,7 @@ const useSettingsConfig = () => { icon: ExportIcon, }, // Integrations - ...mapValues(loadPlugins(), (plugin) => { + ...mapValues(PluginLoader.plugins, (plugin) => { return { name: plugin.config.name, path: integrationSettingsPath(plugin.id), @@ -195,14 +195,7 @@ const useSettingsConfig = () => { icon: ZapierIcon, }, }), - [ - t, - can.createApiKey, - can.update, - can.createImport, - can.createExport, - can.createWebhookSubscription, - ] + [t, can.createApiKey, can.update, can.createImport, can.createExport] ); const enabledConfigs = React.useMemo( diff --git a/app/scenes/Login/AuthenticationProvider.tsx b/app/scenes/Login/AuthenticationProvider.tsx index 7e124c124..af218c06c 100644 --- a/app/scenes/Login/AuthenticationProvider.tsx +++ b/app/scenes/Login/AuthenticationProvider.tsx @@ -4,9 +4,9 @@ import { useTranslation } from "react-i18next"; import styled from "styled-components"; import { Client } from "@shared/types"; import { parseDomain } from "@shared/utils/domains"; -import AuthLogo from "~/components/AuthLogo"; import ButtonLarge from "~/components/ButtonLarge"; import InputLarge from "~/components/InputLarge"; +import PluginIcon from "~/components/PluginIcon"; import env from "~/env"; import { client } from "~/utils/ApiClient"; import Desktop from "~/utils/Desktop"; @@ -117,7 +117,7 @@ function AuthenticationProvider(props: Props) { (window.location.href = href)} - icon={} + icon={} fullwidth > {t("Continue with {{ authProviderName }}", { diff --git a/app/scenes/Settings/Security.tsx b/app/scenes/Settings/Security.tsx index 4a274e86e..d1ad8eeff 100644 --- a/app/scenes/Settings/Security.tsx +++ b/app/scenes/Settings/Security.tsx @@ -6,11 +6,11 @@ import * as React from "react"; import { useTranslation, Trans } from "react-i18next"; import { useTheme } from "styled-components"; import { TeamPreference } from "@shared/types"; -import AuthLogo from "~/components/AuthLogo"; import ConfirmationDialog from "~/components/ConfirmationDialog"; import Flex from "~/components/Flex"; import Heading from "~/components/Heading"; 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"; @@ -155,7 +155,7 @@ function Security() { key={provider.name} label={ - {" "} + {" "} {provider.displayName} } diff --git a/app/utils/PluginLoader.ts b/app/utils/PluginLoader.ts new file mode 100644 index 000000000..4f230a234 --- /dev/null +++ b/app/utils/PluginLoader.ts @@ -0,0 +1,51 @@ +import React from "react"; + +interface Plugin { + id: string; + config: { + name: string; + description: string; + requiredEnvVars?: string[]; + }; + settings: React.FC; + icon: React.FC<{ size?: number; fill?: string }>; +} + +export default class PluginLoader { + private static pluginsCache: { [id: string]: Plugin }; + + public static get plugins(): { [id: string]: Plugin } { + if (this.pluginsCache) { + return this.pluginsCache; + } + const plugins = {}; + + function importAll(r: any, property: string) { + Object.keys(r).forEach((key: string) => { + const id = key.split("/")[3]; + plugins[id] = plugins[id] || { + id, + }; + plugins[id][property] = r[key].default ?? React.lazy(r[key]); + }); + } + + importAll( + import.meta.glob("../../plugins/*/client/Settings.{ts,js,tsx,jsx}"), + "settings" + ); + importAll( + import.meta.glob("../../plugins/*/client/Icon.{ts,js,tsx,jsx}", { + eager: true, + }), + "icon" + ); + importAll( + import.meta.glob("../../plugins/*/plugin.json", { eager: true }), + "config" + ); + + this.pluginsCache = plugins; + return plugins; + } +} diff --git a/app/utils/plugins.ts b/app/utils/plugins.ts deleted file mode 100644 index 7d87c3546..000000000 --- a/app/utils/plugins.ts +++ /dev/null @@ -1,43 +0,0 @@ -interface Plugin { - id: string; - config: { - name: string; - description: string; - requiredEnvVars?: string[]; - }; - settings: React.FC; - icon: React.FC; -} - -export function loadPlugins(): { [id: string]: Plugin } { - const plugins = {}; - - function importAll(r: any, property: string) { - Object.keys(r).forEach((key: string) => { - const id = key.split("/")[3]; - plugins[id] = plugins[id] || { - id, - }; - plugins[id][property] = r[key].default; - }); - } - - importAll( - import.meta.glob("../../plugins/*/client/Settings.{ts,js,tsx,jsx}", { - eager: true, - }), - "settings" - ); - importAll( - import.meta.glob("../../plugins/*/client/Icon.{ts,js,tsx,jsx}", { - eager: true, - }), - "icon" - ); - importAll( - import.meta.glob("../../plugins/*/plugin.json", { eager: true }), - "config" - ); - - return plugins; -} diff --git a/server/routes/auth/providers/README.md b/docs/AUTHENTICATION_PROVIDERS.md similarity index 50% rename from server/routes/auth/providers/README.md rename to docs/AUTHENTICATION_PROVIDERS.md index d0bbcf98a..0c1b32d0a 100644 --- a/server/routes/auth/providers/README.md +++ b/docs/AUTHENTICATION_PROVIDERS.md @@ -1,13 +1,14 @@ # Authentication Providers -A new auth provider can be added with the addition of a single file in this -folder, and (optionally) a matching logo in `/app/components/AuthLogo/index.js` -that will appear on the signin button. +A new auth provider can be added with the addition of a plugin with a koa router +as the default export in /server/auth/[provider].ts and (optionally) a matching +logo in `/client/Icon.tsx` that will appear on the sign-in button. Auth providers generally use [Passport](http://www.passportjs.org/) strategies, -although they can use any custom logic if needed. See the `google` auth provider for the cleanest example of what is required – some rules: +although they can use any custom logic if needed. See the `google` auth provider +for the cleanest example of what is required – some rules: - The strategy name _must_ be lowercase - The strategy _must_ call the `accountProvisioner` command in the verify callback - The auth file _must_ export a `config` object with `name` and `enabled` keys -- The auth file _must_ have a default export with a koa-router \ No newline at end of file +- The auth file _must_ have a default export with a koa-router diff --git a/plugins/azure/client/Icon.tsx b/plugins/azure/client/Icon.tsx new file mode 100644 index 000000000..f0e0c3cfa --- /dev/null +++ b/plugins/azure/client/Icon.tsx @@ -0,0 +1,45 @@ +import * as React from "react"; + +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 */ + fill?: string; + className?: string; +}; + +function MicrosoftLogo({ size = 24, fill = "#FFF", className }: Props) { + return ( + + + + + + + ); +} + +export default MicrosoftLogo; diff --git a/plugins/azure/plugin.json b/plugins/azure/plugin.json new file mode 100644 index 000000000..2a33a206a --- /dev/null +++ b/plugins/azure/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "Microsoft", + "description": "Adds a Microsoft Azure authentication provider.", + "requiredEnvVars": ["AZURE_CLIENT_ID", "AZURE_CLIENT_SECRET"] +} diff --git a/plugins/azure/server/.babelrc b/plugins/azure/server/.babelrc new file mode 100644 index 000000000..c87001bc4 --- /dev/null +++ b/plugins/azure/server/.babelrc @@ -0,0 +1,3 @@ +{ + "extends": "../../../server/.babelrc" +} diff --git a/server/routes/auth/providers/azure.ts b/plugins/azure/server/auth/azure.ts similarity index 98% rename from server/routes/auth/providers/azure.ts rename to plugins/azure/server/auth/azure.ts index 9e71a9743..dbcee6727 100644 --- a/server/routes/auth/providers/azure.ts +++ b/plugins/azure/server/auth/azure.ts @@ -22,11 +22,6 @@ const router = new Router(); const providerName = "azure"; const scopes: string[] = []; -export const config = { - name: "Microsoft", - enabled: !!env.AZURE_CLIENT_ID, -}; - if (env.AZURE_CLIENT_ID && env.AZURE_CLIENT_SECRET) { const strategy = new AzureStrategy( { diff --git a/plugins/email/plugin.json b/plugins/email/plugin.json new file mode 100644 index 000000000..8346107b0 --- /dev/null +++ b/plugins/email/plugin.json @@ -0,0 +1,4 @@ +{ + "name": "Email", + "description": "Adds an email magic link authentication provider." +} diff --git a/plugins/email/server/.babelrc b/plugins/email/server/.babelrc new file mode 100644 index 000000000..c87001bc4 --- /dev/null +++ b/plugins/email/server/.babelrc @@ -0,0 +1,3 @@ +{ + "extends": "../../../server/.babelrc" +} diff --git a/server/routes/auth/providers/email.test.ts b/plugins/email/server/auth/email.test.ts similarity index 100% rename from server/routes/auth/providers/email.test.ts rename to plugins/email/server/auth/email.test.ts diff --git a/server/routes/auth/providers/email.ts b/plugins/email/server/auth/email.ts similarity index 98% rename from server/routes/auth/providers/email.ts rename to plugins/email/server/auth/email.ts index 47f40dc48..215bc417e 100644 --- a/server/routes/auth/providers/email.ts +++ b/plugins/email/server/auth/email.ts @@ -16,11 +16,6 @@ import { assertEmail, assertPresent } from "@server/validation"; const router = new Router(); -export const config = { - name: "Email", - enabled: true, -}; - router.post( "email", rateLimiter(RateLimiterStrategy.TenPerHour), diff --git a/plugins/google/client/Icon.tsx b/plugins/google/client/Icon.tsx new file mode 100644 index 000000000..a8f7cc3ac --- /dev/null +++ b/plugins/google/client/Icon.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; + +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 */ + fill?: string; + className?: string; +}; + +function GoogleLogo({ size = 24, fill = "currentColor", className }: Props) { + return ( + + + + ); +} + +export default GoogleLogo; diff --git a/plugins/google/plugin.json b/plugins/google/plugin.json new file mode 100644 index 000000000..f2b687c2a --- /dev/null +++ b/plugins/google/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "Google", + "description": "Adds a Google authentication provider.", + "requiredEnvVars": ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"] +} diff --git a/plugins/google/server/.babelrc b/plugins/google/server/.babelrc new file mode 100644 index 000000000..c87001bc4 --- /dev/null +++ b/plugins/google/server/.babelrc @@ -0,0 +1,3 @@ +{ + "extends": "../../../server/.babelrc" +} diff --git a/server/routes/auth/providers/google.ts b/plugins/google/server/auth/google.ts similarity index 95% rename from server/routes/auth/providers/google.ts rename to plugins/google/server/auth/google.ts index 7cd63b141..040f18890 100644 --- a/server/routes/auth/providers/google.ts +++ b/plugins/google/server/auth/google.ts @@ -21,17 +21,13 @@ import { } from "@server/utils/passport"; const router = new Router(); -const GOOGLE = "google"; +const providerName = "google"; + const scopes = [ "https://www.googleapis.com/auth/userinfo.profile", "https://www.googleapis.com/auth/userinfo.email", ]; -export const config = { - name: "Google", - enabled: !!env.GOOGLE_CLIENT_ID, -}; - type GoogleProfile = Profile & { email: string; picture: string; @@ -114,7 +110,7 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) { avatarUrl, }, authenticationProvider: { - name: GOOGLE, + name: providerName, providerId: domain ?? "", }, authentication: { @@ -136,13 +132,13 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) { router.get( "google", - passport.authenticate(GOOGLE, { + passport.authenticate(providerName, { accessType: "offline", prompt: "select_account consent", }) ); - router.get("google.callback", passportMiddleware(GOOGLE)); + router.get("google.callback", passportMiddleware(providerName)); } export default router; diff --git a/plugins/oidc/plugin.json b/plugins/oidc/plugin.json new file mode 100644 index 000000000..7d2365788 --- /dev/null +++ b/plugins/oidc/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "OIDC", + "description": "Adds an OpenID compatible authentication provider.", + "requiredEnvVars": [ + "OIDC_CLIENT_ID", + "OIDC_CLIENT_SECRET", + "OIDC_AUTH_URI", + "OIDC_TOKEN_URI", + "OIDC_USERINFO_URI", + "OIDC_DISPLAY_NAME" + ] +} diff --git a/plugins/oidc/server/.babelrc b/plugins/oidc/server/.babelrc new file mode 100644 index 000000000..c87001bc4 --- /dev/null +++ b/plugins/oidc/server/.babelrc @@ -0,0 +1,3 @@ +{ + "extends": "../../../server/.babelrc" +} diff --git a/server/routes/auth/providers/oidc.ts b/plugins/oidc/server/auth/oidc.ts similarity index 90% rename from server/routes/auth/providers/oidc.ts rename to plugins/oidc/server/auth/oidc.ts index 3203221c6..073a8cf7c 100644 --- a/server/routes/auth/providers/oidc.ts +++ b/plugins/oidc/server/auth/oidc.ts @@ -22,32 +22,30 @@ import { const router = new Router(); const providerName = "oidc"; -const OIDC_AUTH_URI = env.OIDC_AUTH_URI || ""; -const OIDC_TOKEN_URI = env.OIDC_TOKEN_URI || ""; -const OIDC_USERINFO_URI = env.OIDC_USERINFO_URI || ""; - -export const config = { - name: env.OIDC_DISPLAY_NAME, - enabled: !!env.OIDC_CLIENT_ID, -}; const scopes = env.OIDC_SCOPES.split(" "); Strategy.prototype.userProfile = async function (accessToken, done) { try { - const response = await request(OIDC_USERINFO_URI, accessToken); + const response = await request(env.OIDC_USERINFO_URI ?? "", accessToken); return done(null, response); } catch (err) { return done(err); } }; -if (env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET) { +if ( + env.OIDC_CLIENT_ID && + env.OIDC_CLIENT_SECRET && + env.OIDC_AUTH_URI && + env.OIDC_TOKEN_URI && + env.OIDC_USERINFO_URI +) { passport.use( providerName, new Strategy( { - authorizationURL: OIDC_AUTH_URI, - tokenURL: OIDC_TOKEN_URI, + authorizationURL: env.OIDC_AUTH_URI, + tokenURL: env.OIDC_TOKEN_URI, clientID: env.OIDC_CLIENT_ID, clientSecret: env.OIDC_CLIENT_SECRET, callbackURL: `${env.URL}/auth/${providerName}.callback`, @@ -144,4 +142,6 @@ if (env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET) { router.get(`${providerName}.callback`, passportMiddleware(providerName)); } +export const name = env.OIDC_DISPLAY_NAME; + export default router; diff --git a/plugins/slack/client/Icon.tsx b/plugins/slack/client/Icon.tsx index db79b9f3c..07088c863 100644 --- a/plugins/slack/client/Icon.tsx +++ b/plugins/slack/client/Icon.tsx @@ -4,19 +4,19 @@ 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; + fill?: string; }; -export default function Icon({ size = 24, color = "currentColor" }: Props) { +export default function Icon({ size = 24, fill = "currentColor" }: Props) { return ( - + ); } diff --git a/plugins/slack/client/Settings.tsx b/plugins/slack/client/Settings.tsx index 71194b286..7c918c519 100644 --- a/plugins/slack/client/Settings.tsx +++ b/plugins/slack/client/Settings.tsx @@ -56,7 +56,7 @@ function Slack() { const appName = env.APP_NAME; return ( - }> + }> Slack {error === "access_denied" && ( @@ -106,7 +106,7 @@ function Slack() { ]} redirectUri={`${env.URL}/auth/slack.commands`} state={team.id} - icon={} + icon={} /> )}

diff --git a/server/models/helpers/AuthenticationHelper.ts b/server/models/helpers/AuthenticationHelper.ts index e910aa955..9a56e6101 100644 --- a/server/models/helpers/AuthenticationHelper.ts +++ b/server/models/helpers/AuthenticationHelper.ts @@ -1,9 +1,70 @@ -import { find } from "lodash"; +/* eslint-disable @typescript-eslint/no-var-requires */ +import path from "path"; +import { glob } from "glob"; +import Router from "koa-router"; +import { find, sortBy } from "lodash"; import env from "@server/env"; import Team from "@server/models/Team"; -import providerConfigs from "../../routes/auth/providers"; + +export type AuthenticationProviderConfig = { + id: string; + name: string; + enabled: boolean; + router: Router; +}; export default class AuthenticationHelper { + private static providersCache: AuthenticationProviderConfig[]; + + /** + * Returns the enabled authentication provider configurations for the current + * installation. + * + * @returns A list of authentication providers + */ + public static get providers() { + if (this.providersCache) { + return this.providersCache; + } + + const authenticationProviderConfigs: AuthenticationProviderConfig[] = []; + const rootDir = env.ENVIRONMENT === "test" ? "" : "build"; + + glob + .sync(path.join(rootDir, "plugins/*/server/auth/!(*.test).[jt]s")) + .forEach((filePath: string) => { + const { default: authProvider, name } = require(path.join( + process.cwd(), + filePath + )); + const id = filePath.replace("build/", "").split("/")[1]; + const config = require(path.join( + process.cwd(), + rootDir, + "plugins", + id, + "plugin.json" + )); + + // Test the all required env vars are set for the auth provider + const enabled = (config.requiredEnvVars ?? []).every( + (name: string) => !!env[name] + ); + + if (enabled) { + authenticationProviderConfigs.push({ + id, + name: name ?? config.name, + enabled, + router: authProvider, + }); + } + }); + + this.providersCache = sortBy(authenticationProviderConfigs, "id"); + return this.providersCache; + } + /** * Returns the enabled authentication provider configurations for a team, * if given otherwise all enabled providers are returned. @@ -11,17 +72,12 @@ export default class AuthenticationHelper { * @param team The team to get enabled providers for * @returns A list of authentication providers */ - static providersForTeam(team?: Team) { + public static providersForTeam(team?: Team) { const isCloudHosted = env.isCloudHosted(); - return providerConfigs + return AuthenticationHelper.providers .sort((config) => (config.id === "email" ? 1 : -1)) .filter((config) => { - // Don't return authentication methods that are not enabled. - if (!config.enabled) { - return false; - } - // Guest sign-in is an exception as it does not have an authentication // provider using passport, instead it exists as a boolean option. if (config.id === "email") { diff --git a/server/presenters/providerConfig.ts b/server/presenters/providerConfig.ts index b18c1e43a..91fdba9db 100644 --- a/server/presenters/providerConfig.ts +++ b/server/presenters/providerConfig.ts @@ -1,5 +1,5 @@ import { signin } from "@shared/utils/routeHelpers"; -import { AuthenticationProviderConfig } from "@server/routes/auth/providers"; +import { AuthenticationProviderConfig } from "@server/models/helpers/AuthenticationHelper"; export default function presentProviderConfig( config: AuthenticationProviderConfig diff --git a/server/routes/api/authenticationProviders/authenticationProviders.ts b/server/routes/api/authenticationProviders/authenticationProviders.ts index 29a68c288..01c754e07 100644 --- a/server/routes/api/authenticationProviders/authenticationProviders.ts +++ b/server/routes/api/authenticationProviders/authenticationProviders.ts @@ -3,13 +3,13 @@ import { sequelize } from "@server/database/sequelize"; import auth from "@server/middlewares/authentication"; import validate from "@server/middlewares/validate"; import { AuthenticationProvider, Event } from "@server/models"; +import AuthenticationHelper from "@server/models/helpers/AuthenticationHelper"; import { authorize } from "@server/policies"; import { presentAuthenticationProvider, presentPolicies, } from "@server/presenters"; import { APIContext } from "@server/types"; -import allAuthenticationProviders from "../../auth/providers"; import * as T from "./schema"; const router = new Router(); @@ -95,7 +95,7 @@ router.post( "authenticationProviders" )) as AuthenticationProvider[]; - const data = allAuthenticationProviders + const data = AuthenticationHelper.providers .filter((p) => p.id !== "email") .map((p) => { const row = teamAuthenticationProviders.find((t) => t.name === p.id); diff --git a/server/routes/auth/index.ts b/server/routes/auth/index.ts index 4a71b944f..1a207f74e 100644 --- a/server/routes/auth/index.ts +++ b/server/routes/auth/index.ts @@ -6,8 +6,8 @@ import Router from "koa-router"; import { AuthenticationError } from "@server/errors"; import auth from "@server/middlewares/authentication"; import { Collection, Team, View } from "@server/models"; +import AuthenticationHelper from "@server/models/helpers/AuthenticationHelper"; import { AppState, AppContext, APIContext } from "@server/types"; -import providers from "./providers"; const app = new Koa(); const router = new Router(); @@ -15,10 +15,8 @@ const router = new Router(); router.use(passport.initialize()); // dynamically load available authentication provider routes -providers.forEach((provider) => { - if (provider.enabled) { - router.use("/", provider.router.routes()); - } +AuthenticationHelper.providers.forEach((provider) => { + router.use("/", provider.router.routes()); }); router.get("/redirect", auth(), async (ctx: APIContext) => { diff --git a/server/routes/auth/providers/index.ts b/server/routes/auth/providers/index.ts deleted file mode 100644 index 902db4cd0..000000000 --- a/server/routes/auth/providers/index.ts +++ /dev/null @@ -1,80 +0,0 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ -import path from "path"; -import { glob } from "glob"; -import Router from "koa-router"; -import { sortBy } from "lodash"; -import env from "@server/env"; -import { requireDirectory } from "@server/utils/fs"; - -export type AuthenticationProviderConfig = { - id: string; - name: string; - enabled: boolean; - router: Router; -}; - -const authenticationProviderConfigs: AuthenticationProviderConfig[] = []; - -requireDirectory<{ - default: Router; - config: { name: string; enabled: boolean }; -}>(__dirname).forEach(([module, id]) => { - const { config, default: router } = module; - - if (id === "index") { - return; - } - - if (!config) { - throw new Error( - `Auth providers must export a 'config' object, missing in ${id}` - ); - } - - if (!router || !router.routes) { - throw new Error( - `Default export of an auth provider must be a koa-router, missing in ${id}` - ); - } - - if (config && config.enabled) { - authenticationProviderConfigs.push({ - id, - name: config.name, - enabled: config.enabled, - router, - }); - } -}); - -// Temporarily also include plugins here until all auth methods are moved over. -glob - .sync( - (env.ENVIRONMENT === "test" ? "" : "build/") + - "plugins/*/server/auth/!(*.test).[jt]s" - ) - .forEach((filePath: string) => { - const authProvider = require(path.join(process.cwd(), filePath)).default; - const id = filePath.replace("build/", "").split("/")[1]; - const config = require(path.join( - process.cwd(), - env.ENVIRONMENT === "test" ? "" : "build", - "plugins", - id, - "plugin.json" - )); - - // Test the all required env vars are set for the auth provider - const enabled = (config.requiredEnvVars ?? []).every( - (name: string) => !!env[name] - ); - - authenticationProviderConfigs.push({ - id, - name: config.name, - enabled, - router: authProvider, - }); - }); - -export default sortBy(authenticationProviderConfigs, "id");