diff --git a/api/server/controllers/AuthController.js b/api/server/controllers/AuthController.js index acaa704985..dca25dfc80 100644 --- a/api/server/controllers/AuthController.js +++ b/api/server/controllers/AuthController.js @@ -334,7 +334,11 @@ const refreshController = async (req, res) => { existingRefreshToken: bridgedRefreshToken, tenantId: retryUser.tenantId, }); - deleteRefreshTokenBridge({ oldRefreshToken: refreshToken }); + await deleteRefreshTokenBridge({ + oldRefreshToken: refreshToken, + userId, + tenantId: bridgeUser.tenantId, + }); return res .status(200) .send({ token, user: sanitizeUserForAuthResponse(retryUser) }); diff --git a/api/server/controllers/AuthController.spec.js b/api/server/controllers/AuthController.spec.js index eb7e67460f..1f39e6b7d5 100644 --- a/api/server/controllers/AuthController.spec.js +++ b/api/server/controllers/AuthController.spec.js @@ -770,7 +770,11 @@ describe('refreshController – OpenID path', () => { existingRefreshToken: 'bridged-refresh', tenantId: undefined, }); - expect(deleteRefreshTokenBridge).toHaveBeenCalledWith({ oldRefreshToken: 'stored-refresh' }); + expect(deleteRefreshTokenBridge).toHaveBeenCalledWith({ + oldRefreshToken: 'stored-refresh', + userId: 'user-db-id', + tenantId: 'tenant-1', + }); expect(res.status).toHaveBeenCalledWith(200); }); diff --git a/api/server/services/RefreshTokenBridge.js b/api/server/services/RefreshTokenBridge.js index 95850f3c45..88431478b6 100644 --- a/api/server/services/RefreshTokenBridge.js +++ b/api/server/services/RefreshTokenBridge.js @@ -1,40 +1,37 @@ const crypto = require('node:crypto'); -const { logger, encryptV2, decryptV2 } = require('@librechat/data-schemas'); +const { + logger, + encryptV2, + decryptV2, + DEFAULT_REFRESH_TOKEN_EXPIRY, +} = require('@librechat/data-schemas'); +const { math } = require('@librechat/api'); +const db = require('~/models'); /** - * Ephemeral server-side recovery bridge for refresh tokens rotated during SSE streaming. + * Server-side recovery bridge for refresh tokens rotated during SSE streaming. * * When an OBO call during SSE streaming rotates a refresh token but cannot sync the - * browser cookie (headers already sent), this bridge stores a temporary mapping so + * browser cookie (headers already sent), this bridge stores a temporary Mongo mapping so * that if the express-session expires and the next /api/auth/refresh uses the stale * cookie, we can look up and use the rotated token instead. * * Bridge key: hash(oldRefreshToken) uniquely identifies which token rotation this is. * Bridge value: encrypted newRefreshToken, userId, tenantId, openidIssuer for verification. - * Bridge TTL: short-lived (default ~max cookie expiry window), auto-deleted on use or expiry. - * - * Process-local: each server instance maintains its own bridge map. Multi-worker deployments - * may have race conditions on cleanup, but the TTL ensures bridges don't accumulate. + * Bridge TTL: matches the refresh-token cookie lifetime and is enforced by Mongo TTL. */ -// hash(oldToken) -> { encryptedNewToken, userId, tenantId, issuer, createdAt, ttl } -const bridges = new Map(); - -/** - * TTL for a refresh-token bridge (ms). Bridges should be short-lived to bound memory - * and avoid stale recovery attempts. Defaults to 24 hours (similar to refresh cookie). - */ -const DEFAULT_BRIDGE_TTL_MS = 24 * 60 * 60 * 1000; +const getBridgeTtlMs = () => math(process.env.REFRESH_TOKEN_EXPIRY, DEFAULT_REFRESH_TOKEN_EXPIRY); /** * Hashes a refresh token for use as a bridge key. Does NOT encrypt; hash is for - * lookup only, safe to expose. Uses SHA-256 truncated to 12 bytes for reasonable - * collision safety (~ 48 bits entropy, same as single-flight key hashing). + * lookup only, safe to expose. Uses full SHA-256 because this value is persisted + * and participates in a unique index. * * @param {string} refreshToken - * @returns {string} hex-encoded 12-byte hash + * @returns {string} hex-encoded SHA-256 hash */ function hashRefreshToken(refreshToken) { - return crypto.createHash('sha256').update(refreshToken).digest('hex').slice(0, 24); + return crypto.createHash('sha256').update(refreshToken).digest('hex'); } /** @@ -48,7 +45,7 @@ function hashRefreshToken(refreshToken) { * @param {string} args.userId — user._id (for verification on lookup) * @param {string} [args.tenantId] — user.tenantId (for multi-tenant deployments) * @param {string} [args.openidIssuer] — user.openidIssuer (for issuer-specific validation) - * @param {number} [args.ttl] — bridge TTL in ms (defaults to DEFAULT_BRIDGE_TTL_MS) + * @param {number} [args.ttl] — bridge TTL in ms (defaults to REFRESH_TOKEN_EXPIRY) */ async function storeRefreshTokenBridge({ oldRefreshToken, @@ -63,23 +60,21 @@ async function storeRefreshTokenBridge({ return; } - purgeExpiredBridges(); - - const key = hashRefreshToken(oldRefreshToken); - const bridgeTtl = ttl ?? DEFAULT_BRIDGE_TTL_MS; + const oldRefreshTokenHash = hashRefreshToken(oldRefreshToken); + const bridgeTtl = ttl ?? getBridgeTtlMs(); const encryptedNewToken = await encryptV2(newRefreshToken); - bridges.set(key, { - encryptedNewToken, + await db.upsertRefreshTokenBridge({ + oldRefreshTokenHash, + encryptedNewRefreshToken: encryptedNewToken, userId, tenantId, - issuer: openidIssuer, - createdAt: Date.now(), - ttl: bridgeTtl, + openidIssuer, + expiresAt: new Date(Date.now() + bridgeTtl), }); logger.debug('[RefreshTokenBridge] Stored recovery bridge', { - tokenHash: key, + tokenHash: oldRefreshTokenHash, userId, ttl: bridgeTtl, }); @@ -103,52 +98,32 @@ async function getRefreshTokenBridge({ oldRefreshToken, userId, tenantId, openid return null; } - const key = hashRefreshToken(oldRefreshToken); - const bridge = bridges.get(key); + const oldRefreshTokenHash = hashRefreshToken(oldRefreshToken); + const bridge = await db.findRefreshTokenBridge({ + oldRefreshTokenHash, + userId, + tenantId, + }); if (!bridge) { return null; } - // Check TTL: if expired, delete and return null - const age = Date.now() - bridge.createdAt; - if (age > bridge.ttl) { - logger.debug('[RefreshTokenBridge] Bridge expired, deleting', { tokenHash: key, age }); - bridges.delete(key); - return null; - } - - // Verify user context matches. Optional tenant/issuer constraints are enforced when stored. - if (bridge.userId !== userId) { - logger.warn('[RefreshTokenBridge] Bridge lookup failed: userId mismatch', { - tokenHash: key, - bridgedUserId: bridge.userId, - currentUserId: userId, - }); - return null; - } - - if (bridge.tenantId && bridge.tenantId !== tenantId) { - logger.warn('[RefreshTokenBridge] Bridge lookup failed: tenantId mismatch', { - tokenHash: key, - }); - return null; - } - - if (bridge.issuer && bridge.issuer !== openidIssuer) { + if (bridge.openidIssuer && bridge.openidIssuer !== openidIssuer) { logger.warn('[RefreshTokenBridge] Bridge lookup failed: issuer mismatch', { - tokenHash: key, + tokenHash: oldRefreshTokenHash, }); return null; } + const age = Date.now() - new Date(bridge.createdAt).getTime(); logger.info('[RefreshTokenBridge] Successfully resolved recovery bridge', { - tokenHash: key, + tokenHash: oldRefreshTokenHash, userId, age, }); - return decryptV2(bridge.encryptedNewToken); + return decryptV2(bridge.encryptedNewRefreshToken); } /** @@ -156,47 +131,26 @@ async function getRefreshTokenBridge({ oldRefreshToken, userId, tenantId, openid * * @param {object} args * @param {string} args.oldRefreshToken - * @returns {boolean} + * @returns {Promise} */ -function deleteRefreshTokenBridge({ oldRefreshToken }) { +async function deleteRefreshTokenBridge({ oldRefreshToken, userId, tenantId }) { if (!oldRefreshToken) { return false; } - return bridges.delete(hashRefreshToken(oldRefreshToken)); -} - -/** - * Cleanup routine: purge expired bridges. Safe to call periodically (e.g., hourly). - * Logs the count of bridges cleaned up. - */ -function purgeExpiredBridges() { - const now = Date.now(); - let purged = 0; - - for (const [key, bridge] of bridges.entries()) { - const age = now - bridge.createdAt; - if (age > bridge.ttl) { - bridges.delete(key); - purged++; - } - } - - if (purged > 0) { - logger.debug('[RefreshTokenBridge] Purged expired bridges', { - count: purged, - remaining: bridges.size, - }); + if (!userId) { + return false; } + const oldRefreshTokenHash = hashRefreshToken(oldRefreshToken); + const result = await db.deleteRefreshTokenBridge({ oldRefreshTokenHash, userId, tenantId }); + return (result.deletedCount ?? 0) > 0; } module.exports = { storeRefreshTokenBridge, getRefreshTokenBridge, deleteRefreshTokenBridge, - purgeExpiredBridges, __internals: { - bridges, hashRefreshToken, - DEFAULT_BRIDGE_TTL_MS, + getBridgeTtlMs, }, }; diff --git a/api/server/services/RefreshTokenBridge.spec.js b/api/server/services/RefreshTokenBridge.spec.js index 23d87ce82e..38f0d76b30 100644 --- a/api/server/services/RefreshTokenBridge.spec.js +++ b/api/server/services/RefreshTokenBridge.spec.js @@ -5,42 +5,61 @@ jest.mock('@librechat/data-schemas', () => ({ warn: jest.fn(), info: jest.fn(), }, + DEFAULT_REFRESH_TOKEN_EXPIRY: 604800000, encryptV2: jest.fn(async (value) => `encrypted:${value}`), decryptV2: jest.fn(async (value) => value.replace(/^encrypted:/, '')), })); +jest.mock('@librechat/api', () => ({ + math: jest.fn((_value, fallback) => fallback), +})); + +jest.mock('~/models', () => ({ + upsertRefreshTokenBridge: jest.fn(), + findRefreshTokenBridge: jest.fn(), + deleteRefreshTokenBridge: jest.fn(), +})); + const { encryptV2, decryptV2 } = require('@librechat/data-schemas'); +const { math } = require('@librechat/api'); +const db = require('~/models'); const { storeRefreshTokenBridge, getRefreshTokenBridge, deleteRefreshTokenBridge, - purgeExpiredBridges, __internals, } = require('./RefreshTokenBridge'); describe('RefreshTokenBridge', () => { beforeEach(() => { jest.clearAllMocks(); - jest.useRealTimers(); - __internals.bridges.clear(); - }); - - afterEach(() => { - jest.useRealTimers(); + db.upsertRefreshTokenBridge.mockResolvedValue({}); + db.findRefreshTokenBridge.mockResolvedValue(null); + db.deleteRefreshTokenBridge.mockResolvedValue({ deletedCount: 0 }); }); describe('storeRefreshTokenBridge', () => { - it('stores an encrypted bridge with required fields', async () => { + it('stores an encrypted Mongo bridge with required fields', async () => { + const before = Date.now(); + await storeRefreshTokenBridge({ oldRefreshToken: 'rt-old', newRefreshToken: 'rt-new', userId: 'user-123', }); - const bridge = Array.from(__internals.bridges.values())[0]; expect(encryptV2).toHaveBeenCalledWith('rt-new'); - expect(bridge.encryptedNewToken).toBe('encrypted:rt-new'); - expect(JSON.stringify(bridge)).not.toContain('"rt-new"'); + expect(db.upsertRefreshTokenBridge).toHaveBeenCalledWith({ + oldRefreshTokenHash: __internals.hashRefreshToken('rt-old'), + encryptedNewRefreshToken: 'encrypted:rt-new', + userId: 'user-123', + tenantId: undefined, + openidIssuer: undefined, + expiresAt: expect.any(Date), + }); + const stored = db.upsertRefreshTokenBridge.mock.calls[0][0]; + expect(JSON.stringify(stored)).not.toContain('"rt-new"'); + expect(stored.expiresAt.getTime()).toBeGreaterThanOrEqual(before + 604800000 - 1000); }); it('stores optional tenant and issuer context', async () => { @@ -52,9 +71,12 @@ describe('RefreshTokenBridge', () => { openidIssuer: 'https://issuer.example.com', }); - const bridge = Array.from(__internals.bridges.values())[0]; - expect(bridge.tenantId).toBe('tenant-1'); - expect(bridge.issuer).toBe('https://issuer.example.com'); + expect(db.upsertRefreshTokenBridge).toHaveBeenCalledWith( + expect.objectContaining({ + tenantId: 'tenant-1', + openidIssuer: 'https://issuer.example.com', + }), + ); }); it('does not store a bridge without required fields', async () => { @@ -63,49 +85,59 @@ describe('RefreshTokenBridge', () => { userId: 'user-123', }); - expect(__internals.bridges.size).toBe(0); + expect(db.upsertRefreshTokenBridge).not.toHaveBeenCalled(); }); - it('purges expired bridges before inserting a new one', async () => { - jest.useFakeTimers(); + it('honors an explicit ttl override', async () => { + const before = Date.now(); + await storeRefreshTokenBridge({ - oldRefreshToken: 'rt-old-1', - newRefreshToken: 'rt-new-1', - userId: 'user-1', - ttl: 100, + oldRefreshToken: 'rt-old', + newRefreshToken: 'rt-new', + userId: 'user-123', + ttl: 1000, }); - jest.advanceTimersByTime(110); - await storeRefreshTokenBridge({ - oldRefreshToken: 'rt-old-2', - newRefreshToken: 'rt-new-2', - userId: 'user-2', - ttl: 10000, - }); - - expect(__internals.bridges.size).toBe(1); - await expect( - getRefreshTokenBridge({ oldRefreshToken: 'rt-old-2', userId: 'user-2' }), - ).resolves.toBe('rt-new-2'); + const stored = db.upsertRefreshTokenBridge.mock.calls[0][0]; + expect(stored.expiresAt.getTime()).toBeGreaterThanOrEqual(before + 1000); + expect(stored.expiresAt.getTime()).toBeLessThanOrEqual(Date.now() + 1000); }); - }); - describe('getRefreshTokenBridge', () => { - it('retrieves a matching bridge without consuming it', async () => { + it('derives the default ttl from REFRESH_TOKEN_EXPIRY', async () => { await storeRefreshTokenBridge({ oldRefreshToken: 'rt-old', newRefreshToken: 'rt-new', userId: 'user-123', }); + expect(math).toHaveBeenCalledWith(process.env.REFRESH_TOKEN_EXPIRY, 604800000); + }); + }); + + describe('getRefreshTokenBridge', () => { + it('retrieves and decrypts a matching bridge', async () => { + db.findRefreshTokenBridge.mockResolvedValue({ + encryptedNewRefreshToken: 'encrypted:rt-new', + userId: 'user-123', + tenantId: 'tenant-1', + openidIssuer: 'https://issuer.example.com', + createdAt: new Date(Date.now() - 100), + }); + const result = await getRefreshTokenBridge({ oldRefreshToken: 'rt-old', userId: 'user-123', + tenantId: 'tenant-1', + openidIssuer: 'https://issuer.example.com', }); - expect(result).toBe('rt-new'); + expect(db.findRefreshTokenBridge).toHaveBeenCalledWith({ + oldRefreshTokenHash: __internals.hashRefreshToken('rt-old'), + userId: 'user-123', + tenantId: 'tenant-1', + }); expect(decryptV2).toHaveBeenCalledWith('encrypted:rt-new'); - expect(__internals.bridges.size).toBe(1); + expect(result).toBe('rt-new'); }); it('returns null when bridge does not exist', async () => { @@ -117,46 +149,12 @@ describe('RefreshTokenBridge', () => { ).resolves.toBeNull(); }); - it('returns null when userId does not match', async () => { - await storeRefreshTokenBridge({ - oldRefreshToken: 'rt-old', - newRefreshToken: 'rt-new', - userId: 'user-123', - }); - - const result = await getRefreshTokenBridge({ - oldRefreshToken: 'rt-old', - userId: 'user-wrong', - }); - - expect(result).toBeNull(); - expect(__internals.bridges.size).toBe(1); - }); - - it('returns null when tenantId does not match', async () => { - await storeRefreshTokenBridge({ - oldRefreshToken: 'rt-old', - newRefreshToken: 'rt-new', - userId: 'user-123', - tenantId: 'tenant-1', - }); - - const result = await getRefreshTokenBridge({ - oldRefreshToken: 'rt-old', - userId: 'user-123', - tenantId: 'tenant-2', - }); - - expect(result).toBeNull(); - expect(__internals.bridges.size).toBe(1); - }); - - it('returns null when issuer does not match', async () => { - await storeRefreshTokenBridge({ - oldRefreshToken: 'rt-old', - newRefreshToken: 'rt-new', + it('returns null when stored issuer does not match', async () => { + db.findRefreshTokenBridge.mockResolvedValue({ + encryptedNewRefreshToken: 'encrypted:rt-new', userId: 'user-123', openidIssuer: 'https://issuer1.example.com', + createdAt: new Date(), }); const result = await getRefreshTokenBridge({ @@ -166,75 +164,37 @@ describe('RefreshTokenBridge', () => { }); expect(result).toBeNull(); - expect(__internals.bridges.size).toBe(1); - }); - - it('returns null and deletes when bridge has expired', async () => { - jest.useFakeTimers(); - await storeRefreshTokenBridge({ - oldRefreshToken: 'rt-old', - newRefreshToken: 'rt-new', - userId: 'user-123', - ttl: 100, - }); - - jest.advanceTimersByTime(110); - - const result = await getRefreshTokenBridge({ - oldRefreshToken: 'rt-old', - userId: 'user-123', - }); - - expect(result).toBeNull(); - expect(__internals.bridges.size).toBe(0); + expect(decryptV2).not.toHaveBeenCalled(); }); }); describe('deleteRefreshTokenBridge', () => { it('deletes an existing bridge explicitly', async () => { - await storeRefreshTokenBridge({ + db.deleteRefreshTokenBridge.mockResolvedValue({ deletedCount: 1 }); + + const result = await deleteRefreshTokenBridge({ oldRefreshToken: 'rt-old', - newRefreshToken: 'rt-new', userId: 'user-123', + tenantId: 'tenant-1', }); - expect(deleteRefreshTokenBridge({ oldRefreshToken: 'rt-old' })).toBe(true); - expect(__internals.bridges.size).toBe(0); + expect(result).toBe(true); + expect(db.deleteRefreshTokenBridge).toHaveBeenCalledWith({ + oldRefreshTokenHash: __internals.hashRefreshToken('rt-old'), + userId: 'user-123', + tenantId: 'tenant-1', + }); }); - it('returns false when the bridge does not exist', () => { - expect(deleteRefreshTokenBridge({ oldRefreshToken: 'missing' })).toBe(false); - }); - }); - - describe('purgeExpiredBridges', () => { - it('removes expired bridges and leaves fresh ones', async () => { - jest.useFakeTimers(); - await storeRefreshTokenBridge({ - oldRefreshToken: 'rt-old-1', - newRefreshToken: 'rt-new-1', - userId: 'user-1', - ttl: 100, - }); - - await storeRefreshTokenBridge({ - oldRefreshToken: 'rt-old-2', - newRefreshToken: 'rt-new-2', - userId: 'user-2', - ttl: 10000, - }); - - jest.advanceTimersByTime(110); - purgeExpiredBridges(); - - expect(__internals.bridges.size).toBe(1); + it('returns false when the bridge does not exist', async () => { await expect( - getRefreshTokenBridge({ oldRefreshToken: 'rt-old-2', userId: 'user-2' }), - ).resolves.toBe('rt-new-2'); + deleteRefreshTokenBridge({ oldRefreshToken: 'missing', userId: 'user-123' }), + ).resolves.toBe(false); }); - it('handles empty bridge map gracefully', () => { - expect(() => purgeExpiredBridges()).not.toThrow(); + it('returns false when userId is omitted', async () => { + await expect(deleteRefreshTokenBridge({ oldRefreshToken: 'missing' })).resolves.toBe(false); + expect(db.deleteRefreshTokenBridge).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/data-schemas/src/methods/index.ts b/packages/data-schemas/src/methods/index.ts index efde53c3e9..e182057545 100644 --- a/packages/data-schemas/src/methods/index.ts +++ b/packages/data-schemas/src/methods/index.ts @@ -1,4 +1,8 @@ import type { RoleMethods, RoleDeps } from './role'; +import { + createRefreshTokenBridgeMethods, + type RefreshTokenBridgeMethods, +} from './refreshTokenBridge'; import { createSessionMethods, DEFAULT_REFRESH_TOKEN_EXPIRY, type SessionMethods } from './session'; import { createUserMethods, DEFAULT_SESSION_EXPIRY, type UserMethods } from './user'; import { createTokenMethods, type TokenMethods } from './token'; @@ -114,6 +118,7 @@ export { AUDIT_SCHEMA_VERSION, MAX_AUDIT_EXPORT_ROWS, MAX_AUDIT_LOG_LIMIT, MAX_A export type AllMethods = UserMethods & SessionMethods & TokenMethods & + RefreshTokenBridgeMethods & RoleMethods & KeyMethods & FileMethods & @@ -241,6 +246,7 @@ export function createMethods( ...createUserMethods(mongoose), ...createSessionMethods(mongoose), ...createTokenMethods(mongoose), + ...createRefreshTokenBridgeMethods(mongoose), ...roleMethods, ...createKeyMethods(mongoose), ...createFileMethods(mongoose), @@ -285,6 +291,7 @@ export type { UserMethods, SessionMethods, TokenMethods, + RefreshTokenBridgeMethods, RoleMethods, KeyMethods, FileMethods, diff --git a/packages/data-schemas/src/methods/refreshTokenBridge.spec.ts b/packages/data-schemas/src/methods/refreshTokenBridge.spec.ts new file mode 100644 index 0000000000..653f117119 --- /dev/null +++ b/packages/data-schemas/src/methods/refreshTokenBridge.spec.ts @@ -0,0 +1,120 @@ +import mongoose from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import type * as t from '~/types'; +import { createRefreshTokenBridgeMethods } from './refreshTokenBridge'; +import refreshTokenBridgeSchema from '~/schema/refreshTokenBridge'; + +jest.mock('~/config/winston', () => ({ + error: jest.fn(), + info: jest.fn(), + debug: jest.fn(), +})); + +let mongoServer: MongoMemoryServer; +let methods: ReturnType; + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + if (!mongoose.models.RefreshTokenBridge) { + mongoose.model('RefreshTokenBridge', refreshTokenBridgeSchema); + } + methods = createRefreshTokenBridgeMethods(mongoose); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer?.stop(); +}); + +beforeEach(async () => { + await mongoose.connection.dropDatabase(); +}); + +describe('RefreshTokenBridge Methods', () => { + it('upserts and finds a bridge by old token hash, user, and tenant', async () => { + await methods.upsertRefreshTokenBridge({ + oldRefreshTokenHash: 'old-hash', + encryptedNewRefreshToken: 'encrypted-new', + userId: 'user-1', + tenantId: 'tenant-1', + openidIssuer: 'https://issuer.example.com', + expiresAt: new Date(Date.now() + 60000), + }); + + const found = await methods.findRefreshTokenBridge({ + oldRefreshTokenHash: 'old-hash', + userId: 'user-1', + tenantId: 'tenant-1', + }); + + expect(found?.encryptedNewRefreshToken).toBe('encrypted-new'); + expect(found?.openidIssuer).toBe('https://issuer.example.com'); + }); + + it('replaces the encrypted token and expiry on repeated stores', async () => { + await methods.upsertRefreshTokenBridge({ + oldRefreshTokenHash: 'old-hash', + encryptedNewRefreshToken: 'encrypted-old', + userId: 'user-1', + expiresAt: new Date(Date.now() + 60000), + }); + + const nextExpiresAt = new Date(Date.now() + 120000); + await methods.upsertRefreshTokenBridge({ + oldRefreshTokenHash: 'old-hash', + encryptedNewRefreshToken: 'encrypted-new', + userId: 'user-1', + expiresAt: nextExpiresAt, + }); + + const found = await methods.findRefreshTokenBridge({ + oldRefreshTokenHash: 'old-hash', + userId: 'user-1', + }); + + expect(found?.encryptedNewRefreshToken).toBe('encrypted-new'); + expect(found?.expiresAt.getTime()).toBe(nextExpiresAt.getTime()); + }); + + it('does not return expired bridges before Mongo TTL cleanup runs', async () => { + await methods.upsertRefreshTokenBridge({ + oldRefreshTokenHash: 'old-hash', + encryptedNewRefreshToken: 'encrypted-new', + userId: 'user-1', + expiresAt: new Date(Date.now() - 1000), + }); + + await expect( + methods.findRefreshTokenBridge({ + oldRefreshTokenHash: 'old-hash', + userId: 'user-1', + }), + ).resolves.toBeNull(); + }); + + it('deletes a bridge by old token hash, user, and tenant', async () => { + await methods.upsertRefreshTokenBridge({ + oldRefreshTokenHash: 'old-hash', + encryptedNewRefreshToken: 'encrypted-new', + userId: 'user-1', + tenantId: 'tenant-1', + expiresAt: new Date(Date.now() + 60000), + }); + + const result = await methods.deleteRefreshTokenBridge({ + oldRefreshTokenHash: 'old-hash', + userId: 'user-1', + tenantId: 'tenant-1', + }); + + expect(result.deletedCount).toBe(1); + await expect( + methods.findRefreshTokenBridge({ + oldRefreshTokenHash: 'old-hash', + userId: 'user-1', + tenantId: 'tenant-1', + }), + ).resolves.toBeNull(); + }); +}); diff --git a/packages/data-schemas/src/methods/refreshTokenBridge.ts b/packages/data-schemas/src/methods/refreshTokenBridge.ts new file mode 100644 index 0000000000..c5084ab56c --- /dev/null +++ b/packages/data-schemas/src/methods/refreshTokenBridge.ts @@ -0,0 +1,100 @@ +import type { Model } from 'mongoose'; +import type { + IRefreshTokenBridge, + RefreshTokenBridgeCreateData, + RefreshTokenBridgeQuery, +} from '~/types'; +import logger from '~/config/winston'; + +function bridgeFilter({ + oldRefreshTokenHash, + userId, + tenantId, +}: RefreshTokenBridgeQuery): Record { + return { + oldRefreshTokenHash, + userId, + tenantId: tenantId ?? { $exists: false }, + }; +} + +export function createRefreshTokenBridgeMethods(mongoose: typeof import('mongoose')): { + upsertRefreshTokenBridge: ( + bridgeData: RefreshTokenBridgeCreateData, + ) => Promise; + findRefreshTokenBridge: (query: RefreshTokenBridgeQuery) => Promise; + deleteRefreshTokenBridge: (query: RefreshTokenBridgeQuery) => Promise<{ deletedCount?: number }>; +} { + async function upsertRefreshTokenBridge( + bridgeData: RefreshTokenBridgeCreateData, + ): Promise { + try { + const RefreshTokenBridge = mongoose.models.RefreshTokenBridge as Model; + const filter = bridgeFilter(bridgeData); + const update: { + $set: Record; + $setOnInsert: Record; + $unset?: Record; + } = { + $set: { + encryptedNewRefreshToken: bridgeData.encryptedNewRefreshToken, + expiresAt: bridgeData.expiresAt, + }, + $setOnInsert: { + oldRefreshTokenHash: bridgeData.oldRefreshTokenHash, + userId: bridgeData.userId, + ...(bridgeData.tenantId != null && { tenantId: bridgeData.tenantId }), + createdAt: new Date(), + }, + }; + if (bridgeData.openidIssuer != null) { + update.$set.openidIssuer = bridgeData.openidIssuer; + } else { + update.$unset = { openidIssuer: '' }; + } + return await RefreshTokenBridge.findOneAndUpdate(filter, update, { + upsert: true, + new: true, + }).lean(); + } catch (error) { + logger.debug('[upsertRefreshTokenBridge] Error storing bridge:', error); + throw error; + } + } + + async function findRefreshTokenBridge( + query: RefreshTokenBridgeQuery, + ): Promise { + try { + const RefreshTokenBridge = mongoose.models.RefreshTokenBridge as Model; + return await RefreshTokenBridge.findOne({ + ...bridgeFilter(query), + expiresAt: { $gt: new Date() }, + }).lean(); + } catch (error) { + logger.debug('[findRefreshTokenBridge] Error finding bridge:', error); + throw error; + } + } + + async function deleteRefreshTokenBridge( + query: RefreshTokenBridgeQuery, + ): Promise<{ deletedCount?: number }> { + try { + const RefreshTokenBridge = mongoose.models.RefreshTokenBridge as Model; + const result = await RefreshTokenBridge.deleteOne(bridgeFilter(query)); + return { deletedCount: result.deletedCount }; + } catch (error) { + logger.debug('[deleteRefreshTokenBridge] Error deleting bridge:', error); + throw error; + } + } + + return { + upsertRefreshTokenBridge, + findRefreshTokenBridge, + deleteRefreshTokenBridge, + }; +} + +export type RefreshTokenBridgeMethods = ReturnType; diff --git a/packages/data-schemas/src/models/index.ts b/packages/data-schemas/src/models/index.ts index 6493013f2c..4ebdf47007 100644 --- a/packages/data-schemas/src/models/index.ts +++ b/packages/data-schemas/src/models/index.ts @@ -1,4 +1,5 @@ import { createSkillSyncCredentialModel } from './skillSyncCredential'; +import { createRefreshTokenBridgeModel } from './refreshTokenBridge'; import { createSkillSyncStatusModel } from './skillSyncStatus'; import { createConversationTagModel } from './conversationTag'; import { createAgentCategoryModel } from './agentCategory'; @@ -75,6 +76,7 @@ export function createModels(mongoose: typeof import('mongoose')): { AuditLog: ReturnType; Group: ReturnType; Config: ReturnType; + RefreshTokenBridge: ReturnType; } { return { User: createUserModel(mongoose), @@ -113,5 +115,6 @@ export function createModels(mongoose: typeof import('mongoose')): { AuditLog: createAuditLogModel(mongoose), Group: createGroupModel(mongoose), Config: createConfigModel(mongoose), + RefreshTokenBridge: createRefreshTokenBridgeModel(mongoose), }; } diff --git a/packages/data-schemas/src/models/refreshTokenBridge.ts b/packages/data-schemas/src/models/refreshTokenBridge.ts new file mode 100644 index 0000000000..f55063c4b2 --- /dev/null +++ b/packages/data-schemas/src/models/refreshTokenBridge.ts @@ -0,0 +1,17 @@ +import { Model } from 'mongoose'; +import type * as t from '~/types'; +import refreshTokenBridgeSchema from '~/schema/refreshTokenBridge'; + +/** + * Refresh-token bridges are looked up from unauthenticated refresh requests + * after user context is recovered from a signed cookie. Methods apply explicit + * tenant checks, so automatic tenant isolation would be the wrong boundary here. + */ +export function createRefreshTokenBridgeModel( + mongoose: typeof import('mongoose'), +): Model { + return ( + mongoose.models.RefreshTokenBridge || + mongoose.model('RefreshTokenBridge', refreshTokenBridgeSchema) + ); +} diff --git a/packages/data-schemas/src/schema/index.ts b/packages/data-schemas/src/schema/index.ts index 08471bf5c8..d25a36ded2 100644 --- a/packages/data-schemas/src/schema/index.ts +++ b/packages/data-schemas/src/schema/index.ts @@ -16,6 +16,7 @@ export { default as pluginAuthSchema } from './pluginAuth'; export { default as presetSchema } from './preset'; export { default as promptSchema } from './prompt'; export { default as promptGroupSchema } from './promptGroup'; +export { default as refreshTokenBridgeSchema } from './refreshTokenBridge'; export { default as roleSchema } from './role'; export { default as sessionSchema } from './session'; export { default as shareSchema } from './share'; diff --git a/packages/data-schemas/src/schema/refreshTokenBridge.ts b/packages/data-schemas/src/schema/refreshTokenBridge.ts new file mode 100644 index 0000000000..31e095b8f5 --- /dev/null +++ b/packages/data-schemas/src/schema/refreshTokenBridge.ts @@ -0,0 +1,48 @@ +import { Schema } from 'mongoose'; +import type { IRefreshTokenBridge } from '~/types'; + +const refreshTokenBridgeSchema: Schema = new Schema({ + oldRefreshTokenHash: { + type: String, + required: true, + }, + encryptedNewRefreshToken: { + type: String, + required: true, + }, + userId: { + type: String, + required: true, + index: true, + }, + tenantId: { + type: String, + index: true, + }, + openidIssuer: { + type: String, + }, + createdAt: { + type: Date, + required: true, + default: Date.now, + }, + expiresAt: { + type: Date, + required: true, + }, +}); + +refreshTokenBridgeSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); +refreshTokenBridgeSchema.index( + { oldRefreshTokenHash: 1, userId: 1, tenantId: 1 }, + { unique: true }, +); +refreshTokenBridgeSchema.index({ + oldRefreshTokenHash: 1, + userId: 1, + tenantId: 1, + openidIssuer: 1, +}); + +export default refreshTokenBridgeSchema; diff --git a/packages/data-schemas/src/types/index.ts b/packages/data-schemas/src/types/index.ts index 3dc5228532..082cb00d40 100644 --- a/packages/data-schemas/src/types/index.ts +++ b/packages/data-schemas/src/types/index.ts @@ -4,6 +4,7 @@ export type ObjectId = Types.ObjectId; export * from './app'; export * from './user'; export * from './token'; +export * from './refreshTokenBridge'; export * from './convo'; export * from './chatProject'; export * from './session'; diff --git a/packages/data-schemas/src/types/refreshTokenBridge.ts b/packages/data-schemas/src/types/refreshTokenBridge.ts new file mode 100644 index 0000000000..10b23ca56d --- /dev/null +++ b/packages/data-schemas/src/types/refreshTokenBridge.ts @@ -0,0 +1,27 @@ +import type { Document } from 'mongoose'; + +export interface IRefreshTokenBridge extends Document { + oldRefreshTokenHash: string; + encryptedNewRefreshToken: string; + userId: string; + tenantId?: string; + openidIssuer?: string; + createdAt: Date; + expiresAt: Date; +} + +export interface RefreshTokenBridgeCreateData { + oldRefreshTokenHash: string; + encryptedNewRefreshToken: string; + userId: string; + tenantId?: string; + openidIssuer?: string; + expiresAt: Date; +} + +export interface RefreshTokenBridgeQuery { + oldRefreshTokenHash: string; + userId: string; + tenantId?: string; + openidIssuer?: string; +}