mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-04-03 22:37:20 +02:00
* feat: add resolveAppConfigForUser utility for tenant-scoped auth config
TypeScript utility in packages/api that wraps getAppConfig in
tenantStorage.run() when the user has a tenantId, falling back to
baseOnly for new users or non-tenant deployments. Uses DI pattern
(getAppConfig passed as parameter) for testability.
Auth flows apply role-level overrides only (userId not passed)
because user/group principal resolution is deferred to post-auth.
* feat: tenant-scoped app config in auth login flows
All auth strategies (LDAP, SAML, OpenID, social login) now use a
two-phase domain check consistent with requestPasswordReset:
1. Fast-fail with base config (memory-cached, zero DB queries)
2. DB user lookup
3. Tenant-scoped re-check via resolveAppConfigForUser (only when
user has a tenantId; otherwise reuse base config)
This preserves the original fast-fail protection against globally
blocked domains while enabling tenant-specific config overrides.
OpenID error ordering preserved: AUTH_FAILED checked before domain
re-check so users with wrong providers get the correct error type.
registerUser unchanged (baseOnly, no user identity yet).
* test: add tenant-scoped config tests for auth strategies
Add resolveAppConfig.spec.ts in packages/api with 8 tests:
- baseOnly fallback for null/undefined/no-tenant users
- tenant-scoped config with role and tenantId
- ALS context propagation verified inside getAppConfig callback
- undefined role with tenantId edge case
Update strategy and AuthService tests to mock resolveAppConfigForUser
via @librechat/api. Tests verify two-phase domain check behavior:
fast-fail before DB, tenant re-check after. Non-tenant users reuse
base config without calling resolveAppConfigForUser.
* refactor: skip redundant domain re-check for non-tenant users
Guard the second isEmailDomainAllowed call with appConfig !== baseConfig
in SAML, OpenID, and social strategies. For non-tenant users the tenant
config is the same base config object, so the second check is a no-op.
Narrow eslint-disable in resolveAppConfig.spec.ts to the specific
require line instead of blanket file-level suppression.
* fix: address review findings — consistency, tests, and ordering
- Consolidate duplicate require('@librechat/api') in AuthService.js
- Add two-phase domain check to LDAP (base fast-fail before findUser),
making all strategies consistent with PR description
- Add appConfig !== baseConfig guard to requestPasswordReset second
domain check, consistent with SAML/OpenID/social strategies
- Move SAML provider check before tenant config resolution to avoid
unnecessary resolveAppConfigForUser call for wrong-provider users
- Add tenant domain rejection tests to SAML, OpenID, and social specs
verifying that tenant config restrictions actually block login
- Add error propagation tests to resolveAppConfig.spec.ts
- Remove redundant mockTenantStorage alias in resolveAppConfig.spec.ts
- Narrow eslint-disable to specific require line
* test: add tenant domain rejection test for LDAP strategy
Covers the appConfig !== baseConfig && !isEmailDomainAllowed path,
consistent with SAML, OpenID, and social strategy specs.
* refactor: rename resolveAppConfig to app/resolve per AGENTS.md
Rename resolveAppConfig.ts → resolve.ts and
resolveAppConfig.spec.ts → resolve.spec.ts to align with
the project's concise naming convention.
* fix: remove fragile reference-equality guard, add logging and docs
Remove appConfig !== baseConfig guard from all strategies and
requestPasswordReset. The guard relied on implicit cache-backend
identity semantics (in-memory Keyv returns same object reference)
that would silently break with Redis or cloned configs. The second
isEmailDomainAllowed call is a cheap synchronous check — always
running it is clearer and eliminates the coupling.
Add audit logging to requestPasswordReset domain blocks (base and
tenant), consistent with all auth strategies.
Extract duplicated error construction into makeDomainDeniedError().
Wrap resolveAppConfigForUser in requestPasswordReset with try/catch
to prevent DB errors from leaking to the client via the controller's
generic catch handler.
Document the dual tenantId propagation (ALS for DB isolation,
explicit param for cache key) in resolveAppConfigForUser JSDoc.
Add comment documenting the LDAP error-type ordering change
(cross-provider users from blocked domains now get 'domain not
allowed' instead of AUTH_FAILED).
Assert resolveAppConfigForUser is not called on LDAP provider
mismatch path.
* fix: return generic response for tenant domain block in password reset
Tenant-scoped domain rejection in requestPasswordReset now returns the
same generic "If an account with that email exists..." response instead
of an Error. This prevents user-enumeration: an attacker cannot
distinguish between "email not found" and "tenant blocks this domain"
by comparing HTTP responses.
The base-config fast-fail (pre-user-lookup) still returns an Error
since it fires before any user existence is revealed.
* docs: document phase 1 vs phase 2 domain check behavior in JSDoc
Phase 1 (base config, pre-findUser) intentionally returns Error/400
to reveal globally blocked domains without confirming user existence.
Phase 2 (tenant config, post-findUser) returns generic 200 to prevent
user-enumeration. This distinction is now explicit in the JSDoc.
854 lines
28 KiB
JavaScript
854 lines
28 KiB
JavaScript
const undici = require('undici');
|
|
const { get } = require('lodash');
|
|
const fetch = require('node-fetch');
|
|
const passport = require('passport');
|
|
const client = require('openid-client');
|
|
const jwtDecode = require('jsonwebtoken/decode');
|
|
const { HttpsProxyAgent } = require('https-proxy-agent');
|
|
const { hashToken, logger } = require('@librechat/data-schemas');
|
|
const { Strategy: OpenIDStrategy } = require('openid-client/passport');
|
|
const { CacheKeys, ErrorTypes, SystemRoles } = require('librechat-data-provider');
|
|
const {
|
|
isEnabled,
|
|
logHeaders,
|
|
safeStringify,
|
|
findOpenIDUser,
|
|
getBalanceConfig,
|
|
isEmailDomainAllowed,
|
|
resolveAppConfigForUser,
|
|
} = require('@librechat/api');
|
|
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
|
const { findUser, createUser, updateUser } = require('~/models');
|
|
const { getAppConfig } = require('~/server/services/Config');
|
|
const getLogStores = require('~/cache/getLogStores');
|
|
|
|
/**
|
|
* @typedef {import('openid-client').ClientMetadata} ClientMetadata
|
|
* @typedef {import('openid-client').Configuration} Configuration
|
|
**/
|
|
|
|
/**
|
|
* @param {string} url
|
|
* @param {client.CustomFetchOptions} options
|
|
*/
|
|
async function customFetch(url, options) {
|
|
const urlStr = url.toString();
|
|
logger.debug(`[openidStrategy] Request to: ${urlStr}`);
|
|
const debugOpenId = isEnabled(process.env.DEBUG_OPENID_REQUESTS);
|
|
if (debugOpenId) {
|
|
logger.debug(`[openidStrategy] Request method: ${options.method || 'GET'}`);
|
|
logger.debug(`[openidStrategy] Request headers: ${logHeaders(options.headers)}`);
|
|
if (options.body) {
|
|
let bodyForLogging = '';
|
|
if (options.body instanceof URLSearchParams) {
|
|
bodyForLogging = options.body.toString();
|
|
} else if (typeof options.body === 'string') {
|
|
bodyForLogging = options.body;
|
|
} else {
|
|
bodyForLogging = safeStringify(options.body);
|
|
}
|
|
logger.debug(`[openidStrategy] Request body: ${bodyForLogging}`);
|
|
}
|
|
}
|
|
|
|
try {
|
|
/** @type {undici.RequestInit} */
|
|
let fetchOptions = options;
|
|
if (process.env.PROXY) {
|
|
logger.info(`[openidStrategy] proxy agent configured: ${process.env.PROXY}`);
|
|
fetchOptions = {
|
|
...options,
|
|
dispatcher: new undici.ProxyAgent(process.env.PROXY),
|
|
};
|
|
}
|
|
|
|
const response = await undici.fetch(url, fetchOptions);
|
|
|
|
if (debugOpenId) {
|
|
logger.debug(`[openidStrategy] Response status: ${response.status} ${response.statusText}`);
|
|
logger.debug(`[openidStrategy] Response headers: ${logHeaders(response.headers)}`);
|
|
}
|
|
|
|
if (response.status === 200 && response.headers.has('www-authenticate')) {
|
|
const wwwAuth = response.headers.get('www-authenticate');
|
|
logger.warn(`[openidStrategy] Non-standard WWW-Authenticate header found in successful response (200 OK): ${wwwAuth}.
|
|
This violates RFC 7235 and may cause issues with strict OAuth clients. Removing header for compatibility.`);
|
|
|
|
/** Cloned response without the WWW-Authenticate header */
|
|
const responseBody = await response.arrayBuffer();
|
|
const newHeaders = new Headers();
|
|
for (const [key, value] of response.headers.entries()) {
|
|
if (key.toLowerCase() !== 'www-authenticate') {
|
|
newHeaders.append(key, value);
|
|
}
|
|
}
|
|
|
|
return new Response(responseBody, {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers: newHeaders,
|
|
});
|
|
}
|
|
|
|
return response;
|
|
} catch (error) {
|
|
logger.error(`[openidStrategy] Fetch error: ${error.message}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/** @typedef {Configuration | null} */
|
|
let openidConfig = null;
|
|
|
|
/**
|
|
* Custom OpenID Strategy
|
|
*
|
|
* Note: Originally overrode currentUrl() to work around Express 4's req.host not including port.
|
|
* With Express 5, req.host now includes the port by default, but we continue to use DOMAIN_SERVER
|
|
* for consistency and explicit configuration control.
|
|
* More info: https://github.com/panva/openid-client/pull/713
|
|
*/
|
|
class CustomOpenIDStrategy extends OpenIDStrategy {
|
|
currentUrl(req) {
|
|
const hostAndProtocol = process.env.DOMAIN_SERVER;
|
|
return new URL(`${hostAndProtocol}${req.originalUrl ?? req.url}`);
|
|
}
|
|
|
|
authorizationRequestParams(req, options) {
|
|
const params = super.authorizationRequestParams(req, options);
|
|
if (options?.state && !params.has('state')) {
|
|
params.set('state', options.state);
|
|
}
|
|
|
|
if (process.env.OPENID_AUDIENCE) {
|
|
params.set('audience', process.env.OPENID_AUDIENCE);
|
|
logger.debug(
|
|
`[openidStrategy] Adding audience to authorization request: ${process.env.OPENID_AUDIENCE}`,
|
|
);
|
|
}
|
|
|
|
/** Generate nonce for federated providers that require it */
|
|
const shouldGenerateNonce = isEnabled(process.env.OPENID_GENERATE_NONCE);
|
|
if (shouldGenerateNonce && !params.has('nonce') && this._sessionKey) {
|
|
const crypto = require('crypto');
|
|
const nonce = crypto.randomBytes(16).toString('hex');
|
|
params.set('nonce', nonce);
|
|
logger.debug('[openidStrategy] Generated nonce for federated provider:', nonce);
|
|
}
|
|
|
|
return params;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Exchange the access token for a new access token using the on-behalf-of flow if required.
|
|
* @param {Configuration} config
|
|
* @param {string} accessToken access token to be exchanged if necessary
|
|
* @param {string} sub - The subject identifier of the user. usually found as "sub" in the claims of the token
|
|
* @param {boolean} fromCache - Indicates whether to use cached tokens.
|
|
* @returns {Promise<string>} The new access token if exchanged, otherwise the original access token.
|
|
*/
|
|
const exchangeAccessTokenIfNeeded = async (config, accessToken, sub, fromCache = false) => {
|
|
const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS);
|
|
const onBehalfFlowRequired = isEnabled(process.env.OPENID_ON_BEHALF_FLOW_FOR_USERINFO_REQUIRED);
|
|
if (onBehalfFlowRequired) {
|
|
if (fromCache) {
|
|
const cachedToken = await tokensCache.get(sub);
|
|
if (cachedToken) {
|
|
return cachedToken.access_token;
|
|
}
|
|
}
|
|
const grantResponse = await client.genericGrantRequest(
|
|
config,
|
|
'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
|
{
|
|
scope: process.env.OPENID_ON_BEHALF_FLOW_USERINFO_SCOPE || 'user.read',
|
|
assertion: accessToken,
|
|
requested_token_use: 'on_behalf_of',
|
|
},
|
|
);
|
|
await tokensCache.set(
|
|
sub,
|
|
{
|
|
access_token: grantResponse.access_token,
|
|
},
|
|
grantResponse.expires_in * 1000,
|
|
);
|
|
return grantResponse.access_token;
|
|
}
|
|
return accessToken;
|
|
};
|
|
|
|
/**
|
|
* get user info from openid provider
|
|
* @param {Configuration} config
|
|
* @param {string} accessToken access token
|
|
* @param {string} sub - The subject identifier of the user. usually found as "sub" in the claims of the token
|
|
* @returns {Promise<Object|null>}
|
|
*/
|
|
const getUserInfo = async (config, accessToken, sub) => {
|
|
try {
|
|
const exchangedAccessToken = await exchangeAccessTokenIfNeeded(config, accessToken, sub);
|
|
return await client.fetchUserInfo(config, exchangedAccessToken, sub);
|
|
} catch (error) {
|
|
logger.error('[openidStrategy] getUserInfo: Error fetching user info:', error);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Downloads an image from a URL using an access token.
|
|
* @param {string} url
|
|
* @param {Configuration} config
|
|
* @param {string} accessToken access token
|
|
* @param {string} sub - The subject identifier of the user. usually found as "sub" in the claims of the token
|
|
* @returns {Promise<Buffer | string>} The image buffer or an empty string if the download fails.
|
|
*/
|
|
const downloadImage = async (url, config, accessToken, sub) => {
|
|
const exchangedAccessToken = await exchangeAccessTokenIfNeeded(config, accessToken, sub, true);
|
|
if (!url) {
|
|
return '';
|
|
}
|
|
|
|
try {
|
|
const options = {
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${exchangedAccessToken}`,
|
|
},
|
|
};
|
|
|
|
if (process.env.PROXY) {
|
|
options.agent = new HttpsProxyAgent(process.env.PROXY);
|
|
}
|
|
|
|
const response = await fetch(url, options);
|
|
|
|
if (response.ok) {
|
|
const buffer = await response.buffer();
|
|
return buffer;
|
|
} else {
|
|
throw new Error(`${response.statusText} (HTTP ${response.status})`);
|
|
}
|
|
} catch (error) {
|
|
logger.error(
|
|
`[openidStrategy] downloadImage: Error downloading image at URL "${url}": ${error}`,
|
|
);
|
|
return '';
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Determines the full name of a user based on OpenID userinfo and environment configuration.
|
|
*
|
|
* @param {Object} userinfo - The user information object from OpenID Connect
|
|
* @param {string} [userinfo.given_name] - The user's first name
|
|
* @param {string} [userinfo.family_name] - The user's last name
|
|
* @param {string} [userinfo.username] - The user's username
|
|
* @param {string} [userinfo.email] - The user's email address
|
|
* @returns {string} The determined full name of the user
|
|
*/
|
|
function getFullName(userinfo) {
|
|
if (process.env.OPENID_NAME_CLAIM) {
|
|
return userinfo[process.env.OPENID_NAME_CLAIM];
|
|
}
|
|
|
|
if (userinfo.given_name && userinfo.family_name) {
|
|
return `${userinfo.given_name} ${userinfo.family_name}`;
|
|
}
|
|
|
|
if (userinfo.given_name) {
|
|
return userinfo.given_name;
|
|
}
|
|
|
|
if (userinfo.family_name) {
|
|
return userinfo.family_name;
|
|
}
|
|
|
|
return userinfo.username || userinfo.email;
|
|
}
|
|
|
|
/**
|
|
* Resolves the user identifier from OpenID claims.
|
|
* Configurable via OPENID_EMAIL_CLAIM; defaults to: email -> preferred_username -> upn.
|
|
*
|
|
* @param {Object} userinfo - The user information object from OpenID Connect
|
|
* @returns {string|undefined} The resolved identifier string
|
|
*/
|
|
function getOpenIdEmail(userinfo) {
|
|
const claimKey = process.env.OPENID_EMAIL_CLAIM?.trim();
|
|
if (claimKey) {
|
|
const value = userinfo[claimKey];
|
|
if (typeof value === 'string' && value) {
|
|
return value;
|
|
}
|
|
if (value !== undefined && value !== null) {
|
|
logger.warn(
|
|
`[openidStrategy] OPENID_EMAIL_CLAIM="${claimKey}" resolved to a non-string value (type: ${typeof value}). Falling back to: email -> preferred_username -> upn.`,
|
|
);
|
|
} else {
|
|
logger.warn(
|
|
`[openidStrategy] OPENID_EMAIL_CLAIM="${claimKey}" not present in userinfo. Falling back to: email -> preferred_username -> upn.`,
|
|
);
|
|
}
|
|
}
|
|
const fallback = userinfo.email || userinfo.preferred_username || userinfo.upn;
|
|
return typeof fallback === 'string' ? fallback : undefined;
|
|
}
|
|
|
|
/**
|
|
* Converts an input into a string suitable for a username.
|
|
* If the input is a string, it will be returned as is.
|
|
* If the input is an array, elements will be joined with underscores.
|
|
* In case of undefined or other falsy values, a default value will be returned.
|
|
*
|
|
* @param {string | string[] | undefined} input - The input value to be converted into a username.
|
|
* @param {string} [defaultValue=''] - The default value to return if the input is falsy.
|
|
* @returns {string} The processed input as a string suitable for a username.
|
|
*/
|
|
function convertToUsername(input, defaultValue = '') {
|
|
if (typeof input === 'string') {
|
|
return input;
|
|
} else if (Array.isArray(input)) {
|
|
return input.join('_');
|
|
}
|
|
|
|
return defaultValue;
|
|
}
|
|
|
|
/**
|
|
* Exchange the access token for a Graph-scoped token using the On-Behalf-Of (OBO) flow.
|
|
*
|
|
* The original access token has the app's own audience (api://<client-id>), which Microsoft Graph
|
|
* rejects. This exchange produces a token with audience https://graph.microsoft.com and the
|
|
* minimum delegated scope (User.Read) required by /me/getMemberObjects.
|
|
*
|
|
* Uses a dedicated cache key (`${sub}:overage`) to avoid collisions with other OBO exchanges
|
|
* in the codebase (userinfo, Graph principal search).
|
|
*
|
|
* @param {string} accessToken - The original access token from the OpenID tokenset
|
|
* @param {string} sub - The subject identifier for cache keying
|
|
* @returns {Promise<string>} A Graph-scoped access token
|
|
* @see https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-on-behalf-of-flow
|
|
*/
|
|
async function exchangeTokenForOverage(accessToken, sub) {
|
|
if (!openidConfig) {
|
|
throw new Error('[openidStrategy] OpenID config not initialized; cannot exchange OBO token');
|
|
}
|
|
|
|
const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS);
|
|
const cacheKey = `${sub}:overage`;
|
|
|
|
const cached = await tokensCache.get(cacheKey);
|
|
if (cached?.access_token) {
|
|
logger.debug('[openidStrategy] Using cached Graph token for overage resolution');
|
|
return cached.access_token;
|
|
}
|
|
|
|
const grantResponse = await client.genericGrantRequest(
|
|
openidConfig,
|
|
'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
|
{
|
|
scope: 'https://graph.microsoft.com/User.Read',
|
|
assertion: accessToken,
|
|
requested_token_use: 'on_behalf_of',
|
|
},
|
|
);
|
|
|
|
if (!grantResponse.access_token) {
|
|
throw new Error(
|
|
'[openidStrategy] OBO exchange succeeded but returned no access_token; cannot call Graph API',
|
|
);
|
|
}
|
|
|
|
const ttlMs =
|
|
Number.isFinite(grantResponse.expires_in) && grantResponse.expires_in > 0
|
|
? grantResponse.expires_in * 1000
|
|
: 3600 * 1000;
|
|
|
|
await tokensCache.set(cacheKey, { access_token: grantResponse.access_token }, ttlMs);
|
|
|
|
return grantResponse.access_token;
|
|
}
|
|
|
|
/**
|
|
* Resolve Azure AD groups when group overage is in effect (groups moved to _claim_names/_claim_sources).
|
|
*
|
|
* NOTE: Microsoft recommends treating _claim_names/_claim_sources as a signal only and using Microsoft Graph
|
|
* to resolve group membership instead of calling the endpoint in _claim_sources directly.
|
|
*
|
|
* Before calling Graph, the access token is exchanged via the OBO flow to obtain a token with the
|
|
* correct audience (https://graph.microsoft.com) and User.Read scope.
|
|
*
|
|
* @param {string} accessToken - Access token from the OpenID tokenset (app audience)
|
|
* @param {string} sub - The subject identifier of the user (for OBO exchange and cache keying)
|
|
* @returns {Promise<string[] | null>} Resolved group IDs or null on failure
|
|
* @see https://learn.microsoft.com/en-us/entra/identity-platform/access-token-claims-reference#groups-overage-claim
|
|
* @see https://learn.microsoft.com/en-us/graph/api/directoryobject-getmemberobjects
|
|
*/
|
|
async function resolveGroupsFromOverage(accessToken, sub) {
|
|
try {
|
|
if (!accessToken) {
|
|
logger.error('[openidStrategy] Access token missing; cannot resolve group overage');
|
|
return null;
|
|
}
|
|
|
|
const graphToken = await exchangeTokenForOverage(accessToken, sub);
|
|
|
|
// Use /me/getMemberObjects so least-privileged delegated permission User.Read is sufficient
|
|
// when resolving the signed-in user's group membership.
|
|
const url = 'https://graph.microsoft.com/v1.0/me/getMemberObjects';
|
|
|
|
logger.debug(
|
|
`[openidStrategy] Detected group overage, resolving groups via Microsoft Graph getMemberObjects: ${url}`,
|
|
);
|
|
|
|
const fetchOptions = {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${graphToken}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ securityEnabledOnly: false }),
|
|
};
|
|
|
|
if (process.env.PROXY) {
|
|
const { ProxyAgent } = undici;
|
|
fetchOptions.dispatcher = new ProxyAgent(process.env.PROXY);
|
|
}
|
|
|
|
const response = await undici.fetch(url, fetchOptions);
|
|
if (!response.ok) {
|
|
logger.error(
|
|
`[openidStrategy] Failed to resolve groups via Microsoft Graph getMemberObjects: HTTP ${response.status} ${response.statusText}`,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
const values = Array.isArray(data?.value) ? data.value : null;
|
|
if (!values) {
|
|
logger.error(
|
|
'[openidStrategy] Unexpected response format when resolving groups via Microsoft Graph getMemberObjects',
|
|
);
|
|
return null;
|
|
}
|
|
const groupIds = values.filter((id) => typeof id === 'string');
|
|
|
|
logger.debug(
|
|
`[openidStrategy] Successfully resolved ${groupIds.length} groups via Microsoft Graph getMemberObjects`,
|
|
);
|
|
return groupIds;
|
|
} catch (err) {
|
|
logger.error(
|
|
'[openidStrategy] Error resolving groups via Microsoft Graph getMemberObjects:',
|
|
err,
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process OpenID authentication tokenset and userinfo
|
|
* This is the core logic extracted from the passport strategy callback
|
|
* Can be reused by both the passport strategy and proxy authentication
|
|
*
|
|
* @param {Object} tokenset - The OpenID tokenset containing access_token, id_token, etc.
|
|
* @param {boolean} existingUsersOnly - If true, only existing users will be processed
|
|
* @returns {Promise<Object>} The authenticated user object with tokenset
|
|
*/
|
|
async function processOpenIDAuth(tokenset, existingUsersOnly = false) {
|
|
const claims = tokenset.claims ? tokenset.claims() : tokenset;
|
|
const userinfo = {
|
|
...claims,
|
|
};
|
|
|
|
if (tokenset.access_token) {
|
|
const providerUserinfo = await getUserInfo(openidConfig, tokenset.access_token, claims.sub);
|
|
Object.assign(userinfo, providerUserinfo);
|
|
}
|
|
|
|
const email = getOpenIdEmail(userinfo);
|
|
|
|
const baseConfig = await getAppConfig({ baseOnly: true });
|
|
if (!isEmailDomainAllowed(email, baseConfig?.registration?.allowedDomains)) {
|
|
logger.error(
|
|
`[OpenID Strategy] Authentication blocked - email domain not allowed [Identifier: ${email}]`,
|
|
);
|
|
throw new Error('Email domain not allowed');
|
|
}
|
|
|
|
const result = await findOpenIDUser({
|
|
findUser,
|
|
email: email,
|
|
openidId: claims.sub || userinfo.sub,
|
|
idOnTheSource: claims.oid || userinfo.oid,
|
|
strategyName: 'openidStrategy',
|
|
});
|
|
let user = result.user;
|
|
const error = result.error;
|
|
|
|
if (error) {
|
|
throw new Error(ErrorTypes.AUTH_FAILED);
|
|
}
|
|
|
|
const appConfig = user?.tenantId ? await resolveAppConfigForUser(getAppConfig, user) : baseConfig;
|
|
|
|
if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
|
|
logger.error(
|
|
`[OpenID Strategy] Authentication blocked - email domain not allowed [Identifier: ${email}]`,
|
|
);
|
|
throw new Error('Email domain not allowed');
|
|
}
|
|
|
|
const fullName = getFullName(userinfo);
|
|
|
|
const requiredRole = process.env.OPENID_REQUIRED_ROLE;
|
|
let resolvedOverageGroups = null;
|
|
|
|
if (requiredRole) {
|
|
const requiredRoles = requiredRole
|
|
.split(',')
|
|
.map((role) => role.trim())
|
|
.filter(Boolean);
|
|
const requiredRoleParameterPath = process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH;
|
|
const requiredRoleTokenKind = process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND;
|
|
|
|
let decodedToken = '';
|
|
if (requiredRoleTokenKind === 'access' && tokenset.access_token) {
|
|
decodedToken = jwtDecode(tokenset.access_token);
|
|
} else if (requiredRoleTokenKind === 'id' && tokenset.id_token) {
|
|
decodedToken = jwtDecode(tokenset.id_token);
|
|
}
|
|
|
|
let roles = get(decodedToken, requiredRoleParameterPath);
|
|
|
|
// Handle Azure AD group overage for ID token groups: when hasgroups or _claim_* indicate overage,
|
|
// resolve groups via Microsoft Graph instead of relying on token group values.
|
|
const hasOverage =
|
|
decodedToken?.hasgroups ||
|
|
(decodedToken?._claim_names?.groups &&
|
|
decodedToken?._claim_sources?.[decodedToken._claim_names.groups]);
|
|
|
|
if (
|
|
requiredRoleTokenKind === 'id' &&
|
|
requiredRoleParameterPath === 'groups' &&
|
|
decodedToken &&
|
|
hasOverage
|
|
) {
|
|
const overageGroups = await resolveGroupsFromOverage(tokenset.access_token, claims.sub);
|
|
if (overageGroups) {
|
|
roles = overageGroups;
|
|
resolvedOverageGroups = overageGroups;
|
|
}
|
|
}
|
|
|
|
if (!roles || (!Array.isArray(roles) && typeof roles !== 'string')) {
|
|
logger.error(
|
|
`[openidStrategy] Key '${requiredRoleParameterPath}' not found in ${requiredRoleTokenKind} token!`,
|
|
);
|
|
const rolesList =
|
|
requiredRoles.length === 1
|
|
? `"${requiredRoles[0]}"`
|
|
: `one of: ${requiredRoles.map((r) => `"${r}"`).join(', ')}`;
|
|
throw new Error(`You must have ${rolesList} role to log in.`);
|
|
}
|
|
|
|
const roleValues = Array.isArray(roles) ? roles : roles.split(/[\s,]+/).filter(Boolean);
|
|
|
|
if (!requiredRoles.some((role) => roleValues.includes(role))) {
|
|
const rolesList =
|
|
requiredRoles.length === 1
|
|
? `"${requiredRoles[0]}"`
|
|
: `one of: ${requiredRoles.map((r) => `"${r}"`).join(', ')}`;
|
|
throw new Error(`You must have ${rolesList} role to log in.`);
|
|
}
|
|
}
|
|
|
|
let username = '';
|
|
if (process.env.OPENID_USERNAME_CLAIM) {
|
|
username = userinfo[process.env.OPENID_USERNAME_CLAIM];
|
|
} else {
|
|
username = convertToUsername(
|
|
userinfo.preferred_username || userinfo.username || userinfo.email,
|
|
);
|
|
}
|
|
|
|
if (existingUsersOnly && !user) {
|
|
throw new Error('User does not exist');
|
|
}
|
|
|
|
if (!user) {
|
|
user = {
|
|
provider: 'openid',
|
|
openidId: userinfo.sub,
|
|
username,
|
|
email: email || '',
|
|
emailVerified: userinfo.email_verified || false,
|
|
name: fullName,
|
|
idOnTheSource: userinfo.oid,
|
|
};
|
|
|
|
const balanceConfig = getBalanceConfig(appConfig);
|
|
user = await createUser(user, balanceConfig, true, true);
|
|
} else {
|
|
user.provider = 'openid';
|
|
user.openidId = userinfo.sub;
|
|
user.username = username;
|
|
user.name = fullName;
|
|
user.idOnTheSource = userinfo.oid;
|
|
if (email && email !== user.email) {
|
|
user.email = email;
|
|
user.emailVerified = userinfo.email_verified || false;
|
|
}
|
|
}
|
|
|
|
const adminRole = process.env.OPENID_ADMIN_ROLE;
|
|
const adminRoleParameterPath = process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH;
|
|
const adminRoleTokenKind = process.env.OPENID_ADMIN_ROLE_TOKEN_KIND;
|
|
|
|
if (adminRole && adminRoleParameterPath && adminRoleTokenKind) {
|
|
let adminRoleObject;
|
|
switch (adminRoleTokenKind) {
|
|
case 'access':
|
|
adminRoleObject = jwtDecode(tokenset.access_token);
|
|
break;
|
|
case 'id':
|
|
adminRoleObject = jwtDecode(tokenset.id_token);
|
|
break;
|
|
case 'userinfo':
|
|
adminRoleObject = userinfo;
|
|
break;
|
|
default:
|
|
logger.error(
|
|
`[openidStrategy] Invalid admin role token kind: ${adminRoleTokenKind}. Must be one of 'access', 'id', or 'userinfo'.`,
|
|
);
|
|
throw new Error('Invalid admin role token kind');
|
|
}
|
|
|
|
let adminRoles = get(adminRoleObject, adminRoleParameterPath);
|
|
|
|
// Handle Azure AD group overage for admin role when using ID token groups
|
|
if (adminRoleTokenKind === 'id' && adminRoleParameterPath === 'groups' && adminRoleObject) {
|
|
const hasAdminOverage =
|
|
adminRoleObject.hasgroups ||
|
|
(adminRoleObject._claim_names?.groups &&
|
|
adminRoleObject._claim_sources?.[adminRoleObject._claim_names.groups]);
|
|
|
|
if (hasAdminOverage) {
|
|
const overageGroups =
|
|
resolvedOverageGroups ||
|
|
(await resolveGroupsFromOverage(tokenset.access_token, claims.sub));
|
|
if (overageGroups) {
|
|
adminRoles = overageGroups;
|
|
}
|
|
}
|
|
}
|
|
|
|
let adminRoleValues = [];
|
|
if (Array.isArray(adminRoles)) {
|
|
adminRoleValues = adminRoles;
|
|
} else if (typeof adminRoles === 'string') {
|
|
adminRoleValues = adminRoles.split(/[\s,]+/).filter(Boolean);
|
|
}
|
|
|
|
if (adminRoles && (adminRoles === true || adminRoleValues.includes(adminRole))) {
|
|
user.role = SystemRoles.ADMIN;
|
|
logger.info(`[openidStrategy] User ${username} is an admin based on role: ${adminRole}`);
|
|
} else if (user.role === SystemRoles.ADMIN) {
|
|
user.role = SystemRoles.USER;
|
|
logger.info(
|
|
`[openidStrategy] User ${username} demoted from admin - role no longer present in token`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) {
|
|
/** @type {string | undefined} */
|
|
const imageUrl = userinfo.picture;
|
|
|
|
let fileName;
|
|
if (crypto) {
|
|
fileName = (await hashToken(userinfo.sub)) + '.png';
|
|
} else {
|
|
fileName = userinfo.sub + '.png';
|
|
}
|
|
|
|
const imageBuffer = await downloadImage(
|
|
imageUrl,
|
|
openidConfig,
|
|
tokenset.access_token,
|
|
userinfo.sub,
|
|
);
|
|
if (imageBuffer) {
|
|
const { saveBuffer } = getStrategyFunctions(
|
|
appConfig?.fileStrategy ?? process.env.CDN_PROVIDER,
|
|
);
|
|
const imagePath = await saveBuffer({
|
|
fileName,
|
|
userId: user._id.toString(),
|
|
buffer: imageBuffer,
|
|
});
|
|
user.avatar = imagePath ?? '';
|
|
}
|
|
}
|
|
|
|
user = await updateUser(user._id, user);
|
|
|
|
logger.info(
|
|
`[openidStrategy] login success openidId: ${user.openidId} | email: ${user.email} | username: ${user.username} `,
|
|
{
|
|
user: {
|
|
openidId: user.openidId,
|
|
username: user.username,
|
|
email: user.email,
|
|
name: user.name,
|
|
},
|
|
},
|
|
);
|
|
|
|
return {
|
|
...user,
|
|
tokenset,
|
|
federatedTokens: {
|
|
access_token: tokenset.access_token,
|
|
id_token: tokenset.id_token,
|
|
refresh_token: tokenset.refresh_token,
|
|
expires_at: tokenset.expires_at,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {boolean | undefined} [existingUsersOnly]
|
|
*/
|
|
function createOpenIDCallback(existingUsersOnly) {
|
|
return async (tokenset, done) => {
|
|
try {
|
|
const user = await processOpenIDAuth(tokenset, existingUsersOnly);
|
|
done(null, user);
|
|
} catch (err) {
|
|
if (err.message === 'Email domain not allowed') {
|
|
return done(null, false, { message: err.message });
|
|
}
|
|
if (err.message === ErrorTypes.AUTH_FAILED) {
|
|
return done(null, false, { message: err.message });
|
|
}
|
|
if (err.message && err.message.includes('role to log in')) {
|
|
return done(null, false, { message: err.message });
|
|
}
|
|
logger.error('[openidStrategy] login failed', err);
|
|
done(err);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Sets up the OpenID strategy specifically for admin authentication.
|
|
* @param {Configuration} openidConfig
|
|
*/
|
|
const setupOpenIdAdmin = (openidConfig) => {
|
|
try {
|
|
if (!openidConfig) {
|
|
throw new Error('OpenID configuration not initialized');
|
|
}
|
|
|
|
const openidAdminLogin = new CustomOpenIDStrategy(
|
|
{
|
|
config: openidConfig,
|
|
scope: process.env.OPENID_SCOPE,
|
|
usePKCE: isEnabled(process.env.OPENID_USE_PKCE),
|
|
clockTolerance: process.env.OPENID_CLOCK_TOLERANCE || 300,
|
|
callbackURL: process.env.DOMAIN_SERVER + '/api/admin/oauth/openid/callback',
|
|
},
|
|
createOpenIDCallback(true),
|
|
);
|
|
|
|
passport.use('openidAdmin', openidAdminLogin);
|
|
} catch (err) {
|
|
logger.error('[openidStrategy] setupOpenIdAdmin', err);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Sets up the OpenID strategy for authentication.
|
|
* This function configures the OpenID client, handles proxy settings,
|
|
* and defines the OpenID strategy for Passport.js.
|
|
*
|
|
* @async
|
|
* @function setupOpenId
|
|
* @returns {Promise<Configuration | null>} A promise that resolves when the OpenID strategy is set up and returns the openid client config object.
|
|
* @throws {Error} If an error occurs during the setup process.
|
|
*/
|
|
async function setupOpenId() {
|
|
try {
|
|
const shouldGenerateNonce = isEnabled(process.env.OPENID_GENERATE_NONCE);
|
|
|
|
/** @type {ClientMetadata} */
|
|
const clientMetadata = {
|
|
client_id: process.env.OPENID_CLIENT_ID,
|
|
client_secret: process.env.OPENID_CLIENT_SECRET,
|
|
};
|
|
|
|
if (shouldGenerateNonce) {
|
|
clientMetadata.response_types = ['code'];
|
|
clientMetadata.grant_types = ['authorization_code'];
|
|
clientMetadata.token_endpoint_auth_method = 'client_secret_post';
|
|
}
|
|
|
|
/** @type {Configuration} */
|
|
openidConfig = await client.discovery(
|
|
new URL(process.env.OPENID_ISSUER),
|
|
process.env.OPENID_CLIENT_ID,
|
|
clientMetadata,
|
|
undefined,
|
|
{
|
|
[client.customFetch]: customFetch,
|
|
},
|
|
);
|
|
|
|
logger.info(`[openidStrategy] OpenID authentication configuration`, {
|
|
generateNonce: shouldGenerateNonce,
|
|
reason: shouldGenerateNonce
|
|
? 'OPENID_GENERATE_NONCE=true - Will generate nonce and use explicit metadata for federated providers'
|
|
: 'OPENID_GENERATE_NONCE=false - Standard flow without explicit nonce or metadata',
|
|
});
|
|
|
|
const openidLogin = new CustomOpenIDStrategy(
|
|
{
|
|
config: openidConfig,
|
|
scope: process.env.OPENID_SCOPE,
|
|
callbackURL: process.env.DOMAIN_SERVER + process.env.OPENID_CALLBACK_URL,
|
|
clockTolerance: process.env.OPENID_CLOCK_TOLERANCE || 300,
|
|
usePKCE: isEnabled(process.env.OPENID_USE_PKCE),
|
|
},
|
|
createOpenIDCallback(),
|
|
);
|
|
passport.use('openid', openidLogin);
|
|
setupOpenIdAdmin(openidConfig);
|
|
return openidConfig;
|
|
} catch (err) {
|
|
logger.error('[openidStrategy]', err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @function getOpenIdConfig
|
|
* @description Returns the OpenID client instance.
|
|
* @throws {Error} If the OpenID client is not initialized.
|
|
* @returns {Configuration}
|
|
*/
|
|
function getOpenIdConfig() {
|
|
if (!openidConfig) {
|
|
throw new Error('OpenID client is not initialized. Please call setupOpenId first.');
|
|
}
|
|
return openidConfig;
|
|
}
|
|
|
|
module.exports = {
|
|
setupOpenId,
|
|
getOpenIdConfig,
|
|
getOpenIdEmail,
|
|
};
|