Enhanced Discord Support (#7005)

* Add Discord Provider Prototype

* Add Discord Logo

* Add Plugin to Plugin Manager

* fixed discord auth support and added icon

* add csv role verification

* grab discord server icon and test server id and roles

* subdomain derived from server name

* use discord server specific nickname if available

* Cleanup and comment

* move discord api types to dev deps

* cleanup of server vs default params

* remove commented out lines

* revert envv.development

* revert in vscode

* update yarn lock

* add gif support for discord server icon

* add comment with docs link

* add env section for discord

* fix errors and clarify env

* add new cannot use without

* fix suggestions
This commit is contained in:
Sebastian Pietschner
2024-06-17 00:04:25 +10:00
committed by GitHub
parent 379d2cb788
commit a9f1086422
12 changed files with 372 additions and 11 deletions

View File

@@ -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://<URL>/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

View File

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

View File

@@ -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 (
<svg
fill={fill}
width={size}
height={size}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M17.5535 7.01557C16.5023 6.5343 15.3925 6.19287 14.2526 6C14.0966 6.27886 13.9555 6.56577 13.8298 6.85952C12.6155 6.67655 11.3807 6.67655 10.1664 6.85952C10.0406 6.5658 9.89949 6.27889 9.74357 6C8.60289 6.1945 7.4924 6.53674 6.44013 7.01809C4.3511 10.1088 3.78479 13.1228 4.06794 16.0941C5.29133 16.9979 6.66066 17.6854 8.11639 18.1265C8.44417 17.6856 8.73422 17.2179 8.98346 16.7283C8.51007 16.5515 8.05317 16.3334 7.61804 16.0764C7.73256 15.9934 7.84456 15.9078 7.95279 15.8247C9.21891 16.4202 10.6008 16.7289 12 16.7289C13.3991 16.7289 14.781 16.4202 16.0472 15.8247C16.1566 15.9141 16.2686 15.9997 16.3819 16.0764C15.9459 16.3338 15.4882 16.5523 15.014 16.7296C15.2629 17.2189 15.553 17.6862 15.881 18.1265C17.338 17.6871 18.7084 17 19.932 16.0953C20.2642 12.6497 19.3644 9.66336 17.5535 7.01557ZM9.34212 14.2668C8.55307 14.2668 7.90119 13.5507 7.90119 12.6698C7.90119 11.7889 8.53042 11.0665 9.3396 11.0665C10.1488 11.0665 10.7956 11.7889 10.7818 12.6698C10.7679 13.5507 10.1463 14.2668 9.34212 14.2668ZM14.6578 14.2668C13.8675 14.2668 13.2182 13.5507 13.2182 12.6698C13.2182 11.7889 13.8474 11.0665 14.6578 11.0665C15.4683 11.0665 16.1101 11.7889 16.0962 12.6698C16.0824 13.5507 15.462 14.2668 14.6578 14.2668Z"
/>
</svg>
);
}
export default DiscordLogo;

View File

@@ -0,0 +1,6 @@
{
"id": "discord",
"name": "Discord",
"priority": 10,
"description": "Adds a Discord authentication provider."
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, string>,
_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.`

View File

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

View File

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