216 lines
7.3 KiB
TypeScript
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;
|