Skip to content

Commit d1f85d2

Browse files
authored
feat: prep work for SMS Sandbox support (#7302)
* feat: add support for banner message * chore: add check for SMS sandbox status * chore: address review comments * chore: fix test * chore: fix lgtm error * chore: change env var name
1 parent 44d25e2 commit d1f85d2

File tree

19 files changed

+377
-17
lines changed

19 files changed

+377
-17
lines changed

packages/amplify-app/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
"node-emoji": "^1.10.0",
4242
"ora": "^4.0.3",
4343
"rimraf": "^3.0.0",
44-
"semver": "^7.1.1",
44+
"semver": "^7.3.5",
4545
"strip-ansi": "^6.0.0",
4646
"xcode": "^2.1.0",
4747
"yargs": "^15.1.0"

packages/amplify-category-auth/src/index.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ const { projectHasAuth } = require('./provider-utils/awscloudformation/utils/pro
2424
const { attachPrevParamsToContext } = require('./provider-utils/awscloudformation/utils/attach-prev-params-to-context');
2525
const { stateManager } = require('amplify-cli-core');
2626

27+
const {
28+
doesConfigurationIncludeSMS,
29+
loadResourceParameters,
30+
loadImportedAuthParameters,
31+
} = require('./provider-utils/awscloudformation/utils/auth-sms-workflow-helper');
32+
2733
// this function is being kept for temporary compatability.
2834
async function add(context) {
2935
const { amplify } = context;
@@ -424,6 +430,18 @@ async function importAuth(context) {
424430
return providerController.importResource(context, serviceSelection, undefined, undefined, false);
425431
}
426432

433+
async function isSMSWorkflowEnabled(context, resourceName) {
434+
const { imported, userPoolId } = context.amplify.getImportedAuthProperties(context);
435+
let userNameAndMfaConfig;
436+
if (imported) {
437+
userNameAndMfaConfig = await loadImportedAuthParameters(context, userPoolId);
438+
} else {
439+
userNameAndMfaConfig = loadResourceParameters(context, resourceName);
440+
}
441+
const result = doesConfigurationIncludeSMS(userNameAndMfaConfig);
442+
return result;
443+
}
444+
427445
module.exports = {
428446
externalAuthEnable,
429447
checkRequirements,
@@ -439,4 +457,5 @@ module.exports = {
439457
uploadFiles,
440458
category,
441459
importAuth,
460+
isSMSWorkflowEnabled,
442461
};

packages/amplify-category-auth/src/provider-utils/awscloudformation/import/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ export interface ProviderUtils {
139139
privateParams: $TSObject,
140140
envSpecificParams: string[],
141141
): void;
142+
loadResourceParameters(context: $TSContext, category: string, resourceName: string): Record<string, any>;
142143
}
143144

144145
export type ImportAuthHeadlessParameters = {
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { $TSContext } from 'amplify-cli-core';
2+
import { ProviderUtils } from '../import/types';
3+
4+
import { ServiceQuestionsResult } from '../service-walkthrough-types';
5+
import { supportedServices } from '../../supported-services';
6+
7+
export type UserPoolMessageConfiguration = {
8+
mfaConfiguration?: string;
9+
mfaTypes?: string[];
10+
usernameAttributes?: string[];
11+
};
12+
13+
export const doesConfigurationIncludeSMS = (request: UserPoolMessageConfiguration): boolean => {
14+
if ((request.mfaConfiguration === 'OPTIONAL' || request.mfaConfiguration === 'ON') && request.mfaTypes?.includes('SMS Text Message')) {
15+
return true;
16+
}
17+
18+
if (request.usernameAttributes?.includes('phone_number')) {
19+
return true;
20+
}
21+
22+
return false;
23+
};
24+
25+
const getProviderPlugin = (context: $TSContext): ProviderUtils => {
26+
const serviceMetaData = supportedServices.Cognito;
27+
const { provider } = serviceMetaData;
28+
29+
return context.amplify.getPluginInstance(context, provider);
30+
};
31+
export const loadResourceParameters = (context: $TSContext, resourceName: string): UserPoolMessageConfiguration => {
32+
const providerPlugin = getProviderPlugin(context);
33+
return providerPlugin.loadResourceParameters(context, 'auth', resourceName) as ServiceQuestionsResult;
34+
};
35+
36+
export const loadImportedAuthParameters = async (context: $TSContext, userPoolName: string): Promise<UserPoolMessageConfiguration> => {
37+
const providerPlugin = getProviderPlugin(context);
38+
const cognitoUserPoolService = await providerPlugin.createCognitoUserPoolService(context);
39+
const userPoolDetails = await cognitoUserPoolService.getUserPoolDetails(userPoolName);
40+
const mfaConfig = await cognitoUserPoolService.getUserPoolMfaConfig(userPoolName);
41+
return {
42+
mfaConfiguration: mfaConfig.MfaConfiguration,
43+
usernameAttributes: userPoolDetails.UsernameAttributes,
44+
mfaTypes: mfaConfig.SmsMfaConfiguration ? ['SMS Text Message'] : [],
45+
};
46+
};

packages/amplify-cli-core/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@
3333
"hjson": "^3.2.1",
3434
"js-yaml": "^4.0.0",
3535
"lodash": "^4.17.19",
36-
"open": "^7.3.1"
36+
"node-fetch": "^2.6.1",
37+
"open": "^7.3.1",
38+
"proxy-agent": "^4.0.1",
39+
"semver": "^7.3.5"
3740
},
3841
"devDependencies": {
3942
"@types/fs-extra": "^8.0.1",
@@ -44,6 +47,7 @@
4447
"@types/rimraf": "^3.0.0",
4548
"@types/uuid": "^8.0.0",
4649
"amplify-function-plugin-interface": "1.7.2",
50+
"nock": "^13.0.11",
4751
"rimraf": "^3.0.0"
4852
},
4953
"jest": {
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import nock from 'nock';
2+
import url from 'url';
3+
import { BannerMessage, AWS_AMPLIFY_DEFAULT_BANNER_URL, Message } from '../../banner-message';
4+
5+
const ONE_DAY = 1000 * 60 * 60 * 24;
6+
let mockServer: nock.Interceptor;
7+
let serverResponse: { version: string; messages: Message[] };
8+
describe('BannerMessage', () => {
9+
beforeEach(async () => {
10+
serverResponse = {
11+
version: '1.0.0',
12+
messages: [
13+
{
14+
message: 'first message',
15+
id: 'first',
16+
conditions: {
17+
enabled: true,
18+
cliVersions: '4.41.0',
19+
startTime: new Date(Date.now() - ONE_DAY).toISOString(),
20+
endTime: new Date(Date.now() + ONE_DAY).toISOString(),
21+
},
22+
},
23+
],
24+
};
25+
26+
const urlInfo = url.parse(AWS_AMPLIFY_DEFAULT_BANNER_URL);
27+
mockServer = nock(`${urlInfo.protocol}//${urlInfo.host}`).get(urlInfo.pathname!);
28+
29+
await BannerMessage.initialize('4.41.0');
30+
});
31+
afterEach(() => {
32+
BannerMessage.releaseInstance();
33+
});
34+
35+
it('should return message by fetching it from remote url', async () => {
36+
mockServer.reply(200, serverResponse, { 'Content-Type': 'application/json' });
37+
const result = await BannerMessage.getMessage('first');
38+
expect(result).toEqual('first message');
39+
});
40+
41+
it('should return message when there are no conditions', async () => {
42+
delete serverResponse.messages[0].conditions;
43+
mockServer.reply(200, serverResponse, { 'Content-Type': 'application/json' });
44+
const result = await BannerMessage.getMessage('first');
45+
expect(result).toEqual('first message');
46+
});
47+
48+
it('should not throw error when server sends 404', async () => {
49+
mockServer.reply(404, 'page not found');
50+
const result = await BannerMessage.getMessage('first');
51+
expect(result).toBeUndefined();
52+
});
53+
54+
it('Should not process the Banner response if the response version is not supported', async () => {
55+
serverResponse.version = '20.2';
56+
mockServer.reply(200, serverResponse, { 'Content-Type': 'application/json' });
57+
const result = await BannerMessage.getMessage('first');
58+
expect(result).toBeUndefined();
59+
});
60+
61+
it('should not return message when the message version does not match', async () => {
62+
serverResponse.messages[0].conditions!.cliVersions! = '110022.0.0';
63+
mockServer.reply(200, serverResponse, { 'Content-Type': 'application/json' });
64+
const result = await BannerMessage.getMessage('first');
65+
expect(result).toBeUndefined();
66+
});
67+
68+
it('should not show message when message is not enabled', async () => {
69+
serverResponse.messages[0].conditions!.enabled = false;
70+
mockServer.reply(200, serverResponse, { 'Content-Type': 'application/json' });
71+
const result = await BannerMessage.getMessage('first');
72+
expect(result).toBeUndefined();
73+
});
74+
75+
it('should show message when conditions.enabled is undefined', async () => {
76+
(serverResponse.messages[0].conditions!.enabled as any) = undefined;
77+
mockServer.reply(200, serverResponse, { 'Content-Type': 'application/json' });
78+
const result = await BannerMessage.getMessage('first');
79+
expect(result).toEqual('first message');
80+
});
81+
82+
it('should not show message when startDate is after current Date', async () => {
83+
serverResponse.messages[0].conditions!.endTime = undefined;
84+
serverResponse.messages[0].conditions!.startTime = new Date(Date.now() + ONE_DAY).toISOString();
85+
mockServer.reply(200, serverResponse, { 'Content-Type': 'application/json' });
86+
const result = await BannerMessage.getMessage('first');
87+
expect(result).toBeUndefined();
88+
});
89+
90+
it('should not show message when endDate is before current Date', async () => {
91+
serverResponse.messages[0].conditions!.startTime = undefined;
92+
serverResponse.messages[0].conditions!.endTime = new Date(Date.now() - ONE_DAY).toISOString();
93+
mockServer.reply(200, serverResponse, { 'Content-Type': 'application/json' });
94+
const result = await BannerMessage.getMessage('first');
95+
expect(result).toBeUndefined();
96+
});
97+
98+
it('should show message when start and endDate are not defined', async () => {
99+
delete serverResponse.messages[0].conditions!.startTime;
100+
delete serverResponse.messages[0]!.conditions!.endTime;
101+
mockServer.reply(200, serverResponse, { 'Content-Type': 'application/json' });
102+
const result = await BannerMessage.getMessage('first');
103+
expect(result).toEqual('first message');
104+
});
105+
106+
it('should show message when cliVersions is undefined', async () => {
107+
delete serverResponse.messages[0].conditions!.cliVersions;
108+
mockServer.reply(200, serverResponse, { 'Content-Type': 'application/json' });
109+
const result = await BannerMessage.getMessage('first');
110+
expect(result).toEqual('first message');
111+
});
112+
113+
it('should throw error when BannerMessage is not initialized', async () => {
114+
BannerMessage.releaseInstance();
115+
await expect(() => BannerMessage.getMessage('first')).rejects.toThrowError('BannerMessage is not initialized');
116+
});
117+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import fetch from 'node-fetch';
2+
import semver from 'semver';
3+
import ProxyAgent from 'proxy-agent';
4+
import { getLogger } from '../logger';
5+
6+
export type Message = {
7+
message: string;
8+
id: string;
9+
conditions?: {
10+
enabled: boolean;
11+
cliVersions?: string;
12+
startTime?: string;
13+
endTime?: string;
14+
};
15+
};
16+
17+
export const AWS_AMPLIFY_DEFAULT_BANNER_URL: string = 'https://aws-amplify.github.io/amplify-cli/banner-message.json';
18+
const MAX_SUPPORTED_MESSAGE_CONFIG_VERSION = '1.0.0';
19+
20+
const logger = getLogger('amplify-cli-core', 'banner-message/index.ts');
21+
22+
export class BannerMessage {
23+
private static instance?: BannerMessage;
24+
private messages: Message[] = [];
25+
26+
public static initialize = (cliVersion: string): BannerMessage => {
27+
if (!BannerMessage.instance) {
28+
BannerMessage.instance = new BannerMessage(cliVersion);
29+
}
30+
31+
return BannerMessage.instance;
32+
};
33+
34+
private static ensureInitialized = () => {
35+
if (!BannerMessage.instance) {
36+
throw new Error('BannerMessage is not initialized');
37+
}
38+
};
39+
40+
private constructor(private cliVersion: string) {}
41+
42+
private fetchMessages = async (url: string): Promise<void> => {
43+
try {
44+
logger.info(`fetch banner messages from ${url}`);
45+
const proxy = process.env.HTTP_PROXY || process.env.HTTPS_PROXY;
46+
const fetchOptions = proxy ? { agent: new ProxyAgent(proxy) } : {};
47+
const result = await fetch(url, fetchOptions);
48+
const body = await result.json();
49+
if (!semver.satisfies(body.version, MAX_SUPPORTED_MESSAGE_CONFIG_VERSION)) {
50+
return;
51+
}
52+
this.messages = body.messages ?? [];
53+
} catch (e) {
54+
// network error should not cause CLI execution failure
55+
logger.error('fetch banner message failed', e);
56+
}
57+
};
58+
59+
public static getMessage = async (messageId: string): Promise<string | undefined> => {
60+
BannerMessage.ensureInitialized();
61+
return BannerMessage.instance!.getMessages(messageId);
62+
};
63+
64+
getMessages = async (messageId: string): Promise<string | undefined> => {
65+
if (!this.messages.length) {
66+
await this.fetchMessages(process.env.AMPLIFY_CLI_BANNER_MESSAGE_URL ?? AWS_AMPLIFY_DEFAULT_BANNER_URL);
67+
}
68+
69+
const matchingMessageItems = this.messages.filter(
70+
m =>
71+
m.id === messageId &&
72+
m.conditions?.enabled !== false &&
73+
(m.conditions?.cliVersions ? semver.satisfies(this.cliVersion, m.conditions.cliVersions) : true),
74+
);
75+
76+
const messageItem = matchingMessageItems.find(m => {
77+
if (m.conditions) {
78+
const currentTime = Date.now();
79+
const startTime = m.conditions?.startTime ? Date.parse(m.conditions?.startTime) : currentTime;
80+
const endTime = m.conditions?.endTime ? Date.parse(m.conditions?.endTime) : currentTime;
81+
return currentTime >= startTime && currentTime <= endTime;
82+
}
83+
return true;
84+
});
85+
86+
return messageItem?.message;
87+
};
88+
89+
/**
90+
* @internal
91+
* package private method used in unit tests to release the instance
92+
*/
93+
public static releaseInstance = (): void => {
94+
BannerMessage.instance = undefined;
95+
};
96+
}

packages/amplify-cli-core/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export * from './cliConstants';
1717
export * from './deploymentSecretsHelper';
1818
export * from './deploymentState';
1919
export * from './utils';
20+
export * from './banner-message';
2021

2122
// Temporary types until we can finish full type definition across the whole CLI
2223

@@ -174,7 +175,7 @@ interface AmplifyToolkit {
174175
getEnvDetails: () => $TSAny;
175176
getEnvInfo: () => $TSAny;
176177
getProviderPlugins: (context: $TSContext) => $TSAny;
177-
getPluginInstance: () => $TSAny;
178+
getPluginInstance: (context: $TSContext, pluginName: string) => $TSAny;
178179
getProjectConfig: () => $TSAny;
179180
getProjectDetails: () => $TSAny;
180181
getProjectMeta: () => $TSMeta;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Redactor, logger } from 'amplify-cli-logger';
2+
3+
export const getLogger = (moduleName: string, fileName: string) => {
4+
return {
5+
info: (message: string, args: any = {}) => {
6+
logger.logInfo({ message: `${moduleName}.${fileName}.${message}(${Redactor(JSON.stringify(args))}` });
7+
},
8+
error: (message: string, error: Error) => {
9+
logger.logError({ message: `${moduleName}.${fileName}.${message}`, error });
10+
},
11+
};
12+
};

packages/amplify-cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@
9393
"parse-json": "^5.0.0",
9494
"progress": "^2.0.3",
9595
"promise-sequential": "^1.1.1",
96-
"semver": "^7.1.1",
96+
"semver": "^7.3.5",
9797
"tar-fs": "^2.1.1",
9898
"update-notifier": "^4.1.0",
9999
"which": "^2.0.2",

0 commit comments

Comments
 (0)