From 0c5f6ffdac9bf5d0d1784fd78d99d5672bb428a3 Mon Sep 17 00:00:00 2001 From: DaneEveritt Date: Sat, 23 May 2026 10:20:00 -0700 Subject: [PATCH 1/2] Attach a scope(s) to JWTs created by the panel --- app/Enum/JwtScope.php | 12 ++++ .../Servers/ServerTransferController.php | 4 +- .../Api/Client/Servers/FileController.php | 2 + .../Client/Servers/FileUploadController.php | 2 + .../Client/Servers/WebsocketController.php | 2 + app/Services/Backups/DownloadLinkService.php | 2 + app/Services/Nodes/NodeJWTService.php | 28 ++++++---- .../Client/Server/WebsocketControllerTest.php | 29 ++++++---- .../Backups/DownloadLinkServiceTest.php | 55 +++++++++++++++++++ 9 files changed, 114 insertions(+), 22 deletions(-) create mode 100644 app/Enum/JwtScope.php create mode 100644 tests/Integration/Services/Backups/DownloadLinkServiceTest.php diff --git a/app/Enum/JwtScope.php b/app/Enum/JwtScope.php new file mode 100644 index 0000000000..210968977b --- /dev/null +++ b/app/Enum/JwtScope.php @@ -0,0 +1,12 @@ +nodeJWTService ->setExpiresAt(CarbonImmutable::now()->addMinutes(15)) ->setSubject($server->uuid) - ->handle($transfer->newNode, $server->uuid, 'sha256'); + ->setScopes(JwtScope::ServerTransfer) + ->handle($transfer->newNode, $server->uuid); // Notify the source node of the pending outgoing transfer. $this->daemonTransferRepository->setServer($server)->notify($transfer->newNode, $token); diff --git a/app/Http/Controllers/Api/Client/Servers/FileController.php b/app/Http/Controllers/Api/Client/Servers/FileController.php index bce98f9190..d5ee9e70eb 100644 --- a/app/Http/Controllers/Api/Client/Servers/FileController.php +++ b/app/Http/Controllers/Api/Client/Servers/FileController.php @@ -4,6 +4,7 @@ use Carbon\CarbonImmutable; use Illuminate\Http\Response; +use Pterodactyl\Enum\JwtScope; use Pterodactyl\Models\Server; use Illuminate\Http\JsonResponse; use Pterodactyl\Facades\Activity; @@ -83,6 +84,7 @@ public function download(GetFileContentsRequest $request, Server $server): array 'file_path' => rawurldecode($request->get('file')), 'server_uuid' => $server->uuid, ]) + ->setScopes(JwtScope::FileDownload) ->handle($server->node, $request->user()->id . $server->uuid); Activity::event('server:file.download')->property('file', $request->get('file'))->log(); diff --git a/app/Http/Controllers/Api/Client/Servers/FileUploadController.php b/app/Http/Controllers/Api/Client/Servers/FileUploadController.php index d2741e2f41..ed86ac7c67 100644 --- a/app/Http/Controllers/Api/Client/Servers/FileUploadController.php +++ b/app/Http/Controllers/Api/Client/Servers/FileUploadController.php @@ -4,6 +4,7 @@ use Carbon\CarbonImmutable; use Pterodactyl\Models\User; +use Pterodactyl\Enum\JwtScope; use Pterodactyl\Models\Server; use Illuminate\Http\JsonResponse; use Pterodactyl\Services\Nodes\NodeJWTService; @@ -43,6 +44,7 @@ protected function getUploadUrl(Server $server, User $user): string ->setExpiresAt(CarbonImmutable::now()->addMinutes(15)) ->setUser($user) ->setClaims(['server_uuid' => $server->uuid]) + ->setScopes(JwtScope::FileUpload) ->handle($server->node, $user->id . $server->uuid); return sprintf( diff --git a/app/Http/Controllers/Api/Client/Servers/WebsocketController.php b/app/Http/Controllers/Api/Client/Servers/WebsocketController.php index 9a3a1f509d..a949bd0a72 100644 --- a/app/Http/Controllers/Api/Client/Servers/WebsocketController.php +++ b/app/Http/Controllers/Api/Client/Servers/WebsocketController.php @@ -3,6 +3,7 @@ namespace Pterodactyl\Http\Controllers\Api\Client\Servers; use Carbon\CarbonImmutable; +use Pterodactyl\Enum\JwtScope; use Pterodactyl\Models\Server; use Illuminate\Http\JsonResponse; use Pterodactyl\Models\Permission; @@ -59,6 +60,7 @@ public function __invoke(ClientApiRequest $request, Server $server): JsonRespons 'server_uuid' => $server->uuid, 'permissions' => $permissions, ]) + ->setScopes(JwtScope::Websocket) ->handle($node, $user->id . $server->uuid); $socket = str_replace(['https://', 'http://'], ['wss://', 'ws://'], $node->getConnectionAddress()); diff --git a/app/Services/Backups/DownloadLinkService.php b/app/Services/Backups/DownloadLinkService.php index f3f76c8451..f27d747c3b 100644 --- a/app/Services/Backups/DownloadLinkService.php +++ b/app/Services/Backups/DownloadLinkService.php @@ -4,6 +4,7 @@ use Carbon\CarbonImmutable; use Pterodactyl\Models\User; +use Pterodactyl\Enum\JwtScope; use Pterodactyl\Models\Backup; use Pterodactyl\Services\Nodes\NodeJWTService; use Pterodactyl\Extensions\Backups\BackupManager; @@ -34,6 +35,7 @@ public function handle(Backup $backup, User $user): string 'backup_uuid' => $backup->uuid, 'server_uuid' => $backup->server->uuid, ]) + ->setScopes(JwtScope::BackupDownload) ->handle($backup->server->node, $user->id . $backup->server->uuid); return sprintf('%s/download/backup?token=%s', $backup->server->node->getConnectionAddress(), $token->toString()); diff --git a/app/Services/Nodes/NodeJWTService.php b/app/Services/Nodes/NodeJWTService.php index 843cb0b749..e757f49ad2 100644 --- a/app/Services/Nodes/NodeJWTService.php +++ b/app/Services/Nodes/NodeJWTService.php @@ -6,6 +6,8 @@ use Illuminate\Support\Str; use Pterodactyl\Models\Node; use Pterodactyl\Models\User; +use Webmozart\Assert\Assert; +use Pterodactyl\Enum\JwtScope; use Lcobucci\JWT\Configuration; use Lcobucci\JWT\UnencryptedToken; use Lcobucci\JWT\Signer\Hmac\Sha256; @@ -16,6 +18,8 @@ class NodeJWTService { private array $claims = []; + private array $scopes; + private ?User $user = null; private \DateTimeImmutable $expiresAt; @@ -32,6 +36,13 @@ public function setClaims(array $claims): self return $this; } + public function setScopes(JwtScope ...$scopes): self + { + $this->scopes = $scopes; + + return $this; + } + /** * Attaches a user to the JWT being created and will automatically inject the * "user_uuid" key into the final claims array with the user's UUID. @@ -60,9 +71,9 @@ public function setSubject(string $subject): self /** * Generate a new JWT for a given node. */ - public function handle(Node $node, ?string $identifiedBy, string $algo = 'md5'): UnencryptedToken + public function handle(Node $node, ?string $identifiedBy): UnencryptedToken { - $identifier = hash($algo, $identifiedBy); + $identifier = hash('sha256', $identifiedBy); $config = Configuration::forSymmetricSigner(new Sha256(), InMemory::plainText($node->getDecryptedKey())); $builder = $config->builder(new TimestampDates()) @@ -85,15 +96,12 @@ public function handle(Node $node, ?string $identifiedBy, string $algo = 'md5'): $builder = $builder->withClaim($key, $value); } + Assert::notEmpty($this->scopes, 'Cannot generate a JWT without providing at least one scope.'); + + $builder = $builder->withClaim('scope', implode(' ', array_map(fn ($scope) => $scope->value, $this->scopes))); + if (!is_null($this->user)) { - $builder = $builder - ->withClaim('user_uuid', $this->user->uuid) - // The "user_id" claim is deprecated and should not be referenced — it remains - // here solely to ensure older versions of Wings are unaffected when the Panel - // is updated. - // - // This claim will be removed in Panel@1.11 or later. - ->withClaim('user_id', $this->user->id); + $builder = $builder->withClaim('user_uuid', $this->user->uuid); } return $builder diff --git a/tests/Integration/Api/Client/Server/WebsocketControllerTest.php b/tests/Integration/Api/Client/Server/WebsocketControllerTest.php index 66bc27af4c..76eee97bb1 100644 --- a/tests/Integration/Api/Client/Server/WebsocketControllerTest.php +++ b/tests/Integration/Api/Client/Server/WebsocketControllerTest.php @@ -4,6 +4,7 @@ use Carbon\CarbonImmutable; use Illuminate\Http\Response; +use Pterodactyl\Enum\JwtScope; use Lcobucci\JWT\Configuration; use Pterodactyl\Models\Permission; use Lcobucci\JWT\Signer\Hmac\Sha256; @@ -53,17 +54,19 @@ public function testJwtAndWebsocketUrlAreReturnedForServerOwner() $server->node->scheme = 'https'; $server->node->save(); - $response = $this->actingAs($user)->getJson("/api/client/servers/$server->uuid/websocket"); - - $response->assertOk(); - $response->assertJsonStructure(['data' => ['token', 'socket']]); + $response = $this->actingAs($user) + ->withoutExceptionHandling() + ->getJson("/api/client/servers/$server->uuid/websocket") + ->assertOk() + ->assertJsonStructure(['data' => ['token', 'socket']]); $connection = $response->json('data.socket'); $this->assertStringStartsWith('wss://', $connection, 'Failed asserting that websocket connection address has expected "wss://" prefix.'); $this->assertStringEndsWith("/api/servers/$server->uuid/ws", $connection, 'Failed asserting that websocket connection address uses expected Wings endpoint.'); $config = Configuration::forSymmetricSigner(new Sha256(), $key = InMemory::plainText($server->node->getDecryptedKey())); - $config->setValidationConstraints(new SignedWith(new Sha256(), $key)); + $config = $config->withValidationConstraints(new SignedWith(new Sha256(), $key)); + /** @var \Lcobucci\JWT\Token\Plain $token */ $token = $config->parser()->parse($response->json('data.token')); @@ -86,9 +89,10 @@ public function testJwtAndWebsocketUrlAreReturnedForServerOwner() $this->assertEquals($expect, $token->claims()->get('iat')); $this->assertEquals($expect->subMinutes(5), $token->claims()->get('nbf')); $this->assertEquals($expect->addMinutes(10), $token->claims()->get('exp')); - $this->assertSame($user->id, $token->claims()->get('user_id')); + $this->assertSame($user->uuid, $token->claims()->get('user_uuid')); $this->assertSame($server->uuid, $token->claims()->get('server_uuid')); $this->assertSame(['*'], $token->claims()->get('permissions')); + $this->assertEquals(JwtScope::Websocket->value, $token->claims()->get('scope')); } /** @@ -102,13 +106,15 @@ public function testJwtIsConfiguredCorrectlyForServerSubuser() /** @var \Pterodactyl\Models\Server $server */ [$user, $server] = $this->generateTestAccount($permissions); - $response = $this->actingAs($user)->getJson("/api/client/servers/$server->uuid/websocket"); - - $response->assertOk(); - $response->assertJsonStructure(['data' => ['token', 'socket']]); + $response = $this->actingAs($user) + ->withoutExceptionHandling() + ->getJson("/api/client/servers/$server->uuid/websocket") + ->assertOk() + ->assertJsonStructure(['data' => ['token', 'socket']]); $config = Configuration::forSymmetricSigner(new Sha256(), $key = InMemory::plainText($server->node->getDecryptedKey())); - $config->setValidationConstraints(new SignedWith(new Sha256(), $key)); + $config = $config->withValidationConstraints(new SignedWith(new Sha256(), $key)); + /** @var \Lcobucci\JWT\Token\Plain $token */ $token = $config->parser()->parse($response->json('data.token')); @@ -119,5 +125,6 @@ public function testJwtIsConfiguredCorrectlyForServerSubuser() // Check that the claims are generated correctly. $this->assertSame($permissions, $token->claims()->get('permissions')); + $this->assertEquals(JwtScope::Websocket->value, $token->claims()->get('scope')); } } diff --git a/tests/Integration/Services/Backups/DownloadLinkServiceTest.php b/tests/Integration/Services/Backups/DownloadLinkServiceTest.php new file mode 100644 index 0000000000..94535a70d7 --- /dev/null +++ b/tests/Integration/Services/Backups/DownloadLinkServiceTest.php @@ -0,0 +1,55 @@ +createServerModel(); + $backup = Backup::factory()->for($server)->create([ + 'disk' => Backup::ADAPTER_WINGS, + ]); + + $url = $this->app->make(DownloadLinkService::class)->handle($backup, $server->user); + + $this->assertStringStartsWith($prefix = $server->node->getConnectionAddress() . '/download/backup?token=', $url); + + $config = Configuration::forSymmetricSigner(new Sha256(), $key = InMemory::plainText($server->node->getDecryptedKey())); + $config = $config->withValidationConstraints(new SignedWith(new Sha256(), $key)); + + /** @var \Lcobucci\JWT\Token\Plain $token */ + $token = $config->parser()->parse(substr($url, strlen($prefix))); + + $this->assertTrue( + $config->validator()->validate($token, ...$config->validationConstraints()), + 'Failed to validate that the JWT data returned was signed using the Node\'s secret key.' + ); + + $timestamp = CarbonImmutable::createFromTimestamp(CarbonImmutable::now()->getTimestamp())->timezone('UTC'); + + // Check that the claims are generated correctly. + $this->assertTrue($token->hasBeenIssuedBy(config('app.url'))); + $this->assertTrue($token->isPermittedFor($server->node->getConnectionAddress())); + $this->assertEquals($timestamp, $token->claims()->get('iat')); + $this->assertEquals($timestamp->subMinutes(5), $token->claims()->get('nbf')); + $this->assertEquals($timestamp->addMinutes(15), $token->claims()->get('exp')); + $this->assertSame($backup->uuid, $token->claims()->get('backup_uuid')); + $this->assertSame($server->uuid, $token->claims()->get('server_uuid')); + $this->assertEquals(JwtScope::BackupDownload->value, $token->claims()->get('scope')); + } +} From 6993a14ee9ab9b58144657edb1413ec746e9f0fa Mon Sep 17 00:00:00 2001 From: DaneEveritt Date: Sat, 23 May 2026 11:02:31 -0700 Subject: [PATCH 2/2] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ec603c339..55aa8ab7eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ This project follows [Semantic Versioning](http://semver.org) guidelines. ### Changed * Added Java 25 as an option to the default Minecraft eggs. * Updates Paper install script and adds support for Java 25 to default egg. +* JWTs now require at least one `JwtScope` enum value to be set when generating. Failure to provide a scope will result in an exception being raised. ## v1.12.2 ### Fixed