Skip to content

Fix FedCM authentication, scope consent, and code-binding issues#322

Open
pfefferle wants to merge 2 commits into
trunkfrom
fix/fedcm-review-findings
Open

Fix FedCM authentication, scope consent, and code-binding issues#322
pfefferle wants to merge 2 commits into
trunkfrom
fix/fedcm-review-findings

Conversation

@pfefferle

Copy link
Copy Markdown
Member

A review of the FedCM implementation (#299) turned up one functional blocker and several security/spec issues. Every fix was developed test-first; the failing test reproducing each issue was written before the fix.

Fixes

FedCM endpoints saw logged-in users as logged out (functional blocker)

accounts and assertion rely on is_user_logged_in(), but core's rest_cookie_check_errors() treats any cookie-authenticated REST request without a _wpnonce/X-WP-Nonce as unauthenticated. The browser's FedCM machinery can never send a REST nonce, so in a real browser the accounts endpoint always answered 401 and the flow dead-ended. (PHPUnit couldn't catch it: wp_set_current_user() doesn't set $wp_rest_auth_cookie, so the nonce path never ran in tests.)

The fix is a rest_authentication_errors filter that exempts requests bearing Sec-Fetch-Dest: webidentity — scoped to the plugin's fedcm/ routes only. Sec--prefixed headers are forbidden for cross-site JavaScript, so this does not open a CSRF hole; non-FedCM routes keep core's strict behavior (covered by tests).

RPs could obtain arbitrary-scope tokens without scope consent

The assertion endpoint accepted whatever params.scope the RP sent and the token endpoint then issued a real access token for it — but the FedCM browser dialog never displays scopes, so a one-click sign-in could silently become a create update token. Requested scopes are now clamped to profile/email; the indieauth_fedcm_scope filter still allows deliberate opt-up.

Code redemption never verified client_id/redirect_uri

verify_local_authorization_code() only checked PKCE — a code could be exchanged with a mismatched client_id or redirect_uri. Both are now enforced at the token endpoint. FedCM codes are issued without a redirect, so they are redeemable without a redirect_uri at both the token and authorization endpoints (keyed off the server-set fedcm marker), and the unused urn:ietf:wg:oauth:2.0:oob placeholder is no longer stored.

Smaller fixes

  • Origin validation: a missing port now counts as the scheme default, so a client_id of https://app.example.com:443/ matches the browser's Origin: https://app.example.com.
  • Error shape: assertion errors follow the FedCM Error API — {"error": {"code": "invalid_request" | "unauthorized_client" | "access_denied"}}.
  • CORS: the public config.json and client_metadata endpoints send Access-Control-Allow-Origin: * without credentials; reflected credentialed CORS remains only on accounts/assertion where the spec requires it.

Testing instructions

  1. npm run test:wp-env — 108 tests, includes new coverage for the nonce exemption (positive + two negative cases), scope clamping, code binding (mismatch rejection + FedCM redemption without redirect_uri), default-port origins, error shape, and public CORS.
  2. Manual check of the critical fix on a wp-env site: log in, then
    curl -b <cookies> -H "Sec-Fetch-Dest: webidentity" <site>/wp-json/indieauth/1.0/fedcm/accounts
    returns the account list without a nonce; the same request without the header is still rejected, and core routes (e.g. /wp/v2/users/me) still 401 without a nonce even with the header.

Follow-ups (not in this PR)

  • IdentityProvider.register() still fires on every dashboard load; it should probably move behind an explicit button on the settings page.
  • The two code-redemption paths (authorization + token endpoint) still duplicate the verification mechanics; worth unifying into one shared verifier in a separate refactor.
  • Worth one manual Chrome run to confirm the auth cookie (default SameSite=Lax) is attached to FedCM fetches; if not, FedCM-enabled sites need SameSite=None.

- Exempt FedCM requests (Sec-Fetch-Dest: webidentity, fedcm routes only)
  from the REST cookie nonce check so the browser's credentialed FedCM
  fetches are not treated as unauthenticated.
- Clamp RP-requested scopes to profile/email; the FedCM browser UI never
  shows scopes, so nothing beyond identity scopes may be granted there.
- Enforce client_id binding on code redemption at the token endpoint and
  redirect_uri binding for non-FedCM codes; allow FedCM codes (issued
  without a redirect) to be redeemed without a redirect_uri at both
  endpoints, and stop storing the unused OOB placeholder redirect_uri.
- Treat a missing port as the scheme default when validating the Origin
  header against the client_id.
- Return FedCM Error API shaped errors from the assertion endpoint.
- Serve the public config and client_metadata endpoints with
  Access-Control-Allow-Origin: * instead of reflected credentialed CORS.
@pfefferle
pfefferle requested review from a team, Copilot and dshanske July 15, 2026 16:10
@pfefferle pfefferle self-assigned this Jul 15, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens and unblocks the FedCM (Federated Credential Management) flow in the IndieAuth WordPress plugin by ensuring cookie-authenticated FedCM REST requests work without a REST nonce, clamping scopes to identity-safe values, and binding authorization codes to their original client parameters during redemption.

Changes:

  • Exempt FedCM REST requests (scoped to the plugin’s FedCM routes) from WordPress REST cookie nonce enforcement.
  • Clamp RP-requested scopes for FedCM assertion to profile/email, and align assertion error responses with the FedCM Error API shape.
  • Enforce client_id and conditional redirect_uri binding during authorization code redemption (token + authorization endpoints), with corresponding PHPUnit coverage.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/phpunit/tests/includes/rest/class-test-token-controller.php Adds tests for client_id/redirect_uri mismatch rejection and FedCM redemption without redirect_uri at the token endpoint.
tests/phpunit/tests/includes/rest/class-test-fedcm-controller.php Adds tests for public CORS behavior, FedCM error shape, default-port origin handling, scope clamping, and REST nonce exemption behavior.
tests/phpunit/tests/includes/rest/class-test-authorization-controller.php Adds a test ensuring FedCM codes can be redeemed without redirect_uri at the authorization endpoint.
includes/rest/class-token-controller.php Makes redirect_uri optional at request-parse time and enforces client_id/redirect_uri binding during code verification (with FedCM exception).
includes/rest/class-fedcm-controller.php Implements REST nonce exemption for FedCM routes, default-port origin equality, public CORS for non-user-data endpoints, FedCM Error API response shape, and scope clamping.
includes/rest/class-authorization-controller.php Makes redirect_uri conditionally required based on whether the stored code is FedCM-marked.
includes/class-indieauth.php Registers the FedCM REST-auth exemption filter.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 423 to 427
$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 +319 to +335
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;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants