
# Laravel 앱에 로그인 추가

이 퀵스타트에서는 표준 OpenID Connect 클라이언트 `facile-it/php-openid-client`를 사용해 Laravel 13 앱에 KeenAuth 로그인을 추가합니다. KeenAuth에는 아직 PHP SDK가 없으며, 필요하지도 않습니다. KeenAuth는 표준 OpenID Connect 제공자이므로 표준 OpenID Connect 클라이언트 라이브러리로 연동할 수 있습니다. 앱은 OAuth 클라이언트이며 모든 토큰을 서버 측 세션에 보관합니다. 브라우저에는 암호화된 세션 ID 쿠키만 전달됩니다. 이 가이드에서는 로그인 라우트를 추가하고, 페이지를 보호하고, 사용자의 액세스 토큰으로 보호된 API를 호출하고, 로그아웃한 뒤 앱을 오프라인에서 테스트하는 과정을 다룹니다.

앱에는 다음 라우트가 있습니다.

| 라우트 | 역할 |
|---|---|
| `GET /` | 공개 홈 페이지: 로그인 버튼, 또는 사용자 이름과 로그아웃 버튼 |
| `GET /dashboard` | 세션이 필요합니다. 사용자 정보와 API가 반환한 메시지를 표시합니다 |
| `POST /login` | 로그인을 시작합니다. Laravel이 CSRF 토큰을 확인합니다 |
| `GET /auth/callback` | 응답을 확인하고 코드를 교환한 뒤 세션을 시작합니다 |
| `POST /logout` | 여기서와 KeenAuth에서 세션을 종료합니다 |

## 시작하기 전에

- KeenAuth 환경. 발급자(issuer)는 `https://your-environment.eu.keenauth.app` 형식입니다.
- 환경의 웹 애플리케이션([애플리케이션 생성](/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` 스코프가 있어야 합니다. 예를 들어 `https://api.example.com`과 같은 `identifier`가 해당 API 액세스 토큰의 대상(audience)입니다. [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` | 예 | 환경의 발급자, `https` |
| `KEENAUTH_CLIENT_ID` | 예 | 웹 애플리케이션의 `client_id` |
| `KEENAUTH_CLIENT_SECRET` | 예 | 해당 클라이언트 시크릿 |
| `APP_BASE_URL` | 예 | 앱의 공개 URL. 리디렉션 URI는 이 값으로 만들어집니다. `localhost`, `127.0.0.1` 또는 `[::1]`에서만 `http`를 사용할 수 있습니다 |
| `API_URL` | 예 | 앱이 호출하는 API. 예: `http://localhost:8080` |
| `KEENAUTH_API_AUDIENCE` | 예 | KeenAuth에 등록된 해당 API의 식별자 |
| `KEENAUTH_ID_TOKEN_ALG` | 아니요 | 환경이 ID 토큰에 서명하는 알고리즘: `RS256`(기본값), `PS256`, `ES256` 또는 `EdDSA` |
| `APP_KEY` | 예 | Laravel 자체 키로, 세션 쿠키를 암호화합니다. `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`으로 제출되는 폼입니다. 따라서 다른 사이트가 사용자를 대신해 로그인을 시작할 수 없습니다:

```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`입니다. `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`입니다. 앱은 로컬 세션을 종료하고, `id_token_hint`, `client_id`, `post_logout_redirect_uri`와 함께 브라우저를 환경의 로그아웃 엔드포인트(OpenID Connect RP-Initiated Logout)로 보냅니다. 이 엔드포인트가 브라우저를 앱으로 되돌려 보냅니다:

```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`을 열고 **로그인**을 선택한 다음 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에 전달되며 쿠키에는 절대 저장되지 않는지. 알 수 없거나 재사용된 `state`, 없거나 잘못된 `iss`, 다른 클라이언트용 또는 다른 nonce의 ID 토큰, 인가 오류가 세션 없이 거부되는지. 세션이 없는 페이지가 홈 페이지로 리디렉션되는지. 로그아웃에 CSRF 토큰이 필요하고 `id_token_hint`와 함께 로그아웃 엔드포인트로 이동하는지.

실제 브라우저로 하는 전체 로그인에는 KeenAuth 환경이 필요합니다. 이는 오프라인 테스트에 포함되지 않습니다.

## 프록시 또는 로드 밸런서 뒤에서

`APP_BASE_URL`을 앱의 공개 URL로 설정하십시오. 리디렉션 URI는 이 값으로 만들어지며, `Host` 헤더로 만들어지지 않습니다. 프록시가 TLS를 종료한다면 해당 프록시만 신뢰하도록 Laravel의 신뢰 프록시를 구성하십시오.

## 현재 제한 사항

- 리프레시 토큰과 자동 로그인(silent sign-in)을 지원하지 않습니다. 액세스 토큰이 만료되면 사용자가 다시 로그인해야 합니다.
- 클라이언트는 클라이언트 시크릿으로 인증합니다. 라이브러리는 `private_key_jwt`도 지원하지만 예제에서는 다루지 않습니다.

## 다음 단계

- [Express API 보호하기](/docs/quickstarts/express): 이 앱이 호출하는 API.
- [토큰 검증](/docs/sdk/token-verification): API 미들웨어 아래에서 동작하는 검증기.
