Offer «Entrar con Pireon» on the panel's login page
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

Under the e-mail and password form, a button takes the redirect flow
(Authorization Code + PKCE), so the password is only ever typed at the
identity server. The routes for it were already there; the page had no
way to reach them.

A Pireon client allows one grant type and the panel's is a password
client, so the redirect flow can use its own: PIREON_REDIRECT_CLIENT_ID
and PIREON_REDIRECT_CLIENT_SECRET (the main client when they are empty).
The button shows up once that client is set, or with
PIREON_REDIRECT_LOGIN=true. Each session renews its token with the
client that issued it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 12:46:07 +02:00
parent 3c119d425e
commit ec4f687ca8
12 changed files with 169 additions and 14 deletions

View File

@@ -76,6 +76,15 @@ PIREON_CLIENT_SECRET=
PIREON_REDIRECT_URI="${APP_URL}/auth/callback" PIREON_REDIRECT_URI="${APP_URL}/auth/callback"
PIREON_SCOPES=account:read PIREON_SCOPES=account:read
# «Entrar con Pireon» (Authorization Code + PKCE) under the login form. A Pireon
# client allows one grant type: the one above is the password client, so
# register another for authorization_code with the redirect URI above and put
# it here; the button shows up once it is set. PIREON_REDIRECT_LOGIN=true shows
# it with the client above instead (when that one allows authorization_code).
PIREON_REDIRECT_CLIENT_ID=
PIREON_REDIRECT_CLIENT_SECRET=
# PIREON_REDIRECT_LOGIN=
# Grants. The panel's sign-in form uses the password grant, so turning this off # Grants. The panel's sign-in form uses the password grant, so turning this off
# locks everyone out of the panel as well as out of POST /api/v1/auth/token. # locks everyone out of the panel as well as out of POST /api/v1/auth/token.
PIREON_PASSWORD_GRANT=true PIREON_PASSWORD_GRANT=true

View File

@@ -70,9 +70,20 @@ al remoto por si alguien comitea con `--no-verify`.
En `https://oauth.pireon.dev` crea un cliente OAuth con: En `https://oauth.pireon.dev` crea un cliente OAuth con:
- **Grant types**: `password` — es el que usa el formulario de acceso del panel - **Grant types**: `password` — es el que usa el formulario de acceso del panel
Para ofrecer también **«Entrar con Pireon»** (redirección con Authorization
Code + PKCE, donde la contraseña solo se escribe en el servidor de identidad),
crea un segundo cliente, porque cada cliente admite un único grant:
- **Grant types**: `authorization_code`
- **Redirect URI**: `https://tu-dominio/auth/callback` (en local, - **Redirect URI**: `https://tu-dominio/auth/callback` (en local,
`http://localhost:8000/auth/callback`), solo si quieres conservar el flujo de `http://localhost:8000/auth/callback`)
redirección, que sigue disponible aunque el panel ya no lo use
y pon sus credenciales en `PIREON_REDIRECT_CLIENT_ID` y
`PIREON_REDIRECT_CLIENT_SECRET`. El botón aparece bajo el formulario en cuanto
están; `PIREON_REDIRECT_LOGIN` lo fuerza (`true`, si el cliente principal
admite los dos grants) o lo quita (`false`). Cada sesión renueva su token con el
cliente que lo emitió.
Copia las credenciales al `.env`: Copia las credenciales al `.env`:

View File

