📉 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:
Ravi Kumar L 2026-07-12 13:53:50 +02:00 committed by GitHub
parent b753da163e
commit 329ed48246
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 947 additions and 24 deletions

View file

@ -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.

View file

@ -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,

View file

@ -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;

View file

@ -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',

View file

@ -8,3 +8,4 @@ export * from './password';
export * from './invite';
export * from './codeapi';
export * from './openidRoleSync';
export * from './userDocCache';

View 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' },
);
});
});

View 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),
});
}
}

View file

@ -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.
*/

View file

@ -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,

View file

@ -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', () => {

View file

@ -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(

View file

@ -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', () => {

View file

@ -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;
}
/**