const cookies = require('cookie'); const jwksRsa = require('jwks-rsa'); const { logger } = require('@librechat/data-schemas'); const { SystemRoles } = require('librechat-data-provider'); const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt'); const { isEnabled, findOpenIDUser, getOpenIdEmail, getOpenIdIssuer, normalizeOpenIdIssuer, getHttpsProxyAgent, math, } = require('@librechat/api'); const { updateUser, findUser } = require('~/models'); const getOpenIdJwtAudience = () => { const parsedAudience = (process.env.OPENID_AUDIENCE ?? '') .split(',') .map((value) => value.trim()) .filter(Boolean); const audiences = [process.env.OPENID_CLIENT_ID, ...parsedAudience].filter(Boolean); const uniqueAudiences = [...new Set(audiences)]; return uniqueAudiences.length > 1 ? uniqueAudiences : uniqueAudiences[0]; }; const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const issuerMatchesTemplate = (expectedIssuer, actualIssuer) => { if (!expectedIssuer.includes('{tenantid}')) { return false; } const escapedTemplate = expectedIssuer.split('{tenantid}').map(escapeRegExp).join('[^/]+'); return new RegExp(`^${escapedTemplate}$`).test(actualIssuer); }; const isOpenIdIssuerAllowed = (payload, openIdConfig) => { const actualIssuer = normalizeOpenIdIssuer(payload?.iss); const expectedIssuer = normalizeOpenIdIssuer(openIdConfig.serverMetadata().issuer); if (!actualIssuer || !expectedIssuer) { return false; } return actualIssuer === expectedIssuer || issuerMatchesTemplate(expectedIssuer, actualIssuer); }; /** * @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, }; const requestAgent = getHttpsProxyAgent(jwksRsaOptions.jwksUri); if (requestAgent) { jwksRsaOptions.requestAgent = requestAgent; } return new JwtStrategy( { jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), secretOrKeyProvider: jwksRsa.passportJwtSecret(jwksRsaOptions), audience: getOpenIdJwtAudience(), 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 { if (!isOpenIdIssuerAllowed(payload, openIdConfig)) { done(null, false, { message: 'Invalid issuer' }); return; } 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;