Add fusionauth oauth

This commit is contained in:
Matej Bačo
2026-04-28 10:43:16 +02:00
parent dfa3ae5274
commit 49e6a38e7f
11 changed files with 604 additions and 5 deletions
+11
View File
@@ -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/',
+2
View File
@@ -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());
+226
View File
@@ -0,0 +1,226 @@
<?php
namespace Appwrite\Auth\OAuth2;
use Appwrite\Auth\OAuth2;
// Reference Material
// https://fusionauth.io/docs/lifecycle/authenticate-users/oauth/endpoints
class FusionAuth 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 'fusionauth';
}
/**
* @return string
*/
public function getLoginURL(): string
{
return 'https://' . $this->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;
}
}
@@ -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,
@@ -0,0 +1,172 @@
<?php
namespace Appwrite\Platform\Modules\Project\Http\Project\OAuth2\FusionAuth;
use Appwrite\Auth\OAuth2\FusionAuth;
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 'fusionauth';
}
public static function getProviderClass(): string
{
return FusionAuth::class;
}
public static function getProviderLabel(): string
{
return 'FusionAuth';
}
public static function getProviderSDKMethod(): string
{
return 'updateOAuth2FusionAuth';
}
public static function getResponseModel(): string
{
return Response::MODEL_OAUTH2_FUSIONAUTH;
}
public static function getClientIdName(): string
{
return 'Client ID';
}
public static function getClientIdExample(): string
{
return 'b2222c00-0000-0000-0000-000000862097';
}
public static function getClientSecretName(): string
{
return 'Client Secret';
}
public static function getClientSecretExample(): string
{
return 'Jx4s0C0000000000000000000000000000000wGqLsc';
}
public static function getParameters(): array
{
return \array_merge(parent::getParameters(), [
[
'$id' => '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());
}
}
@@ -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,
@@ -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());
+1
View File
@@ -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';
@@ -0,0 +1,59 @@
<?php
namespace Appwrite\Utopia\Response\Model;
use Appwrite\Utopia\Response;
class OAuth2FusionAuth extends OAuth2Base
{
public array $conditions = [
'$id' => '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;
}
}
@@ -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,
+128 -5
View File
@@ -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)
// =========================================================================