Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer collective, backed by the Pireon OAuth identity server. The public site is a separate, server-rendered application consuming /api/public/v1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
96 lines
2.9 KiB
PHP
96 lines
2.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Auth;
|
|
|
|
use App\Actions\Auth\SyncPireonUser;
|
|
use App\Models\User;
|
|
use App\Services\Pireon\PireonClient;
|
|
use App\Services\Pireon\PireonException;
|
|
use App\Services\Pireon\PireonIdentity;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Cache;
|
|
|
|
/**
|
|
* Resolves the API caller from a bearer token issued by the Pireon server.
|
|
*
|
|
* The server publishes neither a JWKS document nor an introspection endpoint,
|
|
* so a token is validated the only way available: by spending it on
|
|
* GET /api/user upstream. A 401 there means the token is expired, revoked, or
|
|
* client-credentials-only, and the request is anonymous here.
|
|
*
|
|
* That upstream call is cached briefly, keyed by a SHA-256 hash of the token so
|
|
* the token itself never lands in the cache store. The TTL is the window during
|
|
* which a token revoked upstream still works here — keep it short.
|
|
*/
|
|
class PireonTokenGuard
|
|
{
|
|
public function __construct(
|
|
protected PireonClient $client,
|
|
protected SyncPireonUser $sync,
|
|
) {}
|
|
|
|
public function __invoke(Request $request): ?User
|
|
{
|
|
$token = $request->bearerToken();
|
|
|
|
if (blank($token) || ! $this->client->isConfigured()) {
|
|
return null;
|
|
}
|
|
|
|
$identity = $this->resolveIdentity($token);
|
|
|
|
if ($identity === null || ! $identity->isUsable()) {
|
|
return null;
|
|
}
|
|
|
|
$user = $this->localUser($identity);
|
|
|
|
// Locally deactivating an account revokes API access immediately,
|
|
// without needing anything to change upstream.
|
|
return $user->is_active ? $user : null;
|
|
}
|
|
|
|
protected function resolveIdentity(string $token): ?PireonIdentity
|
|
{
|
|
$key = 'pireon:introspect:'.hash('sha256', $token);
|
|
$ttl = (int) config('pireon.introspection_ttl', 60);
|
|
|
|
$cached = Cache::get($key);
|
|
|
|
if (is_array($cached)) {
|
|
return $cached === [] ? null : PireonIdentity::fromResponse($cached);
|
|
}
|
|
|
|
try {
|
|
$identity = $this->client->identity($token);
|
|
} catch (PireonException $e) {
|
|
// Cache the rejection too, so a client hammering us with a dead
|
|
// token cannot turn into a flood of upstream requests. Never cache
|
|
// a transport failure — that would lock out valid tokens whenever
|
|
// the identity server has a blip.
|
|
if ($e->status === 401) {
|
|
Cache::put($key, [], min($ttl, 60));
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
Cache::put($key, $identity->toArray(), $ttl);
|
|
|
|
return $identity;
|
|
}
|
|
|
|
/**
|
|
* Mirror the identity locally so policies, ownership checks and foreign
|
|
* keys have a real row to work with. Local roles are left untouched.
|
|
*/
|
|
protected function localUser(PireonIdentity $identity): User
|
|
{
|
|
$user = User::query()->where('oauth_id', $identity->id)->first();
|
|
|
|
return $user ?? $this->sync->handle($identity);
|
|
}
|
|
}
|