diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100755
index 0000000..713e6a7
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,32 @@
+name: PHPUnit Test Suite
+
+on:
+ push:
+ branches: [ master ]
+ pull_request:
+ branches: [ master ]
+
+jobs:
+ run:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ php-versions: ['5.6', '7.0', '7.1', '7.2', '7.3', '7.4']
+ name: PHP ${{ matrix.php-versions }} Test on ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v2
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-versions }}
+ extensions: json openssl mbstring
+ tools: phpunit
+
+ - name: Install Composer dependencies
+ run: composer install --no-progress --no-suggest --prefer-dist --optimize-autoloader
+
+ - name: Run Tests
+ run: vendor/bin/phpunit --no-coverage
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d04695f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+composer.lock
+phpunit.xml
+vendor
+._*
+*.swp
+.~lock.*
+.buildpath
+.DS_Store
+.idea
+*.iml
+clover.xml
diff --git a/LICENSE b/LICENSE
new file mode 100755
index 0000000..b1fe24f
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright 2017 CurrencyFair Ltd
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/README b/README
new file mode 100644
index 0000000..e69de29
diff --git a/README.md b/README.md
new file mode 100755
index 0000000..4eee078
--- /dev/null
+++ b/README.md
@@ -0,0 +1,292 @@
+# Apple Sign-In PHP Client
+![PHPUnit Test Suite](https://github.com/CurrencyFair/Apple-Sign-In-PHP-Client/workflows/PHPUnit%20Test%20Suite/badge.svg)
+
+Features Include:
+
+ - Generating an Apple authorisation link to use with your Sign-In button
+ - Verifying and decoding Apple JWTs
+ - Verifying Apple Authorisation Codes and exchanging them with Apple's API for access/refresh tokens
+ - Automatic fetching of Apple's public keys and generating of client secrets.
+
+## Contents
+ - [Installation](#installation)
+ - [Configuration](#configuration)
+ - [Usage & Examples](#usage--examples)
+ - [Verify Auth Code & Fetching Access/Refresh Tokens](#verifying-an-authorisation-code-and-retrieving-the-accessrefresh-tokens)
+ - [Verifying & Decoding Apple's JWTs](#verifying-and-decoding-apple-jwts)
+ - [Generating an Authorisation URL for your Sign-In button](#generating-an-authorisation-url-for-your-sign-in-button)
+ - [End-to-End Sign-In page & Return Page](#end-to-end-sign-in-page--return-page)
+ - [FAQ & Troubleshooting](#faq--troubleshooting)
+ - [Useful Links](#useful-links)
+ - [License](#license)
+
+ ## Installation
+
+```bash
+composer require currencyfair/apple-sign-in-php-client
+```
+
+ ## Configuration
+
+ | Config Key | Description |
+ | ------------- | ------------- |
+ | clientId | Also referred to as Service ID. This can be found [here](https://developer.apple.com/account/resources/identifiers/list/serviceId). |
+ | privateKey | The is required to generate the `Client Secret` which is used to verify Authorisation Codes. You can pass a string or the path to the key file. The key can be created and downloaded [here](https://developer.apple.com/account/resources/authkeys/list). |
+ | keyId | The ID associated with the above `privateKey`. This should be available on the page where you downloaded your private key.|
+ | teamId | This is usually found in the top right corner under your name in the Apple Developer area. |
+ | redirectUri | This is the web page users will be redirect to after (un)successful sign-in. This address must be HTTPS and cannot be localhost. See FAQ for localhost workaround. |
+ | defaultScopes | These are the scopes you would like returned from Apple. Apple only supports `name` and `email`. |
+ | apiKeysEndpoint (optional) | URL containing Apple's public key in [JWK format](https://tools.ietf.org/html/rfc7517). Unless you have a reason to change this the default should be fine. |
+ | apiTokenEndpoint (optional) | The endpoint used to verify Authorisation Codes. Unless you have a reason to change this the default should be fine. |
+ | apiAuthEndpoint (optional) | The authorisation URL used to build the URL users will sign in on. Unless you have a reason to change this the default should be fine. |
+
+ See below for examples of passing config values.
+
+## Usage & Examples
+
+### Verifying an Authorisation Code and retrieving the access/refresh tokens
+
+```php
+ 'https://your-redirect.com/',
+ Config::CLIENT_ID => 'XXX',
+ Config::TEAM_ID => 'XXX',
+ Config::KEY_ID => 'XXX',
+ Config::PRIVATE_KEY => '/full/path/to/key' // Or a string containing your key
+ ]
+);
+
+$client = ClientFactory::create($config);
+$authCodeResponse = $client->verifyAuthCode($_POST['code']);
+
+echo $authCodeResponse->getAccessToken();
+echo $authCodeResponse->getExpiresIn();
+echo $authCodeResponse->getRefreshToken();
+```
+See [AuthCodeVerifyResponse](https://github.com/CurrencyFair/Apple-Sign-In-PHP-Client/blob/master/src/Response/AuthCodeVerifyResponse.php) for all available methods.
+
+## Verifying and Decoding Apple JWTs
+
+```php
+ 'https://your-redirect.com/',
+ Config::CLIENT_ID => 'XXX',
+ Config::TEAM_ID => 'XXX',
+ Config::KEY_ID => 'XXX',
+ Config::PRIVATE_KEY => '/full/path/to/key' // Or a string containing your key
+ ]
+);
+
+$client = ClientFactory::create($config);
+$jwtResponse = $client->verifyAndDecodeJwt($_POST['id_token']);
+
+echo $jwtResponse->getEmail();
+echo $jwtResponse->getSubject(); // Unique user ID provided by Apple
+echo $jwtResponse->getIsPrivateEmail();
+echo $jwtResponse->getDecodedTokenObject(); // The unmodified JWT object (Example format below)
+```
+See [JwtVerifyResponse](https://github.com/CurrencyFair/Apple-Sign-In-PHP-Client/blob/master/src/Response/JwtVerifyResponse.php) for all available methods.
+
+### Example Decoded JWT
+```json
+{
+ "iss": "https://appleid.apple.com",
+ "aud": "com.yourApp.web",
+ "exp": 1586606495,
+ "iat": 1586605895,
+ "sub": "000609.fac4e6e9df6a4c1988870f61b86e0b8e.0000",
+ "at_hash": "XXX",
+ "email": "example@example.com",
+ "email_verified": "true",
+ "is_private_email": "false",
+ "auth_time": 1586605860,
+ "nonce_supported": true
+}
+```
+
+## Generating an Authorisation URL for your Sign-In button
+
+```php
+ 'https://your-redirect.com/',
+ Config::CLIENT_ID => 'XXX',
+ ]
+);
+
+$client = ClientFactory::create($config);
+$authorisationUrl = $client->getAuthoriseUrl();
+
+echo " Sign-In With Apple";
+
+```
+
+You can also use [Apple's JS SDK](https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/configuring_your_webpage_for_sign_in_with_apple)
+to show Apple's pre-styled button. Using the above method is for when you would like more control over
+the style of the button.
+
+## End-to-End Sign-In page & Return Page
+
+### your-sign-in-page.php
+```php
+ 'https://example.com/your-return-page.php',
+ Config::CLIENT_ID => 'XXX',
+ ]
+);
+
+// We will use this to verify the request came from Apple on
+// the return page
+$_SESSION['state'] = 'Something Random';
+
+$client = ClientFactory::create($config);
+$authorisationUrl = $client->getAuthoriseUrl($_SESSION['state']);
+
+echo " Sign-In With Apple";
+```
+
+### your-return-page.php
+```php
+ 'https://example.com/your-return-page.php',
+ Config::CLIENT_ID => 'XXX',
+ Config::TEAM_ID => 'XXX',
+ Config::KEY_ID => 'XXX',
+ Config::PRIVATE_KEY => '/full/path/to/key'
+ ]
+);
+
+$client = ClientFactory::create($config);
+
+$authCodeResponse = $client->verifyAuthCode($code);
+
+echo $authCodeResponse->getAccessToken() . PHP_EOL;
+echo $authCodeResponse->getExpiresIn() . PHP_EOL;
+echo $authCodeResponse->getRefreshToken() . PHP_EOL;
+
+$jwtResponse = $client->verifyAndDecodeJwt($idToken);
+
+echo $jwtResponse->getEmail() . PHP_EOL;
+echo $jwtResponse->getSubject() . PHP_EOL; // Unique user ID provided by Apple
+echo $jwtResponse->getIsPrivateEmail() . PHP_EOL;
+```
+
+## FAQ & Troubleshooting
+### I'm developing on localhost, how do I get the redirect URI to work correctly?
+Unfortunately even during testing Apple doesn't allow using localhost or non-HTTPS redirect URLs. To get around this you can use a browser extension like [Requestly](https://www.requestly.in/) to intercept the
+redirect and direct it to your localhost URL. You can also use a secure tunneling tool like [Ngrok](https://ngrok.com).
+
+### I'm getting an `invalid_request - Invalid redirect_uri` error
+This usually occurs if your Redirect URI isn't configured for use in the Apple Developer area. Or the URI may
+be localhost or non-HTTPS.
+
+### I'm getting an `Invalid Grant` error when verifying my Authorisation Code
+This usually means your token is expired or malformed. Apple's tokens have a 10 minute expiry, after this you
+will need to generate a new token.
+
+### How do I get the user's name from Apple?
+Apple will only send the user's name the first time the user registers on your app. The payload is POSTed to
+the Redirect URI along with the authorisation code and the JWT token. The format will look like this:
+
+```json
+{"name":{"firstName":"Dave","lastName":"Tester"},"email":"an@email.com"}
+```
+
+### I would like the Sign-In to happen in a pop-up window
+You can use [Apple's JS SDK](https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/configuring_your_webpage_for_sign_in_with_apple) to achieve this.
+
+### I'm getting an `Error processing private key` error?
+If you're passing the key as a string ensure the formatting is correct. An example of the correct way to pass the key:
+
+```php
+$privateKey = << $privateKey,
+ ]
+);
+```
+
+### Can the request to fetch Apple's public key be cached?
+Yes, you can use [Guzzle Middleware](https://github.com/Kevinrob/guzzle-cache-middleware#how) to handle caching. You can also inject your own cache enabled client which implements [ClientInterface](https://github.com/guzzle/guzzle/blob/master/src/ClientInterface.php#L13).
+
+## Useful links
+https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/configuring_your_webpage_for_sign_in_with_apple
+
+https://developer.okta.com/blog/2019/06/04/what-the-heck-is-sign-in-with-apple
+
+https://sarunw.com/posts/sign-in-with-apple-3
+
+## License
+
+
+
+
+
+
+Developed by CurrencyFair (https://currencyfair.com) and licensed under the terms of the [Apache License, Version 2.0](https://github.com/CurrencyFair/Apple-Sign-In-PHP-Client/blob/master/LICENSE).
diff --git a/composer.json b/composer.json
new file mode 100755
index 0000000..70ab64a
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,31 @@
+{
+ "name": "currencyfair/apple-sign-in-php-client",
+ "description": "PHP Client for Apple Sign-In",
+ "keywords": [
+ "Apple",
+ "Apple Sign-in",
+ "Apple Jwt"
+ ],
+ "require": {
+ "php": ">=5.6",
+ "guzzlehttp/guzzle": "~6.5",
+ "firebase/php-jwt": "~5.2",
+ "ext-json": "*",
+ "ext-openssl": "*"
+ },
+ "require-dev": {
+ "mockery/mockery": "1.3.1",
+ "phpunit/phpunit": "5.7.27"
+ },
+ "autoload": {
+ "psr-4": {
+ "CurrencyFair\\AppleId\\": "src"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Test\\": "test"
+ }
+ },
+ "license": "Apache-2.0"
+}
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100755
index 0000000..719a2b7
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,16 @@
+
+
+
+
+ ./test
+
+
+
+
+ ./src
+
+
+
+
+
+
diff --git a/src/Client.php b/src/Client.php
new file mode 100644
index 0000000..92765c4
--- /dev/null
+++ b/src/Client.php
@@ -0,0 +1,195 @@
+httpClient = $httpClient;
+ $this->config = $config;
+ }
+
+ /**
+ * Verifies and decodes Apple JWTs and returns the decoded token information
+ *
+ * @param string $jwtToken
+ * @return JwtVerifyResponse
+ *
+ * @throws Exception
+ */
+ public function verifyAndDecodeJwt($jwtToken)
+ {
+ return new JwtVerifyResponse(
+ JWT::decode($jwtToken, $this->getApplePublicKey(), ['RS256'])
+ );
+ }
+
+ /**
+ * Verifies an Authorisation Code provided by Apple and returns token information
+ *
+ * @param string $authCode
+ * @return AuthCodeVerifyResponse
+ *
+ * @throws Exception | RequestException
+ */
+ public function verifyAuthCode($authCode)
+ {
+ $response = $this->httpClient->post(
+ $this->config->get(Config::API_TOKEN_ENDPOINT),
+ [
+ RequestOptions::FORM_PARAMS => [
+ 'grant_type' => 'authorization_code',
+ 'code' => $authCode,
+ 'redirect_uri' => $this->config->get(Config::REDIRECT_URI),
+ 'client_id' => $this->config->get(Config::CLIENT_ID),
+ 'client_secret' => $this->generateClientSecret(),
+ ],
+ RequestOptions::HEADERS => [
+ 'Accept' => 'application/json'
+ ]
+ ]
+ );
+
+ if ($response->getStatusCode() !== self::HTTP_OK) {
+ throw new Exception(
+ sprintf(
+ 'Received %d response while verifying Authorisation Code. Response Body: %s',
+ $response->getStatusCode(),
+ $response->getBody()->getContents()
+ )
+ );
+ }
+
+ return new AuthCodeVerifyResponse(
+ json_decode($response->getBody()->getContents(), true)
+ );
+ }
+
+ /**
+ * Returns a URL used to create a Sign-In with Apple Link
+ *
+ * @param string $state This will be POSTed back by to the redirect_uri.
+ * @return string
+ *
+ * @throws Exception
+ */
+ public function getAuthoriseUrl($state = '')
+ {
+ return $this->config->get(Config::API_AUTH_ENDPOINT) . '?' . http_build_query(
+ [
+ 'response_type' => 'code id_token',
+ 'response_mode' => 'form_post',
+ 'client_id' => $this->config->get(Config::CLIENT_ID),
+ 'redirect_uri' => $this->config->get(Config::REDIRECT_URI),
+ 'state' => $state,
+ 'scope' => $this->config->get(Config::DEFAULT_SCOPES),
+ ]
+ );
+ }
+
+ /**
+ * Generate a client secret using the private key downloaded from the
+ * Apple Developer area
+ *
+ * @see https://developer.apple.com/account/resources/authkeys/list
+ *
+ * @return string
+ *
+ * @throws Exception
+ */
+ private function generateClientSecret()
+ {
+ return JWT::encode(
+ [
+ 'iss' => $this->config->get(Config::TEAM_ID),
+ 'iat' => time(),
+ 'exp' => time() + 3600,
+ 'aud' => 'https://appleid.apple.com',
+ 'sub' => $this->config->get(Config::CLIENT_ID),
+ ],
+ $this->getPrivateKey(),
+ 'ES256',
+ $this->config->get(Config::KEY_ID)
+ );
+ }
+
+ /**
+ * Retrieves Apple's JWK public key
+ *
+ * @return array
+ * @throws Exception | RequestException
+ */
+ private function getApplePublicKey()
+ {
+ $response = $this->httpClient->get($this->config->get(Config::API_KEYS_ENDPOINT));
+ if ($response->getStatusCode() !== self::HTTP_OK) {
+ throw new Exception(
+ sprintf(
+ 'Received %d response while fetching Apple\'s Public Key. Response Body: %s',
+ $response->getStatusCode(),
+ $response->getBody()->getContents()
+ )
+ );
+ }
+
+ $appleJwkKeyArray = json_decode($response->getBody()->getContents(), true);
+ if (!is_array($appleJwkKeyArray)) {
+ throw new Exception('Failed to decode JSON - Invalid data returned');
+ }
+
+ return JWK::parseKeySet($appleJwkKeyArray);
+ }
+
+ /**
+ * @return resource
+ *
+ * @throws Exception
+ */
+ private function getPrivateKey()
+ {
+ $key = $this->config->get(Config::PRIVATE_KEY);
+ $keyContents = is_file($key) ? file_get_contents($key) : $key;
+
+ if (!$keyContents) {
+ throw new Exception('Private key must be a string or a valid file path.');
+ }
+
+ if ($keyResource = openssl_pkey_get_private($keyContents)) {
+ return $keyResource;
+ }
+
+ throw new Exception(
+ sprintf(
+ 'Error processing private key. Please check your \'%s\' config value',
+ Config::PRIVATE_KEY
+ )
+ );
+ }
+}
diff --git a/src/ClientFactory.php b/src/ClientFactory.php
new file mode 100644
index 0000000..25b393e
--- /dev/null
+++ b/src/ClientFactory.php
@@ -0,0 +1,17 @@
+ 'name email',
+ self::API_KEYS_ENDPOINT => 'https://appleid.apple.com/auth/keys',
+ self::API_TOKEN_ENDPOINT => 'https://appleid.apple.com/auth/token',
+ self::API_AUTH_ENDPOINT => 'https://appleid.apple.com/auth/authorize',
+ ];
+
+ public function __construct(array $config)
+ {
+ $this->validateConfig($config);
+ $this->config = array_merge($this->config, $config);
+ }
+
+ /**
+ * @param string $configKey
+ * @return string
+ *
+ * @throws Exception
+ */
+ public function get($configKey)
+ {
+ if (isset($this->config[$configKey])) {
+ return $this->config[$configKey];
+ }
+
+ throw new Exception(sprintf('config value \'%s\' is not set.', $configKey));
+ }
+
+ /**
+ * @param array $config
+ *
+ * @throws InvalidArgumentException
+ */
+ private function validateConfig(array $config)
+ {
+ foreach ($config as $key => $value) {
+ if (!in_array($key, self::$allowedKeys)) {
+ throw new InvalidArgumentException(sprintf('%s is not a valid config value.', $key));
+ }
+ }
+ }
+}
diff --git a/src/Response/AuthCodeVerifyResponse.php b/src/Response/AuthCodeVerifyResponse.php
new file mode 100644
index 0000000..31b4ca6
--- /dev/null
+++ b/src/Response/AuthCodeVerifyResponse.php
@@ -0,0 +1,55 @@
+accessToken = isset($response['access_token']) ? $response['access_token'] : null;
+ $this->tokenType = isset($response['token_type']) ? $response['token_type'] : null;
+ $this->expiresIn = isset($response['expires_in']) ? $response['expires_in'] : null;
+ $this->refreshToken = isset($response['refresh_token']) ? $response['refresh_token'] : null;
+ $this->idToken = isset($response['id_token']) ? $response['id_token'] : null;
+ }
+
+ public function getAccessToken()
+ {
+ return $this->accessToken;
+ }
+
+ public function getTokenType()
+ {
+ return $this->tokenType;
+ }
+
+ public function getExpiresIn()
+ {
+ return $this->expiresIn;
+ }
+
+ public function getRefreshToken()
+ {
+ return $this->refreshToken;
+ }
+
+ public function getIdToken()
+ {
+ return $this->idToken;
+ }
+}
diff --git a/src/Response/JwtVerifyResponse.php b/src/Response/JwtVerifyResponse.php
new file mode 100644
index 0000000..35f7594
--- /dev/null
+++ b/src/Response/JwtVerifyResponse.php
@@ -0,0 +1,134 @@
+issuer = isset($response->iss) ? $response->iss : null;
+ $this->audience = isset($response->aud) ? $response->aud : null;
+ $this->issuedAt = isset($response->iat) ? $response->iat : null;
+ $this->expiry = isset($response->exp) ? $response->exp : null;
+ $this->subject = isset($response->sub) ? $response->sub : null;
+ $this->accessTokenHash = isset($response->at_hash) ? $response->at_hash : null;
+ $this->codeHash = isset($response->c_hash) ? $response->c_hash : null;
+ $this->email = isset($response->email) ? $response->email : null;
+ $this->emailVerified = isset($response->email_verified) ? $response->email_verified : false;
+ $this->isPrivateEmail = isset($response->is_private_email) ? $response->is_private_email : false;
+ $this->authTime = isset($response->auth_time) ? $response->auth_time : null;
+ $this->nonceSupported = isset($response->nonce_supported) ? $response->nonce_supported : false;
+ $this->decodedTokenObject = $response;
+ }
+
+ public function getIssuer()
+ {
+ return $this->issuer;
+ }
+
+ public function getAudience()
+ {
+ return $this->audience;
+ }
+
+ public function getIssuedAt()
+ {
+ return $this->issuedAt;
+ }
+
+ public function getExpiry()
+ {
+ return $this->expiry;
+ }
+
+ public function getSubject()
+ {
+ return $this->subject;
+ }
+
+ public function getAccessTokenHash()
+ {
+ return $this->accessTokenHash;
+ }
+
+ public function getCodeHash()
+ {
+ return $this->codeHash;
+ }
+
+ public function getEmail()
+ {
+ return $this->email;
+ }
+
+ public function getEmailVerified()
+ {
+ return $this->emailVerified === 'true';
+ }
+
+ public function getIsPrivateEmail()
+ {
+ return $this->isPrivateEmail === 'true';
+ }
+
+ public function getAuthTime()
+ {
+ return $this->authTime;
+ }
+
+ public function getNonceSupported()
+ {
+ return $this->nonceSupported === 'true';
+ }
+
+ /**
+ * Return the unmodified decoded token
+ *
+ * @return stdClass
+ */
+ public function getDecodedTokenObject()
+ {
+ return $this->decodedTokenObject;
+ }
+}
diff --git a/test/ClientFactoryTest.php b/test/ClientFactoryTest.php
new file mode 100644
index 0000000..81c1f01
--- /dev/null
+++ b/test/ClientFactoryTest.php
@@ -0,0 +1,18 @@
+assertInstanceOf(Client::class, $client);
+ }
+}
diff --git a/test/ClientTest.php b/test/ClientTest.php
new file mode 100644
index 0000000..a8197d1
--- /dev/null
+++ b/test/ClientTest.php
@@ -0,0 +1,159 @@
+appleJwk = file_get_contents(__DIR__ . '/data/appleJwk.json');
+ $this->appleJwt = file_get_contents(__DIR__ . '/data/appleJwt');
+ parent::setUp();
+ }
+
+ public function testVerifyJwtKeyWithExpiredToken()
+ {
+ $responseStreamMock = m::mock(StreamInterface::class)
+ ->shouldReceive('getContents')
+ ->andReturn($this->appleJwk)
+ ->getMock();
+
+ $httpResponseMock = m::mock(ResponseInterface::class)
+ ->shouldReceive('getStatusCode')
+ ->andReturn(200)
+ ->getMock()
+ ->shouldReceive('getBody')
+ ->andReturn($responseStreamMock)
+ ->getMock();
+
+ /** @var HttpClient $httpClientMock */
+ $httpClientMock = m::mock(HttpClient::class)
+ ->shouldReceive('get')
+ ->with('keys_endpoint.com')
+ ->andReturn($httpResponseMock)
+ ->getMock();
+
+ $service = new Client(
+ $httpClientMock,
+ new Config(
+ [
+ Config::API_KEYS_ENDPOINT => 'keys_endpoint.com'
+ ]
+ )
+ );
+
+ $this->expectException(ExpiredException::class);
+ $this->expectExceptionMessage('Expired token');
+
+ $service->verifyAndDecodeJwt($this->appleJwt);
+ }
+
+ public function testVerifyJwtFailsWhenAppleApiUnavailable()
+ {
+ $expectedError = '{"error":"500"}';
+ $responseStreamMock = m::mock(StreamInterface::class)
+ ->shouldReceive('getContents')
+ ->andReturn($expectedError)
+ ->getMock();
+
+ $httpResponseMock = m::mock(ResponseInterface::class)
+ ->shouldReceive('getStatusCode')
+ ->andReturn(500)
+ ->getMock()
+ ->shouldReceive('getBody')
+ ->andReturn($responseStreamMock)
+ ->getMock();
+
+ /** @var HttpClient $httpClientMock */
+ $httpClientMock = m::mock(HttpClient::class)
+ ->shouldReceive('get')
+ ->with('keys_endpoint.com')
+ ->andReturn($httpResponseMock)
+ ->getMock();
+
+ $config = [
+ Config::API_KEYS_ENDPOINT => 'keys_endpoint.com'
+ ];
+
+ $service = new Client(
+ $httpClientMock, new Config($config)
+ );
+
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage(
+ 'Received 500 response while fetching Apple\'s Public Key. Response Body: ' . $expectedError
+ );
+ $service->verifyAndDecodeJwt($this->appleJwt);
+ }
+
+ public function testVerifyJwtFailsWhenInvalidDataReturned()
+ {
+ $responseStreamMock = m::mock(StreamInterface::class)
+ ->shouldReceive('getContents')
+ ->andReturn('Invalid Data')
+ ->getMock();
+
+ $httpResponseMock = m::mock(ResponseInterface::class)
+ ->shouldReceive('getStatusCode')
+ ->andReturn(200)
+ ->getMock()
+ ->shouldReceive('getBody')
+ ->andReturn($responseStreamMock)
+ ->getMock();
+
+ /** @var HttpClient $httpClientMock */
+ $httpClientMock = m::mock(HttpClient::class)
+ ->shouldReceive('get')
+ ->with('keys_endpoint.com')
+ ->andReturn($httpResponseMock)
+ ->getMock();
+
+ $service = new Client(
+ $httpClientMock, new Config(
+ [
+ Config::API_KEYS_ENDPOINT => 'keys_endpoint.com'
+ ]
+ )
+ );
+
+ $this->expectException(Exception::class);
+ $this->expectExceptionMessage('Failed to decode JSON - Invalid data returned');
+ $service->verifyAndDecodeJwt($this->appleJwt);
+ }
+
+ public function testGetAuthoriseUrl()
+ {
+ $expectedUrl = 'https://appleid.apple.com/auth/authorize?response_type=code+id_token&response_mode=form_post&' .
+ 'client_id=client&redirect_uri=redirect_url&state=state&scope=scopes';
+
+ /** @var HttpClient $httpClientMock */
+ $httpClientMock = m::mock(HttpClient::class);
+ $service = new Client(
+ $httpClientMock, new Config(
+ [
+ Config::CLIENT_ID => 'client',
+ Config::REDIRECT_URI => 'redirect_url',
+ Config::DEFAULT_SCOPES => 'scopes',
+ ]
+ )
+ );
+
+ $actualUrl = $service->getAuthoriseUrl('state');
+ $this->assertSame($expectedUrl, $actualUrl);
+ }
+}
diff --git a/test/ConfigTest.php b/test/ConfigTest.php
new file mode 100644
index 0000000..ac0fd78
--- /dev/null
+++ b/test/ConfigTest.php
@@ -0,0 +1,35 @@
+expectException(Exception::class);
+ $this->expectExceptionMessage('Invalid Key is not a valid config value.');
+ (new Config(['Invalid Key' => 'Value']));
+ }
+
+ public function testDefaultValuesAreSet()
+ {
+ $expectedValue = 'https://appleid.apple.com/auth/keys';
+ $config = new Config([]);
+ $this->assertEquals($expectedValue, $config->get(Config::API_KEYS_ENDPOINT));
+ }
+
+ public function testCanOverrideDefaultValue()
+ {
+ $configValue = 'http://override.com';
+ $config = new Config(
+ [
+ Config::API_KEYS_ENDPOINT => $configValue
+ ]
+ );
+ $this->assertEquals($configValue, $config->get(Config::API_KEYS_ENDPOINT));
+ }
+}
diff --git a/test/Response/AuthCodeVerifyResponseTest.php b/test/Response/AuthCodeVerifyResponseTest.php
new file mode 100644
index 0000000..81ffb80
--- /dev/null
+++ b/test/Response/AuthCodeVerifyResponseTest.php
@@ -0,0 +1,55 @@
+ 'abc',
+ 'refresh_token' => 'abc',
+ 'token_type' => 'Bearer',
+ 'expires_in' => 360,
+ 'id_token' => 'abc'
+ ];
+
+ $authVerifyResponse = new AuthCodeVerifyResponse($expectedValues);
+
+ $actualValues = [
+ 'access_token' => $authVerifyResponse->getAccessToken(),
+ 'refresh_token' => $authVerifyResponse->getRefreshToken(),
+ 'token_type' => $authVerifyResponse->getTokenType(),
+ 'expires_in' => $authVerifyResponse->getExpiresIn(),
+ 'id_token' => $authVerifyResponse->getIdToken(),
+ ];
+
+ $this->assertSame($expectedValues, $actualValues);
+ }
+
+ public function testNullsReturnedForMissingData()
+ {
+ $expectedValues = [
+ 'access_token' => null,
+ 'refresh_token' => null,
+ 'token_type' => null,
+ 'expires_in' => null,
+ 'id_token' => null,
+ ];
+
+ $authVerifyResponse = new AuthCodeVerifyResponse([]);
+
+ $actualValues = [
+ 'access_token' => $authVerifyResponse->getAccessToken(),
+ 'refresh_token' => $authVerifyResponse->getRefreshToken(),
+ 'token_type' => $authVerifyResponse->getTokenType(),
+ 'expires_in' => $authVerifyResponse->getExpiresIn(),
+ 'id_token' => $authVerifyResponse->getIdToken(),
+ ];
+
+ $this->assertSame($expectedValues, $actualValues);
+ }
+}
diff --git a/test/Response/JwtVerifyResponseTest.php b/test/Response/JwtVerifyResponseTest.php
new file mode 100644
index 0000000..0fda81c
--- /dev/null
+++ b/test/Response/JwtVerifyResponseTest.php
@@ -0,0 +1,39 @@
+appleJwt = json_decode(file_get_contents(__DIR__ . '/../data/appleJwtDecoded.json'));
+ parent::setUp();
+ }
+
+ public function testCorrectValuesReturned()
+ {
+ $jwtVerifyResponse = new JwtVerifyResponse($this->appleJwt);
+
+ $this->assertSame($this->appleJwt, $jwtVerifyResponse->getDecodedTokenObject());
+ $this->assertSame($this->appleJwt->email, $jwtVerifyResponse->getEmail());
+ $this->assertSame($this->appleJwt->aud, $jwtVerifyResponse->getAudience());
+ $this->assertSame($this->appleJwt->iat, $jwtVerifyResponse->getIssuedAt());
+ $this->assertSame($this->appleJwt->sub, $jwtVerifyResponse->getSubject());
+ $this->assertSame($this->appleJwt->c_hash, $jwtVerifyResponse->getCodeHash());
+ $this->assertSame($this->appleJwt->at_hash, $jwtVerifyResponse->getAccessTokenHash());
+ $this->assertSame($this->appleJwt->exp, $jwtVerifyResponse->getExpiry());
+ $this->assertSame($this->appleJwt->iss, $jwtVerifyResponse->getIssuer());
+ $this->assertSame($this->appleJwt->auth_time, $jwtVerifyResponse->getAuthTime());
+ $this->assertTrue($jwtVerifyResponse->getEmailVerified());
+ $this->assertTrue($jwtVerifyResponse->getIsPrivateEmail());
+ $this->assertTrue($jwtVerifyResponse->getNonceSupported());
+ $this->assertTrue($jwtVerifyResponse->getIsPrivateEmail());
+ }
+}
diff --git a/test/data/appleJwk.json b/test/data/appleJwk.json
new file mode 100644
index 0000000..11d092c
--- /dev/null
+++ b/test/data/appleJwk.json
@@ -0,0 +1,20 @@
+{
+ "keys": [
+ {
+ "kty": "RSA",
+ "kid": "86D88Kf",
+ "use": "sig",
+ "alg": "RS256",
+ "n": "iGaLqP6y-SJCCBq5Hv6pGDbG_SQ11MNjH7rWHcCFYz4hGwHC4lcSurTlV8u3avoVNM8jXevG1Iu1SY11qInqUvjJur--hghr1b56OPJu6H1iKulSxGjEIyDP6c5BdE1uwprYyr4IO9th8fOwCPygjLFrh44XEGbDIFeImwvBAGOhmMB2AD1n1KviyNsH0bEB7phQtiLk-ILjv1bORSRl8AK677-1T8isGfHKXGZ_ZGtStDe7Lu0Ihp8zoUt59kx2o9uWpROkzF56ypresiIl4WprClRCjz8x6cPZXU2qNWhu71TQvUFwvIvbkE1oYaJMb0jcOTmBRZA2QuYw-zHLwQ",
+ "e": "AQAB"
+ },
+ {
+ "kty": "RSA",
+ "kid": "eXaunmL",
+ "use": "sig",
+ "alg": "RS256",
+ "n": "4dGQ7bQK8LgILOdLsYzfZjkEAoQeVC_aqyc8GC6RX7dq_KvRAQAWPvkam8VQv4GK5T4ogklEKEvj5ISBamdDNq1n52TpxQwI2EqxSk7I9fKPKhRt4F8-2yETlYvye-2s6NeWJim0KBtOVrk0gWvEDgd6WOqJl_yt5WBISvILNyVg1qAAM8JeX6dRPosahRVDjA52G2X-Tip84wqwyRpUlq2ybzcLh3zyhCitBOebiRWDQfG26EH9lTlJhll-p_Dg8vAXxJLIJ4SNLcqgFeZe4OfHLgdzMvxXZJnPp_VgmkcpUdRotazKZumj6dBPcXI_XID4Z4Z3OM1KrZPJNdUhxw",
+ "e": "AQAB"
+ }
+ ]
+}
diff --git a/test/data/appleJwt b/test/data/appleJwt
new file mode 100644
index 0000000..186de20
--- /dev/null
+++ b/test/data/appleJwt
@@ -0,0 +1 @@
+eyJraWQiOiJlWGF1bm1MIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiYXVkIjoiY29tLmN1cnJlbmN5ZmFpci5hcHBzLndlYi5zdGFnaW5nLkN1cnJlbmN5RmFpciIsImV4cCI6MTU4NjI0OTI0MCwiaWF0IjoxNTg2MjQ4NjQwLCJzdWIiOiIwMDA2MDkuZmFjNGU2ZTlkZjZhNGMxOTg4ODcwZjYxYjg2ZTBiOGUuMjE0OSIsImNfaGFzaCI6Ikp4UjZzcFJUN014dkdBQVRUaFN3ZmciLCJlbWFpbCI6ImRhdmlkZWFybGV5QGN1cnJlbmN5ZmFpci5jb20iLCJlbWFpbF92ZXJpZmllZCI6InRydWUiLCJhdXRoX3RpbWUiOjE1ODYyNDg2NDAsIm5vbmNlX3N1cHBvcnRlZCI6dHJ1ZX0.Jf0Zc3DirkWqppRGvTTUKNisxnsv9epx1bbLoh46-yauv1SDARTmqevJuUMyEXmcSD0tbfzK4atLYbqi_KVZpqH4SUaWV9FhonG9FqDa5aCPYurP1a5UCkNjzZZce0rF9oFP_51G-pgxF2SnruPfTghcueGsQgOCB2OnPl2hNWwBgbKTxqZShELbuceNTy0AG495F49x8b3LRsqveYv7Eg9iVzPtArI-Ck3iZ61wB_9s5H-3KY2h-dc2RZwHnbu2RiqzkDNcKG8jJc0oJegpduy3dYSamoPDZT3oycG23yqMQwX8zmL_qVj8pu9KyPtmlxKGNIoCHgBUD49OLueSlQ
diff --git a/test/data/appleJwtDecoded.json b/test/data/appleJwtDecoded.json
new file mode 100644
index 0000000..08972e7
--- /dev/null
+++ b/test/data/appleJwtDecoded.json
@@ -0,0 +1,14 @@
+{
+ "iss": "https://appleid.apple.com",
+ "aud": "aud",
+ "exp": 1586249240,
+ "iat": 1586248640,
+ "sub": "000609.xxx.2149",
+ "c_hash": "xxx",
+ "at_hash": "xxx",
+ "email": "xxx@xxx.com",
+ "is_private_email": "true",
+ "email_verified": "true",
+ "auth_time": 1586248640,
+ "nonce_supported": "true"
+}