Skip to content

Commit 7314404

Browse files
committed
feat(phase-38): API token management with scoped abilities and expiry
- ApiTokenController: list, create, revoke single/all tokens - Supports 11 granular ability scopes (read:invoices, write:products, etc.) - Tokens support optional expires_in days parameter - Routes: /tokens CRUD + /tokens/all bulk revoke - 9 feature tests covering creation, revocation, cross-user isolation Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 90e4bf1 commit 7314404

4 files changed

Lines changed: 198 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ beforeEach(function () {
169169
| 35 | Customer Credit Limits — per-contact limits, hold, check API ||
170170
| 36 | Product Variants REST API — attributes, variants, matrix view ||
171171
| 37 | Webhook Management REST API — CRUD, delivery log, ping, HMAC ||
172+
| 38 | API Token Management — named tokens with abilities and expiry ||
172173

173174
## File Locations Reference
174175

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use Illuminate\Http\JsonResponse;
6+
use Illuminate\Http\Request;
7+
use Laravel\Sanctum\PersonalAccessToken;
8+
9+
class ApiTokenController extends ApiController
10+
{
11+
public static array $validAbilities = [
12+
'read:invoices', 'write:invoices',
13+
'read:contacts', 'write:contacts',
14+
'read:products', 'write:products',
15+
'read:reports',
16+
'read:hr',
17+
'read:purchases', 'write:purchases',
18+
'webhooks:manage',
19+
];
20+
21+
public function index(Request $request): JsonResponse
22+
{
23+
$tokens = $request->user()
24+
->tokens()
25+
->orderByDesc('created_at')
26+
->get()
27+
->map(fn ($t) => [
28+
'id' => $t->id,
29+
'name' => $t->name,
30+
'abilities' => $t->abilities,
31+
'last_used_at' => $t->last_used_at,
32+
'expires_at' => $t->expires_at,
33+
'created_at' => $t->created_at,
34+
]);
35+
36+
return $this->success($tokens);
37+
}
38+
39+
public function store(Request $request): JsonResponse
40+
{
41+
$data = $request->validate([
42+
'name' => ['required', 'string', 'max:255'],
43+
'abilities' => ['nullable', 'array'],
44+
'abilities.*'=> ['string', 'in:' . implode(',', self::$validAbilities)],
45+
'expires_in' => ['nullable', 'integer', 'min:1', 'max:365'],
46+
]);
47+
48+
$abilities = $data['abilities'] ?? ['*'];
49+
$expiresAt = isset($data['expires_in'])
50+
? now()->addDays($data['expires_in'])
51+
: null;
52+
53+
$token = $request->user()->createToken(
54+
$data['name'],
55+
$abilities,
56+
$expiresAt,
57+
);
58+
59+
return $this->success([
60+
'token' => $token->plainTextToken,
61+
'id' => $token->accessToken->id,
62+
'name' => $token->accessToken->name,
63+
'abilities' => $token->accessToken->abilities,
64+
'expires_at' => $token->accessToken->expires_at,
65+
], 201);
66+
}
67+
68+
public function destroy(Request $request, int $tokenId): JsonResponse
69+
{
70+
$deleted = $request->user()->tokens()->where('id', $tokenId)->delete();
71+
72+
if (! $deleted) {
73+
return $this->error('Token not found.', 404);
74+
}
75+
76+
return $this->success(['message' => 'Token revoked.']);
77+
}
78+
79+
public function destroyAll(Request $request): JsonResponse
80+
{
81+
$request->user()->tokens()->delete();
82+
return $this->success(['message' => 'All tokens revoked.']);
83+
}
84+
}

erp/routes/api.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,14 @@
448448
});
449449
Route::get('products/{product}/matrix', [\App\Http\Controllers\Api\V1\ProductVariantController::class, 'matrix']);
450450

