
# Add sign-in to a Laravel app

This quickstart adds KeenAuth sign-in to a Laravel 13 app with the standard OpenID Connect client `facile-it/php-openid-client`. KeenAuth has no PHP SDK yet, and none is needed: KeenAuth is a standard OpenID Connect provider, so a standard OpenID Connect client library works. Your app is the OAuth client and keeps every token in the server-side session; the browser gets only an encrypted session id cookie. You add the sign-in routes, protect a page, call a protected API with the user's access token, sign out and test the app offline.

The app has these routes:

| Route | What it does |
|---|---|
| `GET /` | Public home page: a sign-in button, or the user's name and a sign-out button |
| `GET /dashboard` | Needs a session. Shows the user and the messages that your API returns |
| `POST /login` | Starts sign-in. Laravel checks the CSRF token |
| `GET /auth/callback` | Checks the response, exchanges the code and starts the session |
| `POST /logout` | Ends the session here and at KeenAuth |

## Before you begin

- A KeenAuth environment. Its issuer looks like `https://your-environment.eu.keenauth.app`.
- A web application in the environment ([Create an application](/docs/api/management/applications/create)) with a client secret (`client_secret_basic`). In a `development` environment, register the redirect URI `http://localhost/auth/callback` and the post-logout redirect URI `http://localhost/`; a loopback URI is registered without a port and matches any port, such as `8000`.
- An API registered in the environment ([Create an API](/docs/api/management/apis/create)) with the `messages:read` scope. Its `identifier`, for example `https://api.example.com`, is the audience of its access tokens. The [Express quickstart](/docs/quickstarts/express) builds an API that accepts these tokens.
- PHP 8.3 or later and Composer 2.

## 1. Install

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

The library needs a PSR-18 HTTP client; Guzzle is one. The example pins every package exactly in `composer.json`, and `composer.lock` pins each package to a commit. It installs with `composer install --no-plugins --no-scripts`, so no package code runs during the install.

## 2. Configure

The app reads its configuration from environment variables only, through `config/keenauth.php`. Put them in `.env`, which Laravel loads, and never commit that file.

| Variable | Required | Meaning |
|---|---|---|
| `KEENAUTH_ISSUER` | yes | Your environment's issuer, `https` |
| `KEENAUTH_CLIENT_ID` | yes | The `client_id` of the web application |
| `KEENAUTH_CLIENT_SECRET` | yes | Its client secret |
| `APP_BASE_URL` | yes | The public URL of the app. The redirect URI is built from it. `http` only for `localhost`, `127.0.0.1` or `[::1]` |
| `API_URL` | yes | The API the app calls, for example `http://localhost:8080` |
| `KEENAUTH_API_AUDIENCE` | yes | The identifier of that API in KeenAuth |
| `KEENAUTH_ID_TOKEN_ALG` | no | The algorithm your environment signs ID tokens with: `RS256` (default), `PS256`, `ES256` or `EdDSA` |
| `APP_KEY` | yes | Laravel's own key; it encrypts the session cookie. Generate one with `php artisan key:generate` |

`Config::fromArray` checks these values. A missing or unusable value stops the first request that needs it, and no error message contains a value. An HMAC algorithm is refused, so the client secret can never sign an ID token:

```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. Create the OpenID Connect client

`App\KeenAuth\KeenAuth` builds the client from discovery (`<issuer>/.well-known/openid-configuration`) and caches the metadata and the key set for an hour:

```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,
            ]))
```

The service provider registers it as a singleton, and the HTTP client as a separate binding, so that the tests can replace it.

## 4. Start sign-in

Each sign-in makes a new `state`, `nonce` and PKCE verifier and keeps them in the session:

```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)),
        ];
    }
```

The authorization request sends the S256 challenge of the verifier and the identifier of your API as `resource` (RFC 8707). Without `resource`, the access token is not meant for your API and the API rejects it.

```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,
        ]);
```

Sign-in is a form that posts to `/login` with the CSRF token, so another site cannot start a sign-in for the user:

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

## 5. Handle the callback

The library leaves the `state` check to your app. The callback takes the transaction out of the session, so a `state` works once, and compares it in constant time. Your environment names itself in every authorization response (RFC 9207), so the callback also refuses a response whose `iss` is missing or names another issuer:

```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');
        }
```

Only the code is taken from the callback. The library exchanges it with the PKCE verifier and checks the ID token: its signature with your environment's keys, `iss`, `aud`, `exp`, `iat`, the algorithm and the `nonce` of this sign-in.

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

The app then starts a new session id and keeps the user and the tokens in the server-side session:

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

The session settings are in `config/session.php`: the `file` driver, an encrypted, HttpOnly, `SameSite=Lax` cookie that is `Secure` unless you set `SESSION_SECURE_COOKIE=false` for `http://localhost`. With several servers, use the `redis` or `database` driver.

## 6. Protect pages and call your API

Routes that need a user use the `keenauth` middleware. The example has no refresh token: when the access token expires, the user signs in again.

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

The dashboard calls your API with the access token as a bearer token. The token stays on the server; the browser receives only the 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. Sign out

Sign-out is a `POST` with the CSRF token. The app ends the local session and sends the browser to your environment's logout endpoint (OpenID Connect RP-Initiated Logout) with `id_token_hint`, `client_id` and `post_logout_redirect_uri`, which returns it to your app:

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

## 8. Run the app

Start the API from the Express quickstart (or any API that accepts the tokens) and run the app:

```sh
php artisan serve
```

Open `http://localhost:8000`, choose **Sign in**, sign in at KeenAuth and open the dashboard.

## 9. Test without a network

The tests need no network and no KeenAuth account. A fake PSR-18 client takes the place of the HTTP client and plays your environment (discovery, a key set made for the test run, the token endpoint) and the API. The tests run with `APP_ENV=ci`, because Laravel skips the CSRF check under `testing` and these tests check it.

```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
```

The tests check that the configuration is validated (and that no error contains a secret); that sign-in needs the CSRF token and sends PKCE S256, `state`, `nonce` and `resource`; that the code is exchanged with the matching verifier and client authentication; that the access token goes to the API in the `Authorization` header and never into a cookie; that an unknown or reused `state`, a missing or wrong `iss`, an ID token for another client or with another nonce and an authorization error are refused without a session; that a page without a session redirects to the home page; and that sign-out needs the CSRF token and goes to the logout endpoint with `id_token_hint`.

The full sign-in with a real browser needs a KeenAuth environment. It is not part of the offline tests.

## Behind a proxy or load balancer

Set `APP_BASE_URL` to the public URL of the app; the redirect URI is built from it, never from the `Host` header. If a proxy ends TLS, configure Laravel's trusted proxies for that proxy only.

## Current limits

- No refresh token and no silent sign-in: the user signs in again when the access token expires.
- The client authenticates with a client secret. The library also supports `private_key_jwt`; the example does not show it.

## Next steps

- [Protect an Express API](/docs/quickstarts/express): the API this app calls.
- [Verifying tokens](/docs/sdk/token-verification): the verifier under the middleware of your API.
