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:
Tom Moor
2026-07-16 19:58:32 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 8af56839d1
commit 3ffd088624
7 changed files with 512 additions and 481 deletions
+2 -1
View File
@@ -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,
+167 -164
View File
@@ -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);
}
}
}
)
)
);
+116 -115
View File
@@ -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,
-14
View File
@@ -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);
+189 -186
View File
@@ -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);
}
}
}
)
)
);
+2 -1
View File
@@ -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));
+36
View File
@@ -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.
*/