From 329ed482464da0861fe16d63a1600d98fccd12f4 Mon Sep 17 00:00:00 2001 From: Ravi Kumar L Date: Sun, 12 Jul 2026 13:53:50 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=89=20perf:=20cache=20OpenID=20JWT=20u?= =?UTF-8?q?ser=20documents=20(#14187)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(auth): cache OpenID JWT user documents * fix(auth): invalidate cached auth users on role changes --- AGENTS.md | 2 + api/cache/getLogStores.js | 1 + api/strategies/openIdJwtStrategy.js | 53 ++++- api/strategies/openIdJwtStrategy.spec.js | 137 ++++++++++- packages/api/src/auth/index.ts | 1 + packages/api/src/auth/userDocCache.spec.ts | 225 ++++++++++++++++++ packages/api/src/auth/userDocCache.ts | 214 +++++++++++++++++ packages/data-provider/src/config.ts | 6 + packages/data-schemas/src/methods/index.ts | 2 +- .../src/methods/role.methods.spec.ts | 113 ++++++++- packages/data-schemas/src/methods/role.ts | 37 +++ .../src/methods/user.methods.spec.ts | 121 ++++++++++ packages/data-schemas/src/methods/user.ts | 59 ++++- 13 files changed, 947 insertions(+), 24 deletions(-) create mode 100644 packages/api/src/auth/userDocCache.spec.ts create mode 100644 packages/api/src/auth/userDocCache.ts diff --git a/AGENTS.md b/AGENTS.md index 9b8a940c5c..48e86847e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/api/cache/getLogStores.js b/api/cache/getLogStores.js index 848d22227c..b7f943cc55 100644 --- a/api/cache/getLogStores.js +++ b/api/cache/getLogStores.js @@ -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, diff --git a/api/strategies/openIdJwtStrategy.js b/api/strategies/openIdJwtStrategy.js index 9b9c9644f2..0bdf14b8b6 100644 --- a/api/strategies/openIdJwtStrategy.js +++ b/api/strategies/openIdJwtStrategy.js @@ -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; diff --git a/api/strategies/openIdJwtStrategy.spec.js b/api/strategies/openIdJwtStrategy.spec.js index 4121d64def..076fd99337 100644 --- a/api/strategies/openIdJwtStrategy.spec.js +++ b/api/strategies/openIdJwtStrategy.spec.js @@ -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', diff --git a/packages/api/src/auth/index.ts b/packages/api/src/auth/index.ts index f51f5c4081..0b211cf3b2 100644 --- a/packages/api/src/auth/index.ts +++ b/packages/api/src/auth/index.ts @@ -8,3 +8,4 @@ export * from './password'; export * from './invite'; export * from './codeapi'; export * from './openidRoleSync'; +export * from './userDocCache'; diff --git a/packages/api/src/auth/userDocCache.spec.ts b/packages/api/src/auth/userDocCache.spec.ts new file mode 100644 index 0000000000..e052b77f33 --- /dev/null +++ b/packages/api/src/auth/userDocCache.spec.ts @@ -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(); + return { + values, + get: async (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 }; + 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 (): Promise => { + 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' }, + ); + }); +}); diff --git a/packages/api/src/auth/userDocCache.ts b/packages/api/src/auth/userDocCache.ts new file mode 100644 index 0000000000..95be55fd64 --- /dev/null +++ b/packages/api/src/auth/userDocCache.ts @@ -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: (key: string) => Promise; + set: (key: string, value: unknown, ttl?: number) => Promise; + delete: (key: string) => Promise; +} + +export interface AuthUserDocCacheKeyInput { + strategy: string; + subject?: string; + issuer?: string; + scope?: string; +} + +interface CachedAuthUserDoc { + version: number; + cachedAt: number; + user: CachedAuthUser; +} + +type CachedAuthUser = Omit, '_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): 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 { + const indexKey = buildAuthUserDocReverseIndexKey(userId); + const existing = await store.get(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 { + try { + const cached = await store.get(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, +): Promise { + 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 { + if (!store) { + return; + } + try { + const keys = new Set(); + if (input.cacheKey) { + keys.add(input.cacheKey); + } + if (input.userId) { + const indexKey = buildAuthUserDocReverseIndexKey(input.userId); + const indexed = await store.get(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), + }); + } +} diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index d71a20d250..58dfe30cda 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -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. */ diff --git a/packages/data-schemas/src/methods/index.ts b/packages/data-schemas/src/methods/index.ts index c3c02492b4..18448cccc7 100644 --- a/packages/data-schemas/src/methods/index.ts +++ b/packages/data-schemas/src/methods/index.ts @@ -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, diff --git a/packages/data-schemas/src/methods/role.methods.spec.ts b/packages/data-schemas/src/methods/role.methods.spec.ts index 0255227160..d02d30cf78 100644 --- a/packages/data-schemas/src/methods/role.methods.spec.ts +++ b/packages/data-schemas/src/methods/role.methods.spec.ts @@ -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['initializeRoles']; let createRoleByName: ReturnType['createRoleByName']; let deleteRoleByName: ReturnType['deleteRoleByName']; let updateUsersByRole: ReturnType['updateUsersByRole']; +let updateUsersRoleByIds: ReturnType['updateUsersRoleByIds']; let listUsersByRole: ReturnType['listUsersByRole']; let countUsersByRole: ReturnType['countUsersByRole']; let updateRoleByName: ReturnType['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', () => { diff --git a/packages/data-schemas/src/methods/role.ts b/packages/data-schemas/src/methods/role.ts index 214aa185d6..3b42e82306 100644 --- a/packages/data-schemas/src/methods/role.ts +++ b/packages/data-schemas/src/methods/role.ts @@ -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; + 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 { const User = mongoose.models.User as Model; + const affectedUserIds = await findUserIdsByRole(oldRole); await User.updateMany({ role: oldRole }, { $set: { role: newRole } }); + await invalidateAuthUserDocCache(affectedUserIds); } async function findUserIdsByRole(roleName: string): Promise { @@ -603,6 +612,34 @@ export function createRoleMethods( } const User = mongoose.models.User as Model; await User.updateMany({ _id: { $in: userIds } }, { $set: { role: newRole } }); + await invalidateAuthUserDocCache(userIds); + } + + async function invalidateAuthUserDocCache(userIds: string[]): Promise { + 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( diff --git a/packages/data-schemas/src/methods/user.methods.spec.ts b/packages/data-schemas/src/methods/user.methods.spec.ts index a2d3ea90cd..9f15d2f9a4 100644 --- a/packages/data-schemas/src/methods/user.methods.spec.ts +++ b/packages/data-schemas/src/methods/user.methods.spec.ts @@ -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; let Balance: mongoose.Model; let methods: ReturnType; +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', () => { diff --git a/packages/data-schemas/src/methods/user.ts b/packages/data-schemas/src/methods/user.ts index 4376fc07a0..d044bf40d4 100644 --- a/packages/data-schemas/src/methods/user.ts +++ b/packages/data-schemas/src/methods/user.ts @@ -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, 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(); + await invalidateAuthUserDocCache(userId); + return updated; + } + + async function invalidateAuthUserDocCache(userId: string): Promise { + 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 { 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(); + 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(); + if (updated) { + await invalidateAuthUserDocCache(userId); + } + return updated; } /**