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:
86
app/Services/Pireon/AccessToken.php
Normal file
86
app/Services/Pireon/AccessToken.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Pireon;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Contracts\Support\Arrayable;
|
||||
|
||||
/**
|
||||
* A token set returned by the identity server's /oauth/token endpoint.
|
||||
*
|
||||
* @implements Arrayable<string, mixed>
|
||||
*/
|
||||
final readonly class AccessToken implements Arrayable
|
||||
{
|
||||
public function __construct(
|
||||
public string $accessToken,
|
||||
public ?string $refreshToken,
|
||||
public ?CarbonImmutable $expiresAt,
|
||||
public string $tokenType = 'Bearer',
|
||||
/** @var array<int, string> */
|
||||
public array $scopes = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public static function fromResponse(array $payload): self
|
||||
{
|
||||
$expiresIn = isset($payload['expires_in']) ? (int) $payload['expires_in'] : null;
|
||||
|
||||
return new self(
|
||||
accessToken: (string) $payload['access_token'],
|
||||
refreshToken: isset($payload['refresh_token']) ? (string) $payload['refresh_token'] : null,
|
||||
// Shave a minute off so we refresh slightly before the server
|
||||
// would start rejecting the token mid-request.
|
||||
expiresAt: $expiresIn !== null ? CarbonImmutable::now()->addSeconds($expiresIn)->subMinute() : null,
|
||||
tokenType: (string) ($payload['token_type'] ?? 'Bearer'),
|
||||
scopes: array_values(array_filter(explode(' ', (string) ($payload['scope'] ?? '')))),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $state
|
||||
*/
|
||||
public static function fromArray(array $state): self
|
||||
{
|
||||
return new self(
|
||||
accessToken: (string) $state['access_token'],
|
||||
refreshToken: $state['refresh_token'] ?? null,
|
||||
expiresAt: isset($state['expires_at']) ? CarbonImmutable::parse($state['expires_at']) : null,
|
||||
tokenType: (string) ($state['token_type'] ?? 'Bearer'),
|
||||
scopes: $state['scopes'] ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
public function hasExpired(): bool
|
||||
{
|
||||
return $this->expiresAt !== null && $this->expiresAt->isPast();
|
||||
}
|
||||
|
||||
public function isRefreshable(): bool
|
||||
{
|
||||
return $this->refreshToken !== null;
|
||||
}
|
||||
|
||||
public function hasScope(string $scope): bool
|
||||
{
|
||||
return in_array($scope, $this->scopes, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'access_token' => $this->accessToken,
|
||||
'refresh_token' => $this->refreshToken,
|
||||
'expires_at' => $this->expiresAt?->toIso8601String(),
|
||||
'token_type' => $this->tokenType,
|
||||
'scopes' => $this->scopes,
|
||||
];
|
||||
}
|
||||
}
|
||||
252
app/Services/Pireon/PireonClient.php
Normal file
252
app/Services/Pireon/PireonClient.php
Normal file
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Pireon;
|
||||
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* HTTP client for the Pireon OAuth2 identity server (Laravel Passport).
|
||||
*
|
||||
* Covers the three grants the server advertises in its OpenAPI document:
|
||||
* authorization code + PKCE (used by the panel), password (used by tests and
|
||||
* first-party clients), and client credentials (machine-to-machine).
|
||||
*/
|
||||
class PireonClient
|
||||
{
|
||||
public function __construct(
|
||||
protected string $server,
|
||||
protected ?string $clientId,
|
||||
protected ?string $clientSecret,
|
||||
protected string $redirectUri,
|
||||
) {}
|
||||
|
||||
public static function fromConfig(): self
|
||||
{
|
||||
return new self(
|
||||
server: (string) config('pireon.server'),
|
||||
clientId: config('pireon.client_id'),
|
||||
clientSecret: config('pireon.client_secret'),
|
||||
redirectUri: (string) config('pireon.redirect_uri'),
|
||||
);
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return filled($this->clientId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the URL the browser is sent to, together with the one-time secrets
|
||||
* that must be stashed in the session to complete the flow.
|
||||
*
|
||||
* The verifier is what makes this PKCE: it never leaves our server, and
|
||||
* only its SHA-256 hash travels through the browser. An attacker who
|
||||
* intercepts the returned code cannot redeem it without the verifier.
|
||||
*
|
||||
* @param array<int, string>|null $scopes
|
||||
* @return array{url: string, state: string, code_verifier: string}
|
||||
*/
|
||||
public function authorizationUrl(?array $scopes = null): array
|
||||
{
|
||||
$this->assertConfigured();
|
||||
|
||||
$state = Str::random(40);
|
||||
$verifier = Str::random(128);
|
||||
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
|
||||
|
||||
$query = http_build_query([
|
||||
'client_id' => $this->clientId,
|
||||
'redirect_uri' => $this->redirectUri,
|
||||
'response_type' => 'code',
|
||||
'scope' => implode(' ', $scopes ?? config('pireon.scopes', [])),
|
||||
'state' => $state,
|
||||
'code_challenge' => $challenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
]);
|
||||
|
||||
return [
|
||||
'url' => $this->server.config('pireon.endpoints.authorize').'?'.$query,
|
||||
'state' => $state,
|
||||
'code_verifier' => $verifier,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem an authorization code for tokens.
|
||||
*/
|
||||
public function exchangeCode(string $code, string $codeVerifier): AccessToken
|
||||
{
|
||||
return $this->token([
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $code,
|
||||
'redirect_uri' => $this->redirectUri,
|
||||
'code_verifier' => $codeVerifier,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource owner password grant. Kept for automated tests and first-party
|
||||
* clients that cannot open a browser; gated by `pireon.password_grant_enabled`.
|
||||
*
|
||||
* @param array<int, string>|null $scopes
|
||||
*/
|
||||
public function requestPasswordToken(string $username, string $password, ?array $scopes = null): AccessToken
|
||||
{
|
||||
if (! config('pireon.password_grant_enabled')) {
|
||||
throw new PireonException('The password grant is disabled for this application.', status: 400);
|
||||
}
|
||||
|
||||
return $this->token([
|
||||
'grant_type' => 'password',
|
||||
'username' => $username,
|
||||
'password' => $password,
|
||||
'scope' => implode(' ', $scopes ?? config('pireon.scopes', [])),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Machine-to-machine token representing the application itself rather than
|
||||
* any user. Cached until shortly before it expires.
|
||||
*
|
||||
* @param array<int, string>|null $scopes
|
||||
*/
|
||||
public function clientCredentialsToken(?array $scopes = null): AccessToken
|
||||
{
|
||||
$scopes ??= config('pireon.scopes', []);
|
||||
$key = 'pireon:cc:'.md5($this->clientId.'|'.implode(' ', $scopes));
|
||||
|
||||
$cached = Cache::get($key);
|
||||
|
||||
if (is_array($cached)) {
|
||||
$token = AccessToken::fromArray($cached);
|
||||
|
||||
if (! $token->hasExpired()) {
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
|
||||
$token = $this->token([
|
||||
'grant_type' => 'client_credentials',
|
||||
'scope' => implode(' ', $scopes),
|
||||
]);
|
||||
|
||||
Cache::put($key, $token->toArray(), $token->expiresAt ?? now()->addMinutes(5));
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function refresh(string $refreshToken): AccessToken
|
||||
{
|
||||
return $this->token([
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $refreshToken,
|
||||
'scope' => '',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Who does this access token belong to? Doubles as token validation: the
|
||||
* server answers 401 for a token that is expired, revoked, or not tied to
|
||||
* a user (as with client_credentials).
|
||||
*/
|
||||
public function identity(string $accessToken): PireonIdentity
|
||||
{
|
||||
$response = $this->request()
|
||||
->withToken($accessToken)
|
||||
->get($this->apiUrl(config('pireon.endpoints.user')));
|
||||
|
||||
if ($response->status() === 401) {
|
||||
throw new PireonException('The access token is not valid for any user.', status: 401);
|
||||
}
|
||||
|
||||
$this->assertSuccessful($response);
|
||||
|
||||
return PireonIdentity::fromResponse($response->json());
|
||||
}
|
||||
|
||||
/**
|
||||
* Call any endpoint of the identity server's REST API on behalf of a token.
|
||||
* Used by the optional remote-user screens.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function call(string $accessToken, string $method, string $path, array $data = []): Response
|
||||
{
|
||||
$response = $this->request()
|
||||
->withToken($accessToken)
|
||||
->send(strtoupper($method), $this->apiUrl($path), [
|
||||
strtoupper($method) === 'GET' ? 'query' : 'json' => $data,
|
||||
]);
|
||||
|
||||
$this->assertSuccessful($response);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
protected function token(array $payload): AccessToken
|
||||
{
|
||||
$this->assertConfigured();
|
||||
|
||||
$payload['client_id'] = $this->clientId;
|
||||
|
||||
// Public clients (PKCE from a browser) have no secret. Confidential
|
||||
// clients must authenticate, so only send it when one is configured.
|
||||
if (filled($this->clientSecret)) {
|
||||
$payload['client_secret'] = $this->clientSecret;
|
||||
}
|
||||
|
||||
$url = $this->server.config('pireon.endpoints.token');
|
||||
|
||||
try {
|
||||
$response = $this->request()->asForm()->post($url, $payload);
|
||||
} catch (ConnectionException $e) {
|
||||
throw PireonException::unreachable($url, $e);
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
throw PireonException::tokenRequestFailed($response->status(), (array) $response->json());
|
||||
}
|
||||
|
||||
return AccessToken::fromResponse((array) $response->json());
|
||||
}
|
||||
|
||||
protected function request(): PendingRequest
|
||||
{
|
||||
return Http::acceptJson()
|
||||
->timeout((int) config('pireon.http_timeout', 10))
|
||||
->retry(2, 200, throw: false);
|
||||
}
|
||||
|
||||
protected function apiUrl(string $path): string
|
||||
{
|
||||
return $this->server.config('pireon.api_prefix').'/'.ltrim($path, '/');
|
||||
}
|
||||
|
||||
protected function assertSuccessful(Response $response): void
|
||||
{
|
||||
if ($response->failed()) {
|
||||
throw new PireonException(
|
||||
"The identity server answered {$response->status()} for this request.",
|
||||
status: $response->status(),
|
||||
context: (array) $response->json(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected function assertConfigured(): void
|
||||
{
|
||||
if (blank($this->clientId)) {
|
||||
throw PireonException::misconfigured('PIREON_CLIENT_ID');
|
||||
}
|
||||
}
|
||||
}
|
||||
80
app/Services/Pireon/PireonException.php
Normal file
80
app/Services/Pireon/PireonException.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Pireon;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Raised when the identity server cannot be reached, rejects our credentials,
|
||||
* or answers with something we cannot interpret.
|
||||
*/
|
||||
class PireonException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
public readonly ?int $status = null,
|
||||
/** @var array<string, mixed> */
|
||||
public readonly array $context = [],
|
||||
?Throwable $previous = null,
|
||||
) {
|
||||
parent::__construct($message, $status ?? 0, $previous);
|
||||
}
|
||||
|
||||
public static function misconfigured(string $key): self
|
||||
{
|
||||
return new self(
|
||||
"The Pireon OAuth client is not configured: [{$key}] is missing. ".
|
||||
'Register this app on the identity server and fill in the PIREON_* keys in .env.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $body
|
||||
*/
|
||||
public static function tokenRequestFailed(int $status, array $body): self
|
||||
{
|
||||
$error = $body['error'] ?? 'unknown_error';
|
||||
$description = $body['error_description'] ?? $body['message'] ?? 'no description given';
|
||||
|
||||
return new self(
|
||||
"The identity server rejected the token request [{$error}]: {$description}",
|
||||
status: $status,
|
||||
context: $body,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the identity server actually judged the credentials, as opposed
|
||||
* to never getting far enough to look at them.
|
||||
*
|
||||
* The difference matters to whoever is staring at the screen: "those
|
||||
* credentials are wrong" sends them to check their password, and if the
|
||||
* truth is that the server answered 500 — or did not answer at all — that
|
||||
* is an hour of their life spent on the wrong problem.
|
||||
*/
|
||||
public function isRejection(): bool
|
||||
{
|
||||
return in_array($this->status, [400, 401, 403], true);
|
||||
}
|
||||
|
||||
public static function unreachable(string $url, ?Throwable $previous = null): self
|
||||
{
|
||||
return new self(
|
||||
"The identity server at [{$url}] could not be reached.",
|
||||
context: ['url' => $url],
|
||||
previous: $previous,
|
||||
);
|
||||
}
|
||||
|
||||
public static function invalidState(): self
|
||||
{
|
||||
return new self(
|
||||
'The OAuth callback state did not match the one we issued. '.
|
||||
'The login attempt was discarded; please start again.',
|
||||
status: 400,
|
||||
);
|
||||
}
|
||||
}
|
||||
93
app/Services/Pireon/PireonIdentity.php
Normal file
93
app/Services/Pireon/PireonIdentity.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Pireon;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Contracts\Support\Arrayable;
|
||||
|
||||
/**
|
||||
* The identity server's view of a person, as returned by GET /api/user.
|
||||
*
|
||||
* `roles` and `appRoles` are carried for display and support purposes only.
|
||||
* They deliberately grant nothing: this application resolves authorisation
|
||||
* from its own spatie roles (see App\Actions\Auth\SyncPireonUser).
|
||||
*
|
||||
* @implements Arrayable<string, mixed>
|
||||
*/
|
||||
final readonly class PireonIdentity implements Arrayable
|
||||
{
|
||||
public function __construct(
|
||||
public int $id,
|
||||
public string $name,
|
||||
public string $email,
|
||||
public bool $isActive,
|
||||
/** @var array<int, string> */
|
||||
public array $roles = [],
|
||||
/** @var array<int, string> */
|
||||
public array $appRoles = [],
|
||||
public ?CarbonImmutable $approvedAt = null,
|
||||
public ?CarbonImmutable $createdAt = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public static function fromResponse(array $payload): self
|
||||
{
|
||||
// The server wraps some responses in a "data" envelope and returns
|
||||
// others bare; accept either shape.
|
||||
$payload = $payload['data'] ?? $payload;
|
||||
|
||||
return new self(
|
||||
id: (int) $payload['id'],
|
||||
name: (string) ($payload['name'] ?? ''),
|
||||
email: (string) ($payload['email'] ?? ''),
|
||||
isActive: (bool) ($payload['is_active'] ?? true),
|
||||
roles: array_values(array_map('strval', $payload['roles'] ?? [])),
|
||||
appRoles: array_values(array_map('strval', $payload['app_roles'] ?? [])),
|
||||
approvedAt: isset($payload['approved_at']) ? CarbonImmutable::parse($payload['approved_at']) : null,
|
||||
createdAt: isset($payload['created_at']) ? CarbonImmutable::parse($payload['created_at']) : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-registered accounts stay unapproved until an administrator on the
|
||||
* identity server approves them.
|
||||
*/
|
||||
public function isApproved(): bool
|
||||
{
|
||||
return $this->approvedAt !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this identity is allowed to open a session here at all, per the
|
||||
* `require_active_account` / `require_approved_account` settings.
|
||||
*/
|
||||
public function isUsable(): bool
|
||||
{
|
||||
if (config('pireon.require_active_account') && ! $this->isActive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! (config('pireon.require_approved_account') && ! $this->isApproved());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'email' => $this->email,
|
||||
'is_active' => $this->isActive,
|
||||
'roles' => $this->roles,
|
||||
'app_roles' => $this->appRoles,
|
||||
'approved_at' => $this->approvedAt?->toIso8601String(),
|
||||
'created_at' => $this->createdAt?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
81
app/Services/Pireon/TokenStore.php
Normal file
81
app/Services/Pireon/TokenStore.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Pireon;
|
||||
|
||||
use Illuminate\Contracts\Session\Session;
|
||||
|
||||
/**
|
||||
* Keeps the signed-in panel user's token set in the session and transparently
|
||||
* refreshes it when it ages out.
|
||||
*
|
||||
* Tokens live in the session rather than the database on purpose: they are
|
||||
* per-session credentials, and logging out or the session expiring should take
|
||||
* them with it.
|
||||
*/
|
||||
class TokenStore
|
||||
{
|
||||
protected const KEY = 'pireon.token';
|
||||
|
||||
public function __construct(
|
||||
protected Session $session,
|
||||
protected PireonClient $client,
|
||||
) {}
|
||||
|
||||
public function put(AccessToken $token): void
|
||||
{
|
||||
$this->session->put(self::KEY, $token->toArray());
|
||||
}
|
||||
|
||||
public function get(): ?AccessToken
|
||||
{
|
||||
$state = $this->session->get(self::KEY);
|
||||
|
||||
return is_array($state) ? AccessToken::fromArray($state) : null;
|
||||
}
|
||||
|
||||
public function forget(): void
|
||||
{
|
||||
$this->session->forget(self::KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* The current access token, refreshed first if it has expired.
|
||||
*
|
||||
* Returns null when there is no token, or when the refresh failed — the
|
||||
* caller should treat that as "this session is no longer authenticated"
|
||||
* and send the user back through the login flow.
|
||||
*/
|
||||
public function accessToken(): ?string
|
||||
{
|
||||
$token = $this->get();
|
||||
|
||||
if ($token === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $token->hasExpired()) {
|
||||
return $token->accessToken;
|
||||
}
|
||||
|
||||
if (! $token->isRefreshable()) {
|
||||
$this->forget();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$refreshed = $this->client->refresh($token->refreshToken);
|
||||
} catch (PireonException) {
|
||||
// The refresh token was revoked or expired upstream.
|
||||
$this->forget();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->put($refreshed);
|
||||
|
||||
return $refreshed->accessToken;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user