403 ) ); } // Apply rate limiting. $rate_limiter = new Abilities_Bridge_OAuth_Rate_Limiter(); $rate_check = $rate_limiter->check_rate_limit( 'authorize' ); if ( is_wp_error( $rate_check ) ) { wp_die( esc_html( $rate_check->get_error_message() ), esc_html__( 'Rate Limit Exceeded', 'abilities-bridge' ), array( 'response' => 429 ) ); } // Get transient key from URL using filter_input (nonce verified below). $transient_key = filter_input( INPUT_GET, 'key', FILTER_SANITIZE_FULL_SPECIAL_CHARS ); $transient_key = ! empty( $transient_key ) ? sanitize_text_field( $transient_key ) : ''; if ( empty( $transient_key ) ) { wp_die( esc_html__( 'Invalid authorization request. Missing authorization key.', 'abilities-bridge' ) . '

' . esc_html__( 'This usually happens when clicking an expired authorization link. Please start the authorization process again.', 'abilities-bridge' ) . '

' . esc_html__( 'Return to Home', 'abilities-bridge' ) . '', esc_html__( 'OAuth Authorization Error', 'abilities-bridge' ), array( 'response' => 400 ) ); } // Verify nonce for authorization page access using filter_input. $nonce = filter_input( INPUT_GET, '_wpnonce', FILTER_SANITIZE_FULL_SPECIAL_CHARS ); $nonce = ! empty( $nonce ) ? sanitize_text_field( $nonce ) : ''; if ( ! wp_verify_nonce( $nonce, 'abilities_bridge_oauth_authorize_' . $transient_key ) ) { wp_die( esc_html__( 'Security verification failed. Invalid or expired authorization token.', 'abilities-bridge' ) . '

' . esc_html__( 'Please start the authorization process again from your application.', 'abilities-bridge' ) . '

' . esc_html__( 'Return to Home', 'abilities-bridge' ) . '', esc_html__( 'OAuth Security Error', 'abilities-bridge' ), array( 'response' => 403 ) ); } // Retrieve OAuth params from transient. $oauth_params = get_transient( $transient_key ); // Delete transient immediately (one-time use for security). delete_transient( $transient_key ); if ( false === $oauth_params || ! is_array( $oauth_params ) ) { wp_die( esc_html__( 'Authorization request has expired or is invalid.', 'abilities-bridge' ) . '

' . esc_html__( 'Authorization requests expire after 10 minutes for security. Please start the authorization process again from your application.', 'abilities-bridge' ) . '

