mirror of
https://github.com/outline/outline.git
synced 2026-08-03 13:27:25 +03:00
fix: Consistent usage of configured proxy with auth providers (#13018)
* fix: Consistent usage of configured proxy with auth providers * lint * fix: Harden proxy agent helper against missing OAuth2 client Degrade to a logged warning instead of crashing auth at boot if a strategy's internal _oauth2 client is ever absent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8af56839d1
commit
3ffd088624
@@ -19,6 +19,7 @@ import {
|
||||
getClientFromOAuthState,
|
||||
getUserFromOAuthState,
|
||||
startOAuthFlow,
|
||||
withProxyAgent,
|
||||
} from "@server/utils/passport";
|
||||
import config from "../../plugin.json";
|
||||
import env from "../env";
|
||||
@@ -232,7 +233,7 @@ if (env.AZURE_CLIENT_ID && env.AZURE_CLIENT_SECRET) {
|
||||
}
|
||||
}
|
||||
);
|
||||
passport.use(strategy);
|
||||
passport.use(withProxyAgent(strategy));
|
||||
router.get(
|
||||
config.id,
|
||||
startOAuthFlow,
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
getUserFromOAuthState,
|
||||
request,
|
||||
startOAuthFlow,
|
||||
withProxyAgent,
|
||||
} from "@server/utils/passport";
|
||||
import config from "../../plugin.json";
|
||||
import env from "../env";
|
||||
@@ -43,188 +44,190 @@ if (env.DISCORD_SERVER_ID) {
|
||||
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 (
|
||||
req: Request,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
params: { expires_in: number },
|
||||
_profile: unknown,
|
||||
done: (
|
||||
err: Error | null,
|
||||
user: User | null,
|
||||
result?: AuthenticationResult
|
||||
) => void
|
||||
) {
|
||||
const context = req.ctx;
|
||||
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(
|
||||
withProxyAgent(
|
||||
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 (
|
||||
req: Request,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
params: { expires_in: number },
|
||||
_profile: unknown,
|
||||
done: (
|
||||
err: Error | null,
|
||||
user: User | null,
|
||||
result?: AuthenticationResult
|
||||
) => void
|
||||
) {
|
||||
const context = req.ctx;
|
||||
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/guilds",
|
||||
"https://discord.com/api/users/@me",
|
||||
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();
|
||||
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);
|
||||
|
||||
/**
|
||||
* 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(
|
||||
* 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/${env.DISCORD_SERVER_ID}/member`,
|
||||
"https://discord.com/api/users/@me/guilds",
|
||||
accessToken
|
||||
);
|
||||
|
||||
/** If the user has a nickname in the server, use that as the name */
|
||||
if (guildMember.nick) {
|
||||
userName = guildMember.nick;
|
||||
}
|
||||
/** Find the guild that matches the configured server ID */
|
||||
const guild = guilds?.find((g) => g.id === env.DISCORD_SERVER_ID);
|
||||
|
||||
/** 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 the user is not in the server, throw an error */
|
||||
if (!guild) {
|
||||
throw DiscordGuildError();
|
||||
}
|
||||
|
||||
/** 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)
|
||||
);
|
||||
/**
|
||||
* 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`;
|
||||
}
|
||||
}
|
||||
|
||||
/** If the user does not have any of the roles, throw an error */
|
||||
if (!hasRole) {
|
||||
throw DiscordGuildRoleError();
|
||||
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));
|
||||
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,
|
||||
emailVerified: profile.verified,
|
||||
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(toError(err), null);
|
||||
// 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,
|
||||
emailVerified: profile.verified,
|
||||
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(toError(err), null);
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
getClientFromOAuthState,
|
||||
getUserFromOAuthState,
|
||||
startOAuthFlow,
|
||||
withProxyAgent,
|
||||
} from "@server/utils/passport";
|
||||
import config from "../../plugin.json";
|
||||
import env from "../env";
|
||||
@@ -43,130 +44,130 @@ type GoogleProfile = Profile & {
|
||||
};
|
||||
|
||||
if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
|
||||
passport.use(
|
||||
new GoogleStrategy(
|
||||
{
|
||||
clientID: env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
||||
callbackURL: `${env.URL}/auth/${config.id}.callback`,
|
||||
passReqToCallback: true,
|
||||
// @ts-expect-error StateStore
|
||||
store: new StateStore(),
|
||||
scope: scopes,
|
||||
},
|
||||
async function (
|
||||
req: Request,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
params: { expires_in: number; scope?: string },
|
||||
profile: GoogleProfile,
|
||||
done: (
|
||||
err: Error | null,
|
||||
user: User | null,
|
||||
result?: AuthenticationResult
|
||||
) => void
|
||||
) {
|
||||
const context = req.ctx;
|
||||
try {
|
||||
// "domain" is the Google Workspaces domain
|
||||
const domain = profile._json.hd;
|
||||
let team = await getTeamFromContext(context);
|
||||
const client = getClientFromOAuthState(context);
|
||||
const user =
|
||||
context.state?.auth?.user ?? (await getUserFromOAuthState(context));
|
||||
const strategy = new GoogleStrategy(
|
||||
{
|
||||
clientID: env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
||||
callbackURL: `${env.URL}/auth/${config.id}.callback`,
|
||||
passReqToCallback: true,
|
||||
// @ts-expect-error StateStore
|
||||
store: new StateStore(),
|
||||
scope: scopes,
|
||||
},
|
||||
async function (
|
||||
req: Request,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
params: { expires_in: number; scope?: string },
|
||||
profile: GoogleProfile,
|
||||
done: (
|
||||
err: Error | null,
|
||||
user: User | null,
|
||||
result?: AuthenticationResult
|
||||
) => void
|
||||
) {
|
||||
const context = req.ctx;
|
||||
try {
|
||||
// "domain" is the Google Workspaces domain
|
||||
const domain = profile._json.hd;
|
||||
let team = await getTeamFromContext(context);
|
||||
const client = getClientFromOAuthState(context);
|
||||
const user =
|
||||
context.state?.auth?.user ?? (await getUserFromOAuthState(context));
|
||||
|
||||
// No profile domain means a personal gmail account, and no team means
|
||||
// the request came from the apex domain rather than a workspace
|
||||
// subdomain. We can't infer the workspace from the domain, so resolve
|
||||
// it from the verified email's existing accounts instead.
|
||||
if (!domain && !team) {
|
||||
const existingAccounts = await User.findAll({
|
||||
attributes: ["id", "teamId"],
|
||||
where: { email: profile.email.toLowerCase() },
|
||||
include: [
|
||||
{
|
||||
association: "team",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const teamIds = new Set(
|
||||
existingAccounts.map((account) => account.teamId)
|
||||
);
|
||||
// No profile domain means a personal gmail account, and no team means
|
||||
// the request came from the apex domain rather than a workspace
|
||||
// subdomain. We can't infer the workspace from the domain, so resolve
|
||||
// it from the verified email's existing accounts instead.
|
||||
if (!domain && !team) {
|
||||
const existingAccounts = await User.findAll({
|
||||
attributes: ["id", "teamId"],
|
||||
where: { email: profile.email.toLowerCase() },
|
||||
include: [
|
||||
{
|
||||
association: "team",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const teamIds = new Set(
|
||||
existingAccounts.map((account) => account.teamId)
|
||||
);
|
||||
|
||||
// A personal gmail account cannot be used to create a new workspace.
|
||||
if (teamIds.size === 0) {
|
||||
throw GmailAccountCreationError();
|
||||
}
|
||||
|
||||
// When the email belongs to more than one workspace it is ambiguous
|
||||
// which to sign into, so the user must start from its subdomain.
|
||||
if (teamIds.size > 1) {
|
||||
throw TeamDomainRequiredError();
|
||||
}
|
||||
|
||||
// Belongs to exactly one workspace — resolve it and sign in there.
|
||||
team = existingAccounts[0].team;
|
||||
// A personal gmail account cannot be used to create a new workspace.
|
||||
if (teamIds.size === 0) {
|
||||
throw GmailAccountCreationError();
|
||||
}
|
||||
|
||||
// remove the TLD and form a subdomain from the remaining
|
||||
// subdomains of the form "foo.bar.com" are allowed as primary Google Workspaces domains
|
||||
// see https://support.google.com/nonprofits/thread/19685140/using-a-subdomain-as-a-primary-domain
|
||||
const subdomain = domain ? slugifyDomain(domain) : "";
|
||||
const teamName = capitalize(subdomain);
|
||||
// When the email belongs to more than one workspace it is ambiguous
|
||||
// which to sign into, so the user must start from its subdomain.
|
||||
if (teamIds.size > 1) {
|
||||
throw TeamDomainRequiredError();
|
||||
}
|
||||
|
||||
// Request a larger size profile picture than the default by tweaking
|
||||
// the query parameter.
|
||||
const avatarUrl = profile.picture.replace("=s96-c", "=s128-c");
|
||||
const locale = profile._json.locale;
|
||||
const language = locale
|
||||
? languages.find((l) => l.startsWith(locale))
|
||||
: undefined;
|
||||
|
||||
// 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,
|
||||
},
|
||||
user: {
|
||||
email: profile.email,
|
||||
// Google only returns confirmed workspace email addresses.
|
||||
emailVerified: true,
|
||||
name: profile.displayName,
|
||||
language,
|
||||
avatarUrl,
|
||||
},
|
||||
authenticationProvider: {
|
||||
name: config.id,
|
||||
providerId: domain ?? "",
|
||||
},
|
||||
authentication: {
|
||||
providerId: profile.id,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: params.expires_in,
|
||||
scopes: params.scope ? params.scope.split(" ") : scopes,
|
||||
},
|
||||
});
|
||||
|
||||
return done(null, result.user, { ...result, client });
|
||||
} catch (err) {
|
||||
return done(toError(err), null);
|
||||
// Belongs to exactly one workspace — resolve it and sign in there.
|
||||
team = existingAccounts[0].team;
|
||||
}
|
||||
|
||||
// remove the TLD and form a subdomain from the remaining
|
||||
// subdomains of the form "foo.bar.com" are allowed as primary Google Workspaces domains
|
||||
// see https://support.google.com/nonprofits/thread/19685140/using-a-subdomain-as-a-primary-domain
|
||||
const subdomain = domain ? slugifyDomain(domain) : "";
|
||||
const teamName = capitalize(subdomain);
|
||||
|
||||
// Request a larger size profile picture than the default by tweaking
|
||||
// the query parameter.
|
||||
const avatarUrl = profile.picture.replace("=s96-c", "=s128-c");
|
||||
const locale = profile._json.locale;
|
||||
const language = locale
|
||||
? languages.find((l) => l.startsWith(locale))
|
||||
: undefined;
|
||||
|
||||
// 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,
|
||||
},
|
||||
user: {
|
||||
email: profile.email,
|
||||
// Google only returns confirmed workspace email addresses.
|
||||
emailVerified: true,
|
||||
name: profile.displayName,
|
||||
language,
|
||||
avatarUrl,
|
||||
},
|
||||
authenticationProvider: {
|
||||
name: config.id,
|
||||
providerId: domain ?? "",
|
||||
},
|
||||
authentication: {
|
||||
providerId: profile.id,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: params.expires_in,
|
||||
scopes: params.scope ? params.scope.split(" ") : scopes,
|
||||
},
|
||||
});
|
||||
|
||||
return done(null, result.user, { ...result, client });
|
||||
} catch (err) {
|
||||
return done(toError(err), null);
|
||||
}
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
passport.use(withProxyAgent(strategy));
|
||||
|
||||
router.get(config.id, startOAuthFlow, async (ctx, next) => {
|
||||
const team = await getTeamFromContext(ctx, {
|
||||
includeHostQueryParam: true,
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { Request } from "express";
|
||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
import type OAuth2Strategy from "passport-oauth2";
|
||||
import { Strategy } from "passport-oauth2";
|
||||
|
||||
interface AuthenticateOptions {
|
||||
@@ -9,18 +7,6 @@ interface AuthenticateOptions {
|
||||
}
|
||||
|
||||
export class OIDCStrategy extends Strategy {
|
||||
constructor(
|
||||
options: OAuth2Strategy.StrategyOptionsWithRequest,
|
||||
verify: OAuth2Strategy.VerifyFunctionWithRequest
|
||||
) {
|
||||
super(options, verify);
|
||||
|
||||
if (process.env.https_proxy) {
|
||||
const httpsProxyAgent = new HttpsProxyAgent(process.env.https_proxy);
|
||||
this._oauth2.setAgent(httpsProxyAgent);
|
||||
}
|
||||
}
|
||||
|
||||
authenticate(req: Request, options: AuthenticateOptions) {
|
||||
options.originalQuery = req.query;
|
||||
super.authenticate(req, options);
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
getUserFromOAuthState,
|
||||
request,
|
||||
startOAuthFlow,
|
||||
withProxyAgent,
|
||||
} from "@server/utils/passport";
|
||||
import config from "../../plugin.json";
|
||||
import env from "../env";
|
||||
@@ -52,212 +53,214 @@ export function createOIDCRouter(
|
||||
|
||||
passport.use(
|
||||
config.id,
|
||||
new OIDCStrategy(
|
||||
{
|
||||
authorizationURL: endpoints.authorizationURL,
|
||||
tokenURL: endpoints.tokenURL,
|
||||
clientID: env.OIDC_CLIENT_ID!,
|
||||
clientSecret: env.OIDC_CLIENT_SECRET!,
|
||||
callbackURL: `${env.URL}/auth/${config.id}.callback`,
|
||||
passReqToCallback: true,
|
||||
scope: env.OIDC_SCOPES,
|
||||
// @ts-expect-error custom state store
|
||||
store: new StateStore(endpoints.pkce),
|
||||
state: true,
|
||||
pkce: endpoints.pkce ?? false,
|
||||
},
|
||||
// OpenID Connect standard profile claims can be found in the official
|
||||
// specification.
|
||||
// https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
|
||||
// Non-standard claims may be configured by individual identity providers.
|
||||
// Any claim supplied in response to the userinfo request will be
|
||||
// available on the `profile` parameter
|
||||
async function (
|
||||
req: Request,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
params: { expires_in: number; id_token: string; scope?: string },
|
||||
_profile: unknown,
|
||||
done: (
|
||||
err: Error | null,
|
||||
user: User | null,
|
||||
result?: AuthenticationResult
|
||||
) => void
|
||||
) {
|
||||
const context = req.ctx;
|
||||
try {
|
||||
// Some providers require a POST request to the userinfo endpoint, add them as exceptions here.
|
||||
const usePostMethod = [
|
||||
"https://api.dropboxapi.com/2/openid/userinfo",
|
||||
];
|
||||
withProxyAgent(
|
||||
new OIDCStrategy(
|
||||
{
|
||||
authorizationURL: endpoints.authorizationURL,
|
||||
tokenURL: endpoints.tokenURL,
|
||||
clientID: env.OIDC_CLIENT_ID!,
|
||||
clientSecret: env.OIDC_CLIENT_SECRET!,
|
||||
callbackURL: `${env.URL}/auth/${config.id}.callback`,
|
||||
passReqToCallback: true,
|
||||
scope: env.OIDC_SCOPES,
|
||||
// @ts-expect-error custom state store
|
||||
store: new StateStore(endpoints.pkce),
|
||||
state: true,
|
||||
pkce: endpoints.pkce ?? false,
|
||||
},
|
||||
// OpenID Connect standard profile claims can be found in the official
|
||||
// specification.
|
||||
// https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
|
||||
// Non-standard claims may be configured by individual identity providers.
|
||||
// Any claim supplied in response to the userinfo request will be
|
||||
// available on the `profile` parameter
|
||||
async function (
|
||||
req: Request,
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
params: { expires_in: number; id_token: string; scope?: string },
|
||||
_profile: unknown,
|
||||
done: (
|
||||
err: Error | null,
|
||||
user: User | null,
|
||||
result?: AuthenticationResult
|
||||
) => void
|
||||
) {
|
||||
const context = req.ctx;
|
||||
try {
|
||||
// Some providers require a POST request to the userinfo endpoint, add them as exceptions here.
|
||||
const usePostMethod = [
|
||||
"https://api.dropboxapi.com/2/openid/userinfo",
|
||||
];
|
||||
|
||||
const profile = await request(
|
||||
usePostMethod.includes(endpoints.userInfoURL) ? "POST" : "GET",
|
||||
endpoints.userInfoURL,
|
||||
accessToken
|
||||
);
|
||||
const profile = await request(
|
||||
usePostMethod.includes(endpoints.userInfoURL) ? "POST" : "GET",
|
||||
endpoints.userInfoURL,
|
||||
accessToken
|
||||
);
|
||||
|
||||
// Some providers, namely ADFS, don't provide anything more than the `sub` claim in the userinfo endpoint
|
||||
// So, we'll decode the params.id_token and see if that contains what we need.
|
||||
const token = (() => {
|
||||
try {
|
||||
const decoded = JWT.decode(params.id_token);
|
||||
// Some providers, namely ADFS, don't provide anything more than the `sub` claim in the userinfo endpoint
|
||||
// So, we'll decode the params.id_token and see if that contains what we need.
|
||||
const token = (() => {
|
||||
try {
|
||||
const decoded = JWT.decode(params.id_token);
|
||||
|
||||
if (!decoded || typeof decoded !== "object") {
|
||||
Logger.warn("Decoded id_token is not a valid object");
|
||||
if (!decoded || typeof decoded !== "object") {
|
||||
Logger.warn("Decoded id_token is not a valid object");
|
||||
return {};
|
||||
}
|
||||
|
||||
return decoded as {
|
||||
email?: string;
|
||||
email_verified?: boolean | string;
|
||||
preferred_username?: string;
|
||||
sub?: string;
|
||||
};
|
||||
} catch (err) {
|
||||
Logger.error("id_token decode threw error: ", toError(err));
|
||||
return {};
|
||||
}
|
||||
})();
|
||||
|
||||
return decoded as {
|
||||
email?: string;
|
||||
email_verified?: boolean | string;
|
||||
preferred_username?: string;
|
||||
sub?: string;
|
||||
};
|
||||
} catch (err) {
|
||||
Logger.error("id_token decode threw error: ", toError(err));
|
||||
return {};
|
||||
const email = profile.email ?? token.email ?? null;
|
||||
|
||||
if (!email) {
|
||||
throw AuthenticationError(
|
||||
`An email field was not returned in the profile or id_token parameter, but is required.`
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
const email = profile.email ?? token.email ?? null;
|
||||
// The email_verified claim is part of the OIDC standard claims.
|
||||
// https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
|
||||
const emailVerifiedClaim =
|
||||
profile.email_verified ?? token.email_verified;
|
||||
const emailVerified =
|
||||
emailVerifiedClaim === undefined
|
||||
? undefined
|
||||
: emailVerifiedClaim === true || emailVerifiedClaim === "true";
|
||||
|
||||
if (!email) {
|
||||
throw AuthenticationError(
|
||||
`An email field was not returned in the profile or id_token parameter, but is required.`
|
||||
);
|
||||
}
|
||||
const team = await getTeamFromContext(context);
|
||||
const client = getClientFromOAuthState(context);
|
||||
const user =
|
||||
context.state?.auth?.user ?? (await getUserFromOAuthState(context));
|
||||
const { domain } = parseEmail(email);
|
||||
|
||||
// The email_verified claim is part of the OIDC standard claims.
|
||||
// https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
|
||||
const emailVerifiedClaim =
|
||||
profile.email_verified ?? token.email_verified;
|
||||
const emailVerified =
|
||||
emailVerifiedClaim === undefined
|
||||
? undefined
|
||||
: emailVerifiedClaim === true || emailVerifiedClaim === "true";
|
||||
// Only a single OIDC provider is supported – find the existing, if any.
|
||||
const authenticationProvider = team
|
||||
? ((await AuthenticationProvider.findOne({
|
||||
where: {
|
||||
name: "oidc",
|
||||
teamId: team.id,
|
||||
providerId: domain,
|
||||
},
|
||||
})) ??
|
||||
(await AuthenticationProvider.findOne({
|
||||
where: {
|
||||
name: "oidc",
|
||||
teamId: team.id,
|
||||
},
|
||||
})))
|
||||
: undefined;
|
||||
|
||||
const team = await getTeamFromContext(context);
|
||||
const client = getClientFromOAuthState(context);
|
||||
const user =
|
||||
context.state?.auth?.user ?? (await getUserFromOAuthState(context));
|
||||
const { domain } = parseEmail(email);
|
||||
// Derive a providerId from the OIDC location if there is no existing provider.
|
||||
const oidcURL = new URL(endpoints.authorizationURL);
|
||||
const providerId =
|
||||
authenticationProvider?.providerId ?? oidcURL.hostname;
|
||||
|
||||
// Only a single OIDC provider is supported – find the existing, if any.
|
||||
const authenticationProvider = team
|
||||
? ((await AuthenticationProvider.findOne({
|
||||
where: {
|
||||
name: "oidc",
|
||||
teamId: team.id,
|
||||
providerId: domain,
|
||||
},
|
||||
})) ??
|
||||
(await AuthenticationProvider.findOne({
|
||||
where: {
|
||||
name: "oidc",
|
||||
teamId: team.id,
|
||||
},
|
||||
})))
|
||||
: undefined;
|
||||
if (!domain) {
|
||||
throw OIDCMalformedUserInfoError();
|
||||
}
|
||||
|
||||
// Derive a providerId from the OIDC location if there is no existing provider.
|
||||
const oidcURL = new URL(endpoints.authorizationURL);
|
||||
const providerId =
|
||||
authenticationProvider?.providerId ?? oidcURL.hostname;
|
||||
// remove the TLD and form a subdomain from the remaining
|
||||
const subdomain = slugifyDomain(domain);
|
||||
|
||||
if (!domain) {
|
||||
throw OIDCMalformedUserInfoError();
|
||||
}
|
||||
// Claim name can be overriden using an env variable.
|
||||
// Default is 'preferred_username' as per OIDC spec.
|
||||
// This will default to the profile.preferred_username, but will fall back to preferred_username from the id_token
|
||||
const username =
|
||||
get(profile, env.OIDC_USERNAME_CLAIM) ??
|
||||
get(token, env.OIDC_USERNAME_CLAIM);
|
||||
const name = profile.name || username || profile.username;
|
||||
const profileId = profile.sub ?? token.sub ?? profile.id;
|
||||
|
||||
// remove the TLD and form a subdomain from the remaining
|
||||
const subdomain = slugifyDomain(domain);
|
||||
if (!name) {
|
||||
throw AuthenticationError(
|
||||
`Neither a ${env.OIDC_USERNAME_CLAIM}, "name" or "username" was returned in the profile loaded from ${endpoints.userInfoURL}, but at least one is required.`
|
||||
);
|
||||
}
|
||||
if (!profileId) {
|
||||
throw AuthenticationError(
|
||||
`A user id was not returned in the profile loaded from ${endpoints.userInfoURL}, searched in "sub" and "id" fields.`
|
||||
);
|
||||
}
|
||||
|
||||
// Claim name can be overriden using an env variable.
|
||||
// Default is 'preferred_username' as per OIDC spec.
|
||||
// This will default to the profile.preferred_username, but will fall back to preferred_username from the id_token
|
||||
const username =
|
||||
get(profile, env.OIDC_USERNAME_CLAIM) ??
|
||||
get(token, env.OIDC_USERNAME_CLAIM);
|
||||
const name = profile.name || username || profile.username;
|
||||
const profileId = profile.sub ?? token.sub ?? profile.id;
|
||||
// Check if the picture field is a Base64 data URL and filter it out
|
||||
// to avoid validation errors in the User model
|
||||
let avatarUrl = profile.picture;
|
||||
if (profile.picture && isBase64Url(profile.picture)) {
|
||||
Logger.debug(
|
||||
"authentication",
|
||||
"Filtering out Base64 data URL from avatar",
|
||||
{
|
||||
email,
|
||||
}
|
||||
);
|
||||
avatarUrl = null;
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
throw AuthenticationError(
|
||||
`Neither a ${env.OIDC_USERNAME_CLAIM}, "name" or "username" was returned in the profile loaded from ${endpoints.userInfoURL}, but at least one is required.`
|
||||
);
|
||||
}
|
||||
if (!profileId) {
|
||||
throw AuthenticationError(
|
||||
`A user id was not returned in the profile loaded from ${endpoints.userInfoURL}, searched in "sub" and "id" fields.`
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the picture field is a Base64 data URL and filter it out
|
||||
// to avoid validation errors in the User model
|
||||
let avatarUrl = profile.picture;
|
||||
if (profile.picture && isBase64Url(profile.picture)) {
|
||||
Logger.debug(
|
||||
"authentication",
|
||||
"Filtering out Base64 data URL from avatar",
|
||||
{
|
||||
email,
|
||||
}
|
||||
);
|
||||
avatarUrl = null;
|
||||
}
|
||||
|
||||
const ctx = createContext({
|
||||
ip: context.ip,
|
||||
user,
|
||||
authType: context.state?.auth?.type,
|
||||
});
|
||||
const result = await accountProvisioner(ctx, {
|
||||
team: {
|
||||
teamId: team?.id,
|
||||
name: env.APP_NAME,
|
||||
domain,
|
||||
subdomain,
|
||||
},
|
||||
user: {
|
||||
name,
|
||||
email,
|
||||
emailVerified,
|
||||
avatarUrl,
|
||||
},
|
||||
authenticationProvider: {
|
||||
name: config.id,
|
||||
providerId,
|
||||
},
|
||||
authentication: {
|
||||
providerId: profileId,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: params.expires_in,
|
||||
scopes: params.scope ? params.scope.split(" ") : scopes,
|
||||
},
|
||||
});
|
||||
// Persist the id_token so a later RP-initiated logout can pass it as
|
||||
// the `id_token_hint`, allowing the provider to scope the logout to
|
||||
// this session rather than terminating its global SSO session.
|
||||
if (endpoints.logoutURL && params.id_token) {
|
||||
context.cookies.set("oidcIdToken", params.id_token, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: env.isProduction,
|
||||
path: OIDC_LOGOUT_PATH,
|
||||
domain: getCookieDomain(
|
||||
context.request.hostname,
|
||||
env.isCloudHosted
|
||||
),
|
||||
expires: addMonths(new Date(), 3),
|
||||
const ctx = createContext({
|
||||
ip: context.ip,
|
||||
user,
|
||||
authType: context.state?.auth?.type,
|
||||
});
|
||||
}
|
||||
const result = await accountProvisioner(ctx, {
|
||||
team: {
|
||||
teamId: team?.id,
|
||||
name: env.APP_NAME,
|
||||
domain,
|
||||
subdomain,
|
||||
},
|
||||
user: {
|
||||
name,
|
||||
email,
|
||||
emailVerified,
|
||||
avatarUrl,
|
||||
},
|
||||
authenticationProvider: {
|
||||
name: config.id,
|
||||
providerId,
|
||||
},
|
||||
authentication: {
|
||||
providerId: profileId,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: params.expires_in,
|
||||
scopes: params.scope ? params.scope.split(" ") : scopes,
|
||||
},
|
||||
});
|
||||
// Persist the id_token so a later RP-initiated logout can pass it as
|
||||
// the `id_token_hint`, allowing the provider to scope the logout to
|
||||
// this session rather than terminating its global SSO session.
|
||||
if (endpoints.logoutURL && params.id_token) {
|
||||
context.cookies.set("oidcIdToken", params.id_token, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: env.isProduction,
|
||||
path: OIDC_LOGOUT_PATH,
|
||||
domain: getCookieDomain(
|
||||
context.request.hostname,
|
||||
env.isCloudHosted
|
||||
),
|
||||
expires: addMonths(new Date(), 3),
|
||||
});
|
||||
}
|
||||
|
||||
return done(null, result.user, { ...result, client });
|
||||
} catch (err) {
|
||||
return done(toError(err), null);
|
||||
return done(null, result.user, { ...result, client });
|
||||
} catch (err) {
|
||||
return done(toError(err), null);
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
getUserFromOAuthState,
|
||||
StateStore,
|
||||
startOAuthFlow,
|
||||
withProxyAgent,
|
||||
} from "@server/utils/passport";
|
||||
import { parseEmail } from "@shared/utils/email";
|
||||
import env from "../env";
|
||||
@@ -137,7 +138,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
|
||||
// For some reason the author made the strategy name capatilised, I don't know
|
||||
// why but we need everything lowercase so we just monkey-patch it here.
|
||||
strategy.name = providerName;
|
||||
passport.use(strategy);
|
||||
passport.use(withProxyAgent(strategy));
|
||||
|
||||
router.get("slack", startOAuthFlow, passport.authenticate(providerName));
|
||||
router.get("slack.callback", passportMiddleware(providerName));
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import crypto from "node:crypto";
|
||||
import { addMinutes, subMinutes } from "date-fns";
|
||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
import type { JwtPayload } from "jsonwebtoken";
|
||||
import type { Context, Next } from "koa";
|
||||
import type { Strategy } from "@outlinewiki/koa-passport";
|
||||
import type {
|
||||
StateStoreStoreCallback,
|
||||
StateStoreVerifyCallback,
|
||||
@@ -11,6 +13,7 @@ import { errToString, toError } from "@shared/utils/error";
|
||||
import { Client } from "@shared/types";
|
||||
import { getCookieDomain, parseDomain } from "@shared/utils/domains";
|
||||
import env from "@server/env";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Team, User } from "@server/models";
|
||||
import Redis from "@server/storage/redis";
|
||||
import { InternalError, OAuthStateMismatchError } from "../errors";
|
||||
@@ -94,6 +97,39 @@ export async function startOAuthFlow(ctx: Context, next: Next) {
|
||||
return next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes a passport OAuth2 strategy's outbound token and userinfo requests
|
||||
* through the HTTPS proxy configured in the environment, when present.
|
||||
*
|
||||
* Passport OAuth2 strategies build their own HTTP client and do not honor the
|
||||
* standard proxy environment variables, so the agent must be attached to the
|
||||
* strategy's internal `_oauth2` client explicitly. This is a no-op when no
|
||||
* proxy is configured.
|
||||
*
|
||||
* @param strategy the OAuth2-based passport strategy to configure.
|
||||
* @returns the same strategy instance, for convenient chaining.
|
||||
*/
|
||||
export function withProxyAgent<T extends Strategy>(strategy: T): T {
|
||||
const proxy = process.env.https_proxy ?? process.env.HTTPS_PROXY;
|
||||
if (!proxy) {
|
||||
return strategy;
|
||||
}
|
||||
|
||||
// `_oauth2` is a private internal, so degrade gracefully if upstream removes it.
|
||||
// @ts-expect-error _oauth2 is not part of the public strategy type
|
||||
const client = strategy._oauth2;
|
||||
if (typeof client?.setAgent !== "function") {
|
||||
Logger.warn(
|
||||
"Unable to apply HTTPS proxy to auth strategy; the OAuth2 client is not available",
|
||||
{ strategy: strategy.name }
|
||||
);
|
||||
return strategy;
|
||||
}
|
||||
|
||||
client.setAgent(new HttpsProxyAgent(proxy));
|
||||
return strategy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Passport OAuth state store backed by signed state and a CSRF nonce cookie.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user