Merge pull request #12170 from appwrite/feat-create-dynamic-keys

Feat: create dynamic keys
This commit is contained in:
Matej Bačo
2026-04-29 09:58:22 +02:00
committed by GitHub
31 changed files with 586 additions and 51 deletions
+1 -1
View File
@@ -384,7 +384,7 @@ return [
],
Exception::API_KEY_EXPIRED => [
'name' => Exception::API_KEY_EXPIRED,
'description' => 'The dynamic API key has expired. Please don\'t use dynamic API keys for more than duration of the execution.',
'description' => 'The ephemeral API key has expired. Please don\'t use ephemeral API keys for more than duration of the execution.',
'code' => 401,
],
+9 -1
View File
@@ -27,6 +27,7 @@ use Appwrite\Utopia\Request\Filters\V20 as RequestV20;
use Appwrite\Utopia\Request\Filters\V21 as RequestV21;
use Appwrite\Utopia\Request\Filters\V22 as RequestV22;
use Appwrite\Utopia\Request\Filters\V23 as RequestV23;
use Appwrite\Utopia\Request\Filters\V24 as RequestV24;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Filters\V16 as ResponseV16;
use Appwrite\Utopia\Response\Filters\V17 as ResponseV17;
@@ -36,6 +37,7 @@ use Appwrite\Utopia\Response\Filters\V20 as ResponseV20;
use Appwrite\Utopia\Response\Filters\V21 as ResponseV21;
use Appwrite\Utopia\Response\Filters\V22 as ResponseV22;
use Appwrite\Utopia\Response\Filters\V23 as ResponseV23;
use Appwrite\Utopia\Response\Filters\V24 as ResponseV24;
use Appwrite\Utopia\View;
use Executor\Executor;
use MaxMind\Db\Reader;
@@ -397,7 +399,7 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
'projectId' => $project->getId(),
'scopes' => $resource->getAttribute('scopes', [])
]);
$headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $jwtKey;
$headers['x-appwrite-key'] = API_KEY_EPHEMERAL . '_' . $jwtKey;
$headers['x-appwrite-trigger'] = 'http';
$headers['x-appwrite-user-jwt'] = '';
@@ -899,6 +901,9 @@ Http::init()
if (version_compare($requestFormat, '1.9.2', '<')) {
$request->addFilter(new RequestV23());
}
if (version_compare($requestFormat, '1.9.3', '<')) {
$request->addFilter(new RequestV24());
}
}
$localeParam = (string) $request->getParam('locale', $request->getHeader('x-appwrite-locale', ''));
@@ -923,6 +928,9 @@ Http::init()
*/
$responseFormat = $request->getHeader('x-appwrite-response-format', System::getEnv('_APP_SYSTEM_RESPONSE_FORMAT', ''));
if ($responseFormat) {
if (version_compare($responseFormat, '1.9.3', '<')) {
$response->addFilter(new ResponseV24());
}
if (version_compare($responseFormat, '1.9.2', '<')) {
$response->addFilter(new ResponseV23());
}
+2 -1
View File
@@ -183,7 +183,8 @@ Http::init()
// Handle special app role case
if ($apiKey->getRole() === User::ROLE_APPS) {
// Disable authorization checks for project API keys
if (($apiKey->getType() === API_KEY_STANDARD || $apiKey->getType() === API_KEY_DYNAMIC) && $apiKey->getProjectId() === $project->getId()) {
// Dynamic supported for backwards compatibility
if (($apiKey->getType() === API_KEY_STANDARD || $apiKey->getType() === API_KEY_EPHEMERAL || $apiKey->getType() === 'dynamic') && $apiKey->getProjectId() === $project->getId()) {
$authorization->setDefaultStatus(false);
}
+3 -3
View File
@@ -44,8 +44,8 @@ const APP_PROJECT_ACCESS = 24 * 60 * 60; // 24 hours
const APP_RESOURCE_TOKEN_ACCESS = 24 * 60 * 60; // 24 hours
const APP_FILE_ACCESS = 24 * 60 * 60; // 24 hours
const APP_CACHE_UPDATE = 24 * 60 * 60; // 24 hours
const APP_CACHE_BUSTER = 4323;
const APP_VERSION_STABLE = '1.9.2';
const APP_CACHE_BUSTER = 4324;
const APP_VERSION_STABLE = '1.9.3';
const APP_DATABASE_ATTRIBUTE_EMAIL = 'email';
const APP_DATABASE_ATTRIBUTE_ENUM = 'enum';
const APP_DATABASE_ATTRIBUTE_IP = 'ip';
@@ -255,7 +255,7 @@ const MESSAGE_TYPE_SMS = 'sms';
const MESSAGE_TYPE_PUSH = 'push';
// API key types
const API_KEY_STANDARD = 'standard';
const API_KEY_DYNAMIC = 'dynamic';
const API_KEY_EPHEMERAL = 'ephemeral';
const API_KEY_ORGANIZATION = 'organization';
const API_KEY_ACCOUNT = 'account';
// Usage metrics
+2
View File
@@ -71,6 +71,7 @@ use Appwrite\Utopia\Response\Model\DetectionVariable;
use Appwrite\Utopia\Response\Model\DevKey;
use Appwrite\Utopia\Response\Model\Document as ModelDocument;
use Appwrite\Utopia\Response\Model\Embedding;
use Appwrite\Utopia\Response\Model\EphemeralKey;
use Appwrite\Utopia\Response\Model\Error;
use Appwrite\Utopia\Response\Model\ErrorDev;
use Appwrite\Utopia\Response\Model\Execution;
@@ -392,6 +393,7 @@ Response::setModel(new Execution());
Response::setModel(new Project());
Response::setModel(new Webhook());
Response::setModel(new Key());
Response::setModel(new EphemeralKey());
Response::setModel(new DevKey());
Response::setModel(new MockNumber());
Response::setModel(new OAuth2GitHub());
+5 -3
View File
@@ -105,7 +105,7 @@ class Key
/**
* Decode the given secret key into a Key object, containing the project ID, type, role, scopes, and name.
* Can be a stored API key or a dynamic key (JWT).
* Can be a stored API key or an ephemeral key (JWT).
*
* @throws Exception
*/
@@ -138,7 +138,9 @@ class Key
);
switch ($type) {
case API_KEY_DYNAMIC:
// Dynamic supported for backwards compatibility
case API_KEY_EPHEMERAL:
case 'dynamic':
$jwtObj = new JWT(
key: System::getEnv('_APP_OPENSSL_KEY_V1'),
algo: 'HS256',
@@ -153,7 +155,7 @@ class Key
$expired = true;
}
$name = $payload['name'] ?? 'Dynamic Key';
$name = $payload['name'] ?? 'Ephemeral Key';
$projectId = $payload['projectId'] ?? '';
$disabledMetrics = $payload['disabledMetrics'] ?? [];
$hostnameOverride = $payload['hostnameOverride'] ?? false;
+1
View File
@@ -95,6 +95,7 @@ abstract class Migration
'1.9.0' => 'V24',
'1.9.1' => 'V24',
'1.9.2' => 'V24',
'1.9.3' => 'V24',
];
/**
@@ -228,7 +228,7 @@ class Create extends Base
$executionId = ID::unique();
$headers['x-appwrite-execution-id'] = $executionId;
$headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $apiKey;
$headers['x-appwrite-key'] = API_KEY_EPHEMERAL . '_' . $apiKey;
$headers['x-appwrite-trigger'] = 'http';
$headers['x-appwrite-user-id'] = $user->getId();
$headers['x-appwrite-user-jwt'] = $jwt;
@@ -624,7 +624,7 @@ class Builds extends Action
$vars = [
...$vars,
'APPWRITE_FUNCTION_API_ENDPOINT' => $endpoint,
'APPWRITE_FUNCTION_API_KEY' => API_KEY_DYNAMIC . '_' . $apiKey,
'APPWRITE_FUNCTION_API_KEY' => API_KEY_EPHEMERAL . '_' . $apiKey,
'APPWRITE_FUNCTION_ID' => $resource->getId(),
'APPWRITE_FUNCTION_NAME' => $resource->getAttribute('name'),
'APPWRITE_FUNCTION_DEPLOYMENT' => $deployment->getId(),
@@ -639,7 +639,7 @@ class Builds extends Action
$vars = [
...$vars,
'APPWRITE_SITE_API_ENDPOINT' => $endpoint,
'APPWRITE_SITE_API_KEY' => API_KEY_DYNAMIC . '_' . $apiKey,
'APPWRITE_SITE_API_KEY' => API_KEY_EPHEMERAL . '_' . $apiKey,
'APPWRITE_SITE_ID' => $resource->getId(),
'APPWRITE_SITE_NAME' => $resource->getAttribute('name'),
'APPWRITE_SITE_DEPLOYMENT' => $deployment->getId(),
@@ -168,7 +168,7 @@ class Screenshots extends Action
$config = $configs[$key];
$config['headers'] = \array_merge($config['headers'], [
'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey
'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey
]);
$config['sleep'] = 3000;
@@ -0,0 +1,106 @@
<?php
namespace Appwrite\Platform\Modules\Project\Http\Project\Keys\Ephemeral;
use Ahc\Jwt\JWT;
use Appwrite\Event\Event as QueueEvent;
use Appwrite\Platform\Modules\Compute\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Config\Config;
use Utopia\Database\DateTime as DatabaseDateTime;
use Utopia\Database\Document;
use Utopia\Database\Helpers\ID;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
use Utopia\System\System;
use Utopia\Validator\ArrayList;
use Utopia\Validator\Range;
use Utopia\Validator\WhiteList;
class Create extends Base
{
use HTTP;
public static function getName()
{
return 'createEphemeralProjectKey';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/project/keys/ephemeral')
->httpAlias('/v1/projects/:projectId/jwts')
->desc('Create ephemeral project key')
->groups(['api', 'project'])
->label('scope', 'keys.write')
->label('event', 'keys.[keyId].create')
->label('audits.event', 'project.key.create')
->label('audits.resource', 'project.key/{response.$id}')
->label('sdk', new Method(
namespace: 'project',
group: 'keys',
name: 'createEphemeralKey',
description: <<<EOT
Create a new ephemeral API key. It's recommended to have multiple API keys with strict scopes for separate functions within your project.
You can also create a standard API key if you need a longer-lived key instead.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_CREATED,
model: Response::MODEL_EPHEMERAL_KEY,
)
],
))
->param('scopes', [], new ArrayList(new WhiteList(array_keys(Config::getParam('projectScopes')), true), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Key scopes list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' scopes are allowed.', optional: false)
->param('duration', 900, new Range(1, 3600), 'Time in seconds before ephemeral key expires. Default duration is 900 seconds, and maximum is 3600 seconds.', true)
->inject('response')
->inject('queueForEvents')
->inject('project')
->callback($this->action(...));
}
public function action(
array $scopes,
int $duration,
Response $response,
QueueEvent $queueForEvents,
Document $project,
) {
$keyId = ID::unique();
$jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', $duration, 0);
$secret = $jwt->encode([
'projectId' => $project->getId(),
'scopes' => $scopes
]);
$now = new \DateTime();
$expire = $now->add(new \DateInterval('PT' . $duration . 'S'))->format('Y-m-d\TH:i:s.u\Z');
$key = new Document([
'$id' => $keyId,
'$createdAt' => DatabaseDateTime::now(),
'$updatedAt' => DatabaseDateTime::now(),
'name' => '',
'scopes' => $scopes,
'expire' => $expire,
'sdks' => [],
'accessedAt' => null,
'secret' => API_KEY_EPHEMERAL . '_' . $secret,
]);
$queueForEvents->setParam('keyId', $key->getId());
$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($key, Response::MODEL_EPHEMERAL_KEY);
}
}
@@ -1,6 +1,6 @@
<?php
namespace Appwrite\Platform\Modules\Project\Http\Project\Keys;
namespace Appwrite\Platform\Modules\Project\Http\Project\Keys\Standard;
use Appwrite\Event\Event as QueueEvent;
use Appwrite\Extend\Exception;
@@ -30,16 +30,17 @@ class Create extends Base
public static function getName()
{
return 'createProjectKey';
return 'createStandardProjectKey';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/project/keys')
->setHttpPath('/v1/project/keys/standard')
->httpAlias('/v1/project/keys')
->httpAlias('/v1/projects/:projectId/keys')
->desc('Create project key')
->desc('Create standard project key')
->groups(['api', 'project'])
->label('scope', 'keys.write')
->label('event', 'keys.[keyId].create')
@@ -48,9 +49,11 @@ class Create extends Base
->label('sdk', new Method(
namespace: 'project',
group: 'keys',
name: 'createKey',
name: 'createStandardKey',
description: <<<EOT
Create a new API key. It's recommended to have multiple API keys with strict scopes for separate functions within your project.
Create a new standard API key. It's recommended to have multiple API keys with strict scopes for separate functions within your project.
You can also create an ephemeral API key if you need a short-lived key instead.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
@@ -5,9 +5,10 @@ namespace Appwrite\Platform\Modules\Project\Services;
use Appwrite\Platform\Modules\Project\Http\Init;
use Appwrite\Platform\Modules\Project\Http\Project\AuthMethods\Update as UpdateAuthMethod;
use Appwrite\Platform\Modules\Project\Http\Project\Delete as DeleteProject;
use Appwrite\Platform\Modules\Project\Http\Project\Keys\Create as CreateKey;
use Appwrite\Platform\Modules\Project\Http\Project\Keys\Delete as DeleteKey;
use Appwrite\Platform\Modules\Project\Http\Project\Keys\Ephemeral\Create as CreateEphemeralKey;
use Appwrite\Platform\Modules\Project\Http\Project\Keys\Get as GetKey;
use Appwrite\Platform\Modules\Project\Http\Project\Keys\Standard\Create as CreateStandardKey;
use Appwrite\Platform\Modules\Project\Http\Project\Keys\Update as UpdateKey;
use Appwrite\Platform\Modules\Project\Http\Project\Keys\XList as ListKeys;
use Appwrite\Platform\Modules\Project\Http\Project\Labels\Update as UpdateProjectLabels;
@@ -130,7 +131,8 @@ class Http extends Service
$this->addAction(UpdateVariable::getName(), new UpdateVariable());
// Keys
$this->addAction(CreateKey::getName(), new CreateKey());
$this->addAction(CreateStandardKey::getName(), new CreateStandardKey());
$this->addAction(CreateEphemeralKey::getName(), new CreateEphemeralKey());
$this->addAction(ListKeys::getName(), new ListKeys());
$this->addAction(GetKey::getName(), new GetKey());
$this->addAction(DeleteKey::getName(), new DeleteKey());
+1 -1
View File
@@ -434,7 +434,7 @@ class Functions extends Action
]);
$headers['x-appwrite-execution-id'] = $executionId ?? '';
$headers['x-appwrite-key'] = API_KEY_DYNAMIC . '_' . $apiKey;
$headers['x-appwrite-key'] = API_KEY_EPHEMERAL . '_' . $apiKey;
$headers['x-appwrite-trigger'] = $trigger;
$headers['x-appwrite-event'] = $event ?? '';
$headers['x-appwrite-user-id'] = $user->getId();
+1 -1
View File
@@ -402,7 +402,7 @@ class Migrations extends Action
]
]);
return API_KEY_DYNAMIC . '_' . $apiKey;
return API_KEY_EPHEMERAL . '_' . $apiKey;
}
/**
@@ -0,0 +1,36 @@
<?php
namespace Appwrite\Utopia\Request\Filters;
use Appwrite\Utopia\Request\Filter;
class V24 extends Filter
{
// Convert 1.9.2 params to 1.9.3
public function parse(array $content, string $model): array
{
switch ($model) {
case 'project.createStandardKey':
$content = $this->fillKeyId($content);
$content = $this->parseKeyScopes($content);
break;
}
return $content;
}
protected function fillKeyId(array $content): array
{
$content['keyId'] = $content['keyId'] ?? 'unique()';
return $content;
}
protected function parseKeyScopes(array $content): array
{
if (!\is_array($content['scopes'] ?? null)) {
$content['scopes'] = [];
}
return $content;
}
}
+1
View File
@@ -251,6 +251,7 @@ class Response extends SwooleResponse
public const MODEL_WEBHOOK_LIST = 'webhookList';
public const MODEL_KEY = 'key';
public const MODEL_KEY_LIST = 'keyList';
public const MODEL_EPHEMERAL_KEY = 'ephemeralKey';
public const MODEL_DEV_KEY = 'devKey';
public const MODEL_DEV_KEY_LIST = 'devKeyList';
public const MODEL_MOCK_NUMBER = 'mockNumber';
@@ -0,0 +1,56 @@
<?php
namespace Appwrite\Utopia\Response\Filters;
use Ahc\Jwt\JWT;
use Ahc\Jwt\JWTException;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Filter;
use Utopia\System\System;
// Convert 1.9.3 Data format to 1.9.2 format
class V24 extends Filter
{
public function parse(array $content, string $model): array
{
return match ($model) {
Response::MODEL_EPHEMERAL_KEY => $this->parseEphemeralKey($content),
default => $content,
};
}
private function parseEphemeralKey(array $content): array
{
unset($content['$id']);
unset($content['$createdAt']);
unset($content['$updatedAt']);
unset($content['name']);
unset($content['expire']);
unset($content['sdks']);
unset($content['accessedAt']);
$secret = $content['secret'] ?? '';
unset($content['secret']);
$content['projectId'] = $this->extractProjectId($secret);
$content['jwt'] = $secret;
return $content;
}
private function extractProjectId(string $secret): string
{
$token = explode('_', $secret, 2)[1] ?? '';
if ($token === '') {
return '';
}
$jwt = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256');
try {
return $jwt->decode($token, false)['projectId'] ?? '';
} catch (JWTException) {
return '';
}
}
}
@@ -0,0 +1,33 @@
<?php
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
class EphemeralKey extends Key
{
public function __construct()
{
parent::__construct();
}
/**
* Get Name
*
* @return string
*/
public function getName(): string
{
return 'Ephemeral Key';
}
/**
* Get Type
*
* @return string
*/
public function getType(): string
{
return Response::MODEL_EPHEMERAL_KEY;
}
}
@@ -7,11 +7,6 @@ use Appwrite\Utopia\Response\Model;
class Key extends Model
{
/**
* @var bool
*/
protected bool $public = true; // Public because reused for more key types
public function __construct()
{
$this
+1 -1
View File
@@ -31,7 +31,7 @@ class Comment
'Trigger functions via HTTP, SDKs, events, webhooks, or scheduled cron jobs',
'Each function runs in its own isolated container with custom environment variables',
'Build commands execute in runtime containers during deployment',
'Dynamic API keys are generated automatically for each function execution',
'Ephemeral API keys are generated automatically for each function execution',
'JWT tokens let functions act on behalf of users while preserving their permissions',
'Storage files get ClamAV malware scanning and encryption by default',
'Roll back Sites deployments instantly by switching between versions',
@@ -700,7 +700,7 @@ class FunctionsCustomServerTest extends Scope
$this->assertEquals(200, $function['headers']['status-code']);
$this->assertEquals($deploymentId, $function['body']['deploymentId']);
// Test starter code is used and that dynamic keys work
// Test starter code is used and that ephemeral keys work
$execution = $this->createExecution($functionId, [
'path' => '/ping',
]);
@@ -2129,7 +2129,7 @@ class FunctionsCustomServerTest extends Scope
]);
$deploymentId = $this->setupDeployment($functionId, [
'code' => $this->packageFunction('dynamic-api-key'),
'code' => $this->packageFunction('ephemeral-api-key'),
'activate' => true,
]);
+131
View File
@@ -239,6 +239,112 @@ trait KeysBase
$this->deleteKey($customId);
}
// =========================================================================
// Create ephemeral key tests
// =========================================================================
public function testCreateEphemeralKey(): void
{
$key = $this->createEphemeralKey(
['users.read', 'users.write'],
);
$this->assertSame(201, $key['headers']['status-code']);
$this->assertNotEmpty($key['body']['$id']);
$this->assertSame('', $key['body']['name']);
$this->assertSame(['users.read', 'users.write'], $key['body']['scopes']);
$this->assertNotEmpty($key['body']['secret']);
$this->assertStringStartsWith(API_KEY_EPHEMERAL . '_', $key['body']['secret']);
$this->assertSame([], $key['body']['sdks']);
$this->assertSame('', $key['body']['accessedAt']);
$dateValidator = new DatetimeValidator();
$this->assertSame(true, $dateValidator->isValid($key['body']['$createdAt']));
$this->assertSame(true, $dateValidator->isValid($key['body']['$updatedAt']));
$this->assertSame(true, $dateValidator->isValid($key['body']['expire']));
// Verify JWT payload
$jwt = substr($key['body']['secret'], strlen(API_KEY_EPHEMERAL . '_'));
$parts = explode('.', $jwt);
$this->assertCount(3, $parts);
$payload = json_decode(base64_decode(str_replace(['-', '_'], ['+', '/'], $parts[1])), true);
$this->assertNotEmpty($payload['projectId']);
$this->assertSame(['users.read', 'users.write'], $payload['scopes']);
// Verify default duration (900 seconds)
$expireDt = new \DateTime($key['body']['expire']);
$now = new \DateTime();
$diff = $expireDt->getTimestamp() - $now->getTimestamp();
$this->assertGreaterThanOrEqual(890, $diff);
$this->assertLessThanOrEqual(910, $diff);
}
public function testCreateEphemeralKeyWithDuration(): void
{
$duration = 1800;
$key = $this->createEphemeralKey(
['databases.read'],
$duration,
);
$this->assertSame(201, $key['headers']['status-code']);
$this->assertSame(['databases.read'], $key['body']['scopes']);
$expireDt = new \DateTime($key['body']['expire']);
$now = new \DateTime();
$diff = $expireDt->getTimestamp() - $now->getTimestamp();
$this->assertGreaterThanOrEqual($duration - 10, $diff);
$this->assertLessThanOrEqual($duration + 10, $diff);
}
public function testCreateEphemeralKeyWithEmptyScopes(): void
{
$key = $this->createEphemeralKey(
[],
);
$this->assertSame(201, $key['headers']['status-code']);
$this->assertSame([], $key['body']['scopes']);
}
public function testCreateEphemeralKeyWithoutAuthentication(): void
{
$response = $this->createEphemeralKey(
['users.read'],
null,
false
);
$this->assertSame(401, $response['headers']['status-code']);
}
public function testCreateEphemeralKeyInvalidScope(): void
{
$response = $this->createEphemeralKey(
['invalid.scope'],
);
$this->assertSame(400, $response['headers']['status-code']);
}
public function testCreateEphemeralKeyInvalidDuration(): void
{
$response = $this->createEphemeralKey(
['users.read'],
0,
);
$this->assertSame(400, $response['headers']['status-code']);
$response = $this->createEphemeralKey(
['users.read'],
3601,
);
$this->assertSame(400, $response['headers']['status-code']);
}
// =========================================================================
// Update key tests
// =========================================================================
@@ -855,4 +961,29 @@ trait KeysBase
return $this->client->call(Client::METHOD_DELETE, '/project/keys/' . $keyId, $headers);
}
/**
* @param array<string> $scopes
*/
protected function createEphemeralKey(array $scopes, ?int $duration = null, bool $authenticated = true): mixed
{
$params = [
'scopes' => $scopes,
];
if ($duration !== null) {
$params['duration'] = $duration;
}
$headers = [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
];
if ($authenticated) {
$headers = array_merge($headers, $this->getHeaders());
}
return $this->client->call(Client::METHOD_POST, '/project/keys/ephemeral', $headers, $params);
}
}
@@ -0,0 +1,103 @@
<?php
namespace Tests\E2E\Services\Project;
use Tests\E2E\Client;
use Tests\E2E\Scopes\ProjectCustom;
use Tests\E2E\Scopes\Scope;
use Tests\E2E\Scopes\SideServer;
use Utopia\Database\Helpers\ID;
class KeysIntegrationTest extends Scope
{
use ProjectCustom;
use SideServer;
public function testEphemeralKeyScopeEnforcement(): void
{
$projectId = $this->getProject()['$id'];
$apiKey = $this->getProject()['apiKey'];
$serverHeaders = [
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $apiKey,
];
$consoleHeaders = [
'origin' => 'http://localhost',
'content-type' => 'application/json',
'cookie' => 'a_session_console=' . $this->getRoot()['session'],
'x-appwrite-mode' => 'admin',
'x-appwrite-project' => $projectId,
];
// Step 1: Create an ephemeral key scoped to users.read only.
$ephemeralKey = $this->client->call(
Client::METHOD_POST,
'/project/keys/ephemeral',
$serverHeaders,
[
'scopes' => ['users.read'],
'duration' => 900,
]
);
$this->assertSame(201, $ephemeralKey['headers']['status-code']);
$this->assertNotEmpty($ephemeralKey['body']['secret']);
$ephemeralKeySecret = $ephemeralKey['body']['secret'];
$ephemeralHeaders = [
'content-type' => 'application/json',
'x-appwrite-project' => $projectId,
'x-appwrite-key' => $ephemeralKeySecret,
];
// Step 2: Create a project user using console headers.
$user = $this->client->call(
Client::METHOD_POST,
'/users',
$consoleHeaders,
[
'userId' => ID::unique(),
'email' => 'ephemeral_key_' . \uniqid() . '@localhost.test',
'password' => 'password1234',
'name' => 'Ephemeral Key Test User',
]
);
$this->assertSame(201, $user['headers']['status-code']);
$userId = $user['body']['$id'];
// Step 3: Ephemeral key can list users.
$list = $this->client->call(
Client::METHOD_GET,
'/users',
$ephemeralHeaders
);
$this->assertSame(200, $list['headers']['status-code']);
$this->assertGreaterThanOrEqual(1, $list['body']['total']);
// Step 4: Ephemeral key can get the specific user.
$get = $this->client->call(
Client::METHOD_GET,
'/users/' . $userId,
$ephemeralHeaders
);
$this->assertSame(200, $get['headers']['status-code']);
$this->assertSame($userId, $get['body']['$id']);
// Step 5: Ephemeral key cannot create users (missing users.write scope).
$createAttempt = $this->client->call(
Client::METHOD_POST,
'/users',
$ephemeralHeaders,
[
'userId' => ID::unique(),
'email' => 'should_fail_' . \uniqid() . '@localhost.test',
'password' => 'password1234',
'name' => 'Should Fail',
]
);
$this->assertSame(401, $createAttempt['headers']['status-code']);
}
}
@@ -3941,6 +3941,61 @@ class ProjectsConsoleClientTest extends Scope
$this->assertEmpty($response['body']);
}
// JWT Keys
public function testJWTKey(): void
{
$data = $this->setupProjectData();
$id = $data['projectId'];
// Create JWT key
$response = $this->client->call(Client::METHOD_POST, '/projects/' . $id . '/jwts', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-response-format' => '1.9.2',
], $this->getHeaders()), [
'duration' => 5,
'scopes' => ['users.read'],
]);
$this->assertEquals(201, $response['headers']['status-code']);
$this->assertNotEmpty($response['body']['jwt']);
$this->assertNotEmpty($response['body']['projectId']);
$this->assertSame($id, $response['body']['projectId']);
$jwt = $response['body']['jwt'];
// Ensure JWT key works
$response = $this->client->call(Client::METHOD_GET, '/users', [
'content-type' => 'application/json',
'x-appwrite-project' => $id,
'x-appwrite-key' => $jwt,
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertArrayHasKey('users', $response['body']);
// Ensure JWT key respect scopes
$response = $this->client->call(Client::METHOD_GET, '/functions', [
'content-type' => 'application/json',
'x-appwrite-project' => $id,
'x-appwrite-key' => $jwt,
]);
$this->assertEquals(401, $response['headers']['status-code']);
// Ensure JWT key expires
\sleep(10);
$response = $this->client->call(Client::METHOD_GET, '/users', [
'content-type' => 'application/json',
'x-appwrite-project' => $id,
'x-appwrite-key' => $jwt,
]);
$this->assertEquals(401, $response['headers']['status-code']);
}
// Platforms
public function testCreateProjectPlatform(): void
@@ -2038,7 +2038,7 @@ class SitesCustomServerTest extends Scope
'previewAuthDisabled' => true,
]);
$response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false, headers: [
'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey,
'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey,
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertStringContainsString("Hello Appwrite", $response['body']);
@@ -2046,7 +2046,7 @@ class SitesCustomServerTest extends Scope
$this->assertGreaterThan($contentLength, $response['headers']['content-length']);
$response = $proxyClient->call(Client::METHOD_GET, '/non-existing-path', followRedirects: false, headers: [
'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey,
'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey,
]);
$this->assertEquals(404, $response['headers']['status-code']);
$this->assertStringContainsString("Page not found", $response['body']);
@@ -2882,7 +2882,7 @@ class SitesCustomServerTest extends Scope
]);
$response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false, headers: [
'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey,
'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey,
]);
$this->assertEquals(400, $response['headers']['status-code']);
$deployment = $this->getDeployment($siteId, $deploymentId);
@@ -2924,7 +2924,7 @@ class SitesCustomServerTest extends Scope
// deployment is still building error page
$response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false, headers: [
'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey,
'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey,
]);
$this->assertEquals(400, $response['headers']['status-code']);
$this->assertStringContainsString("Deployment is still building", $response['body']);
@@ -2939,7 +2939,7 @@ class SitesCustomServerTest extends Scope
// deployment failed error page
$response = $proxyClient->call(Client::METHOD_GET, '/', followRedirects: false, headers: [
'x-appwrite-key' => API_KEY_DYNAMIC . '_' . $apiKey,
'x-appwrite-key' => API_KEY_EPHEMERAL . '_' . $apiKey,
]);
$this->assertEquals(400, $response['headers']['status-code']);
$this->assertStringContainsString("Deployment build failed", $response['body']);
@@ -1,11 +1,11 @@
{
"name": "dynamic-api-key",
"name": "ephemeral-api-key",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dynamic-api-key",
"name": "ephemeral-api-key",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
@@ -1,5 +1,5 @@
{
"name": "dynamic-api-key",
"name": "ephemeral-api-key",
"version": "1.0.0",
"main": "index.js",
"scripts": {
+12 -12
View File
@@ -14,7 +14,7 @@ class KeyTest extends TestCase
{
public function testDecode(): void
{
// Decode dynamic key
// Decode ephemeral key
$projectId = 'test';
$usage = false;
$scopes = [
@@ -36,12 +36,12 @@ class KeyTest extends TestCase
$this->assertEquals($projectId, $decoded->getProjectId());
$this->assertEquals('', $decoded->getTeamId());
$this->assertEquals('', $decoded->getUserId());
$this->assertEquals(API_KEY_DYNAMIC, $decoded->getType());
$this->assertEquals(API_KEY_EPHEMERAL, $decoded->getType());
$this->assertEquals(User::ROLE_APPS, $decoded->getRole());
$this->assertEquals(\array_merge($scopes, $roleScopes), $decoded->getScopes());
$this->assertEquals('Dynamic Key', $decoded->getName());
$this->assertEquals('Ephemeral Key', $decoded->getName());
// Decode dynamic key with extras
// Decode ephemeral key with extras
$extra = [
'disabledMetrics' => ['metric123'],
'hostnameOverride' => true,
@@ -60,10 +60,10 @@ class KeyTest extends TestCase
$this->assertEquals($projectId, $decoded->getProjectId());
$this->assertEquals('', $decoded->getTeamId());
$this->assertEquals('', $decoded->getUserId());
$this->assertEquals(API_KEY_DYNAMIC, $decoded->getType());
$this->assertEquals(API_KEY_EPHEMERAL, $decoded->getType());
$this->assertEquals(User::ROLE_APPS, $decoded->getRole());
$this->assertEquals(\array_merge($scopes, $roleScopes), $decoded->getScopes());
$this->assertEquals('Dynamic Key', $decoded->getName());
$this->assertEquals('Ephemeral Key', $decoded->getName());
$this->assertEquals(['metric123'], $decoded->getDisabledMetrics());
$this->assertEquals(true, $decoded->getHostnameOverride());
$this->assertEquals(true, $decoded->isBannerDisabled());
@@ -71,8 +71,8 @@ class KeyTest extends TestCase
$this->assertEquals(true, $decoded->isPreviewAuthDisabled());
$this->assertEquals(true, $decoded->isDeploymentStatusIgnored());
// Decode invalid dynamic key
$invalidKey = API_KEY_DYNAMIC . '_invalid_jwt_token';
// Decode invalid ephemeral key
$invalidKey = API_KEY_EPHEMERAL . '_invalid_jwt_token';
$decoded = Key::decode(
project: new Document(['$id' => $projectId]),
team: new Document(),
@@ -82,12 +82,12 @@ class KeyTest extends TestCase
$this->assertEquals($projectId, $decoded->getProjectId());
$this->assertEquals('', $decoded->getTeamId());
$this->assertEquals('', $decoded->getUserId());
$this->assertEquals(API_KEY_DYNAMIC, $decoded->getType());
$this->assertEquals(API_KEY_EPHEMERAL, $decoded->getType());
$this->assertEquals(User::ROLE_GUESTS, $decoded->getRole());
$this->assertEquals($guestRoleScopes, $decoded->getScopes());
$this->assertEquals('UNKNOWN', $decoded->getName());
// Decode expired dynamic key
// Decode expired ephemeral key
$expiredKey = self::generateKey($projectId, $usage, $scopes, maxAge: 1, timestamp: time() - 60);
\sleep(2);
$decoded = Key::decode(
@@ -99,7 +99,7 @@ class KeyTest extends TestCase
$this->assertEquals($projectId, $decoded->getProjectId());
$this->assertEquals('', $decoded->getTeamId());
$this->assertEquals('', $decoded->getUserId());
$this->assertEquals(API_KEY_DYNAMIC, $decoded->getType());
$this->assertEquals(API_KEY_EPHEMERAL, $decoded->getType());
$this->assertEquals(User::ROLE_GUESTS, $decoded->getRole());
$this->assertEquals($guestRoleScopes, $decoded->getScopes());
$this->assertEquals('UNKNOWN', $decoded->getName());
@@ -363,6 +363,6 @@ class KeyTest extends TestCase
'scopes' => $scopes,
], $extra));
return API_KEY_DYNAMIC . '_' . $apiKey;
return API_KEY_EPHEMERAL . '_' . $apiKey;
}
}