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:
2026-08-19 17:55:27 +02:00
commit 3897113313
534 changed files with 64050 additions and 0 deletions

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