@clduab11/gemini-flow
Version:
Revolutionary AI agent swarm coordination platform with Google Services integration, multimedia processing, and production-ready monitoring. Features 8 Google AI services, quantum computing capabilities, and enterprise-grade security.
645 lines (534 loc) • 20.2 kB
text/typescript
/**
* OAuth2 Provider Unit Tests
*
* Comprehensive test suite for OAuth2 token refresh mechanism, authentication flows,
* and error handling scenarios
*/
import { jest } from '@jest/globals';
import { OAuth2Provider } from '../../../src/core/auth/oauth2-provider';
import { OAuth2Config, AuthCredentials } from '../../../src/types/auth';
// Mock fetch globally
const mockFetch = jest.fn();
global.fetch = mockFetch as any;
describe('OAuth2Provider', () => {
let provider: OAuth2Provider;
let mockConfig: OAuth2Config;
beforeEach(() => {
// Reset mocks
jest.clearAllMocks();
mockFetch.mockClear();
// Default OAuth2 configuration for testing
mockConfig = {
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
redirectUri: 'http://localhost:3000/callback',
authorizationEndpoint: 'https://auth.example.com/oauth/authorize',
tokenEndpoint: 'https://auth.example.com/oauth/token',
revokeEndpoint: 'https://auth.example.com/oauth/revoke',
userinfoEndpoint: 'https://auth.example.com/oauth/userinfo',
scopes: ['read', 'write'],
pkceEnabled: true
};
provider = new OAuth2Provider(mockConfig);
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('Configuration Validation', () => {
it('should validate required configuration fields', () => {
const invalidConfigs = [
{ ...mockConfig, clientId: '' },
{ ...mockConfig, clientSecret: '' },
{ ...mockConfig, redirectUri: '' },
{ ...mockConfig, authorizationEndpoint: '' },
{ ...mockConfig, tokenEndpoint: '' },
{ ...mockConfig, scopes: [] }
];
invalidConfigs.forEach(config => {
expect(() => new OAuth2Provider(config)).toThrow();
});
});
it('should validate URL formats', () => {
const invalidUrlConfig = {
...mockConfig,
authorizationEndpoint: 'invalid-url'
};
expect(() => new OAuth2Provider(invalidUrlConfig)).toThrow(/invalid URLs/i);
});
it('should accept valid configuration', () => {
expect(() => new OAuth2Provider(mockConfig)).not.toThrow();
});
});
describe('Authentication Flow', () => {
it('should start authentication flow successfully', async () => {
const result = await provider.authenticate();
expect(result.success).toBe(true);
expect(result.redirectUrl).toContain(mockConfig.authorizationEndpoint);
expect(result.redirectUrl).toContain('client_id=test-client-id');
expect(result.redirectUrl).toContain('response_type=code');
expect(result.redirectUrl).toContain('scope=read+write');
expect(result.context).toBeDefined();
expect(result.context?.sessionId).toBeDefined();
});
it('should include PKCE parameters when enabled', async () => {
const result = await provider.authenticate();
expect(result.redirectUrl).toContain('code_challenge=');
expect(result.redirectUrl).toContain('code_challenge_method=S256');
expect(result.context?.credentials?.metadata?.pkceVerifier).toBeDefined();
});
it('should include state parameter for CSRF protection', async () => {
const result = await provider.authenticate();
expect(result.redirectUrl).toContain('state=');
expect(result.context?.credentials?.metadata?.state).toBeDefined();
});
});
describe('Token Exchange', () => {
beforeEach(() => {
// Mock successful token response
mockFetch.mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({
access_token: 'test-access-token',
refresh_token: 'test-refresh-token',
expires_in: 3600,
token_type: 'Bearer',
scope: 'read write'
})
});
});
it('should exchange authorization code for tokens', async () => {
const result = await provider.exchangeCodeForTokens(
'test-auth-code',
'test-state',
'test-code-verifier'
);
expect(result.success).toBe(true);
expect(result.credentials).toBeDefined();
expect(result.credentials?.accessToken).toBe('test-access-token');
expect(result.credentials?.refreshToken).toBe('test-refresh-token');
expect(result.credentials?.expiresAt).toBeGreaterThan(Date.now());
expect(result.credentials?.scope).toEqual(['read', 'write']);
// Verify API call
expect(mockFetch).toHaveBeenCalledWith(
mockConfig.tokenEndpoint,
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Content-Type': 'application/x-www-form-urlencoded'
})
})
);
});
it('should validate state parameter', async () => {
// First start auth flow to set state
await provider.authenticate();
const result = await provider.exchangeCodeForTokens(
'test-auth-code',
'invalid-state'
);
expect(result.success).toBe(false);
expect(result.error?.message).toContain('Invalid state parameter');
});
it('should handle token exchange errors', async () => {
mockFetch.mockResolvedValue({
ok: false,
json: jest.fn().mockResolvedValue({
error: 'invalid_grant',
error_description: 'The authorization code is invalid'
})
});
const result = await provider.exchangeCodeForTokens('invalid-code');
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
expect(result.error?.code).toBe('TOKEN_EXCHANGE_FAILED');
});
});
describe('Token Refresh', () => {
let mockCredentials: AuthCredentials;
beforeEach(() => {
mockCredentials = {
type: 'oauth2',
provider: 'oauth2',
accessToken: 'current-access-token',
refreshToken: 'current-refresh-token',
expiresAt: Date.now() + 300000, // 5 minutes from now
scope: ['read', 'write'],
issuedAt: Date.now() - 3600000, // 1 hour ago
metadata: {}
};
});
it('should refresh tokens successfully', async () => {
// Mock successful refresh response
mockFetch.mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({
access_token: 'new-access-token',
refresh_token: 'new-refresh-token',
expires_in: 3600,
token_type: 'Bearer',
scope: 'read write'
})
});
const result = await provider.refresh(mockCredentials);
expect(result.success).toBe(true);
expect(result.credentials).toBeDefined();
expect(result.credentials?.accessToken).toBe('new-access-token');
expect(result.credentials?.refreshToken).toBe('new-refresh-token');
expect(result.credentials?.metadata?.refreshedAt).toBeDefined();
// Verify refresh request
expect(mockFetch).toHaveBeenCalledWith(
mockConfig.tokenEndpoint,
expect.objectContaining({
method: 'POST',
body: expect.stringContaining('grant_type=refresh_token')
})
);
});
it('should handle missing refresh token', async () => {
const credentialsWithoutRefresh = {
...mockCredentials,
refreshToken: undefined
};
const result = await provider.refresh(credentialsWithoutRefresh);
expect(result.success).toBe(false);
expect(result.requiresReauth).toBe(true);
expect(result.error?.code).toBe('NO_REFRESH_TOKEN');
});
it('should handle refresh token errors', async () => {
mockFetch.mockResolvedValue({
ok: false,
json: jest.fn().mockResolvedValue({
error: 'invalid_grant',
error_description: 'Refresh token is invalid'
})
});
const result = await provider.refresh(mockCredentials);
expect(result.success).toBe(false);
expect(result.requiresReauth).toBe(true);
expect(result.error?.code).toBe('TOKEN_REFRESH_FAILED');
});
it('should preserve existing refresh token if not returned', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({
access_token: 'new-access-token',
expires_in: 3600,
token_type: 'Bearer'
// No refresh_token in response
})
});
const result = await provider.refresh(mockCredentials);
expect(result.success).toBe(true);
expect(result.credentials?.refreshToken).toBe('current-refresh-token');
});
it('should handle network errors during refresh', async () => {
mockFetch.mockRejectedValue(new Error('Network error'));
const result = await provider.refresh(mockCredentials);
expect(result.success).toBe(false);
expect(result.error?.code).toBe('TOKEN_REFRESH_FAILED');
expect(result.error?.originalError?.message).toBe('Network error');
});
});
describe('Token Validation', () => {
let validCredentials: AuthCredentials;
let expiredCredentials: AuthCredentials;
beforeEach(() => {
validCredentials = {
type: 'oauth2',
provider: 'oauth2',
accessToken: 'valid-token',
expiresAt: Date.now() + 3600000, // 1 hour from now
scope: ['read', 'write'],
issuedAt: Date.now(),
metadata: {}
};
expiredCredentials = {
...validCredentials,
expiresAt: Date.now() - 1000 // 1 second ago
};
});
it('should validate non-expired tokens', async () => {
const result = await provider.validate(validCredentials);
expect(result.valid).toBe(true);
expect(result.expiresIn).toBeGreaterThan(3500); // ~1 hour
expect(result.scopes).toEqual(['read', 'write']);
});
it('should detect expired tokens', async () => {
const result = await provider.validate(expiredCredentials);
expect(result.valid).toBe(false);
expect(result.expired).toBe(true);
expect(result.error).toContain('expired');
});
it('should handle missing access token', async () => {
const invalidCredentials = {
...validCredentials,
accessToken: ''
};
const result = await provider.validate(invalidCredentials);
expect(result.valid).toBe(false);
expect(result.error).toContain('No access token');
});
it('should validate with userinfo endpoint when available', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({
sub: 'user123',
name: 'Test User'
})
});
const result = await provider.validate(validCredentials);
expect(result.valid).toBe(true);
expect(mockFetch).toHaveBeenCalledWith(
mockConfig.userinfoEndpoint,
expect.objectContaining({
headers: expect.objectContaining({
'Authorization': 'Bearer valid-token'
})
})
);
});
it('should handle userinfo endpoint errors', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 401,
statusText: 'Unauthorized'
});
const result = await provider.validate(validCredentials);
expect(result.valid).toBe(false);
expect(result.error).toContain('userinfo endpoint');
});
});
describe('Token Revocation', () => {
let mockCredentials: AuthCredentials;
beforeEach(() => {
mockCredentials = {
type: 'oauth2',
provider: 'oauth2',
accessToken: 'access-token-to-revoke',
refreshToken: 'refresh-token-to-revoke',
expiresAt: Date.now() + 3600000,
scope: ['read', 'write'],
issuedAt: Date.now(),
metadata: {}
};
});
it('should revoke tokens successfully', async () => {
mockFetch.mockResolvedValue({ ok: true });
await expect(provider.revoke(mockCredentials)).resolves.not.toThrow();
// Should call revoke endpoint for both tokens
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(mockFetch).toHaveBeenCalledWith(
mockConfig.revokeEndpoint,
expect.objectContaining({
method: 'POST',
body: expect.stringContaining('token=access-token-to-revoke')
})
);
expect(mockFetch).toHaveBeenCalledWith(
mockConfig.revokeEndpoint,
expect.objectContaining({
method: 'POST',
body: expect.stringContaining('token=refresh-token-to-revoke')
})
);
});
it('should handle missing revoke endpoint', async () => {
const configWithoutRevoke = {
...mockConfig,
revokeEndpoint: undefined
};
const providerWithoutRevoke = new OAuth2Provider(configWithoutRevoke);
await expect(providerWithoutRevoke.revoke(mockCredentials)).resolves.not.toThrow();
expect(mockFetch).not.toHaveBeenCalled();
});
it('should continue revoking even if one token fails', async () => {
mockFetch
.mockResolvedValueOnce({ ok: false, status: 400 }) // First token fails
.mockResolvedValueOnce({ ok: true }); // Second token succeeds
await expect(provider.revoke(mockCredentials)).resolves.not.toThrow();
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('should handle revocation errors', async () => {
mockFetch.mockRejectedValue(new Error('Network error'));
await expect(provider.revoke(mockCredentials)).rejects.toThrow();
});
});
describe('PKCE Support', () => {
it('should generate valid PKCE parameters', async () => {
const result = await provider.authenticate();
expect(result.redirectUrl).toContain('code_challenge=');
expect(result.redirectUrl).toContain('code_challenge_method=S256');
// Extract code challenge from URL
const urlParams = new URLSearchParams(result.redirectUrl?.split('?')[1]);
const codeChallenge = urlParams.get('code_challenge');
const codeChallengeMethod = urlParams.get('code_challenge_method');
expect(codeChallenge).toBeTruthy();
expect(codeChallenge).toMatch(/^[A-Za-z0-9_-]+$/); // Base64URL format
expect(codeChallengeMethod).toBe('S256');
});
it('should work without PKCE when disabled', async () => {
const configWithoutPKCE = {
...mockConfig,
pkceEnabled: false
};
const providerWithoutPKCE = new OAuth2Provider(configWithoutPKCE);
const result = await providerWithoutPKCE.authenticate();
expect(result.redirectUrl).not.toContain('code_challenge');
expect(result.redirectUrl).not.toContain('code_challenge_method');
});
});
describe('Error Handling', () => {
it('should create standardized auth errors', async () => {
mockFetch.mockRejectedValue(new Error('Network failure'));
const mockCredentials: AuthCredentials = {
type: 'oauth2',
provider: 'oauth2',
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
scope: ['read'],
issuedAt: Date.now(),
metadata: {}
};
const result = await provider.refresh(mockCredentials);
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
expect(result.error?.type).toBe('authentication');
expect(result.error?.code).toBe('TOKEN_REFRESH_FAILED');
expect(result.error?.originalError).toBeDefined();
expect(result.error?.context?.provider).toBe('oauth2');
});
it('should handle malformed token responses', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({
// Missing required fields
expires_in: 3600
})
});
const result = await provider.exchangeCodeForTokens('test-code');
expect(result.success).toBe(true); // Should still succeed with partial data
expect(result.credentials?.accessToken).toBeUndefined();
});
});
describe('Event Emission', () => {
it('should emit authentication events', async () => {
const eventSpy = jest.fn();
provider.on('authenticated', eventSpy);
mockFetch.mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({
access_token: 'test-token',
refresh_token: 'test-refresh',
expires_in: 3600,
token_type: 'Bearer'
})
});
await provider.exchangeCodeForTokens('test-code');
expect(eventSpy).toHaveBeenCalledWith(
expect.objectContaining({
credentials: expect.any(Object),
context: expect.any(Object)
})
);
});
it('should emit token refresh events', async () => {
const eventSpy = jest.fn();
provider.on('token_refreshed', eventSpy);
mockFetch.mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({
access_token: 'new-token',
expires_in: 3600,
token_type: 'Bearer'
})
});
const mockCredentials: AuthCredentials = {
type: 'oauth2',
provider: 'oauth2',
accessToken: 'old-token',
refreshToken: 'refresh-token',
expiresAt: Date.now() + 300000,
scope: ['read'],
issuedAt: Date.now(),
metadata: {}
};
await provider.refresh(mockCredentials);
expect(eventSpy).toHaveBeenCalledWith(
expect.objectContaining({
credentials: expect.any(Object)
})
);
});
it('should emit revocation events', async () => {
const eventSpy = jest.fn();
provider.on('tokens_revoked', eventSpy);
mockFetch.mockResolvedValue({ ok: true });
const mockCredentials: AuthCredentials = {
type: 'oauth2',
provider: 'oauth2',
accessToken: 'token-to-revoke',
refreshToken: 'refresh-to-revoke',
expiresAt: Date.now() + 3600000,
scope: ['read'],
issuedAt: Date.now(),
metadata: {}
};
await provider.revoke(mockCredentials);
expect(eventSpy).toHaveBeenCalledWith(
expect.objectContaining({
credentials: expect.any(Object)
})
);
});
});
describe('Security Features', () => {
it('should use secure defaults for authorization URL', async () => {
const result = await provider.authenticate();
const url = new URL(result.redirectUrl!);
const params = url.searchParams;
expect(params.get('access_type')).toBe('offline');
expect(params.get('prompt')).toBe('consent');
expect(params.get('state')).toBeTruthy();
});
it('should include proper User-Agent header', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({
access_token: 'test-token',
expires_in: 3600
})
});
await provider.exchangeCodeForTokens('test-code');
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
'User-Agent': 'GeminiFlow/1.1.0 OAuth2Provider'
})
})
);
});
it('should detect retryable vs non-retryable errors', async () => {
const networkError = new Error('Network timeout');
const authError = new Error('invalid_grant: Invalid authorization code');
// Both should be handled, but retryable flag should differ
const mockCredentials: AuthCredentials = {
type: 'oauth2',
provider: 'oauth2',
accessToken: 'test-token',
refreshToken: 'test-refresh',
expiresAt: Date.now() + 3600000,
scope: ['read'],
issuedAt: Date.now(),
metadata: {}
};
mockFetch.mockRejectedValueOnce(networkError);
const networkResult = await provider.refresh(mockCredentials);
mockFetch.mockRejectedValueOnce(authError);
const authResult = await provider.refresh(mockCredentials);
expect(networkResult.error?.retryable).toBe(true);
expect(authResult.error?.retryable).toBe(true); // TOKEN_REFRESH_FAILED is retryable
});
});
});