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; namespace App\Console;
use App\Jobs\ApiTokenExpirationWarningJob;
use App\Jobs\CheckForUpdatesJob; use App\Jobs\CheckForUpdatesJob;
use App\Jobs\CheckHelperImageJob; use App\Jobs\CheckHelperImageJob;
use App\Jobs\CheckTraefikVersionJob; use App\Jobs\CheckTraefikVersionJob;
@ -41,6 +42,8 @@ protected function schedule(Schedule $schedule): void
// $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly(); // $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly();
$this->scheduleInstance->command('cleanup:redis --clear-locks')->daily(); $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()) { if (isDev()) {
// Instance Jobs // 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 ?string $description = null;
public ?int $expiresInDays = 30;
public $tokens = []; public $tokens = [];
public array $permissions = ['read']; 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 $isApiEnabled;
public bool $canUseRootPermissions = false; public bool $canUseRootPermissions = false;
@ -90,8 +100,10 @@ public function addNewToken()
$this->validate([ $this->validate([
'description' => 'required|min:3|max:255', '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(); $this->getTokens();
session()->flash('token', $token->plainTextToken); session()->flash('token', $token->plainTextToken);
} catch (\Exception $e) { } 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', 'test',
'ssl_certificate_renewal', 'ssl_certificate_renewal',
'hetzner_deletion_failure', '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> <h3>New Token</h3>
@can('create', App\Models\PersonalAccessToken::class) @can('create', App\Models\PersonalAccessToken::class)
<form class="flex flex-col gap-2" wire:submit='addNewToken'> <form class="flex flex-col gap-2" wire:submit='addNewToken'>
<div class="flex gap-2 items-end w-96"> <div class="flex gap-2 items-end w-lg">
<x-forms.input required id="description" label="Description" /> <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> <x-forms.button type="submit">Create</x-forms.button>
</div> </div>
<div class="flex"> <div class="flex">
Permissions Permissions
<x-helper class="px-1" helper="These permissions will be granted to the token." /><span <span
class="pr-1">:</span> class="pr-1">:</span>
<div class="flex gap-1 font-bold dark:text-white"> <div class="flex gap-1 font-bold dark:text-white">
@if ($permissions) @if ($permissions)
@ -31,7 +37,6 @@ class="pr-1">:</span>
</div> </div>
</div> </div>
<h4>Token Permissions</h4>
<div class="w-64"> <div class="w-64">
@if ($canUseRootPermissions) @if ($canUseRootPermissions)
<x-forms.checkbox label="root" wire:model.live="permissions" domValue="root" <x-forms.checkbox label="root" wire:model.live="permissions" domValue="root"
@ -71,23 +76,55 @@ class="pr-1">:</span>
<div class="pb-4 font-bold dark:text-white"> {{ session('token') }}</div> <div class="pb-4 font-bold dark:text-white"> {{ session('token') }}</div>
@endif @endif
<h3 class="py-4">Issued Tokens</h3> <h3 class="py-4">Issued Tokens</h3>
<div class="grid gap-2 lg:grid-cols-1"> <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) @forelse ($tokens as $token)
<div wire:key="token-{{ $token->id }}" <tr wire:key="token-{{ $token->id }}">
class="flex flex-col gap-1 p-2 border dark:border-coolgray-200 hover:no-underline"> <td class="px-5 py-4 text-sm whitespace-nowrap">{{ $token->name }}</td>
<div>Description: {{ $token->name }}</div> <td class="px-5 py-4 text-sm whitespace-nowrap">
<div>Last used: {{ $token->last_used_at ? $token->last_used_at->diffForHumans() : 'Never' }}</div>
<div class="flex gap-1">
@if ($token->abilities) @if ($token->abilities)
Permissions: <div class="flex gap-1">
@foreach ($token->abilities as $ability) @foreach ($token->abilities as $ability)
<div class="font-bold dark:text-white">{{ $ability }}</div> <div class="font-bold dark:text-white">{{ $ability }}</div>
@endforeach @endforeach
@endif
</div> </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) @if (auth()->id() === $token->tokenable_id)
<x-modal-confirmation title="Confirm API Token Revocation?" isErrorButton buttonTitle="Revoke token" <x-modal-confirmation title="Confirm API Token Revocation?" isErrorButton
buttonTitle="Revoke token"
submitAction="revoke({{ data_get($token, 'id') }})" :actions="[ submitAction="revoke({{ data_get($token, 'id') }})" :actions="[
'This API Token will be revoked and permanently deleted.', 'This API Token will be revoked and permanently deleted.',
'Any API call made with this token will fail.', 'Any API call made with this token will fail.',
@ -97,12 +134,20 @@ class="flex flex-col gap-1 p-2 border dark:border-coolgray-200 hover:no-underlin
shortConfirmationLabel="API Token Description" :confirmWithPassword="false" shortConfirmationLabel="API Token Description" :confirmWithPassword="false"
step2ButtonText="Revoke API Token" /> step2ButtonText="Revoke API Token" />
@endif @endif
</div> </td>
</tr>
@empty @empty
<div> <tr>
<div>No API tokens found.</div> <td class="px-5 py-4 text-sm whitespace-nowrap" colspan="6">No API tokens found.
</div> </td>
</tr>
@endforelse @endforelse
</tbody>
</table>
</div>
</div>
</div>
</div>
</div> </div>
@endif @endif
</div> </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();
});
});