From 83ce2baaecfba64381474a05a0de0a35e46cb6b3 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 19 Feb 2026 10:01:34 +0100 Subject: [PATCH] Add DPoP (RFC 9449) support for OAuth token binding Implement Demonstrating Proof of Possession to cryptographically bind access tokens to a client's key pair, preventing token theft and replay. 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. - Add self-contained DPoP proof validator (ES256, RS256) with no external JWT library dependency - Bind tokens to JWK thumbprint (RFC 7638) at issuance - Validate DPoP proofs on resource access for bound tokens - Preserve DPoP binding through token refresh - Advertise dpop_signing_alg_values_supported in server metadata - Add DPoP to CORS allowed headers - Add 27 tests covering proof validation, key binding, and edge cases --- includes/oauth/class-authorization-code.php | 15 +- includes/oauth/class-dpop.php | 655 ++++++++++++++++ includes/oauth/class-server.php | 78 +- includes/oauth/class-token.php | 39 +- includes/rest/class-oauth-controller.php | 67 +- .../tests/includes/oauth/class-test-dpop.php | 736 ++++++++++++++++++ 6 files changed, 1573 insertions(+), 17 deletions(-) create mode 100644 includes/oauth/class-dpop.php create mode 100644 tests/phpunit/tests/includes/oauth/class-test-dpop.php diff --git a/includes/oauth/class-authorization-code.php b/includes/oauth/class-authorization-code.php index 8f1a6a367e..d2102c9085 100644 --- a/includes/oauth/class-authorization-code.php +++ b/includes/oauth/class-authorization-code.php @@ -103,13 +103,14 @@ public static function create( /** * Exchange authorization code for tokens. * - * @param string $code The authorization code. - * @param string $client_id The client ID. - * @param string $redirect_uri The redirect URI (must match original). - * @param string $code_verifier The PKCE code verifier. + * @param string $code The authorization code. + * @param string $client_id The client ID. + * @param string $redirect_uri The redirect URI (must match original). + * @param string $code_verifier The PKCE code verifier. + * @param string|null $dpop_jkt Optional DPoP JWK thumbprint for proof-of-possession binding. * @return array|\WP_Error Token data or error. */ - public static function exchange( $code, $client_id, $redirect_uri, $code_verifier ) { + public static function exchange( $code, $client_id, $redirect_uri, $code_verifier, $dpop_jkt = null ) { $code_hash = self::hash_code( $code ); $transient = self::TRANSIENT_PREFIX . $code_hash; $code_data = \get_transient( $transient ); @@ -168,7 +169,9 @@ public static function exchange( $code, $client_id, $redirect_uri, $code_verifie return Token::create( $code_data['user_id'], $client_id, - $code_data['scopes'] + $code_data['scopes'], + Token::DEFAULT_EXPIRATION, + $dpop_jkt ); } diff --git a/includes/oauth/class-dpop.php b/includes/oauth/class-dpop.php new file mode 100644 index 0000000000..ea9948ebfe --- /dev/null +++ b/includes/oauth/class-dpop.php @@ -0,0 +1,655 @@ + 401 ) + ); + } + + $header = self::json_decode_base64url( $parts[0] ); + $payload = self::json_decode_base64url( $parts[1] ); + + if ( ! $header || ! $payload ) { + return new \WP_Error( + 'activitypub_dpop_invalid_jwt', + \__( 'Invalid DPoP proof: cannot decode header or payload.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + // Validate typ. + if ( ! isset( $header['typ'] ) || 'dpop+jwt' !== $header['typ'] ) { + return new \WP_Error( + 'activitypub_dpop_invalid_typ', + \__( 'Invalid DPoP proof: typ must be "dpop+jwt".', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + // Validate alg is asymmetric and supported. + if ( ! isset( $header['alg'] ) || ! in_array( $header['alg'], self::SUPPORTED_ALGORITHMS, true ) ) { + return new \WP_Error( + 'activitypub_dpop_unsupported_alg', + \__( 'Invalid DPoP proof: unsupported algorithm.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + // Validate jwk is present. + if ( ! isset( $header['jwk'] ) || ! is_array( $header['jwk'] ) ) { + return new \WP_Error( + 'activitypub_dpop_missing_jwk', + \__( 'Invalid DPoP proof: missing JWK in header.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + // Verify the signature. + $signature_valid = self::verify_signature( + $parts[0] . '.' . $parts[1], + self::base64url_decode( $parts[2] ), + $header['alg'], + $header['jwk'] + ); + + if ( \is_wp_error( $signature_valid ) ) { + return $signature_valid; + } + + // Validate required payload claims. + $required_claims = array( 'jti', 'htm', 'htu', 'iat' ); + foreach ( $required_claims as $claim ) { + if ( ! isset( $payload[ $claim ] ) ) { + return new \WP_Error( + 'activitypub_dpop_missing_claim', + /* translators: %s: The missing claim name */ + sprintf( \__( 'Invalid DPoP proof: missing required claim "%s".', 'activitypub' ), $claim ), + array( 'status' => 401 ) + ); + } + } + + // Validate htm (HTTP method). + if ( strtoupper( $payload['htm'] ) !== strtoupper( $http_method ) ) { + return new \WP_Error( + 'activitypub_dpop_method_mismatch', + \__( 'Invalid DPoP proof: HTTP method mismatch.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + // Validate htu (HTTP URI) — compare without query string and fragment. + $proof_uri = self::normalize_uri( $payload['htu'] ); + $request_uri = self::normalize_uri( $http_uri ); + + if ( $proof_uri !== $request_uri ) { + return new \WP_Error( + 'activitypub_dpop_uri_mismatch', + \__( 'Invalid DPoP proof: HTTP URI mismatch.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + // Validate iat (freshness). + $now = time(); + $iat = (int) $payload['iat']; + + if ( $iat > $now + 5 ) { + // Allow 5 seconds clock skew into the future. + return new \WP_Error( + 'activitypub_dpop_future_iat', + \__( 'Invalid DPoP proof: issued in the future.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + if ( ( $now - $iat ) > self::MAX_AGE ) { + return new \WP_Error( + 'activitypub_dpop_expired', + \__( 'Invalid DPoP proof: proof has expired.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + // Prevent jti replay (RFC 9449 Section 11.1). + $jti_cache_key = 'activitypub_dpop_jti_' . md5( $payload['jti'] ); + + if ( false !== \get_transient( $jti_cache_key ) ) { + return new \WP_Error( + 'activitypub_dpop_jti_replayed', + \__( 'Invalid DPoP proof: replayed jti.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + \set_transient( $jti_cache_key, 1, self::MAX_AGE ); + + // If access token provided, verify ath claim. + if ( null !== $access_token ) { + if ( ! isset( $payload['ath'] ) ) { + return new \WP_Error( + 'activitypub_dpop_missing_ath', + \__( 'Invalid DPoP proof: missing access token hash (ath).', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + $expected_ath = self::base64url_encode( hash( 'sha256', $access_token, true ) ); + + if ( ! hash_equals( $expected_ath, $payload['ath'] ) ) { + return new \WP_Error( + 'activitypub_dpop_ath_mismatch', + \__( 'Invalid DPoP proof: access token hash mismatch.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + } + + // Compute JWK thumbprint. + $jkt = self::compute_jkt( $header['jwk'] ); + + if ( \is_wp_error( $jkt ) ) { + return $jkt; + } + + return array( 'jkt' => $jkt ); + } + + /** + * Compute the JWK Thumbprint (RFC 7638) of a JWK. + * + * @since unreleased + * + * @param array $jwk The JWK as an associative array. + * @return string|\WP_Error The base64url-encoded thumbprint, or WP_Error. + */ + public static function compute_jkt( $jwk ) { + if ( ! isset( $jwk['kty'] ) ) { + return new \WP_Error( + 'activitypub_dpop_invalid_jwk', + \__( 'Invalid JWK: missing key type.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + // Build canonical JSON with required members in lexicographic order. + switch ( $jwk['kty'] ) { + case 'EC': + if ( ! isset( $jwk['crv'], $jwk['x'], $jwk['y'] ) ) { + return new \WP_Error( + 'activitypub_dpop_invalid_jwk', + \__( 'Invalid EC JWK: missing required parameters.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + // RFC 7638: lexicographic order of required members. + $canonical = array( + 'crv' => $jwk['crv'], + 'kty' => $jwk['kty'], + 'x' => $jwk['x'], + 'y' => $jwk['y'], + ); + break; + + case 'RSA': + if ( ! isset( $jwk['e'], $jwk['n'] ) ) { + return new \WP_Error( + 'activitypub_dpop_invalid_jwk', + \__( 'Invalid RSA JWK: missing required parameters.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + // RFC 7638: lexicographic order of required members. + $canonical = array( + 'e' => $jwk['e'], + 'kty' => $jwk['kty'], + 'n' => $jwk['n'], + ); + break; + + default: + return new \WP_Error( + 'activitypub_dpop_unsupported_kty', + \__( 'Unsupported JWK key type.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + $json = wp_json_encode( $canonical, JSON_UNESCAPED_SLASHES ); + + return self::base64url_encode( hash( 'sha256', $json, true ) ); + } + + /** + * Extract the DPoP proof from the request headers. + * + * @since unreleased + * + * @return string|null The DPoP proof JWT or null if not present. + */ + public static function get_proof_from_request() { + // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Opaque JWT token, must not be altered. + if ( ! empty( $_SERVER['HTTP_DPOP'] ) ) { + return \wp_unslash( $_SERVER['HTTP_DPOP'] ); + } + // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + + // Fallback: Apache headers. + if ( function_exists( 'apache_request_headers' ) ) { + $headers = apache_request_headers(); + foreach ( $headers as $key => $value ) { + if ( 'dpop' === strtolower( $key ) ) { + return $value; + } + } + } + + return null; + } + + /** + * Get the HTTP URI for the current request. + * + * Returns scheme + host + path (no query string or fragment) as required by RFC 9449. + * + * @since unreleased + * + * @return string The HTTP URI. + */ + 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; + } + + /** + * Get the HTTP method for the current request. + * + * @since unreleased + * + * @return string The HTTP method. + */ + public static function get_request_method() { + return isset( $_SERVER['REQUEST_METHOD'] ) ? \sanitize_text_field( \wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : 'GET'; + } + + /** + * Verify a JWT signature using the JWK from the header. + * + * @param string $signing_input The header.payload string to verify. + * @param string $signature The raw signature bytes. + * @param string $alg The algorithm (ES256 or RS256). + * @param array $jwk The JWK public key. + * @return true|\WP_Error True on success, WP_Error on failure. + */ + private static function verify_signature( $signing_input, $signature, $alg, $jwk ) { + $pem = self::jwk_to_pem( $jwk, $alg ); + + if ( \is_wp_error( $pem ) ) { + return $pem; + } + + $public_key = openssl_pkey_get_public( $pem ); + + if ( false === $public_key ) { + return new \WP_Error( + 'activitypub_dpop_invalid_key', + \__( 'Invalid DPoP proof: cannot parse public key.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + if ( 'ES256' === $alg ) { + // Convert from JWS format (R || S) to DER format for OpenSSL. + $signature = self::ecdsa_signature_to_der( $signature ); + $openssl_alg = OPENSSL_ALGO_SHA256; + } else { + // RS256. + $openssl_alg = OPENSSL_ALGO_SHA256; + } + + $result = openssl_verify( $signing_input, $signature, $public_key, $openssl_alg ); + + if ( 1 !== $result ) { + return new \WP_Error( + 'activitypub_dpop_bad_signature', + \__( 'Invalid DPoP proof: signature verification failed.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + return true; + } + + /** + * Convert a JWK to PEM format. + * + * @param array $jwk The JWK. + * @param string $alg The algorithm. + * @return string|\WP_Error PEM string or WP_Error. + */ + private static function jwk_to_pem( $jwk, $alg ) { + if ( ! isset( $jwk['kty'] ) ) { + return new \WP_Error( + 'activitypub_dpop_invalid_jwk', + \__( 'Invalid JWK: missing key type.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + if ( 'RS256' === $alg && 'RSA' === $jwk['kty'] ) { + return self::rsa_jwk_to_pem( $jwk ); + } + + if ( 'ES256' === $alg && 'EC' === $jwk['kty'] ) { + return self::ec_jwk_to_pem( $jwk ); + } + + return new \WP_Error( + 'activitypub_dpop_unsupported_key', + \__( 'Unsupported JWK key type for algorithm.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + /** + * Convert an RSA JWK to PEM format. + * + * @param array $jwk The RSA JWK with 'n' and 'e' parameters. + * @return string|\WP_Error PEM string or WP_Error. + */ + private static function rsa_jwk_to_pem( $jwk ) { + if ( ! isset( $jwk['n'], $jwk['e'] ) ) { + return new \WP_Error( + 'activitypub_dpop_invalid_jwk', + \__( 'Invalid RSA JWK: missing n or e.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + $n = self::base64url_decode( $jwk['n'] ); + $e = self::base64url_decode( $jwk['e'] ); + + // Build DER-encoded RSA public key. + $n_der = self::unsigned_int_to_der( $n ); + $e_der = self::unsigned_int_to_der( $e ); + + $rsa_sequence = self::der_sequence( $n_der . $e_der ); + $rsa_bitstring = self::der_bitstring( $rsa_sequence ); + $algorithm_id = self::rsa_algorithm_identifier(); + $public_key_info = self::der_sequence( $algorithm_id . $rsa_bitstring ); + + // phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Required for PEM encoding. + $pem = "-----BEGIN PUBLIC KEY-----\n" + . chunk_split( base64_encode( $public_key_info ), 64, "\n" ) + . '-----END PUBLIC KEY-----'; + // phpcs:enable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + + return $pem; + } + + /** + * Convert an EC (P-256) JWK to PEM format. + * + * @param array $jwk The EC JWK with 'x', 'y', and 'crv' parameters. + * @return string|\WP_Error PEM string or WP_Error. + */ + private static function ec_jwk_to_pem( $jwk ) { + if ( ! isset( $jwk['x'], $jwk['y'], $jwk['crv'] ) ) { + return new \WP_Error( + 'activitypub_dpop_invalid_jwk', + \__( 'Invalid EC JWK: missing x, y, or crv.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + if ( 'P-256' !== $jwk['crv'] ) { + return new \WP_Error( + 'activitypub_dpop_unsupported_curve', + \__( 'Only P-256 curve is supported for ES256.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + $x = str_pad( self::base64url_decode( $jwk['x'] ), 32, "\0", STR_PAD_LEFT ); + $y = str_pad( self::base64url_decode( $jwk['y'] ), 32, "\0", STR_PAD_LEFT ); + + // Uncompressed point: 0x04 || x || y. + $point = "\x04" . $x . $y; + + // OID for P-256 (1.2.840.10045.3.1.7) = 06 08 2a 86 48 ce 3d 03 01 07. + // OID for EC public key (1.2.840.10045.2.1) = 06 07 2a 86 48 ce 3d 02 01. + // Algorithm identifier: SEQUENCE { OID ecPublicKey, OID P-256 }. + $algorithm_id = "\x30\x13\x06\x07\x2a\x86\x48\xce\x3d\x02\x01\x06\x08\x2a\x86\x48\xce\x3d\x03\x01\x07"; + + $bitstring = self::der_bitstring( $point ); + $public_key_info = self::der_sequence( $algorithm_id . $bitstring ); + + // phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Required for PEM encoding. + $pem = "-----BEGIN PUBLIC KEY-----\n" + . chunk_split( base64_encode( $public_key_info ), 64, "\n" ) + . '-----END PUBLIC KEY-----'; + // phpcs:enable WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + + return $pem; + } + + /** + * Convert an ECDSA JWS signature (R || S concatenation) to DER format. + * + * OpenSSL expects DER-encoded signatures, but JWS uses raw R||S concatenation. + * + * @param string $signature The raw R||S signature (64 bytes for P-256). + * @return string The DER-encoded signature. + */ + private static function ecdsa_signature_to_der( $signature ) { + $length = strlen( $signature ); + $half = (int) ( $length / 2 ); + + $r = substr( $signature, 0, $half ); + $s = substr( $signature, $half ); + + $r_der = self::unsigned_int_to_der( $r ); + $s_der = self::unsigned_int_to_der( $s ); + + return self::der_sequence( $r_der . $s_der ); + } + + /** + * Encode a raw unsigned integer as a DER INTEGER. + * + * @param string $raw The raw bytes. + * @return string The DER-encoded INTEGER. + */ + private static function unsigned_int_to_der( $raw ) { + // Remove leading zero bytes. + $raw = ltrim( $raw, "\x00" ); + + if ( '' === $raw ) { + $raw = "\x00"; + } + + // If high bit is set, prepend a zero byte. + if ( ord( $raw[0] ) & 0x80 ) { + $raw = "\x00" . $raw; + } + + return "\x02" . self::der_length( strlen( $raw ) ) . $raw; + } + + /** + * Encode a DER SEQUENCE. + * + * @param string $contents The sequence contents. + * @return string The DER-encoded SEQUENCE. + */ + private static function der_sequence( $contents ) { + return "\x30" . self::der_length( strlen( $contents ) ) . $contents; + } + + /** + * Encode a DER BIT STRING (with zero unused bits). + * + * @param string $contents The bit string contents. + * @return string The DER-encoded BIT STRING. + */ + private static function der_bitstring( $contents ) { + // Prepend 0x00 for "zero unused bits". + $contents = "\x00" . $contents; + return "\x03" . self::der_length( strlen( $contents ) ) . $contents; + } + + /** + * Encode a DER length value. + * + * @param int $length The length. + * @return string The DER-encoded length. + */ + private static function der_length( $length ) { + if ( $length < 0x80 ) { + return chr( $length ); + } + + $temp = ''; + $number = $length; + while ( $number > 0 ) { + $temp = chr( $number & 0xFF ) . $temp; + $number = $number >> 8; + } + + return chr( 0x80 | strlen( $temp ) ) . $temp; + } + + /** + * Get the RSA algorithm identifier for SubjectPublicKeyInfo. + * + * @return string DER-encoded AlgorithmIdentifier for RSA. + */ + private static function rsa_algorithm_identifier() { + // DER-encoded SEQUENCE containing OID for rsaEncryption and NULL params. + return "\x30\x0d\x06\x09\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01\x05\x00"; + } + + /** + * Base64url-decode a string. + * + * @param string $data The base64url-encoded string. + * @return string The decoded data. + */ + public static function base64url_decode( $data ) { + $remainder = strlen( $data ) % 4; + if ( $remainder ) { + $data .= str_repeat( '=', 4 - $remainder ); + } + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Required for JWT/JWK decoding per RFC 7515. + return base64_decode( strtr( $data, '-_', '+/' ) ); + } + + /** + * Base64url-encode a string. + * + * @param string $data The raw data. + * @return string The base64url-encoded string. + */ + public static function base64url_encode( $data ) { + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Required for JWT/JWK encoding per RFC 7515. + return rtrim( strtr( base64_encode( $data ), '+/', '-_' ), '=' ); + } + + /** + * JSON-decode a base64url-encoded string. + * + * @param string $data The base64url-encoded JSON string. + * @return array|null The decoded array or null on failure. + */ + private static function json_decode_base64url( $data ) { + $decoded = self::base64url_decode( $data ); + $json = json_decode( $decoded, true ); + + return is_array( $json ) ? $json : null; + } + + /** + * Normalize a URI for DPoP htu comparison. + * + * Strips query string and fragment, lowercases scheme and host. + * + * @param string $uri The URI to normalize. + * @return string The normalized URI. + */ + private static function normalize_uri( $uri ) { + $parts = \wp_parse_url( $uri ); + + if ( ! $parts ) { + return $uri; + } + + $scheme = isset( $parts['scheme'] ) ? strtolower( $parts['scheme'] ) : 'https'; + $host = isset( $parts['host'] ) ? strtolower( $parts['host'] ) : ''; + $port = isset( $parts['port'] ) ? ':' . $parts['port'] : ''; + $path = isset( $parts['path'] ) ? $parts['path'] : '/'; + + // Omit default ports. + if ( ( 'https' === $scheme && ':443' === $port ) || ( 'http' === $scheme && ':80' === $port ) ) { + $port = ''; + } + + return $scheme . '://' . $host . $port . $path; + } +} diff --git a/includes/oauth/class-server.php b/includes/oauth/class-server.php index 6290d2fe6e..0e14e7e3d7 100644 --- a/includes/oauth/class-server.php +++ b/includes/oauth/class-server.php @@ -75,6 +75,51 @@ public static function authenticate_oauth( $result ) { return $validated; } + // DPoP proof-of-possession validation (RFC 9449). + $dpop_jkt = $validated->get_dpop_jkt(); + + if ( $dpop_jkt ) { + // Token was issued with DPoP — proof is required. + $dpop_proof = DPoP::get_proof_from_request(); + + if ( ! $dpop_proof ) { + return new \WP_Error( + 'activitypub_dpop_proof_required', + \__( 'DPoP proof required for this token.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + // Require DPoP authorization scheme for DPoP-bound tokens. + if ( ! self::is_dpop_auth_scheme() ) { + return new \WP_Error( + 'activitypub_dpop_scheme_required', + \__( 'DPoP-bound tokens must use the DPoP authorization scheme.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + + $proof_result = DPoP::validate_proof( + $dpop_proof, + DPoP::get_request_method(), + DPoP::get_request_uri(), + $token + ); + + if ( \is_wp_error( $proof_result ) ) { + return $proof_result; + } + + // Verify the proof's JWK thumbprint matches the token's bound key. + if ( ! hash_equals( $dpop_jkt, $proof_result['jkt'] ) ) { + return new \WP_Error( + 'activitypub_dpop_key_mismatch', + \__( 'DPoP proof key does not match token binding.', 'activitypub' ), + array( 'status' => 401 ) + ); + } + } + self::$current_token = $validated; \wp_set_current_user( $validated->get_user_id() ); @@ -114,7 +159,7 @@ public static function has_scope( $scope ) { } /** - * Extract Bearer token from Authorization header. + * Extract Bearer or DPoP token from Authorization header. * * @return string|null The token string or null. */ @@ -126,11 +171,33 @@ public static function get_bearer_token() { } // Check for Bearer token. - if ( 0 !== strpos( $auth_header, 'Bearer ' ) ) { - return null; + if ( 0 === strpos( $auth_header, 'Bearer ' ) ) { + return substr( $auth_header, 7 ); + } + + // Check for DPoP token scheme (RFC 9449). + if ( 0 === strpos( $auth_header, 'DPoP ' ) ) { + return substr( $auth_header, 5 ); + } + + return null; + } + + /** + * Check if the current request uses the DPoP authorization scheme. + * + * @since unreleased + * + * @return bool True if the Authorization header uses the DPoP scheme. + */ + public static function is_dpop_auth_scheme() { + $auth_header = self::get_authorization_header(); + + if ( ! $auth_header ) { + return false; } - return substr( $auth_header, 7 ); + return 0 === strpos( $auth_header, 'DPoP ' ); } /** @@ -268,7 +335,7 @@ public static function add_cors_headers( $response, $server, $request ) { $origin = isset( $_SERVER['HTTP_ORIGIN'] ) ? \esc_url_raw( \wp_unslash( $_SERVER['HTTP_ORIGIN'] ) ) : ''; $response->header( 'Access-Control-Allow-Origin', $origin ? $origin : '*' ); $response->header( 'Access-Control-Allow-Methods', 'GET, POST, OPTIONS' ); - $response->header( 'Access-Control-Allow-Headers', 'Accept, Content-Type, Authorization' ); + $response->header( 'Access-Control-Allow-Headers', 'Accept, Content-Type, Authorization, DPoP' ); if ( $origin ) { $response->header( 'Vary', 'Origin' ); @@ -316,6 +383,7 @@ public static function get_metadata() { 'token_endpoint_auth_methods_supported' => array( 'none', 'client_secret_post' ), 'introspection_endpoint_auth_methods_supported' => array( 'bearer' ), 'code_challenge_methods_supported' => array( 'S256', 'plain' ), + 'dpop_signing_alg_values_supported' => DPoP::SUPPORTED_ALGORITHMS, 'service_documentation' => 'https://github.com/swicg/activitypub-api', ); } diff --git a/includes/oauth/class-token.php b/includes/oauth/class-token.php index 5fbd5cdad2..fcb9b1eba4 100644 --- a/includes/oauth/class-token.php +++ b/includes/oauth/class-token.php @@ -82,9 +82,10 @@ public function __construct( $user_id, $token_key, $data ) { * @param string $client_id OAuth client ID. * @param array $scopes Granted scopes. * @param int $expires Expiration time in seconds. + * @param string $dpop_jkt Optional DPoP JWK thumbprint for proof-of-possession binding. * @return array|\WP_Error Token data or error. */ - public static function create( $user_id, $client_id, $scopes, $expires = self::DEFAULT_EXPIRATION ) { + public static function create( $user_id, $client_id, $scopes, $expires = self::DEFAULT_EXPIRATION, $dpop_jkt = null ) { // Generate tokens. $access_token = self::generate_token(); $refresh_token = self::generate_token(); @@ -105,6 +106,11 @@ public static function create( $user_id, $client_id, $scopes, $expires = self::D 'last_used_at' => null, ); + // Store DPoP JWK thumbprint for proof-of-possession binding (RFC 9449). + if ( $dpop_jkt ) { + $token_data['dpop_jkt'] = $dpop_jkt; + } + // Store in user meta with access token hash as key. $access_hash = self::hash_token( $access_token ); $meta_key = self::META_PREFIX . $access_hash; @@ -137,7 +143,7 @@ public static function create( $user_id, $client_id, $scopes, $expires = self::D return array( 'access_token' => $access_token, - 'token_type' => 'Bearer', + 'token_type' => $dpop_jkt ? 'DPoP' : 'Bearer', 'expires_in' => $expires, 'refresh_token' => $refresh_token, 'scope' => Scope::to_string( $token_data['scopes'] ), @@ -302,8 +308,11 @@ public static function refresh( $refresh_token, $client_id ) { \delete_user_meta( $user_id, $meta_key ); \delete_user_meta( $user_id, $refresh_index_key ); + // Preserve DPoP binding (RFC 9449): new token inherits the JWK thumbprint. + $dpop_jkt = isset( $token_data['dpop_jkt'] ) ? $token_data['dpop_jkt'] : null; + // Create a new token. - return self::create( $user_id, $client_id, $token_data['scopes'] ); + return self::create( $user_id, $client_id, $token_data['scopes'], self::DEFAULT_EXPIRATION, $dpop_jkt ); } /** @@ -571,6 +580,17 @@ public function get_last_used_at() { return $this->data['last_used_at'] ?? null; } + /** + * Get the DPoP JWK thumbprint bound to this token. + * + * @since unreleased + * + * @return string|null The JWK thumbprint, or null if not a DPoP-bound token. + */ + public function get_dpop_jkt() { + return $this->data['dpop_jkt'] ?? null; + } + /** * Generate a cryptographically secure random token. * @@ -710,16 +730,25 @@ public static function introspect( $token ) { } $me = ! \is_wp_error( $actor ) ? $actor->get_id() : null; - return array( + $dpop_jkt = $validated->get_dpop_jkt(); + + $response = array( 'active' => true, 'scope' => Scope::to_string( $validated->get_scopes() ), 'client_id' => $validated->get_client_id(), 'username' => $user ? $user->user_login : null, - 'token_type' => 'Bearer', + 'token_type' => $dpop_jkt ? 'DPoP' : 'Bearer', 'exp' => $validated->get_expires_at(), 'iat' => $validated->get_created_at(), 'sub' => (string) $user_id, 'me' => $me, ); + + // Include confirmation claim for DPoP-bound tokens (RFC 9449 Section 6). + if ( $dpop_jkt ) { + $response['cnf'] = array( 'jkt' => $dpop_jkt ); + } + + return $response; } } diff --git a/includes/rest/class-oauth-controller.php b/includes/rest/class-oauth-controller.php index b963721f5b..07da5d229b 100644 --- a/includes/rest/class-oauth-controller.php +++ b/includes/rest/class-oauth-controller.php @@ -9,6 +9,7 @@ use Activitypub\OAuth\Authorization_Code; use Activitypub\OAuth\Client; +use Activitypub\OAuth\DPoP; use Activitypub\OAuth\Scope; use Activitypub\OAuth\Server as OAuth_Server; use Activitypub\OAuth\Token; @@ -488,7 +489,14 @@ private function handle_authorization_code_grant( \WP_REST_Request $request, $cl return $this->token_error( 'invalid_request', 'Authorization code is required.' ); } - $result = Authorization_Code::exchange( $code, $client_id, $redirect_uri, $code_verifier ); + // Check for DPoP proof (RFC 9449) — opt-in by client. + $dpop_jkt = $this->validate_dpop_for_token_request(); + + if ( \is_wp_error( $dpop_jkt ) ) { + return $this->token_error( 'invalid_dpop_proof', $dpop_jkt->get_error_message() ); + } + + $result = Authorization_Code::exchange( $code, $client_id, $redirect_uri, $code_verifier, $dpop_jkt ); if ( \is_wp_error( $result ) ) { return $this->token_error( 'invalid_grant', $result->get_error_message() ); @@ -511,12 +519,38 @@ private function handle_refresh_token_grant( \WP_REST_Request $request, $client_ return $this->token_error( 'invalid_request', 'Refresh token is required.' ); } + // Check for DPoP proof (RFC 9449) — opt-in by client. + $dpop_jkt = $this->validate_dpop_for_token_request(); + + if ( \is_wp_error( $dpop_jkt ) ) { + return $this->token_error( 'invalid_dpop_proof', $dpop_jkt->get_error_message() ); + } + $result = Token::refresh( $refresh_token, $client_id ); if ( \is_wp_error( $result ) ) { return $this->token_error( 'invalid_grant', $result->get_error_message() ); } + /* + * If the refreshed token was DPoP-bound, verify the refresh request + * used the same key. Token::refresh() preserves the original dpop_jkt. + */ + if ( 'DPoP' === $result['token_type'] ) { + if ( ! $dpop_jkt ) { + Token::revoke( $result['access_token'] ); + return $this->token_error( 'invalid_dpop_proof', 'DPoP proof required when refreshing a DPoP-bound token.' ); + } + + // Verify the proof was signed with the same key that was originally bound. + $new_token = Token::validate( $result['access_token'] ); + + if ( ! \is_wp_error( $new_token ) && ! hash_equals( $new_token->get_dpop_jkt(), $dpop_jkt ) ) { + Token::revoke( $result['access_token'] ); + return $this->token_error( 'invalid_dpop_proof', 'DPoP key does not match the original token binding.' ); + } + } + return $this->token_response( $result ); } @@ -754,6 +788,37 @@ private function token_response( $token_data ) { ); } + /** + * Validate a DPoP proof for a token endpoint request (RFC 9449). + * + * If no DPoP header is present, returns null (opt-in by client). + * If present and valid, returns the JWK thumbprint string. + * If present and invalid, returns a WP_Error. + * + * @since unreleased + * + * @return string|null|\WP_Error JWK thumbprint, null if no DPoP, or WP_Error. + */ + private function validate_dpop_for_token_request() { + $dpop_proof = DPoP::get_proof_from_request(); + + if ( ! $dpop_proof ) { + return null; + } + + $result = DPoP::validate_proof( + $dpop_proof, + DPoP::get_request_method(), + DPoP::get_request_uri() + ); + + if ( \is_wp_error( $result ) ) { + return $result; + } + + return $result['jkt']; + } + /** * Redirect with an OAuth error. * diff --git a/tests/phpunit/tests/includes/oauth/class-test-dpop.php b/tests/phpunit/tests/includes/oauth/class-test-dpop.php new file mode 100644 index 0000000000..e934b2e36d --- /dev/null +++ b/tests/phpunit/tests/includes/oauth/class-test-dpop.php @@ -0,0 +1,736 @@ +user_id = $this->factory->user->create( + array( 'role' => 'editor' ) + ); + + $client_result = Client::register( + array( + 'name' => 'DPoP Test Client', + 'redirect_uris' => array( 'https://example.com/callback' ), + ) + ); + $this->client_id = $client_result['client_id']; + + // Generate an EC P-256 key pair for testing. + $this->ec_private_key = openssl_pkey_new( + array( + 'curve_name' => 'prime256v1', + 'private_key_type' => OPENSSL_KEYTYPE_EC, + ) + ); + $ec_details = openssl_pkey_get_details( $this->ec_private_key ); + $this->ec_jwk = array( + 'kty' => 'EC', + 'crv' => 'P-256', + 'x' => DPoP::base64url_encode( str_pad( $ec_details['ec']['x'], 32, "\0", STR_PAD_LEFT ) ), + 'y' => DPoP::base64url_encode( str_pad( $ec_details['ec']['y'], 32, "\0", STR_PAD_LEFT ) ), + ); + + // Generate an RSA key pair for testing. + $this->rsa_private_key = openssl_pkey_new( + array( + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ) + ); + $rsa_details = openssl_pkey_get_details( $this->rsa_private_key ); + $this->rsa_jwk = array( + 'kty' => 'RSA', + 'n' => DPoP::base64url_encode( $rsa_details['rsa']['n'] ), + 'e' => DPoP::base64url_encode( $rsa_details['rsa']['e'] ), + ); + } + + /** + * Tear down the test. + */ + public function tear_down() { + if ( $this->client_id ) { + Client::delete( $this->client_id ); + } + parent::tear_down(); + } + + /** + * Test base64url encoding/decoding roundtrip. + * + * @covers ::base64url_encode + * @covers ::base64url_decode + */ + public function test_base64url_roundtrip() { + $data = random_bytes( 32 ); + $encoded = DPoP::base64url_encode( $data ); + $decoded = DPoP::base64url_decode( $encoded ); + + $this->assertEquals( $data, $decoded ); + // Base64url should not contain +, /, or =. + $this->assertStringNotContainsString( '+', $encoded ); + $this->assertStringNotContainsString( '/', $encoded ); + $this->assertStringNotContainsString( '=', $encoded ); + } + + /** + * Test JWK thumbprint computation for EC key (RFC 7638). + * + * @covers ::compute_jkt + */ + public function test_compute_jkt_ec() { + $jkt = DPoP::compute_jkt( $this->ec_jwk ); + + $this->assertNotInstanceOf( \WP_Error::class, $jkt ); + $this->assertIsString( $jkt ); + // Should be base64url-encoded SHA-256 (43 chars without padding). + $this->assertGreaterThan( 0, strlen( $jkt ) ); + } + + /** + * Test JWK thumbprint computation for RSA key. + * + * @covers ::compute_jkt + */ + public function test_compute_jkt_rsa() { + $jkt = DPoP::compute_jkt( $this->rsa_jwk ); + + $this->assertNotInstanceOf( \WP_Error::class, $jkt ); + $this->assertIsString( $jkt ); + } + + /** + * Test JWK thumbprint is deterministic. + * + * @covers ::compute_jkt + */ + public function test_compute_jkt_deterministic() { + $jkt1 = DPoP::compute_jkt( $this->ec_jwk ); + $jkt2 = DPoP::compute_jkt( $this->ec_jwk ); + + $this->assertEquals( $jkt1, $jkt2 ); + } + + /** + * Test JWK thumbprint fails for missing key type. + * + * @covers ::compute_jkt + */ + public function test_compute_jkt_missing_kty() { + $result = DPoP::compute_jkt( array( 'x' => 'foo' ) ); + $this->assertInstanceOf( \WP_Error::class, $result ); + } + + /** + * Test JWK thumbprint fails for unsupported key type. + * + * @covers ::compute_jkt + */ + public function test_compute_jkt_unsupported_kty() { + $result = DPoP::compute_jkt( array( 'kty' => 'OKP' ) ); + $this->assertInstanceOf( \WP_Error::class, $result ); + } + + /** + * Test valid DPoP proof with ES256. + * + * @covers ::validate_proof + */ + public function test_validate_proof_es256() { + $proof = $this->create_dpop_proof( 'ES256', $this->ec_jwk, $this->ec_private_key, 'POST', 'https://example.com/token' ); + $result = DPoP::validate_proof( $proof, 'POST', 'https://example.com/token' ); + + $this->assertNotInstanceOf( \WP_Error::class, $result ); + $this->assertArrayHasKey( 'jkt', $result ); + $this->assertEquals( DPoP::compute_jkt( $this->ec_jwk ), $result['jkt'] ); + } + + /** + * Test valid DPoP proof with RS256. + * + * @covers ::validate_proof + */ + public function test_validate_proof_rs256() { + $proof = $this->create_dpop_proof( 'RS256', $this->rsa_jwk, $this->rsa_private_key, 'POST', 'https://example.com/token' ); + $result = DPoP::validate_proof( $proof, 'POST', 'https://example.com/token' ); + + $this->assertNotInstanceOf( \WP_Error::class, $result ); + $this->assertArrayHasKey( 'jkt', $result ); + $this->assertEquals( DPoP::compute_jkt( $this->rsa_jwk ), $result['jkt'] ); + } + + /** + * Test DPoP proof with access token hash (ath). + * + * @covers ::validate_proof + */ + public function test_validate_proof_with_ath() { + $access_token = 'test_access_token_value'; + $ath = DPoP::base64url_encode( hash( 'sha256', $access_token, true ) ); + + $proof = $this->create_dpop_proof( + 'ES256', + $this->ec_jwk, + $this->ec_private_key, + 'GET', + 'https://example.com/resource', + array( 'ath' => $ath ) + ); + + $result = DPoP::validate_proof( $proof, 'GET', 'https://example.com/resource', $access_token ); + + $this->assertNotInstanceOf( \WP_Error::class, $result ); + $this->assertArrayHasKey( 'jkt', $result ); + } + + /** + * Test DPoP proof rejected when ath is missing but access token provided. + * + * @covers ::validate_proof + */ + public function test_validate_proof_missing_ath() { + $proof = $this->create_dpop_proof( + 'ES256', + $this->ec_jwk, + $this->ec_private_key, + 'GET', + 'https://example.com/resource' + ); + + $result = DPoP::validate_proof( $proof, 'GET', 'https://example.com/resource', 'some_token' ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertEquals( 'activitypub_dpop_missing_ath', $result->get_error_code() ); + } + + /** + * Test DPoP proof rejected when ath doesn't match. + * + * @covers ::validate_proof + */ + public function test_validate_proof_wrong_ath() { + $ath = DPoP::base64url_encode( hash( 'sha256', 'wrong_token', true ) ); + + $proof = $this->create_dpop_proof( + 'ES256', + $this->ec_jwk, + $this->ec_private_key, + 'GET', + 'https://example.com/resource', + array( 'ath' => $ath ) + ); + + $result = DPoP::validate_proof( $proof, 'GET', 'https://example.com/resource', 'correct_token' ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertEquals( 'activitypub_dpop_ath_mismatch', $result->get_error_code() ); + } + + /** + * Test DPoP proof rejected with wrong HTTP method. + * + * @covers ::validate_proof + */ + public function test_validate_proof_wrong_method() { + $proof = $this->create_dpop_proof( 'ES256', $this->ec_jwk, $this->ec_private_key, 'POST', 'https://example.com/token' ); + $result = DPoP::validate_proof( $proof, 'GET', 'https://example.com/token' ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertEquals( 'activitypub_dpop_method_mismatch', $result->get_error_code() ); + } + + /** + * Test DPoP proof rejected with wrong URI. + * + * @covers ::validate_proof + */ + public function test_validate_proof_wrong_uri() { + $proof = $this->create_dpop_proof( 'ES256', $this->ec_jwk, $this->ec_private_key, 'POST', 'https://example.com/token' ); + $result = DPoP::validate_proof( $proof, 'POST', 'https://other.com/token' ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertEquals( 'activitypub_dpop_uri_mismatch', $result->get_error_code() ); + } + + /** + * Test DPoP proof rejected when expired. + * + * @covers ::validate_proof + */ + public function test_validate_proof_expired() { + $proof = $this->create_dpop_proof( + 'ES256', + $this->ec_jwk, + $this->ec_private_key, + 'POST', + 'https://example.com/token', + array(), + time() - 600 // 10 minutes ago, beyond MAX_AGE. + ); + + $result = DPoP::validate_proof( $proof, 'POST', 'https://example.com/token' ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertEquals( 'activitypub_dpop_expired', $result->get_error_code() ); + } + + /** + * Test DPoP proof rejected when issued in the future. + * + * @covers ::validate_proof + */ + public function test_validate_proof_future_iat() { + $proof = $this->create_dpop_proof( + 'ES256', + $this->ec_jwk, + $this->ec_private_key, + 'POST', + 'https://example.com/token', + array(), + time() + 60 // 60 seconds in the future (beyond 5s skew allowance). + ); + + $result = DPoP::validate_proof( $proof, 'POST', 'https://example.com/token' ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertEquals( 'activitypub_dpop_future_iat', $result->get_error_code() ); + } + + /** + * Test DPoP proof rejected with malformed JWT. + * + * @covers ::validate_proof + */ + public function test_validate_proof_malformed_jwt() { + $result = DPoP::validate_proof( 'not.a.valid.jwt', 'POST', 'https://example.com/token' ); + $this->assertInstanceOf( \WP_Error::class, $result ); + } + + /** + * Test DPoP proof rejected with wrong typ. + * + * @covers ::validate_proof + */ + public function test_validate_proof_wrong_typ() { + $header = array( + 'typ' => 'JWT', + 'alg' => 'ES256', + 'jwk' => $this->ec_jwk, + ); + + $payload = array( + 'jti' => wp_generate_uuid4(), + 'htm' => 'POST', + 'htu' => 'https://example.com/token', + 'iat' => time(), + ); + + $proof = $this->sign_jwt( $header, $payload, $this->ec_private_key, 'ES256' ); + $result = DPoP::validate_proof( $proof, 'POST', 'https://example.com/token' ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertEquals( 'activitypub_dpop_invalid_typ', $result->get_error_code() ); + } + + /** + * Test DPoP proof rejected with symmetric algorithm. + * + * @covers ::validate_proof + */ + public function test_validate_proof_symmetric_alg() { + // Craft a proof claiming HS256 (symmetric) — must be rejected. + $header = DPoP::base64url_encode( + wp_json_encode( + array( + 'typ' => 'dpop+jwt', + 'alg' => 'HS256', + 'jwk' => $this->ec_jwk, + ) + ) + ); + $payload = DPoP::base64url_encode( + wp_json_encode( + array( + 'jti' => wp_generate_uuid4(), + 'htm' => 'POST', + 'htu' => 'https://example.com/token', + 'iat' => time(), + ) + ) + ); + $sig = DPoP::base64url_encode( 'fakesig' ); + + $result = DPoP::validate_proof( "$header.$payload.$sig", 'POST', 'https://example.com/token' ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertEquals( 'activitypub_dpop_unsupported_alg', $result->get_error_code() ); + } + + /** + * Test DPoP proof rejected with bad signature. + * + * @covers ::validate_proof + */ + public function test_validate_proof_bad_signature() { + // Create valid proof but tamper with the signature. + $proof = $this->create_dpop_proof( 'ES256', $this->ec_jwk, $this->ec_private_key, 'POST', 'https://example.com/token' ); + $parts = explode( '.', $proof ); + // Replace signature with random data. + $parts[2] = DPoP::base64url_encode( random_bytes( 64 ) ); + + $result = DPoP::validate_proof( implode( '.', $parts ), 'POST', 'https://example.com/token' ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertEquals( 'activitypub_dpop_bad_signature', $result->get_error_code() ); + } + + /** + * Test token creation with DPoP binding. + * + * @covers \Activitypub\OAuth\Token::create + */ + public function test_token_create_with_dpop() { + $jkt = DPoP::compute_jkt( $this->ec_jwk ); + $result = Token::create( $this->user_id, $this->client_id, array( Scope::READ ), Token::DEFAULT_EXPIRATION, $jkt ); + + $this->assertIsArray( $result ); + $this->assertEquals( 'DPoP', $result['token_type'] ); + } + + /** + * Test token creation without DPoP (backward compatible). + * + * @covers \Activitypub\OAuth\Token::create + */ + public function test_token_create_without_dpop() { + $result = Token::create( $this->user_id, $this->client_id, array( Scope::READ ) ); + + $this->assertIsArray( $result ); + $this->assertEquals( 'Bearer', $result['token_type'] ); + } + + /** + * Test validated token exposes DPoP JKT. + * + * @covers \Activitypub\OAuth\Token::get_dpop_jkt + */ + public function test_token_get_dpop_jkt() { + $jkt = DPoP::compute_jkt( $this->ec_jwk ); + $result = Token::create( $this->user_id, $this->client_id, array( Scope::READ ), Token::DEFAULT_EXPIRATION, $jkt ); + $token = Token::validate( $result['access_token'] ); + + $this->assertInstanceOf( Token::class, $token ); + $this->assertEquals( $jkt, $token->get_dpop_jkt() ); + } + + /** + * Test validated token without DPoP returns null JKT. + * + * @covers \Activitypub\OAuth\Token::get_dpop_jkt + */ + public function test_token_get_dpop_jkt_null_when_no_dpop() { + $result = Token::create( $this->user_id, $this->client_id, array( Scope::READ ) ); + $token = Token::validate( $result['access_token'] ); + + $this->assertInstanceOf( Token::class, $token ); + $this->assertNull( $token->get_dpop_jkt() ); + } + + /** + * Test DPoP binding preserved through refresh. + * + * @covers \Activitypub\OAuth\Token::refresh + */ + public function test_refresh_preserves_dpop_binding() { + $jkt = DPoP::compute_jkt( $this->ec_jwk ); + $original = Token::create( $this->user_id, $this->client_id, array( Scope::READ ), Token::DEFAULT_EXPIRATION, $jkt ); + + $this->assertEquals( 'DPoP', $original['token_type'] ); + + $refreshed = Token::refresh( $original['refresh_token'], $this->client_id ); + + $this->assertIsArray( $refreshed ); + $this->assertEquals( 'DPoP', $refreshed['token_type'] ); + + // Validate the new token has the same JKT. + $token = Token::validate( $refreshed['access_token'] ); + $this->assertEquals( $jkt, $token->get_dpop_jkt() ); + } + + /** + * Test DPoP-bound token introspection includes cnf claim. + * + * @covers \Activitypub\OAuth\Token::introspect + */ + public function test_introspect_dpop_token() { + $jkt = DPoP::compute_jkt( $this->ec_jwk ); + $result = Token::create( $this->user_id, $this->client_id, array( Scope::READ ), Token::DEFAULT_EXPIRATION, $jkt ); + + $introspection = Token::introspect( $result['access_token'] ); + + $this->assertTrue( $introspection['active'] ); + $this->assertEquals( 'DPoP', $introspection['token_type'] ); + $this->assertArrayHasKey( 'cnf', $introspection ); + $this->assertEquals( $jkt, $introspection['cnf']['jkt'] ); + } + + /** + * Test non-DPoP token introspection does not include cnf. + * + * @covers \Activitypub\OAuth\Token::introspect + */ + public function test_introspect_bearer_token_no_cnf() { + $result = Token::create( $this->user_id, $this->client_id, array( Scope::READ ) ); + + $introspection = Token::introspect( $result['access_token'] ); + + $this->assertTrue( $introspection['active'] ); + $this->assertEquals( 'Bearer', $introspection['token_type'] ); + $this->assertArrayNotHasKey( 'cnf', $introspection ); + } + + /** + * Test DPoP proof rejected on jti replay. + * + * @covers ::validate_proof + */ + public function test_validate_proof_jti_replay() { + $jti = wp_generate_uuid4(); + $proof = $this->create_dpop_proof( + 'ES256', + $this->ec_jwk, + $this->ec_private_key, + 'POST', + 'https://example.com/token', + array(), + null, + $jti + ); + + // First use should succeed. + $result = DPoP::validate_proof( $proof, 'POST', 'https://example.com/token' ); + $this->assertNotInstanceOf( \WP_Error::class, $result ); + + // Create a second proof with the same jti (replay). + $replay_proof = $this->create_dpop_proof( + 'ES256', + $this->ec_jwk, + $this->ec_private_key, + 'POST', + 'https://example.com/token', + array(), + null, + $jti + ); + + $replay_result = DPoP::validate_proof( $replay_proof, 'POST', 'https://example.com/token' ); + $this->assertInstanceOf( \WP_Error::class, $replay_result ); + $this->assertEquals( 'activitypub_dpop_jti_replayed', $replay_result->get_error_code() ); + } + + /** + * 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() { + // Create a token bound to EC key. + $ec_jkt = DPoP::compute_jkt( $this->ec_jwk ); + $original = Token::create( $this->user_id, $this->client_id, array( Scope::READ ), Token::DEFAULT_EXPIRATION, $ec_jkt ); + + $this->assertEquals( 'DPoP', $original['token_type'] ); + + // Refresh the token (refresh internally preserves the dpop_jkt). + $refreshed = Token::refresh( $original['refresh_token'], $this->client_id ); + $this->assertIsArray( $refreshed ); + $this->assertEquals( 'DPoP', $refreshed['token_type'] ); + + // The refreshed token still has the original EC key binding. + $new_token = Token::validate( $refreshed['access_token'] ); + $this->assertEquals( $ec_jkt, $new_token->get_dpop_jkt() ); + + // An RSA key would produce a different jkt — the controller should reject this. + $rsa_jkt = DPoP::compute_jkt( $this->rsa_jwk ); + $this->assertNotEquals( $ec_jkt, $rsa_jkt ); + } + + /** + * Test DPoP proof URI normalization ignores query string. + * + * @covers ::validate_proof + */ + public function test_validate_proof_uri_ignores_query() { + $proof = $this->create_dpop_proof( + 'ES256', + $this->ec_jwk, + $this->ec_private_key, + 'POST', + 'https://example.com/token' + ); + + // Request URI with query string should still match. + $result = DPoP::validate_proof( $proof, 'POST', 'https://example.com/token?foo=bar' ); + + $this->assertNotInstanceOf( \WP_Error::class, $result ); + } + + /** + * Helper: create a signed DPoP proof JWT. + * + * @param string $alg Algorithm (ES256 or RS256). + * @param array $jwk JWK public key. + * @param resource $private_key Private key resource. + * @param string $htm HTTP method. + * @param string $htu HTTP URI. + * @param array $extra Extra payload claims. + * @param int|null $iat Override iat timestamp. + * @param string|null $jti Override jti value (for replay testing). + * @return string The signed JWT. + */ + private function create_dpop_proof( $alg, $jwk, $private_key, $htm, $htu, $extra = array(), $iat = null, $jti = null ) { + $header = array( + 'typ' => 'dpop+jwt', + 'alg' => $alg, + 'jwk' => $jwk, + ); + + $payload = array_merge( + array( + 'jti' => null !== $jti ? $jti : wp_generate_uuid4(), + 'htm' => $htm, + 'htu' => $htu, + 'iat' => null !== $iat ? $iat : time(), + ), + $extra + ); + + return $this->sign_jwt( $header, $payload, $private_key, $alg ); + } + + /** + * Helper: sign a JWT with the given key. + * + * @param array $header JWT header. + * @param array $payload JWT payload. + * @param resource $private_key Private key resource. + * @param string $alg Algorithm. + * @return string The signed JWT. + */ + private function sign_jwt( $header, $payload, $private_key, $alg ) { + $header_b64 = DPoP::base64url_encode( wp_json_encode( $header ) ); + $payload_b64 = DPoP::base64url_encode( wp_json_encode( $payload ) ); + $signing_input = $header_b64 . '.' . $payload_b64; + + $signature = ''; + openssl_sign( $signing_input, $signature, $private_key, OPENSSL_ALGO_SHA256 ); + + if ( 'ES256' === $alg ) { + // Convert DER signature to JWS R||S format. + $signature = $this->ecdsa_der_to_jws( $signature ); + } + + $sig_b64 = DPoP::base64url_encode( $signature ); + + return $signing_input . '.' . $sig_b64; + } + + /** + * Helper: convert an ECDSA DER signature to JWS R||S format. + * + * @param string $der_sig DER-encoded ECDSA signature. + * @return string The R||S concatenation (64 bytes for P-256). + */ + private function ecdsa_der_to_jws( $der_sig ) { + // Parse the DER SEQUENCE. + $offset = 2; // Skip SEQUENCE tag and length. + if ( ord( $der_sig[1] ) & 0x80 ) { + $offset = 2 + ( ord( $der_sig[1] ) & 0x7F ); + } + + // Parse R. + $r_length = ord( $der_sig[ $offset + 1 ] ); + $r = substr( $der_sig, $offset + 2, $r_length ); + $offset += 2 + $r_length; + + // Parse S. + $s_length = ord( $der_sig[ $offset + 1 ] ); + $s = substr( $der_sig, $offset + 2, $s_length ); + + // Remove leading zero bytes and pad to 32 bytes. + $r = ltrim( $r, "\x00" ); + $s = ltrim( $s, "\x00" ); + + $r = str_pad( $r, 32, "\x00", STR_PAD_LEFT ); + $s = str_pad( $s, 32, "\x00", STR_PAD_LEFT ); + + return $r . $s; + } +}