diff --git a/api/strategies/openIdJwtStrategy.js b/api/strategies/openIdJwtStrategy.js index cc90e20036..841b645936 100644 --- a/api/strategies/openIdJwtStrategy.js +++ b/api/strategies/openIdJwtStrategy.js @@ -1,10 +1,11 @@ -const { SystemRoles } = require('librechat-data-provider'); -const { HttpsProxyAgent } = require('https-proxy-agent'); -const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt'); -const { updateUser, findUser } = require('~/models'); -const { logger } = require('~/config'); const jwksRsa = require('jwks-rsa'); -const { isEnabled } = require('~/server/utils'); +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 } = require('@librechat/api'); +const { updateUser, findUser } = require('~/models'); + /** * @function openIdJwtLogin * @param {import('openid-client').Configuration} openIdConfig - Configuration object for the JWT strategy. @@ -13,6 +14,14 @@ const { isEnabled } = require('~/server/utils'); * 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 = { @@ -34,19 +43,41 @@ const openIdJwtLogin = (openIdConfig) => { }, async (payload, done) => { try { - const user = await findUser({ openidId: payload?.sub }); + const { user, error, migration } = await findOpenIDUser({ + openidId: payload?.sub, + email: payload?.email, + strategyName: 'openIdJwtLogin', + findUser, + }); + + 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 (!user.role) { user.role = SystemRoles.USER; - await updateUser(user.id, { role: user.role }); + updateData.role = user.role; } + + if (Object.keys(updateData).length > 0) { + await updateUser(user.id, updateData); + } + done(null, user); } else { logger.warn( '[openIdJwtLogin] openId JwtStrategy => no user found with the sub claims: ' + - payload?.sub, + payload?.sub + + (payload?.email ? ' or email: ' + payload.email : ''), ); done(null, false); } diff --git a/api/strategies/openidStrategy.js b/api/strategies/openidStrategy.js index 4cb7493284..1116cbdc45 100644 --- a/api/strategies/openidStrategy.js +++ b/api/strategies/openidStrategy.js @@ -7,7 +7,13 @@ 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, getBalanceConfig } = require('@librechat/api'); +const { + isEnabled, + logHeaders, + safeStringify, + findOpenIDUser, + getBalanceConfig, +} = require('@librechat/api'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { findUser, createUser, updateUser } = require('~/models'); const { getAppConfig } = require('~/server/services/Config'); @@ -333,23 +339,16 @@ async function setupOpenId() { async (tokenset, done) => { try { const claims = tokenset.claims(); - let user = await findUser({ openidId: claims.sub }); - logger.info( - `[openidStrategy] user ${user ? 'found' : 'not found'} with openidId: ${claims.sub}`, - ); + const result = await findOpenIDUser({ + openidId: claims.sub, + email: claims.email, + strategyName: 'openidStrategy', + findUser, + }); + let user = result.user; + const error = result.error; - if (!user) { - user = await findUser({ email: claims.email }); - logger.info( - `[openidStrategy] user ${user ? 'found' : 'not found'} with email: ${ - claims.email - } for openidId: ${claims.sub}`, - ); - } - if (user != null && user.provider !== 'openid') { - logger.info( - `[openidStrategy] Attempted OpenID login by user ${user.email}, was registered with "${user.provider}" provider`, - ); + if (error) { return done(null, false, { message: ErrorTypes.AUTH_FAILED, }); diff --git a/api/strategies/openidStrategy.spec.js b/api/strategies/openidStrategy.spec.js index 5bbf194f45..de033b01f8 100644 --- a/api/strategies/openidStrategy.spec.js +++ b/api/strategies/openidStrategy.spec.js @@ -31,6 +31,7 @@ jest.mock('@librechat/data-schemas', () => ({ ...jest.requireActual('@librechat/api'), logger: { info: jest.fn(), + warn: jest.fn(), debug: jest.fn(), error: jest.fn(), }, diff --git a/packages/api/src/auth/index.ts b/packages/api/src/auth/index.ts new file mode 100644 index 0000000000..a5fb25405a --- /dev/null +++ b/packages/api/src/auth/index.ts @@ -0,0 +1 @@ +export * from './openid'; diff --git a/packages/api/src/auth/openid.ts b/packages/api/src/auth/openid.ts new file mode 100644 index 0000000000..2b1d978c86 --- /dev/null +++ b/packages/api/src/auth/openid.ts @@ -0,0 +1,49 @@ +import { logger } from '@librechat/data-schemas'; +import type { IUser, UserMethods } from '@librechat/data-schemas'; + +/** + * Finds or migrates a user for OpenID authentication + * @returns user object (with migration fields if needed), error message, and whether migration is needed + */ +export async function findOpenIDUser({ + openidId, + email, + findUser, + strategyName = 'openid', +}: { + openidId: string; + findUser: UserMethods['findUser']; + email?: string; + strategyName?: string; +}): Promise<{ user: IUser | null; error: string | null; migration: boolean }> { + let user = await findUser({ openidId }); + logger.info(`[${strategyName}] user ${user ? 'found' : 'not found'} with openidId: ${openidId}`); + + // If user not found by openidId, try to find by email + if (!user && email) { + user = await findUser({ email }); + logger.warn( + `[${strategyName}] user ${user ? 'found' : 'not found'} with email: ${email} for openidId: ${openidId}`, + ); + + // If user found by email, check if they're allowed to use OpenID provider + if (user && user.provider && user.provider !== 'openid') { + logger.warn( + `[${strategyName}] Attempted OpenID login by user ${user.email}, was registered with "${user.provider}" provider`, + ); + return { user: null, error: 'AUTH_FAILED', migration: false }; + } + + // If user found by email but doesn't have openidId, prepare for migration + if (user && !user.openidId) { + logger.info( + `[${strategyName}] Preparing user ${user.email} for migration to OpenID with sub: ${openidId}`, + ); + user.provider = 'openid'; + user.openidId = openidId; + return { user, error: null, migration: true }; + } + } + + return { user, error: null, migration: false }; +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 3fd81fa531..d0c797abaf 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,4 +1,6 @@ export * from './app'; +/* Auth */ +export * from './auth'; /* MCP */ export * from './mcp/MCPManager'; export * from './mcp/connection';