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

James Bannister • August 30, 2021 • 3 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:

// app/Services/AppleToken.php

<?php

namespace App\Services;

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

class AppleToken
{
    private Configuration $jwtConfig;

    private string $teamId;

    private string $keyId;

    private string $clientId;

    public function __construct(Configuration $jwtConfig, $teamId, $keyId, $clientId)
    {
        $this->jwtConfig = $jwtConfig;
        $this->teamId = $teamId;
        $this->keyId = $keyId;
        $this->clientId = $clientId;
    }

    public function generate()
    {
        $now = CarbonImmutable::now();

        $token = $this->jwtConfig->builder()
            ->issuedBy(config('services.apple.team_id'))
            ->issuedAt($now)
            ->expiresAt($now->addHour())
            ->permittedFor('https://appleid.apple.com')
            ->relatedTo(config('services.apple.client_id'))
            ->withHeader('kid', config('services.apple.key_id'))
            ->getToken($this->jwtConfig->signer(), $this->jwtConfig->signingKey());

        return $token->toString();
    }
}

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:

// app/Providers/AuthServiceProvider.php

use App\Services\AppleToken;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Ecdsa\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;

public function boot()
{
    // ...other bindings or setup

    $this->app->bind(Configuration::class, fn () => Configuration::forSymmetricSigner(
        Sha256::create(),
        InMemory::plainText(config('services.apple.private_key')),
    ));
}

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:

// app/Http/Controllers/AppleSocialController.php

use App\Services\AppleToken;
use Laravel\Socialite\Facades\Socialite;

public function handleCallback(AppleToken $appleToken)
{
    config()->set('services.apple.client_secret', $appleToken->generate());

    $socialUser = Socialite::driver('apple')
        ->stateless()
        ->user();

    // Further actions you want to take in your app...
}

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:

<?php

namespace Tests\Unit;

use App\Services\AppleToken;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Token;
use Tests\TestCase;

class AppleTokenTest extends TestCase
{
    private Token $token;

    /** @test */
    public function it_is_permitted_for_apple()
    {
        $token = app(Configuration::class)
            ->parser()
            ->parse(app(AppleToken::class)->generate());

        $this->assertTrue($token->isPermittedFor('https://appleid.apple.com'));
    }
}

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!