433 lines
13 KiB
PHP
433 lines
13 KiB
PHP
<?php
|
|
/**
|
|
* OAuth 2.0 Token REST Controller.
|
|
*
|
|
* @package Activitypub
|
|
*/
|
|
|
|
namespace Activitypub\Rest\OAuth;
|
|
|
|
use Activitypub\OAuth\Authorization_Code;
|
|
use Activitypub\OAuth\Client;
|
|
use Activitypub\OAuth\Scope;
|
|
use Activitypub\OAuth\Server as OAuth_Server;
|
|
use Activitypub\OAuth\Token;
|
|
|
|
use function Activitypub\get_client_ip;
|
|
|
|
/**
|
|
* Token_Controller class for handling OAuth 2.0 token endpoints.
|
|
*
|
|
* Implements:
|
|
* - Token endpoint (POST /oauth/token)
|
|
* - Revocation endpoint (POST /oauth/revoke)
|
|
* - Token introspection endpoint (POST /oauth/introspect)
|
|
*
|
|
* @since 8.1.0
|
|
*/
|
|
class Token_Controller extends \WP_REST_Controller {
|
|
/**
|
|
* The namespace of this controller's route.
|
|
*
|
|
* @var string
|
|
*/
|
|
protected $namespace = ACTIVITYPUB_REST_NAMESPACE;
|
|
|
|
/**
|
|
* The base of this controller's route.
|
|
*
|
|
* @var string
|
|
*/
|
|
protected $rest_base = 'oauth';
|
|
|
|
/**
|
|
* Register routes.
|
|
*/
|
|
public function register_routes() {
|
|
// Token endpoint.
|
|
\register_rest_route(
|
|
$this->namespace,
|
|
'/' . $this->rest_base . '/token',
|
|
array(
|
|
array(
|
|
'methods' => \WP_REST_Server::CREATABLE,
|
|
'callback' => array( $this, 'token' ),
|
|
'permission_callback' => '__return_true',
|
|
'args' => array(
|
|
'grant_type' => array(
|
|
'description' => 'The grant type.',
|
|
'type' => 'string',
|
|
'required' => true,
|
|
'enum' => array( 'authorization_code', 'refresh_token' ),
|
|
),
|
|
'client_id' => array(
|
|
'description' => 'The OAuth client identifier.',
|
|
'type' => 'string',
|
|
),
|
|
'client_secret' => array(
|
|
'description' => 'The OAuth client secret (for confidential clients).',
|
|
'type' => 'string',
|
|
),
|
|
'code' => array(
|
|
'description' => 'The authorization code (for authorization_code grant).',
|
|
'type' => 'string',
|
|
),
|
|
'redirect_uri' => array(
|
|
'description' => 'The redirect URI (must match original for authorization_code grant). Supports custom URI schemes for native apps.',
|
|
'type' => 'string',
|
|
),
|
|
'code_verifier' => array(
|
|
'description' => 'PKCE code verifier.',
|
|
'type' => 'string',
|
|
),
|
|
'refresh_token' => array(
|
|
'description' => 'The refresh token (for refresh_token grant).',
|
|
'type' => 'string',
|
|
),
|
|
'scope' => array(
|
|
'description' => 'Space-separated list of requested scopes.',
|
|
'type' => 'string',
|
|
),
|
|
),
|
|
),
|
|
)
|
|
);
|
|
|
|
// Revocation endpoint (RFC 7009 — requires authentication).
|
|
\register_rest_route(
|
|
$this->namespace,
|
|
'/' . $this->rest_base . '/revoke',
|
|
array(
|
|
array(
|
|
'methods' => \WP_REST_Server::CREATABLE,
|
|
'callback' => array( $this, 'revoke' ),
|
|
'permission_callback' => array( $this, 'revoke_permissions_check' ),
|
|
'args' => array(
|
|
'token' => array(
|
|
'description' => 'The token to revoke.',
|
|
'type' => 'string',
|
|
'required' => true,
|
|
),
|
|
'token_type_hint' => array(
|
|
'description' => 'Hint about the token type.',
|
|
'type' => 'string',
|
|
'enum' => array( 'access_token', 'refresh_token' ),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
);
|
|
|
|
// Token introspection endpoint (RFC 7662).
|
|
\register_rest_route(
|
|
$this->namespace,
|
|
'/' . $this->rest_base . '/introspect',
|
|
array(
|
|
array(
|
|
'methods' => \WP_REST_Server::CREATABLE,
|
|
'callback' => array( $this, 'introspect' ),
|
|
'permission_callback' => array( $this, 'introspect_permissions_check' ),
|
|
'args' => array(
|
|
'token' => array(
|
|
'description' => 'The token to introspect.',
|
|
'type' => 'string',
|
|
'required' => true,
|
|
),
|
|
'token_type_hint' => array(
|
|
'description' => 'Hint about the token type.',
|
|
'type' => 'string',
|
|
'enum' => array( 'access_token', 'refresh_token' ),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Handle token request (POST /oauth/token).
|
|
*
|
|
* @param \WP_REST_Request $request The request object.
|
|
* @return \WP_REST_Response|\WP_Error
|
|
*/
|
|
public function token( \WP_REST_Request $request ) {
|
|
// Rate-limit token requests to prevent brute-force attacks (max 20 per minute per IP).
|
|
$ip = get_client_ip();
|
|
if ( '' === $ip ) {
|
|
return $this->token_error( 'rate_limited', 'Too many token requests. Please try again later.', 429 );
|
|
}
|
|
$transient_key = 'ap_oauth_tok_' . \md5( $ip );
|
|
$count = (int) \get_transient( $transient_key );
|
|
|
|
if ( $count >= 20 ) {
|
|
return $this->token_error( 'rate_limited', 'Too many token requests. Please try again later.', 429 );
|
|
}
|
|
|
|
\set_transient( $transient_key, $count + 1, MINUTE_IN_SECONDS );
|
|
|
|
$grant_type = $request->get_param( 'grant_type' );
|
|
|
|
/*
|
|
* Extract client credentials from either:
|
|
* - client_secret_basic: HTTP Basic Auth header (RFC 6749 Section 2.3.1)
|
|
* - client_secret_post: POST body parameters
|
|
*/
|
|
$client_id = null;
|
|
$client_secret = null;
|
|
$auth_header = $request->get_header( 'Authorization' );
|
|
|
|
if ( $auth_header && 0 === \strpos( $auth_header, 'Basic ' ) ) {
|
|
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Required by OAuth spec.
|
|
$decoded = \base64_decode( \substr( $auth_header, 6 ), true );
|
|
if ( $decoded && false !== \strpos( $decoded, ':' ) ) {
|
|
list( $client_id, $client_secret ) = \explode( ':', $decoded, 2 );
|
|
$client_id = \urldecode( $client_id );
|
|
$client_secret = \urldecode( $client_secret );
|
|
}
|
|
}
|
|
|
|
// Fall back to POST body parameters (client_secret_post).
|
|
if ( ! $client_id ) {
|
|
$client_id = $request->get_param( 'client_id' );
|
|
$client_secret = $request->get_param( 'client_secret' );
|
|
}
|
|
|
|
// Validate client.
|
|
$client = Client::get( $client_id );
|
|
if ( \is_wp_error( $client ) ) {
|
|
return $this->token_error( 'invalid_client', 'Unknown client.' );
|
|
}
|
|
|
|
// Validate client credentials if confidential.
|
|
if ( ! $client->is_public() ) {
|
|
if ( ! Client::validate( $client_id, $client_secret ) ) {
|
|
return $this->token_error( 'invalid_client', 'Invalid client credentials.' );
|
|
}
|
|
}
|
|
|
|
switch ( $grant_type ) {
|
|
case 'authorization_code':
|
|
return $this->handle_authorization_code_grant( $request, $client_id );
|
|
|
|
case 'refresh_token':
|
|
return $this->handle_refresh_token_grant( $request, $client_id );
|
|
|
|
default:
|
|
return $this->token_error( 'unsupported_grant_type', 'Grant type not supported.' );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle authorization code grant.
|
|
*
|
|
* @param \WP_REST_Request $request The request object.
|
|
* @param string $client_id The client ID.
|
|
* @return \WP_REST_Response|\WP_Error
|
|
*/
|
|
private function handle_authorization_code_grant( \WP_REST_Request $request, $client_id ) {
|
|
$code = $request->get_param( 'code' );
|
|
$redirect_uri = $request->get_param( 'redirect_uri' );
|
|
$code_verifier = $request->get_param( 'code_verifier' );
|
|
|
|
if ( empty( $code ) ) {
|
|
return $this->token_error( 'invalid_request', 'Authorization code is required.' );
|
|
}
|
|
|
|
$result = Authorization_Code::exchange( $code, $client_id, $redirect_uri, $code_verifier );
|
|
|
|
if ( \is_wp_error( $result ) ) {
|
|
return $this->token_error( 'invalid_grant', $result->get_error_message() );
|
|
}
|
|
|
|
return $this->token_response( $result );
|
|
}
|
|
|
|
/**
|
|
* Handle refresh token grant.
|
|
*
|
|
* @param \WP_REST_Request $request The request object.
|
|
* @param string $client_id The client ID.
|
|
* @return \WP_REST_Response|\WP_Error
|
|
*/
|
|
private function handle_refresh_token_grant( \WP_REST_Request $request, $client_id ) {
|
|
$refresh_token = $request->get_param( 'refresh_token' );
|
|
|
|
if ( empty( $refresh_token ) ) {
|
|
return $this->token_error( 'invalid_request', 'Refresh token is required.' );
|
|
}
|
|
|
|
$result = Token::refresh( $refresh_token, $client_id );
|
|
|
|
if ( \is_wp_error( $result ) ) {
|
|
return $this->token_error( 'invalid_grant', $result->get_error_message() );
|
|
}
|
|
|
|
return $this->token_response( $result );
|
|
}
|
|
|
|
/**
|
|
* Handle token revocation (POST /oauth/revoke).
|
|
*
|
|
* @param \WP_REST_Request $request The request object.
|
|
* @return \WP_REST_Response
|
|
*/
|
|
public function revoke( \WP_REST_Request $request ) {
|
|
$token = $request->get_param( 'token' );
|
|
|
|
if ( \current_user_can( 'manage_options' ) ) {
|
|
// Site admins may revoke any token. Null-null disables the ownership check.
|
|
Token::revoke( $token );
|
|
} else {
|
|
/*
|
|
* RFC 7009 §2.1: the server must verify the token was issued to
|
|
* the requesting client. When the caller authenticated with a
|
|
* bearer token we know the calling client, so require a client
|
|
* match and ignore the user — otherwise a low-trust client
|
|
* could revoke tokens the user had granted to a different
|
|
* client. For pure cookie-authenticated callers there is no
|
|
* client context, so user match is the only available check.
|
|
*/
|
|
$caller_token = OAuth_Server::get_current_token();
|
|
if ( $caller_token ) {
|
|
Token::revoke( $token, null, $caller_token->get_client_id() );
|
|
} else {
|
|
Token::revoke( $token, \get_current_user_id(), null );
|
|
}
|
|
}
|
|
|
|
// Per RFC 7009, always return 200 even if the token doesn't exist or was not owned.
|
|
return new \WP_REST_Response( null, 200 );
|
|
}
|
|
|
|
/**
|
|
* Handle token introspection (POST /oauth/introspect).
|
|
*
|
|
* Implements RFC 7662 Token Introspection.
|
|
*
|
|
* @param \WP_REST_Request $request The request object.
|
|
* @return \WP_REST_Response
|
|
*/
|
|
public function introspect( \WP_REST_Request $request ) {
|
|
$token = $request->get_param( 'token' );
|
|
|
|
// Introspect the token.
|
|
$response = Token::introspect( $token );
|
|
|
|
// Scope introspection to same client: non-admin users can only
|
|
// introspect tokens belonging to the same client as their own.
|
|
if ( $response['active'] && ! \current_user_can( 'manage_options' ) ) {
|
|
$current_token = OAuth_Server::get_current_token();
|
|
if ( $current_token && $current_token->get_client_id() !== $response['client_id'] ) {
|
|
$response = array( 'active' => false );
|
|
}
|
|
}
|
|
|
|
return new \WP_REST_Response( $response, 200 );
|
|
}
|
|
|
|
/**
|
|
* Permission check for token revocation.
|
|
*
|
|
* Per RFC 7009, the revocation endpoint must be protected.
|
|
* Requires either a logged-in user or a valid Bearer token.
|
|
*
|
|
* @return bool|\WP_Error True if allowed, error otherwise.
|
|
*/
|
|
public function revoke_permissions_check() {
|
|
if ( \is_user_logged_in() ) {
|
|
return true;
|
|
}
|
|
|
|
$token = OAuth_Server::get_bearer_token();
|
|
|
|
if ( $token ) {
|
|
$validated = Token::validate( $token );
|
|
|
|
if ( ! \is_wp_error( $validated ) ) {
|
|
\wp_set_current_user( $validated->get_user_id() );
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return new \WP_Error(
|
|
'activitypub_unauthorized',
|
|
\__( 'Authentication required.', 'activitypub' ),
|
|
array( 'status' => 401 )
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Permission check for token introspection.
|
|
*
|
|
* Per RFC 7662, the introspection endpoint must be protected.
|
|
*
|
|
* @return bool|\WP_Error True if allowed, error otherwise.
|
|
*/
|
|
public function introspect_permissions_check() {
|
|
if ( \is_user_logged_in() ) {
|
|
return true;
|
|
}
|
|
|
|
// Support Bearer token auth for public OAuth clients.
|
|
$token = OAuth_Server::get_bearer_token();
|
|
|
|
if ( $token ) {
|
|
$validated = Token::validate( $token );
|
|
|
|
if ( ! \is_wp_error( $validated ) ) {
|
|
\wp_set_current_user( $validated->get_user_id() );
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return new \WP_Error(
|
|
'activitypub_unauthorized',
|
|
\__( 'Authentication required.', 'activitypub' ),
|
|
array( 'status' => 401 )
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Create a token error response.
|
|
*
|
|
* @param string $error Error code.
|
|
* @param string $error_description Error description.
|
|
* @param int $status Optional. HTTP status code. Defaults to 400 per RFC 6749 §5.2;
|
|
* callers should pass 429 for rate-limit responses (RFC 6585).
|
|
* @return \WP_REST_Response
|
|
*/
|
|
private function token_error( $error, $error_description, $status = 400 ) {
|
|
return new \WP_REST_Response(
|
|
array(
|
|
'error' => $error,
|
|
'error_description' => $error_description,
|
|
),
|
|
$status,
|
|
array(
|
|
'Content-Type' => 'application/json',
|
|
// RFC 6749 §5.1 requires the same no-cache headers on error responses as on success responses.
|
|
'Cache-Control' => 'no-store',
|
|
'Pragma' => 'no-cache',
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Create a token success response.
|
|
*
|
|
* @param array $token_data Token data.
|
|
* @return \WP_REST_Response
|
|
*/
|
|
private function token_response( $token_data ) {
|
|
return new \WP_REST_Response(
|
|
$token_data,
|
|
200,
|
|
array(
|
|
'Content-Type' => 'application/json',
|
|
'Cache-Control' => 'no-store',
|
|
'Pragma' => 'no-cache',
|
|
)
|
|
);
|
|
}
|
|
}
|