[C2S] Add DPoP (RFC 9449) support for OAuth token binding#3119
[C2S] Add DPoP (RFC 9449) support for OAuth token binding#3119
Conversation
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)
There was a problem hiding this comment.
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
DPoPproof 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.jktvia introspection / metadata. - Adds DPoP-related tests and updates CORS to allow the
DPoPheader.
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 |
| $result = DPoP::validate_proof(); | ||
|
|
||
| if ( \is_wp_error( $result ) ) { | ||
| return null; | ||
| } | ||
|
|
||
| return $result['jkt']; |
There was a problem hiding this comment.
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).
| $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; |
| // 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 ) | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| $signature_valid = self::verify_signature( | ||
| $parts[0] . '.' . $parts[1], | ||
| self::base64url_decode( $parts[2] ), |
There was a problem hiding this comment.
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().
| $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, |
| ); | ||
| } | ||
|
|
||
| \set_transient( $jti_cache_key, 1, self::MAX_AGE ); |
There was a problem hiding this comment.
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.
| \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 ); |
| 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; | ||
| } |
There was a problem hiding this comment.
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.
| /** | ||
| * 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() { |
There was a problem hiding this comment.
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.
Supersedes #2941 (rebased cleanly onto current trunk).
Proposed changes:
DPoPproof header during token issuance get DPoP-bound tokens; clients that don't get regular Bearer tokens with no behavior change.Key changes:
DPoPclass (includes/oauth/class-dpop.php) — JWT decode, JWK thumbprint (RFC 7638), JWK-to-PEM conversion, DPoP proof validation with jti replay protectionTokenclass — storesdpop_jktbinding, returnstoken_type: DPoP, preserves binding through refresh, includescnf.jktin introspectionServerclass — acceptsAuthorization: DPoPscheme, validates proofs on resource access, advertisesdpop_signing_alg_values_supportedin metadataToken_Controller— validates DPoP proofs at token endpoint for auth code grantsDPoPinAccess-Control-Allow-HeadersOther information:
Testing instructions:
npm run env-test -- --group=oauthnpm run env-test -- --filter=DPoPdpop_signing_alg_values_supported:curl <site>/wp-json/activitypub/1.0/oauth/authorization-server-metadata | jq .dpop_signing_alg_values_supportedChangelog entry
Changelog Entry Details
Significance
Type
Message
Add DPoP (RFC 9449) support to protect OAuth tokens from theft and replay.