diff --git a/src/Migration/Destinations/Appwrite.php b/src/Migration/Destinations/Appwrite.php index 83ed0128..1baeb8f9 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); @@ -3551,6 +3562,116 @@ protected function createAuthMethods(AuthMethods $resource): bool return true; } + /** + * Read-then-merge a single OAuth2 provider's entries on the project's + * `oAuthProviders` map. The same storage shape covers every provider — + * `{providerKey}Appid` for the readable client identifier, `{providerKey}Secret` + * for the credential blob, and `{providerKey}Enabled` for the toggle. + * Per-provider extras (Apple's keyId/teamId merged into the secret JSON, + * Microsoft's tenant, OIDC's endpoint, Google's prompt) are handled in the + * per-shape branches. + * + * The actual secret material the OAuth handshake needs (`clientSecret` for + * standard providers, `p8File` for Apple) is write-only on the source API, + * so it never makes it to the destination — the admin must re-enter it + * post-migration. `enabled` is propagated as-is; until the admin enters the + * secret, sign-in attempts for that provider will fail at runtime. + */ + 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(); + } + // A provider is at most one of these shapes — the per-shape extras + // (endpoint/tenant/prompt) are folded into the JSON secret blob. + if ($resource instanceof OAuth2WithEndpoint && $resource->getEndpoint() !== '') { + // Endpoint providers (Auth0/Authentik/FusionAuth/Gitlab/Keycloak/Okta/OIDC) + // bundle the endpoint URL inside the JSON secret blob alongside + // the client secret on the destination. + $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 (used for + * Microsoft tenant, OIDC/Auth0/etc. endpoint, Google prompt). Preserves + * any existing keys on the destination — only overrides the ones we + * carry from the source. + * + * @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..4bf42096 100644 --- a/src/Migration/Resource.php +++ b/src/Migration/Resource.php @@ -73,6 +73,14 @@ abstract class Resource implements \JsonSerializable public const TYPE_POLICIES = 'policies'; + // OAuth2 providers — one type constant shared by all 40 provider + // Resource classes under Resources/Auth/OAuth2/. Per-provider dispatch on + // the destination uses `instanceof` on the concrete subclass; the single + // type constant keeps status counters compact (a per-provider constant + // explosion would push the OSS migration document's `statusCounters` JSON + // past its 3KB column limit when OAuth migration is selected). + public const TYPE_OAUTH2_PROVIDER = 'oauth2-provider'; + public const TYPE_ENVIRONMENT_VARIABLE = 'environment-variable'; // Integrations @@ -131,6 +139,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 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; + + /** + * Every OAuth2 provider Resource shares one type name. Per-provider + * dispatch happens via `instanceof` on the concrete subclass — the type + * constant exists only to bucket all OAuth2 resources under one status + * counter (a per-provider TYPE explosion would blow past the 3KB cap on + * the OSS migration document's `statusCounters` column for projects that + * select OAuth migration). + */ + 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; + } + + /** + * The OAuth2 provider key as stored on the project doc (e.g. 'google', + * 'apple', 'github'). Used by the destination to compute the + * `{providerKey}Enabled`/`{providerKey}Appid`/`{providerKey}Secret` + * storage attribute names. + */ + abstract public static function getProviderKey(): string; + + public function getEnabled(): 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; + } +} 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'] ?? ''); + if ($key !== '' && self::oauth2ClassFor($key) !== null) { + $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 +668,23 @@ protected function exportGroupAuth(int $batchSize, array $resources): void )); } + // A single `listOAuth2Providers` call returns every provider; each one + // is emitted as its own typed Resource (with shared TYPE_OAUTH2_PROVIDER). + // Per-provider failures surface in `$this->errors[]`. + 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 +757,129 @@ private function exportAuthMethods(): void $this->callback([$authMethods]); } + /** + * Migration Resource classes for every OAuth2 provider exposed by + * `listOAuth2Providers`. The provider key (`'github'`, `'apple'`, …) + * lives only on the class via `OAuth2Provider::getProviderKey()` — + * `oauth2ClassFor()` lazily builds the key→class lookup. + * + * Adding a provider: drop one new file under Resources/Auth/OAuth2/ and + * append 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\Podio::class, + OAuth2\Salesforce::class, + OAuth2\Slack::class, + OAuth2\Spotify::class, + OAuth2\Stripe::class, + OAuth2\Tradeshift::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; + } + + /** + * `listOAuth2Providers` returns a heterogeneous list — each entry is a + * typed `OAuth2{Provider}` payload with provider-specific fields. We + * route each one through its concrete migration Resource class + * (`Resources/Auth/OAuth2/{Provider}.php`), which extracts the readable + * non-secret fields. Credential fields (`clientSecret`, `p8File`) come + * back blanked from the server and are intentionally not migrated — + * destination admin must re-enter them per provider. + */ + 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) { + // Server exposes a provider this lib has no Resource class for + // (e.g. one added upstream after this release). Surface it as a + // non-fatal error so it shows in the migration report instead of + // vanishing — adding coverage is a one-file change. + $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; + } + + // Hand the raw payload to the per-provider fromArray, which knows + // which fields it cares about. + $payload = $provider; + $payload['id'] = $this->projectId . '-' . $key; + $emitted[] = $class::fromArray($payload); + } + + 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,