}|WP_Error error code is the reason; callers map it to invalid_dpop_proof. */ public static function verify_request( string $method, string $url, ?string $access_token = null, ?string $bound_jkt = null ) { $proof = AgentAdapt_Http::header( 'DPoP' ); if ( null === $proof || '' === $proof ) { return new WP_Error( 'missing', 'DPoP proof required' ); } if ( AgentAdapt_Http::header_is_repeated( 'DPoP' ) ) { return new WP_Error( 'repeated', 'exactly one DPoP header is allowed' ); } return self::verify( $proof, $method, $url, $access_token, $bound_jkt ); } /** * Verify a proof string. * * @param string $proof Compact JWS. * @param string $method Request method. * @param string $url Public URL of the request. * @param string|null $access_token Token presented with it. * @param string|null $bound_jkt Thumbprint the token is bound to. * @return array{jkt: string, jwk: array}|WP_Error */ public static function verify( string $proof, string $method, string $url, ?string $access_token = null, ?string $bound_jkt = null ) { $peek = AgentAdapt_Jose::peek( $proof ); if ( null === $peek ) { return new WP_Error( 'malformed', 'DPoP proof is not a compact JWS' ); } [ $header ] = $peek; if ( 0 !== strcasecmp( (string) ( $header['typ'] ?? '' ), 'dpop+jwt' ) ) { return new WP_Error( 'typ', 'DPoP proof typ must be dpop+jwt' ); } $jwk = $header['jwk'] ?? null; if ( ! is_array( $jwk ) ) { return new WP_Error( 'jwk', 'DPoP proof has no jwk header' ); } $key = AgentAdapt_Jose::key_from_jwk( $jwk ); if ( null === $key ) { return new WP_Error( 'jwk', 'DPoP jwk is not an acceptable public key' ); } $verified = AgentAdapt_Jose::verify( $proof, array( '' => $key ) + ( isset( $header['kid'] ) ? array( (string) $header['kid'] => $key ) : array() ), 'dpop+jwt' ); if ( is_wp_error( $verified ) ) { return new WP_Error( 'signature', 'DPoP proof signature does not verify' ); } $c = $verified['claims']; $jkt = AgentAdapt_Jose::thumbprint( $jwk ); if ( strtoupper( $method ) !== ( $c['htm'] ?? null ) ) { return new WP_Error( 'htm', 'DPoP htm does not match the request method' ); } $htu = is_string( $c['htu'] ?? null ) ? AgentAdapt_Urls::normalise( $c['htu'] ) : ''; if ( '' === $htu || AgentAdapt_Urls::normalise( $url ) !== $htu ) { return new WP_Error( 'htu', 'DPoP htu does not match the request URL' ); } $iat = $c['iat'] ?? null; $now = time(); if ( ! is_int( $iat ) || $iat < $now - self::WINDOW || $iat > $now + AgentAdapt_Jose::LEEWAY ) { return new WP_Error( 'iat', 'DPoP proof is outside the acceptance window' ); } $jti = $c['jti'] ?? null; if ( ! is_string( $jti ) || strlen( $jti ) < 16 || strlen( $jti ) > 256 ) { return new WP_Error( 'jti', 'DPoP proof needs a jti' ); } if ( null !== $access_token ) { if ( ! is_string( $c['ath'] ?? null ) || ! hash_equals( AgentAdapt_Jose::ath( $access_token ), $c['ath'] ) ) { return new WP_Error( 'ath', 'DPoP ath does not match the access token' ); } } if ( null !== $bound_jkt && ! hash_equals( $bound_jkt, $jkt ) ) { return new WP_Error( 'jkt', 'DPoP key is not the key this token is bound to' ); } // Last: only a proof that passed everything else consumes its jti, so a // proof rejected for another reason can be corrected and resent. if ( ! AgentAdapt_Replay::claim( 'dpop', $jkt, $jti, $iat + self::WINDOW ) ) { return new WP_Error( 'replay', 'DPoP proof was already used' ); } return array( 'jkt' => $jkt, 'jwk' => $jwk ); } }