' . esc_html__( 'Return to Home', 'abilities-bridge' ) . '', esc_html__( 'OAuth Authorization Expired', 'abilities-bridge' ), array( 'response' => 400 ) ); } // Extract parameters from transient. $client_id = $oauth_params['client_id'] ?? ''; $redirect_uri = $oauth_params['redirect_uri'] ?? ''; $response_type = $oauth_params['response_type'] ?? ''; $code_challenge = $oauth_params['code_challenge'] ?? ''; $code_challenge_method = $oauth_params['code_challenge_method'] ?? ''; $state = $oauth_params['state'] ?? ''; $scope = $oauth_params['scope'] ?? ''; // Validate required parameters. $validation_error = self::validate_authorize_params( $client_id, $redirect_uri, $response_type, $code_challenge, $code_challenge_method ); if ( is_wp_error( $validation_error ) ) { // If redirect_uri is valid, redirect with error. if ( ! empty( $redirect_uri ) && self::is_valid_redirect_uri( $redirect_uri ) ) { $error_url = add_query_arg( array( 'error' => $validation_error->get_error_code(), 'error_description' => $validation_error->get_error_message(), 'state' => $state, ), $redirect_uri ); wp_safe_redirect( $error_url ); exit; } // Otherwise display error. wp_die( esc_html( $validation_error->get_error_message() ), esc_html__( 'OAuth Authorization Error', 'abilities-bridge' ), array( 'response' => 400 ) ); } // User is already authenticated (admin page requires login). // No need to check is_user_logged_in() - WordPress admin handles this. // Include consent screen template. include ABILITIES_BRIDGE_PLUGIN_DIR . 'admin/oauth-consent-screen.php'; } /** * Handle /authorize endpoint (GET request) * * Displays consent screen for user authorization. * Always redirects to admin page to properly render HTML consent screen. * - If user is NOT logged in: WordPress login intercepts, then redirects to admin page * - If user is logged in: Goes directly to admin page * * @param WP_REST_Request $request Request object. * @return void Redirects to admin page */ public static function handle_authorize_request( $request ) { // Add CORS headers for remote MCP OAuth. Abilities_Bridge_OAuth_Discovery_Handler::add_cors_headers(); // Apply rate limiting. $rate_limiter = new Abilities_Bridge_OAuth_Rate_Limiter(); $rate_check = $rate_limiter->check_rate_limit( 'authorize' ); if ( is_wp_error( $rate_check ) ) { wp_die( esc_html( $rate_check->get_error_message() ), esc_html__( 'Rate Limit Exceeded', 'abilities-bridge' ), array( 'response' => 429 ) ); } // Get OAuth parameters. $client_id = $request->get_param( 'client_id' ); $redirect_uri = $request->get_param( 'redirect_uri' ); $response_type = $request->get_param( 'response_type' ); $code_challenge = $request->get_param( 'code_challenge' ); $code_challenge_method = $request->get_param( 'code_challenge_method' ); $state = $request->get_param( 'state' ); $scope = $request->get_param( 'scope' ); // Validate required parameters. $validation_error = self::validate_authorize_params( $client_id, $redirect_uri, $response_type, $code_challenge, $code_challenge_method ); if ( is_wp_error( $validation_error ) ) { // If redirect_uri is valid, redirect with error. if ( ! empty( $redirect_uri ) && self::is_valid_redirect_uri( $redirect_uri ) ) { $error_url = add_query_arg( array( 'error' => $validation_error->get_error_code(), 'error_description' => $validation_error->get_error_message(), 'state' => $state, ), $redirect_uri ); self::safe_redirect_to_oauth_client( $error_url ); exit; } // Otherwise display error. wp_die( esc_html( $validation_error->get_error_message() ), esc_html__( 'OAuth Authorization Error', 'abilities-bridge' ), array( 'response' => 400 ) ); } // Always redirect to admin page for proper HTML rendering. // This prevents REST API from wrapping the HTML response. // Generate cryptographically secure transient key. $transient_key = 'abilities_bridge_oauth_params_' . bin2hex( random_bytes( 16 ) ); // Generate nonce for authorization page access. $oauth_nonce = wp_create_nonce( 'abilities_bridge_oauth_authorize_' . $transient_key ); // Store OAuth parameters in transient (10 minute expiry). $oauth_params = array( 'client_id' => $client_id, 'redirect_uri' => $redirect_uri, 'response_type' => $response_type, 'code_challenge' => $code_challenge, 'code_challenge_method' => $code_challenge_method, 'state' => $state, 'scope' => $scope, 'oauth_nonce' => $oauth_nonce, // Store nonce with params for verification. ); set_transient( $transient_key, $oauth_params, 600 ); // 10 minutes. // Build admin page URL with transient key and nonce. $admin_url = admin_url( 'admin.php?page=oauth-authorize&key=' . $transient_key . '&_wpnonce=' . $oauth_nonce ); // Redirect to admin page. // - If user is logged in: Goes directly to consent screen. // - If user is NOT logged in: WordPress login intercepts, then continues to consent screen. wp_safe_redirect( $admin_url ); exit; } /** * Handle /authorize endpoint (POST request) * * Processes user consent and generates authorization code. * Supports both REST API POST and admin page POST submissions. * * @param WP_REST_Request $request Request object. * @return void Redirects to redirect_uri */ public static function handle_authorize_approval( $request ) { $logger = new Abilities_Bridge_OAuth_Logger(); // Check if user is logged in first (all authenticated users can authorize). if ( ! is_user_logged_in() ) { wp_die( esc_html__( 'You must be logged in to authorize applications.', 'abilities-bridge' ), esc_html__( 'Authentication Required', 'abilities-bridge' ), array( 'response' => 401 ) ); } // Verify nonce - check if this is coming from our consent form. // Use get_param() to retrieve from HTML form POST (matches old working code). $nonce = $request->get_param( 'oauth_nonce' ); // Verify the nonce is valid. $nonce_valid = wp_verify_nonce( $nonce, 'abilities_bridge_oauth_authorize' ); if ( ! $nonce_valid ) { // Log the failure for debugging. $logger->log( 'nonce_verification_failed', Abilities_Bridge_OAuth_Logger::LEVEL_WARNING, array( 'user_id' => get_current_user_id(), 'nonce_present' => ! empty( $nonce ), 'nonce_value_length' => strlen( $nonce ), ) ); wp_die( esc_html__( 'Security verification failed. Your session may have expired. Please try authorizing again.', 'abilities-bridge' ) . '

