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); } }