Skip to content

Commit 50526e1

Browse files
authored
Merge pull request #191 from utopia-php/add-oauth-providers-migration
Add OAuth providers migration
2 parents 7638a3a + f5a9cd9 commit 50526e1

7 files changed

Lines changed: 367 additions & 0 deletions

File tree

src/Migration/Destinations/Appwrite.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
use Utopia\Migration\Resources\Auth\AuthMethods;
4646
use Utopia\Migration\Resources\Auth\Hash;
4747
use Utopia\Migration\Resources\Auth\Membership;
48+
use Utopia\Migration\Resources\Auth\OAuth2\OAuth2Provider;
4849
use Utopia\Migration\Resources\Auth\Policies;
4950
use Utopia\Migration\Resources\Auth\Team;
5051
use Utopia\Migration\Resources\Auth\User;
@@ -260,6 +261,7 @@ public static function getSupportedResources(): array
260261
Resource::TYPE_MEMBERSHIP,
261262
Resource::TYPE_AUTH_METHODS,
262263
Resource::TYPE_POLICIES,
264+
Resource::TYPE_OAUTH2_PROVIDER,
263265

264266
// Database
265267
Resource::TYPE_DATABASE,
@@ -2196,6 +2198,10 @@ public function importAuthResource(Resource $resource): Resource
21962198
/** @var Policies $resource */
21972199
$this->createPolicies($resource);
21982200
break;
2201+
case Resource::TYPE_OAUTH2_PROVIDER:
2202+
/** @var OAuth2Provider $resource */
2203+
$this->createOAuth2Provider($resource);
2204+
break;
21992205
}
22002206

22012207
$resource->setStatus(Resource::STATUS_SUCCESS);
@@ -3546,6 +3552,58 @@ protected function createAuthMethods(AuthMethods $resource): bool
35463552
return true;
35473553
}
35483554

