Skip to content

Commit 67571e1

Browse files
committed
feat: import social auth IDPs and domain during forward refactor
Gen1 creates the Cognito domain and social IDPs via a Lambda custom resource, so they aren't in the Gen1 CFN template and get left behind when the UserPool is moved to Gen2. After the move, fetch them live from Cognito and import them into the Gen2 stack via CreateChangeSet(IMPORT), matching providers to Gen2 logical IDs by ProviderName. Imported resources are written with DeletionPolicy: Retain so rollback can orphan them safely.
1 parent 2f725c3 commit 67571e1

4 files changed

Lines changed: 333 additions & 5 deletions

File tree

packages/amplify-cli/src/__tests__/commands/gen2-migration/_framework/clients/cognito-identity-provider.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,12 @@ export class CognitoIdentityProviderMock {
124124

125125
const usernameAttributes: string[] = authCliInputs.cognitoConfig.usernameAttributes ?? [];
126126
const aliasAttributes: string[] = authCliInputs.cognitoConfig.aliasAttributes ?? [];
127+
const authMeta = this.app.meta.auth?.[authResourceName];
128+
const domain = authMeta?.output?.HostedUIDomain;
127129
return {
128130
UserPool: {
129131
Id: input.UserPoolId,
132+
Domain: domain,
130133
EmailVerificationMessage: authCliInputs.cognitoConfig.emailVerificationMessage,
131134
EmailVerificationSubject: authCliInputs.cognitoConfig.emailVerificationSubject,
132135
SchemaAttributes: template.Resources.UserPool.Properties.Schema,

packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-cognito-forward.test.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@ import {
1818
ResourceMapping,
1919
} from '@aws-sdk/client-cloudformation';
2020
import { SSMClient } from '@aws-sdk/client-ssm';
21-
import { CognitoIdentityProviderClient, DescribeIdentityProviderCommand } from '@aws-sdk/client-cognito-identity-provider';
21+
import {
22+
CognitoIdentityProviderClient,
23+
DescribeIdentityProviderCommand,
24+
DescribeUserPoolCommand,
25+
ListIdentityProvidersCommand,
26+
} from '@aws-sdk/client-cognito-identity-provider';
2227
import { Cfn } from '../../../../../commands/gen2-migration/refactor/cfn';
2328

2429
const ts = new Date();
@@ -197,8 +202,19 @@ describe('AuthCognitoForwardRefactorer.plan() — operation sequence', () => {
197202
cfnMock.on(DeleteChangeSetCommand).resolves({});
198203

199204
const cognitoMock = mockClient(CognitoIdentityProviderClient);
205+
cognitoMock.on(DescribeUserPoolCommand).resolves({
206+
UserPool: { Id: 'us-east-1_ABC123', Domain: 'test-domain' },
207+
});
208+
cognitoMock.on(ListIdentityProvidersCommand).resolves({
209+
Providers: [{ ProviderName: 'Google', ProviderType: 'Google' }],
210+
});
200211
cognitoMock.on(DescribeIdentityProviderCommand).resolves({
201-
IdentityProvider: { ProviderDetails: { client_id: 'google-id', client_secret: 'google-secret' } },
212+
IdentityProvider: {
213+
ProviderName: 'Google',
214+
ProviderType: 'Google',
215+
ProviderDetails: { client_id: 'google-id', client_secret: 'google-secret', authorize_scopes: 'openid email profile' },
216+
AttributeMapping: { email: 'email' },
217+
},
202218
});
203219

204220
const clients = new (AwsClients as any)({ region: 'us-east-1' });
@@ -218,7 +234,8 @@ describe('AuthCognitoForwardRefactorer.plan() — operation sequence', () => {
218234

219235
const ops = await refactorer.plan();
220236

221-
expect(cognitoMock.commandCalls(DescribeIdentityProviderCommand)).toHaveLength(1);
237+
// Called once by retrieveOAuthValues and once by fetchSocialAuthConfig
238+
expect(cognitoMock.commandCalls(DescribeIdentityProviderCommand)).toHaveLength(2);
222239
expect(ops.length).toBeGreaterThanOrEqual(4);
223240

224241
const { CreateChangeSetCommand: CreateCS } = await import('@aws-sdk/client-cloudformation');

packages/amplify-cli/src/commands/gen2-migration/refactor/auth/auth-cognito-forward.ts

Lines changed: 248 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
1-
import { Output, Parameter } from '@aws-sdk/client-cloudformation';
1+
import { Output, Parameter, ResourceToImport } from '@aws-sdk/client-cloudformation';
2+
import {
3+
DescribeUserPoolCommand,
4+
DescribeIdentityProviderCommand,
5+
ListIdentityProvidersCommand,
6+
} from '@aws-sdk/client-cognito-identity-provider';
27
import { AmplifyError } from '@aws-amplify/amplify-cli-core';
38
import { retrieveOAuthValues } from '../oauth-values-retriever';
49
import { ForwardCategoryRefactorer } from '../workflow/forward-category-refactorer';
10+
import { RefactorBlueprint } from '../workflow/category-refactorer';
511
import { CFNResource } from '../../_infra/cfn-template';
12+
import { AmplifyMigrationOperation } from '../../_infra/operation';
13+
import { extractStackNameFromId } from '../utils';
14+
import CLITable from 'cli-table3';
615

716
const HOSTED_PROVIDER_META_PARAMETER_NAME = 'hostedUIProviderMeta';
817
const HOSTED_PROVIDER_CREDENTIALS_PARAMETER_NAME = 'hostedUIProviderCreds';
@@ -19,27 +28,51 @@ export const USER_POOL_TYPE = 'AWS::Cognito::UserPool';
1928
export const IDENTITY_POOL_TYPE = 'AWS::Cognito::IdentityPool';
2029
export const IDENTITY_POOL_ROLE_ATTACHMENT_TYPE = 'AWS::Cognito::IdentityPoolRoleAttachment';
2130
export const USER_POOL_DOMAIN_TYPE = 'AWS::Cognito::UserPoolDomain';
31+
export const USER_POOL_IDENTITY_PROVIDER_TYPE = 'AWS::Cognito::UserPoolIdentityProvider';
2232

2333
export const RESOURCE_TYPES = [
2434
USER_POOL_TYPE,
2535
USER_POOL_CLIENT_TYPE,
2636
IDENTITY_POOL_TYPE,
2737
IDENTITY_POOL_ROLE_ATTACHMENT_TYPE,
2838
USER_POOL_DOMAIN_TYPE,
39+
USER_POOL_IDENTITY_PROVIDER_TYPE,
2940
];
3041

42+
interface IdpConfig {
43+
readonly providerName: string;
44+
readonly providerType: string;
45+
readonly clientId: string;
46+
readonly clientSecret: string;
47+
readonly authorizeScopes: string;
48+
readonly attributeMapping: Record<string, string>;
49+
}
50+
51+
interface SocialAuthConfig {
52+
readonly userPoolId: string;
53+
readonly domain: string;
54+
readonly providers: IdpConfig[];
55+
}
56+
3157
/**
3258
* Forward refactorer for the auth:Cognito resource.
3359
*
3460
* Moves main auth resources from Gen1 to Gen2.
61+
* For social auth apps, imports Gen1's LambdaCallout-created IDPs and domain
62+
* into the Gen2 stack as native CFN resources during the move phase.
3563
*/
3664
export class AuthCognitoForwardRefactorer extends ForwardCategoryRefactorer {
65+
/**
66+
* Returns the full set including domain and IDP types. These types don't exist in the
67+
* Gen1 CFN template (they're created by a Lambda trigger), so they won't appear in the
68+
* refactor mappings. They are imported into Gen2 as a separate step in move().
69+
*/
3770
protected resourceTypes(): string[] {
3871
return RESOURCE_TYPES;
3972
}
4073

4174
/**
42-
* OAuth hook: retrieves credentials and updates hostedUIProviderCreds parameter.
75+
* OAuth hook: retrieves credentials and updates the hostedUIProviderCreds parameter.
4376
*/
4477
protected override async resolveOAuthParameters(parameters: Parameter[], outputs: Output[]): Promise<Parameter[]> {
4578
const oAuthParam = parameters.find((p) => p.ParameterKey === HOSTED_PROVIDER_META_PARAMETER_NAME);
@@ -68,9 +101,104 @@ export class AuthCognitoForwardRefactorer extends ForwardCategoryRefactorer {
68101
});
69102
}
70103
credsParam.ParameterValue = JSON.stringify(oAuthValues);
104+
71105
return parameters;
72106
}
73107

108+
/**
109+
* Executes the standard resource refactor, then imports Gen1's
110+
* physical domain and IDPs into the Gen2 stack as native CFN resources.
111+
*/
112+
protected override async move(blueprint: RefactorBlueprint): Promise<AmplifyMigrationOperation[]> {
113+
const baseOps = await super.move(blueprint);
114+
115+
const importOp = await this.buildImportSocialAuthOperation(blueprint);
116+
if (importOp) {
117+
return [...baseOps, importOp];
118+
}
119+
120+
return baseOps;
121+
}
122+
123+
/**
124+
* Builds an operation that imports Gen1's physical domain and IDPs into the
125+
* Gen2 stack. Returns undefined if the app doesn't use social auth.
126+
*/
127+
private async buildImportSocialAuthOperation(blueprint: RefactorBlueprint): Promise<AmplifyMigrationOperation | undefined> {
128+
const socialAuthConfig = await this.fetchSocialAuthConfig(blueprint.sourceStackId);
129+
if (!socialAuthConfig) {
130+
return undefined;
131+
}
132+
133+
const gen2StackId = blueprint.targetStackId;
134+
const gen2Template = await this.cfn.fetchTemplate(gen2StackId);
135+
const gen2IdpLogicalIds = new Map<string, string>();
136+
let gen2DomainLogicalId: string | undefined;
137+
138+
// Find the Gen2 logical IDs we'll import the physical Gen1 resources into
139+
// We require providerName + logicalId to disambiguate between multiple providers
140+
for (const [logicalId, resource] of Object.entries(gen2Template.Resources)) {
141+
if (resource.Type === USER_POOL_DOMAIN_TYPE) {
142+
gen2DomainLogicalId = logicalId;
143+
} else if (resource.Type === USER_POOL_IDENTITY_PROVIDER_TYPE) {
144+
const providerName = resource.Properties.ProviderName as string;
145+
if (providerName) {
146+
gen2IdpLogicalIds.set(providerName, logicalId);
147+
}
148+
}
149+
}
150+
151+
if (!gen2DomainLogicalId) {
152+
this.debug('No Gen2 UserPoolDomain resource found — skipping import');
153+
return undefined;
154+
}
155+
156+
if (gen2IdpLogicalIds.size === 0) {
157+
this.debug('No Gen2 UserPoolIdentityProvider resources found — skipping import');
158+
return undefined;
159+
}
160+
161+
return {
162+
resource: this.resource,
163+
validate: () => undefined,
164+
describe: async () => {
165+
const gen2StackName = extractStackNameFromId(gen2StackId);
166+
const table = new CLITable({
167+
head: ['Source Physical ID', 'Target Logical ID'],
168+
style: { head: [] },
169+
});
170+
table.push([socialAuthConfig.domain, gen2DomainLogicalId!]);
171+
for (const provider of socialAuthConfig.providers) {
172+
const logicalId = gen2IdpLogicalIds.get(provider.providerName);
173+
if (logicalId) {
174+
const label =
175+
provider.providerType !== provider.providerName
176+
? `${provider.providerName} (${provider.providerType})`
177+
: provider.providerName;
178+
table.push([label, logicalId]);
179+
}
180+
}
181+
return [`Import social auth resources into '${gen2StackName}'\n\n${table.toString()}`];
182+
},
183+
execute: async () => {
184+
const templateForImport = await this.cfn.fetchTemplate(gen2StackId);
185+
186+
const { resourcesToImport, templateAdditions } = this.buildImportSpec(socialAuthConfig, gen2DomainLogicalId!, gen2IdpLogicalIds);
187+
188+
for (const [logicalId, resource] of Object.entries(templateAdditions)) {
189+
templateForImport.Resources[logicalId] = resource;
190+
}
191+
192+
await this.cfn.importResources({
193+
stackName: gen2StackId,
194+
templateBody: templateForImport,
195+
resourcesToImport,
196+
resource: this.resource,
197+
});
198+
},
199+
};
200+
}
201+
74202
protected override match(sourceId: string, sourceResource: CFNResource, targetId: string, targetResource: CFNResource): boolean {
75203
if (sourceResource.Type !== targetResource.Type) {
76204
return false;
@@ -101,4 +229,122 @@ export class AuthCognitoForwardRefactorer extends ForwardCategoryRefactorer {
101229
// in gen2 all auth resources are in a single auth nested stack
102230
return this.findNestedStack(this.gen2Branch, 'auth');
103231
}
232+
233+
/**
234+
* Fetches domain and IDP config directly from Cognito. These resources are
235+
* Lambda-created (not in the Gen1 CFN template) so the live API is the only source.
236+
*/
237+
private async fetchSocialAuthConfig(sourceStackId: string): Promise<SocialAuthConfig | undefined> {
238+
const sourceStack = await this.gen1Env.fetchStack(sourceStackId);
239+
const userPoolId = (sourceStack.Outputs ?? []).find((o) => o.OutputKey === USER_POOL_ID_OUTPUT_KEY_NAME)?.OutputValue;
240+
if (!userPoolId) {
241+
return undefined;
242+
}
243+
244+
const cognitoClient = this.gen1App.clients.cognitoIdentityProvider;
245+
246+
const poolResponse = await cognitoClient.send(new DescribeUserPoolCommand({ UserPoolId: userPoolId }));
247+
const domain = poolResponse?.UserPool?.Domain;
248+
if (!domain) {
249+
this.debug('Gen1 UserPool has no domain — skipping social auth import');
250+
return undefined;
251+
}
252+
253+
const listResponse = await cognitoClient.send(new ListIdentityProvidersCommand({ UserPoolId: userPoolId }));
254+
const providerSummaries = listResponse?.Providers ?? [];
255+
if (providerSummaries.length === 0) {
256+
this.debug('Gen1 UserPool has no identity providers — skipping social auth import');
257+
return undefined;
258+
}
259+
260+
const providers: IdpConfig[] = [];
261+
for (const summary of providerSummaries) {
262+
const providerName = summary.ProviderName;
263+
if (!providerName) continue;
264+
265+
const describeResponse = await cognitoClient.send(
266+
new DescribeIdentityProviderCommand({ UserPoolId: userPoolId, ProviderName: providerName }),
267+
);
268+
const idp = describeResponse.IdentityProvider;
269+
if (!idp?.ProviderDetails) continue;
270+
271+
providers.push({
272+
providerName,
273+
providerType: idp.ProviderType ?? providerName,
274+
clientId: idp.ProviderDetails.client_id ?? '',
275+
clientSecret: idp.ProviderDetails.client_secret ?? '',
276+
authorizeScopes: idp.ProviderDetails.authorize_scopes ?? '',
277+
attributeMapping: (idp.AttributeMapping as Record<string, string>) ?? {},
278+
});
279+
}
280+
281+
this.debug(`Fetched social auth config: domain=${domain}, providers=${providers.map((p) => p.providerName).join(',')}`);
282+
return { userPoolId, domain, providers };
283+
}
284+
285+
/**
286+
* Builds the CFN import spec: template additions with DeletionPolicy: Retain
287+
* (so rollback can orphan them without deleting the physical resources) and
288+
* resource identifiers for the import change set.
289+
*/
290+
private buildImportSpec(
291+
config: SocialAuthConfig,
292+
domainLogicalId: string,
293+
idpLogicalIds: Map<string, string>,
294+
): { resourcesToImport: ResourceToImport[]; templateAdditions: Record<string, CFNResource> } {
295+
const resourcesToImport: ResourceToImport[] = [];
296+
const templateAdditions: Record<string, CFNResource> = {};
297+
298+
templateAdditions[domainLogicalId] = {
299+
Type: USER_POOL_DOMAIN_TYPE,
300+
DeletionPolicy: 'Retain',
301+
Properties: {
302+
Domain: config.domain,
303+
UserPoolId: config.userPoolId,
304+
},
305+
};
306+
resourcesToImport.push({
307+
ResourceType: USER_POOL_DOMAIN_TYPE,
308+
LogicalResourceId: domainLogicalId,
309+
ResourceIdentifier: {
310+
UserPoolId: config.userPoolId,
311+
Domain: config.domain,
312+
},
313+
});
314+
315+
for (const provider of config.providers) {
316+
const logicalId = idpLogicalIds.get(provider.providerName);
317+
if (!logicalId) {
318+
this.debug(`No Gen2 logical ID for provider ${provider.providerName} — skipping import`);
319+
continue;
320+
}
321+
322+
templateAdditions[logicalId] = {
323+
Type: USER_POOL_IDENTITY_PROVIDER_TYPE,
324+
DeletionPolicy: 'Retain',
325+
Properties: {
326+
UserPoolId: config.userPoolId,
327+
ProviderName: provider.providerName,
328+
ProviderType: provider.providerType,
329+
ProviderDetails: {
330+
client_id: provider.clientId,
331+
client_secret: provider.clientSecret,
332+
authorize_scopes: provider.authorizeScopes,
333+
},
334+
AttributeMapping: provider.attributeMapping,
335+
},
336+
};
337+
338+
resourcesToImport.push({
339+
ResourceType: USER_POOL_IDENTITY_PROVIDER_TYPE,
340+
LogicalResourceId: logicalId,
341+
ResourceIdentifier: {
342+
UserPoolId: config.userPoolId,
343+
ProviderName: provider.providerName,
344+
},
345+
});
346+
}
347+
348+
return { resourcesToImport, templateAdditions };
349+
}
104350
}

0 commit comments

Comments
 (0)