From d01fd7d9e0ae933ae842f0cb898a32f713288d3e Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 17 Sep 2026 10:33:47 +0100 Subject: [PATCH 1/3] fix: keep optional impersonation in URL builder query strings The optional-security migration made the securitySchemes filter drop any scheme that is accepted but not required, so example snippets stop configuring optional credentials. The same filter builds the query string of location and webAuth methods, and a URL builder has no request to carry headers, so dropping ImpersonateUserId there removed the credential rather than trimming an example. Console download, preview and avatar URLs were sent as the impersonator and returned 401. Give payload templates their own locationSchemes filter that keeps every scheme x-appwrite.auth lists; examples keep skipping the optional ones. --- src/SDK/SDK.php | 16 ++++++++++++---- .../java/io/package/services/Service.kt.twig | 4 ++-- templates/dart/base/requests/oauth.twig | 6 +++--- templates/dotnet/base/utils.twig | 2 +- templates/flutter/base/requests/oauth.twig | 6 +++--- .../react-native/src/services/template.ts.twig | 6 +++--- templates/swift/base/params.twig | 6 +++--- templates/web/src/services/template.ts.twig | 4 ++-- tests/generation/GenerationTest.php | 9 ++++++++- tests/resources/spec-openapi3.json | 6 ++++++ 10 files changed, 43 insertions(+), 22 deletions(-) diff --git a/src/SDK/SDK.php b/src/SDK/SDK.php index f02cc82c46..54df7da53a 100644 --- a/src/SDK/SDK.php +++ b/src/SDK/SDK.php @@ -185,6 +185,7 @@ public function __construct(protected Language $language, protected Specificatio $this->twig->addFilter(new TwigFilter('methodHeaders', fn(Operation $operation): array => $this->getMethodHeaders($operation))); $this->twig->addFilter(new TwigFilter('responseDiscriminator', fn(Operation $operation): array => $this->getResponseDiscriminator($operation))); $this->twig->addFilter(new TwigFilter('securitySchemes', fn(Operation $operation): array => $this->getOperationAuthSchemes($operation))); + $this->twig->addFilter(new TwigFilter('locationSchemes', fn(Operation $operation): array => $this->getOperationAuthSchemes($operation, true))); $this->twig->addFilter(new TwigFilter('securityHeaders', fn(Operation $operation): array => $this->getOperationSecuritySchemes($operation, ParameterLocation::HEADER, false))); $this->twig->addFilter(new TwigFilter('securityQueries', fn(Operation $operation): array => $this->getOperationSecuritySchemes($operation, ParameterLocation::QUERY))); $this->twig->addFilter(new TwigFilter('schemaNullable', fn(Schema|Parameter $value): bool => !isset($this->multipartSchemas[\spl_object_id($this->getSchema($value))]) && $this->getSchema($value)->nullable)); @@ -1850,7 +1851,14 @@ protected function getMethodHeaders(Operation $operation): array * * @return array */ - protected function getOperationAuthSchemes(Operation $operation): array + /** + * The schemes `x-appwrite.auth` configures for an operation. Examples skip + * the optional ones; a URL builder has no request to carry headers, so + * every scheme it lists becomes a query parameter and none may be dropped. + * + * @return array + */ + protected function getOperationAuthSchemes(Operation $operation, bool $includeOptional = false): array { $auth = $operation->extensions[Extension::APPWRITE->value][Appwrite::AUTH->value] ?? []; if (!\is_array($auth)) { @@ -1859,10 +1867,10 @@ protected function getOperationAuthSchemes(Operation $operation): array $auth = $auth[$this->getParam('platform')] ?? $auth; $schemes = []; $pathSchemes = []; - $optional = \array_diff($operation->acceptedSecuritySchemeNames(), $operation->requiredSecuritySchemeNames()); + $optional = $includeOptional + ? [] + : \array_diff($operation->acceptedSecuritySchemeNames(), $operation->requiredSecuritySchemeNames()); foreach (\array_keys($auth) as $name) { - // Example configuration is independent of API authentication. Only - // omit a candidate when security explicitly makes it optional. if (\in_array($name, $optional, true)) { continue; } diff --git a/templates/android/library/src/main/java/io/package/services/Service.kt.twig b/templates/android/library/src/main/java/io/package/services/Service.kt.twig index c9598b2f21..2b60605aff 100644 --- a/templates/android/library/src/main/java/io/package/services/Service.kt.twig +++ b/templates/android/library/src/main/java/io/package/services/Service.kt.twig @@ -97,8 +97,8 @@ class {{ service.name | caseUcfirst }}(client: Client) : Service(client) { "{{ parameter.name }}" to {{ parameter.name | caseCamel }}, {%~ endfor %} {%~ if (method | methodType) == 'webAuth' %} - {%~ if [(method | securitySchemes)] | length > 0 %} - {%~ for node in [(method | securitySchemes)] %} + {%~ if [(method | locationSchemes)] | length > 0 %} + {%~ for node in [(method | locationSchemes)] %} {%~ for key,header in node | keys %} "{{ header | caseLower }}" to client.config["{{ header | caseLower }}"], {%~ endfor %} diff --git a/templates/dart/base/requests/oauth.twig b/templates/dart/base/requests/oauth.twig index 20443d2e96..fbf183116f 100644 --- a/templates/dart/base/requests/oauth.twig +++ b/templates/dart/base/requests/oauth.twig @@ -1,9 +1,9 @@ {% import 'dart/base/utils.twig' as utils %} {% set queryParams = method | parameters('query') %} {% set bodyParams = method | parameters('body') %} - final Map params = {% if queryParams|length == 0 and bodyParams|length == 0 and ([(method | securitySchemes)]|length == 0) %}{}{% else %}{ -{{ utils.map_parameter(queryParams) }}{{ utils.map_parameter(bodyParams) }}{% if [(method | securitySchemes)]|length > 0 %} -{% for node in [(method | securitySchemes)] %} + final Map params = {% if queryParams|length == 0 and bodyParams|length == 0 and ([(method | locationSchemes)]|length == 0) %}{}{% else %}{ +{{ utils.map_parameter(queryParams) }}{{ utils.map_parameter(bodyParams) }}{% if [(method | locationSchemes)]|length > 0 %} +{% for node in [(method | locationSchemes)] %} {% for key, header in node|keys %} '{{header|caseLower}}': client.config['{{header|caseLower}}'], {% endfor %} diff --git a/templates/dotnet/base/utils.twig b/templates/dotnet/base/utils.twig index cec7ba06b3..dce98b2009 100644 --- a/templates/dotnet/base/utils.twig +++ b/templates/dotnet/base/utils.twig @@ -9,7 +9,7 @@ {% if parameter.name == 'orderType' %}{{ parameter.name | caseCamel ~ '.ToString()'}}{% elseif (parameter | enumValues) is not empty and (parameter | schemaType) == 'array' %}{{ parameter.name | caseCamel | escapeKeyword }}?.Select(e => e.Value).ToList(){% elseif (parameter | enumValues) is not empty %}{{ parameter.name | caseCamel | escapeKeyword }}?.Value{% else %}{{ parameter.name | caseCamel | escapeKeyword }}{% endif %} {% endmacro %} {% macro methodNeedsSecurityParameters(method) %} -{% if ((method | methodType) == "webAuth" or (method | methodType) == "location") and [(method | securitySchemes)]|length > 0 %}{{ true }}{% else %}{{false}}{% endif %} +{% if ((method | methodType) == "webAuth" or (method | methodType) == "location") and [(method | locationSchemes)]|length > 0 %}{{ true }}{% else %}{{false}}{% endif %} {% endmacro %} {% macro resultType(namespace, method) %} {% if (method | methodType) == "webAuth" %}bool{% elseif (method | methodType) == "location" %}byte[]{% elseif (method | responseModels)|length > 1 %}object{% elseif not (method | responseModel) or (method | responseModel) == 'any' %}object{% else %}Models.{{(method | responseModel) | caseUcfirst | overrideIdentifier }}{% endif %} diff --git a/templates/flutter/base/requests/oauth.twig b/templates/flutter/base/requests/oauth.twig index 7007b7b3e4..011dec0096 100644 --- a/templates/flutter/base/requests/oauth.twig +++ b/templates/flutter/base/requests/oauth.twig @@ -1,9 +1,9 @@ {% import 'flutter/base/utils.twig' as utils %} {% set queryParams = method | parameters('query') %} {% set bodyParams = method | parameters('body') %} - final Map params = {% if queryParams|length == 0 and bodyParams|length == 0 and ([(method | securitySchemes)]|length == 0) %}{}{% else %}{ -{{ utils.map_parameter(queryParams) }}{{ utils.map_parameter(bodyParams) }}{% if [(method | securitySchemes)]|length > 0 %} -{% for node in [(method | securitySchemes)] %} + final Map params = {% if queryParams|length == 0 and bodyParams|length == 0 and ([(method | locationSchemes)]|length == 0) %}{}{% else %}{ +{{ utils.map_parameter(queryParams) }}{{ utils.map_parameter(bodyParams) }}{% if [(method | locationSchemes)]|length > 0 %} +{% for node in [(method | locationSchemes)] %} {% for key, header in node|keys %} '{{header|caseLower}}': client.config['{{header|caseLower}}'], {% endfor %} diff --git a/templates/react-native/src/services/template.ts.twig b/templates/react-native/src/services/template.ts.twig index 8da92025be..5536610d1c 100644 --- a/templates/react-native/src/services/template.ts.twig +++ b/templates/react-native/src/services/template.ts.twig @@ -165,8 +165,8 @@ export class {{ service.name | caseUcfirst }} extends Service { uri.searchParams.append('{{ security.name }}', this.client.config.{{ security.name | caseLower }}); {%~ endfor %} {% if (method | methodType) == 'location' or (method | methodType) == 'webAuth' %} -{% if [(method | securitySchemes)]|length > 0 %} -{% for node in [(method | securitySchemes)] %} +{% if [(method | locationSchemes)]|length > 0 %} +{% for node in [(method | locationSchemes)] %} {% for key,header in node|keys %} payload['{{header|caseLower}}'] = this.client.config.{{header|caseLower}}; @@ -484,7 +484,7 @@ export class {{ service.name | caseUcfirst }} extends Service { {%~ for security in (method | securityQueries) %} uri.searchParams.append('{{ security.name }}', this.client.config.{{ security.name | caseLower }}); {%~ endfor %} -{% for node in [(method | securitySchemes)] %} +{% for node in [(method | locationSchemes)] %} {% for key,header in node|keys %} payload['{{header|caseLower}}'] = this.client.config.{{header|caseLower}}; diff --git a/templates/swift/base/params.twig b/templates/swift/base/params.twig index fd64e8f472..547417d8b9 100644 --- a/templates/swift/base/params.twig +++ b/templates/swift/base/params.twig @@ -22,12 +22,12 @@ {%- else -%} let {%- endif %} apiParams: [String: Any?] = [ {%~ for parameter in (method | parameters('query')) | merge((method | parameters('body'))) %} - "{{ parameter.name }}": {{ parameter.name | caseCamel | escapeSwiftKeyword }}{% if (parameter | enumValues) is not empty %}{% if (parameter | schemaType) == 'array' %}{% if not parameter.required %}?{% endif %}.map { $0.rawValue }{% else %}{% if not parameter.required %}?{% endif %}.rawValue{% endif %}{% endif %}{% if not loop.last or ((method | parameters('query')) | merge((method | parameters('body')))) | length > 1 or ((method | methodType) == 'webAuth' and [(method | securitySchemes)] | length > 0) %},{% endif %} + "{{ parameter.name }}": {{ parameter.name | caseCamel | escapeSwiftKeyword }}{% if (parameter | enumValues) is not empty %}{% if (parameter | schemaType) == 'array' %}{% if not parameter.required %}?{% endif %}.map { $0.rawValue }{% else %}{% if not parameter.required %}?{% endif %}.rawValue{% endif %}{% endif %}{% if not loop.last or ((method | parameters('query')) | merge((method | parameters('body')))) | length > 1 or ((method | methodType) == 'webAuth' and [(method | locationSchemes)] | length > 0) %},{% endif %} {%~ endfor %} {%~ if (method | methodType) == 'webAuth' %} - {%~ if [(method | securitySchemes)] | length > 0 %} - {%~ for node in [(method | securitySchemes)] %} + {%~ if [(method | locationSchemes)] | length > 0 %} + {%~ for node in [(method | locationSchemes)] %} {%~ for key,header in node | keys %} "{{ header | caseLower }}": client.config["{{ header | caseLower }}"]{% if not loop.last or ((method | parameters('query')) | merge((method | parameters('body')))) | length > 0 or (node | keys) | length > 1 %},{% endif %} diff --git a/templates/web/src/services/template.ts.twig b/templates/web/src/services/template.ts.twig index faa3231a0c..3c5da4fe23 100644 --- a/templates/web/src/services/template.ts.twig +++ b/templates/web/src/services/template.ts.twig @@ -174,8 +174,8 @@ export class {{ service.name | caseUcfirst }} { {%~ endif %} {%~ if (method | methodType) == 'location' or (method | methodType) == 'webAuth' %} - {%~ if [(method | securitySchemes)]|length > 0 %} - {%~ for node in [(method | securitySchemes)] %} + {%~ if [(method | locationSchemes)]|length > 0 %} + {%~ for node in [(method | locationSchemes)] %} {%~ for key,header in node|keys %} payload['{{header|caseLower}}'] = this.client.config.{{header|caseLower}}; {%~ endfor %} diff --git a/tests/generation/GenerationTest.php b/tests/generation/GenerationTest.php index fcd982e8ca..35a41ca731 100644 --- a/tests/generation/GenerationTest.php +++ b/tests/generation/GenerationTest.php @@ -297,7 +297,8 @@ public function testFixtureSelection(string $name): void * A canonical document keys `x-appwrite.auth` by platform. The fixture's * platform-auth operation lists `Project` for client and `Project, Key` for * server plus an optional Session, and the server alias variant lists - * `Project, JWT`. Optional security must not become example configuration. + * `Project, JWT`. Optional security must not become example configuration, + * but it must still reach a location method's query string. */ public function testExampleCredentialsFollowPlatformAuth(): void { @@ -306,6 +307,7 @@ public function testExampleCredentialsFollowPlatformAuth(): void ['client', 'docs/examples/general/zzderivedauth.md', ['->setproject('], ['->setkey(', '->setjwt(', '->setsession(']], ['server', 'docs/examples/general/zzplatformalias.md', ['->setproject(', '->setjwt('], ['->setkey(', '->setsession(']], ['client', 'docs/examples/general/zzplatformalias.md', ['->setproject('], ['->setkey(', '->setjwt(', '->setsession(']], + ['client', 'docs/examples/general/download.md', ['->setproject('], ['->setimpersonateuserid(']], ]; foreach ($examples as [$platform, $path, $present, $absent]) { @@ -318,6 +320,11 @@ public function testExampleCredentialsFollowPlatformAuth(): void $this->assertStringNotContainsString($call, $files[$path], "php/{$platform} {$path} configures {$call}"); } } + + // A URL builder carries its credentials in the query string, so an + // optional impersonation scheme must still reach the location payload. + $download = $this->generate('web', 'client')['src/services/general.ts']; + $this->assertStringContainsString("payload['impersonateuserid'] = this.client.config.impersonateuserid;", $download); } /** diff --git a/tests/resources/spec-openapi3.json b/tests/resources/spec-openapi3.json index 057f47bfe6..956ef9c675 100644 --- a/tests/resources/spec-openapi3.json +++ b/tests/resources/spec-openapi3.json @@ -1288,6 +1288,12 @@ "Project": [], "Key": [], "JWT": [] + }, + { + "Project": [], + "Key": [], + "JWT": [], + "ImpersonateUserId": [] } ], "responses": { From 538d7202fb0dc75f7fc13fb42ac45313b775fb63 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 17 Sep 2026 10:45:16 +0100 Subject: [PATCH 2/3] test: prove the Web URL builder delivers impersonation to the server The generation suite asserted on the emitted TypeScript, which mirrors the template rather than the behaviour. Open the built URL against the mock instead: the mock now accepts credentials from the query string, as the real API does for browser navigations, and echoes the impersonated user so the Web E2E fails when the parameter is missing. --- mock-server/app/http.php | 12 ++++++++---- tests/e2e/Base.php | 7 +++++++ tests/e2e/WebChromiumTest.php | 1 + tests/e2e/WebNodeTest.php | 1 + tests/e2e/languages/web/index.html | 8 ++++++++ tests/e2e/languages/web/node.js | 8 ++++++++ tests/generation/GenerationTest.php | 9 ++------- 7 files changed, 35 insertions(+), 11 deletions(-) diff --git a/mock-server/app/http.php b/mock-server/app/http.php index 48dbd1f390..307bf8a82b 100644 --- a/mock-server/app/http.php +++ b/mock-server/app/http.php @@ -317,18 +317,22 @@ ->action(function (Request $request, UtopiaSwooleResponse $response) { // Location methods render from their own request template, so they are the - // easiest place to lose the project header. The real API pairs an API key - // against this header and rejects the request without it. - if (empty($request->getHeader('x-appwrite-project', ''))) { + // easiest place to lose a credential. Server SDKs send headers; a client + // SDK returns a URL for the browser to open, so its credentials arrive in + // the query string. The real API accepts both and rejects a request that + // carries neither. + $project = $request->getHeader('x-appwrite-project', '') ?: $request->getParam('project', ''); + if (empty($project)) { throw new Exception(Exception::GENERAL_MOCK, 'Missing project ID'); } + $impersonate = $request->getHeader('x-appwrite-impersonate-user-id', '') ?: $request->getParam('impersonateuserid', ''); $response ->setContentType('text/plain') ->addHeader('Content-Disposition', 'attachment; filename="test.txt"') ->addHeader('Expires', \date('D, d M Y H:i:s', \time() + (60 * 60 * 24 * 45)) . ' GMT') // 45 days cache ->addHeader('X-Peak', \memory_get_peak_usage()) - ->send("GET:/v1/mock/tests/general/download:passed"); + ->send('GET:/v1/mock/tests/general/download:passed' . ($impersonate === '' ? '' : ':as:' . $impersonate)); }); App::post('/v1/mock/tests/general/upload') diff --git a/tests/e2e/Base.php b/tests/e2e/Base.php index a56dcbb65c..4a1cec896a 100644 --- a/tests/e2e/Base.php +++ b/tests/e2e/Base.php @@ -45,6 +45,13 @@ abstract class Base extends TestCase 'GET:/v1/mock/tests/general/redirect/done:passed', ]; + // A client URL builder is opened by the browser, so every configured + // credential has to travel in the query string, optional ones included. + protected const LOCATION_RESPONSES = [ + 'GET:/v1/mock/tests/general/download:passed', + 'GET:/v1/mock/tests/general/download:passed:as:impersonated', + ]; + protected const PATH_PARAM_RESPONSES = [ 'GET:/v1/mock/tests/general/path/grant%2Fspecial%26id:passed', ]; diff --git a/tests/e2e/WebChromiumTest.php b/tests/e2e/WebChromiumTest.php index 3b87428f09..f347cff96a 100644 --- a/tests/e2e/WebChromiumTest.php +++ b/tests/e2e/WebChromiumTest.php @@ -41,6 +41,7 @@ final class WebChromiumTest extends Base ...Base::BAR_RESPONSES, ...Base::BAR_RESPONSES, // Object params ...Base::GENERAL_RESPONSES, + ...Base::LOCATION_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::PATH_PARAM_RESPONSES, ...Base::UPLOAD_RESPONSE, diff --git a/tests/e2e/WebNodeTest.php b/tests/e2e/WebNodeTest.php index 3440c51b5a..69332da8d0 100644 --- a/tests/e2e/WebNodeTest.php +++ b/tests/e2e/WebNodeTest.php @@ -42,6 +42,7 @@ final class WebNodeTest extends Base ...Base::BAR_RESPONSES, ...Base::BAR_RESPONSES, // Object params ...Base::GENERAL_RESPONSES, + ...Base::LOCATION_RESPONSES, ...Base::PATH_VALIDATION_RESPONSES, ...Base::PATH_PARAM_RESPONSES, ...Base::ENUM_RESPONSES, diff --git a/tests/e2e/languages/web/index.html b/tests/e2e/languages/web/index.html index ebcc1ff538..cd61085d54 100644 --- a/tests/e2e/languages/web/index.html +++ b/tests/e2e/languages/web/index.html @@ -195,6 +195,14 @@ response = await general.redirect(); console.log(response.result); + // Location URL builder carries the configured credentials in the query string + response = await fetch(general.download()); + console.log(await response.text()); + client.setImpersonateUserId('impersonated'); + response = await fetch(general.download()); + console.log(await response.text()); + client.setImpersonateUserId(''); + for (const [id, plain] of [['', '0'], ['0', '']]) { try { await general.validatePath({ id, plain }); diff --git a/tests/e2e/languages/web/node.js b/tests/e2e/languages/web/node.js index b8b3b30a15..f140257b8f 100644 --- a/tests/e2e/languages/web/node.js +++ b/tests/e2e/languages/web/node.js @@ -124,6 +124,14 @@ async function start() { response = await general.redirect(); console.log(response.result); + // Location URL builder carries the configured credentials in the query string + response = await fetch(general.download()); + console.log(await response.text()); + client.setImpersonateUserId('impersonated'); + response = await fetch(general.download()); + console.log(await response.text()); + client.setImpersonateUserId(''); + for (const [id, plain] of [['', '0'], ['0', '']]) { try { await general.validatePath({ id, plain }); diff --git a/tests/generation/GenerationTest.php b/tests/generation/GenerationTest.php index 35a41ca731..41e0d394ab 100644 --- a/tests/generation/GenerationTest.php +++ b/tests/generation/GenerationTest.php @@ -297,8 +297,8 @@ public function testFixtureSelection(string $name): void * A canonical document keys `x-appwrite.auth` by platform. The fixture's * platform-auth operation lists `Project` for client and `Project, Key` for * server plus an optional Session, and the server alias variant lists - * `Project, JWT`. Optional security must not become example configuration, - * but it must still reach a location method's query string. + * `Project, JWT`. Optional security must not become example configuration; + * the Web E2E proves it still reaches a location method's query string. */ public function testExampleCredentialsFollowPlatformAuth(): void { @@ -320,11 +320,6 @@ public function testExampleCredentialsFollowPlatformAuth(): void $this->assertStringNotContainsString($call, $files[$path], "php/{$platform} {$path} configures {$call}"); } } - - // A URL builder carries its credentials in the query string, so an - // optional impersonation scheme must still reach the location payload. - $download = $this->generate('web', 'client')['src/services/general.ts']; - $this->assertStringContainsString("payload['impersonateuserid'] = this.client.config.impersonateuserid;", $download); } /** From 41d5bef8bbe42842c0d8d4884e480d3a37bae232 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Thu, 17 Sep 2026 10:52:51 +0100 Subject: [PATCH 3/3] test: trim mock comments --- mock-server/app/http.php | 7 ++----- tests/e2e/Base.php | 2 -- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/mock-server/app/http.php b/mock-server/app/http.php index 307bf8a82b..c0830e5774 100644 --- a/mock-server/app/http.php +++ b/mock-server/app/http.php @@ -316,11 +316,8 @@ ->inject('response') ->action(function (Request $request, UtopiaSwooleResponse $response) { - // Location methods render from their own request template, so they are the - // easiest place to lose a credential. Server SDKs send headers; a client - // SDK returns a URL for the browser to open, so its credentials arrive in - // the query string. The real API accepts both and rejects a request that - // carries neither. + // Client SDKs build a URL for the browser, so credentials may arrive in + // the query string instead of headers. The real API accepts both. $project = $request->getHeader('x-appwrite-project', '') ?: $request->getParam('project', ''); if (empty($project)) { throw new Exception(Exception::GENERAL_MOCK, 'Missing project ID'); diff --git a/tests/e2e/Base.php b/tests/e2e/Base.php index 4a1cec896a..692296c4d9 100644 --- a/tests/e2e/Base.php +++ b/tests/e2e/Base.php @@ -45,8 +45,6 @@ abstract class Base extends TestCase 'GET:/v1/mock/tests/general/redirect/done:passed', ]; - // A client URL builder is opened by the browser, so every configured - // credential has to travel in the query string, optional ones included. protected const LOCATION_RESPONSES = [ 'GET:/v1/mock/tests/general/download:passed', 'GET:/v1/mock/tests/general/download:passed:as:impersonated',