mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
Merge remote-tracking branch 'origin/1.9.x' into presence-api
This commit is contained in:
@@ -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,
|
||||
],
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 +0,0 @@
|
||||
Get all Environment Variables that are relevant for the console.
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+9
-6
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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']);
|
||||
|
||||
+2
-2
@@ -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
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "dynamic-api-key",
|
||||
"name": "ephemeral-api-key",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
+12
-12
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user