mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
Add Keycloak oauth support
This commit is contained in:
@@ -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/',
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
// =========================================================================
|
||||
|
||||
Reference in New Issue
Block a user