feat(security): support expiration on API tokens with warning notifications

Add optional expiration to personal API tokens. Users pick a duration
(1/7/30/60/90 days or Never) at creation time. Expired tokens are
rejected by Sanctum, pruned hourly by sanctum:prune-expired, and a
team notification fires ~24h before expiry so owners can rotate
before API calls start failing.

- ApiTokens Livewire component stores expires_at from expiresInDays
- Rework issued-tokens UI from card grid to table (matches other views)
- New ApiTokenExpirationWarningJob scheduled hourly (idempotent via RateLimiter)
- New ApiTokenExpiringNotification (email/discord/telegram/slack/pushover)
- api_token_expiring added to alwaysSendEvents so users cannot silence
  expiry warnings from the per-event notification toggle UI
- sanctum:prune-expired cadence moved from daily to hourly

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai 2026-04-20 14:28:38 +02:00
parent bff6d85370
commit 90ddbb3572
9 changed files with 419 additions and 35 deletions

View file

@ -2,6 +2,7 @@
namespace App\Console;
use App\Jobs\ApiTokenExpirationWarningJob;
use App\Jobs\CheckForUpdatesJob;
use App\Jobs\CheckHelperImageJob;
use App\Jobs\CheckTraefikVersionJob;
@ -41,6 +42,8 @@ protected function schedule(Schedule $schedule): void
// $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly();
$this->scheduleInstance->command('cleanup:redis --clear-locks')->daily();
$this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer();
$this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer();
if (isDev()) {
// Instance Jobs

View file

@ -0,0 +1,49 @@
<?php
namespace App\Jobs;
use App\Models\PersonalAccessToken;
use App\Models\Team;
use App\Models\User;
use App\Notifications\ApiTokenExpiringNotification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
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
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 1;
public $timeout = 120;
public function handle(): void
{
PersonalAccessToken::query()
->whereNotNull('expires_at')
->where('expires_at', '>', now())
->where('expires_at', '<=', now()->addDay())
->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,
);
}
});
}
}

View file

