Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
}
],
"require": {
"composer/installers": "~1.0",
"composer/installers": "^1 || ^2",
"php": ">=7.4"
},
"require-dev": {
Expand Down
126 changes: 115 additions & 11 deletions inc/authentication/namespace.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,33 @@
namespace WP\OAuth2\Authentication;

use WP_Error;
use WP_REST_Response;
use WP_User;
use WP\OAuth2\Tokens;
use WP\OAuth2\Well_Known;

/**
* Get the authorization header
* Get a request header by name, case-insensitively.
*
* On certain systems and configurations, the Authorization header will be
* stripped out by the server or PHP. Typically this is then used to
* generate `PHP_AUTH_USER`/`PHP_AUTH_PASS` but not passed on. We use
* `getallheaders` here to try and grab it out instead.
*
* @return string|null Authorization header if set, null otherwise
* @param string $name Header name. Default 'authorization'.
*
* @return string|null Header value if set, null otherwise.
*/
function get_authorization_header() {
if ( ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) ) {
return wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
function get_authorization_header( $name = 'authorization' ) {
$server_key = 'HTTP_' . strtoupper( str_replace( '-', '_', $name ) );
if ( ! empty( $_SERVER[ $server_key ] ) ) {
return wp_unslash( $_SERVER[ $server_key ] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
}

if ( function_exists( 'getallheaders' ) ) {
$headers = getallheaders();

// Check for the authorization header case-insensitively
foreach ( $headers as $key => $value ) {
if ( strtolower( $key ) === 'authorization' ) {
if ( strtolower( $key ) === strtolower( $name ) ) {
return $value;
}
}
Expand All @@ -46,9 +49,21 @@ function get_authorization_header() {
* @return string|null Token on success, null on failure.
*/
function get_provided_token() {
$header = get_authorization_header();
/**
* Filter the authorization header name used to extract the bearer token.
*
* Override when the standard Authorization header is consumed by a proxy
* (e.g. Imperva HTTP Basic Auth) and the token is forwarded under a
* different name such as X-Authorization.
*
* @param string $name Header name. Default 'authorization'.
*/
$header = get_authorization_header( apply_filters( 'oauth2.authentication.authorization_header', 'authorization' ) );
if ( $header ) {
return get_token_from_bearer_header( $header );
$token = get_token_from_bearer_header( $header );
if ( $token ) {
return $token;
}
}

$token = get_token_from_request();
Expand Down Expand Up @@ -196,8 +211,97 @@ function create_invalid_token_error( $token ) {
'oauth2.authentication.attempt_authentication.invalid_token',
__( 'Supplied token is invalid.', 'oauth2' ),
[
'status' => \WP_Http::FORBIDDEN,
'status' => \WP_Http::UNAUTHORIZED,
'token' => $token,
]
);
}

/**
* Adds a `WWW-Authenticate` challenge to unauthorized REST API responses.
*
* Attached to the rest_post_dispatch filter. WordPress answers an anonymous
* request to a protected route with a 401, whoever registered that route, so
* this covers the whole REST API rather than this plugin's own endpoints.
*
* @param WP_REST_Response $response Response about to be sent.
* @param mixed $server REST server instance.
* @param mixed $request Request being answered.
*
* @return WP_REST_Response Response, with a challenge when one applies.
*/
function add_www_authenticate_header( $response, $server = null, $request = null ) {
if ( ! $response instanceof WP_REST_Response || \WP_Http::UNAUTHORIZED !== $response->get_status() ) {
return $response;
}

// This plugin's own endpoints are the authorization server, not a
// resource it protects.
if ( $request && strpos( '/' . ltrim( (string) $request->get_route(), '/' ), '/oauth2/' ) === 0 ) {
return $response;
}

$headers = $response->get_headers();

if ( isset( $headers['WWW-Authenticate'] ) ) {
return $response;
}

$response->header( 'WWW-Authenticate', build_authenticate_challenge() );

return $response;
}

/**
* Builds the `WWW-Authenticate` challenge sent with unauthorized responses.
*
* The error parameters are only included when a token was supplied and
* rejected. RFC 6750 section 3 leaves them out when the client sent no
* credentials at all, since there is nothing yet to report as wrong.
*
* @return string Challenge header value.
*/
function build_authenticate_challenge() {
global $oauth2_error;

$params = [];

if ( is_wp_error( $oauth2_error ) && strpos( $oauth2_error->get_error_code(), 'oauth2.authentication.' ) === 0 ) {
$params['error'] = 'invalid_token';
$params['error_description'] = $oauth2_error->get_error_message();
}

$params['resource_metadata'] = Well_Known\get_protected_resource_metadata_url();

$parts = [];

foreach ( $params as $key => $value ) {
$parts[] = sprintf( '%s="%s"', $key, addcslashes( (string) $value, '"\\' ) );
}

$challenge = 'Bearer ' . implode( ', ', $parts );

/**
* Filter the WWW-Authenticate challenge sent with unauthorized REST API responses.
*
* @param string $challenge Challenge header value.
* @param array $params Challenge parameters used to build it.
*/
return apply_filters( 'oauth2.www_authenticate_challenge', $challenge, $params );
}

/**
* Lets browsers read the `WWW-Authenticate` challenge on cross-origin requests.
*
* Without this the header is hidden from JavaScript, so a browser client
* cannot follow the challenge to the metadata document.
*
* @param string[] $headers Headers exposed to CORS requests.
*
* @return string[] Headers, including the challenge.
*/
function expose_authenticate_header( $headers ) {
$headers[] = 'WWW-Authenticate';

return $headers;
}
81 changes: 57 additions & 24 deletions inc/endpoints/class-token.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,21 @@ public function exchange_token( WP_REST_Request $request ) {
return $this->handle_client_credentials( $request );
}

// RFC 6749 section 2.3.1: a client may authenticate with HTTP Basic
// instead of body parameters. Body parameters take precedence.
if ( $request->get_param( 'client_id' ) === null || $request->get_param( 'client_id' ) === '' ) {
$basic = $this->get_basic_auth_credentials( $request );
if ( is_wp_error( $basic ) ) {
return $basic;
}
if ( null !== $basic ) {
$request->set_param( 'client_id', $basic[0] );
if ( $request->get_param( 'client_secret' ) === null || $request->get_param( 'client_secret' ) === '' ) {
$request->set_param( 'client_secret', $basic[1] );
}
}
}

// The authorization_code grant requires `client_id` and `code`.
// These are declared optional at the schema level so they don't
// apply to client_credentials, so validate presence here. The error
Expand Down Expand Up @@ -201,30 +216,9 @@ private function extract_client_credentials( WP_REST_Request $request ) {
}

// Fall back to Basic authentication from Authorization header
$auth_header = $request->get_header( 'authorization' );

if ( ! empty( $auth_header ) && stripos( $auth_header, 'Basic ' ) === 0 ) {
$encoded = substr( $auth_header, 6 );
$decoded = base64_decode( $encoded, true );

if ( false === $decoded ) {
return new WP_Error(
'oauth2.endpoints.token.invalid_request',
__( 'Invalid Authorization header.', 'oauth2' ),
[ 'status' => WP_Http::BAD_REQUEST ]
);
}

$parts = explode( ':', $decoded, 2 );
if ( count( $parts ) !== 2 ) {
return new WP_Error(
'oauth2.endpoints.token.invalid_request',
__( 'Invalid Authorization header format.', 'oauth2' ),
[ 'status' => WP_Http::BAD_REQUEST ]
);
}

return [ trim( $parts[0] ), trim( $parts[1] ) ];
$basic = $this->get_basic_auth_credentials( $request );
if ( null !== $basic ) {
return $basic;
}

return new WP_Error(
Expand All @@ -233,4 +227,43 @@ private function extract_client_credentials( WP_REST_Request $request ) {
[ 'status' => WP_Http::BAD_REQUEST ]
);
}

/**
* Read client credentials from an HTTP Basic Authorization header.
*
* @param WP_REST_Request $request Request object.
* @return array|WP_Error|null Array with client_id and client_secret, error if the
* header is malformed, or null if there is no Basic header.
*/
private function get_basic_auth_credentials( WP_REST_Request $request ) {
$auth_header = $request->get_header( 'authorization' );

if ( empty( $auth_header ) || stripos( $auth_header, 'Basic ' ) !== 0 ) {
return null;
}

$encoded = substr( $auth_header, 6 );
$decoded = base64_decode( $encoded, true );

if ( false === $decoded ) {
return new WP_Error(
'oauth2.endpoints.token.invalid_request',
__( 'Invalid Authorization header.', 'oauth2' ),
[ 'status' => WP_Http::BAD_REQUEST ]
);
}

$parts = explode( ':', $decoded, 2 );
if ( count( $parts ) !== 2 ) {
return new WP_Error(
'oauth2.endpoints.token.invalid_request',
__( 'Invalid Authorization header format.', 'oauth2' ),
[ 'status' => WP_Http::BAD_REQUEST ]
);
}

// RFC 6749 section 2.3.1: both values are form-encoded before they go
// into the header, so decode them on the way back out.
return [ urldecode( trim( $parts[0] ) ), urldecode( trim( $parts[1] ) ) ];
}
}
2 changes: 2 additions & 0 deletions inc/namespace.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ function bootstrap() {

// REST API integration.
add_filter( 'rest_authentication_errors', __NAMESPACE__ . '\\Authentication\\maybe_report_errors' );
add_filter( 'rest_post_dispatch', __NAMESPACE__ . '\\Authentication\\add_www_authenticate_header', 10, 3 );
add_filter( 'rest_exposed_cors_headers', __NAMESPACE__ . '\\Authentication\\expose_authenticate_header' );
add_filter( 'rest_index', __NAMESPACE__ . '\\register_in_index' );
add_action( 'rest_api_init', __NAMESPACE__ . '\\Endpoints\\register' );
add_action( 'parse_request', __NAMESPACE__ . '\\Well_Known\\maybe_serve_document' );
Expand Down
Loading