mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-14 14:38:11 +01:00
* fix(openid): distinguish ID tokens from access tokens in federated auth Fix OpenID Connect token handling to properly distinguish ID tokens from access tokens. ID tokens and access tokens are now stored and propagated separately, preventing token placeholders from resolving to identical values. - AuthService.js: Added idToken field to session storage - openIdJwtStrategy.js: Updated to read idToken from session - openidStrategy.js: Explicitly included id_token in federatedTokens - Test suites: Added comprehensive test coverage for token distinction Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(openid): add separate openid_id_token cookie for ID token storage Store the OIDC ID token in its own cookie rather than relying solely on the access token, ensuring correct token type is used for identity verification vs API authorization. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test(openid): add JWT strategy cookie fallback tests Cover the token source resolution logic in openIdJwtStrategy: session-only, cookie-only, partial session fallback, raw Bearer fallback, and distinct id_token/access_token from cookies. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
183 lines
5.5 KiB
JavaScript
183 lines
5.5 KiB
JavaScript
const { SystemRoles } = require('librechat-data-provider');
|
||
|
||
// --- Capture the verify callback from JwtStrategy ---
|
||
let capturedVerifyCallback;
|
||
jest.mock('passport-jwt', () => ({
|
||
Strategy: jest.fn((_opts, verifyCallback) => {
|
||
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(),
|
||
math: jest.fn((val, fallback) => fallback),
|
||
}));
|
||
jest.mock('~/models', () => ({
|
||
findUser: jest.fn(),
|
||
updateUser: jest.fn(),
|
||
}));
|
||
|
||
const { findOpenIDUser } = require('@librechat/api');
|
||
const { updateUser } = require('~/models');
|
||
const openIdJwtLogin = require('./openIdJwtStrategy');
|
||
|
||
// Helper: build a mock openIdConfig
|
||
const mockOpenIdConfig = {
|
||
serverMetadata: () => ({ 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 source handling', () => {
|
||
const baseUser = {
|
||
_id: { toString: () => 'user-abc' },
|
||
role: SystemRoles.USER,
|
||
provider: 'openid',
|
||
};
|
||
|
||
const payload = { sub: 'oidc-123', email: 'test@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);
|
||
});
|
||
});
|