2025-09-20 17:01:45 +02:00
|
|
|
// --- Mocks ---
|
|
|
|
|
jest.mock('@librechat/data-schemas', () => ({
|
|
|
|
|
logger: {
|
|
|
|
|
info: jest.fn(),
|
|
|
|
|
warn: jest.fn(),
|
|
|
|
|
debug: jest.fn(),
|
|
|
|
|
error: jest.fn(),
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
jest.mock('@librechat/api', () => ({
|
|
|
|
|
isEnabled: jest.fn(() => false),
|
2025-09-27 21:20:19 -04:00
|
|
|
isEmailDomainAllowed: jest.fn(() => true),
|
2025-09-20 17:01:45 +02:00
|
|
|
getBalanceConfig: jest.fn(() => ({ enabled: false })),
|
🏢 feat: Tenant-Scoped App Config in Auth Login Flows (#12434)
* 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.
2026-03-27 16:08:43 -04:00
|
|
|
resolveAppConfigForUser: jest.fn(async (_getAppConfig, _user) => ({})),
|
2025-09-20 17:01:45 +02:00
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
jest.mock('~/models', () => ({
|
|
|
|
|
findUser: jest.fn(),
|
|
|
|
|
createUser: jest.fn(),
|
|
|
|
|
updateUser: jest.fn(),
|
|
|
|
|
countUsers: jest.fn(),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
jest.mock('~/server/services/Config', () => ({
|
|
|
|
|
getAppConfig: jest.fn().mockResolvedValue({}),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// Mock passport-ldapauth to capture verify callback
|
|
|
|
|
let verifyCallback;
|
|
|
|
|
jest.mock('passport-ldapauth', () => {
|
|
|
|
|
return jest.fn().mockImplementation((options, verify) => {
|
🏢 feat: Tenant-Scoped App Config in Auth Login Flows (#12434)
* 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.
2026-03-27 16:08:43 -04:00
|
|
|
verifyCallback = verify;
|
2025-09-20 17:01:45 +02:00
|
|
|
return { name: 'ldap', options, verify };
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const { ErrorTypes } = require('librechat-data-provider');
|
🏢 feat: Tenant-Scoped App Config in Auth Login Flows (#12434)
* 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.
2026-03-27 16:08:43 -04:00
|
|
|
const { isEmailDomainAllowed, resolveAppConfigForUser } = require('@librechat/api');
|
2025-09-20 17:01:45 +02:00
|
|
|
const { findUser, createUser, updateUser, countUsers } = require('~/models');
|
🏢 feat: Tenant-Scoped App Config in Auth Login Flows (#12434)
* 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.
2026-03-27 16:08:43 -04:00
|
|
|
const { getAppConfig } = require('~/server/services/Config');
|
2025-09-20 17:01:45 +02:00
|
|
|
|
|
|
|
|
// Helper to call the verify callback and wrap in a Promise for convenience
|
|
|
|
|
const callVerify = (userinfo) =>
|
|
|
|
|
new Promise((resolve, reject) => {
|
|
|
|
|
verifyCallback(userinfo, (err, user, info) => {
|
|
|
|
|
if (err) return reject(err);
|
|
|
|
|
resolve({ user, info });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('ldapStrategy', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
jest.clearAllMocks();
|
|
|
|
|
|
|
|
|
|
// minimal required env for ldapStrategy module to export
|
|
|
|
|
process.env.LDAP_URL = 'ldap://example.com';
|
|
|
|
|
process.env.LDAP_USER_SEARCH_BASE = 'ou=users,dc=example,dc=com';
|
|
|
|
|
|
|
|
|
|
// Unset optional envs to exercise defaults
|
|
|
|
|
delete process.env.LDAP_CA_CERT_PATH;
|
|
|
|
|
delete process.env.LDAP_FULL_NAME;
|
|
|
|
|
delete process.env.LDAP_ID;
|
|
|
|
|
delete process.env.LDAP_USERNAME;
|
|
|
|
|
delete process.env.LDAP_EMAIL;
|
|
|
|
|
delete process.env.LDAP_TLS_REJECT_UNAUTHORIZED;
|
|
|
|
|
delete process.env.LDAP_STARTTLS;
|
|
|
|
|
|
|
|
|
|
// Default model/domain mocks
|
|
|
|
|
findUser.mockReset().mockResolvedValue(null);
|
|
|
|
|
createUser.mockReset().mockResolvedValue('newUserId');
|
|
|
|
|
updateUser.mockReset().mockImplementation(async (id, user) => ({ _id: id, ...user }));
|
|
|
|
|
countUsers.mockReset().mockResolvedValue(0);
|
|
|
|
|
isEmailDomainAllowed.mockReset().mockReturnValue(true);
|
|
|
|
|
|
|
|
|
|
// Ensure requiring the strategy sets up the verify callback
|
|
|
|
|
jest.isolateModules(() => {
|
|
|
|
|
require('./ldapStrategy');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('uses the first email when LDAP returns multiple emails (array)', async () => {
|
|
|
|
|
const userinfo = {
|
|
|
|
|
uid: 'uid123',
|
|
|
|
|
givenName: 'Alice',
|
|
|
|
|
cn: 'Alice Doe',
|
|
|
|
|
mail: ['first@example.com', 'second@example.com'],
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const { user } = await callVerify(userinfo);
|
|
|
|
|
|
|
|
|
|
expect(user.email).toBe('first@example.com');
|
|
|
|
|
expect(createUser).toHaveBeenCalledWith(
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
provider: 'ldap',
|
|
|
|
|
ldapId: 'uid123',
|
|
|
|
|
username: 'Alice',
|
|
|
|
|
email: 'first@example.com',
|
|
|
|
|
emailVerified: true,
|
|
|
|
|
name: 'Alice Doe',
|
|
|
|
|
}),
|
|
|
|
|
expect.any(Object),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('blocks login if an existing user has a different provider', async () => {
|
|
|
|
|
findUser.mockResolvedValue({ _id: 'u1', email: 'first@example.com', provider: 'google' });
|
|
|
|
|
|
|
|
|
|
const userinfo = {
|
|
|
|
|
uid: 'uid123',
|
|
|
|
|
mail: 'first@example.com',
|
|
|
|
|
givenName: 'Alice',
|
|
|
|
|
cn: 'Alice Doe',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const { user, info } = await callVerify(userinfo);
|
|
|
|
|
|
|
|
|
|
expect(user).toBe(false);
|
|
|
|
|
expect(info).toEqual({ message: ErrorTypes.AUTH_FAILED });
|
|
|
|
|
expect(createUser).not.toHaveBeenCalled();
|
🏢 feat: Tenant-Scoped App Config in Auth Login Flows (#12434)
* 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.
2026-03-27 16:08:43 -04:00
|
|
|
expect(resolveAppConfigForUser).not.toHaveBeenCalled();
|
2025-09-20 17:01:45 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('updates an existing ldap user with current LDAP info', async () => {
|
|
|
|
|
const existing = {
|
|
|
|
|
_id: 'u2',
|
|
|
|
|
provider: 'ldap',
|
|
|
|
|
email: 'old@example.com',
|
|
|
|
|
ldapId: 'uid123',
|
|
|
|
|
username: 'olduser',
|
|
|
|
|
name: 'Old Name',
|
|
|
|
|
};
|
|
|
|
|
findUser.mockResolvedValue(existing);
|
|
|
|
|
|
|
|
|
|
const userinfo = {
|
|
|
|
|
uid: 'uid123',
|
|
|
|
|
mail: 'new@example.com',
|
|
|
|
|
givenName: 'NewFirst',
|
|
|
|
|
cn: 'NewFirst NewLast',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const { user } = await callVerify(userinfo);
|
|
|
|
|
|
|
|
|
|
expect(createUser).not.toHaveBeenCalled();
|
|
|
|
|
expect(updateUser).toHaveBeenCalledWith(
|
|
|
|
|
'u2',
|
|
|
|
|
expect.objectContaining({
|
|
|
|
|
provider: 'ldap',
|
|
|
|
|
ldapId: 'uid123',
|
|
|
|
|
email: 'new@example.com',
|
|
|
|
|
username: 'NewFirst',
|
|
|
|
|
name: 'NewFirst NewLast',
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
expect(user.email).toBe('new@example.com');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('falls back to username@ldap.local when no email attributes are present', async () => {
|
|
|
|
|
const userinfo = {
|
|
|
|
|
uid: 'uid999',
|
|
|
|
|
givenName: 'John',
|
|
|
|
|
cn: 'John Doe',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const { user } = await callVerify(userinfo);
|
|
|
|
|
|
|
|
|
|
expect(user.email).toBe('John@ldap.local');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('denies login if email domain is not allowed', async () => {
|
|
|
|
|
isEmailDomainAllowed.mockReturnValue(false);
|
|
|
|
|
|
|
|
|
|
const userinfo = {
|
|
|
|
|
uid: 'uid123',
|
|
|
|
|
mail: 'notallowed@blocked.com',
|
|
|
|
|
givenName: 'Alice',
|
|
|
|
|
cn: 'Alice Doe',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const { user, info } = await callVerify(userinfo);
|
|
|
|
|
expect(user).toBe(false);
|
|
|
|
|
expect(info).toEqual({ message: 'Email domain not allowed' });
|
|
|
|
|
});
|
🏢 feat: Tenant-Scoped App Config in Auth Login Flows (#12434)
* 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.
2026-03-27 16:08:43 -04:00
|
|
|
|
|
|
|
|
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' });
|
|
|
|
|
});
|
2025-09-20 17:01:45 +02:00
|
|
|
});
|