Skip to content

Commit 23d0dd3

Browse files
authored
Apple - new option for using private key instead secret key. (#1019)
1 parent 6214dca commit 23d0dd3

File tree

3 files changed

+146
-13
lines changed

3 files changed

+146
-13
lines changed

src/Apple/AppleToken.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
namespace SocialiteProviders\Apple;
4+
5+
use Carbon\CarbonImmutable;
6+
use Lcobucci\JWT\Configuration;
7+
8+
class AppleToken
9+
{
10+
private Configuration $jwtConfig;
11+
12+
public function __construct(Configuration $jwtConfig)
13+
{
14+
$this->jwtConfig = $jwtConfig;
15+
}
16+
17+
public function generate(): string
18+
{
19+
$now = CarbonImmutable::now();
20+
21+
$token = $this->jwtConfig->builder()
22+
->issuedBy(config('services.apple.team_id'))
23+
->issuedAt($now)
24+
->expiresAt($now->addHour())
25+
->permittedFor(Provider::URL)
26+
->relatedTo(config('services.apple.client_id'))
27+
->withHeader('kid', config('services.apple.key_id'))
28+
->getToken($this->jwtConfig->signer(), $this->jwtConfig->signingKey());
29+
30+
return $token->toString();
31+
}
32+
}

src/Apple/Provider.php

Lines changed: 95 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Firebase\JWT\JWK;
77
use GuzzleHttp\Client;
88
use GuzzleHttp\RequestOptions;
9+
use Illuminate\Http\Request;
910
use Illuminate\Support\Arr;
1011
use Illuminate\Support\Facades\Cache;
1112
use Illuminate\Support\Str;
@@ -25,7 +26,7 @@ class Provider extends AbstractProvider
2526
{
2627
public const IDENTIFIER = 'APPLE';
2728

28-
private const URL = 'https://appleid.apple.com';
29+
public const URL = 'https://appleid.apple.com';
2930

3031
protected $scopes = [
3132
'name',
@@ -39,6 +40,23 @@ class Provider extends AbstractProvider
3940

4041
protected $scopeSeparator = ' ';
4142

43+
/**
44+
* JWT Configuration.
45+
*
46+
* @var ?Configuration
47+
*/
48+
protected $jwtConfig = null;
49+
50+
/**
51+
* Private Key.
52+
*
53+
* @var string
54+
*/
55+
protected $privateKey = '';
56+
57+
/**
58+
* {@inheritdoc}
59+
*/
4260
protected function getAuthUrl($state): string
4361
{
4462
return $this->buildAuthUrlFromBase(self::URL.'/auth/authorize', $state);
@@ -76,7 +94,7 @@ protected function getCodeFields($state = null)
7694
public function getAccessTokenResponse($code)
7795
{
7896
$response = $this->getHttpClient()->post($this->getTokenUrl(), [
79-
RequestOptions::HEADERS => ['Authorization' => 'Basic '.base64_encode($this->clientId.':'.$this->clientSecret)],
97+
RequestOptions::HEADERS => ['Authorization' => 'Basic '.base64_encode($this->clientId.':'.$this->getClientSecret())],
8098
RequestOptions::FORM_PARAMS => $this->getTokenFields($code),
8199
]);
82100

@@ -88,12 +106,53 @@ public function getAccessTokenResponse($code)
88106
*/
89107
protected function getUserByToken($token)
90108
{
91-
static::verify($token);
109+
$this->checkToken($token);
92110
$claims = explode('.', $token)[1];
93111

94112
return json_decode(base64_decode($claims), true);
95113
}
96114

115+
protected function getClientSecret()
116+
{
117+
if (!$this->jwtConfig) {
118+
$this->getJwtConfig(); // Generate Client Secret from private key if not set.
119+
}
120+
121+
return $this->clientSecret;
122+
}
123+
124+
protected function getJwtConfig()
125+
{
126+
if (!$this->jwtConfig) {
127+
$private_key_path = $this->getConfig('private_key', '');
128+
$private_key_passphrase = $this->getConfig('passphrase', '');
129+
$signer = $this->getConfig('signer', '');
130+
131+
if (empty($signer) || !class_exists($signer)) {
132+
$signer = !empty($private_key_path) ? \Lcobucci\JWT\Signer\Ecdsa\Sha256::class : AppleSignerNone::class;
133+
}
134+
135+
if (!empty($private_key_path) && file_exists($private_key_path)) {
136+
$this->privateKey = file_get_contents($private_key_path);
137+
} else {
138+
$this->privateKey = $private_key_path; // Support for plain text private keys
139+
}
140+
141+
$this->jwtConfig = Configuration::forSymmetricSigner(
142+
new $signer(),
143+
AppleSignerInMemory::plainText($this->privateKey, $private_key_passphrase)
144+
);
145+
146+
if (!empty($this->privateKey)) {
147+
$appleToken = new AppleToken($this->getJwtConfig());
148+
$this->clientSecret = $appleToken->generate();
149+
config()->set('services.apple.client_secret', $this->clientSecret);
150+
}
151+
}
152+
153+
return $this->jwtConfig;
154+
}
155+
97156
/**
98157
* Return the user given the identity token provided on the client
99158
* side by Apple.
@@ -111,20 +170,16 @@ public function userByIdentityToken(string $token): User
111170
}
112171

113172
/**
114-
* Verify Apple jwt.
173+
* Verify Apple JWT.
115174
*
116175
* @param string $jwt
117176
* @return bool
118177
*
119178
* @see https://appleid.apple.com/auth/keys
120179
*/
121-
public static function verify($jwt)
180+
public function checkToken($jwt)
122181
{
123-
$jwtContainer = Configuration::forSymmetricSigner(
124-
new AppleSignerNone,
125-
AppleSignerInMemory::plainText('')
126-
);
127-
$token = $jwtContainer->parser()->parse($jwt);
182+
$token = $this->getJwtConfig()->parser()->parse($jwt);
128183

129184
$data = Cache::remember('socialite:Apple-JWKSet', 5 * 60, function () {
130185
$response = (new Client)->get(self::URL.'/auth/keys');
@@ -145,7 +200,7 @@ public static function verify($jwt)
145200
];
146201

147202
try {
148-
$jwtContainer->validator()->assert($token, ...$constraints);
203+
$this->jwtConfig->validator()->assert($token, ...$constraints);
149204

150205
return true;
151206
} catch (RequiredConstraintsViolated $e) {
@@ -156,6 +211,25 @@ public static function verify($jwt)
156211
throw new InvalidStateException('Invalid JWT Signature');
157212
}
158213

214+
/**
215+
* Verify Apple jwt via static function.
216+
*
217+
* @param string $jwt
218+
*
219+
* @return bool
220+
*
221+
* @see https://appleid.apple.com/auth/keys
222+
*/
223+
public static function verify($jwt)
224+
{
225+
return (new self(
226+
new Request(),
227+
config('services.apple.client_id'),
228+
config('services.apple.client_secret'),
229+
config('services.apple.redirect')
230+
))->checkToken($jwt);
231+
}
232+
159233
/**
160234
* {@inheritdoc}
161235
*/
@@ -253,9 +327,9 @@ protected function getRevokeUrl(): string
253327
public function revokeToken(string $token, string $hint = 'access_token')
254328
{
255329
return $this->getHttpClient()->post($this->getRevokeUrl(), [
256-
RequestOptions::FORM_PARAMS => [
330+
RequestOptions::FORM_PARAMS => [
257331
'client_id' => $this->clientId,
258-
'client_secret' => $this->clientSecret,
332+
'client_secret' => $this->getClientSecret(),
259333
'token' => $token,
260334
'token_type_hint' => $hint,
261335
],
@@ -285,4 +359,12 @@ public function refreshToken($refreshToken): ResponseInterface
285359
],
286360
]);
287361
}
362+
363+
/**
364+
* {@inheritdoc}
365+
*/
366+
public static function additionalConfigKeys()
367+
{
368+
return ['private_key', 'passphrase', 'signer'];
369+
}
288370
}

src/Apple/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,25 @@ See [Configure Apple ID Authentication](https://developer.okta.com/blog/2019/06/
2222

2323
> 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)
2424
25+
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)).
26+
Add lines to the configuration as follows:
27+
28+
```php
29+
'apple' => [
30+
'client_id' => env('APPLE_CLIENT_ID'), // Required. Bundle ID from Identifier in Apple Developer.
31+
'client_secret' => env('APPLE_CLIENT_SECRET'), // Empty. We create it from private key.
32+
'key_id' => env('APPLE_KEY_ID'), // Required. Key ID from Keys in Apple Developer.
33+
'team_id' => env('APPLE_TEAM_ID'), // Required. App ID Prefix from Identifier in Apple Developer.
34+
'private_key' => env('APPLE_PRIVATE_KEY'), // Required. Must be absolute path, e.g. /var/www/cert/AuthKey_XYZ.p8
35+
'passphrase' => env('APPLE_PASSPHRASE'), // Optional. Set if your private key have a passphrase.
36+
'signer' => env('APPLE_SIGNER'), // Optional. Signer used for Configuration::forSymmetricSigner(). Default: \Lcobucci\JWT\Signer\Ecdsa\Sha256
37+
'redirect' => env('APPLE_REDIRECT_URI') // Required.
38+
],
39+
```
40+
41+
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).
42+
43+
2544
### Add provider event listener
2645

2746
#### Laravel 11+

0 commit comments

Comments
 (0)