diff --git a/.env.sample b/.env.sample index ecdaacd08..b3eb0d7f3 100644 --- a/.env.sample +++ b/.env.sample @@ -127,6 +127,26 @@ GITHUB_APP_NAME= GITHUB_APP_ID= GITHUB_APP_PRIVATE_KEY= +# To configure Discord auth, you'll need to create a Discord Application at +# => https://discord.com/developers/applications/ +# +# When configuring the Client ID, add a redirect URL under "OAuth2": +# https:///api/discord.callback +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= + +# DISCORD_SERVER_ID should be the ID of the Discord server that Outline is +# integrated with. +# Used to verify that the user is a member of the server as well as server +# metadata such as nicknames, server icon and name. +DISCORD_SERVER_ID= + +# DISCORD_SERVER_ROLES should be a comma separated list of role IDs that are +# allowed to access Outline. If this is not set, all members of the server +# will be allowed to access Outline. +# DISCORD_SERVER_ID and DISCORD_SERVER_ROLES must be set together. +DISCORD_SERVER_ROLES= + # –––––––––––––––– OPTIONAL –––––––––––––––– # Base64 encoded private key and certificate for HTTPS termination. This is only diff --git a/package.json b/package.json index 12aa1f4b4..9df242b2b 100644 --- a/package.json +++ b/package.json @@ -328,6 +328,7 @@ "babel-plugin-tsconfig-paths-module-resolver": "^1.0.4", "browserslist-to-esbuild": "^1.2.0", "concurrently": "^8.2.2", + "discord-api-types": "^0.37.87", "eslint": "^8.57.0", "eslint-config-prettier": "^8.10.0", "eslint-import-resolver-typescript": "^3.6.1", diff --git a/plugins/discord/client/Icon.tsx b/plugins/discord/client/Icon.tsx new file mode 100644 index 000000000..585f13867 --- /dev/null +++ b/plugins/discord/client/Icon.tsx @@ -0,0 +1,30 @@ +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 DiscordLogo({ size = 24, fill = "currentColor", className }: Props) { + return ( + + + + ); +} + +export default DiscordLogo; diff --git a/plugins/discord/plugin.json b/plugins/discord/plugin.json new file mode 100644 index 000000000..e075c6227 --- /dev/null +++ b/plugins/discord/plugin.json @@ -0,0 +1,6 @@ +{ + "id": "discord", + "name": "Discord", + "priority": 10, + "description": "Adds a Discord authentication provider." +} \ No newline at end of file diff --git a/plugins/discord/server/auth/discord.ts b/plugins/discord/server/auth/discord.ts new file mode 100644 index 000000000..b8a570ff3 --- /dev/null +++ b/plugins/discord/server/auth/discord.ts @@ -0,0 +1,215 @@ +import passport from "@outlinewiki/koa-passport"; +import type { + RESTGetAPICurrentUserGuildsResult, + RESTGetAPICurrentUserResult, + RESTGetCurrentUserGuildMemberResult, +} from "discord-api-types/v10"; +import type { Context } from "koa"; +import Router from "koa-router"; + +import { Strategy } from "passport-oauth2"; +import { languages } from "@shared/i18n"; +import { slugifyDomain } from "@shared/utils/domains"; +import slugify from "@shared/utils/slugify"; +import accountProvisioner from "@server/commands/accountProvisioner"; +import { InvalidRequestError, TeamDomainRequiredError } from "@server/errors"; +import passportMiddleware from "@server/middlewares/passport"; +import { User } from "@server/models"; +import { AuthenticationResult } from "@server/types"; +import { + StateStore, + getTeamFromContext, + getClientFromContext, + request, +} from "@server/utils/passport"; +import config from "../../plugin.json"; +import env from "../env"; +import { DiscordGuildError, DiscordGuildRoleError } from "../errors"; + +const router = new Router(); + +const scope = ["identify", "email"]; + +if (env.DISCORD_SERVER_ID) { + scope.push("guilds", "guilds.members.read"); +} + +if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) { + passport.use( + config.id, + new Strategy( + { + clientID: env.DISCORD_CLIENT_ID, + clientSecret: env.DISCORD_CLIENT_SECRET, + passReqToCallback: true, + scope, + // @ts-expect-error custom state store + store: new StateStore(), + state: true, + callbackURL: `${env.URL}/auth/${config.id}.callback`, + authorizationURL: "https://discord.com/api/oauth2/authorize", + tokenURL: "https://discord.com/api/oauth2/token", + pkce: false, + }, + async function ( + ctx: Context, + accessToken: string, + refreshToken: string, + params: { expires_in: number }, + _profile: unknown, + done: ( + err: Error | null, + user: User | null, + result?: AuthenticationResult + ) => void + ) { + try { + const team = await getTeamFromContext(ctx); + const client = getClientFromContext(ctx); + /** Fetch the user's profile */ + const profile: RESTGetAPICurrentUserResult = await request( + "https://discord.com/api/users/@me", + accessToken + ); + + const email = profile.email; + if (!email) { + /** We have the email scope, so this should never happen */ + throw InvalidRequestError("Discord profile email is missing"); + } + const parts = email.toLowerCase().split("@"); + const domain = parts.length && parts[1]; + + if (!domain) { + throw TeamDomainRequiredError(); + } + + /** Determine the user's language from the locale */ + const { locale } = profile; + const language = locale + ? languages.find((l) => l.startsWith(locale)) + : undefined; + + /** Default user and team names metadata */ + let userName = profile.username; + let teamName = "Wiki"; + let userAvatarUrl: string = `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.png`; + let teamAvatarUrl: string | undefined = undefined; + let subdomain = slugifyDomain(domain); + + /** + * If a Discord server is configured, we will check if the user is a member of the server + * Additionally, we can get the user's nickname in the server if it exists + */ + if (env.DISCORD_SERVER_ID) { + /** Fetch the guilds a user is in */ + const guilds: RESTGetAPICurrentUserGuildsResult = await request( + "https://discord.com/api/users/@me/guilds", + accessToken + ); + + /** Find the guild that matches the configured server ID */ + const guild = guilds?.find((g) => g.id === env.DISCORD_SERVER_ID); + + /** If the user is not in the server, throw an error */ + if (!guild) { + throw DiscordGuildError(); + } + + /** + * Get the guild's icon + * https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints + **/ + if (guild.icon) { + const isGif = guild.icon.startsWith("a_"); + if (isGif) { + teamAvatarUrl = `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.gif`; + } else { + teamAvatarUrl = `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png`; + } + } + + /** Guild Name */ + teamName = guild.name; + subdomain = slugify(guild.name); + + /** Fetch the user's member object in the server for nickname and roles */ + const guildMember: RESTGetCurrentUserGuildMemberResult = + await request( + `https://discord.com/api/users/@me/guilds/${env.DISCORD_SERVER_ID}/member`, + accessToken + ); + + /** If the user has a nickname in the server, use that as the name */ + if (guildMember.nick) { + userName = guildMember.nick; + } + + /** If the user has a custom avatar in the server, use that as the avatar */ + if (guildMember.avatar) { + userAvatarUrl = `https://cdn.discordapp.com/guilds/${guild.id}/users/${profile.id}/avatars/${guildMember.avatar}.png`; + } + + /** If server roles are configured, check if the user has any of the roles */ + if (env.DISCORD_SERVER_ROLES) { + const { roles } = guildMember; + const hasRole = roles?.some((role) => + env.DISCORD_SERVER_ROLES?.includes(role) + ); + + /** If the user does not have any of the roles, throw an error */ + if (!hasRole) { + throw DiscordGuildRoleError(); + } + } + } + + // 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 result = await accountProvisioner({ + ip: ctx.ip, + team: { + teamId: team?.id, + name: teamName, + domain, + subdomain, + avatarUrl: teamAvatarUrl, + }, + user: { + email, + name: userName, + language, + avatarUrl: userAvatarUrl, + }, + authenticationProvider: { + name: config.id, + providerId: env.DISCORD_SERVER_ID ?? "", + }, + authentication: { + providerId: profile.id, + accessToken, + refreshToken, + expiresIn: params.expires_in, + scopes: scope, + }, + }); + return done(null, result.user, { ...result, client }); + } catch (err) { + return done(err, null); + } + } + ) + ); + + router.get( + config.id, + passport.authenticate(config.id, { + scope, + prompt: "consent", + }) + ); + router.get(`${config.id}.callback`, passportMiddleware(config.id)); +} + +export default router; diff --git a/plugins/discord/server/discord.ts b/plugins/discord/server/discord.ts new file mode 100644 index 000000000..774186bee --- /dev/null +++ b/plugins/discord/server/discord.ts @@ -0,0 +1,18 @@ +import invariant from "invariant"; +import OAuthClient from "@server/utils/oauth"; +import env from "./env"; + +export default class DiscordClient extends OAuthClient { + endpoints = { + authorize: "https://discord.com/oauth2/authorize", + token: "https://discord.com/api/oauth2/token", + userinfo: "https://discord.com/api/users/@me", + }; + + constructor() { + invariant(env.DISCORD_CLIENT_ID, "DISCORD_CLIENT_ID is required"); + invariant(env.DISCORD_CLIENT_SECRET, "DISCORD_CLIENT_SECRET is required"); + + super(env.DISCORD_CLIENT_ID, env.DISCORD_CLIENT_SECRET); + } +} diff --git a/plugins/discord/server/env.ts b/plugins/discord/server/env.ts new file mode 100644 index 000000000..d1fab4842 --- /dev/null +++ b/plugins/discord/server/env.ts @@ -0,0 +1,35 @@ +import { IsOptional } from "class-validator"; +import { Environment } from "@server/env"; +import environment from "@server/utils/environment"; +import { CannotUseWithout } from "@server/utils/validators"; + +class DiscordPluginEnvironment extends Environment { + /** + * Discord OAuth2 client credentials. To enable authentication with Discord. + */ + @IsOptional() + @CannotUseWithout("DISCORD_CLIENT_ID") + public DISCORD_CLIENT_ID = this.toOptionalString( + environment.DISCORD_CLIENT_ID + ); + + @IsOptional() + @CannotUseWithout("DISCORD_CLIENT_SECRET") + public DISCORD_CLIENT_SECRET = this.toOptionalString( + environment.DISCORD_CLIENT_SECRET + ); + + @IsOptional() + @CannotUseWithout("DISCORD_CLIENT_SECRET") + public DISCORD_SERVER_ID = this.toOptionalString( + environment.DISCORD_SERVER_ID + ); + + @CannotUseWithout("DISCORD_SERVER_ID") + @IsOptional() + public DISCORD_SERVER_ROLES = this.toOptionalCommaList( + environment.DISCORD_SERVER_ROLES + ); +} + +export default new DiscordPluginEnvironment(); diff --git a/plugins/discord/server/errors.ts b/plugins/discord/server/errors.ts new file mode 100644 index 000000000..a588705c0 --- /dev/null +++ b/plugins/discord/server/errors.ts @@ -0,0 +1,17 @@ +import httpErrors from "http-errors"; + +export function DiscordGuildError( + message = "User is not a member of the required Discord server" +) { + return httpErrors(400, message, { + id: "discord_guild_error", + }); +} + +export function DiscordGuildRoleError( + message = "User does not have the required role from the Discord server" +) { + return httpErrors(400, message, { + id: "discord_guild_role_error", + }); +} diff --git a/plugins/discord/server/index.ts b/plugins/discord/server/index.ts new file mode 100644 index 000000000..e2144f597 --- /dev/null +++ b/plugins/discord/server/index.ts @@ -0,0 +1,14 @@ +import { PluginManager, Hook } from "@server/utils/PluginManager"; +import config from "../plugin.json"; +import router from "./auth/discord"; +import env from "./env"; + +const enabled = !!env.DISCORD_CLIENT_ID && !!env.DISCORD_CLIENT_SECRET; + +if (enabled) { + PluginManager.add({ + ...config, + type: Hook.AuthProvider, + value: { router, id: config.id }, + }); +} diff --git a/plugins/oidc/server/auth/oidc.ts b/plugins/oidc/server/auth/oidc.ts index 9b4f0abdf..61c9a29a1 100644 --- a/plugins/oidc/server/auth/oidc.ts +++ b/plugins/oidc/server/auth/oidc.ts @@ -14,9 +14,9 @@ import { User } from "@server/models"; import { AuthenticationResult } from "@server/types"; import { StateStore, - request, getTeamFromContext, getClientFromContext, + request, } from "@server/utils/passport"; import config from "../../plugin.json"; import env from "../env"; @@ -24,15 +24,6 @@ import env from "../env"; const router = new Router(); const scopes = env.OIDC_SCOPES.split(" "); -Strategy.prototype.userProfile = async function (accessToken, done) { - try { - const response = await request(env.OIDC_USERINFO_URI ?? "", accessToken); - return done(null, response); - } catch (err) { - return done(err); - } -}; - const authorizationParams = Strategy.prototype.authorizationParams; Strategy.prototype.authorizationParams = function (options) { return { @@ -81,7 +72,7 @@ if ( accessToken: string, refreshToken: string, params: { expires_in: number }, - profile: Record, + _profile: unknown, done: ( err: Error | null, user: User | null, @@ -89,6 +80,11 @@ if ( ) => void ) { try { + const profile = await request( + env.OIDC_USERINFO_URI ?? "", + accessToken + ); + if (!profile.email) { throw AuthenticationError( `An email field was not returned in the profile parameter, but is required.` diff --git a/server/env.ts b/server/env.ts index a5f98fac9..c6ffdd2c2 100644 --- a/server/env.ts +++ b/server/env.ts @@ -625,6 +625,10 @@ export class Environment { return value ? value : undefined; } + protected toOptionalCommaList(value: string | undefined) { + return value ? value.split(",").map((item) => item.trim()) : undefined; + } + protected toOptionalNumber(value: string | undefined) { return value ? parseInt(value, 10) : undefined; } diff --git a/yarn.lock b/yarn.lock index 87889d977..3b35e45bf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7778,6 +7778,11 @@ dir-glob@^3.0.1: dependencies: path-type "^4.0.0" +discord-api-types@^0.37.87: + version "0.37.87" + resolved "https://registry.yarnpkg.com/discord-api-types/-/discord-api-types-0.37.87.tgz#10169b42f156f149997b8eae964cbf96e387442f" + integrity sha512-9KfluFS1J22RuezzouGs2t5X/Yu3jNpmRJrG+dvu1R9R+wfxlhq1RXr/OjjVL6d4rr7Ez92yNWiImf/qX3GV1g== + dnd-core@^16.0.1: version "16.0.1" resolved "https://registry.yarnpkg.com/dnd-core/-/dnd-core-16.0.1.tgz#a1c213ed08961f6bd1959a28bb76f1a868360d19"