@@ -10,6 +10,7 @@ use App\Services\Pireon\PireonClient;
use App\Services\Pireon\PireonException; use App\Services\Pireon\PireonException;
use App\Services\Pireon\TokenStore; use App\Services\Pireon\TokenStore;
use DanHarrin\LivewireRateLimiting\Exceptions\TooManyRequestsException; use DanHarrin\LivewireRateLimiting\Exceptions\TooManyRequestsException;
use Filament\Actions\Action;
use Filament\Auth\Http\Responses\Contracts\LoginResponse; use Filament\Auth\Http\Responses\Contracts\LoginResponse;
use Filament\Auth\Pages\Login; use Filament\Auth\Pages\Login;
use Filament\Facades\Filament; use Filament\Facades\Filament;
@@ -27,6 +28,9 @@ use SensitiveParameter;
* usable password column — but the person signing in never sees that. They * usable password column — but the person signing in never sees that. They
* type their credentials here and land in the panel. * type their credentials here and land in the panel.
* *
* «Entrar con Pireon», under the form, takes the redirect flow instead: the
* password is then typed at the identity server only.
*
* The cost of that convenience is stated plainly because it is real: this * The cost of that convenience is stated plainly because it is real: this
* application now handles the password rather than never seeing it. It is sent * application now handles the password rather than never seeing it. It is sent
* straight to the identity server over the password grant and is never stored, * straight to the identity server over the password grant and is never stored,
@@ -53,6 +57,25 @@ class PireonLogin extends Login
return __('pireon.sign_in_intro'); return __('pireon.sign_in_intro');
} }
/**
* Under «Entrar», «Entrar con Pireon»: the redirect flow, for whoever would
* rather not type their password here (or signs in some other way there).
*/
protected function getFormActions(): array
{
$actions = parent::getFormActions();
if (config('pireon.redirect_login') && PireonClient::forRedirect()->isConfigured()) {
$actions[] = Action::make('pireon')
->label(__('pireon.continue_with_pireon'))
->url(route('auth.redirect'))
->color('gray')
->outlined();
}
return $actions;
}
/** /**
* Filament's own flow checks the credentials against the local user table. * Filament's own flow checks the credentials against the local user table.
* There are no local passwords here, so the whole of it is replaced: the * There are no local passwords here, so the whole of it is replaced: the
@@ -110,7 +133,7 @@ class PireonLogin extends Login
Auth::login($user, (bool) ($data['remember'] ?? false)); Auth::login($user, (bool) ($data['remember'] ?? false));
app(TokenStore::class)->put($token); app(TokenStore::class)->put($token, viaRedirect: false);
if ($user->locale) { if ($user->locale) {
session()->put(config('locales.session_key'), $user->locale); session()->put(config('locales.session_key'), $user->locale);

View File

@@ -19,6 +19,8 @@ use Illuminate\Support\Facades\Log;
* *
* The browser never sees a password belonging to this application, and the * The browser never sees a password belonging to this application, and the
* authorization code alone is useless without the verifier we keep server-side. * authorization code alone is useless without the verifier we keep server-side.
* It is «Entrar con Pireon» on the login page; it uses the redirect client
* (`PireonClient::forRedirect()`), its own one when configured.
*/ */
class PireonAuthController extends Controller class PireonAuthController extends Controller
{ {
@@ -38,7 +40,7 @@ class PireonAuthController extends Controller
} }
try { try {
['url' => $url, 'state' => $state, 'code_verifier' => $verifier] = $this->client->authorizationUrl(); ['url' => $url, 'state' => $state, 'code_verifier' => $verifier] = PireonClient::forRedirect()->authorizationUrl();
} catch (PireonException $e) { } catch (PireonException $e) {
return $this->fail($e, route('login')); return $this->fail($e, route('login'));
} }
@@ -81,7 +83,7 @@ class PireonAuthController extends Controller
} }
try { try {
$token = $this->client->exchangeCode((string) $request->query('code'), (string) $verifier); $token = PireonClient::forRedirect()->exchangeCode((string) $request->query('code'), (string) $verifier);
$identity = $this->client->identity($token->accessToken); $identity = $this->client->identity($token->accessToken);
} catch (PireonException $e) { } catch (PireonException $e) {
return $this->fail($e, route('login')); return $this->fail($e, route('login'));
@@ -100,7 +102,7 @@ class PireonAuthController extends Controller
$request->session()->regenerate(); $request->session()->regenerate();
Auth::login($user, remember: true); Auth::login($user, remember: true);
$this->tokens->put($token); $this->tokens->put($token, viaRedirect: true);
if ($user->locale) { if ($user->locale) {
$request->session()->put(config('locales.session_key'), $user->locale); $request->session()->put(config('locales.session_key'), $user->locale);

View File

@@ -37,6 +37,22 @@ class PireonClient
); );
} }
/**
* The client for the redirect flow: its own when PIREON_REDIRECT_CLIENT_ID
* is set (a Pireon client allows one grant type), the main one otherwise.
*/
public static function forRedirect(): self
{
$own = filled(config('pireon.redirect_client_id'));
return new self(
server: (string) config('pireon.server'),
clientId: $own ? config('pireon.redirect_client_id') : config('pireon.client_id'),
clientSecret: $own ? config('pireon.redirect_client_secret') : config('pireon.client_secret'),
redirectUri: (string) config('pireon.redirect_uri'),
);
}
public function isConfigured(): bool public function isConfigured(): bool
{ {
return filled($this->clientId); return filled($this->clientId);

View File

@@ -18,14 +18,25 @@ class TokenStore
{ {
protected const KEY = 'pireon.token'; protected const KEY = 'pireon.token';
/** Which client issued it: a refresh token only works with that one. */
protected const ISSUER = 'pireon.token_issuer';
public function __construct( public function __construct(
protected Session $session, protected Session $session,
protected PireonClient $client, protected PireonClient $client,
) {} ) {}
public function put(AccessToken $token): void /**
* @param bool|null $viaRedirect Whether the redirect flow's client
* issued it; null keeps what was said.
*/
public function put(AccessToken $token, ?bool $viaRedirect = null): void
{ {
$this->session->put(self::KEY, $token->toArray()); $this->session->put(self::KEY, $token->toArray());
if ($viaRedirect !== null) {
$this->session->put(self::ISSUER, $viaRedirect ? 'redirect' : 'password');
}
} }
public function get(): ?AccessToken public function get(): ?AccessToken
@@ -37,7 +48,7 @@ class TokenStore
public function forget(): void public function forget(): void
{ {
$this->session->forget(self::KEY); $this->session->forget([self::KEY, self::ISSUER]);
} }
/** /**
@@ -66,7 +77,10 @@ class TokenStore
} }
try { try {
$refreshed = $this->client->refresh($token->refreshToken); $issuer = $this->session->get(self::ISSUER) === 'redirect'
? PireonClient::forRedirect()
: $this->client;
$refreshed = $issuer->refresh($token->refreshToken);
} catch (PireonException) { } catch (PireonException) {
// The refresh token was revoked or expired upstream. // The refresh token was revoked or expired upstream.
$this->forget(); $this->forget();

View File

@@ -33,6 +33,24 @@ return [
*/ */
'redirect_uri' => env('PIREON_REDIRECT_URI', env('APP_URL', 'http://localhost').'/auth/callback'), 'redirect_uri' => env('PIREON_REDIRECT_URI', env('APP_URL', 'http://localhost').'/auth/callback'),
/*
| The redirect flow («Entrar con Pireon», Authorization Code + PKCE) can
| use a client of its own. A Pireon client allows one grant type, so when
| the one above is a password client, register another for
| authorization_code (redirect URI as above) and put it here. Left empty,
| the client above is used for both.
*/
'redirect_client_id' => env('PIREON_REDIRECT_CLIENT_ID'),
'redirect_client_secret' => env('PIREON_REDIRECT_CLIENT_SECRET'),
/*
| Offer «Entrar con Pireon» under the e-mail and password form. By default
| only once a redirect client is set: the main one is usually a password
| client, and the button would fail with it. Set it to true when the main
| client allows authorization_code too.
*/
'redirect_login' => (bool) (env('PIREON_REDIRECT_LOGIN') ?? filled(env('PIREON_REDIRECT_CLIENT_ID'))),
/* /*
| Scopes requested for panel sessions. We only ever need to read the | Scopes requested for panel sessions. We only ever need to read the
| identity of the person logging in, so we stay minimal by default. | identity of the person logging in, so we stay minimal by default.

View File

@@ -4,7 +4,8 @@ declare(strict_types=1);
return [ return [
'sign_in' => 'Entra', 'sign_in' => 'Entra',
'sign_in_intro' => 'Accedeix amb el teu correu i la teva contrasenya.', 'sign_in_intro' => 'Accedeix amb el teu correu i la teva contrasenya, o amb el teu compte de Pireon.',
'continue_with_pireon' => 'Entra amb Pireon',
'signing_in' => 'Comprovant les teves credencials…', 'signing_in' => 'Comprovant les teves credencials…',
'password_grant_disabled' => 'L\'accés amb contrasenya està desactivat en aquesta instal·lació. Avisa un administrador.', 'password_grant_disabled' => 'L\'accés amb contrasenya està desactivat en aquesta instal·lació. Avisa un administrador.',
'server_unavailable' => 'El servidor d\'identitat no respon. No és cosa de les teves credencials; avisa un administrador.', 'server_unavailable' => 'El servidor d\'identitat no respon. No és cosa de les teves credencials; avisa un administrador.',

View File

@@ -4,7 +4,8 @@ declare(strict_types=1);
return [ return [
'sign_in' => 'Sign in', 'sign_in' => 'Sign in',
'sign_in_intro' => 'Sign in with your e-mail and password.', 'sign_in_intro' => 'Sign in with your e-mail and password, or with your Pireon account.',
'continue_with_pireon' => 'Sign in with Pireon',
'signing_in' => 'Checking your credentials…', 'signing_in' => 'Checking your credentials…',
'password_grant_disabled' => 'Password sign-in is switched off on this installation. Please tell an administrator.', 'password_grant_disabled' => 'Password sign-in is switched off on this installation. Please tell an administrator.',
'server_unavailable' => 'The identity server is not responding. This is not about your credentials; please tell an administrator.', 'server_unavailable' => 'The identity server is not responding. This is not about your credentials; please tell an administrator.',

View File

@@ -4,7 +4,8 @@ declare(strict_types=1);
return [ return [
'sign_in' => 'Entrar', 'sign_in' => 'Entrar',
'sign_in_intro' => 'Accede con tu email y tu contraseña.', 'sign_in_intro' => 'Accede con tu email y tu contraseña, o con tu cuenta de Pireon.',
'continue_with_pireon' => 'Entrar con Pireon',
'signing_in' => 'Comprobando tus credenciales…', 'signing_in' => 'Comprobando tus credenciales…',
'password_grant_disabled' => 'El acceso con contraseña está desactivado en esta instalación. Avisa a un administrador.', 'password_grant_disabled' => 'El acceso con contraseña está desactivado en esta instalación. Avisa a un administrador.',
'server_unavailable' => 'El servidor de identidad no está respondiendo. No es cosa de tus credenciales; avisa a un administrador.', 'server_unavailable' => 'El servidor de identidad no está respondiendo. No es cosa de tus credenciales; avisa a un administrador.',

View File

@@ -14,8 +14,9 @@ Route::redirect('/', '/admin');
| Authentication — Pireon OAuth2 (Authorization Code + PKCE) | Authentication — Pireon OAuth2 (Authorization Code + PKCE)
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| `login` is the page Filament bounces guests to; it renders a single button | `login` is the page Filament bounces guests to: an e-mail and password form
| that starts the flow at `auth.redirect`. | (password grant) with «Entrar con Pireon» under it, which starts this flow at
| `auth.redirect`.
| |
*/ */

View File

@@ -5,8 +5,13 @@ declare(strict_types=1);
namespace Tests\Feature\Auth; namespace Tests\Feature\Auth;
use App\Models\User; use App\Models\User;
use App\Services\Pireon\AccessToken;
use App\Services\Pireon\TokenStore;
use Carbon\CarbonImmutable;
use Database\Seeders\RolePermissionSeeder; use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon; use Tests\FakesPireon;
use Tests\TestCase; use Tests\TestCase;
@@ -144,4 +149,57 @@ class PireonLoginTest extends TestCase
$this->assertGuest(); $this->assertGuest();
$this->assertNull(session('pireon.token')); $this->assertNull(session('pireon.token'));
} }
#[Test]
public function the_redirect_flow_uses_its_own_client_when_one_is_set(): void
{
config(['pireon.redirect_client_id' => 'code-client', 'pireon.redirect_client_secret' => 'code-secret']);
$response = $this->get(route('auth.redirect'));
$query = [];
parse_str((string) parse_url($response->headers->get('Location'), PHP_URL_QUERY), $query);
$this->assertSame('code-client', $query['client_id']);
$this->fakePireon();
$this
->withSession(['pireon.state' => 'expected-state', 'pireon.code_verifier' => 'the-verifier'])
->get(route('auth.callback', ['code' => 'auth-code', 'state' => 'expected-state']))
->assertRedirect();
// The code is redeemed with the client it was issued for.
Http::assertSent(fn (Request $request) => str_ends_with($request->url(), '/oauth/token')
&& $request['grant_type'] === 'authorization_code'
&& $request['client_id'] === 'code-client'
&& $request['client_secret'] === 'code-secret');
$this->assertSame('redirect', session('pireon.token_issuer'));
}
#[Test]
public function a_redirect_session_renews_its_token_with_the_redirect_client(): void
{
config(['pireon.redirect_client_id' => 'code-client', 'pireon.redirect_client_secret' => null]);
$this->fakePireon(token: ['access_token' => 'renewed-token']);
$store = app(TokenStore::class);
$store->put(new AccessToken('old-token', 'the-refresh-token', CarbonImmutable::now()->subMinute()), viaRedirect: true);
$this->assertSame('renewed-token', $store->accessToken());
Http::assertSent(fn (Request $request) => str_ends_with($request->url(), '/oauth/token')
&& $request['grant_type'] === 'refresh_token'
&& $request['client_id'] === 'code-client');
}
#[Test]
public function the_login_page_offers_pireon_only_with_a_redirect_client(): void
{
config(['pireon.redirect_login' => false]);
$this->get('/admin/login')->assertOk()->assertDontSee(__('pireon.continue_with_pireon'));
config(['pireon.redirect_login' => true, 'pireon.redirect_client_id' => 'code-client']);
$this->get('/admin/login')
->assertOk()
->assertSee(__('pireon.continue_with_pireon'))
->assertSee(route('auth.redirect'), escape: false);
}
} }