diff --git a/app/Http/Controllers/PersonalAccessTokenController.php b/app/Http/Controllers/PersonalAccessTokenController.php index 51f87925d..092cb036d 100644 --- a/app/Http/Controllers/PersonalAccessTokenController.php +++ b/app/Http/Controllers/PersonalAccessTokenController.php @@ -93,6 +93,14 @@ class PersonalAccessTokenController extends Controller public function renew(Request $request, string $token_id): JsonResponse { + // renew() mints a brand-new PAT, so it must honor the same kill-switch + // as store(); otherwise disabling PATs only blocks creation, not renewal. + if (! config('instance.oauth.pat_enabled')) { + return response()->json([ + 'error' => 'Personal access tokens are not enabled on this instance. Please contact your administrator.', + ], 403); + } + $oldToken = $request->user() ->tokens() ->with('client') diff --git a/tests/Feature/Api/PersonalAccessTokenRenewKillSwitchTest.php b/tests/Feature/Api/PersonalAccessTokenRenewKillSwitchTest.php new file mode 100644 index 000000000..a00109d0c --- /dev/null +++ b/tests/Feature/Api/PersonalAccessTokenRenewKillSwitchTest.php @@ -0,0 +1,78 @@ +withoutMiddleware(\Illuminate\Routing\Middleware\ThrottleRequests::class); + app(ClientRepository::class)->createPersonalAccessGrantClient( + 'Test Personal Access Client', + config('auth.guards.api.provider') + ); +}); + +it('refuses to renew a PAT when the feature is disabled', function () { + $user = User::factory()->create(); + $user->refresh(); + $this->actingAs($user, 'web'); + + // Create a real PAT while the feature is enabled. + config(['instance.oauth.pat_enabled' => true]); + $create = $this->postJson('/oauth/personal-access-tokens', [ + 'name' => 'my PAT', + 'scopes' => ['read'], + ])->assertOk(); + $oldId = $create->json('token.id'); + + // Operator disables the PAT feature. + config(['instance.oauth.pat_enabled' => false]); + + // store() correctly refuses. + $this->postJson('/oauth/personal-access-tokens', [ + 'name' => 'second', + 'scopes' => ['read'], + ])->assertStatus(403); + + // renew() must also refuse, and must NOT revoke the old token. + $this->postJson("/oauth/personal-access-tokens/{$oldId}/renew") + ->assertStatus(403); + + $this->assertDatabaseHas('oauth_access_tokens', ['id' => $oldId, 'revoked' => 0]); +}); + +it('allows renew when the feature is enabled', function () { + $user = User::factory()->create(); + $user->refresh(); + $this->actingAs($user, 'web'); + + config(['instance.oauth.pat_enabled' => true]); + $create = $this->postJson('/oauth/personal-access-tokens', [ + 'name' => 'my PAT', + 'scopes' => ['read'], + ])->assertOk(); + $oldId = $create->json('token.id'); + + $renew = $this->postJson("/oauth/personal-access-tokens/{$oldId}/renew") + ->assertOk(); + + expect($renew->json('accessToken'))->not->toBeEmpty() + ->and($renew->json('token.id'))->not->toBe($oldId) + ->and($renew->json('renewedTokenId'))->toBe($oldId); + + $this->assertDatabaseHas('oauth_access_tokens', ['id' => $oldId, 'revoked' => 1]); +});