POST /api/v1/auth/token was limited to 6 requests a minute per IP. The deployed MCP server signs every user in from a single container, so under a per-IP budget one person's mistyped password, or a stranger's guesses, would lock every other user out of it. The named `auth-token` limiter keys on the lower-cased username (or the refresh token) plus the address, still 6 a minute, with a 30-a-minute ceiling per address to bound how many accounts one client can try. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
154 lines
4.9 KiB
PHP
154 lines
4.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\Api;
|
|
|
|
use App\Models\User;
|
|
use Database\Seeders\RolePermissionSeeder;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Http;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use Tests\FakesPireon;
|
|
use Tests\TestCase;
|
|
|
|
class TokenEndpointTest extends TestCase
|
|
{
|
|
use FakesPireon, RefreshDatabase;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->seed(RolePermissionSeeder::class);
|
|
}
|
|
|
|
#[Test]
|
|
public function it_exchanges_credentials_for_a_token_and_provisions_the_user(): void
|
|
{
|
|
$this->fakePireon();
|
|
|
|
$response = $this->postJson('/api/v1/auth/token', [
|
|
'grant_type' => 'password',
|
|
'username' => 'ada@example.com',
|
|
'password' => 'secret',
|
|
]);
|
|
|
|
$response->assertOk()
|
|
->assertJsonPath('access_token', 'fake-access-token')
|
|
->assertJsonPath('user.email', 'ada@example.com')
|
|
->assertJsonPath('user.roles.0', config('pireon.default_role'));
|
|
|
|
$this->assertDatabaseHas('users', ['oauth_id' => 4242]);
|
|
|
|
// The password grant must reach the identity server, never a local
|
|
// password column.
|
|
Http::assertSent(fn ($request) => str_contains($request->url(), '/oauth/token')
|
|
&& $request['grant_type'] === 'password');
|
|
}
|
|
|
|
#[Test]
|
|
public function it_does_not_leak_why_a_credential_was_rejected(): void
|
|
{
|
|
Http::fake([
|
|
"{$this->pireonServer}/oauth/token" => Http::response([
|
|
'error' => 'invalid_grant',
|
|
'error_description' => 'The user credentials were incorrect.',
|
|
], 400),
|
|
]);
|
|
|
|
$response = $this->postJson('/api/v1/auth/token', [
|
|
'grant_type' => 'password',
|
|
'username' => 'ada@example.com',
|
|
'password' => 'wrong',
|
|
]);
|
|
|
|
$response->assertStatus(422);
|
|
$this->assertStringNotContainsString('credentials were incorrect', $response->content());
|
|
}
|
|
|
|
#[Test]
|
|
public function it_refuses_an_account_awaiting_approval(): void
|
|
{
|
|
$this->fakePireon(identity: ['approved_at' => null]);
|
|
|
|
$this->postJson('/api/v1/auth/token', [
|
|
'grant_type' => 'password',
|
|
'username' => 'ada@example.com',
|
|
'password' => 'secret',
|
|
])
|
|
->assertForbidden()
|
|
->assertJsonPath('message', __('pireon.pending_approval'));
|
|
|
|
$this->assertDatabaseCount('users', 0);
|
|
}
|
|
|
|
#[Test]
|
|
public function the_password_grant_can_be_disabled(): void
|
|
{
|
|
config()->set('pireon.password_grant_enabled', false);
|
|
|
|
$this->postJson('/api/v1/auth/token', [
|
|
'grant_type' => 'password',
|
|
'username' => 'ada@example.com',
|
|
'password' => 'secret',
|
|
])->assertStatus(422);
|
|
}
|
|
|
|
#[Test]
|
|
public function it_validates_the_grant_type(): void
|
|
{
|
|
$this->postJson('/api/v1/auth/token', ['grant_type' => 'implicit'])
|
|
->assertUnprocessable()
|
|
->assertJsonValidationErrors('grant_type');
|
|
}
|
|
|
|
#[Test]
|
|
public function one_account_exhausting_its_budget_does_not_lock_out_another_on_the_same_address(): void
|
|
{
|
|
// Everyone the MCP server signs in arrives from its one container.
|
|
// Registered first so it wins over fakePireon()'s token stub, which
|
|
// would otherwise accept every password.
|
|
Http::fake(["{$this->pireonServer}/oauth/token" => fn ($request) => $request['username'] === 'ada@example.com'
|
|
? Http::response(['access_token' => 'fake-access-token', 'token_type' => 'Bearer', 'expires_in' => 3600])
|
|
: Http::response(['error' => 'invalid_grant'], 400)]);
|
|
$this->fakePireon();
|
|
|
|
foreach (range(1, 6) as $attempt) {
|
|
$this->postJson('/api/v1/auth/token', [
|
|
'grant_type' => 'password',
|
|
'username' => 'mallory@example.com',
|
|
'password' => "guess-{$attempt}",
|
|
])->assertStatus(422);
|
|
}
|
|
|
|
$this->postJson('/api/v1/auth/token', [
|
|
'grant_type' => 'password',
|
|
'username' => 'MALLORY@example.com',
|
|
'password' => 'guess-7',
|
|
])->assertTooManyRequests();
|
|
|
|
$this->postJson('/api/v1/auth/token', [
|
|
'grant_type' => 'password',
|
|
'username' => 'ada@example.com',
|
|
'password' => 'secret',
|
|
])->assertOk();
|
|
}
|
|
|
|
#[Test]
|
|
public function repeated_calls_with_the_same_token_hit_the_identity_server_once(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$user->syncRoles(['viewer']);
|
|
|
|
$headers = $this->apiHeadersFor($user);
|
|
|
|
$this->getJson('/api/v1/me', $headers)->assertOk();
|
|
$this->getJson('/api/v1/me', $headers)->assertOk();
|
|
$this->getJson('/api/v1/me', $headers)->assertOk();
|
|
|
|
// Introspection is cached, so three requests cost one upstream call.
|
|
Http::assertSentCount(1);
|
|
}
|
|
}
|