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>
253 lines
7.9 KiB
PHP
253 lines
7.9 KiB
PHP
<?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');
|
|
}
|
|
}
|
|
}
|