diff --git a/app/init/constants.php b/app/init/constants.php index 0cec24b749..ea5c0fb2c5 100644 --- a/app/init/constants.php +++ b/app/init/constants.php @@ -93,6 +93,13 @@ const APP_VCS_GITHUB_EMAIL = 'team@appwrite.io'; const APP_VCS_GITHUB_URL = 'https://github.com/TeamAppwrite'; const APP_BRANDED_EMAIL_BASE_TEMPLATE = 'email-base-styled'; +/** + * JWT for Resource Tokens. + */ +const RESOURCE_TOKEN_ALGORITHM = 'HS256'; +const RESOURCE_TOKEN_MAX_AGE = 86400 * 365 * 10; /* 10 years */ +const RESOURCE_TOKEN_LEEWAY = 10; // 10 seconds + /** * Token Expiration times. */ diff --git a/app/init/resources.php b/app/init/resources.php index 0c05b30a83..98162d3a2b 100644 --- a/app/init/resources.php +++ b/app/init/resources.php @@ -1003,7 +1003,8 @@ App::setResource('resourceToken', function ($project, $dbForProject, $request) { $tokenJWT = $request->getParam('token'); if (!empty($tokenJWT) && !$project->isEmpty()) { // JWT authentication - $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 900, 10); // Instantiate with key, algo, maxAge and leeway. + // Use a large but reasonable maxAge to avoid auto-exp when token has no expiry + $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), RESOURCE_TOKEN_ALGORITHM, RESOURCE_TOKEN_MAX_AGE, RESOURCE_TOKEN_LEEWAY); // Instantiate with key, algo, maxAge and leeway. try { $payload = $jwt->decode($tokenJWT); diff --git a/src/Appwrite/Utopia/Response/Model/ResourceToken.php b/src/Appwrite/Utopia/Response/Model/ResourceToken.php index 87ab66ab5d..c2b3deb56b 100644 --- a/src/Appwrite/Utopia/Response/Model/ResourceToken.php +++ b/src/Appwrite/Utopia/Response/Model/ResourceToken.php @@ -64,7 +64,7 @@ class ResourceToken extends Model $expire = $document->getAttribute('expire'); // Use a large but reasonable maxAge to avoid auto-exp when we set explicit exp - $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 86400 * 365 * 10, 10); // 10 years + $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), RESOURCE_TOKEN_ALGORITHM, RESOURCE_TOKEN_MAX_AGE, RESOURCE_TOKEN_LEEWAY); // 10 years $payload = [ 'tokenId' => $document->getId(), @@ -73,13 +73,13 @@ class ResourceToken extends Model 'resourceInternalId' => $document->getAttribute('resourceInternalId'), ]; + $createdDate = new \DateTime($document->getCreatedAt()); + $payload['iat'] = $createdDate->getTimestamp(); + // Set explicit expiration in JWT payload if we have an expiry date if ($expire !== null) { $expiryDate = new \DateTime($expire); $payload['exp'] = $expiryDate->getTimestamp(); - } else { - // For infinite expiry, set 'iat' to prevent JWT library from auto-adding 'exp' - $payload['iat'] = time(); } $secret = $jwt->encode($payload); diff --git a/tests/e2e/Services/Tokens/TokensConsoleClientTest.php b/tests/e2e/Services/Tokens/TokensConsoleClientTest.php index f1480faba0..7a9181b1dc 100644 --- a/tests/e2e/Services/Tokens/TokensConsoleClientTest.php +++ b/tests/e2e/Services/Tokens/TokensConsoleClientTest.php @@ -72,37 +72,45 @@ class TokensConsoleClientTest extends Scope $this->assertEquals(400, $token['headers']['status-code']); $this->assertStringContainsString('Value must be valid date in the future', $token['body']['message']); - // Success case: No expire date - $token = $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' => null, - ]); + // Success cases: With & without expiry + $expireList = [null, date('Y-m-d', strtotime("tomorrow"))]; + foreach ($expireList as $expire) { + $token = $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' => $expire, + ]); - $this->assertEquals(201, $token['headers']['status-code']); - $this->assertEquals('files', $token['body']['resourceType']); - $this->assertNotEmpty($token['body']['$id']); - $this->assertNotEmpty($token['body']['secret']); + $this->assertEquals(201, $token['headers']['status-code']); + $this->assertEquals('files', $token['body']['resourceType']); + $this->assertNotEmpty($token['body']['$id']); + $this->assertNotEmpty($token['body']['secret']); - // Verify the generated token JWT contains correct resource information - $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 86400 * 365 * 10, 10); // 10 years maxAge - try { - $payload = $jwt->decode($token['body']['secret']); - $this->assertIsArray($payload, 'JWT payload should decode to an array'); - $this->assertArrayHasKey('tokenId', $payload, 'JWT payload should contain tokenId'); - $this->assertArrayHasKey('resourceId', $payload, 'JWT payload should contain resourceId'); - $this->assertArrayHasKey('resourceType', $payload, 'JWT payload should contain resourceType'); - $this->assertArrayHasKey('resourceInternalId', $payload, 'JWT payload should contain resourceInternalId'); + // Verify the generated token JWT contains correct resource information + $jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 86400 * 365 * 10, 10); // 10 years maxAge + try { + $payload = $jwt->decode($token['body']['secret']); + $this->assertIsArray($payload, 'JWT payload should decode to an array'); + $this->assertArrayHasKey('tokenId', $payload, 'JWT payload should contain tokenId'); + $this->assertArrayHasKey('resourceId', $payload, 'JWT payload should contain resourceId'); + $this->assertArrayHasKey('resourceType', $payload, 'JWT payload should contain resourceType'); + $this->assertArrayHasKey('resourceInternalId', $payload, 'JWT payload should contain resourceInternalId'); + $this->assertArrayHasKey('iat', $payload, 'JWT payload should contain iat'); - $this->assertEquals($token['body']['$id'], $payload['tokenId'], 'JWT tokenId should match token ID'); - $this->assertEquals($bucketId . ':' . $fileId, $payload['resourceId'], 'JWT resourceId should match bucketId:fileId format'); - $this->assertEquals('files', $payload['resourceType'], 'JWT resourceType should be files'); + if (!empty($expire)) { + $this->assertArrayHasKey('exp', $payload, 'JWT payload should contain exp'); + } else { + $this->assertArrayNotHasKey('exp', $payload, 'JWT payload should not contain exp field for tokens without expiry'); + } - // For newly created tokens without expiry, should not have exp field - $this->assertArrayNotHasKey('exp', $payload, 'JWT payload should not contain exp field for tokens without expiry'); - } catch (JWTException $e) { - $this->fail('Failed to decode JWT: ' . $e->getMessage()); + $this->assertEquals($token['body']['$id'], $payload['tokenId'], 'JWT tokenId should match token ID'); + $this->assertEquals($bucketId . ':' . $fileId, $payload['resourceId'], 'JWT resourceId should match bucketId:fileId format'); + $this->assertEquals('files', $payload['resourceType'], 'JWT resourceType should be files'); + + } catch (JWTException $e) { + $this->fail('Failed to decode JWT: ' . $e->getMessage()); + } } return [ @@ -218,6 +226,11 @@ class TokensConsoleClientTest extends Scope $this->assertArrayHasKey('resourceId', $payload, 'JWT payload should contain resourceId'); $this->assertArrayHasKey('resourceType', $payload, 'JWT payload should contain resourceType'); $this->assertArrayHasKey('resourceInternalId', $payload, 'JWT payload should contain resourceInternalId'); + $this->assertArrayHasKey('iat', $payload, 'JWT payload should contain iat'); + + if (!empty($token['expire'])) { + $this->assertArrayHasKey('exp', $payload, 'JWT payload should contain exp'); + } $this->assertEquals($token['$id'], $payload['tokenId'], 'JWT tokenId should match token ID'); $this->assertEquals($data['bucketId'] . ':' . $data['fileId'], $payload['resourceId'], 'JWT resourceId should match bucketId:fileId format');