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,132 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Auth;
use App\Actions\Auth\SyncPireonUser;
use App\Http\Controllers\Controller;
use App\Services\Pireon\PireonClient;
use App\Services\Pireon\PireonException;
use App\Services\Pireon\TokenStore;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
/**
* Authorization Code + PKCE flow against the Pireon identity server.
*
* The browser never sees a password belonging to this application, and the
* authorization code alone is useless without the verifier we keep server-side.
*/
class PireonAuthController extends Controller
{
public function __construct(
protected PireonClient $client,
protected TokenStore $tokens,
protected SyncPireonUser $sync,
) {}
/**
* Step 1 — hand the browser off to the identity server.
*/
public function redirect(Request $request): RedirectResponse
{
if (Auth::check()) {
return redirect()->intended(filament()->getUrl());
}
try {
['url' => $url, 'state' => $state, 'code_verifier' => $verifier] = $this->client->authorizationUrl();
} catch (PireonException $e) {
return $this->fail($e, route('login'));
}
$request->session()->put('pireon.state', $state);
$request->session()->put('pireon.code_verifier', $verifier);
// Remember where the user was headed before the redirect, so the
// callback can send them back there.
if ($target = $request->query('intended')) {
$request->session()->put('url.intended', $target);
}
return redirect()->away($url);
}
/**
* Step 2 — the identity server sends the browser back with a code.
*/
public function callback(Request $request): RedirectResponse
{
// The user pressed "deny", or the server refused the request.
if ($request->filled('error')) {
return redirect()->route('login')->with('error', __('pireon.denied', [
'reason' => (string) $request->query('error_description', $request->query('error')),
]));
}
$state = $request->session()->pull('pireon.state');
$verifier = $request->session()->pull('pireon.code_verifier');
// CSRF protection for the callback: a code arriving with a state we
// did not issue is not the result of a login we started.
if (blank($state) || ! hash_equals((string) $state, (string) $request->query('state'))) {
return $this->fail(PireonException::invalidState(), route('login'));
}
if (blank($verifier) || blank($request->query('code'))) {
return $this->fail(PireonException::invalidState(), route('login'));
}
try {
$token = $this->client->exchangeCode((string) $request->query('code'), (string) $verifier);
$identity = $this->client->identity($token->accessToken);
} catch (PireonException $e) {
return $this->fail($e, route('login'));
}
if (! $identity->isUsable()) {
return redirect()->route('login')->with('error', $identity->isApproved()
? __('pireon.inactive')
: __('pireon.pending_approval'));
}
$user = $this->sync->handle($identity);
// Rotate the session id so a fixated pre-login session cannot be
// reused now that it carries an authenticated identity.
$request->session()->regenerate();
Auth::login($user, remember: true);
$this->tokens->put($token);
if ($user->locale) {
$request->session()->put(config('locales.session_key'), $user->locale);
}
return redirect()->intended(filament()->getUrl());
}
public function logout(Request $request): RedirectResponse
{
$this->tokens->forget();
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login');
}
protected function fail(PireonException $e, string $to): RedirectResponse
{
Log::warning('Pireon login failed', [
'message' => $e->getMessage(),
'status' => $e->status,
]);
return redirect()->to($to)->with('error', $e->getMessage());
}
}