Files
landing-api/tests/Feature/Auth/PireonLoginTest.php
Josep Piguillem ec4f687ca8
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
Offer «Entrar con Pireon» on the panel's login page
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>
2026-10-16 12:46:07 +02:00

206 lines
7.4 KiB
PHP

<?php
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;
class PireonLoginTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
}
#[Test]
public function it_redirects_to_the_identity_server_with_a_pkce_challenge(): void
{
$response = $this->get(route('auth.redirect'));
$response->assertRedirectContains($this->pireonServer.'/oauth/authorize');
$query = [];
parse_str((string) parse_url($response->headers->get('Location'), PHP_URL_QUERY), $query);
$this->assertSame('code', $query['response_type']);
$this->assertSame('test-client-id', $query['client_id']);
$this->assertSame('S256', $query['code_challenge_method']);
$this->assertNotEmpty($query['code_challenge']);
// The verifier itself must stay server-side; only its hash travels.
$verifier = session('pireon.code_verifier');
$this->assertNotEmpty($verifier);
$this->assertStringNotContainsString($verifier, (string) $response->headers->get('Location'));
$expectedChallenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
$this->assertSame($expectedChallenge, $query['code_challenge']);
}
#[Test]
public function it_signs_the_user_in_and_provisions_a_local_account(): void
{
$this->fakePireon();
$response = $this
->withSession(['pireon.state' => 'expected-state', 'pireon.code_verifier' => 'the-verifier'])
->get(route('auth.callback', ['code' => 'auth-code', 'state' => 'expected-state']));
$response->assertRedirect();
$user = User::where('oauth_id', 4242)->first();
$this->assertNotNull($user);
$this->assertSame('ada@example.com', $user->email);
$this->assertAuthenticatedAs($user);
// New identities get the least-privileged role, never the app_roles
// the identity server reported.
$this->assertTrue($user->hasRole(config('pireon.default_role')));
$this->assertFalse($user->hasRole('admin'));
$this->assertSame(['user'], $user->remote_roles);
}
#[Test]
public function it_rejects_a_callback_whose_state_does_not_match(): void
{
$this->fakePireon();
$response = $this
->withSession(['pireon.state' => 'expected-state', 'pireon.code_verifier' => 'the-verifier'])
->get(route('auth.callback', ['code' => 'auth-code', 'state' => 'attacker-state']));
$response->assertRedirect(route('login'));
$this->assertGuest();
$this->assertDatabaseCount('users', 0);
}
#[Test]
public function it_refuses_an_account_still_awaiting_approval(): void
{
$this->fakePireon(identity: ['approved_at' => null]);
$response = $this
->withSession(['pireon.state' => 's', 'pireon.code_verifier' => 'v'])
->get(route('auth.callback', ['code' => 'c', 'state' => 's']));
$response->assertRedirect(route('login'));
$response->assertSessionHas('error', __('pireon.pending_approval'));
$this->assertGuest();
}
#[Test]
public function it_refuses_a_deactivated_account(): void
{
$this->fakePireon(identity: ['is_active' => false]);
$response = $this
->withSession(['pireon.state' => 's', 'pireon.code_verifier' => 'v'])
->get(route('auth.callback', ['code' => 'c', 'state' => 's']));
$response->assertSessionHas('error', __('pireon.inactive'));
$this->assertGuest();
}
#[Test]
public function it_adopts_a_pre_existing_local_account_with_the_same_email(): void
{
$existing = User::factory()->unlinked()->create(['email' => 'ada@example.com']);
$existing->assignRole('admin');
$this->fakePireon();
$this->withSession(['pireon.state' => 's', 'pireon.code_verifier' => 'v'])
->get(route('auth.callback', ['code' => 'c', 'state' => 's']));
$this->assertDatabaseCount('users', 1);
$existing->refresh();
$this->assertSame(4242, $existing->oauth_id);
// Roles granted locally survive the first real sign-in.
$this->assertTrue($existing->hasRole('admin'));
}
#[Test]
public function signing_out_clears_the_stored_oauth_token(): void
{
$user = $this->userWithRoles('viewer');
$this->actingAs($user)
->withSession(['pireon.token' => ['access_token' => 'abc']])
->post(route('logout'))
->assertRedirect(route('login'));
$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);
}
}