LibreChat/api/strategies/openIdJwtStrategy.js
Artyom Bogachenko 5683706af5
🔐 feat: OIDC Bearer Token Authentication for Remote Agent API (#12450)
* Remote Agent Auth middleware

* consider migration and update user

* fix eslint errors

* add scope validation

* fix codex review errors

* add filter for use: sig

* add jwks-rsa deps

* Fix remote agent OIDC auth review findings

* Polish remote agent OIDC timeout coverage

* Reject remote OIDC tokens without subject

* Use tenant context for remote agent auth config

* Harden remote agent OIDC scope handling

* Polish remote agent OIDC cache and scope tests

* Resolve remote agent auth review comments

* Reuse OpenID email claim resolver for remote auth

* Skip empty OpenID email fallback claims

* Use pre-auth tenant context for remote auth config

* Downgrade expected OIDC fallback logging

* Require secure remote OIDC endpoints

* Polish remote agent auth edge cases

* Enforce unique balance records

* Bind remote OpenID users to issuer

* Fix issuer-scoped OpenID indexes

* Avoid unique balance index requirement

* Fix remote OpenID issuer normalization boundaries

* Require issuer-bound OpenID lookups

* Enforce tenant API key policy after auth

* Fix remote auth tenant policy types

* Normalize remote OIDC discovery issuer

* Allow normalized remote OIDC issuer validation

* Enforce resolved tenant OIDC policy

* Polish OpenID issuer and scope validation

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
2026-05-04 17:06:35 -04:00

135 lines
4.8 KiB
JavaScript

const cookies = require('cookie');
const jwksRsa = require('jwks-rsa');
const { logger } = require('@librechat/data-schemas');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { SystemRoles } = require('librechat-data-provider');
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
const {
isEnabled,
findOpenIDUser,
getOpenIdEmail,
getOpenIdIssuer,
math,
} = require('@librechat/api');
const { updateUser, findUser } = require('~/models');
/**
* @function openIdJwtLogin
* @param {import('openid-client').Configuration} openIdConfig - Configuration object for the JWT strategy.
* @returns {JwtStrategy}
* @description This function creates a JWT strategy for OpenID authentication.
* It uses the jwks-rsa library to retrieve the signing key from a JWKS endpoint.
* The strategy extracts the JWT from the Authorization header as a Bearer token.
* The JWT is then verified using the signing key, and the user is retrieved from the database.
*
* Includes email fallback mechanism:
* 1. Primary lookup: Search user by openidId (sub claim)
* 2. Fallback lookup: If not found, search by email claim
* 3. User migration: If found by email without openidId, migrate the user by adding openidId
* 4. Provider validation: Ensures users registered with other providers cannot use OpenID
*
* This enables seamless migration for existing users when SharePoint integration is enabled.
*/
const openIdJwtLogin = (openIdConfig) => {
let jwksRsaOptions = {
cache: process.env.OPENID_JWKS_URL_CACHE_ENABLED
? isEnabled(process.env.OPENID_JWKS_URL_CACHE_ENABLED)
: true,
cacheMaxAge: math(process.env.OPENID_JWKS_URL_CACHE_TIME, 60000),
jwksUri: openIdConfig.serverMetadata().jwks_uri,
};
if (process.env.PROXY) {
jwksRsaOptions.requestAgent = new HttpsProxyAgent(process.env.PROXY);
}
return new JwtStrategy(
{
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKeyProvider: jwksRsa.passportJwtSecret(jwksRsaOptions),
passReqToCallback: true,
},
/**
* @param {import('@librechat/api').ServerRequest} req
* @param {import('openid-client').IDToken} payload
* @param {import('passport-jwt').VerifyCallback} done
*/
async (req, payload, done) => {
try {
const authHeader = req.headers.authorization;
const rawToken = authHeader?.replace('Bearer ', '');
const openidIssuer = getOpenIdIssuer(payload, openIdConfig);
const { user, error, migration } = await findOpenIDUser({
findUser,
email: payload ? getOpenIdEmail(payload) : undefined,
openidId: payload?.sub,
openidIssuer,
idOnTheSource: payload?.oid,
strategyName: 'openIdJwtLogin',
});
if (error) {
done(null, false, { message: error });
return;
}
if (user) {
user.id = user._id.toString();
const updateData = {};
if (migration) {
updateData.provider = 'openid';
updateData.openidId = payload?.sub;
if (openidIssuer) {
updateData.openidIssuer = openidIssuer;
}
}
if (!user.role) {
user.role = SystemRoles.USER;
updateData.role = user.role;
}
if (Object.keys(updateData).length > 0) {
await updateUser(user.id, updateData);
}
/** Read tokens from session (server-side) to avoid large cookie issues */
const sessionTokens = req.session?.openidTokens;
let accessToken = sessionTokens?.accessToken;
let idToken = sessionTokens?.idToken;
let refreshToken = sessionTokens?.refreshToken;
/** Fallback to cookies for backward compatibility */
if (!accessToken || !refreshToken || !idToken) {
const cookieHeader = req.headers.cookie;
const parsedCookies = cookieHeader ? cookies.parse(cookieHeader) : {};
accessToken = accessToken || parsedCookies.openid_access_token;
idToken = idToken || parsedCookies.openid_id_token;
refreshToken = refreshToken || parsedCookies.refreshToken;
}
user.federatedTokens = {
access_token: accessToken || rawToken,
id_token: idToken,
refresh_token: refreshToken,
expires_at: payload.exp,
};
done(null, user);
} else {
logger.warn(
'[openIdJwtLogin] openId JwtStrategy => no user found with the sub claims: ' +
payload?.sub +
(payload?.email ? ' or email: ' + payload.email : ''),
);
done(null, false);
}
} catch (err) {
done(err, false);
}
},
);
};
module.exports = openIdJwtLogin;