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

1. Install

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:

        $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:

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

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:

    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.

        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:

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

        $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.

        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:

        // 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.

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.

        $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:

        $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:

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.

    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

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

Next steps