From 90ddbb357231ca3808f277eb87a63c8f650417e6 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:28:38 +0200 Subject: [PATCH] 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 --- app/Console/Kernel.php | 3 + app/Jobs/ApiTokenExpirationWarningJob.php | 49 ++++++++ app/Livewire/Security/ApiTokens.php | 14 ++- .../ApiTokenExpiringNotification.php | 103 ++++++++++++++++ app/Traits/HasNotificationSettings.php | 1 + .../views/emails/api-token-expiring.blade.php | 7 ++ .../livewire/security/api-tokens.blade.php | 113 ++++++++++++------ tests/Feature/ApiTokenExpirationTest.php | 81 +++++++++++++ .../Feature/ApiTokenExpirationWarningTest.php | 83 +++++++++++++ 9 files changed, 419 insertions(+), 35 deletions(-) create mode 100644 app/Jobs/ApiTokenExpirationWarningJob.php create mode 100644 app/Notifications/ApiTokenExpiringNotification.php create mode 100644 resources/views/emails/api-token-expiring.blade.php create mode 100644 tests/Feature/ApiTokenExpirationTest.php create mode 100644 tests/Feature/ApiTokenExpirationWarningTest.php diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index c5e12b7ee..75ec31ae0 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -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 diff --git a/app/Jobs/ApiTokenExpirationWarningJob.php b/app/Jobs/ApiTokenExpirationWarningJob.php new file mode 100644 index 000000000..a8f388c85 --- /dev/null +++ b/app/Jobs/ApiTokenExpirationWarningJob.php @@ -0,0 +1,49 @@ +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, + ); + } + }); + } +} diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php index a263acedf..37d5332f3 100644 --- a/app/Livewire/Security/ApiTokens.php +++ b/app/Livewire/Security/ApiTokens.php @@ -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) { diff --git a/app/Notifications/ApiTokenExpiringNotification.php b/app/Notifications/ApiTokenExpiringNotification.php new file mode 100644 index 000000000..451dd312a --- /dev/null +++ b/app/Notifications/ApiTokenExpiringNotification.php @@ -0,0 +1,103 @@ +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 {$this->tokenName} expires on {$this->expiresAt}.

"; + $message .= 'Action Required: 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(), + ); + } +} diff --git a/app/Traits/HasNotificationSettings.php b/app/Traits/HasNotificationSettings.php index fded435fd..9333eb504 100644 --- a/app/Traits/HasNotificationSettings.php +++ b/app/Traits/HasNotificationSettings.php @@ -19,6 +19,7 @@ trait HasNotificationSettings 'test', 'ssl_certificate_renewal', 'hetzner_deletion_failure', + 'api_token_expiring', ]; /** diff --git a/resources/views/emails/api-token-expiring.blade.php b/resources/views/emails/api-token-expiring.blade.php new file mode 100644 index 000000000..18871f6dc --- /dev/null +++ b/resources/views/emails/api-token-expiring.blade.php @@ -0,0 +1,7 @@ + +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 }}). + diff --git a/resources/views/livewire/security/api-tokens.blade.php b/resources/views/livewire/security/api-tokens.blade.php index 23f0e263e..69eab3e70 100644 --- a/resources/views/livewire/security/api-tokens.blade.php +++ b/resources/views/livewire/security/api-tokens.blade.php @@ -14,13 +14,19 @@

New Token

@can('create', App\Models\PersonalAccessToken::class)
-
- +
+ + + @foreach ($expirationOptions as $days => $label) + + @endforeach + + Create
Permissions - :
@if ($permissions) @@ -31,7 +37,6 @@ class="pr-1">:
-

Token Permissions

@if ($canUseRootPermissions) :
{{ session('token') }}
@endif

Issued Tokens

-
- @forelse ($tokens as $token) -
-
Description: {{ $token->name }}
-
Last used: {{ $token->last_used_at ? $token->last_used_at->diffForHumans() : 'Never' }}
-
- @if ($token->abilities) - Permissions: - @foreach ($token->abilities as $ability) -
{{ $ability }}
- @endforeach - @endif +
+
+
+
+
+ + + + + + + + + + + + + @forelse ($tokens as $token) + + + + + + + + + @empty + + + + @endforelse + +
DescriptionPermissionsLast usedCreatedExpiresActions
{{ $token->name }} + @if ($token->abilities) +
+ @foreach ($token->abilities as $ability) +
{{ $ability }}
+ @endforeach +
+ @endif +
+ {{ $token->last_used_at ? $token->last_used_at->diffForHumans() : 'Never' }} + + {{ $token->created_at->diffForHumans() }} + + @if (! $token->expires_at) + Never + @elseif ($token->expires_at->isPast()) + Expired + {{ $token->expires_at->format('Y-m-d H:i:s') }} + @else + {{ $token->expires_at->format('Y-m-d H:i:s') }} + @endif + + @if (auth()->id() === $token->tokenable_id) + + @endif +
No API tokens found. +
+
- - @if (auth()->id() === $token->tokenable_id) - - @endif
- @empty -
-
No API tokens found.
-
- @endforelse +
@endif
diff --git a/tests/Feature/ApiTokenExpirationTest.php b/tests/Feature/ApiTokenExpirationTest.php new file mode 100644 index 000000000..99a952848 --- /dev/null +++ b/tests/Feature/ApiTokenExpirationTest.php @@ -0,0 +1,81 @@ +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); + }); +}); diff --git a/tests/Feature/ApiTokenExpirationWarningTest.php b/tests/Feature/ApiTokenExpirationWarningTest.php new file mode 100644 index 000000000..5255581dd --- /dev/null +++ b/tests/Feature/ApiTokenExpirationWarningTest.php @@ -0,0 +1,83 @@ +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(); + }); +});