From 90f0282ce334b7351307711d4731ff20ab634700 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 16 Mar 2026 16:31:08 +0100 Subject: [PATCH 01/10] Implement oauth2 token flow tests --- .../Account/AccountCustomClientTest.php | 129 +++++++++++++++++- 1 file changed, 128 insertions(+), 1 deletion(-) diff --git a/tests/e2e/Services/Account/AccountCustomClientTest.php b/tests/e2e/Services/Account/AccountCustomClientTest.php index ea387cff6c..107dceaa5e 100644 --- a/tests/e2e/Services/Account/AccountCustomClientTest.php +++ b/tests/e2e/Services/Account/AccountCustomClientTest.php @@ -2182,11 +2182,138 @@ class AccountCustomClientTest extends Scope ]), [ 'success' => 'http://localhost/v1/mock/tests/general/oauth2/success', 'failure' => 'http://localhost/v1/mock/tests/general/oauth2/failure', - ]); + ], followRedirects: false); + + $this->assertEquals(301, $response['headers']['status-code']); + $this->assertStringStartsWith('http://localhost/v1/mock/tests/general/oauth2', $response['headers']['location']); + + $oauthClient = new Client(); + $oauthClient->setEndpoint(''); + $response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false); + + $this->assertEquals(301, $response['headers']['status-code']); + $this->assertStringStartsWith('http://appwrite:/v1/account/sessions/oauth2/callback/mock/' . $this->getProject()['$id'] . '?code=', $response['headers']['location']); + + $response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false); + + $this->assertEquals(301, $response['headers']['status-code']); + $this->assertStringStartsWith('http://appwrite:/v1/account/sessions/oauth2/mock/redirect?code=', $response['headers']['location']); + + $response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false); + + $this->assertEquals(301, $response['headers']['status-code']); + + $this->assertArrayHasKey('a_session_' . $this->getProject()['$id'] . '_legacy', $response['cookies']); + $this->assertArrayHasKey('a_session_' . $this->getProject()['$id'], $response['cookies']); + + $oauthUserCookie = $response['cookies']['a_session_' . $this->getProject()['$id']]; + $this->assertNotEmpty($oauthUserCookie); + + $response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false); $this->assertEquals(200, $response['headers']['status-code']); $this->assertEquals('success', $response['body']['result']); + // Ensure user is authenticated + $response = $this->client->call(Client::METHOD_GET, '/account', [ + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $oauthUserCookie, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('useroauth@localhost.test', $response['body']['email']); + + $oauthUserId = $response['body']['$id']; + $this->assertNotEmpty($oauthUserId); + + // Ensure session looks as expected + $response = $this->client->call(Client::METHOD_GET, '/account/sessions/current', [ + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $oauthUserCookie, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($oauthUserId, $response['body']['userId']); + $this->assertEquals('mock', $response['body']['provider']); + + // Same sign-in again, but this time with oauth2 token flow + $response = $this->client->call(Client::METHOD_GET, '/account/tokens/oauth2/' . $provider, array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]), [ + 'success' => 'http://localhost/v1/mock/tests/general/oauth2/success', + 'failure' => 'http://localhost/v1/mock/tests/general/oauth2/failure', + ], followRedirects: false); + + $this->assertEquals(301, $response['headers']['status-code']); + $this->assertStringStartsWith('http://localhost/v1/mock/tests/general/oauth2', $response['headers']['location']); + + $oauthClient = new Client(); + $oauthClient->setEndpoint(''); + $response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false); + + $this->assertEquals(301, $response['headers']['status-code']); + $this->assertStringStartsWith('http://appwrite:/v1/account/sessions/oauth2/callback/mock/' . $this->getProject()['$id'] . '?code=', $response['headers']['location']); + + $response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false); + + $this->assertEquals(301, $response['headers']['status-code']); + $this->assertStringStartsWith('http://appwrite:/v1/account/sessions/oauth2/mock/redirect?code=', $response['headers']['location']); + + $response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false); + + $this->assertEquals(301, $response['headers']['status-code']); + $this->assertStringStartsWith('http://localhost/v1/mock/tests/general/oauth2/success?secret=', $response['headers']['location']); + + $oauthParamsString = \parse_url($response['headers']['location'], PHP_URL_QUERY); + $oauthParams = []; + \parse_str($oauthParamsString, $oauthParams); + + $this->assertNotEmpty($oauthParams['secret']); + $this->assertNotEmpty($oauthParams['userId']); + + $response = $oauthClient->call(Client::METHOD_GET, $response['headers']['location'], followRedirects: false); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('success', $response['body']['result']); + + // Claim session + $response = $this->client->call(Client::METHOD_POST, '/account/sessions/token', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], [ + 'userId' => $oauthParams['userId'], + 'secret' => $oauthParams['secret'], + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals('mock', $response['body']['provider']); + + $this->assertArrayHasKey('a_session_' . $this->getProject()['$id'] . '_legacy', $response['cookies']); + $this->assertArrayHasKey('a_session_' . $this->getProject()['$id'], $response['cookies']); + + $oauthUserCookie = $response['cookies']['a_session_' . $this->getProject()['$id']]; + $this->assertNotEmpty($oauthUserCookie); + + $response = $this->client->call(Client::METHOD_GET, '/account', [ + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $oauthUserCookie, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals('useroauth@localhost.test', $response['body']['email']); + + $oauthUserId = $response['body']['$id']; + $this->assertNotEmpty($oauthUserId); + + // Ensure session looks as expected + $response = $this->client->call(Client::METHOD_GET, '/account/sessions/current', [ + 'x-appwrite-project' => $this->getProject()['$id'], + 'cookie' => 'a_session_' . $this->getProject()['$id'] . '=' . $oauthUserCookie, + ]); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertEquals($oauthUserId, $response['body']['userId']); + $this->assertEquals('mock', $response['body']['provider']); + /** * Test for Failure when disabled */ From afd8d8a02018201731840d474876b91a617c237b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 16 Mar 2026 16:57:35 +0100 Subject: [PATCH 02/10] Implement a fix to oauth missing provider --- app/config/collections/common.php | 11 +++++++++++ app/controllers/api/account.php | 3 ++- composer.json | 4 ++-- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/app/config/collections/common.php b/app/config/collections/common.php index 1845ef8a42..d0cfc5f4a4 100644 --- a/app/config/collections/common.php +++ b/app/config/collections/common.php @@ -575,6 +575,17 @@ return [ 'default' => null, 'array' => false, 'filters' => [], + ], + [ + '$id' => ID::custom('provider'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 128, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], ] ], 'indexes' => [ diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index a780bfdac3..b50047d1b2 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -245,7 +245,7 @@ $createSession = function (string $userId, string $secret, Request $request, Res TOKEN_TYPE_INVITE => SESSION_PROVIDER_EMAIL, TOKEN_TYPE_MAGIC_URL => SESSION_PROVIDER_MAGIC_URL, TOKEN_TYPE_PHONE => SESSION_PROVIDER_PHONE, - TOKEN_TYPE_OAUTH2 => SESSION_PROVIDER_OAUTH2, + TOKEN_TYPE_OAUTH2 => $verifiedToken->getAttribute('provider', SESSION_PROVIDER_OAUTH2), default => SESSION_PROVIDER_TOKEN, }; $session = new Document(array_merge( @@ -1878,6 +1878,7 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') 'userId' => $user->getId(), 'userInternalId' => $user->getSequence(), 'type' => TOKEN_TYPE_OAUTH2, + 'provider' => $provider, 'secret' => $proofForTokenOAuth2->hash($secret), // One way hash encryption to protect DB leak 'expire' => $expire, 'userAgent' => $request->getUserAgent('UNKNOWN'), diff --git a/composer.json b/composer.json index 06ee153574..19e6a83e51 100644 --- a/composer.json +++ b/composer.json @@ -13,9 +13,9 @@ "test": "vendor/bin/phpunit", "lint": "vendor/bin/pint --test --config pint.json", "format": "vendor/bin/pint --config pint.json", - "analyze": "./vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=1G", + "analyze": "./vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=1G", "bench": "vendor/bin/phpbench run --report=benchmark", - "check": "./vendor/bin/phpstan analyse -c phpstan.neon", + "check": "./vendor/bin/phpstan analyse -c phpstan.neon --memory-limit=1G", "installer:clean": "php src/Appwrite/Platform/Installer/Server.php --clean", "installer:dev": "docker compose build && composer installer:clean && php src/Appwrite/Platform/Installer/Server.php --docker" }, From 91e252382b753915e2d165276365d0e2efb9b3e1 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 20 Mar 2026 17:33:15 +1300 Subject: [PATCH 03/10] (test): add strict type assertions and list coverage for V21 $sequence filter --- .../unit/Utopia/Response/Filters/V21Test.php | 73 ++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/tests/unit/Utopia/Response/Filters/V21Test.php b/tests/unit/Utopia/Response/Filters/V21Test.php index fdff7fdf3f..a049b70d84 100644 --- a/tests/unit/Utopia/Response/Filters/V21Test.php +++ b/tests/unit/Utopia/Response/Filters/V21Test.php @@ -430,7 +430,7 @@ class V21Test extends TestCase { $result = $this->filter->parse($content, Response::MODEL_DOCUMENT); - $this->assertEquals($expected, $result); + $this->assertSame($expected, $result); } #[DataProvider('documentProvider')] @@ -438,7 +438,76 @@ class V21Test extends TestCase { $result = $this->filter->parse($content, Response::MODEL_ROW); - $this->assertEquals($expected, $result); + $this->assertSame($expected, $result); + } + + public static function documentListProvider(): array + { + return [ + 'cast $sequence in document list' => [ + [ + 'total' => 2, + 'documents' => [ + [ + '$id' => 'doc1', + '$sequence' => '10', + 'name' => 'first', + ], + [ + '$id' => 'doc2', + '$sequence' => '20', + 'name' => 'second', + ], + ], + ], + [ + 'total' => 2, + 'documents' => [ + [ + '$id' => 'doc1', + '$sequence' => 10, + 'name' => 'first', + ], + [ + '$id' => 'doc2', + '$sequence' => 20, + 'name' => 'second', + ], + ], + ] + ], + 'handle empty document list' => [ + [ + 'total' => 0, + 'documents' => [], + ], + [ + 'total' => 0, + 'documents' => [], + ] + ], + ]; + } + + #[DataProvider('documentListProvider')] + public function testDocumentList(array $content, array $expected): void + { + $result = $this->filter->parse($content, Response::MODEL_DOCUMENT_LIST); + + $this->assertSame($expected, $result); + } + + #[DataProvider('documentListProvider')] + public function testRowList(array $content, array $expected): void + { + $content['rows'] = $content['documents']; + unset($content['documents']); + $expected['rows'] = $expected['documents']; + unset($expected['documents']); + + $result = $this->filter->parse($content, Response::MODEL_ROW_LIST); + + $this->assertSame($expected, $result); } public static function defaultPassthroughProvider(): array From c9d023991de971cc403e289ec2ebb1fec24082d7 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 20 Mar 2026 18:03:17 +1300 Subject: [PATCH 04/10] (test): add e2e test for $sequence query type validation per adapter --- .../e2e/Services/Databases/DatabasesBase.php | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/e2e/Services/Databases/DatabasesBase.php b/tests/e2e/Services/Databases/DatabasesBase.php index aecad983de..5f8ac7dd94 100644 --- a/tests/e2e/Services/Databases/DatabasesBase.php +++ b/tests/e2e/Services/Databases/DatabasesBase.php @@ -3518,6 +3518,62 @@ trait DatabasesBase $this->assertEquals(200, $response['headers']['status-code']); } + public function testQueryBySequenceType(): void + { + $data = $this->setupDocuments(); + $databaseId = $data['databaseId']; + + $documents = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::equal('$id', $data['documentIds'])->toString(), + ], + ]); + + $this->assertEquals(200, $documents['headers']['status-code']); + $this->assertGreaterThan(0, count($documents['body'][$this->getRecordResource()])); + + $sequence = $documents['body'][$this->getRecordResource()][0]['$sequence']; + $this->assertIsString($sequence); + + // Query with string $sequence value (supported by all adapters) + $response = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::equal('$sequence', [$sequence])->toString(), + ], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertCount(1, $response['body'][$this->getRecordResource()]); + $this->assertIsString($response['body'][$this->getRecordResource()][0]['$sequence']); + $this->assertSame($sequence, $response['body'][$this->getRecordResource()][0]['$sequence']); + + // Query with int $sequence value (supported by SQL adapters, rejected by MongoDB) + $intSequence = (int)$sequence; + $response = $this->client->call(Client::METHOD_GET, $this->getRecordUrl($databaseId, $data['moviesId']), array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'queries' => [ + Query::equal('$sequence', [$intSequence])->toString(), + ], + ]); + + $adapter = getenv('_APP_DB_ADAPTER'); + if ($adapter === 'mongodb') { + $this->assertEquals(400, $response['headers']['status-code']); + } else { + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertCount(1, $response['body'][$this->getRecordResource()]); + $this->assertIsString($response['body'][$this->getRecordResource()][0]['$sequence']); + } + } + public function testListDocumentsAfterPagination(): void { $data = $this->setupDocuments(); From 1ad2cd68ef4903cc86172706850d83f85529ce18 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 20 Mar 2026 19:12:47 +1300 Subject: [PATCH 05/10] fix: rewrite compose/env files during upgrade so new image versions are applied useExistingConfig was preventing the compose template from being rewritten on non-local upgrades, leaving old image version tags in place. Also fix Upgrade reading hardcoded .env instead of getEnvFileName(). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Appwrite/Platform/Tasks/Install.php | 2 +- src/Appwrite/Platform/Tasks/Upgrade.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php index 59a60062eb..d88f50efe6 100644 --- a/src/Appwrite/Platform/Tasks/Install.php +++ b/src/Appwrite/Platform/Tasks/Install.php @@ -508,7 +508,7 @@ class Install extends Action $this->applyLocalPaths($isLocalInstall, false); $isCLI = php_sapi_name() === 'cli'; - if ($isLocalInstall) { + if ($isLocalInstall || $isUpgrade) { $useExistingConfig = false; } else { $useExistingConfig = file_exists($this->path . '/' . $this->getComposeFileName()) diff --git a/src/Appwrite/Platform/Tasks/Upgrade.php b/src/Appwrite/Platform/Tasks/Upgrade.php index 7214ca2e8c..960f10f3e6 100644 --- a/src/Appwrite/Platform/Tasks/Upgrade.php +++ b/src/Appwrite/Platform/Tasks/Upgrade.php @@ -72,7 +72,7 @@ class Upgrade extends Install } if ($database === null) { - $envData = @file_get_contents($this->path . '/.env'); + $envData = @file_get_contents($this->path . '/' . $this->getEnvFileName()); if ($envData !== false) { $envFile = new Env($envData); $database = $envFile->list()['_APP_DB_ADAPTER'] ?? null; From d4c9af2eb27e84976373e2e2b0f181c55a1019d0 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 20 Mar 2026 20:21:50 +1300 Subject: [PATCH 06/10] fix: strip surrounding quotes when parsing .env file values Env::__construct() now strips " and ' wrapping from values so that _APP_DB_ADAPTER="mariadb" is read as mariadb, not "mariadb". Without this, the upgrade flow rejected the existing database adapter because the quoted value didn't match the whitelist. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Appwrite/Docker/Env.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Appwrite/Docker/Env.php b/src/Appwrite/Docker/Env.php index 51ec9e167c..26566ec484 100644 --- a/src/Appwrite/Docker/Env.php +++ b/src/Appwrite/Docker/Env.php @@ -19,7 +19,7 @@ class Env foreach ($data as &$row) { $row = explode('=', $row, 2); $key = (isset($row[0])) ? trim($row[0]) : null; - $value = (isset($row[1])) ? trim($row[1]) : null; + $value = (isset($row[1])) ? trim(trim($row[1]), '"\'') : null; if ($key) { $this->vars[$key] = $value; From ef0954cdda55b2fff96a9cd21054454670397b97 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 20 Mar 2026 21:56:06 +1300 Subject: [PATCH 07/10] fix: propagate isUpgrade flag from Upgrade to Install for CLI path Install::action() hardcoded isUpgrade=false, so the CLI upgrade path never rewrote compose/env files. Added a protected property that Upgrade sets before calling parent::action(). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Appwrite/Platform/Tasks/Install.php | 3 ++- src/Appwrite/Platform/Tasks/Upgrade.php | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php index d88f50efe6..af768444f2 100644 --- a/src/Appwrite/Platform/Tasks/Install.php +++ b/src/Appwrite/Platform/Tasks/Install.php @@ -33,6 +33,7 @@ class Install extends Action private const string APPWRITE_API_URL = 'http://appwrite'; private const string GROWTH_API_URL = 'https://growth.appwrite.io/v1'; + protected bool $isUpgrade = false; protected string $hostPath = ''; protected ?bool $isLocalInstall = null; protected ?array $installerConfig = null; @@ -66,7 +67,7 @@ class Install extends Action bool $noStart, string $database ): void { - $isUpgrade = false; + $isUpgrade = $this->isUpgrade; $defaultHttpPort = '80'; $defaultHttpsPort = '443'; $config = Config::getParam('variables'); diff --git a/src/Appwrite/Platform/Tasks/Upgrade.php b/src/Appwrite/Platform/Tasks/Upgrade.php index 960f10f3e6..1d61180963 100644 --- a/src/Appwrite/Platform/Tasks/Upgrade.php +++ b/src/Appwrite/Platform/Tasks/Upgrade.php @@ -42,6 +42,7 @@ class Upgrade extends Install bool $noStart, string $database ): void { + $this->isUpgrade = true; $isLocalInstall = $this->isLocalInstall(); $this->applyLocalPaths($isLocalInstall, true); From 3a40b6b629d4b0d2b38514df68b53908e08b6267 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Fri, 20 Mar 2026 22:18:56 +1300 Subject: [PATCH 08/10] Apply suggestion from @greptile-apps[bot] Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- src/Appwrite/Docker/Env.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Appwrite/Docker/Env.php b/src/Appwrite/Docker/Env.php index 26566ec484..3bf6fb2d50 100644 --- a/src/Appwrite/Docker/Env.php +++ b/src/Appwrite/Docker/Env.php @@ -19,7 +19,16 @@ class Env foreach ($data as &$row) { $row = explode('=', $row, 2); $key = (isset($row[0])) ? trim($row[0]) : null; - $value = (isset($row[1])) ? trim(trim($row[1]), '"\'') : null; + $value = (isset($row[1])) ? (function (string $v): string { + $v = trim($v); + if ( + (\str_starts_with($v, '"') && \str_ends_with($v, '"')) || + (\str_starts_with($v, "'") && \str_ends_with($v, "'")) + ) { + return \substr($v, 1, -1); + } + return $v; + })(trim($row[1])) : null; if ($key) { $this->vars[$key] = $value; From 24848a872c4e9686b7819e59943ed954a9c3d095 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Mar 2026 16:47:45 +0000 Subject: [PATCH 09/10] chore: pin trivy-action to safe v0.35.0 SHA to fix compromised 0.20.0 tag Co-authored-by: stnguyen90 <1477010+stnguyen90@users.noreply.github.com> Agent-Logs-Url: https://github.com/appwrite/appwrite/sessions/ad20d09a-e80d-4611-9959-2e35c3413736 --- .github/workflows/nightly.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index cd9b3827e7..5cbec8f867 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -16,7 +16,7 @@ jobs: - name: Build the Docker image run: DOCKER_BUILDKIT=1 docker build . --target production -t appwrite_image:latest - name: Run Trivy vulnerability scanner on image - uses: aquasecurity/trivy-action@0.20.0 + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 with: image-ref: 'appwrite_image:latest' format: 'sarif' @@ -35,7 +35,7 @@ jobs: - name: Check out code uses: actions/checkout@v6 - name: Run Trivy vulnerability scanner on filesystem - uses: aquasecurity/trivy-action@0.20.0 + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 with: scan-type: 'fs' format: 'sarif' From 682105c068b52385c6f777f6501deaf0b07f3ff2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Mon, 23 Mar 2026 11:52:40 +0100 Subject: [PATCH 10/10] Rework without schema changes --- app/config/collections/common.php | 11 ----------- app/controllers/api/account.php | 32 ++++++++++++++++++++++++++++--- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/app/config/collections/common.php b/app/config/collections/common.php index 7d71fefd81..80bb717423 100644 --- a/app/config/collections/common.php +++ b/app/config/collections/common.php @@ -593,17 +593,6 @@ return [ 'default' => null, 'array' => false, 'filters' => [], - ], - [ - '$id' => ID::custom('provider'), - 'type' => Database::VAR_STRING, - 'format' => '', - 'size' => 128, - 'signed' => true, - 'required' => false, - 'default' => null, - 'array' => false, - 'filters' => [], ] ], 'indexes' => [ diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 692851407f..3d7db8f457 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -209,6 +209,22 @@ function sendSessionAlert(Locale $locale, Document $user, Document $project, arr $createSession = function (string $userId, string $secret, Request $request, Response $response, User $user, Database $dbForProject, Document $project, array $platform, Locale $locale, Reader $geodb, Event $queueForEvents, Mail $queueForMails, Store $store, ProofsToken $proofForToken, ProofsCode $proofForCode, Authorization $authorization) { + // Attempt to decode secret as a JWT (used by OAuth2 token flow to carry provider info) + $oauthProvider = null; + try { + $jwtDecoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 60, 0); + $payload = $jwtDecoder->decode($secret); + + if (empty($payload['provider'])) { + throw new Exception(Exception::USER_INVALID_TOKEN); + } + + $oauthProvider = $payload['provider']; + $secret = $payload['secret']; + } catch (\Ahc\Jwt\JWTException) { + // Not a JWT — use secret as-is (non-OAuth flows) + } + /** @var Appwrite\Utopia\Database\Documents\User $userFromRequest */ $userFromRequest = $authorization->skip(fn () => $dbForProject->getDocument('users', $userId)); @@ -220,6 +236,12 @@ $createSession = function (string $userId, string $secret, Request $request, Res ?: $userFromRequest->tokenVerify(null, $secret, $proofForCode); if (!$verifiedToken) { + // Could mean invalid/expired JWT, or expired secret + throw new Exception(Exception::USER_INVALID_TOKEN); + } + + // OAuth2 tokens must have a provider from the JWT + if ($verifiedToken->getAttribute('type') === TOKEN_TYPE_OAUTH2 && $oauthProvider === null) { throw new Exception(Exception::USER_INVALID_TOKEN); } @@ -245,7 +267,7 @@ $createSession = function (string $userId, string $secret, Request $request, Res TOKEN_TYPE_INVITE => SESSION_PROVIDER_EMAIL, TOKEN_TYPE_MAGIC_URL => SESSION_PROVIDER_MAGIC_URL, TOKEN_TYPE_PHONE => SESSION_PROVIDER_PHONE, - TOKEN_TYPE_OAUTH2 => $verifiedToken->getAttribute('provider', SESSION_PROVIDER_OAUTH2), + TOKEN_TYPE_OAUTH2 => $oauthProvider, default => SESSION_PROVIDER_TOKEN, }; $session = new Document(array_merge( @@ -1878,7 +1900,6 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') 'userId' => $user->getId(), 'userInternalId' => $user->getSequence(), 'type' => TOKEN_TYPE_OAUTH2, - 'provider' => $provider, 'secret' => $proofForTokenOAuth2->hash($secret), // One way hash encryption to protect DB leak 'expire' => $expire, 'userAgent' => $request->getUserAgent('UNKNOWN'), @@ -1900,7 +1921,12 @@ Http::get('/v1/account/sessions/oauth2/:provider/redirect') ->setParam('tokenId', $token->getId()) ; - $query['secret'] = $secret; + // Wrap secret in a JWT that also carries the provider name + $jwtEncoder = new JWT(System::getEnv('_APP_OPENSSL_KEY_V1'), 'HS256', 60, 0); + $query['secret'] = $jwtEncoder->encode([ + 'secret' => $secret, + 'provider' => $provider, + ]); $query['userId'] = $user->getId(); // If the `token` param is not set, we persist the session in a cookie