HonkIO Auth

Démarrage rapide Laravel

HonkIO Auth dans Laravel avec un fournisseur Socialite : PKCE, nonce et vérification du jeton d’identité, et un middleware pour le renouvellement et la règle de panne.

Installation

Installez Socialite et firebase/php-jwt, qui vérifie le jeton d’identité avec le JWKS de l’application. Créez une application de test dont l’URI de redirection est votre route /auth/callback, et définissez HONKIO_AUTH_ISSUER, HONKIO_AUTH_CLIENT_ID et HONKIO_AUTH_CLIENT_SECRET.

bash
composer require laravel/socialite firebase/php-jwt

Le fournisseur Socialite

Un fournisseur Socialite pour HonkIO. Il active PKCE, ajoute un nonce et vérifie la signature, le iss, le aud et le nonce du jeton d’identité avant de faire confiance à la connexion.

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;
    }
}

Enregistrez-le :

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

Connexion, rappel, une page réservée et déconnexion. Le rappel ouvre une nouvelle session et garde le jeton de renouvellement et l’expiration du jeton d’accès.

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

Renouvellement et règle de panne

Le middleware renouvelle les jetons expirés selon la règle de panne : une erreur de connexion, un délai dépassé ou une erreur 5xx garde la session jusqu’à 24 heures après l’expiration du jeton d’accès; un refus de HonkIO (invalid_grant) y met fin immédiatement.

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