option('kid') ?: 'kid-'.now()->format('YmdHis'); if (! $this->option('force') && ! $this->confirm("Rotate JWT keys? Current kid: {$currentKid}. New kid: {$newKid}", true) ) { $this->info('Rotation cancelled.'); return self::SUCCESS; } File::ensureDirectoryExists($storage); $archiveDir = $this->archiveExistingKeys($storage, $currentKid); $newDirectory = $storage.DIRECTORY_SEPARATOR.$newKid; if (File::exists($newDirectory)) { $this->error("Target directory already exists: {$newDirectory}"); return self::FAILURE; } File::makeDirectory($newDirectory, 0700, true); $this->generateKeyPair($newDirectory); $this->info('New signing keys generated.'); $this->line("Path: {$newDirectory}"); if ($archiveDir) { $this->line("Previous keys archived at: {$archiveDir}"); $this->line('Existing key remains available for token verification until you prune it.'); } $this->warn("Update OAUTH_JWT_KID in your environment configuration to: {$newKid}"); $this->info('Run `php artisan oauth:list-keys` to verify active signing directories.'); $this->info('Once legacy tokens expire, run `php artisan oauth:prune-keys` to remove retired keys.'); return self::SUCCESS; } private function archiveExistingKeys(string $storage, string $kid): ?string { $existingDir = $storage.DIRECTORY_SEPARATOR.$kid; $legacyPublic = storage_path('app/public.key'); $legacyPrivate = storage_path('app/private.key'); if (File::exists($existingDir)) { $archiveDir = $storage.DIRECTORY_SEPARATOR.'archive'.DIRECTORY_SEPARATOR.$kid.'-'.now()->format('YmdHis'); File::ensureDirectoryExists(dirname($archiveDir)); File::copyDirectory($existingDir, $archiveDir); return $archiveDir; } if (File::exists($legacyPublic) || File::exists($legacyPrivate)) { $archiveDir = $storage.DIRECTORY_SEPARATOR.'archive'.DIRECTORY_SEPARATOR.'legacy-'.now()->format('YmdHis'); File::ensureDirectoryExists($archiveDir); if (File::exists($legacyPublic)) { File::copy($legacyPublic, $archiveDir.DIRECTORY_SEPARATOR.'public.key'); } if (File::exists($legacyPrivate)) { File::copy($legacyPrivate, $archiveDir.DIRECTORY_SEPARATOR.'private.key'); } return $archiveDir; } return null; } private function generateKeyPair(string $directory): void { $config = [ 'digest_alg' => OPENSSL_ALGO_SHA256, 'private_key_bits' => 4096, 'private_key_type' => OPENSSL_KEYTYPE_RSA, ]; $resource = openssl_pkey_new($config); if (! $resource) { throw new \RuntimeException('Failed to generate key pair'); } openssl_pkey_export($resource, $privateKey); $details = openssl_pkey_get_details($resource); $publicKey = $details['key'] ?? null; if (! $publicKey) { throw new \RuntimeException('Unable to extract public key'); } File::put($directory.DIRECTORY_SEPARATOR.'private.key', $privateKey); File::chmod($directory.DIRECTORY_SEPARATOR.'private.key', 0600); File::put($directory.DIRECTORY_SEPARATOR.'public.key', $publicKey); File::chmod($directory.DIRECTORY_SEPARATOR.'public.key', 0644); } }