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
1 change: 1 addition & 0 deletions includes/class-indieauth.php
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ private function register_rest_routes() {

// FedCM Controller hooks.
\add_action( 'rest_api_init', array( $this->fedcm, 'register_routes' ) );
\add_filter( 'rest_authentication_errors', array( $this->fedcm, 'rest_authentication_errors' ) );
\add_filter( 'indieauth_metadata', array( $this->fedcm, 'metadata' ) );
\add_filter( 'rest_index_indieauth_endpoints', array( $this->fedcm, 'rest_index' ) );
\add_action( 'set_logged_in_cookie', array( $this->fedcm, 'set_login_status_logged_in' ) );
Expand Down
16 changes: 13 additions & 3 deletions includes/rest/class-authorization-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,8 @@ public function post( $request ) {
* @return array|OAuth_Response Response to return to the REST Server.
*/
public function authorization_code( $params ) {
$required = array( 'redirect_uri', 'client_id', 'code', 'grant_type' );
// redirect_uri is required conditionally below; FedCM codes are issued without one.
$required = array( 'client_id', 'code', 'grant_type' );
foreach ( $required as $require ) {
if ( ! isset( $params[ $require ] ) ) {
// translators: Name of missing parameter.
Expand All @@ -419,14 +420,23 @@ public function authorization_code( $params ) {

$code = $params['code'];
$code_verifier = isset( $params['code_verifier'] ) ? $params['code_verifier'] : null;
$params = \wp_array_slice_assoc( $params, array( 'client_id', 'redirect_uri' ) );
$token = $this->get_code( $code );
$scopes = isset( $token['scope'] ) ? array_filter( explode( ' ', $token['scope'] ) ) : array();

if ( ! $token ) {
return new OAuth_Response( 'invalid_grant', \__( 'Invalid authorization code', 'indieauth' ), 400 );
Comment on lines 423 to 427
}
$user = \get_user_by( 'id', $token['user'] );

$bound_params = array( 'client_id' );
if ( empty( $token['fedcm'] ) ) {
if ( ! isset( $params['redirect_uri'] ) ) {
// translators: Name of missing parameter.
return new OAuth_Response( 'parameter_absent', sprintf( \__( 'Missing Parameter: %1$s', 'indieauth' ), 'redirect_uri' ), 400 );
}
$bound_params[] = 'redirect_uri';
}
$params = \wp_array_slice_assoc( $params, $bound_params );
$user = \get_user_by( 'id', $token['user'] );
if ( $token['exp'] <= time() ) {
$this->delete_code( $code, $token['user'] );
return new OAuth_Response( 'invalid_grant', \__( 'The authorization code expired', 'indieauth' ), 400 );
Expand Down
114 changes: 89 additions & 25 deletions includes/rest/class-fedcm-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,35 @@ private function is_valid_fedcm_request( $request ) {
return 'webidentity' === $sec_fetch_dest;
}

/**
* Exempt FedCM requests from the REST cookie nonce check.
*
* The browser's FedCM machinery sends the auth cookie but cannot include a
* REST nonce, so `rest_cookie_check_errors()` would treat the request as
* unauthenticated. `Sec-` prefixed headers are forbidden for cross-site
* JavaScript, so requiring `Sec-Fetch-Dest: webidentity` rules out CSRF.
*
* @param \WP_Error|null|true $result Current authentication result.
* @return \WP_Error|null|true True for FedCM requests to FedCM routes, unchanged otherwise.
*/
public function rest_authentication_errors( $result ) {
if ( null !== $result ) {
return $result;
}

if ( ! isset( $_SERVER['HTTP_SEC_FETCH_DEST'] ) || 'webidentity' !== $_SERVER['HTTP_SEC_FETCH_DEST'] ) {
return $result;
}

// Only exempt this plugin's FedCM routes.
$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? \sanitize_text_field( \wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
if ( false === strpos( $request_uri, $this->namespace . '/' . $this->rest_base . '/' ) ) {
return $result;
}

return true;
}
Comment on lines +319 to +335

/**
* Validate Origin header matches client_id scheme, hostname, and port.
*
Expand All @@ -329,10 +358,8 @@ private function validate_origin( $request, $client_id ) {

$origin_scheme = isset( $origin_parts['scheme'] ) ? $origin_parts['scheme'] : null;
$origin_host = isset( $origin_parts['host'] ) ? $origin_parts['host'] : null;
$origin_port = isset( $origin_parts['port'] ) ? (int) $origin_parts['port'] : null;
$client_id_scheme = isset( $client_id_parts['scheme'] ) ? $client_id_parts['scheme'] : null;
$client_id_host = isset( $client_id_parts['host'] ) ? $client_id_parts['host'] : null;
$client_id_port = isset( $client_id_parts['port'] ) ? (int) $client_id_parts['port'] : null;

if ( null === $origin_scheme || null === $origin_host || null === $client_id_scheme || null === $client_id_host ) {
return false;
Expand All @@ -343,12 +370,32 @@ private function validate_origin( $request, $client_id ) {
return false;
}

// Port must match (null means default port for scheme).
// Port must match; a missing port means the default port for the scheme.
$defaults = array(
'http' => 80,
'https' => 443,
);
$default_port = isset( $defaults[ $origin_scheme ] ) ? $defaults[ $origin_scheme ] : null;
$origin_port = isset( $origin_parts['port'] ) ? (int) $origin_parts['port'] : $default_port;
$client_id_port = isset( $client_id_parts['port'] ) ? (int) $client_id_parts['port'] : $default_port;

return $origin_port === $client_id_port;
}

/**
* Add CORS headers for FedCM responses.
* Add public CORS headers for endpoints that serve no user data.
*
* @param \WP_REST_Response $response The response object.
* @return \WP_REST_Response Modified response.
*/
private function add_public_cors_headers( $response ) {
$response->header( 'Access-Control-Allow-Origin', '*' );

return $response;
}

/**
* Add credentialed CORS headers for FedCM responses.
*
* @param \WP_REST_Response $response The response object.
* @param \WP_REST_Request $request The request object.
Expand Down Expand Up @@ -392,7 +439,7 @@ public function config( $request ) {
$response = new \WP_REST_Response( $config, 200 );

// Config endpoint must be accessible cross-origin per FedCM spec.
return $this->add_cors_headers( $response, $request );
return $this->add_public_cors_headers( $response );
}

/**
Expand Down Expand Up @@ -500,7 +547,27 @@ public function client_metadata( $request ) {
array()
);

return $this->add_cors_headers( $response, $request );
return $this->add_public_cors_headers( $response );
}

/**
* Build an ID assertion error response per the FedCM Error API.
*
* @see https://fedidcg.github.io/FedCM/#idp-api-error-response
*
* @param string $code One of the error codes defined by the FedCM specification.
* @param int $status HTTP status code.
* @return \WP_REST_Response The error response.
*/
private function assertion_error( $code, $status ) {
return new \WP_REST_Response(
array(
'error' => array(
'code' => $code,
),
),
$status
);
}

/**
Expand All @@ -514,10 +581,7 @@ public function client_metadata( $request ) {
public function assertion( $request ) {
// Validate FedCM request header.
if ( ! $this->is_valid_fedcm_request( $request ) ) {
return new \WP_REST_Response(
array( 'error' => 'Missing or invalid Sec-Fetch-Dest header' ),
400
);
return $this->assertion_error( 'invalid_request', 400 );
}

$client_id = $request->get_param( 'client_id' );
Expand All @@ -527,29 +591,20 @@ public function assertion( $request ) {

// Validate origin.
if ( ! $this->validate_origin( $request, $client_id ) ) {
return new \WP_REST_Response(
array( 'error' => 'Origin mismatch' ),
403
);
return $this->assertion_error( 'unauthorized_client', 403 );
}

// Check if user is logged in.
if ( ! \is_user_logged_in() ) {
return new \WP_REST_Response(
array( 'error' => 'Not logged in' ),
401
);
return $this->assertion_error( 'access_denied', 401 );
}

$user = \wp_get_current_user();
$me = get_url_from_user( $user->ID );

// Verify account_id matches the current user.
if ( normalize_url( $account_id ) !== normalize_url( $me ) ) {
return new \WP_REST_Response(
array( 'error' => 'Account mismatch' ),
403
);
return $this->assertion_error( 'access_denied', 403 );
}

// Parse PKCE params (already validated by validate_params callback).
Expand All @@ -566,12 +621,22 @@ public function assertion( $request ) {
// Determine scope - default to 'profile' for FedCM.
$scope = 'profile';
if ( is_array( $params ) && isset( $params['scope'] ) ) {
$scope = \sanitize_text_field( $params['scope'] );
// The FedCM browser UI never displays scopes, so only identity
// scopes may be granted without a real consent screen.
$requested = array_filter( explode( ' ', \sanitize_text_field( $params['scope'] ) ) );
$granted = array_values( array_intersect( $requested, array( 'profile', 'email' ) ) );
if ( $granted ) {
$scope = implode( ' ', $granted );
}
}

/**
* Filter the scope used for FedCM-issued authorization codes.
*
* Scopes requested by the client are clamped to 'profile' and 'email'
* before this filter runs, because the FedCM flow has no consent
* screen where a user could review other scopes.
*
* @param string $scope The scope to be stored with the authorization code.
* @param \WP_REST_Request $request The REST request object.
* @param \WP_User $user The authenticated WordPress user.
Expand All @@ -581,14 +646,13 @@ public function assertion( $request ) {
$token = array(
'response_type' => 'code',
'client_id' => $client_id,
'redirect_uri' => 'urn:ietf:wg:oauth:2.0:oob', // FedCM doesn't redirect; use OOB constant.
'scope' => $scope,
'me' => $me,
'code_challenge' => $code_challenge,
'code_challenge_method' => $code_challenge_method,
'user' => $user->ID,
'uuid' => $uuid,
'fedcm' => true, // Mark as FedCM-issued code.
'fedcm' => true, // Mark as FedCM-issued code; these are not bound to a redirect_uri.
);

if ( $nonce ) {
Expand Down
14 changes: 12 additions & 2 deletions includes/rest/class-token-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -257,14 +257,15 @@ public function refresh_token( $params ) {
* @return \WP_REST_Response|OAuth_Response Token response or error.
*/
public function authorization_code( $params ) {
$diff = array_diff( array( 'code', 'client_id', 'redirect_uri' ), array_keys( $params ) );
// redirect_uri is validated against the stored code, which knows whether one was used.
$diff = array_diff( array( 'code', 'client_id' ), array_keys( $params ) );
if ( ! empty( $diff ) ) {
return new OAuth_Response( 'invalid_request', \__( 'The request is missing one or more required parameters', 'indieauth' ), 400 );
}
$args = array_filter(
array(
'code' => $params['code'],
'redirect_uri' => $params['redirect_uri'],
'redirect_uri' => isset( $params['redirect_uri'] ) ? $params['redirect_uri'] : null,
'client_id' => $params['client_id'],
'code_verifier' => isset( $params['code_verifier'] ) ? $params['code_verifier'] : null,
)
Expand Down Expand Up @@ -378,6 +379,15 @@ public function verify_local_authorization_code( $args ) {
if ( ! $return ) {
return new OAuth_Response( 'invalid_code', \__( 'Invalid authorization code', 'indieauth' ), 401 );
}
if ( ! isset( $args['client_id'] ) || ! isset( $return['client_id'] ) || $return['client_id'] !== $args['client_id'] ) {
$codes->destroy( $args['code'] );
return new OAuth_Response( 'invalid_grant', \__( 'The client_id does not match the authorization request', 'indieauth' ), 400 );
}
// FedCM codes are issued without a redirect_uri; every other code is bound to one.
if ( empty( $return['fedcm'] ) && ( ! isset( $args['redirect_uri'] ) || ! isset( $return['redirect_uri'] ) || $return['redirect_uri'] !== $args['redirect_uri'] ) ) {
$codes->destroy( $args['code'] );
return new OAuth_Response( 'invalid_grant', \__( 'The redirect_uri does not match the authorization request', 'indieauth' ), 400 );
}
if ( isset( $return['code_challenge'] ) ) {
if ( ! isset( $args['code_verifier'] ) ) {
$codes->destroy( $args['code'] );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,35 @@ public function test_auth_code_redemption() {
);
}

// FedCM codes are issued without a redirect, so redemption works without redirect_uri.
public function test_fedcm_auth_code_redemption_without_redirect_uri() {
$tokens = new Token_User( '_indieauth_code_' );
$tokens->set_user( self::$author_id );
$code = $tokens->set(
array(
'client_id' => 'https://app.example.com/',
'scope' => 'profile',
'me' => get_author_posts_url( static::$author_id ),
'user' => static::$author_id,
'fedcm' => true,
),
600
);

$response = $this->create_form(
'POST',
array(
'grant_type' => 'authorization_code',
'code' => $code,
'client_id' => 'https://app.example.com/',
)
);
$this->assertEquals( 200, $response->get_status(), 'Response: ' . wp_json_encode( $response ) );
$data = $response->get_data();
$this->assertArrayNotHasKey( 'access_token', $data );
$this->assertEquals( get_author_posts_url( static::$author_id ), $data['me'] );
}

// Tests to Make Sure the Auth Endpoint Does Not Return a Token
public function test_auth_code_redemption_with_scope() {
static::$test_auth_code['scope'] = 'create update';
Expand Down
Loading