🏢 feat: Tenant-Scoped App Config in Auth Login Flows (#12434)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run

* 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.
This commit is contained in:
Danny Avila 2026-03-27 16:08:43 -04:00 committed by GitHub
parent 5972a21479
commit 77712c825f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 2428 additions and 1880 deletions

View file

@ -13,6 +13,7 @@ const {
checkEmailConfig,
isEmailDomainAllowed,
shouldUseSecureCookie,
resolveAppConfigForUser,
} = require('@librechat/api');
const {
findUser,
@ -255,19 +256,52 @@ const registerUser = async (user, additionalData = {}) => {
};
/**
* Request password reset
* Request password reset.
*
* Uses a two-phase domain check: fast-fail with the memory-cached base config
* (zero DB queries) to block globally denied domains before user lookup, then
* re-check with tenant-scoped config after user lookup so tenant-specific
* restrictions are enforced.
*
* Phase 1 (base check) returns an Error (HTTP 400) this intentionally reveals
* that the domain is globally blocked, but fires before any DB lookup so it
* cannot confirm user existence. Phase 2 (tenant check) returns the generic
* success message (HTTP 200) to prevent user-enumeration via status codes.
*
* @param {ServerRequest} req
*/
const requestPasswordReset = async (req) => {
const { email } = req.body;
const appConfig = await getAppConfig({ baseOnly: true });
if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
const baseConfig = await getAppConfig({ baseOnly: true });
if (!isEmailDomainAllowed(email, baseConfig?.registration?.allowedDomains)) {
logger.warn(
`[requestPasswordReset] Blocked - email domain not allowed [Email: ${email}] [IP: ${req.ip}]`,
);
const error = new Error(ErrorTypes.AUTH_FAILED);
error.code = ErrorTypes.AUTH_FAILED;
error.message = 'Email domain not allowed';
return error;
}
const user = await findUser({ email }, 'email _id');
const user = await findUser({ email }, 'email _id role tenantId');
let appConfig = baseConfig;
if (user?.tenantId) {
try {
appConfig = await resolveAppConfigForUser(getAppConfig, user);
} catch (err) {
logger.error('[requestPasswordReset] Failed to resolve tenant config, using base:', err);
}
}
if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
logger.warn(
`[requestPasswordReset] Tenant config blocked domain [Email: ${email}] [IP: ${req.ip}]`,
);
return {
message: 'If an account with that email exists, a password reset link has been sent to it.',
};
}
const emailEnabled = checkEmailConfig();
logger.warn(`[requestPasswordReset] [Password reset request initiated] [Email: ${email}]`);

View file

@ -14,6 +14,7 @@ jest.mock('@librechat/api', () => ({
isEmailDomainAllowed: jest.fn(),
math: jest.fn((val, fallback) => (val ? Number(val) : fallback)),
shouldUseSecureCookie: jest.fn(() => false),
resolveAppConfigForUser: jest.fn(async (_getAppConfig, _user) => ({})),
}));
jest.mock('~/models', () => ({
findUser: jest.fn(),
@ -35,8 +36,14 @@ jest.mock('~/strategies/validators', () => ({ registerSchema: { parse: jest.fn()
jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn() }));
jest.mock('~/server/utils', () => ({ sendEmail: jest.fn() }));
const { shouldUseSecureCookie } = require('@librechat/api');
const { setOpenIDAuthTokens } = require('./AuthService');
const {
shouldUseSecureCookie,
isEmailDomainAllowed,
resolveAppConfigForUser,
} = require('@librechat/api');
const { findUser } = require('~/models');
const { getAppConfig } = require('~/server/services/Config');
const { setOpenIDAuthTokens, requestPasswordReset } = require('./AuthService');
/** Helper to build a mock Express response */
function mockResponse() {
@ -267,3 +274,68 @@ describe('setOpenIDAuthTokens', () => {
});
});
});
describe('requestPasswordReset', () => {
beforeEach(() => {
jest.clearAllMocks();
isEmailDomainAllowed.mockReturnValue(true);
getAppConfig.mockResolvedValue({
registration: { allowedDomains: ['example.com'] },
});
resolveAppConfigForUser.mockResolvedValue({
registration: { allowedDomains: ['example.com'] },
});
});
it('should fast-fail with base config before DB lookup for blocked domains', async () => {
isEmailDomainAllowed.mockReturnValue(false);
const req = { body: { email: 'blocked@evil.com' }, ip: '127.0.0.1' };
const result = await requestPasswordReset(req);
expect(getAppConfig).toHaveBeenCalledWith({ baseOnly: true });
expect(findUser).not.toHaveBeenCalled();
expect(result).toBeInstanceOf(Error);
});
it('should call resolveAppConfigForUser for tenant user', async () => {
const user = {
_id: 'user-tenant',
email: 'user@example.com',
tenantId: 'tenant-x',
role: 'USER',
};
findUser.mockResolvedValue(user);
const req = { body: { email: 'user@example.com' }, ip: '127.0.0.1' };
await requestPasswordReset(req);
expect(resolveAppConfigForUser).toHaveBeenCalledWith(getAppConfig, user);
});
it('should reuse baseConfig for non-tenant user without calling resolveAppConfigForUser', async () => {
findUser.mockResolvedValue({ _id: 'user-no-tenant', email: 'user@example.com' });
const req = { body: { email: 'user@example.com' }, ip: '127.0.0.1' };
await requestPasswordReset(req);
expect(resolveAppConfigForUser).not.toHaveBeenCalled();
});
it('should return generic response when tenant config blocks the domain (non-enumerable)', async () => {
const user = {
_id: 'user-tenant',
email: 'user@example.com',
tenantId: 'tenant-x',
role: 'USER',
};
findUser.mockResolvedValue(user);
isEmailDomainAllowed.mockReturnValueOnce(true).mockReturnValueOnce(false);
const req = { body: { email: 'user@example.com' }, ip: '127.0.0.1' };
const result = await requestPasswordReset(req);
expect(result).not.toBeInstanceOf(Error);
expect(result.message).toContain('If an account with that email exists');
});
});

View file

@ -2,7 +2,12 @@ const fs = require('fs');
const LdapStrategy = require('passport-ldapauth');
const { logger } = require('@librechat/data-schemas');
const { SystemRoles, ErrorTypes } = require('librechat-data-provider');
const { isEnabled, getBalanceConfig, isEmailDomainAllowed } = require('@librechat/api');
const {
isEnabled,
getBalanceConfig,
isEmailDomainAllowed,
resolveAppConfigForUser,
} = require('@librechat/api');
const { createUser, findUser, updateUser, countUsers } = require('~/models');
const { getAppConfig } = require('~/server/services/Config');
@ -89,16 +94,6 @@ const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => {
const ldapId =
(LDAP_ID && userinfo[LDAP_ID]) || userinfo.uid || userinfo.sAMAccountName || userinfo.mail;
let user = await findUser({ ldapId });
if (user && user.provider !== 'ldap') {
logger.info(
`[ldapStrategy] User ${user.email} already exists with provider ${user.provider}`,
);
return done(null, false, {
message: ErrorTypes.AUTH_FAILED,
});
}
const fullNameAttributes = LDAP_FULL_NAME && LDAP_FULL_NAME.split(',');
const fullName =
fullNameAttributes && fullNameAttributes.length > 0
@ -122,7 +117,31 @@ const ldapLogin = new LdapStrategy(ldapOptions, async (userinfo, done) => {
);
}
const appConfig = await getAppConfig({ baseOnly: true });
// Domain check before findUser for two-phase fast-fail (consistent with SAML/OpenID/social).
// This means cross-provider users from blocked domains get 'Email domain not allowed'
// instead of AUTH_FAILED — both deny access.
const baseConfig = await getAppConfig({ baseOnly: true });
if (!isEmailDomainAllowed(mail, baseConfig?.registration?.allowedDomains)) {
logger.error(
`[LDAP Strategy] Authentication blocked - email domain not allowed [Email: ${mail}]`,
);
return done(null, false, { message: 'Email domain not allowed' });
}
let user = await findUser({ ldapId });
if (user && user.provider !== 'ldap') {
logger.info(
`[ldapStrategy] User ${user.email} already exists with provider ${user.provider}`,
);
return done(null, false, {
message: ErrorTypes.AUTH_FAILED,
});
}
const appConfig = user?.tenantId
? await resolveAppConfigForUser(getAppConfig, user)
: baseConfig;
if (!isEmailDomainAllowed(mail, appConfig?.registration?.allowedDomains)) {
logger.error(
`[LDAP Strategy] Authentication blocked - email domain not allowed [Email: ${mail}]`,

View file

@ -9,10 +9,10 @@ jest.mock('@librechat/data-schemas', () => ({
}));
jest.mock('@librechat/api', () => ({
// isEnabled used for TLS flags
isEnabled: jest.fn(() => false),
isEmailDomainAllowed: jest.fn(() => true),
getBalanceConfig: jest.fn(() => ({ enabled: false })),
resolveAppConfigForUser: jest.fn(async (_getAppConfig, _user) => ({})),
}));
jest.mock('~/models', () => ({
@ -30,14 +30,15 @@ jest.mock('~/server/services/Config', () => ({
let verifyCallback;
jest.mock('passport-ldapauth', () => {
return jest.fn().mockImplementation((options, verify) => {
verifyCallback = verify; // capture the strategy verify function
verifyCallback = verify;
return { name: 'ldap', options, verify };
});
});
const { ErrorTypes } = require('librechat-data-provider');
const { isEmailDomainAllowed } = require('@librechat/api');
const { isEmailDomainAllowed, resolveAppConfigForUser } = require('@librechat/api');
const { findUser, createUser, updateUser, countUsers } = require('~/models');
const { getAppConfig } = require('~/server/services/Config');
// Helper to call the verify callback and wrap in a Promise for convenience
const callVerify = (userinfo) =>
@ -117,6 +118,7 @@ describe('ldapStrategy', () => {
expect(user).toBe(false);
expect(info).toEqual({ message: ErrorTypes.AUTH_FAILED });
expect(createUser).not.toHaveBeenCalled();
expect(resolveAppConfigForUser).not.toHaveBeenCalled();
});
it('updates an existing ldap user with current LDAP info', async () => {
@ -158,7 +160,6 @@ describe('ldapStrategy', () => {
uid: 'uid999',
givenName: 'John',
cn: 'John Doe',
// no mail and no custom LDAP_EMAIL
};
const { user } = await callVerify(userinfo);
@ -180,4 +181,66 @@ describe('ldapStrategy', () => {
expect(user).toBe(false);
expect(info).toEqual({ message: 'Email domain not allowed' });
});
it('passes getAppConfig and found user to resolveAppConfigForUser', async () => {
const existing = {
_id: 'u3',
provider: 'ldap',
email: 'tenant@example.com',
ldapId: 'uid-tenant',
username: 'tenantuser',
name: 'Tenant User',
tenantId: 'tenant-a',
role: 'USER',
};
findUser.mockResolvedValue(existing);
const userinfo = {
uid: 'uid-tenant',
mail: 'tenant@example.com',
givenName: 'Tenant',
cn: 'Tenant User',
};
await callVerify(userinfo);
expect(resolveAppConfigForUser).toHaveBeenCalledWith(getAppConfig, existing);
});
it('uses baseConfig for new user without calling resolveAppConfigForUser', async () => {
findUser.mockResolvedValue(null);
const userinfo = {
uid: 'uid-new',
mail: 'newuser@example.com',
givenName: 'New',
cn: 'New User',
};
await callVerify(userinfo);
expect(resolveAppConfigForUser).not.toHaveBeenCalled();
expect(getAppConfig).toHaveBeenCalledWith({ baseOnly: true });
});
it('should block login when tenant config restricts the domain', async () => {
const existing = {
_id: 'u-blocked',
provider: 'ldap',
ldapId: 'uid-tenant',
tenantId: 'tenant-strict',
role: 'USER',
};
findUser.mockResolvedValue(existing);
resolveAppConfigForUser.mockResolvedValue({
registration: { allowedDomains: ['other.com'] },
});
isEmailDomainAllowed.mockReturnValueOnce(true).mockReturnValueOnce(false);
const userinfo = { uid: 'uid-tenant', mail: 'user@example.com', givenName: 'Test', cn: 'Test' };
const { user, info } = await callVerify(userinfo);
expect(user).toBe(false);
expect(info).toEqual({ message: 'Email domain not allowed' });
});
});

View file

@ -15,6 +15,7 @@ const {
findOpenIDUser,
getBalanceConfig,
isEmailDomainAllowed,
resolveAppConfigForUser,
} = require('@librechat/api');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { findUser, createUser, updateUser } = require('~/models');
@ -468,9 +469,10 @@ async function processOpenIDAuth(tokenset, existingUsersOnly = false) {
Object.assign(userinfo, providerUserinfo);
}
const appConfig = await getAppConfig({ baseOnly: true });
const email = getOpenIdEmail(userinfo);
if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
const baseConfig = await getAppConfig({ baseOnly: true });
if (!isEmailDomainAllowed(email, baseConfig?.registration?.allowedDomains)) {
logger.error(
`[OpenID Strategy] Authentication blocked - email domain not allowed [Identifier: ${email}]`,
);
@ -491,6 +493,15 @@ async function processOpenIDAuth(tokenset, existingUsersOnly = false) {
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;

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,11 @@ const passport = require('passport');
const { ErrorTypes } = require('librechat-data-provider');
const { hashToken, logger } = require('@librechat/data-schemas');
const { Strategy: SamlStrategy } = require('@node-saml/passport-saml');
const { getBalanceConfig, isEmailDomainAllowed } = require('@librechat/api');
const {
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');
@ -193,9 +197,9 @@ async function setupSaml() {
logger.debug('[samlStrategy] SAML profile:', profile);
const userEmail = getEmail(profile) || '';
const appConfig = await getAppConfig({ baseOnly: true });
if (!isEmailDomainAllowed(userEmail, appConfig?.registration?.allowedDomains)) {
const baseConfig = await getAppConfig({ baseOnly: true });
if (!isEmailDomainAllowed(userEmail, baseConfig?.registration?.allowedDomains)) {
logger.error(
`[SAML Strategy] Authentication blocked - email domain not allowed [Email: ${userEmail}]`,
);
@ -223,6 +227,17 @@ async function setupSaml() {
});
}
const appConfig = user?.tenantId
? await resolveAppConfigForUser(getAppConfig, user)
: baseConfig;
if (!isEmailDomainAllowed(userEmail, appConfig?.registration?.allowedDomains)) {
logger.error(
`[SAML Strategy] Authentication blocked - email domain not allowed [Email: ${userEmail}]`,
);
return done(null, false, { message: 'Email domain not allowed' });
}
const fullName = getFullName(profile);
const username = convertToUsername(

View file

@ -30,6 +30,7 @@ jest.mock('@librechat/api', () => ({
tokenCredits: 1000,
startBalance: 1000,
})),
resolveAppConfigForUser: jest.fn(async (_getAppConfig, _user) => ({})),
}));
jest.mock('~/server/services/Config/EndpointService', () => ({
config: {},
@ -47,6 +48,9 @@ const fs = require('fs');
const path = require('path');
const fetch = require('node-fetch');
const { Strategy: SamlStrategy } = require('@node-saml/passport-saml');
const { findUser } = require('~/models');
const { resolveAppConfigForUser } = require('@librechat/api');
const { getAppConfig } = require('~/server/services/Config');
const { setupSaml, getCertificateContent } = require('./samlStrategy');
// Configure fs mock
@ -440,4 +444,50 @@ u7wlOSk+oFzDIO/UILIA
expect(fetch).not.toHaveBeenCalled();
});
it('should pass the found user to resolveAppConfigForUser', async () => {
const existingUser = {
_id: 'tenant-user-id',
provider: 'saml',
samlId: 'saml-1234',
email: 'test@example.com',
tenantId: 'tenant-c',
role: 'USER',
};
findUser.mockResolvedValue(existingUser);
const profile = { ...baseProfile };
await validate(profile);
expect(resolveAppConfigForUser).toHaveBeenCalledWith(getAppConfig, existingUser);
});
it('should use baseConfig for new SAML user without calling resolveAppConfigForUser', async () => {
const profile = { ...baseProfile };
await validate(profile);
expect(resolveAppConfigForUser).not.toHaveBeenCalled();
expect(getAppConfig).toHaveBeenCalledWith({ baseOnly: true });
});
it('should block login when tenant config restricts the domain', async () => {
const { isEmailDomainAllowed } = require('@librechat/api');
const existingUser = {
_id: 'tenant-blocked',
provider: 'saml',
samlId: 'saml-1234',
email: 'test@example.com',
tenantId: 'tenant-restrict',
role: 'USER',
};
findUser.mockResolvedValue(existingUser);
resolveAppConfigForUser.mockResolvedValue({
registration: { allowedDomains: ['other.com'] },
});
isEmailDomainAllowed.mockReturnValueOnce(true).mockReturnValueOnce(false);
const profile = { ...baseProfile };
const { user } = await validate(profile);
expect(user).toBe(false);
});
});

View file

@ -1,6 +1,6 @@
const { logger } = require('@librechat/data-schemas');
const { ErrorTypes } = require('librechat-data-provider');
const { isEnabled, isEmailDomainAllowed } = require('@librechat/api');
const { isEnabled, isEmailDomainAllowed, resolveAppConfigForUser } = require('@librechat/api');
const { createSocialUser, handleExistingUser } = require('./process');
const { getAppConfig } = require('~/server/services/Config');
const { findUser } = require('~/models');
@ -13,9 +13,8 @@ const socialLogin =
profile,
});
const appConfig = await getAppConfig({ baseOnly: true });
if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
const baseConfig = await getAppConfig({ baseOnly: true });
if (!isEmailDomainAllowed(email, baseConfig?.registration?.allowedDomains)) {
logger.error(
`[${provider}Login] Authentication blocked - email domain not allowed [Email: ${email}]`,
);
@ -41,6 +40,20 @@ const socialLogin =
}
}
const appConfig = existingUser?.tenantId
? await resolveAppConfigForUser(getAppConfig, existingUser)
: baseConfig;
if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) {
logger.error(
`[${provider}Login] Authentication blocked - email domain not allowed [Email: ${email}]`,
);
const error = new Error(ErrorTypes.AUTH_FAILED);
error.code = ErrorTypes.AUTH_FAILED;
error.message = 'Email domain not allowed';
return cb(error);
}
if (existingUser?.provider === provider) {
await handleExistingUser(existingUser, avatarUrl, appConfig, email);
return cb(null, existingUser);

View file

@ -3,6 +3,8 @@ const { ErrorTypes } = require('librechat-data-provider');
const { createSocialUser, handleExistingUser } = require('./process');
const socialLogin = require('./socialLogin');
const { findUser } = require('~/models');
const { resolveAppConfigForUser } = require('@librechat/api');
const { getAppConfig } = require('~/server/services/Config');
jest.mock('@librechat/data-schemas', () => {
const actualModule = jest.requireActual('@librechat/data-schemas');
@ -25,6 +27,10 @@ jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
isEnabled: jest.fn().mockReturnValue(true),
isEmailDomainAllowed: jest.fn().mockReturnValue(true),
resolveAppConfigForUser: jest.fn().mockResolvedValue({
fileStrategy: 'local',
balance: { enabled: false },
}),
}));
jest.mock('~/models', () => ({
@ -66,10 +72,7 @@ describe('socialLogin', () => {
googleId: googleId,
};
/** Mock findUser to return user on first call (by googleId), null on second call */
findUser
.mockResolvedValueOnce(existingUser) // First call: finds by googleId
.mockResolvedValueOnce(null); // Second call would be by email, but won't be reached
findUser.mockResolvedValueOnce(existingUser).mockResolvedValueOnce(null);
const mockProfile = {
id: googleId,
@ -83,13 +86,9 @@ describe('socialLogin', () => {
await loginFn(null, null, null, mockProfile, callback);
/** Verify it searched by googleId first */
expect(findUser).toHaveBeenNthCalledWith(1, { googleId: googleId });
/** Verify it did NOT search by email (because it found user by googleId) */
expect(findUser).toHaveBeenCalledTimes(1);
/** Verify handleExistingUser was called with the new email */
expect(handleExistingUser).toHaveBeenCalledWith(
existingUser,
'https://example.com/avatar.png',
@ -97,7 +96,6 @@ describe('socialLogin', () => {
newEmail,
);
/** Verify callback was called with success */
expect(callback).toHaveBeenCalledWith(null, existingUser);
});
@ -113,7 +111,7 @@ describe('socialLogin', () => {
facebookId: facebookId,
};
findUser.mockResolvedValue(existingUser); // Always returns user
findUser.mockResolvedValue(existingUser);
const mockProfile = {
id: facebookId,
@ -127,7 +125,6 @@ describe('socialLogin', () => {
await loginFn(null, null, null, mockProfile, callback);
/** Verify it searched by facebookId first */
expect(findUser).toHaveBeenCalledWith({ facebookId: facebookId });
expect(findUser.mock.calls[0]).toEqual([{ facebookId: facebookId }]);
@ -150,13 +147,10 @@ describe('socialLogin', () => {
_id: 'user789',
email: email,
provider: 'google',
googleId: 'old-google-id', // Different googleId (edge case)
googleId: 'old-google-id',
};
/** First call (by googleId) returns null, second call (by email) returns user */
findUser
.mockResolvedValueOnce(null) // By googleId
.mockResolvedValueOnce(existingUser); // By email
findUser.mockResolvedValueOnce(null).mockResolvedValueOnce(existingUser);
const mockProfile = {
id: googleId,
@ -170,13 +164,10 @@ describe('socialLogin', () => {
await loginFn(null, null, null, mockProfile, callback);
/** Verify both searches happened */
expect(findUser).toHaveBeenNthCalledWith(1, { googleId: googleId });
/** Email passed as-is; findUser implementation handles case normalization */
expect(findUser).toHaveBeenNthCalledWith(2, { email: email });
expect(findUser).toHaveBeenCalledTimes(2);
/** Verify warning log */
expect(logger.warn).toHaveBeenCalledWith(
`[${provider}Login] User found by email: ${email} but not by ${provider}Id`,
);
@ -197,7 +188,6 @@ describe('socialLogin', () => {
googleId: googleId,
};
/** Both searches return null */
findUser.mockResolvedValue(null);
createSocialUser.mockResolvedValue(newUser);
@ -213,10 +203,8 @@ describe('socialLogin', () => {
await loginFn(null, null, null, mockProfile, callback);
/** Verify both searches happened */
expect(findUser).toHaveBeenCalledTimes(2);
/** Verify createSocialUser was called */
expect(createSocialUser).toHaveBeenCalledWith({
email: email,
avatarUrl: 'https://example.com/avatar.png',
@ -242,12 +230,10 @@ describe('socialLogin', () => {
const existingUser = {
_id: 'user123',
email: email,
provider: 'local', // Different provider
provider: 'local',
};
findUser
.mockResolvedValueOnce(null) // By googleId
.mockResolvedValueOnce(existingUser); // By email
findUser.mockResolvedValueOnce(null).mockResolvedValueOnce(existingUser);
const mockProfile = {
id: googleId,
@ -261,7 +247,6 @@ describe('socialLogin', () => {
await loginFn(null, null, null, mockProfile, callback);
/** Verify error callback */
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
code: ErrorTypes.AUTH_FAILED,
@ -274,4 +259,104 @@ describe('socialLogin', () => {
);
});
});
describe('Tenant-scoped config', () => {
it('should call resolveAppConfigForUser for tenant user', async () => {
const provider = 'google';
const googleId = 'google-tenant-user';
const email = 'tenant@example.com';
const existingUser = {
_id: 'userTenant',
email,
provider: 'google',
googleId,
tenantId: 'tenant-b',
role: 'USER',
};
findUser.mockResolvedValue(existingUser);
const mockProfile = {
id: googleId,
emails: [{ value: email, verified: true }],
photos: [{ value: 'https://example.com/avatar.png' }],
name: { givenName: 'Tenant', familyName: 'User' },
};
const loginFn = socialLogin(provider, mockGetProfileDetails);
const callback = jest.fn();
await loginFn(null, null, null, mockProfile, callback);
expect(resolveAppConfigForUser).toHaveBeenCalledWith(getAppConfig, existingUser);
});
it('should use baseConfig for non-tenant user without calling resolveAppConfigForUser', async () => {
const provider = 'google';
const googleId = 'google-new-tenant';
const email = 'new@example.com';
findUser.mockResolvedValue(null);
createSocialUser.mockResolvedValue({
_id: 'newUser',
email,
provider: 'google',
googleId,
});
const mockProfile = {
id: googleId,
emails: [{ value: email, verified: true }],
photos: [{ value: 'https://example.com/avatar.png' }],
name: { givenName: 'New', familyName: 'User' },
};
const loginFn = socialLogin(provider, mockGetProfileDetails);
const callback = jest.fn();
await loginFn(null, null, null, mockProfile, callback);
expect(resolveAppConfigForUser).not.toHaveBeenCalled();
expect(getAppConfig).toHaveBeenCalledWith({ baseOnly: true });
});
it('should block login when tenant config restricts the domain', async () => {
const { isEmailDomainAllowed } = require('@librechat/api');
const provider = 'google';
const googleId = 'google-tenant-blocked';
const email = 'blocked@example.com';
const existingUser = {
_id: 'userBlocked',
email,
provider: 'google',
googleId,
tenantId: 'tenant-restrict',
role: 'USER',
};
findUser.mockResolvedValue(existingUser);
resolveAppConfigForUser.mockResolvedValue({
registration: { allowedDomains: ['other.com'] },
});
isEmailDomainAllowed.mockReturnValueOnce(true).mockReturnValueOnce(false);
const mockProfile = {
id: googleId,
emails: [{ value: email, verified: true }],
photos: [{ value: 'https://example.com/avatar.png' }],
name: { givenName: 'Blocked', familyName: 'User' },
};
const loginFn = socialLogin(provider, mockGetProfileDetails);
const callback = jest.fn();
await loginFn(null, null, null, mockProfile, callback);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Email domain not allowed' }),
);
});
});
});

View file

@ -3,3 +3,4 @@ export * from './config';
export * from './permissions';
export * from './cdn';
export * from './checks';
export * from './resolve';

View file

@ -0,0 +1,95 @@
import type { AsyncLocalStorage } from 'async_hooks';
jest.mock('@librechat/data-schemas', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { AsyncLocalStorage: ALS } = require('async_hooks');
return { tenantStorage: new ALS() };
});
import { resolveAppConfigForUser } from './resolve';
const { tenantStorage } = jest.requireMock('@librechat/data-schemas') as {
tenantStorage: AsyncLocalStorage<{ tenantId?: string }>;
};
describe('resolveAppConfigForUser', () => {
const mockGetAppConfig = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
mockGetAppConfig.mockResolvedValue({ registration: {} });
});
it('calls getAppConfig with baseOnly when user is null', async () => {
await resolveAppConfigForUser(mockGetAppConfig, null);
expect(mockGetAppConfig).toHaveBeenCalledWith({ baseOnly: true });
});
it('calls getAppConfig with baseOnly when user is undefined', async () => {
await resolveAppConfigForUser(mockGetAppConfig, undefined);
expect(mockGetAppConfig).toHaveBeenCalledWith({ baseOnly: true });
});
it('calls getAppConfig with baseOnly when user has no tenantId', async () => {
await resolveAppConfigForUser(mockGetAppConfig, { role: 'USER' });
expect(mockGetAppConfig).toHaveBeenCalledWith({ baseOnly: true });
});
it('calls getAppConfig with role and tenantId when user has tenantId', async () => {
await resolveAppConfigForUser(mockGetAppConfig, { tenantId: 'tenant-a', role: 'USER' });
expect(mockGetAppConfig).toHaveBeenCalledWith({ role: 'USER', tenantId: 'tenant-a' });
});
it('calls tenantStorage.run for tenant users but not for non-tenant users', async () => {
const runSpy = jest.spyOn(tenantStorage, 'run');
await resolveAppConfigForUser(mockGetAppConfig, { role: 'USER' });
expect(runSpy).not.toHaveBeenCalled();
await resolveAppConfigForUser(mockGetAppConfig, { tenantId: 'tenant-b', role: 'ADMIN' });
expect(runSpy).toHaveBeenCalledWith({ tenantId: 'tenant-b' }, expect.any(Function));
runSpy.mockRestore();
});
it('makes tenantId available via ALS inside getAppConfig', async () => {
let capturedContext: { tenantId?: string } | undefined;
mockGetAppConfig.mockImplementation(async () => {
capturedContext = tenantStorage.getStore();
return { registration: {} };
});
await resolveAppConfigForUser(mockGetAppConfig, { tenantId: 'tenant-c', role: 'USER' });
expect(capturedContext).toEqual({ tenantId: 'tenant-c' });
});
it('returns the config from getAppConfig', async () => {
const tenantConfig = { registration: { allowedDomains: ['example.com'] } };
mockGetAppConfig.mockResolvedValue(tenantConfig);
const result = await resolveAppConfigForUser(mockGetAppConfig, {
tenantId: 'tenant-d',
role: 'USER',
});
expect(result).toBe(tenantConfig);
});
it('calls getAppConfig with role undefined when user has tenantId but no role', async () => {
await resolveAppConfigForUser(mockGetAppConfig, { tenantId: 'tenant-e' });
expect(mockGetAppConfig).toHaveBeenCalledWith({ role: undefined, tenantId: 'tenant-e' });
});
it('propagates rejection from getAppConfig for tenant users', async () => {
mockGetAppConfig.mockRejectedValue(new Error('config unavailable'));
await expect(
resolveAppConfigForUser(mockGetAppConfig, { tenantId: 'tenant-f', role: 'USER' }),
).rejects.toThrow('config unavailable');
});
it('propagates rejection from getAppConfig for baseOnly path', async () => {
mockGetAppConfig.mockRejectedValue(new Error('cache failure'));
await expect(resolveAppConfigForUser(mockGetAppConfig, null)).rejects.toThrow('cache failure');
});
});

View file

@ -0,0 +1,39 @@
import { tenantStorage } from '@librechat/data-schemas';
import type { AppConfig } from '@librechat/data-schemas';
interface UserForConfigResolution {
tenantId?: string;
role?: string;
}
type GetAppConfig = (opts: {
role?: string;
tenantId?: string;
baseOnly?: boolean;
}) => Promise<AppConfig>;
/**
* Resolves AppConfig scoped to the given user's tenant when available,
* falling back to YAML-only base config for new users or non-tenant deployments.
*
* Auth flows only apply role-level overrides (userId is not passed) because
* user/group principal resolution requires heavier DB work that is deferred
* to post-authentication config calls.
*
* `tenantId` is propagated through two channels that serve different purposes:
* - `tenantStorage.run()` sets the ALS context so Mongoose's `applyTenantIsolation`
* plugin scopes any DB queries (e.g., `getApplicableConfigs`) to the tenant.
* - The explicit `tenantId` parameter to `getAppConfig` is used for cache-key
* computation in `overrideCacheKey()`. Both channels are required.
*/
export async function resolveAppConfigForUser(
getAppConfig: GetAppConfig,
user: UserForConfigResolution | null | undefined,
): Promise<AppConfig> {
if (user?.tenantId) {
return tenantStorage.run({ tenantId: user.tenantId }, async () =>
getAppConfig({ role: user.role, tenantId: user.tenantId }),
);
}
return getAppConfig({ baseOnly: true });
}