| | import { describe, it, expect, jest, beforeEach } from '@jest/globals'; |
| | import { |
| | extractOpenIDTokenInfo, |
| | processOpenIDPlaceholders, |
| | isOpenIDTokenValid, |
| | createBearerAuthHeader, |
| | isOpenIDAvailable, |
| | type OpenIDTokenInfo, |
| | } from '../packages/api/src/utils/oidc'; |
| | import { processMCPEnv, resolveHeaders } from '../packages/api/src/utils/env'; |
| | import type { TUser } from 'librechat-data-provider'; |
| | import type { IUser } from '@librechat/data-schemas'; |
| |
|
| | |
| | jest.mock('@librechat/data-schemas', () => ({ |
| | logger: { |
| | error: jest.fn(), |
| | warn: jest.fn(), |
| | info: jest.fn(), |
| | }, |
| | })); |
| |
|
| | describe('OpenID Connect Federated Provider Token Integration', () => { |
| | |
| | const mockCognitoUser: Partial<IUser> = { |
| | id: 'user-123', |
| | email: 'test@example.com', |
| | name: 'Test User', |
| | provider: 'openid', |
| | openidId: 'cognito-user-123', |
| | federatedTokens: { |
| | access_token: 'cognito-access-token-123', |
| | id_token: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJjb2duaXRvLXVzZXItMTIzIiwiZW1haWwiOiJ0ZXN0QGV4YW1wbGUuY29tIiwibmFtZSI6IlRlc3QgVXNlciIsImV4cCI6MTcwMDAwMDAwMH0.fake-signature', |
| | expires_at: Math.floor(Date.now() / 1000) + 3600, |
| | }, |
| | }; |
| |
|
| | const mockExpiredCognitoUser: Partial<IUser> = { |
| | ...mockCognitoUser, |
| | federatedTokens: { |
| | access_token: 'expired-cognito-token', |
| | id_token: 'expired-cognito-id-token', |
| | expires_at: Math.floor(Date.now() / 1000) - 3600, |
| | }, |
| | }; |
| |
|
| | |
| | const mockOpenIDTokensUser: Partial<IUser> = { |
| | id: 'user-456', |
| | email: 'alt@example.com', |
| | name: 'Alt User', |
| | provider: 'openid', |
| | openidId: 'alt-user-456', |
| | openidTokens: { |
| | access_token: 'alt-access-token-456', |
| | id_token: 'alt-id-token-789', |
| | expires_at: Math.floor(Date.now() / 1000) + 3600, |
| | }, |
| | }; |
| |
|
| | beforeEach(() => { |
| | jest.clearAllMocks(); |
| | }); |
| |
|
| | describe('extractOpenIDTokenInfo', () => { |
| | it('should extract federated provider token info from Cognito user', () => { |
| | const tokenInfo = extractOpenIDTokenInfo(mockCognitoUser as IUser); |
| |
|
| | expect(tokenInfo).toEqual({ |
| | accessToken: 'cognito-access-token-123', |
| | idToken: expect.stringContaining('eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9'), |
| | expiresAt: expect.any(Number), |
| | userId: 'cognito-user-123', |
| | userEmail: 'test@example.com', |
| | userName: 'Test User', |
| | claims: expect.objectContaining({ |
| | sub: 'cognito-user-123', |
| | email: 'test@example.com', |
| | name: 'Test User', |
| | }), |
| | }); |
| | }); |
| |
|
| | it('should extract tokens from alternative storage location', () => { |
| | const tokenInfo = extractOpenIDTokenInfo(mockOpenIDTokensUser as IUser); |
| |
|
| | expect(tokenInfo).toEqual({ |
| | accessToken: 'alt-access-token-456', |
| | idToken: 'alt-id-token-789', |
| | expiresAt: expect.any(Number), |
| | userId: 'alt-user-456', |
| | userEmail: 'alt@example.com', |
| | userName: 'Alt User', |
| | }); |
| | }); |
| |
|
| | it('should return null for non-OpenID user', () => { |
| | const nonOpenIDUser: Partial<IUser> = { |
| | id: 'user-123', |
| | provider: 'google', |
| | email: 'test@example.com', |
| | }; |
| |
|
| | const tokenInfo = extractOpenIDTokenInfo(nonOpenIDUser as IUser); |
| | expect(tokenInfo).toBeNull(); |
| | }); |
| |
|
| | it('should return null for null/undefined user', () => { |
| | expect(extractOpenIDTokenInfo(null)).toBeNull(); |
| | expect(extractOpenIDTokenInfo(undefined)).toBeNull(); |
| | }); |
| |
|
| | it('should handle JWT parsing errors gracefully', () => { |
| | const userWithMalformedJWT: Partial<IUser> = { |
| | ...mockCognitoUser, |
| | federatedTokens: { |
| | access_token: 'valid-access-token', |
| | id_token: 'malformed.jwt.token', |
| | expires_at: Math.floor(Date.now() / 1000) + 3600, |
| | }, |
| | }; |
| |
|
| | const tokenInfo = extractOpenIDTokenInfo(userWithMalformedJWT as IUser); |
| |
|
| | expect(tokenInfo).toBeDefined(); |
| | expect(tokenInfo?.accessToken).toBe('valid-access-token'); |
| | expect(tokenInfo?.claims).toBeUndefined(); |
| | }); |
| | }); |
| |
|
| | describe('isOpenIDTokenValid', () => { |
| | it('should return true for valid Cognito token', () => { |
| | const tokenInfo = extractOpenIDTokenInfo(mockCognitoUser as IUser); |
| | expect(isOpenIDTokenValid(tokenInfo)).toBe(true); |
| | }); |
| |
|
| | it('should return false for expired Cognito token', () => { |
| | const tokenInfo = extractOpenIDTokenInfo(mockExpiredCognitoUser as IUser); |
| | expect(isOpenIDTokenValid(tokenInfo)).toBe(false); |
| | }); |
| |
|
| | it('should return false for null token info', () => { |
| | expect(isOpenIDTokenValid(null)).toBe(false); |
| | }); |
| |
|
| | it('should return false for token info without access token', () => { |
| | const tokenInfo: OpenIDTokenInfo = { |
| | userId: 'user-123', |
| | userEmail: 'test@example.com', |
| | }; |
| | expect(isOpenIDTokenValid(tokenInfo)).toBe(false); |
| | }); |
| | }); |
| |
|
| | describe('processOpenIDPlaceholders', () => { |
| | const tokenInfo: OpenIDTokenInfo = { |
| | accessToken: 'cognito-access-token-123', |
| | idToken: 'cognito-id-token-456', |
| | userId: 'cognito-user-789', |
| | userEmail: 'cognito@example.com', |
| | userName: 'Cognito User', |
| | expiresAt: 1700000000, |
| | }; |
| |
|
| | it('should replace OpenID Connect token placeholders', () => { |
| | const template = 'Bearer {{LIBRECHAT_OPENID_TOKEN}}'; |
| | const result = processOpenIDPlaceholders(template, tokenInfo); |
| | expect(result).toBe('Bearer cognito-access-token-123'); |
| | }); |
| |
|
| | it('should replace specific OpenID Connect placeholders', () => { |
| | const template = ` |
| | Access: {{LIBRECHAT_OPENID_ACCESS_TOKEN}} |
| | ID: {{LIBRECHAT_OPENID_ID_TOKEN}} |
| | User: {{LIBRECHAT_OPENID_USER_ID}} |
| | Email: {{LIBRECHAT_OPENID_USER_EMAIL}} |
| | Name: {{LIBRECHAT_OPENID_USER_NAME}} |
| | Expires: {{LIBRECHAT_OPENID_EXPIRES_AT}} |
| | `; |
| |
|
| | const result = processOpenIDPlaceholders(template, tokenInfo); |
| |
|
| | expect(result).toContain('Access: cognito-access-token-123'); |
| | expect(result).toContain('ID: cognito-id-token-456'); |
| | expect(result).toContain('User: cognito-user-789'); |
| | expect(result).toContain('Email: cognito@example.com'); |
| | expect(result).toContain('Name: Cognito User'); |
| | expect(result).toContain('Expires: 1700000000'); |
| | }); |
| |
|
| | it('should handle missing token fields gracefully', () => { |
| | const partialTokenInfo: OpenIDTokenInfo = { |
| | accessToken: 'partial-cognito-token', |
| | userId: 'user-123', |
| | }; |
| |
|
| | const template = 'Token: {{LIBRECHAT_OPENID_TOKEN}}, Email: {{LIBRECHAT_OPENID_USER_EMAIL}}'; |
| | const result = processOpenIDPlaceholders(template, partialTokenInfo); |
| |
|
| | expect(result).toBe('Token: partial-cognito-token, Email: '); |
| | }); |
| |
|
| | it('should return original value for null token info', () => { |
| | const template = 'Bearer {{LIBRECHAT_OPENID_TOKEN}}'; |
| | const result = processOpenIDPlaceholders(template, null); |
| | expect(result).toBe(template); |
| | }); |
| | }); |
| |
|
| | describe('createBearerAuthHeader', () => { |
| | it('should create proper Bearer header with Cognito token', () => { |
| | const tokenInfo: OpenIDTokenInfo = { |
| | accessToken: 'cognito-test-token-123', |
| | }; |
| |
|
| | const header = createBearerAuthHeader(tokenInfo); |
| | expect(header).toBe('Bearer cognito-test-token-123'); |
| | }); |
| |
|
| | it('should return empty string for null token info', () => { |
| | const header = createBearerAuthHeader(null); |
| | expect(header).toBe(''); |
| | }); |
| |
|
| | it('should return empty string for token info without access token', () => { |
| | const tokenInfo: OpenIDTokenInfo = { |
| | userId: 'user-123', |
| | }; |
| |
|
| | const header = createBearerAuthHeader(tokenInfo); |
| | expect(header).toBe(''); |
| | }); |
| | }); |
| |
|
| | describe('isOpenIDAvailable', () => { |
| | const originalEnv = process.env; |
| |
|
| | beforeEach(() => { |
| | jest.resetModules(); |
| | process.env = { ...originalEnv }; |
| | }); |
| |
|
| | afterAll(() => { |
| | process.env = originalEnv; |
| | }); |
| |
|
| | it('should return true when OpenID Connect is properly configured for Cognito', () => { |
| | process.env.OPENID_CLIENT_ID = 'cognito-client-id'; |
| | process.env.OPENID_CLIENT_SECRET = 'cognito-client-secret'; |
| | process.env.OPENID_ISSUER = 'https://cognito-idp.us-east-1.amazonaws.com/us-east-1_ABC123'; |
| |
|
| | expect(isOpenIDAvailable()).toBe(true); |
| | }); |
| |
|
| | it('should return false when OpenID Connect is not configured', () => { |
| | delete process.env.OPENID_CLIENT_ID; |
| | delete process.env.OPENID_CLIENT_SECRET; |
| | delete process.env.OPENID_ISSUER; |
| |
|
| | expect(isOpenIDAvailable()).toBe(false); |
| | }); |
| |
|
| | it('should return false when OpenID Connect is partially configured', () => { |
| | process.env.OPENID_CLIENT_ID = 'cognito-client-id'; |
| | delete process.env.OPENID_CLIENT_SECRET; |
| | delete process.env.OPENID_ISSUER; |
| |
|
| | expect(isOpenIDAvailable()).toBe(false); |
| | }); |
| | }); |
| |
|
| | describe('Integration with resolveHeaders', () => { |
| | it('should resolve OpenID Connect placeholders in headers for Cognito', () => { |
| | const headers = { |
| | 'Authorization': '{{LIBRECHAT_OPENID_TOKEN}}', |
| | 'X-User-ID': '{{LIBRECHAT_OPENID_USER_ID}}', |
| | 'X-User-Email': '{{LIBRECHAT_OPENID_USER_EMAIL}}', |
| | }; |
| |
|
| | const resolvedHeaders = resolveHeaders({ |
| | headers, |
| | user: mockCognitoUser as TUser, |
| | }); |
| |
|
| | expect(resolvedHeaders['Authorization']).toBe('cognito-access-token-123'); |
| | expect(resolvedHeaders['X-User-ID']).toBe('cognito-user-123'); |
| | expect(resolvedHeaders['X-User-Email']).toBe('test@example.com'); |
| | }); |
| |
|
| | it('should work with Bearer token format for Cognito', () => { |
| | const headers = { |
| | 'Authorization': 'Bearer {{LIBRECHAT_OPENID_TOKEN}}', |
| | }; |
| |
|
| | const resolvedHeaders = resolveHeaders({ |
| | headers, |
| | user: mockCognitoUser as TUser, |
| | }); |
| |
|
| | expect(resolvedHeaders['Authorization']).toBe('Bearer cognito-access-token-123'); |
| | }); |
| |
|
| | it('should work with specific access token placeholder', () => { |
| | const headers = { |
| | 'Authorization': 'Bearer {{LIBRECHAT_OPENID_ACCESS_TOKEN}}', |
| | 'X-Cognito-ID-Token': '{{LIBRECHAT_OPENID_ID_TOKEN}}', |
| | }; |
| |
|
| | const resolvedHeaders = resolveHeaders({ |
| | headers, |
| | user: mockCognitoUser as TUser, |
| | }); |
| |
|
| | expect(resolvedHeaders['Authorization']).toBe('Bearer cognito-access-token-123'); |
| | expect(resolvedHeaders['X-Cognito-ID-Token']).toContain('eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9'); |
| | }); |
| | }); |
| |
|
| | describe('Integration with processMCPEnv', () => { |
| | it('should process OpenID Connect placeholders in MCP environment variables for Cognito', () => { |
| | const mcpOptions = { |
| | command: 'node', |
| | args: ['server.js'], |
| | env: { |
| | 'COGNITO_ACCESS_TOKEN': '{{LIBRECHAT_OPENID_TOKEN}}', |
| | 'USER_ID': '{{LIBRECHAT_OPENID_USER_ID}}', |
| | 'USER_EMAIL': '{{LIBRECHAT_OPENID_USER_EMAIL}}', |
| | }, |
| | }; |
| |
|
| | const processedOptions = processMCPEnv({ |
| | options: mcpOptions, |
| | user: mockCognitoUser as TUser, |
| | }); |
| |
|
| | expect(processedOptions.env?.['COGNITO_ACCESS_TOKEN']).toBe('cognito-access-token-123'); |
| | expect(processedOptions.env?.['USER_ID']).toBe('cognito-user-123'); |
| | expect(processedOptions.env?.['USER_EMAIL']).toBe('test@example.com'); |
| | }); |
| |
|
| | it('should process OpenID Connect placeholders in MCP headers for HTTP transport', () => { |
| | const mcpOptions = { |
| | type: 'sse' as const, |
| | url: 'https://api.example.com/mcp', |
| | headers: { |
| | 'Authorization': 'Bearer {{LIBRECHAT_OPENID_ACCESS_TOKEN}}', |
| | 'X-Cognito-User-Info': '{{LIBRECHAT_OPENID_USER_EMAIL}}', |
| | 'X-Cognito-ID-Token': '{{LIBRECHAT_OPENID_ID_TOKEN}}', |
| | }, |
| | }; |
| |
|
| | const processedOptions = processMCPEnv({ |
| | options: mcpOptions, |
| | user: mockCognitoUser as TUser, |
| | }); |
| |
|
| | expect(processedOptions.headers?.['Authorization']).toBe('Bearer cognito-access-token-123'); |
| | expect(processedOptions.headers?.['X-Cognito-User-Info']).toBe('test@example.com'); |
| | expect(processedOptions.headers?.['X-Cognito-ID-Token']).toContain('eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9'); |
| | }); |
| |
|
| | it('should handle AWS-specific MCP server configuration', () => { |
| | const awsMcpOptions = { |
| | command: 'node', |
| | args: ['aws-mcp-server.js'], |
| | env: { |
| | 'AWS_COGNITO_TOKEN': '{{LIBRECHAT_OPENID_ACCESS_TOKEN}}', |
| | 'AWS_COGNITO_ID_TOKEN': '{{LIBRECHAT_OPENID_ID_TOKEN}}', |
| | 'COGNITO_USER_SUB': '{{LIBRECHAT_OPENID_USER_ID}}', |
| | }, |
| | }; |
| |
|
| | const processedOptions = processMCPEnv({ |
| | options: awsMcpOptions, |
| | user: mockCognitoUser as TUser, |
| | }); |
| |
|
| | expect(processedOptions.env?.['AWS_COGNITO_TOKEN']).toBe('cognito-access-token-123'); |
| | expect(processedOptions.env?.['AWS_COGNITO_ID_TOKEN']).toContain('eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9'); |
| | expect(processedOptions.env?.['COGNITO_USER_SUB']).toBe('cognito-user-123'); |
| | }); |
| | }); |
| |
|
| | describe('Security and Edge Cases', () => { |
| | it('should not process OpenID Connect placeholders for expired tokens', () => { |
| | const headers = { |
| | 'Authorization': 'Bearer {{LIBRECHAT_OPENID_TOKEN}}', |
| | }; |
| |
|
| | const resolvedHeaders = resolveHeaders({ |
| | headers, |
| | user: mockExpiredCognitoUser as TUser, |
| | }); |
| |
|
| | |
| | expect(resolvedHeaders['Authorization']).toBe('Bearer {{LIBRECHAT_OPENID_TOKEN}}'); |
| | }); |
| |
|
| | it('should handle malformed federated token data gracefully', () => { |
| | const malformedUser: Partial<IUser> = { |
| | id: 'user-123', |
| | provider: 'openid', |
| | openidId: 'cognito-user', |
| | federatedTokens: null, |
| | }; |
| |
|
| | const headers = { |
| | 'Authorization': 'Bearer {{LIBRECHAT_OPENID_TOKEN}}', |
| | }; |
| |
|
| | const resolvedHeaders = resolveHeaders({ |
| | headers, |
| | user: malformedUser as TUser, |
| | }); |
| |
|
| | |
| | expect(resolvedHeaders['Authorization']).toBe('Bearer {{LIBRECHAT_OPENID_TOKEN}}'); |
| | }); |
| |
|
| | it('should handle multiple placeholder instances in same string', () => { |
| | const template = '{{LIBRECHAT_OPENID_TOKEN}}-{{LIBRECHAT_OPENID_TOKEN}}-{{LIBRECHAT_OPENID_USER_ID}}'; |
| |
|
| | const tokenInfo: OpenIDTokenInfo = { |
| | accessToken: 'cognito-token123', |
| | userId: 'cognito-user456', |
| | }; |
| |
|
| | const result = processOpenIDPlaceholders(template, tokenInfo); |
| | expect(result).toBe('cognito-token123-cognito-token123-cognito-user456'); |
| | }); |
| |
|
| | it('should handle users without federated tokens storage', () => { |
| | const userWithoutTokens: Partial<IUser> = { |
| | id: 'user-789', |
| | provider: 'openid', |
| | openidId: 'user-without-tokens', |
| | email: 'no-tokens@example.com', |
| | |
| | }; |
| |
|
| | const headers = { |
| | 'Authorization': 'Bearer {{LIBRECHAT_OPENID_TOKEN}}', |
| | }; |
| |
|
| | const resolvedHeaders = resolveHeaders({ |
| | headers, |
| | user: userWithoutTokens as TUser, |
| | }); |
| |
|
| | |
| | expect(resolvedHeaders['Authorization']).toBe('Bearer {{LIBRECHAT_OPENID_TOKEN}}'); |
| | }); |
| |
|
| | it('should prioritize federatedTokens over openidTokens', () => { |
| | const userWithBothTokens: Partial<IUser> = { |
| | id: 'user-priority', |
| | provider: 'openid', |
| | openidId: 'priority-user', |
| | federatedTokens: { |
| | access_token: 'federated-priority-token', |
| | expires_at: Math.floor(Date.now() / 1000) + 3600, |
| | }, |
| | openidTokens: { |
| | access_token: 'openid-fallback-token', |
| | expires_at: Math.floor(Date.now() / 1000) + 3600, |
| | }, |
| | }; |
| |
|
| | const tokenInfo = extractOpenIDTokenInfo(userWithBothTokens as IUser); |
| | expect(tokenInfo?.accessToken).toBe('federated-priority-token'); |
| | }); |
| | }); |
| | }); |