Issue machine tokens with limited permissions and an expiry
A token acts as the person who made it, within the permissions it was given and those they still hold, checked on every request. Permission checks now go through one gate rule, since spatie's answered first and never asked the token. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
182
tests/Feature/Api/MachineTokenTest.php
Normal file
182
tests/Feature/Api/MachineTokenTest.php
Normal file
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use App\Models\ApiToken;
|
||||
use App\Models\Page;
|
||||
use App\Models\Technology;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Machine tokens: made by a person, acting as them, never as more.
|
||||
*/
|
||||
class MachineTokenTest extends TestCase
|
||||
{
|
||||
use FakesPireon, RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(RolePermissionSeeder::class);
|
||||
}
|
||||
|
||||
protected function person(string $role): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$user->syncRoles([$role]);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $abilities
|
||||
*/
|
||||
protected function tokenFor(User $user, array $abilities, ?\DateTimeInterface $expires = null): string
|
||||
{
|
||||
[, $plain] = ApiToken::issue($user, 'CI', $abilities, $expires ?? now()->addDay());
|
||||
|
||||
$this->app['auth']->forgetGuards();
|
||||
$this->withHeaders(['Authorization' => 'Bearer '.$plain, 'Accept' => 'application/json']);
|
||||
|
||||
return $plain;
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_person_makes_a_token_and_sees_it_only_once(): void
|
||||
{
|
||||
$this->withHeaders($this->apiHeadersFor($this->person('editor')));
|
||||
|
||||
$response = $this->postJson('/api/v1/tokens', [
|
||||
'name' => 'Despliegue',
|
||||
'abilities' => ['technologies.*', 'pages.view_any'],
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.status', 'active')
|
||||
->assertJsonPath('data.abilities', ['technologies.*', 'pages.view_any']);
|
||||
|
||||
$plain = $response->json('token');
|
||||
|
||||
$this->assertStringStartsWith(ApiToken::PREFIX, $plain);
|
||||
$this->assertSame(ApiToken::hash($plain), ApiToken::query()->sole()->token_hash);
|
||||
|
||||
$this->getJson('/api/v1/tokens')
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonMissingPath('data.0.token');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_token_cannot_be_given_what_its_maker_lacks(): void
|
||||
{
|
||||
$this->withHeaders($this->apiHeadersFor($this->person('writer')));
|
||||
|
||||
$this->postJson('/api/v1/tokens', ['name' => 'X', 'abilities' => ['users.update']])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('abilities.0');
|
||||
|
||||
$this->postJson('/api/v1/tokens', ['name' => 'X', 'abilities' => ['nothing.*']])
|
||||
->assertUnprocessable();
|
||||
|
||||
$this->postJson('/api/v1/tokens', [
|
||||
'name' => 'X',
|
||||
'abilities' => ['pages.view_any'],
|
||||
'expires_at' => now()->addYears(2)->toIso8601String(),
|
||||
])->assertUnprocessable()->assertJsonValidationErrors('expires_at');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_token_acts_as_its_maker_within_its_abilities(): void
|
||||
{
|
||||
Http::fake();
|
||||
|
||||
$editor = $this->person('editor');
|
||||
$this->tokenFor($editor, ['technologies.*']);
|
||||
|
||||
$this->postJson('/api/v1/technologies', ['name' => 'Vue', 'slug' => 'vue'])->assertCreated();
|
||||
$this->getJson('/api/v1/pages')->assertForbidden();
|
||||
|
||||
$permissions = $this->getJson('/api/v1/me')->assertOk()->json('data.permissions');
|
||||
|
||||
$this->assertContains('technologies.create', $permissions);
|
||||
$this->assertNotContains('pages.view_any', $permissions);
|
||||
|
||||
// Never sent to the identity server to be checked.
|
||||
Http::assertNothingSent();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_super_admin_token_is_still_limited(): void
|
||||
{
|
||||
$this->tokenFor($this->person('super_admin'), ['pages.view_any', 'pages.view']);
|
||||
|
||||
$page = Page::factory()->create();
|
||||
|
||||
$this->getJson('/api/v1/pages')->assertOk();
|
||||
$this->getJson("/api/v1/pages/{$page->id}")->assertOk();
|
||||
$this->deleteJson("/api/v1/pages/{$page->id}")->assertForbidden();
|
||||
$this->postJson('/api/v1/technologies', ['name' => 'Vue', 'slug' => 'vue'])->assertForbidden();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function losing_a_role_narrows_the_token_at_once(): void
|
||||
{
|
||||
$editor = $this->person('editor');
|
||||
$this->tokenFor($editor, ['technologies.*']);
|
||||
|
||||
$editor->syncRoles(['viewer']);
|
||||
|
||||
$this->postJson('/api/v1/technologies', ['name' => 'Vue', 'slug' => 'vue'])->assertForbidden();
|
||||
$this->assertSame(0, Technology::query()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function expired_revoked_or_deactivated_tokens_do_not_work(): void
|
||||
{
|
||||
$editor = $this->person('editor');
|
||||
|
||||
$this->tokenFor($editor, ['pages.*'], now()->subMinute());
|
||||
$this->getJson('/api/v1/pages')->assertUnauthorized();
|
||||
|
||||
$plain = $this->tokenFor($editor, ['pages.*']);
|
||||
ApiToken::query()->where('token_hash', ApiToken::hash($plain))->sole()->revoke();
|
||||
$this->getJson('/api/v1/pages')->assertUnauthorized();
|
||||
|
||||
$this->tokenFor($editor, ['pages.*']);
|
||||
$editor->update(['is_active' => false]);
|
||||
$this->getJson('/api/v1/pages')->assertUnauthorized();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_token_cannot_make_or_revoke_tokens(): void
|
||||
{
|
||||
$editor = $this->person('editor');
|
||||
$this->tokenFor($editor, ['pages.*']);
|
||||
|
||||
$this->postJson('/api/v1/tokens', ['name' => 'Forever', 'abilities' => ['pages.*']])->assertForbidden();
|
||||
$this->getJson('/api/v1/tokens')->assertForbidden();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function a_revoked_token_stays_listed(): void
|
||||
{
|
||||
$editor = $this->person('editor');
|
||||
[$token] = ApiToken::issue($editor, 'Old', ['pages.*'], now()->addDay());
|
||||
|
||||
$this->withHeaders($this->apiHeadersFor($editor));
|
||||
|
||||
$this->deleteJson("/api/v1/tokens/{$token->id}")->assertNoContent();
|
||||
$this->getJson('/api/v1/tokens')->assertJsonPath('data.0.status', 'revoked');
|
||||
|
||||
// Someone else's is not theirs to revoke.
|
||||
[$other] = ApiToken::issue($this->person('editor'), 'Theirs', ['pages.*'], now()->addDay());
|
||||
$this->deleteJson("/api/v1/tokens/{$other->id}")->assertForbidden();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user