├── .gitignore ├── example ├── static │ ├── images │ │ └── logo.png │ └── style.css ├── duo.conf ├── composer.json ├── dockerfile ├── templates │ ├── success.php │ └── login.php ├── README.md └── index.php ├── .duo_linting.xml ├── SECURITY.md ├── src ├── DuoException.php ├── Client.php └── ca_certs.pem ├── composer.json ├── .github └── workflows │ └── php_ci.yml ├── LICENSE ├── README.md └── tests └── ClientTest.php /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | vendor 3 | *.swp 4 | *.swo 5 | .idea 6 | -------------------------------------------------------------------------------- /example/static/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duosecurity/duo_universal_php/HEAD/example/static/images/logo.png -------------------------------------------------------------------------------- /example/duo.conf: -------------------------------------------------------------------------------- 1 | ; Duo integration config 2 | [duo] 3 | client_id = 4 | client_secret = 5 | api_hostname = 6 | redirect_uri = http://localhost:8080/duo-callback 7 | failmode = closed 8 | ; Uncomment to use an HTTP proxy server 9 | ; http_proxy = localhost:8081 10 | -------------------------------------------------------------------------------- /.duo_linting.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | This standard changes the line length 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /example/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": { 3 | "slim/slim": "4.12.0", 4 | "slim/psr7": "1.6.1", 5 | "slim/php-view": "3.2.0", 6 | "bryanjhv/slim-session": "4.1.2", 7 | "duosecurity/duo_universal_php": "@dev" 8 | }, 9 | "repositories": [ 10 | { 11 | "type": "path", 12 | "url": "../" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /example/dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:8 2 | EXPOSE 8080 3 | RUN apt update && apt install -y unzip wget 4 | WORKDIR /root 5 | RUN wget https://raw.githubusercontent.com/composer/getcomposer.org/885ece8a6e1370b204b89b7a542169d25aa21177/web/installer -O - -q | php -- --quiet 6 | 7 | ADD ./composer.json /src/composer.json 8 | WORKDIR /src 9 | RUN /root/composer.phar update 10 | ADD . /src 11 | ENTRYPOINT ["php", "-S", "0.0.0.0:8080"] 12 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | Duo is committed to providing secure software to all our customers and users. We take all security concerns seriously and ask that any disclosures be handled responsibly. 2 | 3 | # Security Policy 4 | 5 | ## Reporting a Vulnerability 6 | **Please do not use Github issues or pull requests to report security vulnerabilities.** 7 | 8 | If you believe you have found a security vulnerability in Duo software, please follow our response process described at https://duo.com/support/security-and-reliability/security-response. 9 | -------------------------------------------------------------------------------- /example/templates/success.php: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 8 |
9 |

Auth Response:

