mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
879d2b8198
* fix: Unable to link secondary auth provider on custom domain * doc * chore: Custom -> Apex transfer token * Refactor, address security concerns * Ensure OAuth intent is single-use * Secure OAuth state actor binding * Use scrypt for OAuth actor session binding
239 lines
8.0 KiB
TypeScript
239 lines
8.0 KiB
TypeScript
import passport from "@outlinewiki/koa-passport";
|
||
import { isURL } from "class-validator";
|
||
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 type { User } from "@server/models";
|
||
import type { AuthenticationResult } from "@server/types";
|
||
import {
|
||
StateStore,
|
||
getTeamFromContext,
|
||
getClientFromOAuthState,
|
||
getUserFromOAuthState,
|
||
request,
|
||
startOAuthFlow,
|
||
} from "@server/utils/passport";
|
||
import config from "../../plugin.json";
|
||
import env from "../env";
|
||
import { DiscordGuildError, DiscordGuildRoleError } from "../errors";
|
||
import { createContext } from "@server/context";
|
||
|
||
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?prompt=none",
|
||
tokenURL: "https://discord.com/api/oauth2/token",
|
||
pkce: false,
|
||
},
|
||
async function (
|
||
context: 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(context);
|
||
const client = getClientFromOAuthState(context);
|
||
/** Fetch the user's profile */
|
||
const profile: RESTGetAPICurrentUserResult = await request(
|
||
"GET",
|
||
"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;
|
||
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(
|
||
"GET",
|
||
"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`;
|
||
}
|
||
}
|
||
|
||
teamName = guild.name;
|
||
subdomain = slugify(guild.name);
|
||
|
||
/** If the guild name is a URL, use the subdomain instead – we do not allow URLs in names. */
|
||
if (
|
||
isURL(teamName, {
|
||
require_host: false,
|
||
require_protocol: false,
|
||
})
|
||
) {
|
||
teamName = subdomain;
|
||
}
|
||
|
||
/** Fetch the user's member object in the server for nickname and roles */
|
||
const guildMember: RESTGetCurrentUserGuildMemberResult =
|
||
await request(
|
||
"GET",
|
||
`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();
|
||
}
|
||
}
|
||
}
|
||
const user =
|
||
context.state?.auth?.user ?? (await getUserFromOAuthState(context));
|
||
|
||
// 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 ctx = createContext({
|
||
ip: context.ip,
|
||
user,
|
||
authType: context.state?.auth?.type,
|
||
});
|
||
const result = await accountProvisioner(ctx, {
|
||
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,
|
||
startOAuthFlow,
|
||
passport.authenticate(config.id, {
|
||
scope,
|
||
})
|
||
);
|
||
router.get(`${config.id}.callback`, passportMiddleware(config.id));
|
||
}
|
||
|
||
export default router;
|