3555+
protected function createOAuth2Provider(OAuth2Provider $resource): bool
3556+
{
3557+
$key = $resource->getProviderKey();
3558+
$project = $this->dbForPlatform->getDocument('projects', $this->projectId);
3559+
$oAuthProviders = $project->getAttribute('oAuthProviders', []);
3560+
3561+
$appId = $resource->getDestinationAppId();
3562+
if ($appId !== null) {
3563+
$oAuthProviders[$key . 'Appid'] = $appId;
3564+
}
3565+
3566+
$secretFields = $resource->getDestinationSecretFields();
3567+
if (!empty($secretFields)) {
3568+
$oAuthProviders[$key . 'Secret'] = $this->mergeJsonSecret(
3569+
$oAuthProviders[$key . 'Secret'] ?? '',
3570+
$secretFields,
3571+
);
3572+
}
3573+
3574+
$oAuthProviders[$key . 'Enabled'] = $resource->getEnabled();
3575+
3576+
$this->dbForPlatform->getAuthorization()->skip(fn () => $this->dbForPlatform->updateDocument(
3577+
'projects',
3578+
$this->projectId,
3579+
new UtopiaDocument(['oAuthProviders' => $oAuthProviders]),
3580+
));
3581+
3582+
$this->dbForPlatform->purgeCachedDocument('projects', $this->projectId);
3583+
3584+
return true;
3585+
}
3586+
3587+
/**
3588+
* @param array<string, mixed> $fields
3589+
*/
3590+
private function mergeJsonSecret(string $existing, array $fields): string
3591+
{
3592+
if (empty($fields)) {
3593+
return $existing;
3594+
}
3595+
3596+
$decoded = $existing === '' ? [] : (\json_decode($existing, true) ?: []);
3597+
if (!\is_array($decoded)) {
3598+
$decoded = [];
3599+
}
3600+
foreach ($fields as $name => $value) {
3601+
$decoded[$name] = $value;
3602+
}
3603+
3604+
return \json_encode($decoded) ?: '';
3605+
}
3606+
35493607
/**
35503608
* Direct DB write — SDK policy setters reject `total: 0` but `0` is the
35513609
* storage value for "disabled". Shares the `auths` map with

src/Migration/Resource.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ abstract class Resource implements \JsonSerializable
7373

7474
public const TYPE_POLICIES = 'policies';
7575

76+
// One type shared by all OAuth2 provider Resource classes (dispatch is by
77+
// `instanceof` on the destination). A per-provider type would overflow the
78+
// migration document's 3KB `statusCounters` column when OAuth is selected.
79+
public const TYPE_OAUTH2_PROVIDER = 'oauth2-provider';
80+
7681
public const TYPE_ENVIRONMENT_VARIABLE = 'environment-variable';
7782

7883
// Integrations
@@ -131,6 +136,7 @@ abstract class Resource implements \JsonSerializable
131136
self::TYPE_MEMBERSHIP,
132137
self::TYPE_AUTH_METHODS,
133138
self::TYPE_POLICIES,
139+
self::TYPE_OAUTH2_PROVIDER,
134140
self::TYPE_PLATFORM,
135141
self::TYPE_API_KEY,
136142
self::TYPE_WEBHOOK,
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
<?php
2+
3+
namespace Utopia\Migration\Resources\Auth\OAuth2;
4+
5+
use Utopia\Migration\Resource;
6+
use Utopia\Migration\Transfer;
7+
8+
/**
9+
* OAuth2 provider secrets are write-only and are not migrated.
10+
*/
11+
final class OAuth2Provider extends Resource
12+
{
13+
private const TARGET_APP_ID = 'appId';
14+
private const TARGET_SECRET = 'secret';
15+
16+
/**
17+
* Allow-list of readable provider fields that are safe to migrate, keyed by
18+
* provider. Each field declares where it lands on the destination:
19+
* - target `appId` -> the provider's `{key}Appid` attribute (one per provider)
20+
* - target `secret` -> merged into the `{key}Secret` JSON blob, renamed via `key`
21+
*
22+
* Anything not listed here (clientSecret, Apple's p8File, etc.) is never copied,
23+
* so a secret field the server may add upstream cannot leak into a migration.
24+
*
25+
* @var array<string, array<string, array{target: string, key?: string}>>
26+
*/
27+
public const PROVIDERS = [
28+
'amazon' => ['clientId' => ['target' => self::TARGET_APP_ID]],
29+
'apple' => [
30+
'serviceId' => ['target' => self::TARGET_APP_ID],
31+
'keyId' => ['target' => self::TARGET_SECRET, 'key' => 'keyID'],
32+
'teamId' => ['target' => self::TARGET_SECRET, 'key' => 'teamID'],
33+
],
34+
'auth0' => ['clientId' => ['target' => self::TARGET_APP_ID], 'endpoint' => ['target' => self::TARGET_SECRET]],
35+
'authentik' => ['clientId' => ['target' => self::TARGET_APP_ID], 'endpoint' => ['target' => self::TARGET_SECRET]],
36+
'autodesk' => ['clientId' => ['target' => self::TARGET_APP_ID]],
37+
'bitbucket' => ['clientId' => ['target' => self::TARGET_APP_ID]],
38+
'bitly' => ['clientId' => ['target' => self::TARGET_APP_ID]],
39+
'box' => ['clientId' => ['target' => self::TARGET_APP_ID]],
40+
'dailymotion' => ['clientId' => ['target' => self::TARGET_APP_ID]],
41+
'discord' => ['clientId' => ['target' => self::TARGET_APP_ID]],
42+
'disqus' => ['clientId' => ['target' => self::TARGET_APP_ID]],
43+
'dropbox' => ['clientId' => ['target' => self::TARGET_APP_ID]],
44+
'etsy' => ['clientId' => ['target' => self::TARGET_APP_ID]],
45+
'facebook' => ['clientId' => ['target' => self::TARGET_APP_ID]],
46+
'figma' => ['clientId' => ['target' => self::TARGET_APP_ID]],
47+
'fusionauth' => ['clientId' => ['target' => self::TARGET_APP_ID], 'endpoint' => ['target' => self::TARGET_SECRET]],
48+
'github' => ['clientId' => ['target' => self::TARGET_APP_ID]],
49+
'gitlab' => ['clientId' => ['target' => self::TARGET_APP_ID], 'endpoint' => ['target' => self::TARGET_SECRET]],
50+
'google' => ['clientId' => ['target' => self::TARGET_APP_ID], 'prompt' => ['target' => self::TARGET_SECRET]],
51+
'keycloak' => [
52+
'clientId' => ['target' => self::TARGET_APP_ID],
53+
'endpoint' => ['target' => self::TARGET_SECRET, 'key' => 'keycloakDomain'],
54+
'realmName' => ['target' => self::TARGET_SECRET, 'key' => 'keycloakRealm'],
55+
],
56+
'kick' => ['clientId' => ['target' => self::TARGET_APP_ID]],
57+
'linkedin' => ['clientId' => ['target' => self::TARGET_APP_ID]],
58+
'microsoft' => ['clientId' => ['target' => self::TARGET_APP_ID], 'tenant' => ['target' => self::TARGET_SECRET]],
59+
'notion' => ['clientId' => ['target' => self::TARGET_APP_ID]],
60+
'oidc' => [
61+
'clientId' => ['target' => self::TARGET_APP_ID],
62+
'wellKnownURL' => ['target' => self::TARGET_SECRET, 'key' => 'wellKnownEndpoint'],
63+
'authorizationURL' => ['target' => self::TARGET_SECRET, 'key' => 'authorizationEndpoint'],
64+
'tokenURL' => ['target' => self::TARGET_SECRET, 'key' => 'tokenEndpoint'],
65+
'userInfoURL' => ['target' => self::TARGET_SECRET, 'key' => 'userInfoEndpoint'],
66+
],
67+
'okta' => [
68+
'clientId' => ['target' => self::TARGET_APP_ID],
69+
'domain' => ['target' => self::TARGET_SECRET, 'key' => 'oktaDomain'],
70+
'authorizationServerId' => ['target' => self::TARGET_SECRET],
71+
],
72+
'paypal' => ['clientId' => ['target' => self::TARGET_APP_ID]],
73+
'paypalSandbox' => ['clientId' => ['target' => self::TARGET_APP_ID]],
74+
'podio' => ['clientId' => ['target' => self::TARGET_APP_ID]],
75+
'salesforce' => ['clientId' => ['target' => self::TARGET_APP_ID]],
76+
'slack' => ['clientId' => ['target' => self::TARGET_APP_ID]],
77+
'spotify' => ['clientId' => ['target' => self::TARGET_APP_ID]],
78+
'stripe' => ['clientId' => ['target' => self::TARGET_APP_ID]],
79+
'tradeshift' => ['clientId' => ['target' => self::TARGET_APP_ID]],
80+
'tradeshiftBox' => ['clientId' => ['target' => self::TARGET_APP_ID]],
81+
'twitch' => ['clientId' => ['target' => self::TARGET_APP_ID]],
82+
'wordpress' => ['clientId' => ['target' => self::TARGET_APP_ID]],
83+
'x' => ['clientId' => ['target' => self::TARGET_APP_ID]],
84+
'yahoo' => ['clientId' => ['target' => self::TARGET_APP_ID]],
85+
'yandex' => ['clientId' => ['target' => self::TARGET_APP_ID]],
86+
'zoho' => ['clientId' => ['target' => self::TARGET_APP_ID]],
87+
'zoom' => ['clientId' => ['target' => self::TARGET_APP_ID]],
88+
];
89+
90+
public function __construct(
91+
string $id,
92+
protected readonly string $providerKey,
93+
protected readonly bool $enabled = false,
94+
protected readonly array $settings = [],
95+
string $createdAt = '',
96+
string $updatedAt = '',
97+
) {
98+
$this->id = $id;
99+
$this->createdAt = $createdAt;
100+
$this->updatedAt = $updatedAt;
101+
}
102+
103+
public static function getName(): string
104+
{
105+
return Resource::TYPE_OAUTH2_PROVIDER;
106+
}
107+
108+
public function getGroup(): string
109+
{
110+
return Transfer::GROUP_AUTH;
111+
}
112+
113+
/**
114+
* @param array<string, mixed> $array
115+
*/
116+
public static function fromArray(string $providerKey, array $array): ?self
117+
{
118+
$allowed = self::PROVIDERS[$providerKey] ?? null;
119+
if ($allowed === null) {
120+
return null;
121+
}
122+
123+
$settings = [];
124+
foreach (\array_keys($allowed) as $field) {
125+
if (\array_key_exists($field, $array)) {
126+
$settings[$field] = $array[$field];
127+
}
128+
}
129+
130+
return new self(
131+
$array['id'],
132+
$providerKey,
133+
(bool) ($array['enabled'] ?? false),
134+
$settings,
135+
createdAt: $array['createdAt'] ?? '',
136+
updatedAt: $array['updatedAt'] ?? '',
137+
);
138+
}
139+
140+
/**
141+
* @return array<string, mixed>
142+
*/
143+
public function jsonSerialize(): array
144+
{
145+
return [
146+
'id' => $this->id,
147+
'providerKey' => $this->providerKey,
148+
'enabled' => $this->enabled,
149+
'settings' => $this->settings,
150+
'createdAt' => $this->createdAt,
151+
'updatedAt' => $this->updatedAt,
152+
];
153+
}
154+
155+
public function getProviderKey(): string
156+
{
157+
return $this->providerKey;
158+
}
159+
160+
public function getEnabled(): bool
161+
{
162+
return $this->enabled;
163+
}
164+
165+
/**
166+
* @return array<string, mixed>
167+
*/
168+
public function getSettings(): array
169+
{
170+
return $this->settings;
171+
}
172+
173+
/**
174+
* Value for the destination's `{key}Appid` attribute (clientId, or serviceId
175+
* for Apple). Null when unset, so callers can skip it without a separate
176+
* emptiness check.
177+
*/
178+
public function getDestinationAppId(): ?string
179+
{
180+
foreach ($this->getDescriptor() as $field => $metadata) {
181+
if ($metadata['target'] !== self::TARGET_APP_ID) {
182+
continue;
183+
}
184+
185+
$value = $this->settings[$field] ?? null;
186+
187+
return self::isEmpty($value) ? null : (string) $value;
188+
}
189+
190+
return null;
191+
}
192+
193+
/**
194+
* @return array<string, mixed>
195+
*/
196+
public function getDestinationSecretFields(): array
197+
{
198+
$fields = [];
199+
foreach ($this->getDescriptor() as $field => $metadata) {
200+
if ($metadata['target'] !== self::TARGET_SECRET || !\array_key_exists($field, $this->settings)) {
201+
continue;
202+
}
203+
204+
$value = $this->settings[$field];
205+
if (self::isEmpty($value)) {
206+
continue;
207+
}
208+
209+
$fields[$metadata['key'] ?? $field] = $value;
210+
}
211+
212+
return $fields;
213+
}
214+
215+
public function isConfigured(): bool
216+
{
217+
return $this->enabled || $this->getDestinationAppId() !== null;
218+
}
219+
220+
/**
221+
* @return array<string, array{target: string, key?: string}>
222+
*/
223+
private function getDescriptor(): array
224+
{
225+
return self::PROVIDERS[$this->providerKey] ?? [];
226+
}
227+
228+
private static function isEmpty(mixed $value): bool
229+
{
230+
return $value === null || $value === '' || $value === [];
231+
}
232+
}

0 commit comments

Comments
 (0)