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 の両方でセッションを終了します

始める前に

1. インストール

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 トークンに署名されることは決してありません。

        $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 時間キャッシュします。

        $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);
            ->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 の検証子を作成し、セッションに保持します。

    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 がそれを拒否します。

        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 するフォームです。これにより、別のサイトがユーザーのサインインを開始することはできません。

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

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

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

        $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 です。

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

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

        // 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 ミドルウェアを使用します。サンプルにはリフレッシュトークンがありません。アクセストークンが期限切れになったら、ユーザーは再度サインインします。

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

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

        $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 を付けます。これにより、ブラウザはアプリに戻ります。

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

8. アプリを実行する

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

php artisan serve

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

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

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

    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');
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 の信頼するプロキシを設定してください。

現在の制限

次のステップ