@ -13,10 +13,20 @@ class ApiTokens extends Component
public ?string $description = null;
public ?int $expiresInDays = 30;
public $tokens = [];
public array $permissions = ['read'];
public array $expirationOptions = [
7 => '7 days',
30 => '30 days',
60 => '60 days',
90 => '90 days',
365 => '1 year',
];
public $isApiEnabled;
public bool $canUseRootPermissions = false;
@ -90,8 +100,10 @@ public function addNewToken()
$this->validate([
'description' => 'required|min:3|max:255',
'expiresInDays' => 'nullable|integer|in:7,30,60,90,365',
]);
$token = auth()->user()->createToken($this->description, array_values($this->permissions));
$expiresAt = $this->expiresInDays ? now()->addDays($this->expiresInDays) : null;
$token = auth()->user()->createToken($this->description, array_values($this->permissions), $expiresAt);
$this->getTokens();
session()->flash('token', $token->plainTextToken);
} catch (\Exception $e) {

View file

@ -0,0 +1,103 @@
<?php
namespace App\Notifications;
use App\Models\PersonalAccessToken;
use App\Notifications\Dto\DiscordMessage;
use App\Notifications\Dto\PushoverMessage;
use App\Notifications\Dto\SlackMessage;
use Illuminate\Notifications\Messages\MailMessage;
class ApiTokenExpiringNotification extends CustomEmailNotification
{
protected string $tokenName;
protected string $expiresAt;
protected string $manageUrl;
public function __construct(public PersonalAccessToken $token)
{
$this->onQueue('high');
$this->tokenName = $token->name;
$this->expiresAt = $token->expires_at?->format('Y-m-d H:i:s') ?? '';
$this->manageUrl = route('security.api-tokens');
}
public function via(object $notifiable): array
{
return $notifiable->getEnabledChannels('api_token_expiring');
}
public function toMail(): MailMessage
{
$mail = new MailMessage;
$mail->subject("Coolify: API token '{$this->tokenName}' expires in 24 hours");
$mail->view('emails.api-token-expiring', [
'tokenName' => $this->tokenName,
'expiresAt' => $this->expiresAt,
'manageUrl' => $this->manageUrl,
]);
return $mail;
}
public function toDiscord(): DiscordMessage
{
$message = new DiscordMessage(
title: '🔑 API token expiring soon',
description: "API token **{$this->tokenName}** expires on {$this->expiresAt}.\n\n**Action Required:** Rotate this token before it expires to avoid API outages.",
color: DiscordMessage::warningColor(),
);
$message->addField('Manage tokens', "[Open Security settings]({$this->manageUrl})");
return $message;
}
public function toTelegram(): array
{
$message = "Coolify: API token '{$this->tokenName}' expires on {$this->expiresAt}.\n\nAction Required: Rotate this token before it expires to avoid API outages.";
return [
'message' => $message,
'buttons' => [
[
'text' => 'Manage API tokens',
'url' => $this->manageUrl,
],
],
];
}
public function toPushover(): PushoverMessage
{
$message = "API token <b>{$this->tokenName}</b> expires on {$this->expiresAt}.<br/><br/>";
$message .= '<b>Action Required:</b> Rotate this token before it expires to avoid API outages.';
return new PushoverMessage(
title: 'API token expiring soon',
level: 'warning',
message: $message,
buttons: [
[
'text' => 'Manage API tokens',
'url' => $this->manageUrl,
],
],
);
}
public function toSlack(): SlackMessage
{
$description = "API token *{$this->tokenName}* expires on {$this->expiresAt}.\n\n";
$description .= "*Action Required:* Rotate this token before it expires to avoid API outages.\n\n";
$description .= "Manage tokens: {$this->manageUrl}";
return new SlackMessage(
title: '🔑 API token expiring soon',
description: $description,
color: SlackMessage::warningColor(),
);
}
}

View file

@ -19,6 +19,7 @@ trait HasNotificationSettings
'test',
'ssl_certificate_renewal',
'hetzner_deletion_failure',
'api_token_expiring',
];
/**

View file

@ -0,0 +1,7 @@
<x-emails.layout>
Your Coolify API token ({{ $tokenName }}) expires on {{ $expiresAt }}.
Rotate this token before it expires. API calls using this token will start failing once the expiration time is reached.
Manage your API tokens [here]({{ $manageUrl }}).
</x-emails.layout>

View file

@ -14,13 +14,19 @@
<h3>New Token</h3>
@can('create', App\Models\PersonalAccessToken::class)
<form class="flex flex-col gap-2" wire:submit='addNewToken'>
<div class="flex gap-2 items-end w-96">
<x-forms.input required id="description" label="Description" />
<div class="flex gap-2 items-end w-lg">
<x-forms.input class="w-64" required id="description" label="Description" />
<x-forms.select id="expiresInDays" label="Expires in" wire:model="expiresInDays">
@foreach ($expirationOptions as $days => $label)
<option value="{{ $days }}">{{ $label }}</option>
@endforeach
<option value="">Never</option>
</x-forms.select>
<x-forms.button type="submit">Create</x-forms.button>
</div>
<div class="flex">
Permissions
<x-helper class="px-1" helper="These permissions will be granted to the token." /><span
<span
class="pr-1">:</span>
<div class="flex gap-1 font-bold dark:text-white">
@if ($permissions)
@ -31,7 +37,6 @@ class="pr-1">:</span>
</div>
</div>
<h4>Token Permissions</h4>
<div class="w-64">
@if ($canUseRootPermissions)
<x-forms.checkbox label="root" wire:model.live="permissions" domValue="root"
@ -71,38 +76,78 @@ class="pr-1">:</span>
<div class="pb-4 font-bold dark:text-white"> {{ session('token') }}</div>
@endif
<h3 class="py-4">Issued Tokens</h3>
<div class="grid gap-2 lg:grid-cols-1">
@forelse ($tokens as $token)
<div wire:key="token-{{ $token->id }}"
class="flex flex-col gap-1 p-2 border dark:border-coolgray-200 hover:no-underline">
<div>Description: {{ $token->name }}</div>
<div>Last used: {{ $token->last_used_at ? $token->last_used_at->diffForHumans() : 'Never' }}</div>
<div class="flex gap-1">
@if ($token->abilities)
Permissions:
@foreach ($token->abilities as $ability)
<div class="font-bold dark:text-white">{{ $ability }}</div>
@endforeach
@endif
<div class="flex flex-col">
<div class="flex flex-col">
<div class="overflow-x-auto">
<div class="inline-block min-w-full">
<div class="overflow-hidden">
<table class="min-w-full">
<thead>
<tr>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Description</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Permissions</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Last used</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Created</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Expires</th>
<th class="px-5 py-3 text-xs font-medium text-left uppercase">Actions</th>
</tr>
</thead>
<tbody>
@forelse ($tokens as $token)
<tr wire:key="token-{{ $token->id }}">
<td class="px-5 py-4 text-sm whitespace-nowrap">{{ $token->name }}</td>
<td class="px-5 py-4 text-sm whitespace-nowrap">
@if ($token->abilities)
<div class="flex gap-1">
@foreach ($token->abilities as $ability)
<div class="font-bold dark:text-white">{{ $ability }}</div>
@endforeach
</div>
@endif
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap">
{{ $token->last_used_at ? $token->last_used_at->diffForHumans() : 'Never' }}
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap">
{{ $token->created_at->diffForHumans() }}
</td>
<td class="px-5 py-4 text-sm whitespace-nowrap">
@if (! $token->expires_at)
Never
@elseif ($token->expires_at->isPast())
<span class="font-bold dark:text-error">Expired
{{ $token->expires_at->format('Y-m-d H:i:s') }}</span>
@else
{{ $token->expires_at->format('Y-m-d H:i:s') }}
@endif
</td>
<td class="px-5 py-4 text-sm font-medium whitespace-nowrap">
@if (auth()->id() === $token->tokenable_id)
<x-modal-confirmation title="Confirm API Token Revocation?" isErrorButton
buttonTitle="Revoke token"
submitAction="revoke({{ data_get($token, 'id') }})" :actions="[
'This API Token will be revoked and permanently deleted.',
'Any API call made with this token will fail.',
]"
confirmationText="{{ $token->name }}"
confirmationLabel="Please confirm the execution of the actions by entering the API Token Description below"
shortConfirmationLabel="API Token Description" :confirmWithPassword="false"
step2ButtonText="Revoke API Token" />
@endif
</td>
</tr>
@empty
<tr>
<td class="px-5 py-4 text-sm whitespace-nowrap" colspan="6">No API tokens found.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
@if (auth()->id() === $token->tokenable_id)
<x-modal-confirmation title="Confirm API Token Revocation?" isErrorButton buttonTitle="Revoke token"
submitAction="revoke({{ data_get($token, 'id') }})" :actions="[
'This API Token will be revoked and permanently deleted.',
'Any API call made with this token will fail.',
]"
confirmationText="{{ $token->name }}"
confirmationLabel="Please confirm the execution of the actions by entering the API Token Description below"
shortConfirmationLabel="API Token Description" :confirmWithPassword="false"
step2ButtonText="Revoke API Token" />
@endif
</div>
@empty
<div>
<div>No API tokens found.</div>
</div>
@endforelse
</div>
</div>
@endif
</div>

View file

@ -0,0 +1,81 @@
<?php
use App\Livewire\Security\ApiTokens;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->actingAs($this->user);
});
describe('token creation with expiration', function () {
test('livewire component stores expires_at when expiresInDays set', function () {
Livewire::test(ApiTokens::class)
->set('description', 'test-token')
->set('expiresInDays', 7)
->set('permissions', ['read'])
->call('addNewToken')
->assertHasNoErrors();
$token = $this->user->tokens()->latest()->first();
expect($token)->not->toBeNull()
->and($token->expires_at)->not->toBeNull()
->and($token->expires_at->diffInDays(now()))->toBeGreaterThanOrEqual(6)
->and($token->expires_at->diffInDays(now()))->toBeLessThanOrEqual(7);
});
test('livewire component stores null expires_at when expiresInDays null (Never)', function () {
Livewire::test(ApiTokens::class)
->set('description', 'never-token')
->set('expiresInDays', null)
->set('permissions', ['read'])
->call('addNewToken')
->assertHasNoErrors();
$token = $this->user->tokens()->latest()->first();
expect($token)->not->toBeNull()
->and($token->expires_at)->toBeNull();
});
test('livewire component rejects invalid expiresInDays value', function () {
Livewire::test(ApiTokens::class)
->set('description', 'bad-token')
->set('expiresInDays', 42)
->set('permissions', ['read'])
->call('addNewToken')
->assertHasErrors('expiresInDays');
});
});
describe('expired token rejected on API', function () {
test('request with expired token returns 401', function () {
$token = $this->user->createToken('expired', ['read'], now()->subDay());
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
])->getJson('/api/v1/projects');
$response->assertStatus(401);
});
test('request with non-expired token works', function () {
$token = $this->user->createToken('valid', ['read'], now()->addDay());
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token->plainTextToken,
])->getJson('/api/v1/projects');
$response->assertStatus(200);
});
});

View file

@ -0,0 +1,83 @@
<?php
use App\Jobs\ApiTokenExpirationWarningJob;
use App\Models\PersonalAccessToken;
use App\Models\Team;
use App\Models\User;
use App\Notifications\ApiTokenExpiringNotification;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Notification;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->team->emailNotificationSettings()->update(['use_instance_email_settings' => true]);
$this->team->discordNotificationSettings()->update([
'discord_enabled' => true,
'discord_webhook_url' => 'https://discord.com/api/webhooks/fake/fake',
]);
session(['currentTeam' => $this->team]);
$this->actingAs($this->user);
Cache::flush();
Notification::fake();
});
function createTokenExpiring(User $user, Team $team, ?Carbon $expiresAt): PersonalAccessToken
{
$plain = $user->createToken('t-'.uniqid(), ['read'], $expiresAt);
$token = $plain->accessToken;
$token->team_id = $team->id;
$token->save();
return $token->fresh();
}
describe('ApiTokenExpirationWarningJob', function () {
test('notifies team when token expires within 24h', function () {
createTokenExpiring($this->user, $this->team, now()->addHours(23));
(new ApiTokenExpirationWarningJob)->handle();
Notification::assertSentTo($this->team, ApiTokenExpiringNotification::class);
});
test('rate limiter prevents duplicate warnings on repeat runs', function () {
createTokenExpiring($this->user, $this->team, now()->addHours(12));
(new ApiTokenExpirationWarningJob)->handle();
(new ApiTokenExpirationWarningJob)->handle();
Notification::assertSentToTimes($this->team, ApiTokenExpiringNotification::class, 1);
});
test('skips tokens expiring more than 24h out', function () {
createTokenExpiring($this->user, $this->team, now()->addDays(3));
(new ApiTokenExpirationWarningJob)->handle();
Notification::assertNothingSent();
});
test('skips already-expired tokens', function () {
createTokenExpiring($this->user, $this->team, now()->subHour());
(new ApiTokenExpirationWarningJob)->handle();
Notification::assertNothingSent();
});
test('skips tokens with null expires_at', function () {
createTokenExpiring($this->user, $this->team, null);
(new ApiTokenExpirationWarningJob)->handle();
Notification::assertNothingSent();
});
});