Merge remote-tracking branch 'origin/1.9.x' into presence-api

This commit is contained in:
ArnabChatterjee20k
2026-04-29 15:04:20 +05:30
42 changed files with 802 additions and 56 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
+6
View File
@@ -56,6 +56,8 @@ use Appwrite\Utopia\Response\Model\ColumnString;
use Appwrite\Utopia\Response\Model\ColumnText;
use Appwrite\Utopia\Response\Model\ColumnURL;
use Appwrite\Utopia\Response\Model\ColumnVarchar;
use Appwrite\Utopia\Response\Model\ConsoleKeyScope;
use Appwrite\Utopia\Response\Model\ConsoleKeyScopeList;
use Appwrite\Utopia\Response\Model\ConsoleOAuth2Provider;
use Appwrite\Utopia\Response\Model\ConsoleOAuth2ProviderList;
use Appwrite\Utopia\Response\Model\ConsoleOAuth2ProviderParameter;
@@ -71,6 +73,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;
@@ -396,6 +399,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());
@@ -491,6 +495,8 @@ Response::setModel(new ConsoleVariables());
Response::setModel(new ConsoleOAuth2ProviderParameter());
Response::setModel(new ConsoleOAuth2Provider());
Response::setModel(new ConsoleOAuth2ProviderList());
Response::setModel(new ConsoleKeyScope());
Response::setModel(new ConsoleKeyScopeList());
Response::setModel(new MFAChallenge());
Response::setModel(new MFARecoveryCodes());
Response::setModel(new MFAType());
+4 -1
View File
@@ -54,7 +54,10 @@ use Utopia\WebSocket\Adapter;
use Utopia\WebSocket\Server;
require_once __DIR__ . '/init.php';
require_once __DIR__ . '/init/span.php';
if (System::getEnv('_APP_EDITION', 'self-hosted') === 'self-hosted') {
require_once __DIR__ . '/init/span.php';
}
/** @var Registry $register */
$register = $GLOBALS['register'] ?? throw new \RuntimeException('Registry not initialized');
@@ -1 +0,0 @@
List all OAuth2 providers supported by the Appwrite server, along with the parameters required to configure each provider. The response excludes mock providers but includes sandbox providers.
-1
View File
@@ -1 +0,0 @@
Get all Environment Variables that are relevant for the console.
+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',
'1.9.3' => 'V25',
];
@@ -34,7 +34,7 @@ class XList extends Action
namespace: 'console',
group: 'console',
name: 'listOAuth2Providers',
description: '/docs/references/console/list-oauth2-providers.md',
description: 'List all OAuth2 providers supported by the Appwrite server, along with the parameters required to configure each provider. The response excludes mock providers but includes sandbox providers.',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
@@ -0,0 +1,67 @@
<?php
namespace Appwrite\Platform\Modules\Console\Http\Scopes\Key;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Config\Config;
use Utopia\Database\Document;
use Utopia\Platform\Action;
use Utopia\Platform\Scope\HTTP;
class XList extends Action
{
use HTTP;
public static function getName(): string
{
return 'listKeyScopes';
}
public function __construct()
{
$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_GET)
->setHttpPath('/v1/console/scopes/key')
->desc('List key scopes')
->groups(['api'])
->label('scope', 'public')
->label('sdk', new Method(
namespace: 'console',
group: 'console',
name: 'listKeyScopes',
description: 'List all scopes available for project API keys, along with a description for each scope.',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_CONSOLE_KEY_SCOPE_LIST,
)
],
contentType: ContentType::JSON
))
->inject('response')
->callback($this->action(...));
}
public function action(Response $response): void
{
$scopesConfig = Config::getParam('projectScopes', []);
$scopes = [];
foreach ($scopesConfig as $scopeId => $scope) {
$scopes[] = new Document([
'$id' => $scopeId,
'description' => $scope['description'] ?? '',
]);
}
$response->dynamic(new Document([
'total' => \count($scopes),
'scopes' => $scopes,
]), Response::MODEL_CONSOLE_KEY_SCOPE_LIST);
}
}
@@ -36,7 +36,7 @@ class Get extends Action
namespace: 'console',
group: 'console',
name: 'variables',
description: '/docs/references/console/variables.md',
description: 'Get all Environment Variables that are relevant for the console.',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
@@ -15,6 +15,7 @@ use Appwrite\Platform\Modules\Console\Http\Redirects\Recover\Get as RedirectReco
use Appwrite\Platform\Modules\Console\Http\Redirects\Register\Get as RedirectRegister;
use Appwrite\Platform\Modules\Console\Http\Redirects\Root\Get as RedirectRoot;
use Appwrite\Platform\Modules\Console\Http\Resources\Get as GetResourceAvailability;
use Appwrite\Platform\Modules\Console\Http\Scopes\Key\XList as ListKeyScopes;
use Appwrite\Platform\Modules\Console\Http\Variables\Get as GetVariables;
use Utopia\Platform\Service;
@@ -30,6 +31,7 @@ class Http extends Service
$this->addAction(GetVariables::getName(), new GetVariables());
$this->addAction(ListOAuth2Providers::getName(), new ListOAuth2Providers());
$this->addAction(ListKeyScopes::getName(), new ListKeyScopes());
$this->addAction(CreateAssistantQuery::getName(), new CreateAssistantQuery());
$this->addAction(GetResourceAvailability::getName(), new GetResourceAvailability());
@@ -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;
}
}
+3
View File
@@ -254,6 +254,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';
@@ -337,6 +338,8 @@ class Response extends SwooleResponse
public const MODEL_CONSOLE_OAUTH2_PROVIDER_PARAMETER = 'consoleOAuth2ProviderParameter';
public const MODEL_CONSOLE_OAUTH2_PROVIDER = 'consoleOAuth2Provider';
public const MODEL_CONSOLE_OAUTH2_PROVIDER_LIST = 'consoleOAuth2ProviderList';
public const MODEL_CONSOLE_KEY_SCOPE = 'consoleKeyScope';
public const MODEL_CONSOLE_KEY_SCOPE_LIST = 'consoleKeyScopeList';
// Deprecated
public const MODEL_PERMISSIONS = 'permissions';
@@ -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,37 @@
<?php
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Model;
class ConsoleKeyScope extends Model
{
public function __construct()
{
$this
->addRule('$id', [
'type' => self::TYPE_STRING,
'description' => 'Scope ID.',
'default' => '',
'example' => 'users.read',
])
->addRule('description', [
'type' => self::TYPE_STRING,
'description' => 'Scope description.',
'default' => '',
'example' => 'Access to read your project\'s users',
])
;
}
public function getName(): string
{
return 'Console Key Scope';
}
public function getType(): string
{
return Response::MODEL_CONSOLE_KEY_SCOPE;
}
}
@@ -0,0 +1,37 @@
<?php
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
use Appwrite\Utopia\Response\Model;
class ConsoleKeyScopeList extends Model
{
public function __construct()
{
$this
->addRule('total', [
'type' => self::TYPE_INTEGER,
'description' => 'Total number of key scopes exposed by the server.',
'default' => 0,
'example' => 5,
])
->addRule('scopes', [
'type' => Response::MODEL_CONSOLE_KEY_SCOPE,
'description' => 'List of key scopes, each with its ID and description.',
'default' => [],
'array' => true,
])
;
}
public function getName(): string
{
return 'Console Key Scopes List';
}
public function getType(): string
{
return Response::MODEL_CONSOLE_KEY_SCOPE_LIST;
}
}
@@ -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',
@@ -128,4 +128,47 @@ class ConsoleConsoleClientTest extends Scope
// Sandbox providers (e.g. paypalSandbox) are included
$this->assertContains('paypalSandbox', $providerIds);
}
public function testListKeyScopes(): void
{
$response = $this->client->call(Client::METHOD_GET, '/console/scopes/key', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
], $this->getHeaders()));
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertIsInt($response['body']['total']);
$this->assertIsArray($response['body']['scopes']);
$this->assertGreaterThan(0, $response['body']['total']);
$this->assertEquals($response['body']['total'], \count($response['body']['scopes']));
$scopeIds = \array_column($response['body']['scopes'], '$id');
// Well-known scopes must be present
$this->assertContains('users.read', $scopeIds);
$this->assertContains('users.write', $scopeIds);
$this->assertContains('functions.read', $scopeIds);
$this->assertContains('functions.write', $scopeIds);
// Every scope has the expected shape
foreach ($response['body']['scopes'] as $scope) {
$this->assertArrayHasKey('$id', $scope);
$this->assertIsString($scope['$id']);
$this->assertNotEmpty($scope['$id']);
$this->assertArrayHasKey('description', $scope);
$this->assertIsString($scope['description']);
$this->assertNotEmpty($scope['description']);
}
// A specific scope has the expected description
$usersRead = null;
foreach ($response['body']['scopes'] as $scope) {
if ($scope['$id'] === 'users.read') {
$usersRead = $scope;
break;
}
}
$this->assertNotNull($usersRead);
$this->assertEquals('Access to read your project\'s users', $usersRead['description']);
}
}
@@ -43,4 +43,22 @@ class ConsoleCustomServerTest extends Scope
$this->assertContains('github', $providerIds);
$this->assertNotContains('mock', $providerIds);
}
public function testListKeyScopes(): void
{
// Public endpoint: must succeed without admin authentication. Drop the
// headers from getHeaders() and only pass project + content-type.
$response = $this->client->call(Client::METHOD_GET, '/console/scopes/key', [
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
]);
$this->assertEquals(200, $response['headers']['status-code']);
$this->assertIsInt($response['body']['total']);
$this->assertIsArray($response['body']['scopes']);
$this->assertGreaterThan(0, $response['body']['total']);
$scopeIds = \array_column($response['body']['scopes'], '$id');
$this->assertContains('users.read', $scopeIds);
}
}
@@ -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;
}
}