// SPDX-License-Identifier: MIT OR Apache-2.0
// Copyright (c) 2025 Pierre Fitness Intelligence
// ABOUTME: Comprehensive OAuth 2.0 flow integration tests for bridge.ts
// ABOUTME: Tests dynamic client registration, authorization, token exchange, validation, and refresh
//
// Licensed under either of Apache License, Version 2.0 or MIT License at your option.
// Copyright ©2025 Async-IO.org
const { ensureServerRunning } = require('../helpers/server');
const { TestConfig } = require('../helpers/fixtures');
const { generateTestToken } = require('../helpers/token-generator');
const path = require('path');
const fs = require('fs');
const os = require('os');
const fetch = global.fetch;
describe('OAuth 2.0 Flow - Dynamic Client Registration', () => {
let serverHandle;
const serverUrl = `http://localhost:${TestConfig.defaultServerPort}`;
const registrationEndpoint = `${serverUrl}/oauth2/register`;
beforeAll(async () => {
serverHandle = await ensureServerRunning({
port: TestConfig.defaultServerPort,
database: TestConfig.testDatabase,
encryptionKey: TestConfig.testEncryptionKey
});
}, 60000);
afterAll(async () => {
if (serverHandle?.cleanup) {
await serverHandle.cleanup();
}
});
test('should register new OAuth client with Pierre server', async () => {
const registrationRequest = {
client_id: `test_client_${Date.now()}`,
client_secret: `test_secret_${Date.now()}`,
redirect_uris: ['http://localhost:35535/oauth/callback'],
grant_types: ['authorization_code'],
response_types: ['code'],
scope: 'read:fitness write:fitness',
client_name: 'Pierre Test Client',
client_uri: 'https://test.example.com'
};
const response = await fetch(registrationEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(registrationRequest)
});
expect(response.ok).toBe(true);
const registrationResponse = await response.json();
expect(registrationResponse).toHaveProperty('client_id');
expect(registrationResponse).toHaveProperty('client_secret');
expect(registrationResponse.client_id).toBeTruthy();
expect(registrationResponse.client_secret).toBeTruthy();
}, 30000);
test('should return server-assigned client_id when registration succeeds', async () => {
const registrationRequest = {
client_id: `bridge_client_${Date.now()}`,
client_secret: `bridge_secret_${Date.now()}`,
redirect_uris: ['http://localhost:35535/oauth/callback'],
grant_types: ['authorization_code'],
response_types: ['code'],
scope: 'read:fitness write:fitness',
client_name: 'Pierre Bridge Client',
client_uri: 'https://claude.ai'
};
const response = await fetch(registrationEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(registrationRequest)
});
expect(response.ok).toBe(true);
const result = await response.json();
// Server may return the same client_id or assign a new one
expect(result.client_id).toBeTruthy();
expect(typeof result.client_id).toBe('string');
expect(result.client_id.length).toBeGreaterThan(0);
}, 30000);
test('should reject registration with missing required fields', async () => {
const invalidRequest = {
// Missing client_id, client_secret, redirect_uris
grant_types: ['authorization_code']
};
const response = await fetch(registrationEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(invalidRequest)
});
expect(response.ok).toBe(false);
expect(response.status).toBeGreaterThanOrEqual(400);
}, 30000);
test('should reject registration with invalid redirect_uri format', async () => {
const invalidRequest = {
client_id: 'test_client',
client_secret: 'test_secret',
redirect_uris: ['not-a-valid-url'], // Invalid URI format
grant_types: ['authorization_code'],
response_types: ['code']
};
const response = await fetch(registrationEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(invalidRequest)
});
// Server should reject invalid URIs
expect(response.ok).toBe(false);
}, 30000);
test('should handle duplicate client registration gracefully', async () => {
const clientId = `duplicate_client_${Date.now()}`;
const registrationRequest = {
client_id: clientId,
client_secret: 'test_secret_1',
redirect_uris: ['http://localhost:35535/oauth/callback'],
grant_types: ['authorization_code'],
response_types: ['code'],
scope: 'read:fitness',
client_name: 'Duplicate Test Client'
};
// First registration
const response1 = await fetch(registrationEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(registrationRequest)
});
expect(response1.ok).toBe(true);
// Second registration with same client_id
const response2 = await fetch(registrationEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
...registrationRequest,
client_secret: 'test_secret_2' // Different secret
})
});
// Server may accept (update) or reject (conflict)
// Either behavior is acceptable as long as it's consistent
expect(response2.status).toBeLessThan(500);
}, 30000);
});
describe('OAuth 2.0 Flow - Client Info Persistence', () => {
const testClientInfoPath = path.join(os.tmpdir(), `test-client-info-${Date.now()}.json`);
afterEach(() => {
// Clean up test client info file
if (fs.existsSync(testClientInfoPath)) {
fs.unlinkSync(testClientInfoPath);
}
});
test('should persist client info to disk after registration', () => {
const clientInfo = {
client_id: 'test_client_persist',
client_secret: 'test_secret_persist',
redirect_uris: ['http://localhost:35535/oauth/callback']
};
// Simulate bridge.ts saveClientInfo behavior
fs.writeFileSync(testClientInfoPath, JSON.stringify(clientInfo, null, 2));
expect(fs.existsSync(testClientInfoPath)).toBe(true);
const loaded = JSON.parse(fs.readFileSync(testClientInfoPath, 'utf-8'));
expect(loaded.client_id).toBe(clientInfo.client_id);
expect(loaded.client_secret).toBe(clientInfo.client_secret);
});
test('should load existing client info from disk on startup', () => {
const clientInfo = {
client_id: 'existing_client',
client_secret: 'existing_secret',
redirect_uris: ['http://localhost:35535/oauth/callback']
};
// Pre-create client info file
fs.writeFileSync(testClientInfoPath, JSON.stringify(clientInfo, null, 2));
// Simulate bridge.ts loadClientInfo behavior
const loaded = JSON.parse(fs.readFileSync(testClientInfoPath, 'utf-8'));
expect(loaded.client_id).toBe('existing_client');
expect(loaded.client_secret).toBe('existing_secret');
});
test('should handle missing client info file gracefully', () => {
const nonExistentPath = path.join(os.tmpdir(), 'non-existent-client-info.json');
// Simulate bridge.ts loadClientInfo with no file
let clientInfo = undefined;
try {
if (fs.existsSync(nonExistentPath)) {
clientInfo = JSON.parse(fs.readFileSync(nonExistentPath, 'utf-8'));
}
} catch (error) {
// Expected - file doesn't exist
}
expect(clientInfo).toBeUndefined();
});
test('should update client info when server assigns new client_id', () => {
const originalClientInfo = {
client_id: 'temp_client_123',
client_secret: 'temp_secret_456'
};
fs.writeFileSync(testClientInfoPath, JSON.stringify(originalClientInfo, null, 2));
// Simulate server returning different client_id
const serverResponse = {
client_id: 'server_assigned_client_789',
client_secret: 'server_assigned_secret_012'
};
// Bridge should update to server-assigned values
fs.writeFileSync(testClientInfoPath, JSON.stringify(serverResponse, null, 2));
const updated = JSON.parse(fs.readFileSync(testClientInfoPath, 'utf-8'));
expect(updated.client_id).toBe('server_assigned_client_789');
expect(updated.client_secret).toBe('server_assigned_secret_012');
});
});
describe('OAuth 2.0 Flow - Authorization URL Generation', () => {
test('should generate valid authorization URL with PKCE', () => {
const serverUrl = 'http://localhost:8080';
const clientId = 'test_client_123';
const redirectUri = 'http://localhost:35535/oauth/callback';
const state = 'random_state_value_123';
const codeChallenge = 'test_code_challenge_xyz';
const authUrl = new URL(`${serverUrl}/oauth2/authorize`);
authUrl.searchParams.set('client_id', clientId);
authUrl.searchParams.set('redirect_uri', redirectUri);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', 'read:fitness write:fitness');
authUrl.searchParams.set('state', state);
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
expect(authUrl.toString()).toContain('client_id=test_client_123');
expect(authUrl.toString()).toContain('code_challenge=test_code_challenge_xyz');
expect(authUrl.toString()).toContain('code_challenge_method=S256');
expect(authUrl.toString()).toContain('state=random_state_value_123');
});
test('should include all required OAuth 2.0 authorization parameters', () => {
const authUrl = new URL('http://localhost:8080/oauth2/authorize');
authUrl.searchParams.set('client_id', 'client');
authUrl.searchParams.set('redirect_uri', 'http://localhost:35535/oauth/callback');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', 'read:fitness');
authUrl.searchParams.set('state', 'state123');
expect(authUrl.searchParams.has('client_id')).toBe(true);
expect(authUrl.searchParams.has('redirect_uri')).toBe(true);
expect(authUrl.searchParams.has('response_type')).toBe(true);
expect(authUrl.searchParams.has('state')).toBe(true);
});
test('should generate unique state parameter for CSRF protection', () => {
const crypto = require('crypto');
// Use crypto.randomBytes to match actual implementation (32 chars)
const state1 = crypto.randomBytes(16).toString('hex');
const state2 = crypto.randomBytes(16).toString('hex');
expect(state1).not.toBe(state2);
expect(state1.length).toBeGreaterThan(20);
expect(state2.length).toBeGreaterThan(20);
});
});