├── .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 |
6 |

7 |
8 |
9 |
Auth Response:
10 |
11 |
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 |
6 |

7 |
8 |
11 |
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 | [](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 |
--------------------------------------------------------------------------------