' . esc_html__( 'Return to Settings', 'abilities-bridge' ) . '', esc_html__( 'Security Error', 'abilities-bridge' ), array( 'response' => 403 ) ); } // Get parameters using get_param() to support HTML form POST (matches old working code). $client_id = $request->get_param( 'client_id' ); $redirect_uri = $request->get_param( 'redirect_uri' ); $code_challenge = $request->get_param( 'code_challenge' ); $code_challenge_method = $request->get_param( 'code_challenge_method' ); $state = $request->get_param( 'state' ); $scope = $request->get_param( 'scope' ); $approved = $request->get_param( 'approved' ); // Debug: Log extracted parameter values. $logger->log( 'parameters_extracted', Abilities_Bridge_OAuth_Logger::LEVEL_DEBUG, array( 'client_id_length' => strlen( $client_id ), 'redirect_uri_length' => strlen( $redirect_uri ), 'has_code_challenge' => ! empty( $code_challenge ), 'code_challenge_method' => $code_challenge_method, 'approved' => $approved, 'user_id' => get_current_user_id(), ) ); // Check if user denied. if ( 'yes' !== $approved ) { $logger->log( 'authorization_denied', Abilities_Bridge_OAuth_Logger::LEVEL_INFO, array( 'client_id' => $client_id, 'user_id' => get_current_user_id(), ) ); $error_url = add_query_arg( array( 'error' => 'access_denied', 'error_description' => 'User denied authorization', 'state' => $state, ), $redirect_uri ); self::safe_redirect_to_oauth_client( $error_url ); exit; } // Generate authorization code. $code_manager = new Abilities_Bridge_OAuth_Authorization_Code(); // Debug: Log before code generation attempt. $logger->log( 'attempting_code_generation', Abilities_Bridge_OAuth_Logger::LEVEL_DEBUG, array( 'client_id' => $client_id, 'user_id' => get_current_user_id(), 'redirect_uri' => $redirect_uri, 'has_code_challenge' => ! empty( $code_challenge ), 'code_challenge_method' => $code_challenge_method, ) ); $code = $code_manager->generate_code( $client_id, get_current_user_id(), $redirect_uri, $code_challenge, $code_challenge_method, $scope, $state ); if ( is_wp_error( $code ) ) { // Enhanced error logging. $logger->log( 'code_generation_failed', Abilities_Bridge_OAuth_Logger::LEVEL_ERROR, array( 'client_id' => $client_id, 'error_code' => $code->get_error_code(), 'error_message' => $code->get_error_message(), 'error_data' => $code->get_error_data(), 'user_id' => get_current_user_id(), ) ); $error_url = add_query_arg( array( 'error' => 'server_error', 'error_description' => $code->get_error_message(), 'state' => $state, ), $redirect_uri ); self::safe_redirect_to_oauth_client( $error_url ); exit; } // Success - redirect with code. $success_url = add_query_arg( array( 'code' => $code, 'state' => $state, ), $redirect_uri ); // Debug: Log successful code generation and redirect URL. $logger->log( 'redirecting_with_code', Abilities_Bridge_OAuth_Logger::LEVEL_DEBUG, array( 'code_length' => strlen( $code ), 'redirect_uri' => $redirect_uri, 'has_state' => ! empty( $state ), 'success_url' => $success_url, 'client_id' => $client_id, ) ); $logger->log_authorization_code_generated( $client_id, get_current_user_id(), $scope ); self::safe_redirect_to_oauth_client( $success_url ); exit; } /** * Validate /authorize request parameters * * @param string $client_id Client ID. * @param string $redirect_uri Redirect URI. * @param string $response_type Response type. * @param string $code_challenge PKCE code challenge. * @param string $code_challenge_method PKCE method. * @return true|WP_Error True if valid, error otherwise */ private static function validate_authorize_params( $client_id, $redirect_uri, $response_type, $code_challenge, $code_challenge_method ) { // Validate client_id. if ( empty( $client_id ) ) { return new WP_Error( 'invalid_request', __( 'Missing client_id parameter.', 'abilities-bridge' ) ); } // For PKCE flows, allow dynamic client registration. // PKCE provides security without requiring pre-registered clients. // This is the standard for modern OAuth 2.0 implementations. if ( empty( $code_challenge ) ) { // No PKCE - require pre-registered client (legacy/testing only). $client = Abilities_Bridge_OAuth_Client_Manager::get_client( $client_id ); if ( ! $client ) { return new WP_Error( 'invalid_client', __( 'Invalid client_id. Pre-registration required for non-PKCE flows.', 'abilities-bridge' ) ); } } // If PKCE is present, allow any client_id (secure via code_challenge/code_verifier). // Validate redirect_uri. if ( empty( $redirect_uri ) ) { return new WP_Error( 'invalid_request', __( 'Missing redirect_uri parameter.', 'abilities-bridge' ) ); } if ( ! self::is_valid_redirect_uri( $redirect_uri ) ) { return new WP_Error( 'invalid_request', __( 'Invalid redirect_uri. Must be a valid HTTPS URL.', 'abilities-bridge' ) ); } // Validate response_type. if ( 'code' !== $response_type ) { return new WP_Error( 'unsupported_response_type', __( 'Only response_type=code is supported.', 'abilities-bridge' ) ); } // Validate PKCE parameters (required for security). if ( empty( $code_challenge ) ) { return new WP_Error( 'invalid_request', __( 'Missing code_challenge parameter. PKCE is required.', 'abilities-bridge' ) ); } if ( empty( $code_challenge_method ) ) { return new WP_Error( 'invalid_request', __( 'Missing code_challenge_method parameter.', 'abilities-bridge' ) ); } if ( ! in_array( $code_challenge_method, array( 'S256', 'plain' ), true ) ) { return new WP_Error( 'invalid_request', __( 'Invalid code_challenge_method. Supported methods: S256, plain', 'abilities-bridge' ) ); } return true; } /** * Safe redirect to OAuth client with allowed hosts filter * * Uses wp_safe_redirect() with a filter to allow validated OAuth redirect URIs. * This ensures WordPress coding standards compliance while supporting external redirects. * * @param string $url URL to redirect to (must be validated with is_valid_redirect_uri first). * @return void */ private static function safe_redirect_to_oauth_client( $url ) { // Add a one-time filter to allow the validated OAuth redirect hosts. add_filter( 'allowed_redirect_hosts', function ( $hosts ) { $allowed_hosts = array( 'claude.ai', 'localhost', '127.0.0.1', ); // Apply the same filter as is_valid_redirect_uri for consistency. $allowed_hosts = apply_filters( 'abilities_bridge_oauth_allowed_redirect_hosts', $allowed_hosts ); return array_merge( $hosts, $allowed_hosts ); } ); wp_safe_redirect( $url ); } /** * Validate redirect URI * * @param string $redirect_uri Redirect URI to validate. * @return bool True if valid */ private static function is_valid_redirect_uri( $redirect_uri ) { // Must be a valid URL. if ( ! filter_var( $redirect_uri, FILTER_VALIDATE_URL ) ) { return false; } $parsed = wp_parse_url( $redirect_uri ); // Must use HTTPS (except localhost for development). if ( 'https' !== $parsed['scheme'] ) { // Allow HTTP for localhost during development. if ( ! isset( $parsed['host'] ) || ! in_array( $parsed['host'], array( 'localhost', '127.0.0.1' ), true ) ) { return false; } } // Check against whitelist (Claude.ai and localhost). $allowed_hosts = array( 'claude.ai', 'localhost', '127.0.0.1', ); // Allow custom redirect URIs via filter. $allowed_hosts = apply_filters( 'abilities_bridge_oauth_allowed_redirect_hosts', $allowed_hosts ); foreach ( $allowed_hosts as $allowed_host ) { if ( isset( $parsed['host'] ) && ( $parsed['host'] === $allowed_host || strpos( $parsed['host'], '.' . $allowed_host ) !== false ) ) { return true; } } return false; } }