Files
outline/plugins/discord/server/auth/discord.ts
2024-07-07 10:54:19 -04:00

216 lines
7.3 KiB
TypeScript

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 { parseEmail } from "@shared/utils/email";
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 { domain } = parseEmail(email);
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;