
# Laravel アプリにサインインを追加する

このクイックスタートでは、標準の OpenID Connect クライアントライブラリ `facile-it/php-openid-client` を使って、Laravel 13 のアプリに KeenAuth サインインを追加します。KeenAuth にはまだ PHP 用 SDK がありませんが、必要ありません。KeenAuth は標準の OpenID Connect プロバイダーなので、標準の OpenID Connect クライアントライブラリで動作します。アプリは OAuth クライアントとして動作し、すべてのトークンをサーバー側のセッションに保持します。ブラウザに渡されるのは暗号化されたセッション ID の Cookie だけです。サインインルートを追加し、ページを保護し、ユーザーのアクセストークンで保護された API を呼び出し、サインアウトし、オフラインでアプリをテストします。

アプリには次のルートがあります。

| ルート | 機能 |
|---|---|
| `GET /` | 公開のホームページです。サインインボタン、または、ユーザー名とサインアウトボタンを表示します |
| `GET /dashboard` | セッションが必要です。ユーザー情報と、API が返すメッセージを表示します |
| `POST /login` | サインインを開始します。Laravel が CSRF トークンを検証します |
| `GET /auth/callback` | レスポンスを検証し、コードを交換して、セッションを開始します |
| `POST /logout` | ここと KeenAuth の両方でセッションを終了します |

## 始める前に

- KeenAuth 環境。その発行者（issuer）は `https://your-environment.eu.keenauth.app` のような形式です。
- 環境内の Web アプリケーション（[アプリケーションを作成する](/docs/api/management/applications/create)）。クライアントシークレット（`client_secret_basic`）を持つものです。`development` 環境では、リダイレクト URI `http://localhost/auth/callback` と、サインアウト後のリダイレクト URI `http://localhost/` を登録してください。ループバック URI はポート番号なしで登録され、`8000` などの任意のポートに一致します。
- 環境に登録された API（[API を作成する](/docs/api/management/apis/create)）。スコープ `messages:read` を持つものです。その `identifier`（例: `https://api.example.com`）が、そのアクセストークンのオーディエンスになります。[Express クイックスタート](/docs/quickstarts/express)では、これらのトークンを受け付ける API を構築します。
- PHP 8.3 以降と Composer 2。

## 1. インストール

```sh
composer require facile-it/php-openid-client:1.0.0 guzzlehttp/guzzle:8.3.1
```

このライブラリには PSR-18 の HTTP クライアントが必要です。Guzzle はその一つです。サンプルでは `composer.json` ですべてのパッケージを正確なバージョンに固定し、`composer.lock` で各パッケージをコミットに固定しています。インストールは `composer install --no-plugins --no-scripts` で行うため、インストール中にパッケージのコードは実行されません。

## 2. 設定

アプリは `config/keenauth.php` を通じて、環境変数からのみ設定を読み込みます。Laravel が読み込む `.env` に設定を書き、そのファイルは決してコミットしないでください。

| 変数 | 必須 | 意味 |
|---|---|---|
| `KEENAUTH_ISSUER` | はい | 環境の発行者（issuer）。`https` を使用します |
| `KEENAUTH_CLIENT_ID` | はい | Web アプリケーションの `client_id` |
| `KEENAUTH_CLIENT_SECRET` | はい | そのクライアントシークレット |
| `APP_BASE_URL` | はい | アプリの公開 URL。リダイレクト URI はこれから組み立てられます。`http` は `localhost`、`127.0.0.1`、`[::1]` の場合のみ使用できます |
| `API_URL` | はい | アプリが呼び出す API（例: `http://localhost:8080`） |
| `KEENAUTH_API_AUDIENCE` | はい | KeenAuth における、その API の識別子 |
| `KEENAUTH_ID_TOKEN_ALG` | いいえ | 環境が ID トークンの署名に使うアルゴリズム。`RS256`（既定）、`PS256`、`ES256` または `EdDSA` |
| `APP_KEY` | はい | Laravel 自身の鍵。セッション Cookie を暗号化します。`php artisan key:generate` で生成します |

`Config::fromArray` がこれらの値を検証します。値が欠けているか使用できない場合、その値を必要とする最初のリクエストで停止し、どのエラーメッセージにも値は含まれません。HMAC アルゴリズムは拒否されるため、クライアントシークレットで ID トークンに署名されることは決してありません。

