mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-22 23:56:34 +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>
511 lines
16 KiB
TypeScript
511 lines
16 KiB
TypeScript
import { extractOpenIDTokenInfo, isOpenIDTokenValid, processOpenIDPlaceholders } from './oidc';
|
|
import type { TUser } from 'librechat-data-provider';
|
|
|
|
describe('OpenID Token Utilities', () => {
|
|
describe('extractOpenIDTokenInfo', () => {
|
|
it('should extract token info from user with federatedTokens', () => {
|
|
const user: Partial<TUser> = {
|
|
id: 'user-123',
|
|
provider: 'openid',
|
|
openidId: 'oidc-sub-456',
|
|
email: 'test@example.com',
|
|
name: 'Test User',
|
|
federatedTokens: {
|
|
access_token: 'access-token-value',
|
|
id_token: 'id-token-value',
|
|
refresh_token: 'refresh-token-value',
|
|
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
|
},
|
|
};
|
|
|
|
const result = extractOpenIDTokenInfo(user);
|
|
|
|
expect(result).toMatchObject({
|
|
accessToken: 'access-token-value',
|
|
idToken: 'id-token-value',
|
|
userId: expect.any(String),
|
|
userEmail: 'test@example.com',
|
|
userName: 'Test User',
|
|
});
|
|
expect(result?.expiresAt).toBeDefined();
|
|
});
|
|
|
|
it('should return null when user is undefined', () => {
|
|
const result = extractOpenIDTokenInfo(undefined);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should return null when user is not OpenID provider', () => {
|
|
const user: Partial<TUser> = {
|
|
id: 'user-123',
|
|
provider: 'email',
|
|
};
|
|
|
|
const result = extractOpenIDTokenInfo(user);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should return token info when user has no federatedTokens but is OpenID provider', () => {
|
|
const user: Partial<TUser> = {
|
|
id: 'user-123',
|
|
provider: 'openid',
|
|
openidId: 'oidc-sub-456',
|
|
email: 'test@example.com',
|
|
name: 'Test User',
|
|
};
|
|
|
|
const result = extractOpenIDTokenInfo(user);
|
|
|
|
expect(result).toMatchObject({
|
|
userId: 'oidc-sub-456',
|
|
userEmail: 'test@example.com',
|
|
userName: 'Test User',
|
|
});
|
|
expect(result?.accessToken).toBeUndefined();
|
|
expect(result?.idToken).toBeUndefined();
|
|
});
|
|
|
|
it('should extract partial token info when some tokens are missing', () => {
|
|
const user: Partial<TUser> = {
|
|
id: 'user-123',
|
|
provider: 'openid',
|
|
openidId: 'oidc-sub-456',
|
|
email: 'test@example.com',
|
|
federatedTokens: {
|
|
access_token: 'access-token-value',
|
|
id_token: undefined,
|
|
refresh_token: undefined,
|
|
expires_at: undefined,
|
|
},
|
|
};
|
|
|
|
const result = extractOpenIDTokenInfo(user);
|
|
|
|
expect(result).toMatchObject({
|
|
accessToken: 'access-token-value',
|
|
userId: 'oidc-sub-456',
|
|
userEmail: 'test@example.com',
|
|
});
|
|
});
|
|
|
|
it('should prioritize openidId over regular id', () => {
|
|
const user: Partial<TUser> = {
|
|
id: 'user-123',
|
|
provider: 'openid',
|
|
openidId: 'oidc-sub-456',
|
|
federatedTokens: {
|
|
access_token: 'access-token-value',
|
|
},
|
|
};
|
|
|
|
const result = extractOpenIDTokenInfo(user);
|
|
|
|
expect(result?.userId).toBe('oidc-sub-456');
|
|
});
|
|
|
|
it('should fall back to regular id when openidId is not available', () => {
|
|
const user: Partial<TUser> = {
|
|
id: 'user-123',
|
|
provider: 'openid',
|
|
federatedTokens: {
|
|
access_token: 'access-token-value',
|
|
},
|
|
};
|
|
|
|
const result = extractOpenIDTokenInfo(user);
|
|
|
|
expect(result?.userId).toBe('user-123');
|
|
});
|
|
});
|
|
|
|
describe('isOpenIDTokenValid', () => {
|
|
it('should return false when tokenInfo is null', () => {
|
|
expect(isOpenIDTokenValid(null)).toBe(false);
|
|
});
|
|
|
|
it('should return false when tokenInfo has no accessToken', () => {
|
|
const tokenInfo = {
|
|
userId: 'oidc-sub-456',
|
|
};
|
|
|
|
expect(isOpenIDTokenValid(tokenInfo)).toBe(false);
|
|
});
|
|
|
|
it('should return true when token has access token and no expiresAt', () => {
|
|
const tokenInfo = {
|
|
accessToken: 'access-token-value',
|
|
userId: 'oidc-sub-456',
|
|
};
|
|
|
|
expect(isOpenIDTokenValid(tokenInfo)).toBe(true);
|
|
});
|
|
|
|
it('should return true when token has not expired', () => {
|
|
const futureTimestamp = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now
|
|
const tokenInfo = {
|
|
accessToken: 'access-token-value',
|
|
expiresAt: futureTimestamp,
|
|
userId: 'oidc-sub-456',
|
|
};
|
|
|
|
expect(isOpenIDTokenValid(tokenInfo)).toBe(true);
|
|
});
|
|
|
|
it('should return false when token has expired', () => {
|
|
const pastTimestamp = Math.floor(Date.now() / 1000) - 3600; // 1 hour ago
|
|
const tokenInfo = {
|
|
accessToken: 'access-token-value',
|
|
expiresAt: pastTimestamp,
|
|
userId: 'oidc-sub-456',
|
|
};
|
|
|
|
expect(isOpenIDTokenValid(tokenInfo)).toBe(false);
|
|
});
|
|
|
|
it('should return false when token expires exactly now', () => {
|
|
const nowTimestamp = Math.floor(Date.now() / 1000);
|
|
const tokenInfo = {
|
|
accessToken: 'access-token-value',
|
|
expiresAt: nowTimestamp,
|
|
userId: 'oidc-sub-456',
|
|
};
|
|
|
|
expect(isOpenIDTokenValid(tokenInfo)).toBe(false);
|
|
});
|
|
|
|
it('should return true when token is just about to expire (within 1 second)', () => {
|
|
const almostExpiredTimestamp = Math.floor(Date.now() / 1000) + 1;
|
|
const tokenInfo = {
|
|
accessToken: 'access-token-value',
|
|
expiresAt: almostExpiredTimestamp,
|
|
userId: 'oidc-sub-456',
|
|
};
|
|
|
|
expect(isOpenIDTokenValid(tokenInfo)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('processOpenIDPlaceholders', () => {
|
|
it('should replace LIBRECHAT_OPENID_TOKEN with access token', () => {
|
|
const tokenInfo = {
|
|
accessToken: 'access-token-value',
|
|
idToken: 'id-token-value',
|
|
userId: 'oidc-sub-456',
|
|
};
|
|
|
|
const input = 'Authorization: Bearer {{LIBRECHAT_OPENID_TOKEN}}';
|
|
const result = processOpenIDPlaceholders(input, tokenInfo);
|
|
|
|
expect(result).toBe('Authorization: Bearer access-token-value');
|
|
});
|
|
|
|
it('should replace LIBRECHAT_OPENID_ACCESS_TOKEN with access token', () => {
|
|
const tokenInfo = {
|
|
accessToken: 'access-token-value',
|
|
userId: 'oidc-sub-456',
|
|
};
|
|
|
|
const input = 'Token: {{LIBRECHAT_OPENID_ACCESS_TOKEN}}';
|
|
const result = processOpenIDPlaceholders(input, tokenInfo);
|
|
|
|
expect(result).toBe('Token: access-token-value');
|
|
});
|
|
|
|
it('should replace LIBRECHAT_OPENID_ID_TOKEN with id token', () => {
|
|
const tokenInfo = {
|
|
idToken: 'id-token-value',
|
|
userId: 'oidc-sub-456',
|
|
};
|
|
|
|
const input = 'ID Token: {{LIBRECHAT_OPENID_ID_TOKEN}}';
|
|
const result = processOpenIDPlaceholders(input, tokenInfo);
|
|
|
|
expect(result).toBe('ID Token: id-token-value');
|
|
});
|
|
|
|
it('should replace LIBRECHAT_OPENID_USER_ID with user id', () => {
|
|
const tokenInfo = {
|
|
userId: 'oidc-sub-456',
|
|
};
|
|
|
|
const input = 'User: {{LIBRECHAT_OPENID_USER_ID}}';
|
|
const result = processOpenIDPlaceholders(input, tokenInfo);
|
|
|
|
expect(result).toBe('User: oidc-sub-456');
|
|
});
|
|
|
|
it('should replace LIBRECHAT_OPENID_USER_EMAIL with user email', () => {
|
|
const tokenInfo = {
|
|
userEmail: 'test@example.com',
|
|
userId: 'oidc-sub-456',
|
|
};
|
|
|
|
const input = 'Email: {{LIBRECHAT_OPENID_USER_EMAIL}}';
|
|
const result = processOpenIDPlaceholders(input, tokenInfo);
|
|
|
|
expect(result).toBe('Email: test@example.com');
|
|
});
|
|
|
|
it('should replace LIBRECHAT_OPENID_USER_NAME with user name', () => {
|
|
const tokenInfo = {
|
|
userName: 'Test User',
|
|
userId: 'oidc-sub-456',
|
|
};
|
|
|
|
const input = 'Name: {{LIBRECHAT_OPENID_USER_NAME}}';
|
|
const result = processOpenIDPlaceholders(input, tokenInfo);
|
|
|
|
expect(result).toBe('Name: Test User');
|
|
});
|
|
|
|
it('should replace multiple placeholders in a single string', () => {
|
|
const tokenInfo = {
|
|
accessToken: 'access-token-value',
|
|
idToken: 'id-token-value',
|
|
userId: 'oidc-sub-456',
|
|
userEmail: 'test@example.com',
|
|
};
|
|
|
|
const input =
|
|
'Authorization: Bearer {{LIBRECHAT_OPENID_TOKEN}}, ID: {{LIBRECHAT_OPENID_ID_TOKEN}}, User: {{LIBRECHAT_OPENID_USER_ID}}';
|
|
const result = processOpenIDPlaceholders(input, tokenInfo);
|
|
|
|
expect(result).toBe(
|
|
'Authorization: Bearer access-token-value, ID: id-token-value, User: oidc-sub-456',
|
|
);
|
|
});
|
|
|
|
it('should replace empty string when token field is undefined', () => {
|
|
const tokenInfo = {
|
|
accessToken: undefined,
|
|
idToken: undefined,
|
|
userId: 'oidc-sub-456',
|
|
};
|
|
|
|
const input =
|
|
'Access: {{LIBRECHAT_OPENID_TOKEN}}, ID: {{LIBRECHAT_OPENID_ID_TOKEN}}, User: {{LIBRECHAT_OPENID_USER_ID}}';
|
|
const result = processOpenIDPlaceholders(input, tokenInfo);
|
|
|
|
expect(result).toBe('Access: , ID: , User: oidc-sub-456');
|
|
});
|
|
|
|
it('should handle all placeholder types in one value', () => {
|
|
const tokenInfo = {
|
|
accessToken: 'access-token-value',
|
|
idToken: 'id-token-value',
|
|
userId: 'oidc-sub-456',
|
|
userEmail: 'test@example.com',
|
|
userName: 'Test User',
|
|
expiresAt: 1234567890,
|
|
};
|
|
|
|
const input = `
|
|
Authorization: Bearer {{LIBRECHAT_OPENID_TOKEN}}
|
|
ID Token: {{LIBRECHAT_OPENID_ID_TOKEN}}
|
|
Access Token (alt): {{LIBRECHAT_OPENID_ACCESS_TOKEN}}
|
|
User ID: {{LIBRECHAT_OPENID_USER_ID}}
|
|
User Email: {{LIBRECHAT_OPENID_USER_EMAIL}}
|
|
User Name: {{LIBRECHAT_OPENID_USER_NAME}}
|
|
Expires: {{LIBRECHAT_OPENID_EXPIRES_AT}}
|
|
`;
|
|
|
|
const result = processOpenIDPlaceholders(input, tokenInfo);
|
|
|
|
expect(result).toContain('Bearer access-token-value');
|
|
expect(result).toContain('ID Token: id-token-value');
|
|
expect(result).toContain('Access Token (alt): access-token-value');
|
|
expect(result).toContain('User ID: oidc-sub-456');
|
|
expect(result).toContain('User Email: test@example.com');
|
|
expect(result).toContain('User Name: Test User');
|
|
expect(result).toContain('Expires: 1234567890');
|
|
});
|
|
|
|
it('should not modify string when no placeholders are present', () => {
|
|
const tokenInfo = {
|
|
accessToken: 'access-token-value',
|
|
userId: 'oidc-sub-456',
|
|
};
|
|
|
|
const input = 'Authorization: Bearer static-token';
|
|
const result = processOpenIDPlaceholders(input, tokenInfo);
|
|
|
|
expect(result).toBe('Authorization: Bearer static-token');
|
|
});
|
|
|
|
it('should handle case-sensitive placeholders', () => {
|
|
const tokenInfo = {
|
|
accessToken: 'access-token-value',
|
|
userId: 'oidc-sub-456',
|
|
};
|
|
|
|
// Wrong case should NOT be replaced
|
|
const input = 'Token: {{librechat_openid_token}}';
|
|
const result = processOpenIDPlaceholders(input, tokenInfo);
|
|
|
|
expect(result).toBe('Token: {{librechat_openid_token}}');
|
|
});
|
|
|
|
it('should handle multiple occurrences of the same placeholder', () => {
|
|
const tokenInfo = {
|
|
accessToken: 'access-token-value',
|
|
userId: 'oidc-sub-456',
|
|
};
|
|
|
|
const input =
|
|
'Primary: {{LIBRECHAT_OPENID_TOKEN}}, Secondary: {{LIBRECHAT_OPENID_TOKEN}}, Backup: {{LIBRECHAT_OPENID_TOKEN}}';
|
|
const result = processOpenIDPlaceholders(input, tokenInfo);
|
|
|
|
expect(result).toBe(
|
|
'Primary: access-token-value, Secondary: access-token-value, Backup: access-token-value',
|
|
);
|
|
});
|
|
|
|
it('should handle token info with all fields undefined except userId', () => {
|
|
const tokenInfo = {
|
|
accessToken: undefined,
|
|
idToken: undefined,
|
|
userId: 'oidc-sub-456',
|
|
userEmail: undefined,
|
|
userName: undefined,
|
|
};
|
|
|
|
const input =
|
|
'Access: {{LIBRECHAT_OPENID_TOKEN}}, ID: {{LIBRECHAT_OPENID_ID_TOKEN}}, User: {{LIBRECHAT_OPENID_USER_ID}}';
|
|
const result = processOpenIDPlaceholders(input, tokenInfo);
|
|
|
|
expect(result).toBe('Access: , ID: , User: oidc-sub-456');
|
|
});
|
|
|
|
it('should return original value when tokenInfo is null', () => {
|
|
const input = 'Authorization: Bearer {{LIBRECHAT_OPENID_TOKEN}}';
|
|
const result = processOpenIDPlaceholders(input, null);
|
|
|
|
expect(result).toBe('Authorization: Bearer {{LIBRECHAT_OPENID_TOKEN}}');
|
|
});
|
|
|
|
it('should return original value when value is not a string', () => {
|
|
const tokenInfo = {
|
|
accessToken: 'access-token-value',
|
|
userId: 'oidc-sub-456',
|
|
};
|
|
|
|
const result = processOpenIDPlaceholders(123 as unknown as string, tokenInfo);
|
|
|
|
expect(result).toBe(123);
|
|
});
|
|
});
|
|
|
|
describe('Integration: Full OpenID Token Flow', () => {
|
|
it('should extract, validate, and process tokens correctly', () => {
|
|
const user: Partial<TUser> = {
|
|
id: 'user-123',
|
|
provider: 'openid',
|
|
openidId: 'oidc-sub-456',
|
|
email: 'test@example.com',
|
|
name: 'Test User',
|
|
federatedTokens: {
|
|
access_token: 'access-token-value',
|
|
id_token: 'id-token-value',
|
|
refresh_token: 'refresh-token-value',
|
|
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
|
},
|
|
};
|
|
|
|
// Step 1: Extract token info
|
|
const tokenInfo = extractOpenIDTokenInfo(user);
|
|
expect(tokenInfo).not.toBeNull();
|
|
|
|
// Step 2: Validate token
|
|
const isValid = isOpenIDTokenValid(tokenInfo!);
|
|
expect(isValid).toBe(true);
|
|
|
|
// Step 3: Process placeholders
|
|
const input =
|
|
'Authorization: Bearer {{LIBRECHAT_OPENID_TOKEN}}, User: {{LIBRECHAT_OPENID_USER_ID}}';
|
|
const result = processOpenIDPlaceholders(input, tokenInfo!);
|
|
expect(result).toContain('Authorization: Bearer access-token-value');
|
|
expect(result).toContain('User:');
|
|
});
|
|
|
|
it('should resolve LIBRECHAT_OPENID_ID_TOKEN and LIBRECHAT_OPENID_ACCESS_TOKEN to different values', () => {
|
|
const user: Partial<TUser> = {
|
|
id: 'user-123',
|
|
provider: 'openid',
|
|
openidId: 'oidc-sub-456',
|
|
email: 'test@example.com',
|
|
name: 'Test User',
|
|
federatedTokens: {
|
|
access_token: 'my-access-token',
|
|
id_token: 'my-id-token',
|
|
refresh_token: 'my-refresh-token',
|
|
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
|
},
|
|
};
|
|
|
|
const tokenInfo = extractOpenIDTokenInfo(user);
|
|
expect(tokenInfo).not.toBeNull();
|
|
expect(tokenInfo!.accessToken).toBe('my-access-token');
|
|
expect(tokenInfo!.idToken).toBe('my-id-token');
|
|
expect(tokenInfo!.accessToken).not.toBe(tokenInfo!.idToken);
|
|
|
|
const input = 'ACCESS={{LIBRECHAT_OPENID_ACCESS_TOKEN}}, ID={{LIBRECHAT_OPENID_ID_TOKEN}}';
|
|
const result = processOpenIDPlaceholders(input, tokenInfo!);
|
|
|
|
expect(result).toBe('ACCESS=my-access-token, ID=my-id-token');
|
|
// Verify they are not the same value (the reported bug)
|
|
expect(result).not.toBe('ACCESS=my-access-token, ID=my-access-token');
|
|
});
|
|
|
|
it('should handle expired tokens correctly', () => {
|
|
const user: Partial<TUser> = {
|
|
id: 'user-123',
|
|
provider: 'openid',
|
|
openidId: 'oidc-sub-456',
|
|
federatedTokens: {
|
|
access_token: 'access-token-value',
|
|
expires_at: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
|
|
},
|
|
};
|
|
|
|
const tokenInfo = extractOpenIDTokenInfo(user);
|
|
expect(tokenInfo).not.toBeNull();
|
|
|
|
const isValid = isOpenIDTokenValid(tokenInfo!);
|
|
expect(isValid).toBe(false); // Token is expired
|
|
|
|
// Even if expired, processOpenIDPlaceholders should still work
|
|
// (validation is checked separately by the caller)
|
|
const input = 'Authorization: Bearer {{LIBRECHAT_OPENID_TOKEN}}';
|
|
const result = processOpenIDPlaceholders(input, tokenInfo!);
|
|
expect(result).toBe('Authorization: Bearer access-token-value');
|
|
});
|
|
|
|
it('should handle user with no federatedTokens but still has OpenID provider', () => {
|
|
const user: Partial<TUser> = {
|
|
id: 'user-123',
|
|
provider: 'openid',
|
|
openidId: 'oidc-sub-456',
|
|
};
|
|
|
|
const tokenInfo = extractOpenIDTokenInfo(user);
|
|
expect(tokenInfo).not.toBeNull();
|
|
expect(tokenInfo?.userId).toBe('oidc-sub-456');
|
|
expect(tokenInfo?.accessToken).toBeUndefined();
|
|
});
|
|
|
|
it('should handle missing user', () => {
|
|
const tokenInfo = extractOpenIDTokenInfo(undefined);
|
|
expect(tokenInfo).toBeNull();
|
|
});
|
|
|
|
it('should handle non-OpenID users', () => {
|
|
const user: Partial<TUser> = {
|
|
id: 'user-123',
|
|
provider: 'email',
|
|
};
|
|
|
|
const tokenInfo = extractOpenIDTokenInfo(user);
|
|
expect(tokenInfo).toBeNull();
|
|
});
|
|
});
|
|
});
|