diff --git a/Dockerfile b/Dockerfile
index 1922a0d2b9..9a61635415 100755
--- a/Dockerfile
+++ b/Dockerfile
@@ -12,7 +12,7 @@ RUN composer install --ignore-platform-reqs --optimize-autoloader \
--no-plugins --no-scripts --prefer-dist \
`if [ "$TESTING" != "true" ]; then echo "--no-dev"; fi`
-FROM appwrite/base:1.2.1 AS base
+FROM appwrite/base:1.4.1 AS base
LABEL maintainer="team@appwrite.io"
diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php
index 63b946f74f..a048920de9 100644
--- a/app/config/scopes/project.php
+++ b/app/config/scopes/project.php
@@ -215,6 +215,16 @@ return [
'description' => 'Access to create function executions',
'category' => 'Functions',
],
+ 'execution.read' => [
+ 'description' => 'Access to read function executions. This scope is deprecated for consistency purposes, and replaced by `executions.read`.',
+ 'category' => 'Functions',
+ 'deprecated' => true,
+ ],
+ 'execution.write' => [
+ 'description' => 'Access to create function executions. This scope is deprecated for consistency purposes, and replaced by `executions.write`.',
+ 'category' => 'Functions',
+ 'deprecated' => true,
+ ],
// Sites
'sites.read' => [
diff --git a/app/config/templates/function.php b/app/config/templates/function.php
index df3a569705..c6ac446509 100644
--- a/app/config/templates/function.php
+++ b/app/config/templates/function.php
@@ -79,12 +79,13 @@ return [
...getRuntimes($templateRuntimes['DENO'], 'deno cache src/main.ts', 'src/main.ts', 'deno/starter', $allowList),
...getRuntimes($templateRuntimes['BUN'], 'bun install', 'src/main.ts', 'bun/starter', $allowList),
...getRuntimes($templateRuntimes['RUBY'], 'bundle install', 'lib/main.rb', 'ruby/starter', $allowList),
+ ...getRuntimes($templateRuntimes['RUST'], '', 'main.rs', 'rust/starter', $allowList),
],
- 'instructions' => 'For documentation and instructions check out file.',
+ 'instructions' => 'For documentation and instructions check out the templates repository.',
'vcsProvider' => 'github',
'providerRepositoryId' => 'templates',
'providerOwner' => 'appwrite',
- 'providerVersion' => '0.2.*',
+ 'providerVersion' => '0.3.*',
'variables' => [],
'scopes' => ['users.read']
],
diff --git a/composer.json b/composer.json
index 1f660a87d4..5599721fed 100644
--- a/composer.json
+++ b/composer.json
@@ -49,7 +49,7 @@
"ext-openssl": "*",
"ext-zlib": "*",
"ext-sockets": "*",
- "appwrite/php-runtimes": "0.19.*",
+ "appwrite/php-runtimes": "0.20.*",
"appwrite/php-clamav": "2.0.*",
"utopia-php/abuse": "1.2.*",
"utopia-php/agents": "1.2.*",
diff --git a/composer.lock b/composer.lock
index 52e076f443..30d2973a47 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "788e59ef0f1f12947b5c3700f905c635",
+ "content-hash": "34190862fe85c76d5325fd6b5dea8f90",
"packages": [
{
"name": "adhocore/jwt",
@@ -161,16 +161,16 @@
},
{
"name": "appwrite/php-runtimes",
- "version": "0.19.5",
+ "version": "0.20.0",
"source": {
"type": "git",
"url": "https://github.com/appwrite/runtimes.git",
- "reference": "aa2f7760cd0493c0880209b92df812c9386b3546"
+ "reference": "7d9b7f4eef5c0a142a60907b06de2219d025c5c3"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/appwrite/runtimes/zipball/aa2f7760cd0493c0880209b92df812c9386b3546",
- "reference": "aa2f7760cd0493c0880209b92df812c9386b3546",
+ "url": "https://api.github.com/repos/appwrite/runtimes/zipball/7d9b7f4eef5c0a142a60907b06de2219d025c5c3",
+ "reference": "7d9b7f4eef5c0a142a60907b06de2219d025c5c3",
"shasum": ""
},
"require": {
@@ -210,9 +210,9 @@
],
"support": {
"issues": "https://github.com/appwrite/runtimes/issues",
- "source": "https://github.com/appwrite/runtimes/tree/0.19.5"
+ "source": "https://github.com/appwrite/runtimes/tree/0.20.0"
},
- "time": "2026-04-01T01:39:23+00:00"
+ "time": "2026-05-01T07:47:07+00:00"
},
{
"name": "brick/math",
diff --git a/src/Appwrite/Auth/OAuth2/Authentik.php b/src/Appwrite/Auth/OAuth2/Authentik.php
index 5d2445088b..aa4b126ae8 100644
--- a/src/Appwrite/Auth/OAuth2/Authentik.php
+++ b/src/Appwrite/Auth/OAuth2/Authentik.php
@@ -37,6 +37,13 @@ class Authentik extends OAuth2
return 'authentik';
}
+ public function verifyCredentials(): void
+ {
+ if (empty($this->getAuthentikDomain())) {
+ throw new \Exception('Authentik endpoint is required.');
+ }
+ }
+
/**
* @return string
*/
diff --git a/src/Appwrite/Auth/OAuth2/FusionAuth.php b/src/Appwrite/Auth/OAuth2/FusionAuth.php
index 415be4c6ad..fa8b45dc72 100644
--- a/src/Appwrite/Auth/OAuth2/FusionAuth.php
+++ b/src/Appwrite/Auth/OAuth2/FusionAuth.php
@@ -37,6 +37,13 @@ class FusionAuth extends OAuth2
return 'fusionauth';
}
+ public function verifyCredentials(): void
+ {
+ if (empty($this->getFusionAuthDomain())) {
+ throw new \Exception('FusionAuth endpoint is required.');
+ }
+ }
+
/**
* @return string
*/
diff --git a/src/Appwrite/Auth/OAuth2/Keycloak.php b/src/Appwrite/Auth/OAuth2/Keycloak.php
index 05e007eb7d..b53b08e2d9 100644
--- a/src/Appwrite/Auth/OAuth2/Keycloak.php
+++ b/src/Appwrite/Auth/OAuth2/Keycloak.php
@@ -37,6 +37,17 @@ class Keycloak extends OAuth2
return 'keycloak';
}
+ public function verifyCredentials(): void
+ {
+ if (empty($this->getKeycloakDomain())) {
+ throw new \Exception('Keycloak endpoint is required.');
+ }
+
+ if (empty($this->getKeycloakRealm())) {
+ throw new \Exception('Keycloak realm name is required.');
+ }
+ }
+
/**
* @return string
*/
diff --git a/src/Appwrite/Auth/OAuth2/Microsoft.php b/src/Appwrite/Auth/OAuth2/Microsoft.php
index bc05843b37..19966ec1ac 100644
--- a/src/Appwrite/Auth/OAuth2/Microsoft.php
+++ b/src/Appwrite/Auth/OAuth2/Microsoft.php
@@ -36,6 +36,13 @@ class Microsoft extends OAuth2
return 'microsoft';
}
+ public function verifyCredentials(): void
+ {
+ if (empty($this->getTenantID())) {
+ throw new \Exception('Microsoft tenant is required.');
+ }
+ }
+
/**
* @return string
*/
@@ -201,7 +208,7 @@ class Microsoft extends OAuth2
}
/**
- * Extracts the Tenant Id from the JSON stored in appSecret. Defaults to 'common' as a fallback
+ * Extracts the Tenant Id from the JSON stored in appSecret.
*
* @return string
*/
@@ -209,6 +216,6 @@ class Microsoft extends OAuth2
{
$secret = $this->getAppSecret();
- return $secret['tenantID'] ?? 'common';
+ return $secret['tenantID'] ?? '';
}
}
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php
index d5d465c3d4..af6b12618a 100644
--- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php
@@ -105,7 +105,7 @@ class Update extends Base
))
->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
- ->param('endpoint', '', new Text(256, 1), 'Domain of Authentik instance. For example: example.authentik.com', optional: false)
+ ->param('endpoint', null, new Nullable(new Text(256, 0)), 'Domain of Authentik instance. For example: example.authentik.com', optional: true)
->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
->inject('response')
->inject('dbForPlatform')
@@ -138,7 +138,7 @@ class Update extends Base
public function handle(
?string $clientId,
?string $clientSecret,
- string $endpoint,
+ ?string $endpoint,
?bool $enabled,
Response $response,
Database $dbForPlatform,
@@ -151,7 +151,7 @@ class Update extends Base
// The secret is stored as JSON `{"clientSecret": "...", "authentikDomain": "..."}`
// to match the shape Authentik's OAuth2 adapter expects (getAuthentikDomain()).
- // The `endpoint` param is required on every call, so it's always written.
+ // The `endpoint` param is optional; if omitted, the existing stored endpoint is preserved.
// `clientSecret` is optional; if omitted, the existing stored secret is preserved.
$storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
$existing = [];
@@ -160,7 +160,7 @@ class Update extends Base
}
$encodedSecret = \json_encode([
'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''),
- 'authentikDomain' => $endpoint,
+ 'authentikDomain' => $endpoint ?? ($existing['authentikDomain'] ?? ''),
]);
$project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled);
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/FusionAuth/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/FusionAuth/Update.php
index 25f81e1459..3cdf0eeb89 100644
--- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/FusionAuth/Update.php
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/FusionAuth/Update.php
@@ -105,7 +105,7 @@ class Update extends Base
))
->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
- ->param('endpoint', '', new Text(256, 1), 'Domain of FusionAuth instance. For example: example.fusionauth.io', optional: false)
+ ->param('endpoint', null, new Nullable(new Text(256, 0)), 'Domain of FusionAuth instance. For example: example.fusionauth.io', optional: true)
->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
->inject('response')
->inject('dbForPlatform')
@@ -138,7 +138,7 @@ class Update extends Base
public function handle(
?string $clientId,
?string $clientSecret,
- string $endpoint,
+ ?string $endpoint,
?bool $enabled,
Response $response,
Database $dbForPlatform,
@@ -151,7 +151,7 @@ class Update extends Base
// The secret is stored as JSON `{"clientSecret": "...", "fusionAuthDomain": "..."}`
// to match the shape FusionAuth's OAuth2 adapter expects (getFusionAuthDomain()).
- // The `endpoint` param is required on every call, so it's always written.
+ // The `endpoint` param is optional; if omitted, the existing stored endpoint is preserved.
// `clientSecret` is optional; if omitted, the existing stored secret is preserved.
$storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
$existing = [];
@@ -160,7 +160,7 @@ class Update extends Base
}
$encodedSecret = \json_encode([
'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''),
- 'fusionAuthDomain' => $endpoint,
+ 'fusionAuthDomain' => $endpoint ?? ($existing['fusionAuthDomain'] ?? ''),
]);
$project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled);
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php
index 3b6f89db06..7c680e5141 100644
--- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php
@@ -35,7 +35,7 @@ class Update extends Base
public static function getClientIdName(): string
{
- return 'OAuth 2 app Client ID, or App ID';
+ return 'OAuth2 app Client ID, or App ID';
}
public static function getClientIdExample(): string
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Keycloak/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Keycloak/Update.php
index 797875cab2..aa41e8a5e9 100644
--- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Keycloak/Update.php
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Keycloak/Update.php
@@ -111,8 +111,8 @@ class Update extends Base
))
->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
- ->param('endpoint', '', new Text(256, 1), 'Domain of Keycloak instance. For example: keycloak.example.com', optional: false)
- ->param('realmName', '', new Text(256, 1), 'Keycloak realm name. For example: appwrite-realm', optional: false)
+ ->param('endpoint', null, new Nullable(new Text(256, 0)), 'Domain of Keycloak instance. For example: keycloak.example.com', optional: true)
+ ->param('realmName', null, new Nullable(new Text(256, 0)), 'Keycloak realm name. For example: appwrite-realm', optional: true)
->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
->inject('response')
->inject('dbForPlatform')
@@ -147,8 +147,8 @@ class Update extends Base
public function handle(
?string $clientId,
?string $clientSecret,
- string $endpoint,
- string $realmName,
+ ?string $endpoint,
+ ?string $realmName,
?bool $enabled,
Response $response,
Database $dbForPlatform,
@@ -161,7 +161,7 @@ class Update extends Base
// The secret is stored as JSON `{"clientSecret": "...", "keycloakDomain": "...", "keycloakRealm": "..."}`
// to match the shape Keycloak's OAuth2 adapter expects (getKeycloakDomain(), getKeycloakRealm()).
- // The `endpoint` and `realmName` params are required on every call, so they're always written.
+ // The `endpoint` and `realmName` params are optional; if omitted, existing stored values are preserved.
// `clientSecret` is optional; if omitted, the existing stored secret is preserved.
$storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
$existing = [];
@@ -170,8 +170,8 @@ class Update extends Base
}
$encodedSecret = \json_encode([
'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''),
- 'keycloakDomain' => $endpoint,
- 'keycloakRealm' => $realmName,
+ 'keycloakDomain' => $endpoint ?? ($existing['keycloakDomain'] ?? ''),
+ 'keycloakRealm' => $realmName ?? ($existing['keycloakRealm'] ?? ''),
]);
$project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled);
diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php
index 0690ee333a..811819a05c 100644
--- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php
+++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Microsoft/Update.php
@@ -115,7 +115,7 @@ class Update extends Base
))
->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true)
->param(static::getClientSecretParamName(), null, new Nullable(new Text(512, 0)), static::getClientSecretDescription(), optional: true)
- ->param('tenant', '', new Text(256, 1), 'Microsoft Entra ID tenant identifier. Use \'common\', \'organizations\', \'consumers\' or a specific tenant ID. For example: common', optional: false)
+ ->param('tenant', null, new Nullable(new Text(256, 0)), 'Microsoft Entra ID tenant identifier. Use \'common\', \'organizations\', \'consumers\' or a specific tenant ID. For example: common', true)
->param('enabled', null, new Nullable(new Boolean()), 'OAuth2 sign-in method status. Set to true to enable new session creation. Setting to true will trigger end-to-end credentials validation, and will throw if the credentials are invalid.', true)
->inject('response')
->inject('dbForPlatform')
@@ -148,7 +148,7 @@ class Update extends Base
public function handle(
?string $applicationId,
?string $applicationSecret,
- string $tenant,
+ ?string $tenant,
?bool $enabled,
Response $response,
Database $dbForPlatform,
@@ -161,7 +161,7 @@ class Update extends Base
// The secret is stored as JSON `{"clientSecret": "...", "tenantID": "..."}`
// to match the shape Microsoft's OAuth2 adapter expects (getTenantID()).
- // The `tenant` param is required on every call, so it's always written.
+ // The `tenant` param is optional; if omitted, the existing stored tenant is preserved.
// `applicationSecret` is optional; if omitted, the existing stored secret is preserved.
$storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? '';
$existing = [];
@@ -170,7 +170,7 @@ class Update extends Base
}
$encodedSecret = \json_encode([
'clientSecret' => $applicationSecret ?? ($existing['clientSecret'] ?? ''),
- 'tenantID' => $tenant,
+ 'tenantID' => $tenant ?? ($existing['tenantID'] ?? ''),
]);
$project = $this->persistCredentials($project, $dbForPlatform, $authorization, $applicationId, $encodedSecret, $enabled);
diff --git a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php
index c111b744c3..c8f921f2ec 100644
--- a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php
+++ b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php
@@ -101,7 +101,7 @@ class ConsoleConsoleClientTest extends Scope
$this->assertCount(2, $github['parameters']);
$clientId = $github['parameters'][0];
$this->assertEquals('clientId', $clientId['$id']);
- $this->assertEquals('OAuth 2 app Client ID, or App ID', $clientId['name']);
+ $this->assertEquals('OAuth2 app Client ID, or App ID', $clientId['name']);
$this->assertEquals('e4d87900000000540733', $clientId['example']);
$this->assertEquals('Example of wrong value: 370006', $clientId['hint']);
$clientSecret = $github['parameters'][1];
diff --git a/tests/e2e/Services/Project/OAuth2Base.php b/tests/e2e/Services/Project/OAuth2Base.php
index 9ff3830ec5..5451435c3c 100644
--- a/tests/e2e/Services/Project/OAuth2Base.php
+++ b/tests/e2e/Services/Project/OAuth2Base.php
@@ -872,30 +872,36 @@ trait OAuth2Base
}
// =========================================================================
- // Update Authentik (clientId + clientSecret + REQUIRED endpoint)
+ // Update Authentik (clientId + clientSecret + optional endpoint)
// =========================================================================
- public function testUpdateOAuth2AuthentikRequiresEndpoint(): void
+ public function testUpdateOAuth2AuthentikAllowsOmittedEndpointWhenDisabled(): void
{
- // The `endpoint` param is required (Text(min=1)); omitting → 400.
$response = $this->updateOAuth2('authentik', [
'clientId' => 'whatever',
'clientSecret' => 'whatever',
+ 'enabled' => false,
]);
- $this->assertSame(400, $response['headers']['status-code']);
- $this->assertSame('general_argument_invalid', $response['body']['type']);
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('authentik', $response['body']['$id']);
+
+ // Cleanup
+ $this->updateOAuth2('authentik', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'endpoint' => '',
+ 'enabled' => false,
+ ]);
}
- public function testUpdateOAuth2AuthentikEmptyEndpointRejected(): void
+ public function testUpdateOAuth2AuthentikEmptyEndpointRejectedWhenEnabling(): void
{
- // The `endpoint` validator is Text(min=1). Sending `''` must be
- // rejected the same way as omitting — the validator should treat the
- // empty-string degenerate case as a missing required field.
$response = $this->updateOAuth2('authentik', [
'clientId' => 'whatever',
'clientSecret' => 'whatever',
'endpoint' => '',
+ 'enabled' => true,
]);
$this->assertSame(400, $response['headers']['status-code']);
@@ -920,15 +926,14 @@ trait OAuth2Base
$this->updateOAuth2('authentik', [
'clientId' => '',
'clientSecret' => '',
- 'endpoint' => 'cleanup.authentik.com',
+ 'endpoint' => '',
'enabled' => false,
]);
}
public function testUpdateOAuth2AuthentikPartialPreservesSecret(): void
{
- // Authentik's `endpoint` is required on every call, so we always
- // re-send it. The `clientSecret` lives in the JSON blob and must
+ // The `clientSecret` and `endpoint` live in the JSON blob and must
// survive when omitted on a subsequent call that only changes clientId.
$this->updateOAuth2('authentik', [
'clientId' => 'authentik-merge-client',
@@ -939,27 +944,24 @@ trait OAuth2Base
$response = $this->updateOAuth2('authentik', [
'clientId' => 'authentik-rotated-client',
- 'endpoint' => 'merge.authentik.com',
]);
$this->assertSame(200, $response['headers']['status-code']);
$this->assertSame('authentik-rotated-client', $response['body']['clientId']);
$this->assertSame('merge.authentik.com', $response['body']['endpoint']);
// Confirm clientSecret survived the omitted-field merge by enabling
- // — Authentik has no verifyCredentials() hook, so non-empty stored
- // secret is enough. `endpoint` must be re-sent (required on enable too).
+ // without re-sending endpoint.
$enable = $this->updateOAuth2('authentik', [
- 'endpoint' => 'merge.authentik.com',
'enabled' => true,
]);
$this->assertSame(200, $enable['headers']['status-code']);
$this->assertTrue($enable['body']['enabled']);
- // Cleanup — endpoint is required, use a placeholder.
+ // Cleanup
$this->updateOAuth2('authentik', [
'clientId' => '',
'clientSecret' => '',
- 'endpoint' => 'cleanup.authentik.com',
+ 'endpoint' => '',
'enabled' => false,
]);
}
@@ -984,40 +986,46 @@ trait OAuth2Base
$this->assertSame('enable.authentik.com', $get['body']['endpoint']);
$this->assertSame('', $get['body']['clientSecret']);
- // Cleanup — endpoint is required (Text(min=1)) so use a placeholder.
+ // Cleanup
$this->updateOAuth2('authentik', [
'clientId' => '',
'clientSecret' => '',
- 'endpoint' => 'cleanup.authentik.com',
+ 'endpoint' => '',
'enabled' => false,
]);
}
// =========================================================================
- // Update FusionAuth (clientId + clientSecret + REQUIRED endpoint)
+ // Update FusionAuth (clientId + clientSecret + optional endpoint)
// =========================================================================
- public function testUpdateOAuth2FusionAuthRequiresEndpoint(): void
+ public function testUpdateOAuth2FusionAuthAllowsOmittedEndpointWhenDisabled(): void
{
- // The `endpoint` param is required (Text(min=1)); omitting → 400.
$response = $this->updateOAuth2('fusionauth', [
'clientId' => 'whatever',
'clientSecret' => 'whatever',
+ 'enabled' => false,
]);
- $this->assertSame(400, $response['headers']['status-code']);
- $this->assertSame('general_argument_invalid', $response['body']['type']);
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('fusionauth', $response['body']['$id']);
+
+ // Cleanup
+ $this->updateOAuth2('fusionauth', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'endpoint' => '',
+ 'enabled' => false,
+ ]);
}
- public function testUpdateOAuth2FusionAuthEmptyEndpointRejected(): void
+ public function testUpdateOAuth2FusionAuthEmptyEndpointRejectedWhenEnabling(): void
{
- // The `endpoint` validator is Text(min=1). Sending `''` must be
- // rejected the same way as omitting — the validator should treat the
- // empty-string degenerate case as a missing required field.
$response = $this->updateOAuth2('fusionauth', [
'clientId' => 'whatever',
'clientSecret' => 'whatever',
'endpoint' => '',
+ 'enabled' => true,
]);
$this->assertSame(400, $response['headers']['status-code']);
@@ -1042,15 +1050,14 @@ trait OAuth2Base
$this->updateOAuth2('fusionauth', [
'clientId' => '',
'clientSecret' => '',
- 'endpoint' => 'cleanup.fusionauth.io',
+ 'endpoint' => '',
'enabled' => false,
]);
}
public function testUpdateOAuth2FusionAuthPartialPreservesSecret(): void
{
- // FusionAuth's `endpoint` is required on every call, so we always
- // re-send it. The `clientSecret` lives in the JSON blob and must
+ // The `clientSecret` and `endpoint` live in the JSON blob and must
// survive when omitted on a subsequent call that only changes clientId.
$this->updateOAuth2('fusionauth', [
'clientId' => 'fusionauth-merge-client',
@@ -1061,27 +1068,24 @@ trait OAuth2Base
$response = $this->updateOAuth2('fusionauth', [
'clientId' => 'fusionauth-rotated-client',
- 'endpoint' => 'merge.fusionauth.io',
]);
$this->assertSame(200, $response['headers']['status-code']);
$this->assertSame('fusionauth-rotated-client', $response['body']['clientId']);
$this->assertSame('merge.fusionauth.io', $response['body']['endpoint']);
// Confirm clientSecret survived the omitted-field merge by enabling
- // — FusionAuth has no verifyCredentials() hook, so non-empty stored
- // secret is enough. `endpoint` must be re-sent (required on enable too).
+ // without re-sending endpoint.
$enable = $this->updateOAuth2('fusionauth', [
- 'endpoint' => 'merge.fusionauth.io',
'enabled' => true,
]);
$this->assertSame(200, $enable['headers']['status-code']);
$this->assertTrue($enable['body']['enabled']);
- // Cleanup — endpoint is required, use a placeholder.
+ // Cleanup
$this->updateOAuth2('fusionauth', [
'clientId' => '',
'clientSecret' => '',
- 'endpoint' => 'cleanup.fusionauth.io',
+ 'endpoint' => '',
'enabled' => false,
]);
}
@@ -1106,70 +1110,85 @@ trait OAuth2Base
$this->assertSame('enable.fusionauth.io', $get['body']['endpoint']);
$this->assertSame('', $get['body']['clientSecret']);
- // Cleanup — endpoint is required (Text(min=1)) so use a placeholder.
+ // Cleanup
$this->updateOAuth2('fusionauth', [
'clientId' => '',
'clientSecret' => '',
- 'endpoint' => 'cleanup.fusionauth.io',
+ 'endpoint' => '',
'enabled' => false,
]);
}
// =========================================================================
- // Update Keycloak (clientId + clientSecret + REQUIRED endpoint + REQUIRED realmName)
+ // Update Keycloak (clientId + clientSecret + optional endpoint + optional realmName)
// =========================================================================
- public function testUpdateOAuth2KeycloakRequiresEndpoint(): void
+ public function testUpdateOAuth2KeycloakAllowsOmittedEndpointWhenDisabled(): void
{
- // The `endpoint` param is required (Text(min=1)); omitting → 400.
$response = $this->updateOAuth2('keycloak', [
'clientId' => 'whatever',
'clientSecret' => 'whatever',
'realmName' => 'appwrite-realm',
+ 'enabled' => false,
]);
- $this->assertSame(400, $response['headers']['status-code']);
- $this->assertSame('general_argument_invalid', $response['body']['type']);
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('keycloak', $response['body']['$id']);
+
+ // Cleanup
+ $this->updateOAuth2('keycloak', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'endpoint' => '',
+ 'realmName' => '',
+ 'enabled' => false,
+ ]);
}
- public function testUpdateOAuth2KeycloakEmptyEndpointRejected(): void
+ public function testUpdateOAuth2KeycloakEmptyEndpointRejectedWhenEnabling(): void
{
- // The `endpoint` validator is Text(min=1). Sending `''` must be
- // rejected the same way as omitting — the validator should treat the
- // empty-string degenerate case as a missing required field.
$response = $this->updateOAuth2('keycloak', [
'clientId' => 'whatever',
'clientSecret' => 'whatever',
'endpoint' => '',
'realmName' => 'appwrite-realm',
+ 'enabled' => true,
]);
$this->assertSame(400, $response['headers']['status-code']);
$this->assertSame('general_argument_invalid', $response['body']['type']);
}
- public function testUpdateOAuth2KeycloakRequiresRealmName(): void
+ public function testUpdateOAuth2KeycloakAllowsOmittedRealmNameWhenDisabled(): void
{
- // The `realmName` param is required (Text(min=1)); omitting → 400.
$response = $this->updateOAuth2('keycloak', [
'clientId' => 'whatever',
'clientSecret' => 'whatever',
'endpoint' => 'keycloak.example.com',
+ 'enabled' => false,
]);
- $this->assertSame(400, $response['headers']['status-code']);
- $this->assertSame('general_argument_invalid', $response['body']['type']);
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('keycloak', $response['body']['$id']);
+
+ // Cleanup
+ $this->updateOAuth2('keycloak', [
+ 'clientId' => '',
+ 'clientSecret' => '',
+ 'endpoint' => '',
+ 'realmName' => '',
+ 'enabled' => false,
+ ]);
}
- public function testUpdateOAuth2KeycloakEmptyRealmNameRejected(): void
+ public function testUpdateOAuth2KeycloakEmptyRealmNameRejectedWhenEnabling(): void
{
- // The `realmName` validator is Text(min=1). Sending `''` must be
- // rejected the same way as omitting.
$response = $this->updateOAuth2('keycloak', [
'clientId' => 'whatever',
'clientSecret' => 'whatever',
'endpoint' => 'keycloak.example.com',
'realmName' => '',
+ 'enabled' => true,
]);
$this->assertSame(400, $response['headers']['status-code']);
@@ -1196,16 +1215,15 @@ trait OAuth2Base
$this->updateOAuth2('keycloak', [
'clientId' => '',
'clientSecret' => '',
- 'endpoint' => 'cleanup.keycloak.com',
- 'realmName' => 'cleanup-realm',
+ 'endpoint' => '',
+ 'realmName' => '',
'enabled' => false,
]);
}
public function testUpdateOAuth2KeycloakPartialPreservesSecret(): void
{
- // Keycloak's `endpoint` and `realmName` are required on every call,
- // so we always re-send them. The `clientSecret` lives in the JSON
+ // The `clientSecret`, `endpoint`, and `realmName` live in the JSON
// blob and must survive when omitted on a subsequent call that only
// changes clientId.
$this->updateOAuth2('keycloak', [
@@ -1218,8 +1236,6 @@ trait OAuth2Base
$response = $this->updateOAuth2('keycloak', [
'clientId' => 'keycloak-rotated-client',
- 'endpoint' => 'merge.keycloak.com',
- 'realmName' => 'merge-realm',
]);
$this->assertSame(200, $response['headers']['status-code']);
$this->assertSame('keycloak-rotated-client', $response['body']['clientId']);
@@ -1227,23 +1243,19 @@ trait OAuth2Base
$this->assertSame('merge-realm', $response['body']['realmName']);
// Confirm clientSecret survived the omitted-field merge by enabling
- // — Keycloak has no verifyCredentials() hook, so non-empty stored
- // secret is enough. `endpoint`/`realmName` must be re-sent (required
- // on enable too).
+ // without re-sending endpoint or realmName.
$enable = $this->updateOAuth2('keycloak', [
- 'endpoint' => 'merge.keycloak.com',
- 'realmName' => 'merge-realm',
'enabled' => true,
]);
$this->assertSame(200, $enable['headers']['status-code']);
$this->assertTrue($enable['body']['enabled']);
- // Cleanup — endpoint and realmName are required, use placeholders.
+ // Cleanup
$this->updateOAuth2('keycloak', [
'clientId' => '',
'clientSecret' => '',
- 'endpoint' => 'cleanup.keycloak.com',
- 'realmName' => 'cleanup-realm',
+ 'endpoint' => '',
+ 'realmName' => '',
'enabled' => false,
]);
}
@@ -1270,40 +1282,47 @@ trait OAuth2Base
$this->assertSame('enable-realm', $get['body']['realmName']);
$this->assertSame('', $get['body']['clientSecret']);
- // Cleanup — endpoint and realmName are required (Text(min=1)) so use placeholders.
+ // Cleanup
$this->updateOAuth2('keycloak', [
'clientId' => '',
'clientSecret' => '',
- 'endpoint' => 'cleanup.keycloak.com',
- 'realmName' => 'cleanup-realm',
+ 'endpoint' => '',
+ 'realmName' => '',
'enabled' => false,
]);
}
// =========================================================================
- // Update Microsoft (applicationId + applicationSecret + REQUIRED tenant)
+ // Update Microsoft (applicationId + applicationSecret + optional tenant)
// =========================================================================
- public function testUpdateOAuth2MicrosoftRequiresTenant(): void
+ public function testUpdateOAuth2MicrosoftAllowsOmittedTenantWhenDisabled(): void
{
$response = $this->updateOAuth2('microsoft', [
'applicationId' => 'whatever',
'applicationSecret' => 'whatever',
+ 'enabled' => false,
]);
- $this->assertSame(400, $response['headers']['status-code']);
- $this->assertSame('general_argument_invalid', $response['body']['type']);
+ $this->assertSame(200, $response['headers']['status-code']);
+ $this->assertSame('microsoft', $response['body']['$id']);
+
+ // Cleanup
+ $this->updateOAuth2('microsoft', [
+ 'applicationId' => '',
+ 'applicationSecret' => '',
+ 'tenant' => '',
+ 'enabled' => false,
+ ]);
}
- public function testUpdateOAuth2MicrosoftEmptyTenantRejected(): void
+ public function testUpdateOAuth2MicrosoftEmptyTenantRejectedWhenEnabling(): void
{
- // The `tenant` validator is Text(min=1). Sending `''` must be rejected
- // the same way as omitting — the validator should treat the empty
- // string as a missing required field.
$response = $this->updateOAuth2('microsoft', [
'applicationId' => 'whatever',
'applicationSecret' => 'whatever',
'tenant' => '',
+ 'enabled' => true,
]);
$this->assertSame(400, $response['headers']['status-code']);
@@ -1331,7 +1350,7 @@ trait OAuth2Base
$this->updateOAuth2('microsoft', [
'applicationId' => '',
'applicationSecret' => '',
- 'tenant' => 'common',
+ 'tenant' => '',
'enabled' => false,
]);
}
@@ -1346,23 +1365,21 @@ trait OAuth2Base
'enabled' => false,
]);
- // Patch with only `tenant` (it's required on every call) and a new
- // applicationId, leaving applicationSecret omitted. The stored secret
- // must not be wiped.
+ // Patch with only a new applicationId, leaving applicationSecret and
+ // tenant omitted. The stored JSON values must not be wiped.
$response = $this->updateOAuth2('microsoft', [
'applicationId' => 'updated-app-id',
- 'tenant' => 'organizations',
]);
$this->assertSame(200, $response['headers']['status-code']);
$this->assertSame('updated-app-id', $response['body']['applicationId']);
- $this->assertSame('organizations', $response['body']['tenant']);
+ $this->assertSame('common', $response['body']['tenant']);
// Cleanup
$this->updateOAuth2('microsoft', [
'applicationId' => '',
'applicationSecret' => '',
- 'tenant' => 'common',
+ 'tenant' => '',
'enabled' => false,
]);
}
@@ -1387,11 +1404,11 @@ trait OAuth2Base
$this->assertSame('common', $get['body']['tenant']);
$this->assertSame('', $get['body']['applicationSecret']);
- // Cleanup — tenant is required (Text(min=1)) so use a placeholder.
+ // Cleanup
$this->updateOAuth2('microsoft', [
'applicationId' => '',
'applicationSecret' => '',
- 'tenant' => 'common',
+ 'tenant' => '',
'enabled' => false,
]);
}
@@ -2401,8 +2418,9 @@ trait OAuth2Base
//
// Ensures each provider's Update endpoint is wired up correctly: routing,
// provider class, response model and `$id`. Custom-shaped providers
- // (apple, auth0, authentik, gitlab, microsoft, oidc, okta, dropbox) and
- // sandboxes (paypalSandbox, tradeshiftSandbox) have dedicated tests above.
+ // (apple, auth0, authentik, fusionauth, gitlab, keycloak, microsoft, oidc,
+ // okta, dropbox) and sandboxes (paypalSandbox, tradeshiftSandbox) have
+ // dedicated tests above.
// Github is excluded because its `verifyCredentials()` hook is exercised
// separately.
// =========================================================================