Files
wehub-resource-sync e115934061
Publish `@librechat/data-schemas` to NPM / pack (push) Failing after 8s
Publish `@librechat/client` to NPM / pack (push) Failing after 0s
GitNexus Index / index (push) Failing after 1s
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been skipped
Sync Helm Chart Tags / Sync chart tags (push) Failing after 2s
Publish `librechat-data-provider` to NPM / pack (push) Failing after 1s
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Failing after 1s
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Failing after 0s
Sync Helm Chart Tags / Ignore non-main push (push) Has been skipped
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Failing after 1s
Publish `@librechat/client` to NPM / publish-npm (push) Has been cancelled
Publish `librechat-data-provider` to NPM / publish-npm (push) Has been cancelled
GitNexus Index / post-index (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:08:12 +08:00

583 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const { SystemRoles } = require('librechat-data-provider');
// --- Capture JwtStrategy inputs ---
let capturedStrategyOptions;
let capturedVerifyCallback;
jest.mock('passport-jwt', () => ({
Strategy: jest.fn((opts, verifyCallback) => {
capturedStrategyOptions = opts;
capturedVerifyCallback = verifyCallback;
return { name: 'jwt' };
}),
ExtractJwt: {
fromAuthHeaderAsBearerToken: jest.fn(() => 'mock-extractor'),
},
}));
jest.mock('jwks-rsa', () => ({
passportJwtSecret: jest.fn(() => 'mock-secret-provider'),
}));
jest.mock('https-proxy-agent', () => ({
HttpsProxyAgent: jest.fn(),
}));
jest.mock('@librechat/data-schemas', () => ({
logger: { info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error: jest.fn() },
}));
jest.mock('@librechat/api', () => ({
isEnabled: jest.fn(() => false),
findOpenIDUser: jest.fn(),
getOpenIdEmail: jest.requireActual('@librechat/api').getOpenIdEmail,
getOpenIdIssuer: jest.fn(() => 'https://issuer.example.com'),
normalizeOpenIdIssuer: jest.requireActual('@librechat/api').normalizeOpenIdIssuer,
getHttpsProxyAgent: jest.fn(() => undefined),
math: jest.fn((val, fallback) => fallback),
}));
jest.mock('~/models', () => ({
findUser: jest.fn(),
updateUser: jest.fn(),
}));
jest.mock('~/server/services/Files/strategies', () => ({
getStrategyFunctions: jest.fn(() => ({
saveBuffer: jest.fn().mockResolvedValue('/fake/path/to/avatar.png'),
})),
}));
jest.mock('~/server/services/Config', () => ({
getAppConfig: jest.fn().mockResolvedValue({}),
}));
jest.mock('~/cache/getLogStores', () =>
jest.fn().mockReturnValue({ get: jest.fn(), set: jest.fn() }),
);
const { findOpenIDUser } = require('@librechat/api');
const openIdJwtLogin = require('./openIdJwtStrategy');
const { findUser, updateUser } = require('~/models');
function withEnv(env, callback) {
const previous = Object.fromEntries(Object.keys(env).map((key) => [key, process.env[key]]));
Object.entries(env).forEach(([key, value]) => {
if (value === undefined) {
delete process.env[key];
return;
}
process.env[key] = value;
});
try {
callback();
} finally {
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) {
delete process.env[key];
return;
}
process.env[key] = value;
});
}
}
// Helper: build a mock openIdConfig
const mockOpenIdConfig = {
serverMetadata: () => ({
issuer: 'https://issuer.example.com',
jwks_uri: 'https://example.com/.well-known/jwks.json',
}),
};
// Helper: invoke the captured verify callback
async function invokeVerify(req, payload) {
return new Promise((resolve, reject) => {
capturedVerifyCallback(req, payload, (err, user, info) => {
if (err) {
return reject(err);
}
resolve({ user, info });
});
});
}
describe('openIdJwtStrategy token validation', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('requires OpenID JWTs to match the configured client audience and issuer', () => {
withEnv({ OPENID_CLIENT_ID: 'librechat-client-id', OPENID_AUDIENCE: undefined }, () => {
openIdJwtLogin(mockOpenIdConfig);
});
expect(capturedStrategyOptions).toMatchObject({
audience: 'librechat-client-id',
passReqToCallback: true,
});
expect(capturedStrategyOptions).not.toHaveProperty('issuer');
});
it('also accepts OPENID_AUDIENCE for providers that mint resource-bound JWTs', () => {
withEnv({ OPENID_CLIENT_ID: 'librechat-client-id', OPENID_AUDIENCE: 'api://librechat' }, () => {
openIdJwtLogin(mockOpenIdConfig);
});
expect(capturedStrategyOptions).toMatchObject({
audience: ['librechat-client-id', 'api://librechat'],
});
});
it('uses a single OPENID_AUDIENCE value when no client ID is configured', () => {
withEnv({ OPENID_CLIENT_ID: undefined, OPENID_AUDIENCE: 'librechat' }, () => {
openIdJwtLogin(mockOpenIdConfig);
});
expect(capturedStrategyOptions.audience).toBe('librechat');
});
it('splits comma-separated OPENID_AUDIENCE values into multiple accepted audiences', () => {
withEnv({ OPENID_CLIENT_ID: undefined, OPENID_AUDIENCE: 'librechat,control-plane-web' }, () => {
openIdJwtLogin(mockOpenIdConfig);
});
expect(capturedStrategyOptions.audience).toEqual(['librechat', 'control-plane-web']);
});
it('trims whitespace around comma-separated OPENID_AUDIENCE values', () => {
withEnv(
{ OPENID_CLIENT_ID: undefined, OPENID_AUDIENCE: ' librechat , control-plane-web ' },
() => {
openIdJwtLogin(mockOpenIdConfig);
},
);
expect(capturedStrategyOptions.audience).toEqual(['librechat', 'control-plane-web']);
});
it('falls back to OPENID_CLIENT_ID when OPENID_AUDIENCE is empty', () => {
withEnv({ OPENID_CLIENT_ID: 'client-id-only', OPENID_AUDIENCE: '' }, () => {
openIdJwtLogin(mockOpenIdConfig);
});
expect(capturedStrategyOptions.audience).toBe('client-id-only');
});
it('combines OPENID_CLIENT_ID with comma-separated OPENID_AUDIENCE values and deduplicates', () => {
withEnv(
{ OPENID_CLIENT_ID: 'librechat', OPENID_AUDIENCE: 'librechat,control-plane-web' },
() => {
openIdJwtLogin(mockOpenIdConfig);
},
);
expect(capturedStrategyOptions.audience).toEqual(['librechat', 'control-plane-web']);
});
it('rejects OpenID JWTs whose issuer does not match the configured issuer', async () => {
findOpenIDUser.mockResolvedValue({ user: null, error: null, migration: false });
openIdJwtLogin(mockOpenIdConfig);
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
const { user, info } = await invokeVerify(req, {
sub: 'oidc-123',
email: 'test@example.com',
iss: 'https://other-issuer.example.com',
exp: 9999999999,
});
expect(user).toBe(false);
expect(info).toEqual({ message: 'Invalid issuer' });
expect(findOpenIDUser).not.toHaveBeenCalled();
});
it('allows Microsoft Entra tenant issuer values for tenant-independent metadata', async () => {
const entraConfig = {
serverMetadata: () => ({
issuer: 'https://login.microsoftonline.com/{tenantid}/v2.0',
jwks_uri: 'https://login.microsoftonline.com/common/discovery/v2.0/keys',
}),
};
const user = {
_id: { toString: () => 'user-abc' },
role: SystemRoles.USER,
provider: 'openid',
};
findOpenIDUser.mockResolvedValue({ user, error: null, migration: false });
updateUser.mockResolvedValue({});
openIdJwtLogin(entraConfig);
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
const { user: result } = await invokeVerify(req, {
sub: 'oidc-123',
email: 'test@example.com',
iss: 'https://login.microsoftonline.com/11111111-2222-3333-4444-555555555555/v2.0',
exp: 9999999999,
});
expect(result).toBeTruthy();
expect(findOpenIDUser).toHaveBeenCalled();
});
});
describe('openIdJwtStrategy token source handling', () => {
const baseUser = {
_id: { toString: () => 'user-abc' },
role: SystemRoles.USER,
provider: 'openid',
};
const payload = {
sub: 'oidc-123',
email: 'test@example.com',
iss: 'https://issuer.example.com',
exp: 9999999999,
};
beforeEach(() => {
jest.clearAllMocks();
findOpenIDUser.mockResolvedValue({ user: { ...baseUser }, error: null, migration: false });
updateUser.mockResolvedValue({});
// Initialize the strategy so capturedVerifyCallback is set
openIdJwtLogin(mockOpenIdConfig);
});
it('should read all tokens from session when available', async () => {
const req = {
headers: { authorization: 'Bearer raw-bearer-token' },
session: {
openidTokens: {
accessToken: 'session-access',
idToken: 'session-id',
refreshToken: 'session-refresh',
},
},
};
const { user } = await invokeVerify(req, payload);
expect(user.federatedTokens).toEqual({
access_token: 'session-access',
id_token: 'session-id',
refresh_token: 'session-refresh',
expires_at: payload.exp,
});
});
it('should fall back to cookies when session is absent', async () => {
const req = {
headers: {
authorization: 'Bearer raw-bearer-token',
cookie:
'openid_access_token=cookie-access; openid_id_token=cookie-id; refreshToken=cookie-refresh',
},
};
const { user } = await invokeVerify(req, payload);
expect(user.federatedTokens).toEqual({
access_token: 'cookie-access',
id_token: 'cookie-id',
refresh_token: 'cookie-refresh',
expires_at: payload.exp,
});
});
it('should fall back to cookie for idToken only when session lacks it', async () => {
const req = {
headers: {
authorization: 'Bearer raw-bearer-token',
cookie: 'openid_id_token=cookie-id',
},
session: {
openidTokens: {
accessToken: 'session-access',
// idToken intentionally missing
refreshToken: 'session-refresh',
},
},
};
const { user } = await invokeVerify(req, payload);
expect(user.federatedTokens).toEqual({
access_token: 'session-access',
id_token: 'cookie-id',
refresh_token: 'session-refresh',
expires_at: payload.exp,
});
});
it('should use raw Bearer token as access_token fallback when neither session nor cookie has one', async () => {
const req = {
headers: {
authorization: 'Bearer raw-bearer-token',
cookie: 'openid_id_token=cookie-id; refreshToken=cookie-refresh',
},
};
const { user } = await invokeVerify(req, payload);
expect(user.federatedTokens.access_token).toBe('raw-bearer-token');
expect(user.federatedTokens.id_token).toBe('cookie-id');
expect(user.federatedTokens.refresh_token).toBe('cookie-refresh');
});
it('should set id_token to undefined when not available in session or cookies', async () => {
const req = {
headers: {
authorization: 'Bearer raw-bearer-token',
cookie: 'openid_access_token=cookie-access; refreshToken=cookie-refresh',
},
};
const { user } = await invokeVerify(req, payload);
expect(user.federatedTokens.access_token).toBe('cookie-access');
expect(user.federatedTokens.id_token).toBeUndefined();
expect(user.federatedTokens.refresh_token).toBe('cookie-refresh');
});
it('should keep id_token and access_token as distinct values from cookies', async () => {
const req = {
headers: {
authorization: 'Bearer raw-bearer-token',
cookie:
'openid_access_token=the-access-token; openid_id_token=the-id-token; refreshToken=the-refresh',
},
};
const { user } = await invokeVerify(req, payload);
expect(user.federatedTokens.access_token).toBe('the-access-token');
expect(user.federatedTokens.id_token).toBe('the-id-token');
expect(user.federatedTokens.access_token).not.toBe(user.federatedTokens.id_token);
});
});
describe('openIdJwtStrategy idOnTheSource boundary coercion', () => {
const payload = {
sub: 'oidc-123',
email: 'test@example.com',
iss: 'https://issuer.example.com',
exp: 9999999999,
};
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
beforeEach(() => {
jest.clearAllMocks();
updateUser.mockResolvedValue({});
openIdJwtLogin(mockOpenIdConfig);
});
it('coerces missing idOnTheSource to null', async () => {
findOpenIDUser.mockResolvedValue({
user: { _id: { toString: () => 'user-abc' }, role: SystemRoles.USER, provider: 'openid' },
error: null,
migration: false,
});
const { user } = await invokeVerify(req, payload);
expect(user.idOnTheSource).toBeNull();
});
it('preserves a stored idOnTheSource', async () => {
findOpenIDUser.mockResolvedValue({
user: {
_id: { toString: () => 'user-abc' },
role: SystemRoles.USER,
provider: 'openid',
idOnTheSource: 'entra-oid-123',
},
error: null,
migration: false,
});
const { user } = await invokeVerify(req, payload);
expect(user.idOnTheSource).toBe('entra-oid-123');
});
});
describe('openIdJwtStrategy OPENID_EMAIL_CLAIM', () => {
const payload = {
sub: 'oidc-123',
email: 'test@example.com',
preferred_username: 'testuser',
upn: 'test@corp.example.com',
iss: 'https://issuer.example.com',
exp: 9999999999,
};
beforeEach(() => {
jest.clearAllMocks();
delete process.env.OPENID_EMAIL_CLAIM;
// Use real findOpenIDUser so it delegates to the findUser mock
const realFindOpenIDUser = jest.requireActual('@librechat/api').findOpenIDUser;
findOpenIDUser.mockImplementation(realFindOpenIDUser);
findUser.mockResolvedValue(null);
updateUser.mockResolvedValue({});
openIdJwtLogin(mockOpenIdConfig);
});
afterEach(() => {
delete process.env.OPENID_EMAIL_CLAIM;
});
it('should use the default email when OPENID_EMAIL_CLAIM is not set', async () => {
const existingUser = {
_id: 'user-id-1',
provider: 'openid',
openidId: payload.sub,
openidIssuer: 'https://issuer.example.com',
email: payload.email,
role: SystemRoles.USER,
};
findUser.mockImplementation(async (query) => {
if (query.openidId === payload.sub && query.openidIssuer === 'https://issuer.example.com') {
return existingUser;
}
return null;
});
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
await invokeVerify(req, payload);
expect(findUser).toHaveBeenCalledWith({
openidId: payload.sub,
openidIssuer: 'https://issuer.example.com',
});
});
it('should use OPENID_EMAIL_CLAIM when set for email lookup', async () => {
process.env.OPENID_EMAIL_CLAIM = 'upn';
findUser.mockResolvedValue(null);
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
const { user } = await invokeVerify(req, payload);
expect(findUser).toHaveBeenCalledTimes(2);
expect(findUser.mock.calls[0][0]).toEqual({
openidId: payload.sub,
openidIssuer: 'https://issuer.example.com',
});
expect(findUser.mock.calls[1][0]).toEqual({
email: 'test@corp.example.com',
});
expect(user).toBe(false);
});
it('should fall back to default chain when OPENID_EMAIL_CLAIM points to missing claim', async () => {
process.env.OPENID_EMAIL_CLAIM = 'nonexistent_claim';
findUser.mockResolvedValue(null);
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
const { user } = await invokeVerify(req, payload);
expect(findUser).toHaveBeenCalledWith({ email: payload.email });
expect(user).toBe(false);
});
it('should reject login when email fallback finds user with mismatched openidId', async () => {
const emailMatchWithDifferentSub = {
_id: 'user-id-2',
provider: 'openid',
openidId: 'different-sub',
email: payload.email,
role: SystemRoles.USER,
};
findUser.mockImplementation(async (query) => {
if (query.$or) {
return null;
}
if (query.email === payload.email) {
return emailMatchWithDifferentSub;
}
return null;
});
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
const { user, info } = await invokeVerify(req, payload);
expect(user).toBe(false);
expect(info).toEqual({ message: 'auth_failed' });
});
it('should trim whitespace from OPENID_EMAIL_CLAIM', async () => {
process.env.OPENID_EMAIL_CLAIM = ' upn ';
findUser.mockResolvedValue(null);
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
await invokeVerify(req, payload);
expect(findUser).toHaveBeenCalledWith({ email: 'test@corp.example.com' });
});
it('should ignore empty string OPENID_EMAIL_CLAIM and use default fallback', async () => {
process.env.OPENID_EMAIL_CLAIM = '';
findUser.mockResolvedValue(null);
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
await invokeVerify(req, payload);
expect(findUser).toHaveBeenCalledWith({ email: payload.email });
});
it('should ignore whitespace-only OPENID_EMAIL_CLAIM and use default fallback', async () => {
process.env.OPENID_EMAIL_CLAIM = ' ';
findUser.mockResolvedValue(null);
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
await invokeVerify(req, payload);
expect(findUser).toHaveBeenCalledWith({ email: payload.email });
});
it('should resolve undefined email when payload is null', async () => {
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
const { user } = await invokeVerify(req, null);
expect(user).toBe(false);
});
it('should attempt email lookup via preferred_username fallback when email claim is absent', async () => {
const payloadNoEmail = {
sub: 'oidc-new-sub',
preferred_username: 'legacy@corp.com',
upn: 'legacy@corp.com',
iss: 'https://issuer.example.com',
exp: 9999999999,
};
const legacyUser = {
_id: 'legacy-db-id',
email: 'legacy@corp.com',
openidId: null,
role: SystemRoles.USER,
};
findUser.mockImplementation(async (query) => {
if (query.$or) {
return null;
}
if (query.email === 'legacy@corp.com') {
return legacyUser;
}
return null;
});
const req = { headers: { authorization: 'Bearer tok' }, session: {} };
const { user } = await invokeVerify(req, payloadNoEmail);
expect(findUser).toHaveBeenCalledTimes(2);
expect(findUser.mock.calls[1][0]).toEqual({ email: 'legacy@corp.com' });
expect(user).toBeTruthy();
expect(updateUser).toHaveBeenCalledWith(
'legacy-db-id',
expect.objectContaining({
provider: 'openid',
openidId: payloadNoEmail.sub,
openidIssuer: 'https://issuer.example.com',
}),
);
});
});