mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
* feat: Add OpenID Connect federated provider token support
Implements support for passing federated provider tokens (Cognito, Azure AD, Auth0)
as variables in LibreChat's librechat.yaml configuration for both custom endpoints
and MCP servers.
Features:
- New LIBRECHAT_OPENID_* template variables for federated provider tokens
- JWT claims parsing from ID tokens without verification (for claim extraction)
- Token validation with expiration checking
- Support for multiple token storage locations (federatedTokens, openidTokens)
- Integration with existing template variable system
- Comprehensive test suite with Cognito-specific scenarios
- Provider-agnostic design supporting Cognito, Azure AD, Auth0, etc.
Security:
- Server-side only token processing
- Automatic token expiration validation
- Graceful fallbacks for missing/invalid tokens
- No client-side token exposure
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: Add federated token propagation to OIDC authentication strategies
Adds federatedTokens object to user during authentication to enable
federated provider token template variables in LibreChat configuration.
Changes:
- OpenID JWT Strategy: Extract raw JWT from Authorization header and
attach as federatedTokens.access_token to enable {{LIBRECHAT_OPENID_TOKEN}}
placeholder resolution
- OpenID Strategy: Attach tokenset tokens as federatedTokens object to
standardize token access across both authentication strategies
This enables proper token propagation for custom endpoints and MCP
servers that require federated provider tokens for authorization.
Resolves missing token issue reported by @ramden in PR #9931
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Denis Ramic <denis.ramic@nfon.com>
Co-Authored-By: Claude <noreply@anthropic.com>
* test: Add federatedTokens validation tests for OIDC strategies
Adds comprehensive test coverage for the federated token propagation
feature implemented in the authentication strategies.
Tests added:
- Verify federatedTokens object is attached to user with correct structure
(access_token, refresh_token, expires_at)
- Verify both tokenset and federatedTokens are present in user object
- Ensure tokens from OIDC provider are correctly propagated
Also fixes existing test suite by adding missing mocks:
- isEmailDomainAllowed function mock
- findOpenIDUser function mock
These tests validate the fix from commit 5874ba29f that enables
{{LIBRECHAT_OPENID_TOKEN}} template variable functionality.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* docs: Remove implementation documentation file
The PR description already contains all necessary implementation details.
This documentation file is redundant and was requested to be removed.
* fix: skip s256 check
* fix(openid): handle missing refresh token in Cognito token refresh response
When OPENID_REUSE_TOKENS=true, the token refresh flow was failing because
Cognito (and most OAuth providers) don't return a new refresh token in the
refresh grant response - they only return new access and ID tokens.
Changes:
- Modified setOpenIDAuthTokens() to accept optional existingRefreshToken parameter
- Updated validation to only require access_token (refresh_token now optional)
- Added logic to reuse existing refresh token when not provided in tokenset
- Updated refreshController to pass original refresh token as fallback
- Added comments explaining standard OAuth 2.0 refresh token behavior
This fixes the "Token is not present. User is not authenticated." error that
occurred during silent token refresh with Cognito as the OpenID provider.
Fixes: Authentication loop with OPENID_REUSE_TOKENS=true and AWS Cognito
* fix(openid): extract refresh token from cookies for template variable replacement
When OPENID_REUSE_TOKENS=true, the openIdJwtStrategy populates user.federatedTokens
to enable template variable replacement (e.g., {{LIBRECHAT_OPENID_ACCESS_TOKEN}}).
However, the refresh_token field was incorrectly sourced from payload.refresh_token,
which is always undefined because:
1. JWTs don't contain refresh tokens in their payload
2. The JWT itself IS the access token
3. Refresh tokens are separate opaque tokens stored in HTTP-only cookies
This caused extractOpenIDTokenInfo() to receive incomplete federatedTokens,
resulting in template variables remaining unreplaced in headers.
**Root Cause:**
- Line 90: `refresh_token: payload.refresh_token` (always undefined)
- JWTs only contain access token data in their claims
- Refresh tokens are separate, stored securely in cookies
**Solution:**
- Import `cookie` module to parse cookies from request
- Extract refresh token from `refreshToken` cookie
- Populate federatedTokens with both access token (JWT) and refresh token (from cookie)
**Impact:**
- Template variables like {{LIBRECHAT_OPENID_ACCESS_TOKEN}} now work correctly
- Headers in librechat.yaml are properly replaced with actual tokens
- MCP server authentication with federated tokens now functional
**Technical Details:**
- passReqToCallback=true in JWT strategy provides req object access
- Refresh token extracted via cookies.parse(req.headers.cookie).refreshToken
- Falls back gracefully if cookie header or refreshToken is missing
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: re-resolve headers on each request to pick up fresh federatedTokens
- OpenAIClient now re-resolves headers in chatCompletion() before each API call
- This ensures template variables like {{LIBRECHAT_OPENID_TOKEN}} are replaced
with actual token values from req.user.federatedTokens
- initialize.js now stores original template headers instead of pre-resolved ones
- Fixes template variable replacement when OPENID_REUSE_TOKENS=true
The issue was that headers were only resolved once during client initialization,
before openIdJwtStrategy had populated user.federatedTokens. Now headers are
re-resolved on every request with the current user's fresh tokens.
* debug: add logging to track header resolution in OpenAIClient
* debug: log tokenset structure after refresh to diagnose missing access_token
* fix: set federatedTokens on user object after OAuth refresh
- After successful OAuth token refresh, the user object was not being
updated with federatedTokens
- This caused template variable resolution to fail on subsequent requests
- Now sets user.federatedTokens with access_token, id_token, refresh_token
and expires_at from the refreshed tokenset
- Fixes template variables like {{LIBRECHAT_OPENID_TOKEN}} not being
replaced after token refresh
- Related to PR #9931 (OpenID federated token support)
* fix(openid): pass user object through agent chain for template variable resolution
Root cause: buildAgentContext in agents/run.ts called resolveHeaders without
the user parameter, preventing OpenID federated token template variables from
being resolved in agent runtime parameters.
Changes:
- packages/api/src/agents/run.ts: Add user parameter to createRun signature
- packages/api/src/agents/run.ts: Pass user to resolveHeaders in buildAgentContext
- api/server/controllers/agents/client.js: Pass user when calling createRun
- api/server/services/Endpoints/bedrock/options.js: Add resolveHeaders call with debug logging
- api/server/services/Endpoints/custom/initialize.js: Add debug logging
- packages/api/src/utils/env.ts: Add comprehensive debug logging and stack traces
- packages/api/src/utils/oidc.ts: Fix eslint errors (unused type, explicit any)
This ensures template variables like {{LIBRECHAT_OPENID_TOKEN}} and
{{LIBRECHAT_USER_OPENIDID}} are properly resolved in both custom endpoint
headers and Bedrock AgentCore runtime parameters.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: remove debug logging from OpenID token template feature
Removed excessive debug logging that was added during development to make
the PR more suitable for upstream review:
- Removed 7 debug statements from OpenAIClient.js
- Removed all console.log statements from packages/api/src/utils/env.ts
- Removed debug logging from bedrock/options.js
- Removed debug logging from custom/initialize.js
- Removed debug statement from AuthController.js
This reduces the changeset by ~50 lines while maintaining full functionality
of the OpenID federated token template variable feature.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* test(openid): add comprehensive unit tests for template variable substitution
- Add 34 unit tests for OIDC token utilities (oidc.spec.ts)
- Test coverage for token extraction, validation, and placeholder processing
- Integration tests for full OpenID token flow
- All tests pass with comprehensive edge case coverage
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
* test: fix OpenID federated tokens test failures
- Add serverMetadata() mock to openid-client mock configuration
* Fixes TypeError in openIdJwtStrategy.js where serverMetadata() was being called
* Mock now returns jwks_uri and end_session_endpoint as expected by the code
- Update outdated initialize.spec.js test
* Remove test expecting resolveHeaders call during initialization
* Header resolution was refactored to be deferred until LLM request time
* Update test to verify options are returned correctly with useLegacyContent flag
Fixes #9931 CI failures for backend unit tests
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* chore: fix package-lock.json conflict
* chore: sync package-log with upstream
* chore: cleanup
* fix: use createSafeUser
* fix: fix createSafeUser signature
* chore: remove comments
* chore: purge comments
* fix: update Jest testPathPattern to testPathPatterns for Jest 30+ compatibility
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Denis Ramic <denis.ramic@nfon.com>
Co-authored-by: kristjanaapro <kristjana@apro.is>
chore: import order and add back JSDoc for OpenID JWT callback
584 lines
20 KiB
JavaScript
584 lines
20 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 { CacheKeys, ErrorTypes } = require('librechat-data-provider');
|
|
const { Strategy: OpenIDStrategy } = require('openid-client/passport');
|
|
const {
|
|
isEnabled,
|
|
logHeaders,
|
|
safeStringify,
|
|
findOpenIDUser,
|
|
getBalanceConfig,
|
|
isEmailDomainAllowed,
|
|
} = 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;
|
|
|
|
//overload currenturl function because of express version 4 buggy req.host doesn't include port
|
|
//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;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
},
|
|
);
|
|
|
|
const requiredRole = process.env.OPENID_REQUIRED_ROLE;
|
|
const requiredRoleParameterPath = process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH;
|
|
const requiredRoleTokenKind = process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND;
|
|
const usePKCE = isEnabled(process.env.OPENID_USE_PKCE);
|
|
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',
|
|
});
|
|
|
|
// Set of env variables that specify how to set if a user is an admin
|
|
// If not set, all users will be treated as regular users
|
|
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;
|
|
|
|
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,
|
|
},
|
|
/**
|
|
* @param {import('openid-client').TokenEndpointResponseHelpers} tokenset
|
|
* @param {import('passport-jwt').VerifyCallback} done
|
|
*/
|
|
async (tokenset, done) => {
|
|
try {
|
|
const claims = tokenset.claims();
|
|
const userinfo = {
|
|
...claims,
|
|
...(await getUserInfo(openidConfig, tokenset.access_token, claims.sub)),
|
|
};
|
|
|
|
const appConfig = await getAppConfig();
|
|
/** Azure AD sometimes doesn't return email, use preferred_username as fallback */
|
|
const email = userinfo.email || userinfo.preferred_username || userinfo.upn;
|
|
if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
|
|
logger.error(
|
|
`[OpenID Strategy] Authentication blocked - email domain not allowed [Email: ${email}]`,
|
|
);
|
|
return done(null, false, { message: 'Email domain not allowed' });
|
|
}
|
|
|
|
const result = await findOpenIDUser({
|
|
findUser,
|
|
email: email,
|
|
openidId: claims.sub,
|
|
idOnTheSource: claims.oid,
|
|
strategyName: 'openidStrategy',
|
|
});
|
|
let user = result.user;
|
|
const error = result.error;
|
|
|
|
if (error) {
|
|
return done(null, false, {
|
|
message: ErrorTypes.AUTH_FAILED,
|
|
});
|
|
}
|
|
|
|
const fullName = getFullName(userinfo);
|
|
|
|
if (requiredRole) {
|
|
const requiredRoles = requiredRole
|
|
.split(',')
|
|
.map((role) => role.trim())
|
|
.filter(Boolean);
|
|
let decodedToken = '';
|
|
if (requiredRoleTokenKind === 'access') {
|
|
decodedToken = jwtDecode(tokenset.access_token);
|
|
} else if (requiredRoleTokenKind === 'id') {
|
|
decodedToken = jwtDecode(tokenset.id_token);
|
|
}
|
|
|
|
let roles = get(decodedToken, requiredRoleParameterPath);
|
|
if (!roles || (!Array.isArray(roles) && typeof roles !== 'string')) {
|
|
logger.error(
|
|
`[openidStrategy] Key '${requiredRoleParameterPath}' not found or invalid type in ${requiredRoleTokenKind} token!`,
|
|
);
|
|
const rolesList =
|
|
requiredRoles.length === 1
|
|
? `"${requiredRoles[0]}"`
|
|
: `one of: ${requiredRoles.map((r) => `"${r}"`).join(', ')}`;
|
|
return done(null, false, {
|
|
message: `You must have ${rolesList} role to log in.`,
|
|
});
|
|
}
|
|
|
|
if (!requiredRoles.some((role) => roles.includes(role))) {
|
|
const rolesList =
|
|
requiredRoles.length === 1
|
|
? `"${requiredRoles[0]}"`
|
|
: `one of: ${requiredRoles.map((r) => `"${r}"`).join(', ')}`;
|
|
return done(null, false, {
|
|
message: `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 (!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;
|
|
}
|
|
}
|
|
|
|
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'.`,
|
|
);
|
|
return done(new Error('Invalid admin role token kind'));
|
|
}
|
|
|
|
const adminRoles = get(adminRoleObject, adminRoleParameterPath);
|
|
|
|
// Accept 3 types of values for the object extracted from adminRoleParameterPath:
|
|
// 1. A boolean value indicating if the user is an admin
|
|
// 2. A string with a single role name
|
|
// 3. An array of role names
|
|
|
|
if (
|
|
adminRoles &&
|
|
(adminRoles === true ||
|
|
adminRoles === adminRole ||
|
|
(Array.isArray(adminRoles) && adminRoles.includes(adminRole)))
|
|
) {
|
|
user.role = 'ADMIN';
|
|
logger.info(
|
|
`[openidStrategy] User ${username} is an admin based on role: ${adminRole}`,
|
|
);
|
|
} else if (user.role === 'ADMIN') {
|
|
user.role = '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,
|
|
},
|
|
},
|
|
);
|
|
|
|
done(null, {
|
|
...user,
|
|
tokenset,
|
|
federatedTokens: {
|
|
access_token: tokenset.access_token,
|
|
refresh_token: tokenset.refresh_token,
|
|
expires_at: tokenset.expires_at,
|
|
},
|
|
});
|
|
} catch (err) {
|
|
logger.error('[openidStrategy] login failed', err);
|
|
done(err);
|
|
}
|
|
},
|
|
);
|
|
passport.use('openid', openidLogin);
|
|
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,
|
|
};
|