CI = &get_instance(); $this->CI->load->helper('url'); $this->CI->load->library('desk_moloni/tokenmanager'); $this->token_manager = $this->CI->tokenmanager; // Set redirect URI $this->redirect_uri = admin_url('desk_moloni/oauth_callback'); // Load saved configuration $this->load_configuration(); } /** * Load OAuth configuration from database */ private function load_configuration() { $this->client_id = get_option('desk_moloni_client_id'); $this->client_secret = get_option('desk_moloni_client_secret'); $this->request_timeout = (int)get_option('desk_moloni_oauth_timeout', 30); $this->use_pkce = (bool)get_option('desk_moloni_use_pkce', true); } /** * Configure OAuth credentials * * @param string $client_id OAuth client ID * @param string $client_secret OAuth client secret * @param array $options Additional configuration options * @return bool Configuration success */ public function configure($client_id, $client_secret, $options = []) { // Validate inputs if (empty($client_id) || empty($client_secret)) { throw new InvalidArgumentException('Client ID and Client Secret are required'); } $this->client_id = $client_id; $this->client_secret = $client_secret; // Process options if (isset($options['redirect_uri'])) { $this->redirect_uri = $options['redirect_uri']; } if (isset($options['timeout'])) { $this->request_timeout = (int)$options['timeout']; } if (isset($options['use_pkce'])) { $this->use_pkce = (bool)$options['use_pkce']; } // Save to database update_option('desk_moloni_client_id', $client_id); update_option('desk_moloni_client_secret', $client_secret); update_option('desk_moloni_oauth_timeout', $this->request_timeout); update_option('desk_moloni_use_pkce', $this->use_pkce); log_activity('Desk-Moloni: OAuth configuration updated'); return true; } /** * Check if OAuth is properly configured * * @return bool Configuration status */ public function is_configured() { return !empty($this->client_id) && !empty($this->client_secret); } /** * Check if OAuth is connected (has valid token) * * @return bool Connection status */ public function is_connected() { if (!$this->is_configured()) { return false; } // Check token validity if (!$this->token_manager->are_tokens_valid()) { // Try to refresh if we have a refresh token return $this->refresh_access_token(); } return true; } /** * Generate authorization URL for OAuth flow * * @param string|null $state Optional state parameter for CSRF protection * @param array $scopes OAuth scopes to request * @return string Authorization URL */ public function get_authorization_url($state = null, $scopes = []) { if (!$this->is_configured()) { throw new Exception('OAuth not configured'); } // Generate PKCE parameters if enabled if ($this->use_pkce) { $this->generate_pkce_parameters(); } // Default state if not provided if ($state === null) { $state = bin2hex(random_bytes(16)); $this->CI->session->set_userdata('desk_moloni_oauth_state', $state); } $params = [ 'response_type' => 'code', 'client_id' => $this->client_id, 'redirect_uri' => $this->redirect_uri, 'state' => $state, 'scope' => empty($scopes) ? 'read write' : implode(' ', $scopes) ]; // Add PKCE challenge if enabled if ($this->use_pkce && $this->code_challenge) { $params['code_challenge'] = $this->code_challenge; $params['code_challenge_method'] = 'S256'; // Store code verifier in session $this->CI->session->set_userdata('desk_moloni_code_verifier', $this->code_verifier); } $url = $this->auth_url . '?' . http_build_query($params); log_activity('Desk-Moloni: Authorization URL generated'); return $url; } /** * Handle OAuth callback and exchange code for tokens * * @param string $code Authorization code * @param string|null $state State parameter for verification * @return bool Exchange success */ public function handle_callback($code, $state = null) { if (!$this->is_configured()) { throw new Exception('OAuth not configured'); } // Validate state parameter for CSRF protection if ($state !== null) { $stored_state = $this->CI->session->userdata('desk_moloni_oauth_state'); if ($state !== $stored_state) { throw new Exception('Invalid state parameter - possible CSRF attack'); } $this->CI->session->unset_userdata('desk_moloni_oauth_state'); } // Prepare token exchange data $data = [ 'grant_type' => 'authorization_code', 'client_id' => $this->client_id, 'client_secret' => $this->client_secret, 'redirect_uri' => $this->redirect_uri, 'code' => $code ]; // Add PKCE verifier if used if ($this->use_pkce) { $code_verifier = $this->CI->session->userdata('desk_moloni_code_verifier'); if ($code_verifier) { $data['code_verifier'] = $code_verifier; $this->CI->session->unset_userdata('desk_moloni_code_verifier'); } } try { $response = $this->make_token_request($data); if (isset($response['access_token'])) { $success = $this->token_manager->save_tokens($response); if ($success) { log_activity('Desk-Moloni: OAuth tokens received and saved'); return true; } } throw new Exception('Token exchange failed: Invalid response format'); } catch (Exception $e) { log_activity('Desk-Moloni: OAuth callback failed - ' . $e->getMessage()); throw new Exception('OAuth callback failed: ' . $e->getMessage()); } } /** * Refresh access token using refresh token * * @return bool Refresh success */ public function refresh_access_token() { $refresh_token = $this->token_manager->get_refresh_token(); if (empty($refresh_token)) { return false; } $data = [ 'grant_type' => 'refresh_token', 'client_id' => $this->client_id, 'client_secret' => $this->client_secret, 'refresh_token' => $refresh_token ]; try { $response = $this->make_token_request($data); if (isset($response['access_token'])) { $success = $this->token_manager->save_tokens($response); if ($success) { log_activity('Desk-Moloni: Access token refreshed successfully'); return true; } } return false; } catch (Exception $e) { log_activity('Desk-Moloni: Token refresh failed - ' . $e->getMessage()); // Clear invalid tokens $this->token_manager->clear_tokens(); return false; } } /** * Get current access token * * @return string Access token * @throws Exception If not connected */ public function get_access_token() { if (!$this->is_connected()) { throw new Exception('OAuth not connected'); } return $this->token_manager->get_access_token(); } /** * Revoke access and clear tokens * * @return bool Revocation success */ public function revoke_access() { try { // Try to revoke token via API if possible $access_token = $this->token_manager->get_access_token(); if ($access_token) { // Moloni doesn't currently support token revocation endpoint // So we just clear local tokens log_activity('Desk-Moloni: OAuth access revoked (local clear only)'); } return $this->token_manager->clear_tokens(); } catch (Exception $e) { log_activity('Desk-Moloni: Token revocation failed - ' . $e->getMessage()); // Still try to clear local tokens return $this->token_manager->clear_tokens(); } } /** * Make token request to Moloni OAuth endpoint * * @param array $data Request data * @return array Response data * @throws Exception On request failure */ private function make_token_request($data) { // Apply rate limiting $this->enforce_oauth_rate_limit(); $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $this->token_url, CURLOPT_POST => true, CURLOPT_POSTFIELDS => http_build_query($data), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => $this->request_timeout, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_HTTPHEADER => [ 'Content-Type: application/x-www-form-urlencoded', 'Accept: application/json', 'User-Agent: Desk-Moloni/3.0 OAuth' ], CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_FOLLOWLOCATION => false, CURLOPT_MAXREDIRS => 0 ]); $response = curl_exec($ch); $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $error = curl_error($ch); curl_close($ch); if ($error) { throw new Exception("CURL Error: {$error}"); } $decoded = json_decode($response, true); if (json_last_error() !== JSON_ERROR_NONE) { throw new Exception('Invalid JSON response from OAuth endpoint'); } if ($http_code >= 400) { $error_msg = $decoded['error_description'] ?? $decoded['error'] ?? "HTTP {$http_code}"; throw new Exception("OAuth Error: {$error_msg}"); } return $decoded; } /** * Generate PKCE parameters for enhanced security */ private function generate_pkce_parameters() { // Generate code verifier (43-128 characters) $this->code_verifier = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); // Generate code challenge $this->code_challenge = rtrim(strtr(base64_encode(hash('sha256', $this->code_verifier, true)), '+/', '-_'), '='); } /** * Enforce rate limiting for OAuth requests */ private function enforce_oauth_rate_limit() { $current_time = time(); // Reset counter if new window (5 minutes for OAuth) if ($current_time - $this->oauth_window_start >= 300) { $this->oauth_window_start = $current_time; $this->oauth_request_count = 0; } // Check if we've exceeded the limit if ($this->oauth_request_count >= $this->oauth_max_requests) { $wait_time = 300 - ($current_time - $this->oauth_window_start); throw new Exception("OAuth rate limit exceeded. Please wait {$wait_time} seconds."); } $this->oauth_request_count++; } /** * Get comprehensive OAuth status * * @return array OAuth status information */ public function get_status() { $token_status = $this->token_manager->get_token_status(); return [ 'configured' => $this->is_configured(), 'connected' => $this->is_connected(), 'client_id' => $this->client_id ? substr($this->client_id, 0, 8) . '...' : null, 'redirect_uri' => $this->redirect_uri, 'use_pkce' => $this->use_pkce, 'request_timeout' => $this->request_timeout, 'rate_limit' => [ 'max_requests' => $this->oauth_max_requests, 'current_count' => $this->oauth_request_count, 'window_start' => $this->oauth_window_start ], 'tokens' => $token_status ]; } /** * Test OAuth configuration * * @return array Test results */ public function test_configuration() { $issues = []; // Check basic configuration if (!$this->is_configured()) { $issues[] = 'OAuth not configured - missing client credentials'; } // Validate URLs if (!filter_var($this->redirect_uri, FILTER_VALIDATE_URL)) { $issues[] = 'Invalid redirect URI'; } // Check SSL/TLS support if (!function_exists('curl_init')) { $issues[] = 'cURL extension not available'; } // Test connectivity to OAuth endpoints try { $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $this->auth_url, CURLOPT_NOBODY => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2 ]); $result = curl_exec($ch); $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($result === false || $http_code >= 500) { $issues[] = 'Cannot reach Moloni OAuth endpoints'; } } catch (Exception $e) { $issues[] = 'OAuth endpoint connectivity test failed: ' . $e->getMessage(); } // Test token manager $encryption_validation = $this->token_manager->validate_encryption(); if (!$encryption_validation['is_valid']) { $issues = array_merge($issues, $encryption_validation['issues']); } return [ 'is_valid' => empty($issues), 'issues' => $issues, 'endpoints' => [ 'auth_url' => $this->auth_url, 'token_url' => $this->token_url ], 'encryption' => $encryption_validation ]; } /** * Force token refresh (for testing or manual refresh) * * @return bool Refresh success */ public function force_token_refresh() { if (!$this->is_configured()) { throw new Exception('OAuth not configured'); } $refresh_token = $this->token_manager->get_refresh_token(); if (empty($refresh_token)) { throw new Exception('No refresh token available'); } return $this->refresh_access_token(); } /** * Get token expiration info * * @return array Token expiration details */ public function get_token_expiration_info() { $expires_at = $this->token_manager->get_token_expiration(); if (!$expires_at) { return [ 'has_token' => false, 'expires_at' => null, 'expires_in' => null, 'is_expired' => true, 'expires_soon' => false ]; } $now = time(); $expires_in = $expires_at - $now; return [ 'has_token' => true, 'expires_at' => date('Y-m-d H:i:s', $expires_at), 'expires_at_timestamp' => $expires_at, 'expires_in' => max(0, $expires_in), 'expires_in_minutes' => max(0, round($expires_in / 60)), 'is_expired' => $expires_in <= 0, 'expires_soon' => $expires_in <= 300 // 5 minutes ]; } /** * Validate OAuth state parameter * * @param string $state State parameter to validate * @return bool Valid state */ public function validate_state($state) { $stored_state = $this->CI->session->userdata('desk_moloni_oauth_state'); if (empty($stored_state) || $state !== $stored_state) { return false; } // Clear used state $this->CI->session->unset_userdata('desk_moloni_oauth_state'); return true; } /** * Security audit for OAuth implementation * * @return array Security audit results */ public function security_audit() { $audit = [ 'overall_score' => 0, 'max_score' => 100, 'checks' => [], 'recommendations' => [] ]; $score = 0; // PKCE usage (20 points) if ($this->use_pkce) { $audit['checks']['pkce'] = ['status' => 'pass', 'points' => 20]; $score += 20; } else { $audit['checks']['pkce'] = ['status' => 'fail', 'points' => 0]; $audit['recommendations'][] = 'Enable PKCE for enhanced security'; } // HTTPS usage (20 points) $uses_https = strpos($this->redirect_uri, 'https://') === 0 || $this->is_localhost(); if ($uses_https) { $audit['checks']['https'] = ['status' => 'pass', 'points' => 20]; $score += 20; } else { $audit['checks']['https'] = ['status' => 'fail', 'points' => 0]; $audit['recommendations'][] = 'Use HTTPS for OAuth redirect URI in production'; } // Token encryption (20 points) $encryption_valid = $this->token_manager->validate_encryption()['is_valid']; if ($encryption_valid) { $audit['checks']['token_encryption'] = ['status' => 'pass', 'points' => 20]; $score += 20; } else { $audit['checks']['token_encryption'] = ['status' => 'fail', 'points' => 0]; $audit['recommendations'][] = 'Fix token encryption issues'; } // Rate limiting (15 points) $audit['checks']['rate_limiting'] = ['status' => 'pass', 'points' => 15]; $score += 15; // Session security (15 points) $secure_sessions = ini_get('session.cookie_secure') === '1' || $this->is_localhost(); if ($secure_sessions) { $audit['checks']['session_security'] = ['status' => 'pass', 'points' => 15]; $score += 15; } else { $audit['checks']['session_security'] = ['status' => 'fail', 'points' => 0]; $audit['recommendations'][] = 'Enable secure session cookies'; } // Error handling (10 points) $audit['checks']['error_handling'] = ['status' => 'pass', 'points' => 10]; $score += 10; $audit['overall_score'] = $score; $audit['grade'] = $this->calculate_security_grade($score); return $audit; } /** * Check if running on localhost * * @return bool True if localhost */ private function is_localhost() { $server_name = $_SERVER['SERVER_NAME'] ?? ''; return in_array($server_name, ['localhost', '127.0.0.1', '::1']) || strpos($server_name, '.local') !== false; } /** * Calculate security grade from score * * @param int $score Security score * @return string Grade (A, B, C, D, F) */ private function calculate_security_grade($score) { if ($score >= 90) return 'A'; if ($score >= 80) return 'B'; if ($score >= 70) return 'C'; if ($score >= 60) return 'D'; return 'F'; } }