6
votes

I'm trying to authenticate to Apple's AppStoreConnect API with an ES256 signed JWT (per their instructions at https://developer.apple.com/documentation/appstoreconnectapi) using PHP.

Sending my request always results in a 401 NOT_AUTHORIZED error.

I've verified that the the contents of my header and claims are correct - I even found a Ruby script online for generating an ES256 signed JWT and using my Apple provided Issuer, Key ID, Private Key, it works swimmingly - Apple accepts the token. That tells me that my credentials are good and I'm doing something wrong in php.

Unless I've simply stared at this code for too long, the JWT format is correct, base64 encoded correctly, and the bearer token is set correctly in the header.

To rule out an issue with request sending I've tried both GuzzleHTTP and CLI cURL - both a 401.


Here's the relevant code. You'll see that the create method is encoding the header and claims, signing the "payload", and concatenating all 3.

public function create()
{
    $header = $this->encode(
        json_encode([
            'kid' => 'my_key_id',
            'alg' => 'ES256',
            'typ' => 'JWT',
        ])
    );

    $claims = $this->encode(
        json_encode([
            'iss' => 'my_issuer_uuid',
            'exp' => time() + (20 * 60),
            'aud' => 'appstoreconnect-v1',
        ])
    );

    $signature = $this->encode(
        $this->sign("$header.$claims")
    );

    return $header . '.' . $claims . '.' . $signature;
}

This code successfully returns an open ssl resource, $data has the expected contents.

public function sign($data)
{
    if (!$key = openssl_pkey_get_private('file://my_key_file.p8')) {
        throw new \Exception('Failed to read PEM');
    }

    if (!openssl_sign($data, $signature, $key, OPENSSL_ALGO_SHA256)) {
        throw new \Exception('Claims signing failed');
    }

    return $signature;
}

Base64 URL encoding... $data has the expected contents.

public function encode($data)
{
    return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($data));
}

At this point I'm stumped to what it is I'm doing wrong or missing. I'm hoping some extra eyes will find something! Using the token that my code dumps out:

curl  https://api.appstoreconnect.apple.com/v1/users --Header "Authorization: Bearer <token>”

...always returns a 401. I suspect there's something wrong in the signing portion of the code as it's the only part I haven't been able to verify (again, worked in Ruby), though looking at all the docs and examples for openssl_sign, I'm pretty sure it's right.

For reference, this is the Ruby script I mention https://shashikantjagtap.net/generating-jwt-tokens-for-app-store-connect-api/

4

4 Answers

2
votes

After struggling alot, i finally got it working using https://github.com/lcobucci/jwt

use Curl\Curl;
use Lcobucci\JWT\Builder;
use Lcobucci\JWT\Signer\Key;
use Lcobucci\JWT\Signer\Ecdsa\Sha256;

$signer = new Sha256();
$privateKey = new Key('file://AuthKey_XYZ.p8');
$time = time();

$Issuer_ID = "FROM_APPLE_PAGE";
$Key_ID = "FROM_APPLE_PAGE";

$token = (new Builder())->issuedBy($Issuer_ID)// Configures the issuer (iss claim)
->permittedFor("appstoreconnect-v1")// Configures the audience (aud claim)
->identifiedBy('XXYYZZ', true)// Configures the id (jti claim), replicating as a header item
->withHeader('kid', $Key_ID)
->withHeader('type', 'JWT')
    ->withHeader('alg', 'ES256')
    ->issuedAt($time)// Configures the time that the token was issue (iat claim)
    ->expiresAt($time + 1200)// Configures the expiration time of the token (exp claim)
    ->withClaim('uid', 1)// Configures a new claim, called "uid"
    ->getToken($signer, $privateKey); // Retrieves the generated token


$token->getHeaders(); // Retrieves the token headers
$token->getClaims(); // Retrieves the token claims
2
votes

After a lot of testing here is what worked for me:

Install this package:

composer require lcobucci/jwt

Try generating token now:

<?php

require 'vendor/autoload.php';

use Lcobucci\JWT\Builder;
use Lcobucci\JWT\Signer\Key;
use Lcobucci\JWT\Signer\Ecdsa\Sha256; 

$signer = new Sha256();
$key = file_get_contents('AuthKey_xxxx.p8');

$privateKey = new Key($key);
$time = time();

$token = (new Builder())->issuedBy('AppleTeamID') // Configures the issuer (iss claim)
    ->withHeader('alg', 'ES256')
    ->withHeader('kid', 'AppleKeyID')
    ->issuedAt($time) // Configures the time that the token was issue (iat claim)
    ->expiresAt($time + 1200) // Configures the expiration time of the token (exp claim)
    ->getToken($signer, $privateKey); // Retrieves the generated token

// Test if your key works OK
exec("curl -v -H 'Authorization: Bearer {$token}' \"https://api.music.apple.com/v1/catalog/us/artists/36954\"");


exit;
1
votes

The signature returned by OpenSSL is an ASN.1 sequence that contains additional information. You have to remove the extra data before concatenation.

You can use that simple class I wrote (method fromDER) to convert the OpenSSL signature.

0
votes

You can use composer require firebase/php-jwt to generate the JWT.

Firstly, read the private key file. whatever suits your choice, fread or file_get_contents or SplFileObject or from env file. Just get the content of the .p8 file.

Then,

// $filePath = 'file:///var/www/html/AuthKey_KEY-ID-HERE-(JUST_IGNORE).p8'
JWT::encode([
    'iss' => $teamId, // 10-character team id, under your name
    'iat' => $iat, // use strtotime('now') or Carbon::now()->timestamp
    'exp' => $exp, // use strtotime('+60 days') or Carbon::now()->days(60)->timestamp
    'aud' => "https://appleid.apple.com", // till date, it's constant
    'sub' => $sub, // Service ID identifier in https://developer.apple.com/account/resources/identifiers/list/serviceId, the id where you registered your "REDIRECT_URI"
], (new Key($filePath))->getContent(), 'ES256', $keyId); // Used Lcobucci\JWT\Signer\Key class as an experiment, and it also worked. You can you any of the above mentioned methods to get your key.

After running this, you'll get your client_secret for the client id. Next you can go with the documentation.


JUST TESTED and got the expected result.