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.

bash
composer require laravel/socialite firebase/php-jwt

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

app/Auth/HonkioProvider.php
<?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
// 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.

routes/web.php
<?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.

app/Http/Middleware/EnsureHonkioSession.php
<?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);
    }
}