Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
45926dc
Add OAuth providers migration
premtsd-code May 26, 2026
327bcc3
Merge remote-tracking branch 'origin/add-email-templates-migration' i…
premtsd-code May 28, 2026
f60dc3b
Use listOAuth2Providers SDK call; migrate enabled flag only (SDK 24 m…
premtsd-code May 28, 2026
72324bd
Address Greptile review: reorder OAuth export block; guard empty appI…
premtsd-code May 28, 2026
b0820de
OAuth: skip enabling providers without destination credentials (avoid…
premtsd-code May 28, 2026
3269cf3
OAuth2: switch to per-provider Resource classes; migrate all readable…
premtsd-code May 29, 2026
e809d36
OAuth2: collapse 40 TYPE constants to single TYPE_OAUTH2_PROVIDER (st…
premtsd-code May 29, 2026
4829ace
OAuth2: drop conditional-enable guard; propagate enabled as-is across…
premtsd-code May 29, 2026
69dabe5
OAuth2: derive provider key from class instead of duplicating in sour…
premtsd-code May 29, 2026
ce19cdd
OAuth2: match sibling migration style for dispatch, report, and export
premtsd-code May 31, 2026
d13d2d5
OAuth2: DRY secret merge, surface unmapped providers, add tests
premtsd-code May 31, 2026
4168789
Drop OAuth2 tests to match the lib's existing migration-resource cove…
premtsd-code May 31, 2026
0d537b6
Merge branch 'add-email-templates-migration' into add-oauth-providers…
premtsd-code Jun 1, 2026
5d63dc6
Register TYPE_OAUTH2_PROVIDER in mock source/destination supported re…
premtsd-code Jun 1, 2026
d1387d9
Merge branch 'add-email-templates-migration' into add-oauth-providers…
premtsd-code Jun 1, 2026
a1afab9
Trim OAuth2 comments to the critical why; de-duplicate shared rationale
premtsd-code Jun 1, 2026
4ac6933
Add PaypalSandbox/TradeshiftSandbox OAuth2 providers (server enables …
premtsd-code Jun 1, 2026
d2a941d
OAuth2: migrate only configured providers (enabled or appId set)
premtsd-code Jun 1, 2026
e0741a5
Merge branch 'add-email-templates-migration' into add-oauth-providers…
premtsd-code Jun 2, 2026
6e4e416
OAuth2 export: null-guard listOAuth2Providers response, matching report
premtsd-code Jun 2, 2026
3ce16c5
Consolidate OAuth2 provider resources into a single class
premtsd-code Jun 3, 2026
5810023
Merge branch 'main' into add-oauth-providers-migration
premtsd-code Jun 3, 2026
99def10
Trim OAuth provider migration comments
premtsd-code Jun 3, 2026
09334e6
Improve OAuth provider migration mapping
premtsd-code Jun 3, 2026
b797f38
Tidy OAuth2Provider: drop dead getter and de-duplicate emptiness check
premtsd-code Jun 3, 2026
dede00a
Fix OAuth provider secret field mappings
premtsd-code Jun 3, 2026
f5a9cd9
Remove OAuth provider unit tests
premtsd-code Jun 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions src/Migration/Destinations/Appwrite.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<string, mixed> $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
Expand Down
6 changes: 6 additions & 0 deletions src/Migration/Resource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
232 changes: 232 additions & 0 deletions src/Migration/Resources/Auth/OAuth2/OAuth2Provider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
<?php

namespace Utopia\Migration\Resources\Auth\OAuth2;

use Utopia\Migration\Resource;
use Utopia\Migration\Transfer;

/**
* OAuth2 provider secrets are write-only and are not migrated.
*/
final class OAuth2Provider extends Resource
{
private const TARGET_APP_ID = 'appId';
private const TARGET_SECRET = 'secret';

/**
* Allow-list of readable provider fields that are safe to migrate, keyed by
* provider. Each field declares where it lands on the destination:
* - target `appId` -> 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<string, array<string, array{target: string, key?: string}>>
*/
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<string, mixed> $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<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed>
*/
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<string, array{target: string, key?: string}>
*/
private function getDescriptor(): array
{
return self::PROVIDERS[$this->providerKey] ?? [];
}

private static function isEmpty(mixed $value): bool
{
return $value === null || $value === '' || $value === [];
}
}
Loading
Loading