fix(api-tokens): persist expiration warning state

Track when expiration warnings are sent on personal access tokens so repeated job runs or cache flushes do not send duplicate notifications.
This commit is contained in:
Andras Bacsai committed 2026-05-13 10:11:40 +02:00
1 parent f098895abf
commit 3911a0305c
4 files changed
+94 -12

No files matched your search

+22 -9
View File
@@ -12,7 +12,6 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\RateLimiter;
use Laravel\Horizon\Contracts\Silenced;
class ApiTokenExpirationWarningJob implements ShouldBeEncrypted, ShouldQueue, Silenced
@@ -29,20 +28,34 @@ class ApiTokenExpirationWarningJob implements ShouldBeEncrypted, ShouldQueue, Si
->whereNotNull('expires_at')
->where('expires_at', '>', now())
->where('expires_at', '<=', now()->addDay())
->whereNull('api_token_expiration_warning_sent_at')
->where('tokenable_type', User::class)
->chunkById(100, function ($tokens) {
foreach ($tokens as $token) {
if (! $token->team_id) {
continue;
}
RateLimiter::attempt(
'api-token-expiring:'.$token->id,
$maxAttempts = 0,
function () use ($token) {
Team::find($token->team_id)?->notify(new ApiTokenExpiringNotification($token));
},
$decaySeconds = 7 * 24 * 3600,
);
$team = Team::find($token->team_id);
if (! $team) {
continue;
}
$warningSentAt = now();
$markedAsSent = PersonalAccessToken::query()
->whereKey($token->getKey())
->whereNotNull('expires_at')
->where('expires_at', '>', now())
->where('expires_at', '<=', now()->addDay())
->whereNull('api_token_expiration_warning_sent_at')
->update(['api_token_expiration_warning_sent_at' => $warningSentAt]);
if ($markedAsSent !== 1) {
continue;
}
$token->forceFill(['api_token_expiration_warning_sent_at' => $warningSentAt]);
$team->notify(new ApiTokenExpiringNotification($token));
}
});
}
+8
View File
@@ -11,6 +11,14 @@ class PersonalAccessToken extends SanctumPersonalAccessToken
'token',
'abilities',
'expires_at',
'api_token_expiration_warning_sent_at',
'team_id',
];
protected function casts(): array
{
return [
'api_token_expiration_warning_sent_at' => 'datetime',
];
}
}
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('personal_access_tokens', function (Blueprint $table) {
$table->timestamp('api_token_expiration_warning_sent_at')->nullable()->after('expires_at');
$table->index(['expires_at', 'api_token_expiration_warning_sent_at'], 'personal_access_tokens_expiration_warning_index');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('personal_access_tokens', function (Blueprint $table) {
$table->dropIndex('personal_access_tokens_expiration_warning_index');
$table->dropColumn('api_token_expiration_warning_sent_at');
});
}
};
@@ -29,11 +29,12 @@ beforeEach(function () {
Notification::fake();
});
function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt): PersonalAccessToken
function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt, ?Carbon $warningSentAt = null): PersonalAccessToken
{
$plain = $user->createToken('t-'.uniqid(), ['read'], $expiresAt);
$token = $plain->accessToken;
$token->team_id = $team->id;
$token->api_token_expiration_warning_sent_at = $warningSentAt;
$token->save();
return $token->fresh();
@@ -41,14 +42,15 @@ function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt): Person
describe('ApiTokenExpirationWarningJob', function () {
test('notifies team when token expires within 24h', function () {
createTokenExpiring($this->user, $this->team, now()->addHours(23));
$token = createTokenExpiring($this->user, $this->team, now()->addHours(23));
(new ApiTokenExpirationWarningJob)->handle();
Notification::assertSentTo($this->team, ApiTokenExpiringNotification::class);
expect($token->fresh()->api_token_expiration_warning_sent_at)->not->toBeNull();
});
test('rate limiter prevents duplicate warnings on repeat runs', function () {
test('database marker prevents duplicate warnings on repeat runs', function () {
createTokenExpiring($this->user, $this->team, now()->addHours(12));
(new ApiTokenExpirationWarningJob)->handle();
@@ -57,6 +59,35 @@ describe('ApiTokenExpirationWarningJob', function () {
Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 1);
});
test('database marker prevents duplicate warnings after cache is flushed', function () {
createTokenExpiring($this->user, $this->team, now()->addHours(12));
(new ApiTokenExpirationWarningJob)->handle();
Cache::flush();
(new ApiTokenExpirationWarningJob)->handle();
Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 1);
});
test('skips tokens that already have an expiration warning marker', function () {
createTokenExpiring($this->user, $this->team, now()->addHours(12), now()->subHour());
(new ApiTokenExpirationWarningJob)->handle();
Notification::assertNothingSent();
});
test('notifies once for each unmarked expiring token', function () {
createTokenExpiring($this->user, $this->team, now()->addHours(12));
createTokenExpiring($this->user, $this->team, now()->addHours(23));
(new ApiTokenExpirationWarningJob)->handle();
Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 2);
});
test('skips tokens expiring more than 24h out', function () {
createTokenExpiring($this->user, $this->team, now()->addDays(3));