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' diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 6d33b45f0b..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 => SESSION_PROVIDER_OAUTH2, + TOKEN_TYPE_OAUTH2 => $oauthProvider, default => SESSION_PROVIDER_TOKEN, }; $session = new Document(array_merge( @@ -1899,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 diff --git a/composer.json b/composer.json index ebfdce5123..d514f9f593 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" }, diff --git a/src/Appwrite/Docker/Env.php b/src/Appwrite/Docker/Env.php index 51ec9e167c..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($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; diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php index 59a60062eb..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'); @@ -508,7 +509,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..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); @@ -72,7 +73,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; 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 */ 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(); 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