diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index e2027230..64e7e0b1 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -45,6 +45,7 @@ use Utopia\Migration\Resources\Auth\AuthMethods; use Utopia\Migration\Resources\Auth\Hash; use Utopia\Migration\Resources\Auth\Membership; +use Utopia\Migration\Resources\Auth\OAuth2\OAuth2Provider; use Utopia\Migration\Resources\Auth\Policies; use Utopia\Migration\Resources\Auth\Team; use Utopia\Migration\Resources\Auth\User; @@ -260,6 +261,7 @@ public static function getSupportedResources(): array Resource::TYPE_MEMBERSHIP, Resource::TYPE_AUTH_METHODS, Resource::TYPE_POLICIES, + Resource::TYPE_OAUTH2_PROVIDER, // Database Resource::TYPE_DATABASE, @@ -2196,6 +2198,10 @@ public function importAuthResource(Resource $resource): Resource /** @var Policies $resource */ $this->createPolicies($resource); break; + case Resource::TYPE_OAUTH2_PROVIDER: + /** @var OAuth2Provider $resource */ + $this->createOAuth2Provider($resource); + break; } $resource->setStatus(Resource::STATUS_SUCCESS); @@ -3546,6 +3552,58 @@ protected function createAuthMethods(AuthMethods $resource): bool return true; } + protected function createOAuth2Provider(OAuth2Provider $resource): bool + { + $key = $resource->getProviderKey(); + $project = $this->dbForPlatform->getDocument('projects', $this->projectId); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + + $appId = $resource->getDestinationAppId(); + if ($appId !== null) { + $oAuthProviders[$key . 'Appid'] = $appId; + } + + $secretFields = $resource->getDestinationSecretFields(); + if (!empty($secretFields)) { + $oAuthProviders[$key . 'Secret'] = $this->mergeJsonSecret( + $oAuthProviders[$key . 'Secret'] ?? '', + $secretFields, + ); + } + + $oAuthProviders[$key . 'Enabled'] = $resource->getEnabled(); + + $this->dbForPlatform->getAuthorization()->skip(fn () => $this->dbForPlatform->updateDocument( + 'projects', + $this->projectId, + new UtopiaDocument(['oAuthProviders' => $oAuthProviders]), + )); + + $this->dbForPlatform->purgeCachedDocument('projects', $this->projectId); + + return true; + } + + /** + * @param array $fields + */ + private function mergeJsonSecret(string $existing, array $fields): string + { + if (empty($fields)) { + return $existing; + } + + $decoded = $existing === '' ? [] : (\json_decode($existing, true) ?: []); + if (!\is_array($decoded)) { + $decoded = []; + } + foreach ($fields as $name => $value) { + $decoded[$name] = $value; + } + + return \json_encode($decoded) ?: ''; + } + /** * Direct DB write — SDK policy setters reject `total: 0` but `0` is the * storage value for "disabled". Shares the `auths` map with diff --git a/src/Migration/Resource.php b/src/Migration/Resource.php index 128278a8..3f4e816e 100644 --- a/src/Migration/Resource.php +++ b/src/Migration/Resource.php @@ -73,6 +73,11 @@ abstract class Resource implements \JsonSerializable public const TYPE_POLICIES = 'policies'; + // One type shared by all OAuth2 provider Resource classes (dispatch is by + // `instanceof` on the destination). A per-provider type would overflow the + // migration document's 3KB `statusCounters` column when OAuth is selected. + public const TYPE_OAUTH2_PROVIDER = 'oauth2-provider'; + public const TYPE_ENVIRONMENT_VARIABLE = 'environment-variable'; // Integrations @@ -131,6 +136,7 @@ abstract class Resource implements \JsonSerializable self::TYPE_MEMBERSHIP, self::TYPE_AUTH_METHODS, self::TYPE_POLICIES, + self::TYPE_OAUTH2_PROVIDER, self::TYPE_PLATFORM, self::TYPE_API_KEY, self::TYPE_WEBHOOK, diff --git a/src/Migration/Resources/Auth/OAuth2/OAuth2Provider.php b/src/Migration/Resources/Auth/OAuth2/OAuth2Provider.php new file mode 100644 index 00000000..d291f61b --- /dev/null +++ b/src/Migration/Resources/Auth/OAuth2/OAuth2Provider.php @@ -0,0 +1,232 @@ + the provider's `{key}Appid` attribute (one per provider) + * - target `secret` -> merged into the `{key}Secret` JSON blob, renamed via `key` + * + * Anything not listed here (clientSecret, Apple's p8File, etc.) is never copied, + * so a secret field the server may add upstream cannot leak into a migration. + * + * @var array> + */ + public const PROVIDERS = [ + 'amazon' => ['clientId' => ['target' => self::TARGET_APP_ID]], + 'apple' => [ + 'serviceId' => ['target' => self::TARGET_APP_ID], + 'keyId' => ['target' => self::TARGET_SECRET, 'key' => 'keyID'], + 'teamId' => ['target' => self::TARGET_SECRET, 'key' => 'teamID'], + ], + 'auth0' => ['clientId' => ['target' => self::TARGET_APP_ID], 'endpoint' => ['target' => self::TARGET_SECRET]], + 'authentik' => ['clientId' => ['target' => self::TARGET_APP_ID], 'endpoint' => ['target' => self::TARGET_SECRET]], + 'autodesk' => ['clientId' => ['target' => self::TARGET_APP_ID]], + 'bitbucket' => ['clientId' => ['target' => self::TARGET_APP_ID]], + 'bitly' => ['clientId' => ['target' => self::TARGET_APP_ID]], + 'box' => ['clientId' => ['target' => self::TARGET_APP_ID]], + 'dailymotion' => ['clientId' => ['target' => self::TARGET_APP_ID]], + 'discord' => ['clientId' => ['target' => self::TARGET_APP_ID]], + 'disqus' => ['clientId' => ['target' => self::TARGET_APP_ID]], + 'dropbox' => ['clientId' => ['target' => self::TARGET_APP_ID]], + 'etsy' => ['clientId' => ['target' => self::TARGET_APP_ID]], + 'facebook' => ['clientId' => ['target' => self::TARGET_APP_ID]], + 'figma' => ['clientId' => ['target' => self::TARGET_APP_ID]], + 'fusionauth' => ['clientId' => ['target' => self::TARGET_APP_ID], 'endpoint' => ['target' => self::TARGET_SECRET]], + 'github' => ['clientId' => ['target' => self::TARGET_APP_ID]], + 'gitlab' => ['clientId' => ['target' => self::TARGET_APP_ID], 'endpoint' => ['target' => self::TARGET_SECRET]], + 'google' => ['clientId' => ['target' => self::TARGET_APP_ID], 'prompt' => ['target' => self::TARGET_SECRET]], + 'keycloak' => [ + 'clientId' => ['target' => self::TARGET_APP_ID], + 'endpoint' => ['target' => self::TARGET_SECRET, 'key' => 'keycloakDomain'], + 'realmName' => ['target' => self::TARGET_SECRET, 'key' => 'keycloakRealm'], + ], + 'kick' => ['clientId' => ['target' => self::TARGET_APP_ID]], + 'linkedin' => ['clientId' => ['target' => self::TARGET_APP_ID]], + 'microsoft' => ['clientId' => ['target' => self::TARGET_APP_ID], 'tenant' => ['target' => self::TARGET_SECRET]], + 'notion' => ['clientId' => ['target' => self::TARGET_APP_ID]], + 'oidc' => [ + 'clientId' => ['target' => self::TARGET_APP_ID], + 'wellKnownURL' => ['target' => self::TARGET_SECRET, 'key' => 'wellKnownEndpoint'], + 'authorizationURL' => ['target' => self::TARGET_SECRET, 'key' => 'authorizationEndpoint'], + 'tokenURL' => ['target' => self::TARGET_SECRET, 'key' => 'tokenEndpoint'], + 'userInfoURL' => ['target' => self::TARGET_SECRET, 'key' => 'userInfoEndpoint'], + ], + 'okta' => [ + 'clientId' => ['target' => self::TARGET_APP_ID], + 'domain' => ['target' => self::TARGET_SECRET, 'key' => 'oktaDomain'], + 'authorizationServerId' => ['target' => self::TARGET_SECRET], + ], + 'paypal' => ['clientId' => ['target' => self::TARGET_APP_ID]], + 'paypalSandbox' => ['clientId' => ['target' => self::TARGET_APP_ID]], + 'podio' => ['clientId' => ['target' => self::TARGET_APP_ID]], + 'salesforce' => ['clientId' => ['target' => self::TARGET_APP_ID]], + 'slack' => ['clientId' => ['target' => self::TARGET_APP_ID]], + 'spotify' => ['clientId' => ['target' => self::TARGET_APP_ID]], + 'stripe' => ['clientId' => ['target' => self::TARGET_APP_ID]], + 'tradeshift' => ['clientId' => ['target' => self::TARGET_APP_ID]], + 'tradeshiftBox' => ['clientId' => ['target' => self::TARGET_APP_ID]], + 'twitch' => ['clientId' => ['target' => self::TARGET_APP_ID]], + 'wordpress' => ['clientId' => ['target' => self::TARGET_APP_ID]], + 'x' => ['clientId' => ['target' => self::TARGET_APP_ID]], + 'yahoo' => ['clientId' => ['target' => self::TARGET_APP_ID]], + 'yandex' => ['clientId' => ['target' => self::TARGET_APP_ID]], + 'zoho' => ['clientId' => ['target' => self::TARGET_APP_ID]], + 'zoom' => ['clientId' => ['target' => self::TARGET_APP_ID]], + ]; + + public function __construct( + string $id, + protected readonly string $providerKey, + protected readonly bool $enabled = false, + protected readonly array $settings = [], + string $createdAt = '', + string $updatedAt = '', + ) { + $this->id = $id; + $this->createdAt = $createdAt; + $this->updatedAt = $updatedAt; + } + + public static function getName(): string + { + return Resource::TYPE_OAUTH2_PROVIDER; + } + + public function getGroup(): string + { + return Transfer::GROUP_AUTH; + } + + /** + * @param array $array + */ + public static function fromArray(string $providerKey, array $array): ?self + { + $allowed = self::PROVIDERS[$providerKey] ?? null; + if ($allowed === null) { + return null; + } + + $settings = []; + foreach (\array_keys($allowed) as $field) { + if (\array_key_exists($field, $array)) { + $settings[$field] = $array[$field]; + } + } + + return new self( + $array['id'], + $providerKey, + (bool) ($array['enabled'] ?? false), + $settings, + createdAt: $array['createdAt'] ?? '', + updatedAt: $array['updatedAt'] ?? '', + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'providerKey' => $this->providerKey, + 'enabled' => $this->enabled, + 'settings' => $this->settings, + 'createdAt' => $this->createdAt, + 'updatedAt' => $this->updatedAt, + ]; + } + + public function getProviderKey(): string + { + return $this->providerKey; + } + + public function getEnabled(): bool + { + return $this->enabled; + } + + /** + * @return array + */ + public function getSettings(): array + { + return $this->settings; + } + + /** + * Value for the destination's `{key}Appid` attribute (clientId, or serviceId + * for Apple). Null when unset, so callers can skip it without a separate + * emptiness check. + */ + public function getDestinationAppId(): ?string + { + foreach ($this->getDescriptor() as $field => $metadata) { + if ($metadata['target'] !== self::TARGET_APP_ID) { + continue; + } + + $value = $this->settings[$field] ?? null; + + return self::isEmpty($value) ? null : (string) $value; + } + + return null; + } + + /** + * @return array + */ + public function getDestinationSecretFields(): array + { + $fields = []; + foreach ($this->getDescriptor() as $field => $metadata) { + if ($metadata['target'] !== self::TARGET_SECRET || !\array_key_exists($field, $this->settings)) { + continue; + } + + $value = $this->settings[$field]; + if (self::isEmpty($value)) { + continue; + } + + $fields[$metadata['key'] ?? $field] = $value; + } + + return $fields; + } + + public function isConfigured(): bool + { + return $this->enabled || $this->getDestinationAppId() !== null; + } + + /** + * @return array + */ + private function getDescriptor(): array + { + return self::PROVIDERS[$this->providerKey] ?? []; + } + + private static function isEmpty(mixed $value): bool + { + return $value === null || $value === '' || $value === []; + } +} diff --git a/src/Migration/Sources/Appwrite.php b/src/Migration/Sources/Appwrite.php index 24c182d2..4cf069ae 100644 --- a/src/Migration/Sources/Appwrite.php +++ b/src/Migration/Sources/Appwrite.php @@ -26,6 +26,7 @@ use Utopia\Migration\Resources\Auth\AuthMethods; use Utopia\Migration\Resources\Auth\Hash; use Utopia\Migration\Resources\Auth\Membership; +use Utopia\Migration\Resources\Auth\OAuth2\OAuth2Provider; use Utopia\Migration\Resources\Auth\Policies; use Utopia\Migration\Resources\Auth\Team; use Utopia\Migration\Resources\Auth\User; @@ -191,6 +192,7 @@ public static function getSupportedResources(): array Resource::TYPE_MEMBERSHIP, Resource::TYPE_AUTH_METHODS, Resource::TYPE_POLICIES, + Resource::TYPE_OAUTH2_PROVIDER, // Database Resource::TYPE_DATABASE, @@ -391,6 +393,10 @@ private function reportAuth(array $resources, array &$report, array $resourceIds $report[Resource::TYPE_AUTH_METHODS] = 1; } + if (\in_array(Resource::TYPE_OAUTH2_PROVIDER, $resources)) { + $report[Resource::TYPE_OAUTH2_PROVIDER] = \count($this->getOAuth2ProviderResources()); + } + if (\in_array(Resource::TYPE_POLICIES, $resources)) { // Singleton — one policies config per project. $report[Resource::TYPE_POLICIES] = 1; @@ -650,6 +656,20 @@ protected function exportGroupAuth(int $batchSize, array $resources): void )); } + try { + if (\in_array(Resource::TYPE_OAUTH2_PROVIDER, $resources)) { + $this->exportOAuth2Providers(); + } + } catch (\Throwable $e) { + $this->addError(new Exception( + Resource::TYPE_OAUTH2_PROVIDER, + Transfer::GROUP_AUTH, + message: $e->getMessage(), + code: (int) $e->getCode() ?: Exception::CODE_INTERNAL, + previous: $e + )); + } + try { if (\in_array(Resource::TYPE_POLICIES, $resources)) { $this->exportPolicies(); @@ -722,6 +742,53 @@ private function exportAuthMethods(): void $this->callback([$authMethods]); } + private function exportOAuth2Providers(): void + { + $resources = $this->getOAuth2ProviderResources(true); + + if (!empty($resources)) { + $this->callback($resources); + } + } + + /** + * @return array + */ + private function getOAuth2ProviderResources(bool $reportUnknownProviders = false): array + { + $resources = []; + foreach ($this->project->listOAuth2Providers()->providers ?? [] as $provider) { + $key = (string) ($provider['$id'] ?? ''); + if ($key === '') { + continue; + } + + $payload = $provider; + $payload['id'] = $this->projectId . '-' . $key; + $resource = OAuth2Provider::fromArray($key, $payload); + + if ($resource === null) { + if ($reportUnknownProviders) { + $this->addError(new Exception( + Resource::TYPE_OAUTH2_PROVIDER, + Transfer::GROUP_AUTH, + message: "No migration resource for OAuth2 provider '{$key}'; skipped.", + code: Exception::CODE_INTERNAL, + )); + } + continue; + } + + if (!$resource->isConfigured()) { + continue; + } + + $resources[] = $resource; + } + + return $resources; + } + /** * @throws AppwriteException */ diff --git a/src/Migration/Transfer.php b/src/Migration/Transfer.php index 84750c06..05e6a242 100644 --- a/src/Migration/Transfer.php +++ b/src/Migration/Transfer.php @@ -39,6 +39,7 @@ class Transfer Resource::TYPE_HASH, Resource::TYPE_AUTH_METHODS, Resource::TYPE_POLICIES, + Resource::TYPE_OAUTH2_PROVIDER, ]; public const GROUP_STORAGE_RESOURCES = [ @@ -129,6 +130,7 @@ class Transfer Resource::TYPE_MEMBERSHIP, Resource::TYPE_AUTH_METHODS, Resource::TYPE_POLICIES, + Resource::TYPE_OAUTH2_PROVIDER, Resource::TYPE_FILE, Resource::TYPE_BUCKET, Resource::TYPE_FUNCTION, diff --git a/tests/Migration/Unit/Adapters/MockDestination.php b/tests/Migration/Unit/Adapters/MockDestination.php index 0ac11b8e..b383e852 100644 --- a/tests/Migration/Unit/Adapters/MockDestination.php +++ b/tests/Migration/Unit/Adapters/MockDestination.php @@ -51,6 +51,7 @@ public static function getSupportedResources(): array Resource::TYPE_ENVIRONMENT_VARIABLE, Resource::TYPE_TEAM, Resource::TYPE_MEMBERSHIP, + Resource::TYPE_OAUTH2_PROVIDER, Resource::TYPE_PLATFORM, Resource::TYPE_API_KEY, Resource::TYPE_SMTP, diff --git a/tests/Migration/Unit/Adapters/MockSource.php b/tests/Migration/Unit/Adapters/MockSource.php index e4e3b77a..cbdf9602 100644 --- a/tests/Migration/Unit/Adapters/MockSource.php +++ b/tests/Migration/Unit/Adapters/MockSource.php @@ -80,6 +80,7 @@ public static function getSupportedResources(): array Resource::TYPE_ENVIRONMENT_VARIABLE, Resource::TYPE_TEAM, Resource::TYPE_MEMBERSHIP, + Resource::TYPE_OAUTH2_PROVIDER, Resource::TYPE_PLATFORM, Resource::TYPE_API_KEY, Resource::TYPE_SMTP,