From 49e6a38e7fe337a585eab8712fe01ea61a98089d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 28 Apr 2026 10:43:16 +0200 Subject: [PATCH] Add fusionauth oauth --- app/config/oAuthProviders.php | 11 + app/init/models.php | 2 + src/Appwrite/Auth/OAuth2/FusionAuth.php | 226 ++++++++++++++++++ .../Project/Http/Project/OAuth2/Base.php | 1 + .../Http/Project/OAuth2/FusionAuth/Update.php | 172 +++++++++++++ .../Project/Http/Project/OAuth2/Get.php | 1 + .../Modules/Project/Services/Http.php | 2 + src/Appwrite/Utopia/Response.php | 1 + .../Response/Model/OAuth2FusionAuth.php | 59 +++++ .../Response/Model/OAuth2ProviderList.php | 1 + tests/e2e/Services/Project/OAuth2Base.php | 133 ++++++++++- 11 files changed, 604 insertions(+), 5 deletions(-) create mode 100644 src/Appwrite/Auth/OAuth2/FusionAuth.php create mode 100644 src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/FusionAuth/Update.php create mode 100644 src/Appwrite/Utopia/Response/Model/OAuth2FusionAuth.php diff --git a/app/config/oAuthProviders.php b/app/config/oAuthProviders.php index 0dc2cb8b1e..3b490bd153 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/', diff --git a/app/init/models.php b/app/init/models.php index 39bc90e23c..ab397d6fdf 100644 --- a/app/init/models.php +++ b/app/init/models.php @@ -123,6 +123,7 @@ 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; @@ -425,6 +426,7 @@ Response::setModel(new OAuth2Paypal()); Response::setModel(new OAuth2Gitlab()); Response::setModel(new OAuth2Authentik()); Response::setModel(new OAuth2Auth0()); +Response::setModel(new OAuth2FusionAuth()); Response::setModel(new OAuth2Oidc()); Response::setModel(new OAuth2Okta()); Response::setModel(new OAuth2Kick()); 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/Platform/Modules/Project/Http/Project/OAuth2/Base.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php index b0f59e7c08..3925abb582 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Base.php @@ -311,6 +311,7 @@ abstract class Base extends Action 'gitlab' => Gitlab\Update::class, 'authentik' => Authentik\Update::class, 'auth0' => Auth0\Update::class, + 'fusionauth' => FusionAuth\Update::class, 'oidc' => Oidc\Update::class, 'okta' => Okta\Update::class, 'kick' => Kick\Update::class, diff --git a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/FusionAuth/Update.php b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/FusionAuth/Update.php new file mode 100644 index 0000000000..25f81e1459 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/FusionAuth/Update.php @@ -0,0 +1,172 @@ + '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 index 419d80f829..0e10a8841c 100644 --- a/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php +++ b/src/Appwrite/Platform/Modules/Project/Http/Project/OAuth2/Get.php @@ -75,6 +75,7 @@ class Get extends Action Response::MODEL_OAUTH2_GITLAB, Response::MODEL_OAUTH2_AUTHENTIK, Response::MODEL_OAUTH2_AUTH0, + Response::MODEL_OAUTH2_FUSIONAUTH, Response::MODEL_OAUTH2_OIDC, Response::MODEL_OAUTH2_APPLE, Response::MODEL_OAUTH2_OKTA, diff --git a/src/Appwrite/Platform/Modules/Project/Services/Http.php b/src/Appwrite/Platform/Modules/Project/Services/Http.php index d6ff3c4925..76dbf58ef8 100644 --- a/src/Appwrite/Platform/Modules/Project/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Project/Services/Http.php @@ -31,6 +31,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Dropbox\Update as Upda use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Etsy\Update as UpdateOAuth2Etsy; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Facebook\Update as UpdateOAuth2Facebook; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Figma\Update as UpdateOAuth2Figma; +use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\FusionAuth\Update as UpdateOAuth2FusionAuth; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Get as GetOAuth2Provider; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\GitHub\Update as UpdateOAuth2GitHub; use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Gitlab\Update as UpdateOAuth2Gitlab; @@ -210,6 +211,7 @@ class Http extends Service $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(UpdateOAuth2Oidc::getName(), new UpdateOAuth2Oidc()); $this->addAction(UpdateOAuth2Okta::getName(), new UpdateOAuth2Okta()); $this->addAction(UpdateOAuth2Kick::getName(), new UpdateOAuth2Kick()); diff --git a/src/Appwrite/Utopia/Response.php b/src/Appwrite/Utopia/Response.php index 7670b027e9..14bfbdb9ef 100644 --- a/src/Appwrite/Utopia/Response.php +++ b/src/Appwrite/Utopia/Response.php @@ -311,6 +311,7 @@ class Response extends SwooleResponse 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_OIDC = 'oAuth2Oidc'; public const MODEL_OAUTH2_APPLE = 'oAuth2Apple'; public const MODEL_OAUTH2_OKTA = 'oAuth2Okta'; 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/OAuth2ProviderList.php b/src/Appwrite/Utopia/Response/Model/OAuth2ProviderList.php index 5d1fb16a9a..71cf5ed2eb 100644 --- a/src/Appwrite/Utopia/Response/Model/OAuth2ProviderList.php +++ b/src/Appwrite/Utopia/Response/Model/OAuth2ProviderList.php @@ -51,6 +51,7 @@ class OAuth2ProviderList extends Model Response::MODEL_OAUTH2_GITLAB, Response::MODEL_OAUTH2_AUTHENTIK, Response::MODEL_OAUTH2_AUTH0, + Response::MODEL_OAUTH2_FUSIONAUTH, Response::MODEL_OAUTH2_OIDC, Response::MODEL_OAUTH2_APPLE, Response::MODEL_OAUTH2_OKTA, diff --git a/tests/e2e/Services/Project/OAuth2Base.php b/tests/e2e/Services/Project/OAuth2Base.php index ec070531e7..5cb1b7b0c4 100644 --- a/tests/e2e/Services/Project/OAuth2Base.php +++ b/tests/e2e/Services/Project/OAuth2Base.php @@ -64,6 +64,7 @@ trait OAuth2Base 'apple', 'auth0', 'authentik', + 'fusionauth', 'gitlab', 'oidc', 'okta', @@ -95,11 +96,11 @@ trait OAuth2Base $expected = [ 'amazon', 'apple', 'auth0', 'authentik', 'autodesk', 'bitbucket', 'bitly', 'box', 'dailymotion', 'discord', 'disqus', 'dropbox', - 'etsy', 'facebook', 'figma', 'github', 'gitlab', 'google', 'kick', - 'linkedin', 'microsoft', 'notion', 'oidc', 'okta', 'paypal', - 'paypalSandbox', 'podio', 'salesforce', 'slack', 'spotify', - 'stripe', 'tradeshift', 'tradeshiftBox', 'twitch', 'wordpress', - 'x', 'yahoo', 'yandex', 'zoho', 'zoom', + 'etsy', 'facebook', 'figma', 'fusionauth', 'github', 'gitlab', + 'google', 'kick', 'linkedin', 'microsoft', 'notion', 'oidc', + 'okta', 'paypal', 'paypalSandbox', 'podio', 'salesforce', 'slack', + 'spotify', 'stripe', 'tradeshift', 'tradeshiftBox', 'twitch', + 'wordpress', 'x', 'yahoo', 'yandex', 'zoho', 'zoom', ]; \sort($expected); @@ -995,6 +996,128 @@ trait OAuth2Base ]); } + // ========================================================================= + // 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 Microsoft (applicationId + applicationSecret + REQUIRED tenant) // =========================================================================