Skip to content

[C2S] Add DPoP (RFC 9449) support for OAuth token binding#3119

Open
pfefferle wants to merge 3 commits intotrunkfrom
add/dpop-support-v2
Open

[C2S] Add DPoP (RFC 9449) support for OAuth token binding#3119
pfefferle wants to merge 3 commits intotrunkfrom
add/dpop-support-v2

Conversation

@pfefferle
Copy link
Copy Markdown
Member

Supersedes #2941 (rebased cleanly onto current trunk).

Proposed changes:

  • Add DPoP (Demonstrating Proof of Possession, RFC 9449) support for the C2S OAuth implementation. This cryptographically binds access tokens to a client's key pair, preventing token theft and replay attacks.
  • DPoP is fully opt-in: clients that include a DPoP proof header during token issuance get DPoP-bound tokens; clients that don't get regular Bearer tokens with no behavior change.
  • Self-contained implementation with no external JWT library — uses PHP's OpenSSL extension directly, compatible with PHP 7.4+.
  • Supports ES256 (ECDSA P-256) and RS256 signing algorithms.

Key changes:

  • New DPoP class (includes/oauth/class-dpop.php) — JWT decode, JWK thumbprint (RFC 7638), JWK-to-PEM conversion, DPoP proof validation with jti replay protection
  • Token class — stores dpop_jkt binding, returns token_type: DPoP, preserves binding through refresh, includes cnf.jkt in introspection
  • Server class — accepts Authorization: DPoP scheme, validates proofs on resource access, advertises dpop_signing_alg_values_supported in metadata
  • Token_Controller — validates DPoP proofs at token endpoint for auth code grants
  • CORS headers now include DPoP in Access-Control-Allow-Headers

Other information:

  • Have you written new tests for your changes, if applicable?

Testing instructions:

  • Run existing OAuth tests to verify no regressions: npm run env-test -- --group=oauth
  • Run DPoP-specific tests: npm run env-test -- --filter=DPoP
  • Verify server metadata includes dpop_signing_alg_values_supported: curl <site>/wp-json/activitypub/1.0/oauth/authorization-server-metadata | jq .dpop_signing_alg_values_supported
  • Existing Bearer token flows should work identically (backward compatible)

Changelog entry

  • Automatically create a changelog entry from the details below.
Changelog Entry Details

Significance

  • Patch
  • Minor
  • Major

Type

  • Added - for new features
  • Changed - for changes in existing functionality
  • Deprecated - for soon-to-be removed features
  • Removed - for now removed features
  • Fixed - for any bug fixes
  • Security - in case of vulnerabilities

Message

Add DPoP (RFC 9449) support to protect OAuth tokens from theft and replay.

Adds proof-of-possession token binding to the C2S OAuth implementation.
DPoP cryptographically binds access tokens to a client's key pair,
preventing token theft and replay attacks.

DPoP is fully opt-in: clients that include a DPoP proof header during
token issuance get DPoP-bound tokens; clients that don't get regular
Bearer tokens with no behavior change.

Key changes:
- New DPoP class with JWT decode, JWK thumbprint (RFC 7638), signature
  verification (ES256/RS256), and jti replay protection
- Token class stores dpop_jkt binding, returns token_type: DPoP,
  preserves binding through refresh, includes cnf.jkt in introspection
- Server class accepts Authorization: DPoP scheme, validates proofs on
  resource access, advertises dpop_signing_alg_values_supported
- Token controller validates DPoP proofs at token endpoint for auth
  code grants
- CORS headers include DPoP
- Comprehensive test suite (29 tests)
Copilot AI review requested due to automatic review settings March 30, 2026 15:02
@pfefferle pfefferle added the Enhancement New feature or request label Mar 30, 2026
@pfefferle pfefferle self-assigned this Mar 30, 2026
@pfefferle pfefferle requested a review from a team March 30, 2026 15:03
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds opt-in DPoP (RFC 9449) proof-of-possession support to the C2S OAuth flow, binding issued access tokens to a client key to reduce token replay/theft risk.

Changes:

  • Introduces DPoP proof validation + JWK thumbprint computation and wires it into token issuance/introspection and resource authentication.
  • Extends tokens to persist DPoP binding through refresh and expose cnf.jkt via introspection / metadata.
  • Adds DPoP-related tests and updates CORS to allow the DPoP header.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tests/phpunit/tests/includes/oauth/class-test-dpop.php Adds PHPUnit coverage for DPoP proof validation, token binding, refresh, introspection, and replay protection
includes/rest/oauth/class-token-controller.php Binds token issuance to DPoP key thumbprint when a DPoP proof is provided
includes/rest/class-server.php Allows DPoP header in CORS preflight/response headers
includes/oauth/class-token.php Persists dpop_jkt, returns token_type: DPoP, preserves binding on refresh, exposes cnf.jkt in introspection
includes/oauth/class-server.php Accepts Authorization: DPoP, validates proofs on resource access, and advertises DPoP signing algs in metadata
includes/oauth/class-dpop.php Implements DPoP JWT validation, signature verification (ES256/RS256), JWK thumbprint, and replay protection
includes/oauth/class-authorization-code.php Threads optional dpop_jkt through auth code exchange into token creation
.github/changelog/3119-from-description Adds changelog entry for the new DPoP feature

Comment on lines +389 to +395
$result = DPoP::validate_proof();

if ( \is_wp_error( $result ) ) {
return null;
}

return $result['jkt'];
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

