Files
landing-api/app/Auth/PireonTokenGuard.php
Josep Piguillem d10ce1ca57
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
Take Pireon's webhooks: a deactivation or a revocation counts at once
Until now, an account switched off or a token revoked at the identity
server kept working here: bearer tokens until the introspection cache
ran out, panel sessions until they ended, and machine tokens for good,
as they only look at the local account.

POST /api/v1/webhooks/pireon takes user.deactivated (the account goes
inactive, which stops its machine tokens and the panel; its sessions
end and its cached tokens are forgotten) and token.revoked (sessions
and cached tokens go). Pireon signs each delivery with the secret of
the client that registered the webhook, so either PIREON_WEBHOOK_SECRET
(password client) or PIREON_REDIRECT_WEBHOOK_SECRET («Entrar con
Pireon») verifies it; with neither, the endpoint refuses everything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:01:57 +02:00

145 lines
4.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Auth;
use App\Actions\Auth\SyncPireonUser;
use App\Models\ApiToken;
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)) {
return null;
}
// Ours, not the identity server's: checked here and never sent there.
if (str_starts_with($token, ApiToken::PREFIX)) {
return $this->machine($token);
}
if (! $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 machine(string $plain): ?User
{
$token = ApiToken::findUsable($plain);
$user = $token?->user;
// A person switched off here takes their tokens with them.
if ($user === null || ! $user->is_active) {
return null;
}
$token->touchLastUsed();
return $user->withAccessToken($token);
}
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);
// Which cached tokens are whose, so a webhook can drop a person's.
$index = self::indexKey($identity->id);
Cache::put($index, array_values(array_unique([...(array) Cache::get($index, []), $key])), $ttl);
return $identity;
}
/**
* Forget every token of that person checked lately: the next request with
* any of them asks the identity server again.
*/
public static function forgetUser(int|string $oauthId): void
{
$index = self::indexKey($oauthId);
foreach ((array) Cache::get($index, []) as $key) {
Cache::forget((string) $key);
}
Cache::forget($index);
}
protected static function indexKey(int|string $oauthId): string
{
return 'pireon:introspect:user:'.$oauthId;
}
/**
* 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);
}
}