diff --git a/src/Appwrite/Utopia/Response/Model/ResourceToken.php b/src/Appwrite/Utopia/Response/Model/ResourceToken.php index ef186c3d0b..73c3efee72 100644 --- a/src/Appwrite/Utopia/Response/Model/ResourceToken.php +++ b/src/Appwrite/Utopia/Response/Model/ResourceToken.php @@ -61,24 +61,40 @@ class ResourceToken extends Model public function filter(Document $document): Document { - $maxAge = PHP_INT_MAX; $expire = $document->getAttribute('expire'); - + $now = new \DateTime(); + + // Calculate expiration timestamp for JWT + $expTimestamp = null; if ($expire !== null) { - $now = new \DateTime(); $expiryDate = new \DateTime($expire); - - // set 1 min if expired, we check for expiry later on route hooks for validation! - $maxAge = min(60, $expiryDate->getTimestamp() - $now->getTimestamp()); + $secondsUntilExpiry = $expiryDate->getTimestamp() - $now->getTimestamp(); + + // If token is expired, set expiration to 1 minute from now + // We check for actual expiry later on route hooks for validation + if ($secondsUntilExpiry <= 0) { + $expTimestamp = $now->getTimestamp() + 60; + } else { + $expTimestamp = $expiryDate->getTimestamp(); + } } - $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $maxAge, 10); - $secret = $jwt->encode([ + // Use maxAge as fallback, but rely on exp in payload for actual expiration + $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', PHP_INT_MAX, 10); + + $payload = [ 'tokenId' => $document->getId(), 'resourceId' => $document->getAttribute('resourceId'), 'resourceType' => $document->getAttribute('resourceType'), 'resourceInternalId' => $document->getAttribute('resourceInternalId'), - ]); + ]; + + // Set explicit expiration in JWT payload if we have an expiry date + if ($expTimestamp !== null) { + $payload['exp'] = $expTimestamp; + } + + $secret = $jwt->encode($payload); $document->setAttribute('secret', $secret); diff --git a/tests/e2e/Services/Tokens/TokensConsoleClientTest.php b/tests/e2e/Services/Tokens/TokensConsoleClientTest.php index 4a7aab474a..d8bcd946ef 100644 --- a/tests/e2e/Services/Tokens/TokensConsoleClientTest.php +++ b/tests/e2e/Services/Tokens/TokensConsoleClientTest.php @@ -107,6 +107,44 @@ class TokensConsoleClientTest extends Scope return $data; } + /** + * @depends testCreateToken + */ + public function testExpiredTokenJWT(array $data): array + { + $fileId = $data['fileId']; + $bucketId = $data['bucketId']; + + // Create a token with an expiry date in the past (expired) + $pastExpiry = DateTime::addSeconds(new \DateTime(), -3600); // 1 hour ago + $expiredToken = $this->client->call(Client::METHOD_POST, '/tokens/buckets/' . $bucketId . '/files/' . $fileId, array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'] + ], $this->getHeaders()), [ + 'expire' => $pastExpiry, + ]); + + $this->assertEquals(201, $expiredToken['headers']['status-code']); + $this->assertEquals('files', $expiredToken['body']['resourceType']); + + // Verify that the JWT is generated without causing a 500 error + $this->assertNotEmpty($expiredToken['body']['secret']); + + // Parse the JWT to verify expiration is set correctly for expired tokens + $jwtParts = explode('.', $expiredToken['body']['secret']); + $this->assertCount(3, $jwtParts, 'JWT should have 3 parts'); + + $payload = json_decode(base64_decode($jwtParts[1]), true); + $this->assertArrayHasKey('exp', $payload, 'JWT payload should contain exp field'); + + // For expired tokens, exp should be set to a short time in the future (around 1 minute) + $now = time(); + $this->assertGreaterThan($now, $payload['exp'], 'JWT exp should be in the future even for expired tokens'); + $this->assertLessThanOrEqual($now + 120, $payload['exp'], 'JWT exp should not be more than 2 minutes in the future for expired tokens'); + + return $data; + } + /** * @depends testCreateToken */