Add Keycloak oauth support

This commit is contained in:
Matej Bačo
2026-04-28 10:54:13 +02:00
parent 49e6a38e7f
commit cb4cff120b
11 changed files with 687 additions and 4 deletions
+11
View File
@@ -211,6 +211,17 @@ 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/',
+2
View File
@@ -127,6 +127,7 @@ 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;
@@ -427,6 +428,7 @@ 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());
+249
View File
@@ -0,0 +1,249 @@
<?php
namespace Appwrite\Auth\OAuth2;
use Appwrite\Auth\OAuth2;
// Reference Material
// https://www.keycloak.org/docs/latest/securing_apps/#_oidc
class Keycloak extends OAuth2
{
/**
* @var array
*/
protected array $scopes = [
'openid',
'profile',
'email',
'offline_access'
];
/**
* @var array
*/
protected array $user = [];
/**
* @var array
*/
protected array $tokens = [];
/**
* @return string
*/
public function getName(): string
{
return 'keycloak';
}
/**
* @return string
*/
public function getLoginURL(): string
{
return $this->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;
}
}
@@ -312,6 +312,7 @@ abstract class Base extends Action
'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,
@@ -76,6 +76,7 @@ class Get extends Action
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,
@@ -0,0 +1,183 @@
<?php
namespace Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Keycloak;
use Appwrite\Auth\OAuth2\Keycloak;
use Appwrite\Event\Event as QueueEvent;
use Appwrite\Platform\Action;
use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
use Utopia\Validator\Boolean;
use Utopia\Validator\Nullable;
use Utopia\Validator\Text;
class Update extends Base
{
public static function getProviderId(): string
{
return 'keycloak';
}
public static function getProviderClass(): string
{
return Keycloak::class;
}
public static function getProviderLabel(): string
{
return 'Keycloak';
}
public static function getProviderSDKMethod(): string
{
return 'updateOAuth2Keycloak';
}
public static function getResponseModel(): string
{
return Response::MODEL_OAUTH2_KEYCLOAK;
}
public static function getClientIdName(): string
{
return 'Client ID';
}
public static function getClientIdExample(): string
{
return 'appwrite-o0000000st-app';
}
public static function getClientSecretName(): string
{
return 'Client Secret';
}
public static function getClientSecretExample(): string
{
return 'jdjrJd00000000000000000000HUsaZO';
}
public static function getParameters(): array
{
return \array_merge(parent::getParameters(), [
[
'$id' => '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());
}
}
@@ -36,6 +36,7 @@ use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Get as GetOAuth2Provid
use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\GitHub\Update as UpdateOAuth2GitHub;
use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Gitlab\Update as UpdateOAuth2Gitlab;
use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Google\Update as UpdateOAuth2Google;
use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Keycloak\Update as UpdateOAuth2Keycloak;
use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Kick\Update as UpdateOAuth2Kick;
use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Linkedin\Update as UpdateOAuth2Linkedin;
use Appwrite\Platform\Modules\Project\Http\Project\OAuth2\Microsoft\Update as UpdateOAuth2Microsoft;
@@ -212,6 +213,7 @@ class Http extends Service
$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());
+1
View File
@@ -312,6 +312,7 @@ class Response extends SwooleResponse
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';
@@ -0,0 +1,66 @@
<?php
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
class OAuth2Keycloak extends OAuth2Base
{
public array $conditions = [
'$id' => '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;
}
}
@@ -52,6 +52,7 @@ class OAuth2ProviderList extends Model
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,
+170 -4
View File
@@ -66,6 +66,7 @@ trait OAuth2Base
'authentik',
'fusionauth',
'gitlab',
'keycloak',
'oidc',
'okta',
'microsoft',
@@ -97,10 +98,10 @@ trait OAuth2Base
'amazon', 'apple', 'auth0', 'authentik', 'autodesk', 'bitbucket',
'bitly', 'box', 'dailymotion', 'discord', 'disqus', 'dropbox',
'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',
'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);
@@ -1118,6 +1119,171 @@ trait OAuth2Base
]);
}
// =========================================================================
// 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)
// =========================================================================