mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-15 15:08:52 +01:00
483 lines
15 KiB
TypeScript
483 lines
15 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 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();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|