451+
// API Token Management
452+
Route::prefix('tokens')->group(function () {
453+
Route::get('/', [\App\Http\Controllers\Api\V1\ApiTokenController::class, 'index']);
454+
Route::post('/', [\App\Http\Controllers\Api\V1\ApiTokenController::class, 'store']);
455+
Route::delete('/all', [\App\Http\Controllers\Api\V1\ApiTokenController::class, 'destroyAll']);
456+
Route::delete('/{tokenId}', [\App\Http\Controllers\Api\V1\ApiTokenController::class, 'destroy']);
457+
});
458+
451459
// Webhook Management
452460
Route::get('/webhooks/events', [\App\Http\Controllers\Api\V1\WebhookApiController::class, 'events']);
453461
Route::prefix('webhooks')->group(function () {
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<?php
2+
3+
use App\Models\User;
4+
use App\Modules\Core\Models\Tenant;
5+
use Database\Seeders\RolePermissionSeeder;
6+
7+
beforeEach(function () {
8+
$this->seed(RolePermissionSeeder::class);
9+
$this->tenant = Tenant::create(['name' => 'Token Co', 'slug' => 'token-co-' . uniqid()]);
10+
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
11+
$this->user->assignRole('super-admin');
12+
$this->token = $this->user->createToken('main')->plainTextToken;
13+
app()->instance('tenant', $this->tenant);
14+
});
15+
16+
test('can list api tokens', function () {
17+
$this->user->createToken('Second Token', ['read:invoices']);
18+
19+
$this->withToken($this->token)
20+
->getJson('/api/v1/tokens')
21+
->assertStatus(200);
22+
23+
$tokens = $this->withToken($this->token)->getJson('/api/v1/tokens')->json('data');
24+
expect(count($tokens))->toBeGreaterThanOrEqual(2);
25+
});
26+
27+
test('can create an api token with abilities', function () {
28+
$this->withToken($this->token)
29+
->postJson('/api/v1/tokens', [
30+
'name' => 'Reporting Token',
31+
'abilities' => ['read:invoices', 'read:reports'],
32+
])
33+
->assertStatus(201)
34+
->assertJsonStructure(['data' => ['token', 'id', 'name', 'abilities', 'expires_at']])
35+
->assertJsonPath('data.name', 'Reporting Token');
36+
});
37+
38+
test('can create a token with expiry', function () {
39+
$response = $this->withToken($this->token)
40+
->postJson('/api/v1/tokens', [
41+
'name' => 'Temp Token',
42+
'expires_in' => 7,
43+
])
44+
->assertStatus(201);
45+
46+
$expiresAt = $response->json('data.expires_at');
47+
expect($expiresAt)->not->toBeNull();
48+
});
49+
50+
test('store validates ability names', function () {
51+
$this->withToken($this->token)
52+
->postJson('/api/v1/tokens', [
53+
'name' => 'Bad Token',
54+
'abilities' => ['hack:everything'],
55+
])
56+
->assertStatus(422)
57+
->assertJsonValidationErrors(['abilities.0']);
58+
});
59+
60+
test('can revoke a specific token', function () {
61+
$newToken = $this->user->createToken('Revoke Me');
62+
$tokenId = $newToken->accessToken->id;
63+
64+
$this->withToken($this->token)
65+
->deleteJson("/api/v1/tokens/{$tokenId}")
66+
->assertStatus(200);
67+
68+
expect($this->user->tokens()->where('id', $tokenId)->count())->toBe(0);
69+
});
70+
71+
test('cannot revoke another users token', function () {
72+
$otherUser = User::factory()->create(['tenant_id' => $this->tenant->id]);
73+
$otherToken = $otherUser->createToken('Other Token');
74+
$tokenId = $otherToken->accessToken->id;
75+
76+
$this->withToken($this->token)
77+
->deleteJson("/api/v1/tokens/{$tokenId}")
78+
->assertStatus(404);
79+
});
80+
81+
test('can revoke all tokens', function () {
82+
$this->user->createToken('Token A');
83+
$this->user->createToken('Token B');
84+
85+
$this->withToken($this->token)
86+
->deleteJson('/api/v1/tokens/all')
87+
->assertStatus(200);
88+
89+
expect($this->user->tokens()->count())->toBe(0);
90+
});
91+
92+
test('token with wildcard ability works as full access', function () {
93+
$response = $this->withToken($this->token)
94+
->postJson('/api/v1/tokens', [
95+
'name' => 'Full Access',
96+
])
97+
->assertStatus(201);
98+
99+
$abilities = $response->json('data.abilities');
100+
expect($abilities)->toContain('*');
101+
});
102+
103+
test('requires authentication', function () {
104+
$this->getJson('/api/v1/tokens')->assertStatus(401);
105+
});

0 commit comments

Comments
 (0)