diff --git a/.env b/.env index 9abfa756e1..3dc7afe34a 100644 --- a/.env +++ b/.env @@ -146,3 +146,5 @@ _APP_STATS_USAGE_DUAL_WRITING_DBS=database_db_main _APP_TRUSTED_HEADERS=x-forwarded-for _APP_POOL_ADAPTER=stack _APP_WORKER_SCREENSHOTS_ROUTER=http://appwrite +_TESTS_OAUTH2_GITHUB_CLIENT_ID= +_TESTS_OAUTH2_GITHUB_CLIENT_SECRET= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9063c6055..e63ff578e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -457,8 +457,10 @@ jobs: name: ${{ env.IMAGE }} path: /tmp - - name: Set database environment + - name: Set environment run: | + echo "_APP_OPTIONS_ROUTER_PROTECTION=enabled" >> $GITHUB_ENV + if [ "${{ matrix.database }}" = "MariaDB" ]; then echo "COMPOSE_PROFILES=mariadb" >> $GITHUB_ENV echo "_APP_DB_ADAPTER=mariadb" >> $GITHUB_ENV @@ -527,6 +529,8 @@ jobs: docker compose exec -T \ -e _APP_E2E_RESPONSE_FORMAT="${{ github.event.inputs.response_format }}" \ + -e _TESTS_OAUTH2_GITHUB_CLIENT_ID="${{ secrets.TESTS_OAUTH2_GITHUB_CLIENT_ID }}" \ + -e _TESTS_OAUTH2_GITHUB_CLIENT_SECRET="${{ secrets.TESTS_OAUTH2_GITHUB_CLIENT_SECRET }}" \ appwrite vendor/bin/paratest --processes "$PARATEST_PROCESSES" $FUNCTIONAL_FLAG "$SERVICE_PATH" --exclude-group abuseEnabled --exclude-group screenshots --log-junit tests/e2e/Services/${{ matrix.service }}/junit.xml - name: Failure Logs diff --git a/app/config/oAuthProviders.php b/app/config/oAuthProviders.php index cda6459519..3b492fd8bf 100644 --- a/app/config/oAuthProviders.php +++ b/app/config/oAuthProviders.php @@ -167,6 +167,17 @@ return [ 'mock' => false, 'class' => 'Appwrite\\Auth\\OAuth2\\Figma', ], + 'fusionauth' => [ + 'name' => 'FusionAuth', + 'developers' => 'https://fusionauth.io/docs/', + 'icon' => 'icon-fusionauth', + 'enabled' => true, + 'sandbox' => false, + 'form' => 'fusionauth.phtml', + 'beta' => false, + 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\FusionAuth', + ], 'github' => [ 'name' => 'GitHub', 'developers' => 'https://developer.github.com/', @@ -200,6 +211,28 @@ return [ 'mock' => false, 'class' => 'Appwrite\\Auth\\OAuth2\\Google', ], + 'keycloak' => [ + 'name' => 'Keycloak', + 'developers' => 'https://www.keycloak.org/documentation', + 'icon' => 'icon-keycloak', + 'enabled' => true, + 'sandbox' => false, + 'form' => 'keycloak.phtml', + 'beta' => false, + 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Keycloak', + ], + 'kick' => [ + 'name' => 'Kick', + 'developers' => 'https://docs.kick.com/', + 'icon' => 'icon-kick', + 'enabled' => true, + 'sandbox' => false, + 'form' => false, + 'beta' => false, + 'mock' => false, + 'class' => 'Appwrite\\Auth\\OAuth2\\Kick', + ], 'linkedin' => [ 'name' => 'LinkedIn', 'developers' => 'https://developer.linkedin.com/', diff --git a/app/config/roles.php b/app/config/roles.php index cd1e2963c7..cc545b75d9 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -59,6 +59,8 @@ $admins = [ 'tables.write', 'platforms.read', 'platforms.write', + 'oauth2.read', + 'oauth2.write', 'mocks.read', 'mocks.write', 'policies.read', diff --git a/app/config/scopes/project.php b/app/config/scopes/project.php index fb0bd39ce7..2a085d8050 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -234,4 +234,12 @@ return [ // List of publicly visible scopes "description" => "Access to create, update, and delete project\'s templates", ], + "oauth2.read" => [ + "description" => + "Access to read project\'s OAuth2 configuration", + ], + "oauth2.write" => [ + "description" => + "Access to update project\'s OAuth2 configuration", + ], ]; diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index cf920b695f..494aa11150 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -58,23 +58,11 @@ Http::get('/v1/projects/:projectId') $response->dynamic($project, Response::MODEL_PROJECT); }); +// Backwards compatibility Http::patch('/v1/projects/:projectId/oauth2') ->desc('Update project OAuth2') ->groups(['api', 'projects']) ->label('scope', 'projects.write') - ->label('sdk', new Method( - namespace: 'projects', - group: 'auth', - name: 'updateOAuth2', - description: '/docs/references/projects/update-oauth2.md', - auth: [AuthType::ADMIN], - responses: [ - new SDKResponse( - code: Response::STATUS_CODE_OK, - model: Response::MODEL_PROJECT, - ) - ] - )) ->param('projectId', '', fn (Database $dbForPlatform) => new UID($dbForPlatform->getAdapter()->getMaxUIDLength()), 'Project unique ID.', false, ['dbForPlatform']) ->param('provider', '', new WhiteList(\array_keys(Config::getParam('oAuthProviders')), true), 'Provider Name') ->param('appId', null, new Nullable(new Text(256)), 'Provider app ID. Max length: 256 chars.', true) diff --git a/app/controllers/general.php b/app/controllers/general.php index 2cec14cc1d..70bd323fb5 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -120,7 +120,7 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S } } - if (!in_array($host, $platformHostnames)) { + if (!in_array($host, $platformHostnames) && System::getEnv('_APP_OPTIONS_ROUTER_PROTECTION', 'enabled') === 'enabled') { throw new AppwriteException(AppwriteException::GENERAL_ACCESS_FORBIDDEN, 'Router protection does not allow accessing Appwrite over this domain. Please add it as custom domain to your project or disable _APP_OPTIONS_ROUTER_PROTECTION environment variable.', view: $errorView); } diff --git a/app/init/models.php b/app/init/models.php index 143ec213ce..7344bb0531 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -56,6 +56,9 @@ use Appwrite\Utopia\Response\Model\ColumnString; use Appwrite\Utopia\Response\Model\ColumnText; use Appwrite\Utopia\Response\Model\ColumnURL; use Appwrite\Utopia\Response\Model\ColumnVarchar; +use Appwrite\Utopia\Response\Model\ConsoleOAuth2Provider; +use Appwrite\Utopia\Response\Model\ConsoleOAuth2ProviderList; +use Appwrite\Utopia\Response\Model\ConsoleOAuth2ProviderParameter; use Appwrite\Utopia\Response\Model\ConsoleVariables; use Appwrite\Utopia\Response\Model\Continent; use Appwrite\Utopia\Response\Model\Country; @@ -105,6 +108,47 @@ use Appwrite\Utopia\Response\Model\MigrationReport; use Appwrite\Utopia\Response\Model\Mock; use Appwrite\Utopia\Response\Model\MockNumber; use Appwrite\Utopia\Response\Model\None; +use Appwrite\Utopia\Response\Model\OAuth2Amazon; +use Appwrite\Utopia\Response\Model\OAuth2Apple; +use Appwrite\Utopia\Response\Model\OAuth2Auth0; +use Appwrite\Utopia\Response\Model\OAuth2Authentik; +use Appwrite\Utopia\Response\Model\OAuth2Autodesk; +use Appwrite\Utopia\Response\Model\OAuth2Bitbucket; +use Appwrite\Utopia\Response\Model\OAuth2Bitly; +use Appwrite\Utopia\Response\Model\OAuth2Box; +use Appwrite\Utopia\Response\Model\OAuth2Dailymotion; +use Appwrite\Utopia\Response\Model\OAuth2Discord; +use Appwrite\Utopia\Response\Model\OAuth2Disqus; +use Appwrite\Utopia\Response\Model\OAuth2Dropbox; +use Appwrite\Utopia\Response\Model\OAuth2Etsy; +use Appwrite\Utopia\Response\Model\OAuth2Facebook; +use Appwrite\Utopia\Response\Model\OAuth2Figma; +use Appwrite\Utopia\Response\Model\OAuth2FusionAuth; +use Appwrite\Utopia\Response\Model\OAuth2GitHub; +use Appwrite\Utopia\Response\Model\OAuth2Gitlab; +use Appwrite\Utopia\Response\Model\OAuth2Google; +use Appwrite\Utopia\Response\Model\OAuth2Keycloak; +use Appwrite\Utopia\Response\Model\OAuth2Kick; +use Appwrite\Utopia\Response\Model\OAuth2Linkedin; +use Appwrite\Utopia\Response\Model\OAuth2Microsoft; +use Appwrite\Utopia\Response\Model\OAuth2Notion; +use Appwrite\Utopia\Response\Model\OAuth2Oidc; +use Appwrite\Utopia\Response\Model\OAuth2Okta; +use Appwrite\Utopia\Response\Model\OAuth2Paypal; +use Appwrite\Utopia\Response\Model\OAuth2Podio; +use Appwrite\Utopia\Response\Model\OAuth2ProviderList; +use Appwrite\Utopia\Response\Model\OAuth2Salesforce; +use Appwrite\Utopia\Response\Model\OAuth2Slack; +use Appwrite\Utopia\Response\Model\OAuth2Spotify; +use Appwrite\Utopia\Response\Model\OAuth2Stripe; +use Appwrite\Utopia\Response\Model\OAuth2Tradeshift; +use Appwrite\Utopia\Response\Model\OAuth2Twitch; +use Appwrite\Utopia\Response\Model\OAuth2WordPress; +use Appwrite\Utopia\Response\Model\OAuth2X; +use Appwrite\Utopia\Response\Model\OAuth2Yahoo; +use Appwrite\Utopia\Response\Model\OAuth2Yandex; +use Appwrite\Utopia\Response\Model\OAuth2Zoho; +use Appwrite\Utopia\Response\Model\OAuth2Zoom; use Appwrite\Utopia\Response\Model\Phone; use Appwrite\Utopia\Response\Model\PlatformAndroid; use Appwrite\Utopia\Response\Model\PlatformApple; @@ -354,6 +398,47 @@ Response::setModel(new Webhook()); Response::setModel(new Key()); Response::setModel(new DevKey()); Response::setModel(new MockNumber()); +Response::setModel(new OAuth2GitHub()); +Response::setModel(new OAuth2Discord()); +Response::setModel(new OAuth2Figma()); +Response::setModel(new OAuth2Dropbox()); +Response::setModel(new OAuth2Dailymotion()); +Response::setModel(new OAuth2Bitbucket()); +Response::setModel(new OAuth2Bitly()); +Response::setModel(new OAuth2Box()); +Response::setModel(new OAuth2Autodesk()); +Response::setModel(new OAuth2Google()); +Response::setModel(new OAuth2Zoom()); +Response::setModel(new OAuth2Zoho()); +Response::setModel(new OAuth2Yandex()); +Response::setModel(new OAuth2X()); +Response::setModel(new OAuth2WordPress()); +Response::setModel(new OAuth2Twitch()); +Response::setModel(new OAuth2Stripe()); +Response::setModel(new OAuth2Spotify()); +Response::setModel(new OAuth2Slack()); +Response::setModel(new OAuth2Podio()); +Response::setModel(new OAuth2Notion()); +Response::setModel(new OAuth2Salesforce()); +Response::setModel(new OAuth2Yahoo()); +Response::setModel(new OAuth2Linkedin()); +Response::setModel(new OAuth2Disqus()); +Response::setModel(new OAuth2Amazon()); +Response::setModel(new OAuth2Etsy()); +Response::setModel(new OAuth2Facebook()); +Response::setModel(new OAuth2Tradeshift()); +Response::setModel(new OAuth2Paypal()); +Response::setModel(new OAuth2Gitlab()); +Response::setModel(new OAuth2Authentik()); +Response::setModel(new OAuth2Auth0()); +Response::setModel(new OAuth2FusionAuth()); +Response::setModel(new OAuth2Keycloak()); +Response::setModel(new OAuth2Oidc()); +Response::setModel(new OAuth2Okta()); +Response::setModel(new OAuth2Kick()); +Response::setModel(new OAuth2Apple()); +Response::setModel(new OAuth2Microsoft()); +Response::setModel(new OAuth2ProviderList()); Response::setModel(new PolicyPasswordDictionary()); Response::setModel(new PolicyPasswordHistory()); Response::setModel(new PolicyPasswordPersonalData()); @@ -403,6 +488,9 @@ Response::setModel(new Rule()); Response::setModel(new Schedule()); Response::setModel(new TemplateEmail()); Response::setModel(new ConsoleVariables()); +Response::setModel(new ConsoleOAuth2ProviderParameter()); +Response::setModel(new ConsoleOAuth2Provider()); +Response::setModel(new ConsoleOAuth2ProviderList()); Response::setModel(new MFAChallenge()); Response::setModel(new MFARecoveryCodes()); Response::setModel(new MFAType()); diff --git a/app/init/realtime/connection.php b/app/init/realtime/connection.php index c0219fa816..a090635bb5 100644 --- a/app/init/realtime/connection.php +++ b/app/init/realtime/connection.php @@ -327,9 +327,11 @@ return function (Container $container): void { } } - $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', ''); - $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); - $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); + // Query params mirror the header fallback pattern used by ?project= and ?devKey=, + // allowing Console to embed impersonation in direct file/image URLs where headers cannot be set. + $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', (string)$request->getParam('impersonateUserId', '')); + $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', (string)$request->getParam('impersonateEmail', '')); + $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', (string)$request->getParam('impersonatePhone', '')); if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) { $userDb = ($mode === APP_MODE_ADMIN || $project->getId() === 'console') ? $dbForPlatform : $dbForProject; diff --git a/app/init/resources/request.php b/app/init/resources/request.php index 7d1731b80d..1aa53b7403 100644 --- a/app/init/resources/request.php +++ b/app/init/resources/request.php @@ -571,10 +571,12 @@ return function (Container $container): void { } } - // Impersonation: if current user has impersonator capability and headers are set, act as another user - $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', ''); - $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', ''); - $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', ''); + // Impersonation: if current user has impersonator capability and headers/params are set, act as another user + // Query params mirror the header fallback pattern used by ?project= and ?devKey=, + // allowing Console to embed impersonation in direct file/image URLs where headers cannot be set. + $impersonateUserId = $request->getHeader('x-appwrite-impersonate-user-id', (string)$request->getParam('impersonateUserId', '')); + $impersonateEmail = $request->getHeader('x-appwrite-impersonate-user-email', (string)$request->getParam('impersonateEmail', '')); + $impersonatePhone = $request->getHeader('x-appwrite-impersonate-user-phone', (string)$request->getParam('impersonatePhone', '')); if (!$user->isEmpty() && $user->getAttribute('impersonator', false)) { $userDb = (APP_MODE_ADMIN === $mode || $project->getId() === 'console') ? $dbForPlatform : $dbForProject; $targetUser = null; diff --git a/app/realtime.php b/app/realtime.php index 5be64a131f..e7406ac9b7 100644 --- a/app/realtime.php +++ b/app/realtime.php @@ -715,6 +715,7 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, $roles = $user->getRoles($database->getAuthorization()); $authorization = $realtime->connections[$connection]['authorization'] ?? null; + $previousUserId = $realtime->connections[$connection]['userId'] ?? ''; $meta = $realtime->getSubscriptionMetadata($connection); @@ -722,13 +723,19 @@ $server->onWorkerStart(function (int $workerId) use ($server, $register, $stats, foreach ($meta as $subscriptionId => $subscription) { $queries = Query::parseQueries($subscription['queries'] ?? []); + $channels = Realtime::rebindAccountChannels( + $subscription['channels'] ?? [], + $previousUserId, + $userId + ); $realtime->subscribe( $projectId, $connection, $subscriptionId, $roles, - $subscription['channels'] ?? [], - $queries + $channels, + $queries, + $userId ); } @@ -1228,6 +1235,11 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re $authorization = $realtime->connections[$connection]['authorization'] ?? null; $projectId = $realtime->connections[$connection]['projectId'] ?? null; + // Capture the pre-auth userId so we can rebind any account channels + // that were stored under it (e.g. guest who subscribed to `account` + // and now authenticates). unsubscribe() below clears the connection + // entry, so we must read it first. + $previousUserId = $realtime->connections[$connection]['userId'] ?? ''; $subscriptionsBefore = \count($realtime->getSubscriptionMetadata($connection)); $meta = $realtime->getSubscriptionMetadata($connection); @@ -1237,13 +1249,18 @@ $server->onMessage(function (int $connection, string $message) use ($server, $re if (!empty($projectId)) { foreach ($meta as $subscriptionId => $subscription) { $queries = Query::parseQueries($subscription['queries'] ?? []); + $channels = Realtime::rebindAccountChannels( + $subscription['channels'] ?? [], + $previousUserId, + $user->getId() + ); $realtime->subscribe( $projectId, $connection, $subscriptionId, $roles, - $subscription['channels'] ?? [], + $channels, $queries, $user->getId() ); diff --git a/composer.json b/composer.json index 6312243e32..b5ca436c3f 100644 --- a/composer.json +++ b/composer.json @@ -69,6 +69,7 @@ "utopia-php/dsn": "0.2.1", "utopia-php/http": "0.34.*", "utopia-php/fetch": "0.5.*", + "utopia-php/validators": "0.2.*", "utopia-php/image": "0.8.*", "utopia-php/locale": "0.8.*", "utopia-php/logger": "0.6.*", diff --git a/composer.lock b/composer.lock index 02590020e0..2cf57b95a3 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": "c5ae97637fd0ec0a950044d1c33677ea", + "content-hash": "805802552f7482eaeae4bdaa505ae982", "packages": [ { "name": "adhocore/jwt", @@ -1996,16 +1996,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.51", + "version": "3.0.52", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "d59c94077f9c9915abb51ddb52ce85188ece1748" + "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/d59c94077f9c9915abb51ddb52ce85188ece1748", - "reference": "d59c94077f9c9915abb51ddb52ce85188ece1748", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/2adaefc83df2ec548558307690f376dd7d4f4fce", + "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce", "shasum": "" }, "require": { @@ -2086,7 +2086,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.51" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.52" }, "funding": [ { @@ -2102,7 +2102,7 @@ "type": "tidelift" } ], - "time": "2026-04-10T01:33:53+00:00" + "time": "2026-04-27T07:02:15+00:00" }, { "name": "psr/clock", @@ -2887,7 +2887,7 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", @@ -2948,7 +2948,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" }, "funding": [ { @@ -2972,7 +2972,7 @@ }, { "name": "symfony/polyfill-php82", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php82.git", @@ -3028,7 +3028,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php82/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-php82/tree/v1.37.0" }, "funding": [ { @@ -3052,7 +3052,7 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", @@ -3108,7 +3108,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.37.0" }, "funding": [ { @@ -3132,16 +3132,16 @@ }, { "name": "symfony/polyfill-php85", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "2c408a6bb0313e6001a83628dc5506100474254e" + "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/2c408a6bb0313e6001a83628dc5506100474254e", - "reference": "2c408a6bb0313e6001a83628dc5506100474254e", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/fcfa4973a9917cef23f2e38774da74a2b7d115ee", + "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee", "shasum": "" }, "require": { @@ -3188,7 +3188,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.37.0" }, "funding": [ { @@ -3208,7 +3208,7 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:50:15+00:00" + "time": "2026-04-26T13:10:57+00:00" }, { "name": "symfony/service-contracts", @@ -3658,16 +3658,16 @@ }, { "name": "utopia-php/cli", - "version": "0.23.1", + "version": "0.23.2", "source": { "type": "git", "url": "https://github.com/utopia-php/cli.git", - "reference": "8d1955b8bc4dc631f45d7c7df689ed7b63f70621" + "reference": "145b91fef827853bcceaa3ab8ca2b1d6faaca2ab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/cli/zipball/8d1955b8bc4dc631f45d7c7df689ed7b63f70621", - "reference": "8d1955b8bc4dc631f45d7c7df689ed7b63f70621", + "url": "https://api.github.com/repos/utopia-php/cli/zipball/145b91fef827853bcceaa3ab8ca2b1d6faaca2ab", + "reference": "145b91fef827853bcceaa3ab8ca2b1d6faaca2ab", "shasum": "" }, "require": { @@ -3703,9 +3703,9 @@ ], "support": { "issues": "https://github.com/utopia-php/cli/issues", - "source": "https://github.com/utopia-php/cli/tree/0.23.1" + "source": "https://github.com/utopia-php/cli/tree/0.23.2" }, - "time": "2026-04-05T15:27:35+00:00" + "time": "2026-04-27T09:19:04+00:00" }, { "name": "utopia-php/compression", @@ -4271,21 +4271,20 @@ }, { "name": "utopia-php/http", - "version": "0.34.21", + "version": "0.34.24", "source": { "type": "git", "url": "https://github.com/utopia-php/http.git", - "reference": "49a6bd3ea0d2966aa19cf707255d442675288a24" + "reference": "d1eced0627c5a9fceddf53992ed97d664b810d33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/http/zipball/49a6bd3ea0d2966aa19cf707255d442675288a24", - "reference": "49a6bd3ea0d2966aa19cf707255d442675288a24", + "url": "https://api.github.com/repos/utopia-php/http/zipball/d1eced0627c5a9fceddf53992ed97d664b810d33", + "reference": "d1eced0627c5a9fceddf53992ed97d664b810d33", "shasum": "" }, "require": { - "ext-swoole": "*", - "php": ">=8.2", + "php": ">=8.3", "utopia-php/compression": "0.1.*", "utopia-php/di": "0.3.*", "utopia-php/servers": "0.3.*", @@ -4295,11 +4294,14 @@ "require-dev": { "doctrine/instantiator": "^1.5", "laravel/pint": "1.*", - "phpbench/phpbench": "^1.2", - "phpstan/phpstan": "1.*", - "phpunit/phpunit": "^9.5.25", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^12.0", + "rector/rector": "^2.4", "swoole/ide-helper": "4.8.3" }, + "suggest": { + "ext-swoole": "Required to use the Swoole server adapter (\\Utopia\\Http\\Adapter\\Swoole\\Server)." + }, "type": "library", "autoload": { "psr-4": { @@ -4319,9 +4321,9 @@ ], "support": { "issues": "https://github.com/utopia-php/http/issues", - "source": "https://github.com/utopia-php/http/tree/0.34.21" + "source": "https://github.com/utopia-php/http/tree/0.34.24" }, - "time": "2026-04-19T19:44:04+00:00" + "time": "2026-04-24T12:16:53+00:00" }, { "name": "utopia-php/image", @@ -4528,16 +4530,16 @@ }, { "name": "utopia-php/migration", - "version": "1.9.1", + "version": "1.9.3", "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "7a86aeadf182b63a9f4ceba7e137588b31c5d2e2" + "reference": "111f6221d04578a6f721c23ac872002375f176ae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/7a86aeadf182b63a9f4ceba7e137588b31c5d2e2", - "reference": "7a86aeadf182b63a9f4ceba7e137588b31c5d2e2", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/111f6221d04578a6f721c23ac872002375f176ae", + "reference": "111f6221d04578a6f721c23ac872002375f176ae", "shasum": "" }, "require": { @@ -4577,22 +4579,22 @@ ], "support": { "issues": "https://github.com/utopia-php/migration/issues", - "source": "https://github.com/utopia-php/migration/tree/1.9.1" + "source": "https://github.com/utopia-php/migration/tree/1.9.3" }, - "time": "2026-03-25T07:05:27+00:00" + "time": "2026-04-22T07:13:26+00:00" }, { "name": "utopia-php/mongo", - "version": "1.0.2", + "version": "1.1.0", "source": { "type": "git", "url": "https://github.com/utopia-php/mongo.git", - "reference": "677a21c53f7a1316c528b4b45b3fce886cee7223" + "reference": "73593682deee4696525a04e26524c1c1226e1530" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/mongo/zipball/677a21c53f7a1316c528b4b45b3fce886cee7223", - "reference": "677a21c53f7a1316c528b4b45b3fce886cee7223", + "url": "https://api.github.com/repos/utopia-php/mongo/zipball/73593682deee4696525a04e26524c1c1226e1530", + "reference": "73593682deee4696525a04e26524c1c1226e1530", "shasum": "" }, "require": { @@ -4638,9 +4640,9 @@ ], "support": { "issues": "https://github.com/utopia-php/mongo/issues", - "source": "https://github.com/utopia-php/mongo/tree/1.0.2" + "source": "https://github.com/utopia-php/mongo/tree/1.1.0" }, - "time": "2026-03-18T02:45:50+00:00" + "time": "2026-04-24T06:15:10+00:00" }, { "name": "utopia-php/platform", @@ -5182,16 +5184,16 @@ }, { "name": "utopia-php/validators", - "version": "0.2.0", + "version": "0.2.2", "source": { "type": "git", "url": "https://github.com/utopia-php/validators.git", - "reference": "30b6030a5b100fc1dff34506e5053759594b2a20" + "reference": "5d7d494e64457cd4eb67fdcfd9481f2c89796aa6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/validators/zipball/30b6030a5b100fc1dff34506e5053759594b2a20", - "reference": "30b6030a5b100fc1dff34506e5053759594b2a20", + "url": "https://api.github.com/repos/utopia-php/validators/zipball/5d7d494e64457cd4eb67fdcfd9481f2c89796aa6", + "reference": "5d7d494e64457cd4eb67fdcfd9481f2c89796aa6", "shasum": "" }, "require": { @@ -5221,9 +5223,9 @@ ], "support": { "issues": "https://github.com/utopia-php/validators/issues", - "source": "https://github.com/utopia-php/validators/tree/0.2.0" + "source": "https://github.com/utopia-php/validators/tree/0.2.2" }, - "time": "2026-01-13T09:16:51+00:00" + "time": "2026-04-27T16:30:24+00:00" }, { "name": "utopia-php/vcs", @@ -5464,16 +5466,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.20", + "version": "1.24.0", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "525f0630520c95100fcdfb63c9dac859c1d02588" + "reference": "6d4d26659bc7a1c347c1d4d8dae3b77b5562e0cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/525f0630520c95100fcdfb63c9dac859c1d02588", - "reference": "525f0630520c95100fcdfb63c9dac859c1d02588", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/6d4d26659bc7a1c347c1d4d8dae3b77b5562e0cb", + "reference": "6d4d26659bc7a1c347c1d4d8dae3b77b5562e0cb", "shasum": "" }, "require": { @@ -5509,9 +5511,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.20" + "source": "https://github.com/appwrite/sdk-generator/tree/1.24.0" }, - "time": "2026-04-20T05:45:00+00:00" + "time": "2026-04-24T12:50:05+00:00" }, { "name": "brianium/paratest", @@ -5793,16 +5795,16 @@ }, { "name": "laravel/pint", - "version": "v1.29.0", + "version": "v1.29.1", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "bdec963f53172c5e36330f3a400604c69bf02d39" + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/bdec963f53172c5e36330f3a400604c69bf02d39", - "reference": "bdec963f53172c5e36330f3a400604c69bf02d39", + "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80", + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80", "shasum": "" }, "require": { @@ -5813,14 +5815,14 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.94.2", - "illuminate/view": "^12.54.1", - "larastan/larastan": "^3.9.3", - "laravel-zero/framework": "^12.0.5", + "friendsofphp/php-cs-fixer": "^3.95.1", + "illuminate/view": "^12.56.0", + "larastan/larastan": "^3.9.6", + "laravel-zero/framework": "^12.1.0", "mockery/mockery": "^1.6.12", "nunomaduro/termwind": "^2.4.0", "pestphp/pest": "^3.8.6", - "shipfastlabs/agent-detector": "^1.1.0" + "shipfastlabs/agent-detector": "^1.1.3" }, "bin": [ "builds/pint" @@ -5857,7 +5859,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-03-12T15:51:39+00:00" + "time": "2026-04-20T15:26:14+00:00" }, { "name": "matthiasmullie/minify", @@ -6220,11 +6222,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.50", + "version": "2.1.51", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/d452086fb4cf648c6b2d8cf3b639351f79e4f3e2", - "reference": "d452086fb4cf648c6b2d8cf3b639351f79e4f3e2", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dc3b523c45e714c70de2ac5113b958223b55dc59", + "reference": "dc3b523c45e714c70de2ac5113b958223b55dc59", "shasum": "" }, "require": { @@ -6269,7 +6271,7 @@ "type": "github" } ], - "time": "2026-04-17T13:10:32+00:00" + "time": "2026-04-21T18:22:01+00:00" }, { "name": "phpunit/php-code-coverage", @@ -7779,7 +7781,7 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", @@ -7838,7 +7840,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { @@ -7862,16 +7864,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df" + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/ad1b7b9092976d6c948b8a187cec9faaea9ec1df", - "reference": "ad1b7b9092976d6c948b8a187cec9faaea9ec1df", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e", + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e", "shasum": "" }, "require": { @@ -7920,7 +7922,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0" }, "funding": [ { @@ -7940,11 +7942,11 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-04-26T13:13:48+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -8005,7 +8007,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0" }, "funding": [ { @@ -8029,7 +8031,7 @@ }, { "name": "symfony/polyfill-php81", - "version": "v1.36.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", @@ -8085,7 +8087,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.36.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.37.0" }, "funding": [ { diff --git a/docker-compose.yml b/docker-compose.yml index 7d53d2965d..da5efac438 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -247,6 +247,8 @@ services: - _APP_CUSTOM_DOMAIN_DENY_LIST - _APP_TRUSTED_HEADERS - _APP_MIGRATION_HOST + - _TESTS_OAUTH2_GITHUB_CLIENT_ID + - _TESTS_OAUTH2_GITHUB_CLIENT_SECRET extra_hosts: - "host.docker.internal:host-gateway" diff --git a/docs/references/console/list-oauth2-providers.md b/docs/references/console/list-oauth2-providers.md new file mode 100644 index 0000000000..d813296031 --- /dev/null +++ b/docs/references/console/list-oauth2-providers.md @@ -0,0 +1 @@ +List all OAuth2 providers supported by the Appwrite server, along with the parameters required to configure each provider. The response excludes mock providers but includes sandbox providers. diff --git a/src/Appwrite/Auth/OAuth2/FusionAuth.php b/src/Appwrite/Auth/OAuth2/FusionAuth.php new file mode 100644 index 0000000000..415be4c6ad --- /dev/null +++ b/src/Appwrite/Auth/OAuth2/FusionAuth.php @@ -0,0 +1,226 @@ +getFusionAuthDomain() . '/oauth2/authorize?' . \http_build_query([ + 'client_id' => $this->appID, + 'redirect_uri' => $this->callback, + 'state' => \json_encode($this->state), + 'scope' => \implode(' ', $this->getScopes()), + 'response_type' => 'code' + ]); + } + + /** + * @param string $code + * + * @return array + */ + protected function getTokens(string $code): array + { + if (empty($this->tokens)) { + $headers = ['Content-Type: application/x-www-form-urlencoded']; + $this->tokens = \json_decode($this->request( + 'POST', + 'https://' . $this->getFusionAuthDomain() . '/oauth2/token', + $headers, + \http_build_query([ + 'code' => $code, + 'client_id' => $this->appID, + 'client_secret' => $this->getClientSecret(), + 'redirect_uri' => $this->callback, + 'scope' => \implode(' ', $this->getScopes()), + 'grant_type' => 'authorization_code' + ]) + ), true); + } + return $this->tokens; + } + + /** + * @param string $refreshToken + * + * @return array + */ + public function refreshTokens(string $refreshToken): array + { + $headers = ['Content-Type: application/x-www-form-urlencoded']; + $this->tokens = \json_decode($this->request( + 'POST', + 'https://' . $this->getFusionAuthDomain() . '/oauth2/token', + $headers, + \http_build_query([ + 'refresh_token' => $refreshToken, + 'client_id' => $this->appID, + 'client_secret' => $this->getClientSecret(), + 'grant_type' => 'refresh_token' + ]) + ), true); + + if (empty($this->tokens['refresh_token'])) { + $this->tokens['refresh_token'] = $refreshToken; + } + + return $this->tokens; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserID(string $accessToken): string + { + $user = $this->getUser($accessToken); + + if (isset($user['sub'])) { + return $user['sub']; + } + + return ''; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserEmail(string $accessToken): string + { + $user = $this->getUser($accessToken); + + if (isset($user['email'])) { + return $user['email']; + } + + return ''; + } + + /** + * Check if the User email is verified + * + * @param string $accessToken + * + * @return bool + */ + public function isEmailVerified(string $accessToken): bool + { + $user = $this->getUser($accessToken); + + if ($user['email_verified'] ?? false) { + return true; + } + + return false; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserName(string $accessToken): string + { + $user = $this->getUser($accessToken); + + if (isset($user['name'])) { + return $user['name']; + } + + return ''; + } + + /** + * @param string $accessToken + * + * @return array + */ + protected function getUser(string $accessToken): array + { + if (empty($this->user)) { + $headers = ['Authorization: Bearer ' . \urlencode($accessToken)]; + $user = $this->request('GET', 'https://' . $this->getFusionAuthDomain() . '/oauth2/userinfo', $headers); + $this->user = \json_decode($user, true); + } + + return $this->user; + } + + /** + * Extracts the Client Secret from the JSON stored in appSecret + * + * @return string + */ + protected function getClientSecret(): string + { + $secret = $this->getAppSecret(); + + return $secret['clientSecret'] ?? ''; + } + + /** + * Extracts the FusionAuth Domain from the JSON stored in appSecret + * + * @return string + */ + protected function getFusionAuthDomain(): string + { + $secret = $this->getAppSecret(); + return $secret['fusionAuthDomain'] ?? ''; + } + + /** + * Decode the JSON stored in appSecret + * + * @return array + */ + protected function getAppSecret(): array + { + try { + $secret = \json_decode($this->appSecret, true, 512, JSON_THROW_ON_ERROR); + } catch (\Throwable $th) { + throw new \Exception('Invalid secret'); + } + return $secret; + } +} diff --git a/src/Appwrite/Auth/OAuth2/Github.php b/src/Appwrite/Auth/OAuth2/Github.php index 1cefc397c5..d5d3b07918 100644 --- a/src/Appwrite/Auth/OAuth2/Github.php +++ b/src/Appwrite/Auth/OAuth2/Github.php @@ -3,6 +3,7 @@ namespace Appwrite\Auth\OAuth2; use Appwrite\Auth\OAuth2; +use Utopia\Fetch\Client as FetchClient; class Github extends OAuth2 { @@ -219,4 +220,34 @@ class Github extends OAuth2 $repository = \json_decode($repository, true); return $repository; } + + public function verifyCredentials(): void + { + $client = new FetchClient(); + $client->addHeader('Accept', 'application/json'); + + $response = $client->fetch( + url: 'https://github.com/login/oauth/access_token', + method: FetchClient::METHOD_POST, + query: [ + 'client_id' => $this->appID, + 'client_secret' => $this->appSecret, + 'code' => 'intentionally-invalid-code', + 'redirect_uri' => 'intentionally-invalid-redirect', + ] + ); + + $json = \json_decode($response->getBody(), true); + + if (isset($json['error']) && $json['error'] === "Not Found") { + throw new \Exception('GitHub application with provided Client ID is does not exist.'); + } + + if (isset($json['error']) && $json['error'] === "incorrect_client_credentials") { + throw new \Exception('GitHub application with provided Client ID is valid, but the provided Client Secret is incorrect.'); + } + + // We still expect error, like redirect_uri_mismatch or bad_verification_code, + // but that indicates valid credentials + } } diff --git a/src/Appwrite/Auth/OAuth2/Keycloak.php b/src/Appwrite/Auth/OAuth2/Keycloak.php new file mode 100644 index 0000000000..05e007eb7d --- /dev/null +++ b/src/Appwrite/Auth/OAuth2/Keycloak.php @@ -0,0 +1,249 @@ +getRealmBaseURL() . '/protocol/openid-connect/auth?' . \http_build_query([ + 'client_id' => $this->appID, + 'redirect_uri' => $this->callback, + 'state' => \json_encode($this->state), + 'scope' => \implode(' ', $this->getScopes()), + 'response_type' => 'code' + ]); + } + + /** + * @param string $code + * + * @return array + */ + protected function getTokens(string $code): array + { + if (empty($this->tokens)) { + $headers = ['Content-Type: application/x-www-form-urlencoded']; + $this->tokens = \json_decode($this->request( + 'POST', + $this->getRealmBaseURL() . '/protocol/openid-connect/token', + $headers, + \http_build_query([ + 'code' => $code, + 'client_id' => $this->appID, + 'client_secret' => $this->getClientSecret(), + 'redirect_uri' => $this->callback, + 'scope' => \implode(' ', $this->getScopes()), + 'grant_type' => 'authorization_code' + ]) + ), true); + } + return $this->tokens; + } + + /** + * @param string $refreshToken + * + * @return array + */ + public function refreshTokens(string $refreshToken): array + { + $headers = ['Content-Type: application/x-www-form-urlencoded']; + $this->tokens = \json_decode($this->request( + 'POST', + $this->getRealmBaseURL() . '/protocol/openid-connect/token', + $headers, + \http_build_query([ + 'refresh_token' => $refreshToken, + 'client_id' => $this->appID, + 'client_secret' => $this->getClientSecret(), + 'grant_type' => 'refresh_token' + ]) + ), true); + + if (empty($this->tokens['refresh_token'])) { + $this->tokens['refresh_token'] = $refreshToken; + } + + return $this->tokens; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserID(string $accessToken): string + { + $user = $this->getUser($accessToken); + + if (isset($user['sub'])) { + return $user['sub']; + } + + return ''; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserEmail(string $accessToken): string + { + $user = $this->getUser($accessToken); + + if (isset($user['email'])) { + return $user['email']; + } + + return ''; + } + + /** + * Check if the User email is verified + * + * @param string $accessToken + * + * @return bool + */ + public function isEmailVerified(string $accessToken): bool + { + $user = $this->getUser($accessToken); + + if ($user['email_verified'] ?? false) { + return true; + } + + return false; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserName(string $accessToken): string + { + $user = $this->getUser($accessToken); + + if (isset($user['name'])) { + return $user['name']; + } + + return ''; + } + + /** + * @param string $accessToken + * + * @return array + */ + protected function getUser(string $accessToken): array + { + if (empty($this->user)) { + $headers = ['Authorization: Bearer ' . \urlencode($accessToken)]; + $user = $this->request('GET', $this->getRealmBaseURL() . '/protocol/openid-connect/userinfo', $headers); + $this->user = \json_decode($user, true); + } + + return $this->user; + } + + /** + * Extracts the Client Secret from the JSON stored in appSecret + * + * @return string + */ + protected function getClientSecret(): string + { + $secret = $this->getAppSecret(); + + return $secret['clientSecret'] ?? ''; + } + + /** + * Extracts the Keycloak Domain from the JSON stored in appSecret + * + * @return string + */ + protected function getKeycloakDomain(): string + { + $secret = $this->getAppSecret(); + return $secret['keycloakDomain'] ?? ''; + } + + /** + * Extracts the Keycloak Realm from the JSON stored in appSecret + * + * @return string + */ + protected function getKeycloakRealm(): string + { + $secret = $this->getAppSecret(); + return $secret['keycloakRealm'] ?? ''; + } + + /** + * Build the realm-scoped base URL: `https://{domain}/realms/{realm}`. + * Keycloak realm names allow spaces and other characters that must be + * percent-encoded in URLs (e.g. `my realm` → `my%20realm`). + * + * @return string + */ + protected function getRealmBaseURL(): string + { + return 'https://' . $this->getKeycloakDomain() . '/realms/' . \rawurlencode($this->getKeycloakRealm()); + } + + /** + * Decode the JSON stored in appSecret + * + * @return array + */ + protected function getAppSecret(): array + { + try { + $secret = \json_decode($this->appSecret, true, 512, JSON_THROW_ON_ERROR); + } catch (\Throwable $th) { + throw new \Exception('Invalid secret'); + } + return $secret; + } +} diff --git a/src/Appwrite/Auth/OAuth2/Kick.php b/src/Appwrite/Auth/OAuth2/Kick.php new file mode 100644 index 0000000000..85b447fcd8 --- /dev/null +++ b/src/Appwrite/Auth/OAuth2/Kick.php @@ -0,0 +1,230 @@ +state; + $state[self::PKCE_STATE_KEY] = $this->getPKCEVerifier(); + + return 'https://id.kick.com/oauth/authorize?' . \http_build_query([ + 'response_type' => 'code', + 'client_id' => $this->appID, + 'redirect_uri' => $this->callback, + 'scope' => \implode(' ', $this->getScopes()), + 'state' => \json_encode($state), + 'code_challenge' => $this->getPKCEChallenge(), + 'code_challenge_method' => 'S256', + ]); + } + + /** + * @param string $code + * + * @return array + */ + protected function getTokens(string $code): array + { + if (empty($this->tokens)) { + $headers = ['Content-Type: application/x-www-form-urlencoded']; + $this->tokens = \json_decode($this->request( + 'POST', + 'https://id.kick.com/oauth/token', + $headers, + \http_build_query([ + 'grant_type' => 'authorization_code', + 'client_id' => $this->appID, + 'client_secret' => $this->appSecret, + 'redirect_uri' => $this->callback, + 'code_verifier' => $this->getPKCEVerifier(), + 'code' => $code, + ]) + ), true); + } + + return $this->tokens; + } + + /** + * @param string $refreshToken + * + * @return array + */ + public function refreshTokens(string $refreshToken): array + { + $headers = ['Content-Type: application/x-www-form-urlencoded']; + $this->tokens = \json_decode($this->request( + 'POST', + 'https://id.kick.com/oauth/token', + $headers, + \http_build_query([ + 'grant_type' => 'refresh_token', + 'client_id' => $this->appID, + 'client_secret' => $this->appSecret, + 'refresh_token' => $refreshToken, + ]) + ), true); + + if (empty($this->tokens['refresh_token'])) { + $this->tokens['refresh_token'] = $refreshToken; + } + + return $this->tokens; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserID(string $accessToken): string + { + $user = $this->getUser($accessToken); + + return isset($user['user_id']) ? (string)$user['user_id'] : ''; + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserEmail(string $accessToken): string + { + $user = $this->getUser($accessToken); + + return $user['email'] ?? ''; + } + + /** + * Check if the OAuth email is verified. + * + * Kick only returns an email when the user has granted the `user:read` + * scope and the account email is verified, so a non-empty email is + * treated as verified. + * + * @param string $accessToken + * + * @return bool + */ + public function isEmailVerified(string $accessToken): bool + { + return !empty($this->getUserEmail($accessToken)); + } + + /** + * @param string $accessToken + * + * @return string + */ + public function getUserName(string $accessToken): string + { + $user = $this->getUser($accessToken); + + return $user['name'] ?? ''; + } + + /** + * @param string $accessToken + * + * @return array + */ + protected function getUser(string $accessToken): array + { + if (empty($this->user)) { + $headers = ['Authorization: Bearer ' . $accessToken]; + $response = \json_decode($this->request( + 'GET', + 'https://api.kick.com/public/v1/users', + $headers + ), true); + + $this->user = $response['data'][0] ?? []; + } + + return $this->user; + } + + /** + * Extract the PKCE verifier from the state on the callback so the same + * value generated in getLoginURL() can be sent to the token endpoint. + * + * @param string $state + * + * @return array|null + */ + public function parseState(string $state): ?array + { + $parsed = \json_decode($state, true); + + if (!\is_array($parsed)) { + return null; + } + + $verifier = $parsed[self::PKCE_STATE_KEY] ?? null; + if (\is_string($verifier)) { + $this->pkceVerifier = $verifier; + } + + unset($parsed[self::PKCE_STATE_KEY]); + + return $parsed; + } + + private function getPKCEVerifier(): string + { + if ($this->pkceVerifier === '') { + $this->pkceVerifier = \rtrim(\strtr(\base64_encode(\random_bytes(64)), '+/', '-_'), '='); + } + + return $this->pkceVerifier; + } + + private function getPKCEChallenge(): string + { + return \rtrim(\strtr(\base64_encode(\hash('sha256', $this->getPKCEVerifier(), true)), '+/', '-_'), '='); + } +} diff --git a/src/Appwrite/Messaging/Adapter/Realtime.php b/src/Appwrite/Messaging/Adapter/Realtime.php index 60bd9f9d10..12caab3067 100644 --- a/src/Appwrite/Messaging/Adapter/Realtime.php +++ b/src/Appwrite/Messaging/Adapter/Realtime.php @@ -14,6 +14,28 @@ use Utopia\Database\Query; class Realtime extends MessagingAdapter { + public const SUPPORTED_ACTIONS = ['create', 'update', 'upsert', 'delete']; + + // Resources whose channels receive an action-suffixed sibling at publish time. + // The suffix loop in fromPayload() treats any channel whose last OR second-to-last + // segment matches an entry here as a candidate for `.{action}` suffixing. + // + // `functions` is intentionally a parent-only entry: fromPayload publishes + // `functions.{functionId}` (suffixed to `functions.{functionId}.{action}`) but + // never emits a bare `functions` channel — so subscribing to bare + // `functions.{action}` is a silent no-op. Per-function filters + // (`functions.{functionId}.{action}`) are the supported form. + private const RESOURCE_LEAF_NAMES = [ + 'documents', + 'rows', + 'files', + 'executions', + 'functions', + 'account', + 'teams', + 'memberships', + ]; + /** * Connection Tree * @@ -46,8 +68,6 @@ class Realtime extends MessagingAdapter /** * Get the PubSubPool instance, initializing it lazily if needed. * This allows unit tests to work without requiring the global $register. - * - * @return PubSubPool */ private function getPubSubPool(): PubSubPool { @@ -55,6 +75,7 @@ class Realtime extends MessagingAdapter global $register; $this->pubSubPool = new PubSubPool($register->get('pools')->get('pubsub')); } + return $this->pubSubPool; } @@ -149,7 +170,7 @@ class Realtime extends MessagingAdapter $roles = $this->connections[$connection]['roles'] ?? []; $channels = $this->connections[$connection]['channels'] ?? []; - if (!$projectId || empty($roles) || empty($channels)) { + if (! $projectId || empty($roles) || empty($channels)) { return []; } @@ -170,7 +191,7 @@ class Realtime extends MessagingAdapter if (!isset($subscriptions[$subscriptionId])) { $subscriptions[$subscriptionId] = [ 'channels' => [], - 'queries' => $data['strings'] ?? [] + 'queries' => $data['strings'] ?? [], ]; } if (!\in_array($channel, $subscriptions[$subscriptionId]['channels'])) { @@ -308,7 +329,7 @@ class Realtime extends MessagingAdapter */ public function hasSubscriber(string $projectId, string $role, string $channel = ''): bool { - //TODO: look into moving it to an abstract class in the parent class + // TODO: look into moving it to an abstract class in the parent class if (empty($channel)) { return array_key_exists($projectId, $this->subscriptions) && array_key_exists($role, $this->subscriptions[$projectId]); @@ -329,6 +350,7 @@ class Realtime extends MessagingAdapter * @param array $roles * @param array $options * @return void + * * @throws \Exception */ public function send(string $projectId, array $payload, array $events, array $channels, array $roles, array $options = []): void @@ -349,8 +371,8 @@ class Realtime extends MessagingAdapter 'events' => $events, 'channels' => $channels, 'timestamp' => DateTime::formatTz(DateTime::now()), - 'payload' => $payload - ] + 'payload' => $payload, + ], ])); } @@ -363,7 +385,6 @@ class Realtime extends MessagingAdapter * - 1.5 ms | 1,000 Connections / 10,000 Subscriptions * - 15 ms | 10,000 Connections / 100,000 Subscriptions * - * @param array $event * @return array Map of connection IDs to matched query groups */ public function getSubscribers(array $event): array @@ -379,7 +400,7 @@ class Realtime extends MessagingAdapter foreach ($this->subscriptions[$event['project']] as $role => $subscriptionsByChannel) { foreach ($event['data']['channels'] as $channel) { if ( - !\array_key_exists($channel, $subscriptionsByChannel) + ! \array_key_exists($channel, $subscriptionsByChannel) || (!\in_array($role, $event['roles']) && !\in_array(Role::any()->toString(), $event['roles'])) ) { continue; @@ -412,6 +433,12 @@ class Realtime extends MessagingAdapter /** * Converts the channels from the Query Params into an array. + * Also renames the account channel to account.USER_ID, rewrites action-suffixed + * account variants (`account.create`, `account.update`, `account.upsert`, + * `account.delete`) to `account.USER_ID.{action}` so they match the channels + * fromPayload() publishes for top-level user events, and removes all other + * illegal account channel variations (e.g. another user's `account.{otherId}`). + * * Also renames the account channel to account.USER_ID and removes all illegal account channel variations. * @param array $channels * @param string $userId @@ -423,27 +450,94 @@ class Realtime extends MessagingAdapter foreach ($channels as $key => $value) { switch (true) { - case str_starts_with($key, 'account.'): - unset($channels[$key]); - break; - case $key === 'account': if (!empty($userId)) { - $channels['account.' . $userId] = $value; + $channels['account.'.$userId] = $value; } break; + + case \in_array(\substr($key, \strlen('account.')), self::SUPPORTED_ACTIONS, true) && str_starts_with($key, 'account.'): + // Authenticated: rewrite `account.{action}` → `account.{userId}.{action}` + // so the subscriber only receives their own account events. + // Guest: keep the literal `account.{action}` so the action filter + // applies to the broadcast `account.{action}` channel that fromPayload + // emits for top-level user events. On in-band auth, rebindAccountChannels + // rewrites the literal to the user-scoped form. + if (!empty($userId)) { + unset($channels[$key]); + $action = \substr($key, \strlen('account.')); + $channels['account.'.$userId.'.'.$action] = $value; + } + break; + + case str_starts_with($key, 'account.'): + unset($channels[$key]); + break; } } return $channels; } + /** + * Rewrites stored account channels to match a new userId. Used when in-band + * authentication changes the connection's user identity: + * + * - guest → authenticated: rewrites the literal `account.{action}` form + * that convertChannels preserves for guests into `account.{userId}.{action}`. + * - reauth as a different user: rewrites `account.{oldUserId}` and + * `account.{oldUserId}.{action}` to the new userId. + * + * Returns channels unchanged when there's nothing to do — same user, or an + * empty target (defensive: avoids producing malformed `account.` strings if + * a caller ever passes `$newUserId = ''`, e.g. an in-band logout flow). + */ + public static function rebindAccountChannels(array $channels, string $oldUserId, string $newUserId): array + { + if ($newUserId === '' || $oldUserId === $newUserId) { + return $channels; + } + + return \array_map(function (string $channel) use ($oldUserId, $newUserId) { + if (!\str_starts_with($channel, 'account.')) { + return $channel; + } + + // Guest origin: literal `account.{action}` (preserved by convertChannels + // for unauthenticated connections) becomes `account.{newUserId}.{action}`. + if ($oldUserId === '') { + $suffix = \substr($channel, \strlen('account.')); + if (\in_array($suffix, self::SUPPORTED_ACTIONS, true)) { + return 'account.'.$newUserId.'.'.$suffix; + } + + return $channel; + } + + // Authenticated → different user. + if ($channel === 'account.'.$oldUserId) { + return 'account.'.$newUserId; + } + + $oldPrefix = 'account.'.$oldUserId.'.'; + if (\str_starts_with($channel, $oldPrefix)) { + $action = \substr($channel, \strlen($oldPrefix)); + if (\in_array($action, self::SUPPORTED_ACTIONS, true)) { + return 'account.'.$newUserId.'.'.$action; + } + } + + return $channel; + }, $channels); + } + /** * Constructs subscriptions from query parameters. * * @param array $channelNames * @param callable $getQueryParam * @return array [index => ['channels' => string[], 'queries' => Query[]]] + * * @throws QueryException */ public static function constructSubscriptions(array $channelNames, callable $getQueryParam): array @@ -484,10 +578,11 @@ class Realtime extends MessagingAdapter if (empty($subscriptions[0]['queries'])) { $subscriptions[0]['queries'] = [Query::select(['*'])]; } + continue; } - if (!\is_array($params)) { + if (! \is_array($params)) { $params = [$params]; } @@ -514,6 +609,7 @@ class Realtime extends MessagingAdapter * Converts the queries from the Query Params into an array. * @param array|string $queries * @return array + * * @throws QueryException */ public static function convertQueries(mixed $queries): array @@ -526,7 +622,7 @@ class Realtime extends MessagingAdapter $query = array_pop($stack); $method = $query->getMethod(); - if (!in_array($method, RuntimeQuery::ALLOWED_QUERIES, true)) { + if (! in_array($method, RuntimeQuery::ALLOWED_QUERIES, true)) { throw new QueryException( "Query method '{$method}' is not supported in Realtime queries. Allowed: {$allowed}" ); @@ -687,11 +783,53 @@ class Realtime extends MessagingAdapter break; } + // Action is the last segment for plain CRUD events (e.g. `documents.X.create`), + // and the second-to-last segment for attribute-trailing events + // (e.g. `users.U.update.email`, `teams.T.update.prefs`, + // `teams.T.memberships.M.update.status`). Without the second-to-last fallback + $count = \count($parts); + $action = null; + if (\in_array($parts[$count - 1], self::SUPPORTED_ACTIONS, true)) { + $action = $parts[$count - 1]; + } elseif ($count >= 2 && \in_array($parts[$count - 2], self::SUPPORTED_ACTIONS, true)) { + $action = $parts[$count - 2]; + } + + // The `users` branch emits only user-level account channels + // (`account`, `account.{userId}`) regardless of event depth, so nested events + // like `users.U.sessions.S.create` or `users.U.challenges.C.create` would + // otherwise be suffixed as `account.create` — making a subscription to + // `account.create` receive unrelated session/challenge/recovery/verification + // events. Restrict suffixing to top-level user events where the action sits + // at parts[2] (`users.U.create`, `users.U.update.email`, etc.). + if ( + $action !== null + && $parts[0] === 'users' + && ($parts[2] ?? null) !== $action + ) { + $action = null; + } + + if ($action !== null && !empty($channels)) { + $augmented = $channels; + foreach ($channels as $channel) { + $segments = \explode('.', $channel); + $segCount = \count($segments); + $leafIsResource = \in_array($segments[$segCount - 1], self::RESOURCE_LEAF_NAMES, true); + $parentIsResource = $segCount >= 2 && \in_array($segments[$segCount - 2], self::RESOURCE_LEAF_NAMES, true); + + if ($leafIsResource || $parentIsResource) { + $augmented[] = $channel. '.' .$action; + } + } + $channels = \array_values(\array_unique($augmented)); + } + return [ 'channels' => $channels, 'roles' => $roles, 'permissionsChanged' => $permissionsChanged, - 'projectId' => $projectId + 'projectId' => $projectId, ]; } diff --git a/src/Appwrite/Platform/Modules/Console/Http/OAuth2Providers/XList.php b/src/Appwrite/Platform/Modules/Console/Http/OAuth2Providers/XList.php new file mode 100644 index 0000000000..574f7a5f6a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Console/Http/OAuth2Providers/XList.php @@ -0,0 +1,80 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/console/oauth2-providers') + ->desc('List OAuth2 providers') + ->groups(['api']) + ->label('scope', 'public') + ->label('sdk', new Method( + namespace: 'console', + group: 'console', + name: 'listOAuth2Providers', + description: '/docs/references/console/list-oauth2-providers.md', + auth: [AuthType::ADMIN], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_CONSOLE_OAUTH2_PROVIDER_LIST, + ) + ], + contentType: ContentType::JSON + )) + ->inject('response') + ->callback($this->action(...)); + } + + public function action(Response $response): void + { + $providersConfig = Config::getParam('oAuthProviders', []); + $actions = OAuth2Base::getProviderActions(); + + $providers = []; + foreach ($actions as $providerId => $updateClass) { + $config = $providersConfig[$providerId] ?? null; + if ($config === null) { + continue; + } + if (!($config['enabled'] ?? false)) { + continue; + } + if ($config['mock'] ?? false) { + continue; + } + + $providers[] = new Document([ + '$id' => $providerId, + 'parameters' => $updateClass::getParameters(), + ]); + } + + $response->dynamic(new Document([ + 'total' => \count($providers), + 'oAuth2Providers' => $providers, + ]), Response::MODEL_CONSOLE_OAUTH2_PROVIDER_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Console/Services/Http.php b/src/Appwrite/Platform/Modules/Console/Services/Http.php index f3ca6218f2..77029af0f9 100644 --- a/src/Appwrite/Platform/Modules/Console/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Console/Services/Http.php @@ -5,6 +5,7 @@ namespace Appwrite\Platform\Modules\Console\Services; use Appwrite\Platform\Modules\Console\Http\Assistant\Create as CreateAssistantQuery; use Appwrite\Platform\Modules\Console\Http\Init\API; use Appwrite\Platform\Modules\Console\Http\Init\Web; +use Appwrite\Platform\Modules\Console\Http\OAuth2Providers\XList as ListOAuth2Providers; use Appwrite\Platform\Modules\Console\Http\Redirects\Auth\Get as RedirectAuth; use Appwrite\Platform\Modules\Console\Http\Redirects\Card\Get as RedirectCard; use Appwrite\Platform\Modules\Console\Http\Redirects\Invite\Get as RedirectInvite; @@ -28,6 +29,7 @@ class Http extends Service $this->addAction(Web::getName(), new Web()); $this->addAction(GetVariables::getName(), new GetVariables()); + $this->addAction(ListOAuth2Providers::getName(), new ListOAuth2Providers()); $this->addAction(CreateAssistantQuery::getName(), new CreateAssistantQuery()); $this->addAction(GetResourceAvailability::getName(), new GetResourceAvailability()); diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Amazon/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Amazon/Update.php new file mode 100644 index 0000000000..7c68ff4032 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Amazon/Update.php @@ -0,0 +1,55 @@ + static::getClientIdParamName(), + 'name' => static::getClientIdName(), + 'example' => static::getClientIdExample(), + 'hint' => '', + ], + [ + '$id' => 'keyId', + 'name' => 'Key ID', + 'example' => 'P4000000N8', + 'hint' => '', + ], + [ + '$id' => 'teamId', + 'name' => 'Team ID', + 'example' => 'D4000000R6', + 'hint' => '', + ], + [ + '$id' => 'p8File', + 'name' => 'P8 File', + 'example' => '-----BEGIN PRIVATE KEY-----MIGTAg...jy2Xbna-----END PRIVATE KEY-----', + 'hint' => '', + ], + ]; + } + + public function __construct() + { + $providerId = static::getProviderId(); + $providerLabel = static::getProviderLabel(); + + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/' . $providerId) + ->desc('Update project OAuth2 ' . $providerLabel) + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.[providerId].update') + ->label('audits.event', 'project.oauth2.[providerId].update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: static::getProviderSDKMethod(), + description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: static::getResponseModel(), + ) + ], + )) + ->param(static::getClientIdParamName(), null, new Nullable(new Text(256, 0)), static::getClientIdDescription(), optional: true) + ->param('keyId', null, new Nullable(new Text(256, 0)), '\'Key ID\' of Apple OAuth2 app. For example: P4000000N8', optional: true) + ->param('teamId', null, new Nullable(new Text(256, 0)), '\'Team ID\' of Apple OAuth2 app. For example: D4000000R6', optional: true) + ->param('p8File', null, new Nullable(new Text(4096, 0)), 'Contents of the Apple OAuth2 app .p8 private key file. The secret key wrapped by the PEM markers is 200 characters long. For example: -----BEGIN PRIVATE KEY-----MIGTAg...jy2Xbna-----END PRIVATE KEY-----', 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') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->handle(...)); + } + + public function buildReadResponse(Document $project): Document + { + $providerId = static::getProviderId(); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + + return new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + 'keyId' => '', + 'teamId' => '', + 'p8File' => '', + ]); + } + + /** + * Custom callback used instead of the parent's `action()` because Apple's + * client secret is composed of three fields (.p8 file contents, Key ID and + * Team ID) that must be JSON-encoded to match the shape Apple's OAuth2 + * adapter expects in getAppSecret(). The method is named differently to + * avoid an LSP-incompatible override of Base::action(). + */ + public function handle( + ?string $serviceId, + ?string $keyId, + ?string $teamId, + ?string $p8File, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + QueueEvent $queueForEvents + ): void { + $providerId = static::getProviderId(); + $queueForEvents->setParam('providerId', $providerId); + + // The secret is stored as JSON `{"p8": "...", "keyID": "...", "teamID": "..."}` + // to match the shape Apple's OAuth2 adapter expects in getAppSecret(). + // Merge new values with what's already stored so that submitting only + // some of the fields leaves the rest untouched. + $encodedSecret = null; + if (!\is_null($keyId) || !\is_null($teamId) || !\is_null($p8File)) { + $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; + $existing = []; + if (!empty($storedRaw)) { + $existing = \json_decode($storedRaw, true) ?: []; + } + $encodedSecret = \json_encode([ + 'p8' => $p8File ?? ($existing['p8'] ?? ''), + 'keyID' => $keyId ?? ($existing['keyID'] ?? ''), + 'teamID' => $teamId ?? ($existing['teamID'] ?? ''), + ]); + } + + $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $serviceId, $encodedSecret, $enabled); + + // Reuse buildReadResponse to keep PATCH/GET shapes identical and + // guarantee keyId/teamId/p8File are write-only on every response path. + $response->dynamic($this->buildReadResponse($project), static::getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php new file mode 100644 index 0000000000..aa5f39b213 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Auth0/Update.php @@ -0,0 +1,175 @@ + 'endpoint', + 'name' => 'Domain', + 'example' => 'example.us.auth0.com', + 'hint' => '', + ], + ]); + } + + public function __construct() + { + $providerId = static::getProviderId(); + $providerLabel = static::getProviderLabel(); + + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/' . $providerId) + ->desc('Update project OAuth2 ' . $providerLabel) + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.[providerId].update') + ->label('audits.event', 'project.oauth2.[providerId].update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: static::getProviderSDKMethod(), + description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: static::getResponseModel(), + ) + ], + )) + ->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', null, new Nullable(new Text(256, 0)), 'Domain of Auth0 instance. For example: example.us.auth0.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') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->handle(...)); + } + + public function buildReadResponse(Document $project): Document + { + $providerId = static::getProviderId(); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $decoded = $this->decodeStoredSecret($project); + + return new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => '', + 'endpoint' => $decoded['auth0Domain'] ?? '', + ]); + } + + /** + * Custom callback used instead of the parent's `action()` because Auth0 + * takes an additional optional `endpoint` parameter. The method is named + * differently to avoid an LSP-incompatible override of Base::action(). + */ + public function handle( + ?string $clientId, + ?string $clientSecret, + ?string $endpoint, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + QueueEvent $queueForEvents + ): void { + $providerId = static::getProviderId(); + $queueForEvents->setParam('providerId', $providerId); + + // The secret is stored as JSON `{"clientSecret": "...", "auth0Domain": "..."}` + // to match the shape Auth0's OAuth2 adapter expects (getAuth0Domain()). + // Merge new values with existing storage so that submitting only one of + // `clientSecret`/`endpoint` leaves the other untouched. + $encodedSecret = null; + if (!\is_null($clientSecret) || !\is_null($endpoint)) { + $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; + $existing = []; + if (!empty($storedRaw)) { + $existing = \json_decode($storedRaw, true) ?: []; + } + $encodedSecret = \json_encode([ + 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''), + 'auth0Domain' => $endpoint ?? ($existing['auth0Domain'] ?? ''), + ]); + } + + $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled); + + // Reuse buildReadResponse to keep PATCH/GET shapes identical and + // guarantee the clientSecret is write-only on every response path. + $response->dynamic($this->buildReadResponse($project), static::getResponseModel()); + } +} 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 new file mode 100644 index 0000000000..d5d465c3d4 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Authentik/Update.php @@ -0,0 +1,172 @@ + 'endpoint', + 'name' => 'Domain', + 'example' => 'example.authentik.com', + 'hint' => '', + ], + ]); + } + + public function __construct() + { + $providerId = static::getProviderId(); + $providerLabel = static::getProviderLabel(); + + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/' . $providerId) + ->desc('Update project OAuth2 ' . $providerLabel) + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.[providerId].update') + ->label('audits.event', 'project.oauth2.[providerId].update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: static::getProviderSDKMethod(), + description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: static::getResponseModel(), + ) + ], + )) + ->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('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') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->handle(...)); + } + + public function buildReadResponse(Document $project): Document + { + $providerId = static::getProviderId(); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $decoded = $this->decodeStoredSecret($project); + + return new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => '', + 'endpoint' => $decoded['authentikDomain'] ?? '', + ]); + } + + /** + * Custom callback used instead of the parent's `action()` because Authentik + * takes an additional required `endpoint` parameter. The method is named + * differently to avoid an LSP-incompatible override of Base::action(). + */ + public function handle( + ?string $clientId, + ?string $clientSecret, + string $endpoint, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + QueueEvent $queueForEvents + ): void { + $providerId = static::getProviderId(); + $queueForEvents->setParam('providerId', $providerId); + + // 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. + // `clientSecret` is optional; if omitted, the existing stored secret is preserved. + $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; + $existing = []; + if (!empty($storedRaw)) { + $existing = \json_decode($storedRaw, true) ?: []; + } + $encodedSecret = \json_encode([ + 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''), + 'authentikDomain' => $endpoint, + ]); + + $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled); + + // Reuse buildReadResponse to keep PATCH/GET shapes identical and + // guarantee the clientSecret is write-only on every response path. + $response->dynamic($this->buildReadResponse($project), static::getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Autodesk/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Autodesk/Update.php new file mode 100644 index 0000000000..dd4f4f6faa --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Autodesk/Update.php @@ -0,0 +1,55 @@ +' of OAuth2 app. For example: [. ]". + * Returns an empty string when the name is empty. + */ + private static function buildParamDescription(string $name, string $example, string $hint): string + { + if ($name === '') { + return ''; + } + + $description = '\'' . $name . '\' of ' . static::getProviderLabel() . ' OAuth2 app. For example: ' . $example; + if ($hint !== '') { + $description .= '. ' . $hint; + } + + return $description; + } + + /** + * Verbose, user-facing name of the clientId param. Includes alternate + * names when the provider exposes more than one (e.g. "Client ID or App + * ID", "Application ID (also known as Client ID)"). + * + * @return string + */ + abstract public static function getClientIdName(): string; + + /** + * Example value of the clientId param. Used to build the public OAuth2 + * providers metadata response. + * + * @return string + */ + abstract public static function getClientIdExample(): string; + + /** + * Optional hint for the clientId param. Typically used to call out a + * common wrong value (e.g. "Example of wrong value: 370006"). Defaults + * to an empty string. + */ + public static function getClientIdHint(): string + { + return ''; + } + + /** + * Verbose, user-facing name of the clientSecret param. Returns an empty + * string for providers that don't have a single clientSecret param + * (e.g. Apple uses keyId/teamId/p8File instead). + * + * @return string + */ + abstract public static function getClientSecretName(): string; + + /** + * Example value of the clientSecret param. Returns an empty string for + * providers without a clientSecret param. + * + * @return string + */ + abstract public static function getClientSecretExample(): string; + + /** + * Optional hint for the clientSecret param. Defaults to an empty string. + */ + public static function getClientSecretHint(): string + { + return ''; + } + + /** + * Public-facing parameter metadata for this provider. Used by the public + * console OAuth2 providers endpoint to describe the form fields a project + * owner must fill in to configure the provider. + * + * Default shape: clientId + clientSecret. Providers that take additional + * fields (Apple, Auth0, Authentik, Gitlab, Microsoft, Oidc, Okta) + * override this method to add or replace entries. Each parameter is an + * associative array with keys `$id`, `name`, `example`, `hint`. + * + * @return array> + */ + public static function getParameters(): array + { + $parameters = []; + + $clientIdName = static::getClientIdName(); + if ($clientIdName !== '') { + $parameters[] = [ + '$id' => static::getClientIdParamName(), + 'name' => $clientIdName, + 'example' => static::getClientIdExample(), + 'hint' => static::getClientIdHint(), + ]; + } + + $clientSecretName = static::getClientSecretName(); + if ($clientSecretName !== '') { + $parameters[] = [ + '$id' => static::getClientSecretParamName(), + 'name' => $clientSecretName, + 'example' => static::getClientSecretExample(), + 'hint' => static::getClientSecretHint(), + ]; + } + + return $parameters; + } + + /** + * Public-facing name of the clientId param. Some providers use a different + * terminology (e.g. Dropbox calls it "App key"), so the param name and the + * corresponding response field can be customized by overriding this method. + * + * @return string e.g. 'clientId' (default), 'appKey' + */ + public static function getClientIdParamName(): string + { + return 'clientId'; + } + + /** + * Public-facing name of the clientSecret param. Some providers use a + * different terminology (e.g. Dropbox calls it "App secret"), so the param + * name and the corresponding response field can be customized by + * overriding this method. + * + * @return string e.g. 'clientSecret' (default), 'appSecret' + */ + public static function getClientSecretParamName(): string + { + return 'clientSecret'; + } + + /** + * SDK method name exposed to clients. + * + * @return string e.g. 'updateOAuth2GitHub' + */ + abstract public static function getProviderSDKMethod(): string; + + public static function getName() + { + return 'updateProjectOAuth2' . static::getProviderLabel(); + } + + public function __construct() + { + $providerId = static::getProviderId(); + $providerLabel = static::getProviderLabel(); + + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/' . $providerId) + ->desc('Update project OAuth2 ' . $providerLabel) + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.[providerId].update') + ->label('audits.event', 'project.oauth2.[providerId].update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: static::getProviderSDKMethod(), + description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: static::getResponseModel(), + ) + ], + )) + ->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('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') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->action(...)); + } + + /** + * Registry of provider ID -> Update action class. Mirrors the OAuth2 + * actions registered in Project\Services\Http. Used by the Get and XList + * read endpoints to dispatch per-provider response shaping. + * + * @return array> + */ + public static function getProviderActions(): array + { + return [ + 'github' => GitHub\Update::class, + 'discord' => Discord\Update::class, + 'figma' => Figma\Update::class, + 'dropbox' => Dropbox\Update::class, + 'dailymotion' => Dailymotion\Update::class, + 'bitbucket' => Bitbucket\Update::class, + 'bitly' => Bitly\Update::class, + 'box' => Box\Update::class, + 'autodesk' => Autodesk\Update::class, + 'google' => Google\Update::class, + 'zoom' => Zoom\Update::class, + 'zoho' => Zoho\Update::class, + 'yandex' => Yandex\Update::class, + 'x' => X\Update::class, + 'wordpress' => WordPress\Update::class, + 'twitch' => Twitch\Update::class, + 'stripe' => Stripe\Update::class, + 'spotify' => Spotify\Update::class, + 'slack' => Slack\Update::class, + 'podio' => Podio\Update::class, + 'notion' => Notion\Update::class, + 'salesforce' => Salesforce\Update::class, + 'yahoo' => Yahoo\Update::class, + 'linkedin' => Linkedin\Update::class, + 'disqus' => Disqus\Update::class, + 'amazon' => Amazon\Update::class, + 'etsy' => Etsy\Update::class, + 'facebook' => Facebook\Update::class, + 'tradeshift' => Tradeshift\Update::class, + 'tradeshiftBox' => TradeshiftSandbox\Update::class, + 'paypal' => Paypal\Update::class, + 'paypalSandbox' => PaypalSandbox\Update::class, + 'gitlab' => Gitlab\Update::class, + 'authentik' => Authentik\Update::class, + 'auth0' => Auth0\Update::class, + 'fusionauth' => FusionAuth\Update::class, + 'keycloak' => Keycloak\Update::class, + 'oidc' => Oidc\Update::class, + 'okta' => Okta\Update::class, + 'kick' => Kick\Update::class, + 'apple' => Apple\Update::class, + 'microsoft' => Microsoft\Update::class, + ]; + } + + /** + * Build the read-only response document for this provider, with credential + * fields zeroed out (write-only). Default implementation handles providers + * that store a plain client ID + client secret. Special providers (Apple, + * Gitlab, Auth0, Authentik, Oidc, Okta) override to expose their + * non-secret extras (endpoint, domain, discovery URLs, ...) decoded from + * the JSON-encoded secret blob. + */ + public function buildReadResponse(Document $project): Document + { + $providerId = static::getProviderId(); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + + return new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => '', + ]); + } + + /** + * Decode the JSON-encoded secret blob stored under `{providerId}Secret`. + * Returns an empty array when the value is empty or not valid JSON. + */ + protected function decodeStoredSecret(Document $project): array + { + $providerId = static::getProviderId(); + $stored = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; + + if (empty($stored)) { + return []; + } + + $decoded = \json_decode($stored, true); + return \is_array($decoded) ? $decoded : []; + } + + /** + * Apply the provided credential changes to the project's oAuthProviders map, + * run the optional credential verification hook, persist the project, and + * return the updated project document. + * + * Providers that need to serialize multiple values into a single secret + * (e.g. GitLab, which stores `{clientSecret, endpoint}` as JSON) should + * encode those values into `$clientSecret` before calling this method. + */ + protected function persistCredentials( + Document $project, + Database $dbForPlatform, + Authorization $authorization, + ?string $clientId, + ?string $clientSecret, + ?bool $enabled + ): Document { + $providerId = static::getProviderId(); + if (!(\in_array($providerId, \array_keys(Config::getParam('oAuthProviders'))))) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Provider ' . $providerId . ' is not supported by server configuration.'); + } + + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + + $appIdKey = $providerId . 'Appid'; + $appSecretKey = $providerId . 'Secret'; + $enabledKey = $providerId . 'Enabled'; + + if (!\is_null($clientId)) { + $oAuthProviders[$appIdKey] = $clientId; + } + + if (!\is_null($clientSecret)) { + $oAuthProviders[$appSecretKey] = $clientSecret; + } + + if (!\is_null($enabled)) { + $oAuthProviders[$enabledKey] = $enabled; + } + + if ($enabled === true || \is_null($enabled)) { + try { + if (empty($oAuthProviders[$appIdKey]) || empty($oAuthProviders[$appSecretKey])) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Client ID and Client Secret are required when enabling OAuth2 provider.'); + } + + $providerClass = static::getProviderClass(); + $providerInstance = new $providerClass(appId: $oAuthProviders[$appIdKey], appSecret: $oAuthProviders[$appSecretKey], callback: '', state: [], scopes: []); + + // E2E integration check + if (\method_exists($providerInstance, 'verifyCredentials')) { + $providerInstance->verifyCredentials(); + } + + $oAuthProviders[$enabledKey] = true; + } catch (\Throwable $err) { + if ($enabled === true) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Could not enable OAuth2 provider: ' . $err->getMessage()); + } + } + } + + $updates = new Document([ + 'oAuthProviders' => $oAuthProviders + ]); + + return $authorization->skip(fn () => $dbForPlatform->updateDocument('projects', $project->getId(), $updates)); + } + + public function action( + ?string $clientId, + ?string $clientSecret, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + QueueEvent $queueForEvents + ): void { + $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $clientSecret, $enabled); + + $queueForEvents->setParam('providerId', static::getProviderId()); + + // Reuse buildReadResponse to keep PATCH/GET shapes identical and + // guarantee the clientSecret is write-only on every response path. + $response->dynamic($this->buildReadResponse($project), static::getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitbucket/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitbucket/Update.php new file mode 100644 index 0000000000..a477bfbefb --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Bitbucket/Update.php @@ -0,0 +1,65 @@ + 'endpoint', + 'name' => 'Domain', + 'example' => 'example.fusionauth.io', + 'hint' => '', + ], + ]); + } + + public function __construct() + { + $providerId = static::getProviderId(); + $providerLabel = static::getProviderLabel(); + + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/' . $providerId) + ->desc('Update project OAuth2 ' . $providerLabel) + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.[providerId].update') + ->label('audits.event', 'project.oauth2.[providerId].update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: static::getProviderSDKMethod(), + description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: static::getResponseModel(), + ) + ], + )) + ->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('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') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->handle(...)); + } + + public function buildReadResponse(Document $project): Document + { + $providerId = static::getProviderId(); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $decoded = $this->decodeStoredSecret($project); + + return new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => '', + 'endpoint' => $decoded['fusionAuthDomain'] ?? '', + ]); + } + + /** + * Custom callback used instead of the parent's `action()` because FusionAuth + * takes an additional required `endpoint` parameter. The method is named + * differently to avoid an LSP-incompatible override of Base::action(). + */ + public function handle( + ?string $clientId, + ?string $clientSecret, + string $endpoint, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + QueueEvent $queueForEvents + ): void { + $providerId = static::getProviderId(); + $queueForEvents->setParam('providerId', $providerId); + + // 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. + // `clientSecret` is optional; if omitted, the existing stored secret is preserved. + $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; + $existing = []; + if (!empty($storedRaw)) { + $existing = \json_decode($storedRaw, true) ?: []; + } + $encodedSecret = \json_encode([ + 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''), + 'fusionAuthDomain' => $endpoint, + ]); + + $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled); + + // Reuse buildReadResponse to keep PATCH/GET shapes identical and + // guarantee the clientSecret is write-only on every response path. + $response->dynamic($this->buildReadResponse($project), static::getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php new file mode 100644 index 0000000000..ae46a59c67 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php @@ -0,0 +1,115 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/oauth2/:provider') + ->desc('Get project OAuth2 provider') + ->groups(['api', 'project']) + ->label('scope', 'oauth2.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: 'getOAuth2Provider', + description: <<param('provider', '', new Text(128), 'OAuth2 provider key. For example: github, google, apple.') + ->inject('response') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + string $provider, + Response $response, + Document $project, + ): void { + $providers = Config::getParam('oAuthProviders', []); + if (!\array_key_exists($provider, $providers) || !($providers[$provider]['enabled'] ?? false)) { + throw new Exception(Exception::PROJECT_PROVIDER_UNSUPPORTED); + } + + $actions = Base::getProviderActions(); + if (!isset($actions[$provider])) { + throw new Exception(Exception::PROJECT_PROVIDER_UNSUPPORTED); + } + + $updateClass = $actions[$provider]; + $action = new $updateClass(); + + $response->dynamic($action->buildReadResponse($project), $updateClass::getResponseModel()); + } +} 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 new file mode 100644 index 0000000000..3b6f89db06 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/GitHub/Update.php @@ -0,0 +1,60 @@ + 'endpoint', + 'name' => 'Endpoint', + 'example' => 'https://gitlab.com', + 'hint' => '', + ], + ]); + } + + public function __construct() + { + $providerId = static::getProviderId(); + $providerLabel = static::getProviderLabel(); + + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/' . $providerId) + ->desc('Update project OAuth2 ' . $providerLabel) + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.[providerId].update') + ->label('audits.event', 'project.oauth2.[providerId].update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: static::getProviderSDKMethod(), + description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: static::getResponseModel(), + ) + ], + )) + ->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', null, new Nullable(new URL(allowEmpty: true)), 'Endpoint URL of self-hosted GitLab instance. For example: https://gitlab.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') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->handle(...)); + } + + public function buildReadResponse(Document $project): Document + { + $providerId = static::getProviderId(); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $decoded = $this->decodeStoredSecret($project); + + return new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => '', + 'endpoint' => $decoded['endpoint'] ?? '', + ]); + } + + /** + * Custom callback used instead of the parent's `action()` because Gitlab + * takes an additional `endpoint` parameter. The method is named + * differently to avoid an LSP-incompatible override of Base::action(). + */ + public function handle( + ?string $applicationId, + ?string $secret, + ?string $endpoint, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + QueueEvent $queueForEvents + ): void { + $providerId = static::getProviderId(); + $queueForEvents->setParam('providerId', $providerId); + + // The secret is stored as JSON `{"clientSecret": "...", "endpoint": "..."}` + // so that the Gitlab OAuth2 adapter can extract the endpoint via getEndpoint(). + // Merge the new values with what's already stored so that submitting only + // one of `secret`/`endpoint` leaves the other untouched. + $encodedSecret = null; + if (!\is_null($secret) || !\is_null($endpoint)) { + $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; + $existing = []; + if (!empty($storedRaw)) { + $existing = \json_decode($storedRaw, true) ?: []; + } + $encodedSecret = \json_encode([ + 'clientSecret' => $secret ?? ($existing['clientSecret'] ?? ''), + 'endpoint' => $endpoint ?? ($existing['endpoint'] ?? ''), + ]); + } + + $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $applicationId, $encodedSecret, $enabled); + + // Reuse buildReadResponse to keep PATCH/GET shapes identical and + // guarantee the secret is write-only on every response path. + $response->dynamic($this->buildReadResponse($project), static::getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php new file mode 100644 index 0000000000..9b985f4aed --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Google/Update.php @@ -0,0 +1,55 @@ + 'endpoint', + 'name' => 'Domain', + 'example' => 'keycloak.example.com', + 'hint' => '', + ], + [ + '$id' => 'realmName', + 'name' => 'Realm name', + 'example' => 'appwrite-realm', + 'hint' => '', + ], + ]); + } + + public function __construct() + { + $providerId = static::getProviderId(); + $providerLabel = static::getProviderLabel(); + + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/' . $providerId) + ->desc('Update project OAuth2 ' . $providerLabel) + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.[providerId].update') + ->label('audits.event', 'project.oauth2.[providerId].update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: static::getProviderSDKMethod(), + description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: static::getResponseModel(), + ) + ], + )) + ->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('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') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->handle(...)); + } + + public function buildReadResponse(Document $project): Document + { + $providerId = static::getProviderId(); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $decoded = $this->decodeStoredSecret($project); + + return new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => '', + 'endpoint' => $decoded['keycloakDomain'] ?? '', + 'realmName' => $decoded['keycloakRealm'] ?? '', + ]); + } + + /** + * Custom callback used instead of the parent's `action()` because Keycloak + * takes additional required `endpoint` and `realmName` parameters. The + * method is named differently to avoid an LSP-incompatible override of + * Base::action(). + */ + public function handle( + ?string $clientId, + ?string $clientSecret, + string $endpoint, + string $realmName, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + QueueEvent $queueForEvents + ): void { + $providerId = static::getProviderId(); + $queueForEvents->setParam('providerId', $providerId); + + // 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. + // `clientSecret` is optional; if omitted, the existing stored secret is preserved. + $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; + $existing = []; + if (!empty($storedRaw)) { + $existing = \json_decode($storedRaw, true) ?: []; + } + $encodedSecret = \json_encode([ + 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''), + 'keycloakDomain' => $endpoint, + 'keycloakRealm' => $realmName, + ]); + + $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled); + + // Reuse buildReadResponse to keep PATCH/GET shapes identical and + // guarantee the clientSecret is write-only on every response path. + $response->dynamic($this->buildReadResponse($project), static::getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Kick/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Kick/Update.php new file mode 100644 index 0000000000..db4a20174f --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Kick/Update.php @@ -0,0 +1,55 @@ + 'tenant', + 'name' => 'Tenant', + 'example' => 'common', + 'hint' => '', + ], + ]); + } + + public function __construct() + { + $providerId = static::getProviderId(); + $providerLabel = static::getProviderLabel(); + + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/' . $providerId) + ->desc('Update project OAuth2 ' . $providerLabel) + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.[providerId].update') + ->label('audits.event', 'project.oauth2.[providerId].update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: static::getProviderSDKMethod(), + description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: static::getResponseModel(), + ) + ], + )) + ->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('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') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->handle(...)); + } + + public function buildReadResponse(Document $project): Document + { + $providerId = static::getProviderId(); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $decoded = $this->decodeStoredSecret($project); + + return new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => '', + 'tenant' => $decoded['tenantID'] ?? '', + ]); + } + + /** + * Custom callback used instead of the parent's `action()` because Microsoft + * takes an additional required `tenant` parameter. The method is named + * differently to avoid an LSP-incompatible override of Base::action(). + */ + public function handle( + ?string $applicationId, + ?string $applicationSecret, + string $tenant, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + QueueEvent $queueForEvents + ): void { + $providerId = static::getProviderId(); + $queueForEvents->setParam('providerId', $providerId); + + // 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. + // `applicationSecret` is optional; if omitted, the existing stored secret is preserved. + $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; + $existing = []; + if (!empty($storedRaw)) { + $existing = \json_decode($storedRaw, true) ?: []; + } + $encodedSecret = \json_encode([ + 'clientSecret' => $applicationSecret ?? ($existing['clientSecret'] ?? ''), + 'tenantID' => $tenant, + ]); + + $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $applicationId, $encodedSecret, $enabled); + + // Reuse buildReadResponse to keep PATCH/GET shapes identical and + // guarantee the applicationSecret is write-only on every response path. + $response->dynamic($this->buildReadResponse($project), static::getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Notion/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Notion/Update.php new file mode 100644 index 0000000000..4b048b0c0b --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Notion/Update.php @@ -0,0 +1,65 @@ + 'wellKnownURL', + 'name' => 'Well-known URL', + 'example' => 'https://myoauth.com/.well-known/openid-configuration', + 'hint' => '', + ], + [ + '$id' => 'authorizationURL', + 'name' => 'Authorization URL', + 'example' => 'https://myoauth.com/oauth2/authorize', + 'hint' => '', + ], + [ + '$id' => 'tokenUrl', + 'name' => 'Token URL', + 'example' => 'https://myoauth.com/oauth2/token', + 'hint' => '', + ], + [ + '$id' => 'userInfoUrl', + 'name' => 'User Info URL', + 'example' => 'https://myoauth.com/oauth2/userinfo', + 'hint' => '', + ], + ]); + } + + public function __construct() + { + $providerId = static::getProviderId(); + $providerLabel = static::getProviderLabel(); + + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/' . $providerId) + ->desc('Update project OAuth2 ' . $providerLabel) + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.[providerId].update') + ->label('audits.event', 'project.oauth2.[providerId].update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: static::getProviderSDKMethod(), + description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: static::getResponseModel(), + ) + ], + )) + ->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('wellKnownURL', null, new Nullable(new URL(allowEmpty: true)), 'OpenID Connect well-known configuration URL. When provided, authorization, token, and user info endpoints can be discovered automatically. For example: https://myoauth.com/.well-known/openid-configuration', optional: true) + ->param('authorizationURL', null, new Nullable(new URL(allowEmpty: true)), 'OpenID Connect authorization endpoint URL. Required when wellKnownURL is not provided. For example: https://myoauth.com/oauth2/authorize', optional: true) + ->param('tokenUrl', null, new Nullable(new URL(allowEmpty: true)), 'OpenID Connect token endpoint URL. Required when wellKnownURL is not provided. For example: https://myoauth.com/oauth2/token', optional: true) + ->param('userInfoUrl', null, new Nullable(new URL(allowEmpty: true)), 'OpenID Connect user info endpoint URL. Required when wellKnownURL is not provided. For example: https://myoauth.com/oauth2/userinfo', 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') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->handle(...)); + } + + public function buildReadResponse(Document $project): Document + { + $providerId = static::getProviderId(); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $decoded = $this->decodeStoredSecret($project); + + return new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => '', + 'wellKnownURL' => $decoded['wellKnownEndpoint'] ?? '', + 'authorizationURL' => $decoded['authorizationEndpoint'] ?? '', + 'tokenUrl' => $decoded['tokenEndpoint'] ?? '', + 'userInfoUrl' => $decoded['userInfoEndpoint'] ?? '', + ]); + } + + /** + * Custom callback used instead of the parent's `action()` because OIDC takes + * a well-known URL plus three discovery URLs (authorization, token, user + * info), all stored together with the client secret as JSON. The method is + * named differently to avoid an LSP-incompatible override of Base::action(). + * + * Enabling the provider requires either a non-empty `wellKnownEndpoint`, + * or all three of `authorizationEndpoint`, `tokenEndpoint`, and + * `userInfoEndpoint` to be set. The check considers the merged state of + * existing stored values plus the new values from the request, so callers + * can enable the provider in a single request without re-sending fields + * that were configured previously. + */ + public function handle( + ?string $clientId, + ?string $clientSecret, + ?string $wellKnownURL, + ?string $authorizationURL, + ?string $tokenUrl, + ?string $userInfoUrl, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + QueueEvent $queueForEvents + ): void { + $providerId = static::getProviderId(); + $queueForEvents->setParam('providerId', $providerId); + + // The secret is stored as JSON + // `{"clientSecret": "...", "wellKnownEndpoint": "...", "authorizationEndpoint": "...", "tokenEndpoint": "...", "userInfoEndpoint": "..."}` + // so that the OIDC OAuth2 adapter can extract each endpoint individually. + // Merge new values with what's already stored so that submitting only a + // subset of fields leaves the others untouched. + $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; + $existing = []; + if (!empty($storedRaw)) { + $existing = \json_decode($storedRaw, true) ?: []; + } + + $merged = [ + 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''), + 'wellKnownEndpoint' => $wellKnownURL ?? ($existing['wellKnownEndpoint'] ?? ''), + 'authorizationEndpoint' => $authorizationURL ?? ($existing['authorizationEndpoint'] ?? ''), + 'tokenEndpoint' => $tokenUrl ?? ($existing['tokenEndpoint'] ?? ''), + 'userInfoEndpoint' => $userInfoUrl ?? ($existing['userInfoEndpoint'] ?? ''), + ]; + + // When enabling, require either wellKnownEndpoint alone, or all three + // discovery URLs (authorization, token, user info). Skip this check + // when disabling or when leaving the enabled flag unchanged. + if ($enabled === true) { + $hasWellKnown = !empty($merged['wellKnownEndpoint']); + $hasAllDiscovery = !empty($merged['authorizationEndpoint']) + && !empty($merged['tokenEndpoint']) + && !empty($merged['userInfoEndpoint']); + + if (!$hasWellKnown && !$hasAllDiscovery) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Enabling OpenID Connect requires either wellKnownURL, or all of authorizationURL, tokenUrl, and userInfoUrl.'); + } + } + + $encodedSecret = \json_encode($merged); + + $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled); + + // Reuse buildReadResponse to keep PATCH/GET shapes identical and + // guarantee the clientSecret is write-only on every response path. + $response->dynamic($this->buildReadResponse($project), static::getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php new file mode 100644 index 0000000000..0344b6a14a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Okta/Update.php @@ -0,0 +1,198 @@ + 'domain', + 'name' => 'Domain', + 'example' => 'trial-6400025.okta.com', + 'hint' => 'Example of wrong value: trial-6400025-admin.okta.com, or https://trial-6400025.okta.com/', + ], + [ + '$id' => 'authorizationServerId', + 'name' => 'Authorization Server ID', + 'example' => 'aus000000000000000h7z', + 'hint' => '', + ], + ]); + } + + public function __construct() + { + $providerId = static::getProviderId(); + $providerLabel = static::getProviderLabel(); + + $this + ->setHttpMethod(Action::HTTP_REQUEST_METHOD_PATCH) + ->setHttpPath('/v1/project/oauth2/' . $providerId) + ->desc('Update project OAuth2 ' . $providerLabel) + ->groups(['api', 'project']) + ->label('scope', 'oauth2.write') + ->label('event', 'oauth2.[providerId].update') + ->label('audits.event', 'project.oauth2.[providerId].update') + ->label('audits.resource', 'project.oauth2/{response.$id}') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: static::getProviderSDKMethod(), + description: 'Update the project OAuth2 ' . $providerLabel . ' configuration.', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: static::getResponseModel(), + ) + ], + )) + ->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('domain', null, new Nullable(new ValidatorDomain(allowEmpty: true)), 'Okta company domain. Required when enabling the provider. For example: trial-6400025.okta.com. Example of wrong value: trial-6400025-admin.okta.com, or https://trial-6400025.okta.com/', optional: true) + ->param('authorizationServerId', null, new Nullable(new Text(256, 0)), 'Custom Authorization Servers. Optional, can be left empty or unconfigured. For example: aus000000000000000h7z', 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') + ->inject('project') + ->inject('authorization') + ->inject('queueForEvents') + ->callback($this->handle(...)); + } + + public function buildReadResponse(Document $project): Document + { + $providerId = static::getProviderId(); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + $decoded = $this->decodeStoredSecret($project); + + return new Document([ + '$id' => $providerId, + 'enabled' => $oAuthProviders[$providerId . 'Enabled'] ?? false, + static::getClientIdParamName() => $oAuthProviders[$providerId . 'Appid'] ?? '', + static::getClientSecretParamName() => '', + 'domain' => $decoded['oktaDomain'] ?? '', + 'authorizationServerId' => $decoded['authorizationServerId'] ?? '', + ]); + } + + /** + * Custom callback used instead of the parent's `action()` because Okta + * takes additional optional `domain` and `authorizationServerId` parameters. + * The method is named differently to avoid an LSP-incompatible override of + * Base::action(). + */ + public function handle( + ?string $clientId, + ?string $clientSecret, + ?string $domain, + ?string $authorizationServerId, + ?bool $enabled, + Response $response, + Database $dbForPlatform, + Document $project, + Authorization $authorization, + QueueEvent $queueForEvents + ): void { + $providerId = static::getProviderId(); + $queueForEvents->setParam('providerId', $providerId); + + // The secret is stored as JSON `{"clientSecret": "...", "oktaDomain": "...", "authorizationServerId": "..."}` + // to match the shape Okta's OAuth2 adapter expects. + // Merge new values with existing storage so that submitting only some of + // the parameters leaves the others untouched. + $storedRaw = $project->getAttribute('oAuthProviders', [])[$providerId . 'Secret'] ?? ''; + $existing = []; + if (!empty($storedRaw)) { + $existing = \json_decode($storedRaw, true) ?: []; + } + + $encodedSecret = null; + if (!\is_null($clientSecret) || !\is_null($domain) || !\is_null($authorizationServerId)) { + $encodedSecret = \json_encode([ + 'clientSecret' => $clientSecret ?? ($existing['clientSecret'] ?? ''), + 'oktaDomain' => $domain ?? ($existing['oktaDomain'] ?? ''), + 'authorizationServerId' => $authorizationServerId ?? ($existing['authorizationServerId'] ?? ''), + ]); + } + + // Domain is required when enabling the provider, since Okta builds its + // authorization, token and userinfo URLs from it. + if ($enabled === true) { + $effectiveDomain = $domain ?? ($existing['oktaDomain'] ?? ''); + if (empty($effectiveDomain)) { + throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Domain is required when enabling Okta OAuth2 provider.'); + } + } + + $project = $this->persistCredentials($project, $dbForPlatform, $authorization, $clientId, $encodedSecret, $enabled); + + // Reuse buildReadResponse to keep PATCH/GET shapes identical and + // guarantee the clientSecret is write-only on every response path. + $response->dynamic($this->buildReadResponse($project), static::getResponseModel()); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Paypal/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Paypal/Update.php new file mode 100644 index 0000000000..87b4e1576b --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Paypal/Update.php @@ -0,0 +1,60 @@ +setHttpMethod(Action::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/project/oauth2') + ->desc('List project OAuth2 providers') + ->groups(['api', 'project']) + ->label('scope', 'oauth2.read') + ->label('sdk', new Method( + namespace: 'project', + group: 'oauth2', + name: 'listOAuth2Providers', + description: <<inject('response') + ->inject('project') + ->callback($this->action(...)); + } + + public function action( + Response $response, + Document $project, + ): void { + $providers = Config::getParam('oAuthProviders', []); + $actions = Base::getProviderActions(); + + $documents = []; + foreach ($actions as $providerId => $updateClass) { + if (!($providers[$providerId]['enabled'] ?? false)) { + // Disabled by Appwrite configuration, exclude from response + continue; + } + + $action = new $updateClass(); + $documents[] = $action->buildReadResponse($project); + } + + $response->dynamic(new Document([ + 'total' => \count($documents), + 'providers' => $documents, + ]), Response::MODEL_OAUTH2_PROVIDER_LIST); + } +} diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yahoo/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yahoo/Update.php new file mode 100644 index 0000000000..45cf1f5a66 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Yahoo/Update.php @@ -0,0 +1,55 @@ +addAction(UpdateAuthMethod::getName(), new UpdateAuthMethod()); + + // OAuth2 + $this->addAction(ListOAuth2Providers::getName(), new ListOAuth2Providers()); + $this->addAction(GetOAuth2Provider::getName(), new GetOAuth2Provider()); + $this->addAction(UpdateOAuth2GitHub::getName(), new UpdateOAuth2GitHub()); + $this->addAction(UpdateOAuth2Discord::getName(), new UpdateOAuth2Discord()); + $this->addAction(UpdateOAuth2Figma::getName(), new UpdateOAuth2Figma()); + $this->addAction(UpdateOAuth2Dropbox::getName(), new UpdateOAuth2Dropbox()); + $this->addAction(UpdateOAuth2Dailymotion::getName(), new UpdateOAuth2Dailymotion()); + $this->addAction(UpdateOAuth2Bitbucket::getName(), new UpdateOAuth2Bitbucket()); + $this->addAction(UpdateOAuth2Bitly::getName(), new UpdateOAuth2Bitly()); + $this->addAction(UpdateOAuth2Box::getName(), new UpdateOAuth2Box()); + $this->addAction(UpdateOAuth2Autodesk::getName(), new UpdateOAuth2Autodesk()); + $this->addAction(UpdateOAuth2Google::getName(), new UpdateOAuth2Google()); + $this->addAction(UpdateOAuth2Zoom::getName(), new UpdateOAuth2Zoom()); + $this->addAction(UpdateOAuth2Zoho::getName(), new UpdateOAuth2Zoho()); + $this->addAction(UpdateOAuth2Yandex::getName(), new UpdateOAuth2Yandex()); + $this->addAction(UpdateOAuth2X::getName(), new UpdateOAuth2X()); + $this->addAction(UpdateOAuth2WordPress::getName(), new UpdateOAuth2WordPress()); + $this->addAction(UpdateOAuth2Twitch::getName(), new UpdateOAuth2Twitch()); + $this->addAction(UpdateOAuth2Stripe::getName(), new UpdateOAuth2Stripe()); + $this->addAction(UpdateOAuth2Spotify::getName(), new UpdateOAuth2Spotify()); + $this->addAction(UpdateOAuth2Slack::getName(), new UpdateOAuth2Slack()); + $this->addAction(UpdateOAuth2Podio::getName(), new UpdateOAuth2Podio()); + $this->addAction(UpdateOAuth2Notion::getName(), new UpdateOAuth2Notion()); + $this->addAction(UpdateOAuth2Salesforce::getName(), new UpdateOAuth2Salesforce()); + $this->addAction(UpdateOAuth2Yahoo::getName(), new UpdateOAuth2Yahoo()); + $this->addAction(UpdateOAuth2Linkedin::getName(), new UpdateOAuth2Linkedin()); + $this->addAction(UpdateOAuth2Disqus::getName(), new UpdateOAuth2Disqus()); + $this->addAction(UpdateOAuth2Amazon::getName(), new UpdateOAuth2Amazon()); + $this->addAction(UpdateOAuth2Etsy::getName(), new UpdateOAuth2Etsy()); + $this->addAction(UpdateOAuth2Facebook::getName(), new UpdateOAuth2Facebook()); + $this->addAction(UpdateOAuth2Tradeshift::getName(), new UpdateOAuth2Tradeshift()); + $this->addAction(UpdateOAuth2TradeshiftSandbox::getName(), new UpdateOAuth2TradeshiftSandbox()); + $this->addAction(UpdateOAuth2Paypal::getName(), new UpdateOAuth2Paypal()); + $this->addAction(UpdateOAuth2PaypalSandbox::getName(), new UpdateOAuth2PaypalSandbox()); + $this->addAction(UpdateOAuth2Gitlab::getName(), new UpdateOAuth2Gitlab()); + $this->addAction(UpdateOAuth2Authentik::getName(), new UpdateOAuth2Authentik()); + $this->addAction(UpdateOAuth2Auth0::getName(), new UpdateOAuth2Auth0()); + $this->addAction(UpdateOAuth2FusionAuth::getName(), new UpdateOAuth2FusionAuth()); + $this->addAction(UpdateOAuth2Keycloak::getName(), new UpdateOAuth2Keycloak()); + $this->addAction(UpdateOAuth2Oidc::getName(), new UpdateOAuth2Oidc()); + $this->addAction(UpdateOAuth2Okta::getName(), new UpdateOAuth2Okta()); + $this->addAction(UpdateOAuth2Kick::getName(), new UpdateOAuth2Kick()); + $this->addAction(UpdateOAuth2Apple::getName(), new UpdateOAuth2Apple()); + $this->addAction(UpdateOAuth2Microsoft::getName(), new UpdateOAuth2Microsoft()); } } diff --git a/src/Appwrite/Platform/Workers/Migrations.php b/src/Appwrite/Platform/Workers/Migrations.php index cfe8d2d567..fa2ed5883f 100644 --- a/src/Appwrite/Platform/Workers/Migrations.php +++ b/src/Appwrite/Platform/Workers/Migrations.php @@ -391,6 +391,8 @@ class Migrations extends Action 'keys.write', 'platforms.read', 'platforms.write', + 'oauth2.read', + 'oauth2.write', 'mocks.read', 'mocks.write', 'policies.read', diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 2521a01973..1d6170b373 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -281,6 +281,47 @@ class Response extends SwooleResponse public const MODEL_VCS = 'vcs'; public const MODEL_EMAIL_TEMPLATE = 'emailTemplate'; public const MODEL_EMAIL_TEMPLATE_LIST = 'emailTemplateList'; + public const MODEL_OAUTH2_GITHUB = 'oAuth2Github'; + public const MODEL_OAUTH2_DISCORD = 'oAuth2Discord'; + public const MODEL_OAUTH2_FIGMA = 'oAuth2Figma'; + public const MODEL_OAUTH2_DROPBOX = 'oAuth2Dropbox'; + public const MODEL_OAUTH2_DAILYMOTION = 'oAuth2Dailymotion'; + public const MODEL_OAUTH2_BITBUCKET = 'oAuth2Bitbucket'; + public const MODEL_OAUTH2_BITLY = 'oAuth2Bitly'; + public const MODEL_OAUTH2_BOX = 'oAuth2Box'; + public const MODEL_OAUTH2_AUTODESK = 'oAuth2Autodesk'; + public const MODEL_OAUTH2_GOOGLE = 'oAuth2Google'; + public const MODEL_OAUTH2_ZOOM = 'oAuth2Zoom'; + public const MODEL_OAUTH2_ZOHO = 'oAuth2Zoho'; + public const MODEL_OAUTH2_YANDEX = 'oAuth2Yandex'; + public const MODEL_OAUTH2_X = 'oAuth2X'; + public const MODEL_OAUTH2_WORDPRESS = 'oAuth2WordPress'; + public const MODEL_OAUTH2_TWITCH = 'oAuth2Twitch'; + public const MODEL_OAUTH2_STRIPE = 'oAuth2Stripe'; + public const MODEL_OAUTH2_SPOTIFY = 'oAuth2Spotify'; + public const MODEL_OAUTH2_SLACK = 'oAuth2Slack'; + public const MODEL_OAUTH2_PODIO = 'oAuth2Podio'; + public const MODEL_OAUTH2_NOTION = 'oAuth2Notion'; + public const MODEL_OAUTH2_SALESFORCE = 'oAuth2Salesforce'; + public const MODEL_OAUTH2_YAHOO = 'oAuth2Yahoo'; + public const MODEL_OAUTH2_LINKEDIN = 'oAuth2Linkedin'; + public const MODEL_OAUTH2_DISQUS = 'oAuth2Disqus'; + public const MODEL_OAUTH2_AMAZON = 'oAuth2Amazon'; + public const MODEL_OAUTH2_ETSY = 'oAuth2Etsy'; + public const MODEL_OAUTH2_FACEBOOK = 'oAuth2Facebook'; + public const MODEL_OAUTH2_TRADESHIFT = 'oAuth2Tradeshift'; + public const MODEL_OAUTH2_PAYPAL = 'oAuth2Paypal'; + public const MODEL_OAUTH2_GITLAB = 'oAuth2Gitlab'; + public const MODEL_OAUTH2_AUTHENTIK = 'oAuth2Authentik'; + public const MODEL_OAUTH2_AUTH0 = 'oAuth2Auth0'; + public const MODEL_OAUTH2_FUSIONAUTH = 'oAuth2FusionAuth'; + public const MODEL_OAUTH2_KEYCLOAK = 'oAuth2Keycloak'; + public const MODEL_OAUTH2_OIDC = 'oAuth2Oidc'; + public const MODEL_OAUTH2_APPLE = 'oAuth2Apple'; + public const MODEL_OAUTH2_OKTA = 'oAuth2Okta'; + public const MODEL_OAUTH2_KICK = 'oAuth2Kick'; + public const MODEL_OAUTH2_MICROSOFT = 'oAuth2Microsoft'; + public const MODEL_OAUTH2_PROVIDER_LIST = 'oAuth2ProviderList'; // Health public const MODEL_HEALTH_STATUS = 'healthStatus'; @@ -293,6 +334,9 @@ class Response extends SwooleResponse // Console public const MODEL_CONSOLE_VARIABLES = 'consoleVariables'; + public const MODEL_CONSOLE_OAUTH2_PROVIDER_PARAMETER = 'consoleOAuth2ProviderParameter'; + public const MODEL_CONSOLE_OAUTH2_PROVIDER = 'consoleOAuth2Provider'; + public const MODEL_CONSOLE_OAUTH2_PROVIDER_LIST = 'consoleOAuth2ProviderList'; // Deprecated public const MODEL_PERMISSIONS = 'permissions'; diff --git a/src/Appwrite/Utopia/Response/Model/AuthProvider.php b/src/Appwrite/Utopia/Response/Model/AuthProvider.php index 2b8f962cd0..034be623e8 100644 --- a/src/Appwrite/Utopia/Response/Model/AuthProvider.php +++ b/src/Appwrite/Utopia/Response/Model/AuthProvider.php @@ -30,9 +30,9 @@ class AuthProvider extends Model ]) ->addRule('secret', [ 'type' => self::TYPE_STRING, - 'description' => 'OAuth 2.0 application secret. Might be JSON string if provider requires extra configuration.', + 'description' => 'OAuth 2.0 application secret. Might be JSON string if provider requires extra configuration. This property is write-only and always returned empty.', 'default' => '', - 'example' => 'Bpw_g9c2TGXxfgLshDbSaL8tsCcqgczQ', + 'example' => '', ]) ->addRule('enabled', [ 'type' => self::TYPE_BOOLEAN, diff --git a/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2Provider.php b/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2Provider.php new file mode 100644 index 0000000000..05969a5e8c --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2Provider.php @@ -0,0 +1,37 @@ +addRule('$id', [ + 'type' => self::TYPE_STRING, + 'description' => 'OAuth2 provider ID.', + 'default' => '', + 'example' => 'github', + ]) + ->addRule('parameters', [ + 'type' => Response::MODEL_CONSOLE_OAUTH2_PROVIDER_PARAMETER, + 'description' => 'List of parameters required to configure this OAuth2 provider.', + 'default' => [], + 'array' => true, + ]) + ; + } + + public function getName(): string + { + return 'Console OAuth2 Provider'; + } + + public function getType(): string + { + return Response::MODEL_CONSOLE_OAUTH2_PROVIDER; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2ProviderList.php b/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2ProviderList.php new file mode 100644 index 0000000000..42d6936d42 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2ProviderList.php @@ -0,0 +1,37 @@ +addRule('total', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of OAuth2 providers exposed by the server.', + 'default' => 0, + 'example' => 5, + ]) + ->addRule('oAuth2Providers', [ + 'type' => Response::MODEL_CONSOLE_OAUTH2_PROVIDER, + 'description' => 'List of OAuth2 providers, each with the parameters required to configure it.', + 'default' => [], + 'array' => true, + ]) + ; + } + + public function getName(): string + { + return 'Console OAuth2 Providers List'; + } + + public function getType(): string + { + return Response::MODEL_CONSOLE_OAUTH2_PROVIDER_LIST; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2ProviderParameter.php b/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2ProviderParameter.php new file mode 100644 index 0000000000..a097718492 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/ConsoleOAuth2ProviderParameter.php @@ -0,0 +1,49 @@ +addRule('$id', [ + 'type' => self::TYPE_STRING, + 'description' => 'Parameter ID. Maps to the request body field used by the project OAuth2 update endpoint (e.g. `clientId`, `appKey`, `tenant`).', + 'default' => '', + 'example' => 'clientId', + ]) + ->addRule('name', [ + 'type' => self::TYPE_STRING, + 'description' => 'Verbose, user-facing parameter name as shown in the provider\'s own dashboard. Includes alternate names when the provider exposes more than one.', + 'default' => '', + 'example' => 'Client ID or App ID', + ]) + ->addRule('example', [ + 'type' => self::TYPE_STRING, + 'description' => 'Example value for this parameter.', + 'default' => '', + 'example' => 'e4d87900000000540733', + ]) + ->addRule('hint', [ + 'type' => self::TYPE_STRING, + 'description' => 'Optional hint for this parameter, typically calling out a common wrong value. Empty string when no hint is set.', + 'default' => '', + 'example' => 'Example of wrong value: 370006', + ]) + ; + } + + public function getName(): string + { + return 'Console OAuth2 Provider Parameter'; + } + + public function getType(): string + { + return Response::MODEL_CONSOLE_OAUTH2_PROVIDER_PARAMETER; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Amazon.php b/src/Appwrite/Utopia/Response/Model/OAuth2Amazon.php new file mode 100644 index 0000000000..f6c935648d --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Amazon.php @@ -0,0 +1,47 @@ + 'amazon', + ]; + + public function getProviderLabel(): string + { + return 'Amazon'; + } + + public function getClientIdExample(): string + { + return 'amzn1.application-oa2-client.87400c00000000000000000000063d5b2'; + } + + public function getClientSecretExample(): string + { + return '79ffe4000000000000000000000000000000000000000000000000000002de55'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Amazon'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_AMAZON; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Apple.php b/src/Appwrite/Utopia/Response/Model/OAuth2Apple.php new file mode 100644 index 0000000000..075494b8ef --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Apple.php @@ -0,0 +1,103 @@ + 'apple', + ]; + + public function getProviderLabel(): string + { + return 'Apple'; + } + + public function getClientIdExample(): string + { + return 'ip.appwrite.app.web'; + } + + public function getClientSecretExample(): string + { + // Unused: this model overrides __construct() to expose keyId, teamId + // and p8File instead of a single clientSecret field. + return ''; + } + + public function getClientIdFieldName(): string + { + return 'serviceId'; + } + + public function getClientIdLabel(): string + { + return 'service ID'; + } + + public function __construct() + { + // Apple's OAuth2 app credential is split into three fields (.p8 file + // contents, Key ID, Team ID) instead of a single clientSecret, so the + // rules are defined manually rather than delegating to OAuth2Base. + $this + ->addRule('$id', [ + 'type' => self::TYPE_STRING, + 'description' => 'OAuth2 provider ID.', + 'default' => '', + 'example' => 'apple', + ]) + ->addRule('enabled', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'OAuth2 provider is active and can be used to create sessions.', + 'default' => false, + 'example' => false, + ]) + ->addRule($this->getClientIdFieldName(), [ + 'type' => self::TYPE_STRING, + 'description' => $this->getClientIdDescription(), + 'default' => '', + 'example' => $this->getClientIdExample(), + ]) + ->addRule('keyId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Apple OAuth2 key ID.', + 'default' => '', + 'example' => 'P4000000N8', + ]) + ->addRule('teamId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Apple OAuth2 team ID.', + 'default' => '', + 'example' => 'D4000000R6', + ]) + ->addRule('p8File', [ + 'type' => self::TYPE_STRING, + 'description' => 'Apple OAuth2 .p8 private key file contents. The secret key wrapped by the PEM markers is 200 characters long.', + 'default' => '', + 'example' => '-----BEGIN PRIVATE KEY-----MIGTAg...jy2Xbna-----END PRIVATE KEY-----', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Apple'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_APPLE; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Auth0.php b/src/Appwrite/Utopia/Response/Model/OAuth2Auth0.php new file mode 100644 index 0000000000..6e83b1b05b --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Auth0.php @@ -0,0 +1,59 @@ + 'auth0', + ]; + + public function getProviderLabel(): string + { + return 'Auth0'; + } + + public function getClientIdExample(): string + { + return 'OaOkIA000000000000000000005KLSYq'; + } + + public function getClientSecretExample(): string + { + return 'zXz0000-00000000000000000000000000000-00000000000000000000PJafnF'; + } + + public function __construct() + { + parent::__construct(); + + $this->addRule('endpoint', [ + 'type' => self::TYPE_STRING, + 'description' => 'Auth0 OAuth2 endpoint domain.', + 'default' => '', + 'example' => 'example.us.auth0.com', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Auth0'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_AUTH0; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Authentik.php b/src/Appwrite/Utopia/Response/Model/OAuth2Authentik.php new file mode 100644 index 0000000000..db192ea24b --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Authentik.php @@ -0,0 +1,59 @@ + 'authentik', + ]; + + public function getProviderLabel(): string + { + return 'Authentik'; + } + + public function getClientIdExample(): string + { + return 'dTKOPa0000000000000000000000000000e7G8hv'; + } + + public function getClientSecretExample(): string + { + return 'ntQadq000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000Hp5WK'; + } + + public function __construct() + { + parent::__construct(); + + $this->addRule('endpoint', [ + 'type' => self::TYPE_STRING, + 'description' => 'Authentik OAuth2 endpoint domain.', + 'default' => '', + 'example' => 'example.authentik.com', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Authentik'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_AUTHENTIK; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Autodesk.php b/src/Appwrite/Utopia/Response/Model/OAuth2Autodesk.php new file mode 100644 index 0000000000..3317f15bec --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Autodesk.php @@ -0,0 +1,47 @@ + 'autodesk', + ]; + + public function getProviderLabel(): string + { + return 'Autodesk'; + } + + public function getClientIdExample(): string + { + return '5zw90v00000000000000000000kVYXN7'; + } + + public function getClientSecretExample(): string + { + return '7I000000000000MW'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Autodesk'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_AUTODESK; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Base.php b/src/Appwrite/Utopia/Response/Model/OAuth2Base.php new file mode 100644 index 0000000000..058afc0fa1 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Base.php @@ -0,0 +1,125 @@ + 'appKey'). + * + * @return string + */ + public function getClientIdFieldName(): string + { + return 'clientId'; + } + + /** + * Public-facing field name of the client secret. Providers may override + * when they use different terminology (e.g. Dropbox -> 'appSecret'). + * + * @return string + */ + public function getClientSecretFieldName(): string + { + return 'clientSecret'; + } + + /** + * Human-readable label for the client ID, used in the generated rule + * description. Providers may override (e.g. Dropbox -> 'app key'). + * + * @return string + */ + public function getClientIdLabel(): string + { + return 'client ID'; + } + + /** + * Human-readable label for the client secret, used in the generated rule + * description. Providers may override (e.g. Dropbox -> 'app secret'). + * + * @return string + */ + public function getClientSecretLabel(): string + { + return 'client secret'; + } + + /** + * Rule description for the client ID. Auto-generated from the provider + * label and client ID label. Providers may override to add extra context. + * + * @return string + */ + public function getClientIdDescription(): string + { + return $this->getProviderLabel() . ' OAuth2 ' . $this->getClientIdLabel() . '.'; + } + + /** + * Rule description for the client secret. Auto-generated from the provider + * label and client secret label. Providers may override to add extra + * context. + * + * @return string + */ + public function getClientSecretDescription(): string + { + return $this->getProviderLabel() . ' OAuth2 ' . $this->getClientSecretLabel() . '.'; + } + + public function __construct() + { + $this + ->addRule('$id', [ + 'type' => self::TYPE_STRING, + 'description' => 'OAuth2 provider ID.', + 'default' => '', + 'example' => 'github', + ]) + ->addRule('enabled', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'OAuth2 provider is active and can be used to create sessions.', + 'default' => false, + 'example' => false, + ]) + ->addRule($this->getClientIdFieldName(), [ + 'type' => self::TYPE_STRING, + 'description' => $this->getClientIdDescription(), + 'default' => '', + 'example' => $this->getClientIdExample(), + ]) + ->addRule($this->getClientSecretFieldName(), [ + 'type' => self::TYPE_STRING, + 'description' => $this->getClientSecretDescription(), + 'default' => '', + 'example' => $this->getClientSecretExample(), + ]); + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Bitbucket.php b/src/Appwrite/Utopia/Response/Model/OAuth2Bitbucket.php new file mode 100644 index 0000000000..870cd0bda3 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Bitbucket.php @@ -0,0 +1,67 @@ + 'bitbucket', + ]; + + public function getProviderLabel(): string + { + return 'Bitbucket'; + } + + public function getClientIdExample(): string + { + return 'Knt70000000000ByRc'; + } + + public function getClientSecretExample(): string + { + return 'NMfLZJ00000000000000000000TLQdDx'; + } + + public function getClientIdFieldName(): string + { + return 'key'; + } + + public function getClientSecretFieldName(): string + { + return 'secret'; + } + + public function getClientIdLabel(): string + { + return 'key'; + } + + public function getClientSecretLabel(): string + { + return 'secret'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Bitbucket'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_BITBUCKET; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Bitly.php b/src/Appwrite/Utopia/Response/Model/OAuth2Bitly.php new file mode 100644 index 0000000000..6a27176d3d --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Bitly.php @@ -0,0 +1,47 @@ + 'bitly', + ]; + + public function getProviderLabel(): string + { + return 'Bitly'; + } + + public function getClientIdExample(): string + { + return 'd95151000000000000000000000000000067af9b'; + } + + public function getClientSecretExample(): string + { + return 'a13e250000000000000000000000000000d73095'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Bitly'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_BITLY; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Box.php b/src/Appwrite/Utopia/Response/Model/OAuth2Box.php new file mode 100644 index 0000000000..9bbfd6021f --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Box.php @@ -0,0 +1,47 @@ + 'box', + ]; + + public function getProviderLabel(): string + { + return 'Box'; + } + + public function getClientIdExample(): string + { + return 'deglcs00000000000000000000x2og6y'; + } + + public function getClientSecretExample(): string + { + return 'OKM1f100000000000000000000eshEif'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Box'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_BOX; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Dailymotion.php b/src/Appwrite/Utopia/Response/Model/OAuth2Dailymotion.php new file mode 100644 index 0000000000..6c3d0eba95 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Dailymotion.php @@ -0,0 +1,67 @@ + 'dailymotion', + ]; + + public function getProviderLabel(): string + { + return 'Dailymotion'; + } + + public function getClientIdExample(): string + { + return '07a9000000000000067f'; + } + + public function getClientSecretExample(): string + { + return 'a399a90000000000000000000000000000d90639'; + } + + public function getClientIdFieldName(): string + { + return 'apiKey'; + } + + public function getClientSecretFieldName(): string + { + return 'apiSecret'; + } + + public function getClientIdLabel(): string + { + return 'API key'; + } + + public function getClientSecretLabel(): string + { + return 'API secret'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Dailymotion'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_DAILYMOTION; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Discord.php b/src/Appwrite/Utopia/Response/Model/OAuth2Discord.php new file mode 100644 index 0000000000..6ac72ad8e4 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Discord.php @@ -0,0 +1,47 @@ + 'discord', + ]; + + public function getProviderLabel(): string + { + return 'Discord'; + } + + public function getClientIdExample(): string + { + return '950722000000343754'; + } + + public function getClientSecretExample(): string + { + return 'YmPXnM000000000000000000002zFg5D'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Discord'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_DISCORD; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Disqus.php b/src/Appwrite/Utopia/Response/Model/OAuth2Disqus.php new file mode 100644 index 0000000000..bec78ed189 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Disqus.php @@ -0,0 +1,67 @@ + 'disqus', + ]; + + public function getProviderLabel(): string + { + return 'Disqus'; + } + + public function getClientIdExample(): string + { + return 'cgegH70000000000000000000000000000000000000000000000000000Hr1nYX'; + } + + public function getClientSecretExample(): string + { + return 'W7Bykj00000000000000000000000000000000000000000000000000003o43w9'; + } + + public function getClientIdFieldName(): string + { + return 'publicKey'; + } + + public function getClientSecretFieldName(): string + { + return 'secretKey'; + } + + public function getClientIdLabel(): string + { + return 'public key'; + } + + public function getClientSecretLabel(): string + { + return 'secret key'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Disqus'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_DISQUS; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Dropbox.php b/src/Appwrite/Utopia/Response/Model/OAuth2Dropbox.php new file mode 100644 index 0000000000..db7285fd47 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Dropbox.php @@ -0,0 +1,67 @@ + 'dropbox', + ]; + + public function getProviderLabel(): string + { + return 'Dropbox'; + } + + public function getClientIdExample(): string + { + return 'jl000000000009t'; + } + + public function getClientSecretExample(): string + { + return 'g200000000000vw'; + } + + public function getClientIdFieldName(): string + { + return 'appKey'; + } + + public function getClientSecretFieldName(): string + { + return 'appSecret'; + } + + public function getClientIdLabel(): string + { + return 'app key'; + } + + public function getClientSecretLabel(): string + { + return 'app secret'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Dropbox'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_DROPBOX; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Etsy.php b/src/Appwrite/Utopia/Response/Model/OAuth2Etsy.php new file mode 100644 index 0000000000..be12e4c51c --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Etsy.php @@ -0,0 +1,67 @@ + 'etsy', + ]; + + public function getProviderLabel(): string + { + return 'Etsy'; + } + + public function getClientIdExample(): string + { + return 'nsgzxh0000000000008j85a2'; + } + + public function getClientSecretExample(): string + { + return 'tp000000ru'; + } + + public function getClientIdFieldName(): string + { + return 'keyString'; + } + + public function getClientSecretFieldName(): string + { + return 'sharedSecret'; + } + + public function getClientIdLabel(): string + { + return 'keystring'; + } + + public function getClientSecretLabel(): string + { + return 'shared secret'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Etsy'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_ETSY; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Facebook.php b/src/Appwrite/Utopia/Response/Model/OAuth2Facebook.php new file mode 100644 index 0000000000..9ad14bdb2a --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Facebook.php @@ -0,0 +1,67 @@ + 'facebook', + ]; + + public function getProviderLabel(): string + { + return 'Facebook'; + } + + public function getClientIdExample(): string + { + return '260600000007694'; + } + + public function getClientSecretExample(): string + { + return '2d0b2800000000000000000000d38af4'; + } + + public function getClientIdFieldName(): string + { + return 'appId'; + } + + public function getClientSecretFieldName(): string + { + return 'appSecret'; + } + + public function getClientIdLabel(): string + { + return 'app ID'; + } + + public function getClientSecretLabel(): string + { + return 'app secret'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Facebook'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_FACEBOOK; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Figma.php b/src/Appwrite/Utopia/Response/Model/OAuth2Figma.php new file mode 100644 index 0000000000..9339257e5b --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Figma.php @@ -0,0 +1,47 @@ + 'figma', + ]; + + public function getProviderLabel(): string + { + return 'Figma'; + } + + public function getClientIdExample(): string + { + return 'byay5H0000000000VtiI40'; + } + + public function getClientSecretExample(): string + { + return 'yEpOYn0000000000000000004iIsU5'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Figma'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_FIGMA; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2FusionAuth.php b/src/Appwrite/Utopia/Response/Model/OAuth2FusionAuth.php new file mode 100644 index 0000000000..8dbe3c76f0 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2FusionAuth.php @@ -0,0 +1,59 @@ + 'fusionauth', + ]; + + public function getProviderLabel(): string + { + return 'FusionAuth'; + } + + public function getClientIdExample(): string + { + return 'b2222c00-0000-0000-0000-000000862097'; + } + + public function getClientSecretExample(): string + { + return 'Jx4s0C0000000000000000000000000000000wGqLsc'; + } + + public function __construct() + { + parent::__construct(); + + $this->addRule('endpoint', [ + 'type' => self::TYPE_STRING, + 'description' => 'FusionAuth OAuth2 endpoint domain.', + 'default' => '', + 'example' => 'example.fusionauth.io', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2FusionAuth'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_FUSIONAUTH; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2GitHub.php b/src/Appwrite/Utopia/Response/Model/OAuth2GitHub.php new file mode 100644 index 0000000000..2f975f16e4 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2GitHub.php @@ -0,0 +1,52 @@ + 'github', + ]; + + public function getProviderLabel(): string + { + return 'GitHub'; + } + + public function getClientIdExample(): string + { + return 'e4d87900000000540733'; + } + + public function getClientSecretExample(): string + { + return '5e07c00000000000000000000000000000198bcc'; + } + + public function getClientIdDescription(): string + { + return parent::getClientIdDescription() . ' For GitHub Apps, use the "App ID" when both an App ID and client ID are available.'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2GitHub'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_GITHUB; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Gitlab.php b/src/Appwrite/Utopia/Response/Model/OAuth2Gitlab.php new file mode 100644 index 0000000000..39c148caec --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Gitlab.php @@ -0,0 +1,79 @@ + 'gitlab', + ]; + + public function getProviderLabel(): string + { + return 'GitLab'; + } + + public function getClientIdExample(): string + { + return 'd41ffe0000000000000000000000000000000000000000000000000000d5e252'; + } + + public function getClientSecretExample(): string + { + return 'gloas-838cfa0000000000000000000000000000000000000000000000000000ecbb38'; + } + + public function getClientIdFieldName(): string + { + return 'applicationId'; + } + + public function getClientSecretFieldName(): string + { + return 'secret'; + } + + public function getClientIdLabel(): string + { + return 'application ID'; + } + + public function getClientSecretLabel(): string + { + return 'secret'; + } + + public function __construct() + { + parent::__construct(); + + $this->addRule('endpoint', [ + 'type' => self::TYPE_STRING, + 'description' => 'GitLab OAuth2 endpoint URL. Defaults to https://gitlab.com for self-hosted instances.', + 'default' => '', + 'example' => 'https://gitlab.com', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Gitlab'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_GITLAB; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Google.php b/src/Appwrite/Utopia/Response/Model/OAuth2Google.php new file mode 100644 index 0000000000..3dbc892631 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Google.php @@ -0,0 +1,47 @@ + 'google', + ]; + + public function getProviderLabel(): string + { + return 'Google'; + } + + public function getClientIdExample(): string + { + return '120000000095-92ifjb00000000000000000000g7ijfb.apps.googleusercontent.com'; + } + + public function getClientSecretExample(): string + { + return 'GOCSPX-2k8gsR0000000000000000VNahJj'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Google'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_GOOGLE; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Keycloak.php b/src/Appwrite/Utopia/Response/Model/OAuth2Keycloak.php new file mode 100644 index 0000000000..063f7d2a5c --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Keycloak.php @@ -0,0 +1,66 @@ + 'keycloak', + ]; + + public function getProviderLabel(): string + { + return 'Keycloak'; + } + + public function getClientIdExample(): string + { + return 'appwrite-o0000000st-app'; + } + + public function getClientSecretExample(): string + { + return 'jdjrJd00000000000000000000HUsaZO'; + } + + public function __construct() + { + parent::__construct(); + + $this->addRule('endpoint', [ + 'type' => self::TYPE_STRING, + 'description' => 'Keycloak OAuth2 endpoint domain.', + 'default' => '', + 'example' => 'keycloak.example.com', + ]); + + $this->addRule('realmName', [ + 'type' => self::TYPE_STRING, + 'description' => 'Keycloak OAuth2 realm name.', + 'default' => '', + 'example' => 'appwrite-realm', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Keycloak'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_KEYCLOAK; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Kick.php b/src/Appwrite/Utopia/Response/Model/OAuth2Kick.php new file mode 100644 index 0000000000..2f5814f1d3 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Kick.php @@ -0,0 +1,47 @@ + 'kick', + ]; + + public function getProviderLabel(): string + { + return 'Kick'; + } + + public function getClientIdExample(): string + { + return '01KQ7C00000000000001MFHS32'; + } + + public function getClientSecretExample(): string + { + return '34ac5600000000000000000000000000000000000000000000000000e830c8b'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Kick'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_KICK; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Linkedin.php b/src/Appwrite/Utopia/Response/Model/OAuth2Linkedin.php new file mode 100644 index 0000000000..012aa85735 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Linkedin.php @@ -0,0 +1,57 @@ + 'linkedin', + ]; + + public function getProviderLabel(): string + { + return 'LinkedIn'; + } + + public function getClientIdExample(): string + { + return '770000000000dv'; + } + + public function getClientSecretExample(): string + { + return 'WPL_AP1.2Bf0000000000000./HtlYw=='; + } + + public function getClientSecretFieldName(): string + { + return 'primaryClientSecret'; + } + + public function getClientSecretLabel(): string + { + return 'primary client secret'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Linkedin'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_LINKEDIN; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Microsoft.php b/src/Appwrite/Utopia/Response/Model/OAuth2Microsoft.php new file mode 100644 index 0000000000..b7004fdb85 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Microsoft.php @@ -0,0 +1,79 @@ + 'microsoft', + ]; + + public function getProviderLabel(): string + { + return 'Microsoft'; + } + + public function getClientIdExample(): string + { + return '00001111-aaaa-2222-bbbb-3333cccc4444'; + } + + public function getClientSecretExample(): string + { + return 'A1bC2dE3fH4iJ5kL6mN7oP8qR9sT0u'; + } + + public function getClientIdFieldName(): string + { + return 'applicationId'; + } + + public function getClientSecretFieldName(): string + { + return 'applicationSecret'; + } + + public function getClientIdLabel(): string + { + return 'application ID'; + } + + public function getClientSecretLabel(): string + { + return 'application secret'; + } + + public function __construct() + { + parent::__construct(); + + $this->addRule('tenant', [ + 'type' => self::TYPE_STRING, + 'description' => 'Microsoft Entra ID tenant identifier. Use \'common\', \'organizations\', \'consumers\' or a specific tenant ID.', + 'default' => '', + 'example' => 'common', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Microsoft'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_MICROSOFT; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Notion.php b/src/Appwrite/Utopia/Response/Model/OAuth2Notion.php new file mode 100644 index 0000000000..8796ce603e --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Notion.php @@ -0,0 +1,57 @@ + 'notion', + ]; + + public function getProviderLabel(): string + { + return 'Notion'; + } + + public function getClientIdExample(): string + { + return '341d8700-0000-0000-0000-000000446ee3'; + } + + public function getClientSecretExample(): string + { + return 'secret_dLUr4b000000000000000000000000000000lFHAa9'; + } + + public function getClientIdFieldName(): string + { + return 'oauthClientId'; + } + + public function getClientSecretFieldName(): string + { + return 'oauthClientSecret'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Notion'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_NOTION; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Oidc.php b/src/Appwrite/Utopia/Response/Model/OAuth2Oidc.php new file mode 100644 index 0000000000..e4f0919666 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Oidc.php @@ -0,0 +1,78 @@ + 'oidc', + ]; + + public function getProviderLabel(): string + { + return 'OpenID Connect'; + } + + public function getClientIdExample(): string + { + return 'qibI2x0000000000000000000000000006L2YFoG'; + } + + public function getClientSecretExample(): string + { + return 'Ah68ed000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003qpcHV'; + } + + public function __construct() + { + parent::__construct(); + + $this + ->addRule('wellKnownURL', [ + 'type' => self::TYPE_STRING, + 'description' => 'OpenID Connect well-known configuration URL. When set, authorization, token, and user info endpoints can be discovered automatically.', + 'default' => '', + 'example' => 'https://myoauth.com/.well-known/openid-configuration', + ]) + ->addRule('authorizationURL', [ + 'type' => self::TYPE_STRING, + 'description' => 'OpenID Connect authorization endpoint URL.', + 'default' => '', + 'example' => 'https://myoauth.com/oauth2/authorize', + ]) + ->addRule('tokenUrl', [ + 'type' => self::TYPE_STRING, + 'description' => 'OpenID Connect token endpoint URL.', + 'default' => '', + 'example' => 'https://myoauth.com/oauth2/token', + ]) + ->addRule('userInfoUrl', [ + 'type' => self::TYPE_STRING, + 'description' => 'OpenID Connect user info endpoint URL.', + 'default' => '', + 'example' => 'https://myoauth.com/oauth2/userinfo', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Oidc'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_OIDC; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Okta.php b/src/Appwrite/Utopia/Response/Model/OAuth2Okta.php new file mode 100644 index 0000000000..0804adfa1b --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Okta.php @@ -0,0 +1,66 @@ + 'okta', + ]; + + public function getProviderLabel(): string + { + return 'Okta'; + } + + public function getClientIdExample(): string + { + return '0oa00000000000000698'; + } + + public function getClientSecretExample(): string + { + return 'Kiq0000000000000000000000000000000000000-00000000000H2L5-3SJ-vRV'; + } + + public function __construct() + { + parent::__construct(); + + $this->addRule('domain', [ + 'type' => self::TYPE_STRING, + 'description' => 'Okta OAuth2 domain.', + 'default' => '', + 'example' => 'trial-6400025.okta.com', + ]); + + $this->addRule('authorizationServerId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Okta OAuth2 authorization server ID.', + 'default' => '', + 'example' => 'aus000000000000000h7z', + ]); + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Okta'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_OKTA; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Paypal.php b/src/Appwrite/Utopia/Response/Model/OAuth2Paypal.php new file mode 100644 index 0000000000..20ff9f9ba5 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Paypal.php @@ -0,0 +1,57 @@ + ['paypal', 'paypalSandbox'], + ]; + + public function getProviderLabel(): string + { + return 'PayPal'; + } + + public function getClientIdExample(): string + { + return 'AdhIEG7-000000000000-0000000000000000000000000000000-0000000000000000000000-2pyB'; + } + + public function getClientSecretExample(): string + { + return 'EH8KCXtew--000000000000000000000000000000000000000_C-1_5UP_000000000000000CB7KDp'; + } + + public function getClientSecretFieldName(): string + { + return 'secretKey'; + } + + public function getClientSecretLabel(): string + { + return 'secret key'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Paypal'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_PAYPAL; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Podio.php b/src/Appwrite/Utopia/Response/Model/OAuth2Podio.php new file mode 100644 index 0000000000..f588136a62 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Podio.php @@ -0,0 +1,47 @@ + 'podio', + ]; + + public function getProviderLabel(): string + { + return 'Podio'; + } + + public function getClientIdExample(): string + { + return 'appwrite-oauth-test-app'; + } + + public function getClientSecretExample(): string + { + return 'Rn247T0000000000000000000000000000000000000000000000000000W2zWTN'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Podio'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_PODIO; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2ProviderList.php b/src/Appwrite/Utopia/Response/Model/OAuth2ProviderList.php new file mode 100644 index 0000000000..81c23c803c --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2ProviderList.php @@ -0,0 +1,78 @@ +addRule('total', [ + 'type' => self::TYPE_INTEGER, + 'description' => 'Total number of OAuth2 providers in the given project.', + 'default' => 0, + 'example' => 5, + ]) + ->addRule('providers', [ + 'type' => [ + Response::MODEL_OAUTH2_GITHUB, + Response::MODEL_OAUTH2_DISCORD, + Response::MODEL_OAUTH2_FIGMA, + Response::MODEL_OAUTH2_DROPBOX, + Response::MODEL_OAUTH2_DAILYMOTION, + Response::MODEL_OAUTH2_BITBUCKET, + Response::MODEL_OAUTH2_BITLY, + Response::MODEL_OAUTH2_BOX, + Response::MODEL_OAUTH2_AUTODESK, + Response::MODEL_OAUTH2_GOOGLE, + Response::MODEL_OAUTH2_ZOOM, + Response::MODEL_OAUTH2_ZOHO, + Response::MODEL_OAUTH2_YANDEX, + Response::MODEL_OAUTH2_X, + Response::MODEL_OAUTH2_WORDPRESS, + Response::MODEL_OAUTH2_TWITCH, + Response::MODEL_OAUTH2_STRIPE, + Response::MODEL_OAUTH2_SPOTIFY, + Response::MODEL_OAUTH2_SLACK, + Response::MODEL_OAUTH2_PODIO, + Response::MODEL_OAUTH2_NOTION, + Response::MODEL_OAUTH2_SALESFORCE, + Response::MODEL_OAUTH2_YAHOO, + Response::MODEL_OAUTH2_LINKEDIN, + Response::MODEL_OAUTH2_DISQUS, + Response::MODEL_OAUTH2_AMAZON, + Response::MODEL_OAUTH2_ETSY, + Response::MODEL_OAUTH2_FACEBOOK, + Response::MODEL_OAUTH2_TRADESHIFT, + Response::MODEL_OAUTH2_PAYPAL, + Response::MODEL_OAUTH2_GITLAB, + Response::MODEL_OAUTH2_AUTHENTIK, + Response::MODEL_OAUTH2_AUTH0, + Response::MODEL_OAUTH2_FUSIONAUTH, + Response::MODEL_OAUTH2_KEYCLOAK, + Response::MODEL_OAUTH2_OIDC, + Response::MODEL_OAUTH2_APPLE, + Response::MODEL_OAUTH2_OKTA, + Response::MODEL_OAUTH2_KICK, + Response::MODEL_OAUTH2_MICROSOFT, + ], + 'description' => 'List of OAuth2 providers.', + 'default' => [], + 'array' => true, + ]) + ; + } + + public function getName(): string + { + return 'OAuth2 Providers List'; + } + + public function getType(): string + { + return Response::MODEL_OAUTH2_PROVIDER_LIST; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Salesforce.php b/src/Appwrite/Utopia/Response/Model/OAuth2Salesforce.php new file mode 100644 index 0000000000..c76ddce854 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Salesforce.php @@ -0,0 +1,67 @@ + 'salesforce', + ]; + + public function getProviderLabel(): string + { + return 'Salesforce'; + } + + public function getClientIdExample(): string + { + return '3MVG9I0000000000000000000000000000000000000000000000000000000000000000000000000C5Aejq'; + } + + public function getClientSecretExample(): string + { + return '3w000000000000e2'; + } + + public function getClientIdFieldName(): string + { + return 'customerKey'; + } + + public function getClientSecretFieldName(): string + { + return 'customerSecret'; + } + + public function getClientIdLabel(): string + { + return 'consumer key'; + } + + public function getClientSecretLabel(): string + { + return 'consumer secret'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Salesforce'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_SALESFORCE; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Slack.php b/src/Appwrite/Utopia/Response/Model/OAuth2Slack.php new file mode 100644 index 0000000000..47eb058816 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Slack.php @@ -0,0 +1,47 @@ + 'slack', + ]; + + public function getProviderLabel(): string + { + return 'Slack'; + } + + public function getClientIdExample(): string + { + return '23000000089.15000000000023'; + } + + public function getClientSecretExample(): string + { + return '81656000000000000000000000f3d2fd'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Slack'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_SLACK; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Spotify.php b/src/Appwrite/Utopia/Response/Model/OAuth2Spotify.php new file mode 100644 index 0000000000..3fdf9da659 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Spotify.php @@ -0,0 +1,47 @@ + 'spotify', + ]; + + public function getProviderLabel(): string + { + return 'Spotify'; + } + + public function getClientIdExample(): string + { + return '6ec271000000000000000000009beace'; + } + + public function getClientSecretExample(): string + { + return 'db068a000000000000000000008b5b9f'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Spotify'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_SPOTIFY; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Stripe.php b/src/Appwrite/Utopia/Response/Model/OAuth2Stripe.php new file mode 100644 index 0000000000..98c7a88af7 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Stripe.php @@ -0,0 +1,57 @@ + 'stripe', + ]; + + public function getProviderLabel(): string + { + return 'Stripe'; + } + + public function getClientIdExample(): string + { + return 'ca_UKibXX0000000000000000000006byvR'; + } + + public function getClientSecretExample(): string + { + return 'sk_51SfOd000000000000000000000000000000000000000000000000000000000000000000000000000000000000000QGWYfp'; + } + + public function getClientSecretFieldName(): string + { + return 'apiSecretKey'; + } + + public function getClientSecretLabel(): string + { + return 'API secret key'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Stripe'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_STRIPE; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Tradeshift.php b/src/Appwrite/Utopia/Response/Model/OAuth2Tradeshift.php new file mode 100644 index 0000000000..4d2c37a951 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Tradeshift.php @@ -0,0 +1,57 @@ + ['tradeshift', 'tradeshiftBox'], + ]; + + public function getProviderLabel(): string + { + return 'Tradeshift'; + } + + public function getClientIdExample(): string + { + return 'appwrite-test-org.appwrite-test-app'; + } + + public function getClientSecretExample(): string + { + return '7cb52700-0000-0000-0000-000000ca5b83'; + } + + public function getClientIdFieldName(): string + { + return 'oauth2ClientId'; + } + + public function getClientSecretFieldName(): string + { + return 'oauth2ClientSecret'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Tradeshift'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_TRADESHIFT; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Twitch.php b/src/Appwrite/Utopia/Response/Model/OAuth2Twitch.php new file mode 100644 index 0000000000..4b03b3d6cc --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Twitch.php @@ -0,0 +1,47 @@ + 'twitch', + ]; + + public function getProviderLabel(): string + { + return 'Twitch'; + } + + public function getClientIdExample(): string + { + return 'vvi0in000000000000000000ikmt9p'; + } + + public function getClientSecretExample(): string + { + return 'pmapue000000000000000000zylw3v'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Twitch'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_TWITCH; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2WordPress.php b/src/Appwrite/Utopia/Response/Model/OAuth2WordPress.php new file mode 100644 index 0000000000..89df7a081e --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2WordPress.php @@ -0,0 +1,47 @@ + 'wordpress', + ]; + + public function getProviderLabel(): string + { + return 'WordPress'; + } + + public function getClientIdExample(): string + { + return '130005'; + } + + public function getClientSecretExample(): string + { + return 'PlBfJS0000000000000000000000000000000000000000000000000000EdUZJk'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2WordPress'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_WORDPRESS; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2X.php b/src/Appwrite/Utopia/Response/Model/OAuth2X.php new file mode 100644 index 0000000000..2f36166c19 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2X.php @@ -0,0 +1,67 @@ + 'x', + ]; + + public function getProviderLabel(): string + { + return 'X'; + } + + public function getClientIdExample(): string + { + return 'slzZV0000000000000NFLaWT'; + } + + public function getClientSecretExample(): string + { + return 'tkEPkp00000000000000000000000000000000000000FTxbI9'; + } + + public function getClientIdFieldName(): string + { + return 'customerKey'; + } + + public function getClientSecretFieldName(): string + { + return 'secretKey'; + } + + public function getClientIdLabel(): string + { + return 'customer key'; + } + + public function getClientSecretLabel(): string + { + return 'secret key'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2X'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_X; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Yahoo.php b/src/Appwrite/Utopia/Response/Model/OAuth2Yahoo.php new file mode 100644 index 0000000000..0e3bc7b8a6 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Yahoo.php @@ -0,0 +1,47 @@ + 'yahoo', + ]; + + public function getProviderLabel(): string + { + return 'Yahoo'; + } + + public function getClientIdExample(): string + { + return 'dj0yJm000000000000000000000000000000000000000000000000000000000000000000000000000000000000Z4PWRm'; + } + + public function getClientSecretExample(): string + { + return 'cf978f0000000000000000000000000000c5e2e9'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Yahoo'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_YAHOO; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Yandex.php b/src/Appwrite/Utopia/Response/Model/OAuth2Yandex.php new file mode 100644 index 0000000000..dd6b8a4486 --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Yandex.php @@ -0,0 +1,47 @@ + 'yandex', + ]; + + public function getProviderLabel(): string + { + return 'Yandex'; + } + + public function getClientIdExample(): string + { + return '6a8a6a0000000000000000000091483c'; + } + + public function getClientSecretExample(): string + { + return 'bbf98500000000000000000000c75a63'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Yandex'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_YANDEX; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Zoho.php b/src/Appwrite/Utopia/Response/Model/OAuth2Zoho.php new file mode 100644 index 0000000000..abf9e98d9a --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Zoho.php @@ -0,0 +1,47 @@ + 'zoho', + ]; + + public function getProviderLabel(): string + { + return 'Zoho'; + } + + public function getClientIdExample(): string + { + return '1000.83C178000000000000000000RPNX0B'; + } + + public function getClientSecretExample(): string + { + return 'fb5cac000000000000000000000000000000a68f6e'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Zoho'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_ZOHO; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/OAuth2Zoom.php b/src/Appwrite/Utopia/Response/Model/OAuth2Zoom.php new file mode 100644 index 0000000000..d14fe6d0cf --- /dev/null +++ b/src/Appwrite/Utopia/Response/Model/OAuth2Zoom.php @@ -0,0 +1,47 @@ + 'zoom', + ]; + + public function getProviderLabel(): string + { + return 'Zoom'; + } + + public function getClientIdExample(): string + { + return 'QMAC00000000000000w0AQ'; + } + + public function getClientSecretExample(): string + { + return 'GAWsG4000000000000000000007U01ON'; + } + + /** + * Get Name + * + * @return string + */ + public function getName(): string + { + return 'OAuth2Zoom'; + } + + /** + * Get Type + * + * @return string + */ + public function getType(): string + { + return Response::MODEL_OAUTH2_ZOOM; + } +} diff --git a/src/Appwrite/Utopia/Response/Model/Project.php b/src/Appwrite/Utopia/Response/Model/Project.php index 97b58d8a51..36be3b751f 100644 --- a/src/Appwrite/Utopia/Response/Model/Project.php +++ b/src/Appwrite/Utopia/Response/Model/Project.php @@ -525,7 +525,7 @@ class Project extends Model 'key' => $key, 'name' => $provider['name'] ?? '', 'appId' => $providerValues[$key . 'Appid'] ?? '', - 'secret' => $providerValues[$key . 'Secret'] ?? '', + 'secret' => '', // Write-only: never expose the stored value 'enabled' => $providerValues[$key . 'Enabled'] ?? false, ]); } diff --git a/tests/benchmarks/http.js b/tests/benchmarks/http.js index 4009024069..6466ffd361 100644 --- a/tests/benchmarks/http.js +++ b/tests/benchmarks/http.js @@ -90,6 +90,8 @@ const API_SCOPES = [ 'tokens.write', 'platforms.read', 'platforms.write', + 'oauth2.read', + 'oauth2.write', ]; const BASE_PERMISSIONS = [ diff --git a/tests/e2e/Scopes/ProjectCustom.php b/tests/e2e/Scopes/ProjectCustom.php index f531ed774d..31d85524af 100644 --- a/tests/e2e/Scopes/ProjectCustom.php +++ b/tests/e2e/Scopes/ProjectCustom.php @@ -169,6 +169,8 @@ trait ProjectCustom 'keys.write', 'platforms.read', 'platforms.write', + 'oauth2.read', + 'oauth2.write', 'mocks.read', 'mocks.write', 'policies.read', diff --git a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php index 373383e3ec..3b3232cda3 100644 --- a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php +++ b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php @@ -41,4 +41,91 @@ class ConsoleConsoleClientTest extends Scope $this->assertIsString($response['body']['_APP_DB_ADAPTER']); // When adding new keys, dont forget to update count a few lines above } + + public function testListOAuth2Providers(): void + { + $response = $this->client->call(Client::METHOD_GET, '/console/oauth2-providers', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders())); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsInt($response['body']['total']); + $this->assertIsArray($response['body']['oAuth2Providers']); + $this->assertGreaterThan(0, $response['body']['total']); + $this->assertEquals($response['body']['total'], \count($response['body']['oAuth2Providers'])); + + $providerIds = \array_column($response['body']['oAuth2Providers'], '$id'); + + // Well-known providers must be present + $this->assertContains('github', $providerIds); + $this->assertContains('google', $providerIds); + + // Mock providers must be excluded + $this->assertNotContains('mock', $providerIds); + $this->assertNotContains('mock-unverified', $providerIds); + + // Every provider has the expected shape + foreach ($response['body']['oAuth2Providers'] as $provider) { + $this->assertArrayHasKey('$id', $provider); + $this->assertIsString($provider['$id']); + $this->assertArrayHasKey('parameters', $provider); + $this->assertIsArray($provider['parameters']); + $this->assertGreaterThan(0, \count($provider['parameters'])); + + foreach ($provider['parameters'] as $parameter) { + $this->assertArrayHasKey('$id', $parameter); + $this->assertIsString($parameter['$id']); + $this->assertNotEmpty($parameter['$id']); + $this->assertArrayHasKey('name', $parameter); + $this->assertIsString($parameter['name']); + $this->assertNotEmpty($parameter['name']); + $this->assertArrayHasKey('example', $parameter); + $this->assertIsString($parameter['example']); + $this->assertArrayHasKey('hint', $parameter); + $this->assertIsString($parameter['hint']); + } + } + + // GitHub provider has the expected metadata for clientId, including the hint + $github = null; + foreach ($response['body']['oAuth2Providers'] as $provider) { + if ($provider['$id'] === 'github') { + $github = $provider; + break; + } + } + $this->assertNotNull($github); + $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('e4d87900000000540733', $clientId['example']); + $this->assertEquals('Example of wrong value: 370006', $clientId['hint']); + $clientSecret = $github['parameters'][1]; + $this->assertEquals('clientSecret', $clientSecret['$id']); + $this->assertEquals('Client Secret', $clientSecret['name']); + $this->assertNotEmpty($clientSecret['example']); + $this->assertEquals('', $clientSecret['hint']); + + // Multi-parameter provider (Apple) exposes its non-clientSecret fields + $apple = null; + foreach ($response['body']['oAuth2Providers'] as $provider) { + if ($provider['$id'] === 'apple') { + $apple = $provider; + break; + } + } + $this->assertNotNull($apple); + $appleParamIds = \array_column($apple['parameters'], '$id'); + $this->assertContains('serviceId', $appleParamIds); + $this->assertContains('keyId', $appleParamIds); + $this->assertContains('teamId', $appleParamIds); + $this->assertContains('p8File', $appleParamIds); + // Apple does not expose a single clientSecret param + $this->assertNotContains('clientSecret', $appleParamIds); + + // Sandbox providers (e.g. paypalSandbox) are included + $this->assertContains('paypalSandbox', $providerIds); + } } diff --git a/tests/e2e/Services/Console/ConsoleCustomServerTest.php b/tests/e2e/Services/Console/ConsoleCustomServerTest.php index 3748bbe546..d3c64ae039 100644 --- a/tests/e2e/Services/Console/ConsoleCustomServerTest.php +++ b/tests/e2e/Services/Console/ConsoleCustomServerTest.php @@ -24,4 +24,23 @@ class ConsoleCustomServerTest extends Scope $this->assertEquals(401, $response['headers']['status-code']); } + + public function testListOAuth2Providers(): void + { + // Public endpoint: must succeed without admin authentication. Drop the + // headers from getHeaders() and only pass project + content-type. + $response = $this->client->call(Client::METHOD_GET, '/console/oauth2-providers', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsInt($response['body']['total']); + $this->assertIsArray($response['body']['oAuth2Providers']); + $this->assertGreaterThan(0, $response['body']['total']); + + $providerIds = \array_column($response['body']['oAuth2Providers'], '$id'); + $this->assertContains('github', $providerIds); + $this->assertNotContains('mock', $providerIds); + } } diff --git a/tests/e2e/Services/Project/OAuth2Base.php b/tests/e2e/Services/Project/OAuth2Base.php new file mode 100644 index 0000000000..8345bfab0a --- /dev/null +++ b/tests/e2e/Services/Project/OAuth2Base.php @@ -0,0 +1,2597 @@ +updateOAuth2('amazon', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + + $this->assertSame( + 200, + $response['headers']['status-code'], + 'OAuth2 reset failed — downstream tests will be unreliable. Body: ' . \json_encode($response['body'] ?? null), + ); + } + + // ========================================================================= + // List OAuth2 providers + // ========================================================================= + + public function testListOAuth2Providers(): void + { + $response = $this->listOAuth2Providers(); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('total', $response['body']); + $this->assertArrayHasKey('providers', $response['body']); + $this->assertGreaterThan(0, $response['body']['total']); + $this->assertSame($response['body']['total'], \count($response['body']['providers'])); + } + + public function testListOAuth2ProvidersIncludesKnownProviders(): void + { + $response = $this->listOAuth2Providers(); + + $this->assertSame(200, $response['headers']['status-code']); + + $ids = \array_column($response['body']['providers'], '$id'); + + // Spot-check a representative cross-section of providers across all + // provider shapes (plain, multi-field, sandboxed, custom param names). + $expected = [ + 'github', + 'amazon', + 'apple', + 'auth0', + 'authentik', + 'fusionauth', + 'gitlab', + 'keycloak', + 'oidc', + 'okta', + 'microsoft', + 'dropbox', + 'paypalSandbox', + 'kick', + ]; + + foreach ($expected as $providerId) { + $this->assertContains($providerId, $ids, "Missing provider {$providerId} in listOAuth2Providers response"); + } + } + + /** + * Pin the exact set of registered providers — adding or removing a + * provider must be a deliberate change to this assertion. Catches + * registration drift (e.g. forgetting to wire a new provider into + * `Base::getProviderActions()`). + */ + public function testListOAuth2ProvidersExposesEntireRegistry(): void + { + $response = $this->listOAuth2Providers(); + $this->assertSame(200, $response['headers']['status-code']); + + $ids = \array_column($response['body']['providers'], '$id'); + \sort($ids); + + $expected = [ + 'amazon', 'apple', 'auth0', 'authentik', 'autodesk', 'bitbucket', + 'bitly', 'box', 'dailymotion', 'discord', 'disqus', 'dropbox', + 'etsy', 'facebook', 'figma', 'fusionauth', 'github', 'gitlab', + 'google', 'keycloak', 'kick', 'linkedin', 'microsoft', 'notion', + 'oidc', 'okta', 'paypal', 'paypalSandbox', 'podio', 'salesforce', + 'slack', 'spotify', 'stripe', 'tradeshift', 'tradeshiftBox', + 'twitch', 'wordpress', 'x', 'yahoo', 'yandex', 'zoho', 'zoom', + ]; + \sort($expected); + + $this->assertSame($expected, $ids, 'Registry drift — listed providers do not match the expected set.'); + } + + public function testListOAuth2ProvidersResponseShape(): void + { + $response = $this->listOAuth2Providers(); + + $this->assertSame(200, $response['headers']['status-code']); + + foreach ($response['body']['providers'] as $provider) { + $this->assertArrayHasKey('$id', $provider); + $this->assertArrayHasKey('enabled', $provider); + $this->assertIsString($provider['$id']); + $this->assertIsBool($provider['enabled']); + } + } + + public function testListOAuth2ProvidersClientSecretsNotExposed(): void + { + // Seed credentials so the list cannot trivially return empty values. + $this->updateOAuth2('amazon', [ + 'clientId' => 'amzn1.application-oa2-client.testListSeed', + 'clientSecret' => 'super-secret-must-not-leak', + 'enabled' => false, + ]); + + $response = $this->listOAuth2Providers(); + + $this->assertSame(200, $response['headers']['status-code']); + + $matched = false; + foreach ($response['body']['providers'] as $provider) { + if ($provider['$id'] !== 'amazon') { + continue; + } + + $matched = true; + $this->assertSame('amzn1.application-oa2-client.testListSeed', $provider['clientId']); + $this->assertSame('', $provider['clientSecret']); + } + + $this->assertTrue($matched, 'List did not include the seeded provider.'); + } + + public function testListOAuth2ProvidersWithoutAuthentication(): void + { + $response = $this->listOAuth2Providers(authenticated: false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testListOAuth2ProvidersExcludesUnregisteredConfigEntries(): void + { + // `mock` and `mock-unverified` exist in oAuthProviders config (enabled: true) + // but are intentionally absent from Base::getProviderActions() — they're + // internal Mock OAuth2 adapters used by other test suites, not public + // providers. XList iterates the action registry, so they must never be + // included even though config marks them enabled. + $response = $this->listOAuth2Providers(); + + $this->assertSame(200, $response['headers']['status-code']); + + $ids = \array_column($response['body']['providers'], '$id'); + $this->assertNotContains('mock', $ids); + $this->assertNotContains('mock-unverified', $ids); + } + + // ========================================================================= + // Get OAuth2 provider + // ========================================================================= + + public function testGetOAuth2Provider(): void + { + $response = $this->getOAuth2Provider('github'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('github', $response['body']['$id']); + $this->assertArrayHasKey('enabled', $response['body']); + $this->assertArrayHasKey('clientId', $response['body']); + $this->assertArrayHasKey('clientSecret', $response['body']); + $this->assertSame('', $response['body']['clientSecret']); + } + + public function testGetOAuth2ProviderClientSecretWriteOnly(): void + { + $this->updateOAuth2('amazon', [ + 'clientId' => 'amzn1.application-oa2-client.getSecretCheck', + 'clientSecret' => 'must-never-be-returned', + 'enabled' => false, + ]); + + $response = $this->getOAuth2Provider('amazon'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('amzn1.application-oa2-client.getSecretCheck', $response['body']['clientId']); + $this->assertSame('', $response['body']['clientSecret']); + } + + public function testGetOAuth2ProviderMatchesListEntry(): void + { + $list = $this->listOAuth2Providers(); + $this->assertSame(200, $list['headers']['status-code']); + + // Drive the loop directly off the LIST result so any provider added + // to the registry is automatically checked for List/Get parity. + foreach ($list['body']['providers'] as $listEntry) { + $providerId = $listEntry['$id']; + $get = $this->getOAuth2Provider($providerId); + + $this->assertSame(200, $get['headers']['status-code'], "GET failed for {$providerId}"); + $this->assertSame($listEntry, $get['body'], "List/Get drift on {$providerId}"); + } + } + + public function testGetOAuth2ProviderUnsupported(): void + { + $response = $this->getOAuth2Provider('not-a-real-provider'); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('project_provider_unsupported', $response['body']['type']); + } + + public function testGetOAuth2ProviderRegisteredInConfigButNoUpdateClass(): void + { + // `mock` is present in oAuthProviders config (enabled: true) but is NOT + // registered in Base::getProviderActions(). Get::action has two + // separate `unsupported` throw branches — testGetOAuth2ProviderUnsupported + // covers the first (provider missing from config); this covers the + // second (provider in config but missing from the action registry). + $response = $this->getOAuth2Provider('mock'); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('project_provider_unsupported', $response['body']['type']); + } + + public function testGetOAuth2ProviderWithoutAuthentication(): void + { + $response = $this->getOAuth2Provider('github', authenticated: false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + // ========================================================================= + // Update plain provider (Amazon — clientId + clientSecret, no extra fields) + // ========================================================================= + + public function testUpdateOAuth2Plain(): void + { + $response = $this->updateOAuth2('amazon', [ + 'clientId' => 'amzn1.application-oa2-client.test01', + 'clientSecret' => 'test-secret-01', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('amazon', $response['body']['$id']); + $this->assertSame('amzn1.application-oa2-client.test01', $response['body']['clientId']); + $this->assertSame(false, $response['body']['enabled']); + } + + public function testUpdateOAuth2PlainEnable(): void + { + // Amazon has no verifyCredentials() hook, so enabling with arbitrary + // credentials succeeds without making a real network call. + $response = $this->updateOAuth2('amazon', [ + 'clientId' => 'amzn1.application-oa2-client.test02', + 'clientSecret' => 'test-secret-02', + 'enabled' => true, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(true, $response['body']['enabled']); + } + + public function testUpdateOAuth2PlainDisable(): void + { + $this->updateOAuth2('amazon', [ + 'clientId' => 'amzn1.application-oa2-client.test03', + 'clientSecret' => 'test-secret-03', + 'enabled' => true, + ]); + + $response = $this->updateOAuth2('amazon', [ + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['enabled']); + // Credentials persist across an enabled toggle. + $this->assertSame('amzn1.application-oa2-client.test03', $response['body']['clientId']); + } + + public function testUpdateOAuth2PlainPartial(): void + { + // Seed both credentials. + $this->updateOAuth2('amazon', [ + 'clientId' => 'seed-client-id', + 'clientSecret' => 'seed-secret', + 'enabled' => false, + ]); + + // Patch only clientId. + $response = $this->updateOAuth2('amazon', [ + 'clientId' => 'updated-client-id', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('updated-client-id', $response['body']['clientId']); + + // Read back through GET to confirm the secret is still set internally + // (write-only, so we cannot inspect the value, but enabling should still + // succeed because the secret remains non-empty). + $enable = $this->updateOAuth2('amazon', [ + 'enabled' => true, + ]); + $this->assertSame(200, $enable['headers']['status-code']); + $this->assertSame(true, $enable['body']['enabled']); + } + + public function testUpdateOAuth2PlainEnableRequiresCredentials(): void + { + // Start from a clean state with no credentials. + $this->updateOAuth2('amazon', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('amazon', [ + 'enabled' => true, + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2PlainEnabledOmittedDoesNotThrow(): void + { + // With enabled omitted (null) and no credentials, the silent-validation + // branch must not surface as an error. + $this->updateOAuth2('amazon', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('amazon', [ + 'clientId' => 'partial-only', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['enabled']); + $this->assertSame('partial-only', $response['body']['clientId']); + } + + public function testUpdateOAuth2PlainResponseModel(): void + { + $response = $this->updateOAuth2('amazon', [ + 'clientId' => 'amzn1.application-oa2-client.modelCheck', + 'clientSecret' => 'model-check-secret', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('$id', $response['body']); + $this->assertArrayHasKey('enabled', $response['body']); + $this->assertArrayHasKey('clientId', $response['body']); + $this->assertArrayHasKey('clientSecret', $response['body']); + } + + public function testUpdateOAuth2WithoutAuthentication(): void + { + $response = $this->updateOAuth2('amazon', [ + 'clientId' => 'no-auth', + 'clientSecret' => 'no-auth', + 'enabled' => false, + ], authenticated: false); + + $this->assertSame(401, $response['headers']['status-code']); + } + + public function testUpdateOAuth2UnknownProvider(): void + { + // Each Update endpoint is registered at a fixed `/oauth2/{providerId}` + // path, so an unknown provider does not match any route → 404. + $response = $this->updateOAuth2('not-a-real-provider', [ + 'clientId' => 'whatever', + 'clientSecret' => 'whatever', + 'enabled' => false, + ]); + + $this->assertSame(404, $response['headers']['status-code']); + } + + public function testUpdateOAuth2InvalidEnabled(): void + { + $response = $this->updateOAuth2('amazon', [ + 'enabled' => 'not-a-boolean', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + // ========================================================================= + // Update GitHub (verifyCredentials makes a real call to GitHub on enable) + // + // Only failure paths and the silent-on-disable branch are tested here. + // Happy-path enable would require real GitHub OAuth2 credentials, which + // CI doesn't have. Wiring, validation, and the non-enabling branch are + // sufficient to surface most regressions; success-path issues are caught + // by integration / staging environments instead. + // ========================================================================= + + public function testUpdateOAuth2GitHubInvalidCredentialsRejected(): void + { + // GitHub is the only provider with a real verifyCredentials() hook. + // Enabling with bogus credentials must surface a 400 from the wrapping + // exception, not silently succeed. + $response = $this->updateOAuth2('github', [ + 'clientId' => 'fake-client-id-' . \uniqid(), + 'clientSecret' => 'fake-client-secret', + 'enabled' => true, + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + + // Cleanup: ensure it's left disabled. + $this->updateOAuth2('github', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2GitHubInvalidCredentialsSilentWhenNotEnabling(): void + { + // When `enabled` is omitted, verifyCredentials() failure is swallowed. + // The provider remains disabled but the request succeeds. + $response = $this->updateOAuth2('github', [ + 'clientId' => 'still-fake-' . \uniqid(), + 'clientSecret' => 'still-fake-secret', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame(false, $response['body']['enabled']); + + // Cleanup + $this->updateOAuth2('github', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update Apple (serviceId + keyId + teamId + p8File) + // ========================================================================= + + public function testUpdateOAuth2Apple(): void + { + $response = $this->updateOAuth2('apple', [ + 'serviceId' => 'ip.appwrite.app.web', + 'keyId' => 'P4000000N8', + 'teamId' => 'D4000000R6', + 'p8File' => '-----BEGIN PRIVATE KEY-----TEST-----END PRIVATE KEY-----', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('apple', $response['body']['$id']); + $this->assertSame('ip.appwrite.app.web', $response['body']['serviceId']); + // keyId / teamId / p8File are write-only — PATCH response must not echo them back. + $this->assertSame('', $response['body']['keyId']); + $this->assertSame('', $response['body']['teamId']); + $this->assertSame('', $response['body']['p8File']); + $this->assertSame(false, $response['body']['enabled']); + + // Cleanup + $this->updateOAuth2('apple', [ + 'serviceId' => '', + 'keyId' => '', + 'teamId' => '', + 'p8File' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2ApplePartial(): void + { + // Seed all four fields. + $this->updateOAuth2('apple', [ + 'serviceId' => 'ip.appwrite.app.seed', + 'keyId' => 'KEYSEED01', + 'teamId' => 'TEAMSEED01', + 'p8File' => '-----BEGIN PRIVATE KEY-----SEED-----END PRIVATE KEY-----', + 'enabled' => false, + ]); + + // Patch only `keyId` — others must be preserved. + $response = $this->updateOAuth2('apple', [ + 'keyId' => 'KEYUPDATED', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + // serviceId is the (non-secret) clientId; keyId/teamId are write-only + // and must not surface in the response. Persistence of the merged + // values is verified separately via the enable-after-merge tests. + $this->assertSame('ip.appwrite.app.seed', $response['body']['serviceId']); + $this->assertSame('', $response['body']['keyId']); + $this->assertSame('', $response['body']['teamId']); + + // Cleanup + $this->updateOAuth2('apple', [ + 'serviceId' => '', + 'keyId' => '', + 'teamId' => '', + 'p8File' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2ApplePartialPreservesEachField(): void + { + // Seed all four fields, then patch each one individually and confirm + // the others survive across the chain. testUpdateOAuth2ApplePartial + // only covers `keyId`; this exercises serviceId/teamId/p8File too. + $this->updateOAuth2('apple', [ + 'serviceId' => 'ip.appwrite.app.merge', + 'keyId' => 'KEYMERGE01', + 'teamId' => 'TEAMMERGE', + 'p8File' => '-----BEGIN PRIVATE KEY-----MERGE-----END PRIVATE KEY-----', + 'enabled' => false, + ]); + + // Patch only `teamId`. + $teamOnly = $this->updateOAuth2('apple', [ + 'teamId' => 'TEAMROTATED', + ]); + $this->assertSame(200, $teamOnly['headers']['status-code']); + // teamId is write-only; verify only the non-secret serviceId echo. + // The actual merge is validated by the enable-after-merge call below. + $this->assertSame('', $teamOnly['body']['teamId']); + $this->assertSame('ip.appwrite.app.merge', $teamOnly['body']['serviceId']); + + // Patch only `serviceId` — keyId/teamId/p8File live in the JSON blob + // and must survive a top-level (non-blob) field update. + $serviceOnly = $this->updateOAuth2('apple', [ + 'serviceId' => 'ip.appwrite.app.rotated', + ]); + $this->assertSame(200, $serviceOnly['headers']['status-code']); + $this->assertSame('ip.appwrite.app.rotated', $serviceOnly['body']['serviceId']); + + // Patch only `p8File`. keyId/teamId/serviceId must still be set + // internally — confirm by enabling. Apple has no verifyCredentials() + // hook, so persistCredentials only checks for non-empty serviceId and + // non-empty stored secret blob. + $p8Only = $this->updateOAuth2('apple', [ + 'p8File' => '-----BEGIN PRIVATE KEY-----ROTATED-----END PRIVATE KEY-----', + ]); + $this->assertSame(200, $p8Only['headers']['status-code']); + + $enable = $this->updateOAuth2('apple', ['enabled' => true]); + $this->assertSame(200, $enable['headers']['status-code']); + $this->assertTrue($enable['body']['enabled']); + + // Cleanup + $this->updateOAuth2('apple', [ + 'serviceId' => '', + 'keyId' => '', + 'teamId' => '', + 'p8File' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2AppleClearAllFieldsBlocksEnable(): void + { + // Seed all four Apple fields. + $this->updateOAuth2('apple', [ + 'serviceId' => 'ip.appwrite.app.clearAll', + 'keyId' => 'KEYCLEARALL', + 'teamId' => 'TEAMCLEARALL', + 'p8File' => '-----BEGIN PRIVATE KEY-----CLEARALL-----END PRIVATE KEY-----', + 'enabled' => false, + ]); + + // Clear all credentials with empty strings. With `enabled` omitted, the + // silent-validation branch swallows the empty-credentials throw, so the + // call still succeeds — see testUpdateOAuth2PlainEnabledOmittedDoesNotThrow. + $clear = $this->updateOAuth2('apple', [ + 'serviceId' => '', + 'keyId' => '', + 'teamId' => '', + 'p8File' => '', + ]); + $this->assertSame(200, $clear['headers']['status-code']); + $this->assertSame('', $clear['body']['serviceId']); + + // A subsequent `enabled => true` must now 400. Empty serviceId trips + // persistCredentials' empty(appId) guard before any provider hook runs, + // proving that the clear actually took effect on stored state. + $enable = $this->updateOAuth2('apple', [ + 'enabled' => true, + ]); + $this->assertSame(400, $enable['headers']['status-code']); + $this->assertSame('general_argument_invalid', $enable['body']['type']); + + // Cleanup (already cleared; included for reset symmetry). + $this->updateOAuth2('apple', [ + 'serviceId' => '', + 'keyId' => '', + 'teamId' => '', + 'p8File' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2AppleResponseModel(): void + { + $response = $this->updateOAuth2('apple', [ + 'serviceId' => 'ip.appwrite.app.shape', + 'keyId' => 'SHAPEKEY01', + 'teamId' => 'SHAPETEAM', + 'p8File' => '-----BEGIN PRIVATE KEY-----SHAPE-----END PRIVATE KEY-----', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertArrayHasKey('$id', $response['body']); + $this->assertArrayHasKey('enabled', $response['body']); + $this->assertArrayHasKey('serviceId', $response['body']); + $this->assertArrayHasKey('keyId', $response['body']); + $this->assertArrayHasKey('teamId', $response['body']); + $this->assertArrayHasKey('p8File', $response['body']); + // Apple has no clientId/clientSecret in the response model. + $this->assertArrayNotHasKey('clientId', $response['body']); + $this->assertArrayNotHasKey('clientSecret', $response['body']); + + // Cleanup + $this->updateOAuth2('apple', [ + 'serviceId' => '', + 'keyId' => '', + 'teamId' => '', + 'p8File' => '', + 'enabled' => false, + ]); + } + + public function testGetOAuth2AppleSecretsWriteOnly(): void + { + $this->updateOAuth2('apple', [ + 'serviceId' => 'ip.appwrite.app.read', + 'keyId' => 'KEYREAD', + 'teamId' => 'TEAMREAD', + 'p8File' => '-----BEGIN PRIVATE KEY-----READ-----END PRIVATE KEY-----', + 'enabled' => false, + ]); + + $response = $this->getOAuth2Provider('apple'); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('ip.appwrite.app.read', $response['body']['serviceId']); + // All three secret-bearing fields must be hidden on read. + $this->assertSame('', $response['body']['keyId']); + $this->assertSame('', $response['body']['teamId']); + $this->assertSame('', $response['body']['p8File']); + + // Cleanup + $this->updateOAuth2('apple', [ + 'serviceId' => '', + 'keyId' => '', + 'teamId' => '', + 'p8File' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2AppleEnableAndReadBack(): void + { + // Apple has no verifyCredentials() hook, so enabling with arbitrary + // (well-formed) values succeeds without any real Apple network call. + $update = $this->updateOAuth2('apple', [ + 'serviceId' => 'ip.appwrite.app.enable', + 'keyId' => 'ENABLEKEY', + 'teamId' => 'ENABLETEAM', + 'p8File' => '-----BEGIN PRIVATE KEY-----ENABLE-----END PRIVATE KEY-----', + 'enabled' => true, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertTrue($update['body']['enabled']); + + // GET must hide all three secret-bearing fields while keeping serviceId. + $get = $this->getOAuth2Provider('apple'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['enabled']); + $this->assertSame('ip.appwrite.app.enable', $get['body']['serviceId']); + $this->assertSame('', $get['body']['keyId']); + $this->assertSame('', $get['body']['teamId']); + $this->assertSame('', $get['body']['p8File']); + + // Cleanup + $this->updateOAuth2('apple', [ + 'serviceId' => '', + 'keyId' => '', + 'teamId' => '', + 'p8File' => '', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update Auth0 (clientId + clientSecret + optional endpoint) + // ========================================================================= + + public function testUpdateOAuth2Auth0(): void + { + $response = $this->updateOAuth2('auth0', [ + 'clientId' => 'OaOkIA000000000000000000005KLSYq', + 'clientSecret' => 'auth0-test-secret', + 'endpoint' => 'example.us.auth0.com', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('auth0', $response['body']['$id']); + $this->assertSame('OaOkIA000000000000000000005KLSYq', $response['body']['clientId']); + $this->assertSame('example.us.auth0.com', $response['body']['endpoint']); + + // Cleanup + $this->updateOAuth2('auth0', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2Auth0PartialEndpoint(): void + { + // Seed clientSecret + endpoint. + $this->updateOAuth2('auth0', [ + 'clientId' => 'auth0-seed-client', + 'clientSecret' => 'auth0-seed-secret', + 'endpoint' => 'seed.us.auth0.com', + 'enabled' => false, + ]); + + // Update only endpoint. + $response = $this->updateOAuth2('auth0', [ + 'endpoint' => 'updated.us.auth0.com', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('updated.us.auth0.com', $response['body']['endpoint']); + // clientId is unchanged on top-level provider state. + $this->assertSame('auth0-seed-client', $response['body']['clientId']); + + // Cleanup + $this->updateOAuth2('auth0', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2Auth0PartialPreservesEachField(): void + { + // testUpdateOAuth2Auth0PartialEndpoint only patches `endpoint`. Cover + // patching `clientSecret` alone (must not wipe endpoint) and `clientId` + // alone (must not wipe the JSON-blob fields). + $this->updateOAuth2('auth0', [ + 'clientId' => 'auth0-merge-client', + 'clientSecret' => 'auth0-merge-secret', + 'endpoint' => 'merge.us.auth0.com', + 'enabled' => false, + ]); + + // Patch only clientSecret — clientId and endpoint must survive. + $secretOnly = $this->updateOAuth2('auth0', [ + 'clientSecret' => 'auth0-rotated-secret', + ]); + $this->assertSame(200, $secretOnly['headers']['status-code']); + $this->assertSame('auth0-merge-client', $secretOnly['body']['clientId']); + $this->assertSame('merge.us.auth0.com', $secretOnly['body']['endpoint']); + + // Patch only clientId — endpoint must survive. + $idOnly = $this->updateOAuth2('auth0', [ + 'clientId' => 'auth0-rotated-client', + ]); + $this->assertSame(200, $idOnly['headers']['status-code']); + $this->assertSame('auth0-rotated-client', $idOnly['body']['clientId']); + $this->assertSame('merge.us.auth0.com', $idOnly['body']['endpoint']); + + // Confirm the rotated clientSecret survived the chain by enabling. + // Auth0 has no verifyCredentials() hook; non-empty secret is enough. + $enable = $this->updateOAuth2('auth0', ['enabled' => true]); + $this->assertSame(200, $enable['headers']['status-code']); + $this->assertTrue($enable['body']['enabled']); + + // Cleanup + $this->updateOAuth2('auth0', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2Auth0EndpointAcceptsEmpty(): void + { + // Auth0's `endpoint` validator is `Nullable(Text(256, 0))`. Passing + // `''` must clear the stored value rather than leave it untouched + // (would happen if the merge fell back to existing on empty-string). + $this->updateOAuth2('auth0', [ + 'clientId' => 'auth0-clear-client', + 'clientSecret' => 'auth0-clear-secret', + 'endpoint' => 'before.us.auth0.com', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('auth0', [ + 'endpoint' => '', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['endpoint']); + $this->assertSame('auth0-clear-client', $response['body']['clientId']); + + // Cleanup + $this->updateOAuth2('auth0', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2Auth0EnableAndReadBack(): void + { + $update = $this->updateOAuth2('auth0', [ + 'clientId' => 'auth0-enable-client', + 'clientSecret' => 'auth0-enable-secret', + 'endpoint' => 'enable.us.auth0.com', + 'enabled' => true, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertTrue($update['body']['enabled']); + + // GET must hide clientSecret while keeping clientId and endpoint. + $get = $this->getOAuth2Provider('auth0'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['enabled']); + $this->assertSame('auth0-enable-client', $get['body']['clientId']); + $this->assertSame('enable.us.auth0.com', $get['body']['endpoint']); + $this->assertSame('', $get['body']['clientSecret']); + + // Cleanup + $this->updateOAuth2('auth0', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => '', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update Authentik (clientId + clientSecret + REQUIRED endpoint) + // ========================================================================= + + public function testUpdateOAuth2AuthentikRequiresEndpoint(): void + { + // The `endpoint` param is required (Text(min=1)); omitting → 400. + $response = $this->updateOAuth2('authentik', [ + 'clientId' => 'whatever', + 'clientSecret' => 'whatever', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2AuthentikEmptyEndpointRejected(): 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' => '', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2Authentik(): void + { + $response = $this->updateOAuth2('authentik', [ + 'clientId' => 'dTKOPa0000000000000000000000000000e7G8hv', + 'clientSecret' => 'authentik-secret', + 'endpoint' => 'example.authentik.com', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('authentik', $response['body']['$id']); + $this->assertSame('dTKOPa0000000000000000000000000000e7G8hv', $response['body']['clientId']); + $this->assertSame('example.authentik.com', $response['body']['endpoint']); + + // Cleanup + $this->updateOAuth2('authentik', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => 'cleanup.authentik.com', + '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 + // survive when omitted on a subsequent call that only changes clientId. + $this->updateOAuth2('authentik', [ + 'clientId' => 'authentik-merge-client', + 'clientSecret' => 'authentik-merge-secret', + 'endpoint' => 'merge.authentik.com', + 'enabled' => false, + ]); + + $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). + $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. + $this->updateOAuth2('authentik', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => 'cleanup.authentik.com', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2AuthentikEnableAndReadBack(): void + { + $update = $this->updateOAuth2('authentik', [ + 'clientId' => 'authentik-enable-client', + 'clientSecret' => 'authentik-enable-secret', + 'endpoint' => 'enable.authentik.com', + 'enabled' => true, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertTrue($update['body']['enabled']); + + // GET must hide clientSecret while keeping clientId and endpoint. + $get = $this->getOAuth2Provider('authentik'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['enabled']); + $this->assertSame('authentik-enable-client', $get['body']['clientId']); + $this->assertSame('enable.authentik.com', $get['body']['endpoint']); + $this->assertSame('', $get['body']['clientSecret']); + + // Cleanup — endpoint is required (Text(min=1)) so use a placeholder. + $this->updateOAuth2('authentik', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => 'cleanup.authentik.com', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update FusionAuth (clientId + clientSecret + REQUIRED endpoint) + // ========================================================================= + + public function testUpdateOAuth2FusionAuthRequiresEndpoint(): void + { + // The `endpoint` param is required (Text(min=1)); omitting → 400. + $response = $this->updateOAuth2('fusionauth', [ + 'clientId' => 'whatever', + 'clientSecret' => 'whatever', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2FusionAuthEmptyEndpointRejected(): 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' => '', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2FusionAuth(): void + { + $response = $this->updateOAuth2('fusionauth', [ + 'clientId' => 'b2222c00-0000-0000-0000-000000862097', + 'clientSecret' => 'fusionauth-secret', + 'endpoint' => 'example.fusionauth.io', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('fusionauth', $response['body']['$id']); + $this->assertSame('b2222c00-0000-0000-0000-000000862097', $response['body']['clientId']); + $this->assertSame('example.fusionauth.io', $response['body']['endpoint']); + + // Cleanup + $this->updateOAuth2('fusionauth', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => 'cleanup.fusionauth.io', + '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 + // survive when omitted on a subsequent call that only changes clientId. + $this->updateOAuth2('fusionauth', [ + 'clientId' => 'fusionauth-merge-client', + 'clientSecret' => 'fusionauth-merge-secret', + 'endpoint' => 'merge.fusionauth.io', + 'enabled' => false, + ]); + + $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). + $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. + $this->updateOAuth2('fusionauth', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => 'cleanup.fusionauth.io', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2FusionAuthEnableAndReadBack(): void + { + $update = $this->updateOAuth2('fusionauth', [ + 'clientId' => 'fusionauth-enable-client', + 'clientSecret' => 'fusionauth-enable-secret', + 'endpoint' => 'enable.fusionauth.io', + 'enabled' => true, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertTrue($update['body']['enabled']); + + // GET must hide clientSecret while keeping clientId and endpoint. + $get = $this->getOAuth2Provider('fusionauth'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['enabled']); + $this->assertSame('fusionauth-enable-client', $get['body']['clientId']); + $this->assertSame('enable.fusionauth.io', $get['body']['endpoint']); + $this->assertSame('', $get['body']['clientSecret']); + + // Cleanup — endpoint is required (Text(min=1)) so use a placeholder. + $this->updateOAuth2('fusionauth', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => 'cleanup.fusionauth.io', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update Keycloak (clientId + clientSecret + REQUIRED endpoint + REQUIRED realmName) + // ========================================================================= + + public function testUpdateOAuth2KeycloakRequiresEndpoint(): void + { + // The `endpoint` param is required (Text(min=1)); omitting → 400. + $response = $this->updateOAuth2('keycloak', [ + 'clientId' => 'whatever', + 'clientSecret' => 'whatever', + 'realmName' => 'appwrite-realm', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2KeycloakEmptyEndpointRejected(): 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', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2KeycloakRequiresRealmName(): void + { + // The `realmName` param is required (Text(min=1)); omitting → 400. + $response = $this->updateOAuth2('keycloak', [ + 'clientId' => 'whatever', + 'clientSecret' => 'whatever', + 'endpoint' => 'keycloak.example.com', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2KeycloakEmptyRealmNameRejected(): 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' => '', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2Keycloak(): void + { + $response = $this->updateOAuth2('keycloak', [ + 'clientId' => 'appwrite-o0000000st-app', + 'clientSecret' => 'keycloak-secret', + 'endpoint' => 'keycloak.example.com', + 'realmName' => 'appwrite-realm', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('keycloak', $response['body']['$id']); + $this->assertSame('appwrite-o0000000st-app', $response['body']['clientId']); + $this->assertSame('keycloak.example.com', $response['body']['endpoint']); + $this->assertSame('appwrite-realm', $response['body']['realmName']); + + // Cleanup + $this->updateOAuth2('keycloak', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => 'cleanup.keycloak.com', + 'realmName' => 'cleanup-realm', + '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 + // blob and must survive when omitted on a subsequent call that only + // changes clientId. + $this->updateOAuth2('keycloak', [ + 'clientId' => 'keycloak-merge-client', + 'clientSecret' => 'keycloak-merge-secret', + 'endpoint' => 'merge.keycloak.com', + 'realmName' => 'merge-realm', + 'enabled' => false, + ]); + + $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']); + $this->assertSame('merge.keycloak.com', $response['body']['endpoint']); + $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). + $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. + $this->updateOAuth2('keycloak', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => 'cleanup.keycloak.com', + 'realmName' => 'cleanup-realm', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2KeycloakEnableAndReadBack(): void + { + $update = $this->updateOAuth2('keycloak', [ + 'clientId' => 'keycloak-enable-client', + 'clientSecret' => 'keycloak-enable-secret', + 'endpoint' => 'enable.keycloak.com', + 'realmName' => 'enable-realm', + 'enabled' => true, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertTrue($update['body']['enabled']); + + // GET must hide clientSecret while keeping clientId, endpoint, realmName. + $get = $this->getOAuth2Provider('keycloak'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['enabled']); + $this->assertSame('keycloak-enable-client', $get['body']['clientId']); + $this->assertSame('enable.keycloak.com', $get['body']['endpoint']); + $this->assertSame('enable-realm', $get['body']['realmName']); + $this->assertSame('', $get['body']['clientSecret']); + + // Cleanup — endpoint and realmName are required (Text(min=1)) so use placeholders. + $this->updateOAuth2('keycloak', [ + 'clientId' => '', + 'clientSecret' => '', + 'endpoint' => 'cleanup.keycloak.com', + 'realmName' => 'cleanup-realm', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update Microsoft (applicationId + applicationSecret + REQUIRED tenant) + // ========================================================================= + + public function testUpdateOAuth2MicrosoftRequiresTenant(): void + { + $response = $this->updateOAuth2('microsoft', [ + 'applicationId' => 'whatever', + 'applicationSecret' => 'whatever', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2MicrosoftEmptyTenantRejected(): 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' => '', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2Microsoft(): void + { + $response = $this->updateOAuth2('microsoft', [ + 'applicationId' => '00001111-aaaa-2222-bbbb-3333cccc4444', + 'applicationSecret' => 'A1bC2dE3fH4iJ5kL6mN7oP8qR9sT0u', + 'tenant' => 'common', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('microsoft', $response['body']['$id']); + $this->assertSame('00001111-aaaa-2222-bbbb-3333cccc4444', $response['body']['applicationId']); + $this->assertSame('common', $response['body']['tenant']); + // Custom param names: applicationId/applicationSecret, not clientId/clientSecret. + $this->assertArrayNotHasKey('clientId', $response['body']); + $this->assertArrayNotHasKey('clientSecret', $response['body']); + + // Cleanup + $this->updateOAuth2('microsoft', [ + 'applicationId' => '', + 'applicationSecret' => '', + 'tenant' => 'common', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2MicrosoftPartialPreservesSecret(): void + { + // Seed full credentials. + $this->updateOAuth2('microsoft', [ + 'applicationId' => 'seed-app-id', + 'applicationSecret' => 'seed-app-secret', + 'tenant' => 'common', + '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. + $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']); + + // Cleanup + $this->updateOAuth2('microsoft', [ + 'applicationId' => '', + 'applicationSecret' => '', + 'tenant' => 'common', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2MicrosoftEnableAndReadBack(): void + { + $update = $this->updateOAuth2('microsoft', [ + 'applicationId' => 'microsoft-enable-app', + 'applicationSecret' => 'microsoft-enable-secret', + 'tenant' => 'common', + 'enabled' => true, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertTrue($update['body']['enabled']); + + // GET must hide applicationSecret while keeping applicationId/tenant. + $get = $this->getOAuth2Provider('microsoft'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['enabled']); + $this->assertSame('microsoft-enable-app', $get['body']['applicationId']); + $this->assertSame('common', $get['body']['tenant']); + $this->assertSame('', $get['body']['applicationSecret']); + + // Cleanup — tenant is required (Text(min=1)) so use a placeholder. + $this->updateOAuth2('microsoft', [ + 'applicationId' => '', + 'applicationSecret' => '', + 'tenant' => 'common', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update Gitlab (applicationId + secret + optional endpoint, custom names) + // ========================================================================= + + public function testUpdateOAuth2Gitlab(): void + { + $response = $this->updateOAuth2('gitlab', [ + 'applicationId' => 'd41ffe0000000000000000000000000000000000000000000000000000d5e252', + 'secret' => 'gloas-838cfa00', + 'endpoint' => 'https://gitlab.example.com', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('gitlab', $response['body']['$id']); + $this->assertSame('d41ffe0000000000000000000000000000000000000000000000000000d5e252', $response['body']['applicationId']); + $this->assertSame('https://gitlab.example.com', $response['body']['endpoint']); + // Custom names — the response model exposes `applicationId`/`secret`. + $this->assertArrayNotHasKey('clientId', $response['body']); + $this->assertArrayNotHasKey('clientSecret', $response['body']); + + // Cleanup + $this->updateOAuth2('gitlab', [ + 'applicationId' => '', + 'secret' => '', + 'endpoint' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2GitlabInvalidEndpoint(): void + { + $response = $this->updateOAuth2('gitlab', [ + 'applicationId' => 'whatever', + 'secret' => 'whatever', + 'endpoint' => 'not a url', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2GitlabPartialEndpoint(): void + { + $this->updateOAuth2('gitlab', [ + 'applicationId' => 'gitlab-seed-app', + 'secret' => 'gitlab-seed-secret', + 'endpoint' => 'https://seed.gitlab.com', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('gitlab', [ + 'endpoint' => 'https://updated.gitlab.com', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('https://updated.gitlab.com', $response['body']['endpoint']); + $this->assertSame('gitlab-seed-app', $response['body']['applicationId']); + + // Cleanup + $this->updateOAuth2('gitlab', [ + 'applicationId' => '', + 'secret' => '', + 'endpoint' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2GitlabPartialPreservesEachField(): void + { + // testUpdateOAuth2GitlabPartialEndpoint covers patching only `endpoint`. + // Cover patching `secret` alone (must not wipe applicationId/endpoint) + // and `applicationId` alone (must not wipe the JSON-blob endpoint). + $this->updateOAuth2('gitlab', [ + 'applicationId' => 'gitlab-merge-app', + 'secret' => 'gitlab-merge-secret', + 'endpoint' => 'https://merge.gitlab.com', + 'enabled' => false, + ]); + + // Patch only `secret`. + $secretOnly = $this->updateOAuth2('gitlab', [ + 'secret' => 'gitlab-rotated-secret', + ]); + $this->assertSame(200, $secretOnly['headers']['status-code']); + $this->assertSame('gitlab-merge-app', $secretOnly['body']['applicationId']); + $this->assertSame('https://merge.gitlab.com', $secretOnly['body']['endpoint']); + + // Patch only `applicationId`. + $idOnly = $this->updateOAuth2('gitlab', [ + 'applicationId' => 'gitlab-rotated-app', + ]); + $this->assertSame(200, $idOnly['headers']['status-code']); + $this->assertSame('gitlab-rotated-app', $idOnly['body']['applicationId']); + $this->assertSame('https://merge.gitlab.com', $idOnly['body']['endpoint']); + + // Cleanup + $this->updateOAuth2('gitlab', [ + 'applicationId' => '', + 'secret' => '', + 'endpoint' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2GitlabEnableAndReadBack(): void + { + $update = $this->updateOAuth2('gitlab', [ + 'applicationId' => 'gitlab-enable-app', + 'secret' => 'gitlab-enable-secret', + 'endpoint' => 'https://enable.gitlab.com', + 'enabled' => true, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertTrue($update['body']['enabled']); + + // GET must hide `secret` while keeping applicationId and endpoint. + $get = $this->getOAuth2Provider('gitlab'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['enabled']); + $this->assertSame('gitlab-enable-app', $get['body']['applicationId']); + $this->assertSame('https://enable.gitlab.com', $get['body']['endpoint']); + $this->assertSame('', $get['body']['secret']); + + // Cleanup + $this->updateOAuth2('gitlab', [ + 'applicationId' => '', + 'secret' => '', + 'endpoint' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2GitlabEndpointAcceptsEmpty(): void + { + // The `endpoint` validator is `Nullable(URL(allowEmpty: true))`. Passing + // `''` must clear the stored value rather than 400 on URL validation. + $this->updateOAuth2('gitlab', [ + 'applicationId' => 'gitlab-clear-app', + 'secret' => 'gitlab-clear-secret', + 'endpoint' => 'https://before.gitlab.com', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('gitlab', [ + 'endpoint' => '', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['endpoint']); + + // Cleanup + $this->updateOAuth2('gitlab', [ + 'applicationId' => '', + 'secret' => '', + 'endpoint' => '', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update OIDC (clientId + secret + wellKnownURL or 3 discovery URLs) + // ========================================================================= + + public function testUpdateOAuth2OidcWithWellKnown(): void + { + $response = $this->updateOAuth2('oidc', [ + 'clientId' => 'oidc-client', + 'clientSecret' => 'oidc-secret', + 'wellKnownURL' => 'https://idp.example.com/.well-known/openid-configuration', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('https://idp.example.com/.well-known/openid-configuration', $response['body']['wellKnownURL']); + $this->assertArrayHasKey('authorizationURL', $response['body']); + $this->assertArrayHasKey('tokenUrl', $response['body']); + $this->assertArrayHasKey('userInfoUrl', $response['body']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OidcWithDiscoveryURLs(): void + { + $response = $this->updateOAuth2('oidc', [ + 'clientId' => 'oidc-discovery', + 'clientSecret' => 'oidc-discovery-secret', + 'authorizationURL' => 'https://idp.example.com/oauth2/authorize', + 'tokenUrl' => 'https://idp.example.com/oauth2/token', + 'userInfoUrl' => 'https://idp.example.com/oauth2/userinfo', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('https://idp.example.com/oauth2/authorize', $response['body']['authorizationURL']); + $this->assertSame('https://idp.example.com/oauth2/token', $response['body']['tokenUrl']); + $this->assertSame('https://idp.example.com/oauth2/userinfo', $response['body']['userInfoUrl']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OidcEnableMissingURLs(): void + { + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('oidc', [ + 'clientId' => 'oidc-no-urls', + 'clientSecret' => 'oidc-no-urls', + 'enabled' => true, + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OidcEnablePartialDiscoveryFails(): void + { + // Only authorization+token, missing userInfo — must fail to enable. + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('oidc', [ + 'clientId' => 'oidc-partial', + 'clientSecret' => 'oidc-partial-secret', + 'authorizationURL' => 'https://idp.example.com/oauth2/authorize', + 'tokenUrl' => 'https://idp.example.com/oauth2/token', + 'enabled' => true, + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OidcEnableSucceedsWithWellKnown(): void + { + $update = $this->updateOAuth2('oidc', [ + 'clientId' => 'oidc-enable-client', + 'clientSecret' => 'oidc-enable-secret', + 'wellKnownURL' => 'https://idp.example.com/.well-known/openid-configuration', + 'enabled' => true, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertTrue($update['body']['enabled']); + + // GET must hide clientSecret while keeping clientId and the URL. + $get = $this->getOAuth2Provider('oidc'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['enabled']); + $this->assertSame('oidc-enable-client', $get['body']['clientId']); + $this->assertSame('https://idp.example.com/.well-known/openid-configuration', $get['body']['wellKnownURL']); + $this->assertSame('', $get['body']['clientSecret']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OidcEnableInSeparateRequestWithWellKnown(): void + { + // Configure URLs first with `enabled: false`. Then enable in a SECOND + // request that omits all URL fields. The merge-on-enable logic in + // Oidc::handle() must see the previously-stored wellKnownEndpoint and + // allow the toggle. This is the headline feature of the merge logic. + $this->updateOAuth2('oidc', [ + 'clientId' => 'oidc-split-wk-client', + 'clientSecret' => 'oidc-split-wk-secret', + 'wellKnownURL' => 'https://idp.example.com/.well-known/openid-configuration', + 'enabled' => false, + ]); + + $enable = $this->updateOAuth2('oidc', [ + 'enabled' => true, + ]); + $this->assertSame(200, $enable['headers']['status-code']); + $this->assertTrue($enable['body']['enabled']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OidcEnableAcrossRequestsWithDiscoveryURLs(): void + { + // Reset to clean state — earlier tests in this section may have left + // partial URL state when running in any order. + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + + // Request 1: configure two of the three discovery URLs. + $this->updateOAuth2('oidc', [ + 'clientId' => 'oidc-split-discovery', + 'clientSecret' => 'oidc-split-discovery-secret', + 'authorizationURL' => 'https://idp.example.com/oauth2/authorize', + 'tokenUrl' => 'https://idp.example.com/oauth2/token', + 'enabled' => false, + ]); + + // Request 2: send only the third URL plus enable=true. The merged + // state must include the two stored URLs + the new one to satisfy + // the all-three-discovery-URLs branch of the enable check. + $enable = $this->updateOAuth2('oidc', [ + 'userInfoUrl' => 'https://idp.example.com/oauth2/userinfo', + 'enabled' => true, + ]); + $this->assertSame(200, $enable['headers']['status-code']); + $this->assertTrue($enable['body']['enabled']); + + // Confirm all three URLs ended up persisted (merge wrote the new + // userInfoUrl while preserving the previously stored two). + $get = $this->getOAuth2Provider('oidc'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('https://idp.example.com/oauth2/authorize', $get['body']['authorizationURL']); + $this->assertSame('https://idp.example.com/oauth2/token', $get['body']['tokenUrl']); + $this->assertSame('https://idp.example.com/oauth2/userinfo', $get['body']['userInfoUrl']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OidcEnableFailsAfterClearingWellKnown(): void + { + // Seed wellKnownURL only (no discovery URLs). + $this->updateOAuth2('oidc', [ + 'clientId' => 'oidc-clear-then-enable', + 'clientSecret' => 'oidc-clear-then-enable-secret', + 'wellKnownURL' => 'https://idp.example.com/.well-known/openid-configuration', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + + // Clear wellKnownURL and try to enable in the same request. Merge + // sees `wellKnown=''` (the cleared empty wins over the stored value + // because the new value is non-null) and no discovery URLs → 400. + // This is the inverse of testUpdateOAuth2OidcEnableInSeparateRequestWithWellKnown: + // confirms the merge correctly *replaces* with empty rather than + // falling back to the stored non-empty value. + $response = $this->updateOAuth2('oidc', [ + 'wellKnownURL' => '', + 'enabled' => true, + ]); + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OidcSwitchModesWellKnownToDiscovery(): void + { + // Configure with wellKnownURL, then switch to the three-discovery-URL + // mode in a single request: clear wellKnown, set the three URLs, + // enable. Merge sees wellKnown='' AND all three discovery URLs set → + // hasAllDiscovery branch passes. + $this->updateOAuth2('oidc', [ + 'clientId' => 'oidc-switch-client', + 'clientSecret' => 'oidc-switch-secret', + 'wellKnownURL' => 'https://idp.example.com/.well-known/openid-configuration', + 'enabled' => false, + ]); + + $switch = $this->updateOAuth2('oidc', [ + 'wellKnownURL' => '', + 'authorizationURL' => 'https://idp.example.com/oauth2/authorize', + 'tokenUrl' => 'https://idp.example.com/oauth2/token', + 'userInfoUrl' => 'https://idp.example.com/oauth2/userinfo', + 'enabled' => true, + ]); + $this->assertSame(200, $switch['headers']['status-code']); + $this->assertTrue($switch['body']['enabled']); + $this->assertSame('', $switch['body']['wellKnownURL']); + $this->assertSame('https://idp.example.com/oauth2/authorize', $switch['body']['authorizationURL']); + $this->assertSame('https://idp.example.com/oauth2/token', $switch['body']['tokenUrl']); + $this->assertSame('https://idp.example.com/oauth2/userinfo', $switch['body']['userInfoUrl']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OidcURLsAcceptEmpty(): void + { + // All four URL fields use `Nullable(URL(allowEmpty: true))`. Passing `''` + // for each must clear them rather than 400 on URL validation. + $this->updateOAuth2('oidc', [ + 'clientId' => 'oidc-clear-client', + 'clientSecret' => 'oidc-clear-secret', + 'wellKnownURL' => 'https://idp.example.com/.well-known/openid-configuration', + 'authorizationURL' => 'https://idp.example.com/oauth2/authorize', + 'tokenUrl' => 'https://idp.example.com/oauth2/token', + 'userInfoUrl' => 'https://idp.example.com/oauth2/userinfo', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('oidc', [ + 'wellKnownURL' => '', + 'authorizationURL' => '', + 'tokenUrl' => '', + 'userInfoUrl' => '', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['wellKnownURL']); + $this->assertSame('', $response['body']['authorizationURL']); + $this->assertSame('', $response['body']['tokenUrl']); + $this->assertSame('', $response['body']['userInfoUrl']); + + // Cleanup + $this->updateOAuth2('oidc', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update Okta (clientId + clientSecret + optional domain/authServer) + // ========================================================================= + + public function testUpdateOAuth2Okta(): void + { + $response = $this->updateOAuth2('okta', [ + 'clientId' => '0oa00000000000000698', + 'clientSecret' => 'okta-secret', + 'domain' => 'trial-6400025.okta.com', + 'authorizationServerId' => 'aus000000000000000h7z', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('okta', $response['body']['$id']); + $this->assertSame('0oa00000000000000698', $response['body']['clientId']); + $this->assertSame('trial-6400025.okta.com', $response['body']['domain']); + $this->assertSame('aus000000000000000h7z', $response['body']['authorizationServerId']); + + // Cleanup + $this->updateOAuth2('okta', [ + 'clientId' => '', + 'clientSecret' => '', + 'domain' => '', + 'authorizationServerId' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OktaInvalidDomain(): void + { + $response = $this->updateOAuth2('okta', [ + 'clientId' => 'whatever', + 'clientSecret' => 'whatever', + 'domain' => 'https://trial-6400025.okta.com/', + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + } + + public function testUpdateOAuth2OktaEnableRequiresDomain(): void + { + $this->updateOAuth2('okta', [ + 'clientId' => '', + 'clientSecret' => '', + 'domain' => '', + 'authorizationServerId' => '', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('okta', [ + 'clientId' => 'okta-no-domain', + 'clientSecret' => 'okta-no-domain-secret', + 'enabled' => true, + ]); + + $this->assertSame(400, $response['headers']['status-code']); + $this->assertSame('general_argument_invalid', $response['body']['type']); + + // Cleanup + $this->updateOAuth2('okta', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OktaEnableSucceedsWithDomain(): void + { + $update = $this->updateOAuth2('okta', [ + 'clientId' => 'okta-enable-client', + 'clientSecret' => 'okta-enable-secret', + 'domain' => 'enable.okta.com', + 'authorizationServerId' => 'aus000000000000000h7z', + 'enabled' => true, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertTrue($update['body']['enabled']); + + // GET must hide clientSecret while keeping clientId, domain and authServerId. + $get = $this->getOAuth2Provider('okta'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['enabled']); + $this->assertSame('okta-enable-client', $get['body']['clientId']); + $this->assertSame('enable.okta.com', $get['body']['domain']); + $this->assertSame('aus000000000000000h7z', $get['body']['authorizationServerId']); + $this->assertSame('', $get['body']['clientSecret']); + + // Cleanup + $this->updateOAuth2('okta', [ + 'clientId' => '', + 'clientSecret' => '', + 'domain' => '', + 'authorizationServerId' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OktaPartialPreservesEachField(): void + { + // Okta has no field-by-field partial test in the existing suite. Cover + // each of `domain`, `authorizationServerId`, and `clientSecret` being + // patched alone — all three live in the same JSON blob. + $this->updateOAuth2('okta', [ + 'clientId' => 'okta-merge-client', + 'clientSecret' => 'okta-merge-secret', + 'domain' => 'merge.okta.com', + 'authorizationServerId' => 'aus000000000000merge', + 'enabled' => false, + ]); + + // Patch only `domain` — others must survive. + $domainOnly = $this->updateOAuth2('okta', [ + 'domain' => 'rotated.okta.com', + ]); + $this->assertSame(200, $domainOnly['headers']['status-code']); + $this->assertSame('rotated.okta.com', $domainOnly['body']['domain']); + $this->assertSame('okta-merge-client', $domainOnly['body']['clientId']); + $this->assertSame('aus000000000000merge', $domainOnly['body']['authorizationServerId']); + + // Patch only `authorizationServerId`. + $authServerOnly = $this->updateOAuth2('okta', [ + 'authorizationServerId' => 'aus000000000rotated00', + ]); + $this->assertSame(200, $authServerOnly['headers']['status-code']); + $this->assertSame('rotated.okta.com', $authServerOnly['body']['domain']); + $this->assertSame('aus000000000rotated00', $authServerOnly['body']['authorizationServerId']); + + // Patch only `clientSecret` — domain and authServerId in the JSON blob + // must survive. Confirm the rotated secret persisted by enabling. + $secretOnly = $this->updateOAuth2('okta', [ + 'clientSecret' => 'okta-rotated-secret', + ]); + $this->assertSame(200, $secretOnly['headers']['status-code']); + $this->assertSame('rotated.okta.com', $secretOnly['body']['domain']); + $this->assertSame('aus000000000rotated00', $secretOnly['body']['authorizationServerId']); + + $enable = $this->updateOAuth2('okta', ['enabled' => true]); + $this->assertSame(200, $enable['headers']['status-code']); + $this->assertTrue($enable['body']['enabled']); + + // Cleanup + $this->updateOAuth2('okta', [ + 'clientId' => '', + 'clientSecret' => '', + 'domain' => '', + 'authorizationServerId' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OktaAuthServerIdAcceptsEmpty(): void + { + // `authorizationServerId` is `Nullable(Text(256, 0))`. Passing `''` + // must clear the stored value while leaving the rest of the JSON blob + // (clientSecret, oktaDomain) untouched. + $this->updateOAuth2('okta', [ + 'clientId' => 'okta-clear-auth-server', + 'clientSecret' => 'okta-clear-auth-server-secret', + 'domain' => 'authserver.okta.com', + 'authorizationServerId' => 'aus0000000000beforeauth', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('okta', [ + 'authorizationServerId' => '', + ]); + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['authorizationServerId']); + // domain (also stored in the JSON blob) must NOT have been wiped. + $this->assertSame('authserver.okta.com', $response['body']['domain']); + + // Cleanup + $this->updateOAuth2('okta', [ + 'clientId' => '', + 'clientSecret' => '', + 'domain' => '', + 'authorizationServerId' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2OktaDomainAcceptsEmpty(): void + { + // The `domain` validator is `Nullable(Domain(allowEmpty: true))`. Passing + // `''` must clear the stored value rather than 400 on Domain validation. + $this->updateOAuth2('okta', [ + 'clientId' => 'okta-clear-client', + 'clientSecret' => 'okta-clear-secret', + 'domain' => 'before.okta.com', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('okta', [ + 'domain' => '', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('', $response['body']['domain']); + + // Cleanup + $this->updateOAuth2('okta', [ + 'clientId' => '', + 'clientSecret' => '', + 'domain' => '', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update Dropbox (custom param names: appKey + appSecret) + // ========================================================================= + + public function testUpdateOAuth2DropboxFieldNames(): void + { + $response = $this->updateOAuth2('dropbox', [ + 'appKey' => 'jl000000000009t', + 'appSecret' => 'g200000000000vw', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('dropbox', $response['body']['$id']); + $this->assertSame('jl000000000009t', $response['body']['appKey']); + $this->assertArrayHasKey('appSecret', $response['body']); + $this->assertArrayNotHasKey('clientId', $response['body']); + $this->assertArrayNotHasKey('clientSecret', $response['body']); + + // GET enforces write-only on the secret regardless of the custom name. + $get = $this->getOAuth2Provider('dropbox'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame('jl000000000009t', $get['body']['appKey']); + $this->assertSame('', $get['body']['appSecret']); + + // Cleanup + $this->updateOAuth2('dropbox', [ + 'appKey' => '', + 'appSecret' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2DropboxPartial(): void + { + // Seed both fields, then patch only `appKey` and verify `appSecret` + // persists by enabling — Dropbox has no verifyCredentials() hook, so + // enabling succeeds purely from local state. + $this->updateOAuth2('dropbox', [ + 'appKey' => 'dropbox-seed-key', + 'appSecret' => 'dropbox-seed-secret', + 'enabled' => false, + ]); + + $response = $this->updateOAuth2('dropbox', [ + 'appKey' => 'dropbox-updated-key', + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('dropbox-updated-key', $response['body']['appKey']); + + $enable = $this->updateOAuth2('dropbox', [ + 'enabled' => true, + ]); + $this->assertSame(200, $enable['headers']['status-code']); + $this->assertSame(true, $enable['body']['enabled']); + + // Cleanup + $this->updateOAuth2('dropbox', [ + 'appKey' => '', + 'appSecret' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2DropboxEnableAndReadBack(): void + { + $update = $this->updateOAuth2('dropbox', [ + 'appKey' => 'dropbox-enable-key', + 'appSecret' => 'dropbox-enable-secret', + 'enabled' => true, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertTrue($update['body']['enabled']); + + // GET must hide `appSecret` while keeping `appKey`. + $get = $this->getOAuth2Provider('dropbox'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertTrue($get['body']['enabled']); + $this->assertSame('dropbox-enable-key', $get['body']['appKey']); + $this->assertSame('', $get['body']['appSecret']); + + // Cleanup + $this->updateOAuth2('dropbox', [ + 'appKey' => '', + 'appSecret' => '', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update Paypal Sandbox (inherits from Paypal — independent provider ID) + // ========================================================================= + + public function testUpdateOAuth2PaypalSandbox(): void + { + $response = $this->updateOAuth2('paypalSandbox', [ + 'clientId' => 'paypal-sandbox-client', + 'clientSecret' => 'paypal-sandbox-secret', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('paypalSandbox', $response['body']['$id']); + $this->assertSame('paypal-sandbox-client', $response['body']['clientId']); + + // Sandbox is independent of the regular paypal entry. + $regular = $this->getOAuth2Provider('paypal'); + $this->assertSame(200, $regular['headers']['status-code']); + $this->assertSame('paypal', $regular['body']['$id']); + $this->assertNotSame('paypal-sandbox-client', $regular['body']['clientId']); + + // Cleanup + $this->updateOAuth2('paypalSandbox', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2PaypalSandboxResponseModel(): void + { + // PaypalSandbox inherits from Paypal: param/response field is + // `secretKey` instead of `clientSecret`. A regression that adds the + // default `clientSecret` to the response model would leak the + // unwritten field; pin its absence on both PATCH and GET. + $update = $this->updateOAuth2('paypalSandbox', [ + 'clientId' => 'paypal-sandbox-shape', + 'secretKey' => 'paypal-sandbox-shape-secret', + 'enabled' => false, + ]); + $this->assertSame(200, $update['headers']['status-code']); + $this->assertArrayHasKey('secretKey', $update['body']); + $this->assertArrayNotHasKey('clientSecret', $update['body']); + + $get = $this->getOAuth2Provider('paypalSandbox'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertArrayHasKey('secretKey', $get['body']); + $this->assertArrayNotHasKey('clientSecret', $get['body']); + + // Cleanup + $this->updateOAuth2('paypalSandbox', [ + 'clientId' => '', + 'secretKey' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2PaypalDoesNotAffectSandbox(): void + { + // Reverse direction: writing to regular paypal must leave sandbox state intact. + $this->updateOAuth2('paypalSandbox', [ + 'clientId' => 'sandbox-untouched', + 'clientSecret' => 'sandbox-secret', + 'enabled' => false, + ]); + + $this->updateOAuth2('paypal', [ + 'clientId' => 'paypal-prod', + 'secretKey' => 'paypal-prod-secret', + 'enabled' => false, + ]); + + $sandbox = $this->getOAuth2Provider('paypalSandbox'); + $this->assertSame(200, $sandbox['headers']['status-code']); + $this->assertSame('sandbox-untouched', $sandbox['body']['clientId']); + + // Cleanup both + $this->updateOAuth2('paypal', [ + 'clientId' => '', + 'secretKey' => '', + 'enabled' => false, + ]); + $this->updateOAuth2('paypalSandbox', [ + 'clientId' => '', + 'clientSecret' => '', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Update Tradeshift Sandbox (inherits from Tradeshift — independent provider ID) + // ========================================================================= + + public function testUpdateOAuth2TradeshiftBox(): void + { + $response = $this->updateOAuth2('tradeshiftBox', [ + 'oauth2ClientId' => 'tradeshift-sandbox-client', + 'oauth2ClientSecret' => 'tradeshift-sandbox-secret', + 'enabled' => false, + ]); + + $this->assertSame(200, $response['headers']['status-code']); + $this->assertSame('tradeshiftBox', $response['body']['$id']); + $this->assertSame('tradeshift-sandbox-client', $response['body']['oauth2ClientId']); + + // Sandbox is independent of the regular tradeshift entry. + $regular = $this->getOAuth2Provider('tradeshift'); + $this->assertSame(200, $regular['headers']['status-code']); + $this->assertSame('tradeshift', $regular['body']['$id']); + $this->assertNotSame('tradeshift-sandbox-client', $regular['body']['oauth2ClientId']); + + // Cleanup + $this->updateOAuth2('tradeshiftBox', [ + 'oauth2ClientId' => '', + 'oauth2ClientSecret' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2TradeshiftBoxResponseModel(): void + { + // TradeshiftSandbox inherits from Tradeshift: both clientId AND + // clientSecret are renamed (oauth2ClientId / oauth2ClientSecret). + // Pin that the default field names are absent from PATCH and GET + // responses so a stray addition to the response model is caught. + $update = $this->updateOAuth2('tradeshiftBox', [ + 'oauth2ClientId' => 'tradeshift-box-shape', + 'oauth2ClientSecret' => 'tradeshift-box-shape-secret', + 'enabled' => false, + ]); + $this->assertSame(200, $update['headers']['status-code']); + $this->assertArrayHasKey('oauth2ClientId', $update['body']); + $this->assertArrayHasKey('oauth2ClientSecret', $update['body']); + $this->assertArrayNotHasKey('clientId', $update['body']); + $this->assertArrayNotHasKey('clientSecret', $update['body']); + + $get = $this->getOAuth2Provider('tradeshiftBox'); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertArrayHasKey('oauth2ClientId', $get['body']); + $this->assertArrayHasKey('oauth2ClientSecret', $get['body']); + $this->assertArrayNotHasKey('clientId', $get['body']); + $this->assertArrayNotHasKey('clientSecret', $get['body']); + + // Cleanup + $this->updateOAuth2('tradeshiftBox', [ + 'oauth2ClientId' => '', + 'oauth2ClientSecret' => '', + 'enabled' => false, + ]); + } + + public function testUpdateOAuth2TradeshiftDoesNotAffectSandbox(): void + { + // Reverse direction: writing to regular tradeshift must not touch sandbox state. + $this->updateOAuth2('tradeshiftBox', [ + 'oauth2ClientId' => 'tradeshift-sandbox-untouched', + 'oauth2ClientSecret' => 'tradeshift-sandbox-secret', + 'enabled' => false, + ]); + + $this->updateOAuth2('tradeshift', [ + 'oauth2ClientId' => 'tradeshift-prod', + 'oauth2ClientSecret' => 'tradeshift-prod-secret', + 'enabled' => false, + ]); + + $sandbox = $this->getOAuth2Provider('tradeshiftBox'); + $this->assertSame(200, $sandbox['headers']['status-code']); + $this->assertSame('tradeshift-sandbox-untouched', $sandbox['body']['oauth2ClientId']); + + // Cleanup both + $this->updateOAuth2('tradeshift', [ + 'oauth2ClientId' => '', + 'oauth2ClientSecret' => '', + 'enabled' => false, + ]); + $this->updateOAuth2('tradeshiftBox', [ + 'oauth2ClientId' => '', + 'oauth2ClientSecret' => '', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Smoke test: every plain (clientId + clientSecret) provider + // + // 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. + // Github is excluded because its `verifyCredentials()` hook is exercised + // separately. + // ========================================================================= + + /** + * Provider, ID-field, secret-field. Many providers rename one or both of + * the two credential params (`clientId`/`clientSecret`) to match the + * upstream provider's terminology, so the smoke test parameterises both. + * + * @return array> + */ + public static function plainProviders(): array + { + return [ + 'discord' => ['discord', 'clientId', 'clientSecret'], + 'figma' => ['figma', 'clientId', 'clientSecret'], + 'dailymotion' => ['dailymotion', 'apiKey', 'apiSecret'], + 'bitbucket' => ['bitbucket', 'key', 'secret'], + 'bitly' => ['bitly', 'clientId', 'clientSecret'], + 'box' => ['box', 'clientId', 'clientSecret'], + 'autodesk' => ['autodesk', 'clientId', 'clientSecret'], + 'google' => ['google', 'clientId', 'clientSecret'], + 'zoom' => ['zoom', 'clientId', 'clientSecret'], + 'zoho' => ['zoho', 'clientId', 'clientSecret'], + 'yandex' => ['yandex', 'clientId', 'clientSecret'], + 'x' => ['x', 'customerKey', 'secretKey'], + 'wordpress' => ['wordpress', 'clientId', 'clientSecret'], + 'twitch' => ['twitch', 'clientId', 'clientSecret'], + 'stripe' => ['stripe', 'clientId', 'apiSecretKey'], + 'spotify' => ['spotify', 'clientId', 'clientSecret'], + 'slack' => ['slack', 'clientId', 'clientSecret'], + 'podio' => ['podio', 'clientId', 'clientSecret'], + 'notion' => ['notion', 'oauthClientId', 'oauthClientSecret'], + 'salesforce' => ['salesforce', 'customerKey', 'customerSecret'], + 'yahoo' => ['yahoo', 'clientId', 'clientSecret'], + 'linkedin' => ['linkedin', 'clientId', 'primaryClientSecret'], + 'disqus' => ['disqus', 'publicKey', 'secretKey'], + 'etsy' => ['etsy', 'keyString', 'sharedSecret'], + 'facebook' => ['facebook', 'appId', 'appSecret'], + 'tradeshift' => ['tradeshift', 'oauth2ClientId', 'oauth2ClientSecret'], + 'paypal' => ['paypal', 'clientId', 'secretKey'], + 'kick' => ['kick', 'clientId', 'clientSecret'], + ]; + } + + #[DataProvider('plainProviders')] + public function testUpdateOAuth2PlainProvider(string $providerId, string $idField, string $secretField): void + { + $clientId = $providerId . '-smoke-client'; + $clientSecret = $providerId . '-smoke-secret'; + + $update = $this->updateOAuth2($providerId, [ + $idField => $clientId, + $secretField => $clientSecret, + 'enabled' => false, + ]); + + $this->assertSame(200, $update['headers']['status-code']); + $this->assertSame($providerId, $update['body']['$id']); + $this->assertSame($clientId, $update['body'][$idField]); + $this->assertFalse($update['body']['enabled']); + + // GET round-trip — confirms the value actually persisted (catches a + // PATCH that only echoes input without writing) and that the secret + // is hidden on read. + $get = $this->getOAuth2Provider($providerId); + $this->assertSame(200, $get['headers']['status-code']); + $this->assertSame($providerId, $get['body']['$id']); + $this->assertSame($clientId, $get['body'][$idField]); + $this->assertSame('', $get['body'][$secretField]); + $this->assertFalse($get['body']['enabled']); + + // Cleanup + $this->updateOAuth2($providerId, [ + $idField => '', + $secretField => '', + 'enabled' => false, + ]); + } + + /** + * For providers that rename `clientId` / `clientSecret` to a custom field + * (e.g. `apiKey`/`apiSecret`, `customerKey`/`secretKey`, `oauthClientId`), + * the renamed field replaces the default — the response model must NOT + * also expose the default name. Catches a regression where adding a + * custom param name forgets to remove the default from the response. + */ + #[DataProvider('plainProviders')] + public function testUpdateOAuth2PlainProviderResponseDoesNotLeakDefaultNames(string $providerId, string $idField, string $secretField): void + { + if ($idField === 'clientId' && $secretField === 'clientSecret') { + // Default-named provider — nothing to leak. Avoids a no-op assertion. + $this->markTestSkipped("{$providerId} uses default field names."); + } + + $update = $this->updateOAuth2($providerId, [ + $idField => $providerId . '-leak-check-id', + $secretField => $providerId . '-leak-check-secret', + 'enabled' => false, + ]); + $this->assertSame(200, $update['headers']['status-code']); + + if ($idField !== 'clientId') { + $this->assertArrayNotHasKey('clientId', $update['body'], "PATCH response for {$providerId} leaks default `clientId` despite using `{$idField}`."); + } + if ($secretField !== 'clientSecret') { + $this->assertArrayNotHasKey('clientSecret', $update['body'], "PATCH response for {$providerId} leaks default `clientSecret` despite using `{$secretField}`."); + } + + $get = $this->getOAuth2Provider($providerId); + $this->assertSame(200, $get['headers']['status-code']); + if ($idField !== 'clientId') { + $this->assertArrayNotHasKey('clientId', $get['body'], "GET response for {$providerId} leaks default `clientId` despite using `{$idField}`."); + } + if ($secretField !== 'clientSecret') { + $this->assertArrayNotHasKey('clientSecret', $get['body'], "GET response for {$providerId} leaks default `clientSecret` despite using `{$secretField}`."); + } + + // Cleanup + $this->updateOAuth2($providerId, [ + $idField => '', + $secretField => '', + 'enabled' => false, + ]); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + /** + * @param array $params + */ + protected function updateOAuth2(string $provider, array $params, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + return $this->client->call( + Client::METHOD_PATCH, + '/project/oauth2/' . $provider, + $headers, + $params, + ); + } + + protected function getOAuth2Provider(string $provider, bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + return $this->client->call( + Client::METHOD_GET, + '/project/oauth2/' . $provider, + $headers, + ); + } + + protected function listOAuth2Providers(bool $authenticated = true): mixed + { + $headers = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]; + + if ($authenticated) { + $headers = \array_merge($headers, $this->getHeaders()); + } + + return $this->client->call( + Client::METHOD_GET, + '/project/oauth2', + $headers, + ); + } +} diff --git a/tests/e2e/Services/Project/OAuth2ConsoleClientTest.php b/tests/e2e/Services/Project/OAuth2ConsoleClientTest.php new file mode 100644 index 0000000000..b5654ffe4b --- /dev/null +++ b/tests/e2e/Services/Project/OAuth2ConsoleClientTest.php @@ -0,0 +1,14 @@ +markTestSkipped('GitHub OAuth2 credentials not configured (_TESTS_OAUTH2_GITHUB_CLIENT_ID, _TESTS_OAUTH2_GITHUB_CLIENT_SECRET)'); + } + + $consoleHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-project' => 'console', + ]; + + // Step 1: Create new organization (team) + $team = $this->client->call(Client::METHOD_POST, '/teams', $consoleHeaders, [ + 'teamId' => ID::unique(), + 'name' => 'GitHub OAuth Org ' . uniqid(), + ]); + $this->assertSame(201, $team['headers']['status-code']); + $teamId = $team['body']['$id']; + + // Step 2: Create new project + $project = $this->client->call(Client::METHOD_POST, '/projects', $consoleHeaders, [ + 'projectId' => 'githuboauthapp', // Must be this ID, its used in redirect URL set in GitHub app configuration + 'name' => 'GitHub OAuth Project', + 'teamId' => $teamId, + 'region' => System::getEnv('_APP_REGION', 'default'), + ]); + $this->assertSame(201, $project['headers']['status-code']); + $newProjectId = $project['body']['$id']; + + // Step 3: Configure GitHub provider on the new project via PATCH /v1/project/oauth2/github + $newProjectAdminHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-project' => $newProjectId, + 'x-appwrite-mode' => 'admin', + ]; + + $configResponse = $this->client->call(Client::METHOD_PATCH, '/project/oauth2/github', $newProjectAdminHeaders, [ + 'clientId' => $clientId, + 'clientSecret' => $clientSecret, + 'enabled' => true, + ]); + $this->assertSame(200, $configResponse['headers']['status-code']); + $this->assertTrue($configResponse['body']['enabled']); + $this->assertSame($clientId, $configResponse['body']['clientId']); + + // Step 4: Verify OAuth provider is enabled via GET /v1/projects/:projectId + $projectDetails = $this->client->call(Client::METHOD_GET, '/projects/' . $newProjectId, $consoleHeaders); + $this->assertSame(200, $projectDetails['headers']['status-code']); + + $githubProvider = null; + foreach ($projectDetails['body']['oAuthProviders'] as $provider) { + if ($provider['key'] === 'github') { + $githubProvider = $provider; + break; + } + } + $this->assertNotNull($githubProvider, 'GitHub OAuth provider not found in project details'); + $this->assertTrue($githubProvider['enabled']); + $this->assertSame($clientId, $githubProvider['appId']); + $this->assertSame('', $githubProvider['secret']); // Write only + + // Step 5: Without client headers (no API key), go through the OAuth flow + $clientHeaders = [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $newProjectId, + ]; + + $oauthInit = $this->client->call( + Client::METHOD_GET, + '/account/sessions/oauth2/github', + $clientHeaders, + [ + 'success' => 'http://localhost:4000/success', + 'failure' => 'http://localhost:4000/failure', + ], + followRedirects: false + ); + + $this->assertSame(301, $oauthInit['headers']['status-code']); + $this->assertArrayHasKey('location', $oauthInit['headers']); + $this->assertStringStartsWith('https://github.com/login/oauth/authorize', $oauthInit['headers']['location']); + $this->assertStringContainsString('client_id=' . \urlencode($clientId), $oauthInit['headers']['location']); + $this->assertStringContainsString('redirect_uri=', $oauthInit['headers']['location']); + + // Follow the redirect to GitHub's authorization endpoint. With a real user agent, GitHub + // would prompt for login + app approval, then redirect back to Appwrite's callback with a + // valid `code`. Appwrite would then exchange the code, create the session and redirect to + // the success URL with the session cookie set. + $oauthClient = new Client(); + $oauthClient->setEndpoint(''); + + $githubResponse = $oauthClient->call( + Client::METHOD_GET, + $oauthInit['headers']['location'], + [], + [], + decode: false, + followRedirects: false + ); + + // GitHub returns 200 (login HTML) or 302 (redirect to login) — both indicate the flow + // reached GitHub. Anything else means our redirect is malformed. + $this->assertContains($githubResponse['headers']['status-code'], [200, 302]); + + // Cleanup: delete the project + $deleteProject = $this->client->call(Client::METHOD_DELETE, '/projects/' . $newProjectId, $consoleHeaders); + $this->assertSame(204, $deleteProject['headers']['status-code']); + + // Cleanup: delete the organization (team) + $deleteTeam = $this->client->call(Client::METHOD_DELETE, '/teams/' . $teamId, $consoleHeaders); + $this->assertSame(204, $deleteTeam['headers']['status-code']); + } +} diff --git a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php index f88db41e8c..8322e37de1 100644 --- a/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php +++ b/tests/e2e/Services/Projects/ProjectsConsoleClientTest.php @@ -1634,7 +1634,7 @@ class ProjectsConsoleClientTest extends Scope foreach ($response['body']['oAuthProviders'] as $responseProvider) { if ($responseProvider['key'] === $key) { $this->assertEquals('AppId-' . ucfirst($key), $responseProvider['appId']); - $this->assertEquals('Secret-' . ucfirst($key), $responseProvider['secret']); + $this->assertEmpty($responseProvider['secret']); $this->assertFalse($responseProvider['enabled']); $asserted = true; break; diff --git a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php index ef1c5fce7a..813ef70ff0 100644 --- a/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php +++ b/tests/e2e/Services/Realtime/RealtimeCustomClientTest.php @@ -335,10 +335,12 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertContains('account.update', $response['data']['channels']); + $this->assertContains('account.' . $userId . '.update', $response['data']['channels']); $this->assertContains("users.{$userId}.update.name", $response['data']['events']); $this->assertContains("users.{$userId}.update", $response['data']['events']); $this->assertContains("users.{$userId}", $response['data']['events']); @@ -368,10 +370,12 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertContains('account.update', $response['data']['channels']); + $this->assertContains('account.' . $userId . '.update', $response['data']['channels']); $this->assertContains("users.{$userId}.update.password", $response['data']['events']); $this->assertContains("users.{$userId}.update", $response['data']['events']); $this->assertContains("users.{$userId}", $response['data']['events']); @@ -401,10 +405,12 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertContains('account.update', $response['data']['channels']); + $this->assertContains('account.' . $userId . '.update', $response['data']['channels']); $this->assertContains("users.{$userId}.update.email", $response['data']['events']); $this->assertContains("users.{$userId}.update", $response['data']['events']); $this->assertContains("users.{$userId}", $response['data']['events']); @@ -432,11 +438,14 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); + // Nested user event (verification) — must NOT suffix the account channels. $this->assertCount(2, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertArrayNotHasKey('secret', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertNotContains('account.create', $response['data']['channels']); + $this->assertNotContains('account.' . $userId . '.create', $response['data']['channels']); $this->assertContains("users.{$userId}.verification.{$verificationId}.create", $response['data']['events']); $this->assertContains("users.{$userId}.verification.{$verificationId}", $response['data']['events']); $this->assertContains("users.{$userId}.verification.*.create", $response['data']['events']); @@ -475,10 +484,13 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); + // Nested user event (verification) — must NOT suffix the account channels. $this->assertCount(2, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertNotContains('account.update', $response['data']['channels']); + $this->assertNotContains('account.' . $userId . '.update', $response['data']['channels']); $this->assertContains("users.{$userId}.verification.{$verificationId}.update", $response['data']['events']); $this->assertContains("users.{$userId}.verification.{$verificationId}", $response['data']['events']); $this->assertContains("users.{$userId}.verification.*.update", $response['data']['events']); @@ -510,10 +522,12 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertContains('account.update', $response['data']['channels']); + $this->assertContains('account.' . $userId . '.update', $response['data']['channels']); $this->assertContains("users.{$userId}.update.prefs", $response['data']['events']); $this->assertContains("users.{$userId}.update", $response['data']['events']); $this->assertContains("users.{$userId}", $response['data']['events']); @@ -551,10 +565,13 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); + // Nested user event (sessions) — must NOT suffix the account channels. $this->assertCount(2, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertNotContains('account.create', $response['data']['channels']); + $this->assertNotContains('account.' . $userId . '.create', $response['data']['channels']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}.create", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.*.create", $response['data']['events']); @@ -583,10 +600,13 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); + // Nested user event (sessions) — must NOT suffix the account channels. $this->assertCount(2, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertNotContains('account.delete', $response['data']['channels']); + $this->assertNotContains('account.' . $userId . '.delete', $response['data']['channels']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}.delete", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.*.delete", $response['data']['events']); @@ -620,10 +640,13 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); + // Nested user event (sessions) — must NOT suffix the account channels. $this->assertCount(2, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertNotContains('account.delete', $response['data']['channels']); + $this->assertNotContains('account.' . $userId . '.delete', $response['data']['channels']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}.delete", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.{$sessionNewId}", $response['data']['events']); $this->assertContains("users.{$userId}.sessions.*.delete", $response['data']['events']); @@ -661,10 +684,13 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); + // Nested user event (recovery) — must NOT suffix the account channels. $this->assertCount(2, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertNotContains('account.create', $response['data']['channels']); + $this->assertNotContains('account.' . $userId . '.create', $response['data']['channels']); $this->assertContains("users.{$userId}.recovery.{$recoveryId}.create", $response['data']['events']); $this->assertContains("users.{$userId}.recovery.{$recoveryId}", $response['data']['events']); $this->assertContains("users.{$userId}.recovery.*.create", $response['data']['events']); @@ -695,10 +721,13 @@ class RealtimeCustomClientTest extends Scope $this->assertArrayHasKey('data', $response); $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); + // Nested user event (recovery) — must NOT suffix the account channels. $this->assertCount(2, $response['data']['channels']); $this->assertArrayHasKey('timestamp', $response['data']); $this->assertContains('account', $response['data']['channels']); $this->assertContains('account.' . $userId, $response['data']['channels']); + $this->assertNotContains('account.update', $response['data']['channels']); + $this->assertNotContains('account.' . $userId . '.update', $response['data']['channels']); $this->assertContains("users.{$userId}.recovery.{$recoveryId}.update", $response['data']['events']); $this->assertContains("users.{$userId}.recovery.{$recoveryId}", $response['data']['events']); $this->assertContains("users.{$userId}.recovery.*.update", $response['data']['events']); @@ -820,7 +849,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains('databases.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']); $this->assertContains('databases.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); @@ -865,7 +894,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); @@ -921,7 +950,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); @@ -977,7 +1006,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.create", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.create", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']); @@ -1009,7 +1038,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.create", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.create", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']); @@ -1058,7 +1087,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -1086,7 +1115,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -1114,7 +1143,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -1151,7 +1180,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -1180,7 +1209,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -1209,7 +1238,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -1256,7 +1285,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.upsert", $response['data']['events']); @@ -1435,7 +1464,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response1['type']); $this->assertNotEmpty($response1['data']); $this->assertArrayHasKey('timestamp', $response1['data']); - $this->assertCount(8, $response1['data']['channels']); + $this->assertCount(16, $response1['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response1['data']['payload']['$id']}.create", $response1['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.create", $response1['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response1['data']['events']); @@ -1466,7 +1495,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response2['type']); $this->assertNotEmpty($response2['data']); $this->assertArrayHasKey('timestamp', $response2['data']); - $this->assertCount(8, $response2['data']['channels']); + $this->assertCount(16, $response2['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response2['data']['payload']['$id']}.create", $response2['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.create", $response2['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response2['data']['events']); @@ -1516,7 +1545,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response1['type']); $this->assertNotEmpty($response1['data']); $this->assertArrayHasKey('timestamp', $response1['data']); - $this->assertCount(8, $response1['data']['channels']); + $this->assertCount(16, $response1['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response1['data']['payload']['$id']}.update", $response1['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response1['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response1['data']['events']); @@ -1570,7 +1599,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response2['type']); $this->assertNotEmpty($response2['data']); $this->assertArrayHasKey('timestamp', $response2['data']); - $this->assertCount(8, $response2['data']['channels']); + $this->assertCount(16, $response2['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response2['data']['payload']['$id']}.update", $response2['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response2['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response2['data']['events']); @@ -1623,7 +1652,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response1['type']); $this->assertNotEmpty($response1['data']); $this->assertArrayHasKey('timestamp', $response1['data']); - $this->assertCount(8, $response1['data']['channels']); + $this->assertCount(16, $response1['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response1['data']['payload']['$id']}.update", $response1['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response1['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response1['data']['events']); @@ -1650,7 +1679,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response2['type']); $this->assertNotEmpty($response2['data']); $this->assertArrayHasKey('timestamp', $response2['data']); - $this->assertCount(8, $response2['data']['channels']); + $this->assertCount(16, $response2['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response2['data']['payload']['$id']}.update", $response2['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response2['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response2['data']['events']); @@ -1689,7 +1718,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response1['type']); $this->assertNotEmpty($response1['data']); $this->assertArrayHasKey('timestamp', $response1['data']); - $this->assertCount(8, $response1['data']['channels']); + $this->assertCount(16, $response1['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response1['data']['payload']['$id']}.delete", $response1['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.delete", $response1['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response1['data']['events']); @@ -1720,7 +1749,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response2['type']); $this->assertNotEmpty($response2['data']); $this->assertArrayHasKey('timestamp', $response2['data']); - $this->assertCount(8, $response2['data']['channels']); + $this->assertCount(16, $response2['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response2['data']['payload']['$id']}.delete", $response2['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.delete", $response2['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response2['data']['events']); @@ -1773,7 +1802,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.upsert", $response['data']['events']); @@ -1811,7 +1840,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.upsert", $response['data']['events']); @@ -1953,7 +1982,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); @@ -1992,7 +2021,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); @@ -2042,7 +2071,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); @@ -2130,10 +2159,13 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('files', $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}", $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files", $response['data']['channels']); + $this->assertContains('files.create', $response['data']['channels']); + $this->assertContains("buckets.{$bucketId}.files.create", $response['data']['channels']); + $this->assertContains("buckets.{$bucketId}.files.{$fileId}.create", $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}.create", $response['data']['events']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}", $response['data']['events']); $this->assertContains("buckets.{$bucketId}.files.*.create", $response['data']['events']); @@ -2169,10 +2201,13 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('files', $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}", $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files", $response['data']['channels']); + $this->assertContains('files.update', $response['data']['channels']); + $this->assertContains("buckets.{$bucketId}.files.update", $response['data']['channels']); + $this->assertContains("buckets.{$bucketId}.files.{$fileId}.update", $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}.update", $response['data']['events']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}", $response['data']['events']); $this->assertContains("buckets.{$bucketId}.files.*.update", $response['data']['events']); @@ -2200,10 +2235,13 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('files', $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}", $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files", $response['data']['channels']); + $this->assertContains('files.delete', $response['data']['channels']); + $this->assertContains("buckets.{$bucketId}.files.delete", $response['data']['channels']); + $this->assertContains("buckets.{$bucketId}.files.{$fileId}.delete", $response['data']['channels']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}.delete", $response['data']['events']); $this->assertContains("buckets.{$bucketId}.files.{$fileId}", $response['data']['events']); $this->assertContains("buckets.{$bucketId}.files.*.delete", $response['data']['events']); @@ -2320,7 +2358,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(5, $response['data']['channels']); + $this->assertCount(8, $response['data']['channels']); $this->assertContains('console', $response['data']['channels']); $this->assertContains("projects.{$this->getProject()['$id']}", $response['data']['channels']); $this->assertContains('executions', $response['data']['channels']); @@ -2343,7 +2381,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $responseUpdate['type']); $this->assertNotEmpty($responseUpdate['data']); $this->assertArrayHasKey('timestamp', $responseUpdate['data']); - $this->assertCount(5, $responseUpdate['data']['channels']); + $this->assertCount(8, $responseUpdate['data']['channels']); $this->assertContains('console', $responseUpdate['data']['channels']); $this->assertContains("projects.{$this->getProject()['$id']}", $response['data']['channels']); $this->assertContains('executions', $responseUpdate['data']['channels']); @@ -2418,9 +2456,11 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertContains('teams', $response['data']['channels']); $this->assertContains("teams.{$teamId}", $response['data']['channels']); + $this->assertContains('teams.create', $response['data']['channels']); + $this->assertContains("teams.{$teamId}.create", $response['data']['channels']); $this->assertContains("teams.{$teamId}.create", $response['data']['events']); $this->assertContains("teams.{$teamId}", $response['data']['events']); $this->assertContains("teams.*.create", $response['data']['events']); @@ -2447,9 +2487,11 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertContains('teams', $response['data']['channels']); $this->assertContains("teams.{$teamId}", $response['data']['channels']); + $this->assertContains('teams.update', $response['data']['channels']); + $this->assertContains("teams.{$teamId}.update", $response['data']['channels']); $this->assertContains("teams.{$teamId}.update", $response['data']['events']); $this->assertContains("teams.{$teamId}", $response['data']['events']); $this->assertContains("teams.*.update", $response['data']['events']); @@ -2480,9 +2522,11 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertContains('teams', $response['data']['channels']); $this->assertContains("teams.{$teamId}", $response['data']['channels']); + $this->assertContains('teams.update', $response['data']['channels']); + $this->assertContains("teams.{$teamId}.update", $response['data']['channels']); $this->assertContains("teams.{$teamId}.update", $response['data']['events']); $this->assertContains("teams.{$teamId}.update.prefs", $response['data']['events']); $this->assertContains("teams.{$teamId}", $response['data']['events']); @@ -2547,9 +2591,11 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(2, $response['data']['channels']); + $this->assertCount(4, $response['data']['channels']); $this->assertContains('memberships', $response['data']['channels']); $this->assertContains("memberships.{$membershipId}", $response['data']['channels']); + $this->assertContains('memberships.update', $response['data']['channels']); + $this->assertContains("memberships.{$membershipId}.update", $response['data']['channels']); $this->assertContains("teams.{$teamId}.memberships.{$membershipId}.update", $response['data']['events']); $this->assertContains("teams.{$teamId}.memberships.{$membershipId}", $response['data']['events']); $this->assertContains("teams.{$teamId}.memberships.*.update", $response['data']['events']); @@ -4276,7 +4322,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains('databases.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $rowId, $response['data']['channels']); $this->assertContains('databases.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); @@ -4333,7 +4379,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$rowId}", $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); @@ -4401,7 +4447,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains('rows', $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$rowId}", $response['data']['channels']); @@ -4472,7 +4518,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.create", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.create", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']); @@ -4518,7 +4564,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.create", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.create", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.create", $response['data']['events']); @@ -4582,7 +4628,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -4624,7 +4670,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -4666,7 +4712,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -4717,7 +4763,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -4760,7 +4806,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -4789,7 +4835,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("databases.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -4836,7 +4882,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']); $this->assertContains("databases.*.collections.*.documents.*.upsert", $response['data']['events']); @@ -4957,7 +5003,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains('documentsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']); $this->assertContains('documentsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); @@ -4992,7 +5038,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); @@ -5036,7 +5082,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$documentId}", $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents", $response['data']['channels']); @@ -5080,7 +5126,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertNotEmpty($response['data']['payload']); $this->assertIsArray($response['data']['payload']); $this->assertArrayHasKey('$id', $response['data']['payload']); @@ -5098,7 +5144,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertNotEmpty($response['data']['payload']); $this->assertIsArray($response['data']['payload']); $this->assertArrayHasKey('$id', $response['data']['payload']); @@ -5133,7 +5179,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("documentsdb.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -5161,7 +5207,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("documentsdb.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -5189,7 +5235,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.update", $response['data']['events']); $this->assertContains("documentsdb.*.collections.*.documents.*.update", $response['data']['events']); $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.update", $response['data']['events']); @@ -5226,7 +5272,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("documentsdb.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -5255,7 +5301,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("documentsdb.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -5284,7 +5330,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.delete", $response['data']['events']); $this->assertContains("documentsdb.*.collections.*.documents.*.delete", $response['data']['events']); $this->assertContains("documentsdb.{$databaseId}.collections.*.documents.*.delete", $response['data']['events']); @@ -5331,7 +5377,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains("documentsdb.{$databaseId}.collections.{$actorsId}.documents.{$response['data']['payload']['$id']}.upsert", $response['data']['events']); $this->assertContains("documentsdb.*.collections.*.documents.*.upsert", $response['data']['events']); @@ -5436,7 +5482,7 @@ class RealtimeCustomClientTest extends Scope $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); // vectorsdb channels should include 3 items like documentsdb - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('documents', $response['data']['channels']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); @@ -5467,7 +5513,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); $this->assertNotEmpty($response['data']['payload']); @@ -5486,7 +5532,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $documentId, $response['data']['channels']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents', $response['data']['channels']); @@ -5525,7 +5571,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $response['data']['payload']['$id'] . '.create', $response['data']['events']); $this->assertContains('vectorsdb.*.collections.*.documents.*.create', $response['data']['events']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.*.documents.*.create', $response['data']['events']); @@ -5540,7 +5586,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(3, $response['data']['channels']); + $this->assertCount(6, $response['data']['channels']); $this->assertContains('vectorsdb.' . $databaseId . '.collections.' . $actorsId . '.documents.' . $response['data']['payload']['$id'] . '.create', $response['data']['events']); $client->close(); @@ -5643,7 +5689,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.update", $response['data']['events']); $this->assertNotEmpty($response['data']['payload']); @@ -5674,7 +5720,7 @@ class RealtimeCustomClientTest extends Scope $this->assertEquals('event', $response['type']); $this->assertNotEmpty($response['data']); $this->assertArrayHasKey('timestamp', $response['data']); - $this->assertCount(8, $response['data']['channels']); + $this->assertCount(16, $response['data']['channels']); $this->assertContains("databases.{$databaseId}.collections.{$actorsId}.documents.{$documentId}.update", $response['data']['events']); $this->assertNotEmpty($response['data']['payload']); diff --git a/tests/e2e/Services/Realtime/RealtimeQueryBase.php b/tests/e2e/Services/Realtime/RealtimeQueryBase.php index 04b8400b57..5ab5c26253 100644 --- a/tests/e2e/Services/Realtime/RealtimeQueryBase.php +++ b/tests/e2e/Services/Realtime/RealtimeQueryBase.php @@ -2446,4 +2446,471 @@ trait RealtimeQueryBase $clientWithMatchingQuery->close(); $clientWithNonMatchingQuery->close(); } + + /** + * Sets up a database + collection + 'name' string attribute, returning their IDs. + * Used by action-channel tests to avoid duplicating fixture code. + * + * @return array{databaseId: string, collectionId: string} + */ + private function createActorsCollection(): array + { + $database = $this->client->call(Client::METHOD_POST, '/databases', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'databaseId' => ID::unique(), + 'name' => 'Action Channel DB', + ]); + $databaseId = $database['body']['$id']; + + $collection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Actors', + 'permissions' => [ + Permission::create(Role::user($this->getUser()['$id'])), + ], + 'documentSecurity' => true, + ]); + $collectionId = $collection['body']['$id']; + + $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/string', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'key' => 'name', + 'size' => 256, + 'required' => true, + ]); + + $this->assertEventually(function () use ($databaseId, $collectionId) { + $response = $this->client->call(Client::METHOD_GET, '/databases/' . $databaseId . '/collections/' . $collectionId . '/attributes/name', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'], + ])); + $this->assertEquals('available', $response['body']['status']); + }, 30000, 250); + + return ['databaseId' => $databaseId, 'collectionId' => $collectionId]; + } + + /** + * Creates a document with the given ID and name. Returns the parsed body. + * Permissions allow Role::any() for all CRUD so any session can observe the events. + * + * @return array + */ + private function createActor(string $databaseId, string $collectionId, string $documentId, string $name): array + { + $document = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections/' . $collectionId . '/documents', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ], $this->getHeaders()), [ + 'documentId' => $documentId, + 'data' => ['name' => $name], + 'permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ]); + + return $document['body']; + } + + public function testChannelActionFilterReflectedInConnectedResponse(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + // Subscribing with an action suffix should round-trip the original channel + // name on the connected response. Only meaningful in URL-subscribe mode — + // the message-based path consumes the connected response inside its + // getWebsocket helper before returning, so we can't observe it here. + $client = $this->getWebsocket([ + 'documents.create', + 'documents.update', + 'documents.upsert', + 'documents', + ], $headers); + + $connected = $this->assertConnectionStatusIfSupported($client); + if ($connected === null) { + $client->close(); + $this->markTestSkipped('Connected-response channels are not surfaced through the message-based subscribe path.'); + } + + $this->assertContains('documents.create', $connected['data']['channels']); + $this->assertContains('documents.update', $connected['data']['channels']); + $this->assertContains('documents.upsert', $connected['data']['channels']); + $this->assertContains('documents', $connected['data']['channels']); + + $client->close(); + } + + public function testChannelActionFilterDeliversOnlyMatchingActions(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + ['databaseId' => $databaseId, 'collectionId' => $collectionId] = $this->createActorsCollection(); + + $createChannel = "databases.{$databaseId}.collections.{$collectionId}.documents.create"; + $updateChannel = "databases.{$databaseId}.collections.{$collectionId}.documents.update"; + $upsertChannel = "databases.{$databaseId}.collections.{$collectionId}.documents.upsert"; + + $clientCreate = $this->getWebsocket([$createChannel], $headers); + $clientUpdate = $this->getWebsocket([$updateChannel], $headers); + $clientUpsert = $this->getWebsocket([$upsertChannel], $headers); + + $this->assertConnectionStatusIfSupported($clientCreate); + $this->assertConnectionStatusIfSupported($clientUpdate); + $this->assertConnectionStatusIfSupported($clientUpsert); + + $documentId = ID::unique(); + $this->createActor($databaseId, $collectionId, $documentId, 'Chris Evans'); + + // Create event delivers only to the .create subscriber. + $createEvent = json_decode($clientCreate->receive(), true); + $this->assertEquals('event', $createEvent['type']); + $this->assertContains( + "databases.{$databaseId}.collections.{$collectionId}.documents.{$documentId}.create", + $createEvent['data']['events'] + ); + $this->assertEquals('Chris Evans', $createEvent['data']['payload']['name']); + + try { + $clientUpdate->receive(); + $this->fail('Update subscriber should not receive a create event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + try { + $clientUpsert->receive(); + $this->fail('Upsert subscriber should not receive a create event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + // Update fires update events; only the .update subscriber should hear them. + $this->client->call(Client::METHOD_PATCH, "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'data' => ['name' => 'Chris Evans 2'], + ]); + + $updateEvent = json_decode($clientUpdate->receive(), true); + $this->assertEquals('event', $updateEvent['type']); + $this->assertContains( + "databases.{$databaseId}.collections.{$collectionId}.documents.{$documentId}.update", + $updateEvent['data']['events'] + ); + $this->assertEquals('Chris Evans 2', $updateEvent['data']['payload']['name']); + + try { + $clientCreate->receive(); + $this->fail('Create subscriber should not receive an update event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + try { + $clientUpsert->receive(); + $this->fail('Upsert subscriber should not receive an update event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + // PUT bulk upsert fires upsert events; only the .upsert subscriber should hear them. + $this->client->call(Client::METHOD_PUT, "/databases/{$databaseId}/collections/{$collectionId}/documents", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $this->getProject()['apiKey'], + ]), [ + 'documents' => [ + [ + '$id' => ID::unique(), + 'name' => 'Robert Downey Jr.', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ], + ], + ]); + + $upsertEvent = json_decode($clientUpsert->receive(), true); + $this->assertEquals('event', $upsertEvent['type']); + $this->assertContains( + "databases.{$databaseId}.collections.*.documents.*.upsert", + $upsertEvent['data']['events'] + ); + + try { + $clientCreate->receive(); + $this->fail('Create subscriber should not receive an upsert event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + try { + $clientUpdate->receive(); + $this->fail('Update subscriber should not receive an upsert event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + $clientCreate->close(); + $clientUpdate->close(); + $clientUpsert->close(); + } + + public function testChannelActionFilterByDocumentId(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + ['databaseId' => $databaseId, 'collectionId' => $collectionId] = $this->createActorsCollection(); + + // Use a known custom ID so the .id.action channel can be subscribed before the + // document exists. Without this the channel name can't be predicted. + $watchedId = 'actor-watched'; + $idCreateChannel = "databases.{$databaseId}.collections.{$collectionId}.documents.{$watchedId}.create"; + + $clientWatched = $this->getWebsocket([$idCreateChannel], $headers); + $connected = $this->assertConnectionStatusIfSupported($clientWatched); + if ($connected !== null) { + $this->assertContains($idCreateChannel, $connected['data']['channels']); + } + + // Creating a *different* document should not trigger the watched-id subscription. + $this->createActor($databaseId, $collectionId, ID::unique(), 'Other Actor'); + + try { + $clientWatched->receive(); + $this->fail('Subscriber to .{id}.create should not receive events for a different document.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + // Creating the watched document delivers exactly one create event. + $this->createActor($databaseId, $collectionId, $watchedId, 'Watched Actor'); + + $event = json_decode($clientWatched->receive(), true); + $this->assertEquals('event', $event['type']); + $this->assertContains( + "databases.{$databaseId}.collections.{$collectionId}.documents.{$watchedId}.create", + $event['data']['events'] + ); + $this->assertEquals($watchedId, $event['data']['payload']['$id']); + $this->assertEquals('Watched Actor', $event['data']['payload']['name']); + + // Updating the watched document does NOT match — action filter is `create` only. + $this->client->call(Client::METHOD_PATCH, "/databases/{$databaseId}/collections/{$collectionId}/documents/{$watchedId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'data' => ['name' => 'Watched Actor v2'], + ]); + + try { + $clientWatched->receive(); + $this->fail('Subscriber to .{id}.create should not receive update events on the same document.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + $clientWatched->close(); + } + + public function testChannelActionFilterMultiChannelSubscription(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + ['databaseId' => $databaseId, 'collectionId' => $collectionId] = $this->createActorsCollection(); + + $watchedId = 'actor-multi'; + $idCreateChannel = "databases.{$databaseId}.collections.{$collectionId}.documents.{$watchedId}.create"; + $rowsChannel = "databases.{$databaseId}.tables.{$collectionId}.rows"; + + // One subscription that listens on both: + // 1. `databases...documents.{watchedId}.create` — narrow, action-filtered + // 2. `databases...tables.{collectionId}.rows` — broad, non-action (tablesdb mirror) + // A create on the watched document must reach this subscriber via *both* channels. + $clientMulti = $this->getWebsocket([$idCreateChannel, $rowsChannel], $headers); + $connected = $this->assertConnectionStatusIfSupported($clientMulti); + if ($connected !== null) { + $this->assertContains($idCreateChannel, $connected['data']['channels']); + $this->assertContains($rowsChannel, $connected['data']['channels']); + } + + $this->createActor($databaseId, $collectionId, $watchedId, 'Multi Actor'); + + $event = json_decode($clientMulti->receive(), true); + $this->assertEquals('event', $event['type']); + // The event payload's channels list reports the underlying base channels that + // the published event carries. Both the broad rows channel and the document + // channel that the action filter is anchored on should be present. + $this->assertContains($rowsChannel, $event['data']['channels']); + $this->assertContains( + "databases.{$databaseId}.collections.{$collectionId}.documents.{$watchedId}", + $event['data']['channels'] + ); + $this->assertContains( + "databases.{$databaseId}.collections.{$collectionId}.documents.{$watchedId}.create", + $event['data']['events'] + ); + $this->assertEquals('Multi Actor', $event['data']['payload']['name']); + + // Update on the same doc: the .{id}.create branch is filtered out, but the + // broad rows channel has no action filter — the subscription still receives + // the event via that branch (a single delivery, not two). + $this->client->call(Client::METHOD_PATCH, "/databases/{$databaseId}/collections/{$collectionId}/documents/{$watchedId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()), [ + 'data' => ['name' => 'Multi Actor v2'], + ]); + + $update = json_decode($clientMulti->receive(), true); + $this->assertEquals('event', $update['type']); + $this->assertContains($rowsChannel, $update['data']['channels']); + $this->assertContains( + "databases.{$databaseId}.collections.{$collectionId}.documents.{$watchedId}.update", + $update['data']['events'] + ); + + // No second copy of the same update should arrive — getSubscribers folds + // multi-channel matches into a single connection delivery. + try { + $clientMulti->receive(); + $this->fail('Multi-channel subscriber should receive a single delivery per event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + $clientMulti->close(); + } + + public function testChannelActionFilterDeliversDeleteEvents(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + ['databaseId' => $databaseId, 'collectionId' => $collectionId] = $this->createActorsCollection(); + + $deleteChannel = "databases.{$databaseId}.collections.{$collectionId}.documents.delete"; + $clientDelete = $this->getWebsocket([$deleteChannel], $headers); + $connected = $this->assertConnectionStatusIfSupported($clientDelete); + if ($connected !== null) { + $this->assertContains($deleteChannel, $connected['data']['channels']); + } + + $documentId = ID::unique(); + $this->createActor($databaseId, $collectionId, $documentId, 'About To Be Deleted'); + + // Create event must not arrive — the action filter is `delete`. + try { + $clientDelete->receive(); + $this->fail('Delete subscriber should not receive a create event.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + $this->client->call(Client::METHOD_DELETE, "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders())); + + $deleteEvent = json_decode($clientDelete->receive(), true); + $this->assertEquals('event', $deleteEvent['type']); + $this->assertContains( + "databases.{$databaseId}.collections.{$collectionId}.documents.{$documentId}.delete", + $deleteEvent['data']['events'] + ); + $this->assertEquals($documentId, $deleteEvent['data']['payload']['$id']); + + $clientDelete->close(); + } + + public function testChannelActionFilterUnknownSuffixTreatedAsLiteral(): void + { + $user = $this->getUser(); + $session = $user['session'] ?? ''; + $projectId = $this->getProject()['$id']; + + $headers = [ + 'origin' => 'http://localhost', + 'cookie' => 'a_session_' . $projectId . '=' . $session, + ]; + + ['databaseId' => $databaseId, 'collectionId' => $collectionId] = $this->createActorsCollection(); + + // An unrecognised suffix is NOT in SUPPORTED_ACTIONS, so parseActionChannel + // leaves the channel name intact and treats it as a literal channel that no + // published event ever carries — the subscriber should receive nothing. + $client = $this->getWebsocket(['documents.bogus'], $headers); + $connected = $this->assertConnectionStatusIfSupported($client); + if ($connected !== null) { + $this->assertContains('documents.bogus', $connected['data']['channels']); + } + + $documentId = ID::unique(); + $this->createActor($databaseId, $collectionId, $documentId, 'No Bogus Listener'); + + $this->client->call(Client::METHOD_DELETE, "/databases/{$databaseId}/collections/{$collectionId}/documents/{$documentId}", array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders())); + + try { + $client->receive(); + $this->fail('Unrecognised action suffix should not deliver any events.'); + } catch (TimeoutException $e) { + $this->addToAssertionCount(1); + } + + $client->close(); + } } diff --git a/tests/e2e/Services/Users/UsersBase.php b/tests/e2e/Services/Users/UsersBase.php index 3255d9a67f..b06e2d88e1 100644 --- a/tests/e2e/Services/Users/UsersBase.php +++ b/tests/e2e/Services/Users/UsersBase.php @@ -2708,6 +2708,102 @@ trait UsersBase $this->assertIsArray($response['body']['users']); } + /** + * Test impersonation via URL query params — mirrors the ?project= and ?devKey= pattern. + * Allows Console to embed impersonation in direct file/image URLs where headers cannot be set. + */ + public function testImpersonateByQueryParams(): void + { + $projectId = $this->getProject()['$id']; + $headers = array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], $this->getHeaders()); + + $emailA = 'queryparam-impersonator-' . \uniqid() . '@appwrite.io'; + $emailB = 'queryparam-target-' . \uniqid() . '@appwrite.io'; + $emailC = 'queryparam-target-c-' . \uniqid() . '@appwrite.io'; + $phone = '+1' . \rand(1000000000, 9999999999); + + $userA = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => $emailA, + 'password' => 'password', + 'name' => 'Query Param Impersonator', + ]); + $this->assertEquals(201, $userA['headers']['status-code']); + $idA = $userA['body']['$id']; + + $userB = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => $emailB, + 'password' => 'password', + 'name' => 'Query Param Target', + ]); + $this->assertEquals(201, $userB['headers']['status-code']); + $idB = $userB['body']['$id']; + + $patch = $this->client->call(Client::METHOD_PATCH, '/users/' . $idA . '/impersonator', $headers, ['impersonator' => true]); + $this->assertEquals(200, $patch['headers']['status-code']); + + $session = $this->client->call(Client::METHOD_POST, '/users/' . $idA . '/sessions', $headers); + $this->assertEquals(201, $session['headers']['status-code']); + $sessionSecret = $session['body']['secret']; + + $sessionHeaders = [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-session' => $sessionSecret, + ]; + + // Impersonate by user ID via query param + $account = $this->client->call(Client::METHOD_GET, '/account', $sessionHeaders, [ + 'impersonateUserId' => $idB, + ]); + $this->assertEquals(200, $account['headers']['status-code']); + $this->assertEquals($idB, $account['body']['$id']); + $this->assertEquals('Query Param Target', $account['body']['name']); + $this->assertEquals($idA, $account['body']['impersonatorUserId']); + + // Impersonate by email via query param + $accountByEmail = $this->client->call(Client::METHOD_GET, '/account', $sessionHeaders, [ + 'impersonateEmail' => $emailB, + ]); + $this->assertEquals(200, $accountByEmail['headers']['status-code']); + $this->assertEquals($idB, $accountByEmail['body']['$id']); + $this->assertEquals($idA, $accountByEmail['body']['impersonatorUserId']); + + // Impersonate by phone via query param (update target user with a phone first) + $this->client->call(Client::METHOD_PATCH, '/users/' . $idB . '/phone', $headers, [ + 'number' => $phone, + ]); + $accountByPhone = $this->client->call(Client::METHOD_GET, '/account', $sessionHeaders, [ + 'impersonatePhone' => $phone, + ]); + $this->assertEquals(200, $accountByPhone['headers']['status-code']); + $this->assertEquals($idB, $accountByPhone['body']['$id']); + $this->assertEquals($idA, $accountByPhone['body']['impersonatorUserId']); + + // Header takes priority over query param when both are present + $userC = $this->client->call(Client::METHOD_POST, '/users', $headers, [ + 'userId' => ID::unique(), + 'email' => $emailC, + 'password' => 'password', + 'name' => 'Query Param Target C', + ]); + $this->assertEquals(201, $userC['headers']['status-code']); + $idC = $userC['body']['$id']; + + $accountHeaderPriority = $this->client->call( + Client::METHOD_GET, + '/account', + array_merge($sessionHeaders, ['x-appwrite-impersonate-user-id' => $idC]), + ['impersonateUserId' => $idB] + ); + $this->assertEquals(200, $accountHeaderPriority['headers']['status-code']); + $this->assertEquals($idC, $accountHeaderPriority['body']['$id'], 'header must take priority over query param'); + } + /** * Test PATCH /users/:userId/impersonator for non-existent user returns 404 */ diff --git a/tests/unit/Messaging/MessagingTest.php b/tests/unit/Messaging/MessagingTest.php index 7a8885f6e3..d2b125251a 100644 --- a/tests/unit/Messaging/MessagingTest.php +++ b/tests/unit/Messaging/MessagingTest.php @@ -396,6 +396,248 @@ class MessagingTest extends TestCase $this->assertArrayNotHasKey('account.456', $channels); } + public function testConvertChannelsRewritesAccountActionSuffixes(): void + { + // Authenticated subscriber to `account.{action}` is translated to the + // user-scoped `account.{userId}.{action}` form so events from other + // users' accounts don't leak through the literal channel. + $channels = Realtime::convertChannels( + ['account.create', 'account.update', 'account.upsert', 'account.delete'], + '123', + ); + + $this->assertArrayHasKey('account.123.create', $channels); + $this->assertArrayHasKey('account.123.update', $channels); + $this->assertArrayHasKey('account.123.upsert', $channels); + $this->assertArrayHasKey('account.123.delete', $channels); + $this->assertArrayNotHasKey('account.create', $channels); + $this->assertArrayNotHasKey('account.update', $channels); + $this->assertArrayNotHasKey('account.upsert', $channels); + $this->assertArrayNotHasKey('account.delete', $channels); + + // Other-user channels and unknown action-like suffixes still get stripped. + $channels = Realtime::convertChannels( + ['account.other_id', 'account.bogus', 'account.123', 'account.create'], + '123', + ); + $this->assertArrayNotHasKey('account.other_id', $channels); + $this->assertArrayNotHasKey('account.bogus', $channels); + $this->assertArrayNotHasKey('account.123', $channels); + $this->assertArrayHasKey('account.123.create', $channels); + } + + public function testConvertChannelsPreservesAccountActionsForGuest(): void + { + // Guests can't scope an action filter to a userId yet, so `account.{action}` + // is preserved verbatim. fromPayload publishes the unscoped `account.{action}` + // channel for top-level user events, so the guest's stored form matches and + // delivers correctly. After the connection authenticates, + // rebindAccountChannels rewrites the literal to `account.{userId}.{action}` + // so the action filter survives the auth transition. + $channels = Realtime::convertChannels( + ['account.create', 'account.update', 'account.upsert', 'account.delete', 'account'], + '', + ); + + $this->assertArrayHasKey('account.create', $channels); + $this->assertArrayHasKey('account.update', $channels); + $this->assertArrayHasKey('account.upsert', $channels); + $this->assertArrayHasKey('account.delete', $channels); + $this->assertArrayHasKey('account', $channels); + } + + public function testRebindAccountChannelsRemapsAfterReauth(): void + { + // Reauth as a different user must remap the user-scoped channels so the + // connection no longer receives the previous user's account events. + $rebound = Realtime::rebindAccountChannels( + ['account.A', 'account.A.create', 'account.A.update', 'documents', 'documents.A.something'], + 'A', + 'B', + ); + + $this->assertContains('account.B', $rebound); + $this->assertContains('account.B.create', $rebound); + $this->assertContains('account.B.update', $rebound); + $this->assertNotContains('account.A', $rebound); + $this->assertNotContains('account.A.create', $rebound); + $this->assertNotContains('account.A.update', $rebound); + + // Non-account channels left alone — the rewrite is precise. + $this->assertContains('documents', $rebound); + $this->assertContains('documents.A.something', $rebound); + } + + public function testRebindAccountChannelsIsNoopForUnchangedUser(): void + { + // Same user → nothing to rewrite. Avoids unnecessary churn when the + // permissionsChanged path fires (roles change, userId is constant). + $channels = ['account.A', 'account.A.create', 'documents']; + $this->assertSame($channels, Realtime::rebindAccountChannels($channels, 'A', 'A')); + } + + public function testRebindAccountChannelsIsNoopForEmptyTarget(): void + { + // Defensive: if a caller ever passes an empty $newUserId (e.g. a + // hypothetical in-band logout), we leave channels untouched rather than + // producing malformed `account.` strings. + $channels = ['account.A', 'account.A.create', 'account.create', 'documents']; + $this->assertSame($channels, Realtime::rebindAccountChannels($channels, 'A', '')); + $this->assertSame($channels, Realtime::rebindAccountChannels($channels, '', '')); + } + + public function testRebindAccountChannelsPromotesGuestActionFilters(): void + { + // Guest connections store `account.{action}` literally (convertChannels + // preserves the form when userId is empty). On in-band authentication, + // rebindAccountChannels promotes those literals to user-scoped form so + // the action filter survives. + $rebound = Realtime::rebindAccountChannels( + ['account', 'account.create', 'account.update', 'documents'], + '', + 'B', + ); + + $this->assertContains('account.B.create', $rebound); + $this->assertContains('account.B.update', $rebound); + $this->assertNotContains('account.create', $rebound); + $this->assertNotContains('account.update', $rebound); + + // Plain `account` and unrelated channels are left alone. + $this->assertContains('account', $rebound); + $this->assertContains('documents', $rebound); + } + + public function testRebindAccountChannelsOnlyRemapsKnownActions(): void + { + // Defensive: only suffixes in SUPPORTED_ACTIONS are rewritten, so a + // channel like `account.A.bogus` stays intact rather than being + // silently rebound. + $rebound = Realtime::rebindAccountChannels( + ['account.A.bogus', 'account.A.create'], + 'A', + 'B', + ); + + $this->assertContains('account.A.bogus', $rebound); + $this->assertContains('account.B.create', $rebound); + $this->assertNotContains('account.B.bogus', $rebound); + $this->assertNotContains('account.A.create', $rebound); + } + + public function testReauthThenPermissionsChangeThenReauthPreservesAccountAction(): void + { + // Full lifecycle, mirrors the auth + permissionsChanged handler logic in + // app/realtime.php: + // 1. user A subscribes to account.create (stored as account.A.create) + // 2. in-band reauth as B → rebound to account.B.create, userId=B + // 3. permissions-change for B → userId on connection MUST stay 'B' + // so a subsequent reauth as C still has previousUserId='B'. + // 4. reauth as C → rebound to account.C.create, userId=C + $realtime = new Realtime(); + + // Step 1. + $aChannels = \array_keys(Realtime::convertChannels(['account.create'], 'A')); + $this->assertSame(['account.A.create'], $aChannels); + $realtime->subscribe('1', 1, 'sub-1', [Role::user(ID::custom('A'))->toString()], $aChannels, [], 'A'); + $this->assertSame('A', $realtime->connections[1]['userId']); + + // Step 2: A → B. + $previousUserId = $realtime->connections[1]['userId']; + $meta = $realtime->getSubscriptionMetadata(1); + $realtime->unsubscribe(1); + foreach ($meta as $subId => $sub) { + $rebound = Realtime::rebindAccountChannels($sub['channels'], $previousUserId, 'B'); + $realtime->subscribe('1', 1, $subId, [Role::user(ID::custom('B'))->toString()], $rebound, [], 'B'); + } + $this->assertSame('B', $realtime->connections[1]['userId']); + $this->assertContains('account.B.create', $realtime->connections[1]['channels']); + + // Step 3: permissions-change for B (userId stays 'B'). + $previousUserId = $realtime->connections[1]['userId']; + $meta = $realtime->getSubscriptionMetadata(1); + $realtime->unsubscribe(1); + foreach ($meta as $subId => $sub) { + $rebound = Realtime::rebindAccountChannels($sub['channels'], $previousUserId, 'B'); + $realtime->subscribe('1', 1, $subId, [Role::user(ID::custom('B'))->toString()], $rebound, [], 'B'); + } + $this->assertSame('B', $realtime->connections[1]['userId']); + $this->assertContains('account.B.create', $realtime->connections[1]['channels']); + + // Step 4: B → C. + $previousUserId = $realtime->connections[1]['userId']; + $meta = $realtime->getSubscriptionMetadata(1); + $realtime->unsubscribe(1); + foreach ($meta as $subId => $sub) { + $rebound = Realtime::rebindAccountChannels($sub['channels'], $previousUserId, 'C'); + $realtime->subscribe('1', 1, $subId, [Role::user(ID::custom('C'))->toString()], $rebound, [], 'C'); + } + $this->assertSame('C', $realtime->connections[1]['userId']); + $this->assertContains('account.C.create', $realtime->connections[1]['channels']); + $this->assertNotContains('account.B.create', $realtime->connections[1]['channels']); + $this->assertNotContains('account.A.create', $realtime->connections[1]['channels']); + } + + public function testGuestAccountActionFilterSurvivesAuthenticationEndToEnd(): void + { + // Full lifecycle: + // 1. Guest connects, subscribes to `account.create`. + // 2. fromPayload publishes a top-level `users.B.create` event — guest + // receives it via the unscoped `account.create` broadcast channel. + // 3. Guest authenticates as B. Resubscribe goes through + // rebindAccountChannels so the same subscription is now scoped to + // `account.B.create` and only matches B's events. + $realtime = new Realtime(); + + // Step 1: guest subscribes. convertChannels preserves the literal form. + $guestChannels = \array_keys(Realtime::convertChannels(['account.create'], '')); + $this->assertSame(['account.create'], $guestChannels); + $realtime->subscribe('1', 1, 'sub-1', [Role::guests()->toString()], $guestChannels, [], ''); + + // Step 2: fromPayload publishes account.create alongside the user-scoped form. + $publish = Realtime::fromPayload( + event: 'users.B.create', + payload: new Document(['$id' => ID::custom('B')]), + ); + $this->assertContains('account.create', $publish['channels']); + $this->assertContains('account.B.create', $publish['channels']); + + // Guest receives the unscoped channel. + $event = [ + 'project' => '1', + 'roles' => [Role::guests()->toString()], + 'data' => [ + 'channels' => $publish['channels'], + 'payload' => ['$id' => 'B'], + ], + ]; + $this->assertArrayHasKey(1, $realtime->getSubscribers($event)); + + // Step 3: in-band auth promotes the guest to user 'B'. + $previousUserId = $realtime->connections[1]['userId'] ?? ''; + $meta = $realtime->getSubscriptionMetadata(1); + $realtime->unsubscribe(1); + foreach ($meta as $subId => $sub) { + $rebound = Realtime::rebindAccountChannels($sub['channels'], $previousUserId, 'B'); + $realtime->subscribe('1', 1, $subId, [Role::user(ID::custom('B'))->toString()], $rebound, [], 'B'); + } + + // Literal channel is gone; user-scoped form is in place. + $this->assertNotContains('account.create', $realtime->connections[1]['channels']); + $this->assertContains('account.B.create', $realtime->connections[1]['channels']); + + // B-scoped event delivers via the user-scoped channel. + $bEvent = [ + 'project' => '1', + 'roles' => [Role::user(ID::custom('B'))->toString()], + 'data' => [ + 'channels' => $publish['channels'], + 'payload' => ['$id' => 'B'], + ], + ]; + $this->assertArrayHasKey(1, $realtime->getSubscribers($bEvent)); + } + public function testFromPayloadPermissions(): void { /** @@ -517,6 +759,272 @@ class MessagingTest extends TestCase $this->assertContains(Role::any()->toString(), $result['roles']); $this->assertContains(Role::team('123abc')->toString(), $result['roles']); } + public function testFromPayloadEmitsActionSuffixedChannels(): void + { + $result = Realtime::fromPayload( + event: 'databases.database_id.collections.collection_id.documents.document_id.create', + payload: new Document([ + '$id' => ID::custom('document_id'), + '$collection' => ID::custom('collection_id'), + '$collectionId' => 'collection_id', + '$permissions' => [Permission::read(Role::any())], + ]), + database: new Document(['$id' => ID::custom('database_id')]), + collection: new Document([ + '$id' => ID::custom('collection_id'), + '$permissions' => [Permission::read(Role::any())], + ]) + ); + + // Base channels remain. + $this->assertContains('documents', $result['channels']); + $this->assertContains('databases.database_id.collections.collection_id.documents', $result['channels']); + $this->assertContains('databases.database_id.collections.collection_id.documents.document_id', $result['channels']); + + // Action-suffixed variants are appended for every base channel. + $this->assertContains('documents.create', $result['channels']); + $this->assertContains('databases.database_id.collections.collection_id.documents.create', $result['channels']); + $this->assertContains('databases.database_id.collections.collection_id.documents.document_id.create', $result['channels']); + + // No mismatched action suffixes leak in. + $this->assertNotContains('documents.update', $result['channels']); + $this->assertNotContains('documents.delete', $result['channels']); + } + + public function testFromPayloadEmitsActionSuffixForEveryAction(): void + { + foreach (['create', 'update', 'upsert', 'delete'] as $action) { + $result = Realtime::fromPayload( + event: "databases.database_id.collections.collection_id.documents.document_id.{$action}", + payload: new Document([ + '$id' => ID::custom('document_id'), + '$collection' => ID::custom('collection_id'), + '$collectionId' => 'collection_id', + '$permissions' => [Permission::read(Role::any())], + ]), + database: new Document(['$id' => ID::custom('database_id')]), + collection: new Document([ + '$id' => ID::custom('collection_id'), + '$permissions' => [Permission::read(Role::any())], + ]) + ); + + $this->assertContains("documents.{$action}", $result['channels'], "documents.{$action} missing"); + $this->assertContains( + "databases.database_id.collections.collection_id.documents.document_id.{$action}", + $result['channels'], + "specific-doc {$action} channel missing" + ); + } + } + + public function testFromPayloadDoesNotSuffixWhenNoAction(): void + { + // Synthetic event without an action segment: e.g. an attribute event whose + // last segment is not a known action and whose second-to-last segment is + // also not a known action. + $result = Realtime::fromPayload( + event: 'buckets.bucket_id.files.file_id.update', + payload: new Document([ + '$id' => ID::custom('file_id'), + 'bucketId' => 'bucket_id', + '$permissions' => [Permission::read(Role::any())], + ]), + bucket: new Document([ + '$id' => ID::custom('bucket_id'), + '$permissions' => [Permission::read(Role::any())], + ]) + ); + + // Action-suffixed variants for the file event. + $this->assertContains('files.update', $result['channels']); + $this->assertContains('buckets.bucket_id.files.update', $result['channels']); + $this->assertContains('buckets.bucket_id.files.file_id.update', $result['channels']); + + // Base channels remain. + $this->assertContains('files', $result['channels']); + $this->assertContains('buckets.bucket_id.files', $result['channels']); + $this->assertContains('buckets.bucket_id.files.file_id', $result['channels']); + } + + public function testFromPayloadDoesNotSuffixAdminChannels(): void + { + // Function execution event emits resource-leaf channels (executions / functions) + // alongside admin channels (console / projects.X). Admin channels must NOT + // get an action suffix — only the resource-leaf channels do. + $result = Realtime::fromPayload( + event: 'functions.function_id.executions.execution_id.create', + payload: new Document([ + '$id' => ID::custom('execution_id'), + 'functionId' => 'function_id', + '$read' => [Role::any()->toString()], + '$permissions' => [Permission::read(Role::any())], + ]), + project: new Document([ + '$id' => ID::custom('project_id'), + 'teamId' => '123abc', + ]) + ); + + // Resource-leaf channels are suffixed. + $this->assertContains('executions', $result['channels']); + $this->assertContains('executions.create', $result['channels']); + $this->assertContains('executions.execution_id', $result['channels']); + $this->assertContains('executions.execution_id.create', $result['channels']); + $this->assertContains('functions.function_id', $result['channels']); + $this->assertContains('functions.function_id.create', $result['channels']); + + // Admin channels are NOT suffixed. + $this->assertContains('console', $result['channels']); + $this->assertNotContains('console.create', $result['channels']); + $this->assertContains('projects.project_id', $result['channels']); + $this->assertNotContains('projects.project_id.create', $result['channels']); + + // The bare `functions` channel is never emitted by fromPayload (only + // `functions.{functionId}` is). The per-function action variant + // (`functions.{functionId}.create`) is the supported subscription + // form — bare `functions.create` would be a silent no-op and must + // therefore NOT appear in the published channel set either. + $this->assertNotContains('functions', $result['channels']); + $this->assertNotContains('functions.create', $result['channels']); + } + + public function testFromPayloadHandlesAttributeTrailingActionEvents(): void + { + // `users.[userId].update.{attr}` (e.g. .email, .prefs, .name) — action is the + // second-to-last segment, not the last one. The suffix must still be `.update`. + $userResult = Realtime::fromPayload( + event: 'users.user_id.update.email', + payload: new Document(['$id' => ID::custom('user_id')]) + ); + + $this->assertContains('account', $userResult['channels']); + $this->assertContains('account.user_id', $userResult['channels']); + $this->assertContains('account.update', $userResult['channels']); + $this->assertContains('account.user_id.update', $userResult['channels']); + // The attribute name must NOT leak into the channel namespace. + $this->assertNotContains('account.email', $userResult['channels']); + $this->assertNotContains('account.user_id.email', $userResult['channels']); + + // `teams.[teamId].update.prefs` — same shape at the team level. + $teamResult = Realtime::fromPayload( + event: 'teams.team_id.update.prefs', + payload: new Document(['$id' => ID::custom('team_id')]) + ); + + $this->assertContains('teams', $teamResult['channels']); + $this->assertContains('teams.team_id', $teamResult['channels']); + $this->assertContains('teams.update', $teamResult['channels']); + $this->assertContains('teams.team_id.update', $teamResult['channels']); + $this->assertNotContains('teams.prefs', $teamResult['channels']); + $this->assertNotContains('teams.team_id.prefs', $teamResult['channels']); + + // `teams.[teamId].memberships.[membershipId].update.{attr}` — same again, deeper. + $membershipResult = Realtime::fromPayload( + event: 'teams.team_id.memberships.membership_id.update.status', + payload: new Document(['$id' => ID::custom('membership_id')]) + ); + + $this->assertContains('memberships', $membershipResult['channels']); + $this->assertContains('memberships.membership_id', $membershipResult['channels']); + $this->assertContains('memberships.update', $membershipResult['channels']); + $this->assertContains('memberships.membership_id.update', $membershipResult['channels']); + $this->assertNotContains('memberships.status', $membershipResult['channels']); + $this->assertNotContains('memberships.membership_id.status', $membershipResult['channels']); + } + + public function testFromPayloadDoesNotSuffixAccountForNestedUserEvents(): void + { + // Nested user events (challenges/sessions/recovery/verification) emit only + // user-level account channels in fromPayload. The trailing action belongs to + // the nested resource, NOT to the user account. A subscriber to + // `account.create` must not receive `users.U.challenges.C.create` or + // `users.U.sessions.S.delete` events — that would silently leak unrelated + // MFA / session traffic into account-level filters. + foreach (['challenges', 'sessions', 'recovery', 'verification'] as $sub) { + foreach (['create', 'update', 'delete'] as $action) { + $result = Realtime::fromPayload( + event: "users.user_id.{$sub}.sub_id.{$action}", + payload: new Document(['$id' => ID::custom('sub_id')]) + ); + + $this->assertContains('account', $result['channels'], "{$sub}.{$action} should still emit base account channel"); + $this->assertContains('account.user_id', $result['channels'], "{$sub}.{$action} should still emit user-scoped account channel"); + $this->assertNotContains("account.{$action}", $result['channels'], "{$sub}.{$action} must NOT leak action suffix onto account channel"); + $this->assertNotContains("account.user_id.{$action}", $result['channels'], "{$sub}.{$action} must NOT leak action suffix onto user-scoped account channel"); + } + } + + // Top-level user events SHOULD still suffix — guard against an over-eager fix + // that suppresses the suffix for legitimate account-level CRUD. + $createResult = Realtime::fromPayload( + event: 'users.user_id.create', + payload: new Document(['$id' => ID::custom('user_id')]) + ); + $this->assertContains('account.create', $createResult['channels']); + $this->assertContains('account.user_id.create', $createResult['channels']); + + $updateResult = Realtime::fromPayload( + event: 'users.user_id.update.email', + payload: new Document(['$id' => ID::custom('user_id')]) + ); + $this->assertContains('account.update', $updateResult['channels']); + $this->assertContains('account.user_id.update', $updateResult['channels']); + } + + public function testActionSuffixDeliversOnlyMatchingActionEndToEnd(): void + { + $realtime = new Realtime(); + + // Subscriber A scopes to creates; Subscriber B scopes to deletes. + $realtime->subscribe('1', 1, 'sub-create', [Role::any()->toString()], ['documents.create']); + $realtime->subscribe('1', 2, 'sub-delete', [Role::any()->toString()], ['documents.delete']); + + // Simulate what fromPayload would publish for a create event. + $createEvent = [ + 'project' => '1', + 'roles' => [Role::any()->toString()], + 'data' => [ + 'channels' => ['documents', 'documents.create'], + 'payload' => ['$id' => 'doc'], + ], + ]; + $createReceivers = $realtime->getSubscribers($createEvent); + $this->assertArrayHasKey(1, $createReceivers); + $this->assertArrayNotHasKey(2, $createReceivers); + + // Delete event. + $deleteEvent = [ + 'project' => '1', + 'roles' => [Role::any()->toString()], + 'data' => [ + 'channels' => ['documents', 'documents.delete'], + 'payload' => ['$id' => 'doc'], + ], + ]; + $deleteReceivers = $realtime->getSubscribers($deleteEvent); + $this->assertArrayHasKey(2, $deleteReceivers); + $this->assertArrayNotHasKey(1, $deleteReceivers); + } + + public function testPlainChannelStillReceivesAllActionsEndToEnd(): void + { + $realtime = new Realtime(); + + $realtime->subscribe('1', 1, 'sub-all', [Role::any()->toString()], ['documents']); + + foreach (['create', 'update', 'upsert', 'delete'] as $action) { + $event = [ + 'project' => '1', + 'roles' => [Role::any()->toString()], + 'data' => [ + 'channels' => ['documents', "documents.{$action}"], + 'payload' => ['$id' => 'doc'], + ], + ]; + $this->assertArrayHasKey(1, $realtime->getSubscribers($event), "plain `documents` should match {$action} event"); + } + } public function testFromPayloadPresenceChannels(): void {