HonkIO Auth
Laravel quickstart
HonkIO Auth in Laravel with a Socialite provider: PKCE, nonce and ID token checks, and a middleware for the refresh and the outage rule.
Install
Install Socialite and firebase/php-jwt, which checks the ID token against the app’s JWKS. Create a test app whose redirect URI is your /auth/callback route, and set HONKIO_AUTH_ISSUER, HONKIO_AUTH_CLIENT_ID and HONKIO_AUTH_CLIENT_SECRET.
composer require laravel/socialite firebase/php-jwtThe Socialite provider
A Socialite provider for HonkIO. It turns on PKCE, adds a nonce, and checks the ID token’s signature, iss, aud and nonce before trusting the sign-in.
<?php
// app/Auth/HonkioProvider.php
namespace App\Auth;
use Firebase\JWT\JWK;
use Firebase\JWT\JWT;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Laravel\Socialite\Two\AbstractProvider;
use Laravel\Socialite\Two\InvalidStateException;
use Laravel\Socialite\Two\User;
class HonkioProvider extends AbstractProvider
{
protected $scopes = ['openid', 'email'];
protected $scopeSeparator = ' ';
protected $usesPKCE = true; // S256, required by HonkIO
protected string $issuer;
public function setIssuer(string $issuer): static
{
$this->issuer = rtrim($issuer, '/');
return $this;
}
protected function getAuthUrl($state): string
{
return $this->buildAuthUrlFromBase($this->issuer.'/authorize', $state);
}
protected function getCodeFields($state = null): array
{
$nonce = Str::random(40);
$this->request->session()->put('honkio_nonce', $nonce);
return array_merge(parent::getCodeFields($state), ['nonce' => $nonce]);
}
protected function getTokenUrl(): string
{
return $this->issuer.'/token';
}
protected function getUserByToken($token): array
{
return Http::withToken($token)->timeout(5)->get($this->issuer.'/userinfo')->throw()->json();
}
protected function mapUserToObject(array $user): User
{
return (new User)->setRaw($user)->map(['id' => $user['sub'], 'email' => $user['email'] ?? null]);
}
/** Checks the ID token (signature, iss, aud, nonce) before trusting the sign-in. */
public function user()
{
if ($this->hasInvalidState()) {
throw new InvalidStateException;
}
$response = $this->getAccessTokenResponse($this->getCode());
$claims = $this->verifyIdToken($response['id_token'] ?? '');
$nonce = $this->request->session()->pull('honkio_nonce');
if (! $nonce || ! hash_equals($nonce, $claims->nonce ?? '')) {
throw new InvalidStateException('nonce mismatch');
}
return $this->mapUserToObject(['sub' => $claims->sub, 'email' => $claims->email ?? null])
->setToken($response['access_token'])
->setRefreshToken($response['refresh_token'] ?? null)
->setExpiresIn($response['expires_in'] ?? 600)
->setApprovedScopes(explode(' ', $response['scope'] ?? ''))
->setRaw(['id_token' => $response['id_token']]);
}
private function verifyIdToken(string $jwt): object
{
$jwks = Cache::remember('honkio_jwks', 300, fn () => $this->fetchJwks());
// Signing keys rotate: a token naming a key that isn't cached refetches the keys, at
// most once a minute across workers, so made-up key ids can't force a fetch per request.
if (! in_array($this->kidOf($jwt), array_column($jwks['keys'] ?? [], 'kid'), true)
&& Cache::add('honkio_jwks_refetched', true, 60)) {
$jwks = $this->fetchJwks();
Cache::put('honkio_jwks', $jwks, 300);
}
$claims = JWT::decode($jwt, JWK::parseKeySet($jwks, 'ES256'));
if (($claims->iss ?? null) !== $this->issuer || ($claims->aud ?? null) !== $this->clientId) {
throw new InvalidStateException('wrong issuer or audience');
}
return $claims;
}
private function fetchJwks(): array
{
return Http::timeout(5)->get($this->issuer.'/.well-known/jwks.json')->throw()->json();
}
private function kidOf(string $jwt): ?string
{
$header = json_decode(JWT::urlsafeB64Decode(explode('.', $jwt)[0]), true);
return is_array($header) && is_string($header['kid'] ?? null) ? $header['kid'] : null;
}
}Register it:
// config/services.php
'honkio' => [
'issuer' => env('HONKIO_AUTH_ISSUER'), // https://login-dev.acme.ca
'client_id' => env('HONKIO_AUTH_CLIENT_ID'),
'client_secret' => env('HONKIO_AUTH_CLIENT_SECRET'),
'redirect' => env('APP_URL').'/auth/callback',
],
// app/Providers/AppServiceProvider.php, in boot():
use App\Auth\HonkioProvider;
use Laravel\Socialite\Facades\Socialite;
Socialite::extend('honkio', function ($app) {
$config = $app['config']['services.honkio'];
return Socialite::buildProvider(HonkioProvider::class, $config)->setIssuer($config['issuer']);
});Routes
Sign-in, the callback, a signed-in page and sign-out. The callback starts a fresh session and keeps the refresh token and the access token’s expiry.
<?php
// routes/web.php
use App\Http\Middleware\EnsureHonkioSession;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Laravel\Socialite\Facades\Socialite;
Route::get('/auth/login', fn () => Socialite::driver('honkio')->redirect())->name('login');
Route::get('/auth/callback', function (Request $request) {
$user = Socialite::driver('honkio')->user();
$request->session()->regenerate();
$request->session()->put('honkio', [
'sub' => $user->getId(),
'email' => $user->getEmail(),
'id_token' => $user->getRaw()['id_token'],
'refresh_token' => $user->refreshToken,
'access_expires_at' => now()->addSeconds($user->expiresIn)->timestamp,
]);
return redirect('/');
});
Route::get('/', fn (Request $request) => 'Signed in as '.$request->session()->get('honkio.email'))
->middleware(EnsureHonkioSession::class);
Route::post('/auth/logout', function (Request $request) {
$idToken = $request->session()->get('honkio.id_token');
$request->session()->invalidate();
return redirect(config('services.honkio.issuer').'/logout?'.http_build_query([
'post_logout_redirect_uri' => url('/'),
'id_token_hint' => $idToken,
]));
});Refresh and the outage rule
The middleware refreshes expired tokens with the outage rule: a connection error, a timeout or a 5xx keeps the session until 24 hours past the access token’s expiry; a refusal from HonkIO (invalid_grant) ends it at once.
<?php
// app/Http/Middleware/EnsureHonkioSession.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
class EnsureHonkioSession
{
private const OUTAGE_GRACE_SECONDS = 24 * 60 * 60;
public function handle(Request $request, Closure $next)
{
$s = $request->session()->get('honkio');
if (! $s) {
return redirect()->route('login');
}
$now = time();
if ($now < $s['access_expires_at'] - 30) {
return $next($request);
}
$withinGrace = $now < $s['access_expires_at'] + self::OUTAGE_GRACE_SECONDS;
if ($withinGrace && $now < ($s['retry_refresh_at'] ?? 0)) {
return $next($request);
}
$config = config('services.honkio');
try {
$response = Http::asForm()->timeout(5)
->withBasicAuth($config['client_id'], $config['client_secret'])
->post($config['issuer'].'/token', ['grant_type' => 'refresh_token', 'refresh_token' => $s['refresh_token']]);
} catch (ConnectionException) {
$response = null; // network error or timeout: HonkIO can't be reached
}
if ($response === null || $response->serverError()) {
if ($withinGrace) {
$s['retry_refresh_at'] = $now + 60; // keep the session, retry in a minute
$request->session()->put('honkio', $s);
return $next($request);
}
$request->session()->invalidate(); // the outage outlasted 24 hours past expiry
return redirect()->route('login');
}
if (! $response->successful()) {
// invalid_grant: revoked, blocked, deleted or removed from the organization.
$request->session()->invalidate();
return redirect()->route('login');
}
$token = $response->json();
$s['refresh_token'] = $token['refresh_token'] ?? $s['refresh_token']; // rotates on every use
$s['access_expires_at'] = $now + ($token['expires_in'] ?? 600);
unset($s['retry_refresh_at']);
$request->session()->put('honkio', $s);
return $next($request);
}
}
HonkIO