Apple 登录“invalid_client",使用 PHP 和 openSSL 签署 JWT 进行身份验证

2022-01-07 00:00:00 jwt openssl php android apple-sign-in

我正在尝试使用这个 图书馆.文档中描述了主要流程:库在Android端返回授权码.必须将此授权码发送到我的后端,后端又将其发送到 Apple 服务器以取回访问令牌.

I'm trying to implement Apple sign in into an Android App using this library. The main flow is described in the documentation: the library returns an authorization code on the Android side. This authorization code has to be sent to my backend which, in turn, sends it to the Apple servers in order to get back an access token.

如所述此处和此处,为了获得访问令牌,我们需要向 Apple API 发送参数列表、授权码和签名的 JWT.特别是,JWT 需要使用 ES256 算法使用私有 .p8 密钥进行签名,该密钥必须从 Apple 开发者门户生成和下载.苹果文档

As described here and here, in order to obtain the access token we need to send to the Apple API a list of parameters, the authorization code and a signed JWT. In particular, JWT needs to be signed with a ES256 algorithm using a private .p8 key which has to be generated and downloaded from the Apple developer portal. Apple doc

这是我的 PHP 脚本:

Here is my PHP script:

<?php

$authorization_code = $_POST('auth_code');

$privateKey = <<<EOD
-----BEGIN PRIVATE KEY-----
my_private_key_downloaded_from_apple_developer_portal (.p8 format)
-----END PRIVATE KEY-----
EOD;

$kid = 'key_id_of_the_private_key'; //Generated in Apple developer Portal
$iss = 'team_id_of_my_developer_profile';
$client_id = 'identifier_setted_in_developer_portal'; //Generated in Apple developer Portal

$signed_jwt = $this->generateJWT($kid, $iss, $client_id, $privateKey);

$data = [
            'client_id' => $client_id,
            'client_secret' => $signed_jwt,
            'code' => $authorization_code,
            'grant_type' => 'authorization_code'
        ];
$ch = curl_init();

curl_setopt($ch, CURLOPT_URL, 'https://appleid.apple.com/auth/token');
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$serverOutput = curl_exec($ch);

curl_close ($ch);

var_dump($serverOutput);

function generateJWT($kid, $iss, $sub, $key) {

    $header = [
        'alg' => 'ES256',
        'kid' => $kid
    ];
    $body = [
        'iss' => $iss,
        'iat' => time(),
        'exp' => time() + 3600,
        'aud' => 'https://appleid.apple.com',
        'sub' => $sub
    ];

    $privKey = openssl_pkey_get_private($key);
    if (!$privKey) return false;

    $payload = $this->encode(json_encode($header)).'.'.$this->encode(json_encode($body));
    $signature = '';
    $success = openssl_sign($payload, $signature, $privKey, OPENSSL_ALGO_SHA256);
    if (!$success) return false;

    return $payload.'.'.$this->encode($signature);
}

function encode($data) {
    $encoded = strtr(base64_encode($data), '+/', '-_');
    return rtrim($encoded, '=');
}

?>

问题是苹果的回应总是:

The problem is that the response from Apple is always:

{"error":"invalid_client"}

