Generating A Client Secret For Sign In With Apple On Each Request

James Bannister • August 30, 2021 • 2 minute read

laravel

Recently I was tasked with adding Sign In with Apple to the federated authentication service we use at my day job.

Like many other social providers, the open-source Socialite Providers organisation provides a driver for integrating with Apple.

The setup experience is a little more complicated than many of the other providers out there, but Okta have a great tutorial/walkthrough for how to get this going.

The article linked above mentions that you need to generate you client secret; which needs to be a JWT token. Okta provide a Ruby script to help you do this. In fact this approach is echoed in Laravel specific packages, like this one from Genea Labs.

The issue with these generated tokens is that they expire after 6 months, as this is the maximum lifetime that Apple will allow for the generated JWT tokens.

Whilst using the script can get you up and running quicker, it places a burden on you or your team to ensure this token is updated at least every 6 months and introduces a point-of-failure into your code.

A better approach, in my opinion, is to generate the JWT for your client secret on each request. It is very fast to generate and will not add any noticeable performance decrease to your requests.

First, we create a class to generate the Apple JWT token:

1// app/Services/AppleToken.php
2 
3<?php
4 
5namespace App\Services;
6 
7use Carbon\CarbonImmutable;
8use Lcobucci\JWT\Configuration;
9 
10class AppleToken
11{
12 private Configuration $jwtConfig;
13 
14 public function __construct(Configuration $jwtConfig)
15 {
16 $this->jwtConfig = $jwtConfig;
17 }
18 
19 public function generate()
20 {
21 $now = CarbonImmutable::now();
22 
23 $token = $this->jwtConfig->builder()
24 ->issuedBy(config('services.apple.team_id'))
25 ->issuedAt($now)
26 ->expiresAt($now->addHour())
27 ->permittedFor('https://appleid.apple.com')
28 ->relatedTo(config('services.apple.client_id'))
29 ->withHeader('kid', config('services.apple.key_id'))
30 ->getToken($this->jwtConfig->signer(), $this->jwtConfig->signingKey());
31 
32 return $token->toString();
33 }
34}

The class itself requires a configuration object, let's provide this from the AuthServiceProvider. You might ask why I've chosen the AuthServiceProvider for this; the AppleToken class is directly related to authentication and thus is perfectly at home in the AuthServiceProvider, you could opt for the AppServiceProvider, but I find this usually ends up being a dumping ground for bindings that didn't find a good home anywhere else:

1// app/Providers/AuthServiceProvider.php
2 
3use App\Services\AppleToken;
4use Lcobucci\JWT\Configuration;
5use Lcobucci\JWT\Signer\Ecdsa\Sha256;
6use Lcobucci\JWT\Signer\Key\InMemory;
7 
8public function boot()
9{
10 // ...other bindings or setup
11 
12 $this->app->bind(Configuration::class, fn () => Configuration::forSymmetricSigner(
13 Sha256::create(),
14 InMemory::plainText(config('services.apple.private_key')),
15 ));
16}

There's also a few values we need to add to our config/services.php file:

1// config/services.php
2 
3'apple' => [
4 'client_id' => env('APPLE_CLIENT_ID'),
5 'client_secret' => env('APPLE_CLIENT_SECRET'),
6 'team_id' => env('APPLE_TEAM_ID'),
7 'key_id' => env('APPLE_KEY_ID'),
8 'private_key' => env('APPLE_PRIVATE_KEY'),
9],

With the AppleToken class written, and the AuthServiceProvider providing the configuration it needs, we just need to use the AppleToken class to generate the token and update the Apple Socialite configuration on-the-fly during the callback request from Apple:

1// app/Http/Controllers/AppleSocialController.php
2 
3use App\Services\AppleToken;
4use Laravel\Socialite\Facades\Socialite;
5 
6public function handleCallback(AppleToken $appleToken)
7{
8 config()->set('services.apple.client_secret', $appleToken->generate());
9 
10 $socialUser = Socialite::driver('apple')
11 ->stateless()
12 ->user();
13 
14 // Further actions you want to take in your app...
15}

By generating a JWT client secret on each request, we don't have to worry about this expiring or having to keep it updated every 6 months.

If you're wondering how you might go about testing this in your application, I opted to parse the token and then run assertions against the values stored in the JWT. An example:

1<?php
2 
3namespace Tests\Unit;
4 
5use App\Services\AppleToken;
6use Lcobucci\JWT\Configuration;
7use Lcobucci\JWT\Token;
8use Tests\TestCase;
9 
10class AppleTokenTest extends TestCase
11{
12 /** @test */
13 public function it_is_permitted_for_apple()
14 {
15 $token = app(Configuration::class)
16 ->parser()
17 ->parse(app(AppleToken::class)->generate());
18 
19 $this->assertTrue($token->isPermittedFor('https://appleid.apple.com'));
20 }
21}

I hope you found this useful. If you have any feedback, thoughts, or different approaches to this then I'd love to hear from you; I'm @jryd_13 on Twitter.

Thanks!