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.
// Generate a unique consent token tied to this specific authorization request.
$consent_token = wp_generate_password( 32, false );
set_transient( 'ab_consent_' . $consent_token, $oauth_params, 300 ); // 5 minutes.
// Include consent screen template.
// Template receives: $client_id, $redirect_uri, $response_type, $code_challenge,
// $code_challenge_method, $state, $scope, $consent_token.
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 request-specific nonce tied to the consent token.
$consent_token = sanitize_text_field( $request->get_param( 'consent_token' ) );
$nonce = $request->get_param( 'oauth_nonce' );
if ( empty( $consent_token ) ) {
$logger->log(
'nonce_verification_failed',
Abilities_Bridge_OAuth_Logger::LEVEL_WARNING,
array(
'user_id' => get_current_user_id(),
'reason' => 'missing_consent_token',
)
);
wp_die(
esc_html__( 'Security verification failed. Missing consent token. Please try authorizing again.', 'abilities-bridge' ) .
'
' .
esc_html__( 'Return to Settings', 'abilities-bridge' ) . '',
esc_html__( 'Security Error', 'abilities-bridge' ),
array( 'response' => 403 )
);
}
$nonce_valid = wp_verify_nonce( $nonce, 'abilities_bridge_oauth_consent_' . $consent_token );
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 ),
'consent_token' => ! empty( $consent_token ),
)
);
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 )
);
}
// Retrieve and validate the original OAuth params from the consent transient.
$oauth_params = get_transient( 'ab_consent_' . $consent_token );
if ( ! $oauth_params || ! is_array( $oauth_params ) ) {
$logger->log(
'consent_transient_expired',
Abilities_Bridge_OAuth_Logger::LEVEL_WARNING,
array(
'user_id' => get_current_user_id(),
'consent_token' => ! empty( $consent_token ),
)
);
wp_die(
esc_html__( 'Authorization request has expired. Please start the authorization process again.', 'abilities-bridge' ) .
'
' .
esc_html__( 'Return to Settings', 'abilities-bridge' ) . '',
esc_html__( 'Authorization Expired', 'abilities-bridge' ),
array( 'response' => 400 )
);
}
delete_transient( 'ab_consent_' . $consent_token );
// Use OAuth params from the server-side transient (not from POST data).
$client_id = $oauth_params['client_id'] ?? '';
$redirect_uri = $oauth_params['redirect_uri'] ?? '';
$code_challenge = $oauth_params['code_challenge'] ?? '';
$code_challenge_method = $oauth_params['code_challenge_method'] ?? '';
$state = $oauth_params['state'] ?? '';
$scope = $oauth_params['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(),
'source' => 'consent_transient',
)
);
// 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' )
);
}
$client = Abilities_Bridge_OAuth_Client_Manager::get_client( $client_id );
if ( ! $client ) {
return new WP_Error(
'invalid_client',
__( 'Invalid client_id. Use generated MCP client credentials from the Abilities Bridge settings page.', 'abilities-bridge' )
);
}
// 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, $client_id ) ) {
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 ( 'S256' !== $code_challenge_method ) {
return new WP_Error(
'invalid_request',
__( 'Invalid code_challenge_method. Supported method: S256', '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 ) {
$parsed_url = wp_parse_url( $url );
$host = isset( $parsed_url['host'] ) ? $parsed_url['host'] : '';
if ( ! empty( $host ) ) {
add_filter(
'allowed_redirect_hosts',
function ( $hosts ) use ( $host ) {
$hosts[] = $host;
return array_values( array_unique( $hosts ) );
}
);
}
wp_safe_redirect( $url );
}
/**
* Validate redirect URI
*
* @param string $redirect_uri Redirect URI to validate.
* @param string $client_id Client ID for profile-aware validation.
* @return bool True if valid
*/
private static function is_valid_redirect_uri( $redirect_uri, $client_id = '' ) {
if ( ! filter_var( $redirect_uri, FILTER_VALIDATE_URL ) ) {
return false;
}
$parsed = wp_parse_url( $redirect_uri );
$host = isset( $parsed['host'] ) ? $parsed['host'] : '';
if ( empty( $host ) ) {
return false;
}
if ( 'https' !== $parsed['scheme'] ) {
if ( ! in_array( $host, array( 'localhost', '127.0.0.1' ), true ) ) {
return false;
}
}
$client = ! empty( $client_id ) ? Abilities_Bridge_OAuth_Client_Manager::get_client( $client_id ) : null;
$profile = $client && ! empty( $client['profile'] ) ? Abilities_Bridge_OAuth_Client_Manager::normalize_profile( $client['profile'] ) : Abilities_Bridge_OAuth_Client_Manager::PROFILE_ANTHROPIC;
$allowed_hosts = array(
'localhost',
'127.0.0.1',
'claude.ai',
'chat.openai.com',
'chatgpt.com',
);
$allowed_hosts = apply_filters( 'abilities_bridge_oauth_allowed_redirect_hosts', array_values( array_unique( $allowed_hosts ) ), $client_id, $profile );
foreach ( $allowed_hosts as $allowed_host ) {
if ( $host === $allowed_host || strpos( $host, '.' . $allowed_host ) !== false ) {
return true;
}
}
return false;
}
}