Initial commit: Pireon Admin — headless admin panel and public API
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>
This commit is contained in:
95
app/Auth/PireonTokenGuard.php
Normal file
95
app/Auth/PireonTokenGuard.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user