DPoP::validate_proof() is called without required arguments, but the method signature requires at least $proof, $http_method, and $http_uri. This will fatally error at runtime. Pass the extracted $proof and the current request method/URI (e.g., via DPoP::get_request_method() / DPoP::get_request_uri() or the REST request object), and consider returning/propagating a WP_Error if a DPoP header is present but invalid (instead of silently returning null).

Suggested change
$result = DPoP::validate_proof();
if ( \is_wp_error( $result ) ) {
return null;
}
return $result['jkt'];
$http_method = DPoP::get_request_method();
$http_uri = DPoP::get_request_uri();
$result = DPoP::validate_proof( $proof, $http_method, $http_uri );
if ( \is_wp_error( $result ) ) {
return null;
}
return isset( $result['jkt'] ) ? $result['jkt'] : null;

Copilot uses AI. Check for mistakes.
Comment on lines +65 to +93
// DPoP proof validation (RFC 9449).
$dpop_jkt = $validated->get_dpop_jkt();

if ( $dpop_jkt ) {
// DPoP-bound token requires DPoP authorization scheme.
if ( ! self::is_dpop_auth_scheme() ) {
return new \WP_Error(
'activitypub_dpop_required',
\__( 'DPoP-bound token requires DPoP authorization scheme.', 'activitypub' ),
array( 'status' => 401 )
);
}

// Validate the DPoP proof.
$proof_result = DPoP::validate_proof( $token );

if ( \is_wp_error( $proof_result ) ) {
return $proof_result;
}

// Verify proof JWK thumbprint matches token binding.
if ( $proof_result['jkt'] !== $dpop_jkt ) {
return new \WP_Error(
'activitypub_dpop_key_mismatch',
\__( 'DPoP proof key does not match token binding.', 'activitypub' ),
array( 'status' => 401 )
);
}
}
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

On resource requests, DPoP::validate_proof() is being called with the access token ($token) as the first argument, but the validator expects the DPoP proof JWT plus request method/URI (and the access token should be provided as the optional $access_token for ath verification). This currently both mis-validates the request and skips the ath binding check. Fix by extracting the DPoP proof from the DPoP header (e.g., DPoP::get_proof_from_request()), passing the actual HTTP method/URI, and passing $token as the $access_token parameter.

Copilot uses AI. Check for mistakes.
Comment on lines +99 to +101
$signature_valid = self::verify_signature(
$parts[0] . '.' . $parts[1],
self::base64url_decode( $parts[2] ),
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

base64url_decode() can return false for invalid input, but verify_signature() (and downstream ECDSA conversion) assumes a string. A malformed signature segment can trigger warnings or incorrect verification behavior. Handle decode failure explicitly (e.g., treat a false decode result as activitypub_dpop_invalid_jwt) before calling verify_signature().

Suggested change
$signature_valid = self::verify_signature(
$parts[0] . '.' . $parts[1],
self::base64url_decode( $parts[2] ),
$signature = self::base64url_decode( $parts[2] );
if ( false === $signature ) {
return new \WP_Error(
'activitypub_dpop_invalid_jwt',
\__( 'Invalid DPoP proof: cannot decode signature.', 'activitypub' ),
array( 'status' => 401 )
);
}
$signature_valid = self::verify_signature(
$parts[0] . '.' . $parts[1],
$signature,

Copilot uses AI. Check for mistakes.
);
}

\set_transient( $jti_cache_key, 1, self::MAX_AGE );
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

The replay cache entry is always kept for MAX_AGE seconds, even when the proof is near expiry. This retains unnecessary transient entries and can increase options/transient churn under load. Consider setting the transient TTL to the remaining validity window (e.g., max( 1, self::MAX_AGE - ( $now - $iat ) )) so storage duration matches the proof’s actual replay-relevant lifetime.

Suggested change
\set_transient( $jti_cache_key, 1, self::MAX_AGE );
// Store the jti for the remaining validity window of the proof.
$age = $now - $iat;
$ttl = max( 1, self::MAX_AGE - $age );
\set_transient( $jti_cache_key, 1, $ttl );

Copilot uses AI. Check for mistakes.
Comment on lines +310 to +319
public static function get_request_uri() {
$scheme = \is_ssl() ? 'https' : 'http';
$host = isset( $_SERVER['HTTP_HOST'] ) ? \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
$path = isset( $_SERVER['REQUEST_URI'] ) ? \sanitize_text_field( \wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';

// Remove query string from path.
$path = strtok( $path, '?' );

return $scheme . '://' . $host . $path;
}
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

For RFC 9449 htu comparison, sanitizing HTTP_HOST / REQUEST_URI with sanitize_text_field() can modify the values (e.g., stripping or normalizing characters), which can cause valid DPoP proofs to fail htu matching (or produce mismatched normalization relative to what the client signed). Prefer using the raw header/URI values (unslashed) and normalizing via URI parsing/recomposition rather than text-field sanitization.

Copilot uses AI. Check for mistakes.
Comment on lines +599 to +604
/**
* Test refresh with wrong DPoP key is rejected via token binding check.
*
* @covers \Activitypub\OAuth\Token::refresh
*/
public function test_refresh_with_different_key_preserves_original_binding() {
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

The docblock says the refresh with a wrong DPoP key is rejected, but the test body only checks that refreshed tokens preserve the original binding and that RSA/EC JKT differ—there is no assertion that a refresh attempt using a mismatched DPoP proof is rejected. Either update the docblock to match what’s being tested, or extend the test to exercise the controller/resource-layer rejection behavior.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants