mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 06:52:47 +00:00
📉 perf: cache OpenID JWT user documents (#14187)
* feat(auth): cache OpenID JWT user documents * fix(auth): invalidate cached auth users on role changes
This commit is contained in:
parent
b753da163e
commit
329ed48246
13 changed files with 947 additions and 24 deletions
|
|
@ -1 +1,3 @@
|
|||
See CLAUDE.md.
|
||||
|
||||
When adding or changing code that mutates user documents, invalidate the auth user document cache for affected users. This includes single-user updates and bulk role/user mutations; otherwise OpenID JWT request burst caching can serve a stale `req.user` until its TTL expires.
|
||||
|
|
|
|||
1
api/cache/getLogStores.js
vendored
1
api/cache/getLogStores.js
vendored
|
|
@ -63,6 +63,7 @@ const namespaces = {
|
|||
CacheKeys.OPENID_EXCHANGED_TOKENS,
|
||||
Time.TEN_MINUTES,
|
||||
),
|
||||
[CacheKeys.AUTH_USER_DOC]: standardCache(CacheKeys.AUTH_USER_DOC),
|
||||
[CacheKeys.ADMIN_OAUTH_EXCHANGE]: standardCache(
|
||||
CacheKeys.ADMIN_OAUTH_EXCHANGE,
|
||||
Time.THIRTY_SECONDS,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
const cookies = require('cookie');
|
||||
const jwksRsa = require('jwks-rsa');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { SystemRoles } = require('librechat-data-provider');
|
||||
const { CacheKeys, SystemRoles } = require('librechat-data-provider');
|
||||
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
|
||||
const {
|
||||
isEnabled,
|
||||
|
|
@ -9,10 +9,16 @@ const {
|
|||
getOpenIdEmail,
|
||||
getOpenIdIssuer,
|
||||
normalizeOpenIdIssuer,
|
||||
buildAuthUserDocCacheKey,
|
||||
getAuthUserDocCacheMode,
|
||||
getCachedAuthUserDoc,
|
||||
invalidateCachedAuthUserDoc,
|
||||
setCachedAuthUserDoc,
|
||||
getHttpsProxyAgent,
|
||||
math,
|
||||
} = require('@librechat/api');
|
||||
const { updateUser, findUser } = require('~/models');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
|
||||
const getOpenIdJwtAudience = () => {
|
||||
const parsedAudience = (process.env.OPENID_AUDIENCE ?? '')
|
||||
|
|
@ -47,6 +53,8 @@ const isOpenIdIssuerAllowed = (payload, openIdConfig) => {
|
|||
return actualIssuer === expectedIssuer || issuerMatchesTemplate(expectedIssuer, actualIssuer);
|
||||
};
|
||||
|
||||
const getAuthUserDocCacheStore = () => getLogStores(CacheKeys.AUTH_USER_DOC);
|
||||
|
||||
/**
|
||||
* @function openIdJwtLogin
|
||||
* @param {import('openid-client').Configuration} openIdConfig - Configuration object for the JWT strategy.
|
||||
|
|
@ -100,15 +108,31 @@ const openIdJwtLogin = (openIdConfig) => {
|
|||
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',
|
||||
const authUserCacheKey = buildAuthUserDocCacheKey({
|
||||
strategy: 'openid-jwt',
|
||||
subject: payload?.sub,
|
||||
issuer: openidIssuer,
|
||||
});
|
||||
const authUserCacheMode = getAuthUserDocCacheMode();
|
||||
const authUserCacheStore =
|
||||
authUserCacheMode !== 'off' && authUserCacheKey ? getAuthUserDocCacheStore() : undefined;
|
||||
const cachedUser =
|
||||
authUserCacheMode !== 'off' && authUserCacheStore && authUserCacheKey
|
||||
? await getCachedAuthUserDoc(authUserCacheStore, authUserCacheKey)
|
||||
: undefined;
|
||||
|
||||
const servedCachedUser = authUserCacheMode === 'on' && cachedUser;
|
||||
const lookupResult = servedCachedUser
|
||||
? { user: cachedUser, error: null, migration: false }
|
||||
: await findOpenIDUser({
|
||||
findUser,
|
||||
email: payload ? getOpenIdEmail(payload) : undefined,
|
||||
openidId: payload?.sub,
|
||||
openidIssuer,
|
||||
idOnTheSource: payload?.oid,
|
||||
strategyName: 'openIdJwtLogin',
|
||||
});
|
||||
const { user, error, migration } = lookupResult;
|
||||
|
||||
if (error) {
|
||||
done(null, false, { message: error });
|
||||
|
|
@ -137,6 +161,17 @@ const openIdJwtLogin = (openIdConfig) => {
|
|||
await updateUser(user.id, updateData);
|
||||
}
|
||||
|
||||
if (authUserCacheStore && authUserCacheKey) {
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await invalidateCachedAuthUserDoc(authUserCacheStore, {
|
||||
userId: user.id,
|
||||
cacheKey: authUserCacheKey,
|
||||
});
|
||||
} else if (!servedCachedUser) {
|
||||
await setCachedAuthUserDoc(authUserCacheStore, authUserCacheKey, user);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read tokens from session (server-side) to avoid large cookie issues */
|
||||
const sessionTokens = req.session?.openidTokens;
|
||||
let accessToken = sessionTokens?.accessToken;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,12 @@ const { SystemRoles } = require('librechat-data-provider');
|
|||
// --- Capture JwtStrategy inputs ---
|
||||
let capturedStrategyOptions;
|
||||
let capturedVerifyCallback;
|
||||
const mockAuthUserDocCacheStore = {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
const mockGetLogStores = jest.fn(() => mockAuthUserDocCacheStore);
|
||||
jest.mock('passport-jwt', () => ({
|
||||
Strategy: jest.fn((opts, verifyCallback) => {
|
||||
capturedStrategyOptions = opts;
|
||||
|
|
@ -28,6 +34,11 @@ jest.mock('@librechat/api', () => ({
|
|||
getOpenIdEmail: jest.requireActual('@librechat/api').getOpenIdEmail,
|
||||
getOpenIdIssuer: jest.fn(() => 'https://issuer.example.com'),
|
||||
normalizeOpenIdIssuer: jest.requireActual('@librechat/api').normalizeOpenIdIssuer,
|
||||
buildAuthUserDocCacheKey: jest.fn(() => 'auth-user-doc-key'),
|
||||
getAuthUserDocCacheMode: jest.fn(() => 'off'),
|
||||
getCachedAuthUserDoc: jest.fn(),
|
||||
invalidateCachedAuthUserDoc: jest.fn(),
|
||||
setCachedAuthUserDoc: jest.fn(),
|
||||
getHttpsProxyAgent: jest.fn(() => undefined),
|
||||
math: jest.fn((val, fallback) => fallback),
|
||||
}));
|
||||
|
|
@ -43,14 +54,35 @@ jest.mock('~/server/services/Files/strategies', () => ({
|
|||
jest.mock('~/server/services/Config', () => ({
|
||||
getAppConfig: jest.fn().mockResolvedValue({}),
|
||||
}));
|
||||
jest.mock('~/cache/getLogStores', () =>
|
||||
jest.fn().mockReturnValue({ get: jest.fn(), set: jest.fn() }),
|
||||
);
|
||||
jest.mock('~/cache/getLogStores', () => mockGetLogStores);
|
||||
|
||||
const { findOpenIDUser } = require('@librechat/api');
|
||||
const {
|
||||
buildAuthUserDocCacheKey,
|
||||
findOpenIDUser,
|
||||
getAuthUserDocCacheMode,
|
||||
getCachedAuthUserDoc,
|
||||
invalidateCachedAuthUserDoc,
|
||||
setCachedAuthUserDoc,
|
||||
} = require('@librechat/api');
|
||||
const openIdJwtLogin = require('./openIdJwtStrategy');
|
||||
const { findUser, updateUser } = require('~/models');
|
||||
|
||||
function resetAuthUserDocCacheMocks() {
|
||||
mockAuthUserDocCacheStore.get.mockResolvedValue(undefined);
|
||||
mockAuthUserDocCacheStore.set.mockResolvedValue(undefined);
|
||||
mockAuthUserDocCacheStore.delete.mockResolvedValue(undefined);
|
||||
mockGetLogStores.mockReturnValue(mockAuthUserDocCacheStore);
|
||||
buildAuthUserDocCacheKey.mockReturnValue('auth-user-doc-key');
|
||||
getAuthUserDocCacheMode.mockReturnValue('off');
|
||||
getCachedAuthUserDoc.mockResolvedValue(undefined);
|
||||
invalidateCachedAuthUserDoc.mockResolvedValue(undefined);
|
||||
setCachedAuthUserDoc.mockResolvedValue(undefined);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAuthUserDocCacheMocks();
|
||||
});
|
||||
|
||||
function withEnv(env, callback) {
|
||||
const previous = Object.fromEntries(Object.keys(env).map((key) => [key, process.env[key]]));
|
||||
Object.entries(env).forEach(([key, value]) => {
|
||||
|
|
@ -348,6 +380,103 @@ describe('openIdJwtStrategy – token source handling', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('openIdJwtStrategy – auth user document cache', () => {
|
||||
const payload = {
|
||||
sub: 'oidc-123',
|
||||
email: 'test@example.com',
|
||||
iss: 'https://issuer.example.com',
|
||||
exp: 9999999999,
|
||||
};
|
||||
|
||||
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
|
||||
|
||||
const baseUser = {
|
||||
_id: { toString: () => 'user-abc' },
|
||||
role: SystemRoles.USER,
|
||||
provider: 'openid',
|
||||
email: 'test@example.com',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
resetAuthUserDocCacheMocks();
|
||||
updateUser.mockResolvedValue({});
|
||||
openIdJwtLogin(mockOpenIdConfig);
|
||||
});
|
||||
|
||||
it('does not initialize the cache store while cache mode is off', async () => {
|
||||
findOpenIDUser.mockResolvedValue({ user: { ...baseUser }, error: null, migration: false });
|
||||
|
||||
await invokeVerify(req, payload);
|
||||
|
||||
expect(findOpenIDUser).toHaveBeenCalled();
|
||||
expect(mockGetLogStores).not.toHaveBeenCalled();
|
||||
expect(getCachedAuthUserDoc).not.toHaveBeenCalled();
|
||||
expect(setCachedAuthUserDoc).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the cached user document in on mode without a database lookup', async () => {
|
||||
const cachedUser = {
|
||||
_id: 'cached-user',
|
||||
role: SystemRoles.USER,
|
||||
provider: 'openid',
|
||||
email: 'cached@example.com',
|
||||
};
|
||||
getAuthUserDocCacheMode.mockReturnValue('on');
|
||||
getCachedAuthUserDoc.mockResolvedValue(cachedUser);
|
||||
|
||||
const { user } = await invokeVerify(req, payload);
|
||||
|
||||
expect(buildAuthUserDocCacheKey).toHaveBeenCalledWith({
|
||||
strategy: 'openid-jwt',
|
||||
subject: payload.sub,
|
||||
issuer: 'https://issuer.example.com',
|
||||
});
|
||||
expect(findOpenIDUser).not.toHaveBeenCalled();
|
||||
expect(user).toMatchObject({
|
||||
id: 'cached-user',
|
||||
email: 'cached@example.com',
|
||||
idOnTheSource: null,
|
||||
});
|
||||
expect(setCachedAuthUserDoc).not.toHaveBeenCalled();
|
||||
expect(invalidateCachedAuthUserDoc).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('populates the cache after a miss with the fresh user document', async () => {
|
||||
getAuthUserDocCacheMode.mockReturnValue('on');
|
||||
getCachedAuthUserDoc.mockResolvedValue(undefined);
|
||||
findOpenIDUser.mockResolvedValue({ user: { ...baseUser }, error: null, migration: false });
|
||||
|
||||
await invokeVerify(req, payload);
|
||||
|
||||
expect(findOpenIDUser).toHaveBeenCalled();
|
||||
expect(setCachedAuthUserDoc).toHaveBeenCalledWith(
|
||||
mockAuthUserDocCacheStore,
|
||||
'auth-user-doc-key',
|
||||
expect.objectContaining({ id: 'user-abc' }),
|
||||
);
|
||||
expect(invalidateCachedAuthUserDoc).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('invalidates instead of populating when login mutates the user', async () => {
|
||||
getAuthUserDocCacheMode.mockReturnValue('on');
|
||||
findOpenIDUser.mockResolvedValue({
|
||||
user: { ...baseUser, role: undefined },
|
||||
error: null,
|
||||
migration: false,
|
||||
});
|
||||
|
||||
await invokeVerify(req, payload);
|
||||
|
||||
expect(updateUser).toHaveBeenCalledWith('user-abc', { role: SystemRoles.USER });
|
||||
expect(setCachedAuthUserDoc).not.toHaveBeenCalled();
|
||||
expect(invalidateCachedAuthUserDoc).toHaveBeenCalledWith(mockAuthUserDocCacheStore, {
|
||||
userId: 'user-abc',
|
||||
cacheKey: 'auth-user-doc-key',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('openIdJwtStrategy – idOnTheSource boundary coercion', () => {
|
||||
const payload = {
|
||||
sub: 'oidc-123',
|
||||
|
|
|
|||
|
|
@ -8,3 +8,4 @@ export * from './password';
|
|||
export * from './invite';
|
||||
export * from './codeapi';
|
||||
export * from './openidRoleSync';
|
||||
export * from './userDocCache';
|
||||
|
|
|
|||
225
packages/api/src/auth/userDocCache.spec.ts
Normal file
225
packages/api/src/auth/userDocCache.spec.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import { Types } from 'mongoose';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { CacheKeys } from 'librechat-data-provider';
|
||||
import {
|
||||
AUTH_USER_DOC_CACHE_TTL_MS,
|
||||
buildAuthUserDocCacheKey,
|
||||
buildAuthUserDocReverseIndexKey,
|
||||
getAuthUserDocCacheMode,
|
||||
getCachedAuthUserDoc,
|
||||
invalidateCachedAuthUserDoc,
|
||||
setCachedAuthUserDoc,
|
||||
} from './userDocCache';
|
||||
import { cacheConfig } from '~/cache/cacheConfig';
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: {
|
||||
warn: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const ORIGINAL_ENV = {
|
||||
AUTH_USER_CACHE_MODE: process.env.AUTH_USER_CACHE_MODE,
|
||||
};
|
||||
|
||||
const ORIGINAL_CACHE_CONFIG = {
|
||||
USE_REDIS: cacheConfig.USE_REDIS,
|
||||
FORCED_IN_MEMORY_CACHE_NAMESPACES: [...cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES],
|
||||
};
|
||||
|
||||
function restoreEnv() {
|
||||
for (const [key, value] of Object.entries(ORIGINAL_ENV)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
cacheConfig.USE_REDIS = ORIGINAL_CACHE_CONFIG.USE_REDIS;
|
||||
cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES = [
|
||||
...ORIGINAL_CACHE_CONFIG.FORCED_IN_MEMORY_CACHE_NAMESPACES,
|
||||
];
|
||||
}
|
||||
|
||||
function makeStore() {
|
||||
const values = new Map<string, unknown>();
|
||||
return {
|
||||
values,
|
||||
get: async <T = unknown>(key: string) => values.get(key) as T | undefined,
|
||||
set: jest.fn(async (key: string, value: unknown, _ttl?: number) => {
|
||||
values.set(key, value);
|
||||
return true;
|
||||
}),
|
||||
delete: jest.fn(async (key: string) => values.delete(key)),
|
||||
};
|
||||
}
|
||||
|
||||
describe('auth user document cache helpers', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
restoreEnv();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
restoreEnv();
|
||||
});
|
||||
|
||||
it('only enables user request burst caching when Redis backs the auth user namespace', () => {
|
||||
process.env.AUTH_USER_CACHE_MODE = 'on';
|
||||
cacheConfig.USE_REDIS = false;
|
||||
expect(getAuthUserDocCacheMode()).toBe('off');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'[authUserDocCache] User request burst caching requires Redis; disabling auth user cache',
|
||||
);
|
||||
|
||||
cacheConfig.USE_REDIS = true;
|
||||
cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES = [CacheKeys.AUTH_USER_DOC];
|
||||
expect(getAuthUserDocCacheMode()).toBe('off');
|
||||
|
||||
cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES = [CacheKeys.APP_CONFIG];
|
||||
expect(getAuthUserDocCacheMode()).toBe('on');
|
||||
|
||||
process.env.AUTH_USER_CACHE_MODE = 'shadow';
|
||||
expect(getAuthUserDocCacheMode()).toBe('off');
|
||||
|
||||
process.env.AUTH_USER_CACHE_MODE = 'invalid';
|
||||
expect(getAuthUserDocCacheMode()).toBe('off');
|
||||
});
|
||||
|
||||
it('builds stable keys from strategy, subject, issuer, and scope', () => {
|
||||
const key = buildAuthUserDocCacheKey({
|
||||
strategy: ' OpenID-JWT ',
|
||||
subject: 'subject-1',
|
||||
issuer: 'https://issuer.example.com/',
|
||||
scope: ' Org-A ',
|
||||
});
|
||||
const equivalent = buildAuthUserDocCacheKey({
|
||||
strategy: 'openid-jwt',
|
||||
subject: 'subject-1',
|
||||
issuer: 'https://issuer.example.com',
|
||||
scope: 'org-a',
|
||||
});
|
||||
const otherScope = buildAuthUserDocCacheKey({
|
||||
strategy: 'openid-jwt',
|
||||
subject: 'subject-1',
|
||||
issuer: 'https://issuer.example.com',
|
||||
scope: 'org-b',
|
||||
});
|
||||
|
||||
expect(key).toMatch(/^auth-user-doc:v1:/);
|
||||
expect(key).toBe(equivalent);
|
||||
expect(key).not.toBe(otherScope);
|
||||
expect(buildAuthUserDocCacheKey({ strategy: '', subject: 'subject-1' })).toBeUndefined();
|
||||
expect(buildAuthUserDocCacheKey({ strategy: 'openid-jwt' })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sanitizes sensitive fields and remembers cache keys by user id', async () => {
|
||||
const store = makeStore();
|
||||
const cacheKey = 'auth-user-doc:v1:key';
|
||||
const userId = new Types.ObjectId();
|
||||
|
||||
await setCachedAuthUserDoc(store, cacheKey, {
|
||||
_id: userId,
|
||||
id: userId.toString(),
|
||||
email: 'user@example.com',
|
||||
provider: 'openid',
|
||||
password: 'secret',
|
||||
refreshToken: [{ refreshToken: 'secret' }],
|
||||
federatedTokens: { access_token: 'secret' },
|
||||
openidTokens: { access_token: 'secret' },
|
||||
totpSecret: 'secret',
|
||||
backupCodes: [{ codeHash: 'secret', used: false }],
|
||||
});
|
||||
|
||||
const cached = store.values.get(cacheKey) as { user: Record<string, unknown> };
|
||||
expect(cached.user).toMatchObject({
|
||||
_id: userId.toString(),
|
||||
id: userId.toString(),
|
||||
email: 'user@example.com',
|
||||
});
|
||||
expect(cached.user.password).toBeUndefined();
|
||||
expect(cached.user.refreshToken).toBeUndefined();
|
||||
expect(cached.user.federatedTokens).toBeUndefined();
|
||||
expect(cached.user.openidTokens).toBeUndefined();
|
||||
expect(cached.user.totpSecret).toBeUndefined();
|
||||
expect(cached.user.backupCodes).toBeUndefined();
|
||||
|
||||
expect(store.set).toHaveBeenCalledWith(
|
||||
cacheKey,
|
||||
expect.objectContaining({ version: 1, user: expect.any(Object) }),
|
||||
AUTH_USER_DOC_CACHE_TTL_MS,
|
||||
);
|
||||
expect(store.values.get(buildAuthUserDocReverseIndexKey(userId.toString()))).toEqual([
|
||||
cacheKey,
|
||||
]);
|
||||
expect(store.set).toHaveBeenCalledWith(
|
||||
buildAuthUserDocReverseIndexKey(userId.toString()),
|
||||
[cacheKey],
|
||||
AUTH_USER_DOC_CACHE_TTL_MS,
|
||||
);
|
||||
});
|
||||
|
||||
it('deduplicates reverse-index keys and caps the remembered set', async () => {
|
||||
const store = makeStore();
|
||||
const objectId = new Types.ObjectId();
|
||||
const userId = objectId.toString();
|
||||
const indexKey = buildAuthUserDocReverseIndexKey(userId);
|
||||
store.values.set(
|
||||
indexKey,
|
||||
Array.from({ length: 20 }, (_value, index) => `existing-key-${index}`),
|
||||
);
|
||||
|
||||
await setCachedAuthUserDoc(store, 'existing-key-10', {
|
||||
_id: objectId,
|
||||
email: 'user@example.com',
|
||||
});
|
||||
await setCachedAuthUserDoc(store, 'new-key', {
|
||||
_id: objectId,
|
||||
email: 'user@example.com',
|
||||
});
|
||||
|
||||
const indexed = store.values.get(indexKey);
|
||||
expect(indexed).toHaveLength(20);
|
||||
expect(indexed).not.toContain('existing-key-0');
|
||||
expect(indexed).toContain('existing-key-10');
|
||||
expect(indexed).toContain('new-key');
|
||||
});
|
||||
|
||||
it('returns cached user documents only for the current cache version', async () => {
|
||||
const store = makeStore();
|
||||
store.values.set('current', { version: 1, cachedAt: Date.now(), user: { id: 'user-1' } });
|
||||
store.values.set('stale', { version: 0, cachedAt: Date.now(), user: { id: 'user-2' } });
|
||||
|
||||
await expect(getCachedAuthUserDoc(store, 'current')).resolves.toEqual({ id: 'user-1' });
|
||||
await expect(getCachedAuthUserDoc(store, 'stale')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('invalidates explicit and reverse-indexed cache keys', async () => {
|
||||
const store = makeStore();
|
||||
store.values.set(buildAuthUserDocReverseIndexKey('user-1'), ['key-a', 'key-b']);
|
||||
|
||||
await invalidateCachedAuthUserDoc(store, { userId: 'user-1', cacheKey: 'key-c' });
|
||||
|
||||
expect(store.delete).toHaveBeenCalledWith(buildAuthUserDocReverseIndexKey('user-1'));
|
||||
expect(store.delete).toHaveBeenCalledWith('key-a');
|
||||
expect(store.delete).toHaveBeenCalledWith('key-b');
|
||||
expect(store.delete).toHaveBeenCalledWith('key-c');
|
||||
});
|
||||
|
||||
it('logs cache failures without throwing', async () => {
|
||||
const store = {
|
||||
get: async <T = unknown>(): Promise<T | undefined> => {
|
||||
throw new Error('redis unavailable');
|
||||
},
|
||||
set: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
|
||||
await expect(getCachedAuthUserDoc(store, 'key')).resolves.toBeUndefined();
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'[authUserDocCache] Cache read failed; falling back to user lookup',
|
||||
{ error: 'redis unavailable' },
|
||||
);
|
||||
});
|
||||
});
|
||||
214
packages/api/src/auth/userDocCache.ts
Normal file
214
packages/api/src/auth/userDocCache.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import { createHash } from 'crypto';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
import { AUTH_USER_DOC_BY_ID_PREFIX, CacheKeys } from 'librechat-data-provider';
|
||||
import type { IUser } from '@librechat/data-schemas';
|
||||
import { cacheConfig } from '~/cache/cacheConfig';
|
||||
|
||||
const AUTH_USER_DOC_CACHE_VERSION = 1;
|
||||
export const AUTH_USER_DOC_CACHE_TTL_MS = 5000;
|
||||
|
||||
export type AuthUserDocCacheMode = 'off' | 'on';
|
||||
|
||||
export interface AuthUserDocCacheStore {
|
||||
get: <T = unknown>(key: string) => Promise<T | undefined>;
|
||||
set: (key: string, value: unknown, ttl?: number) => Promise<unknown>;
|
||||
delete: (key: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface AuthUserDocCacheKeyInput {
|
||||
strategy: string;
|
||||
subject?: string;
|
||||
issuer?: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
interface CachedAuthUserDoc {
|
||||
version: number;
|
||||
cachedAt: number;
|
||||
user: CachedAuthUser;
|
||||
}
|
||||
|
||||
type CachedAuthUser = Omit<Partial<IUser>, '_id'> & {
|
||||
_id?: string;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
type UserIdInput = {
|
||||
_id?: string | { toString(): string };
|
||||
id?: string;
|
||||
};
|
||||
|
||||
let warnedAuthUserDocCacheRequiresRedis = false;
|
||||
|
||||
export function getAuthUserDocCacheTtlMs(): number {
|
||||
return AUTH_USER_DOC_CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
function isAuthUserDocCacheRedisBacked(): boolean {
|
||||
return (
|
||||
cacheConfig.USE_REDIS &&
|
||||
!cacheConfig.FORCED_IN_MEMORY_CACHE_NAMESPACES?.includes(CacheKeys.AUTH_USER_DOC)
|
||||
);
|
||||
}
|
||||
|
||||
export function getAuthUserDocCacheMode(): AuthUserDocCacheMode {
|
||||
if (process.env.AUTH_USER_CACHE_MODE !== 'on') {
|
||||
return 'off';
|
||||
}
|
||||
if (!isAuthUserDocCacheRedisBacked()) {
|
||||
if (!warnedAuthUserDocCacheRequiresRedis) {
|
||||
warnedAuthUserDocCacheRequiresRedis = true;
|
||||
logger.warn(
|
||||
'[authUserDocCache] User request burst caching requires Redis; disabling auth user cache',
|
||||
);
|
||||
}
|
||||
return 'off';
|
||||
}
|
||||
return 'on';
|
||||
}
|
||||
|
||||
function normalizeKeyPart(value: string | undefined): string {
|
||||
return (value ?? '').trim().toLowerCase().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export function buildAuthUserDocCacheKey(input: AuthUserDocCacheKeyInput): string | undefined {
|
||||
const strategy = input.strategy.trim();
|
||||
const subject = input.subject?.trim();
|
||||
if (!strategy || !subject) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const digest = createHash('sha256')
|
||||
.update(
|
||||
[
|
||||
normalizeKeyPart(strategy),
|
||||
subject,
|
||||
normalizeKeyPart(input.issuer),
|
||||
normalizeKeyPart(input.scope),
|
||||
].join('\0'),
|
||||
)
|
||||
.digest('base64url');
|
||||
|
||||
return `auth-user-doc:v${AUTH_USER_DOC_CACHE_VERSION}:${digest}`;
|
||||
}
|
||||
|
||||
function getUserId(user: UserIdInput): string | undefined {
|
||||
const id = user._id ?? user.id;
|
||||
if (id == null) {
|
||||
return undefined;
|
||||
}
|
||||
return typeof id === 'string' ? id : id.toString();
|
||||
}
|
||||
|
||||
export function buildAuthUserDocReverseIndexKey(userId: string): string {
|
||||
return `${AUTH_USER_DOC_BY_ID_PREFIX}:${userId}`;
|
||||
}
|
||||
|
||||
function sanitizeUserForCache(user: Partial<IUser>): CachedAuthUser {
|
||||
const id = getUserId(user);
|
||||
const { _id: _ignored, ...rest } = user;
|
||||
const sanitized: CachedAuthUser = { ...rest };
|
||||
if (id) {
|
||||
sanitized._id = id;
|
||||
sanitized.id = id;
|
||||
}
|
||||
|
||||
delete sanitized.password;
|
||||
delete sanitized.refreshToken;
|
||||
delete sanitized.totpSecret;
|
||||
delete sanitized.pendingTotpSecret;
|
||||
delete sanitized.backupCodes;
|
||||
delete sanitized.pendingBackupCodes;
|
||||
delete sanitized.federatedTokens;
|
||||
delete sanitized.openidTokens;
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
async function rememberUserCacheKey(
|
||||
store: AuthUserDocCacheStore,
|
||||
userId: string,
|
||||
cacheKey: string,
|
||||
ttlMs: number,
|
||||
): Promise<void> {
|
||||
const indexKey = buildAuthUserDocReverseIndexKey(userId);
|
||||
const existing = await store.get<string[]>(indexKey);
|
||||
const keys = Array.isArray(existing) ? existing.filter((value) => value !== cacheKey) : [];
|
||||
keys.push(cacheKey);
|
||||
await store.set(indexKey, keys.slice(-20), ttlMs);
|
||||
}
|
||||
|
||||
export async function getCachedAuthUserDoc(
|
||||
store: AuthUserDocCacheStore,
|
||||
cacheKey: string,
|
||||
): Promise<CachedAuthUser | undefined> {
|
||||
try {
|
||||
const cached = await store.get<CachedAuthUserDoc>(cacheKey);
|
||||
if (!cached || cached.version !== AUTH_USER_DOC_CACHE_VERSION || !cached.user) {
|
||||
return undefined;
|
||||
}
|
||||
return cached.user;
|
||||
} catch (error) {
|
||||
logger.warn('[authUserDocCache] Cache read failed; falling back to user lookup', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setCachedAuthUserDoc(
|
||||
store: AuthUserDocCacheStore,
|
||||
cacheKey: string,
|
||||
user: Partial<IUser>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const sanitized = sanitizeUserForCache(user);
|
||||
await store.set(
|
||||
cacheKey,
|
||||
{
|
||||
version: AUTH_USER_DOC_CACHE_VERSION,
|
||||
cachedAt: Date.now(),
|
||||
user: sanitized,
|
||||
} satisfies CachedAuthUserDoc,
|
||||
AUTH_USER_DOC_CACHE_TTL_MS,
|
||||
);
|
||||
const userId = getUserId(sanitized);
|
||||
if (userId) {
|
||||
await rememberUserCacheKey(store, userId, cacheKey, AUTH_USER_DOC_CACHE_TTL_MS);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('[authUserDocCache] Cache write failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function invalidateCachedAuthUserDoc(
|
||||
store: AuthUserDocCacheStore | undefined,
|
||||
input: { userId?: string; cacheKey?: string },
|
||||
): Promise<void> {
|
||||
if (!store) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const keys = new Set<string>();
|
||||
if (input.cacheKey) {
|
||||
keys.add(input.cacheKey);
|
||||
}
|
||||
if (input.userId) {
|
||||
const indexKey = buildAuthUserDocReverseIndexKey(input.userId);
|
||||
const indexed = await store.get<string[]>(indexKey);
|
||||
if (Array.isArray(indexed)) {
|
||||
for (const key of indexed) {
|
||||
keys.add(key);
|
||||
}
|
||||
}
|
||||
await store.delete(indexKey);
|
||||
}
|
||||
await Promise.all([...keys].map((key) => store.delete(key)));
|
||||
} catch (error) {
|
||||
logger.warn('[authUserDocCache] Cache invalidation failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -2367,6 +2367,10 @@ export enum CacheKeys {
|
|||
* key for open id exchanged tokens
|
||||
*/
|
||||
OPENID_EXCHANGED_TOKENS = 'OPENID_EXCHANGED_TOKENS',
|
||||
/**
|
||||
* Key for cached authenticated user documents.
|
||||
*/
|
||||
AUTH_USER_DOC = 'AUTH_USER_DOC',
|
||||
/**
|
||||
* Key for OpenID session.
|
||||
*/
|
||||
|
|
@ -2381,6 +2385,8 @@ export enum CacheKeys {
|
|||
ADMIN_OAUTH_EXCHANGE = 'ADMIN_OAUTH_EXCHANGE',
|
||||
}
|
||||
|
||||
export const AUTH_USER_DOC_BY_ID_PREFIX = 'auth-user-doc-byid';
|
||||
|
||||
/**
|
||||
* Enum for violation types, used to identify, log, and cache violations.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -247,7 +247,7 @@ export function createMethods(
|
|||
const agentMethods = createAgentMethods(mongoose, agentDeps);
|
||||
|
||||
return {
|
||||
...createUserMethods(mongoose),
|
||||
...createUserMethods(mongoose, { getCache: deps.getCache }),
|
||||
...createSessionMethods(mongoose),
|
||||
...createTokenMethods(mongoose),
|
||||
...roleMethods,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
import mongoose from 'mongoose';
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server';
|
||||
import { SystemRoles, Permissions, roleDefaults, PermissionTypes } from 'librechat-data-provider';
|
||||
import {
|
||||
AUTH_USER_DOC_BY_ID_PREFIX,
|
||||
SystemRoles,
|
||||
Permissions,
|
||||
roleDefaults,
|
||||
PermissionTypes,
|
||||
CacheKeys,
|
||||
} from 'librechat-data-provider';
|
||||
import type { IRole, IUser, RolePermissions } from '..';
|
||||
import { _resetStrictCache } from '../models/plugins/tenantIsolation';
|
||||
import { tenantStorage } from '~/config/tenantContext';
|
||||
|
|
@ -17,7 +24,7 @@ jest.mock('~/config/winston', () => ({
|
|||
const mockCache = {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
del: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
|
||||
const mockGetCache = jest.fn().mockReturnValue(mockCache);
|
||||
|
|
@ -31,6 +38,7 @@ let initializeRoles: ReturnType<typeof createRoleMethods>['initializeRoles'];
|
|||
let createRoleByName: ReturnType<typeof createRoleMethods>['createRoleByName'];
|
||||
let deleteRoleByName: ReturnType<typeof createRoleMethods>['deleteRoleByName'];
|
||||
let updateUsersByRole: ReturnType<typeof createRoleMethods>['updateUsersByRole'];
|
||||
let updateUsersRoleByIds: ReturnType<typeof createRoleMethods>['updateUsersRoleByIds'];
|
||||
let listUsersByRole: ReturnType<typeof createRoleMethods>['listUsersByRole'];
|
||||
let countUsersByRole: ReturnType<typeof createRoleMethods>['countUsersByRole'];
|
||||
let updateRoleByName: ReturnType<typeof createRoleMethods>['updateRoleByName'];
|
||||
|
|
@ -54,6 +62,7 @@ beforeAll(async () => {
|
|||
deleteRoleByName = methods.deleteRoleByName;
|
||||
updateRoleByName = methods.updateRoleByName;
|
||||
updateUsersByRole = methods.updateUsersByRole;
|
||||
updateUsersRoleByIds = methods.updateUsersRoleByIds;
|
||||
listUsersByRole = methods.listUsersByRole;
|
||||
countUsersByRole = methods.countUsersByRole;
|
||||
listRoles = methods.listRoles;
|
||||
|
|
@ -69,9 +78,11 @@ beforeEach(async () => {
|
|||
await Role.deleteMany({});
|
||||
await User.deleteMany({});
|
||||
mockGetCache.mockClear();
|
||||
mockCache.get.mockClear();
|
||||
mockCache.set.mockClear();
|
||||
mockCache.del.mockClear();
|
||||
mockGetCache.mockReturnValue(mockCache);
|
||||
mockCache.get.mockReset();
|
||||
mockCache.set.mockReset();
|
||||
mockCache.delete.mockReset();
|
||||
delete process.env.AUTH_USER_CACHE_MODE;
|
||||
});
|
||||
|
||||
describe('findRolesByNames', () => {
|
||||
|
|
@ -901,6 +912,36 @@ describe('deleteRoleByName', () => {
|
|||
expect(result).toBeNull();
|
||||
expect(mockCache.set).toHaveBeenCalledWith('nonexistent', null);
|
||||
});
|
||||
|
||||
it('invalidates cached auth user documents for reassigned users', async () => {
|
||||
process.env.AUTH_USER_CACHE_MODE = 'on';
|
||||
await createRoleByName({ name: 'editor' });
|
||||
const [alice, bob] = await User.create([
|
||||
{ name: 'Alice', email: 'alice@test.com', role: 'editor', username: 'alice' },
|
||||
{ name: 'Bob', email: 'bob@test.com', role: 'editor', username: 'bob' },
|
||||
]);
|
||||
mockCache.get.mockImplementation((key: string) => {
|
||||
if (key === `${AUTH_USER_DOC_BY_ID_PREFIX}:${alice._id.toString()}`) {
|
||||
return Promise.resolve(['auth-cache-alice']);
|
||||
}
|
||||
if (key === `${AUTH_USER_DOC_BY_ID_PREFIX}:${bob._id.toString()}`) {
|
||||
return Promise.resolve(['auth-cache-bob']);
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
|
||||
await deleteRoleByName('editor');
|
||||
|
||||
expect(mockGetCache).toHaveBeenCalledWith(CacheKeys.AUTH_USER_DOC);
|
||||
expect(mockCache.delete).toHaveBeenCalledWith('auth-cache-alice');
|
||||
expect(mockCache.delete).toHaveBeenCalledWith(
|
||||
`${AUTH_USER_DOC_BY_ID_PREFIX}:${alice._id.toString()}`,
|
||||
);
|
||||
expect(mockCache.delete).toHaveBeenCalledWith('auth-cache-bob');
|
||||
expect(mockCache.delete).toHaveBeenCalledWith(
|
||||
`${AUTH_USER_DOC_BY_ID_PREFIX}:${bob._id.toString()}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateRoleByName - cache on rename', () => {
|
||||
|
|
@ -1024,6 +1065,68 @@ describe('updateUsersByRole', () => {
|
|||
const alice = await User.findOne({ email: 'alice@test.com' }).lean();
|
||||
expect(alice!.role).toBe(SystemRoles.USER);
|
||||
});
|
||||
|
||||
it('invalidates cached auth user documents for migrated users', async () => {
|
||||
process.env.AUTH_USER_CACHE_MODE = 'on';
|
||||
const [alice, bob] = await User.create([
|
||||
{ name: 'Alice', email: 'alice@test.com', role: 'editor', username: 'alice' },
|
||||
{ name: 'Bob', email: 'bob@test.com', role: 'editor', username: 'bob' },
|
||||
]);
|
||||
mockCache.get.mockImplementation((key: string) => {
|
||||
if (key === `${AUTH_USER_DOC_BY_ID_PREFIX}:${alice._id.toString()}`) {
|
||||
return Promise.resolve(['auth-cache-alice']);
|
||||
}
|
||||
if (key === `${AUTH_USER_DOC_BY_ID_PREFIX}:${bob._id.toString()}`) {
|
||||
return Promise.resolve(['auth-cache-bob']);
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
|
||||
await updateUsersByRole('editor', 'senior-editor');
|
||||
|
||||
expect(mockGetCache).toHaveBeenCalledWith(CacheKeys.AUTH_USER_DOC);
|
||||
expect(mockCache.delete).toHaveBeenCalledWith('auth-cache-alice');
|
||||
expect(mockCache.delete).toHaveBeenCalledWith(
|
||||
`${AUTH_USER_DOC_BY_ID_PREFIX}:${alice._id.toString()}`,
|
||||
);
|
||||
expect(mockCache.delete).toHaveBeenCalledWith('auth-cache-bob');
|
||||
expect(mockCache.delete).toHaveBeenCalledWith(
|
||||
`${AUTH_USER_DOC_BY_ID_PREFIX}:${bob._id.toString()}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateUsersRoleByIds', () => {
|
||||
it('invalidates cached auth user documents for explicitly reassigned users', async () => {
|
||||
process.env.AUTH_USER_CACHE_MODE = 'on';
|
||||
const [alice, bob] = await User.create([
|
||||
{ name: 'Alice', email: 'alice@test.com', role: 'editor', username: 'alice' },
|
||||
{ name: 'Bob', email: 'bob@test.com', role: 'viewer', username: 'bob' },
|
||||
]);
|
||||
mockCache.get.mockImplementation((key: string) => {
|
||||
if (key === `${AUTH_USER_DOC_BY_ID_PREFIX}:${alice._id.toString()}`) {
|
||||
return Promise.resolve(['auth-cache-alice']);
|
||||
}
|
||||
if (key === `${AUTH_USER_DOC_BY_ID_PREFIX}:${bob._id.toString()}`) {
|
||||
return Promise.resolve(['auth-cache-bob']);
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
|
||||
await updateUsersRoleByIds([alice._id.toString(), bob._id.toString()], 'admin-lite');
|
||||
|
||||
const updatedUsers = await User.find({ _id: { $in: [alice._id, bob._id] } }).lean();
|
||||
expect(updatedUsers.map((user) => user.role)).toEqual(['admin-lite', 'admin-lite']);
|
||||
expect(mockGetCache).toHaveBeenCalledWith(CacheKeys.AUTH_USER_DOC);
|
||||
expect(mockCache.delete).toHaveBeenCalledWith('auth-cache-alice');
|
||||
expect(mockCache.delete).toHaveBeenCalledWith(
|
||||
`${AUTH_USER_DOC_BY_ID_PREFIX}:${alice._id.toString()}`,
|
||||
);
|
||||
expect(mockCache.delete).toHaveBeenCalledWith('auth-cache-bob');
|
||||
expect(mockCache.delete).toHaveBeenCalledWith(
|
||||
`${AUTH_USER_DOC_BY_ID_PREFIX}:${bob._id.toString()}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('countUsersByRole', () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
AUTH_USER_DOC_BY_ID_PREFIX,
|
||||
CacheKeys,
|
||||
SystemRoles,
|
||||
roleDefaults,
|
||||
|
|
@ -18,6 +19,10 @@ function isSystemRoleName(name: string): boolean {
|
|||
return systemRoleValues.has(name.toUpperCase());
|
||||
}
|
||||
|
||||
function isAuthUserDocCacheEnabled(): boolean {
|
||||
return process.env.AUTH_USER_CACHE_MODE === 'on';
|
||||
}
|
||||
|
||||
export class RoleConflictError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
|
|
@ -570,7 +575,9 @@ export function createRoleMethods(
|
|||
}
|
||||
const Role = mongoose.models.Role;
|
||||
const User = mongoose.models.User as Model<IUser>;
|
||||
const affectedUserIds = await findUserIdsByRole(roleName);
|
||||
await User.updateMany({ role: roleName }, { $set: { role: SystemRoles.USER } });
|
||||
await invalidateAuthUserDocCache(affectedUserIds);
|
||||
const deleted = await Role.findOneAndDelete({ name: roleName }).lean();
|
||||
try {
|
||||
const cache = deps.getCache?.(CacheKeys.ROLES);
|
||||
|
|
@ -588,7 +595,9 @@ export function createRoleMethods(
|
|||
|
||||
async function updateUsersByRole(oldRole: string, newRole: string): Promise<void> {
|
||||
const User = mongoose.models.User as Model<IUser>;
|
||||
const affectedUserIds = await findUserIdsByRole(oldRole);
|
||||
await User.updateMany({ role: oldRole }, { $set: { role: newRole } });
|
||||
await invalidateAuthUserDocCache(affectedUserIds);
|
||||
}
|
||||
|
||||
async function findUserIdsByRole(roleName: string): Promise<string[]> {
|
||||
|
|
@ -603,6 +612,34 @@ export function createRoleMethods(
|
|||
}
|
||||
const User = mongoose.models.User as Model<IUser>;
|
||||
await User.updateMany({ _id: { $in: userIds } }, { $set: { role: newRole } });
|
||||
await invalidateAuthUserDocCache(userIds);
|
||||
}
|
||||
|
||||
async function invalidateAuthUserDocCache(userIds: string[]): Promise<void> {
|
||||
if (!isAuthUserDocCacheEnabled() || userIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const cache = deps.getCache?.(CacheKeys.AUTH_USER_DOC);
|
||||
if (!cache?.get || !cache?.delete) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const uniqueUserIds = [...new Set(userIds.map((userId) => userId.toString()))];
|
||||
await Promise.all(
|
||||
uniqueUserIds.map(async (userId) => {
|
||||
const indexKey = `${AUTH_USER_DOC_BY_ID_PREFIX}:${userId}`;
|
||||
const cachedKeys = await cache.get(indexKey);
|
||||
if (Array.isArray(cachedKeys)) {
|
||||
await Promise.all(
|
||||
cachedKeys.map((key) => (typeof key === 'string' ? cache.delete?.(key) : undefined)),
|
||||
);
|
||||
}
|
||||
await cache.delete?.(indexKey);
|
||||
}),
|
||||
);
|
||||
} catch (cacheError) {
|
||||
logger.error('[roleMethods] auth user doc cache invalidation failed:', cacheError);
|
||||
}
|
||||
}
|
||||
|
||||
async function listUsersByRole(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import mongoose from 'mongoose';
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server';
|
||||
import { AUTH_USER_DOC_BY_ID_PREFIX, CacheKeys } from 'librechat-data-provider';
|
||||
import type * as t from '~/types';
|
||||
import balanceSchema from '~/schema/balance';
|
||||
import { createUserMethods } from './user';
|
||||
|
|
@ -15,6 +16,24 @@ let User: mongoose.Model<t.IUser>;
|
|||
let Balance: mongoose.Model<t.IBalance>;
|
||||
let methods: ReturnType<typeof createUserMethods>;
|
||||
|
||||
const ORIGINAL_AUTH_USER_CACHE_ENV = {
|
||||
AUTH_USER_CACHE_MODE: process.env.AUTH_USER_CACHE_MODE,
|
||||
};
|
||||
|
||||
function restoreAuthUserCacheEnv() {
|
||||
for (const [key, value] of Object.entries(ORIGINAL_AUTH_USER_CACHE_ENV)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function enableAuthUserDocCache() {
|
||||
process.env.AUTH_USER_CACHE_MODE = 'on';
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
const mongoUri = mongoServer.getUri();
|
||||
|
|
@ -34,9 +53,14 @@ afterAll(async () => {
|
|||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
restoreAuthUserCacheEnv();
|
||||
await mongoose.connection.dropDatabase();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreAuthUserCacheEnv();
|
||||
});
|
||||
|
||||
describe('User schema indexes', () => {
|
||||
test('should define an issuer-bound idOnTheSource lookup index', async () => {
|
||||
await User.syncIndexes();
|
||||
|
|
@ -323,6 +347,55 @@ describe('User Methods - Database Tests', () => {
|
|||
expect(updated?.expiresAt).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should invalidate cached auth user documents on update', async () => {
|
||||
enableAuthUserDocCache();
|
||||
const user = await User.create({
|
||||
name: 'Cached Auth User',
|
||||
email: 'cached-auth@example.com',
|
||||
provider: 'openid',
|
||||
});
|
||||
const indexKey = `${AUTH_USER_DOC_BY_ID_PREFIX}:${user._id?.toString()}`;
|
||||
const cache = {
|
||||
get: jest.fn().mockResolvedValue(['auth-cache-key-a', 'auth-cache-key-b']),
|
||||
delete: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
const getCache = jest.fn().mockReturnValue(cache);
|
||||
const methodsWithCache = createUserMethods(mongoose, { getCache });
|
||||
|
||||
await methodsWithCache.updateUser(user._id?.toString() ?? '', {
|
||||
name: 'Updated Cached Auth User',
|
||||
});
|
||||
|
||||
expect(getCache).toHaveBeenCalledWith(CacheKeys.AUTH_USER_DOC);
|
||||
expect(cache.get).toHaveBeenCalledWith(indexKey);
|
||||
expect(cache.delete).toHaveBeenCalledWith('auth-cache-key-a');
|
||||
expect(cache.delete).toHaveBeenCalledWith('auth-cache-key-b');
|
||||
expect(cache.delete).toHaveBeenCalledWith(indexKey);
|
||||
});
|
||||
|
||||
test('should invalidate cached auth user documents on delete', async () => {
|
||||
enableAuthUserDocCache();
|
||||
const user = await User.create({
|
||||
name: 'Deleted Cached Auth User',
|
||||
email: 'deleted-cached-auth@example.com',
|
||||
provider: 'openid',
|
||||
});
|
||||
const indexKey = `${AUTH_USER_DOC_BY_ID_PREFIX}:${user._id?.toString()}`;
|
||||
const cache = {
|
||||
get: jest.fn().mockResolvedValue(['auth-cache-key-a']),
|
||||
delete: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
const getCache = jest.fn().mockReturnValue(cache);
|
||||
const methodsWithCache = createUserMethods(mongoose, { getCache });
|
||||
|
||||
await methodsWithCache.deleteUserById(user._id?.toString() ?? '');
|
||||
|
||||
expect(getCache).toHaveBeenCalledWith(CacheKeys.AUTH_USER_DOC);
|
||||
expect(cache.get).toHaveBeenCalledWith(indexKey);
|
||||
expect(cache.delete).toHaveBeenCalledWith('auth-cache-key-a');
|
||||
expect(cache.delete).toHaveBeenCalledWith(indexKey);
|
||||
});
|
||||
|
||||
test('should return null for non-existent user', async () => {
|
||||
const fakeId = new mongoose.Types.ObjectId();
|
||||
const result = await methods.updateUser(fakeId.toString(), { name: 'Test' });
|
||||
|
|
@ -424,6 +497,29 @@ describe('User Methods - Database Tests', () => {
|
|||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('should invalidate cached auth user documents on acceptance', async () => {
|
||||
enableAuthUserDocCache();
|
||||
const user = await User.create({
|
||||
name: 'Cached Terms User',
|
||||
email: 'cached-terms@example.com',
|
||||
provider: 'openid',
|
||||
});
|
||||
const indexKey = `${AUTH_USER_DOC_BY_ID_PREFIX}:${user._id?.toString()}`;
|
||||
const cache = {
|
||||
get: jest.fn().mockResolvedValue(['auth-cache-key-a']),
|
||||
delete: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
const getCache = jest.fn().mockReturnValue(cache);
|
||||
const methodsWithCache = createUserMethods(mongoose, { getCache });
|
||||
|
||||
await methodsWithCache.acceptTerms(user._id?.toString() ?? '');
|
||||
|
||||
expect(getCache).toHaveBeenCalledWith(CacheKeys.AUTH_USER_DOC);
|
||||
expect(cache.get).toHaveBeenCalledWith(indexKey);
|
||||
expect(cache.delete).toHaveBeenCalledWith('auth-cache-key-a');
|
||||
expect(cache.delete).toHaveBeenCalledWith(indexKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteUserById', () => {
|
||||
|
|
@ -647,6 +743,31 @@ describe('User Methods - Database Tests', () => {
|
|||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('should invalidate cached auth user documents when memories preference changes', async () => {
|
||||
enableAuthUserDocCache();
|
||||
const user = await User.create({
|
||||
name: 'Cached Memory User',
|
||||
email: 'cached-memory@example.com',
|
||||
provider: 'openid',
|
||||
personalization: { memories: true },
|
||||
});
|
||||
const indexKey = `${AUTH_USER_DOC_BY_ID_PREFIX}:${user._id?.toString()}`;
|
||||
const cache = {
|
||||
get: jest.fn().mockResolvedValue(['auth-cache-key-a']),
|
||||
set: jest.fn().mockResolvedValue(true),
|
||||
delete: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
const getCache = jest.fn().mockReturnValue(cache);
|
||||
const methodsWithCache = createUserMethods(mongoose, { getCache });
|
||||
|
||||
await methodsWithCache.toggleUserMemories(user._id?.toString() ?? '', false);
|
||||
|
||||
expect(getCache).toHaveBeenCalledWith(CacheKeys.AUTH_USER_DOC);
|
||||
expect(cache.get).toHaveBeenCalledWith(indexKey);
|
||||
expect(cache.delete).toHaveBeenCalledWith('auth-cache-key-a');
|
||||
expect(cache.delete).toHaveBeenCalledWith(indexKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Email Normalization Edge Cases', () => {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,30 @@
|
|||
import mongoose, { FilterQuery } from 'mongoose';
|
||||
import type { RefillIntervalUnit } from 'librechat-data-provider';
|
||||
import {
|
||||
AUTH_USER_DOC_BY_ID_PREFIX,
|
||||
CacheKeys,
|
||||
type RefillIntervalUnit,
|
||||
} from 'librechat-data-provider';
|
||||
import type { IUser, BalanceConfig, CreateUserRequest, UserDeleteResult } from '~/types';
|
||||
import type { CacheStore } from '~/types';
|
||||
import { escapeRegExp } from '~/utils/string';
|
||||
import { signPayload } from '~/crypto';
|
||||
|
||||
/** Default JWT session expiry: 15 minutes in milliseconds */
|
||||
export const DEFAULT_SESSION_EXPIRY: number = 1000 * 60 * 15;
|
||||
|
||||
interface UserMethodDeps {
|
||||
getCache?: (key: string) => CacheStore | undefined;
|
||||
}
|
||||
|
||||
function isAuthUserDocCacheEnabled(): boolean {
|
||||
return process.env.AUTH_USER_CACHE_MODE === 'on';
|
||||
}
|
||||
|
||||
/** Factory function that takes mongoose instance and returns the methods */
|
||||
export function createUserMethods(mongoose: typeof import('mongoose')): {
|
||||
export function createUserMethods(
|
||||
mongoose: typeof import('mongoose'),
|
||||
deps: UserMethodDeps = {},
|
||||
): {
|
||||
findUser: (
|
||||
searchCriteria: FilterQuery<IUser>,
|
||||
fieldsToSelect?: string | string[] | null,
|
||||
|
|
@ -248,10 +264,34 @@ export function createUserMethods(mongoose: typeof import('mongoose')): {
|
|||
$set: updateData,
|
||||
$unset: { expiresAt: '' }, // Remove the expiresAt field to prevent TTL
|
||||
};
|
||||
return await User.findByIdAndUpdate(userId, updateOperation, {
|
||||
const updated = await User.findByIdAndUpdate(userId, updateOperation, {
|
||||
new: true,
|
||||
runValidators: true,
|
||||
}).lean<IUser>();
|
||||
await invalidateAuthUserDocCache(userId);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async function invalidateAuthUserDocCache(userId: string): Promise<void> {
|
||||
if (!isAuthUserDocCacheEnabled()) {
|
||||
return;
|
||||
}
|
||||
const cache = deps.getCache?.(CacheKeys.AUTH_USER_DOC);
|
||||
if (!cache?.get || !cache?.delete) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const indexKey = `${AUTH_USER_DOC_BY_ID_PREFIX}:${userId}`;
|
||||
const cachedKeys = await cache.get(indexKey);
|
||||
if (Array.isArray(cachedKeys)) {
|
||||
await Promise.all(
|
||||
cachedKeys.map((key) => (typeof key === 'string' ? cache.delete?.(key) : undefined)),
|
||||
);
|
||||
}
|
||||
await cache.delete(indexKey);
|
||||
} catch {
|
||||
// Cache invalidation must not make a user update fail.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -262,7 +302,7 @@ export function createUserMethods(mongoose: typeof import('mongoose')): {
|
|||
*/
|
||||
async function acceptTerms(userId: string): Promise<IUser | null> {
|
||||
const User = mongoose.models.User;
|
||||
return await User.findByIdAndUpdate(
|
||||
const updated = await User.findByIdAndUpdate(
|
||||
userId,
|
||||
[
|
||||
{
|
||||
|
|
@ -274,6 +314,10 @@ export function createUserMethods(mongoose: typeof import('mongoose')): {
|
|||
],
|
||||
{ new: true, runValidators: true },
|
||||
).lean<IUser>();
|
||||
if (updated) {
|
||||
await invalidateAuthUserDocCache(userId);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -301,6 +345,7 @@ export function createUserMethods(mongoose: typeof import('mongoose')): {
|
|||
if (result.deletedCount === 0) {
|
||||
return { deletedCount: 0, message: 'No user found with that ID.' };
|
||||
}
|
||||
await invalidateAuthUserDocCache(userId);
|
||||
return { deletedCount: result.deletedCount, message: 'User was deleted successfully.' };
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
|
@ -355,10 +400,14 @@ export function createUserMethods(mongoose: typeof import('mongoose')): {
|
|||
},
|
||||
};
|
||||
|
||||
return await User.findByIdAndUpdate(userId, updateOperation, {
|
||||
const updated = await User.findByIdAndUpdate(userId, updateOperation, {
|
||||
new: true,
|
||||
runValidators: true,
|
||||
}).lean<IUser>();
|
||||
if (updated) {
|
||||
await invalidateAuthUserDocCache(userId);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue