Web版 Sign in with Apple を PHP で実装する

```
update: 2020/04/21: 少し表現を修正を行いました。
```

## 概要
本記事はWebページ上で Sign in with Apple を使ってサインインを行い、認証情報の取得を行う
なお、作成したコード類は Lumen / PHP　で記述しており、個人的興味で作成し、備忘録として残したものである。

## Sign in with apple とは
Apple が発表した Apple ID を用いてサインインを行えるようにする機能。
iOS13以降の端末であれば、Face ID, Touch ID での認証に対応している。
また、それ以外の端末及び Web, Andorid では Webview を使った認証に対応している。

iOSアプリの場合は SNSログインを用いている場合は対応を行う必要がある。
※ 投稿時点では 6/30 までに対応する必要があるようです。
(最近社会情勢等でどんどんデットラインのリミットが伸びているようです)

https://developer.apple.com/jp/sign-in-with-apple/

## サインインできるようになるまでの概略
Apple と サーバ(App) とのやり取りとしては以下の形になる。
![Untitled (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61893/afeef978-9101-a81b-b3ce-88c22c59e246.png)

サインインを行った場合にコールバックに設定したURLに対してPOSTメソッドでリクエストを実行する
その際に `code`, `id_token`, `state` がリクエストとして実行される。

- `code`: 5分間のみ有効な Authorization Code でこの値を利用して token を利用してAppleからtokenを取得する
- `id_token`: JWT(JSON Web Token) 形式でID状態が格納されている。参照できるようにするためにはAppleから取得できる公開鍵を利用してデコードする必要がある
- `state`: サインイン実行時に設定した値。CSRFでの検証に利用できる。

## Apple から必要な情報を作成する
Sign in with Apple を実装するにはまずサインインを行うために必要な情報を Apple Developer から取得する必要がある。
※ Apple Developer から情報を取得できるようになるために Developer Program に加入する必要があるので、加入しましょう

必要なもの
- Team ID (Apple Developer Program に加入したあとに発番されるもの、Membership 上で確認可能)
- Service ID (App ID に紐づく形でWeb上でサインインできるように作成するもの(現状機能がサインイン機能しかないためサインイン専用とみてもいいと思われます。))
- Key ID (App ID で紐づいた鍵のID)
- Apple から取得できる p8 形式の秘密鍵 (Key ID に紐づいた秘密鍵)

Service ID の取得
(なお `Service ID` に必要なApp ID の取得は省略します。)

Certificates, Identifiers & Profilesのページで新規作成を行う。
Identifiersを選択し `+` を押下すると選択肢が出てきますので AppID を作成している場合は Service IDs を選択する

`Description` と `Identifier` (AppIDが com.example であれば com.example.auth のような形で合わせる形が無難)を設定する

![Inkedコメント 2020-02-18 232627_LI.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61893/a9210dd4-5a2d-2bc8-604c-04fbc6978887.jpeg)

設定後に Sign in with Apple の設定が可能になるため有効化を行い、App ID の紐づけと callback URL の設定を行います。
一回実装しようとしていた 2月当時ではドメインの検証を行う必要がありましたが、現在では不要になっています。

![コメント 2020-04-19 184815.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61893/bbedb1e9-e6a1-3536-c280-c15635df4806.jpeg)


KeyIDと鍵の取得
Certificates, Identifiers & ProfilesからKeysを選択しService IDの作成と同様 `+` を押下

![コメント 2020-04-19 185146.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61893/d7f7bd90-5d95-6d28-13ea-a649e5ac6d35.jpeg)

鍵の名前(管理しやすいもの)とSign in with Apple を有効化し、設定で作成した App IDを指定します。
生成すると秘密鍵のダウンロードとKeyIDが表示されるので秘密鍵をダウンロードする。
※秘密鍵は1度きりのダウンロードになるため、ページを離れるとダウンロードできなくなります。


## サインインをできるようにする
まずは、Appleでサインインを行えるように Web上にサインインボタンの設置を行い、ログイン画面を出せるようにする。 Apple側より `Sign in with Apple JS` のSDKが配布されているため難易度は高くない 
https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js

![コメント 2020-04-19 181616.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61893/b3ea0832-ef62-8f3d-804c-c81ec1dbda3f.jpeg)

`AppleID.auth.init` を利用して各種情報 client_id(service_idと同値), redirectURI(登録したコールバックURL), 必要であれば stateを設定する。

`scope ` を設定すれば user, email の情報を取得できるが、これらの情報はApple側で初回認証を行った場合にのみレスポンスに帰ってくるため、2回目以降の情報取得には注意が必要となる。

※ `usePopup` を有効にした場合はポップアップで表示してくれる(デフォルトはfalse)

`id="appleid-signin"` をクリックすることで `appleid.auth.js` 内で処理が行われサインインページに遷移を行う。

なお自動的にサインインページに遷移させたい場合は `AppleID.auth.signIn();` を init のあとに入れることで遷移されるようになる。

```html
<html>
    <head>
      <title>Apple Lumen</title>
      <script type="text/javascript" src="https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js"></script>
    </head>
    <body>
        <h1>Hello,Lumen</h1>
        <div id="appleid-signin" data-color="black" data-border="true" data-type="sign in"></div>
        <script>
          AppleID.auth.init({
            clientId : '<?php echo $client_id ?>',
            redirectURI : '<?php echo $redirect_uri ?>',
            state : '<?php echo $state ?>'
          });
        </script>
    </body>
</html>
```

![コメント 2020-04-19 181642.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61893/bcf6df89-04b6-c94d-fe55-7f724eb6e873.jpeg)

##  Authorization Code の検証
Apple の認証を終えてコールバックURLが実行された際に、まず送られてきたリクエストが正しいものかどうかを確認するために検証を行う。

検証を行うために使用するエンドポイントは `POST https://appleid.apple.com/auth/token` である。
https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens

以下の情報を上記エンドポイントにリクエストを行う必要がある。

- code: コールバックURLのリクエスト(code)を使用
- grant_type: authorization_code (今回は認証のみのためこちらを使用する)
- redirect_uri: コールバックURL
- client_id: Service ID
- client_secret: 生成方法を後述する

実装としては以下の形になる env は lumen を使用しており、環境ごとに変わるため.envに定義する

必要なパラメータを準備し `application/x-www-form-urlencoded` の形で、curlを実行する

```php5
    private $token_validation_link = 'https://appleid.apple.com/auth/token';

    private function verify_token($code){
        try {
            $params = array(
                'code' => $code,
                'grant_type' => 'authorization_code',
                'redirect_uri'	=>  env('REDIRECT_URL', 'callback'),
                'client_id' => env('CLIENT_ID', 'client_id'),
                'client_secret' => $this->create_client_secret()
            );

            $data = http_build_query($params);

            $header = array(
                "Content-Type: application/x-www-form-urlencoded",
                "Content-Length: ".strlen($data),
                "User-Agent: UA"
            );

            $curl = curl_init();
            curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
            curl_setopt($curl, CURLOPT_URL, $this->token_validation_link);
            curl_setopt($curl, CURLOPT_HTTPHEADER, $header);
            curl_setopt($curl, CURLOPT_CUSTOMREQUEST, 'POST');
            curl_setopt($curl, CURLOPT_POSTFIELDS, $data);
            curl_setopt($curl, CURLOPT_TIMEOUT, 5);

            $result = curl_exec($curl);
            $response = json_decode($result, true);
            $status_code = curl_getinfo($curl, CURLINFO_HTTP_CODE);

            if ($status_code !== 200) {
                Log::info(curl_error($curl));
                return FALSE;
            }

            curl_close($curl);
            return $response['id_token'];
        } catch (Exception $e) {
            Log::info($e->getMessage());
            return FALSE;
        }
    }
```

## client_secret の作成
token の検証を行う際には JWT でエンコードされたclient_secretを作成する必要がある
エンコードの形式も Apple から取得した秘密鍵を利用して `ES256` でエンコードする必要がある

JWTのエンコードを行う前には以下の形式でヘッダとペイロードの作成を行う

```
{
    "alg": "ES256", // 固定
    "kid": "${KeyID}"
}
{
    "iss": "${TeamID}",
    "iat": unix時間で現時刻,
    "exp": unix時間で有効期限,
    "aud": "https://appleid.apple.com", // 固定
    "sub": "${ServiceID}"
}
```

JWT のエンコード・デコードには firebase が php 向けに提供しているためこちらを利用する

https://github.com/firebase/php-jwt

こちらの情報を元にエンコードを実装したものが下記になる

```php5
    use Firebase\JWT\JWT;

    private function create_client_secret()
    {
        $key = file_get_contents(env('PRIVATE_KET_PASS', 'null'));
        $now = time();
        $expire = $now + (7 * 24 * 60 * 60);

        $payload = array(
            'iss' => env('TEAM_ID', 'team_id'),
            'iat' => $now,
            'exp' => $expire,
            'aud' => 'https://appleid.apple.com',
            'sub' =>  env('SERVICE_ID', 'service_id')
        );

        return JWT::encode($payload, $key, 'ES256', env('KEY_ID', 'key_id'));
    }
```

## JWT のデコード
token の検証を終えた後は JWTのデコードを行い、IDの情報を取得する。
まず JWTのデコードに必要な情報を Apple 側から取得する。ただ、レスポンスの結果は公開鍵そのものではない(JSON Web Keyの形式)ので RSAの公開鍵として生成する必要がある。

https://developer.apple.com/documentation/sign_in_with_apple/fetch_apple_s_public_key_for_verifying_token_signature

JSON Web Key から公開鍵を生成するには `phpseclib` を利用してRSA形式の鍵を生成する
生成の方式は以下を参考に実装をした。
 
https://github.com/phpseclib/phpseclib

```php 
    use phpseclib\Crypt\RSA;
    use phpseclib\Math\BigInteger;

    private function create_jwk_public_key($jwk)
    {
        $rsa = new RSA();
        $rsa->loadKey(
            [
                'e' => new BigInteger(JWT::urlsafeB64Decode($jwk['e']), 256),
                'n' => new BigInteger(JWT::urlsafeB64Decode($jwk['n']),  256)
            ]
        );
        $rsa->setPublicKey();

        return $rsa->getPublicKey();
    }
```

公開鍵を生成したのち、JWTでデコードを行って、デコードが行えた場合はその値を渡すことで情報が取得される。また、Apple から取得できる JSON Web Key は複数個存在するため、それぞれのパターンで検証を行う必要がある。


```php
    use Firebase\JWT\JWT;

    private $get_public_key_link = 'https://appleid.apple.com/auth/keys';

    private function decode_user_token($jwt_token)
    {
        $curl = curl_init($this->get_public_key_link);

        curl_setopt($curl, CURLOPT_CUSTOMREQUEST, 'GET'); 
        curl_setopt($curl, CURLOPT_HEADER, false);
        curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);

        $response = curl_exec($curl);
        $info = curl_getinfo($curl);
        curl_close($curl);

        if ($info['http_code'] != 200) {
            return null;
        }
        $response = json_decode($response, true);
        $public_keys = $response['keys'];

        if ($public_keys === null) {
            return null;
        }

        $last_key = end($public_keys);
        foreach($public_keys as $data) {
            try {
                // decode action
                $public_key = $this->create_jwk_public_key($data);
                $token = JWT::decode($jwt_token, $public_key, array('RS256'));
                break;
            } catch (Exception $e) {
                if($data === $last_key) {
                    return null;
                }
            }
        }

        return $token;
    }
```

## 最後に
この流れで取得された結果デコードされたデータに `sub` があるが、その値がAppleから取得できるIDとなり、サインインする度に変更される値ではない。
そのため、subを利用することでサービス内でユーザ情報を特定することが可能になる。

現状、Appleでのサインイン対応は少ないためあまり機能は多くないが、iOS13 以降の端末では、サインインが簡単に行えるようになるため、今後対応されるアプリが増えていくことで、利用できる機能が増えることに期待したい。

## 参考
https://developer.apple.com/documentation/sign_in_with_apple
https://blog.katsubemakito.net/articles/sign-in-with-apple-js

https://qiita.com/kiwi26/items/5b8cc53ed8d10a403f00