LaravelでAuth0のIDトークン(JWT)を検証・デコードする

# 環境
- [Laravel Framework 9.13.0](https://laravel.com/ "Laravel")
- [PHP 8.1.6](https://www.php.net/ "PHP")
- [auth0/auth0-php 8.2](https://github.com/auth0/auth0-PHP "auth0-php GitHub")

## 前提
Auth0で`Single Page Appliation`を作成し、`ID Token`を**発行できる状態**を前提とします。
詳細な説明・方法は[【Auth0】LaravelでAuth0を使って認証する：IDトークン編【Laravel】](https://qiita.com/akkino_D-En/items/9c2a8c0d13ac802b04e7 "")@akkino_D-Enがわかりやすかったので参照してください。

## 注意
`auth0-php`の**7系**では今回の方法で**実装できません**。
また、`auth0/login`という似たライブラリもあるので注意してください。

# 実装
## 1. auth0-phpのインストール
実行：`composer require auth0/auth0-php`

`.env`に`Auth0`から取得した環境変数を追加します。
```.env.example
# 追加
AUTH0_DOMAIN=tenant.auth0.com
AUTH0_CLIENT_ID=client_id
AUTH0_CLIENT_SECRET=secret
```

```.env
# 追加
AUTH0_DOMAIN=******.auth0.com
AUTH0_CLIENT_ID=******
AUTH0_CLIENT_SECRET=******
```

`config/auth0.php`を作成し、`config()`で取得できるようにします。
実行：`touch config/auth0.php`
```php:config/auth0.php
<?php

return [
    'domain' => env('AUTH0_DOMAIN'),
    'clientId' => env('AUTH0_CLIENT_ID'),
    'clientSecret' => env('AUTH0_CLIENT_SECRET'),
];
```

## 2. ミドルウェアの作成
実行：`php artisan make:middleware CheckIdToken`

ミドルウェアを`Kernel.php`に登録
```php:app/Http/Kernel.php
    protected $routeMiddleware = [
        //....
        'auth0' => \App\Http\Middleware\CheckIdToken::class, //追加
　　];
```

ミドルウェアを編集
```php:app/Http/Middleware/CheckIdToken.php
<?php

namespace App\Http\Middleware;

use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Auth0\SDK\Auth0;
use Auth0\SDK\Token;
use Closure;

class CheckIdToken
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        $configure = [
            'domain'       => config('auth0.domain'),
            'clientId'     => config('auth0.clientId'),
            'clientSecret' => config('auth0.clientSecret'),
            'tokenJwksUri' => 'https://'.config('auth0.domain').'/.well-known/jwks.json',
            'tokenCache' => null,
            'tokenCacheTtl' => 43200
        ];

        $auth0 = new Auth0($configure);

        // SDKの設定でキャッシュを有効化させる
        $tokenCache = new FilesystemAdapter();
        $auth0->configuration()->setTokenCache($tokenCache);

        // リクエストヘッダにBearerトークンが存在するか確認
        if (empty($request->bearerToken())) {
            return response()->json(["message" => "Token dose not exist"], 401);
        }

        $id_token = $request->bearerToken();

        // IDトークンの検証・デコード
        try {
            $auth0->decode($id_token, null, null, null, null, null, null, Token::TYPE_ID_TOKEN);
        } catch (\Exception $e) {
            return response()->json([
                "message" => config('app.debug') ? $e->getMessage() : "401: Unauthorized"
            ], 401);
        }

        $token = new Token($auth0->configuration(), $id_token, Token::TYPE_ID_TOKEN);
        $payload = json_decode($token->toJson()); //IDトークンに格納されたClaimを取得

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

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

### 解説
`Auth0\SDK\Auth0`の`decode()`を使って`ID Token`を検証・デコードしています。
`ID Token`には`email`や`sub`などの情報が含まれているため、この段階で`$auth0`にユーザ情報が追加されています。

`decode()`は成功時に`$this`を返します。`dd()`で中を見ると`tokenClaims` `tokenHeaders` `tokenSignature`が含まれていることがわかります。
これらのプロパティは`private`なため、直接**参照できません**（arrayキャストすれば一応できますが）。

`Auth0\SDK\Token`にgetterがあるのでそちらを使います。

```php
$token = new Token($auth0->configuration(), $id_token, Token::TYPE_ID_TOKEN);

// 'aud' クレームを取得
$token->getAudience();

// 'azp' クレームを取得
$token->getAuthorizedParty();

// 'auth_time' クレームを取得
$token->getAuthTime();

// 'exp' クレームを取得
$token->getExpiration();

// 'iat' クレームを取得
$token->getIssued();

// 'iss' クレームを取得
$token->getIssuer();

// 'nonce' クレームを取得
$token->getNonce();

// 'org_id' クレームを取得
$token->getOrganization();

// 'sub' クレームを取得
$token->getSubject();

// ID Tokenのペイロード部を連想配列として取得
$payload = $token->toArray();

// ID Tokenのペイロード部をJson形式の文字列として取得
$payload = $token->toJson();
```

`ID Token`の詳細な仕様は[IDトークンが分かれば OpenID Connect が分かる](https://qiita.com/TakahikoKawasaki/items/8f0e422c7edd2d220e06#7-id-%E3%83%88%E3%83%BC%E3%82%AF%E3%83%B3)@TakahikoKawasakiがわかりやすいです。

`sub`をプライマリーキーの値として実装するケースが一般的なようなので、今回も$requestに`sub`を追加し、コントローラに渡しました。

試しに`api.php`もしくは`web.php`にエンドポイントを追加します。

```php:routes/api.php
Route::group(['middleware' => ['auth0']], function () {
    Route::get('/test', function (Request $request) {
        return response()->json(["auth_id" => $request['auth0_user_id']);
    });
});
```

`Postman`などを用いて`Headers`の`KEY`に`Authorization`を追加、`VALUE`に`ID Token`を
```
Bearer eyJhbGciOi******.************.******
```
のように追加して送信します。
すると`sub`の値が取得できるはずです。

## 3. ユーザのデータを取得する
最後はおまけです。
認証済みユーザーがアクセスできるエンドポイントには常にユーザ情報が取得できる状態にしたかったので、`getAuthUser()`を作成してDBかキャッシュから取得しています。

ど素人文系大学生の実装なのであくまで**参考程度にしてください**（**逆にアドバイスなどがあればお願いします**）。

```php:app/Http/Middleware/CheckIdToken.php
<?php

namespace App\Http\Middleware;

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Auth0\SDK\Auth0;
use Auth0\SDK\Token;
use Closure;

class CheckIdToken
{
    /**
     * ユーザデータをキャッシュする時間
     *
     * @var integer
     */
    private int $cache_minutes = 30;

    /**
     * IDトークンから取得した`sub`(`auth_id`)
     *
     * @var string|null
     */
    private ?string $auth_id = null;

    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        $configure = [
            'domain'       => config('auth0.domain'),
            'clientId'     => config('auth0.clientId'),
            'clientSecret' => config('auth0.clientSecret'),
            'tokenJwksUri' => 'https://'.config('auth0.domain').'/.well-known/jwks.json',
            'tokenCache' => null,
            'tokenCacheTtl' => 43200
        ];

        $auth0 = new Auth0($configure);

        // SDKの設定でキャッシュを有効化させる
        $tokenCache = new FilesystemAdapter();
        $auth0->configuration()->setTokenCache($tokenCache);

        // リクエストヘッダにBearerトークンが存在するか確認
        if (empty($request->bearerToken())) {
            return response()->json(["message" => "Token dose not exist"], 401);
        }

        $id_token = $request->bearerToken();

        // IDトークンの検証・デコード
        try {
            $auth0->decode($id_token, null, null, null, null, null, null, Token::TYPE_ID_TOKEN);
        } catch (\Exception $e) {
            return response()->json([
                "message" => config('app.debug') ? $e->getMessage() : "401: Unauthorized"
            ], 401);
        }

        //IDトークンに格納されたClaimを取得
        $token = new Token($auth0->configuration(), $id_token, Token::TYPE_ID_TOKEN);
        $payload = json_decode($token->toJson());

        $this->auth_id = $payload->sub;

        $user = json_decode($this->getAuthUser());

        // ユーザのデータが存在しない場合
        if(!$user){
            return response()->json(["message" => "User profile is not registered"]);
        }

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

        return $next($request);
    }

    /**
     * キャッシュから`auth_id`に一致するユーザを返す。存在しな場合はDBを参照する
     *
     * @return ?string
     */
    private function getAuthUser()
    {
        return Cache::remember($this->auth_id, $this->cache_minutes, function () {
            $u = DB::table('users')->where('auth_id', $this->auth_id)->get();
            return json_encode($u);
        });
    }
}
```

# 今後やりたいこと
自動テストの実行のために`ID Token`を自動で取得できるようにしたいです。
次回、気が向いたら記事にします。

# 参考記事
- [auth0/auth0-php 8.2](https://github.com/auth0/auth0-PHP "auth0-php GitHub")
- [【Auth0】LaravelでAuth0を使って認証する：IDトークン編【Laravel】](https://qiita.com/akkino_D-En/items/9c2a8c0d13ac802b04e7 "")@akkino_D-En
- [IDトークンが分かれば OpenID Connect が分かる](https://qiita.com/TakahikoKawasaki/items/8f0e422c7edd2d220e06#7-id-%E3%83%88%E3%83%BC%E3%82%AF%E3%83%B3)@TakahikoKawasaki