Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Apple - new option for using private key instead secret key. #1019

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
32 changes: 32 additions & 0 deletions src/Apple/AppleToken.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

namespace SocialiteProviders\Apple;

use Carbon\CarbonImmutable;
use Lcobucci\JWT\Configuration;

class AppleToken
{
private Configuration $jwtConfig;

public function __construct(Configuration $jwtConfig)
{
$this->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();
}
}
97 changes: 84 additions & 13 deletions src/Apple/Provider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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';

/**
* {@inheritdoc}
Expand All @@ -46,6 +47,20 @@ class Provider extends AbstractProvider
*/
protected $scopeSeparator = ' ';

/**
* JWT Configuration.
*
* @var ?Configuration
*/
protected $jwtConfig = null;

/**
* Private Key.
*
* @var string
*/
protected $privateKey = '';

/**
* {@inheritdoc}
*/
Expand Down Expand Up @@ -89,7 +104,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),
]);

Expand All @@ -101,12 +116,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 = config('services.apple.private_key', '');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we access this via the standard method, ie add to

public static function additionalConfigKeys()
    {
        return ['private_key', 'otherkey...'];
    }
$this->getConfig('private_key', '')

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, we can. Changed.

$private_key_passphrase = config('services.apple.passphrase', '');
$signer = config('services.apple.signer', '');

if (empty($signer) || !class_exists($signer)) {
$signer = !empty($private_key_path) ? \Lcobucci\JWT\Signer\Ecdsa\Sha256::class : AppleSignerNone::class;
}

$this->privateKey = $private_key_path; // Support for plain text private keys

if (!empty($private_key_path) && file_exists($private_key_path)) {
$this->privateKey = file_get_contents($private_key_path);
}

$this->jwtConfig = Configuration::forSymmetricSigner(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to confirm, there is no bc break here, existing integrations using the secret key will continiue to work fine?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, if you pass the path to the private key file, it will get the contents of the private key file, otherwise you put the private key contents by default.

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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might need to set this to a variable on the class with above switch to $this->getConfig

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you think so?

}
}

return $this->jwtConfig;
}

/**
* Return the user given the identity token provided on the client
* side by Apple.
Expand All @@ -124,20 +180,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');
Expand All @@ -157,7 +209,7 @@ public static function verify($jwt)
];

try {
$jwtContainer->validator()->assert($token, ...$constraints);
$this->jwtConfig->validator()->assert($token, ...$constraints);

return true;
} catch (RequiredConstraintsViolated $e) {
Expand All @@ -168,6 +220,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'),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
config('services.apple.client_id'),
$this->getConfig('client_id', '')

And other calls to config()

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can call $this-> in static function, or ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh my bad, didn't realise it was static. Is that why it overrides the global config above?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh my bad, didn't realise it was static. Is that why it overrides the global config above?

yes

config('services.apple.client_secret'),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this method still work if the secret is dervied?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it will work. I had to do that (create new object) because this function is called statically (don't know why) and it was necessary to call other non static functions.

config('services.apple.redirect')
))->checkToken($jwt);
}

/**
* {@inheritdoc}
*/
Expand Down Expand Up @@ -265,9 +336,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,
],
Expand Down
19 changes: 19 additions & 0 deletions src/Apple/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Configure the package's listener to listen for `SocialiteWasCalled` events.
Expand Down