From ec4f687ca8d4b975d3240107b1aabd6aef5ca940 Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Fri, 16 Oct 2026 12:46:07 +0200 Subject: [PATCH] =?UTF-8?q?Offer=20=C2=ABEntrar=20con=20Pireon=C2=BB=20on?= =?UTF-8?q?=20the=20panel's=20login=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 9 +++ README.md | 15 ++++- app/Filament/Pages/Auth/PireonLogin.php | 25 +++++++- .../Controllers/Auth/PireonAuthController.php | 8 ++- app/Services/Pireon/PireonClient.php | 16 +++++ app/Services/Pireon/TokenStore.php | 20 ++++++- config/pireon.php | 18 ++++++ lang/ca/pireon.php | 3 +- lang/en/pireon.php | 3 +- lang/es/pireon.php | 3 +- routes/web.php | 5 +- tests/Feature/Auth/PireonLoginTest.php | 58 +++++++++++++++++++ 12 files changed, 169 insertions(+), 14 deletions(-) diff --git a/.env.example b/.env.example index 36151c0..e156b22 100644 --- a/.env.example +++ b/.env.example @@ -76,6 +76,15 @@ PIREON_CLIENT_SECRET= PIREON_REDIRECT_URI="${APP_URL}/auth/callback" 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 # locks everyone out of the panel as well as out of POST /api/v1/auth/token. PIREON_PASSWORD_GRANT=true diff --git a/README.md b/README.md index 4a14a49..d1a6cee 100644 --- a/README.md +++ b/README.md @@ -70,9 +70,20 @@ al remoto por si alguien comitea con `--no-verify`. En `https://oauth.pireon.dev` crea un cliente OAuth con: - **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, - `http://localhost:8000/auth/callback`), solo si quieres conservar el flujo de - redirección, que sigue disponible aunque el panel ya no lo use + `http://localhost:8000/auth/callback`) + +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`: diff --git a/app/Filament/Pages/Auth/PireonLogin.php b/app/Filament/Pages/Auth/PireonLogin.php index 4c1085d..debd272 100644 --- a/app/Filament/Pages/Auth/PireonLogin.php +++ b/app/Filament/Pages/Auth/PireonLogin.php @@ -10,6 +10,7 @@ use App\Services\Pireon\PireonClient; use App\Services\Pireon\PireonException; use App\Services\Pireon\TokenStore; use DanHarrin\LivewireRateLimiting\Exceptions\TooManyRequestsException; +use Filament\Actions\Action; use Filament\Auth\Http\Responses\Contracts\LoginResponse; use Filament\Auth\Pages\Login; use Filament\Facades\Filament; @@ -27,6 +28,9 @@ use SensitiveParameter; * usable password column — but the person signing in never sees that. They * 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 * 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, @@ -53,6 +57,25 @@ class PireonLogin extends Login 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. * 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)); - app(TokenStore::class)->put($token); + app(TokenStore::class)->put($token, viaRedirect: false); if ($user->locale) { session()->put(config('locales.session_key'), $user->locale); diff --git a/app/Http/Controllers/Auth/PireonAuthController.php b/app/Http/Controllers/Auth/PireonAuthController.php index c886f1a..23a47c6 100644 --- a/app/Http/Controllers/Auth/PireonAuthController.php +++ b/app/Http/Controllers/Auth/PireonAuthController.php @@ -19,6 +19,8 @@ use Illuminate\Support\Facades\Log; * * The browser never sees a password belonging to this application, and the * 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 { @@ -38,7 +40,7 @@ class PireonAuthController extends Controller } try { - ['url' => $url, 'state' => $state, 'code_verifier' => $verifier] = $this->client->authorizationUrl(); + ['url' => $url, 'state' => $state, 'code_verifier' => $verifier] = PireonClient::forRedirect()->authorizationUrl(); } catch (PireonException $e) { return $this->fail($e, route('login')); } @@ -81,7 +83,7 @@ class PireonAuthController extends Controller } 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); } catch (PireonException $e) { return $this->fail($e, route('login')); @@ -100,7 +102,7 @@ class PireonAuthController extends Controller $request->session()->regenerate(); Auth::login($user, remember: true); - $this->tokens->put($token); + $this->tokens->put($token, viaRedirect: true); if ($user->locale) { $request->session()->put(config('locales.session_key'), $user->locale); diff --git a/app/Services/Pireon/PireonClient.php b/app/Services/Pireon/PireonClient.php index a3fe73a..fc376d5 100644 --- a/app/Services/Pireon/PireonClient.php +++ b/app/Services/Pireon/PireonClient.php @@ -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 { return filled($this->clientId); diff --git a/app/Services/Pireon/TokenStore.php b/app/Services/Pireon/TokenStore.php index 2485d1f..99ce7f0 100644 --- a/app/Services/Pireon/TokenStore.php +++ b/app/Services/Pireon/TokenStore.php @@ -18,14 +18,25 @@ class TokenStore { 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( protected Session $session, 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()); + + if ($viaRedirect !== null) { + $this->session->put(self::ISSUER, $viaRedirect ? 'redirect' : 'password'); + } } public function get(): ?AccessToken @@ -37,7 +48,7 @@ class TokenStore public function forget(): void { - $this->session->forget(self::KEY); + $this->session->forget([self::KEY, self::ISSUER]); } /** @@ -66,7 +77,10 @@ class TokenStore } 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) { // The refresh token was revoked or expired upstream. $this->forget(); diff --git a/config/pireon.php b/config/pireon.php index b4fce11..9190b1c 100644 --- a/config/pireon.php +++ b/config/pireon.php @@ -33,6 +33,24 @@ return [ */ '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 | identity of the person logging in, so we stay minimal by default. diff --git a/lang/ca/pireon.php b/lang/ca/pireon.php index 5076218..d95f3c2 100644 --- a/lang/ca/pireon.php +++ b/lang/ca/pireon.php @@ -4,7 +4,8 @@ declare(strict_types=1); return [ '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…', '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.', diff --git a/lang/en/pireon.php b/lang/en/pireon.php index de2c312..57468a5 100644 --- a/lang/en/pireon.php +++ b/lang/en/pireon.php @@ -4,7 +4,8 @@ declare(strict_types=1); return [ '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…', '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.', diff --git a/lang/es/pireon.php b/lang/es/pireon.php index 8c93ed2..8ee0e7e 100644 --- a/lang/es/pireon.php +++ b/lang/es/pireon.php @@ -4,7 +4,8 @@ declare(strict_types=1); return [ '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…', '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.', diff --git a/routes/web.php b/routes/web.php index 0d92dff..1958e87 100644 --- a/routes/web.php +++ b/routes/web.php @@ -14,8 +14,9 @@ Route::redirect('/', '/admin'); | Authentication — Pireon OAuth2 (Authorization Code + PKCE) |-------------------------------------------------------------------------- | -| `login` is the page Filament bounces guests to; it renders a single button -| that starts the flow at `auth.redirect`. +| `login` is the page Filament bounces guests to: an e-mail and password form +| (password grant) with «Entrar con Pireon» under it, which starts this flow at +| `auth.redirect`. | */ diff --git a/tests/Feature/Auth/PireonLoginTest.php b/tests/Feature/Auth/PireonLoginTest.php index 6c124cd..d9d090f 100644 --- a/tests/Feature/Auth/PireonLoginTest.php +++ b/tests/Feature/Auth/PireonLoginTest.php @@ -5,8 +5,13 @@ declare(strict_types=1); namespace Tests\Feature\Auth; use App\Models\User; +use App\Services\Pireon\AccessToken; +use App\Services\Pireon\TokenStore; +use Carbon\CarbonImmutable; use Database\Seeders\RolePermissionSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\Client\Request; +use Illuminate\Support\Facades\Http; use PHPUnit\Framework\Attributes\Test; use Tests\FakesPireon; use Tests\TestCase; @@ -144,4 +149,57 @@ class PireonLoginTest extends TestCase $this->assertGuest(); $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); + } }