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 a056ff8510..e521ac3771 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -456,8 +456,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 @@ -526,6 +528,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 33c7ffc9de..d653b4857c 100644 --- a/app/config/roles.php +++ b/app/config/roles.php @@ -55,6 +55,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 592e032ba1..947cd863f8 100644 --- a/app/config/scopes/project.php +++ b/app/config/scopes/project.php @@ -228,4 +228,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 b713d61cd2..77ca9be451 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; @@ -350,6 +394,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()); @@ -398,6 +483,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/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/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 c4e616ea12..e37e2c6043 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -278,6 +278,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'; @@ -290,6 +331,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;