Merge pull request #10877 from appwrite/ser-648

Fix file token expiry
This commit is contained in:
Matej Bačo 2025-11-27 15:54:31 +01:00 committed by GitHub
commit c4366c9de1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 53 additions and 32 deletions

View file

@ -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.
*/

View file

@ -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);

View file

@ -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);

View file

@ -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');