diff --git a/src/Apple/AppleToken.php b/src/Apple/AppleToken.php new file mode 100644 index 000000000..b93791e67 --- /dev/null +++ b/src/Apple/AppleToken.php @@ -0,0 +1,32 @@ +jwtConfig = $jwtConfig; + } + + public function generate(): string + { + $now = CarbonImmutable::now(); + + $token = $this->jwtConfig->builder() + ->issuedBy(config('services.apple.team_id')) + ->issuedAt($now) + ->expiresAt($now->addHour()) + ->permittedFor(Provider::URL) + ->relatedTo(config('services.apple.client_id')) + ->withHeader('kid', config('services.apple.key_id')) + ->getToken($this->jwtConfig->signer(), $this->jwtConfig->signingKey()); + + return $token->toString(); + } +} diff --git a/src/Apple/Provider.php b/src/Apple/Provider.php index 9bec2a5d2..447d22a52 100644 --- a/src/Apple/Provider.php +++ b/src/Apple/Provider.php @@ -5,6 +5,7 @@ use Firebase\JWT\JWK; use GuzzleHttp\Client; use GuzzleHttp\RequestOptions; +use Illuminate\Http\Request; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; @@ -24,7 +25,7 @@ class Provider extends AbstractProvider { public const IDENTIFIER = 'APPLE'; - private const URL = 'https://appleid.apple.com'; + public const URL = 'https://appleid.apple.com'; protected $scopes = [ 'name', @@ -38,6 +39,23 @@ class Provider extends AbstractProvider protected $scopeSeparator = ' '; + /** + * JWT Configuration. + * + * @var ?Configuration + */ + protected $jwtConfig = null; + + /** + * Private Key. + * + * @var string + */ + protected $privateKey = ''; + + /** + * {@inheritdoc} + */ protected function getAuthUrl($state): string { return $this->buildAuthUrlFromBase(self::URL.'/auth/authorize', $state); @@ -75,7 +93,7 @@ protected function getCodeFields($state = null) public function getAccessTokenResponse($code) { $response = $this->getHttpClient()->post($this->getTokenUrl(), [ - RequestOptions::HEADERS => ['Authorization' => 'Basic '.base64_encode($this->clientId.':'.$this->clientSecret)], + RequestOptions::HEADERS => ['Authorization' => 'Basic '.base64_encode($this->clientId.':'.$this->getClientSecret())], RequestOptions::FORM_PARAMS => $this->getTokenFields($code), ]); @@ -87,12 +105,53 @@ public function getAccessTokenResponse($code) */ protected function getUserByToken($token) { - static::verify($token); + $this->checkToken($token); $claims = explode('.', $token)[1]; return json_decode(base64_decode($claims), true); } + protected function getClientSecret() + { + if (!$this->jwtConfig) { + $this->getJwtConfig(); // Generate Client Secret from private key if not set. + } + + return $this->clientSecret; + } + + protected function getJwtConfig() + { + if (!$this->jwtConfig) { + $private_key_path = $this->getConfig('private_key', ''); + $private_key_passphrase = $this->getConfig('passphrase', ''); + $signer = $this->getConfig('signer', ''); + + if (empty($signer) || !class_exists($signer)) { + $signer = !empty($private_key_path) ? \Lcobucci\JWT\Signer\Ecdsa\Sha256::class : AppleSignerNone::class; + } + + if (!empty($private_key_path) && file_exists($private_key_path)) { + $this->privateKey = file_get_contents($private_key_path); + } else { + $this->privateKey = $private_key_path; // Support for plain text private keys + } + + $this->jwtConfig = Configuration::forSymmetricSigner( + new $signer(), + AppleSignerInMemory::plainText($this->privateKey, $private_key_passphrase) + ); + + if (!empty($this->privateKey)) { + $appleToken = new AppleToken($this->getJwtConfig()); + $this->clientSecret = $appleToken->generate(); + config()->set('services.apple.client_secret', $this->clientSecret); + } + } + + return $this->jwtConfig; + } + /** * Return the user given the identity token provided on the client * side by Apple. @@ -110,20 +169,16 @@ public function userByIdentityToken(string $token): User } /** - * Verify Apple jwt. + * Verify Apple JWT. * * @param string $jwt * @return bool * * @see https://appleid.apple.com/auth/keys */ - public static function verify($jwt) + public function checkToken($jwt) { - $jwtContainer = Configuration::forSymmetricSigner( - new AppleSignerNone, - AppleSignerInMemory::plainText('') - ); - $token = $jwtContainer->parser()->parse($jwt); + $token = $this->getJwtConfig()->parser()->parse($jwt); $data = Cache::remember('socialite:Apple-JWKSet', 5 * 60, function () { $response = (new Client)->get(self::URL.'/auth/keys'); @@ -143,7 +198,7 @@ public static function verify($jwt) ]; try { - $jwtContainer->validator()->assert($token, ...$constraints); + $this->jwtConfig->validator()->assert($token, ...$constraints); return true; } catch (RequiredConstraintsViolated $e) { @@ -154,6 +209,25 @@ public static function verify($jwt) throw new InvalidStateException('Invalid JWT Signature'); } + /** + * Verify Apple jwt via static function. + * + * @param string $jwt + * + * @return bool + * + * @see https://appleid.apple.com/auth/keys + */ + public static function verify($jwt) + { + return (new self( + new Request(), + config('services.apple.client_id'), + config('services.apple.client_secret'), + config('services.apple.redirect') + ))->checkToken($jwt); + } + /** * {@inheritdoc} */ @@ -251,9 +325,9 @@ protected function getRevokeUrl(): string public function revokeToken(string $token, string $hint = 'access_token') { return $this->getHttpClient()->post($this->getRevokeUrl(), [ - RequestOptions::FORM_PARAMS => [ + RequestOptions::FORM_PARAMS => [ 'client_id' => $this->clientId, - 'client_secret' => $this->clientSecret, + 'client_secret' => $this->getClientSecret(), 'token' => $token, 'token_type_hint' => $hint, ], @@ -283,4 +357,12 @@ public function refreshToken($refreshToken): ResponseInterface ], ]); } + + /** + * {@inheritdoc} + */ + public static function additionalConfigKeys() + { + return ['private_key', 'passphrase', 'signer']; + } } diff --git a/src/Apple/README.md b/src/Apple/README.md index cae5fc37c..94f92d5bb 100644 --- a/src/Apple/README.md +++ b/src/Apple/README.md @@ -22,6 +22,25 @@ See [Configure Apple ID Authentication](https://developer.okta.com/blog/2019/06/ > Note: the client secret used for "Sign In with Apple" is a JWT token that can have a maximum lifetime of 6 months. The article above explains how to generate the client secret on demand and you'll need to update this every 6 months. To generate the client secret for each request, see [Generating A Client Secret For Sign In With Apple On Each Request](https://bannister.me/blog/generating-a-client-secret-for-sign-in-with-apple-on-each-request) +If you don't have secret token, or you don't want to it do manually, you can use a private key ([see official docs](https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens#3262048)). +Add lines to the configuration as follows: + +```php +'apple' => [ + 'client_id' => env('APPLE_CLIENT_ID'), // Required. Bundle ID from Identifier in Apple Developer. + 'client_secret' => env('APPLE_CLIENT_SECRET'), // Empty. We create it from private key. + 'key_id' => env('APPLE_KEY_ID'), // Required. Key ID from Keys in Apple Developer. + 'team_id' => env('APPLE_TEAM_ID'), // Required. App ID Prefix from Identifier in Apple Developer. + 'private_key' => env('APPLE_PRIVATE_KEY'), // Required. Must be absolute path, e.g. /var/www/cert/AuthKey_XYZ.p8 + 'passphrase' => env('APPLE_PASSPHRASE'), // Optional. Set if your private key have a passphrase. + 'signer' => env('APPLE_SIGNER'), // Optional. Signer used for Configuration::forSymmetricSigner(). Default: \Lcobucci\JWT\Signer\Ecdsa\Sha256 + 'redirect' => env('APPLE_REDIRECT_URI') // Required. +], +``` + +If you receive error `400 Bad Request {"error":"invalid_client"}` , a possible solution is to use another Signer (Asymmetric algorithms), see [Asymmetric algorithms](https://lcobucci-jwt.readthedocs.io/en/stable/supported-algorithms/#asymmetric-algorithms). + + ### Add provider event listener #### Laravel 11+