10 |
11 |
12 |
13 |
14 |
15 | 16 | -------------------------------------------------------------------------------- /src/DuoException.php: -------------------------------------------------------------------------------- 1 | 11 | * @license https://license_url TODO 12 | * @link TODO 13 | * @file 14 | */ 15 | namespace Duo\DuoUniversal; 16 | 17 | /** 18 | * This class contains a Duo specific exception 19 | * 20 | * @category TODO 21 | * @package DuoUniversal 22 | * @author Duo Security 23 | * @license https://license_url TODO 24 | * @link TODO 25 | */ 26 | class DuoException extends \Exception 27 | { 28 | } 29 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "duosecurity/duo_universal_php", 3 | "description": "A PHP implementation of the Duo Universal SDK.", 4 | "homepage": "https://duo.com/", 5 | "license": "BSD-3-Clause", 6 | "support": { 7 | "email": "support@duosecurity.com" 8 | }, 9 | "autoload": { 10 | "psr-4": { 11 | "Duo\\DuoUniversal\\": "src/" 12 | } 13 | }, 14 | "autoload-dev": { 15 | "psr-4": { 16 | "Unit\\": "tests/" 17 | } 18 | }, 19 | "require": { 20 | "php": ">=7.4", 21 | "ext-curl": "*", 22 | "ext-json": "*", 23 | "firebase/php-jwt": "^6.0" 24 | }, 25 | "require-dev": { 26 | "phpunit/phpunit": "^9.0", 27 | "squizlabs/php_codesniffer": "3.*" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /example/static/style.css: -------------------------------------------------------------------------------- 1 | body, input { 2 | font: 17px arial, sans-serif; 3 | } 4 | 5 | .content, 6 | .input-form { 7 | display: flex; 8 | flex-direction: column; 9 | align-items: center; 10 | } 11 | 12 | img { 13 | padding: 20px; 14 | height: 84px; 15 | margin-top: 40px; 16 | } 17 | 18 | input { 19 | width: 250px; 20 | padding: 12px; 21 | margin-top: 10px; 22 | } 23 | 24 | input[type=text] { 25 | margin-left: 44px; 26 | } 27 | 28 | input[type=password] { 29 | margin-left: 10px; 30 | } 31 | 32 | h3 { 33 | margin-top: 23px; 34 | } 35 | 36 | pre.language-json { 37 | font-size: 20px; 38 | } 39 | 40 | div.success { 41 | margin-top: 20px; 42 | } 43 | 44 | pre.auth-token { 45 | display: inline-block; 46 | text-align: left; 47 | } 48 | 49 | button { 50 | background-color: #6BBF4E; 51 | color: white; 52 | border: none; 53 | padding: 9px 18px; 54 | margin-top: 17px; 55 | border-radius: 4px; 56 | } 57 | -------------------------------------------------------------------------------- /example/templates/login.php: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 8 |
9 |
10 |
11 |
12 |
13 | 14 | 15 |
16 |
17 | 18 | 19 |
20 |
21 | 22 |
23 |
24 |
25 | 26 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Duo Universal PHP SDK Demo 2 | 3 | A simple PHP web application that serves a logon page integrated with Duo 2FA. 4 | 5 | ## Setup 6 | Change to the "example" directory 7 | ``` 8 | cd example 9 | ``` 10 | 11 | Install the demo requirements: 12 | ``` 13 | composer update 14 | ``` 15 | 16 | Then, create a `Web SDK` application in the Duo Admin Panel. See https://duo.com/docs/protecting-applications for more details. 17 | 18 | ## Using the App 19 | 20 | 1. Copy the Client ID, Client Secret, and API Hostname values for your `Web SDK` application into the `duo.conf` file. 21 | 1. Start the app. 22 | ``` 23 | php -d session.save_path=/tmp -S localhost:8080 24 | ``` 25 | 1. Navigate to http://localhost:8080. 26 | 1. Log in with the user you would like to enroll in Duo or with an already enrolled user (any password will work). 27 | 28 | ## (Optional) Run the demo using docker 29 | 30 | A dockerfile is included to easily run the demo app with a known working PHP configuration. 31 | 32 | 1. Copy the Client ID, Client Secret, and API Hostname values for your `Web SDK` application into the `duo.conf` file. 33 | 1. Build the docker image: `docker build -t duo_php_example .` 34 | 1. Run the docker container `docker run -p 8080:8080 duo_php_example` 35 | 1. Navigate to http://localhost:8080. 36 | 1. Log in with the user you would like to enroll in Duo or with an already enrolled user (any password will work). 37 | -------------------------------------------------------------------------------- /.github/workflows/php_ci.yml: -------------------------------------------------------------------------------- 1 | name: PHP CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | 10 | jobs: 11 | ci: 12 | name: PHP CI - test 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | php: [7.4, 8.0, 8.1, 8.2, 8.3, 8.4, 8.5] 18 | 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v3 22 | 23 | - name: Setup PHP 24 | uses: shivammathur/setup-php@8e2ac35f639d3e794c1da1f28999385ab6fdf0fc 25 | with: 26 | php-version: ${{ matrix.php }} 27 | coverage: xdebug 28 | 29 | - name: Composer install 30 | run: composer install 31 | 32 | - name: PHP Linting 33 | run: ./vendor/bin/phpcs --standard=.duo_linting.xml -n src/* tests 34 | 35 | - name: PHP tests 36 | run: ./vendor/bin/phpunit --process-isolation tests 37 | 38 | - name: Composer install example 39 | working-directory: example 40 | run: composer install 41 | 42 | - name: Inject dummy example config 43 | working-directory: example 44 | run: printf "[duo]\nclient_id=DIAAAAAAAAAAAAAAAAAA\nclient_secret=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\napi_hostname=example.duosecurity.com\nredirect_uri=http://localhost:8080\nfailmode=closed\n" > ./duo.conf 45 | 46 | - name: Ensure example runs 47 | working-directory: example 48 | run: php index.php 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022 Cisco Systems, Inc. and/or its affiliates 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Duo Universal PHP library 2 | 3 | [![Build Status](https://github.com/duosecurity/duo_universal_php/workflows/PHP%20CI/badge.svg)](https://github.com/duosecurity/duo_universal_php/actions) 4 | 5 | This SDK allows a web developer to quickly add Duo's interactive, self-service, two-factor authentication to any PHP web login form. 6 | 7 | 8 | What's included: 9 | * `src` - The PHP Duo SDK for interacting with the Duo Universal Prompt 10 | * `example` - An example PHP application with Duo integrated 11 | * `tests` - Test cases 12 | 13 | ## Getting started 14 | This library requires PHP 7.4 or later 15 | 16 | To use SDK in your existing developing environment, install it from Packagist 17 | ``` 18 | composer require duosecurity/duo_universal_php 19 | ``` 20 | Once it's installed, see our developer documentation at https://duo.com/docs/duoweb and `sample/index.php` in this repo for guidance on integrating Duo 2FA into your web application. 21 | 22 | ### TLS 1.2 and 1.3 Support 23 | 24 | Duo_universal_php uses PHP's cURL extension and OpenSSL for TLS operations. TLS support will depend on the versions of multiple libraries: 25 | 26 | TLS 1.2 support requires PHP 5.5 or higher, curl 7.34.0 or higher, and OpenSSL 1.0.1 or higher. 27 | 28 | TLS 1.3 support requires PHP 7.3 or higher, curl 7.61.0 or higher, and OpenSSL 1.1.1 or higher. 29 | 30 | 31 | ## Contribute 32 | To contribute, fork this repo and make a pull request with your changes when they are ready. 33 | 34 | Install the SDK from source: 35 | ``` 36 | composer install 37 | ``` 38 | 39 | Run interactive mode 40 | ``` 41 | php -a -d auto_prepend_file=vendor/autoload.php 42 | 43 | Interactive shell 44 | 45 | php > $client = new Duo\DuoUniversal\Client("IntegrationKey", "SecretKey", "api-XXXXXXXX.duosecurity.com", "https://example.com"); 46 | php > $state = $client->generateState(); 47 | php > $username = "example"; 48 | string(700) "https://api-XXXXXXXX.duosecurity.com/oauth/v1/authorize?response_type=code&client_id=DIXXXXXXXXXXXXXXXXXX&scope=openid&redirect_uri=https%3A%2F%2Fexample.com&request=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzY29wZSI6Im9wZW5pZCIsInJlZGlyZWN0X3VyaSI6Imh0dHBzOlwvXC9leGFtcGxlLmNvbSIsImNsaWVudF9pZCI6IkRJWFhYWFhYWFhYWFhYWFhYWFhYIiwiaXNzIjoiRElYWFhYWFhYWFhYWFhYWFhYWFgiLCJhdWQiOiJodHRwczpcL1wvYXBpLVhYWFhYWFhYLmR1b3NlY3VyaXR5LmNvbSIsImV4cCI6MTYxMjI5OTA3Nywic3RhdGUiOiJtYjlWalFGeDNzMEswRVpidVBJMmlCVWE4N29qbWFMTUl2VksiLCJyZXNwb25zZV90eXBlIjoiY29kZSIsImR1b191bmFtZSI6ImV4YW1wbGUiLCJ1c2VfZHVvX2NvZGVfYXR0cmlidXRlIjp0cnVlfQ.8Pr02LJd0pi6rsiAf5mvzGbf51piHysHyP5PlmnMiwNIkQ0HsYED0wECilXxsIyISz--oU528Cy7Sfebj0copg" 49 | ``` 50 | 51 | ## Tests 52 | ``` 53 | ./vendor/bin/phpunit --process-isolation tests 54 | ``` 55 | 56 | ## Lint 57 | To run linter 58 | ``` 59 | ./vendor/bin/phpcs --standard=.duo_linting.xml -n src/* tests 60 | ``` 61 | -------------------------------------------------------------------------------- /example/index.php: -------------------------------------------------------------------------------- 1 | getMessage()); 28 | } 29 | $duo_failmode = strtoupper($config['failmode']); 30 | 31 | $app = AppFactory::create(); 32 | $logger = new Logger(); 33 | $errorMiddleware = $app->addErrorMiddleware(true, true, true, $logger); 34 | $app->add(new Session()); 35 | 36 | $app->get('/', function (Request $request, Response $response, $args) { 37 | $renderer = new PhpRenderer('./templates'); 38 | $args["message"] = "This is a demo"; 39 | return $renderer->render($response, "login.php", $args); 40 | }); 41 | 42 | $app->post('/', function (Request $request, Response $response, $args) use ($app, $duo_client, $duo_failmode, $logger) { 43 | $renderer = new PhpRenderer('./templates'); 44 | 45 | $request_body = $request->getParsedBody(); 46 | $username = $request_body['username']; 47 | $password = $request_body['password']; 48 | 49 | # Check user's first factor 50 | if (empty($username) || empty($password)) { 51 | $args["message"] = "Incorrect username or password"; 52 | return $renderer->render($response, "login.php", $args); 53 | } 54 | 55 | try { 56 | $duo_client->healthCheck(); 57 | } catch (DuoException $e) { 58 | $logger->error($e->getMessage()); 59 | if ($duo_failmode == "OPEN") { 60 | # If we're failing open, errors in 2FA still allow for success 61 | $args["message"] = "Login 'Successful', but 2FA Not Performed. Confirm Duo client/secret/host values are correct"; 62 | $render_template = "success.php"; 63 | } else { 64 | # Otherwise the login fails and redirect user to the login page 65 | $args["message"] = "2FA Unavailable. Confirm Duo client/secret/host values are correct"; 66 | $render_template = "login.php"; 67 | } 68 | return $renderer->render($response, $render_template, $args); 69 | } 70 | 71 | # Generate random string to act as a state for the exchange. 72 | # Store it in the session to be later used by the callback. 73 | # This example demonstrates use of the http session (cookie-based) 74 | # for storing the state. In some applications, strict cookie 75 | # controls or other session security measures will mean a different 76 | # mechanism to persist the state and username will be necessary. 77 | $state = $duo_client->generateState(); 78 | $session = new \SlimSession\Helper(); 79 | $session->set("state", $state); 80 | $session->set("username", $username); 81 | unset($session); 82 | 83 | # Redirect to prompt URI which will redirect to the client's redirect URI after 2FA 84 | $prompt_uri = $duo_client->createAuthUrl($username, $state); 85 | return $response 86 | ->withHeader('Location', $prompt_uri) 87 | ->withStatus(302); 88 | }); 89 | 90 | # This route URL must match the redirect_uri passed to the duo client 91 | $app->get('/duo-callback', function (Request $request, Response $response, $args) use ($duo_client, $logger) { 92 | $query_params = $request->getQueryParams(); 93 | $renderer = new PhpRenderer('./templates'); 94 | 95 | # Check for errors from the Duo authentication 96 | if (isset($query_params["error"])) { 97 | $error_msg = $query_params["error"] . ":" . $query_params["error_description"]; 98 | $logger->error($error_msg); 99 | $response->getBody()->write("Got Error: " . $error_msg); 100 | return $response; 101 | } 102 | 103 | # Get authorization token to trade for 2FA 104 | $code = $query_params["duo_code"]; 105 | 106 | # Get state to verify consistency and originality 107 | $state = $query_params["state"]; 108 | 109 | # Retrieve the previously stored state and username from the session 110 | $session = new \SlimSession\Helper(); 111 | $saved_state = $session->get("state"); 112 | $username = $session->get("username"); 113 | unset($session); 114 | 115 | if (empty($saved_state) || empty($username)) { 116 | # If the URL used to get to login.php is not localhost, (e.g. 127.0.0.1), then the sessions will be different 117 | # and the localhost session will not have the state. 118 | $args["message"] = "No saved state please login again"; 119 | return $renderer->render($response, "login.php", $args); 120 | } 121 | 122 | # Ensure nonce matches from initial request 123 | if ($state != $saved_state) { 124 | $args["message"] = "Duo state does not match saved state"; 125 | return $renderer->render($response, "login.php", $args); 126 | } 127 | 128 | try { 129 | $decoded_token = $duo_client->exchangeAuthorizationCodeFor2FAResult($code, $username); 130 | } catch (DuoException $e) { 131 | $logger->error($e->getMessage()); 132 | $args["message"] = "Error decoding Duo result. Confirm device clock is correct."; 133 | return $renderer->render($response, "login.php", $args); 134 | } 135 | 136 | # Exchange happened successfully so render success page 137 | $args["message"] = json_encode($decoded_token, JSON_PRETTY_PRINT); 138 | return $renderer->render($response, "success.php", $args); 139 | }); 140 | 141 | $app->run(); 142 | -------------------------------------------------------------------------------- /src/Client.php: -------------------------------------------------------------------------------- 1 | 13 | * @license https://opensource.org/licenses/BSD-3-Clause 14 | * @link https://duo.com/docs/duoweb-v4 15 | * @file 16 | */ 17 | namespace Duo\DuoUniversal; 18 | 19 | use \Firebase\JWT\JWT; 20 | use \Firebase\JWT\Key; 21 | use \Firebase\JWT\BeforeValidException; 22 | use \Firebase\JWT\ExpiredException; 23 | use \Firebase\JWT\SignatureInvalidException; 24 | use UnexpectedValueException; 25 | 26 | /** 27 | * This class contains the client for the Universal flow. 28 | */ 29 | class Client 30 | { 31 | const MAX_STATE_LENGTH = 1024; 32 | const MIN_STATE_LENGTH = 22; 33 | const JTI_LENGTH = 36; 34 | const DEFAULT_STATE_LENGTH = 36; 35 | const CLIENT_ID_LENGTH = 20; 36 | const CLIENT_SECRET_LENGTH = 40; 37 | const JWT_EXPIRATION = 300; 38 | const JWT_LEEWAY = 60; 39 | const SUCCESS_STATUS_CODE = 200; 40 | 41 | const USER_AGENT = "duo_universal_php/1.1.1"; 42 | const SIG_ALGORITHM = "HS512"; 43 | const GRANT_TYPE = "authorization_code"; 44 | const CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; 45 | 46 | const HEALTH_CHECK_ENDPOINT = "/oauth/v1/health_check"; 47 | const TOKEN_ENDPOINT = "/oauth/v1/token"; 48 | const AUTHORIZE_ENDPOINT = "/oauth/v1/authorize"; 49 | 50 | const USERNAME_ERROR = "The username is invalid."; 51 | const NONCE_ERROR = "The nonce is invalid."; 52 | const JWT_DECODE_ERROR = "Error decoding JWT"; 53 | const INVALID_CLIENT_ID_ERROR = "The Client ID is invalid"; 54 | const INVALID_CLIENT_SECRET_ERROR = "The Client Secret is invalid"; 55 | const DUO_STATE_ERROR = "State must be at least " . self::MIN_STATE_LENGTH . " characters long and no longer than " . self::MAX_STATE_LENGTH . " characters"; 56 | const FAILED_CONNECTION = "Unable to connect to Duo"; 57 | const MALFORMED_RESPONSE = "Result missing expected data."; 58 | const DUO_CERTS = __DIR__ . "/ca_certs.pem"; 59 | 60 | public $client_id; 61 | public $api_host; 62 | public $http_proxy; 63 | public $redirect_url; 64 | public $use_duo_code_attribute; 65 | private $client_secret; 66 | private $user_agent_extension; 67 | 68 | /** 69 | * Retrieves exception message for DuoException from HTTPS result message. 70 | * 71 | * @param array $result The result from the HTTPS request 72 | * 73 | * @return string The exception message taken from the message or MALFORMED_RESPONSE 74 | */ 75 | private function getExceptionFromResult(array $result): string 76 | { 77 | if (isset($result["message"]) && isset($result["message_detail"])) { 78 | return $result["message"] . ": " . $result["message_detail"]; 79 | } elseif (isset($result["error"]) && isset($result["error_description"])) { 80 | return $result["error"] . ": " . $result["error_description"]; 81 | } 82 | return self::MALFORMED_RESPONSE; 83 | } 84 | 85 | /** 86 | * Make HTTPS calls to Duo. 87 | * 88 | * @param string $endpoint The endpoint we are trying to hit 89 | * @param array $request Information to send to Duo 90 | * @param string|null $user_agent (Optional) A user-agent string 91 | * 92 | * @return array of strings 93 | * @throws DuoException For failure to connect to Duo 94 | */ 95 | protected function makeHttpsCall(string $endpoint, array $request, ?string $user_agent = null): array 96 | { 97 | $ch = curl_init(); 98 | curl_setopt($ch, CURLOPT_URL, "https://" . $this->api_host . $endpoint); 99 | curl_setopt($ch, CURLOPT_POST, true); 100 | curl_setopt($ch, CURLOPT_POSTFIELDS, $request); 101 | curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS); 102 | curl_setopt($ch, CURLOPT_CAINFO, self::DUO_CERTS); 103 | curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); 104 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 105 | if ($user_agent !== null) { 106 | curl_setopt($ch, CURLOPT_USERAGENT, $user_agent); 107 | } 108 | if (!is_null($this->http_proxy)) { 109 | curl_setopt($ch, CURLOPT_PROXYTYPE, CURLPROXY_HTTP); 110 | curl_setopt($ch, CURLOPT_PROXY, $this->http_proxy); 111 | }; 112 | $result = curl_exec($ch); 113 | 114 | /* Throw an error if the result doesn't exist or if our request returned a 5XX status */ 115 | if (!$result) { 116 | throw new DuoException(self::FAILED_CONNECTION); 117 | } 118 | if (self::SUCCESS_STATUS_CODE !== curl_getinfo($ch, CURLINFO_HTTP_CODE)) { 119 | throw new DuoException($this->getExceptionFromResult(json_decode($result, true))); 120 | } 121 | return json_decode($result, true); 122 | } 123 | 124 | private function createJwtPayload(string $audience): string 125 | { 126 | $date = new \DateTime(); 127 | $current_date = $date->getTimestamp(); 128 | $payload = [ "iss" => $this->client_id, 129 | "sub" => $this->client_id, 130 | "aud" => $audience, 131 | "jti" => $this->generateRandomString(self::JTI_LENGTH), 132 | "iat" => $current_date, 133 | "exp" => $current_date + self::JWT_EXPIRATION 134 | ]; 135 | return JWT::encode($payload, $this->client_secret, self::SIG_ALGORITHM); 136 | } 137 | 138 | /** 139 | * Generates a random hex string. 140 | * 141 | * @param int $state_length The length of the hex string 142 | * 143 | * @return string A hexadecimal string 144 | * @throws DuoException For lengths that are shorter than MIN_STATE_LENGTH or longer than MAX_STATE_LENGTH 145 | */ 146 | private function generateRandomString(int $state_length): string 147 | { 148 | if ($state_length > self::MAX_STATE_LENGTH || $state_length < self::MIN_STATE_LENGTH 149 | ) { 150 | throw new DuoException(self::DUO_STATE_ERROR); 151 | } 152 | 153 | $ALPHANUMERICS = array_merge(range('A', 'Z'), range('a', 'z'), range(0, 9)); 154 | $state = ""; 155 | 156 | for ($i = 0; $i < $state_length; ++$i) { 157 | $state = $state . $ALPHANUMERICS[random_int(0, count($ALPHANUMERICS) - 1)]; 158 | } 159 | return $state; 160 | } 161 | 162 | /** 163 | * Constructor for Client class. 164 | * 165 | * @param string $client_id The Client ID found in the admin panel 166 | * @param string $client_secret The Client Secret found in the admin panel 167 | * @param string $api_host The api-host found in the admin panel 168 | * @param string $redirect_url The URL to redirect back to after the prompt 169 | * @param bool $use_duo_code_attribute (Optional: default true) Flag to use `duo_code` instead of `code` for returned authorization parameter 170 | * @param string|null $http_proxy (Optional) HTTP proxy to tunnel requests through 171 | * 172 | * @throws DuoException For invalid Client ID or Client Secret 173 | */ 174 | public function __construct( 175 | string $client_id, 176 | string $client_secret, 177 | string $api_host, 178 | string $redirect_url, 179 | bool $use_duo_code_attribute = true, 180 | ?string $http_proxy = null 181 | ) { 182 | if (strlen($client_id) !== self::CLIENT_ID_LENGTH) { 183 | throw new DuoException(self::INVALID_CLIENT_ID_ERROR); 184 | } 185 | if (strlen($client_secret) !== self::CLIENT_SECRET_LENGTH) { 186 | throw new DuoException(self::INVALID_CLIENT_SECRET_ERROR); 187 | } 188 | $this->client_id = $client_id; 189 | $this->client_secret = $client_secret; 190 | $this->api_host = $api_host; 191 | $this->redirect_url = $redirect_url; 192 | $this->use_duo_code_attribute = $use_duo_code_attribute; 193 | $this->http_proxy = $http_proxy; 194 | $this->user_agent_extension = null; 195 | } 196 | 197 | /** 198 | * Append custom information to the user agent string. 199 | * 200 | * @param string $user_agent_extension Custom user agent information 201 | * 202 | * @return void 203 | */ 204 | public function appendToUserAgent(string $user_agent_extension): void 205 | { 206 | $this->user_agent_extension = trim($user_agent_extension); 207 | } 208 | 209 | /** 210 | * Build the complete user agent string. 211 | * 212 | * @return string The complete user agent string 213 | */ 214 | private function buildUserAgent(): string 215 | { 216 | $base_user_agent = self::USER_AGENT . " php/" . phpversion() . " " 217 | . php_uname(); 218 | if (!empty($this->user_agent_extension)) { 219 | return $base_user_agent . " " . $this->user_agent_extension; 220 | } 221 | return $base_user_agent; 222 | } 223 | 224 | /** 225 | * Generate a random hex string with a length of DEFAULT_STATE_LENGTH. 226 | * 227 | * @return string 228 | */ 229 | public function generateState(): string 230 | { 231 | return $this->generateRandomString(self::DEFAULT_STATE_LENGTH); 232 | } 233 | 234 | /** 235 | * Makes a call to HEALTH_CHECK_ENDPOINT to see if Duo is available. 236 | * 237 | * @return array The result of the health check 238 | * @throws DuoException For failure to connect to Duo or failed health check 239 | */ 240 | public function healthCheck(): array 241 | { 242 | $audience = "https://" . $this->api_host . self::HEALTH_CHECK_ENDPOINT; 243 | $jwt = $this->createJwtPayload($audience); 244 | $request = ["client_id" => $this->client_id, "client_assertion" => $jwt]; 245 | 246 | $result = $this->makeHttpsCall(self::HEALTH_CHECK_ENDPOINT, $request); 247 | 248 | if (!isset($result["stat"]) || $result["stat"] !== "OK") { 249 | throw new DuoException($this->getExceptionFromResult($result)); 250 | } 251 | return $result; 252 | } 253 | 254 | /** 255 | * Generate URI to redirect to for the Duo prompt. 256 | * 257 | * @param string $username The username of the user trying to auth 258 | * @param string $state Randomly generated character string of at least 22 259 | * chars returned to the integration by Duo after 2FA 260 | * 261 | * @return string The URI used to redirect to the Duo prompt 262 | * @throws DuoException For invalid inputs 263 | */ 264 | public function createAuthUrl(string $username, string $state): string 265 | { 266 | if (strlen($state) < self::MIN_STATE_LENGTH || strlen($state) > self::MAX_STATE_LENGTH 267 | ) { 268 | throw new DuoException(self::DUO_STATE_ERROR); 269 | } 270 | 271 | $date = new \DateTime(); 272 | $current_date = $date->getTimestamp(); 273 | $payload = [ 274 | 'scope' => 'openid', 275 | 'redirect_uri' => $this->redirect_url, 276 | 'client_id' => $this->client_id, 277 | 'iss' => $this->client_id, 278 | 'aud' => "https://" . $this->api_host, 279 | 'exp' => $current_date + self::JWT_EXPIRATION, 280 | 'state' => $state, 281 | 'response_type' => 'code', 282 | 'duo_uname' => $username, 283 | 'use_duo_code_attribute' => $this->use_duo_code_attribute 284 | ]; 285 | 286 | $jwt = JWT::encode($payload, $this->client_secret, self::SIG_ALGORITHM); 287 | $allArgs = [ 288 | 'response_type' => 'code', 289 | 'client_id' => $this->client_id, 290 | 'scope' => 'openid', 291 | 'redirect_uri' => $this->redirect_url, 292 | 'request' => $jwt 293 | ]; 294 | 295 | $arguments = http_build_query($allArgs); 296 | return "https://" . $this->api_host . self::AUTHORIZE_ENDPOINT . "?" . $arguments; 297 | } 298 | 299 | /** 300 | * Exchange a code returned by Duo for a token that contains information about the authorization. 301 | * 302 | * @param string $duoCode The code returned by Duo as a URL parameter after a successful authentication 303 | * @param string $username The username of the user trying to authenticate with Duo 304 | * @param string|null $nonce (Optional) Random 36B string used to associate a session with an ID token 305 | * 306 | * @return array of strings that contains information about the authentication 307 | * 308 | * @throws DuoException For malformed response from Duo, problems decoding the JWT, 309 | * the wrong username, and the wrong nonce 310 | */ 311 | public function exchangeAuthorizationCodeFor2FAResult(string $duoCode, string $username, ?string $nonce = null): array 312 | { 313 | $token_endpoint = "https://" . $this->api_host . self::TOKEN_ENDPOINT; 314 | $useragent = $this->buildUserAgent(); 315 | $jwt = $this->createJwtPayload($token_endpoint); 316 | $request = ["grant_type" => self::GRANT_TYPE, 317 | "code" => $duoCode, 318 | "redirect_uri" => $this->redirect_url, 319 | "client_id" => $this->client_id, 320 | "client_assertion_type" => self::CLIENT_ASSERTION_TYPE, 321 | "client_assertion" => $jwt]; 322 | $result = $this->makeHttpsCall(self::TOKEN_ENDPOINT, $request, $useragent); 323 | 324 | /* Verify that we are receiving the expected response from Duo */ 325 | $required_keys = ["id_token", "access_token", "expires_in", "token_type"]; 326 | foreach ($required_keys as $key) { 327 | if (!isset($result[$key])) { 328 | throw new DuoException(self::MALFORMED_RESPONSE); 329 | } 330 | } 331 | if ($result["token_type"] !== "Bearer") { 332 | throw new DuoException(self::MALFORMED_RESPONSE); 333 | } 334 | 335 | try { 336 | JWT::$leeway = self::JWT_LEEWAY; 337 | $jwt_key = new Key($this->client_secret, self::SIG_ALGORITHM); 338 | $token_obj = JWT::decode($result['id_token'], @$jwt_key); 339 | /* JWT::decode returns a PHP object, this will turn the object into a multidimensional array */ 340 | $token = json_decode(json_encode($token_obj), true); 341 | } catch (SignatureInvalidException | BeforeValidException | ExpiredException | UnexpectedValueException $e) { 342 | throw new DuoException(self::JWT_DECODE_ERROR); 343 | } 344 | 345 | $required_token_key = ["exp", "iat", "iss", "aud"]; 346 | foreach ($required_token_key as $key) { 347 | if (!isset($token[$key])) { 348 | throw new DuoException(self::MALFORMED_RESPONSE); 349 | } 350 | } 351 | /* Verify we have all expected fields in our token */ 352 | if ($token['iss'] !== $token_endpoint || $token['aud'] !== $this->client_id) { 353 | throw new DuoException(self::MALFORMED_RESPONSE); 354 | } 355 | 356 | if (!isset($token['preferred_username']) || $token['preferred_username'] !== $username) { 357 | throw new DuoException(self::USERNAME_ERROR); 358 | } 359 | if (is_string($nonce) && (!isset($token['nonce']) || $token['nonce'] !== $nonce)) { 360 | throw new DuoException(self::NONCE_ERROR); 361 | } 362 | return $token; 363 | } 364 | } 365 | -------------------------------------------------------------------------------- /src/ca_certs.pem: -------------------------------------------------------------------------------- 1 | # Source URL: https://www.amazontrust.com/repository/AmazonRootCA1.cer 2 | # Certificate #1 Details: 3 | # Original Format: DER 4 | # Subject: CN=Amazon Root CA 1,O=Amazon,C=US 5 | # Issuer: CN=Amazon Root CA 1,O=Amazon,C=US 6 | # Expiration Date: 2038-01-17 00:00:00 7 | # Serial Number: 66C9FCF99BF8C0A39E2F0788A43E696365BCA 8 | # SHA256 Fingerprint: 8ecde6884f3d87b1125ba31ac3fcb13d7016de7f57cc904fe1cb97c6ae98196e 9 | 10 | -----BEGIN CERTIFICATE----- 11 | MIIDQTCCAimgAwIBAgITBmyfz5m/jAo54vB4ikPmljZbyjANBgkqhkiG9w0BAQsF 12 | ADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6 13 | b24gUm9vdCBDQSAxMB4XDTE1MDUyNjAwMDAwMFoXDTM4MDExNzAwMDAwMFowOTEL 14 | MAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJv 15 | b3QgQ0EgMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALJ4gHHKeNXj 16 | ca9HgFB0fW7Y14h29Jlo91ghYPl0hAEvrAIthtOgQ3pOsqTQNroBvo3bSMgHFzZM 17 | 9O6II8c+6zf1tRn4SWiw3te5djgdYZ6k/oI2peVKVuRF4fn9tBb6dNqcmzU5L/qw 18 | IFAGbHrQgLKm+a/sRxmPUDgH3KKHOVj4utWp+UhnMJbulHheb4mjUcAwhmahRWa6 19 | VOujw5H5SNz/0egwLX0tdHA114gk957EWW67c4cX8jJGKLhD+rcdqsq08p8kDi1L 20 | 93FcXmn/6pUCyziKrlA4b9v7LWIbxcceVOF34GfID5yHI9Y/QCB/IIDEgEw+OyQm 21 | jgSubJrIqg0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMC 22 | AYYwHQYDVR0OBBYEFIQYzIU07LwMlJQuCFmcx7IQTgoIMA0GCSqGSIb3DQEBCwUA 23 | A4IBAQCY8jdaQZChGsV2USggNiMOruYou6r4lK5IpDB/G/wkjUu0yKGX9rbxenDI 24 | U5PMCCjjmCXPI6T53iHTfIUJrU6adTrCC2qJeHZERxhlbI1Bjjt/msv0tadQ1wUs 25 | N+gDS63pYaACbvXy8MWy7Vu33PqUXHeeE6V/Uq2V8viTO96LXFvKWlJbYK8U90vv 26 | o/ufQJVtMVT8QtPHRh8jrdkPSHCa2XV4cdFyQzR1bldZwgJcJmApzyMZFo6IQ6XU 27 | 5MsI+yMRQ+hDKXJioaldXgjUkK642M4UwtBV8ob2xJNDd2ZhwLnoQdeXeGADbkpy 28 | rqXRfboQnoZsG4q5WTP468SQvvG5 29 | -----END CERTIFICATE----- 30 | 31 | 32 | # Source URL: https://www.amazontrust.com/repository/AmazonRootCA2.cer 33 | # Certificate #1 Details: 34 | # Original Format: DER 35 | # Subject: CN=Amazon Root CA 2,O=Amazon,C=US 36 | # Issuer: CN=Amazon Root CA 2,O=Amazon,C=US 37 | # Expiration Date: 2040-05-26 00:00:00 38 | # Serial Number: 66C9FD29635869F0A0FE58678F85B26BB8A37 39 | # SHA256 Fingerprint: 1ba5b2aa8c65401a82960118f80bec4f62304d83cec4713a19c39c011ea46db4 40 | 41 | -----BEGIN CERTIFICATE----- 42 | MIIFQTCCAymgAwIBAgITBmyf0pY1hp8KD+WGePhbJruKNzANBgkqhkiG9w0BAQwF 43 | ADA5MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6 44 | b24gUm9vdCBDQSAyMB4XDTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTEL 45 | MAkGA1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJv 46 | b3QgQ0EgMjCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK2Wny2cSkxK 47 | gXlRmeyKy2tgURO8TW0G/LAIjd0ZEGrHJgw12MBvIITplLGbhQPDW9tK6Mj4kHbZ 48 | W0/jTOgGNk3Mmqw9DJArktQGGWCsN0R5hYGCrVo34A3MnaZMUnbqQ523BNFQ9lXg 49 | 1dKmSYXpN+nKfq5clU1Imj+uIFptiJXZNLhSGkOQsL9sBbm2eLfq0OQ6PBJTYv9K 50 | 8nu+NQWpEjTj82R0Yiw9AElaKP4yRLuH3WUnAnE72kr3H9rN9yFVkE8P7K6C4Z9r 51 | 2UXTu/Bfh+08LDmG2j/e7HJV63mjrdvdfLC6HM783k81ds8P+HgfajZRRidhW+me 52 | z/CiVX18JYpvL7TFz4QuK/0NURBs+18bvBt+xa47mAExkv8LV/SasrlX6avvDXbR 53 | 8O70zoan4G7ptGmh32n2M8ZpLpcTnqWHsFcQgTfJU7O7f/aS0ZzQGPSSbtqDT6Zj 54 | mUyl+17vIWR6IF9sZIUVyzfpYgwLKhbcAS4y2j5L9Z469hdAlO+ekQiG+r5jqFoz 55 | 7Mt0Q5X5bGlSNscpb/xVA1wf+5+9R+vnSUeVC06JIglJ4PVhHvG/LopyboBZ/1c6 56 | +XUyo05f7O0oYtlNc/LMgRdg7c3r3NunysV+Ar3yVAhU/bQtCSwXVEqY0VThUWcI 57 | 0u1ufm8/0i2BWSlmy5A5lREedCf+3euvAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMB 58 | Af8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBSwDPBMMPQFWAJI/TPlUq9LhONm 59 | UjANBgkqhkiG9w0BAQwFAAOCAgEAqqiAjw54o+Ci1M3m9Zh6O+oAA7CXDpO8Wqj2 60 | LIxyh6mx/H9z/WNxeKWHWc8w4Q0QshNabYL1auaAn6AFC2jkR2vHat+2/XcycuUY 61 | +gn0oJMsXdKMdYV2ZZAMA3m3MSNjrXiDCYZohMr/+c8mmpJ5581LxedhpxfL86kS 62 | k5Nrp+gvU5LEYFiwzAJRGFuFjWJZY7attN6a+yb3ACfAXVU3dJnJUH/jWS5E4ywl 63 | 7uxMMne0nxrpS10gxdr9HIcWxkPo1LsmmkVwXqkLN1PiRnsn/eBG8om3zEK2yygm 64 | btmlyTrIQRNg91CMFa6ybRoVGld45pIq2WWQgj9sAq+uEjonljYE1x2igGOpm/Hl 65 | urR8FLBOybEfdF849lHqm/osohHUqS0nGkWxr7JOcQ3AWEbWaQbLU8uz/mtBzUF+ 66 | fUwPfHJ5elnNXkoOrJupmHN5fLT0zLm4BwyydFy4x2+IoZCn9Kr5v2c69BoVYh63 67 | n749sSmvZ6ES8lgQGVMDMBu4Gon2nL2XA46jCfMdiyHxtN/kHNGfZQIG6lzWE7OE 68 | 76KlXIx3KadowGuuQNKotOrN8I1LOJwZmhsoVLiJkO/KdYE+HvJkJMcYr07/R54H 69 | 9jVlpNMKVv/1F2Rs76giJUmTtt8AF9pYfl3uxRuw0dFfIRDH+fO6AgonB8Xx1sfT 70 | 4PsJYGw= 71 | -----END CERTIFICATE----- 72 | 73 | 74 | # Source URL: https://www.amazontrust.com/repository/AmazonRootCA3.cer 75 | # Certificate #1 Details: 76 | # Original Format: DER 77 | # Subject: CN=Amazon Root CA 3,O=Amazon,C=US 78 | # Issuer: CN=Amazon Root CA 3,O=Amazon,C=US 79 | # Expiration Date: 2040-05-26 00:00:00 80 | # Serial Number: 66C9FD5749736663F3B0B9AD9E89E7603F24A 81 | # SHA256 Fingerprint: 18ce6cfe7bf14e60b2e347b8dfe868cb31d02ebb3ada271569f50343b46db3a4 82 | 83 | -----BEGIN CERTIFICATE----- 84 | MIIBtjCCAVugAwIBAgITBmyf1XSXNmY/Owua2eiedgPySjAKBggqhkjOPQQDAjA5 85 | MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24g 86 | Um9vdCBDQSAzMB4XDTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkG 87 | A1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJvb3Qg 88 | Q0EgMzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABCmXp8ZBf8ANm+gBG1bG8lKl 89 | ui2yEujSLtf6ycXYqm0fc4E7O5hrOXwzpcVOho6AF2hiRVd9RFgdszflZwjrZt6j 90 | QjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBSr 91 | ttvXBp43rDCGB5Fwx5zEGbF4wDAKBggqhkjOPQQDAgNJADBGAiEA4IWSoxe3jfkr 92 | BqWTrBqYaGFy+uGh0PsceGCmQ5nFuMQCIQCcAu/xlJyzlvnrxir4tiz+OpAUFteM 93 | YyRIHN8wfdVoOw== 94 | -----END CERTIFICATE----- 95 | 96 | 97 | # Source URL: https://www.amazontrust.com/repository/AmazonRootCA4.cer 98 | # Certificate #1 Details: 99 | # Original Format: DER 100 | # Subject: CN=Amazon Root CA 4,O=Amazon,C=US 101 | # Issuer: CN=Amazon Root CA 4,O=Amazon,C=US 102 | # Expiration Date: 2040-05-26 00:00:00 103 | # Serial Number: 66C9FD7C1BB104C2943E5717B7B2CC81AC10E 104 | # SHA256 Fingerprint: e35d28419ed02025cfa69038cd623962458da5c695fbdea3c22b0bfb25897092 105 | 106 | -----BEGIN CERTIFICATE----- 107 | MIIB8jCCAXigAwIBAgITBmyf18G7EEwpQ+Vxe3ssyBrBDjAKBggqhkjOPQQDAzA5 108 | MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRkwFwYDVQQDExBBbWF6b24g 109 | Um9vdCBDQSA0MB4XDTE1MDUyNjAwMDAwMFoXDTQwMDUyNjAwMDAwMFowOTELMAkG 110 | A1UEBhMCVVMxDzANBgNVBAoTBkFtYXpvbjEZMBcGA1UEAxMQQW1hem9uIFJvb3Qg 111 | Q0EgNDB2MBAGByqGSM49AgEGBSuBBAAiA2IABNKrijdPo1MN/sGKe0uoe0ZLY7Bi 112 | 9i0b2whxIdIA6GO9mif78DluXeo9pcmBqqNbIJhFXRbb/egQbeOc4OO9X4Ri83Bk 113 | M6DLJC9wuoihKqB1+IGuYgbEgds5bimwHvouXKNCMEAwDwYDVR0TAQH/BAUwAwEB 114 | /zAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0OBBYEFNPsxzplbszh2naaVvuc84ZtV+WB 115 | MAoGCCqGSM49BAMDA2gAMGUCMDqLIfG9fhGt0O9Yli/W651+kI0rz2ZVwyzjKKlw 116 | CkcO8DdZEv8tmZQoTipPNU0zWgIxAOp1AE47xDqUEpHJWEadIRNyp4iciuRMStuW 117 | 1KyLa2tJElMzrdfkviT8tQp21KW8EA== 118 | -----END CERTIFICATE----- 119 | 120 | 121 | # Source URL: https://www.amazontrust.com/repository/SFSRootCAG2.cer 122 | # Certificate #1 Details: 123 | # Original Format: DER 124 | # Subject: CN=Starfield Services Root Certificate Authority - G2,O=Starfield Technologies\, Inc.,L=Scottsdale,ST=Arizona,C=US 125 | # Issuer: CN=Starfield Services Root Certificate Authority - G2,O=Starfield Technologies\, Inc.,L=Scottsdale,ST=Arizona,C=US 126 | # Expiration Date: 2037-12-31 23:59:59 127 | # Serial Number: 0 128 | # SHA256 Fingerprint: 568d6905a2c88708a4b3025190edcfedb1974a606a13c6e5290fcb2ae63edab5 129 | 130 | -----BEGIN CERTIFICATE----- 131 | MIID7zCCAtegAwIBAgIBADANBgkqhkiG9w0BAQsFADCBmDELMAkGA1UEBhMCVVMx 132 | EDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxJTAjBgNVBAoT 133 | HFN0YXJmaWVsZCBUZWNobm9sb2dpZXMsIEluYy4xOzA5BgNVBAMTMlN0YXJmaWVs 134 | ZCBTZXJ2aWNlcyBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5 135 | MDkwMTAwMDAwMFoXDTM3MTIzMTIzNTk1OVowgZgxCzAJBgNVBAYTAlVTMRAwDgYD 136 | VQQIEwdBcml6b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMSUwIwYDVQQKExxTdGFy 137 | ZmllbGQgVGVjaG5vbG9naWVzLCBJbmMuMTswOQYDVQQDEzJTdGFyZmllbGQgU2Vy 138 | dmljZXMgUm9vdCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZI 139 | hvcNAQEBBQADggEPADCCAQoCggEBANUMOsQq+U7i9b4Zl1+OiFOxHz/Lz58gE20p 140 | OsgPfTz3a3Y4Y9k2YKibXlwAgLIvWX/2h/klQ4bnaRtSmpDhcePYLQ1Ob/bISdm2 141 | 8xpWriu2dBTrz/sm4xq6HZYuajtYlIlHVv8loJNwU4PahHQUw2eeBGg6345AWh1K 142 | Ts9DkTvnVtYAcMtS7nt9rjrnvDH5RfbCYM8TWQIrgMw0R9+53pBlbQLPLJGmpufe 143 | hRhJfGZOozptqbXuNC66DQO4M99H67FrjSXZm86B0UVGMpZwh94CDklDhbZsc7tk 144 | 6mFBrMnUVN+HL8cisibMn1lUaJ/8viovxFUcdUBgF4UCVTmLfwUCAwEAAaNCMEAw 145 | DwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFJxfAN+q 146 | AdcwKziIorhtSpzyEZGDMA0GCSqGSIb3DQEBCwUAA4IBAQBLNqaEd2ndOxmfZyMI 147 | bw5hyf2E3F/YNoHN2BtBLZ9g3ccaaNnRbobhiCPPE95Dz+I0swSdHynVv/heyNXB 148 | ve6SbzJ08pGCL72CQnqtKrcgfU28elUSwhXqvfdqlS5sdJ/PHLTyxQGjhdByPq1z 149 | qwubdQxtRbeOlKyWN7Wg0I8VRw7j6IPdj/3vQQF3zCepYoUz8jcI73HPdwbeyBkd 150 | iEDPfUYd/x7H4c7/I9vG+o1VTqkC50cRRj70/b17KSa7qWFiNyi2LSr2EIZkyXCn 151 | 0q23KXB56jzaYyWf/Wi3MOxw+3WKt21gZ7IeyLnp2KhvAotnDU0mV3HaIPzBSlCN 152 | sSi6 153 | -----END CERTIFICATE----- 154 | 155 | 156 | # Source URL: https://cacerts.digicert.com/DigiCertHighAssuranceEVRootCA.crt 157 | # Certificate #1 Details: 158 | # Original Format: DER 159 | # Subject: CN=DigiCert High Assurance EV Root CA,OU=www.digicert.com,O=DigiCert Inc,C=US 160 | # Issuer: CN=DigiCert High Assurance EV Root CA,OU=www.digicert.com,O=DigiCert Inc,C=US 161 | # Expiration Date: 2031-11-10 00:00:00 162 | # Serial Number: 2AC5C266A0B409B8F0B79F2AE462577 163 | # SHA256 Fingerprint: 7431e5f4c3c1ce4690774f0b61e05440883ba9a01ed00ba6abd7806ed3b118cf 164 | 165 | -----BEGIN CERTIFICATE----- 166 | MIIDxTCCAq2gAwIBAgIQAqxcJmoLQJuPC3nyrkYldzANBgkqhkiG9w0BAQUFADBs 167 | MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3 168 | d3cuZGlnaWNlcnQuY29tMSswKQYDVQQDEyJEaWdpQ2VydCBIaWdoIEFzc3VyYW5j 169 | ZSBFViBSb290IENBMB4XDTA2MTExMDAwMDAwMFoXDTMxMTExMDAwMDAwMFowbDEL 170 | MAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3 171 | LmRpZ2ljZXJ0LmNvbTErMCkGA1UEAxMiRGlnaUNlcnQgSGlnaCBBc3N1cmFuY2Ug 172 | RVYgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMbM5XPm 173 | +9S75S0tMqbf5YE/yc0lSbZxKsPVlDRnogocsF9ppkCxxLeyj9CYpKlBWTrT3JTW 174 | PNt0OKRKzE0lgvdKpVMSOO7zSW1xkX5jtqumX8OkhPhPYlG++MXs2ziS4wblCJEM 175 | xChBVfvLWokVfnHoNb9Ncgk9vjo4UFt3MRuNs8ckRZqnrG0AFFoEt7oT61EKmEFB 176 | Ik5lYYeBQVCmeVyJ3hlKV9Uu5l0cUyx+mM0aBhakaHPQNAQTXKFx01p8VdteZOE3 177 | hzBWBOURtCmAEvF5OYiiAhF8J2a3iLd48soKqDirCmTCv2ZdlYTBoSUeh10aUAsg 178 | EsxBu24LUTi4S8sCAwEAAaNjMGEwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQF 179 | MAMBAf8wHQYDVR0OBBYEFLE+w2kD+L9HAdSYJhoIAu9jZCvDMB8GA1UdIwQYMBaA 180 | FLE+w2kD+L9HAdSYJhoIAu9jZCvDMA0GCSqGSIb3DQEBBQUAA4IBAQAcGgaX3Nec 181 | nzyIZgYIVyHbIUf4KmeqvxgydkAQV8GK83rZEWWONfqe/EW1ntlMMUu4kehDLI6z 182 | eM7b41N5cdblIZQB2lWHmiRk9opmzN6cN82oNLFpmyPInngiK3BD41VHMWEZ71jF 183 | hS9OMPagMRYjyOfiZRYzy78aG6A9+MpeizGLYAiJLQwGXFK3xPkKmNEVX58Svnw2 184 | Yzi9RKR/5CYrCsSXaQ3pjOLAEFe4yHYSkVXySGnYvCoCWw9E1CAx2/S6cCZdkGCe 185 | vEsXCS+0yx5DaMkHJ8HSXPfqIbloEpw8nL+e/IBcm2PN7EeqJSdnoDfzAIJ9VNep 186 | +OkuE6N36B9K 187 | -----END CERTIFICATE----- 188 | 189 | 190 | # Source URL: https://cacerts.digicert.com/DigiCertTLSECCP384RootG5.crt 191 | # Certificate #1 Details: 192 | # Original Format: DER 193 | # Subject: CN=DigiCert TLS ECC P384 Root G5,O=DigiCert\, Inc.,C=US 194 | # Issuer: CN=DigiCert TLS ECC P384 Root G5,O=DigiCert\, Inc.,C=US 195 | # Expiration Date: 2046-01-14 23:59:59 196 | # Serial Number: 9E09365ACF7D9C8B93E1C0B042A2EF3 197 | # SHA256 Fingerprint: 018e13f0772532cf809bd1b17281867283fc48c6e13be9c69812854a490c1b05 198 | 199 | -----BEGIN CERTIFICATE----- 200 | MIICGTCCAZ+gAwIBAgIQCeCTZaz32ci5PhwLBCou8zAKBggqhkjOPQQDAzBOMQsw 201 | CQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xJjAkBgNVBAMTHURp 202 | Z2lDZXJ0IFRMUyBFQ0MgUDM4NCBSb290IEc1MB4XDTIxMDExNTAwMDAwMFoXDTQ2 203 | MDExNDIzNTk1OVowTjELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJ 204 | bmMuMSYwJAYDVQQDEx1EaWdpQ2VydCBUTFMgRUNDIFAzODQgUm9vdCBHNTB2MBAG 205 | ByqGSM49AgEGBSuBBAAiA2IABMFEoc8Rl1Ca3iOCNQfN0MsYndLxf3c1TzvdlHJS 206 | 7cI7+Oz6e2tYIOyZrsn8aLN1udsJ7MgT9U7GCh1mMEy7H0cKPGEQQil8pQgO4CLp 207 | 0zVozptjn4S1mU1YoI71VOeVyaNCMEAwHQYDVR0OBBYEFMFRRVBZqz7nLFr6ICIS 208 | B4CIfBFqMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49 209 | BAMDA2gAMGUCMQCJao1H5+z8blUD2WdsJk6Dxv3J+ysTvLd6jLRl0mlpYxNjOyZQ 210 | LgGheQaRnUi/wr4CMEfDFXuxoJGZSZOoPHzoRgaLLPIxAJSdYsiJvRmEFOml+wG4 211 | DXZDjC5Ty3zfDBeWUA== 212 | -----END CERTIFICATE----- 213 | 214 | 215 | # Source URL: https://cacerts.digicert.com/DigiCertTLSRSA4096RootG5.crt 216 | # Certificate #1 Details: 217 | # Original Format: DER 218 | # Subject: CN=DigiCert TLS RSA4096 Root G5,O=DigiCert\, Inc.,C=US 219 | # Issuer: CN=DigiCert TLS RSA4096 Root G5,O=DigiCert\, Inc.,C=US 220 | # Expiration Date: 2046-01-14 23:59:59 221 | # Serial Number: 8F9B478A8FA7EDA6A333789DE7CCF8A 222 | # SHA256 Fingerprint: 371a00dc0533b3721a7eeb40e8419e70799d2b0a0f2c1d80693165f7cec4ad75 223 | 224 | -----BEGIN CERTIFICATE----- 225 | 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 254 | -----END CERTIFICATE----- 255 | 256 | 257 | # Source URL: https://secure.globalsign.com/cacert/rootr46.crt 258 | # Certificate #1 Details: 259 | # Original Format: DER 260 | # Subject: CN=GlobalSign Root R46,O=GlobalSign nv-sa,C=BE 261 | # Issuer: CN=GlobalSign Root R46,O=GlobalSign nv-sa,C=BE 262 | # Expiration Date: 2046-03-20 00:00:00 263 | # Serial Number: 11D2BBB9D723189E405F0A9D2DD0DF2567D1 264 | # SHA256 Fingerprint: 4fa3126d8d3a11d1c4855a4f807cbad6cf919d3a5a88b03bea2c6372d93c40c9 265 | 266 | -----BEGIN CERTIFICATE----- 267 | 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 296 | -----END CERTIFICATE----- 297 | 298 | 299 | # Source URL: https://secure.globalsign.com/cacert/roote46.crt 300 | # Certificate #1 Details: 301 | # Original Format: DER 302 | # Subject: CN=GlobalSign Root E46,O=GlobalSign nv-sa,C=BE 303 | # Issuer: CN=GlobalSign Root E46,O=GlobalSign nv-sa,C=BE 304 | # Expiration Date: 2046-03-20 00:00:00 305 | # Serial Number: 11D2BBBA336ED4BCE62468C50D841D98E843 306 | # SHA256 Fingerprint: cbb9c44d84b8043e1050ea31a69f514955d7bfd2e2c6b49301019ad61d9f5058 307 | 308 | -----BEGIN CERTIFICATE----- 309 | MIICCzCCAZGgAwIBAgISEdK7ujNu1LzmJGjFDYQdmOhDMAoGCCqGSM49BAMDMEYx 310 | CzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMRwwGgYDVQQD 311 | ExNHbG9iYWxTaWduIFJvb3QgRTQ2MB4XDTE5MDMyMDAwMDAwMFoXDTQ2MDMyMDAw 312 | MDAwMFowRjELMAkGA1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYtc2Ex 313 | HDAaBgNVBAMTE0dsb2JhbFNpZ24gUm9vdCBFNDYwdjAQBgcqhkjOPQIBBgUrgQQA 314 | IgNiAAScDrHPt+ieUnd1NPqlRqetMhkytAepJ8qUuwzSChDH2omwlwxwEwkBjtjq 315 | R+q+soArzfwoDdusvKSGN+1wCAB16pMLey5SnCNoIwZD7JIvU4Tb+0cUB+hflGdd 316 | yXqBPCCjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud 317 | DgQWBBQxCpCPtsad0kRLgLWi5h+xEk8blTAKBggqhkjOPQQDAwNoADBlAjEA31SQ 318 | 7Zvvi5QCkxeCmb6zniz2C5GMn0oUsfZkvLtoURMMA/cVi4RguYv/Uo7njLwcAjA8 319 | +RHUjE7AwWHCFUyqqx0LMV87HOIAl0Qx5v5zli/altP+CAezNIm8BZ/3Hobui3A= 320 | -----END CERTIFICATE----- 321 | 322 | 323 | # Source URL: https://i.pki.goog/r2.crt 324 | # Certificate #1 Details: 325 | # Original Format: DER 326 | # Subject: CN=GTS Root R2,O=Google Trust Services LLC,C=US 327 | # Issuer: CN=GTS Root R2,O=Google Trust Services LLC,C=US 328 | # Expiration Date: 2036-06-22 00:00:00 329 | # Serial Number: 203E5AEC58D04251AAB1125AA 330 | # SHA256 Fingerprint: 8d25cd97229dbf70356bda4eb3cc734031e24cf00fafcfd32dc76eb5841c7ea8 331 | 332 | -----BEGIN CERTIFICATE----- 333 | MIIFVzCCAz+gAwIBAgINAgPlrsWNBCUaqxElqjANBgkqhkiG9w0BAQwFADBHMQsw 334 | CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU 335 | MBIGA1UEAxMLR1RTIFJvb3QgUjIwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw 336 | MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp 337 | Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjIwggIiMA0GCSqGSIb3DQEBAQUA 338 | A4ICDwAwggIKAoICAQDO3v2m++zsFDQ8BwZabFn3GTXd98GdVarTzTukk3LvCvpt 339 | nfbwhYBboUhSnznFt+4orO/LdmgUud+tAWyZH8QiHZ/+cnfgLFuv5AS/T3KgGjSY 340 | 6Dlo7JUle3ah5mm5hRm9iYz+re026nO8/4Piy33B0s5Ks40FnotJk9/BW9BuXvAu 341 | MC6C/Pq8tBcKSOWIm8Wba96wyrQD8Nr0kLhlZPdcTK3ofmZemde4wj7I0BOdre7k 342 | RXuJVfeKH2JShBKzwkCX44ofR5GmdFrS+LFjKBC4swm4VndAoiaYecb+3yXuPuWg 343 | f9RhD1FLPD+M2uFwdNjCaKH5wQzpoeJ/u1U8dgbuak7MkogwTZq9TwtImoS1mKPV 344 | +3PBV2HdKFZ1E66HjucMUQkQdYhMvI35ezzUIkgfKtzra7tEscszcTJGr61K8Yzo 345 | dDqs5xoic4DSMPclQsciOzsSrZYuxsN2B6ogtzVJV+mSSeh2FnIxZyuWfoqjx5RW 346 | Ir9qS34BIbIjMt/kmkRtWVtd9QCgHJvGeJeNkP+byKq0rxFROV7Z+2et1VsRnTKa 347 | G73VululycslaVNVJ1zgyjbLiGH7HrfQy+4W+9OmTN6SpdTi3/UGVN4unUu0kzCq 348 | gc7dGtxRcw1PcOnlthYhGXmy5okLdWTK1au8CcEYof/UVKGFPP0UJAOyh9OktwID 349 | AQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E 350 | FgQUu//KjiOfT5nK2+JopqUVJxce2Q4wDQYJKoZIhvcNAQEMBQADggIBAB/Kzt3H 351 | vqGf2SdMC9wXmBFqiN495nFWcrKeGk6c1SuYJF2ba3uwM4IJvd8lRuqYnrYb/oM8 352 | 0mJhwQTtzuDFycgTE1XnqGOtjHsB/ncw4c5omwX4Eu55MaBBRTUoCnGkJE+M3DyC 353 | B19m3H0Q/gxhswWV7uGugQ+o+MePTagjAiZrHYNSVc61LwDKgEDg4XSsYPWHgJ2u 354 | NmSRXbBoGOqKYcl3qJfEycel/FVL8/B/uWU9J2jQzGv6U53hkRrJXRqWbTKH7QMg 355 | yALOWr7Z6v2yTcQvG99fevX4i8buMTolUVVnjWQye+mew4K6Ki3pHrTgSAai/Gev 356 | HyICc/sgCq+dVEuhzf9gR7A/Xe8bVr2XIZYtCtFenTgCR2y59PYjJbigapordwj6 357 | xLEokCZYCDzifqrXPW+6MYgKBesntaFJ7qBFVHvmJ2WZICGoo7z7GJa7Um8M7YNR 358 | TOlZ4iBgxcJlkoKM8xAfDoqXvneCbT+PHV28SSe9zE8P4c52hgQjxcCMElv924Sg 359 | JPFI/2R80L5cFtHvma3AH/vLrrw4IgYmZNralw4/KBVEqE8AyvCazM90arQ+POuV 360 | 7LXTWtiBmelDGDfrs7vRWGJB82bSj6p4lVQgw1oudCvV0b4YacCs1aTPObpRhANl 361 | 6WLAYv7YTVWW4tAR+kg0Eeye7QUd5MjWHYbL 362 | -----END CERTIFICATE----- 363 | 364 | 365 | # Source URL: https://i.pki.goog/r4.crt 366 | # Certificate #1 Details: 367 | # Original Format: DER 368 | # Subject: CN=GTS Root R4,O=Google Trust Services LLC,C=US 369 | # Issuer: CN=GTS Root R4,O=Google Trust Services LLC,C=US 370 | # Expiration Date: 2036-06-22 00:00:00 371 | # Serial Number: 203E5C068EF631A9C72905052 372 | # SHA256 Fingerprint: 349dfa4058c5e263123b398ae795573c4e1313c83fe68f93556cd5e8031b3c7d 373 | 374 | -----BEGIN CERTIFICATE----- 375 | MIICCTCCAY6gAwIBAgINAgPlwGjvYxqccpBQUjAKBggqhkjOPQQDAzBHMQswCQYD 376 | VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG 377 | A1UEAxMLR1RTIFJvb3QgUjQwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAw 378 | WjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2Vz 379 | IExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQwdjAQBgcqhkjOPQIBBgUrgQQAIgNi 380 | AATzdHOnaItgrkO4NcWBMHtLSZ37wWHO5t5GvWvVYRg1rkDdc/eJkTBa6zzuhXyi 381 | QHY7qca4R9gq55KRanPpsXI5nymfopjTX15YhmUPoYRlBtHci8nHc8iMai/lxKvR 382 | HYqjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW 383 | BBSATNbrdP9JNqPV2Py1PsVq8JQdjDAKBggqhkjOPQQDAwNpADBmAjEA6ED/g94D 384 | 9J+uHXqnLrmvT/aDHQ4thQEd0dlq7A/Cr8deVl5c1RxYIigL9zC2L7F8AjEA8GE8 385 | p/SgguMh1YQdc4acLa/KNJvxn7kjNuK8YAOdgLOaVsjh4rsUecrNIdSUtUlD 386 | -----END CERTIFICATE----- 387 | 388 | 389 | # Source URL: https://www.identrust.com/file-download/download/public/5718 390 | # Certificate #1 Details: 391 | # Original Format: PKCS7-DER 392 | # Subject: CN=IdenTrust Commercial Root CA 1,O=IdenTrust,C=US 393 | # Issuer: CN=IdenTrust Commercial Root CA 1,O=IdenTrust,C=US 394 | # Expiration Date: 2034-01-16 18:12:23 395 | # Serial Number: A0142800000014523C844B500000002 396 | # SHA256 Fingerprint: 5d56499be4d2e08bcfcad08a3e38723d50503bde706948e42f55603019e528ae 397 | 398 | -----BEGIN CERTIFICATE----- 399 | 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 428 | -----END CERTIFICATE----- 429 | 430 | 431 | # Source URL: https://www.identrust.com/file-download/download/public/5842 432 | # Certificate #1 Details: 433 | # Original Format: PKCS7-PEM 434 | # Subject: CN=IdenTrust Commercial Root TLS ECC CA 2,O=IdenTrust,C=US 435 | # Issuer: CN=IdenTrust Commercial Root TLS ECC CA 2,O=IdenTrust,C=US 436 | # Expiration Date: 2039-04-11 21:11:10 437 | # Serial Number: 40018ECF000DE911D7447B73E4C1F82E 438 | # SHA256 Fingerprint: 983d826ba9c87f653ff9e8384c5413e1d59acf19ddc9c98cecae5fdea2ac229c 439 | 440 | -----BEGIN CERTIFICATE----- 441 | MIICbDCCAc2gAwIBAgIQQAGOzwAN6RHXRHtz5MH4LjAKBggqhkjOPQQDBDBSMQsw 442 | CQYDVQQGEwJVUzESMBAGA1UEChMJSWRlblRydXN0MS8wLQYDVQQDEyZJZGVuVHJ1 443 | c3QgQ29tbWVyY2lhbCBSb290IFRMUyBFQ0MgQ0EgMjAeFw0yNDA0MTEyMTExMTFa 444 | Fw0zOTA0MTEyMTExMTBaMFIxCzAJBgNVBAYTAlVTMRIwEAYDVQQKEwlJZGVuVHJ1 445 | c3QxLzAtBgNVBAMTJklkZW5UcnVzdCBDb21tZXJjaWFsIFJvb3QgVExTIEVDQyBD 446 | QSAyMIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQBwomiZTgLg8KqEImMmnO5rNPb 447 | Oo9sv5w4nJh45CXs9Gcu8YET9ulxsyVBCVSfSYeppdtXFEWYyBi0QRCAlp5YZHQB 448 | H675v5rWVKRXvhzsuUNi9Xw0Zy1bAXaikmsrY/J0L52j2RulW4q4WvE7f23VFwZu 449 | d82J8k0YG+M4MpmdOho1rsKjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/ 450 | BAQDAgGGMB0GA1UdDgQWBBQhNGgGrnXhVx/FuQqjXpuH+IlbwzAKBggqhkjOPQQD 451 | BAOBjAAwgYgCQgDc9F4WOxAgci2uQWfsX9cjeIvDXaaeVjDz31Ycc+ZdPrK1JKrB 452 | f6CuTwWy8VojtGxdM3PJMkJC4LGPuhcvkHLo4gJCAV5h+PXe4bDJ3QxE8hkGFoUW 453 | Ak6KtMCIpbLyt5pHrROi+YW9MpScoNGJkg96G1ETvJTWz6dv0uQYjKXt3jlOfQ7g 454 | -----END CERTIFICATE----- 455 | 456 | 457 | # Source URL: https://ssl-ccp.secureserver.net/repository/sfroot-g2.crt 458 | # Certificate #1 Details: 459 | # Original Format: PEM 460 | # Subject: CN=Starfield Root Certificate Authority - G2,O=Starfield Technologies\, Inc.,L=Scottsdale,ST=Arizona,C=US 461 | # Issuer: CN=Starfield Root Certificate Authority - G2,O=Starfield Technologies\, Inc.,L=Scottsdale,ST=Arizona,C=US 462 | # Expiration Date: 2037-12-31 23:59:59 463 | # Serial Number: 0 464 | # SHA256 Fingerprint: 2ce1cb0bf9d2f9e102993fbe215152c3b2dd0cabde1c68e5319b839154dbb7f5 465 | 466 | -----BEGIN CERTIFICATE----- 467 | MIID3TCCAsWgAwIBAgIBADANBgkqhkiG9w0BAQsFADCBjzELMAkGA1UEBhMCVVMx 468 | EDAOBgNVBAgTB0FyaXpvbmExEzARBgNVBAcTClNjb3R0c2RhbGUxJTAjBgNVBAoT 469 | HFN0YXJmaWVsZCBUZWNobm9sb2dpZXMsIEluYy4xMjAwBgNVBAMTKVN0YXJmaWVs 470 | ZCBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eSAtIEcyMB4XDTA5MDkwMTAwMDAw 471 | MFoXDTM3MTIzMTIzNTk1OVowgY8xCzAJBgNVBAYTAlVTMRAwDgYDVQQIEwdBcml6 472 | b25hMRMwEQYDVQQHEwpTY290dHNkYWxlMSUwIwYDVQQKExxTdGFyZmllbGQgVGVj 473 | aG5vbG9naWVzLCBJbmMuMTIwMAYDVQQDEylTdGFyZmllbGQgUm9vdCBDZXJ0aWZp 474 | Y2F0ZSBBdXRob3JpdHkgLSBHMjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC 475 | ggEBAL3twQP89o/8ArFvW59I2Z154qK3A2FWGMNHttfKPTUuiUP3oWmb3ooa/RMg 476 | nLRJdzIpVv257IzdIvpy3Cdhl+72WoTsbhm5iSzchFvVdPtrX8WJpRBSiUZV9Lh1 477 | HOZ/5FSuS/hVclcCGfgXcVnrHigHdMWdSL5stPSksPNkN3mSwOxGXn/hbVNMYq/N 478 | Hwtjuzqd+/x5AJhhdM8mgkBj87JyahkNmcrUDnXMN/uLicFZ8WJ/X7NfZTD4p7dN 479 | dloedl40wOiWVpmKs/B/pM293DIxfJHP4F8R+GuqSVzRmZTRouNjWwl2tVZi4Ut0 480 | HZbUJtQIBFnQmA4O5t78w+wfkPECAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAO 481 | BgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFHwMMh+n2TB/xH1oo2Kooc6rB1snMA0G 482 | CSqGSIb3DQEBCwUAA4IBAQARWfolTwNvlJk7mh+ChTnUdgWUXuEok21iXQnCoKjU 483 | sHU48TRqneSfioYmUeYs0cYtbpUgSpIB7LiKZ3sx4mcujJUDJi5DnUox9g61DLu3 484 | 4jd/IroAow57UvtruzvE03lRTs2Q9GcHGcg8RnoNAX3FWOdt5oUwF5okxBDgBPfg 485 | 8n/Uqgr/Qh037ZTlZFkSIHc40zI+OIF1lnP6aI+xy84fxez6nH7PfrHxBy22/L/K 486 | pL/QlwVKvOoYKAKQvVR4CSFx09F9HdkWsKlhPdAKACL8x3vLCWRFCztAgfd9fDL1 487 | mMpYjn0q7pBZc2T5NnReJaH1ZgUufzkVqSr7UIuOhWn0 488 | -----END CERTIFICATE----- 489 | 490 | 491 | -------------------------------------------------------------------------------- /tests/ClientTest.php: -------------------------------------------------------------------------------- 1 | ["timestamp" => 1607009339], 28 | "stat" => "OK"]; 29 | public $bad_http_request = ["message" => "invalid_client", 30 | "code" => 40002, 31 | "timestamp" => 1607014550, 32 | "message_detail" => "Failed to verify signature.", 33 | "stat" => "FAIL"]; 34 | public $missing_stat_health_check = ["response" => ["timestamp" => 1607009339]]; 35 | public $missing_message_health_check = ["stat" => "Fail"]; 36 | public $good_state = "deadbeefdeadbeefdeadbeefdeadbeefdead"; 37 | public $short_state = "deadbeefdeadbeefdeadb"; 38 | public $bad_http_request_exception = "invalid_client: Failed to verify signature."; 39 | public $expected_good_http_request = array("response" => array("timestamp" => 1607009339), 40 | "stat" => "OK"); 41 | 42 | 43 | protected function setUp(): void 44 | { 45 | // null is the default behavior and signifies that JWT will use the real current timestamp 46 | JWT::$timestamp = null; 47 | } 48 | 49 | /** 50 | * Create Client 51 | */ 52 | public function createGoodClient(): Client 53 | { 54 | return new Client( 55 | $this->client_id, 56 | $this->client_secret, 57 | $this->api_host, 58 | $this->redirect_url 59 | ); 60 | } 61 | 62 | /** 63 | * Create Client with mocked out makeHttpsCall() to return $result 64 | * 65 | * @param array $result The data makeHttpsCall will return when running test 66 | * @param string $bad_client_secret (Optional) Use bad client secret to create client 67 | */ 68 | public function createClientMockHttp(array $result, string $bad_client_secret = '') 69 | { 70 | $client_secret = $bad_client_secret ? $bad_client_secret : $this->client_secret; 71 | $client = $this->getMockBuilder(Client::class) 72 | ->setConstructorArgs([$this->client_id, $client_secret, $this->api_host, $this->redirect_url]) 73 | ->setMethods(['makeHttpsCall']) 74 | ->getMock(); 75 | $client->method('makeHttpsCall') 76 | ->will($this->returnValue($result)); 77 | return $client; 78 | } 79 | 80 | /** 81 | * Creates and signs jwt to be used for id_token in createTokenResult. 82 | * 83 | * @param string|null $remove_index Removes entry in $payload 84 | * @param array $change_val Changes entry for key to new value in $payload 85 | * 86 | * @return string encoded JWT 87 | */ 88 | public function createIdToken(?string $remove_index = null, array $change_val = []): string 89 | { 90 | $date = new \DateTime(); 91 | $current_date = $date->getTimestamp(); 92 | $payload = ["exp" => $current_date + Client::JWT_EXPIRATION, 93 | "iat" => $current_date, 94 | "iss" => "https://" . $this->api_host . Client::TOKEN_ENDPOINT, 95 | "aud" => $this->client_id, 96 | "preferred_username" => $this->username, 97 | "nonce" => $this->nonce 98 | ]; 99 | if ($remove_index) { 100 | unset($payload[$remove_index]); 101 | } 102 | if ($change_val) { 103 | $payload[key($change_val)] = $change_val[key($change_val)]; 104 | } 105 | return JWT::encode($payload, $this->client_secret, Client::SIG_ALGORITHM); 106 | } 107 | 108 | /** 109 | * Create token result returned From Duo after exchange with code. 110 | * 111 | * @param string $id_token A signed JWT 112 | * 113 | * @return array An array containing the token data 114 | */ 115 | public function createTokenResult(string $id_token = ''): array 116 | { 117 | if (!$id_token) { 118 | $id_token = $this->createIdToken(); 119 | } 120 | return ["id_token" => $id_token, 121 | "access_token" => "90101112", 122 | "expires_in" => "1234567890", 123 | "token_type" => "Bearer"]; 124 | } 125 | 126 | /** 127 | * Test that creating a client with proper inputs does not throw an error. 128 | */ 129 | public function testClientGood(): void 130 | { 131 | $client = $this->createGoodClient(); 132 | $this->assertInstanceOf(Client::class, $client); 133 | } 134 | 135 | /** 136 | * Test that an invalid client_id will cause the Client to throw a DuoException 137 | */ 138 | public function testClientBadClientId(): void 139 | { 140 | $this->expectException(DuoException::class); 141 | $this->expectExceptionMessage(Client::INVALID_CLIENT_ID_ERROR); 142 | $client = new Client( 143 | $this->bad_client_id, 144 | $this->client_secret, 145 | $this->api_host, 146 | $this->redirect_url 147 | ); 148 | } 149 | 150 | /** 151 | * Test that an invalid client_secret 152 | * will cause the Client to throw a DuoException 153 | */ 154 | public function testClientBadClientSecret(): void 155 | { 156 | $this->expectException(DuoException::class); 157 | $this->expectExceptionMessage(Client::INVALID_CLIENT_SECRET_ERROR); 158 | $client = new Client( 159 | $this->client_id, 160 | $this->long_client_secret, 161 | $this->api_host, 162 | $this->redirect_url 163 | ); 164 | } 165 | 166 | /** 167 | * Test that generateState does not return the same 168 | * string twice. 169 | */ 170 | public function testGenerateState(): void 171 | { 172 | $client = $this->createGoodClient(); 173 | $string_1 = $client->generateState(); 174 | $this->assertNotEquals( 175 | $string_1, 176 | $client->generateState() 177 | ); 178 | } 179 | 180 | /** 181 | * Test that a successful health check returns a successful result. 182 | */ 183 | public function testHealthCheckGood(): void 184 | { 185 | $client = $this->createClientMockHttp($this->good_http_request); 186 | $result = $client->healthCheck(); 187 | $this->assertEquals($this->expected_good_http_request, $result); 188 | } 189 | 190 | /** 191 | * Test that a failed connection to Duo throws a FAILED_CONNECTION exception. 192 | */ 193 | public function testHealthCheckConnectionFail(): void 194 | { 195 | $this->expectException(DuoException::class); 196 | $this->expectExceptionMessage(Client::FAILED_CONNECTION); 197 | $client = $this->getMockBuilder(Client::class) 198 | ->setConstructorArgs([$this->client_id, $this->client_secret, $this->api_host, $this->redirect_url]) 199 | ->setMethods(['makeHttpsCall']) 200 | ->getMock(); 201 | $client->method('makeHttpsCall') 202 | ->will($this->throwException(new DuoException(Client::FAILED_CONNECTION))); 203 | $client->healthCheck(); 204 | } 205 | 206 | /** 207 | * Test that when Duo is down the client throws an error 208 | */ 209 | public function testHealthCheckBadSig(): void 210 | { 211 | $this->expectException(DuoException::class); 212 | $this->expectExceptionMessage($this->bad_http_request_exception); 213 | $client = $this->createClientMockHttp($this->bad_http_request); 214 | $client->healthCheck(); 215 | } 216 | 217 | /** 218 | * Test that if the health check response is missing stat then the client throws an error. 219 | */ 220 | public function testHealthCheckMissingStat(): void 221 | { 222 | $this->expectException(DuoException::class); 223 | $this->expectExceptionMessage(Client::MALFORMED_RESPONSE); 224 | $client = $this->createClientMockHttp($this->missing_stat_health_check); 225 | $client->healthCheck(); 226 | } 227 | 228 | /** 229 | * Test that if the health check failed and the response is malformed then the client throws an error. 230 | */ 231 | public function testHealthCheckMissingMessage(): void 232 | { 233 | $this->expectException(DuoException::class); 234 | $this->expectExceptionMessage(Client::MALFORMED_RESPONSE); 235 | $client = $this->createClientMockHttp($this->missing_message_health_check); 236 | $client->healthCheck(); 237 | } 238 | 239 | /** 240 | * @dataProvider providerMissingResponseField 241 | */ 242 | public function testMissingResponseField($missing_field): void 243 | { 244 | $result = $this->createTokenResult(); 245 | unset($result[$missing_field]); 246 | $this->expectException(DuoException::class); 247 | $this->expectExceptionMessage(Client::MALFORMED_RESPONSE); 248 | $client = $this->createClientMockHttp($result); 249 | $client->exchangeAuthorizationCodeFor2FAResult($this->code, $this->username); 250 | } 251 | 252 | /** 253 | * Provides a list of missing fields for the response when hitting the TOKEN_ENDPOINT. 254 | */ 255 | public function providerMissingResponseField(): array 256 | { 257 | return [ 258 | ["token_type"], 259 | ["access_token"], 260 | ["expires_in"], 261 | ["id_token"] 262 | ]; 263 | } 264 | /** 265 | * Test bad token_type in response during token exchange throws an error. 266 | */ 267 | public function testTokenExchangeBadTokenType(): void 268 | { 269 | $result_good = $this->createTokenResult(); 270 | $result = str_replace('Bearer', 'BadTokenType', $result_good); 271 | $this->expectException(DuoException::class); 272 | $this->expectExceptionMessage(Client::MALFORMED_RESPONSE); 273 | $client = $this->createClientMockHttp($result); 274 | $client->exchangeAuthorizationCodeFor2FAResult($this->code, $this->username); 275 | } 276 | 277 | /** 278 | * Test bad nonce in id_token during token exchange throws an error. 279 | */ 280 | public function testTokenExchangeBadNonce(): void 281 | { 282 | $payload = $this->createIdToken("nonce"); 283 | $result = $this->createTokenResult($payload); 284 | $this->expectException(DuoException::class); 285 | $this->expectExceptionMessage(Client::NONCE_ERROR); 286 | $client = $this->createClientMockHttp($result); 287 | $client->exchangeAuthorizationCodeFor2FAResult($this->code, $this->username, $this->bad_nonce); 288 | } 289 | 290 | /** 291 | * Test bad JWT signature for id_token during token exchange throws an error. 292 | */ 293 | public function testTokenExchangeBadSig(): void 294 | { 295 | $result = $this->createTokenResult(); 296 | $this->expectException(DuoException::class); 297 | $this->expectExceptionMessage(Client::JWT_DECODE_ERROR); 298 | $client = $this->createClientMockHttp($result, $this->bad_client_secret); 299 | $client->exchangeAuthorizationCodeFor2FAResult($this->code, $this->username); 300 | } 301 | 302 | /** 303 | * Test expired id_token during token exchange throws an error. 304 | */ 305 | public function testTokenExchangeExpired(): void 306 | { 307 | $expired = ["exp" => $this->bad_expiration]; 308 | $payload = $this->createIdToken(null, $expired); 309 | $result = $this->createTokenResult($payload); 310 | $this->expectException(DuoException::class); 311 | $this->expectExceptionMessage(Client::JWT_DECODE_ERROR); 312 | $client = $this->createClientMockHttp($result); 313 | $client->exchangeAuthorizationCodeFor2FAResult($this->code, $this->username); 314 | } 315 | 316 | /** 317 | * Test clock skew more than leeway throws an error. 318 | */ 319 | public function testTokenExchangeLargeClockSkew(): void 320 | { 321 | // Simulate a clock skew (greater than the leeway) by feeding JWT a slightly different timestamp. 322 | JWT::$timestamp = time() - Client::JWT_LEEWAY * 2; 323 | 324 | $payload = $this->createIdToken(); 325 | $result = $this->createTokenResult($payload); 326 | $this->expectException(DuoException::class); 327 | $this->expectExceptionMessage(Client::JWT_DECODE_ERROR); 328 | $client = $this->createClientMockHttp($result); 329 | $client->exchangeAuthorizationCodeFor2FAResult($this->code, $this->username); 330 | } 331 | 332 | /** 333 | * Test clock skew less than leeway is successful. 334 | */ 335 | public function testTokenExchangeSmallClockSkew(): void 336 | { 337 | // Simulate a clock skew (smaller than the leeway) by feeding JWT a slightly different timestamp. 338 | JWT::$timestamp = time() - Client::JWT_LEEWAY / 2; 339 | 340 | $payload = $this->createIdToken(); 341 | $result = $this->createTokenResult($payload); 342 | $client = $this->createClientMockHttp($result); 343 | $token = $client->exchangeAuthorizationCodeFor2FAResult($this->code, $this->username); 344 | $this->assertEquals($this->username, $token['preferred_username']); 345 | } 346 | 347 | /** 348 | * @dataProvider providerMissingField 349 | */ 350 | public function testMissingField(string $missing_field, string $expected_response): void 351 | { 352 | $payload = $this->createIdToken($missing_field); 353 | $result = $this->createTokenResult($payload); 354 | $this->expectException(DuoException::class); 355 | $this->expectExceptionMessage($expected_response); 356 | $client = $this->createClientMockHttp($result); 357 | $client->exchangeAuthorizationCodeFor2FAResult($this->code, $this->username, $this->nonce); 358 | } 359 | 360 | /** 361 | * Provides a list of missing fields and expected expections 362 | * for the id_token in the response when hitting the TOKEN_ENDPOINT. 363 | */ 364 | public function providerMissingField(): array 365 | { 366 | return [ 367 | [ "exp", Client::MALFORMED_RESPONSE], 368 | [ "iat", Client::MALFORMED_RESPONSE], 369 | [ "iss", Client::MALFORMED_RESPONSE], 370 | [ "aud", Client::MALFORMED_RESPONSE], 371 | [ "nonce", Client::NONCE_ERROR], 372 | [ "preferred_username", Client::USERNAME_ERROR ] 373 | ]; 374 | } 375 | 376 | /** 377 | * Test bad iss in id_token during token exchange throws an error. 378 | */ 379 | public function testTokenExchangeBadIss(): void 380 | { 381 | $bad_iss = ["iss" => "https://" . $this->bad_api_host . Client::TOKEN_ENDPOINT]; 382 | $payload = $this->createIdToken(null, $bad_iss); 383 | $result = $this->createTokenResult($payload); 384 | $this->expectException(DuoException::class); 385 | $this->expectExceptionMessage(Client::MALFORMED_RESPONSE); 386 | $client = $this->createClientMockHttp($result); 387 | $client->exchangeAuthorizationCodeFor2FAResult($this->code, $this->username); 388 | } 389 | 390 | /** 391 | * Test bad aud in id_token during token exchange throws an error. 392 | */ 393 | public function testTokenExchangeBadAud(): void 394 | { 395 | $bad_aud = ["aud" => $this->bad_client_id]; 396 | $payload = $this->createIdToken(null, $bad_aud); 397 | $result = $this->createTokenResult($payload); 398 | $this->expectException(DuoException::class); 399 | $this->expectExceptionMessage(Client::MALFORMED_RESPONSE); 400 | $client = $this->createClientMockHttp($result); 401 | $client->exchangeAuthorizationCodeFor2FAResult($this->code, $this->username); 402 | } 403 | 404 | /** 405 | * Test wrong preferred_username in id_token during token exchange throws an error. 406 | */ 407 | public function testTokenExchangeBadUsername(): void 408 | { 409 | $bad_aud = ["preferred_username" => $this->bad_username]; 410 | $payload = $this->createIdToken(null, $bad_aud); 411 | $result = $this->createTokenResult($payload); 412 | $this->expectException(DuoException::class); 413 | $this->expectExceptionMessage(Client::USERNAME_ERROR); 414 | $client = $this->createClientMockHttp($result); 415 | $client->exchangeAuthorizationCodeFor2FAResult($this->code, $this->username); 416 | } 417 | 418 | /** 419 | * Test a successful token exchange. 420 | */ 421 | public function testTokenExchangeSuccess(): void 422 | { 423 | $id_token = $this->createIdToken(); 424 | $result = $this->createTokenResult($id_token); 425 | $jwt_key = new Key($this->client_secret, Client::SIG_ALGORITHM); 426 | $expected_result_obj = JWT::decode($id_token, $jwt_key); 427 | $expected_result = json_decode(json_encode($expected_result_obj), true); 428 | $client = $this->createClientMockHttp($result); 429 | $exchange_result = $client->exchangeAuthorizationCodeFor2FAResult($this->code, $this->username); 430 | $this->assertEquals($expected_result, $exchange_result); 431 | } 432 | 433 | /** 434 | * @dataProvider providerState 435 | */ 436 | public function testCreateAuthUrlState(string $state): void 437 | { 438 | $this->expectException(DuoException::class); 439 | $this->expectExceptionMessage(Client::DUO_STATE_ERROR); 440 | $client = $this->createGoodClient(); 441 | $client->createAuthUrl($this->username, $state); 442 | } 443 | 444 | /** 445 | * Provides a list of invalid states for createAuthUrl 446 | */ 447 | public function providerState(): array 448 | { 449 | $long_state = str_repeat("a", Client::MAX_STATE_LENGTH + 1); 450 | return [ 451 | [$this->short_state], 452 | [$long_state] 453 | ]; 454 | } 455 | 456 | /** 457 | * Test that by default we request the duo_code parameter in our JWT 458 | */ 459 | public function testDuoCodeDefaultTrue(): void 460 | { 461 | $client = new Client( 462 | $this->client_id, 463 | $this->client_secret, 464 | $this->api_host, 465 | $this->redirect_url 466 | ); 467 | $auth_url = $client->createAuthUrl($this->username, $this->good_state); 468 | $jwt = $this->decodeJWTFromURL($auth_url); 469 | $this->assertTrue($jwt["use_duo_code_attribute"]); 470 | } 471 | 472 | /** 473 | * Test that passing false to constructor causes our JWT not request use_duo_code_attribute 474 | */ 475 | public function testDuoCodeSetFalse(): void 476 | { 477 | $client = new Client( 478 | $this->client_id, 479 | $this->client_secret, 480 | $this->api_host, 481 | $this->redirect_url, 482 | false 483 | ); 484 | $auth_url = $client->createAuthUrl($this->username, $this->good_state); 485 | $jwt = $this->decodeJWTFromURL($auth_url); 486 | $this->assertFalse($jwt["use_duo_code_attribute"]); 487 | } 488 | 489 | /** 490 | * Helper to decode a JWT from a URL 491 | */ 492 | public function decodeJWTFromURL(string $url): array 493 | { 494 | $query_str = parse_url($url, PHP_URL_QUERY); 495 | parse_str($query_str, $query_params); 496 | $token = $query_params["request"]; 497 | $jwt_key = new Key($this->client_secret, Client::SIG_ALGORITHM); 498 | $result_obj = JWT::decode($token, $jwt_key); 499 | return json_decode(json_encode($result_obj), true); 500 | } 501 | 502 | /** 503 | * Test a successful createAuthUrl returns a good uri. 504 | */ 505 | public function testCreateAuthUrlSuccess(): void 506 | { 507 | $client = $this->createGoodClient(); 508 | $duo_uri = $client->createAuthUrl($this->username, $this->good_state); 509 | $expected_client_id = "client_id=" . $this->client_id; 510 | $expected_redir_uri = "redirect_uri=" . $this->url_enc_redirect_url; 511 | 512 | $this->assertStringContainsString($expected_client_id, $duo_uri); 513 | $this->assertStringContainsString("response_type=code", $duo_uri); 514 | $this->assertStringContainsString("scope=openid", $duo_uri); 515 | $this->assertStringContainsString($expected_redir_uri, $duo_uri); 516 | } 517 | 518 | /** 519 | * Test that the user agent extension can be set and is included in requests. 520 | */ 521 | public function testAppendToUserAgent(): void 522 | { 523 | $custom_extension = "MyApp/1.0.0"; 524 | $id_token = $this->createIdToken(); 525 | $result = $this->createTokenResult($id_token); 526 | 527 | // Mock the client to capture the user agent sent in HTTP requests 528 | $client = $this->getMockBuilder(Client::class) 529 | ->setConstructorArgs([$this->client_id, $this->client_secret, $this->api_host, $this->redirect_url]) 530 | ->setMethods(['makeHttpsCall']) 531 | ->getMock(); 532 | 533 | // Set up the mock to capture the user agent parameter 534 | $captured_user_agent = null; 535 | $client->method('makeHttpsCall') 536 | ->willReturnCallback(function ($endpoint, $request, $user_agent = null) use (&$captured_user_agent, $result) { 537 | $captured_user_agent = $user_agent; 538 | return $result; 539 | }); 540 | 541 | // Append custom user agent extension 542 | $client->appendToUserAgent($custom_extension); 543 | 544 | // Make a call that uses the user agent 545 | $client->exchangeAuthorizationCodeFor2FAResult($this->code, $this->username); 546 | 547 | // Verify the user agent includes our custom extension 548 | $this->assertNotNull($captured_user_agent); 549 | $this->assertStringContainsString($custom_extension, $captured_user_agent); 550 | $this->assertStringContainsString(Client::USER_AGENT, $captured_user_agent); 551 | $this->assertStringContainsString("php/" . phpversion(), $captured_user_agent); 552 | } 553 | 554 | /** 555 | * Test that user agent works correctly without any extension. 556 | */ 557 | public function testUserAgentWithoutExtension(): void 558 | { 559 | $id_token = $this->createIdToken(); 560 | $result = $this->createTokenResult($id_token); 561 | 562 | // Mock the client to capture the user agent sent in HTTP requests 563 | $client = $this->getMockBuilder(Client::class) 564 | ->setConstructorArgs([$this->client_id, $this->client_secret, $this->api_host, $this->redirect_url]) 565 | ->setMethods(['makeHttpsCall']) 566 | ->getMock(); 567 | 568 | // Set up the mock to capture the user agent parameter 569 | $captured_user_agent = null; 570 | $client->method('makeHttpsCall') 571 | ->willReturnCallback(function ($endpoint, $request, $user_agent = null) use (&$captured_user_agent, $result) { 572 | $captured_user_agent = $user_agent; 573 | return $result; 574 | }); 575 | 576 | // Make a call without setting any custom user agent extension 577 | $client->exchangeAuthorizationCodeFor2FAResult($this->code, $this->username); 578 | 579 | // Verify the user agent contains default information but no custom extension 580 | $this->assertNotNull($captured_user_agent); 581 | $this->assertStringContainsString(Client::USER_AGENT, $captured_user_agent); 582 | $this->assertStringContainsString("php/" . phpversion(), $captured_user_agent); 583 | $this->assertStringContainsString(php_uname(), $captured_user_agent); 584 | } 585 | 586 | /** 587 | * Test that empty user agent extension is handled correctly. 588 | */ 589 | public function testAppendToUserAgentEmpty(): void 590 | { 591 | $id_token = $this->createIdToken(); 592 | $result = $this->createTokenResult($id_token); 593 | 594 | // Mock the client to capture the user agent sent in HTTP requests 595 | $client = $this->getMockBuilder(Client::class) 596 | ->setConstructorArgs([$this->client_id, $this->client_secret, $this->api_host, $this->redirect_url]) 597 | ->setMethods(['makeHttpsCall']) 598 | ->getMock(); 599 | 600 | // Set up the mock to capture the user agent parameter 601 | $captured_user_agent = null; 602 | $client->method('makeHttpsCall') 603 | ->willReturnCallback(function ($endpoint, $request, $user_agent = null) use (&$captured_user_agent, $result) { 604 | $captured_user_agent = $user_agent; 605 | return $result; 606 | }); 607 | 608 | // Append empty user agent extension 609 | $client->appendToUserAgent(""); 610 | 611 | // Make a call 612 | $client->exchangeAuthorizationCodeFor2FAResult($this->code, $this->username); 613 | 614 | // Verify the user agent contains default information but no trailing space 615 | $this->assertNotNull($captured_user_agent); 616 | $this->assertStringContainsString(Client::USER_AGENT, $captured_user_agent); 617 | $this->assertStringContainsString("php/" . phpversion(), $captured_user_agent); 618 | $this->assertStringContainsString(php_uname(), $captured_user_agent); 619 | // Ensure no trailing spaces from empty extension 620 | $expected_base = Client::USER_AGENT . " php/" . phpversion() . " " . php_uname(); 621 | $this->assertEquals($expected_base, $captured_user_agent); 622 | } 623 | 624 | /** 625 | * Test that whitespace-only user agent extension is handled correctly. 626 | */ 627 | public function testAppendToUserAgentWhitespace(): void 628 | { 629 | $id_token = $this->createIdToken(); 630 | $result = $this->createTokenResult($id_token); 631 | 632 | // Mock the client to capture the user agent sent in HTTP requests 633 | $client = $this->getMockBuilder(Client::class) 634 | ->setConstructorArgs([$this->client_id, $this->client_secret, $this->api_host, $this->redirect_url]) 635 | ->setMethods(['makeHttpsCall']) 636 | ->getMock(); 637 | 638 | // Set up the mock to capture the user agent parameter 639 | $captured_user_agent = null; 640 | $client->method('makeHttpsCall') 641 | ->willReturnCallback(function ($endpoint, $request, $user_agent = null) use (&$captured_user_agent, $result) { 642 | $captured_user_agent = $user_agent; 643 | return $result; 644 | }); 645 | 646 | // Append whitespace-only user agent extension 647 | $client->appendToUserAgent(" "); 648 | 649 | // Make a call 650 | $client->exchangeAuthorizationCodeFor2FAResult($this->code, $this->username); 651 | 652 | // Verify the user agent contains default information but no extra whitespace 653 | $this->assertNotNull($captured_user_agent); 654 | $expected_base = Client::USER_AGENT . " php/" . phpversion() . " " . php_uname(); 655 | $this->assertEquals($expected_base, $captured_user_agent); 656 | } 657 | } 658 | --------------------------------------------------------------------------------