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 @@
+