diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/_framework/clients/cognito-identity-provider.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/_framework/clients/cognito-identity-provider.ts index d59bcdb3d99..3ee64acf83b 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/_framework/clients/cognito-identity-provider.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/_framework/clients/cognito-identity-provider.ts @@ -124,9 +124,12 @@ export class CognitoIdentityProviderMock { const usernameAttributes: string[] = authCliInputs.cognitoConfig.usernameAttributes ?? []; const aliasAttributes: string[] = authCliInputs.cognitoConfig.aliasAttributes ?? []; + const authMeta = this.app.meta.auth?.[authResourceName]; + const domain = authMeta?.output?.HostedUIDomain; return { UserPool: { Id: input.UserPoolId, + Domain: domain, EmailVerificationMessage: authCliInputs.cognitoConfig.emailVerificationMessage, EmailVerificationSubject: authCliInputs.cognitoConfig.emailVerificationSubject, SchemaAttributes: template.Resources.UserPool.Properties.Schema, diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-cognito-forward.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-cognito-forward.test.ts index 9bcc6dccbe7..84b82e34474 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-cognito-forward.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-cognito-forward.test.ts @@ -18,7 +18,12 @@ import { ResourceMapping, } from '@aws-sdk/client-cloudformation'; import { SSMClient } from '@aws-sdk/client-ssm'; -import { CognitoIdentityProviderClient, DescribeIdentityProviderCommand } from '@aws-sdk/client-cognito-identity-provider'; +import { + CognitoIdentityProviderClient, + DescribeIdentityProviderCommand, + DescribeUserPoolCommand, + ListIdentityProvidersCommand, +} from '@aws-sdk/client-cognito-identity-provider'; import { Cfn } from '../../../../../commands/gen2-migration/refactor/cfn'; const ts = new Date(); @@ -197,8 +202,19 @@ describe('AuthCognitoForwardRefactorer.plan() — operation sequence', () => { cfnMock.on(DeleteChangeSetCommand).resolves({}); const cognitoMock = mockClient(CognitoIdentityProviderClient); + cognitoMock.on(DescribeUserPoolCommand).resolves({ + UserPool: { Id: 'us-east-1_ABC123', Domain: 'test-domain' }, + }); + cognitoMock.on(ListIdentityProvidersCommand).resolves({ + Providers: [{ ProviderName: 'Google', ProviderType: 'Google' }], + }); cognitoMock.on(DescribeIdentityProviderCommand).resolves({ - IdentityProvider: { ProviderDetails: { client_id: 'google-id', client_secret: 'google-secret' } }, + IdentityProvider: { + ProviderName: 'Google', + ProviderType: 'Google', + ProviderDetails: { client_id: 'google-id', client_secret: 'google-secret', authorize_scopes: 'openid email profile' }, + AttributeMapping: { email: 'email' }, + }, }); const clients = new (AwsClients as any)({ region: 'us-east-1' }); @@ -218,7 +234,8 @@ describe('AuthCognitoForwardRefactorer.plan() — operation sequence', () => { const ops = await refactorer.plan(); - expect(cognitoMock.commandCalls(DescribeIdentityProviderCommand)).toHaveLength(1); + // Called once by retrieveOAuthValues and once by fetchSocialAuthConfig + expect(cognitoMock.commandCalls(DescribeIdentityProviderCommand)).toHaveLength(2); expect(ops.length).toBeGreaterThanOrEqual(4); const { CreateChangeSetCommand: CreateCS } = await import('@aws-sdk/client-cloudformation'); diff --git a/packages/amplify-cli/src/commands/gen2-migration/_infra/cfn-template.ts b/packages/amplify-cli/src/commands/gen2-migration/_infra/cfn-template.ts index 5d544c44f80..e1b3ba4a517 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/_infra/cfn-template.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/_infra/cfn-template.ts @@ -36,6 +36,7 @@ export interface CFNResource { readonly Condition?: string; // DependsOn is mutable: resolvers and buildBlueprint remap dependencies on cloned templates. DependsOn?: string | string[]; + DeletionPolicy?: string; } export interface CFNParameter { diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/auth/auth.renderer.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/auth/auth.renderer.ts index 1406f6e3e21..5fa6463f002 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/auth/auth.renderer.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/auth/auth.renderer.ts @@ -262,12 +262,12 @@ export class AuthRenderer { private static deriveExternalProviders(details?: readonly IdentityProviderType[]): { readonly oidcProviders: readonly OidcProviderConfig[]; readonly samlProvider: SamlProviderConfig | undefined; - readonly attributeMappings: Readonly>>; + readonly attributeMappings: Readonly; custom: Record }>>; readonly providerScopes: Readonly>; } { const oidcProviders: OidcProviderConfig[] = []; let samlProvider: SamlProviderConfig | undefined; - const attributeMappings: Record> = {}; + const attributeMappings: Record; custom: Record }> = {}; const providerScopes: Record = {}; if (!details) { @@ -283,21 +283,23 @@ export class AuthRenderer { authorize_url && token_url && attributes_url && jwks_uri ? { authorization: authorize_url, token: token_url, userInfo: attributes_url, jwksUri: jwks_uri } : undefined; + const oidcMapping = AttributeMapping ? AuthRenderer.filterAttributeMapping(AttributeMapping) : undefined; oidcProviders.push({ issuerUrl: oidc_issuer, name: ProviderName, endpoints, - attributeMapping: AttributeMapping ? AuthRenderer.filterAttributeMapping(AttributeMapping) : undefined, + attributeMapping: oidcMapping ? { ...oidcMapping.standard, ...oidcMapping.custom } : undefined, }); } else if (ProviderType === IdentityProviderTypeType.SAML && ProviderDetails) { const { metadataURL, metadataContent } = ProviderDetails; + const samlMapping = AttributeMapping ? AuthRenderer.filterAttributeMapping(AttributeMapping) : undefined; samlProvider = { metadata: { metadataContent: metadataURL || metadataContent, metadataType: metadataURL ? ('URL' as const) : ('FILE' as const), }, name: ProviderName, - attributeMapping: AttributeMapping ? AuthRenderer.filterAttributeMapping(AttributeMapping) : undefined, + attributeMapping: samlMapping ? { ...samlMapping.standard, ...samlMapping.custom } : undefined, }; } else { if (AttributeMapping) { @@ -311,9 +313,7 @@ export class AuthRenderer { if (ProviderDetails) { const scopes = AuthRenderer.deriveProviderSpecificScopes(ProviderDetails); if (scopes.length > 0) { - const mapped = scopes - .map((scope) => (scope === 'public_profile' ? 'profile' : scope)) - .filter((scope) => VALID_SCOPES.includes(scope)); + const mapped = scopes.filter((scope) => scope.length > 0); if (mapped.length > 0 && ProviderType) { providerScopes[ProviderType] = mapped; } @@ -445,7 +445,7 @@ export class AuthRenderer { * Extracts provider-specific scopes from provider details. */ private static deriveProviderSpecificScopes(providerDetails: Record): string[] { - const scopeFields = ['authorized_scopes', 'scope', 'scopes']; + const scopeFields = ['authorize_scopes', 'authorized_scopes', 'scope', 'scopes']; for (const field of scopeFields) { if (providerDetails[field]) { return providerDetails[field].split(/[\s,]+/).filter((scope) => scope.length > 0); @@ -457,12 +457,22 @@ export class AuthRenderer { /** * Filters attribute mappings to only known standard attributes. */ - private static filterAttributeMapping(attributeMapping: Record): Record { - return Object.fromEntries( - Object.entries(attributeMapping) - .filter(([key]) => Object.keys(MAPPED_USER_ATTRIBUTE_NAME).includes(key)) - .map(([key, value]) => [MAPPED_USER_ATTRIBUTE_NAME[key], value]), - ); + private static filterAttributeMapping(attributeMapping: Record): { + standard: Record; + custom: Record; + } { + const standard: Record = {}; + const custom: Record = {}; + + for (const [key, value] of Object.entries(attributeMapping)) { + if (key in MAPPED_USER_ATTRIBUTE_NAME) { + standard[MAPPED_USER_ATTRIBUTE_NAME[key]] = value; + } else { + custom[key] = value; + } + } + + return { standard, custom }; } // ── AST rendering helpers ──────────────────────────────────────── @@ -653,7 +663,7 @@ export class AuthRenderer { externalProviders: { readonly oidcProviders: readonly OidcProviderConfig[]; readonly samlProvider: SamlProviderConfig | undefined; - readonly attributeMappings: Readonly>>; + readonly attributeMappings: Readonly; custom: Record }>>; readonly providerScopes: Readonly>; }, callbackUrls?: readonly string[], @@ -844,7 +854,7 @@ export class AuthRenderer { private static createProviderConfig( config: Record, - attributeMapping: Record | undefined, + attributeMapping: { standard: Record; custom: Record } | undefined, ): ts.ObjectLiteralElementLike[] { const properties: ts.ObjectLiteralElementLike[] = []; @@ -870,10 +880,23 @@ export class AuthRenderer { if (attributeMapping) { const mappingProperties: ts.ObjectLiteralElementLike[] = []; - Object.entries(attributeMapping).forEach(([key, value]) => + Object.entries(attributeMapping.standard).forEach(([key, value]) => mappingProperties.push(factory.createPropertyAssignment(factory.createIdentifier(key), factory.createStringLiteral(value))), ); + if (Object.keys(attributeMapping.custom).length > 0) { + const customProperties: ts.ObjectLiteralElementLike[] = []; + Object.entries(attributeMapping.custom).forEach(([key, value]) => + customProperties.push(factory.createPropertyAssignment(factory.createIdentifier(key), factory.createStringLiteral(value))), + ); + mappingProperties.push( + factory.createPropertyAssignment( + factory.createIdentifier('custom'), + factory.createObjectLiteralExpression(customProperties, true), + ), + ); + } + properties.push( factory.createPropertyAssignment( factory.createIdentifier('attributeMapping'), @@ -888,7 +911,7 @@ export class AuthRenderer { private static createProviderPropertyAssignment( name: string, config: Record, - attributeMapping: Record | undefined, + attributeMapping: { standard: Record; custom: Record } | undefined, ): PropertyAssignment { return factory.createPropertyAssignment( factory.createIdentifier(name), diff --git a/packages/amplify-cli/src/commands/gen2-migration/lock.ts b/packages/amplify-cli/src/commands/gen2-migration/lock.ts index 4612f8c3c81..96170962c46 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/lock.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/lock.ts @@ -2,13 +2,25 @@ import { AmplifyMigrationStep } from './_infra/step'; import { AmplifyMigrationOperation, ValidationResult } from './_infra/operation'; import { Plan } from './_infra/plan'; import { AmplifyError } from '@aws-amplify/amplify-cli-core'; -import { SetStackPolicyCommand, GetStackPolicyCommand } from '@aws-sdk/client-cloudformation'; +import { + SetStackPolicyCommand, + GetStackPolicyCommand, + DescribeStackResourcesCommand, + DescribeStacksCommand, + GetTemplateCommand, + UpdateStackCommand, + waitUntilStackUpdateComplete, +} from '@aws-sdk/client-cloudformation'; import { UpdateAppCommand, GetAppCommand } from '@aws-sdk/client-amplify'; import { UpdateTableCommand, paginateListTables } from '@aws-sdk/client-dynamodb'; import { paginateListGraphqlApis } from '@aws-sdk/client-appsync'; +import { CFNTemplate } from './_infra/cfn-template'; const GEN2_MIGRATION_ENVIRONMENT_NAME = 'GEN2_MIGRATION_ENVIRONMENT_NAME'; +const HOSTED_UI_CUSTOM_RESOURCES = ['HostedUICustomResourceInputs', 'HostedUIProvidersCustomResourceInputs']; +const CFN_IAM_CAPABILITY = 'CAPABILITY_NAMED_IAM'; + const LOCK_STATEMENT = { Effect: 'Deny', Action: 'Update:*', @@ -69,6 +81,11 @@ export class AmplifyMigrationLockStep extends AmplifyMigrationStep { }); } + const hostedUiRetainOp = await this.buildHostedUiRetainOperation(); + if (hostedUiRetainOp) { + operations.push(hostedUiRetainOp); + } + operations.push({ validate: () => undefined, describe: async () => [`Add environment variable '${GEN2_MIGRATION_ENVIRONMENT_NAME}' (value: ${this.gen1App.envName})`], @@ -228,6 +245,55 @@ export class AmplifyMigrationLockStep extends AmplifyMigrationStep { return this._dynamoTableNames; } + private async buildHostedUiRetainOperation(): Promise { + const authResource = this.gen1App.discover().find((r) => r.category === 'auth' && r.service === 'Cognito'); + if (!authResource) return undefined; + + const nestedStackPrefix = `auth${authResource.resourceName}`; + const rootResources = await this.gen1App.clients.cloudFormation.send( + new DescribeStackResourcesCommand({ StackName: this.gen1App.rootStackName }), + ); + const authStack = (rootResources.StackResources ?? []).find( + (r) => r.ResourceType === 'AWS::CloudFormation::Stack' && r.LogicalResourceId?.startsWith(nestedStackPrefix), + ); + if (!authStack?.PhysicalResourceId) return undefined; + + const authStackId = authStack.PhysicalResourceId; + const templateResponse = await this.gen1App.clients.cloudFormation.send( + new GetTemplateCommand({ StackName: authStackId, TemplateStage: 'Original' }), + ); + if (!templateResponse.TemplateBody) return undefined; + + const template = JSON.parse(templateResponse.TemplateBody) as CFNTemplate; + const resourcesToRetain = HOSTED_UI_CUSTOM_RESOURCES.filter((id) => id in template.Resources); + if (resourcesToRetain.length === 0) return undefined; + + return { + validate: () => undefined, + describe: async () => [`Set DeletionPolicy: Retain on social auth custom resources: ${resourcesToRetain.join(', ')}`], + execute: async () => { + for (const logicalId of resourcesToRetain) { + template.Resources[logicalId].DeletionPolicy = 'Retain'; + } + const { Stacks } = await this.gen1App.clients.cloudFormation.send(new DescribeStacksCommand({ StackName: authStackId })); + const parameters = (Stacks?.[0]?.Parameters ?? []).map((p) => ({ + ParameterKey: p.ParameterKey, + UsePreviousValue: true, + })); + await this.gen1App.clients.cloudFormation.send( + new UpdateStackCommand({ + StackName: authStackId, + TemplateBody: JSON.stringify(template), + Parameters: parameters, + Capabilities: [CFN_IAM_CAPABILITY], + }), + ); + await waitUntilStackUpdateComplete({ client: this.gen1App.clients.cloudFormation, maxWaitTime: 900 }, { StackName: authStackId }); + this.logger.info(`Set DeletionPolicy: Retain on ${resourcesToRetain.join(', ')}`); + }, + }; + } + private async getExistingStackPolicy(): Promise<{ Statement: Record[] }> { const response = await this.gen1App.clients.cloudFormation.send( new GetStackPolicyCommand({ diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor/auth/auth-cognito-forward.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor/auth/auth-cognito-forward.ts index 93cc90f5192..1ced56473ed 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/refactor/auth/auth-cognito-forward.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/refactor/auth/auth-cognito-forward.ts @@ -1,8 +1,17 @@ -import { Output, Parameter } from '@aws-sdk/client-cloudformation'; +import { Output, Parameter, ResourceToImport } from '@aws-sdk/client-cloudformation'; +import { + DescribeUserPoolCommand, + DescribeIdentityProviderCommand, + ListIdentityProvidersCommand, +} from '@aws-sdk/client-cognito-identity-provider'; import { AmplifyError } from '@aws-amplify/amplify-cli-core'; import { retrieveOAuthValues } from '../oauth-values-retriever'; import { ForwardCategoryRefactorer } from '../workflow/forward-category-refactorer'; +import { RefactorBlueprint } from '../workflow/category-refactorer'; import { CFNResource } from '../../_infra/cfn-template'; +import { AmplifyMigrationOperation } from '../../_infra/operation'; +import { extractStackNameFromId } from '../utils'; +import CLITable from 'cli-table3'; const HOSTED_PROVIDER_META_PARAMETER_NAME = 'hostedUIProviderMeta'; const HOSTED_PROVIDER_CREDENTIALS_PARAMETER_NAME = 'hostedUIProviderCreds'; @@ -19,6 +28,7 @@ export const USER_POOL_TYPE = 'AWS::Cognito::UserPool'; export const IDENTITY_POOL_TYPE = 'AWS::Cognito::IdentityPool'; export const IDENTITY_POOL_ROLE_ATTACHMENT_TYPE = 'AWS::Cognito::IdentityPoolRoleAttachment'; export const USER_POOL_DOMAIN_TYPE = 'AWS::Cognito::UserPoolDomain'; +export const USER_POOL_IDENTITY_PROVIDER_TYPE = 'AWS::Cognito::UserPoolIdentityProvider'; export const RESOURCE_TYPES = [ USER_POOL_TYPE, @@ -26,20 +36,43 @@ export const RESOURCE_TYPES = [ IDENTITY_POOL_TYPE, IDENTITY_POOL_ROLE_ATTACHMENT_TYPE, USER_POOL_DOMAIN_TYPE, + USER_POOL_IDENTITY_PROVIDER_TYPE, ]; +interface IdpConfig { + readonly providerName: string; + readonly providerType: string; + readonly clientId: string; + readonly clientSecret: string; + readonly authorizeScopes: string; + readonly attributeMapping: Record; +} + +interface SocialAuthConfig { + readonly userPoolId: string; + readonly domain: string; + readonly providers: IdpConfig[]; +} + /** * Forward refactorer for the auth:Cognito resource. * * Moves main auth resources from Gen1 to Gen2. + * For social auth apps, imports Gen1's LambdaCallout-created IDPs and domain + * into the Gen2 stack as native CFN resources during the move phase. */ export class AuthCognitoForwardRefactorer extends ForwardCategoryRefactorer { + /** + * Returns the full set including domain and IDP types. These types don't exist in the + * Gen1 CFN template (they're created by a Lambda trigger), so they won't appear in the + * refactor mappings. They are imported into Gen2 as a separate step in move(). + */ protected resourceTypes(): string[] { return RESOURCE_TYPES; } /** - * OAuth hook: retrieves credentials and updates hostedUIProviderCreds parameter. + * OAuth hook: retrieves credentials and updates the hostedUIProviderCreds parameter. */ protected override async resolveOAuthParameters(parameters: Parameter[], outputs: Output[]): Promise { const oAuthParam = parameters.find((p) => p.ParameterKey === HOSTED_PROVIDER_META_PARAMETER_NAME); @@ -68,9 +101,104 @@ export class AuthCognitoForwardRefactorer extends ForwardCategoryRefactorer { }); } credsParam.ParameterValue = JSON.stringify(oAuthValues); + return parameters; } + /** + * Executes the standard resource refactor, then imports Gen1's + * physical domain and IDPs into the Gen2 stack as native CFN resources. + */ + protected override async move(blueprint: RefactorBlueprint): Promise { + const baseOps = await super.move(blueprint); + + const importOp = await this.buildImportSocialAuthOperation(blueprint); + if (importOp) { + return [...baseOps, importOp]; + } + + return baseOps; + } + + /** + * Builds an operation that imports Gen1's physical domain and IDPs into the + * Gen2 stack. Returns undefined if the app doesn't use social auth. + */ + private async buildImportSocialAuthOperation(blueprint: RefactorBlueprint): Promise { + const socialAuthConfig = await this.fetchSocialAuthConfig(blueprint.sourceStackId); + if (!socialAuthConfig) { + return undefined; + } + + const gen2StackId = blueprint.targetStackId; + const gen2Template = await this.cfn.fetchTemplate(gen2StackId); + const gen2IdpLogicalIds = new Map(); + let gen2DomainLogicalId: string | undefined; + + // Find the Gen2 logical IDs we'll import the physical Gen1 resources into + // We require providerName + logicalId to disambiguate between multiple providers + for (const [logicalId, resource] of Object.entries(gen2Template.Resources)) { + if (resource.Type === USER_POOL_DOMAIN_TYPE) { + gen2DomainLogicalId = logicalId; + } else if (resource.Type === USER_POOL_IDENTITY_PROVIDER_TYPE) { + const providerName = resource.Properties.ProviderName as string; + if (providerName) { + gen2IdpLogicalIds.set(providerName, logicalId); + } + } + } + + if (!gen2DomainLogicalId) { + this.debug('No Gen2 UserPoolDomain resource found — skipping import'); + return undefined; + } + + if (gen2IdpLogicalIds.size === 0) { + this.debug('No Gen2 UserPoolIdentityProvider resources found — skipping import'); + return undefined; + } + + return { + resource: this.resource, + validate: () => undefined, + describe: async () => { + const gen2StackName = extractStackNameFromId(gen2StackId); + const table = new CLITable({ + head: ['Source Physical ID', 'Target Logical ID'], + style: { head: [] }, + }); + table.push([socialAuthConfig.domain, gen2DomainLogicalId!]); + for (const provider of socialAuthConfig.providers) { + const logicalId = gen2IdpLogicalIds.get(provider.providerName); + if (logicalId) { + const label = + provider.providerType !== provider.providerName + ? `${provider.providerName} (${provider.providerType})` + : provider.providerName; + table.push([label, logicalId]); + } + } + return [`Import social auth resources into '${gen2StackName}'\n\n${table.toString()}`]; + }, + execute: async () => { + const templateForImport = await this.cfn.fetchTemplate(gen2StackId); + + const { resourcesToImport, templateAdditions } = this.buildImportSpec(socialAuthConfig, gen2DomainLogicalId!, gen2IdpLogicalIds); + + for (const [logicalId, resource] of Object.entries(templateAdditions)) { + templateForImport.Resources[logicalId] = resource; + } + + await this.cfn.importResources({ + stackName: gen2StackId, + templateBody: templateForImport, + resourcesToImport, + resource: this.resource, + }); + }, + }; + } + protected override match(sourceId: string, sourceResource: CFNResource, targetId: string, targetResource: CFNResource): boolean { if (sourceResource.Type !== targetResource.Type) { return false; @@ -101,4 +229,122 @@ export class AuthCognitoForwardRefactorer extends ForwardCategoryRefactorer { // in gen2 all auth resources are in a single auth nested stack return this.findNestedStack(this.gen2Branch, 'auth'); } + + /** + * Fetches domain and IDP config directly from Cognito. These resources are + * Lambda-created (not in the Gen1 CFN template) so the live API is the only source. + */ + private async fetchSocialAuthConfig(sourceStackId: string): Promise { + const sourceStack = await this.gen1Env.fetchStack(sourceStackId); + const userPoolId = (sourceStack.Outputs ?? []).find((o) => o.OutputKey === USER_POOL_ID_OUTPUT_KEY_NAME)?.OutputValue; + if (!userPoolId) { + return undefined; + } + + const cognitoClient = this.gen1App.clients.cognitoIdentityProvider; + + const poolResponse = await cognitoClient.send(new DescribeUserPoolCommand({ UserPoolId: userPoolId })); + const domain = poolResponse?.UserPool?.Domain; + if (!domain) { + this.debug('Gen1 UserPool has no domain — skipping social auth import'); + return undefined; + } + + const listResponse = await cognitoClient.send(new ListIdentityProvidersCommand({ UserPoolId: userPoolId })); + const providerSummaries = listResponse?.Providers ?? []; + if (providerSummaries.length === 0) { + this.debug('Gen1 UserPool has no identity providers — skipping social auth import'); + return undefined; + } + + const providers: IdpConfig[] = []; + for (const summary of providerSummaries) { + const providerName = summary.ProviderName; + if (!providerName) continue; + + const describeResponse = await cognitoClient.send( + new DescribeIdentityProviderCommand({ UserPoolId: userPoolId, ProviderName: providerName }), + ); + const idp = describeResponse.IdentityProvider; + if (!idp?.ProviderDetails) continue; + + providers.push({ + providerName, + providerType: idp.ProviderType ?? providerName, + clientId: idp.ProviderDetails.client_id ?? '', + clientSecret: idp.ProviderDetails.client_secret ?? '', + authorizeScopes: idp.ProviderDetails.authorize_scopes ?? '', + attributeMapping: (idp.AttributeMapping as Record) ?? {}, + }); + } + + this.debug(`Fetched social auth config: domain=${domain}, providers=${providers.map((p) => p.providerName).join(',')}`); + return { userPoolId, domain, providers }; + } + + /** + * Builds the CFN import spec: template additions with DeletionPolicy: Retain + * (so rollback can orphan them without deleting the physical resources) and + * resource identifiers for the import change set. + */ + private buildImportSpec( + config: SocialAuthConfig, + domainLogicalId: string, + idpLogicalIds: Map, + ): { resourcesToImport: ResourceToImport[]; templateAdditions: Record } { + const resourcesToImport: ResourceToImport[] = []; + const templateAdditions: Record = {}; + + templateAdditions[domainLogicalId] = { + Type: USER_POOL_DOMAIN_TYPE, + DeletionPolicy: 'Retain', + Properties: { + Domain: config.domain, + UserPoolId: config.userPoolId, + }, + }; + resourcesToImport.push({ + ResourceType: USER_POOL_DOMAIN_TYPE, + LogicalResourceId: domainLogicalId, + ResourceIdentifier: { + UserPoolId: config.userPoolId, + Domain: config.domain, + }, + }); + + for (const provider of config.providers) { + const logicalId = idpLogicalIds.get(provider.providerName); + if (!logicalId) { + this.debug(`No Gen2 logical ID for provider ${provider.providerName} — skipping import`); + continue; + } + + templateAdditions[logicalId] = { + Type: USER_POOL_IDENTITY_PROVIDER_TYPE, + DeletionPolicy: 'Retain', + Properties: { + UserPoolId: config.userPoolId, + ProviderName: provider.providerName, + ProviderType: provider.providerType, + ProviderDetails: { + client_id: provider.clientId, + client_secret: provider.clientSecret, + authorize_scopes: provider.authorizeScopes, + }, + AttributeMapping: provider.attributeMapping, + }, + }; + + resourcesToImport.push({ + ResourceType: USER_POOL_IDENTITY_PROVIDER_TYPE, + LogicalResourceId: logicalId, + ResourceIdentifier: { + UserPoolId: config.userPoolId, + ProviderName: provider.providerName, + }, + }); + } + + return { resourcesToImport, templateAdditions }; + } } diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor/cfn.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor/cfn.ts index 613e5e8c855..50c15dd9393 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/refactor/cfn.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/refactor/cfn.ts @@ -14,12 +14,14 @@ import { GetTemplateCommand, Parameter, ResourceMapping, + ResourceToImport, Stack, UpdateStackCommand, UpdateStackCommandInput, waitUntilChangeSetCreateComplete, waitUntilStackCreateComplete, waitUntilStackDeleteComplete, + waitUntilStackImportComplete, waitUntilStackRefactorCreateComplete, waitUntilStackRefactorExecuteComplete, waitUntilStackUpdateComplete, @@ -279,6 +281,55 @@ export class Cfn { await waitUntilStackUpdateComplete({ client: this.client, maxWaitTime: MAX_WAIT_TIME_SECONDS }, { StackName: changeSet.StackName }); } + /** + * Imports existing physical resources into a stack via CreateChangeSet(IMPORT). + * The template must include resource definitions matching the physical state. + */ + public async importResources(params: { + readonly stackName: string; + readonly templateBody: CFNTemplate; + readonly resourcesToImport: ResourceToImport[]; + readonly resource?: DiscoveredResource; + }): Promise { + const { stackName, templateBody, resourcesToImport, resource } = params; + const displayName = extractStackNameFromId(stackName); + const changeSetName = `import-resources-${Date.now()}`; + + writeImportSnapshot({ + stackName, + templateBody: JSON.stringify(templateBody), + parameters: [], + resourcesToImport, + }); + + this.info(`Creating import changeset for ${displayName} (${resourcesToImport.length} resource(s))`, resource); + + await this.client.send( + new CreateChangeSetCommand({ + StackName: stackName, + ChangeSetName: changeSetName, + ChangeSetType: 'IMPORT', + TemplateBody: JSON.stringify(templateBody), + ResourcesToImport: resourcesToImport, + Capabilities: [CFN_IAM_CAPABILITY], + }), + ); + + this.info(`Waiting for import changeset creation: ${displayName}`, resource); + await waitUntilChangeSetCreateComplete( + { client: this.client, maxWaitTime: 120 }, + { StackName: stackName, ChangeSetName: changeSetName }, + ); + + this.info(`Executing import changeset: ${displayName}`, resource); + await this.client.send(new ExecuteChangeSetCommand({ StackName: stackName, ChangeSetName: changeSetName })); + + this.info(`Waiting for import to complete: ${displayName}`, resource); + await waitUntilStackImportComplete({ client: this.client, maxWaitTime: MAX_WAIT_TIME_SECONDS }, { StackName: stackName }); + + this.info(`Import complete: ${displayName}`, resource); + } + /** * Deletes a change set without executing it. */ @@ -431,6 +482,17 @@ interface WriteUpdateSnapshotInput { readonly parameters: Parameter[]; } +interface WriteImportSnapshotInput extends WriteUpdateSnapshotInput { + readonly resourcesToImport: ResourceToImport[]; +} + +function writeImportSnapshot(input: WriteImportSnapshotInput): void { + const stackName = extractStackNameFromId(input.stackName); + fs.writeFileSync(path.join(OUTPUT_DIRECTORY, `import.${stackName}.template.json`), formatTemplateBody(input.templateBody)); + fs.writeFileSync(path.join(OUTPUT_DIRECTORY, `import.${stackName}.parameters.json`), JSON.stringify(input.parameters, null, 2)); + fs.writeFileSync(path.join(OUTPUT_DIRECTORY, `import.${stackName}.resources.json`), JSON.stringify(input.resourcesToImport, null, 2)); +} + function writeUpdateSnapshot(input: WriteUpdateSnapshotInput): void { const stackName = extractStackNameFromId(input.stackName); fs.writeFileSync(path.join(OUTPUT_DIRECTORY, `update.${stackName}.template.json`), formatTemplateBody(input.templateBody)); diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor/resolvers/cfn-output-resolver.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor/resolvers/cfn-output-resolver.ts index bf9bf365045..9b20f694db3 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/refactor/resolvers/cfn-output-resolver.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/refactor/resolvers/cfn-output-resolver.ts @@ -54,6 +54,11 @@ export function resolveOutputs(params: { if (typeof logicalId === 'string' && typeof attrName === 'string') { const outputValue = getAttLookup.get(logicalId); if (outputValue !== undefined && attrName === 'Arn') { + // getAttLookup stores runtime output values. When the output is a + // GetAtt...Arn, the runtime value is already the full ARN. Passing + // it through buildArn() would nest the ARN inside another ARN + // (e.g. arn:.../userpool/arn:.../userpool/poolId). + if (outputValue.startsWith('arn:')) return outputValue; const resourceType = templateResources[logicalId]?.Type; if (resourceType) { const arn = buildArn(resourceType, outputValue, region, accountId); @@ -67,6 +72,17 @@ export function resolveOutputs(params: { }) as Record; // Phase 2: Resolve remaining Fn::GetAtt using physical resource IDs (fallback) + // + // Known limitation: this phase assumes physical resource ID == attribute value, + // which is only true for native AWS resources where PhysicalResourceId is the + // primary identifier. It is incorrect for Custom:: resources (whose GetAtt + // attributes come from Lambda response Data, not the physical ID) and for Arn + // attributes when the physical ID is already an ARN. + // + // A more robust approach would be to only resolve GetAtts for resources that + // are being moved between stacks (since those are the only references that + // will break post-refactor), but that requires threading resource mappings + // into the resolver. cloned.Resources = walkCfnTree(cloned.Resources, (node) => { if ('Fn::GetAtt' in node && Array.isArray(node['Fn::GetAtt']) && Object.keys(node).length === 1) { const [logicalId, attrName] = node['Fn::GetAtt'] as [string, string]; @@ -78,6 +94,11 @@ export function resolveOutputs(params: { const physicalId = stackResource.PhysicalResourceId; const resourceType = stackResource.ResourceType ?? ''; + // Custom resource GetAtt attributes are returned by the backing Lambda + // in its response Data object — they bear no relation to the physical + // resource ID. Leave these unresolved so CloudFormation evaluates them. + if (resourceType.startsWith('Custom::')) return undefined; + // Kinesis streams require ARN in outputs — physical ID is the stream name, not ARN if (resourceType === 'AWS::Kinesis::Stream' && attrName === 'Arn' && !physicalId.startsWith('arn:aws:kinesis')) { throw new AmplifyError('InvalidStackError', {