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) with a client secret (
client_secret_basic). In adevelopmentenvironment, register the redirect URIhttp://localhost/auth/callbackand the post-logout redirect URIhttp://localhost/; a loopback URI is registered without a port and matches any port, such as8000. - An API registered in the environment (Create an API) with the
messages:readscope. Itsidentifier, for examplehttps://api.example.com, is the audience of its access tokens. The Express quickstart builds an API that accepts these tokens. - PHP 8.3 or later and Composer 2.
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
- 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: the API this app calls.
- Verifying tokens: the verifier under the middleware of your API.