mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-11 17:11:23 +00:00
Reuse getValidOpenIDReuseUserId for the bridge-recovery user lookup in
refreshController instead of re-verifying openid_user_id inline. The shared
helper enforces the JWT_REFRESH_SECRET presence check and a strict
typeof payload.id === 'string' guard, rejecting tokens whose id claim is
present but not a string (e.g. a numeric id) that the inline check accepted.
Fail closed on issuer mismatch in getRefreshTokenBridge. Both the stored and
the expected issuer are now normalized and compared for equality, so a bridge
is recovered only when both sides agree (both absent, or both present and
equal after normalization). Previously the check was skipped whenever the
stored issuer was absent, allowing recovery across mismatched issuer context.
Drop the unused {oldRefreshTokenHash, userId, tenantId, openidIssuer} index
and the openidIssuer field on RefreshTokenBridgeQuery. The data-layer filter
only queries the 3-field {oldRefreshTokenHash, userId, tenantId} index; the
issuer is verified in application code, not the query. Hoist the repeated
model accessor into getRefreshTokenBridgeModel.
Note: issuer is now load-bearing for recovery. A bridge stored with an issuer
recovers only when the lookup supplies a matching issuer; the recovery lookup
reads user.openidIssuer via AUTH_REFRESH_USER_PROJECTION (an exclusion
projection that retains the field). If a user's persisted openidIssuer is
empty while the stored bridge has one, recovery fails closed (falls through to
normal re-authentication) until the bridge TTLs out — no security regression.
Tests cover invalid signed-cookie payloads bypassing the bridge, both
asymmetric issuer-presence cases, issuer normalization before comparison, and
an index-alignment assertion guarding against re-adding the dropped index.
221 lines
6.9 KiB
JavaScript
221 lines
6.9 KiB
JavaScript
jest.mock('@librechat/data-schemas', () => ({
|
|
logger: {
|
|
error: jest.fn(),
|
|
debug: jest.fn(),
|
|
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', () => ({
|
|
...jest.requireActual('@librechat/api'),
|
|
math: jest.fn((_value, fallback) => fallback),
|
|
}));
|
|
|
|
jest.mock('~/models', () => ({
|
|
upsertRefreshTokenBridge: jest.fn(),
|
|
findRefreshTokenBridge: jest.fn(),
|
|
}));
|
|
|
|
const { encryptV2, decryptV2 } = require('@librechat/data-schemas');
|
|
const { math } = require('@librechat/api');
|
|
const db = require('~/models');
|
|
const {
|
|
storeRefreshTokenBridge,
|
|
getRefreshTokenBridge,
|
|
__internals,
|
|
} = require('./RefreshTokenBridge');
|
|
|
|
describe('RefreshTokenBridge', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
db.upsertRefreshTokenBridge.mockResolvedValue({});
|
|
db.findRefreshTokenBridge.mockResolvedValue(null);
|
|
});
|
|
|
|
describe('storeRefreshTokenBridge', () => {
|
|
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',
|
|
});
|
|
|
|
expect(encryptV2).toHaveBeenCalledWith('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 () => {
|
|
await storeRefreshTokenBridge({
|
|
oldRefreshToken: 'rt-old',
|
|
newRefreshToken: 'rt-new',
|
|
userId: ' user-123 ',
|
|
tenantId: ' tenant-1 ',
|
|
openidIssuer: 'https://issuer.example.com/.well-known/openid-configuration',
|
|
});
|
|
|
|
expect(db.upsertRefreshTokenBridge).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
userId: 'user-123',
|
|
tenantId: 'tenant-1',
|
|
openidIssuer: 'https://issuer.example.com',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('does not store a bridge without required fields', async () => {
|
|
await storeRefreshTokenBridge({
|
|
oldRefreshToken: 'rt-old',
|
|
userId: 'user-123',
|
|
});
|
|
|
|
expect(db.upsertRefreshTokenBridge).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('honors an explicit ttl override', async () => {
|
|
const before = Date.now();
|
|
|
|
await storeRefreshTokenBridge({
|
|
oldRefreshToken: 'rt-old',
|
|
newRefreshToken: 'rt-new',
|
|
userId: 'user-123',
|
|
ttl: 1000,
|
|
});
|
|
|
|
const stored = db.upsertRefreshTokenBridge.mock.calls[0][0];
|
|
expect(stored.expiresAt.getTime()).toBeGreaterThanOrEqual(before + 1000);
|
|
expect(stored.expiresAt.getTime()).toBeLessThanOrEqual(Date.now() + 1000);
|
|
});
|
|
|
|
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/.well-known/openid-configuration',
|
|
});
|
|
|
|
expect(db.findRefreshTokenBridge).toHaveBeenCalledWith({
|
|
oldRefreshTokenHash: __internals.hashRefreshToken('rt-old'),
|
|
userId: 'user-123',
|
|
tenantId: 'tenant-1',
|
|
});
|
|
expect(decryptV2).toHaveBeenCalledWith('encrypted:rt-new');
|
|
expect(result).toBe('rt-new');
|
|
});
|
|
|
|
it('returns null when bridge does not exist', async () => {
|
|
await expect(
|
|
getRefreshTokenBridge({
|
|
oldRefreshToken: 'rt-nonexistent',
|
|
userId: 'user-123',
|
|
}),
|
|
).resolves.toBeNull();
|
|
});
|
|
|
|
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({
|
|
oldRefreshToken: 'rt-old',
|
|
userId: 'user-123',
|
|
openidIssuer: 'https://issuer2.example.com',
|
|
});
|
|
|
|
expect(result).toBeNull();
|
|
expect(decryptV2).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns null when only the expected issuer is present', async () => {
|
|
db.findRefreshTokenBridge.mockResolvedValue({
|
|
encryptedNewRefreshToken: 'encrypted:rt-new',
|
|
userId: 'user-123',
|
|
createdAt: new Date(),
|
|
});
|
|
|
|
const result = await getRefreshTokenBridge({
|
|
oldRefreshToken: 'rt-old',
|
|
userId: 'user-123',
|
|
openidIssuer: 'https://issuer.example.com',
|
|
});
|
|
|
|
expect(result).toBeNull();
|
|
expect(decryptV2).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns null when only the stored issuer is present', async () => {
|
|
db.findRefreshTokenBridge.mockResolvedValue({
|
|
encryptedNewRefreshToken: 'encrypted:rt-new',
|
|
userId: 'user-123',
|
|
openidIssuer: 'https://issuer.example.com',
|
|
createdAt: new Date(),
|
|
});
|
|
|
|
const result = await getRefreshTokenBridge({
|
|
oldRefreshToken: 'rt-old',
|
|
userId: 'user-123',
|
|
});
|
|
|
|
expect(result).toBeNull();
|
|
expect(decryptV2).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('normalizes the stored issuer before validation', async () => {
|
|
db.findRefreshTokenBridge.mockResolvedValue({
|
|
encryptedNewRefreshToken: 'encrypted:rt-new',
|
|
userId: 'user-123',
|
|
openidIssuer: 'https://issuer.example.com/.well-known/openid-configuration',
|
|
createdAt: new Date(),
|
|
});
|
|
|
|
const result = await getRefreshTokenBridge({
|
|
oldRefreshToken: 'rt-old',
|
|
userId: 'user-123',
|
|
openidIssuer: 'https://issuer.example.com/',
|
|
});
|
|
|
|
expect(decryptV2).toHaveBeenCalledWith('encrypted:rt-new');
|
|
expect(result).toBe('rt-new');
|
|
});
|
|
});
|
|
});
|