Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
93 changes: 92 additions & 1 deletion inc/authentication/namespace.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
namespace WP\OAuth2\Authentication;

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

/**
* Get a request header by name, case-insensitively.
Expand Down Expand Up @@ -209,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;
}
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
78 changes: 67 additions & 11 deletions inc/well-known/namespace.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,62 @@

const AUTHORIZATION_SERVER_DOCUMENT = 'oauth-authorization-server';
const AUTHORIZATION_SERVER_PATH = '/.well-known/' . AUTHORIZATION_SERVER_DOCUMENT;
const PROTECTED_RESOURCE_DOCUMENT = 'oauth-protected-resource';
const PROTECTED_RESOURCE_PATH = '/.well-known/' . PROTECTED_RESOURCE_DOCUMENT;

/**
* Gets the discovery documents this plugin serves.
*
* Keys are well-known document names, values are handlers that receive the
* matched request path and either send a document or return.
*
* @return callable[] Map of document name to handler.
*/
function get_documents() {
$documents = [
AUTHORIZATION_SERVER_DOCUMENT => __NAMESPACE__ . '\\serve_authorization_server_document',
PROTECTED_RESOURCE_DOCUMENT => __NAMESPACE__ . '\\serve_protected_resource_document',
];

/**
* Filter the well-known discovery documents this plugin serves.
*
* @param callable[] $documents Map of document name to handler.
*/
return apply_filters( 'oauth2.well_known_documents', $documents );
}

/**
* Intercepts `.well-known/` requests before WordPress tries to match a
* post/page, and serves the matching discovery document.
*
* The request only gets this far if the server sends unknown paths to
* WordPress. Pretty permalinks arrange that on Apache; nginx setups usually
* do it whatever the permalink setting is.
*/
function maybe_serve_document() {
$site_path = match_well_known_path( $_SERVER['REQUEST_URI'] ?? '' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
$request_uri = $_SERVER['REQUEST_URI'] ?? ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput

foreach ( get_documents() as $document => $handler ) {
$matched = match_well_known_path( $request_uri, '/.well-known/' . $document );

if ( null === $matched ) {
continue;
}

if ( null === $site_path ) {
// Handlers exit once they have sent a document. Returning lets
// WordPress carry on and 404 the request.
$handler( $matched );
return;
}
}

/**
* Serves the RFC 8414 authorization server metadata document.
*
* @param string $site_path Path of the site being asked about, with a trailing slash.
*/
function serve_authorization_server_document( $site_path ) {
$site_id = get_site_id_by_path( $site_path );

if ( null === $site_id ) {
Expand All @@ -33,10 +77,10 @@ function maybe_serve_document() {
}

/**
* Works out which site, if any, a request URI is asking for metadata about.
* Works out which path, if any, a request URI is asking for metadata about.
*
* RFC 8414 puts the well-known path in front of the site's own path, so a
* site at `https://example.com/blog` publishes its metadata at
* The well-known path goes in front of the path being described, so a site at
* `https://example.com/blog` publishes its metadata at
* `https://example.com/.well-known/oauth-authorization-server/blog`. On a
* subdirectory network that request lands on the root site, which then has
* to answer for the subsite.
Expand All @@ -45,23 +89,35 @@ function maybe_serve_document() {
* clients ask for, and it is the only one a site in a subdirectory can
* answer without owning the domain root.
*
* The returned path is always measured from the domain root, whichever form
* was used, so callers get the same answer either way. What that path means
* depends on the document: RFC 8414 describes a site, RFC 9728 a resource.
*
* Tolerates a trailing slash: some hosts redirect extensionless GET paths to
* their trailing-slash form before WordPress runs, and clients following
* that redirect must still get the document.
*
* @param string $request_uri Raw request URI, as in `$_SERVER['REQUEST_URI']`.
* @return string|null Path of the site being asked about, or null if this isn't a metadata request.
* @param string $request_uri Raw request URI, as in `$_SERVER['REQUEST_URI']`.
* @param string $well_known_path Well-known path to match, e.g. `/.well-known/oauth-authorization-server`.
* @return string|null Path being asked about, with a trailing slash, or null if this isn't a metadata request.
*/
function match_well_known_path( $request_uri ) {
function match_well_known_path( $request_uri, $well_known_path = AUTHORIZATION_SERVER_PATH ) {
$path = untrailingslashit( (string) wp_parse_url( $request_uri, PHP_URL_PATH ) );
$current_path = get_current_site_path();
$site_prefix = untrailingslashit( $current_path ) . $well_known_path;

if ( untrailingslashit( $current_path ) . AUTHORIZATION_SERVER_PATH === $path ) {
// The site's own path, e.g. `/blog/.well-known/oauth-protected-resource`.
if ( $site_prefix === $path ) {
return $current_path;
}

if ( strpos( $path, AUTHORIZATION_SERVER_PATH . '/' ) === 0 ) {
return trailingslashit( substr( $path, strlen( AUTHORIZATION_SERVER_PATH ) ) );
if ( strpos( $path, $site_prefix . '/' ) === 0 ) {
return trailingslashit( untrailingslashit( $current_path ) . substr( $path, strlen( $site_prefix ) ) );
}

// The domain root, e.g. `/.well-known/oauth-protected-resource/blog`.
if ( strpos( $path, $well_known_path . '/' ) === 0 ) {
return trailingslashit( substr( $path, strlen( $well_known_path ) ) );
}

return null;
Expand Down
Loading