diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index bd899514..ee35067f 100644 --- a/src/Migration/Destinations/Appwrite.php +++ b/src/Migration/Destinations/Appwrite.php @@ -45,6 +45,12 @@ use Utopia\Migration\Resources\Auth\AuthMethods; use Utopia\Migration\Resources\Auth\Hash; use Utopia\Migration\Resources\Auth\Membership; +use Utopia\Migration\Resources\Auth\OAuth2\Apple as OAuth2Apple; +use Utopia\Migration\Resources\Auth\OAuth2\Google as OAuth2Google; +use Utopia\Migration\Resources\Auth\OAuth2\Microsoft as OAuth2Microsoft; +use Utopia\Migration\Resources\Auth\OAuth2\OAuth2Provider; +use Utopia\Migration\Resources\Auth\OAuth2\StandardProvider as OAuth2Standard; +use Utopia\Migration\Resources\Auth\OAuth2\WithEndpointProvider as OAuth2WithEndpoint; use Utopia\Migration\Resources\Auth\Policies; use Utopia\Migration\Resources\Auth\Team; use Utopia\Migration\Resources\Auth\User; @@ -260,6 +266,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 +2203,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); @@ -3548,6 +3559,102 @@ protected function createAuthMethods(AuthMethods $resource): bool return true; } + /** + * Read-then-merge a single provider's entries on the project's + * `oAuthProviders` map, keyed `{providerKey}Appid` / `{providerKey}Secret` / + * `{providerKey}Enabled`. The handshake secret is never migrated (see + * OAuth2Provider), so `enabled` is propagated as-is and sign-in will fail + * until the admin re-enters the secret on the destination. + */ + protected function createOAuth2Provider(OAuth2Provider $resource): bool + { + $key = $resource::getProviderKey(); + $project = $this->dbForPlatform->getDocument('projects', $this->projectId); + $oAuthProviders = $project->getAttribute('oAuthProviders', []); + + if ($resource instanceof OAuth2Apple) { + if ($resource->getServiceId() !== '') { + $oAuthProviders[$key . 'Appid'] = $resource->getServiceId(); + } + $oAuthProviders[$key . 'Secret'] = $this->mergeAppleSecret( + $oAuthProviders[$key . 'Secret'] ?? '', + $resource->getKeyId(), + $resource->getTeamId(), + ); + } elseif ($resource instanceof OAuth2Standard) { + if ($resource->getClientId() !== '') { + $oAuthProviders[$key . 'Appid'] = $resource->getClientId(); + } + // Per-shape extras (endpoint/tenant/prompt) ride inside the JSON secret blob. + if ($resource instanceof OAuth2WithEndpoint && $resource->getEndpoint() !== '') { + $oAuthProviders[$key . 'Secret'] = $this->mergeJsonSecret( + $oAuthProviders[$key . 'Secret'] ?? '', + ['endpoint' => $resource->getEndpoint()], + ); + } elseif ($resource instanceof OAuth2Microsoft && $resource->getTenant() !== '') { + $oAuthProviders[$key . 'Secret'] = $this->mergeJsonSecret( + $oAuthProviders[$key . 'Secret'] ?? '', + ['tenant' => $resource->getTenant()], + ); + } elseif ($resource instanceof OAuth2Google && !empty($resource->getPrompt())) { + $oAuthProviders[$key . 'Secret'] = $this->mergeJsonSecret( + $oAuthProviders[$key . 'Secret'] ?? '', + ['prompt' => $resource->getPrompt()], + ); + } + } + + $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; + } + + /** + * Apple stores its credential as a JSON blob of `{keyID, teamID, p8}`. + * Migration carries keyID/teamID (readable) but not p8 (write-only). + * Read the destination's existing blob, overlay the migrated fields, keep + * the destination's `p8` untouched. + */ + private function mergeAppleSecret(string $existing, string $keyId, string $teamId): string + { + $fields = []; + if ($keyId !== '') { + $fields['keyID'] = $keyId; + } + if ($teamId !== '') { + $fields['teamID'] = $teamId; + } + + return $this->mergeJsonSecret($existing, $fields); + } + + /** + * Merge a partial fields map into a JSON-encoded secret blob, preserving + * existing keys on the destination and overriding only the migrated ones. + * + * @param array $fields + */ + private function mergeJsonSecret(string $existing, array $fields): string + { + $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/Amazon.php b/src/Migration/Resources/Auth/OAuth2/Amazon.php new file mode 100644 index 00000000..d580b2c0 --- /dev/null +++ b/src/Migration/Resources/Auth/OAuth2/Amazon.php @@ -0,0 +1,11 @@ + $array + */ + public static function fromArray(array $array): self + { + return new self( + $array['id'], + (bool) ($array['enabled'] ?? false), + (string) ($array['serviceId'] ?? ''), + (string) ($array['keyId'] ?? ''), + (string) ($array['teamId'] ?? ''), + createdAt: $array['createdAt'] ?? '', + updatedAt: $array['updatedAt'] ?? '', + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'enabled' => $this->enabled, + 'serviceId' => $this->serviceId, + 'keyId' => $this->keyId, + 'teamId' => $this->teamId, + 'createdAt' => $this->createdAt, + 'updatedAt' => $this->updatedAt, + ]; + } + + public static function getProviderKey(): string + { + return 'apple'; + } + + public function isConfigured(): bool + { + return $this->enabled || $this->serviceId !== ''; + } + + public function getServiceId(): string + { + return $this->serviceId; + } + + public function getKeyId(): string + { + return $this->keyId; + } + + public function getTeamId(): string + { + return $this->teamId; + } +} diff --git a/src/Migration/Resources/Auth/OAuth2/Auth0.php b/src/Migration/Resources/Auth/OAuth2/Auth0.php new file mode 100644 index 00000000..6fb20fce --- /dev/null +++ b/src/Migration/Resources/Auth/OAuth2/Auth0.php @@ -0,0 +1,11 @@ + $prompt + */ + public function __construct( + string $id, + bool $enabled, + string $clientId = '', + private readonly array $prompt = [], + string $createdAt = '', + string $updatedAt = '', + ) { + parent::__construct($id, $enabled, $clientId, $createdAt, $updatedAt); + } + + /** + * @param array $array + */ + public static function fromArray(array $array): self + { + return new self( + $array['id'], + (bool) ($array['enabled'] ?? false), + (string) ($array['clientId'] ?? ''), + (array) ($array['prompt'] ?? []), + createdAt: $array['createdAt'] ?? '', + updatedAt: $array['updatedAt'] ?? '', + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'enabled' => $this->enabled, + 'clientId' => $this->clientId, + 'prompt' => $this->prompt, + 'createdAt' => $this->createdAt, + 'updatedAt' => $this->updatedAt, + ]; + } + + public static function getProviderKey(): string + { + return 'google'; + } + + /** + * @return array + */ + public function getPrompt(): array + { + return $this->prompt; + } +} diff --git a/src/Migration/Resources/Auth/OAuth2/Keycloak.php b/src/Migration/Resources/Auth/OAuth2/Keycloak.php new file mode 100644 index 00000000..f64967af --- /dev/null +++ b/src/Migration/Resources/Auth/OAuth2/Keycloak.php @@ -0,0 +1,11 @@ + $array + */ + public static function fromArray(array $array): self + { + return new self( + $array['id'], + (bool) ($array['enabled'] ?? false), + (string) ($array['clientId'] ?? ''), + (string) ($array['tenant'] ?? ''), + createdAt: $array['createdAt'] ?? '', + updatedAt: $array['updatedAt'] ?? '', + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'enabled' => $this->enabled, + 'clientId' => $this->clientId, + 'tenant' => $this->tenant, + 'createdAt' => $this->createdAt, + 'updatedAt' => $this->updatedAt, + ]; + } + + public static function getProviderKey(): string + { + return 'microsoft'; + } + + public function getTenant(): string + { + return $this->tenant; + } +} diff --git a/src/Migration/Resources/Auth/OAuth2/Notion.php b/src/Migration/Resources/Auth/OAuth2/Notion.php new file mode 100644 index 00000000..bf559817 --- /dev/null +++ b/src/Migration/Resources/Auth/OAuth2/Notion.php @@ -0,0 +1,11 @@ + $array + */ + abstract public static function fromArray(array $array): self; + + public static function getName(): string + { + return Resource::TYPE_OAUTH2_PROVIDER; + } + + public function __construct( + string $id, + protected readonly bool $enabled = false, + string $createdAt = '', + string $updatedAt = '', + ) { + $this->id = $id; + $this->createdAt = $createdAt; + $this->updatedAt = $updatedAt; + } + + public function getGroup(): string + { + return Transfer::GROUP_AUTH; + } + + /** + * Provider key as stored on the project doc (e.g. 'google', 'apple'). The + * destination derives the `{providerKey}Enabled/Appid/Secret` attribute + * names from it. + */ + abstract public static function getProviderKey(): string; + + public function getEnabled(): bool + { + return $this->enabled; + } + + /** + * Whether the project actually set this provider up — `listOAuth2Providers` + * returns every supported provider, but only configured ones are migrated. + * Subclasses also count a set-but-disabled appId. + */ + public function isConfigured(): bool + { + return $this->enabled; + } +} diff --git a/src/Migration/Resources/Auth/OAuth2/Oidc.php b/src/Migration/Resources/Auth/OAuth2/Oidc.php new file mode 100644 index 00000000..65f788fc --- /dev/null +++ b/src/Migration/Resources/Auth/OAuth2/Oidc.php @@ -0,0 +1,11 @@ + $array + */ + public static function fromArray(array $array): self + { + return new static( + $array['id'], + (bool) ($array['enabled'] ?? false), + (string) ($array['clientId'] ?? ''), + createdAt: $array['createdAt'] ?? '', + updatedAt: $array['updatedAt'] ?? '', + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'enabled' => $this->enabled, + 'clientId' => $this->clientId, + 'createdAt' => $this->createdAt, + 'updatedAt' => $this->updatedAt, + ]; + } + + public function getClientId(): string + { + return $this->clientId; + } + + public function isConfigured(): bool + { + return $this->enabled || $this->clientId !== ''; + } +} diff --git a/src/Migration/Resources/Auth/OAuth2/Stripe.php b/src/Migration/Resources/Auth/OAuth2/Stripe.php new file mode 100644 index 00000000..b702efb1 --- /dev/null +++ b/src/Migration/Resources/Auth/OAuth2/Stripe.php @@ -0,0 +1,11 @@ + $array + */ + public static function fromArray(array $array): self + { + return new static( + $array['id'], + (bool) ($array['enabled'] ?? false), + (string) ($array['clientId'] ?? ''), + (string) ($array['endpoint'] ?? ''), + createdAt: $array['createdAt'] ?? '', + updatedAt: $array['updatedAt'] ?? '', + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'enabled' => $this->enabled, + 'clientId' => $this->clientId, + 'endpoint' => $this->endpoint, + 'createdAt' => $this->createdAt, + 'updatedAt' => $this->updatedAt, + ]; + } + + public function getEndpoint(): string + { + return $this->endpoint; + } +} diff --git a/src/Migration/Resources/Auth/OAuth2/Wordpress.php b/src/Migration/Resources/Auth/OAuth2/Wordpress.php new file mode 100644 index 00000000..99e1a348 --- /dev/null +++ b/src/Migration/Resources/Auth/OAuth2/Wordpress.php @@ -0,0 +1,11 @@ +project->listOAuth2Providers()->providers ?? [] as $provider) { + $key = (string) ($provider['$id'] ?? ''); + $class = $key !== '' ? self::oauth2ClassFor($key) : null; + if ($class === null) { + continue; + } + $provider['id'] = $key; + if ($class::fromArray($provider)->isConfigured()) { + $count++; + } + } + $report[Resource::TYPE_OAUTH2_PROVIDER] = $count; + } + if (\in_array(Resource::TYPE_POLICIES, $resources)) { // Singleton — one policies config per project. $report[Resource::TYPE_POLICIES] = 1; @@ -650,6 +670,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 +756,126 @@ private function exportAuthMethods(): void $this->callback([$authMethods]); } + /** + * Every migratable OAuth2 provider class. The provider key lives only on the + * class (OAuth2Provider::getProviderKey()); oauth2ClassFor() builds the + * key→class lookup. Add a provider by adding a file here and one line below. + * + * @var array> + */ + private const OAUTH2_PROVIDER_CLASSES = [ + OAuth2\Amazon::class, + OAuth2\Apple::class, + OAuth2\Auth0::class, + OAuth2\Authentik::class, + OAuth2\Autodesk::class, + OAuth2\Bitbucket::class, + OAuth2\Bitly::class, + OAuth2\Box::class, + OAuth2\Dailymotion::class, + OAuth2\Discord::class, + OAuth2\Disqus::class, + OAuth2\Dropbox::class, + OAuth2\Etsy::class, + OAuth2\Facebook::class, + OAuth2\Figma::class, + OAuth2\FusionAuth::class, + OAuth2\Github::class, + OAuth2\Gitlab::class, + OAuth2\Google::class, + OAuth2\Keycloak::class, + OAuth2\Kick::class, + OAuth2\Linkedin::class, + OAuth2\Microsoft::class, + OAuth2\Notion::class, + OAuth2\Oidc::class, + OAuth2\Okta::class, + OAuth2\Paypal::class, + OAuth2\PaypalSandbox::class, + OAuth2\Podio::class, + OAuth2\Salesforce::class, + OAuth2\Slack::class, + OAuth2\Spotify::class, + OAuth2\Stripe::class, + OAuth2\Tradeshift::class, + OAuth2\TradeshiftSandbox::class, + OAuth2\Twitch::class, + OAuth2\Wordpress::class, + OAuth2\X::class, + OAuth2\Yahoo::class, + OAuth2\Yandex::class, + OAuth2\Zoho::class, + OAuth2\Zoom::class, + ]; + + /** @var array>|null */ + private static ?array $oauth2ClassByKey = null; + + /** + * Resolve a provider key (from the SDK list response's `$id`) to its + * Resource class. Returns `null` when the server lists a provider this + * lib has no class for yet (e.g. a newly added upstream provider). + * + * @return class-string|null + */ + private static function oauth2ClassFor(string $key): ?string + { + if (self::$oauth2ClassByKey === null) { + self::$oauth2ClassByKey = []; + foreach (self::OAUTH2_PROVIDER_CLASSES as $class) { + self::$oauth2ClassByKey[$class::getProviderKey()] = $class; + } + } + + return self::$oauth2ClassByKey[$key] ?? null; + } + + /** + * Route each entry of the heterogeneous `listOAuth2Providers` response + * through its concrete Resource class, which extracts that provider's + * readable fields. Secrets come back blanked and are not migrated. + */ + private function exportOAuth2Providers(): void + { + $response = $this->project->listOAuth2Providers(); + + $emitted = []; + foreach ($response->providers ?? [] as $provider) { + $key = (string) ($provider['$id'] ?? ''); + if ($key === '') { + continue; + } + + $class = self::oauth2ClassFor($key); + if ($class === null) { + // Provider with no Resource class yet (added upstream after this + // release): report it as non-fatal rather than dropping it silently. + $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; + } + + $payload = $provider; + $payload['id'] = $this->projectId . '-' . $key; + $resource = $class::fromArray($payload); + + // The server lists every provider; carry over only configured ones. + if (!$resource->isConfigured()) { + continue; + } + + $emitted[] = $resource; + } + + if (!empty($emitted)) { + $this->callback($emitted); + } + } + /** * @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,