CakePHP4 で 認証を乗り越えて Swagger3 を使ってAPIを呼び出す

「[CakePHP4 で Swagger3 を使って API ドキュメントを作る](https://qiita.com/katsuhiko/items/22511d5ed5eff3e4616a)」で APIドキュメントを作ったんですが、「[CakePHP4 の認証処理 cakephp/authentication で Auth0 を使った認証を行う](https://qiita.com/katsuhiko/items/a1c5efc49e2065686fa8)」を行ったら、Swagger UI からの API 実行で認証エラーが発生するようになりました。

認証があっても Swagger UI から API 実行を行えるように対応します。

# この記事でわかること

- CakePHP4 で認証を行った Swagger3 のAPI呼び出し方法。
- CakePHP4 cakephp/authentication で複数の Authenticator の指定方法。
- この記事内のソースは以下で公開しています。
    - https://github.com/katsuhiko/cakephp-vue-study


# バージョン情報

|  | バージョン |
|:-----------------|:------------------|
| CakePHP4                | 4.0.6 |
| cakephp/authentication  | 2.1.0 |
| zircote/swagger-php     | 3.0.3 |


# 事前準備

- 以下の記事のソースから発展させてます。
    - [CakePHP4 で Swagger3 を使って API ドキュメントを作る](https://qiita.com/katsuhiko/items/22511d5ed5eff3e4616a)
    - [CakePHP4 の認証処理 cakephp/authentication で Auth0 を使った認証を行う](https://qiita.com/katsuhiko/items/a1c5efc49e2065686fa8)
- docker-compose を実行し、コンテナを立ち上げます。

```sh
docker-compose up -d
```


# APIドキュメント

## 認証の記述追加

共通のドキュメント内容を記述している ./src/Controller/Api/swagger.php に認証の記述を追加します。

SecurityScheme を記述して、HTTPヘッダ「X-Api-User-Local」に JSON形式でユーザー情報を渡せるようにします。
あくまで、ローカルで実行した場合の対応です。本番環境ではこのようなユーザー情報の渡し方は動かないようにします（無効化します）。

そのあと、すべての API に SecurityScheme で定義した認証を利用するように OpenApi security を記述します。
記述方法については、以下の issue が参考になりました。

- https://github.com/zircote/swagger-php/issues/598

```php:src/Controller/Api/swagger.php
// ... snip
/**
 * @OA\SecurityScheme(
 *   securityScheme="api_user_local",
 *   type="apiKey",
 *   in="header",
 *   name="X-Api-User-Local",
 *   description="ローカルで実行した場合のユーザー情報 ({""sub"":""DUMMY""})",
 * ),
 * @OA\OpenApi(
 *   security={
 *     {
 *       "api_user_local":{}
 *     },
 *   },
 * ),
 */
```


# CORS の対応

## X-Api-User-Local ヘッダの追加

ローカル開発環境で実行された場合、 CORS に対応したヘッダ情報を付与してレスポンスを返す  `./src/Middleware/CorsMiddleware.php` を変更します。
Access-Control-Allow-Headers ヘッダとして返却する値に、 `X-Api-User-Local` を追加して、 `X-Api-User-Local` ヘッダを API呼び出し時に指定できるようにします。

```php:src/Middleware/CorsMiddleware.php
class CorsMiddleware implements MiddlewareInterface
{
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $response = $handler->handle($request);

        // ローカル開発モードのときのみ CORS に対応する
        if (filter_var(env('SERVER', false))) {
            return $response;
        }

        $response = $response->withHeader('Access-Control-Allow-Origin', '*');
        $response = $response->withHeader('Access-Control-Allow-Methods', '*');
        $response = $response->withHeader('Access-Control-Allow-Headers', 'Content-Type, X-Api-User-Local');
        $response = $response->withHeader('Access-Control-Max-Age', '172800');

        return $response;
    }
}
```


## OPTIONS リクエスト時は認証を行わないようにする

OPTIONS リクエストの処理を行っている `./src/Controller/Api/CorsController.php` を変更します。
OPTIONS リクエストは、認証を行わないようにするために `beforeFilter()` で `$this->Authentication->addUnauthenticatedActions()` を指定します。

```php:src/Controller/Api/CorsController.php
class CorsController extends AppController
{
    public function beforeFilter(EventInterface $event): void
    {
        parent::beforeFilter($event);
        $this->Authentication->addUnauthenticatedActions(['options']);
    }

    public function options(): void
    {
        // ローカル開発モードのときのみ CORS に対応する
        if (filter_var(env('SERVER', false))) {
            throw new NotFoundException('Not support CORS.');
        }

        $this->viewBuilder()->setOption('serialize', []);
    }
}
```


# Authenticator の追加

## SwaggerAuthenticator の作成

ローカル開発環境のときのみ、ヘッダ「X-Api-User-Local」による認証を認めます。
ユーザー情報は、ヘッダ「X-Api-User-Local」に JSON 形式でセットされていることを想定しています。

```php:src/Authenticator/SwaggerAuthenticator.php
class SwaggerAuthenticator extends AbstractAuthenticator
{
    protected $_defaultConfig = [];

    public function authenticate(ServerRequestInterface $request): ResultInterface
    {
        // ローカル開発モードのときのみ Swagger によるアクセスを許可するため
        // ローカル開発モードのとき以外は、ユーザー情報なしで返却する
        if (filter_var(env('SERVER', false))) {
            return new Result(null, Result::FAILURE_OTHER, ['Not support Swagger.']);
        }

        $user = $request->getHeaderLine('x-api-user-local');
        if (!$user) {
            return new Result(null, Result::FAILURE_CREDENTIALS_MISSING);
        }

        return new Result(json_decode($user, true), Result::SUCCESS);
    }
}
```

## Application.php の修正

上記で作った SwaggerAuthenticator を利用するように `./src/Application.php` を修正します。

API のときのみ SwaggerAuthenticator を利用するように `$authenticationService->loadAuthenticator('Swagger')` を行います。
 
Authenticator は記述された順番で呼び出されます。
Auth0Authenticator より前に SwaggerAuthenticator を呼び出すようにします（今回のケースだと、どちらの Authenticator が先に処理されても問題なく動作します）。

```php:src/Application.php
    public function getAuthenticationService(ServerRequestInterface $request): AuthenticationServiceInterface
    {
        $path = $request->getUri()->getPath();
        $isApi = (strpos($path, '/api/') === 0);

        if ($isApi) {
            return $this->getAuthenticationServiceForApi();
        } else {
            return $this->getAuthenticationServiceForPage();
        }
    }

    private function getAuthenticationServiceForApi(): AuthenticationServiceInterface
    {
        $authenticationService = new AuthenticationService([
            'unauthenticatedRedirect' => null,
            'queryParam' => 'redirect',
            'identityClass' => function ($identityData) {
                return new Identity($identityData, [
                    'fieldMap' => [
                        'id' => 'sub',
                    ],
                ]);
            },
        ]);

        $authenticationService->loadAuthenticator('Swagger');
        $authenticationService->loadAuthenticator('Auth0');

        return $authenticationService;
    }

    private function getAuthenticationServiceForPage(): AuthenticationServiceInterface
    {
        $authenticationService = new AuthenticationService([
            'unauthenticatedRedirect' => '/users/login',
            'queryParam' => 'redirect',
            'identityClass' => function ($identityData) {
                return new Identity($identityData, [
                    'fieldMap' => [
                        'id' => 'sub',
                    ],
                ]);
            },
        ]);

        $authenticationService->loadAuthenticator('Auth0');

        return $authenticationService;
    }
```


# 動作確認

上記の対応で Swagger UI (僕は、 VSCode の Swagger Viewer
を使っています) からは認証を経由して API を呼び出すことができます。

デフォルトのテンプレートの場合、右上にある Authorize ボタンからユーザー情報を JSON形式（例えば、 `{"sub":"DUMMY"}`）で入力すると、各 API を呼び出すことで確認できます。