From 8bbe73309a8953644cad059b435ef15d17129f94 Mon Sep 17 00:00:00 2001 From: Jeffrey Parker Date: Wed, 15 Jul 2026 15:09:23 -0400 Subject: [PATCH] Add appendToUserAgent() method to Client class Allow callers to append custom identifiers to the User-Agent header, matching the pattern established in duo_universal_php. The extension is trimmed before storage and appended space-separated after the existing UA string. Subsequent calls overwrite (last value wins). No-op when the extension is empty or whitespace-only. --- src/Client.php | 13 ++++- tests/Unit/ClientTest.php | 113 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/src/Client.php b/src/Client.php index bd6a9c3..c47d27b 100644 --- a/src/Client.php +++ b/src/Client.php @@ -22,6 +22,7 @@ class Client public $paging; public $options; public $sleep_service; + private $user_agent_extension = ""; public function __construct( $ikey, @@ -89,6 +90,12 @@ public function disableCaPinning() return $this; } + public function appendToUserAgent(string $user_agent_extension) + { + $this->user_agent_extension = trim($user_agent_extension); + return $this; + } + private function signParameters($method, $host, $path, $params, $skey, $ikey, $now, $body, $additional_headers) { assert(is_string($method)); @@ -242,9 +249,13 @@ public function apiCall($method, $path, $params, $additional_headers = []) $headers["Date"] = $now; $ca_pinning_status = (!empty($this->options["disable_ca_pinning"])) ? "disabled" : "enabled"; - $headers["User-Agent"] = "duo_api_php/" . VERSION + $user_agent = "duo_api_php/" . VERSION . " ca_bundle/" . CA_BUNDLE_VERSION . " (ca_pinning=" . $ca_pinning_status . ")"; + if (!empty($this->user_agent_extension)) { + $user_agent .= " " . $this->user_agent_extension; + } + $headers["User-Agent"] = $user_agent; $headers["Authorization"] = self::signParameters( $method, $this->host, diff --git a/tests/Unit/ClientTest.php b/tests/Unit/ClientTest.php index b5adefd..57a33d1 100644 --- a/tests/Unit/ClientTest.php +++ b/tests/Unit/ClientTest.php @@ -533,6 +533,119 @@ public function testUserAgentWithCaPinningDisabled() $response = $client->apiCall("GET", "/foo/bar", []); } + public function testUserAgentWithExtension() + { + $success_resp = [ + "success" => true, + "response" => "not rate limited", + "http_status_code" => 200, + ]; + + $response = [$success_resp]; + + $client = self::getMockedClient("Client", $response, $paged = true); + $client->appendToUserAgent("MyApp/1.0"); + $this->mocked_curl_requester->method('execute')->with( + $this->anything(), + $this->anything(), + $this->callback(function($headers) { + $expected = "duo_api_php/" . \DuoAPI\VERSION + . " ca_bundle/" . \DuoAPI\CA_BUNDLE_VERSION + . " (ca_pinning=enabled) MyApp/1.0"; + $this->assertArrayHasKey("User-Agent", $headers); + $this->assertEquals($expected, $headers["User-Agent"]); + return true; + }), + $this->anything() + ); + $response = $client->apiCall("GET", "/foo/bar", []); + } + + public function testUserAgentWithWhitespaceExtension() + { + $success_resp = [ + "success" => true, + "response" => "not rate limited", + "http_status_code" => 200, + ]; + + $response = [$success_resp]; + + $client = self::getMockedClient("Client", $response, $paged = true); + $client->appendToUserAgent(" "); + $this->mocked_curl_requester->method('execute')->with( + $this->anything(), + $this->anything(), + $this->callback(function($headers) { + $expected = "duo_api_php/" . \DuoAPI\VERSION + . " ca_bundle/" . \DuoAPI\CA_BUNDLE_VERSION + . " (ca_pinning=enabled)"; + $this->assertArrayHasKey("User-Agent", $headers); + $this->assertEquals($expected, $headers["User-Agent"]); + return true; + }), + $this->anything() + ); + $response = $client->apiCall("GET", "/foo/bar", []); + } + + public function testUserAgentExtensionIsTrimmed() + { + $success_resp = [ + "success" => true, + "response" => "not rate limited", + "http_status_code" => 200, + ]; + + $response = [$success_resp]; + + $client = self::getMockedClient("Client", $response, $paged = true); + $client->appendToUserAgent(" MyApp/1.0 "); + $this->mocked_curl_requester->method('execute')->with( + $this->anything(), + $this->anything(), + $this->callback(function($headers) { + $expected = "duo_api_php/" . \DuoAPI\VERSION + . " ca_bundle/" . \DuoAPI\CA_BUNDLE_VERSION + . " (ca_pinning=enabled) MyApp/1.0"; + $this->assertArrayHasKey("User-Agent", $headers); + $this->assertEquals($expected, $headers["User-Agent"]); + return true; + }), + $this->anything() + ); + $response = $client->apiCall("GET", "/foo/bar", []); + } + + public function testAppendToUserAgentOverwritesPrevious() + { + $success_resp = [ + "success" => true, + "response" => "not rate limited", + "http_status_code" => 200, + ]; + + $response = [$success_resp]; + + $client = self::getMockedClient("Client", $response, $paged = true); + $client->appendToUserAgent("First/1.0"); + $client->appendToUserAgent("Second/2.0"); + $this->mocked_curl_requester->method('execute')->with( + $this->anything(), + $this->anything(), + $this->callback(function($headers) { + $expected = "duo_api_php/" . \DuoAPI\VERSION + . " ca_bundle/" . \DuoAPI\CA_BUNDLE_VERSION + . " (ca_pinning=enabled) Second/2.0"; + $this->assertArrayHasKey("User-Agent", $headers); + $this->assertEquals($expected, $headers["User-Agent"]); + return true; + }), + $this->anything() + ); + $response = $client->apiCall("GET", "/foo/bar", []); + } + public function testDisableCaPinning() { $client = new \DuoAPI\Client("TEST", "TEST", "TEST");