阅读here似乎问题可能与生成一个对 Apple 不正确的签名(OpenSSL 的 ES256 签名结果是 DER 编码的 ASN.1 结构(大小超过 64).(不是原始 R || S 值)").

Reading here it seems that the problem could be related to openSSL which generates a signature which is not correct for Apple ("OpenSSL's ES256 signature result is a DER-encoded ASN.1 structure (it's size exceed 64). (not a raw R || S value)").

有没有办法使用 openSSL 获取正确的签名?

p8 格式是否是 openssl_sign 和 openssl_pkey_get_private 函数的正确输入?(我注意到如果在 jwt.io 中使用提供的 .p8 密钥来计算签名的 jwt.)

Is the p8 format the correct input for the openssl_sign and openssl_pkey_get_private functions? (I noticed that the provided .p8 key does not work if used in jwt.io in order to compute the signed jwt.)

在 openSSL 文档中,我读到应该提供 pem 密钥,如何将 .p8 转换为 .pem 密钥?

In the openSSL documentation I read that a pem key should be provided, how can I convert a .p8 in a .pem key?

我还尝试了一些 PHP 库,它们基本上使用上述相同的步骤,例如 firebase/php-jwt 和 lcobucci/jwt 但 Apple 的响应仍然是无效客户端".

I also tried with some PHP libraries which basically use the same steps described above like firebase/php-jwt and lcobucci/jwt but the Apple response is still "invalid client".

预先感谢您的帮助,

编辑

我试图从等式中完全删除 openSSL.使用从 .p8 生成的 .pem 密钥,我用 jwt.io 生成了一个签名的 JWT.有了这个签名的 JWT,Apple API 就会正确回复.在这一点上,我几乎可以肯定这是一个 openSSL 签名问题.关键问题是如何使用PHP和openSSL获取正确的ES256签名.

I tried to completely remove openSSL from the equation. Using the .pem key generated from the .p8 one I have generated a signed JWT with jwt.io. With this signed JWT the Apple API replies correctly. At this point I'm almost sure it's an openSSL signature problem. The key problem is how to obtain a proper ES256 signature using PHP and openSSL.

推荐答案

如所示 此处,问题其实出在openSSL生成的签名上.

As indicated here, the problem is actually in the signature generated by openSSL.

使用 ES256,数字签名是两个无符号整数的串联,表示为 R 和 S,它们是椭圆曲线 (EC) 算法的结果.R || 的长度S 是 64.

Using ES256, the digital signature is the concatenation of two unsigned integers, denoted as R and S, which are the result of the Elliptic Curve (EC) algorithm. The length of R || S is 64.

openssl_sign 函数生成一个签名,它是一个 DER 编码的 ASN.1 结构(大小 > 64).

The openssl_sign function generates a signature which is a DER-encoded ASN.1 structure (with size > 64).

解决方案是将 DER 编码的签名转换为 R 和 S 值的原始串联.在这个库 函数fromDER";存在执行这样的转换:

The solution is to convert the DER-encoded signature into a raw concatenation of the R and S values. In this library a function "fromDER" is present which perform such a conversion:

    /**
     * @param string $der
     * @param int    $partLength
     *
     * @return string
     */
    public static function fromDER(string $der, int $partLength)
    {
        $hex = unpack('H*', $der)[1];
        if ('30' !== mb_substr($hex, 0, 2, '8bit')) { // SEQUENCE
            throw new RuntimeException();
        }
        if ('81' === mb_substr($hex, 2, 2, '8bit')) { // LENGTH > 128
            $hex = mb_substr($hex, 6, null, '8bit');
        } else {
            $hex = mb_substr($hex, 4, null, '8bit');
        }
        if ('02' !== mb_substr($hex, 0, 2, '8bit')) { // INTEGER
            throw new RuntimeException();
        }
        $Rl = hexdec(mb_substr($hex, 2, 2, '8bit'));
        $R = self::retrievePositiveInteger(mb_substr($hex, 4, $Rl * 2, '8bit'));
        $R = str_pad($R, $partLength, '0', STR_PAD_LEFT);
        $hex = mb_substr($hex, 4 + $Rl * 2, null, '8bit');
        if ('02' !== mb_substr($hex, 0, 2, '8bit')) { // INTEGER
            throw new RuntimeException();
        }
        $Sl = hexdec(mb_substr($hex, 2, 2, '8bit'));
        $S = self::retrievePositiveInteger(mb_substr($hex, 4, $Sl * 2, '8bit'));
        $S = str_pad($S, $partLength, '0', STR_PAD_LEFT);
        return pack('H*', $R.$S);
    }
    /**
     * @param string $data
     *
     * @return string
     */
    private static function preparePositiveInteger(string $data)
    {
        if (mb_substr($data, 0, 2, '8bit') > '7f') {
            return '00'.$data;
        }
        while ('00' === mb_substr($data, 0, 2, '8bit') && mb_substr($data, 2, 2, '8bit') <= '7f') {
            $data = mb_substr($data, 2, null, '8bit');
        }
        return $data;
    }
    /**
     * @param string $data
     *
     * @return string
     */
    private static function retrievePositiveInteger(string $data)
    {
        while ('00' === mb_substr($data, 0, 2, '8bit') && mb_substr($data, 2, 2, '8bit') > '7f') {
            $data = mb_substr($data, 2, null, '8bit');
        }
        return $data;
    }

另一点是应该向 open_ssl_sign 函数提供 .pem 密钥.从从 Apple 开发人员下载的 .p8 密钥开始,我使用 openSSL 创建了 .pem:

Another point is that a .pem key should be provided to the open_ssl_sign function. Starting from the .p8 key downloaded from the Apple developer I have created the .pem one by using openSSL:

openssl pkcs8 -in AuthKey_KEY_ID.p8 -nocrypt -out AuthKey_KEY_ID.pem

在下面我新的 generateJWT 函数代码中,它使用 .pem 密钥和 fromDER 函数来转换由 openSSL 生成的签名:

In the following my new generateJWT function code which uses the .pem key and the fromDER function to convert the signature generated by openSSL:

    function generateJWT($kid, $iss, $sub) {
        
        $header = [
            'alg' => 'ES256',
            'kid' => $kid
        ];
        $body = [
            'iss' => $iss,
            'iat' => time(),
            'exp' => time() + 3600,
            'aud' => 'https://appleid.apple.com',
            'sub' => $sub
        ];

        $privKey = openssl_pkey_get_private(file_get_contents('AuthKey_.pem'));
        if (!$privKey){
           return false;
        }

        $payload = $this->encode(json_encode($header)).'.'.$this->encode(json_encode($body));
        
        $signature = '';
        $success = openssl_sign($payload, $signature, $privKey, OPENSSL_ALGO_SHA256);
        if (!$success) return false;

        $raw_signature = $this->fromDER($signature, 64);
        
        return $payload.'.'.$this->encode($raw_signature);
    }

希望能帮到你

相关文章