```php
        $alg = $values['id_token_alg'] ?? 'RS256';
        if (!in_array($alg, ['RS256', 'PS256', 'ES256', 'EdDSA'], true)) {
            // Never an HMAC algorithm: the client secret must not be able to sign an ID token.
            throw new InvalidArgumentException('KEENAUTH_ID_TOKEN_ALG must be RS256, PS256, ES256 or EdDSA');
        }
```

## 3. OpenID Connect クライアントを作成する

`App\KeenAuth\KeenAuth` は、ディスカバリー（`<issuer>/.well-known/openid-configuration`）からクライアントを構築し、メタデータと鍵セットを 1 時間キャッシュします。

```php
        $issuer = (new IssuerBuilder())
            ->setMetadataProviderBuilder((new MetadataProviderBuilder())->setHttpClient($this->http)
                ->setCache($this->cache)->setCacheTtl(3600))
            ->setJwksProviderBuilder((new JwksProviderBuilder())->withHttpClient($this->http)
                ->withCache($this->cache)->withCacheTtl(3600))
            ->build($this->config->issuer);
```

```php
            ->setClientMetadata(ClientMetadata::fromArray([
                'client_id' => $this->config->clientId,
                'client_secret' => $this->config->clientSecret,
                'token_endpoint_auth_method' => 'client_secret_basic',
                'redirect_uris' => [$this->config->redirectUri()],
                // The verifier accepts only this algorithm, never an HMAC one.
                'id_token_signed_response_alg' => $this->config->idTokenAlg,
            ]))
```

サービスプロバイダーはこれをシングルトンとして登録し、HTTP クライアントはテストで差し替えられるように別のバインディングとして登録します。

## 4. サインインを開始する

サインインのたびに、新しい `state`、`nonce`、PKCE の検証子を作成し、セッションに保持します。

```php
    public static function newTransaction(): array
    {
        return [
            'state' => self::base64url(random_bytes(32)),
            'nonce' => self::base64url(random_bytes(32)),
            'code_verifier' => self::base64url(random_bytes(32)),
        ];
    }
```

認可リクエストでは、検証子の S256 チャレンジと、API の識別子を `resource`（RFC 8707）として送信します。`resource` がないと、アクセストークンは自分の API 向けにはならず、API がそれを拒否します。

```php
        return $this->service()->getAuthorizationUri($this->client(), [
            // messages:read is a scope of your API; the example API requires it on /api/messages.
            'scope' => 'openid profile email messages:read',
            'state' => $transaction['state'],
            'nonce' => $transaction['nonce'],
            // PKCE (RFC 7636): S256 of the verifier that only this session knows.
            'code_challenge' => self::base64url(hash('sha256', $transaction['code_verifier'], true)),
            'code_challenge_method' => 'S256',
            // RFC 8707: an access token for your API.
            'resource' => $this->config->apiAudience,
        ]);
```

サインインは、CSRF トークンを付けて `/login` に POST するフォームです。これにより、別のサイトがユーザーのサインインを開始することはできません。

```php
    <form action="/login" method="post">
      @csrf
      <button type="submit">Sign in</button>
    </form>
```

## 5. コールバックを処理する

ライブラリは `state` の検証をアプリに任せています。コールバックはセッションからトランザクションを取り出すため、`state` は一度しか使えず、定数時間で比較されます。環境は認可レスポンスのすべてに自身を示す値を含めるため（RFC 9207）、コールバックは `iss` が欠けているか、別の発行者を示すレスポンスも拒否します。

```php
        $transaction = $request->session()->pull('keenauth.transaction');
        $state = $request->query('state');
        if (!is_array($transaction) || !is_string($state) || !hash_equals($transaction['state'], $state)) {
            return $this->failure('invalid_state');
        }
        // RFC 9207: the response names its issuer, so a response from another provider is refused.
        $iss = $request->query('iss');
        if ($keenauth->issuerInResponse() && $iss !== $keenauth->config->issuer) {
            return $this->failure('invalid_issuer');
        }
```

コールバックから取り出すのはコードだけです。ライブラリは PKCE の検証子を使ってコードを交換し、ID トークンを検証します。検証するのは、環境の鍵による署名、`iss`、`aud`、`exp`、`iat`、アルゴリズム、そしてこのサインインの `nonce` です。

```php
        return $this->service()->callback($this->client(), ['code' => $code], $this->config->redirectUri(),
            AuthSession::fromArray($transaction));
```

その後、アプリは新しいセッション ID を開始し、ユーザーとトークンをサーバー側のセッションに保持します。

```php
        // A new session id after sign-in prevents session fixation.
        $request->session()->regenerate(destroy: true);
```

