From 66e090bc89714d069dea376e9ac0dc8d3a5b2606 Mon Sep 17 00:00:00 2001 From: 9pace Date: Mon, 13 Apr 2026 17:04:21 -0400 Subject: [PATCH 1/4] fix(cli): fix OAuth provider scopes and attribute mappings in generate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs in auth.renderer.ts caused generate to produce broken OAuth config for social identity providers (Google, Facebook): 1. Scope field name typo: deriveProviderSpecificScopes() looked for 'authorized_scopes' but Cognito returns 'authorize_scopes'. Scopes were never collected. Added the correct field name to the lookup. 2. Scope mangling: provider scopes were filtered against VALID_SCOPES (Cognito OAuth scopes), which is the wrong namespace. Facebook's 'public_profile' was mapped to 'profile'. Removed the filter — provider scopes are now passed through as-is. 3. Attribute mapping: filterAttributeMapping() dropped keys not in MAPPED_USER_ATTRIBUTE_NAME (e.g. 'username' -> 'sub' for Google, 'username' -> 'id' for Facebook). Changed to route unknown keys into a 'custom' sub-object matching Gen2's AttributeMapping CDK interface. Caveats: - Provider scopes are no longer validated. The old validation was incorrect (wrong namespace), but there is now no validation at all — whatever the provider returns is passed through verbatim. - OIDC/SAML providers flatten standard + custom back into a single Record since their rendering path doesn't support the custom sub-object. If a custom key collides with a mapped standard key, the custom value wins. Verified end-to-end with a test app using Google and Facebook social login. Gen2 deployed with correct scopes and attribute mappings without manual post-generate edits to OAuth config. --- .../generate/amplify/auth/auth.renderer.ts | 59 +++++++++++++------ 1 file changed, 41 insertions(+), 18 deletions(-) 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), From be26bd82ee96f691a81f4f618109730602ba8f36 Mon Sep 17 00:00:00 2001 From: 9pace Date: Mon, 20 Apr 2026 17:16:05 -0400 Subject: [PATCH 2/4] fix(cli): fix ARN nesting and Custom:: resolution in cfn-output-resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in cfn-output-resolver.ts caused incorrect template resolution during the refactor step: 1. Phase 1 GetAtt resolution passed already-resolved ARN values through buildArn(), producing nested ARNs (e.g. arn:.../userpool/arn:...). Added a guard to short-circuit when the output value is already an ARN. 2. Phase 2 fallback resolution used PhysicalResourceId for Custom:: resources, but custom resource GetAtt attributes come from the backing Lambda's response Data — not the physical ID. These are now skipped so CloudFormation evaluates them at deploy time. --- .../refactor/resolvers/cfn-output-resolver.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) 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', { From 2f725c323d01ba92f19af5d003fc7a8335d2e21c Mon Sep 17 00:00:00 2001 From: 9pace Date: Wed, 22 Apr 2026 10:43:35 -0400 Subject: [PATCH 3/4] fix: set DeletionPolicy Retain on oAuth resources during lock Add a new operation to the lock step that sets DeletionPolicy: Retain on HostedUICustomResourceInputs and HostedUIProvidersCustomResourceInputs in the auth nested stack. This prevents social auth custom resources (IDPs and Domain) from being deleted during or after migration. The operation fetches the auth nested stack template, patches the DeletionPolicy for the relevant resources, and updates the stack while preserving existing parameter values. --- .../gen2-migration/_infra/cfn-template.ts | 1 + .../src/commands/gen2-migration/lock.ts | 68 ++++++++++++++++++- 2 files changed, 68 insertions(+), 1 deletion(-) 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/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({ From 67571e1c30e30efa9142ec8cb8ac6ce4aa0598b3 Mon Sep 17 00:00:00 2001 From: 9pace Date: Wed, 22 Apr 2026 16:15:46 -0400 Subject: [PATCH 4/4] 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. --- .../clients/cognito-identity-provider.ts | 3 + .../auth/auth-cognito-forward.test.ts | 23 +- .../refactor/auth/auth-cognito-forward.ts | 250 +++++++++++++++++- .../commands/gen2-migration/refactor/cfn.ts | 62 +++++ 4 files changed, 333 insertions(+), 5 deletions(-) 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/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));