Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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' });
Expand All @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -262,12 +262,12 @@ export class AuthRenderer {
private static deriveExternalProviders(details?: readonly IdentityProviderType[]): {
readonly oidcProviders: readonly OidcProviderConfig[];
readonly samlProvider: SamlProviderConfig | undefined;
readonly attributeMappings: Readonly<Record<string, Record<string, string>>>;
readonly attributeMappings: Readonly<Record<string, { standard: Record<string, string>; custom: Record<string, string> }>>;
readonly providerScopes: Readonly<Record<string, readonly string[]>>;
} {
const oidcProviders: OidcProviderConfig[] = [];
let samlProvider: SamlProviderConfig | undefined;
const attributeMappings: Record<string, Record<string, string>> = {};
const attributeMappings: Record<string, { standard: Record<string, string>; custom: Record<string, string> }> = {};
const providerScopes: Record<string, string[]> = {};

if (!details) {
Expand All @@ -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) {
Expand All @@ -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;
}
Expand Down Expand Up @@ -445,7 +445,7 @@ export class AuthRenderer {
* Extracts provider-specific scopes from provider details.
*/
private static deriveProviderSpecificScopes(providerDetails: Record<string, string>): 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);
Expand All @@ -457,12 +457,22 @@ export class AuthRenderer {
/**
* Filters attribute mappings to only known standard attributes.
*/
private static filterAttributeMapping(attributeMapping: Record<string, string>): Record<string, string> {
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<string, string>): {
standard: Record<string, string>;
custom: Record<string, string>;
} {
const standard: Record<string, string> = {};
const custom: Record<string, string> = {};

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 ────────────────────────────────────────
Expand Down Expand Up @@ -653,7 +663,7 @@ export class AuthRenderer {
externalProviders: {
readonly oidcProviders: readonly OidcProviderConfig[];
readonly samlProvider: SamlProviderConfig | undefined;
readonly attributeMappings: Readonly<Record<string, Record<string, string>>>;
readonly attributeMappings: Readonly<Record<string, { standard: Record<string, string>; custom: Record<string, string> }>>;
readonly providerScopes: Readonly<Record<string, readonly string[]>>;
},
callbackUrls?: readonly string[],
Expand Down Expand Up @@ -844,7 +854,7 @@ export class AuthRenderer {

private static createProviderConfig(
config: Record<string, string>,
attributeMapping: Record<string, string> | undefined,
attributeMapping: { standard: Record<string, string>; custom: Record<string, string> } | undefined,
): ts.ObjectLiteralElementLike[] {
const properties: ts.ObjectLiteralElementLike[] = [];

Expand All @@ -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'),
Expand All @@ -888,7 +911,7 @@ export class AuthRenderer {
private static createProviderPropertyAssignment(
name: string,
config: Record<string, string>,
attributeMapping: Record<string, string> | undefined,
attributeMapping: { standard: Record<string, string>; custom: Record<string, string> } | undefined,
): PropertyAssignment {
return factory.createPropertyAssignment(
factory.createIdentifier(name),
Expand Down
68 changes: 67 additions & 1 deletion packages/amplify-cli/src/commands/gen2-migration/lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:*',
Expand Down Expand Up @@ -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})`],
Expand Down Expand Up @@ -228,6 +245,55 @@ export class AmplifyMigrationLockStep extends AmplifyMigrationStep {
return this._dynamoTableNames;
}

private async buildHostedUiRetainOperation(): Promise<AmplifyMigrationOperation | undefined> {
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<string, string>[] }> {
const response = await this.gen1App.clients.cloudFormation.send(
new GetStackPolicyCommand({
Expand Down
Loading