セッションの設定は `config/session.php` にあります。`file` ドライバーを使い、暗号化され、HttpOnly で、`SameSite=Lax` の Cookie になります。`SESSION_SECURE_COOKIE=false` を設定しない限り `Secure` になります（`http://localhost` の場合に設定します）。複数のサーバーがある場合は、`redis` または `database` ドライバーを使用してください。

## 6. ページを保護し、API を呼び出す

ユーザーを必要とするルートでは、`keenauth` ミドルウェアを使用します。サンプルにはリフレッシュトークンがありません。アクセストークンが期限切れになったら、ユーザーは再度サインインします。

```php
Route::get('/dashboard', [DashboardController::class, 'show'])->middleware('keenauth');
```

ダッシュボードは、アクセストークンをベアラートークンとして API を呼び出します。トークンはサーバー上にとどまり、ブラウザが受け取るのは HTML だけです。

```php
        $response = $http->sendRequest(new HttpRequest('GET', $keenauth->config->apiUrl . '/api/messages', [
            'Authorization' => 'Bearer ' . $request->session()->get('keenauth.access_token'),
            'Accept' => 'application/json',
        ]));
```

## 7. サインアウトする

サインアウトは CSRF トークンを付けた `POST` です。アプリはローカルのセッションを終了し、ブラウザを環境のログアウトエンドポイント（OpenID Connect RP-Initiated Logout）へ送ります。その際、`id_token_hint`、`client_id`、`post_logout_redirect_uri` を付けます。これにより、ブラウザはアプリに戻ります。

```php
        $idToken = $request->session()->get('keenauth.id_token');
        $request->session()->invalidate();
        $request->session()->regenerateToken();
```

## 8. アプリを実行する

Express クイックスタートの API（またはこのトークンを受け付ける任意の API）を起動してから、アプリを実行します。

```sh
php artisan serve
```

`http://localhost:8000` を開き、**Sign in** を選び、KeenAuth でサインインして、ダッシュボードを開いてください。

## 9. ネットワークなしでテストする

テストにはネットワークも KeenAuth アカウントも必要ありません。偽の PSR-18 クライアントが HTTP クライアントの代わりになり、環境（ディスカバリー、テスト実行用に作成された鍵セット、トークンエンドポイント）と API を演じます。Laravel は `testing` 環境では CSRF チェックを省略しますが、これらのテストではそれを確認するため、テストは `APP_ENV=ci` で実行します。

```php
    public function test_callback_with_an_unknown_state_is_refused_before_any_token_request(): void
    {
        $this->startSignIn();
        $this->returnFromKeenAuth(['state' => 'not-the-state'])->assertStatus(401)->assertSee('invalid_state');
```

```sh
vendor/bin/phpunit
php ../scripts/php_deps.php    # exact pins, commits and licences
```

テストでは次の点を確認します。設定が検証されること（どのエラーにもシークレットが含まれないこと）。サインインには CSRF トークンが必要で、PKCE S256、`state`、`nonce`、`resource` を送信すること。コードは対応する検証子とクライアント認証を使って交換されること。アクセストークンは `Authorization` ヘッダーで API に送られ、Cookie には決して入らないこと。未知または再利用された `state`、欠けているか誤った `iss`、別のクライアント向けの ID トークンや別の nonce を持つ ID トークン、認可エラーは、セッションなしで拒否されること。セッションのないページはホームページにリダイレクトされること。サインアウトには CSRF トークンが必要で、`id_token_hint` を付けてログアウトエンドポイントへ移動すること。

実際のブラウザでの完全なサインインには、KeenAuth 環境が必要です。これはオフラインテストには含まれません。

## プロキシまたはロードバランサーの背後で

`APP_BASE_URL` にアプリの公開 URL を設定してください。リダイレクト URI はこれから組み立てられ、`Host` ヘッダーからは決して組み立てられません。プロキシが TLS を終端する場合は、そのプロキシに対してのみ、Laravel の信頼するプロキシを設定してください。

## 現在の制限

- リフレッシュトークンがなく、サイレントサインインもありません。アクセストークンが期限切れになったら、ユーザーは再度サインインします。
- クライアントはクライアントシークレットで認証します。ライブラリは `private_key_jwt` もサポートしていますが、サンプルでは示していません。

## 次のステップ

- [Express API を保護する](/docs/quickstarts/express): このアプリが呼び出す API です。
- [トークンの検証](/docs/sdk/token-verification): API のミドルウェアの下で動作する検証器です。
