【Auth0】LaravelでAuth0を使って認証する：IDトークン編【Laravel】

今回はSPAを作成する際に使用するJWT形式のIDトークンを検証をする方法について記事にしました。

### 関連記事
[Validating JWTs with Auth0-PHP](https://auth0.com/docs/libraries/auth0-php/jwt-validation)
[【Auth0】LaravelでAuth0を使って認証する：アクセストークン編【Laravel】](https://qiita.com/akkino_D-En/items/7c772aeb09b64bb62a7d)

# 環境
PHP: 7.2.5
laravel: 7.0
auth0/auth0-php: 7.2

# Auth0の設定

**追記：後述のSPA Applicationはデフォルトの`Manegimant API`を使用するので、APIを作成する必要はありませんでした。**

まずはAuth0にフリートライアルで登録して、APIを作成しましょう。
そうしたら`APIs`タブを開き、`CREATE API`から新規Auth0 APIを作成します。

![https___qiita-image-store.s3.ap-northeast-1.amazonaws.com_0_391127_ba0dde3b-fa26-f2ec-74b0-a3bbf289a671.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/391127/2c67addc-cf1c-dc7e-30a8-dc73902a0e13.png)

API名と識別子は先例に習って`Quickstarts API`と`https://quickstarts/api`にします。またアルゴリズムは特に理由がなければ`RS256`にしましょう。
`RS256`を使用することで`JWKs`を使用したトークン検証を行えるようになります。

![https___qiita-image-store.s3.ap-northeast-1.amazonaws.com_0_391127_cf346442-fb1a-73cb-fbcd-1090f8908c5b.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/391127/6ad93916-1442-eee9-9150-4153a2bb8959.png)

ものすごく丁寧なことに今作成したアプリケーションにアクセスするためのクイックスタートを記載したページに飛ばしてくれます。

ですが今は一旦無視して`Permissions`タブを開きましょう。
ここではアクセストークンを使用してAuth0からどの情報まで取得できるかの権限を設定することができます。
とりあえず今は`read:messages`, `read:email`, `read:users`あたりを設定しておきましょう。

![https___qiita-image-store.s3.ap-northeast-1.amazonaws.com_0_391127_ecf2609b-fde8-2eff-0ad4-f67dc84fa7bd.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/391127/95563f63-cadc-9796-7e28-f770c6570ff6.png)


# SPA Applicationの作成

今回はID Tokenでの認証を行うので、`Single Page Appliation`(SPA)用のAuth0 Appを作成します。

`Applications`タブの右上の`CREATE APPLICATION`を選択します。

<img width="1114" alt="スクリーンショット 2020-06-21 11.44.59.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/391127/d60dc549-308c-9aa4-71d4-41a73524a10a.png">

好きな名前を設定し、`Single Page Web Applications`を選択して新規にAppを作成します。

<img width="833" alt="スクリーンショット 2020-06-21 11.45.28.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/391127/7b607fc8-7a91-04a9-12d5-d5a3fa79dfdd.png">



# ライブラリの導入と設定

ID Tokenの検証には[Auth0のPHP用のライブラリ](https://github.com/auth0/auth0-PHP)を使用します。

```
$ composer require auth0/auth0-php
```

`auth0/login`というライブラリがありますが、間違えないように注意しましょう。

次に`.env`にAuth0との接続情報を記入します。

```:.env
AUTH0_DOMAIN=tenant.auth0.com
AUTH0_CLIENT_ID=hogehogeClientId
AUTH0_CLIENT_SECRET=hogehogeClientSecret
```

`Applications`から先ほど作成したSPAアプリを選択し、その`Domain`、`Client ID`、`Client Secret`を使用します。

<img width="1011" alt="スクリーンショット 2020-06-22 20.42.26.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/391127/4f32eb24-d33c-b393-9c6a-fc244ae70e91.png">

また定数クラスを作成しておきます。

```php:config/const.php
return [
    'auth0' => [
        'domain' => env('AUTH0_DOMAIN', ''),
        'client_id' => env('AUTH0_CLIENT_ID', ''),
        'client_secret' => env('AUTH0_CLIENT_SECRET', ''),
    ]
];
```

# Laravelでの実装

まずはID Tokenを検証するためのミドルウェアを作成します。

```
$ php artisan make:middleware CheckIdToken
```


```php:App\Http\Middleware\CheckIdToken.php
namespace App\Http\Middleware;

use Auth0\SDK\Helpers\JWKFetcher;
use Auth0\SDK\Helpers\Tokens\AsymmetricVerifier;
use Auth0\SDK\Helpers\Tokens\IdTokenVerifier;
use Auth0\SDK\Helpers\Tokens\SymmetricVerifier;
use Closure;

class CheckIdToken
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        // リクエストヘッダにBearerトークンが存在するか確認
        if (empty($request->bearerToken())) {
            return response()->json(["message" => "Token dose not exist"], 401);
        }

        $id_token = $request->bearerToken();

        // JWTのヘッダー部分を取得し、デコードしてalgを取り出す
        $id_token_header = explode('.', $id_token)[0];

        try {
            $token_alg = json_decode(base64_decode($id_token_header))->alg;
        } catch (\Exception $e) {
            return response()->json(["message" => $e->getMessage()], 401);
        }

        $token_issuer = 'https://' . config('const.auth0.domain') . '/';

        $signature_verifier = null;

        // id tokenを検証するためのVerifierクラスを呼び出す
        // RS256のみで検証したい場合はHS256の分岐を削除する
        if ('RS256' === $token_alg) {
            // 指定したissuerからjwksを取得し、証明書(CERTIFICATE)で取得する
            $jwks_fetcher = new JWKFetcher();
            $jwks = $jwks_fetcher->getKeys($token_issuer.'.well-known/jwks.json');
            $signature_verifier = new AsymmetricVerifier($jwks);
        } else if ('HS256' === $token_alg) {
            $signature_verifier = new SymmetricVerifier(config('const.auth0.client_secret'));
        } else {
            return response()->json(["message" => "Invalid alg"]);
        }

        $token_verifier = new IdTokenVerifier(
            $token_issuer,
            config('const.auth0.client_id'),
            $signature_verifier
        );

        // トークンを検証する
        try {
            $decoded_token = $token_verifier->verify($id_token);
        } catch (\Exception $e) {
            return response()->json(["message" => $e->getMessage()], 401);
        }

        // user_idを$requestに追加する。
        $request->merge([
            'auth0_user_id' => $decoded_token['sub']
        ]);

        return $next($request);
    }
}
```

作成したミドルウェアをjwtという名前でカーネル登録します。

```php:Kernel.php
class Kernel extends HttpKernel {
    // ...
    protected $routeMiddleware = [
        // ...
        'jwt' => \App\Http\Middleware\CheckIdToken::class,
        // ...
    ];
    // ...
}
```

最後にエンドポイントにミドルウェアを設定します。

```php:routes/api.php
Route::middleware('jwt')->get('/private', function (Request $request) {
    return response()->json([
        "autho_user_id" => $request['auth0_user_id'],
        "message" => "プライベートなエンドポイントへようこそ！これを表示するには有効なIDトークンが必要です。"
    ]);
});
```

# ID Tokenを発行する

## Auth0にユーザを登録する

まずは作成したAuth0のアプリケーションにSSO（シングルサインオン）でアクセスしてユーザをAuth0に登録しましょう。

今回はGoogleのSSOを使用します。

`Connections`→`Social`タブからデフォルトで有効化しているGoogleを選択し、トグルスイッチの上にある`TRY`をクリックします。

<img width="1202" alt="スクリーンショット 2020-06-20 23.52.24.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/391127/9aeaf02e-855e-352a-f7f3-a730eec9a758.png">

もしくはGoogleと表示されている部分をクリックして設定を展開し、一番下にある`TRY`をクリックします。

<img width="766" alt="スクリーンショット 2020-06-20 23.52.43.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/391127/81876c99-634a-57de-7def-a61e270faa8f.png">

するとGoogleのログイン画面が表示され、ログインが成功するとAuth0にリダイレクトされ、ログインしたユーザの情報が表示されます。

<img width="1036" alt="スクリーンショット 2020-06-20 23.53.01.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/391127/0d043501-ca02-10b0-9abc-72b800b95927.png">

この状態で`Users & Roles`から`Users`タブを開くと先ほどログインしたユーザが追加されているのがわかります。（画像はほとんど黒塗りでわかりにくいかもしれません）

<img width="1105" alt="スクリーンショット 2020-06-20 23.53.15.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/391127/f5ec88c8-2bd5-1f05-7daa-c20acfd9d615.png">

## Authentication API Debuggerの登録

今回はID Tokenを取得するのに[Auth0 Authentication API Debugger](https://auth0.com/docs/extensions/authentication-api-debugger#authorize-the-extension)を使用します。

`Extensions`タブから`Auth0 Authentication API Debugger`を検索し、有効化します。

<img width="1108" alt="スクリーンショット 2020-06-20 23.53.48.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/391127/3f2bf198-2c38-dd73-0f5d-98cf03f167f2.png">

<img width="714" alt="スクリーンショット 2020-06-20 23.53.54.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/391127/9dbb93ee-6c3b-d0cf-6fb5-8d386541efd3.png">

そうしたら`Callback URL`の値を最初に作成した`Laravel-Sample`アプリケーションの`Allowed Callback URLs`に登録します。

<img width="1121" alt="スクリーンショット 2020-06-28 17.09.08.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/391127/772bc589-a9c4-048a-f91f-4ecf973b3fd9.png">

<img width="811" alt="スクリーンショット 2020-06-20 23.56.02.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/391127/c30dae06-d173-664a-9dbd-21ff2eaf6887.png">

入力したらページの最下部にある`SAVE CHANGES`を押して変更を保存します。

## ID Tokenを取得する

`Authentication API Debugger`の`Login`→`Configuration`内の`Application`にID Tokenを取得したいアプリケーションを指定します。

<img width="1152" alt="スクリーンショット 2020-06-28 17.19.24.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/391127/13db4b8a-87c3-5b5f-e5ab-4cfa988a8153.png">

次に`OAuth2/OIDC`タブの下の方にある`Setting`で`Response Type`と`Nonce`に`token id_token`を設定します。

<img width="1104" alt="スクリーンショット 2020-06-20 23.54.45.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/391127/768d8894-f54b-8934-a96e-bcc40c941b70.png">

最後に`User Flows`の`OAUTH2 / OIDC LOGIN`と書いてある水色のボタンを押すと画面が遷移し、ID Tokenを取得できます。

<img width="1158" alt="スクリーンショット 2020-06-20 23.55.00.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/391127/2220d16e-bbc4-d704-9592-8b10ad27fb26.png">

今回は`token id_token`を指定したのでアクセストークンとIDトークンの両方を取得しています。

また`ID Token`のブロックではIDトークン（JWT）をデコードした中身をみることができます。

<img width="1137" alt="スクリーンショット 2020-06-28 17.33.14.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/391127/39a14afb-7a11-3495-d24d-38640ce38c70.png">

# リクエストを送る

IDトークンを取得できたのでサーバーを起動してリクエストを送ってみます。

```
$ php artisan serve --port=3010
```

先ほど取得したIDトークンをAuthorizationヘッダーにセットして`http://localhost:3010/api/private`にリクエストしましょう。

```json:
{
  "autho_user_id": "google-oauth2|1234567890",
  "message": "プライベートなエンドポイントへようこそ！これを表示するには有効なアクセストークンが必要です。"
}
```

# jwksをキャッシュする

この実装だとリクエストのたびに`jwks`を取得しに行ってしまいます。
なのでキャッシュする実装に変更しましょう。

[Auth0のQ&A](https://community.auth0.com/t/caching-jwks-signing-key/17654)を見ると、`jwks`はトークンの検証に失敗した際に再取得しにいくのが良いようです。

※Redisや関連ライブラリの導入方法に関しての説明は省きます。

```php:App\Http\Middleware\CheckIdToken.php
class CheckIdToken
{
    public function handle($request, Closure $next)
    {
        //...中略...

        $signature_verifier = null;

        if ('RS256' === $token_alg) {
            // キャッシュに保存する
            $jwks = Cache::remember('auht0_jwks_key', 43200, function () use ($token_issuer) {
                $jwks_fetcher = new JWKFetcher();
                return $jwks_fetcher->getKeys($token_issuer.'.well-known/jwks.json');
            });
            $signature_verifier = new AsymmetricVerifier($jwks);
        } else if ('HS256' === $token_alg) {
            $signature_verifier = new SymmetricVerifier(config('const.auth0.client_secret'));
        } else {
            return response()->json(["message" => "Invalid alg"]);
        }

        $token_verifier = new IdTokenVerifier(
            $token_issuer,
            config('const.auth0.client_id'),
            $signature_verifier
        );

        // トークンを検証する
        try {
            $decoded_token = $token_verifier->verify($id_token);
        } catch (\Exception $e) {
            logger()->info('id_tokenの初回検証に失敗しました。 Caught: Exception - '.$e->getMessage());

            // 検証に失敗したら一度だけjwksを最新のものに更新し、再度検証する
            // 実際に実装する際はprivate functionなどに抜き出して共通化すると良いでしょう
            $jwks_fetcher = new JWKFetcher();
            $new_jwks = $jwks_fetcher->getKeys($token_issuer.'.well-known/jwks.json');
            Cache::put('auht0_jwks_key', $new_jwks, 43200)

            $new_signature_verifier = new AsymmetricVerifier($new_jwks);

            $new_token_verifier = new IdTokenVerifier(
                $token_issuer,
                config('const.auth0.client_id'),
                $new_signature_verifier
            );

            try {
                $decoded_token = $new_token_verifier->verify($id_token);
            } catch (\Exception $e) {
                logger()->warning('id_tokenの２回目の検証に失敗しました。');
                return response()->json(["message" => $e->getMessage()], 401);
            };
        }

        // user_idを$requestに追加する。
        $request->merge([
            'auth0_user_id' => $decoded_token['sub']
        ]);

        return $next($request);
    }
}
```

本当は`jwks`内に一致する`kid`が存在しない場合のみ更新するようにしたかったのですが、トークンの検証失敗時には全て`InvalidTokenException`という型が投げられ判別できなかったので、このような形になっています。

# 参考資料
自分のメモ代わりとして、参考資料へのリンクは多めに貼ってあります。

[【Auth0】LaravelでAuth0を使って認証する：アクセストークン編【Laravel】](https://qiita.com/akkino_D-En/items/7c772aeb09b64bb62a7d)
[SPA + API: Solution Overview](https://auth0.com/docs/architecture-scenarios/spa-api/part-1#api-authentication-and-authorization)
[Validating JWTs with Auth0-PHP](https://auth0.com/docs/libraries/auth0-php/jwt-validation)
[Auth0 Quickstarts - PHP](https://auth0.com/docs/quickstart/webapp/php)
[Auth0-PHP](https://auth0.com/docs/libraries/auth0-php)
[Auth0 Authentication API Debugger](https://auth0.com/docs/extensions/authentication-api-debugger#authorize-the-extension)
[Caching JWKS signing key](https://community.auth0.com/t/caching-jwks-signing-key/17654)
[Validate JSON Web Tokens](https://auth0.com/docs/tokens/guides/validate-jwts)