From ffffbdf88dd9004384861d8e1e52dbdfca011f2e Mon Sep 17 00:00:00 2001 From: Hui Zhao Date: Mon, 2 Oct 2023 14:05:34 -0700 Subject: [PATCH 01/20] fix(auth): signInWithRedirect to complete with code exchange in RN --- .../auth/src/providers/cognito/apis/signInWithRedirect.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/auth/src/providers/cognito/apis/signInWithRedirect.ts b/packages/auth/src/providers/cognito/apis/signInWithRedirect.ts index 250d07fbb84..aaee2d6513b 100644 --- a/packages/auth/src/providers/cognito/apis/signInWithRedirect.ts +++ b/packages/auth/src/providers/cognito/apis/signInWithRedirect.ts @@ -109,7 +109,9 @@ async function oauthSignIn({ const { type, error, url } = (await openAuthSession(oAuthUrl, redirectSignIn)) ?? {}; if (type === 'success' && url) { - handleAuthResponse({ + // ensure the code exchange completion resolves the signInWithRedirect + // returned promise in react-native + await handleAuthResponse({ currentUrl: url, clientId, domain, From 335b6f1ca4398bef8ebcd4861e63e8dff9e15c57 Mon Sep 17 00:00:00 2001 From: David McAfee Date: Tue, 3 Oct 2023 16:29:00 -0700 Subject: [PATCH 02/20] feat(data): add request cancellation functionality to GraphQL API V6 (#12142) --- packages/api-graphql/src/GraphQLAPI.ts | 18 +++++++ .../src/internals/InternalGraphQLAPI.ts | 49 +++++++++++++++++-- packages/api-graphql/src/internals/index.ts | 2 +- packages/api-graphql/src/internals/v6.ts | 18 +++++++ packages/api/src/API.ts | 10 +++- packages/api/src/internals/InternalAPI.ts | 20 -------- 6 files changed, 91 insertions(+), 26 deletions(-) diff --git a/packages/api-graphql/src/GraphQLAPI.ts b/packages/api-graphql/src/GraphQLAPI.ts index f0169bf5246..2189b9c6455 100644 --- a/packages/api-graphql/src/GraphQLAPI.ts +++ b/packages/api-graphql/src/GraphQLAPI.ts @@ -35,6 +35,24 @@ export class GraphQLAPIClass extends InternalGraphQLAPIClass { ): Observable> | Promise> { return super.graphql(options, additionalHeaders); } + + /** + * Checks to see if an error thrown is from an api request cancellation + * @param error - Any error + * @returns A boolean indicating if the error was from an api request cancellation + */ + isCancelError(error: any): boolean { + return super.isCancelError(error); + } + + /** + * Cancels an inflight request. Only applicable for graphql queries and mutations + * @param {any} request - request to cancel + * @returns A boolean indicating if the request was cancelled + */ + cancel(request: Promise, message?: string): boolean { + return super.cancel(request, message); + } } export const GraphQLAPI = new GraphQLAPIClass(null); diff --git a/packages/api-graphql/src/internals/InternalGraphQLAPI.ts b/packages/api-graphql/src/internals/InternalGraphQLAPI.ts index 5e7755dde08..99ed70d50bf 100644 --- a/packages/api-graphql/src/internals/InternalGraphQLAPI.ts +++ b/packages/api-graphql/src/internals/InternalGraphQLAPI.ts @@ -21,7 +21,12 @@ import { GraphQLOperation, GraphQLOptions, } from '../types'; -import { post } from '@aws-amplify/api-rest/internals'; +import { isCancelError as isCancelErrorREST } from '@aws-amplify/api-rest'; +import { + post, + cancel as cancelREST, + updateRequestToBeCancellable, +} from '@aws-amplify/api-rest/internals'; import { AWSAppSyncRealTimeProvider } from '../Providers/AWSAppSyncRealTimeProvider'; const USER_AGENT_HEADER = 'x-amz-user-agent'; @@ -49,7 +54,7 @@ export class InternalGraphQLAPIClass { private appSyncRealTime: AWSAppSyncRealTimeProvider | null; Cache = Cache; - private _api = { post }; + private _api = { post, updateRequestToBeCancellable }; /** * Initialize GraphQL API with AWS configuration @@ -177,11 +182,17 @@ export class InternalGraphQLAPIClass { switch (operationType) { case 'query': case 'mutation': + const abortController = new AbortController(); const responsePromise = this._graphql( { query, variables, authMode }, headers, + abortController, customUserAgentDetails ); + this._api.updateRequestToBeCancellable( + responsePromise, + abortController + ); return responsePromise; case 'subscription': return this._graphqlSubscribe( @@ -197,6 +208,7 @@ export class InternalGraphQLAPIClass { private async _graphql( { query, variables, authMode }: GraphQLOptions, additionalHeaders = {}, + abortController: AbortController, customUserAgentDetails?: CustomUserAgentDetails ): Promise> { const config = Amplify.getConfig(); @@ -246,7 +258,7 @@ export class InternalGraphQLAPIClass { let response; try { - const { body: responsePayload } = await this._api.post({ + const { body: responseBody } = await this._api.post({ url: new URL(endpoint), options: { headers, @@ -256,9 +268,20 @@ export class InternalGraphQLAPIClass { region, }, }, + abortController, }); - response = await responsePayload.json(); + + const result = { data: await responseBody.json() }; + + response = result; } catch (err) { + // If the exception is because user intentionally + // cancelled the request, do not modify the exception + // so that clients can identify the exception correctly. + if (isCancelErrorREST(err)) { + throw err; + } + response = { data: {}, errors: [new GraphQLError(err.message, null, null, null, null, err)], @@ -274,6 +297,24 @@ export class InternalGraphQLAPIClass { return response; } + /** + * Checks to see if an error thrown is from an api request cancellation + * @param {any} error - Any error + * @return {boolean} - A boolean indicating if the error was from an api request cancellation + */ + isCancelError(error: any): boolean { + return isCancelErrorREST(error); + } + + /** + * Cancels an inflight request. Only applicable for graphql queries and mutations + * @param {any} request - request to cancel + * @returns - A boolean indicating if the request was cancelled + */ + cancel(request: Promise, message?: string): boolean { + return cancelREST(request, message); + } + private _graphqlSubscribe( { query, diff --git a/packages/api-graphql/src/internals/index.ts b/packages/api-graphql/src/internals/index.ts index eb382eac470..2a274ac881d 100644 --- a/packages/api-graphql/src/internals/index.ts +++ b/packages/api-graphql/src/internals/index.ts @@ -5,4 +5,4 @@ export { InternalGraphQLAPIClass, } from './InternalGraphQLAPI'; -export { graphql } from './v6'; +export { graphql, cancel, isCancelError } from './v6'; diff --git a/packages/api-graphql/src/internals/v6.ts b/packages/api-graphql/src/internals/v6.ts index 6267d817195..affe62c8dd7 100644 --- a/packages/api-graphql/src/internals/v6.ts +++ b/packages/api-graphql/src/internals/v6.ts @@ -103,4 +103,22 @@ export function graphql< return result as any; } +/** + * Cancels an inflight request. Only applicable for graphql queries and mutations + * @param {any} request - request to cancel + * @returns - A boolean indicating if the request was cancelled + */ +export function cancel(promise: Promise, message?: string): boolean { + return GraphQLAPI.cancel(promise, message); +} + +/** + * Checks to see if an error thrown is from an api request cancellation + * @param {any} error - Any error + * @returns - A boolean indicating if the error was from an api request cancellation + */ +export function isCancelError(error: any): boolean { + return GraphQLAPI.isCancelError(error); +} + export { GraphQLOptionsV6, GraphQLResponseV6 }; diff --git a/packages/api/src/API.ts b/packages/api/src/API.ts index 4ed0b88a8e1..ad13b176e02 100644 --- a/packages/api/src/API.ts +++ b/packages/api/src/API.ts @@ -7,7 +7,11 @@ import { GraphQLQuery, GraphQLSubscription, } from '@aws-amplify/api-graphql'; -import { graphql as v6graphql } from '@aws-amplify/api-graphql/internals'; +import { + graphql as v6graphql, + cancel as v6cancel, + isCancelError as v6isCancelError, +} from '@aws-amplify/api-graphql/internals'; import { Observable } from 'rxjs'; import { InternalAPIClass } from './internals/InternalAPI'; @@ -52,6 +56,8 @@ export class APIClass extends InternalAPIClass { generateClient = never>(): V6Client { const client: V6Client = { graphql: v6graphql, + cancel: v6cancel, + isCancelError: v6isCancelError, }; return client as V6Client; @@ -68,6 +74,8 @@ type ExcludeNeverFields = { // If no T is passed, ExcludeNeverFields removes "models" from the client declare type V6Client = never> = ExcludeNeverFields<{ graphql: typeof v6graphql; + cancel: typeof v6cancel; + isCancelError: typeof v6isCancelError; }>; export const API = new APIClass(null); diff --git a/packages/api/src/internals/InternalAPI.ts b/packages/api/src/internals/InternalAPI.ts index a23a4917081..ea83b7eab7c 100644 --- a/packages/api/src/internals/InternalAPI.ts +++ b/packages/api/src/internals/InternalAPI.ts @@ -10,8 +10,6 @@ import { GraphQLSubscription, } from '@aws-amplify/api-graphql'; import { InternalGraphQLAPIClass } from '@aws-amplify/api-graphql/internals'; -import { isCancelError } from '@aws-amplify/api-rest'; -import { cancel } from '@aws-amplify/api-rest/internals'; import { Cache } from '@aws-amplify/core'; import { ApiAction, @@ -51,24 +49,6 @@ export class InternalAPIClass { return 'InternalAPI'; } - /** - * Checks to see if an error thrown is from an api request cancellation - * @param error - Any error - * @return If the error was from an api request cancellation - */ - isCancel(error: any): boolean { - return isCancelError(error); - } - /** - * Cancels an inflight request for either a GraphQL request or a Rest API request. - * @param request - request to cancel - * @param [message] - custom error message - * @return If the request was cancelled - */ - cancel(request: Promise, message?: string): boolean { - return cancel(request, message); - } - /** * to get the operation type * @param operation From 8432bc935af16fbf35588f794d53d8e300681beb Mon Sep 17 00:00:00 2001 From: Aaron S <94858815+stocaaro@users.noreply.github.com> Date: Wed, 4 Oct 2023 09:32:54 -0500 Subject: [PATCH 03/20] chore: Fix appsync argument passing (#12169) * chore: Fix appsync argument passing * fix(api-graphql): Re-enable _headerBasedAuth override authMode --- .../api-graphql/__tests__/utils/expects.ts | 3 +- .../src/internals/InternalGraphQLAPI.ts | 48 ++++++++----------- packages/api-graphql/src/types/index.ts | 2 +- 3 files changed, 23 insertions(+), 30 deletions(-) diff --git a/packages/api-graphql/__tests__/utils/expects.ts b/packages/api-graphql/__tests__/utils/expects.ts index 7bd2dda0b4e..01f50593c63 100644 --- a/packages/api-graphql/__tests__/utils/expects.ts +++ b/packages/api-graphql/__tests__/utils/expects.ts @@ -101,6 +101,7 @@ export function expectSub( `${opName}(filter: $filter, owner: $owner)` ), variables: expect.objectContaining(item), - }) + }), + undefined ); } diff --git a/packages/api-graphql/src/internals/InternalGraphQLAPI.ts b/packages/api-graphql/src/internals/InternalGraphQLAPI.ts index 99ed70d50bf..e6ec00da54d 100644 --- a/packages/api-graphql/src/internals/InternalGraphQLAPI.ts +++ b/packages/api-graphql/src/internals/InternalGraphQLAPI.ts @@ -11,6 +11,7 @@ import { import { Observable } from 'rxjs'; import { Amplify, Cache, fetchAuthSession } from '@aws-amplify/core'; import { + APIAuthMode, CustomUserAgentDetails, ConsoleLogger as Logger, getAmplifyUserAgent, @@ -70,9 +71,8 @@ export class InternalGraphQLAPIClass { } private async _headerBasedAuth( - defaultAuthenticationType?, - additionalHeaders: { [key: string]: string } = {}, - customUserAgentDetails?: CustomUserAgentDetails + authMode: APIAuthMode, + additionalHeaders: { [key: string]: string } = {} ) { const config = Amplify.getConfig(); const { @@ -82,9 +82,10 @@ export class InternalGraphQLAPIClass { defaultAuthMode, } = config.API.GraphQL; + const authenticationType = authMode || defaultAuthMode || 'iam'; let headers = {}; - switch (defaultAuthMode) { + switch (authenticationType) { case 'apiKey': if (!apiKey) { throw new Error(GraphQLAuthError.NO_API_KEY); @@ -221,18 +222,10 @@ export class InternalGraphQLAPIClass { const headers = { ...(!customGraphqlEndpoint && - (await this._headerBasedAuth( - authMode, - additionalHeaders, - customUserAgentDetails - ))), + (await this._headerBasedAuth(authMode, additionalHeaders))), ...(customGraphqlEndpoint && (customEndpointRegion - ? await this._headerBasedAuth( - authMode, - additionalHeaders, - customUserAgentDetails - ) + ? await this._headerBasedAuth(authMode, additionalHeaders) : { Authorization: null })), ...additionalHeaders, ...(!customGraphqlEndpoint && { @@ -316,12 +309,7 @@ export class InternalGraphQLAPIClass { } private _graphqlSubscribe( - { - query, - variables, - authMode: defaultAuthenticationType, - authToken, - }: GraphQLOptions, + { query, variables, authMode }: GraphQLOptions, additionalHeaders = {}, customUserAgentDetails?: CustomUserAgentDetails ): Observable { @@ -329,14 +317,18 @@ export class InternalGraphQLAPIClass { if (!this.appSyncRealTime) { this.appSyncRealTime = new AWSAppSyncRealTimeProvider(); } - return this.appSyncRealTime.subscribe({ - query: print(query as DocumentNode), - variables, - appSyncGraphqlEndpoint: GraphQL?.endpoint, - region: GraphQL?.region, - authenticationType: GraphQL?.defaultAuthMode, - apiKey: GraphQL?.apiKey, - }); + return this.appSyncRealTime.subscribe( + { + query: print(query as DocumentNode), + variables, + appSyncGraphqlEndpoint: GraphQL?.endpoint, + region: GraphQL?.region, + authenticationType: authMode ?? GraphQL?.defaultAuthMode, + apiKey: GraphQL?.apiKey, + additionalHeaders, + }, + customUserAgentDetails + ); } } diff --git a/packages/api-graphql/src/types/index.ts b/packages/api-graphql/src/types/index.ts index fa94c5de69b..a8a554bbca5 100644 --- a/packages/api-graphql/src/types/index.ts +++ b/packages/api-graphql/src/types/index.ts @@ -12,7 +12,7 @@ export { CONTROL_MSG, ConnectionState } from './PubSub'; export interface GraphQLOptions { query: string | DocumentNode; variables?: Record; - authMode?: string; + authMode?: APIAuthMode; authToken?: string; /** * @deprecated This property should not be used From 5dbae8bf39a8fd4135700adafabbd63f0890ab0f Mon Sep 17 00:00:00 2001 From: israx <70438514+israx@users.noreply.github.com> Date: Wed, 4 Oct 2023 11:21:24 -0400 Subject: [PATCH 04/20] chore(core): export hub class (#12176) * chore: import hub class and fix dispatch type * fix channel type * remove type support for dispatch * fix type --------- Co-authored-by: Jim Blanchard --- packages/core/src/Hub/index.ts | 1 + packages/core/src/libraryUtils.ts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/core/src/Hub/index.ts b/packages/core/src/Hub/index.ts index 81caa6c7d06..cf989243e68 100644 --- a/packages/core/src/Hub/index.ts +++ b/packages/core/src/Hub/index.ts @@ -72,6 +72,7 @@ export class HubClass { * @param ampSymbol - Symbol used to determine if the event is dispatched internally on a protected channel * */ + dispatch( channel: Channel, payload: HubPayload, diff --git a/packages/core/src/libraryUtils.ts b/packages/core/src/libraryUtils.ts index 823b9040c63..6e1926178c7 100644 --- a/packages/core/src/libraryUtils.ts +++ b/packages/core/src/libraryUtils.ts @@ -91,3 +91,6 @@ export { fetchAuthSession } from './singleton/apis/internal/fetchAuthSession'; export { AMPLIFY_SYMBOL } from './Hub'; export { base64Decoder, base64Encoder } from './utils/convert'; export { getCrypto } from './utils/globalHelpers'; + +// Hub +export { HubClass } from './Hub'; From 9a931a35e4d464d97c61ed899470446c7dacfc6c Mon Sep 17 00:00:00 2001 From: Chris F <5827964+cshfang@users.noreply.github.com> Date: Wed, 4 Oct 2023 09:29:18 -0700 Subject: [PATCH 05/20] refactor: Update SRP helpers (#12177) * refactor: Update SRP helpers * Remove extraneous commented tests --------- Co-authored-by: israx <70438514+israx@users.noreply.github.com> --- .../__tests__/AuthenticationHelper.test.ts | 908 ------------------ .../utils/srp/AuthenticationHelper.test.ts | 217 +++++ .../utils/srp/calculate/calculateA.test.ts | 38 + .../utils/srp/calculate/calculateS.test.ts | 76 ++ .../utils/srp/calculate/calculateU.test.ts | 44 + .../utils/srp/getAuthenticationHelper.test.ts | 41 + .../cognito/utils/srp/getHashFromData.test.ts | 11 + .../cognito/utils/srp/getHashFromHex.test.ts | 17 + .../cognito/utils/srp/getHkdfKey.test.ts | 18 + .../cognito/utils/srp/getPaddedHex.test.ts | 553 +++++++++++ .../cognito/utils/srp/getRandomString.test.ts | 15 + .../__tests__/testUtils/promisifyCallback.ts | 24 - .../providers/cognito/utils/signInHelpers.ts | 106 +- .../AuthenticationHelper.ts | 538 +++-------- .../srp/AuthenticationHelper/index.native.ts | 26 - .../utils/srp/BigInteger/BigInteger.ts | 4 +- .../utils/srp/BigInteger/index.native.ts | 12 +- .../cognito/utils/srp/BigInteger/index.ts | 2 +- .../cognito/utils/srp/BigInteger/types.ts | 4 +- .../cognito/utils/srp/calculate/calculateA.ts | 33 + .../utils/srp/calculate/calculateS.native.ts | 33 + .../cognito/utils/srp/calculate/calculateS.ts | 46 + .../cognito/utils/srp/calculate/calculateU.ts | 28 + .../cognito/utils/srp/calculate/index.ts | 6 + .../providers/cognito/utils/srp/constants.ts | 32 + .../utils/srp/getAuthenticationHelper.ts | 39 + .../cognito/utils/srp/getBytesFromHex.ts | 29 + .../cognito/utils/srp/getHashFromData.ts | 21 + .../cognito/utils/srp/getHashFromHex.ts | 14 + .../cognito/utils/srp/getHexFromBytes.ts | 18 + .../providers/cognito/utils/srp/getHkdfKey.ts | 33 + .../cognito/utils/srp/getNowString.ts | 48 + .../cognito/utils/srp/getPaddedHex.ts | 84 ++ .../cognito/utils/srp/getRandomBytes.ts | 17 + .../cognito/utils/srp/getRandomString.ts | 14 + .../cognito/utils/srp/getSignatureString.ts | 65 ++ .../providers/cognito/utils/srp/helpers.ts | 225 ----- .../src/providers/cognito/utils/srp/index.ts | 7 + 38 files changed, 1768 insertions(+), 1678 deletions(-) delete mode 100644 packages/auth/__tests__/AuthenticationHelper.test.ts create mode 100644 packages/auth/__tests__/providers/cognito/utils/srp/AuthenticationHelper.test.ts create mode 100644 packages/auth/__tests__/providers/cognito/utils/srp/calculate/calculateA.test.ts create mode 100644 packages/auth/__tests__/providers/cognito/utils/srp/calculate/calculateS.test.ts create mode 100644 packages/auth/__tests__/providers/cognito/utils/srp/calculate/calculateU.test.ts create mode 100644 packages/auth/__tests__/providers/cognito/utils/srp/getAuthenticationHelper.test.ts create mode 100644 packages/auth/__tests__/providers/cognito/utils/srp/getHashFromData.test.ts create mode 100644 packages/auth/__tests__/providers/cognito/utils/srp/getHashFromHex.test.ts create mode 100644 packages/auth/__tests__/providers/cognito/utils/srp/getHkdfKey.test.ts create mode 100644 packages/auth/__tests__/providers/cognito/utils/srp/getPaddedHex.test.ts create mode 100644 packages/auth/__tests__/providers/cognito/utils/srp/getRandomString.test.ts delete mode 100644 packages/auth/__tests__/testUtils/promisifyCallback.ts delete mode 100644 packages/auth/src/providers/cognito/utils/srp/AuthenticationHelper/index.native.ts create mode 100644 packages/auth/src/providers/cognito/utils/srp/calculate/calculateA.ts create mode 100644 packages/auth/src/providers/cognito/utils/srp/calculate/calculateS.native.ts create mode 100644 packages/auth/src/providers/cognito/utils/srp/calculate/calculateS.ts create mode 100644 packages/auth/src/providers/cognito/utils/srp/calculate/calculateU.ts create mode 100644 packages/auth/src/providers/cognito/utils/srp/calculate/index.ts create mode 100644 packages/auth/src/providers/cognito/utils/srp/constants.ts create mode 100644 packages/auth/src/providers/cognito/utils/srp/getAuthenticationHelper.ts create mode 100644 packages/auth/src/providers/cognito/utils/srp/getBytesFromHex.ts create mode 100644 packages/auth/src/providers/cognito/utils/srp/getHashFromData.ts create mode 100644 packages/auth/src/providers/cognito/utils/srp/getHashFromHex.ts create mode 100644 packages/auth/src/providers/cognito/utils/srp/getHexFromBytes.ts create mode 100644 packages/auth/src/providers/cognito/utils/srp/getHkdfKey.ts create mode 100644 packages/auth/src/providers/cognito/utils/srp/getNowString.ts create mode 100644 packages/auth/src/providers/cognito/utils/srp/getPaddedHex.ts create mode 100644 packages/auth/src/providers/cognito/utils/srp/getRandomBytes.ts create mode 100644 packages/auth/src/providers/cognito/utils/srp/getRandomString.ts create mode 100644 packages/auth/src/providers/cognito/utils/srp/getSignatureString.ts delete mode 100644 packages/auth/src/providers/cognito/utils/srp/helpers.ts create mode 100644 packages/auth/src/providers/cognito/utils/srp/index.ts diff --git a/packages/auth/__tests__/AuthenticationHelper.test.ts b/packages/auth/__tests__/AuthenticationHelper.test.ts deleted file mode 100644 index 6ce48898bc7..00000000000 --- a/packages/auth/__tests__/AuthenticationHelper.test.ts +++ /dev/null @@ -1,908 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { Sha256 } from '@aws-crypto/sha256-js'; -import { BigInteger } from '../src/providers/cognito/utils/srp/BigInteger'; -import { AuthenticationHelper } from '../src/providers/cognito/utils/srp/AuthenticationHelper'; -import { promisifyCallback } from './testUtils/promisifyCallback'; - -const instance = new AuthenticationHelper('TestPoolName'); - -const bigIntError = new Error('BigInteger Error'); -describe('AuthenticatorHelper for padHex ', () => { - /* - Test cases generated in Java with: - - import java.math.BigInteger; - public class Main - { - private static final char[] HEX_ARRAY = '0123456789ABCDEF'.toCharArray(); - public static String bytesToHex(byte[] bytes) { - char[] hexChars = new char[bytes.length * 2]; - for (int j = 0; j < bytes.length; j++) { - int v = bytes[j] & 0xFF; - hexChars[j * 2] = HEX_ARRAY[v >>> 4]; - hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F]; - } - return new String(hexChars); - } - public static void main(String[] args) { - for(int i = -256; i <=256; i++) { - byte arr[] = BigInteger.valueOf(i).toByteArray(); - System.out.println('[' + i +', '' + bytesToHex(arr) + ''],'); - } - } - } - */ - test.each([ - [-256, 'FF00'], - [-255, 'FF01'], - [-254, 'FF02'], - [-253, 'FF03'], - [-252, 'FF04'], - [-251, 'FF05'], - [-250, 'FF06'], - [-249, 'FF07'], - [-248, 'FF08'], - [-247, 'FF09'], - [-246, 'FF0A'], - [-245, 'FF0B'], - [-244, 'FF0C'], - [-243, 'FF0D'], - [-242, 'FF0E'], - [-241, 'FF0F'], - [-240, 'FF10'], - [-239, 'FF11'], - [-238, 'FF12'], - [-237, 'FF13'], - [-236, 'FF14'], - [-235, 'FF15'], - [-234, 'FF16'], - [-233, 'FF17'], - [-232, 'FF18'], - [-231, 'FF19'], - [-230, 'FF1A'], - [-229, 'FF1B'], - [-228, 'FF1C'], - [-227, 'FF1D'], - [-226, 'FF1E'], - [-225, 'FF1F'], - [-224, 'FF20'], - [-223, 'FF21'], - [-222, 'FF22'], - [-221, 'FF23'], - [-220, 'FF24'], - [-219, 'FF25'], - [-218, 'FF26'], - [-217, 'FF27'], - [-216, 'FF28'], - [-215, 'FF29'], - [-214, 'FF2A'], - [-213, 'FF2B'], - [-212, 'FF2C'], - [-211, 'FF2D'], - [-210, 'FF2E'], - [-209, 'FF2F'], - [-208, 'FF30'], - [-207, 'FF31'], - [-206, 'FF32'], - [-205, 'FF33'], - [-204, 'FF34'], - [-203, 'FF35'], - [-202, 'FF36'], - [-201, 'FF37'], - [-200, 'FF38'], - [-199, 'FF39'], - [-198, 'FF3A'], - [-197, 'FF3B'], - [-196, 'FF3C'], - [-195, 'FF3D'], - [-194, 'FF3E'], - [-193, 'FF3F'], - [-192, 'FF40'], - [-191, 'FF41'], - [-190, 'FF42'], - [-189, 'FF43'], - [-188, 'FF44'], - [-187, 'FF45'], - [-186, 'FF46'], - [-185, 'FF47'], - [-184, 'FF48'], - [-183, 'FF49'], - [-182, 'FF4A'], - [-181, 'FF4B'], - [-180, 'FF4C'], - [-179, 'FF4D'], - [-178, 'FF4E'], - [-177, 'FF4F'], - [-176, 'FF50'], - [-175, 'FF51'], - [-174, 'FF52'], - [-173, 'FF53'], - [-172, 'FF54'], - [-171, 'FF55'], - [-170, 'FF56'], - [-169, 'FF57'], - [-168, 'FF58'], - [-167, 'FF59'], - [-166, 'FF5A'], - [-165, 'FF5B'], - [-164, 'FF5C'], - [-163, 'FF5D'], - [-162, 'FF5E'], - [-161, 'FF5F'], - [-160, 'FF60'], - [-159, 'FF61'], - [-158, 'FF62'], - [-157, 'FF63'], - [-156, 'FF64'], - [-155, 'FF65'], - [-154, 'FF66'], - [-153, 'FF67'], - [-152, 'FF68'], - [-151, 'FF69'], - [-150, 'FF6A'], - [-149, 'FF6B'], - [-148, 'FF6C'], - [-147, 'FF6D'], - [-146, 'FF6E'], - [-145, 'FF6F'], - [-144, 'FF70'], - [-143, 'FF71'], - [-142, 'FF72'], - [-141, 'FF73'], - [-140, 'FF74'], - [-139, 'FF75'], - [-138, 'FF76'], - [-137, 'FF77'], - [-136, 'FF78'], - [-135, 'FF79'], - [-134, 'FF7A'], - [-133, 'FF7B'], - [-132, 'FF7C'], - [-131, 'FF7D'], - [-130, 'FF7E'], - [-129, 'FF7F'], - [-128, '80'], - [-127, '81'], - [-126, '82'], - [-125, '83'], - [-124, '84'], - [-123, '85'], - [-122, '86'], - [-121, '87'], - [-120, '88'], - [-119, '89'], - [-118, '8A'], - [-117, '8B'], - [-116, '8C'], - [-115, '8D'], - [-114, '8E'], - [-113, '8F'], - [-112, '90'], - [-111, '91'], - [-110, '92'], - [-109, '93'], - [-108, '94'], - [-107, '95'], - [-106, '96'], - [-105, '97'], - [-104, '98'], - [-103, '99'], - [-102, '9A'], - [-101, '9B'], - [-100, '9C'], - [-99, '9D'], - [-98, '9E'], - [-97, '9F'], - [-96, 'A0'], - [-95, 'A1'], - [-94, 'A2'], - [-93, 'A3'], - [-92, 'A4'], - [-91, 'A5'], - [-90, 'A6'], - [-89, 'A7'], - [-88, 'A8'], - [-87, 'A9'], - [-86, 'AA'], - [-85, 'AB'], - [-84, 'AC'], - [-83, 'AD'], - [-82, 'AE'], - [-81, 'AF'], - [-80, 'B0'], - [-79, 'B1'], - [-78, 'B2'], - [-77, 'B3'], - [-76, 'B4'], - [-75, 'B5'], - [-74, 'B6'], - [-73, 'B7'], - [-72, 'B8'], - [-71, 'B9'], - [-70, 'BA'], - [-69, 'BB'], - [-68, 'BC'], - [-67, 'BD'], - [-66, 'BE'], - [-65, 'BF'], - [-64, 'C0'], - [-63, 'C1'], - [-62, 'C2'], - [-61, 'C3'], - [-60, 'C4'], - [-59, 'C5'], - [-58, 'C6'], - [-57, 'C7'], - [-56, 'C8'], - [-55, 'C9'], - [-54, 'CA'], - [-53, 'CB'], - [-52, 'CC'], - [-51, 'CD'], - [-50, 'CE'], - [-49, 'CF'], - [-48, 'D0'], - [-47, 'D1'], - [-46, 'D2'], - [-45, 'D3'], - [-44, 'D4'], - [-43, 'D5'], - [-42, 'D6'], - [-41, 'D7'], - [-40, 'D8'], - [-39, 'D9'], - [-38, 'DA'], - [-37, 'DB'], - [-36, 'DC'], - [-35, 'DD'], - [-34, 'DE'], - [-33, 'DF'], - [-32, 'E0'], - [-31, 'E1'], - [-30, 'E2'], - [-29, 'E3'], - [-28, 'E4'], - [-27, 'E5'], - [-26, 'E6'], - [-25, 'E7'], - [-24, 'E8'], - [-23, 'E9'], - [-22, 'EA'], - [-21, 'EB'], - [-20, 'EC'], - [-19, 'ED'], - [-18, 'EE'], - [-17, 'EF'], - [-16, 'F0'], - [-15, 'F1'], - [-14, 'F2'], - [-13, 'F3'], - [-12, 'F4'], - [-11, 'F5'], - [-10, 'F6'], - [-9, 'F7'], - [-8, 'F8'], - [-7, 'F9'], - [-6, 'FA'], - [-5, 'FB'], - [-4, 'FC'], - [-3, 'FD'], - [-2, 'FE'], - [-1, 'FF'], - [0, '00'], - [1, '01'], - [2, '02'], - [3, '03'], - [4, '04'], - [5, '05'], - [6, '06'], - [7, '07'], - [8, '08'], - [9, '09'], - [10, '0A'], - [11, '0B'], - [12, '0C'], - [13, '0D'], - [14, '0E'], - [15, '0F'], - [16, '10'], - [17, '11'], - [18, '12'], - [19, '13'], - [20, '14'], - [21, '15'], - [22, '16'], - [23, '17'], - [24, '18'], - [25, '19'], - [26, '1A'], - [27, '1B'], - [28, '1C'], - [29, '1D'], - [30, '1E'], - [31, '1F'], - [32, '20'], - [33, '21'], - [34, '22'], - [35, '23'], - [36, '24'], - [37, '25'], - [38, '26'], - [39, '27'], - [40, '28'], - [41, '29'], - [42, '2A'], - [43, '2B'], - [44, '2C'], - [45, '2D'], - [46, '2E'], - [47, '2F'], - [48, '30'], - [49, '31'], - [50, '32'], - [51, '33'], - [52, '34'], - [53, '35'], - [54, '36'], - [55, '37'], - [56, '38'], - [57, '39'], - [58, '3A'], - [59, '3B'], - [60, '3C'], - [61, '3D'], - [62, '3E'], - [63, '3F'], - [64, '40'], - [65, '41'], - [66, '42'], - [67, '43'], - [68, '44'], - [69, '45'], - [70, '46'], - [71, '47'], - [72, '48'], - [73, '49'], - [74, '4A'], - [75, '4B'], - [76, '4C'], - [77, '4D'], - [78, '4E'], - [79, '4F'], - [80, '50'], - [81, '51'], - [82, '52'], - [83, '53'], - [84, '54'], - [85, '55'], - [86, '56'], - [87, '57'], - [88, '58'], - [89, '59'], - [90, '5A'], - [91, '5B'], - [92, '5C'], - [93, '5D'], - [94, '5E'], - [95, '5F'], - [96, '60'], - [97, '61'], - [98, '62'], - [99, '63'], - [100, '64'], - [101, '65'], - [102, '66'], - [103, '67'], - [104, '68'], - [105, '69'], - [106, '6A'], - [107, '6B'], - [108, '6C'], - [109, '6D'], - [110, '6E'], - [111, '6F'], - [112, '70'], - [113, '71'], - [114, '72'], - [115, '73'], - [116, '74'], - [117, '75'], - [118, '76'], - [119, '77'], - [120, '78'], - [121, '79'], - [122, '7A'], - [123, '7B'], - [124, '7C'], - [125, '7D'], - [126, '7E'], - [127, '7F'], - [128, '0080'], - [129, '0081'], - [130, '0082'], - [131, '0083'], - [132, '0084'], - [133, '0085'], - [134, '0086'], - [135, '0087'], - [136, '0088'], - [137, '0089'], - [138, '008A'], - [139, '008B'], - [140, '008C'], - [141, '008D'], - [142, '008E'], - [143, '008F'], - [144, '0090'], - [145, '0091'], - [146, '0092'], - [147, '0093'], - [148, '0094'], - [149, '0095'], - [150, '0096'], - [151, '0097'], - [152, '0098'], - [153, '0099'], - [154, '009A'], - [155, '009B'], - [156, '009C'], - [157, '009D'], - [158, '009E'], - [159, '009F'], - [160, '00A0'], - [161, '00A1'], - [162, '00A2'], - [163, '00A3'], - [164, '00A4'], - [165, '00A5'], - [166, '00A6'], - [167, '00A7'], - [168, '00A8'], - [169, '00A9'], - [170, '00AA'], - [171, '00AB'], - [172, '00AC'], - [173, '00AD'], - [174, '00AE'], - [175, '00AF'], - [176, '00B0'], - [177, '00B1'], - [178, '00B2'], - [179, '00B3'], - [180, '00B4'], - [181, '00B5'], - [182, '00B6'], - [183, '00B7'], - [184, '00B8'], - [185, '00B9'], - [186, '00BA'], - [187, '00BB'], - [188, '00BC'], - [189, '00BD'], - [190, '00BE'], - [191, '00BF'], - [192, '00C0'], - [193, '00C1'], - [194, '00C2'], - [195, '00C3'], - [196, '00C4'], - [197, '00C5'], - [198, '00C6'], - [199, '00C7'], - [200, '00C8'], - [201, '00C9'], - [202, '00CA'], - [203, '00CB'], - [204, '00CC'], - [205, '00CD'], - [206, '00CE'], - [207, '00CF'], - [208, '00D0'], - [209, '00D1'], - [210, '00D2'], - [211, '00D3'], - [212, '00D4'], - [213, '00D5'], - [214, '00D6'], - [215, '00D7'], - [216, '00D8'], - [217, '00D9'], - [218, '00DA'], - [219, '00DB'], - [220, '00DC'], - [221, '00DD'], - [222, '00DE'], - [223, '00DF'], - [224, '00E0'], - [225, '00E1'], - [226, '00E2'], - [227, '00E3'], - [228, '00E4'], - [229, '00E5'], - [230, '00E6'], - [231, '00E7'], - [232, '00E8'], - [233, '00E9'], - [234, '00EA'], - [235, '00EB'], - [236, '00EC'], - [237, '00ED'], - [238, '00EE'], - [239, '00EF'], - [240, '00F0'], - [241, '00F1'], - [242, '00F2'], - [243, '00F3'], - [244, '00F4'], - [245, '00F5'], - [246, '00F6'], - [247, '00F7'], - [248, '00F8'], - [249, '00F9'], - [250, '00FA'], - [251, '00FB'], - [252, '00FC'], - [253, '00FD'], - [254, '00FE'], - [255, '00FF'], - [256, '0100'], - ])('padHex(bigInteger.fromInt(%p))\t=== %p', (i, expected) => { - const bigInt = new BigInteger(); - bigInt.fromInt(i); - - const x = instance.padHex(bigInt); - expect(x.toLowerCase()).toBe(expected.toLowerCase()); - }); -}); - -describe('Getters for AuthHelper class', () => { - test('getSmallA() should match the instance variable', () => { - expect(instance.getSmallAValue()).toBe(instance.smallAValue); - }); - - test('getRandomPassword() should throw as it was not previously defined', () => { - expect(() => instance.getRandomPassword()).toThrow(); - }); - - test('getSaltDevices() should throw as it was not previously defined', () => { - expect(() => { - (instance as any).getSaltDevices(); - }).toThrow(); - }); - - test('getVerifierDevices() should throw as it was not previously defined', () => { - expect(() => instance.getVerifierDevices()).toThrow(); - }); - - test('Constant prefix for new password challenge', () => { - expect( - instance.getNewPasswordRequiredChallengeUserAttributePrefix() - ).toEqual('userAttributes.'); - }); -}); - -describe('getLargeAValue()', () => { - afterAll(() => { - jest.restoreAllMocks(); - jest.clearAllMocks(); - }); - - instance.largeAValue = undefined; - test('happy path should callback with a calculateA bigInt', async () => { - const result = await promisifyCallback(instance, 'getLargeAValue'); - expect(result).toEqual(instance.largeAValue); - }); - - test('when largeAValue exists, getLargeA should return it', async () => { - expect(instance.largeAValue).not.toBe(null); - await promisifyCallback(instance, 'getLargeAValue').then(res => { - expect(res).toEqual(instance.largeAValue); - }); - }); - test('mock an error from calculate A', async () => { - instance.largeAValue = undefined; - jest - .spyOn(AuthenticationHelper.prototype, 'calculateA') - .mockImplementationOnce((...[, callback]) => { - callback(bigIntError, null); - }); - - await promisifyCallback(instance, 'getLargeAValue').catch(e => { - expect(e).toEqual(bigIntError); - }); - - // preserving invariant of largeAValue - const cb = jest.fn(); - instance.getLargeAValue(cb); - }); -}); - -describe('generateRandomSmallA(), generateRandomString()', () => { - test('Generate Random Small A is generating a BigInteger', () => { - expect(instance.generateRandomSmallA()).toBeInstanceOf(BigInteger); - }); - - test('Ensure that generateRandomSmallA is non deterministic', () => { - const firstSmallA = instance.generateRandomSmallA(); - const secondSmallA = instance.generateRandomSmallA(); - expect(firstSmallA).not.toEqual(secondSmallA); - }); - - test('Generate random strings', () => { - // AuthHelper generates 40 randomBytes and convert it to a base64 string - expect(instance.generateRandomString().length).toEqual(56); - }); - - test('Generate random strings is non-deterministic', () => { - expect(instance.generateRandomString()).not.toEqual( - instance.generateRandomString() - ); - }); -}); - -describe('generateHashDevice()', () => { - test('happy path for generate hash devices should instantiate the verifierDevices of the instance', async () => { - const deviceGroupKey = instance.generateRandomString(); - const username = instance.generateRandomString(); - // should throw as it is not defined - expect(() => { - instance.getVerifierDevices(); - }).toThrow(); - await promisifyCallback( - instance, - 'generateHashDevice', - deviceGroupKey, - username - ); - expect(instance.getVerifierDevices()).toEqual(instance.verifierDevices); - }); - test('modPow throws an error', async () => { - const deviceGroupKey = instance.generateRandomString(); - const username = instance.generateRandomString(); - - jest - .spyOn(BigInteger.prototype, 'modPow') - .mockImplementationOnce((...args: any) => { - args[2](bigIntError, null); - }); - await promisifyCallback( - instance, - 'generateHashDevice', - deviceGroupKey, - username - ).catch(e => { - expect(e).toEqual(bigIntError); - }); - }); -}); - -describe('calculateA()', () => { - const callback = jest.fn(); - - afterEach(() => { - callback.mockClear(); - }); - - afterAll(() => { - jest.restoreAllMocks(); - }); - - test('Calculate A happy path', async () => { - const result = await promisifyCallback( - instance, - 'calculateA', - instance.smallAValue - ); - // length of the big integer - expect(Object.keys(result as string).length).toEqual(223); - }); - - test('calculateA gets an error from g.modPow', async () => { - jest - .spyOn(BigInteger.prototype, 'modPow') - .mockImplementationOnce( - (...[, , callback]: [unknown, unknown, Function]) => { - callback(bigIntError, null); - } - ); - - await promisifyCallback(instance, 'calculateA', instance.smallAValue).catch( - e => { - expect(e).toEqual(bigIntError); - } - ); - }); - - test('A mod N equals BigInt 0 should throw an illegal parameter error', async () => { - jest - .spyOn(BigInteger.prototype, 'modPow') - .mockImplementationOnce( - (...[, , callback]: [unknown, unknown, Function]) => { - callback(null, BigInteger.ZERO); - } - ); - - await promisifyCallback(instance, 'calculateA', instance.smallAValue).catch( - e => { - expect(e).toEqual(new Error('Illegal paramater. A mod N cannot be 0.')); - } - ); - }); -}); - -describe('calculateU()', () => { - test("Calculate the client's value U", () => { - const hexA = new BigInteger('abcd1234', 16); - const hexB = new BigInteger('deadbeef', 16); - - const hashed = instance.hexHash( - instance.padHex(hexA) + instance.padHex(hexB) - ); - const expected = new BigInteger(hashed, 16); - const result = instance.calculateU(hexA, hexB); - expect(expected).toEqual(result); - }); -}); - -describe('hexhash() and hash()', () => { - test('Test hexHash function produces a valid hex string with regex', () => { - const regEx = /[0-9a-f]/g; - const awsCryptoHash = new Sha256(); - awsCryptoHash.update('testString'); - const resultFromAWSCrypto = awsCryptoHash.digestSync(); - const hashHex = Buffer.from(resultFromAWSCrypto).toString('hex'); - - expect(regEx.test(instance.hexHash(hashHex))).toBe(true); - }); - - test('Hashing a buffer returns a string', () => { - const buf = Buffer.from('7468697320697320612074c3a97374', 'binary'); - expect(typeof instance.hash(buf)).toBe('string'); - }); -}); - -describe('computehkdf()', () => { - test('happy path hkdf algorithm returns a length 16 hex string', () => { - const inputKey = Buffer.from('secretInputKey', 'ascii'); - const salt = Buffer.from('7468697320697320612074c3a97374', 'hex'); - const key = instance.computehkdf(inputKey, salt); - expect(Object.keys(key).length).toEqual(16); - }); -}); - -describe('getPasswordAuthKey()', () => { - const username = 'cognitoUser'; - const password = 'cognitoPassword'; - const badServerValue = BigInteger.ZERO; - const realServerValue = new BigInteger('deadbeef', 16); - const salt = new BigInteger('deadbeef', 16); - - afterEach(() => { - jest.clearAllMocks(); - }); - - afterAll(() => { - jest.restoreAllMocks(); - }); - - test('Happy path should computeHKDF', async () => { - const result = await promisifyCallback( - instance, - 'getPasswordAuthenticationKey', - username, - password, - realServerValue, - salt - ); - expect(Object.keys(result).length).toEqual(16); - }); - - test('failing within calculateS callback', async () => { - jest - .spyOn(AuthenticationHelper.prototype, 'calculateS') - .mockImplementationOnce((...[, , callback]) => { - callback(bigIntError, null); - }); - await promisifyCallback( - instance, - 'getPasswordAuthenticationKey', - username, - password, - realServerValue, - salt - ).catch(e => { - expect(e).toEqual(bigIntError); - }); - }); - - test('Getting a bad server value', async () => { - await promisifyCallback( - instance, - 'getPasswordAuthenticationKey', - username, - password, - badServerValue, - salt - ).catch(e => { - expect(e).toEqual(new Error('B cannot be zero.')); - }); - }); - - test('Getting a U Value of zero', async () => { - jest - .spyOn(AuthenticationHelper.prototype, 'calculateU') - .mockImplementationOnce(() => { - return BigInteger.ZERO; - }); - - const realServerValue = new BigInteger('deadbeef', 16); - await promisifyCallback( - instance, - 'getPasswordAuthenticationKey', - username, - password, - realServerValue, - salt - ).catch(e => { - expect(e).toEqual(new Error('U cannot be zero.')); - }); - }); -}); - -describe('calculateS()', () => { - const xValue = new BigInteger('deadbeef', 16); - const serverValue = new BigInteger('deadbeef', 16); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - test('happy path should callback with null, and a bigInteger', async () => { - instance.k = new BigInteger('deadbeef', 16); - instance.UValue = instance.calculateU(instance.largeAValue, xValue); - const result = await promisifyCallback( - instance, - 'calculateS', - xValue, - serverValue - ); - // length of the big integer - expect(Object.keys(result).length).toEqual(113); - }); - - test('modPow throws an error ', async () => { - jest - .spyOn(BigInteger.prototype, 'modPow') - .mockImplementationOnce((...args: any) => { - args[2](bigIntError, null); - }); - - await promisifyCallback(instance, 'calculateS', xValue, serverValue).catch( - e => { - expect(e).toEqual(bigIntError); - } - ); - }); - - test('second modPow throws an error ', async () => { - // need to mock a working modPow to then fail in the second mock - jest - .spyOn(BigInteger.prototype, 'modPow') - .mockImplementationOnce((...args: any) => { - args[2](null, new BigInteger('deadbeef', 16)); - }); - jest - .spyOn(BigInteger.prototype, 'modPow') - .mockImplementationOnce((...args: any) => { - args[2](bigIntError, null); - }); - - await promisifyCallback(instance, 'calculateS', xValue, serverValue).catch( - e => { - expect(e).toEqual(bigIntError); - } - ); - }); -}); diff --git a/packages/auth/__tests__/providers/cognito/utils/srp/AuthenticationHelper.test.ts b/packages/auth/__tests__/providers/cognito/utils/srp/AuthenticationHelper.test.ts new file mode 100644 index 00000000000..f8dd7cb83d3 --- /dev/null +++ b/packages/auth/__tests__/providers/cognito/utils/srp/AuthenticationHelper.test.ts @@ -0,0 +1,217 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { BigInteger } from '../../../../../src/providers/cognito/utils/srp/BigInteger'; +import { AuthenticationHelper } from '../../../../../src/providers/cognito/utils/srp/AuthenticationHelper'; +import { + calculateS, + calculateU, +} from '../../../../../src/providers/cognito/utils/srp/calculate'; +import { getHashFromData } from '../../../../../src/providers/cognito/utils/srp/getHashFromData'; +import { getHashFromHex } from '../../../../../src/providers/cognito/utils/srp/getHashFromHex'; +import { getHkdfKey } from '../../../../../src/providers/cognito/utils/srp/getHkdfKey'; +import { getPaddedHex } from '../../../../../src/providers/cognito/utils/srp/getPaddedHex'; +import { getRandomString } from '../../../../../src/providers/cognito/utils/srp/getRandomString'; +import { textEncoder } from '../../../../../src/providers/cognito/utils/textEncoder'; + +jest.mock('../../../../../src/providers/cognito/utils/srp/calculate'); +jest.mock('../../../../../src/providers/cognito/utils/srp/getBytesFromHex'); +jest.mock('../../../../../src/providers/cognito/utils/srp/getHashFromData'); +jest.mock('../../../../../src/providers/cognito/utils/srp/getHashFromHex'); +jest.mock('../../../../../src/providers/cognito/utils/srp/getHexFromBytes'); +jest.mock('../../../../../src/providers/cognito/utils/srp/getHkdfKey'); +jest.mock('../../../../../src/providers/cognito/utils/srp/getPaddedHex'); +jest.mock('../../../../../src/providers/cognito/utils/srp/getRandomBytes'); +jest.mock('../../../../../src/providers/cognito/utils/srp/getRandomString'); +jest.mock('../../../../../src/providers/cognito/utils/textEncoder'); + +describe('AuthenticationHelper', () => { + let instance: AuthenticationHelper; + const a = new BigInteger('a', 16); + const g = new BigInteger('g', 16); + const A = new BigInteger('A', 16); + const N = new BigInteger('N', 16); + const S = new BigInteger('S', 16); + const U = new BigInteger('U', 16); + const randomString = 'random-string'; + // create mocks + const mockGetHashFromData = getHashFromData as jest.Mock; + const mockGetPaddedHex = getPaddedHex as jest.Mock; + const mockGetRandomString = getRandomString as jest.Mock; + const mockTextEncoderConvert = textEncoder.convert as jest.Mock; + + beforeAll(() => { + mockGetRandomString.mockReturnValue(randomString); + mockTextEncoderConvert.mockReturnValue(new Uint8Array()); + }); + + beforeEach(() => { + instance = new AuthenticationHelper({ + userPoolName: 'TestPoolName', + a, + g, + A, + N, + }); + }); + + afterEach(() => { + mockGetHashFromData.mockReset(); + }); + + describe('getRandomPassword', () => { + it('should throw as it was not previously defined', () => { + expect(() => instance.getRandomPassword()).toThrow(); + }); + }); + + describe('getSaltToHashDevices', () => { + it('should throw as it was not previously defined', () => { + expect(() => { + instance.getSaltToHashDevices(); + }).toThrow(); + }); + }); + + describe('getVerifierDevices', () => { + it('should throw as it was not previously defined', () => { + expect(() => instance.getVerifierDevices()).toThrow(); + }); + }); + + describe('generateHashDevice', () => { + const deviceGroupKey = 'device-group-key'; + const username = 'user-name'; + const randomString = 'random-string'; + // create spies + const modPowSpy = jest.spyOn(BigInteger.prototype, 'modPow'); + + beforeAll(() => { + mockGetHashFromData.mockReturnValue('hashed-string'); + mockGetPaddedHex.mockReturnValue('padded-hex'); + }); + + afterEach(() => { + modPowSpy.mockReset(); + }); + + afterAll(() => { + mockGetHashFromData.mockReset(); + mockGetPaddedHex.mockReset(); + }); + + it('should instantiate the verifierDevices of the instance', async () => { + await instance.generateHashDevice(deviceGroupKey, username); + + expect(mockGetHashFromData).toBeCalledWith( + `${deviceGroupKey}${username}:${randomString}` + ); + expect(instance.getVerifierDevices()).toBeDefined(); + }); + + it('should throw an error if modPow fails', async () => { + modPowSpy.mockImplementation((_: any, __: any, callback: any) => { + callback(new Error()); + }); + + await expect( + instance.generateHashDevice(deviceGroupKey, username) + ).rejects.toThrow(); + }); + }); + + describe('getPasswordAuthenticationKey', () => { + const username = 'username'; + const password = 'password'; + const usernamePasswordHash = `${username}-${password}-hash`; + const serverBValue = new BigInteger('server-b-value', 16); + const salt = new BigInteger('salt', 16); + const hkdfKey = new Uint8Array(Buffer.from('hkdf-key')); + // create mocks + const mockCalculateS = calculateS as jest.Mock; + const mockCalculateU = calculateU as jest.Mock; + const mockGetHashFromHex = getHashFromHex as jest.Mock; + const mockGetHkdfKey = getHkdfKey as jest.Mock; + + beforeAll(() => { + mockGetHashFromData.mockReturnValue(usernamePasswordHash); + mockGetHashFromHex.mockReturnValue('foo'); + mockGetHkdfKey.mockReturnValue(hkdfKey); + mockGetPaddedHex.mockReturnValue(''); + }); + + beforeEach(() => { + mockCalculateS.mockReturnValue(S); + mockCalculateU.mockReturnValue(U); + }); + + afterEach(() => { + mockCalculateS.mockReset(); + mockCalculateU.mockReset(); + mockGetHashFromHex.mockClear(); + mockGetPaddedHex.mockClear(); + }); + + it('should return hkdfKey', async () => { + expect( + await instance.getPasswordAuthenticationKey({ + username, + password, + serverBValue, + salt, + }) + ).toBe(hkdfKey); + expect(mockCalculateU).toBeCalledWith({ A, B: serverBValue }); + expect(mockGetPaddedHex).toBeCalledWith(salt); + expect(mockGetHashFromHex).toBeCalledWith(usernamePasswordHash); + expect(mockCalculateS).toBeCalledWith({ + a, + g, + k: expect.any(BigInteger), + x: expect.any(BigInteger), + B: serverBValue, + N, + U, + }); + }); + + it('should throw an error if calculateU fails', async () => { + mockCalculateU.mockImplementation(() => { + throw new Error(); + }); + await expect( + instance.getPasswordAuthenticationKey({ + username, + password, + serverBValue, + salt, + }) + ).rejects.toThrow(); + }); + + it('should throw an error if calculateS fails', async () => { + mockCalculateS.mockImplementation(() => { + throw new Error(); + }); + await expect( + instance.getPasswordAuthenticationKey({ + username, + password, + serverBValue, + salt, + }) + ).rejects.toThrow(); + }); + + it('should throw an error if it receives a bad server value', async () => { + await expect( + instance.getPasswordAuthenticationKey({ + username, + password, + serverBValue: BigInteger.ZERO, + salt, + }) + ).rejects.toThrow('B cannot be zero'); + }); + }); +}); diff --git a/packages/auth/__tests__/providers/cognito/utils/srp/calculate/calculateA.test.ts b/packages/auth/__tests__/providers/cognito/utils/srp/calculate/calculateA.test.ts new file mode 100644 index 00000000000..bc0123cb73f --- /dev/null +++ b/packages/auth/__tests__/providers/cognito/utils/srp/calculate/calculateA.test.ts @@ -0,0 +1,38 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { BigInteger } from '../../../../../../src/providers/cognito/utils/srp/BigInteger'; +import { calculateA } from '../../../../../../src/providers/cognito/utils/srp/calculate'; + +describe('calculateA', () => { + const a = new BigInteger('a', 16); + const g = new BigInteger('g', 16); + const N = new BigInteger('N', 16); + // create spies + const modPowSpy = jest.spyOn(BigInteger.prototype, 'modPow'); + + afterEach(() => { + modPowSpy.mockReset(); + }); + + it('calculates A', async () => { + expect(await calculateA({ a, g, N })).toBeDefined(); + expect(modPowSpy).toBeCalledWith(a, N, expect.any(Function)); + }); + + it('should throw an error if modPow fails', async () => { + modPowSpy.mockImplementation((_: any, __: any, callback: any) => { + callback(new Error()); + }); + + await expect(calculateA({ a, g, N })).rejects.toThrow(); + }); + + it('should throw an error if A mod N equals BigInteger.ZERO', async () => { + modPowSpy.mockImplementation((_: any, __: any, callback: any) => { + callback(null, BigInteger.ZERO); + }); + + await expect(calculateA({ a, g, N })).rejects.toThrow('Illegal parameter'); + }); +}); diff --git a/packages/auth/__tests__/providers/cognito/utils/srp/calculate/calculateS.test.ts b/packages/auth/__tests__/providers/cognito/utils/srp/calculate/calculateS.test.ts new file mode 100644 index 00000000000..6aa93ade01a --- /dev/null +++ b/packages/auth/__tests__/providers/cognito/utils/srp/calculate/calculateS.test.ts @@ -0,0 +1,76 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { BigInteger } from '../../../../../../src/providers/cognito/utils/srp/BigInteger'; +import { calculateS } from '../../../../../../src/providers/cognito/utils/srp/calculate'; + +describe('calculateS', () => { + const a = new BigInteger('a', 16); + const g = new BigInteger('g', 16); + const k = new BigInteger('k', 16); + const x = new BigInteger('x', 16); + const B = new BigInteger('B', 16); + const N = new BigInteger('N', 16); + const U = new BigInteger('U', 16); + // create spies + const modPowSpy = jest.spyOn(BigInteger.prototype, 'modPow'); + + afterEach(() => { + modPowSpy.mockReset(); + }); + + it('calculates S', async () => { + expect( + await calculateS({ + a, + g, + k, + x, + B, + N, + U, + }) + ).toBeDefined(); + expect(modPowSpy).toBeCalledWith(x, N, expect.any(Function)); + }); + + it('should throw an error if outer modPow fails', async () => { + modPowSpy.mockImplementationOnce((_: any, __: any, callback: any) => { + callback(new Error()); + }); + + await expect( + calculateS({ + a, + g, + k, + x, + B, + N, + U, + }) + ).rejects.toThrow(); + }); + + it('should throw an error if inner modPow fails', async () => { + modPowSpy + .mockImplementationOnce((_: any, __: any, callback: any) => { + callback(null, new BigInteger('outer-result', 16)); + }) + .mockImplementationOnce((_: any, __: any, callback: any) => { + callback(new Error()); + }); + + await expect( + calculateS({ + a, + g, + k, + x, + B, + N, + U, + }) + ).rejects.toThrow(); + }); +}); diff --git a/packages/auth/__tests__/providers/cognito/utils/srp/calculate/calculateU.test.ts b/packages/auth/__tests__/providers/cognito/utils/srp/calculate/calculateU.test.ts new file mode 100644 index 00000000000..296be8f92d0 --- /dev/null +++ b/packages/auth/__tests__/providers/cognito/utils/srp/calculate/calculateU.test.ts @@ -0,0 +1,44 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { BigInteger } from '../../../../../../src/providers/cognito/utils/srp/BigInteger'; +import { calculateU } from '../../../../../../src/providers/cognito/utils/srp/calculate'; +import { getHashFromHex } from '../../../../../../src/providers/cognito/utils/srp/getHashFromHex'; +import { getPaddedHex } from '../../../../../../src/providers/cognito/utils/srp/getPaddedHex'; + +jest.mock('../../../../../../src/providers/cognito/utils/srp/getHashFromHex'); +jest.mock('../../../../../../src/providers/cognito/utils/srp/getPaddedHex'); + +describe('calculateU', () => { + const A = new BigInteger('A', 16); + const B = new BigInteger('B', 16); + // create mocks + const mockGetHashFromHex = getHashFromHex as jest.Mock; + const mockGetPaddedHex = getPaddedHex as jest.Mock; + + beforeAll(() => { + mockGetPaddedHex.mockReturnValue(''); + }); + + afterEach(() => { + mockGetHashFromHex.mockReset(); + mockGetPaddedHex.mockClear(); + }); + + it('calculates U', () => { + mockGetHashFromHex.mockReturnValue('A+B'); + + expect(calculateU({ A, B })).toBeDefined(); + expect(mockGetPaddedHex).toBeCalledWith(A); + expect(mockGetPaddedHex).toBeCalledWith(B); + expect(mockGetHashFromHex).toBeCalled(); + }); + + it('should throw an error if U equals BigInteger.ZERO', async () => { + mockGetHashFromHex.mockReturnValue(BigInteger.ZERO); + + expect(() => { + calculateU({ A, B }); + }).toThrow('U cannot be zero'); + }); +}); diff --git a/packages/auth/__tests__/providers/cognito/utils/srp/getAuthenticationHelper.test.ts b/packages/auth/__tests__/providers/cognito/utils/srp/getAuthenticationHelper.test.ts new file mode 100644 index 00000000000..506a52f5af6 --- /dev/null +++ b/packages/auth/__tests__/providers/cognito/utils/srp/getAuthenticationHelper.test.ts @@ -0,0 +1,41 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AuthenticationHelper } from '../../../../../src/providers/cognito/utils/srp/AuthenticationHelper'; +import { getAuthenticationHelper } from '../../../../../src/providers/cognito/utils/srp/getAuthenticationHelper'; +import { calculateA } from '../../../../../src/providers/cognito/utils/srp/calculate'; + +jest.mock('../../../../../src/providers/cognito/utils/srp/calculate'); + +describe('getAuthenticationHelper', () => { + let helper: AuthenticationHelper; + // create mocks + const mockCalculateA = calculateA as jest.Mock; + + beforeEach(async () => { + helper = await getAuthenticationHelper('TestPoolName'); + }); + + afterEach(() => { + mockCalculateA.mockReset(); + }); + + it('returns an instance of AuthenticationHelper', () => { + expect(helper).toBeDefined(); + expect(helper).toBeInstanceOf(AuthenticationHelper); + }); + + it('should generate with non-deterministic seeding', async () => { + const arr: string[] = []; + for (let i = 0; i < 20; i++) { + const helper = await getAuthenticationHelper('TestPoolName'); + arr.push(helper.a.toString(16)); + } + expect(arr.length).toBe(new Set(arr).size); + }); + + it('should throw an error', async () => { + mockCalculateA.mockRejectedValue(new Error()); + await expect(getAuthenticationHelper('TestPoolName')).rejects.toThrow(); + }); +}); diff --git a/packages/auth/__tests__/providers/cognito/utils/srp/getHashFromData.test.ts b/packages/auth/__tests__/providers/cognito/utils/srp/getHashFromData.test.ts new file mode 100644 index 00000000000..28aa579d1b3 --- /dev/null +++ b/packages/auth/__tests__/providers/cognito/utils/srp/getHashFromData.test.ts @@ -0,0 +1,11 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getHashFromData } from '../../../../../src/providers/cognito/utils/srp/getHashFromData'; + +describe('getHashFromData', () => { + it('Hashing a buffer returns a string', () => { + const buf = Buffer.from('7468697320697320612074c3a97374', 'binary'); + expect(typeof getHashFromData(buf)).toBe('string'); + }); +}); diff --git a/packages/auth/__tests__/providers/cognito/utils/srp/getHashFromHex.test.ts b/packages/auth/__tests__/providers/cognito/utils/srp/getHashFromHex.test.ts new file mode 100644 index 00000000000..c8a8a61d1ea --- /dev/null +++ b/packages/auth/__tests__/providers/cognito/utils/srp/getHashFromHex.test.ts @@ -0,0 +1,17 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Sha256 } from '@aws-crypto/sha256-js'; +import { getHashFromHex } from '../../../../../src/providers/cognito/utils/srp/getHashFromHex'; + +describe('getHashFromHex', () => { + it('produces a valid hex string with regex', () => { + const regEx = /[0-9a-f]/g; + const awsCryptoHash = new Sha256(); + awsCryptoHash.update('testString'); + const resultFromAWSCrypto = awsCryptoHash.digestSync(); + const hashHex = Buffer.from(resultFromAWSCrypto).toString('hex'); + + expect(regEx.test(getHashFromHex(hashHex))).toBe(true); + }); +}); diff --git a/packages/auth/__tests__/providers/cognito/utils/srp/getHkdfKey.test.ts b/packages/auth/__tests__/providers/cognito/utils/srp/getHkdfKey.test.ts new file mode 100644 index 00000000000..b1ca3060c1d --- /dev/null +++ b/packages/auth/__tests__/providers/cognito/utils/srp/getHkdfKey.test.ts @@ -0,0 +1,18 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getHkdfKey } from '../../../../../src/providers/cognito/utils/srp/getHkdfKey'; + +describe('getHkdfKey', () => { + it('returns a length 16 hex string', () => { + const inputKey = Buffer.from('secretInputKey', 'ascii'); + const salt = Buffer.from('7468697320697320612074c3a97374', 'hex'); + const context = Buffer.from('Caldera Derived Key', 'utf8'); + const spacer = Buffer.from(String.fromCharCode(1), 'utf8'); + const info = new Uint8Array(context.byteLength + spacer.byteLength); + info.set(context, 0); + info.set(spacer, context.byteLength); + + expect(getHkdfKey(inputKey, salt, info).length).toEqual(16); + }); +}); diff --git a/packages/auth/__tests__/providers/cognito/utils/srp/getPaddedHex.test.ts b/packages/auth/__tests__/providers/cognito/utils/srp/getPaddedHex.test.ts new file mode 100644 index 00000000000..545a1a5ce3d --- /dev/null +++ b/packages/auth/__tests__/providers/cognito/utils/srp/getPaddedHex.test.ts @@ -0,0 +1,553 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { BigInteger } from '../../../../../src/providers/cognito/utils/srp/BigInteger'; +import { getPaddedHex } from '../../../../../src/providers/cognito/utils/srp/getPaddedHex'; + +describe('getPaddedHex', () => { + /* + Test cases generated in Java with: + + import java.math.BigInteger; + public class Main + { + private static final char[] HEX_ARRAY = '0123456789ABCDEF'.toCharArray(); + public static String bytesToHex(byte[] bytes) { + char[] hexChars = new char[bytes.length * 2]; + for (int j = 0; j < bytes.length; j++) { + int v = bytes[j] & 0xFF; + hexChars[j * 2] = HEX_ARRAY[v >>> 4]; + hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F]; + } + return new String(hexChars); + } + public static void main(String[] args) { + for(int i = -256; i <=256; i++) { + byte arr[] = BigInteger.valueOf(i).toByteArray(); + System.out.println('[' + i +', '' + bytesToHex(arr) + ''],'); + } + } + } + */ + test.each([ + [-256, 'FF00'], + [-255, 'FF01'], + [-254, 'FF02'], + [-253, 'FF03'], + [-252, 'FF04'], + [-251, 'FF05'], + [-250, 'FF06'], + [-249, 'FF07'], + [-248, 'FF08'], + [-247, 'FF09'], + [-246, 'FF0A'], + [-245, 'FF0B'], + [-244, 'FF0C'], + [-243, 'FF0D'], + [-242, 'FF0E'], + [-241, 'FF0F'], + [-240, 'FF10'], + [-239, 'FF11'], + [-238, 'FF12'], + [-237, 'FF13'], + [-236, 'FF14'], + [-235, 'FF15'], + [-234, 'FF16'], + [-233, 'FF17'], + [-232, 'FF18'], + [-231, 'FF19'], + [-230, 'FF1A'], + [-229, 'FF1B'], + [-228, 'FF1C'], + [-227, 'FF1D'], + [-226, 'FF1E'], + [-225, 'FF1F'], + [-224, 'FF20'], + [-223, 'FF21'], + [-222, 'FF22'], + [-221, 'FF23'], + [-220, 'FF24'], + [-219, 'FF25'], + [-218, 'FF26'], + [-217, 'FF27'], + [-216, 'FF28'], + [-215, 'FF29'], + [-214, 'FF2A'], + [-213, 'FF2B'], + [-212, 'FF2C'], + [-211, 'FF2D'], + [-210, 'FF2E'], + [-209, 'FF2F'], + [-208, 'FF30'], + [-207, 'FF31'], + [-206, 'FF32'], + [-205, 'FF33'], + [-204, 'FF34'], + [-203, 'FF35'], + [-202, 'FF36'], + [-201, 'FF37'], + [-200, 'FF38'], + [-199, 'FF39'], + [-198, 'FF3A'], + [-197, 'FF3B'], + [-196, 'FF3C'], + [-195, 'FF3D'], + [-194, 'FF3E'], + [-193, 'FF3F'], + [-192, 'FF40'], + [-191, 'FF41'], + [-190, 'FF42'], + [-189, 'FF43'], + [-188, 'FF44'], + [-187, 'FF45'], + [-186, 'FF46'], + [-185, 'FF47'], + [-184, 'FF48'], + [-183, 'FF49'], + [-182, 'FF4A'], + [-181, 'FF4B'], + [-180, 'FF4C'], + [-179, 'FF4D'], + [-178, 'FF4E'], + [-177, 'FF4F'], + [-176, 'FF50'], + [-175, 'FF51'], + [-174, 'FF52'], + [-173, 'FF53'], + [-172, 'FF54'], + [-171, 'FF55'], + [-170, 'FF56'], + [-169, 'FF57'], + [-168, 'FF58'], + [-167, 'FF59'], + [-166, 'FF5A'], + [-165, 'FF5B'], + [-164, 'FF5C'], + [-163, 'FF5D'], + [-162, 'FF5E'], + [-161, 'FF5F'], + [-160, 'FF60'], + [-159, 'FF61'], + [-158, 'FF62'], + [-157, 'FF63'], + [-156, 'FF64'], + [-155, 'FF65'], + [-154, 'FF66'], + [-153, 'FF67'], + [-152, 'FF68'], + [-151, 'FF69'], + [-150, 'FF6A'], + [-149, 'FF6B'], + [-148, 'FF6C'], + [-147, 'FF6D'], + [-146, 'FF6E'], + [-145, 'FF6F'], + [-144, 'FF70'], + [-143, 'FF71'], + [-142, 'FF72'], + [-141, 'FF73'], + [-140, 'FF74'], + [-139, 'FF75'], + [-138, 'FF76'], + [-137, 'FF77'], + [-136, 'FF78'], + [-135, 'FF79'], + [-134, 'FF7A'], + [-133, 'FF7B'], + [-132, 'FF7C'], + [-131, 'FF7D'], + [-130, 'FF7E'], + [-129, 'FF7F'], + [-128, '80'], + [-127, '81'], + [-126, '82'], + [-125, '83'], + [-124, '84'], + [-123, '85'], + [-122, '86'], + [-121, '87'], + [-120, '88'], + [-119, '89'], + [-118, '8A'], + [-117, '8B'], + [-116, '8C'], + [-115, '8D'], + [-114, '8E'], + [-113, '8F'], + [-112, '90'], + [-111, '91'], + [-110, '92'], + [-109, '93'], + [-108, '94'], + [-107, '95'], + [-106, '96'], + [-105, '97'], + [-104, '98'], + [-103, '99'], + [-102, '9A'], + [-101, '9B'], + [-100, '9C'], + [-99, '9D'], + [-98, '9E'], + [-97, '9F'], + [-96, 'A0'], + [-95, 'A1'], + [-94, 'A2'], + [-93, 'A3'], + [-92, 'A4'], + [-91, 'A5'], + [-90, 'A6'], + [-89, 'A7'], + [-88, 'A8'], + [-87, 'A9'], + [-86, 'AA'], + [-85, 'AB'], + [-84, 'AC'], + [-83, 'AD'], + [-82, 'AE'], + [-81, 'AF'], + [-80, 'B0'], + [-79, 'B1'], + [-78, 'B2'], + [-77, 'B3'], + [-76, 'B4'], + [-75, 'B5'], + [-74, 'B6'], + [-73, 'B7'], + [-72, 'B8'], + [-71, 'B9'], + [-70, 'BA'], + [-69, 'BB'], + [-68, 'BC'], + [-67, 'BD'], + [-66, 'BE'], + [-65, 'BF'], + [-64, 'C0'], + [-63, 'C1'], + [-62, 'C2'], + [-61, 'C3'], + [-60, 'C4'], + [-59, 'C5'], + [-58, 'C6'], + [-57, 'C7'], + [-56, 'C8'], + [-55, 'C9'], + [-54, 'CA'], + [-53, 'CB'], + [-52, 'CC'], + [-51, 'CD'], + [-50, 'CE'], + [-49, 'CF'], + [-48, 'D0'], + [-47, 'D1'], + [-46, 'D2'], + [-45, 'D3'], + [-44, 'D4'], + [-43, 'D5'], + [-42, 'D6'], + [-41, 'D7'], + [-40, 'D8'], + [-39, 'D9'], + [-38, 'DA'], + [-37, 'DB'], + [-36, 'DC'], + [-35, 'DD'], + [-34, 'DE'], + [-33, 'DF'], + [-32, 'E0'], + [-31, 'E1'], + [-30, 'E2'], + [-29, 'E3'], + [-28, 'E4'], + [-27, 'E5'], + [-26, 'E6'], + [-25, 'E7'], + [-24, 'E8'], + [-23, 'E9'], + [-22, 'EA'], + [-21, 'EB'], + [-20, 'EC'], + [-19, 'ED'], + [-18, 'EE'], + [-17, 'EF'], + [-16, 'F0'], + [-15, 'F1'], + [-14, 'F2'], + [-13, 'F3'], + [-12, 'F4'], + [-11, 'F5'], + [-10, 'F6'], + [-9, 'F7'], + [-8, 'F8'], + [-7, 'F9'], + [-6, 'FA'], + [-5, 'FB'], + [-4, 'FC'], + [-3, 'FD'], + [-2, 'FE'], + [-1, 'FF'], + [0, '00'], + [1, '01'], + [2, '02'], + [3, '03'], + [4, '04'], + [5, '05'], + [6, '06'], + [7, '07'], + [8, '08'], + [9, '09'], + [10, '0A'], + [11, '0B'], + [12, '0C'], + [13, '0D'], + [14, '0E'], + [15, '0F'], + [16, '10'], + [17, '11'], + [18, '12'], + [19, '13'], + [20, '14'], + [21, '15'], + [22, '16'], + [23, '17'], + [24, '18'], + [25, '19'], + [26, '1A'], + [27, '1B'], + [28, '1C'], + [29, '1D'], + [30, '1E'], + [31, '1F'], + [32, '20'], + [33, '21'], + [34, '22'], + [35, '23'], + [36, '24'], + [37, '25'], + [38, '26'], + [39, '27'], + [40, '28'], + [41, '29'], + [42, '2A'], + [43, '2B'], + [44, '2C'], + [45, '2D'], + [46, '2E'], + [47, '2F'], + [48, '30'], + [49, '31'], + [50, '32'], + [51, '33'], + [52, '34'], + [53, '35'], + [54, '36'], + [55, '37'], + [56, '38'], + [57, '39'], + [58, '3A'], + [59, '3B'], + [60, '3C'], + [61, '3D'], + [62, '3E'], + [63, '3F'], + [64, '40'], + [65, '41'], + [66, '42'], + [67, '43'], + [68, '44'], + [69, '45'], + [70, '46'], + [71, '47'], + [72, '48'], + [73, '49'], + [74, '4A'], + [75, '4B'], + [76, '4C'], + [77, '4D'], + [78, '4E'], + [79, '4F'], + [80, '50'], + [81, '51'], + [82, '52'], + [83, '53'], + [84, '54'], + [85, '55'], + [86, '56'], + [87, '57'], + [88, '58'], + [89, '59'], + [90, '5A'], + [91, '5B'], + [92, '5C'], + [93, '5D'], + [94, '5E'], + [95, '5F'], + [96, '60'], + [97, '61'], + [98, '62'], + [99, '63'], + [100, '64'], + [101, '65'], + [102, '66'], + [103, '67'], + [104, '68'], + [105, '69'], + [106, '6A'], + [107, '6B'], + [108, '6C'], + [109, '6D'], + [110, '6E'], + [111, '6F'], + [112, '70'], + [113, '71'], + [114, '72'], + [115, '73'], + [116, '74'], + [117, '75'], + [118, '76'], + [119, '77'], + [120, '78'], + [121, '79'], + [122, '7A'], + [123, '7B'], + [124, '7C'], + [125, '7D'], + [126, '7E'], + [127, '7F'], + [128, '0080'], + [129, '0081'], + [130, '0082'], + [131, '0083'], + [132, '0084'], + [133, '0085'], + [134, '0086'], + [135, '0087'], + [136, '0088'], + [137, '0089'], + [138, '008A'], + [139, '008B'], + [140, '008C'], + [141, '008D'], + [142, '008E'], + [143, '008F'], + [144, '0090'], + [145, '0091'], + [146, '0092'], + [147, '0093'], + [148, '0094'], + [149, '0095'], + [150, '0096'], + [151, '0097'], + [152, '0098'], + [153, '0099'], + [154, '009A'], + [155, '009B'], + [156, '009C'], + [157, '009D'], + [158, '009E'], + [159, '009F'], + [160, '00A0'], + [161, '00A1'], + [162, '00A2'], + [163, '00A3'], + [164, '00A4'], + [165, '00A5'], + [166, '00A6'], + [167, '00A7'], + [168, '00A8'], + [169, '00A9'], + [170, '00AA'], + [171, '00AB'], + [172, '00AC'], + [173, '00AD'], + [174, '00AE'], + [175, '00AF'], + [176, '00B0'], + [177, '00B1'], + [178, '00B2'], + [179, '00B3'], + [180, '00B4'], + [181, '00B5'], + [182, '00B6'], + [183, '00B7'], + [184, '00B8'], + [185, '00B9'], + [186, '00BA'], + [187, '00BB'], + [188, '00BC'], + [189, '00BD'], + [190, '00BE'], + [191, '00BF'], + [192, '00C0'], + [193, '00C1'], + [194, '00C2'], + [195, '00C3'], + [196, '00C4'], + [197, '00C5'], + [198, '00C6'], + [199, '00C7'], + [200, '00C8'], + [201, '00C9'], + [202, '00CA'], + [203, '00CB'], + [204, '00CC'], + [205, '00CD'], + [206, '00CE'], + [207, '00CF'], + [208, '00D0'], + [209, '00D1'], + [210, '00D2'], + [211, '00D3'], + [212, '00D4'], + [213, '00D5'], + [214, '00D6'], + [215, '00D7'], + [216, '00D8'], + [217, '00D9'], + [218, '00DA'], + [219, '00DB'], + [220, '00DC'], + [221, '00DD'], + [222, '00DE'], + [223, '00DF'], + [224, '00E0'], + [225, '00E1'], + [226, '00E2'], + [227, '00E3'], + [228, '00E4'], + [229, '00E5'], + [230, '00E6'], + [231, '00E7'], + [232, '00E8'], + [233, '00E9'], + [234, '00EA'], + [235, '00EB'], + [236, '00EC'], + [237, '00ED'], + [238, '00EE'], + [239, '00EF'], + [240, '00F0'], + [241, '00F1'], + [242, '00F2'], + [243, '00F3'], + [244, '00F4'], + [245, '00F5'], + [246, '00F6'], + [247, '00F7'], + [248, '00F8'], + [249, '00F9'], + [250, '00FA'], + [251, '00FB'], + [252, '00FC'], + [253, '00FD'], + [254, '00FE'], + [255, '00FF'], + [256, '0100'], + ])('padHex(bigInteger.fromInt(%p))\t=== %p', (i, expected) => { + const bigInt = new BigInteger(); + bigInt.fromInt(i); + + const x = getPaddedHex(bigInt); + expect(x.toLowerCase()).toBe(expected.toLowerCase()); + }); +}); diff --git a/packages/auth/__tests__/providers/cognito/utils/srp/getRandomString.test.ts b/packages/auth/__tests__/providers/cognito/utils/srp/getRandomString.test.ts new file mode 100644 index 00000000000..0f9a67c685e --- /dev/null +++ b/packages/auth/__tests__/providers/cognito/utils/srp/getRandomString.test.ts @@ -0,0 +1,15 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getRandomString } from '../../../../../src/providers/cognito/utils/srp/getRandomString'; + +describe('getRandomString', () => { + it('should generate non-deterministic strings', () => { + const arr: string[] = []; + for (let i = 0; i < 20; i++) { + const str = getRandomString(); + arr.push(str); + } + expect(arr.length).toBe(new Set(arr).size); + }); +}); diff --git a/packages/auth/__tests__/testUtils/promisifyCallback.ts b/packages/auth/__tests__/testUtils/promisifyCallback.ts deleted file mode 100644 index c508d76bd81..00000000000 --- a/packages/auth/__tests__/testUtils/promisifyCallback.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -/** - * Utility function that uses promises/resolve pattern to test asynchronous code in the Jest library - * @param {object} obj - pass the entire object/file being test to resolve dependencies utilized within fn - * @param {function} fn - name of the function that will be called. - * @param {[args]} ...args - an array of arguments that varies with every function - * - * More information here: https://jestjs.io/docs/asynchronous#callbacks - **/ -export async function promisifyCallback(obj: object, fn: string, ...args: any) { - return new Promise((resolve, reject) => { - const callback = (err, data) => { - err ? reject(err) : resolve(data); - }; - try { - // in case .apply() fails - obj[fn].apply(obj, [...args, callback]); - } catch (error) { - reject(error); - } - }); -} diff --git a/packages/auth/src/providers/cognito/utils/signInHelpers.ts b/packages/auth/src/providers/cognito/utils/signInHelpers.ts index d6a12b1094a..b0e993dd622 100644 --- a/packages/auth/src/providers/cognito/utils/signInHelpers.ts +++ b/packages/auth/src/providers/cognito/utils/signInHelpers.ts @@ -6,15 +6,14 @@ import { assertTokenProviderConfig, base64Encoder, } from '@aws-amplify/core/internals/utils'; +import { AuthenticationHelper } from './srp/AuthenticationHelper'; +import { BigInteger } from './srp/BigInteger'; import { - fromHex, - getLargeAValue, + getAuthenticationHelper, + getBytesFromHex, getNowString, - getPasswordAuthenticationKey, getSignatureString, -} from './srp/helpers'; -import { AuthenticationHelper } from './srp/AuthenticationHelper/'; -import { BigInteger } from './srp/BigInteger'; +} from './srp'; import { ClientMetadata, ConfirmSignInOptions } from '../types'; import { @@ -305,11 +304,11 @@ export async function handleUserSRPAuthFlow( ): Promise { const { userPoolId, userPoolClientId } = config; const userPoolName = userPoolId?.split('_')[1] || ''; - const authenticationHelper = new AuthenticationHelper(userPoolName); + const authenticationHelper = await getAuthenticationHelper(userPoolName); const authParameters: Record = { USERNAME: username, - SRP_A: ((await getLargeAValue(authenticationHelper)) as any).toString(16), + SRP_A: authenticationHelper.A.toString(16), }; const deviceMetadata = await tokenOrchestrator.getDeviceMetadata(); @@ -385,13 +384,13 @@ export async function handleCustomSRPAuthFlow( const { userPoolId, userPoolClientId } = config; const userPoolName = userPoolId?.split('_')[1] || ''; - const authenticationHelper = new AuthenticationHelper(userPoolName); + const authenticationHelper = await getAuthenticationHelper(userPoolName); const deviceMetadata = await tokenOrchestrator.getDeviceMetadata(); const authParameters: Record = { USERNAME: username, - SRP_A: ((await getLargeAValue(authenticationHelper)) as any).toString(16), + SRP_A: authenticationHelper.A.toString(16), CHALLENGE_NAME: 'SRP_A', }; if (deviceMetadata && deviceMetadata.deviceKey) { @@ -430,12 +429,12 @@ async function handleDeviceSRPAuth({ const clientId = config.userPoolClientId; const deviceMetadata = await tokenOrchestrator?.getDeviceMetadata(); assertDeviceMetadata(deviceMetadata); - const authenticationHelper = new AuthenticationHelper( + const authenticationHelper = await getAuthenticationHelper( deviceMetadata.deviceGroupKey ); const challengeResponses: Record = { USERNAME: username, - SRP_A: ((await getLargeAValue(authenticationHelper)) as any).toString(16), + SRP_A: authenticationHelper.A.toString(16), DEVICE_KEY: deviceMetadata.deviceKey, }; @@ -478,8 +477,7 @@ async function handleDevicePasswordVerifier( const salt = new BigInteger(challengeParameters?.SALT, 16); const deviceKey = deviceMetadata.deviceKey; const deviceGroupKey = deviceMetadata.deviceGroupKey; - const hkdf = await getPasswordAuthenticationKey({ - authenticationHelper, + const hkdf = await authenticationHelper.getPasswordAuthenticationKey({ username: deviceMetadata.deviceKey, password: deviceMetadata.randomPassword, serverBValue, @@ -534,8 +532,7 @@ export async function handlePasswordVerifierChallenge( name: 'EmptyUserIdForSRPException', message: 'USER_ID_FOR_SRP was not found in challengeParameters', }); - const hkdf = await getPasswordAuthenticationKey({ - authenticationHelper, + const hkdf = await authenticationHelper.getPasswordAuthenticationKey({ username, password, serverBValue, @@ -879,52 +876,47 @@ export async function getNewDeviceMetatada( ): Promise { if (!newDeviceMetadata) return undefined; const userPoolName = userPoolId.split('_')[1] || ''; - const authenticationHelper = new AuthenticationHelper(userPoolName); + const authenticationHelper = await getAuthenticationHelper(userPoolName); const deviceKey = newDeviceMetadata?.DeviceKey; const deviceGroupKey = newDeviceMetadata?.DeviceGroupKey; - return new Promise((resolve, _) => { - authenticationHelper.generateHashDevice( + try { + await authenticationHelper.generateHashDevice( deviceGroupKey ?? '', - deviceKey ?? '', - async (errGenHash: unknown) => { - if (errGenHash) { - // TODO: log error here - resolve(undefined); - return; - } + deviceKey ?? '' + ); + } catch (errGenHash) { + // TODO: log error here + return undefined; + } - const deviceSecretVerifierConfig = { - Salt: base64Encoder.convert( - fromHex(authenticationHelper.getSaltToHashDevices()) - ), - PasswordVerifier: base64Encoder.convert( - fromHex(authenticationHelper.getVerifierDevices()) - ), - }; - - const randomPassword = authenticationHelper.getRandomPassword(); - - try { - await confirmDevice( - { region: getRegion(userPoolId) }, - { - AccessToken: accessToken, - DeviceKey: newDeviceMetadata?.DeviceKey, - DeviceSecretVerifierConfig: deviceSecretVerifierConfig, - } - ); - - resolve({ - deviceKey, - deviceGroupKey, - randomPassword, - }); - } catch (error) { - // TODO: log error here - resolve(undefined); - } + const deviceSecretVerifierConfig = { + Salt: base64Encoder.convert( + getBytesFromHex(authenticationHelper.getSaltToHashDevices()) + ), + PasswordVerifier: base64Encoder.convert( + getBytesFromHex(authenticationHelper.getVerifierDevices()) + ), + }; + const randomPassword = authenticationHelper.getRandomPassword(); + + try { + await confirmDevice( + { region: getRegion(userPoolId) }, + { + AccessToken: accessToken, + DeviceKey: newDeviceMetadata?.DeviceKey, + DeviceSecretVerifierConfig: deviceSecretVerifierConfig, } ); - }); + + return { + deviceKey, + deviceGroupKey, + randomPassword, + }; + } catch (error) { + // TODO: log error here + return undefined; + } } diff --git a/packages/auth/src/providers/cognito/utils/srp/AuthenticationHelper/AuthenticationHelper.ts b/packages/auth/src/providers/cognito/utils/srp/AuthenticationHelper/AuthenticationHelper.ts index d2c35fbadbc..49a3cf4142d 100644 --- a/packages/auth/src/providers/cognito/utils/srp/AuthenticationHelper/AuthenticationHelper.ts +++ b/packages/auth/src/providers/cognito/utils/srp/AuthenticationHelper/AuthenticationHelper.ts @@ -1,179 +1,55 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { Sha256 as jsSha256 } from '@aws-crypto/sha256-js'; -import { base64Encoder } from '@aws-amplify/core/internals/utils'; -import { BigInteger } from '../BigInteger'; -import { toHex, fromHex } from '../helpers'; -import WordArray from '../WordArray'; import { AuthError } from '../../../../../errors/AuthError'; import { textEncoder } from '../../textEncoder'; - -// Prevent infer the BigInteger type the lib.dom.d -type BigInteger = typeof BigInteger; - -const SHORT_TO_HEX: Record = {}; -const HEX_TO_SHORT: Record = {}; - -for (let i = 0; i < 256; i++) { - let encodedByte = i.toString(16).toLowerCase(); - if (encodedByte.length === 1) { - encodedByte = `0${encodedByte}`; - } - - SHORT_TO_HEX[i] = encodedByte; - HEX_TO_SHORT[encodedByte] = i; -} - -/** - * Returns a Uint8Array with a sequence of random nBytes - * - * @param {number} nBytes - * @returns {Uint8Array} fixed-length sequence of random bytes - */ -function randomBytes(nBytes: number): Uint8Array { - const str = new WordArray().random(nBytes).toString(); - - return fromHex(str); -} - -/** - * Returns a Uint8Array with a sequence of random nBytes - * - * @param {number} nBytes - * @returns {Uint8Array} fixed-length sequence of random bytes - */ - -/** - * Tests if a hex string has it most significant bit set (case-insensitive regex) - */ -const HEX_MSB_REGEX = /^[89a-f]/i; - -const initN = - 'FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1' + - '29024E088A67CC74020BBEA63B139B22514A08798E3404DD' + - 'EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245' + - 'E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED' + - 'EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D' + - 'C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F' + - '83655D23DCA3AD961C62F356208552BB9ED529077096966D' + - '670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B' + - 'E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9' + - 'DE2BCBF6955817183995497CEA956AE515D2261898FA0510' + - '15728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64' + - 'ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7' + - 'ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6B' + - 'F12FFA06D98A0864D87602733EC86A64521F2B18177B200C' + - 'BBE117577A615D6C770988C0BAD946E208E24FA074E5AB31' + - '43DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF'; - -const newPasswordRequiredChallengeUserAttributePrefix = 'userAttributes.'; +import { AuthBigInteger, BigInteger } from '../BigInteger'; +import { calculateS, calculateU } from '../calculate'; +import { getBytesFromHex } from '../getBytesFromHex'; +import { getHashFromData } from '../getHashFromData'; +import { getHashFromHex } from '../getHashFromHex'; +import { getHexFromBytes } from '../getHexFromBytes'; +import { getHkdfKey } from '../getHkdfKey'; +import { getPaddedHex } from '../getPaddedHex'; +import { getRandomBytes } from '../getRandomBytes'; +import { getRandomString } from '../getRandomString'; /** @class */ export default class AuthenticationHelper { encoder = textEncoder; - smallAValue: BigInteger; - infoBits: Uint8Array; - poolName: string; - largeAValue?: BigInteger; + userPoolName: string; randomPassword?: string; - SaltToHashDevices?: string; + saltToHashDevices?: string; verifierDevices?: string; - UHexHash?: string; - UValue?: BigInteger; - N: BigInteger; - g: BigInteger; - k: BigInteger; - /** - * Constructs a new AuthenticationHelper object - * @param {string} PoolName Cognito user pool name. - */ - constructor(PoolName: string) { - this.N = new BigInteger(initN, 16); - this.g = new BigInteger('2', 16); + + a: AuthBigInteger; + N: AuthBigInteger; + g: AuthBigInteger; + k: AuthBigInteger; + A: AuthBigInteger; + + constructor({ + userPoolName, + a, + g, + A, + N, + }: { + userPoolName: string; + a: AuthBigInteger; + g: AuthBigInteger; + A: AuthBigInteger; + N: AuthBigInteger; + }) { + this.userPoolName = userPoolName; + this.a = a; + this.g = g; + this.A = A; + this.N = N; this.k = new BigInteger( - this.hexHash(`${this.padHex(this.N)}${this.padHex(this.g)}`), + getHashFromHex(`${getPaddedHex(N)}${getPaddedHex(g)}`), 16 ); - - this.smallAValue = this.generateRandomSmallA(); - this.getLargeAValue(() => {}); - - this.infoBits = this.encoder.convert('Caldera Derived Key'); - - this.poolName = PoolName; - } - - getLargeA(): BigInteger { - if (!this.largeAValue) { - throw new AuthError({ - name: 'EmptyBigIntegerLargeAValue', - message: 'largeAValue was not defined', - }); - } - return this.largeAValue; - } - getUValue(): BigInteger { - if (!this.UValue) - throw new AuthError({ - name: 'EmptyBigIntegerUValue', - message: 'UValue is empty', - }); - return this.UValue; - } - /** - * @returns {BigInteger} small A, a random number - */ - getSmallAValue(): BigInteger { - return this.smallAValue; - } - - /** - * @param {nodeCallback} callback Called with (err, largeAValue) - * @returns {void} - */ - getLargeAValue(callback: Function): void { - if (this.largeAValue) { - callback(null, this.largeAValue); - } else { - this.calculateA( - this.smallAValue, - (err: unknown, largeAValue: BigInteger) => { - if (err) { - callback(err, null); - } - - this.largeAValue = largeAValue; - callback(null, this.largeAValue); - } - ); - } - } - - /** - * helper function to generate a random big integer - * @returns {BigInteger} a random value. - * @private - */ - generateRandomSmallA(): BigInteger { - // This will be interpreted as a postive 128-bit integer - - const hexRandom = toHex(randomBytes(128)); - - const randomBigInt = new BigInteger(hexRandom, 16); - - // There is no need to do randomBigInt.mod(this.N - 1) as N (3072-bit) is > 128 bytes (1024-bit) - - return randomBigInt; - } - - /** - * helper function to generate a random string - * @returns {string} a random value. - * @private - */ - generateRandomString(): string { - return base64Encoder.convert(randomBytes(40)); } /** @@ -193,13 +69,13 @@ export default class AuthenticationHelper { * @returns {string} Generated random value included in devices hash. */ getSaltToHashDevices(): string { - if (!this.SaltToHashDevices) { + if (!this.saltToHashDevices) { throw new AuthError({ - name: 'EmptyBigIntegerSaltToHashDevices', - message: 'SaltToHashDevices is empty', + name: 'EmptyBigIntegersaltToHashDevices', + message: 'saltToHashDevices is empty', }); } - return this.SaltToHashDevices; + return this.saltToHashDevices; } /** @@ -217,293 +93,101 @@ export default class AuthenticationHelper { /** * Generate salts and compute verifier. + * * @param {string} deviceGroupKey Devices to generate verifier for. * @param {string} username User to generate verifier for. - * @param {nodeCallback} callback Called with (err, null) - * @returns {void} + * + * @returns {Promise} */ - generateHashDevice( + async generateHashDevice( deviceGroupKey: string, - username: string, - callback: Function - ): void { - this.randomPassword = this.generateRandomString(); + username: string + ): Promise { + this.randomPassword = getRandomString(); const combinedString = `${deviceGroupKey}${username}:${this.randomPassword}`; - const hashedString = this.hash(combinedString); + const hashedString = getHashFromData(combinedString); - const hexRandom = toHex(randomBytes(16)); + const hexRandom = getHexFromBytes(getRandomBytes(16)); // The random hex will be unambiguously represented as a postive integer - this.SaltToHashDevices = this.padHex(new BigInteger(hexRandom, 16)); + this.saltToHashDevices = getPaddedHex(new BigInteger(hexRandom, 16)); + + return new Promise((resolve, reject) => { + this.g.modPow( + new BigInteger( + getHashFromHex(this.saltToHashDevices + hashedString), + 16 + ), + this.N, + (err: unknown, result: AuthBigInteger) => { + if (err) { + reject(err); + return; + } - this.g.modPow( - new BigInteger(this.hexHash(this.SaltToHashDevices + hashedString), 16), - this.N, - (err: unknown, verifierDevicesNotPadded: BigInteger) => { - if (err) { - callback(err, null); + this.verifierDevices = getPaddedHex(result); + resolve(); } - - this.verifierDevices = this.padHex(verifierDevicesNotPadded); - callback(null, null); - } - ); - } - - /** - * Calculate the client's public value A = g^a%N - * with the generated random number a - * @param {BigInteger} a Randomly generated small A. - * @param {nodeCallback} callback Called with (err, largeAValue) - * @returns {void} - * @private - */ - calculateA(a: BigInteger, callback: Function) { - this.g.modPow(a, this.N, (err: unknown, A: BigInteger) => { - if (err) { - callback(err, null); - } - - if (A.mod(this.N).equals(BigInteger.ZERO)) { - callback(new Error('Illegal paramater. A mod N cannot be 0.'), null); - } - - callback(null, A); + ); }); } /** - * Calculate the client's value U which is the hash of A and B - * @param {BigInteger} A Large A value. - * @param {BigInteger} B Server B value. - * @returns {BigInteger} Computed U value. - * @private - */ - calculateU(A: BigInteger, B: BigInteger): BigInteger { - this.UHexHash = this.hexHash(this.padHex(A) + this.padHex(B)); - const finalU = new BigInteger(this.UHexHash, 16); - - return finalU; - } - - /** - * Calculate a hash from a bitArray - * @param {Uint8Array} buf Value to hash. - * @returns {String} Hex-encoded hash. - * @private - */ - hash(buf: any): string { - const awsCryptoHash = new jsSha256(); - awsCryptoHash.update(buf); - - const resultFromAWSCrypto = awsCryptoHash.digestSync(); - const hashHexFromUint8 = toHex(resultFromAWSCrypto); - return new Array(64 - hashHexFromUint8.length).join('0') + hashHexFromUint8; - } - - /** - * Calculate a hash from a hex string - * @param {String} hexStr Value to hash. - * @returns {String} Hex-encoded hash. - * @private - */ - hexHash(hexStr: string): string { - return this.hash(fromHex(hexStr)); - } - - /** - * Standard hkdf algorithm - * @param {Uint8Array} ikm Input key material. - * @param {Uint8Array} salt Salt value. - * @returns {Uint8Array} Strong key material. - * @private - */ - computehkdf(ikm: Uint8Array, salt: Uint8Array): Uint8Array { - const stringOne = this.encoder.convert(String.fromCharCode(1)); - const bufConcat = new Uint8Array( - this.infoBits.byteLength + stringOne.byteLength - ); - bufConcat.set(this.infoBits, 0); - bufConcat.set(stringOne, this.infoBits.byteLength); - - const awsCryptoHash = new jsSha256(salt); - awsCryptoHash.update(ikm); - - const resultFromAWSCryptoPrk = awsCryptoHash.digestSync(); - - const awsCryptoHashHmac = new jsSha256(resultFromAWSCryptoPrk); - awsCryptoHashHmac.update(bufConcat); - const resultFromAWSCryptoHmac = awsCryptoHashHmac.digestSync(); - - const hashHexFromAWSCrypto = resultFromAWSCryptoHmac; - - const currentHex = hashHexFromAWSCrypto.slice(0, 16); - - return currentHex; - } - - /** - * Calculates the final hkdf based on computed S value, and computed U value and the key + * Calculates the final HKDF key based on computed S value, computed U value and the key + * * @param {String} username Username. * @param {String} password Password. - * @param {BigInteger} serverBValue Server B value. - * @param {BigInteger} salt Generated salt. - * @param {nodeCallback} callback Called with (err, hkdfValue) - * @returns {void} - */ - getPasswordAuthenticationKey( - username: string, - password: string, - serverBValue: BigInteger, - salt: BigInteger, - callback: Function - ) { + * @param {AuthBigInteger} B Server B value. + * @param {AuthBigInteger} salt Generated salt. + */ + async getPasswordAuthenticationKey({ + username, + password, + serverBValue, + salt, + }: { + username: string; + password: string; + serverBValue: AuthBigInteger; + salt: AuthBigInteger; + }): Promise { if (serverBValue.mod(this.N).equals(BigInteger.ZERO)) { throw new Error('B cannot be zero.'); } - this.UValue = this.calculateU(this.getLargeA(), serverBValue); - - if (this.UValue.equals(BigInteger.ZERO)) { - throw new Error('U cannot be zero.'); - } + const U = calculateU({ + A: this.A, + B: serverBValue, + }); - const usernamePassword = `${this.poolName}${username}:${password}`; - const usernamePasswordHash = this.hash(usernamePassword); + const usernamePassword = `${this.userPoolName}${username}:${password}`; + const usernamePasswordHash = getHashFromData(usernamePassword); - const xValue = new BigInteger( - this.hexHash(this.padHex(salt) + usernamePasswordHash), + const x = new BigInteger( + getHashFromHex(getPaddedHex(salt) + usernamePasswordHash), 16 ); - this.calculateS( - xValue, - serverBValue, - (err: unknown, sValue: BigInteger) => { - if (err) { - callback(err, null); - } - - const hkdf = this.computehkdf( - fromHex(this.padHex(sValue)), - fromHex(this.padHex(this.getUValue())) - ); - - callback(null, hkdf); - } - ); - } - /** - * Calculates the S value used in getPasswordAuthenticationKey - * @param {BigInteger} xValue Salted password hash value. - * @param {BigInteger} serverBValue Server B value. - * @param {nodeCallback} callback Called on success or error. - * @returns {void} - */ - calculateS( - xValue: BigInteger, - serverBValue: BigInteger, - callback: Function - ): void { - this.g.modPow(xValue, this.N, (err: unknown, gModPowXN: Function) => { - if (err) { - callback(err, null); - } - - const intValue2 = serverBValue.subtract(this.k.multiply(gModPowXN)); - intValue2.modPow( - this.smallAValue.add(this.getUValue().multiply(xValue)), - this.N, - (err2: unknown, result: BigInteger) => { - if (err2) { - callback(err2, null); - } - callback(null, result.mod(this.N)); - } - ); + const S = await calculateS({ + a: this.a, + g: this.g, + k: this.k, + x, + B: serverBValue, + N: this.N, + U, }); - } - /** - * Return constant newPasswordRequiredChallengeUserAttributePrefix - * @return {newPasswordRequiredChallengeUserAttributePrefix} constant prefix value - */ - getNewPasswordRequiredChallengeUserAttributePrefix() { - return newPasswordRequiredChallengeUserAttributePrefix; - } - - /** - * Returns an unambiguous, even-length hex string of the two's complement encoding of an integer. - * - * It is compatible with the hex encoding of Java's BigInteger's toByteArray(), wich returns a - * byte array containing the two's-complement representation of a BigInteger. The array contains - * the minimum number of bytes required to represent the BigInteger, including at least one sign bit. - * - * Examples showing how ambiguity is avoided by left padding with: - * "00" (for positive values where the most-significant-bit is set) - * "FF" (for negative values where the most-significant-bit is set) - * - * padHex(bigInteger.fromInt(-236)) === "FF14" - * padHex(bigInteger.fromInt(20)) === "14" - * - * padHex(bigInteger.fromInt(-200)) === "FF38" - * padHex(bigInteger.fromInt(56)) === "38" - * - * padHex(bigInteger.fromInt(-20)) === "EC" - * padHex(bigInteger.fromInt(236)) === "00EC" - * - * padHex(bigInteger.fromInt(-56)) === "C8" - * padHex(bigInteger.fromInt(200)) === "00C8" - * - * @param {BigInteger} bigInt Number to encode. - * @returns {String} even-length hex string of the two's complement encoding. - */ - padHex(bigInt: BigInteger): string { - if (!(bigInt instanceof BigInteger)) { - throw new Error('Not a BigInteger'); - } - - const isNegative = bigInt.compareTo(BigInteger.ZERO) < 0; - - /* Get a hex string for abs(bigInt) */ - let hexStr = bigInt.abs().toString(16); - - /* Pad hex to even length if needed */ - hexStr = hexStr.length % 2 !== 0 ? `0${hexStr}` : hexStr; - - /* Prepend "00" if the most significant bit is set */ - hexStr = HEX_MSB_REGEX.test(hexStr) ? `00${hexStr}` : hexStr; - - if (isNegative) { - /* Flip the bits of the representation */ - const invertedNibbles = hexStr - .split('') - .map((x: string) => { - const invertedNibble = ~parseInt(x, 16) & 0xf; - return '0123456789ABCDEF'.charAt(invertedNibble); - }) - .join(''); - - /* After flipping the bits, add one to get the 2's complement representation */ - const flippedBitsBI = new BigInteger(invertedNibbles, 16).add( - BigInteger.ONE - ); - - hexStr = flippedBitsBI.toString(16); - - /* - For hex strings starting with 'FF8', 'FF' can be dropped, e.g. 0xFFFF80=0xFF80=0x80=-128 - - Any sequence of '1' bits on the left can always be substituted with a single '1' bit - without changing the represented value. - - This only happens in the case when the input is 80...00 - */ - if (hexStr.toUpperCase().startsWith('FF8')) { - hexStr = hexStr.substring(2); - } - } - - return hexStr; + const context = this.encoder.convert('Caldera Derived Key'); + const spacer = this.encoder.convert(String.fromCharCode(1)); + const info = new Uint8Array(context.byteLength + spacer.byteLength); + info.set(context, 0); + info.set(spacer, context.byteLength); + const hkdfKey = getHkdfKey( + getBytesFromHex(getPaddedHex(S)), + getBytesFromHex(getPaddedHex(U)), + info + ); + return hkdfKey; } } diff --git a/packages/auth/src/providers/cognito/utils/srp/AuthenticationHelper/index.native.ts b/packages/auth/src/providers/cognito/utils/srp/AuthenticationHelper/index.native.ts deleted file mode 100644 index 4bb632546fd..00000000000 --- a/packages/auth/src/providers/cognito/utils/srp/AuthenticationHelper/index.native.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { computeS } from '@aws-amplify/react-native'; -import AuthenticationHelper from './AuthenticationHelper'; -import { BigIntegerInterface } from '../BigInteger/types'; -import { BigInteger } from '../BigInteger'; - -AuthenticationHelper.prototype.calculateS = function calculateS( - xValue: BigIntegerInterface, - serverBValue: BigIntegerInterface, - callback: Function -) { - computeS({ - g: (this as unknown as AuthenticationHelper).g.toString(16), - x: xValue.toString(16), - k: (this as unknown as AuthenticationHelper).k.toString(16), - a: (this as unknown as AuthenticationHelper).smallAValue.toString(16), - b: serverBValue.toString(16), - u: (this as unknown as AuthenticationHelper).getUValue().toString(16), - }) - .then(result => callback(null, new BigInteger(result, 16))) - .catch(error => callback(new Error(error), null)); -}; - -export { AuthenticationHelper }; diff --git a/packages/auth/src/providers/cognito/utils/srp/BigInteger/BigInteger.ts b/packages/auth/src/providers/cognito/utils/srp/BigInteger/BigInteger.ts index 2be64e516d7..634a2ec1c15 100644 --- a/packages/auth/src/providers/cognito/utils/srp/BigInteger/BigInteger.ts +++ b/packages/auth/src/providers/cognito/utils/srp/BigInteger/BigInteger.ts @@ -17,9 +17,9 @@ // divide // modPow -import { BigIntegerInterface } from './types'; +import { AuthBigInteger } from './types'; -export default BigInteger as BigIntegerInterface; +export default BigInteger as AuthBigInteger; type BNP = { s: number; t: number }; /* diff --git a/packages/auth/src/providers/cognito/utils/srp/BigInteger/index.native.ts b/packages/auth/src/providers/cognito/utils/srp/BigInteger/index.native.ts index a0d7500cb36..826abb92f09 100644 --- a/packages/auth/src/providers/cognito/utils/srp/BigInteger/index.native.ts +++ b/packages/auth/src/providers/cognito/utils/srp/BigInteger/index.native.ts @@ -4,20 +4,20 @@ import { computeModPow } from '@aws-amplify/react-native'; import BigInteger from './BigInteger'; -import { BigIntegerInterface } from './types'; +import { AuthBigInteger } from './types'; BigInteger.prototype.modPow = function modPow( - e: BigIntegerInterface, - m: BigIntegerInterface, + e: AuthBigInteger, + m: AuthBigInteger, callback: Function ) { computeModPow({ - base: (this as unknown as BigIntegerInterface).toString(16), + base: (this as unknown as AuthBigInteger).toString(16), exponent: e.toString(16), divisor: m.toString(16), }) - .then(result => callback(null, new BigInteger(result, 16))) - .catch(error => callback(new Error(error), null)); + .then((result: any) => callback(null, new BigInteger(result, 16))) + .catch((error: any) => callback(new Error(error), null)); }; export { BigInteger }; diff --git a/packages/auth/src/providers/cognito/utils/srp/BigInteger/index.ts b/packages/auth/src/providers/cognito/utils/srp/BigInteger/index.ts index b20bdc48137..8bb8d7e19cc 100644 --- a/packages/auth/src/providers/cognito/utils/srp/BigInteger/index.ts +++ b/packages/auth/src/providers/cognito/utils/srp/BigInteger/index.ts @@ -2,5 +2,5 @@ // SPDX-License-Identifier: Apache-2.0 import BigInteger from './BigInteger'; - +export { AuthBigInteger } from './types'; export { BigInteger }; diff --git a/packages/auth/src/providers/cognito/utils/srp/BigInteger/types.ts b/packages/auth/src/providers/cognito/utils/srp/BigInteger/types.ts index a90f9460c9f..55888d19fb2 100644 --- a/packages/auth/src/providers/cognito/utils/srp/BigInteger/types.ts +++ b/packages/auth/src/providers/cognito/utils/srp/BigInteger/types.ts @@ -1,8 +1,8 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -export interface BigIntegerInterface { - new (a?: any, b?: any): BigIntegerInterface; +export interface AuthBigInteger { + new (a?: any, b?: any): AuthBigInteger; subtract: Function; add: Function; multiply: Function; diff --git a/packages/auth/src/providers/cognito/utils/srp/calculate/calculateA.ts b/packages/auth/src/providers/cognito/utils/srp/calculate/calculateA.ts new file mode 100644 index 00000000000..ba8f1a99d4f --- /dev/null +++ b/packages/auth/src/providers/cognito/utils/srp/calculate/calculateA.ts @@ -0,0 +1,33 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AuthBigInteger, BigInteger } from '../BigInteger'; + +/** + * @internal + */ +export const calculateA = async ({ + a, + g, + N, +}: { + a: AuthBigInteger; + g: AuthBigInteger; + N: AuthBigInteger; +}): Promise => { + return new Promise((resolve, reject) => { + g.modPow(a, N, (err: unknown, A: AuthBigInteger) => { + if (err) { + reject(err); + return; + } + + if (A.mod(N).equals(BigInteger.ZERO)) { + reject(new Error('Illegal parameter. A mod N cannot be 0.')); + return; + } + + resolve(A); + }); + }); +}; diff --git a/packages/auth/src/providers/cognito/utils/srp/calculate/calculateS.native.ts b/packages/auth/src/providers/cognito/utils/srp/calculate/calculateS.native.ts new file mode 100644 index 00000000000..8e42da77e36 --- /dev/null +++ b/packages/auth/src/providers/cognito/utils/srp/calculate/calculateS.native.ts @@ -0,0 +1,33 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { computeS } from '@aws-amplify/react-native'; +import { AuthBigInteger, BigInteger } from '../BigInteger'; + +export const calculateS = async ({ + a, + g, + k, + x, + B, + N, + U, +}: { + a: AuthBigInteger; + g: AuthBigInteger; + k: AuthBigInteger; + x: AuthBigInteger; + B: AuthBigInteger; + N: AuthBigInteger; + U: AuthBigInteger; +}): Promise => { + const result = await computeS({ + a: a.toString(16), + g: g.toString(16), + k: k.toString(16), + x: x.toString(16), + b: B.toString(16), + u: U.toString(16), + }); + return new BigInteger(result, 16); +}; diff --git a/packages/auth/src/providers/cognito/utils/srp/calculate/calculateS.ts b/packages/auth/src/providers/cognito/utils/srp/calculate/calculateS.ts new file mode 100644 index 00000000000..3537fec56fd --- /dev/null +++ b/packages/auth/src/providers/cognito/utils/srp/calculate/calculateS.ts @@ -0,0 +1,46 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AuthBigInteger } from '../BigInteger'; + +/** + * @internal + */ +export const calculateS = async ({ + a, + g, + k, + x, + B, + N, + U, +}: { + a: AuthBigInteger; + g: AuthBigInteger; + k: AuthBigInteger; + x: AuthBigInteger; + B: AuthBigInteger; + N: AuthBigInteger; + U: AuthBigInteger; +}): Promise => { + return new Promise((resolve, reject) => { + g.modPow(x, N, (outerErr: unknown, outerResult: AuthBigInteger) => { + if (outerErr) { + reject(outerErr); + return; + } + + B.subtract(k.multiply(outerResult)).modPow( + a.add(U.multiply(x)), + N, + (innerErr: unknown, innerResult: AuthBigInteger) => { + if (innerErr) { + reject(innerErr); + return; + } + resolve(innerResult.mod(N)); + } + ); + }); + }); +}; diff --git a/packages/auth/src/providers/cognito/utils/srp/calculate/calculateU.ts b/packages/auth/src/providers/cognito/utils/srp/calculate/calculateU.ts new file mode 100644 index 00000000000..c73a95c692c --- /dev/null +++ b/packages/auth/src/providers/cognito/utils/srp/calculate/calculateU.ts @@ -0,0 +1,28 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AuthBigInteger, BigInteger } from '../BigInteger'; +import { getHashFromHex } from '../getHashFromHex'; +import { getPaddedHex } from '../getPaddedHex'; + +/** + * @internal + */ +export const calculateU = ({ + A, + B, +}: { + A: AuthBigInteger; + B: AuthBigInteger; +}): AuthBigInteger => { + const U = new BigInteger( + getHashFromHex(getPaddedHex(A) + getPaddedHex(B)), + 16 + ); + + if (U.equals(BigInteger.ZERO)) { + throw new Error('U cannot be zero.'); + } + + return U; +}; diff --git a/packages/auth/src/providers/cognito/utils/srp/calculate/index.ts b/packages/auth/src/providers/cognito/utils/srp/calculate/index.ts new file mode 100644 index 00000000000..b3116287df6 --- /dev/null +++ b/packages/auth/src/providers/cognito/utils/srp/calculate/index.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { calculateA } from './calculateA'; +export { calculateS } from './calculateS'; +export { calculateU } from './calculateU'; diff --git a/packages/auth/src/providers/cognito/utils/srp/constants.ts b/packages/auth/src/providers/cognito/utils/srp/constants.ts new file mode 100644 index 00000000000..42b0d3b09f9 --- /dev/null +++ b/packages/auth/src/providers/cognito/utils/srp/constants.ts @@ -0,0 +1,32 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const INIT_N = + 'FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1' + + '29024E088A67CC74020BBEA63B139B22514A08798E3404DD' + + 'EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245' + + 'E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED' + + 'EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D' + + 'C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F' + + '83655D23DCA3AD961C62F356208552BB9ED529077096966D' + + '670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B' + + 'E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9' + + 'DE2BCBF6955817183995497CEA956AE515D2261898FA0510' + + '15728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64' + + 'ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7' + + 'ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6B' + + 'F12FFA06D98A0864D87602733EC86A64521F2B18177B200C' + + 'BBE117577A615D6C770988C0BAD946E208E24FA074E5AB31' + + '43DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF'; +export const SHORT_TO_HEX: Record = {}; +export const HEX_TO_SHORT: Record = {}; + +for (let i = 0; i < 256; i++) { + let encodedByte = i.toString(16).toLowerCase(); + if (encodedByte.length === 1) { + encodedByte = `0${encodedByte}`; + } + + SHORT_TO_HEX[i] = encodedByte; + HEX_TO_SHORT[encodedByte] = i; +} diff --git a/packages/auth/src/providers/cognito/utils/srp/getAuthenticationHelper.ts b/packages/auth/src/providers/cognito/utils/srp/getAuthenticationHelper.ts new file mode 100644 index 00000000000..d76b9e63d92 --- /dev/null +++ b/packages/auth/src/providers/cognito/utils/srp/getAuthenticationHelper.ts @@ -0,0 +1,39 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AuthenticationHelper } from './AuthenticationHelper'; +import { AuthBigInteger, BigInteger } from './BigInteger'; +import { calculateA } from './calculate'; +import { INIT_N } from './constants'; +import { getHexFromBytes } from './getHexFromBytes'; +import { getRandomBytes } from './getRandomBytes'; + +/** + * Returns a new {@link AuthenticationHelper} instance with randomly generated BigInteger seed + * + * @param userPoolName Cognito user pool name. + * @returns An {@link AuthenticationHelper} instance. + * + * @internal + */ +export const getAuthenticationHelper = async (userPoolName: string) => { + const N = new BigInteger(INIT_N, 16); + const g = new BigInteger('2', 16); + const a = generateRandomBigInteger(); + const A = await calculateA({ a, g, N }); + + return new AuthenticationHelper({ userPoolName, a, g, A, N }); +}; + +/** + * Generates a random BigInteger. + * + * @returns {BigInteger} a random value. + */ +const generateRandomBigInteger = (): AuthBigInteger => { + // This will be interpreted as a postive 128-bit integer + const hexRandom = getHexFromBytes(getRandomBytes(128)); + + // There is no need to do randomBigInt.mod(this.N - 1) as N (3072-bit) is > 128 bytes (1024-bit) + return new BigInteger(hexRandom, 16); +}; diff --git a/packages/auth/src/providers/cognito/utils/srp/getBytesFromHex.ts b/packages/auth/src/providers/cognito/utils/srp/getBytesFromHex.ts new file mode 100644 index 00000000000..ebb9ec398ef --- /dev/null +++ b/packages/auth/src/providers/cognito/utils/srp/getBytesFromHex.ts @@ -0,0 +1,29 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { HEX_TO_SHORT } from './constants'; + +/** + * Converts a hexadecimal encoded string to a Uint8Array of bytes. + * + * @param encoded The hexadecimal encoded string + */ +export const getBytesFromHex = (encoded: string): Uint8Array => { + if (encoded.length % 2 !== 0) { + throw new Error('Hex encoded strings must have an even number length'); + } + + const out = new Uint8Array(encoded.length / 2); + for (let i = 0; i < encoded.length; i += 2) { + const encodedByte = encoded.slice(i, i + 2).toLowerCase(); + if (encodedByte in HEX_TO_SHORT) { + out[i / 2] = HEX_TO_SHORT[encodedByte]; + } else { + throw new Error( + `Cannot decode unrecognized sequence ${encodedByte} as hexadecimal` + ); + } + } + + return out; +}; diff --git a/packages/auth/src/providers/cognito/utils/srp/getHashFromData.ts b/packages/auth/src/providers/cognito/utils/srp/getHashFromData.ts new file mode 100644 index 00000000000..89987bc0503 --- /dev/null +++ b/packages/auth/src/providers/cognito/utils/srp/getHashFromData.ts @@ -0,0 +1,21 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Sha256 } from '@aws-crypto/sha256-js'; +import { SourceData } from '@smithy/types'; +import { getHexFromBytes } from './getHexFromBytes'; + +/** + * Calculate a hash from a `SourceData` + * @param {SourceData} data Value to hash. + * @returns {string} Hex-encoded hash. + * @private + */ +export const getHashFromData = (data: SourceData): string => { + const sha256 = new Sha256(); + sha256.update(data); + + const hashedData = sha256.digestSync(); + const hashHexFromUint8 = getHexFromBytes(hashedData); + return new Array(64 - hashHexFromUint8.length).join('0') + hashHexFromUint8; +}; diff --git a/packages/auth/src/providers/cognito/utils/srp/getHashFromHex.ts b/packages/auth/src/providers/cognito/utils/srp/getHashFromHex.ts new file mode 100644 index 00000000000..b367261a155 --- /dev/null +++ b/packages/auth/src/providers/cognito/utils/srp/getHashFromHex.ts @@ -0,0 +1,14 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getBytesFromHex } from './getBytesFromHex'; +import { getHashFromData } from './getHashFromData'; + +/** + * Calculate a hash from a hex string + * @param {string} hexStr Value to hash. + * @returns {string} Hex-encoded hash. + * @private + */ +export const getHashFromHex = (hexStr: string): string => + getHashFromData(getBytesFromHex(hexStr)); diff --git a/packages/auth/src/providers/cognito/utils/srp/getHexFromBytes.ts b/packages/auth/src/providers/cognito/utils/srp/getHexFromBytes.ts new file mode 100644 index 00000000000..d51c0cf59d5 --- /dev/null +++ b/packages/auth/src/providers/cognito/utils/srp/getHexFromBytes.ts @@ -0,0 +1,18 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { SHORT_TO_HEX } from './constants'; + +/** + * Converts a Uint8Array of binary data to a hexadecimal encoded string. + * + * @param bytes The binary data to encode + */ +export const getHexFromBytes = (bytes: Uint8Array): string => { + let out = ''; + for (let i = 0; i < bytes.byteLength; i++) { + out += SHORT_TO_HEX[bytes[i]]; + } + + return out; +}; diff --git a/packages/auth/src/providers/cognito/utils/srp/getHkdfKey.ts b/packages/auth/src/providers/cognito/utils/srp/getHkdfKey.ts new file mode 100644 index 00000000000..39810e7dba4 --- /dev/null +++ b/packages/auth/src/providers/cognito/utils/srp/getHkdfKey.ts @@ -0,0 +1,33 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Sha256 } from '@aws-crypto/sha256-js'; + +/** + * Standard HKDF algorithm. + * + * @param {Uint8Array} ikm Input key material. + * @param {Uint8Array} salt Salt value. + * @param {Uint8Array} info Context and application specific info. + * + * @returns {Uint8Array} Strong key material. + * + * @internal + */ +export const getHkdfKey = ( + ikm: Uint8Array, + salt: Uint8Array, + info: Uint8Array +): Uint8Array => { + const awsCryptoHash = new Sha256(salt); + awsCryptoHash.update(ikm); + + const resultFromAWSCryptoPrk = awsCryptoHash.digestSync(); + const awsCryptoHashHmac = new Sha256(resultFromAWSCryptoPrk); + awsCryptoHashHmac.update(info); + + const resultFromAWSCryptoHmac = awsCryptoHashHmac.digestSync(); + const hashHexFromAWSCrypto = resultFromAWSCryptoHmac; + + return hashHexFromAWSCrypto.slice(0, 16); +}; diff --git a/packages/auth/src/providers/cognito/utils/srp/getNowString.ts b/packages/auth/src/providers/cognito/utils/srp/getNowString.ts new file mode 100644 index 00000000000..d2e3997191f --- /dev/null +++ b/packages/auth/src/providers/cognito/utils/srp/getNowString.ts @@ -0,0 +1,48 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +const MONTH_NAMES = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; +const WEEK_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + +export const getNowString = (): string => { + const now = new Date(); + + const weekDay = WEEK_NAMES[now.getUTCDay()]; + const month = MONTH_NAMES[now.getUTCMonth()]; + const day = now.getUTCDate(); + + let hours: string | number = now.getUTCHours(); + if (hours < 10) { + hours = `0${hours}`; + } + + let minutes: string | number = now.getUTCMinutes(); + if (minutes < 10) { + minutes = `0${minutes}`; + } + + let seconds: string | number = now.getUTCSeconds(); + if (seconds < 10) { + seconds = `0${seconds}`; + } + + const year = now.getUTCFullYear(); + + // ddd MMM D HH:mm:ss UTC YYYY + const dateNow = `${weekDay} ${month} ${day} ${hours}:${minutes}:${seconds} UTC ${year}`; + + return dateNow; +}; diff --git a/packages/auth/src/providers/cognito/utils/srp/getPaddedHex.ts b/packages/auth/src/providers/cognito/utils/srp/getPaddedHex.ts new file mode 100644 index 00000000000..0195d8c3d6b --- /dev/null +++ b/packages/auth/src/providers/cognito/utils/srp/getPaddedHex.ts @@ -0,0 +1,84 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AuthBigInteger, BigInteger } from './BigInteger'; + +/** + * Tests if a hex string has it most significant bit set (case-insensitive regex) + */ +const HEX_MSB_REGEX = /^[89a-f]/i; + +/** + * Returns an unambiguous, even-length hex string of the two's complement encoding of an integer. + * + * It is compatible with the hex encoding of Java's BigInteger's toByteArray(), wich returns a + * byte array containing the two's-complement representation of a BigInteger. The array contains + * the minimum number of bytes required to represent the BigInteger, including at least one sign bit. + * + * Examples showing how ambiguity is avoided by left padding with: + * "00" (for positive values where the most-significant-bit is set) + * "FF" (for negative values where the most-significant-bit is set) + * + * padHex(bigInteger.fromInt(-236)) === "FF14" + * padHex(bigInteger.fromInt(20)) === "14" + * + * padHex(bigInteger.fromInt(-200)) === "FF38" + * padHex(bigInteger.fromInt(56)) === "38" + * + * padHex(bigInteger.fromInt(-20)) === "EC" + * padHex(bigInteger.fromInt(236)) === "00EC" + * + * padHex(bigInteger.fromInt(-56)) === "C8" + * padHex(bigInteger.fromInt(200)) === "00C8" + * + * @param {AuthBigInteger} bigInt Number to encode. + * @returns {String} even-length hex string of the two's complement encoding. + */ +export const getPaddedHex = (bigInt: AuthBigInteger): string => { + if (!(bigInt instanceof BigInteger)) { + throw new Error('Not a BigInteger'); + } + + const isNegative = bigInt.compareTo(BigInteger.ZERO) < 0; + + /* Get a hex string for abs(bigInt) */ + let hexStr = bigInt.abs().toString(16); + + /* Pad hex to even length if needed */ + hexStr = hexStr.length % 2 !== 0 ? `0${hexStr}` : hexStr; + + /* Prepend "00" if the most significant bit is set */ + hexStr = HEX_MSB_REGEX.test(hexStr) ? `00${hexStr}` : hexStr; + + if (isNegative) { + /* Flip the bits of the representation */ + const invertedNibbles = hexStr + .split('') + .map((x: string) => { + const invertedNibble = ~parseInt(x, 16) & 0xf; + return '0123456789ABCDEF'.charAt(invertedNibble); + }) + .join(''); + + /* After flipping the bits, add one to get the 2's complement representation */ + const flippedBitsBI = new BigInteger(invertedNibbles, 16).add( + BigInteger.ONE + ); + + hexStr = flippedBitsBI.toString(16); + + /* + For hex strings starting with 'FF8', 'FF' can be dropped, e.g. 0xFFFF80=0xFF80=0x80=-128 + + Any sequence of '1' bits on the left can always be substituted with a single '1' bit + without changing the represented value. + + This only happens in the case when the input is 80...00 + */ + if (hexStr.toUpperCase().startsWith('FF8')) { + hexStr = hexStr.substring(2); + } + } + + return hexStr; +}; diff --git a/packages/auth/src/providers/cognito/utils/srp/getRandomBytes.ts b/packages/auth/src/providers/cognito/utils/srp/getRandomBytes.ts new file mode 100644 index 00000000000..b26993f59df --- /dev/null +++ b/packages/auth/src/providers/cognito/utils/srp/getRandomBytes.ts @@ -0,0 +1,17 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getBytesFromHex } from './getBytesFromHex'; +import WordArray from './WordArray'; + +/** + * Returns a Uint8Array with a sequence of random nBytes + * + * @param {number} nBytes + * @returns {Uint8Array} fixed-length sequence of random bytes + */ +export const getRandomBytes = (nBytes: number): Uint8Array => { + const str = new WordArray().random(nBytes).toString(); + + return getBytesFromHex(str); +}; diff --git a/packages/auth/src/providers/cognito/utils/srp/getRandomString.ts b/packages/auth/src/providers/cognito/utils/srp/getRandomString.ts new file mode 100644 index 00000000000..097fab42fde --- /dev/null +++ b/packages/auth/src/providers/cognito/utils/srp/getRandomString.ts @@ -0,0 +1,14 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { base64Encoder } from '@aws-amplify/core/internals/utils'; +import { getRandomBytes } from './getRandomBytes'; + +/** + * Helper function to generate a random string + * @returns {string} a random value. + * + * @internal + */ +export const getRandomString = (): string => + base64Encoder.convert(getRandomBytes(40)); diff --git a/packages/auth/src/providers/cognito/utils/srp/getSignatureString.ts b/packages/auth/src/providers/cognito/utils/srp/getSignatureString.ts new file mode 100644 index 00000000000..9ef5b559a5d --- /dev/null +++ b/packages/auth/src/providers/cognito/utils/srp/getSignatureString.ts @@ -0,0 +1,65 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Sha256 } from '@aws-crypto/sha256-js'; +import { SourceData } from '@smithy/types'; +import { + base64Decoder, + base64Encoder, +} from '@aws-amplify/core/internals/utils'; + +export const getSignatureString = ({ + userPoolName, + username, + challengeParameters, + dateNow, + hkdf, +}: { + userPoolName: string; + username: string; + challengeParameters: Record; + dateNow: string; + hkdf: SourceData; +}): string => { + const encoder = new TextEncoder(); + + const bufUPIDaToB = encoder.encode(userPoolName); + const bufUNaToB = encoder.encode(username); + const bufSBaToB = urlB64ToUint8Array(challengeParameters.SECRET_BLOCK); + const bufDNaToB = encoder.encode(dateNow); + + const bufConcat = new Uint8Array( + bufUPIDaToB.byteLength + + bufUNaToB.byteLength + + bufSBaToB.byteLength + + bufDNaToB.byteLength + ); + bufConcat.set(bufUPIDaToB, 0); + bufConcat.set(bufUNaToB, bufUPIDaToB.byteLength); + bufConcat.set(bufSBaToB, bufUPIDaToB.byteLength + bufUNaToB.byteLength); + bufConcat.set( + bufDNaToB, + bufUPIDaToB.byteLength + bufUNaToB.byteLength + bufSBaToB.byteLength + ); + + const awsCryptoHash = new Sha256(hkdf); + awsCryptoHash.update(bufConcat); + const resultFromAWSCrypto = awsCryptoHash.digestSync(); + const signatureString = base64Encoder.convert(resultFromAWSCrypto); + return signatureString; +}; + +const urlB64ToUint8Array = (base64String: string): Uint8Array => { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding) + .replace(/\-/g, '+') + .replace(/_/g, '/'); + + const rawData = base64Decoder.convert(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +}; diff --git a/packages/auth/src/providers/cognito/utils/srp/helpers.ts b/packages/auth/src/providers/cognito/utils/srp/helpers.ts deleted file mode 100644 index 9a2353b6ca4..00000000000 --- a/packages/auth/src/providers/cognito/utils/srp/helpers.ts +++ /dev/null @@ -1,225 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { Sha256 } from '@aws-crypto/sha256-js'; -import { SourceData } from '@smithy/types'; -import { - base64Encoder, - base64Decoder, -} from '@aws-amplify/core/internals/utils'; -import { AuthenticationHelper } from './AuthenticationHelper'; -import { textEncoder } from '../textEncoder'; -import { BigIntegerInterface } from './BigInteger/types'; - -export function hash(buf: SourceData) { - const awsCryptoHash = new Sha256(); - awsCryptoHash.update(buf); - - const resultFromAWSCrypto = awsCryptoHash.digestSync(); - const hashHexFromUint8 = toHex(resultFromAWSCrypto); - return new Array(64 - hashHexFromUint8.length).join('0') + hashHexFromUint8; -} - -/** - * Calculate a hash from a hex string - * @param {String} hexStr Value to hash. - * @returns {String} Hex-encoded hash. - * @private - */ -export function hexHash(hexStr: string) { - return hash(fromHex(hexStr)); -} - -const SHORT_TO_HEX: Record = {}; -const HEX_TO_SHORT: Record = {}; - -for (let i = 0; i < 256; i++) { - let encodedByte = i.toString(16).toLowerCase(); - if (encodedByte.length === 1) { - encodedByte = `0${encodedByte}`; - } - - SHORT_TO_HEX[i] = encodedByte; - HEX_TO_SHORT[encodedByte] = i; -} - -/** - * Converts a hexadecimal encoded string to a Uint8Array of bytes. - * - * @param encoded The hexadecimal encoded string - */ -export function fromHex(encoded: string) { - if (encoded.length % 2 !== 0) { - throw new Error('Hex encoded strings must have an even number length'); - } - - const out = new Uint8Array(encoded.length / 2); - for (let i = 0; i < encoded.length; i += 2) { - const encodedByte = encoded.slice(i, i + 2).toLowerCase(); - if (encodedByte in HEX_TO_SHORT) { - out[i / 2] = HEX_TO_SHORT[encodedByte]; - } else { - throw new Error( - `Cannot decode unrecognized sequence ${encodedByte} as hexadecimal` - ); - } - } - - return out; -} - -/** - * Converts a Uint8Array of binary data to a hexadecimal encoded string. - * - * @param bytes The binary data to encode - */ -export function toHex(bytes: Uint8Array) { - let out = ''; - for (let i = 0; i < bytes.byteLength; i++) { - out += SHORT_TO_HEX[bytes[i]]; - } - - return out; -} - -export function _urlB64ToUint8Array(base64String: string) { - const padding = '='.repeat((4 - (base64String.length % 4)) % 4); - const base64 = (base64String + padding) - .replace(/\-/g, '+') - .replace(/_/g, '/'); - - const rawData = base64Decoder.convert(base64); - const outputArray = new Uint8Array(rawData.length); - - for (let i = 0; i < rawData.length; ++i) { - outputArray[i] = rawData.charCodeAt(i); - } - return outputArray; -} - -const monthNames = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', -]; -const weekNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; - -export function getNowString() { - const now = new Date(); - - const weekDay = weekNames[now.getUTCDay()]; - const month = monthNames[now.getUTCMonth()]; - const day = now.getUTCDate(); - - let hours: string | number = now.getUTCHours(); - if (hours < 10) { - hours = `0${hours}`; - } - - let minutes: string | number = now.getUTCMinutes(); - if (minutes < 10) { - minutes = `0${minutes}`; - } - - let seconds: string | number = now.getUTCSeconds(); - if (seconds < 10) { - seconds = `0${seconds}`; - } - - const year = now.getUTCFullYear(); - - // ddd MMM D HH:mm:ss UTC YYYY - const dateNow = `${weekDay} ${month} ${day} ${hours}:${minutes}:${seconds} UTC ${year}`; - - return dateNow; -} - -export function getSignatureString({ - userPoolName, - username, - challengeParameters, - dateNow, - hkdf, -}: { - userPoolName: string; - username: string; - challengeParameters: Record; - dateNow: string; - hkdf: SourceData; -}): string { - const encoder = textEncoder; - - const bufUPIDaToB = encoder.convert(userPoolName); - const bufUNaToB = encoder.convert(username); - const bufSBaToB = _urlB64ToUint8Array(challengeParameters.SECRET_BLOCK); - const bufDNaToB = encoder.convert(dateNow); - - const bufConcat = new Uint8Array( - bufUPIDaToB.byteLength + - bufUNaToB.byteLength + - bufSBaToB.byteLength + - bufDNaToB.byteLength - ); - bufConcat.set(bufUPIDaToB, 0); - bufConcat.set(bufUNaToB, bufUPIDaToB.byteLength); - bufConcat.set(bufSBaToB, bufUPIDaToB.byteLength + bufUNaToB.byteLength); - bufConcat.set( - bufDNaToB, - bufUPIDaToB.byteLength + bufUNaToB.byteLength + bufSBaToB.byteLength - ); - - const awsCryptoHash = new Sha256(hkdf); - awsCryptoHash.update(bufConcat); - const resultFromAWSCrypto = awsCryptoHash.digestSync(); - const signatureString = base64Encoder.convert(resultFromAWSCrypto); - return signatureString; -} - -export function getLargeAValue(authenticationHelper: AuthenticationHelper) { - return new Promise(res => { - authenticationHelper.getLargeAValue( - (err: unknown, aValue: BigIntegerInterface) => { - res(aValue); - } - ); - }); -} - -export function getPasswordAuthenticationKey({ - authenticationHelper, - username, - password, - serverBValue, - salt, -}: { - authenticationHelper: AuthenticationHelper; - username: string; - password: string; - serverBValue: BigIntegerInterface; - salt: BigIntegerInterface; -}): Promise { - return new Promise((res, rej) => { - authenticationHelper.getPasswordAuthenticationKey( - username, - password, - serverBValue, - salt, - (err: unknown, hkdf: SourceData) => { - if (err) { - return rej(err); - } - - res(hkdf); - } - ); - }); -} diff --git a/packages/auth/src/providers/cognito/utils/srp/index.ts b/packages/auth/src/providers/cognito/utils/srp/index.ts new file mode 100644 index 00000000000..405227808b4 --- /dev/null +++ b/packages/auth/src/providers/cognito/utils/srp/index.ts @@ -0,0 +1,7 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { getAuthenticationHelper } from './getAuthenticationHelper'; +export { getBytesFromHex } from './getBytesFromHex'; +export { getNowString } from './getNowString'; +export { getSignatureString } from './getSignatureString'; From 6c75c33c8fbf883026aca379b78ba05c80820900 Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Date: Wed, 4 Oct 2023 09:49:50 -0700 Subject: [PATCH 06/20] feat(auth) add forgetDevice api (#12161) * feat(auth) add forgetDevice api * fix: remove device keys from tokenStore * fix(auth): add option to forget external device * Update packages/auth/src/providers/cognito/apis/forgetDevice.ts Co-authored-by: Hui Zhao <10602282+HuiSF@users.noreply.github.com> * fix: forget current device with input params --------- Co-authored-by: Ashwin Kumar Co-authored-by: Hui Zhao <10602282+HuiSF@users.noreply.github.com> Co-authored-by: israx <70438514+israx@users.noreply.github.com> --- .../providers/cognito/forgetDevice.test.ts | 201 ++++++++++++++++++ packages/auth/src/index.ts | 2 + .../providers/cognito/apis/forgetDevice.ts | 44 ++++ packages/auth/src/providers/cognito/index.ts | 2 + .../auth/src/providers/cognito/types/index.ts | 1 + .../src/providers/cognito/types/inputs.ts | 6 + .../clients/CognitoIdentityProvider/index.ts | 6 +- packages/auth/src/types/index.ts | 1 + packages/auth/src/types/inputs.ts | 10 + packages/auth/src/types/models.ts | 8 + .../aws-amplify/__tests__/exports.test.ts | 2 + 11 files changed, 280 insertions(+), 3 deletions(-) create mode 100644 packages/auth/__tests__/providers/cognito/forgetDevice.test.ts create mode 100644 packages/auth/src/providers/cognito/apis/forgetDevice.ts diff --git a/packages/auth/__tests__/providers/cognito/forgetDevice.test.ts b/packages/auth/__tests__/providers/cognito/forgetDevice.test.ts new file mode 100644 index 00000000000..93e1bc7e240 --- /dev/null +++ b/packages/auth/__tests__/providers/cognito/forgetDevice.test.ts @@ -0,0 +1,201 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AuthError } from '../../../src/errors/AuthError'; +import { DEVICE_METADATA_NOT_FOUND_EXCEPTION } from '../../../src/errors/constants'; +import { forgetDevice } from '../../../src/providers/cognito'; +import { ForgetDeviceException } from '../../../src/providers/cognito/types/errors'; +import * as clients from '../../../src/providers/cognito/utils/clients/CognitoIdentityProvider'; +import * as TokenProvider from '../../../src/providers/cognito/tokenProvider'; +import { Amplify } from 'aws-amplify'; +import { decodeJWT, retry } from '@aws-amplify/core/internals/utils'; +import * as authUtils from '../../../src'; +import { fetchTransferHandler } from '@aws-amplify/core/internals/aws-client-utils'; +import { buildMockErrorResponse, mockJsonResponse } from './testUtils/data'; +jest.mock('@aws-amplify/core/lib/clients/handlers/fetch'); + +Amplify.configure({ + Auth: { + Cognito: { + userPoolClientId: '111111-aaaaa-42d8-891d-ee81a1549398', + userPoolId: 'us-west-2_zzzzz', + identityPoolId: 'us-west-2:xxxxxx', + }, + }, +}); +const mockedAccessToken = + 'test_eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'; +const mockDeviceMetadata = { + deviceKey: 'deviceKey', + deviceGroupKey: 'deviceGroupKey', + randomPassword: 'randomPassword', +}; + +describe('forgetDevice API happy path cases', () => { + let fetchAuthSessionsSpy; + let forgetDeviceStatusClientSpy; + let getDeviceMetadataSpy; + let clearDeviceMetadataSpy; + beforeEach(() => { + fetchAuthSessionsSpy = jest + .spyOn(authUtils, 'fetchAuthSession') + .mockImplementationOnce( + async (): Promise<{ tokens: { accessToken: any } }> => { + return { + tokens: { + accessToken: decodeJWT(mockedAccessToken), + }, + }; + } + ); + forgetDeviceStatusClientSpy = jest + .spyOn(clients, 'forgetDevice') + .mockImplementationOnce(async () => { + return { + $metadata: {}, + }; + }); + getDeviceMetadataSpy = jest + .spyOn(TokenProvider.tokenOrchestrator, 'getDeviceMetadata') + .mockImplementationOnce(async () => mockDeviceMetadata); + clearDeviceMetadataSpy = jest + .spyOn(TokenProvider.tokenOrchestrator, 'clearDeviceMetadata') + .mockImplementationOnce(async () => {}); + }); + + afterEach(() => { + fetchAuthSessionsSpy.mockClear(); + forgetDeviceStatusClientSpy.mockClear(); + getDeviceMetadataSpy.mockClear(); + clearDeviceMetadataSpy.mockClear(); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it(`should forget 'external device' 'with' inputParams when tokenStore deviceMetadata 'present'`, async () => { + expect.assertions(3); + await forgetDevice({ device: { id: 'externalDeviceKey' } }); + expect(forgetDeviceStatusClientSpy).toHaveBeenCalledWith( + expect.objectContaining({ region: 'us-west-2' }), + expect.objectContaining({ + AccessToken: mockedAccessToken, + DeviceKey: 'externalDeviceKey', + }) + ); + expect(forgetDeviceStatusClientSpy).toBeCalledTimes(1); + expect(clearDeviceMetadataSpy).not.toBeCalled(); + }); + + it(`should forget 'current device' 'with' inputParams when tokenStore deviceMetadata 'present'`, async () => { + expect.assertions(3); + await forgetDevice({ device: { id: mockDeviceMetadata.deviceKey } }); + expect(forgetDeviceStatusClientSpy).toHaveBeenCalledWith( + expect.objectContaining({ region: 'us-west-2' }), + expect.objectContaining({ + AccessToken: mockedAccessToken, + DeviceKey: mockDeviceMetadata.deviceKey, + }) + ); + expect(forgetDeviceStatusClientSpy).toBeCalledTimes(1); + expect(clearDeviceMetadataSpy).toBeCalled(); + }); + + it(`should forget 'current device' 'without' inputParams when tokenStore deviceMetadata 'present'`, async () => { + expect.assertions(3); + await forgetDevice(); + expect(forgetDeviceStatusClientSpy).toHaveBeenCalledWith( + expect.objectContaining({ region: 'us-west-2' }), + expect.objectContaining({ + AccessToken: mockedAccessToken, + DeviceKey: mockDeviceMetadata.deviceKey, + }) + ); + expect(forgetDeviceStatusClientSpy).toBeCalledTimes(1); + expect(clearDeviceMetadataSpy).toBeCalled(); + }); + + it(`should forget 'external device' 'with' inputParams when tokenStore deviceMetadata 'not present'`, async () => { + getDeviceMetadataSpy = jest + .spyOn(TokenProvider.tokenOrchestrator, 'getDeviceMetadata') + .mockImplementationOnce(async () => null); + await forgetDevice({ device: { id: 'externalDeviceKey' } }); + expect(forgetDeviceStatusClientSpy).toHaveBeenCalledWith( + expect.objectContaining({ region: 'us-west-2' }), + expect.objectContaining({ + AccessToken: mockedAccessToken, + DeviceKey: 'externalDeviceKey', + }) + ); + expect(forgetDeviceStatusClientSpy).toBeCalledTimes(1); + expect(clearDeviceMetadataSpy).not.toBeCalled(); + }); + + it(`should forget 'current device' 'with' inputParams when tokenStore deviceMetadata 'not present'`, async () => { + getDeviceMetadataSpy = jest + .spyOn(TokenProvider.tokenOrchestrator, 'getDeviceMetadata') + .mockImplementationOnce(async () => null); + expect.assertions(3); + await forgetDevice({ device: { id: mockDeviceMetadata.deviceKey } }); + expect(forgetDeviceStatusClientSpy).toHaveBeenCalledWith( + expect.objectContaining({ region: 'us-west-2' }), + expect.objectContaining({ + AccessToken: mockedAccessToken, + DeviceKey: mockDeviceMetadata.deviceKey, + }) + ); + expect(forgetDeviceStatusClientSpy).toBeCalledTimes(1); + expect(clearDeviceMetadataSpy).not.toBeCalled(); + }); +}); + +describe('forgetDevice API error path cases', () => { + let fetchAuthSessionsSpy; + let getDeviceMetadataSpy; + beforeEach(() => { + fetchAuthSessionsSpy = jest + .spyOn(authUtils, 'fetchAuthSession') + .mockImplementationOnce( + async (): Promise<{ tokens: { accessToken: any } }> => { + return { + tokens: { + accessToken: decodeJWT(mockedAccessToken), + }, + }; + } + ); + getDeviceMetadataSpy = jest + .spyOn(TokenProvider.tokenOrchestrator, 'getDeviceMetadata') + .mockImplementationOnce(async () => null); + }); + afterEach(() => { + fetchAuthSessionsSpy.mockClear(); + getDeviceMetadataSpy.mockClear(); + }); + + it(`should raise deviceMatadata not found exception when forget 'current device' 'without' inputParams when tokenStore deviceMetadata 'not present'`, async () => { + expect.assertions(2); + try { + await forgetDevice(); + } catch (error) { + expect(error).toBeInstanceOf(AuthError); + expect(error.name).toBe(DEVICE_METADATA_NOT_FOUND_EXCEPTION); + } + }); + + it('should raise service error', async () => { + expect.assertions(2); + (fetchTransferHandler as jest.Mock).mockResolvedValue( + mockJsonResponse( + buildMockErrorResponse(ForgetDeviceException.InvalidParameterException) + ) + ); + try { + await forgetDevice({ device: { id: mockDeviceMetadata.deviceKey } }); + } catch (error) { + expect(error).toBeInstanceOf(AuthError); + expect(error.name).toBe(ForgetDeviceException.InvalidParameterException); + } + }); +}); diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 40b0f4833bd..c7c4113d209 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -26,6 +26,7 @@ export { deleteUserAttributes, deleteUser, rememberDevice, + forgetDevice, } from './providers/cognito'; export { @@ -46,6 +47,7 @@ export { VerifyTOTPSetupInput, SendUserAttributeVerificationCodeInput, DeleteUserAttributesInput, + ForgetDeviceInput, } from './providers/cognito'; export { diff --git a/packages/auth/src/providers/cognito/apis/forgetDevice.ts b/packages/auth/src/providers/cognito/apis/forgetDevice.ts new file mode 100644 index 00000000000..5fea13a05bf --- /dev/null +++ b/packages/auth/src/providers/cognito/apis/forgetDevice.ts @@ -0,0 +1,44 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { forgetDevice as serviceForgetDevice } from '../utils/clients/CognitoIdentityProvider'; +import { Amplify } from '@aws-amplify/core'; +import { assertAuthTokens, assertDeviceMetadata } from '../utils/types'; +import { assertTokenProviderConfig } from '@aws-amplify/core/internals/utils'; +import { fetchAuthSession } from '../../../'; +import { getRegion } from '../utils/clients/CognitoIdentityProvider/utils'; +import { tokenOrchestrator } from '../tokenProvider'; +import { ForgetDeviceInput } from '../types'; +import { ForgetDeviceException } from '../../cognito/types/errors'; + +/** + * Forget a remembered device while authenticated. + * + * @param input - The ForgetDeviceInput object. + * @throws - {@link ForgetDeviceException} - Cognito service errors thrown when + * forgetting device with invalid device key + * @throws AuthTokenConfigException - Thrown when the token provider config is invalid. + */ +export async function forgetDevice(input?: ForgetDeviceInput): Promise { + const { device: { id: externalDeviceKey } = { id: undefined } } = input ?? {}; + const authConfig = Amplify.getConfig().Auth?.Cognito; + assertTokenProviderConfig(authConfig); + + const { tokens } = await fetchAuthSession(); + assertAuthTokens(tokens); + + const deviceMetadata = await tokenOrchestrator.getDeviceMetadata(); + const currentDeviceKey = deviceMetadata?.deviceKey; + if (!externalDeviceKey) assertDeviceMetadata(deviceMetadata); + + await serviceForgetDevice( + { region: getRegion(authConfig.userPoolId) }, + { + AccessToken: tokens.accessToken.toString(), + DeviceKey: externalDeviceKey ?? currentDeviceKey, + } + ); + + if (!externalDeviceKey || externalDeviceKey === currentDeviceKey) + await tokenOrchestrator.clearDeviceMetadata(); +} diff --git a/packages/auth/src/providers/cognito/index.ts b/packages/auth/src/providers/cognito/index.ts index 327813c45f2..5149b72a41d 100644 --- a/packages/auth/src/providers/cognito/index.ts +++ b/packages/auth/src/providers/cognito/index.ts @@ -26,6 +26,7 @@ export { sendUserAttributeVerificationCode } from './apis/sendUserAttributeVerif export { deleteUserAttributes } from './apis/deleteUserAttributes'; export { deleteUser } from './apis/deleteUser'; export { rememberDevice } from './apis/rememberDevice'; +export { forgetDevice } from './apis/forgetDevice'; export { ConfirmResetPasswordInput, ConfirmSignInInput, @@ -44,6 +45,7 @@ export { VerifyTOTPSetupInput, SendUserAttributeVerificationCodeInput, DeleteUserAttributesInput, + ForgetDeviceInput, } from './types/inputs'; export { diff --git a/packages/auth/src/providers/cognito/types/index.ts b/packages/auth/src/providers/cognito/types/index.ts index bcc8b121cb1..7e6641b4855 100644 --- a/packages/auth/src/providers/cognito/types/index.ts +++ b/packages/auth/src/providers/cognito/types/index.ts @@ -46,6 +46,7 @@ export { UpdateUserAttributeInput, SendUserAttributeVerificationCodeInput, DeleteUserAttributesInput, + ForgetDeviceInput, } from './inputs'; export { diff --git a/packages/auth/src/providers/cognito/types/inputs.ts b/packages/auth/src/providers/cognito/types/inputs.ts index dece25f39c5..742fd3118e3 100644 --- a/packages/auth/src/providers/cognito/types/inputs.ts +++ b/packages/auth/src/providers/cognito/types/inputs.ts @@ -34,6 +34,7 @@ import { AuthVerifyTOTPSetupInput, AuthSendUserAttributeVerificationCodeInput, AuthDeleteUserAttributesInput, + AuthForgetDeviceInput, } from '../../../types'; /** @@ -158,3 +159,8 @@ export type UpdateUserAttributeInput = AuthUpdateUserAttributeInput< */ export type DeleteUserAttributesInput = AuthDeleteUserAttributesInput; + +/** + * Input type for Cognito forgetDevice API. + */ +export type ForgetDeviceInput = AuthForgetDeviceInput; diff --git a/packages/auth/src/providers/cognito/utils/clients/CognitoIdentityProvider/index.ts b/packages/auth/src/providers/cognito/utils/clients/CognitoIdentityProvider/index.ts index 58f13c51284..f0ec6fc21b1 100644 --- a/packages/auth/src/providers/cognito/utils/clients/CognitoIdentityProvider/index.ts +++ b/packages/auth/src/providers/cognito/utils/clients/CognitoIdentityProvider/index.ts @@ -118,7 +118,7 @@ const buildUserPoolDeserializer = (): (( }; }; -const buildDeleteDeserializer = (): (( +const handleEmptyResponseDeserializer = (): (( response: HttpResponse ) => Promise) => { return async (response: HttpResponse): Promise => { @@ -227,13 +227,13 @@ export const confirmDevice = composeServiceApi( export const forgetDevice = composeServiceApi( cognitoUserPoolTransferHandler, buildUserPoolSerializer('ForgetDevice'), - buildUserPoolDeserializer(), + handleEmptyResponseDeserializer(), defaultConfig ); export const deleteUser = composeServiceApi( cognitoUserPoolTransferHandler, buildUserPoolSerializer('DeleteUser'), - buildDeleteDeserializer(), + handleEmptyResponseDeserializer(), defaultConfig ); export const getUserAttributeVerificationCode = composeServiceApi( diff --git a/packages/auth/src/types/index.ts b/packages/auth/src/types/index.ts index 8a489b0bf63..cbc41626c13 100644 --- a/packages/auth/src/types/index.ts +++ b/packages/auth/src/types/index.ts @@ -43,6 +43,7 @@ export { AuthSignOutInput, AuthSendUserAttributeVerificationCodeInput, AuthDeleteUserAttributesInput, + AuthForgetDeviceInput, } from './inputs'; export { diff --git a/packages/auth/src/types/inputs.ts b/packages/auth/src/types/inputs.ts index 796891e1827..d29e8e4e6e9 100644 --- a/packages/auth/src/types/inputs.ts +++ b/packages/auth/src/types/inputs.ts @@ -5,6 +5,7 @@ import { AuthUserAttributes, AuthUserAttribute, AuthUserAttributeKey, + AuthDevice, } from './models'; import { AuthServiceOptions, AuthSignUpOptions } from './options'; @@ -189,3 +190,12 @@ export type AuthSendUserAttributeVerificationCodeInput< export type AuthDeleteUserAttributesInput< UserAttributeKey extends AuthUserAttributeKey = AuthUserAttributeKey > = { userAttributeKeys: [UserAttributeKey, ...UserAttributeKey[]] }; + +/** + * Constructs a `forgetDevice` input. + * + * @param device - optional parameter to forget an external device + */ +export type AuthForgetDeviceInput = { + device?: AuthDevice; +}; diff --git a/packages/auth/src/types/models.ts b/packages/auth/src/types/models.ts index e5bbd9e2e95..ab092909756 100644 --- a/packages/auth/src/types/models.ts +++ b/packages/auth/src/types/models.ts @@ -275,3 +275,11 @@ export type AuthUser = { username: string; userId: string; }; + +/** + * The AuthDevice object contains id and name of the device. + */ +export type AuthDevice = { + id: string; + name?: string; +}; diff --git a/packages/aws-amplify/__tests__/exports.test.ts b/packages/aws-amplify/__tests__/exports.test.ts index e345ca0fa54..02342b5d643 100644 --- a/packages/aws-amplify/__tests__/exports.test.ts +++ b/packages/aws-amplify/__tests__/exports.test.ts @@ -87,6 +87,7 @@ describe('aws-amplify Exports', () => { "deleteUserAttributes", "deleteUser", "rememberDevice", + "forgetDevice", "AuthError", "fetchAuthSession", ] @@ -119,6 +120,7 @@ describe('aws-amplify Exports', () => { "deleteUserAttributes", "deleteUser", "rememberDevice", + "forgetDevice", "cognitoCredentialsProvider", "CognitoAWSCredentialsAndIdentityIdProvider", "DefaultIdentityIdStore", From c4c4db8f88f6f9c68b8f54b5197cad1957949e98 Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Date: Wed, 4 Oct 2023 10:09:54 -0700 Subject: [PATCH 07/20] feat(auth): add fetchDevices api (#12171) * wip: initial commit for device tracking apis * feat: listDevices API * wip: updating reviewing comments * fix: use util functions and update API return type * fix: update comments and type doc * code cleanup * fix: fetchDevices api * fix: update unit test * Update packages/auth/src/providers/cognito/apis/fetchDevices.ts Co-authored-by: Hui Zhao <10602282+HuiSF@users.noreply.github.com> * address feedback * fix: add api exports test * Update packages/auth/src/providers/cognito/apis/fetchDevices.ts Co-authored-by: Jim Blanchard * Update packages/auth/src/providers/cognito/types/models.ts Co-authored-by: Jim Blanchard * address feedback --------- Co-authored-by: ManojNB Co-authored-by: israx <70438514+israx@users.noreply.github.com> Co-authored-by: Ashwin Kumar Co-authored-by: Hui Zhao <10602282+HuiSF@users.noreply.github.com> Co-authored-by: Jim Blanchard --- .../providers/cognito/fetchDevices.test.ts | 139 ++++++++++++++++++ packages/auth/src/index.ts | 2 + .../providers/cognito/apis/fetchDevices.ts | 82 +++++++++++ packages/auth/src/providers/cognito/index.ts | 2 + .../auth/src/providers/cognito/types/index.ts | 2 + .../src/providers/cognito/types/models.ts | 12 ++ .../src/providers/cognito/types/outputs.ts | 7 +- .../clients/CognitoIdentityProvider/types.ts | 6 +- packages/auth/src/types/index.ts | 1 + .../aws-amplify/__tests__/exports.test.ts | 2 + 10 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 packages/auth/__tests__/providers/cognito/fetchDevices.test.ts create mode 100644 packages/auth/src/providers/cognito/apis/fetchDevices.ts diff --git a/packages/auth/__tests__/providers/cognito/fetchDevices.test.ts b/packages/auth/__tests__/providers/cognito/fetchDevices.test.ts new file mode 100644 index 00000000000..cbaf2cc8b2a --- /dev/null +++ b/packages/auth/__tests__/providers/cognito/fetchDevices.test.ts @@ -0,0 +1,139 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AuthError } from '../../../src/errors/AuthError'; +import { fetchDevices } from '../../../src/providers/cognito'; +import * as clients from '../../../src/providers/cognito/utils/clients/CognitoIdentityProvider'; +import { Amplify } from '@aws-amplify/core'; +import { decodeJWT } from '@aws-amplify/core/internals/utils'; +import * as authUtils from '../../../src'; +import { fetchTransferHandler } from '@aws-amplify/core/internals/aws-client-utils'; +import { buildMockErrorResponse, mockJsonResponse } from './testUtils/data'; +import { ListDevicesException } from '../../../src/providers/cognito/types/errors'; +jest.mock('@aws-amplify/core/lib/clients/handlers/fetch'); + +Amplify.configure({ + Auth: { + Cognito: { + userPoolClientId: '111111-aaaaa-42d8-891d-ee81a1549398', + userPoolId: 'us-west-2_zzzzz', + identityPoolId: 'us-west-2:xxxxxx', + }, + }, +}); +const mockedAccessToken = + 'test_eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'; +const dateEpoch = 1.696296885807e9; +const date = new Date(dateEpoch * 1000); +const clientResponseDevice = { + DeviceAttributes: [{ Name: 'attributeName', Value: 'attributeValue' }], + DeviceCreateDate: dateEpoch, + DeviceKey: 'DeviceKey', + DeviceLastAuthenticatedDate: dateEpoch, + DeviceLastModifiedDate: dateEpoch, +}; +const apiOutputDevice = { + id: 'DeviceKey', + name: undefined, + attributes: { + attributeName: 'attributeValue', + }, + createDate: date, + lastModifiedDate: date, + lastAuthenticatedDate: date, +}; + +describe('fetchDevices API happy path cases', () => { + let fetchAuthSessionsSpy; + beforeEach(() => { + fetchAuthSessionsSpy = jest + .spyOn(authUtils, 'fetchAuthSession') + .mockImplementationOnce( + async (): Promise<{ tokens: { accessToken: any } }> => { + return { + tokens: { + accessToken: decodeJWT(mockedAccessToken), + }, + }; + } + ); + }); + afterEach(() => { + fetchAuthSessionsSpy.mockClear(); + }); + + it('should fetch devices and parse client response correctly with and without device name', async () => { + const deviceName = { + Name: 'device_name', + Value: 'test-device-name', + }; + + const fetchDevicesClientSpy = jest + .spyOn(clients, 'listDevices') + .mockImplementationOnce(async () => { + return { + Devices: [ + { + ...clientResponseDevice, + DeviceKey: 'DeviceKey1', + DeviceAttributes: [ + ...clientResponseDevice.DeviceAttributes, + deviceName, + ], + }, + { ...clientResponseDevice, DeviceKey: 'DeviceKey2' }, + ], + $metadata: {}, + }; + }); + + expect(await fetchDevices()).toEqual([ + { + ...apiOutputDevice, + id: 'DeviceKey1', + name: deviceName.Value, + attributes: { + ...apiOutputDevice.attributes, + [deviceName.Name]: deviceName.Value, + }, + }, + { ...apiOutputDevice, id: 'DeviceKey2' }, + ]); + expect(fetchDevicesClientSpy).toHaveBeenCalledWith( + expect.objectContaining({ region: 'us-west-2' }), + expect.objectContaining({ + AccessToken: mockedAccessToken, + Limit: 60, + }) + ); + expect(fetchDevicesClientSpy).toBeCalledTimes(1); + }); +}); + +describe('fetchDevices API error path cases', () => { + it('should raise service error', async () => { + expect.assertions(2); + jest + .spyOn(authUtils, 'fetchAuthSession') + .mockImplementationOnce( + async (): Promise<{ tokens: { accessToken: any } }> => { + return { + tokens: { + accessToken: decodeJWT(mockedAccessToken), + }, + }; + } + ); + (fetchTransferHandler as jest.Mock).mockResolvedValue( + mockJsonResponse( + buildMockErrorResponse(ListDevicesException.InvalidParameterException) + ) + ); + try { + await fetchDevices(); + } catch (error) { + expect(error).toBeInstanceOf(AuthError); + expect(error.name).toBe(ListDevicesException.InvalidParameterException); + } + }); +}); diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index c7c4113d209..0517c8f5356 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -27,6 +27,7 @@ export { deleteUser, rememberDevice, forgetDevice, + fetchDevices, } from './providers/cognito'; export { @@ -65,6 +66,7 @@ export { UpdateUserAttributesOutput, SendUserAttributeVerificationCodeOutput, UpdateUserAttributeOutput, + FetchDevicesOutput, } from './providers/cognito'; export { AuthError } from './errors/AuthError'; diff --git a/packages/auth/src/providers/cognito/apis/fetchDevices.ts b/packages/auth/src/providers/cognito/apis/fetchDevices.ts new file mode 100644 index 00000000000..4c44a6bf652 --- /dev/null +++ b/packages/auth/src/providers/cognito/apis/fetchDevices.ts @@ -0,0 +1,82 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify } from '@aws-amplify/core'; +import { assertTokenProviderConfig } from '@aws-amplify/core/internals/utils'; +import { fetchAuthSession } from '../../../'; +import { FetchDevicesOutput } from '../types'; +import { listDevices } from '../utils/clients/CognitoIdentityProvider'; +import { DeviceType } from '../utils/clients/CognitoIdentityProvider/types'; +import { assertAuthTokens } from '../utils/types'; +import { getRegion } from '../utils/clients/CognitoIdentityProvider/utils'; +import { rememberDevice } from '..'; +import { ListDevicesException } from '../types/errors'; + +// Cognito Documentation for max device +// https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_ListDevices.html#API_ListDevices_RequestSyntax +const MAX_DEVICES = 60; + +/** + * Fetches devices that have been remembered using {@link rememberDevice} + * for the currently authenticated user. + * + * @returns FetchDevicesOutput + * @throws {@link ListDevicesException} + * @throws AuthTokenConfigException - Thrown when the token provider config is invalid. + */ +export async function fetchDevices(): Promise { + const authConfig = Amplify.getConfig().Auth?.Cognito; + assertTokenProviderConfig(authConfig); + + const { tokens } = await fetchAuthSession(); + assertAuthTokens(tokens); + + const response = await listDevices( + { region: getRegion(authConfig.userPoolId) }, + { + AccessToken: tokens.accessToken.toString(), + Limit: MAX_DEVICES, + } + ); + return parseDevicesResponse(response.Devices ?? []); +} + +const parseDevicesResponse = async ( + devices: DeviceType[] +): Promise => { + return devices.map( + ({ + DeviceKey: id = '', + DeviceAttributes = [], + DeviceCreateDate, + DeviceLastModifiedDate, + DeviceLastAuthenticatedDate, + }) => { + let name: string | undefined; + const attributes = DeviceAttributes.reduce( + (attrs: any, { Name, Value }) => { + if (Name && Value) { + if (Name === 'device_name') name = Value; + attrs[Name] = Value; + } + return attrs; + }, + {} + ); + return { + id, + name, + attributes, + createDate: DeviceCreateDate + ? new Date(DeviceCreateDate * 1000) + : undefined, + lastModifiedDate: DeviceLastModifiedDate + ? new Date(DeviceLastModifiedDate * 1000) + : undefined, + lastAuthenticatedDate: DeviceLastAuthenticatedDate + ? new Date(DeviceLastAuthenticatedDate * 1000) + : undefined, + }; + } + ); +}; diff --git a/packages/auth/src/providers/cognito/index.ts b/packages/auth/src/providers/cognito/index.ts index 5149b72a41d..82dda42a806 100644 --- a/packages/auth/src/providers/cognito/index.ts +++ b/packages/auth/src/providers/cognito/index.ts @@ -27,6 +27,7 @@ export { deleteUserAttributes } from './apis/deleteUserAttributes'; export { deleteUser } from './apis/deleteUser'; export { rememberDevice } from './apis/rememberDevice'; export { forgetDevice } from './apis/forgetDevice'; +export { fetchDevices } from './apis/fetchDevices'; export { ConfirmResetPasswordInput, ConfirmSignInInput, @@ -63,6 +64,7 @@ export { UpdateUserAttributesOutput, UpdateUserAttributeOutput, SendUserAttributeVerificationCodeOutput, + FetchDevicesOutput, } from './types/outputs'; export { cognitoCredentialsProvider, diff --git a/packages/auth/src/providers/cognito/types/index.ts b/packages/auth/src/providers/cognito/types/index.ts index 7e6641b4855..e7d03aa9c8f 100644 --- a/packages/auth/src/providers/cognito/types/index.ts +++ b/packages/auth/src/providers/cognito/types/index.ts @@ -8,6 +8,7 @@ export { UserAttributeKey, VerifiableUserAttributeKey, MFAPreference, + AWSAuthDevice, } from './models'; export { @@ -68,4 +69,5 @@ export { UpdateUserAttributesOutput, UpdateUserAttributeOutput, SendUserAttributeVerificationCodeOutput, + FetchDevicesOutput, } from './outputs'; diff --git a/packages/auth/src/providers/cognito/types/models.ts b/packages/auth/src/providers/cognito/types/models.ts index f1342ee20f7..16fd2eb6d5f 100644 --- a/packages/auth/src/providers/cognito/types/models.ts +++ b/packages/auth/src/providers/cognito/types/models.ts @@ -4,6 +4,8 @@ import { AuthStandardAttributeKey, AuthVerifiableAttributeKey, + AuthUserAttribute, + AuthDevice, } from '../../../types'; import { AuthProvider } from '../../../types/inputs'; @@ -60,3 +62,13 @@ export type MFAPreference = | 'DISABLED' | 'PREFERRED' | 'NOT_PREFERRED'; + +/** + * Holds the device specific information along with it's id and name. + */ +export type AWSAuthDevice = AuthDevice & { + attributes: AuthUserAttribute; + createDate?: Date; + lastAuthenticatedDate?: Date; + lastModifiedDate?: Date; +}; diff --git a/packages/auth/src/providers/cognito/types/outputs.ts b/packages/auth/src/providers/cognito/types/outputs.ts index e36d8476dc3..8f975d9effa 100644 --- a/packages/auth/src/providers/cognito/types/outputs.ts +++ b/packages/auth/src/providers/cognito/types/outputs.ts @@ -15,7 +15,7 @@ import { AuthUpdateUserAttributesOutput, AuthUpdateUserAttributeOutput, } from '../../../types'; -import { UserAttributeKey, CustomAttribute } from '../types'; +import { AWSAuthDevice, UserAttributeKey, CustomAttribute } from '../types'; export type FetchMFAPreferenceOutput = { enabled?: AuthMFAType[]; @@ -115,3 +115,8 @@ export type SendUserAttributeVerificationCodeOutput = */ export type UpdateUserAttributeOutput = AuthUpdateUserAttributeOutput; + +/** + * Output type for Cognito fetchDevices API. + */ +export type FetchDevicesOutput = AWSAuthDevice[]; diff --git a/packages/auth/src/providers/cognito/utils/clients/CognitoIdentityProvider/types.ts b/packages/auth/src/providers/cognito/utils/clients/CognitoIdentityProvider/types.ts index 28f7b1820f4..866ec6321e5 100644 --- a/packages/auth/src/providers/cognito/utils/clients/CognitoIdentityProvider/types.ts +++ b/packages/auth/src/providers/cognito/utils/clients/CognitoIdentityProvider/types.ts @@ -745,15 +745,15 @@ export interface DeviceType { /** *

The creation date of the device.

*/ - DeviceCreateDate?: Date; + DeviceCreateDate?: number; /** *

The last modified date of the device.

*/ - DeviceLastModifiedDate?: Date; + DeviceLastModifiedDate?: number; /** *

The date when the device was last authenticated.

*/ - DeviceLastAuthenticatedDate?: Date; + DeviceLastAuthenticatedDate?: number; } export interface ForgetDeviceCommandInput extends ForgetDeviceRequest {} export interface ForgetDeviceCommandOutput extends __MetadataBearer {} diff --git a/packages/auth/src/types/index.ts b/packages/auth/src/types/index.ts index cbc41626c13..cc42c94ef15 100644 --- a/packages/auth/src/types/index.ts +++ b/packages/auth/src/types/index.ts @@ -22,6 +22,7 @@ export { AuthResetPasswordStep, AuthSignUpStep, AuthUpdateAttributeStep, + AuthDevice, } from './models'; export { AuthServiceOptions, AuthSignUpOptions } from './options'; diff --git a/packages/aws-amplify/__tests__/exports.test.ts b/packages/aws-amplify/__tests__/exports.test.ts index 02342b5d643..9b4eea0e7a5 100644 --- a/packages/aws-amplify/__tests__/exports.test.ts +++ b/packages/aws-amplify/__tests__/exports.test.ts @@ -88,6 +88,7 @@ describe('aws-amplify Exports', () => { "deleteUser", "rememberDevice", "forgetDevice", + "fetchDevices", "AuthError", "fetchAuthSession", ] @@ -121,6 +122,7 @@ describe('aws-amplify Exports', () => { "deleteUser", "rememberDevice", "forgetDevice", + "fetchDevices", "cognitoCredentialsProvider", "CognitoAWSCredentialsAndIdentityIdProvider", "DefaultIdentityIdStore", From b83333f508c154badc4832aeaa8c5911e6716fba Mon Sep 17 00:00:00 2001 From: ManojNB Date: Wed, 4 Oct 2023 10:35:24 -0700 Subject: [PATCH 08/20] feat(InApp): functional identifyUser API (#12159) * feat: functional identifyUser * chore: address reviews, add exports tests * fix: serviceOptions were included and verified * fix: add necessary types for mock data --------- Co-authored-by: Jim Blanchard --- .../providers/pinpoint/apis/identifyUser.ts | 6 +- .../aws-amplify/__tests__/exports.test.ts | 23 ++ packages/core/src/Platform/types.ts | 5 +- packages/notifications/__mocks__/data.ts | 2 +- .../pinpoint/apis/identifyUser.test.ts | 92 +++++- .../pinpoint/apis/syncMessages.test.ts | 1 - .../common/AWSPinpointProviderCommon/index.ts | 6 +- .../common/AWSPinpointProviderCommon/types.ts | 4 +- .../notifications/src/inAppMessaging/index.ts | 13 - .../providers/pinpoint/apis/identifyUser.ts | 97 +++++- .../providers/pinpoint/apis/syncMessages.ts | 8 +- .../providers/pinpoint/types/errors.ts | 12 + .../providers/pinpoint/types/index.ts | 6 + .../providers/pinpoint/types/inputs.ts | 11 + .../providers/pinpoint/types/options.ts | 9 + .../providers/pinpoint/utils/utils.ts | 304 ------------------ .../notifications/src/inAppMessaging/types.ts | 142 -------- .../src/inAppMessaging/types/config.ts | 8 + .../src/inAppMessaging/types/event.ts | 14 + .../src/inAppMessaging/types/index.ts | 8 + .../src/inAppMessaging/types/inputs.ts | 29 ++ .../src/inAppMessaging/types/message.ts | 62 ++++ .../src/inAppMessaging/types/options.ts | 7 + packages/notifications/src/index.ts | 15 +- .../providers/AWSPinpointProvider/index.ts | 33 +- .../src/pushNotifications/types.ts | 17 +- 26 files changed, 405 insertions(+), 529 deletions(-) create mode 100644 packages/notifications/src/inAppMessaging/providers/pinpoint/types/errors.ts create mode 100644 packages/notifications/src/inAppMessaging/providers/pinpoint/types/index.ts create mode 100644 packages/notifications/src/inAppMessaging/providers/pinpoint/types/inputs.ts create mode 100644 packages/notifications/src/inAppMessaging/providers/pinpoint/types/options.ts delete mode 100644 packages/notifications/src/inAppMessaging/providers/pinpoint/utils/utils.ts delete mode 100644 packages/notifications/src/inAppMessaging/types.ts create mode 100644 packages/notifications/src/inAppMessaging/types/config.ts create mode 100644 packages/notifications/src/inAppMessaging/types/event.ts create mode 100644 packages/notifications/src/inAppMessaging/types/index.ts create mode 100644 packages/notifications/src/inAppMessaging/types/inputs.ts create mode 100644 packages/notifications/src/inAppMessaging/types/message.ts create mode 100644 packages/notifications/src/inAppMessaging/types/options.ts diff --git a/packages/analytics/src/providers/pinpoint/apis/identifyUser.ts b/packages/analytics/src/providers/pinpoint/apis/identifyUser.ts index b8b5a2bc0d9..72443820c37 100644 --- a/packages/analytics/src/providers/pinpoint/apis/identifyUser.ts +++ b/packages/analytics/src/providers/pinpoint/apis/identifyUser.ts @@ -28,7 +28,7 @@ import { resolveConfig, resolveCredentials } from '../utils'; * await identifyUser({ * userId, * userProfile: { - * email: [userEmail] + * email: 'userEmail@example.com' * customProperties: { * phoneNumber: ['555-555-5555'], * }, @@ -42,7 +42,7 @@ import { resolveConfig, resolveCredentials } from '../utils'; * await identifyUser({ * userId, * userProfile: { - * email: [userEmail] + * email: 'userEmail@example.com' * customProperties: { * phoneNumber: ['555-555-5555'], * }, @@ -70,6 +70,6 @@ export const identifyUser = async ({ userAttributes, userId, userProfile, - userAgentValue: getAnalyticsUserAgentString(AnalyticsAction.UpdateEndpoint), + userAgentValue: getAnalyticsUserAgentString(AnalyticsAction.IdentifyUser), }); }; diff --git a/packages/aws-amplify/__tests__/exports.test.ts b/packages/aws-amplify/__tests__/exports.test.ts index 9b4eea0e7a5..13c0c21b390 100644 --- a/packages/aws-amplify/__tests__/exports.test.ts +++ b/packages/aws-amplify/__tests__/exports.test.ts @@ -7,6 +7,8 @@ import * as authTopLevelExports from '../src/auth'; import * as authCognitoExports from '../src/auth/cognito'; import * as analyticsTopLevelExports from '../src/analytics'; import * as analyticsPinpointExports from '../src/analytics/pinpoint'; +import * as inAppMessagingTopLevelExports from '../src/in-app-messaging'; +import * as inAppMessagingPinpointTopLevelExports from '../src/in-app-messaging/pinpoint'; import * as storageTopLevelExports from '../src/storage'; import * as storageS3Exports from '../src/storage/s3'; @@ -60,6 +62,27 @@ describe('aws-amplify Exports', () => { }); }); + describe('InAppMessaging exports', () => { + it('should only export expected symbols from the top-level', () => { + expect(Object.keys(inAppMessagingTopLevelExports)).toMatchInlineSnapshot(` + Array [ + "identifyUser", + "syncMessages", + ] + `); + }); + + it('should only export expected symbols from the Pinpoint provider', () => { + expect(Object.keys(inAppMessagingPinpointTopLevelExports)) + .toMatchInlineSnapshot(` + Array [ + "identifyUser", + "syncMessages", + ] + `); + }); + }); + describe('Auth exports', () => { it('should only export expected symbols from the top-level', () => { expect(Object.keys(authTopLevelExports)).toMatchInlineSnapshot(` diff --git a/packages/core/src/Platform/types.ts b/packages/core/src/Platform/types.ts index 86929507b36..7e594981dbe 100644 --- a/packages/core/src/Platform/types.ts +++ b/packages/core/src/Platform/types.ts @@ -41,7 +41,7 @@ export enum Category { export enum AnalyticsAction { Record = '1', - UpdateEndpoint = '2', + IdentifyUser = '2', } export enum ApiAction { GraphQl = '1', @@ -96,7 +96,8 @@ export enum GeoAction { None = '0', } export enum InAppMessagingAction { - None = '0', + SyncMessages = '1', + IdentifyUser = '2', } export enum InteractionsAction { None = '0', diff --git a/packages/notifications/__mocks__/data.ts b/packages/notifications/__mocks__/data.ts index ff618a5c738..a4afbef9c62 100644 --- a/packages/notifications/__mocks__/data.ts +++ b/packages/notifications/__mocks__/data.ts @@ -5,7 +5,7 @@ import type { Event, InAppMessageCampaign as PinpointInAppMessage, } from '@aws-amplify/core/internals/aws-clients/pinpoint'; -import { InAppMessage, InAppMessagingEvent } from '../src/inAppMessaging'; +import { InAppMessage, InAppMessagingEvent } from '../src/inAppMessaging/types'; import { PushNotificationMessage } from '../src/pushNotifications'; import { UserInfo } from '../src'; import { NotificationsConfig } from '../src'; diff --git a/packages/notifications/__tests__/inAppMessaging/providers/pinpoint/apis/identifyUser.test.ts b/packages/notifications/__tests__/inAppMessaging/providers/pinpoint/apis/identifyUser.test.ts index b73192733f3..566792db9ca 100644 --- a/packages/notifications/__tests__/inAppMessaging/providers/pinpoint/apis/identifyUser.test.ts +++ b/packages/notifications/__tests__/inAppMessaging/providers/pinpoint/apis/identifyUser.test.ts @@ -1,6 +1,94 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -describe('Pinpoint Provider API: identifyUser', () => { - it('WIP: add tests', async () => {}); +import { identifyUser } from '../../../../../src/inAppMessaging/providers/pinpoint/apis'; +import { + resolveCredentials, + resolveConfig, + getInAppMessagingUserAgentString, + CATEGORY, + CHANNEL_TYPE, +} from '../../../../../src/inAppMessaging/providers/pinpoint/utils'; +import { updateEndpoint } from '@aws-amplify/core/internals/providers/pinpoint'; + +import { IdentifyUserInput } from '../../../../../src/inAppMessaging/providers/pinpoint/types'; + +jest.mock('@aws-amplify/core/internals/providers/pinpoint'); +jest.mock('../../../../../src/inAppMessaging/providers/pinpoint/utils'); + +describe('InAppMessaging Pinpoint Provider API: identifyUser', () => { + const credentials = { + credentials: { + accessKeyId: 'access-key-id', + secretAccessKey: 'secret-access-key', + }, + identityId: 'identity-id', + }; + const config = { appId: 'app-id', region: 'region' }; + const userAgentValue = 'user-agent-value'; + // assert mocks + const mockUpdateEndpoint = updateEndpoint as jest.Mock; + const mockgetInAppMessagingUserAgentString = + getInAppMessagingUserAgentString as jest.Mock; + const mockResolveConfig = resolveConfig as jest.Mock; + const mockResolveCredentials = resolveCredentials as jest.Mock; + + beforeAll(() => { + mockgetInAppMessagingUserAgentString.mockReturnValue(userAgentValue); + mockResolveConfig.mockReturnValue(config); + mockResolveCredentials.mockResolvedValue(credentials); + }); + + beforeEach(() => { + mockUpdateEndpoint.mockClear(); + }); + + it('passes through parameters to core Pinpoint updateEndpoint API', async () => { + const input: IdentifyUserInput = { + userId: 'user-id', + userProfile: { + customProperties: { + hobbies: ['biking', 'climbing'], + }, + email: 'email', + name: 'name', + plan: 'plan', + }, + }; + await identifyUser(input); + expect(mockUpdateEndpoint).toBeCalledWith({ + ...input, + ...credentials, + ...config, + channelType: CHANNEL_TYPE, + category: CATEGORY, + userAgentValue, + }); + }); + + it('passes through service options along with input and other params to core Pinpoint updateEndpoint API', async () => { + const userAttributes = { hobbies: ['biking', 'climbing'] }; + const input: IdentifyUserInput = { + userId: 'user-id', + userProfile: {}, + }; + const options: IdentifyUserInput['options'] = { + serviceOptions: { + address: 'test-address', + optOut: 'NONE', + userAttributes, + }, + }; + await identifyUser({ ...input, options }); + expect(mockUpdateEndpoint).toBeCalledWith({ + ...input, + ...options.serviceOptions, + ...credentials, + ...config, + channelType: CHANNEL_TYPE, + category: CATEGORY, + userAgentValue, + userAttributes, + }); + }); }); diff --git a/packages/notifications/__tests__/inAppMessaging/providers/pinpoint/apis/syncMessages.test.ts b/packages/notifications/__tests__/inAppMessaging/providers/pinpoint/apis/syncMessages.test.ts index c57139e169b..c3b058de5a3 100644 --- a/packages/notifications/__tests__/inAppMessaging/providers/pinpoint/apis/syncMessages.test.ts +++ b/packages/notifications/__tests__/inAppMessaging/providers/pinpoint/apis/syncMessages.test.ts @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import { defaultStorage } from '@aws-amplify/core'; -import { ConsoleLogger as Logger } from '@aws-amplify/core/internals/utils'; import { syncMessages } from '../../../../../src/inAppMessaging/providers/pinpoint/apis'; import { STORAGE_KEY_SUFFIX, diff --git a/packages/notifications/src/common/AWSPinpointProviderCommon/index.ts b/packages/notifications/src/common/AWSPinpointProviderCommon/index.ts index 398c3f8f7e7..5bcb0e74918 100644 --- a/packages/notifications/src/common/AWSPinpointProviderCommon/index.ts +++ b/packages/notifications/src/common/AWSPinpointProviderCommon/index.ts @@ -27,7 +27,7 @@ import { NotificationsProvider, UserInfo, } from '../../types'; -import { AWSPinpointUserInfo } from './types'; +import { PinpointUserInfo } from './types'; export default abstract class AWSPinpointProviderCommon implements NotificationsProvider @@ -115,7 +115,7 @@ export default abstract class AWSPinpointProviderCommon } else { customUserAgentDetails = { category: Category.InAppMessaging, - action: InAppMessagingAction.None, + action: InAppMessagingAction.IdentifyUser, }; } @@ -159,7 +159,7 @@ export default abstract class AWSPinpointProviderCommon protected updateEndpoint = async ( userId: string = null, - userInfo: AWSPinpointUserInfo = null + userInfo: PinpointUserInfo = null ): Promise => { const credentials = await this.getCredentials(); // Shallow compare to determine if credentials stored here are outdated diff --git a/packages/notifications/src/common/AWSPinpointProviderCommon/types.ts b/packages/notifications/src/common/AWSPinpointProviderCommon/types.ts index 36041ad5745..a18cd17ff29 100644 --- a/packages/notifications/src/common/AWSPinpointProviderCommon/types.ts +++ b/packages/notifications/src/common/AWSPinpointProviderCommon/types.ts @@ -3,12 +3,12 @@ import { UserInfo } from '../../types'; -export interface AWSPinpointProviderConfig { +export interface PinpointProviderConfig { appId: string; region: string; } -export interface AWSPinpointUserInfo extends UserInfo { +export interface PinpointUserInfo extends UserInfo { address?: string; optOut?: 'ALL' | 'NONE'; } diff --git a/packages/notifications/src/inAppMessaging/index.ts b/packages/notifications/src/inAppMessaging/index.ts index 65b835aeb4a..9152b0d2973 100644 --- a/packages/notifications/src/inAppMessaging/index.ts +++ b/packages/notifications/src/inAppMessaging/index.ts @@ -2,16 +2,3 @@ // SPDX-License-Identifier: Apache-2.0 export { identifyUser, syncMessages } from './providers/pinpoint'; -export { - InAppMessage, - InAppMessageAction, - InAppMessageButton, - InAppMessageContent, - InAppMessageImage, - InAppMessageInteractionEvent, - InAppMessageLayout, - InAppMessageStyle, - InAppMessageTextAlign, - InAppMessagingConfig, - InAppMessagingEvent, -} from './types'; diff --git a/packages/notifications/src/inAppMessaging/providers/pinpoint/apis/identifyUser.ts b/packages/notifications/src/inAppMessaging/providers/pinpoint/apis/identifyUser.ts index ac47bfefc23..a7208ba43a4 100644 --- a/packages/notifications/src/inAppMessaging/providers/pinpoint/apis/identifyUser.ts +++ b/packages/notifications/src/inAppMessaging/providers/pinpoint/apis/identifyUser.ts @@ -1,11 +1,94 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { UserInfo } from '../../../../types'; +import { InAppMessagingAction } from '@aws-amplify/core/internals/utils'; +import { updateEndpoint } from '@aws-amplify/core/internals/providers/pinpoint'; +import { InAppMessagingValidationErrorCode } from '../../../errors'; +import { + CATEGORY, + CHANNEL_TYPE, + getInAppMessagingUserAgentString, + resolveConfig, + resolveCredentials, +} from '../utils'; +import { IdentifyUserInput } from '../types'; -export function identifyUser( - userId: string, - userInfo: UserInfo -): Promise { - throw new Error('WIP'); -} +/** + * Sends information about a user to Pinpoint. Sending user information allows you to associate a user to their user + * profile and activities or actions in your application. Activity can be tracked across devices & platforms by using + * the same `userId`. + * + * @param {IdentifyUserParameters} params The input object used to construct requests sent to Pinpoint's UpdateEndpoint + * API. + * + * @throws service: {@link UpdateEndpointException} - Thrown when the underlying Pinpoint service returns an error. + * @throws validation: {@link InAppMessagingValidationErrorCode} - Thrown when the provided parameters or library + * configuration is incorrect. + * + * @returns A promise that will resolve when the operation is complete. + * + * @example + * ```ts + * // Identify a user with Pinpoint + * await identifyUser({ + * userId, + * userProfile: { + * email: 'userEmail@example.com' + * customProperties: { + * phoneNumber: ['555-555-5555'], + * }, + * } + * }); + * ``` + * + * @example + * ```ts + * // Identify a user with Pinpoint specific options + * await identifyUser({ + * userId, + * userProfile: { + * email: 'userEmail@example.com' + * customProperties: { + * phoneNumber: ['555-555-5555'], + * }, + * demographic: { + * platform: 'ios', + * timezone: 'America/Los_Angeles' + * } + * }, + * options: { + * serviceOptions: { + * address: 'device-address', + * optOut: 'NONE', + * userAttributes: { + * interests: ['food'] + * }, + * }, + * }, + * }); + */ +export const identifyUser = async ({ + userId, + userProfile, + options, +}: IdentifyUserInput): Promise => { + const { credentials, identityId } = await resolveCredentials(); + const { appId, region } = resolveConfig(); + const { address, optOut, userAttributes } = options?.serviceOptions ?? {}; + updateEndpoint({ + address, + channelType: CHANNEL_TYPE, + optOut, + appId, + category: CATEGORY, + credentials, + identityId, + region, + userAttributes, + userId, + userProfile, + userAgentValue: getInAppMessagingUserAgentString( + InAppMessagingAction.IdentifyUser + ), + }); +}; diff --git a/packages/notifications/src/inAppMessaging/providers/pinpoint/apis/syncMessages.ts b/packages/notifications/src/inAppMessaging/providers/pinpoint/apis/syncMessages.ts index c8d62b3a637..4b72868d97e 100644 --- a/packages/notifications/src/inAppMessaging/providers/pinpoint/apis/syncMessages.ts +++ b/packages/notifications/src/inAppMessaging/providers/pinpoint/apis/syncMessages.ts @@ -1,10 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { - InAppMessagingAction, - ConsoleLogger as Logger, -} from '@aws-amplify/core/internals/utils'; +import { InAppMessagingAction } from '@aws-amplify/core/internals/utils'; import { updateEndpoint, getEndpointId, @@ -77,9 +74,8 @@ async function fetchInAppMessages() { credentials, identityId, region, - // TODO(V6): Update InAppMessagingAction.None userAgentValue: getInAppMessagingUserAgentString( - InAppMessagingAction.None + InAppMessagingAction.SyncMessages ), }); diff --git a/packages/notifications/src/inAppMessaging/providers/pinpoint/types/errors.ts b/packages/notifications/src/inAppMessaging/providers/pinpoint/types/errors.ts new file mode 100644 index 00000000000..2dba0b81b9e --- /dev/null +++ b/packages/notifications/src/inAppMessaging/providers/pinpoint/types/errors.ts @@ -0,0 +1,12 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export enum UpdateEndpointException { + BadRequestException = 'BadRequestException', + ForbiddenException = 'ForbiddenException', + InternalServerErrorException = 'InternalServerErrorException', + MethodNotAllowedException = 'MethodNotAllowedException', + NotFoundException = 'NotFoundException', + PayloadTooLargeException = 'PayloadTooLargeException', + TooManyRequestsException = 'TooManyRequestsException', +} diff --git a/packages/notifications/src/inAppMessaging/providers/pinpoint/types/index.ts b/packages/notifications/src/inAppMessaging/providers/pinpoint/types/index.ts new file mode 100644 index 00000000000..fc8965c66b8 --- /dev/null +++ b/packages/notifications/src/inAppMessaging/providers/pinpoint/types/index.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { UpdateEndpointException } from './errors'; +export { IdentifyUserInput } from './inputs'; +export { IdentifyUserOptions } from './options'; diff --git a/packages/notifications/src/inAppMessaging/providers/pinpoint/types/inputs.ts b/packages/notifications/src/inAppMessaging/providers/pinpoint/types/inputs.ts new file mode 100644 index 00000000000..f103f97b69e --- /dev/null +++ b/packages/notifications/src/inAppMessaging/providers/pinpoint/types/inputs.ts @@ -0,0 +1,11 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { IdentifyUserOptions } from '.'; +import { InAppMessagingIdentifyUserInput } from '../../../types'; + +/** + * Input type for Pinpoint identifyUser API. + */ +export type IdentifyUserInput = + InAppMessagingIdentifyUserInput; diff --git a/packages/notifications/src/inAppMessaging/providers/pinpoint/types/options.ts b/packages/notifications/src/inAppMessaging/providers/pinpoint/types/options.ts new file mode 100644 index 00000000000..8f1d053b6b6 --- /dev/null +++ b/packages/notifications/src/inAppMessaging/providers/pinpoint/types/options.ts @@ -0,0 +1,9 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PinpointServiceOptions } from '@aws-amplify/core/internals/providers/pinpoint'; + +/** + * Options specific to Pinpoint identityUser. + */ +export type IdentifyUserOptions = PinpointServiceOptions; diff --git a/packages/notifications/src/inAppMessaging/providers/pinpoint/utils/utils.ts b/packages/notifications/src/inAppMessaging/providers/pinpoint/utils/utils.ts deleted file mode 100644 index 8f5d728ad1a..00000000000 --- a/packages/notifications/src/inAppMessaging/providers/pinpoint/utils/utils.ts +++ /dev/null @@ -1,304 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { ConsoleLogger } from '@aws-amplify/core/internals/utils'; -import type { InAppMessageCampaign as PinpointInAppMessage } from '@aws-amplify/core/internals/aws-clients/pinpoint'; -import isEmpty from 'lodash/isEmpty'; -import { - InAppMessage, - InAppMessageAction, - InAppMessageContent, - InAppMessageLayout, - InAppMessageTextAlign, - InAppMessagingEvent, -} from '../../../types'; - -import { AWSPinpointMessageEvent, MetricsComparator } from '../types/types'; - -const DELIVERY_TYPE = 'IN_APP_MESSAGE'; - -let eventNameMemo = {}; -let eventAttributesMemo = {}; -let eventMetricsMemo = {}; - -export const logger = new ConsoleLogger('InAppMessaging.AWSPinpointProvider'); - -export const recordAnalyticsEvent = ( - event: AWSPinpointMessageEvent, - message: InAppMessage -) => { - // TODO(V6) : Add back recording here without validation - // if (Amplify.Analytics && typeof Amplify.Analytics.record === 'function') { - // const { id, metadata } = message; - // Amplify.Analytics.record({ - // name: event, - // attributes: { - // campaign_id: id, - // delivery_type: DELIVERY_TYPE, - // treatment_id: metadata?.treatmentId, - // }, - // }); - // } else { - // logger.debug('Analytics module is not registered into Amplify'); - // } -}; - -export const getStartOfDay = (): string => { - const now = new Date(); - now.setHours(0, 0, 0, 0); - return now.toISOString(); -}; - -export const matchesEventType = ( - { CampaignId, Schedule }: PinpointInAppMessage, - { name: eventType }: InAppMessagingEvent -) => { - const { EventType } = Schedule?.EventFilter?.Dimensions; - const memoKey = `${CampaignId}:${eventType}`; - if (!eventNameMemo.hasOwnProperty(memoKey)) { - eventNameMemo[memoKey] = !!EventType?.Values.includes(eventType); - } - return eventNameMemo[memoKey]; -}; - -export const matchesAttributes = ( - { CampaignId, Schedule }: PinpointInAppMessage, - { attributes }: InAppMessagingEvent -): boolean => { - const { Attributes } = Schedule?.EventFilter?.Dimensions; - if (isEmpty(Attributes)) { - // if message does not have attributes defined it does not matter what attributes are on the event - return true; - } - if (isEmpty(attributes)) { - // if message does have attributes but the event does not then it always fails the check - return false; - } - const memoKey = `${CampaignId}:${JSON.stringify(attributes)}`; - if (!eventAttributesMemo.hasOwnProperty(memoKey)) { - eventAttributesMemo[memoKey] = Object.entries(Attributes).every( - ([key, { Values }]) => Values.includes(attributes[key]) - ); - } - return eventAttributesMemo[memoKey]; -}; - -export const matchesMetrics = ( - { CampaignId, Schedule }: PinpointInAppMessage, - { metrics }: InAppMessagingEvent -): boolean => { - const { Metrics } = Schedule?.EventFilter?.Dimensions; - if (isEmpty(Metrics)) { - // if message does not have metrics defined it does not matter what metrics are on the event - return true; - } - if (isEmpty(metrics)) { - // if message does have metrics but the event does not then it always fails the check - return false; - } - const memoKey = `${CampaignId}:${JSON.stringify(metrics)}`; - if (!eventMetricsMemo.hasOwnProperty(memoKey)) { - eventMetricsMemo[memoKey] = Object.entries(Metrics).every( - ([key, { ComparisonOperator, Value }]) => { - const compare = getComparator(ComparisonOperator); - // if there is some unknown comparison operator, treat as a comparison failure - return compare ? compare(Value, metrics[key]) : false; - } - ); - } - return eventMetricsMemo[memoKey]; -}; - -export const getComparator = (operator: string): MetricsComparator => { - switch (operator) { - case 'EQUAL': - return (metricsVal, eventVal) => metricsVal === eventVal; - case 'GREATER_THAN': - return (metricsVal, eventVal) => metricsVal < eventVal; - case 'GREATER_THAN_OR_EQUAL': - return (metricsVal, eventVal) => metricsVal <= eventVal; - case 'LESS_THAN': - return (metricsVal, eventVal) => metricsVal > eventVal; - case 'LESS_THAN_OR_EQUAL': - return (metricsVal, eventVal) => metricsVal >= eventVal; - default: - return null; - } -}; - -export const isBeforeEndDate = ({ - Schedule, -}: PinpointInAppMessage): boolean => { - if (!Schedule?.EndDate) { - return true; - } - return new Date() < new Date(Schedule.EndDate); -}; - -export const isQuietTime = (message: PinpointInAppMessage): boolean => { - const { Schedule } = message; - if (!Schedule?.QuietTime) { - return false; - } - - const pattern = /^[0-2]\d:[0-5]\d$/; // basic sanity check, not a fully featured HH:MM validation - const { Start, End } = Schedule.QuietTime; - if ( - !Start || - !End || - Start === End || - !pattern.test(Start) || - !pattern.test(End) - ) { - return false; - } - - const now = new Date(); - const start = new Date(now); - const end = new Date(now); - const [startHours, startMinutes] = Start.split(':'); - const [endHours, endMinutes] = End.split(':'); - - start.setHours( - Number.parseInt(startHours, 10), - Number.parseInt(startMinutes, 10), - 0, - 0 - ); - end.setHours( - Number.parseInt(endHours, 10), - Number.parseInt(endMinutes, 10), - 0, - 0 - ); - - // if quiet time includes midnight, bump the end time to the next day - if (start > end) { - end.setDate(end.getDate() + 1); - } - - const isQuietTime = now >= start && now <= end; - if (isQuietTime) { - logger.debug('message filtered due to quiet time', message); - } - return isQuietTime; -}; - -export const clearMemo = () => { - eventNameMemo = {}; - eventAttributesMemo = {}; - eventMetricsMemo = {}; -}; - -// in the pinpoint console when a message is created with a Modal or Full Screen layout, -// it is assigned a layout value of MOBILE_FEED or OVERLAYS respectively in the message payload. -// In the future, Pinpoint will be updating the layout values in the aforementioned scenario -// to MODAL and FULL_SCREEN. -// -// This utility acts as a safeguard to ensure that: -// - 1. the usage of MOBILE_FEED and OVERLAYS as values for message layouts are not leaked -// outside the Pinpoint provider -// - 2. Amplify correctly handles the legacy layout values from Pinpoint after they are updated -export const interpretLayout = ( - layout: PinpointInAppMessage['InAppMessage']['Layout'] -): InAppMessageLayout => { - if (layout === 'MOBILE_FEED') { - return 'MODAL'; - } - - if (layout === 'OVERLAYS') { - return 'FULL_SCREEN'; - } - - // cast as PinpointInAppMessage['InAppMessage']['Layout'] allows `string` as a value - return layout as InAppMessageLayout; -}; - -export const extractContent = ({ - InAppMessage: message, -}: PinpointInAppMessage): InAppMessageContent[] => { - return ( - message?.Content?.map(content => { - const { - BackgroundColor, - BodyConfig, - HeaderConfig, - ImageUrl, - PrimaryBtn, - SecondaryBtn, - } = content; - const defaultPrimaryButton = PrimaryBtn?.DefaultConfig; - const defaultSecondaryButton = SecondaryBtn?.DefaultConfig; - const extractedContent: InAppMessageContent = {}; - if (BackgroundColor) { - extractedContent.container = { - style: { - backgroundColor: BackgroundColor, - }, - }; - } - if (HeaderConfig) { - extractedContent.header = { - content: HeaderConfig.Header, - style: { - color: HeaderConfig.TextColor, - textAlign: - HeaderConfig.Alignment.toLowerCase() as InAppMessageTextAlign, - }, - }; - } - if (BodyConfig) { - extractedContent.body = { - content: BodyConfig.Body, - style: { - color: BodyConfig.TextColor, - textAlign: - BodyConfig.Alignment.toLowerCase() as InAppMessageTextAlign, - }, - }; - } - if (ImageUrl) { - extractedContent.image = { - src: ImageUrl, - }; - } - if (defaultPrimaryButton) { - extractedContent.primaryButton = { - title: defaultPrimaryButton.Text, - action: defaultPrimaryButton.ButtonAction as InAppMessageAction, - url: defaultPrimaryButton.Link, - style: { - backgroundColor: defaultPrimaryButton.BackgroundColor, - borderRadius: defaultPrimaryButton.BorderRadius, - color: defaultPrimaryButton.TextColor, - }, - }; - } - if (defaultSecondaryButton) { - extractedContent.secondaryButton = { - title: defaultSecondaryButton.Text, - action: defaultSecondaryButton.ButtonAction as InAppMessageAction, - url: defaultSecondaryButton.Link, - style: { - backgroundColor: defaultSecondaryButton.BackgroundColor, - borderRadius: defaultSecondaryButton.BorderRadius, - color: defaultSecondaryButton.TextColor, - }, - }; - } - return extractedContent; - }) ?? [] - ); -}; - -export const extractMetadata = ({ - InAppMessage, - Priority, - Schedule, - TreatmentId, -}: PinpointInAppMessage): InAppMessage['metadata'] => ({ - customData: InAppMessage?.CustomConfig, - endDate: Schedule?.EndDate, - priority: Priority, - treatmentId: TreatmentId, -}); diff --git a/packages/notifications/src/inAppMessaging/types.ts b/packages/notifications/src/inAppMessaging/types.ts deleted file mode 100644 index 34fdd74d6ab..00000000000 --- a/packages/notifications/src/inAppMessaging/types.ts +++ /dev/null @@ -1,142 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { EventListener } from '../common'; -import { AWSPinpointProviderConfig } from '../common/AWSPinpointProviderCommon/types'; -import { - NotificationsProvider, - NotificationsSubCategory as NotificationsSubCategories, - UserInfo, -} from '../types'; - -export type NotificationsSubCategory = Extract< - NotificationsSubCategories, - 'InAppMessaging' ->; - -export interface InAppMessagingInterface { - configure: (config: InAppMessagingConfig) => InAppMessagingConfig; - getModuleName: () => NotificationsSubCategory; - getPluggable: (providerName: string) => InAppMessagingProvider; - addPluggable: (pluggable: InAppMessagingProvider) => void; - removePluggable: (providerName: string) => void; - syncMessages: () => Promise; - clearMessages: () => Promise; - dispatchEvent: (event: InAppMessagingEvent) => Promise; - identifyUser: (userId: string, userInfo: UserInfo) => Promise; - onMessageReceived: ( - handler: OnMessageInteractionEventHandler - ) => EventListener; - onMessageDisplayed: ( - handler: OnMessageInteractionEventHandler - ) => EventListener; - onMessageDismissed: ( - handler: OnMessageInteractionEventHandler - ) => EventListener; - onMessageActionTaken: ( - handler: OnMessageInteractionEventHandler - ) => EventListener; - notifyMessageInteraction: ( - message: InAppMessage, - type: InAppMessageInteractionEvent - ) => void; - setConflictHandler: (handler: InAppMessageConflictHandler) => void; -} - -export interface InAppMessagingProvider extends NotificationsProvider { - // return sub-category ('InAppMessaging') - getSubCategory(): NotificationsSubCategory; - - // get in-app messages from provider - getInAppMessages(): Promise; - - // filters in-app messages based on event input and provider logic - processInAppMessages( - messages: InAppMessage[], - event: InAppMessagingEvent - ): Promise; -} - -export interface InAppMessagingConfig { - listenForAnalyticsEvents?: boolean; - AWSPinpoint?: AWSPinpointProviderConfig; -} - -export type InAppMessagingEvent = { - name: string; - attributes?: Record; - metrics?: Record; -}; - -export type InAppMessageLayout = - | 'BOTTOM_BANNER' - | 'CAROUSEL' - | 'FULL_SCREEN' - | 'MIDDLE_BANNER' - | 'MODAL' - | 'TOP_BANNER'; - -export type InAppMessageAction = 'CLOSE' | 'DEEP_LINK' | 'LINK'; - -export type InAppMessageTextAlign = 'center' | 'left' | 'right'; - -interface InAppMessageContainer { - style?: InAppMessageStyle; -} - -interface InAppMessageHeader { - content: string; - style?: InAppMessageStyle; -} - -interface InAppMessageBody { - content: string; - style?: InAppMessageStyle; -} - -export interface InAppMessageImage { - src: string; -} - -export interface InAppMessageButton { - title: string; - action: InAppMessageAction; - url?: string; - style?: InAppMessageStyle; -} - -export interface InAppMessageStyle { - backgroundColor?: string; - borderRadius?: number; - color?: string; - textAlign?: InAppMessageTextAlign; -} - -export interface InAppMessageContent { - container?: InAppMessageContainer; - header?: InAppMessageHeader; - body?: InAppMessageBody; - image?: InAppMessageImage; - primaryButton?: InAppMessageButton; - secondaryButton?: InAppMessageButton; -} - -export interface InAppMessage { - id: string; - layout: InAppMessageLayout; - content: InAppMessageContent[]; - metadata?: any; -} - -export type OnMessageInteractionEventHandler = (message: InAppMessage) => any; - -export enum InAppMessageInteractionEvent { - MESSAGE_RECEIVED = 'MESSAGE_RECEIVED_EVENT', - MESSAGE_DISPLAYED = 'MESSAGE_DISPLAYED_EVENT', - MESSAGE_DISMISSED = 'MESSAGE_DISMISSED_EVENT', - MESSAGE_ACTION_TAKEN = 'MESSAGE_ACTION_TAKEN_EVENT', -} - -export type InAppMessageConflictHandler = ( - messages: InAppMessage[] -) => InAppMessage; diff --git a/packages/notifications/src/inAppMessaging/types/config.ts b/packages/notifications/src/inAppMessaging/types/config.ts new file mode 100644 index 00000000000..420ca0bb1d4 --- /dev/null +++ b/packages/notifications/src/inAppMessaging/types/config.ts @@ -0,0 +1,8 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { PinpointProviderConfig } from '../../common/AWSPinpointProviderCommon/types'; + +export interface InAppMessagingConfig { + listenForAnalyticsEvents?: boolean; + AWSPinpoint?: PinpointProviderConfig; +} diff --git a/packages/notifications/src/inAppMessaging/types/event.ts b/packages/notifications/src/inAppMessaging/types/event.ts new file mode 100644 index 00000000000..2b5d0255c4e --- /dev/null +++ b/packages/notifications/src/inAppMessaging/types/event.ts @@ -0,0 +1,14 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export type InAppMessageInteractionEvent = + | 'messageReceived' + | 'messageDisplayed' + | 'messageDismissed' + | 'messageActionTaken'; + +export type InAppMessagingEvent = { + name: string; + attributes?: Record; + metrics?: Record; +}; diff --git a/packages/notifications/src/inAppMessaging/types/index.ts b/packages/notifications/src/inAppMessaging/types/index.ts new file mode 100644 index 00000000000..9f1f00bb861 --- /dev/null +++ b/packages/notifications/src/inAppMessaging/types/index.ts @@ -0,0 +1,8 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { InAppMessagingServiceOptions } from './options'; +export { InAppMessagingIdentifyUserInput } from './inputs'; +export { InAppMessagingConfig } from './config'; +export { InAppMessageInteractionEvent, InAppMessagingEvent } from './event'; +export { InAppMessage } from './message'; diff --git a/packages/notifications/src/inAppMessaging/types/inputs.ts b/packages/notifications/src/inAppMessaging/types/inputs.ts new file mode 100644 index 00000000000..4ad446a3551 --- /dev/null +++ b/packages/notifications/src/inAppMessaging/types/inputs.ts @@ -0,0 +1,29 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { UserProfile } from '@aws-amplify/core'; +import { InAppMessagingServiceOptions } from '.'; + +/** + * Input type for `identifyUser`. + */ +export type InAppMessagingIdentifyUserInput< + ServiceOptions extends InAppMessagingServiceOptions = InAppMessagingServiceOptions +> = { + /** + * A User ID associated to the current device. + */ + userId: string; + + /** + * Additional information about the user and their device. + */ + userProfile: UserProfile; + + /** + * Options to be passed to the API. + */ + options?: { + serviceOptions?: ServiceOptions; + }; +}; diff --git a/packages/notifications/src/inAppMessaging/types/message.ts b/packages/notifications/src/inAppMessaging/types/message.ts new file mode 100644 index 00000000000..0e84cacf9f9 --- /dev/null +++ b/packages/notifications/src/inAppMessaging/types/message.ts @@ -0,0 +1,62 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export type InAppMessageLayout = + | 'BOTTOM_BANNER' + | 'CAROUSEL' + | 'FULL_SCREEN' + | 'MIDDLE_BANNER' + | 'MODAL' + | 'TOP_BANNER'; + +export type InAppMessageAction = 'CLOSE' | 'DEEP_LINK' | 'LINK'; + +export type InAppMessageTextAlign = 'center' | 'left' | 'right'; + +interface InAppMessageContainer { + style?: InAppMessageStyle; +} + +interface InAppMessageHeader { + content: string; + style?: InAppMessageStyle; +} + +interface InAppMessageBody { + content: string; + style?: InAppMessageStyle; +} + +export interface InAppMessageImage { + src: string; +} + +export interface InAppMessageButton { + title: string; + action: InAppMessageAction; + url?: string; + style?: InAppMessageStyle; +} + +export interface InAppMessageStyle { + backgroundColor?: string; + borderRadius?: number; + color?: string; + textAlign?: InAppMessageTextAlign; +} + +export interface InAppMessageContent { + container?: InAppMessageContainer; + header?: InAppMessageHeader; + body?: InAppMessageBody; + image?: InAppMessageImage; + primaryButton?: InAppMessageButton; + secondaryButton?: InAppMessageButton; +} + +export interface InAppMessage { + id: string; + layout: InAppMessageLayout; + content: InAppMessageContent[]; + metadata?: any; +} diff --git a/packages/notifications/src/inAppMessaging/types/options.ts b/packages/notifications/src/inAppMessaging/types/options.ts new file mode 100644 index 00000000000..c3ddc3e06c6 --- /dev/null +++ b/packages/notifications/src/inAppMessaging/types/options.ts @@ -0,0 +1,7 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Base type for service options. + */ +export type InAppMessagingServiceOptions = any; diff --git a/packages/notifications/src/index.ts b/packages/notifications/src/index.ts index ff433312a21..5594ab1d456 100644 --- a/packages/notifications/src/index.ts +++ b/packages/notifications/src/index.ts @@ -2,20 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 export { AWSPinpointProviderCommon } from './common'; -export { AWSPinpointUserInfo } from './common/AWSPinpointProviderCommon/types'; -export { - InAppMessage, - InAppMessageAction, - InAppMessageButton, - InAppMessageContent, - InAppMessageImage, - InAppMessageInteractionEvent, - InAppMessageLayout, - InAppMessageStyle, - InAppMessageTextAlign, - InAppMessagingConfig, - InAppMessagingEvent, -} from './inAppMessaging'; +export { PinpointUserInfo } from './common/AWSPinpointProviderCommon/types'; export { PushNotificationMessage, PushNotificationPermissions, diff --git a/packages/notifications/src/pushNotifications/providers/AWSPinpointProvider/index.ts b/packages/notifications/src/pushNotifications/providers/AWSPinpointProvider/index.ts index f36fa432798..4324201e5c9 100644 --- a/packages/notifications/src/pushNotifications/providers/AWSPinpointProvider/index.ts +++ b/packages/notifications/src/pushNotifications/providers/AWSPinpointProvider/index.ts @@ -3,16 +3,13 @@ import { addEventListener, AWSPinpointProviderCommon } from '../../../common'; import { ChannelType } from '../../../common/AWSPinpointProviderCommon/types'; -import PlatformNotSupportedError from '../../PlatformNotSupportedError'; -import { Platform } from '../../Platform'; import { - PushNotificationEvent, PushNotificationMessage, PushNotificationProvider, NotificationsSubCategory, } from '../../types'; import { AWSPinpointMessageEvent } from './types'; -import { getAnalyticsEvent, logger } from './utils'; +import { logger } from './utils'; export default class AWSPinpointProvider extends AWSPinpointProviderCommon @@ -42,24 +39,20 @@ export default class AWSPinpointProvider // some configuration steps should not be re-run even if provider is re-configured for some reason if (!this.configured) { // wire up default Pinpoint message event handling - addEventListener( - PushNotificationEvent.BACKGROUND_MESSAGE_RECEIVED, - message => - this.recordMessageEvent( - message, - AWSPinpointMessageEvent.BACKGROUND_MESSAGE_RECEIVED - ) + addEventListener('backgroundMessageReceived', message => + this.recordMessageEvent( + message, + AWSPinpointMessageEvent.BACKGROUND_MESSAGE_RECEIVED + ) ); - addEventListener( - PushNotificationEvent.FOREGROUND_MESSAGE_RECEIVED, - message => - this.recordMessageEvent( - message, - AWSPinpointMessageEvent.FOREGROUND_MESSAGE_RECEIVED - ) + addEventListener('foregroundMessageReceived', message => + this.recordMessageEvent( + message, + AWSPinpointMessageEvent.FOREGROUND_MESSAGE_RECEIVED + ) ); const launchNotificationOpenedListener = addEventListener( - PushNotificationEvent.LAUNCH_NOTIFICATION_OPENED, + 'launchNotificationsOpened', message => { this.recordMessageEvent( message, @@ -69,7 +62,7 @@ export default class AWSPinpointProvider launchNotificationOpenedListener?.remove(); } ); - addEventListener(PushNotificationEvent.NOTIFICATION_OPENED, message => { + addEventListener('notificationOpened', message => { this.recordMessageEvent( message, AWSPinpointMessageEvent.NOTIFICATION_OPENED diff --git a/packages/notifications/src/pushNotifications/types.ts b/packages/notifications/src/pushNotifications/types.ts index e92af1ceec3..949ad03754d 100644 --- a/packages/notifications/src/pushNotifications/types.ts +++ b/packages/notifications/src/pushNotifications/types.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { EventListener } from '../common'; -import { AWSPinpointProviderConfig } from '../common/AWSPinpointProviderCommon/types'; +import { PinpointProviderConfig } from '../common/AWSPinpointProviderCommon/types'; import { NotificationsProvider, NotificationsSubCategory as NotificationsSubCategories, @@ -52,7 +52,7 @@ export interface PushNotificationProvider extends NotificationsProvider { } export interface PushNotificationConfig { - AWSPinpoint?: AWSPinpointProviderConfig; + Pinpoint?: PinpointProviderConfig; } export interface PushNotificationMessage { @@ -97,13 +97,12 @@ export type OnPushNotificationMessageHandler = ( message: PushNotificationMessage ) => any; -export const enum PushNotificationEvent { - BACKGROUND_MESSAGE_RECEIVED, - FOREGROUND_MESSAGE_RECEIVED, - LAUNCH_NOTIFICATION_OPENED, - NOTIFICATION_OPENED, - TOKEN_RECEIVED, -} +export type PushNotificationEvent = + | 'backgroundMessageReceived' + | 'foregroundMessageReceived' + | 'launchNotificationsOpened' + | 'notificationOpened' + | 'tokenReceived'; export interface NormalizedValues { body?: string; From 21f0bac721435e483cfc14ddae10c183319d04b4 Mon Sep 17 00:00:00 2001 From: Di Wu Date: Thu, 28 Sep 2023 12:57:29 -0700 Subject: [PATCH 09/20] feat(analytics): add record api for kinesis service provider (#12099) * feat(analytics): add record api for kinesis service provider * update default config * add unit test for groupBy * add unit test cases for eventBuffer * add test case for resolveConfig * add unit test cases for resolveCredentials * add unit test for getEventBuffer * add unit test cases for record API * move resolveCredentials to plugin module level * add default config value for resendLimit * resolve failed unit test cases * resolve comments * resolve more comments * resolve comments * update unit test folder name * add test case for AnalyticsKinesis module in export.test --------- Co-authored-by: Jim Blanchard --- .../providers/kinesis/apis/record.test.ts | 72 +++++ .../kinesis/utils/getEventBuffer.test.ts | 60 ++++ .../kinesis/utils/resolveConfig.test.ts | 65 +++++ .../__tests__/testUtils/mockConstants.test.ts | 22 ++ .../utils/eventBuffer/EventBuffer.test.ts | 105 +++++++ .../analytics/__tests__/utils/groupBy.test.ts | 38 +++ .../utils/resolveCredentials.test.ts | 36 +++ packages/analytics/package.json | 1 + packages/analytics/src/errors/validation.ts | 4 + .../src/providers/kinesis/apis/record.ts | 43 ++- .../src/providers/kinesis/types/buffer.ts | 20 ++ .../src/providers/kinesis/types/index.ts | 3 +- .../src/providers/kinesis/types/inputs.ts | 10 +- .../src/providers/kinesis/utils/constants.ts | 9 + .../providers/kinesis/utils/getEventBuffer.ts | 108 ++++++++ .../providers/kinesis/utils/resolveConfig.ts | 36 +++ packages/analytics/src/types/index.ts | 2 + packages/analytics/src/types/kinesis.ts | 13 + .../src/utils/eventBuffer/EventBuffer.ts | 80 ++++++ .../analytics/src/utils/eventBuffer/index.ts | 5 + .../analytics/src/utils/eventBuffer/types.ts | 12 + packages/analytics/src/utils/groupBy.ts | 12 + packages/analytics/src/utils/index.ts | 7 + .../analytics/src/utils/resolveCredentials.ts | 14 + .../aws-amplify/__tests__/exports.test.ts | 9 + packages/aws-amplify/package.json | 4 +- packages/core/src/Signer/DateUtils.ts | 2 +- .../core/src/providers/kinesis/types/index.ts | 4 + .../src/providers/kinesis/types/kinesis.ts | 12 + .../src/providers/pinpoint/types/pinpoint.ts | 2 +- .../core/src/singleton/Analytics/types.ts | 3 +- yarn.lock | 262 +++++++++--------- 32 files changed, 931 insertions(+), 144 deletions(-) create mode 100644 packages/analytics/__tests__/providers/kinesis/apis/record.test.ts create mode 100644 packages/analytics/__tests__/providers/kinesis/utils/getEventBuffer.test.ts create mode 100644 packages/analytics/__tests__/providers/kinesis/utils/resolveConfig.test.ts create mode 100644 packages/analytics/__tests__/testUtils/mockConstants.test.ts create mode 100644 packages/analytics/__tests__/utils/eventBuffer/EventBuffer.test.ts create mode 100644 packages/analytics/__tests__/utils/groupBy.test.ts create mode 100644 packages/analytics/__tests__/utils/resolveCredentials.test.ts create mode 100644 packages/analytics/src/providers/kinesis/types/buffer.ts create mode 100644 packages/analytics/src/providers/kinesis/utils/constants.ts create mode 100644 packages/analytics/src/providers/kinesis/utils/getEventBuffer.ts create mode 100644 packages/analytics/src/providers/kinesis/utils/resolveConfig.ts create mode 100644 packages/analytics/src/types/kinesis.ts create mode 100644 packages/analytics/src/utils/eventBuffer/EventBuffer.ts create mode 100644 packages/analytics/src/utils/eventBuffer/index.ts create mode 100644 packages/analytics/src/utils/eventBuffer/types.ts create mode 100644 packages/analytics/src/utils/groupBy.ts create mode 100644 packages/analytics/src/utils/resolveCredentials.ts create mode 100644 packages/core/src/providers/kinesis/types/index.ts create mode 100644 packages/core/src/providers/kinesis/types/kinesis.ts diff --git a/packages/analytics/__tests__/providers/kinesis/apis/record.test.ts b/packages/analytics/__tests__/providers/kinesis/apis/record.test.ts new file mode 100644 index 00000000000..a60ecd30365 --- /dev/null +++ b/packages/analytics/__tests__/providers/kinesis/apis/record.test.ts @@ -0,0 +1,72 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getEventBuffer } from '../../../../src/providers/kinesis/utils/getEventBuffer'; +import { resolveConfig } from '../../../../src/providers/kinesis/utils/resolveConfig'; +import { resolveCredentials } from '../../../../src/utils'; +import { + mockConfig, + mockCredentialConfig, +} from '../../../testUtils/mockConstants.test'; +import { record } from '../../../../src/providers/kinesis'; +import { KinesisEvent } from '../../../../src/providers/kinesis/types'; +import { ConsoleLogger as Logger } from '@aws-amplify/core/lib/Logger'; + +jest.mock('../../../../src/utils'); +jest.mock('../../../../src/providers/kinesis/utils/resolveConfig'); +jest.mock('../../../../src/providers/kinesis/utils/getEventBuffer'); + +describe('Analytics Kinesis API: record', () => { + const mockEvent: KinesisEvent = { + streamName: 'stream0', + partitionKey: 'partition0', + data: new Uint8Array([0x01, 0x02, 0xff]), + }; + + const mockResolveConfig = resolveConfig as jest.Mock; + const mockResolveCredentials = resolveCredentials as jest.Mock; + const mockGetEventBuffer = getEventBuffer as jest.Mock; + const mockAppend = jest.fn(); + const loggerWarnSpy = jest.spyOn(Logger.prototype, 'warn'); + + beforeEach(() => { + mockResolveConfig.mockReturnValue(mockConfig); + mockResolveCredentials.mockReturnValue( + Promise.resolve(mockCredentialConfig) + ); + mockGetEventBuffer.mockImplementation(() => ({ + append: mockAppend, + })); + }); + + afterEach(() => { + mockResolveConfig.mockReset(); + mockResolveCredentials.mockReset(); + mockAppend.mockReset(); + mockGetEventBuffer.mockReset(); + }); + + it('append to event buffer if record provided', async () => { + record(mockEvent); + await new Promise(process.nextTick); + expect(mockGetEventBuffer).toHaveBeenCalledTimes(1); + expect(mockAppend).toBeCalledWith( + expect.objectContaining({ + region: mockConfig.region, + streamName: mockEvent.streamName, + partitionKey: mockEvent.partitionKey, + event: mockEvent.data, + retryCount: 0, + }) + ); + }); + + it('logs an error when credentials can not be fetched', async () => { + mockResolveCredentials.mockRejectedValue(new Error('Mock Error')); + + record(mockEvent); + + await new Promise(process.nextTick); + expect(loggerWarnSpy).toBeCalledWith(expect.any(String), expect.any(Error)); + }); +}); diff --git a/packages/analytics/__tests__/providers/kinesis/utils/getEventBuffer.test.ts b/packages/analytics/__tests__/providers/kinesis/utils/getEventBuffer.test.ts new file mode 100644 index 00000000000..96556f3507b --- /dev/null +++ b/packages/analytics/__tests__/providers/kinesis/utils/getEventBuffer.test.ts @@ -0,0 +1,60 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getEventBuffer } from '../../../../src/providers/kinesis/utils/getEventBuffer'; +import { EventBuffer } from '../../../../src/utils'; +import { + mockBufferConfig, + mockConfig, + mockCredentialConfig, +} from '../../../testUtils/mockConstants.test'; + +jest.mock('../../../../src/utils'); + +describe('Kinesis Provider Util: getEventBuffer', () => { + const mockEventBuffer = EventBuffer as jest.Mock; + + afterEach(() => { + mockEventBuffer.mockReset(); + }); + + it("create a buffer if one doesn't exist", () => { + const testBuffer = getEventBuffer({ + ...mockConfig, + ...mockCredentialConfig, + }); + + expect(mockEventBuffer).toBeCalledWith( + mockBufferConfig, + expect.any(Function) + ); + expect(testBuffer).toBeInstanceOf(EventBuffer); + }); + + it('returns an existing buffer instance', () => { + const testBuffer1 = getEventBuffer({ + ...mockConfig, + ...mockCredentialConfig, + }); + const testBuffer2 = getEventBuffer({ + ...mockConfig, + ...mockCredentialConfig, + }); + expect(testBuffer1).toBe(testBuffer2); + }); + + it('release other buffers & creates a new one if credential has changed', () => { + const testBuffer1 = getEventBuffer({ + ...mockConfig, + ...mockCredentialConfig, + }); + const testBuffer2 = getEventBuffer({ + ...mockConfig, + ...mockCredentialConfig, + identityId: 'identityId2', + }); + + expect(testBuffer1.release).toHaveBeenCalledTimes(1); + expect(testBuffer1).not.toBe(testBuffer2); + }); +}); diff --git a/packages/analytics/__tests__/providers/kinesis/utils/resolveConfig.test.ts b/packages/analytics/__tests__/providers/kinesis/utils/resolveConfig.test.ts new file mode 100644 index 00000000000..a0b39266770 --- /dev/null +++ b/packages/analytics/__tests__/providers/kinesis/utils/resolveConfig.test.ts @@ -0,0 +1,65 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify } from '@aws-amplify/core'; +import { resolveConfig } from '../../../../src/providers/kinesis/utils/resolveConfig'; +import { DEFAULT_KINESIS_CONFIG } from '../../../../src/providers/kinesis/utils/constants'; + +describe('Analytics Kinesis Provider Util: resolveConfig', () => { + const kinesisConfig = { + region: 'us-east-1', + bufferSize: 100, + flushSize: 10, + flushInterval: 1000, + resendLimit: 3, + }; + + const getConfigSpy = jest.spyOn(Amplify, 'getConfig'); + + beforeEach(() => { + getConfigSpy.mockReset(); + }); + + it('returns required config', () => { + getConfigSpy.mockReturnValue({ + Analytics: { Kinesis: kinesisConfig }, + }); + + expect(resolveConfig()).toStrictEqual(kinesisConfig); + }); + + it('use default config for optional fields', () => { + const requiredFields = { + region: 'us-east-1', + bufferSize: undefined, + resendLimit: undefined, + }; + getConfigSpy.mockReturnValue({ + Analytics: { Kinesis: requiredFields }, + }); + + expect(resolveConfig()).toStrictEqual({ + ...DEFAULT_KINESIS_CONFIG, + region: requiredFields.region, + resendLimit: requiredFields.resendLimit, + }); + }); + + it('throws if region is missing', () => { + getConfigSpy.mockReturnValue({ + Analytics: { Kinesis: { ...kinesisConfig, region: undefined } }, + }); + + expect(resolveConfig).toThrow(); + }); + + it('throws if flushSize is larger than bufferSize', () => { + getConfigSpy.mockReturnValue({ + Analytics: { + Kinesis: { ...kinesisConfig, flushSize: kinesisConfig.bufferSize + 1 }, + }, + }); + + expect(resolveConfig).toThrow(); + }); +}); diff --git a/packages/analytics/__tests__/testUtils/mockConstants.test.ts b/packages/analytics/__tests__/testUtils/mockConstants.test.ts new file mode 100644 index 00000000000..002d557f746 --- /dev/null +++ b/packages/analytics/__tests__/testUtils/mockConstants.test.ts @@ -0,0 +1,22 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const mockBufferConfig = { + bufferSize: 10, + flushSize: 5, + flushInterval: 50, +}; + +export const mockConfig = { + ...mockBufferConfig, + region: 'us-east-1', +}; + +export const mockCredentialConfig = { + credentials: { + accessKeyId: 'accessKeyId0', + secretAccessKey: 'secretAccessKey0', + sessionToken: 'sessionToken0', + }, + identityId: 'identity0', +}; diff --git a/packages/analytics/__tests__/utils/eventBuffer/EventBuffer.test.ts b/packages/analytics/__tests__/utils/eventBuffer/EventBuffer.test.ts new file mode 100644 index 00000000000..986e833e0d4 --- /dev/null +++ b/packages/analytics/__tests__/utils/eventBuffer/EventBuffer.test.ts @@ -0,0 +1,105 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { EventBuffer } from '../../../src/utils'; + +describe('EventBuffer', () => { + type TestEvent = { + id: string; + timestamp: number; + }; + + it('append events in order', done => { + const result: TestEvent[] = []; + const eventBuffer: EventBuffer = new EventBuffer( + { + bufferSize: 2, + flushSize: 1, + flushInterval: 25, + }, + () => events => { + result.push(...events); + return Promise.resolve([]); + } + ); + + const testEvents: TestEvent[] = [ + { id: '1', timestamp: 1 }, + { id: '2', timestamp: 2 }, + ]; + testEvents.forEach(x => eventBuffer.append(x)); + setTimeout(() => { + eventBuffer.release(); + expect(result[0]).toEqual(testEvents[0]); + expect(result[1]).toEqual(testEvents[1]); + done(); + }, 100); + }); + + it('flush all events at once', done => { + const results = []; + const testEvents: TestEvent[] = [ + { id: '1', timestamp: 1 }, + { id: '2', timestamp: 2 }, + { id: '3', timestamp: 3 }, + ]; + + const eventBuffer: EventBuffer = new EventBuffer( + { + bufferSize: 3, + flushSize: 1, + flushInterval: 25, + }, + () => events => { + results.push(events.length); + return Promise.resolve(events); + } + ); + + testEvents.forEach(x => eventBuffer.append(x)); + setTimeout(() => { + eventBuffer.release(); + expect(results.filter(x => x === testEvents.length).length).toEqual(1); + expect(results.filter(x => x !== testEvents.length).length).toEqual( + results.length - 1 + ); + done(); + }, 100); + eventBuffer.flushAll(); + }); + + it('release all resources', done => { + const results = []; + const testEvents: TestEvent[] = [ + { id: '1', timestamp: 1 }, + { id: '2', timestamp: 2 }, + { id: '3', timestamp: 3 }, + ]; + + const eventBuffer: EventBuffer = new EventBuffer( + { + bufferSize: 3, + flushSize: 1, + flushInterval: 25, + }, + () => events => { + results.push(...events); + return Promise.resolve([]); + } + ); + + testEvents.forEach(x => eventBuffer.append(x)); + setTimeout(() => { + eventBuffer.release(); + }, 100); + eventBuffer.append({ id: '4', timestamp: 4 }); + + setTimeout(() => { + expect(results.length).toEqual(testEvents.length); + expect(results.filter(x => x.timestamp > results.length).length).toEqual( + 0 + ); + done(); + }, 150); + }); +}); diff --git a/packages/analytics/__tests__/utils/groupBy.test.ts b/packages/analytics/__tests__/utils/groupBy.test.ts new file mode 100644 index 00000000000..1108013e2f0 --- /dev/null +++ b/packages/analytics/__tests__/utils/groupBy.test.ts @@ -0,0 +1,38 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { groupBy } from '../../src/utils'; + +describe('Generic groupBy util function', () => { + type TestType = { + x: number; + y: string; + z: boolean; + }; + + const testData: TestType[] = [ + { + x: 1, + y: 'a', + z: true, + }, + { + x: 1, + y: 'b', + z: true, + }, + { + x: 2, + y: 'b', + z: false, + }, + ]; + + it('group list by groupId function', () => { + const result = groupBy(x => x.y, testData); + expect(new Set(Object.keys(result))).toEqual(new Set(['a', 'b'])); + expect(result['a'].length).toStrictEqual(1); + expect(result['a'][0]).toStrictEqual(testData[0]); + expect(result['b'].length).toStrictEqual(2); + }); +}); diff --git a/packages/analytics/__tests__/utils/resolveCredentials.test.ts b/packages/analytics/__tests__/utils/resolveCredentials.test.ts new file mode 100644 index 00000000000..8c7e452b893 --- /dev/null +++ b/packages/analytics/__tests__/utils/resolveCredentials.test.ts @@ -0,0 +1,36 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { fetchAuthSession } from '@aws-amplify/core'; +import { resolveCredentials } from '../../src/utils'; +import { AnalyticsError } from '../../src'; + +jest.mock('@aws-amplify/core'); +describe('Analytics Kinesis Provider Util: resolveCredentials', () => { + const credentials = { + credentials: { + accessKeyId: 'access-key-id', + secretAccessKey: 'secret-access-key', + sessionToken: 'session-token', + }, + identityId: 'identity-id', + }; + const mockFetchAuthSession = fetchAuthSession as jest.Mock; + + beforeEach(() => { + mockFetchAuthSession.mockReset(); + }); + + it('resolves required credentials', async () => { + mockFetchAuthSession.mockResolvedValue(credentials); + expect(await resolveCredentials()).toStrictEqual(credentials); + }); + + it('throws if credentials are missing', async () => { + mockFetchAuthSession.mockReturnValue({ + ...credentials, + credentials: undefined, + }); + await expect(resolveCredentials()).rejects.toBeInstanceOf(AnalyticsError); + }); +}); diff --git a/packages/analytics/package.json b/packages/analytics/package.json index 77717360de8..9997d9990fc 100644 --- a/packages/analytics/package.json +++ b/packages/analytics/package.json @@ -104,6 +104,7 @@ "@aws-sdk/client-firehose": "3.398.0", "@aws-sdk/client-personalize-events": "3.398.0", "@aws-sdk/types": "3.398.0", + "@smithy/util-utf8": "2.0.0", "@types/uuid": "^9.0.0", "typescript": "5.0.2" }, diff --git a/packages/analytics/src/errors/validation.ts b/packages/analytics/src/errors/validation.ts index 29702877033..9ff4312c1fc 100644 --- a/packages/analytics/src/errors/validation.ts +++ b/packages/analytics/src/errors/validation.ts @@ -8,6 +8,7 @@ export enum AnalyticsValidationErrorCode { NoCredentials = 'NoCredentials', NoEventName = 'NoEventName', NoRegion = 'NoRegion', + InvalidFlushSize = 'InvalidFlushSize', } export const validationErrorMap: AmplifyErrorMap = @@ -24,4 +25,7 @@ export const validationErrorMap: AmplifyErrorMap = [AnalyticsValidationErrorCode.NoRegion]: { message: 'Missing region.', }, + [AnalyticsValidationErrorCode.InvalidFlushSize]: { + message: 'Invalid FlushSize, it should smaller than BufferSize', + }, }; diff --git a/packages/analytics/src/providers/kinesis/apis/record.ts b/packages/analytics/src/providers/kinesis/apis/record.ts index df021514b50..91e79ef0421 100644 --- a/packages/analytics/src/providers/kinesis/apis/record.ts +++ b/packages/analytics/src/providers/kinesis/apis/record.ts @@ -2,7 +2,46 @@ // SPDX-License-Identifier: Apache-2.0 import { RecordInput } from '../types'; +import { getEventBuffer } from '../utils/getEventBuffer'; +import { resolveConfig } from '../utils/resolveConfig'; +import { resolveCredentials } from '../../../utils'; +import { fromUtf8 } from '@smithy/util-utf8'; +import { ConsoleLogger } from '@aws-amplify/core/lib/Logger'; -export const record = (input: RecordInput): void => { - throw new Error('Not Yet Implemented!'); +const logger = new ConsoleLogger('Kinesis'); + +export const record = ({ + streamName, + partitionKey, + data, +}: RecordInput): void => { + const timestamp = Date.now(); + const { region, bufferSize, flushSize, flushInterval, resendLimit } = + resolveConfig(); + + resolveCredentials() + .then(({ credentials, identityId }) => { + const buffer = getEventBuffer({ + region, + bufferSize, + flushSize, + flushInterval, + credentials, + identityId, + resendLimit, + }); + + buffer.append({ + region, + streamName, + partitionKey, + event: ArrayBuffer.isView(data) ? data : fromUtf8(JSON.stringify(data)), + timestamp, + retryCount: 0, + }); + }) + .catch(e => { + // An error occured while fetching credentials or persisting the event to the buffer + logger.warn('Failed to record event.', e); + }); }; diff --git a/packages/analytics/src/providers/kinesis/types/buffer.ts b/packages/analytics/src/providers/kinesis/types/buffer.ts new file mode 100644 index 00000000000..b41e00cd587 --- /dev/null +++ b/packages/analytics/src/providers/kinesis/types/buffer.ts @@ -0,0 +1,20 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { EventBufferConfig } from '../../../utils'; +import { Credentials } from '@aws-sdk/types'; +import { KinesisShard } from '../../../types'; + +export type KinesisBufferEvent = KinesisShard & { + event: Uint8Array; + timestamp: number; + retryCount: number; +}; + +export type KinesisEventBufferConfig = EventBufferConfig & { + region: string; + credentials: Credentials; + identityId?: string; + resendLimit?: number; + userAgentValue?: string; +}; diff --git a/packages/analytics/src/providers/kinesis/types/index.ts b/packages/analytics/src/providers/kinesis/types/index.ts index 0993221738f..6f4dc6cc4a9 100644 --- a/packages/analytics/src/providers/kinesis/types/index.ts +++ b/packages/analytics/src/providers/kinesis/types/index.ts @@ -1,4 +1,5 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -export { RecordInput } from './inputs'; +export { RecordInput, KinesisEvent } from './inputs'; +export { KinesisBufferEvent, KinesisEventBufferConfig } from './buffer'; diff --git a/packages/analytics/src/providers/kinesis/types/inputs.ts b/packages/analytics/src/providers/kinesis/types/inputs.ts index 43ff8eb704f..f9cd08ce714 100644 --- a/packages/analytics/src/providers/kinesis/types/inputs.ts +++ b/packages/analytics/src/providers/kinesis/types/inputs.ts @@ -1,4 +1,12 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -export type RecordInput = {}; +import { KinesisEventData } from '../../../types'; + +export type KinesisEvent = { + streamName: string; + partitionKey: string; + data: KinesisEventData; +}; + +export type RecordInput = KinesisEvent; diff --git a/packages/analytics/src/providers/kinesis/utils/constants.ts b/packages/analytics/src/providers/kinesis/utils/constants.ts new file mode 100644 index 00000000000..a6961a2dc1c --- /dev/null +++ b/packages/analytics/src/providers/kinesis/utils/constants.ts @@ -0,0 +1,9 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const DEFAULT_KINESIS_CONFIG = { + bufferSize: 1_000, + flushSize: 100, + flushInterval: 5_000, + resendLimit: 5, +}; diff --git a/packages/analytics/src/providers/kinesis/utils/getEventBuffer.ts b/packages/analytics/src/providers/kinesis/utils/getEventBuffer.ts new file mode 100644 index 00000000000..6d0f0b86a2d --- /dev/null +++ b/packages/analytics/src/providers/kinesis/utils/getEventBuffer.ts @@ -0,0 +1,108 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { KinesisBufferEvent, KinesisEventBufferConfig } from '../types'; +import { EventBuffer, groupBy, IAnalyticsClient } from '../../../utils'; +import { KinesisClient, PutRecordsCommand } from '@aws-sdk/client-kinesis'; + +/** + * These Records hold cached event buffers and AWS clients. + * The hash key is determined by the region and session, + * consisting of a combined value comprising [region, sessionToken, identityId]. + * + * Only one active session should exist at any given moment. + * When a new session is initiated, the previous ones should be released. + * */ +const eventBufferMap: Record> = {}; +const cachedClients: Record = {}; + +const createKinesisPutRecordsCommand = ( + streamName: string, + events: KinesisBufferEvent[] +): PutRecordsCommand => + new PutRecordsCommand({ + StreamName: streamName, + Records: events.map(event => ({ + PartitionKey: event.partitionKey, + Data: event.event, + })), + }); + +const submitEvents = async ( + events: KinesisBufferEvent[], + client: KinesisClient, + resendLimit?: number +): Promise => { + const groupedByStreamName = Object.entries( + groupBy(event => event.streamName, events) + ); + const requests = groupedByStreamName + .map(([streamName, events]) => + createKinesisPutRecordsCommand(streamName, events) + ) + .map(command => client.send(command)); + + const responses = await Promise.allSettled(requests); + const failedEvents = responses + .map((response, i) => + response.status === 'rejected' ? groupedByStreamName[i][1] : [] + ) + .flat(); + return resendLimit + ? failedEvents + .filter(event => event.retryCount < resendLimit) + .map(event => ({ ...event, retryCount: event.retryCount + 1 })) + .sort((a, b) => a.timestamp - b.timestamp) + : []; +}; + +export const getEventBuffer = ({ + region, + flushInterval, + flushSize, + bufferSize, + credentials, + identityId, + resendLimit, +}: KinesisEventBufferConfig): EventBuffer => { + const { sessionToken } = credentials; + const sessionIdentityKey = [region, sessionToken, identityId] + .filter(x => !!x) + .join('-'); + + if (!eventBufferMap[sessionIdentityKey]) { + const getKinesisClient = (): IAnalyticsClient => { + if (!cachedClients[sessionIdentityKey]) { + cachedClients[sessionIdentityKey] = new KinesisClient({ + credentials, + region, + }); + } + + return events => + submitEvents(events, cachedClients[sessionIdentityKey], resendLimit); + }; + + // create new session + eventBufferMap[sessionIdentityKey] = new EventBuffer( + { + flushInterval, + flushSize, + bufferSize, + }, + getKinesisClient + ); + + // release other sessions + const releaseSessionKeys = Object.keys(eventBufferMap).filter( + x => x !== sessionIdentityKey + ); + for (const releaseSessionKey of releaseSessionKeys) { + eventBufferMap[releaseSessionKey].release(); + delete eventBufferMap[releaseSessionKey]; + delete cachedClients[releaseSessionKey]; + } + } + + return eventBufferMap[sessionIdentityKey]; +}; diff --git a/packages/analytics/src/providers/kinesis/utils/resolveConfig.ts b/packages/analytics/src/providers/kinesis/utils/resolveConfig.ts new file mode 100644 index 00000000000..2eaf8edf4ff --- /dev/null +++ b/packages/analytics/src/providers/kinesis/utils/resolveConfig.ts @@ -0,0 +1,36 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify } from '@aws-amplify/core'; +import { + AnalyticsValidationErrorCode, + assertValidationError, +} from '../../../errors'; +import { DEFAULT_KINESIS_CONFIG } from './constants'; + +export const resolveConfig = () => { + const config = Amplify.getConfig().Analytics?.Kinesis; + const { + region, + bufferSize = DEFAULT_KINESIS_CONFIG.bufferSize, + flushSize = DEFAULT_KINESIS_CONFIG.flushSize, + flushInterval = DEFAULT_KINESIS_CONFIG.flushInterval, + resendLimit, + } = { + ...DEFAULT_KINESIS_CONFIG, + ...config, + }; + + assertValidationError(!!region, AnalyticsValidationErrorCode.NoRegion); + assertValidationError( + flushSize < bufferSize, + AnalyticsValidationErrorCode.InvalidFlushSize + ); + return { + region, + bufferSize, + flushSize, + flushInterval, + resendLimit, + }; +}; diff --git a/packages/analytics/src/types/index.ts b/packages/analytics/src/types/index.ts index 212f96c73f7..2e141714285 100644 --- a/packages/analytics/src/types/index.ts +++ b/packages/analytics/src/types/index.ts @@ -16,3 +16,5 @@ export { export { AnalyticsServiceOptions } from './options'; export { AnalyticsIdentifyUserInput } from './inputs'; + +export { KinesisStream, KinesisShard, KinesisEventData } from './kinesis'; diff --git a/packages/analytics/src/types/kinesis.ts b/packages/analytics/src/types/kinesis.ts new file mode 100644 index 00000000000..eb09b36f729 --- /dev/null +++ b/packages/analytics/src/types/kinesis.ts @@ -0,0 +1,13 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export type KinesisStream = { + region: string; + streamName: string; +}; + +export type KinesisShard = KinesisStream & { + partitionKey: string; +}; + +export type KinesisEventData = Record | Uint8Array; diff --git a/packages/analytics/src/utils/eventBuffer/EventBuffer.ts b/packages/analytics/src/utils/eventBuffer/EventBuffer.ts new file mode 100644 index 00000000000..cd26027a9a5 --- /dev/null +++ b/packages/analytics/src/utils/eventBuffer/EventBuffer.ts @@ -0,0 +1,80 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ConsoleLogger } from '@aws-amplify/core/lib/Logger'; +import { EventBufferConfig, IAnalyticsClient } from './'; + +const logger = new ConsoleLogger('EventBuffer'); + +export class EventBuffer { + private list: T[]; + private readonly config: EventBufferConfig; + private getAnalyticsClient: () => IAnalyticsClient; + + private timer?: ReturnType; + + constructor( + config: EventBufferConfig, + getAnalyticsClient: () => IAnalyticsClient + ) { + this.list = []; + this.config = config; + this.getAnalyticsClient = getAnalyticsClient; + this.startEventLoop(); + } + + public append(...events: T[]) { + for (const event of events) { + if (this.list.length + 1 > this.config.bufferSize) { + logger.debug( + `Exceed ${typeof event} event buffer limits, event dropped` + ); + continue; + } + this.list.push(event); + } + } + + public flushAll(): Promise { + return this.submitEvents(this.list.length); + } + + public release() { + this.list = []; + if (this.timer) { + clearInterval(this.timer); + } + } + + private head(count: number) { + return this.list.splice(0, count); + } + + private insertAtBeginning(...data: T[]) { + this.list.unshift(...data); + } + + private startEventLoop() { + if (this.timer) { + clearInterval(this.timer); + } + + const { flushSize, flushInterval } = this.config; + setInterval(() => { + this.submitEvents(flushSize); + }, flushInterval); + } + + private submitEvents(count: number): Promise { + const events = this.head(count); + if (events.length === 0) { + return Promise.resolve(); + } + + return this.getAnalyticsClient()(events).then(result => { + if (result.length > 0) { + this.insertAtBeginning(...result); + } + }); + } +} diff --git a/packages/analytics/src/utils/eventBuffer/index.ts b/packages/analytics/src/utils/eventBuffer/index.ts new file mode 100644 index 00000000000..10ccbbb2a4c --- /dev/null +++ b/packages/analytics/src/utils/eventBuffer/index.ts @@ -0,0 +1,5 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { EventBuffer } from './EventBuffer'; +export { EventBufferConfig, IAnalyticsClient } from './types'; diff --git a/packages/analytics/src/utils/eventBuffer/types.ts b/packages/analytics/src/utils/eventBuffer/types.ts new file mode 100644 index 00000000000..0907a175389 --- /dev/null +++ b/packages/analytics/src/utils/eventBuffer/types.ts @@ -0,0 +1,12 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export interface IAnalyticsClient { + (events: T[]): Promise; +} + +export type EventBufferConfig = { + flushSize: number; + flushInterval: number; + bufferSize: number; +}; diff --git a/packages/analytics/src/utils/groupBy.ts b/packages/analytics/src/utils/groupBy.ts new file mode 100644 index 00000000000..014483a195f --- /dev/null +++ b/packages/analytics/src/utils/groupBy.ts @@ -0,0 +1,12 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const groupBy = ( + getGroupId: (x: T) => string, + list: T[] +): Record => { + return list.reduce((result, current) => { + const groupId = getGroupId(current); + return { ...result, [groupId]: [...(result[groupId] ?? []), current] }; + }, {} as Record); +}; diff --git a/packages/analytics/src/utils/index.ts b/packages/analytics/src/utils/index.ts index 5c68bbcf3cf..0a32ad150ce 100644 --- a/packages/analytics/src/utils/index.ts +++ b/packages/analytics/src/utils/index.ts @@ -1,6 +1,13 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +export { resolveCredentials } from './resolveCredentials'; +export { groupBy } from './groupBy'; +export { + EventBuffer, + IAnalyticsClient, + EventBufferConfig, +} from './eventBuffer'; export { enableAnalytics, disableAnalytics, diff --git a/packages/analytics/src/utils/resolveCredentials.ts b/packages/analytics/src/utils/resolveCredentials.ts new file mode 100644 index 00000000000..53047419f45 --- /dev/null +++ b/packages/analytics/src/utils/resolveCredentials.ts @@ -0,0 +1,14 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AnalyticsValidationErrorCode, assertValidationError } from '../errors'; +import { fetchAuthSession } from '@aws-amplify/core'; + +export const resolveCredentials = async () => { + const { credentials, identityId } = await fetchAuthSession(); + assertValidationError( + !!credentials, + AnalyticsValidationErrorCode.NoCredentials + ); + return { credentials, identityId }; +}; diff --git a/packages/aws-amplify/__tests__/exports.test.ts b/packages/aws-amplify/__tests__/exports.test.ts index 13c0c21b390..75c4c45f23b 100644 --- a/packages/aws-amplify/__tests__/exports.test.ts +++ b/packages/aws-amplify/__tests__/exports.test.ts @@ -9,6 +9,7 @@ import * as analyticsTopLevelExports from '../src/analytics'; import * as analyticsPinpointExports from '../src/analytics/pinpoint'; import * as inAppMessagingTopLevelExports from '../src/in-app-messaging'; import * as inAppMessagingPinpointTopLevelExports from '../src/in-app-messaging/pinpoint'; +import * as analyticsKinesisExports from '../src/analytics/kinesis'; import * as storageTopLevelExports from '../src/storage'; import * as storageS3Exports from '../src/storage/s3'; @@ -60,6 +61,14 @@ describe('aws-amplify Exports', () => { ] `); }); + + it('should only export expected symbols from the Kinesis provider', () => { + expect(Object.keys(analyticsKinesisExports)).toMatchInlineSnapshot(` + Array [ + "record", + ] + `); + }); }); describe('InAppMessaging exports', () => { diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index 08bd2d64371..c94235e4f8a 100644 --- a/packages/aws-amplify/package.json +++ b/packages/aws-amplify/package.json @@ -237,13 +237,13 @@ "name": "[Analytics] enable", "path": "./lib-esm/analytics/index.js", "import": "{ enable }", - "limit": "0.025 kB" + "limit": "0.50 kB" }, { "name": "[Analytics] disable", "path": "./lib-esm/analytics/index.js", "import": "{ disable }", - "limit": "0.025 kB" + "limit": "0.50 kB" }, { "name": "[API] class (AppSync)", diff --git a/packages/core/src/Signer/DateUtils.ts b/packages/core/src/Signer/DateUtils.ts index 03bf47b2a22..77031d9ac85 100644 --- a/packages/core/src/Signer/DateUtils.ts +++ b/packages/core/src/Signer/DateUtils.ts @@ -46,7 +46,7 @@ export const DateUtils: DateUtils = { }, getDateFromHeaderString(header: string) { - const [,year, month, day, hour, minute, second] = header.match( + const [, year, month, day, hour, minute, second] = header.match( /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2}).+/ ) as any[]; diff --git a/packages/core/src/providers/kinesis/types/index.ts b/packages/core/src/providers/kinesis/types/index.ts new file mode 100644 index 00000000000..512a93e5e29 --- /dev/null +++ b/packages/core/src/providers/kinesis/types/index.ts @@ -0,0 +1,4 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export * from './kinesis'; diff --git a/packages/core/src/providers/kinesis/types/kinesis.ts b/packages/core/src/providers/kinesis/types/kinesis.ts new file mode 100644 index 00000000000..e98e54558e3 --- /dev/null +++ b/packages/core/src/providers/kinesis/types/kinesis.ts @@ -0,0 +1,12 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export type KinesisProviderConfig = { + Kinesis?: { + region: string; + bufferSize?: number; + flushSize?: number; + flushInterval?: number; + resendLimit?: number; + }; +}; diff --git a/packages/core/src/providers/pinpoint/types/pinpoint.ts b/packages/core/src/providers/pinpoint/types/pinpoint.ts index 22378fa4c67..1deacfd9361 100644 --- a/packages/core/src/providers/pinpoint/types/pinpoint.ts +++ b/packages/core/src/providers/pinpoint/types/pinpoint.ts @@ -12,7 +12,7 @@ export type SupportedCategory = export type SupportedChannelType = 'APNS' | 'APNS_SANDBOX' | 'GCM' | 'IN_APP'; export type PinpointProviderConfig = { - Pinpoint: { + Pinpoint?: { appId: string; region: string; }; diff --git a/packages/core/src/singleton/Analytics/types.ts b/packages/core/src/singleton/Analytics/types.ts index 2ff7b66b3a7..48ff88bc214 100644 --- a/packages/core/src/singleton/Analytics/types.ts +++ b/packages/core/src/singleton/Analytics/types.ts @@ -2,5 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import { PinpointProviderConfig } from '../../providers/pinpoint/types'; +import { KinesisProviderConfig } from '../../providers/kinesis/types'; -export type AnalyticsConfig = PinpointProviderConfig; +export type AnalyticsConfig = PinpointProviderConfig & KinesisProviderConfig; diff --git a/yarn.lock b/yarn.lock index a45a8f7a95b..bf60cb95c02 100644 --- a/yarn.lock +++ b/yarn.lock @@ -541,11 +541,11 @@ tslib "^2.5.0" "@aws-sdk/types@^3.222.0": - version "3.418.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.418.0.tgz#c23213110b0c313d5546c810da032a441682f49a" - integrity sha512-y4PQSH+ulfFLY0+FYkaK4qbIaQI9IJNMO2xsxukW6/aNoApNymN1D2FSi2la8Qbp/iPjNDKsG8suNPm9NtsWXQ== + version "3.425.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.425.0.tgz#8d4e94743a69c865a83785a9f3bcfd49945836f7" + integrity sha512-6lqbmorwerN4v+J5dqbHPAsjynI0mkEF+blf+69QTaKKGaxBBVaXgqoqul9RXYcK5MMrrYRbQIMd0zYOoy90kA== dependencies: - "@smithy/types" "^2.3.3" + "@smithy/types" "^2.3.4" tslib "^2.5.0" "@aws-sdk/util-endpoints@3.398.0": @@ -2274,55 +2274,55 @@ write-pkg "4.0.0" yargs "16.2.0" -"@next/env@13.5.3": - version "13.5.3" - resolved "https://registry.yarnpkg.com/@next/env/-/env-13.5.3.tgz#402da9a0af87f93d853519f0c2a602b1ab637c2c" - integrity sha512-X4te86vsbjsB7iO4usY9jLPtZ827Mbx+WcwNBGUOIuswuTAKQtzsuoxc/6KLxCMvogKG795MhrR1LDhYgDvasg== - -"@next/swc-darwin-arm64@13.5.3": - version "13.5.3" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.5.3.tgz#f72eac8c7b71d33e0768bd3c8baf68b00fea0160" - integrity sha512-6hiYNJxJmyYvvKGrVThzo4nTcqvqUTA/JvKim7Auaj33NexDqSNwN5YrrQu+QhZJCIpv2tULSHt+lf+rUflLSw== - -"@next/swc-darwin-x64@13.5.3": - version "13.5.3" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-13.5.3.tgz#96eda3a1247a713579eb241d76d3f503291c8938" - integrity sha512-UpBKxu2ob9scbpJyEq/xPgpdrgBgN3aLYlxyGqlYX5/KnwpJpFuIHU2lx8upQQ7L+MEmz+fA1XSgesoK92ppwQ== - -"@next/swc-linux-arm64-gnu@13.5.3": - version "13.5.3" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.5.3.tgz#132e155a029310fffcdfd3e3c4255f7ce9fd2714" - integrity sha512-5AzM7Yx1Ky+oLY6pHs7tjONTF22JirDPd5Jw/3/NazJ73uGB05NqhGhB4SbeCchg7SlVYVBeRMrMSZwJwq/xoA== - -"@next/swc-linux-arm64-musl@13.5.3": - version "13.5.3" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.5.3.tgz#981d7d8fdcf040bd0c89588ef4139c28805f5cf1" - integrity sha512-A/C1shbyUhj7wRtokmn73eBksjTM7fFQoY2v/0rTM5wehpkjQRLOXI8WJsag2uLhnZ4ii5OzR1rFPwoD9cvOgA== - -"@next/swc-linux-x64-gnu@13.5.3": - version "13.5.3" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.5.3.tgz#b8263663acda7b84bc2c4ffa39ca4b0172a78060" - integrity sha512-FubPuw/Boz8tKkk+5eOuDHOpk36F80rbgxlx4+xty/U71e3wZZxVYHfZXmf0IRToBn1Crb8WvLM9OYj/Ur815g== - -"@next/swc-linux-x64-musl@13.5.3": - version "13.5.3" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.5.3.tgz#cd0bed8ee92032c25090bed9d95602ac698d925f" - integrity sha512-DPw8nFuM1uEpbX47tM3wiXIR0Qa+atSzs9Q3peY1urkhofx44o7E1svnq+a5Q0r8lAcssLrwiM+OyJJgV/oj7g== - -"@next/swc-win32-arm64-msvc@13.5.3": - version "13.5.3" - resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.5.3.tgz#7f556674ca97e6936220d10c58252cc36522d80a" - integrity sha512-zBPSP8cHL51Gub/YV8UUePW7AVGukp2D8JU93IHbVDu2qmhFAn9LWXiOOLKplZQKxnIPUkJTQAJDCWBWU4UWUA== - -"@next/swc-win32-ia32-msvc@13.5.3": - version "13.5.3" - resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.5.3.tgz#4912721fb8695f11daec4cde42e73dc57bcc479f" - integrity sha512-ONcL/lYyGUj4W37D4I2I450SZtSenmFAvapkJQNIJhrPMhzDU/AdfLkW98NvH1D2+7FXwe7yclf3+B7v28uzBQ== - -"@next/swc-win32-x64-msvc@13.5.3": - version "13.5.3" - resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.5.3.tgz#97340a709febb60ff73003566b99d127d4e5b881" - integrity sha512-2Vz2tYWaLqJvLcWbbTlJ5k9AN6JD7a5CN2pAeIzpbecK8ZF/yobA39cXtv6e+Z8c5UJuVOmaTldEAIxvsIux/Q== +"@next/env@13.5.4": + version "13.5.4" + resolved "https://registry.yarnpkg.com/@next/env/-/env-13.5.4.tgz#777c3af16de2cf2f611b6c8126910062d13d222c" + integrity sha512-LGegJkMvRNw90WWphGJ3RMHMVplYcOfRWf2Be3td3sUa+1AaxmsYyANsA+znrGCBjXJNi4XAQlSoEfUxs/4kIQ== + +"@next/swc-darwin-arm64@13.5.4": + version "13.5.4" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.5.4.tgz#241957774fef3f876dc714cfc0ca6f00f561737e" + integrity sha512-Df8SHuXgF1p+aonBMcDPEsaahNo2TCwuie7VXED4FVyECvdXfRT9unapm54NssV9tF3OQFKBFOdlje4T43VO0w== + +"@next/swc-darwin-x64@13.5.4": + version "13.5.4" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-13.5.4.tgz#fa11bb97bf06cd45cbd554354b46bf93e22c025b" + integrity sha512-siPuUwO45PnNRMeZnSa8n/Lye5ZX93IJom9wQRB5DEOdFrw0JjOMu1GINB8jAEdwa7Vdyn1oJ2xGNaQpdQQ9Pw== + +"@next/swc-linux-arm64-gnu@13.5.4": + version "13.5.4" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.5.4.tgz#dd3a482cd6871ed23b049066a0f3c4c2f955dc88" + integrity sha512-l/k/fvRP/zmB2jkFMfefmFkyZbDkYW0mRM/LB+tH5u9pB98WsHXC0WvDHlGCYp3CH/jlkJPL7gN8nkTQVrQ/2w== + +"@next/swc-linux-arm64-musl@13.5.4": + version "13.5.4" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.5.4.tgz#ed6d7abaf5712cff2752ce5300d6bacc6aff1b18" + integrity sha512-YYGb7SlLkI+XqfQa8VPErljb7k9nUnhhRrVaOdfJNCaQnHBcvbT7cx/UjDQLdleJcfyg1Hkn5YSSIeVfjgmkTg== + +"@next/swc-linux-x64-gnu@13.5.4": + version "13.5.4" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.5.4.tgz#977a040388e8a685a3a85e0dbdff90a4ee2a7189" + integrity sha512-uE61vyUSClnCH18YHjA8tE1prr/PBFlBFhxBZis4XBRJoR+txAky5d7gGNUIbQ8sZZ7LVkSVgm/5Fc7mwXmRAg== + +"@next/swc-linux-x64-musl@13.5.4": + version "13.5.4" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.5.4.tgz#3e29a0ad8efc016196c3a120da04397eea328b2a" + integrity sha512-qVEKFYML/GvJSy9CfYqAdUexA6M5AklYcQCW+8JECmkQHGoPxCf04iMh7CPR7wkHyWWK+XLt4Ja7hhsPJtSnhg== + +"@next/swc-win32-arm64-msvc@13.5.4": + version "13.5.4" + resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.5.4.tgz#18a236c3fe5a48d24b56d939e6a05488bb682b7e" + integrity sha512-mDSQfqxAlfpeZOLPxLymZkX0hYF3juN57W6vFHTvwKlnHfmh12Pt7hPIRLYIShk8uYRsKPtMTth/EzpwRI+u8w== + +"@next/swc-win32-ia32-msvc@13.5.4": + version "13.5.4" + resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.5.4.tgz#255132243ab6fb20d3c7c92a585e2c4fa50368fe" + integrity sha512-aoqAT2XIekIWoriwzOmGFAvTtVY5O7JjV21giozBTP5c6uZhpvTWRbmHXbmsjZqY4HnEZQRXWkSAppsIBweKqw== + +"@next/swc-win32-x64-msvc@13.5.4": + version "13.5.4" + resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.5.4.tgz#cc542907b55247c5634d9a8298e1c143a1847e25" + integrity sha512-cyRvlAxwlddlqeB9xtPSfNSCRy8BOa4wtMo0IuI9P7Y0XT2qpDrpFKRyZ7kUngZis59mPVla5k8X1oOJ8RxDYg== "@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3": version "2.1.8-no-fsevents.3" @@ -3485,10 +3485,10 @@ "@smithy/types" "^2.3.4" tslib "^2.5.0" -"@smithy/fetch-http-handler@^2.0.5", "@smithy/fetch-http-handler@^2.2.0": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@smithy/fetch-http-handler/-/fetch-http-handler-2.2.0.tgz#f430f7721f66618a0979231e446567665c61866c" - integrity sha512-P2808PM0CsEkXj3rnQAi3QyqRbAAi8iuePYUB5GveJ+dVd1WMv03NM+CYCI14IGXt1j/r7jHGvMJHO+Gv+kdMQ== +"@smithy/fetch-http-handler@^2.0.5", "@smithy/fetch-http-handler@^2.2.1": + version "2.2.1" + resolved "https://registry.yarnpkg.com/@smithy/fetch-http-handler/-/fetch-http-handler-2.2.1.tgz#a8abbd339c2c3d76456f4d16e65cf934727fc7ad" + integrity sha512-bXyM8PBAIKxVV++2ZSNBEposTDjFQ31XWOdHED+2hWMNvJHUoQqFbECg/uhcVOa6vHie2/UnzIZfXBSTpDBnEw== dependencies: "@smithy/protocol-http" "^3.0.6" "@smithy/querystring-builder" "^2.0.10" @@ -3671,17 +3671,17 @@ "@smithy/util-utf8" "^2.0.0" tslib "^2.5.0" -"@smithy/smithy-client@^2.0.5", "@smithy/smithy-client@^2.1.8": - version "2.1.8" - resolved "https://registry.yarnpkg.com/@smithy/smithy-client/-/smithy-client-2.1.8.tgz#aa9dcb483aa177ed0515463320da7c43bd4ec407" - integrity sha512-Puuc4wuhdTSs8wstkNJ/JtpaFwIh0qDE27zawfRVzzjpXprpT+4wROqO2+NVoZ+6GKv7kz7QgZx6AI5325bSeQ== +"@smithy/smithy-client@^2.0.5", "@smithy/smithy-client@^2.1.9": + version "2.1.9" + resolved "https://registry.yarnpkg.com/@smithy/smithy-client/-/smithy-client-2.1.9.tgz#5a0a185947ae4e66d12d2a6135628dd2fc36924c" + integrity sha512-HTicQSn/lOcXKJT+DKJ4YMu51S6PzbWsO8Z6Pwueo30mSoFKXg5P0BDkg2VCDqCVR0mtddM/F6hKhjW6YAV/yg== dependencies: "@smithy/middleware-stack" "^2.0.4" "@smithy/types" "^2.3.4" - "@smithy/util-stream" "^2.0.13" + "@smithy/util-stream" "^2.0.14" tslib "^2.5.0" -"@smithy/types@^2.1.0", "@smithy/types@^2.2.2", "@smithy/types@^2.3.1", "@smithy/types@^2.3.3", "@smithy/types@^2.3.4": +"@smithy/types@^2.1.0", "@smithy/types@^2.2.2", "@smithy/types@^2.3.1", "@smithy/types@^2.3.4": version "2.3.4" resolved "https://registry.yarnpkg.com/@smithy/types/-/types-2.3.4.tgz#3b9bc15000af0a0b1f4fda741f78c1580ba15e92" integrity sha512-D7xlM9FOMFyFw7YnMXn9dK2KuN6+JhnrZwVt1fWaIu8hCk5CigysweeIT/H/nCo4YV+s8/oqUdLfexbkPZtvqw== @@ -3735,26 +3735,26 @@ tslib "^2.5.0" "@smithy/util-defaults-mode-browser@^2.0.5": - version "2.0.12" - resolved "https://registry.yarnpkg.com/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-2.0.12.tgz#38c040f11636cb17110c25c75a61bd5d83ced0b1" - integrity sha512-BCsFPdNThMS2312/Zj3/TtFsXfO2BwkbDNsoWbdtZ0cAv9cE6vqGKllYXmq2Gj6u+Vv8V3wUgBUicNol6s/7Sg== + version "2.0.13" + resolved "https://registry.yarnpkg.com/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-2.0.13.tgz#8136955f1bef6e66cb8a8702693e7685dcd33e26" + integrity sha512-UmmOdUzaQjqdsl1EjbpEaQxM0VDFqTj6zDuI26/hXN7L/a1k1koTwkYpogHMvunDX3fjrQusg5gv1Td4UsGyog== dependencies: "@smithy/property-provider" "^2.0.11" - "@smithy/smithy-client" "^2.1.8" + "@smithy/smithy-client" "^2.1.9" "@smithy/types" "^2.3.4" bowser "^2.11.0" tslib "^2.5.0" "@smithy/util-defaults-mode-node@^2.0.5": - version "2.0.14" - resolved "https://registry.yarnpkg.com/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-2.0.14.tgz#fe8caddaef3fde4f0640ce8d17273b5aeec18d96" - integrity sha512-EtomtYsWDkBGs0fLeF+7N2df+zIqGix+O4llWqQD+97rbo2hk+GBWeZzBkujKrzFeXNUbPkFqfvZPLdoq4S4XQ== + version "2.0.15" + resolved "https://registry.yarnpkg.com/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-2.0.15.tgz#24f7b9de978206909ced7b522f24e7f450187372" + integrity sha512-g6J7MHAibVPMTlXyH3mL+Iet4lMJKFVhsOhJmn+IKG81uy9m42CkRSDlwdQSJAcprLQBIaOPdFxNXQvrg2w1Uw== dependencies: "@smithy/config-resolver" "^2.0.11" "@smithy/credential-provider-imds" "^2.0.13" "@smithy/node-config-provider" "^2.0.13" "@smithy/property-provider" "^2.0.11" - "@smithy/smithy-client" "^2.1.8" + "@smithy/smithy-client" "^2.1.9" "@smithy/types" "^2.3.4" tslib "^2.5.0" @@ -3782,12 +3782,12 @@ "@smithy/types" "^2.3.4" tslib "^2.5.0" -"@smithy/util-stream@^2.0.13", "@smithy/util-stream@^2.0.5": - version "2.0.13" - resolved "https://registry.yarnpkg.com/@smithy/util-stream/-/util-stream-2.0.13.tgz#8c18d21446a470f795b1d30df52696ed4c725f94" - integrity sha512-aeua6pN0WMdQtZNRRJ8J+mop57fezLMsApYbk5Q3q11pyHwZypVPuKoelr7K9PMJZcuYk90dQyUsUAd7hTCeRg== +"@smithy/util-stream@^2.0.14", "@smithy/util-stream@^2.0.5": + version "2.0.14" + resolved "https://registry.yarnpkg.com/@smithy/util-stream/-/util-stream-2.0.14.tgz#3fdd934e2bced80331dcaff18aefbcfe39ebf3cd" + integrity sha512-XjvlDYe+9DieXhLf7p+EgkXwFtl34kHZcWfHnc5KaILbhyVfDLWuqKTFx6WwCFqb01iFIig8trGwExRIqqkBYg== dependencies: - "@smithy/fetch-http-handler" "^2.2.0" + "@smithy/fetch-http-handler" "^2.2.1" "@smithy/node-http-handler" "^2.1.6" "@smithy/types" "^2.3.4" "@smithy/util-base64" "^2.0.0" @@ -3803,7 +3803,7 @@ dependencies: tslib "^2.5.0" -"@smithy/util-utf8@^2.0.0": +"@smithy/util-utf8@2.0.0", "@smithy/util-utf8@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@smithy/util-utf8/-/util-utf8-2.0.0.tgz#b4da87566ea7757435e153799df9da717262ad42" integrity sha512-rctU1VkziY84n5OXe3bPNpKR001ZCME2JCaBBFgtiM2hfKbHFudc/BkMuPab8hRbLd0j3vbnBTTZ1igBf0wgiQ== @@ -4168,14 +4168,14 @@ integrity sha512-ZYFzrvyWUNhaPomn80dsMNgMeXxNWZBdkuG/hWlUvXvbdUH8ZERNBGXnU87McuGcWDsyzX2aChCv/SVN348k3A== "@types/node@*", "@types/node@^20.3.1": - version "20.8.0" - resolved "https://registry.yarnpkg.com/@types/node/-/node-20.8.0.tgz#10ddf0119cf20028781c06d7115562934e53f745" - integrity sha512-LzcWltT83s1bthcvjBmiBvGJiiUe84NWRHkw+ZV6Fr41z2FbIzvc815dk2nQ3RAKMuN2fkenM/z3Xv2QzEpYxQ== + version "20.8.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.8.2.tgz#d76fb80d87d0d8abfe334fc6d292e83e5524efc4" + integrity sha512-Vvycsc9FQdwhxE3y3DzeIxuEJbWGDsnrxvMADzTDF/lcdR9/K+AQIeAghTQsHtotg/q0j3WEOYS/jQgSdWue3w== "@types/node@^16.11.7": - version "16.18.55" - resolved "https://registry.yarnpkg.com/@types/node/-/node-16.18.55.tgz#3d9ac633ed401238c13ccaeed54297bd653412a3" - integrity sha512-Y1zz/LIuJek01+hlPNzzXQhmq/Z2BCP96j18MSXC0S0jSu/IG4FFxmBs7W4/lI2vPJ7foVfEB0hUVtnOjnCiTg== + version "16.18.57" + resolved "https://registry.yarnpkg.com/@types/node/-/node-16.18.57.tgz#1ba31c0e5c403aab90a3b7826576e6782ded779b" + integrity sha512-piPoDozdPaX1hNWFJQzzgWqE40gh986VvVx/QO9RU4qYRE55ld7iepDVgZ3ccGUw0R4wge0Oy1dd+3xOQNkkUQ== "@types/node@^8.9.5": version "8.10.66" @@ -4211,9 +4211,9 @@ "@types/node" "*" "@types/react-dom@^18.2.6": - version "18.2.8" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.8.tgz#338f1b0a646c9f10e0a97208c1d26b9f473dffd6" - integrity sha512-bAIvO5lN/U8sPGvs1Xm61rlRHHaq5rp5N3kp9C+NJ/Q41P8iqjkXSu0+/qu8POsjH9pNWb0OYabFez7taP7omw== + version "18.2.10" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.10.tgz#06247cb600e39b63a0a385f6a5014c44bab296f2" + integrity sha512-5VEC5RgXIk1HHdyN1pHlg0cOqnxHzvPGpMMyGAP5qSaDRmyZNDaQ0kkVAkK6NYlDhP6YBID3llaXlmAS/mdgCA== dependencies: "@types/react" "*" @@ -5445,9 +5445,9 @@ camelcase@^6.0.0, camelcase@^6.2.0: integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== caniuse-lite@^1.0.30001406, caniuse-lite@^1.0.30001541: - version "1.0.30001542" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001542.tgz#823ddb5aed0a70d5e2bfb49126478e84e9514b85" - integrity sha512-UrtAXVcj1mvPBFQ4sKd38daP8dEcXXr5sQe6QNNinaPd0iA/cxg9/l3VrSdL73jgw5sKyuQ6jNgiKO12W3SsVA== + version "1.0.30001543" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001543.tgz#478a3e9dddbb353c5ab214b0ecb0dbed529ed1d8" + integrity sha512-qxdO8KPWPQ+Zk6bvNpPeQIOH47qZSYdFZd6dXQzb2KzhnSXju4Kd7H1PkSJx6NICSMgo/IhRZRhhfPTHYpJUCA== capture-exit@^2.0.0: version "2.0.0" @@ -5538,9 +5538,9 @@ ci-info@^2.0.0: integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== ci-info@^3.2.0, ci-info@^3.6.1: - version "3.8.0" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.8.0.tgz#81408265a5380c929f0bc665d62256628ce9ef91" - integrity sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw== + version "3.9.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" + integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== class-utils@^0.3.5: version "0.3.6" @@ -6459,9 +6459,9 @@ ejs@^3.1.7: jake "^10.8.5" electron-to-chromium@^1.4.535: - version "1.4.538" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.538.tgz#86d6b60a8b7e0af3d2aaad3f4ba5a33838cc72ea" - integrity sha512-1a2m63NEookb1beNFTGDihgF3CKL7ksZ7PSA0VloON5DpTEhnOVgaDes8xkrDhkXRxlcN8JymQDGnv+Nn+uvhg== + version "1.4.540" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.540.tgz#c685f2f035e93eb21dd6a9cfe2c735bad8f77401" + integrity sha512-aoCqgU6r9+o9/S7wkcSbmPRFi7OWZWiXS9rtjEd+Ouyu/Xyw5RSq2XN8s5Qp8IaFOLiRrhQCphCIjAxgG3eCAg== emoji-regex@^7.0.1: version "7.0.3" @@ -7780,11 +7780,9 @@ has-values@^1.0.0: kind-of "^4.0.0" has@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" - integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== - dependencies: - function-bind "^1.1.1" + version "1.0.4" + resolved "https://registry.yarnpkg.com/has/-/has-1.0.4.tgz#2eb2860e000011dae4f1406a86fe80e530fb2ec6" + integrity sha512-qdSAmqLF6209RFj4VVItywPMbm3vWylknmB3nvNiUIs72xAimcM8nVYxYr7ncvZq5qzk9MKIZR8ijqD/1QuYjQ== hermes-estree@0.12.0: version "0.12.0" @@ -9175,9 +9173,9 @@ jest@^24.x.x: jest-cli "^24.9.0" joi@^17.2.1: - version "17.10.2" - resolved "https://registry.yarnpkg.com/joi/-/joi-17.10.2.tgz#4ecc348aa89ede0b48335aad172e0f5591e55b29" - integrity sha512-hcVhjBxRNW/is3nNLdGLIjkgXetkeGc2wyhydhz8KumG23Aerk4HPjU5zaPAMRqXQFc0xNqXTC7+zQjxr0GlKA== + version "17.11.0" + resolved "https://registry.yarnpkg.com/joi/-/joi-17.11.0.tgz#aa9da753578ec7720e6f0ca2c7046996ed04fc1a" + integrity sha512-NgB+lZLNoqISVy1rZocE9PZI36bL/77ie924Ri43yEvi9GUUMPeyVIr8KdFTMUlby1p0PBYMk9spIxEUQYqrJQ== dependencies: "@hapi/hoek" "^9.0.0" "@hapi/topo" "^5.0.0" @@ -11252,7 +11250,7 @@ nan@^2.12.1: resolved "https://registry.yarnpkg.com/nan/-/nan-2.18.0.tgz#26a6faae7ffbeb293a39660e88a76b82e30b7554" integrity sha512-W7tfG7vMOGtD30sHoZSSc/JVYiyDPEyQVso/Zz+/uQd0B0L46gtC+pHha5FFMRpil6fm/AoEcRWyOVi4+E/f8w== -nanoid@^3.3.4, nanoid@^3.3.6: +nanoid@^3.3.6: version "3.3.6" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c" integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA== @@ -11302,28 +11300,27 @@ neo-async@^2.5.0, neo-async@^2.6.2: integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== "next@>= 13.4.0 < 14.0.0": - version "13.5.3" - resolved "https://registry.yarnpkg.com/next/-/next-13.5.3.tgz#631efcbcc9d756c610855d9b94f3d8c4e73ee131" - integrity sha512-4Nt4HRLYDW/yRpJ/QR2t1v63UOMS55A38dnWv3UDOWGezuY0ZyFO1ABNbD7mulVzs9qVhgy2+ppjdsANpKP1mg== + version "13.5.4" + resolved "https://registry.yarnpkg.com/next/-/next-13.5.4.tgz#7e6a93c9c2b9a2c78bf6906a6c5cc73ae02d5b4d" + integrity sha512-+93un5S779gho8y9ASQhb/bTkQF17FNQOtXLKAj3lsNgltEcF0C5PMLLncDmH+8X1EnJH1kbqAERa29nRXqhjA== dependencies: - "@next/env" "13.5.3" + "@next/env" "13.5.4" "@swc/helpers" "0.5.2" busboy "1.6.0" caniuse-lite "^1.0.30001406" - postcss "8.4.14" + postcss "8.4.31" styled-jsx "5.1.1" watchpack "2.4.0" - zod "3.21.4" optionalDependencies: - "@next/swc-darwin-arm64" "13.5.3" - "@next/swc-darwin-x64" "13.5.3" - "@next/swc-linux-arm64-gnu" "13.5.3" - "@next/swc-linux-arm64-musl" "13.5.3" - "@next/swc-linux-x64-gnu" "13.5.3" - "@next/swc-linux-x64-musl" "13.5.3" - "@next/swc-win32-arm64-msvc" "13.5.3" - "@next/swc-win32-ia32-msvc" "13.5.3" - "@next/swc-win32-x64-msvc" "13.5.3" + "@next/swc-darwin-arm64" "13.5.4" + "@next/swc-darwin-x64" "13.5.4" + "@next/swc-linux-arm64-gnu" "13.5.4" + "@next/swc-linux-arm64-musl" "13.5.4" + "@next/swc-linux-x64-gnu" "13.5.4" + "@next/swc-linux-x64-musl" "13.5.4" + "@next/swc-win32-arm64-msvc" "13.5.4" + "@next/swc-win32-ia32-msvc" "13.5.4" + "@next/swc-win32-x64-msvc" "13.5.4" nice-try@^1.0.4: version "1.0.5" @@ -12348,12 +12345,12 @@ postcss-selector-parser@^6.0.10: cssesc "^3.0.0" util-deprecate "^1.0.2" -postcss@8.4.14: - version "8.4.14" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.14.tgz#ee9274d5622b4858c1007a74d76e42e56fd21caf" - integrity sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig== +postcss@8.4.31: + version "8.4.31" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d" + integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ== dependencies: - nanoid "^3.3.4" + nanoid "^3.3.6" picocolors "^1.0.0" source-map-js "^1.0.2" @@ -12602,9 +12599,9 @@ react-devtools-core@4.24.0: ws "^7" react-devtools-core@^4.27.2: - version "4.28.0" - resolved "https://registry.yarnpkg.com/react-devtools-core/-/react-devtools-core-4.28.0.tgz#3fa18709b24414adddadac33b6b9cea96db60f2f" - integrity sha512-E3C3X1skWBdBzwpOUbmXG8SgH6BtsluSMe+s6rRcujNKG1DGi8uIfhdhszkgDpAsMoE55hwqRUzeXCmETDBpTg== + version "4.28.4" + resolved "https://registry.yarnpkg.com/react-devtools-core/-/react-devtools-core-4.28.4.tgz#fb8183eada77093f4c2f9830e664bf22255abe27" + integrity sha512-IUZKLv3CimeM07G3vX4H4loxVpByrzq3HvfTX7v9migalwvLs9ZY5D3S3pKR33U+GguYfBBdMMZyToFhsSE/iQ== dependencies: shell-quote "^1.6.1" ws "^7" @@ -14372,9 +14369,9 @@ terser-webpack-plugin@^5.3.6, terser-webpack-plugin@^5.3.7: terser "^5.16.8" terser@^5.15.0, terser@^5.16.8: - version "5.20.0" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.20.0.tgz#ea42aea62578703e33def47d5c5b93c49772423e" - integrity sha512-e56ETryaQDyebBwJIWYB2TT6f2EZ0fL0sW/JRXNMN26zZdKi2u/E/5my5lG6jNxym6qsrVXfFRmOdV42zlAgLQ== + version "5.21.0" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.21.0.tgz#d2b27e92b5e56650bc83b6defa00a110f0b124b2" + integrity sha512-WtnFKrxu9kaoXuiZFSGrcAvvBqAdmKx0SFNmVNYdJamMu9yyN3I/QF0FbH4QcqJQ+y1CJnzxGIKH0cSj+FGYRw== dependencies: "@jridgewell/source-map" "^0.3.3" acorn "^8.8.2" @@ -15791,8 +15788,3 @@ yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== - -zod@3.21.4: - version "3.21.4" - resolved "https://registry.yarnpkg.com/zod/-/zod-3.21.4.tgz#10882231d992519f0a10b5dd58a38c9dabbb64db" - integrity sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw== From ad0ce0c1b2c1f659e36de2c417910f87926424a6 Mon Sep 17 00:00:00 2001 From: Di Wu Date: Fri, 29 Sep 2023 15:41:12 -0700 Subject: [PATCH 10/20] feat(analytics): add record API for service provider kinesis firehose (#12148) * feat(analytics): add record API for service provider kinesis firehose * resolve comments --- .../kinesis-firehose/apis/record.test.ts | 84 +++++++++++++ .../utils/getEventBuffer.test.ts | 60 +++++++++ .../utils/resolveConfig.test.ts | 68 ++++++++++ .../providers/kinesis/apis/record.test.ts | 31 +++-- .../providers/kinesis-firehose/apis/record.ts | 41 ++++++- .../kinesis-firehose/types/buffer.ts | 20 +++ .../providers/kinesis-firehose/types/index.ts | 5 + .../kinesis-firehose/types/inputs.ts | 7 +- .../kinesis-firehose/utils/constants.ts | 9 ++ .../kinesis-firehose/utils/getEventBuffer.ts | 116 ++++++++++++++++++ .../providers/kinesis-firehose/utils/index.ts | 5 + .../kinesis-firehose/utils/resolveConfig.ts | 36 ++++++ .../src/providers/kinesis/apis/record.ts | 9 +- .../src/providers/kinesis/types/index.ts | 2 +- .../src/providers/kinesis/types/inputs.ts | 4 +- .../src/utils/eventBuffer/EventBuffer.ts | 2 +- .../aws-amplify/__tests__/exports.test.ts | 10 ++ .../providers/kinesis-firehose/types/index.ts | 4 + .../types/kinesis-firehose.ts | 12 ++ .../core/src/singleton/Analytics/types.ts | 5 +- 20 files changed, 510 insertions(+), 20 deletions(-) create mode 100644 packages/analytics/__tests__/providers/kinesis-firehose/apis/record.test.ts create mode 100644 packages/analytics/__tests__/providers/kinesis-firehose/utils/getEventBuffer.test.ts create mode 100644 packages/analytics/__tests__/providers/kinesis-firehose/utils/resolveConfig.test.ts create mode 100644 packages/analytics/src/providers/kinesis-firehose/types/buffer.ts create mode 100644 packages/analytics/src/providers/kinesis-firehose/utils/constants.ts create mode 100644 packages/analytics/src/providers/kinesis-firehose/utils/getEventBuffer.ts create mode 100644 packages/analytics/src/providers/kinesis-firehose/utils/index.ts create mode 100644 packages/analytics/src/providers/kinesis-firehose/utils/resolveConfig.ts create mode 100644 packages/core/src/providers/kinesis-firehose/types/index.ts create mode 100644 packages/core/src/providers/kinesis-firehose/types/kinesis-firehose.ts diff --git a/packages/analytics/__tests__/providers/kinesis-firehose/apis/record.test.ts b/packages/analytics/__tests__/providers/kinesis-firehose/apis/record.test.ts new file mode 100644 index 00000000000..69ad736e59b --- /dev/null +++ b/packages/analytics/__tests__/providers/kinesis-firehose/apis/record.test.ts @@ -0,0 +1,84 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + getEventBuffer, + resolveConfig, +} from '../../../../src/providers/kinesis-firehose/utils'; +import { isAnalyticsEnabled, resolveCredentials } from '../../../../src/utils'; +import { + mockConfig, + mockCredentialConfig, +} from '../../../testUtils/mockConstants.test'; +import { record } from '../../../../src/providers/kinesis-firehose'; +import { ConsoleLogger as Logger } from '@aws-amplify/core/internals/utils'; +import { RecordInput as KinesisFirehoseRecordInput } from '../../../../src/providers/kinesis-firehose/types'; + +jest.mock('../../../../src/utils'); +jest.mock('../../../../src/providers/kinesis-firehose/utils'); + +describe('Analytics KinesisFirehose API: record', () => { + const mockRecordInput: KinesisFirehoseRecordInput = { + streamName: 'stream0', + data: new Uint8Array([0x01, 0x02, 0xff]), + }; + + const mockResolveConfig = resolveConfig as jest.Mock; + const mockResolveCredentials = resolveCredentials as jest.Mock; + const mockIsAnalyticsEnabled = isAnalyticsEnabled as jest.Mock; + const mockGetEventBuffer = getEventBuffer as jest.Mock; + const mockAppend = jest.fn(); + const loggerWarnSpy = jest.spyOn(Logger.prototype, 'warn'); + const loggerDebugSpy = jest.spyOn(Logger.prototype, 'debug'); + + beforeEach(() => { + mockIsAnalyticsEnabled.mockReturnValue(true); + mockResolveConfig.mockReturnValue(mockConfig); + mockResolveCredentials.mockReturnValue( + Promise.resolve(mockCredentialConfig) + ); + mockGetEventBuffer.mockImplementation(() => ({ + append: mockAppend, + })); + }); + + afterEach(() => { + mockResolveConfig.mockReset(); + mockResolveCredentials.mockReset(); + mockAppend.mockReset(); + mockGetEventBuffer.mockReset(); + mockIsAnalyticsEnabled.mockReset(); + }); + + it('append to event buffer if record provided', async () => { + record(mockRecordInput); + await new Promise(process.nextTick); + expect(mockGetEventBuffer).toHaveBeenCalledTimes(1); + expect(mockAppend).toBeCalledWith( + expect.objectContaining({ + region: mockConfig.region, + streamName: mockRecordInput.streamName, + event: mockRecordInput.data, + retryCount: 0, + }) + ); + }); + + it('logs an error when credentials can not be fetched', async () => { + mockResolveCredentials.mockRejectedValue(new Error('Mock Error')); + + record(mockRecordInput); + + await new Promise(process.nextTick); + expect(loggerWarnSpy).toBeCalledWith(expect.any(String), expect.any(Error)); + }); + + it('logs and skip the event recoding if Analytics plugin is not enabled', async () => { + mockIsAnalyticsEnabled.mockReturnValue(false); + record(mockRecordInput); + await new Promise(process.nextTick); + expect(loggerDebugSpy).toBeCalledWith(expect.any(String)); + expect(mockGetEventBuffer).not.toBeCalled(); + expect(mockAppend).not.toBeCalled(); + }); +}); diff --git a/packages/analytics/__tests__/providers/kinesis-firehose/utils/getEventBuffer.test.ts b/packages/analytics/__tests__/providers/kinesis-firehose/utils/getEventBuffer.test.ts new file mode 100644 index 00000000000..76562df1598 --- /dev/null +++ b/packages/analytics/__tests__/providers/kinesis-firehose/utils/getEventBuffer.test.ts @@ -0,0 +1,60 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getEventBuffer } from '../../../../src/providers/kinesis-firehose/utils'; +import { EventBuffer } from '../../../../src/utils'; +import { + mockBufferConfig, + mockConfig, + mockCredentialConfig, +} from '../../../testUtils/mockConstants.test'; + +jest.mock('../../../../src/utils'); + +describe('KinesisFirehose Provider Util: getEventBuffer', () => { + const mockEventBuffer = EventBuffer as jest.Mock; + + afterEach(() => { + mockEventBuffer.mockReset(); + }); + + it("create a buffer if one doesn't exist", () => { + const testBuffer = getEventBuffer({ + ...mockConfig, + ...mockCredentialConfig, + }); + + expect(mockEventBuffer).toBeCalledWith( + mockBufferConfig, + expect.any(Function) + ); + expect(testBuffer).toBeInstanceOf(EventBuffer); + }); + + it('returns an existing buffer instance', () => { + const testBuffer1 = getEventBuffer({ + ...mockConfig, + ...mockCredentialConfig, + }); + const testBuffer2 = getEventBuffer({ + ...mockConfig, + ...mockCredentialConfig, + }); + expect(testBuffer1).toBe(testBuffer2); + }); + + it('release other buffers & creates a new one if credential has changed', () => { + const testBuffer1 = getEventBuffer({ + ...mockConfig, + ...mockCredentialConfig, + }); + const testBuffer2 = getEventBuffer({ + ...mockConfig, + ...mockCredentialConfig, + identityId: 'identityId2', + }); + + expect(testBuffer1.release).toHaveBeenCalledTimes(1); + expect(testBuffer1).not.toBe(testBuffer2); + }); +}); diff --git a/packages/analytics/__tests__/providers/kinesis-firehose/utils/resolveConfig.test.ts b/packages/analytics/__tests__/providers/kinesis-firehose/utils/resolveConfig.test.ts new file mode 100644 index 00000000000..62ceaba295e --- /dev/null +++ b/packages/analytics/__tests__/providers/kinesis-firehose/utils/resolveConfig.test.ts @@ -0,0 +1,68 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify } from '@aws-amplify/core'; +import { resolveConfig } from '../../../../src/providers/kinesis-firehose/utils'; +import { DEFAULT_KINESIS_FIREHOSE_CONFIG } from '../../../../src/providers/kinesis-firehose/utils/constants'; + +describe('Analytics KinesisFirehose Provider Util: resolveConfig', () => { + const providedConfig = { + region: 'us-east-1', + bufferSize: 100, + flushSize: 10, + flushInterval: 1000, + resendLimit: 3, + }; + + const getConfigSpy = jest.spyOn(Amplify, 'getConfig'); + + beforeEach(() => { + getConfigSpy.mockReset(); + }); + + it('returns required config', () => { + getConfigSpy.mockReturnValue({ + Analytics: { KinesisFirehose: providedConfig }, + }); + + expect(resolveConfig()).toStrictEqual(providedConfig); + }); + + it('use default config for optional fields', () => { + const requiredFields = { + region: 'us-east-1', + bufferSize: undefined, + resendLimit: undefined, + }; + getConfigSpy.mockReturnValue({ + Analytics: { KinesisFirehose: requiredFields }, + }); + + expect(resolveConfig()).toStrictEqual({ + ...DEFAULT_KINESIS_FIREHOSE_CONFIG, + region: requiredFields.region, + resendLimit: requiredFields.resendLimit, + }); + }); + + it('throws if region is missing', () => { + getConfigSpy.mockReturnValue({ + Analytics: { KinesisFirehose: { ...providedConfig, region: undefined } }, + }); + + expect(resolveConfig).toThrow(); + }); + + it('throws if flushSize is larger than bufferSize', () => { + getConfigSpy.mockReturnValue({ + Analytics: { + KinesisFirehose: { + ...providedConfig, + flushSize: providedConfig.bufferSize + 1, + }, + }, + }); + + expect(resolveConfig).toThrow(); + }); +}); diff --git a/packages/analytics/__tests__/providers/kinesis/apis/record.test.ts b/packages/analytics/__tests__/providers/kinesis/apis/record.test.ts index a60ecd30365..15891f876a5 100644 --- a/packages/analytics/__tests__/providers/kinesis/apis/record.test.ts +++ b/packages/analytics/__tests__/providers/kinesis/apis/record.test.ts @@ -3,21 +3,21 @@ import { getEventBuffer } from '../../../../src/providers/kinesis/utils/getEventBuffer'; import { resolveConfig } from '../../../../src/providers/kinesis/utils/resolveConfig'; -import { resolveCredentials } from '../../../../src/utils'; +import { isAnalyticsEnabled, resolveCredentials } from '../../../../src/utils'; import { mockConfig, mockCredentialConfig, } from '../../../testUtils/mockConstants.test'; import { record } from '../../../../src/providers/kinesis'; -import { KinesisEvent } from '../../../../src/providers/kinesis/types'; -import { ConsoleLogger as Logger } from '@aws-amplify/core/lib/Logger'; +import { ConsoleLogger as Logger } from '@aws-amplify/core/internals/utils'; +import { RecordInput as KinesisRecordInput } from '../../../../src/providers/kinesis/types'; jest.mock('../../../../src/utils'); jest.mock('../../../../src/providers/kinesis/utils/resolveConfig'); jest.mock('../../../../src/providers/kinesis/utils/getEventBuffer'); describe('Analytics Kinesis API: record', () => { - const mockEvent: KinesisEvent = { + const mockRecordInput: KinesisRecordInput = { streamName: 'stream0', partitionKey: 'partition0', data: new Uint8Array([0x01, 0x02, 0xff]), @@ -26,10 +26,13 @@ describe('Analytics Kinesis API: record', () => { const mockResolveConfig = resolveConfig as jest.Mock; const mockResolveCredentials = resolveCredentials as jest.Mock; const mockGetEventBuffer = getEventBuffer as jest.Mock; + const mockIsAnalyticsEnabled = isAnalyticsEnabled as jest.Mock; const mockAppend = jest.fn(); const loggerWarnSpy = jest.spyOn(Logger.prototype, 'warn'); + const loggerDebugSpy = jest.spyOn(Logger.prototype, 'debug'); beforeEach(() => { + mockIsAnalyticsEnabled.mockReturnValue(true); mockResolveConfig.mockReturnValue(mockConfig); mockResolveCredentials.mockReturnValue( Promise.resolve(mockCredentialConfig) @@ -44,18 +47,19 @@ describe('Analytics Kinesis API: record', () => { mockResolveCredentials.mockReset(); mockAppend.mockReset(); mockGetEventBuffer.mockReset(); + mockIsAnalyticsEnabled.mockReset(); }); it('append to event buffer if record provided', async () => { - record(mockEvent); + record(mockRecordInput); await new Promise(process.nextTick); expect(mockGetEventBuffer).toHaveBeenCalledTimes(1); expect(mockAppend).toBeCalledWith( expect.objectContaining({ region: mockConfig.region, - streamName: mockEvent.streamName, - partitionKey: mockEvent.partitionKey, - event: mockEvent.data, + streamName: mockRecordInput.streamName, + partitionKey: mockRecordInput.partitionKey, + event: mockRecordInput.data, retryCount: 0, }) ); @@ -64,9 +68,18 @@ describe('Analytics Kinesis API: record', () => { it('logs an error when credentials can not be fetched', async () => { mockResolveCredentials.mockRejectedValue(new Error('Mock Error')); - record(mockEvent); + record(mockRecordInput); await new Promise(process.nextTick); expect(loggerWarnSpy).toBeCalledWith(expect.any(String), expect.any(Error)); }); + + it('logs and skip the event recoding if Analytics plugin is not enabled', async () => { + mockIsAnalyticsEnabled.mockReturnValue(false); + record(mockRecordInput); + await new Promise(process.nextTick); + expect(loggerDebugSpy).toBeCalledWith(expect.any(String)); + expect(mockGetEventBuffer).not.toBeCalled(); + expect(mockAppend).not.toBeCalled(); + }); }); diff --git a/packages/analytics/src/providers/kinesis-firehose/apis/record.ts b/packages/analytics/src/providers/kinesis-firehose/apis/record.ts index a078e05ebff..b91ab73b56e 100644 --- a/packages/analytics/src/providers/kinesis-firehose/apis/record.ts +++ b/packages/analytics/src/providers/kinesis-firehose/apis/record.ts @@ -2,7 +2,44 @@ // SPDX-License-Identifier: Apache-2.0 import { RecordInput } from '../types'; +import { getEventBuffer, resolveConfig } from '../utils'; +import { isAnalyticsEnabled, resolveCredentials } from '../../../utils'; +import { fromUtf8 } from '@smithy/util-utf8'; +import { ConsoleLogger as Logger } from '@aws-amplify/core/internals/utils'; -export const record = (input: RecordInput): void => { - throw new Error('Not Yet Implemented'); +const logger = new Logger('KinesisFirehose'); + +export const record = ({ streamName, data }: RecordInput): void => { + if (!isAnalyticsEnabled()) { + logger.debug('Analytics is disabled, event will not be recorded.'); + return; + } + + const timestamp = Date.now(); + const { region, bufferSize, flushSize, flushInterval, resendLimit } = + resolveConfig(); + + resolveCredentials() + .then(({ credentials, identityId }) => { + const buffer = getEventBuffer({ + region, + credentials, + identityId, + bufferSize, + flushSize, + flushInterval, + resendLimit, + }); + + buffer.append({ + region, + streamName, + event: ArrayBuffer.isView(data) ? data : fromUtf8(JSON.stringify(data)), + timestamp, + retryCount: 0, + }); + }) + .catch(e => { + logger.warn('Failed to record event.', e); + }); }; diff --git a/packages/analytics/src/providers/kinesis-firehose/types/buffer.ts b/packages/analytics/src/providers/kinesis-firehose/types/buffer.ts new file mode 100644 index 00000000000..405005a02b7 --- /dev/null +++ b/packages/analytics/src/providers/kinesis-firehose/types/buffer.ts @@ -0,0 +1,20 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { EventBufferConfig } from '../../../utils'; +import { KinesisStream } from '../../../types'; +import { Credentials } from '@aws-sdk/types'; + +export type KinesisFirehoseBufferEvent = KinesisStream & { + event: Uint8Array; + retryCount: number; + timestamp: number; +}; + +export type KinesisFirehoseEventBufferConfig = EventBufferConfig & { + region: string; + credentials: Credentials; + identityId?: string; + resendLimit?: number; + userAgentValue?: string; +}; diff --git a/packages/analytics/src/providers/kinesis-firehose/types/index.ts b/packages/analytics/src/providers/kinesis-firehose/types/index.ts index 0993221738f..53bec94e557 100644 --- a/packages/analytics/src/providers/kinesis-firehose/types/index.ts +++ b/packages/analytics/src/providers/kinesis-firehose/types/index.ts @@ -2,3 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 export { RecordInput } from './inputs'; + +export { + KinesisFirehoseBufferEvent, + KinesisFirehoseEventBufferConfig, +} from './buffer'; diff --git a/packages/analytics/src/providers/kinesis-firehose/types/inputs.ts b/packages/analytics/src/providers/kinesis-firehose/types/inputs.ts index 43ff8eb704f..12a84b1ff56 100644 --- a/packages/analytics/src/providers/kinesis-firehose/types/inputs.ts +++ b/packages/analytics/src/providers/kinesis-firehose/types/inputs.ts @@ -1,4 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -export type RecordInput = {}; +import { KinesisEventData } from '../../../types'; + +export type RecordInput = { + streamName: string; + data: KinesisEventData; +}; diff --git a/packages/analytics/src/providers/kinesis-firehose/utils/constants.ts b/packages/analytics/src/providers/kinesis-firehose/utils/constants.ts new file mode 100644 index 00000000000..efbcafac01b --- /dev/null +++ b/packages/analytics/src/providers/kinesis-firehose/utils/constants.ts @@ -0,0 +1,9 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const DEFAULT_KINESIS_FIREHOSE_CONFIG = { + bufferSize: 1_000, + flushSize: 100, + flushInterval: 5_000, + resendLimit: 5, +}; diff --git a/packages/analytics/src/providers/kinesis-firehose/utils/getEventBuffer.ts b/packages/analytics/src/providers/kinesis-firehose/utils/getEventBuffer.ts new file mode 100644 index 00000000000..ae840a53269 --- /dev/null +++ b/packages/analytics/src/providers/kinesis-firehose/utils/getEventBuffer.ts @@ -0,0 +1,116 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { EventBuffer, groupBy, IAnalyticsClient } from '../../../utils'; +import { + FirehoseClient, + PutRecordBatchCommand, +} from '@aws-sdk/client-firehose'; +import { + KinesisFirehoseBufferEvent, + KinesisFirehoseEventBufferConfig, +} from '../types'; + +/** + * These Records hold cached event buffers and AWS clients. + * The hash key is determined by the region and session, + * consisting of a combined value comprising [region, sessionToken, identityId]. + * + * Only one active session should exist at any given moment. + * When a new session is initiated, the previous ones should be released. + * */ +const eventBufferMap: Record< + string, + EventBuffer +> = {}; +const cachedClients: Record = {}; + +const createPutRecordsBatchCommand = ( + streamName: string, + events: KinesisFirehoseBufferEvent[] +): PutRecordBatchCommand => + new PutRecordBatchCommand({ + DeliveryStreamName: streamName, + Records: events.map(event => ({ + Data: event.event, + })), + }); + +const submitEvents = async ( + events: KinesisFirehoseBufferEvent[], + client: FirehoseClient, + resendLimit?: number +): Promise => { + const groupedByStreamName = Object.entries( + groupBy(event => event.streamName, events) + ); + + const requests = groupedByStreamName + .map(([streamName, events]) => + createPutRecordsBatchCommand(streamName, events) + ) + .map(command => client.send(command)); + + const responses = await Promise.allSettled(requests); + const failedEvents = responses + .map((response, i) => + response.status === 'rejected' ? groupedByStreamName[i][1] : [] + ) + .flat(); + return resendLimit + ? failedEvents + .filter(event => event.retryCount < resendLimit) + .map(event => ({ ...event, retryCount: event.retryCount + 1 })) + .sort((a, b) => a.timestamp - b.timestamp) + : []; +}; + +export const getEventBuffer = ({ + region, + credentials, + identityId, + bufferSize, + flushSize, + flushInterval, + resendLimit, +}: KinesisFirehoseEventBufferConfig): EventBuffer => { + const { sessionToken } = credentials; + const sessionIdentityKey = [region, sessionToken, identityId] + .filter(id => !!id) + .join('-'); + + if (!eventBufferMap[sessionIdentityKey]) { + const getClient = (): IAnalyticsClient => { + if (!cachedClients[sessionIdentityKey]) { + cachedClients[sessionIdentityKey] = new FirehoseClient({ + region, + credentials, + }); + } + + const firehoseClient = cachedClients[sessionIdentityKey]; + return events => submitEvents(events, firehoseClient, resendLimit); + }; + + eventBufferMap[sessionIdentityKey] = + new EventBuffer( + { + bufferSize, + flushSize, + flushInterval, + }, + getClient + ); + + const releaseSessionKeys = Object.keys(eventBufferMap).filter( + key => key !== sessionIdentityKey + ); + for (const releaseSessionKey of releaseSessionKeys) { + eventBufferMap[releaseSessionKey].release(); + delete eventBufferMap[releaseSessionKey]; + delete cachedClients[releaseSessionKey]; + } + } + + return eventBufferMap[sessionIdentityKey]; +}; diff --git a/packages/analytics/src/providers/kinesis-firehose/utils/index.ts b/packages/analytics/src/providers/kinesis-firehose/utils/index.ts new file mode 100644 index 00000000000..7cb7089ec5c --- /dev/null +++ b/packages/analytics/src/providers/kinesis-firehose/utils/index.ts @@ -0,0 +1,5 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { getEventBuffer } from './getEventBuffer'; +export { resolveConfig } from './resolveConfig'; diff --git a/packages/analytics/src/providers/kinesis-firehose/utils/resolveConfig.ts b/packages/analytics/src/providers/kinesis-firehose/utils/resolveConfig.ts new file mode 100644 index 00000000000..7ce9969a259 --- /dev/null +++ b/packages/analytics/src/providers/kinesis-firehose/utils/resolveConfig.ts @@ -0,0 +1,36 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify } from '@aws-amplify/core'; +import { + AnalyticsValidationErrorCode, + assertValidationError, +} from '../../../errors'; +import { DEFAULT_KINESIS_FIREHOSE_CONFIG } from './constants'; + +export const resolveConfig = () => { + const config = Amplify.getConfig().Analytics?.KinesisFirehose; + const { + region, + bufferSize = DEFAULT_KINESIS_FIREHOSE_CONFIG.bufferSize, + flushSize = DEFAULT_KINESIS_FIREHOSE_CONFIG.flushSize, + flushInterval = DEFAULT_KINESIS_FIREHOSE_CONFIG.flushInterval, + resendLimit, + } = { + ...DEFAULT_KINESIS_FIREHOSE_CONFIG, + ...config, + }; + + assertValidationError(!!region, AnalyticsValidationErrorCode.NoRegion); + assertValidationError( + flushSize < bufferSize, + AnalyticsValidationErrorCode.InvalidFlushSize + ); + return { + region, + bufferSize, + flushSize, + flushInterval, + resendLimit, + }; +}; diff --git a/packages/analytics/src/providers/kinesis/apis/record.ts b/packages/analytics/src/providers/kinesis/apis/record.ts index 91e79ef0421..b6588fd2739 100644 --- a/packages/analytics/src/providers/kinesis/apis/record.ts +++ b/packages/analytics/src/providers/kinesis/apis/record.ts @@ -4,9 +4,9 @@ import { RecordInput } from '../types'; import { getEventBuffer } from '../utils/getEventBuffer'; import { resolveConfig } from '../utils/resolveConfig'; -import { resolveCredentials } from '../../../utils'; +import { isAnalyticsEnabled, resolveCredentials } from '../../../utils'; import { fromUtf8 } from '@smithy/util-utf8'; -import { ConsoleLogger } from '@aws-amplify/core/lib/Logger'; +import { ConsoleLogger } from '@aws-amplify/core/internals/utils'; const logger = new ConsoleLogger('Kinesis'); @@ -15,6 +15,11 @@ export const record = ({ partitionKey, data, }: RecordInput): void => { + if (!isAnalyticsEnabled()) { + logger.debug('Analytics is disabled, event will not be recorded.'); + return; + } + const timestamp = Date.now(); const { region, bufferSize, flushSize, flushInterval, resendLimit } = resolveConfig(); diff --git a/packages/analytics/src/providers/kinesis/types/index.ts b/packages/analytics/src/providers/kinesis/types/index.ts index 6f4dc6cc4a9..bd4931a0ef5 100644 --- a/packages/analytics/src/providers/kinesis/types/index.ts +++ b/packages/analytics/src/providers/kinesis/types/index.ts @@ -1,5 +1,5 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -export { RecordInput, KinesisEvent } from './inputs'; +export { RecordInput } from './inputs'; export { KinesisBufferEvent, KinesisEventBufferConfig } from './buffer'; diff --git a/packages/analytics/src/providers/kinesis/types/inputs.ts b/packages/analytics/src/providers/kinesis/types/inputs.ts index f9cd08ce714..5d59d527447 100644 --- a/packages/analytics/src/providers/kinesis/types/inputs.ts +++ b/packages/analytics/src/providers/kinesis/types/inputs.ts @@ -3,10 +3,8 @@ import { KinesisEventData } from '../../../types'; -export type KinesisEvent = { +export type RecordInput = { streamName: string; partitionKey: string; data: KinesisEventData; }; - -export type RecordInput = KinesisEvent; diff --git a/packages/analytics/src/utils/eventBuffer/EventBuffer.ts b/packages/analytics/src/utils/eventBuffer/EventBuffer.ts index cd26027a9a5..15d0a5b1b88 100644 --- a/packages/analytics/src/utils/eventBuffer/EventBuffer.ts +++ b/packages/analytics/src/utils/eventBuffer/EventBuffer.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { ConsoleLogger } from '@aws-amplify/core/lib/Logger'; +import { ConsoleLogger } from '@aws-amplify/core/internals/utils'; import { EventBufferConfig, IAnalyticsClient } from './'; const logger = new ConsoleLogger('EventBuffer'); diff --git a/packages/aws-amplify/__tests__/exports.test.ts b/packages/aws-amplify/__tests__/exports.test.ts index 75c4c45f23b..0d3b87e1b0b 100644 --- a/packages/aws-amplify/__tests__/exports.test.ts +++ b/packages/aws-amplify/__tests__/exports.test.ts @@ -10,6 +10,7 @@ import * as analyticsPinpointExports from '../src/analytics/pinpoint'; import * as inAppMessagingTopLevelExports from '../src/in-app-messaging'; import * as inAppMessagingPinpointTopLevelExports from '../src/in-app-messaging/pinpoint'; import * as analyticsKinesisExports from '../src/analytics/kinesis'; +import * as analyticsKinesisFirehoseExports from '../src/analytics/kinesis-firehose'; import * as storageTopLevelExports from '../src/storage'; import * as storageS3Exports from '../src/storage/s3'; @@ -69,6 +70,15 @@ describe('aws-amplify Exports', () => { ] `); }); + + it('should only export expected symbols from the Kinesis Firehose provider', () => { + expect(Object.keys(analyticsKinesisFirehoseExports)) + .toMatchInlineSnapshot(` + Array [ + "record", + ] + `); + }); }); describe('InAppMessaging exports', () => { diff --git a/packages/core/src/providers/kinesis-firehose/types/index.ts b/packages/core/src/providers/kinesis-firehose/types/index.ts new file mode 100644 index 00000000000..4ff6777df94 --- /dev/null +++ b/packages/core/src/providers/kinesis-firehose/types/index.ts @@ -0,0 +1,4 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { KinesisFirehoseProviderConfig } from './kinesis-firehose'; diff --git a/packages/core/src/providers/kinesis-firehose/types/kinesis-firehose.ts b/packages/core/src/providers/kinesis-firehose/types/kinesis-firehose.ts new file mode 100644 index 00000000000..e089446b226 --- /dev/null +++ b/packages/core/src/providers/kinesis-firehose/types/kinesis-firehose.ts @@ -0,0 +1,12 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export type KinesisFirehoseProviderConfig = { + KinesisFirehose?: { + region: string; + bufferSize?: number; + flushSize?: number; + flushInterval?: number; + resendLimit?: number; + }; +}; diff --git a/packages/core/src/singleton/Analytics/types.ts b/packages/core/src/singleton/Analytics/types.ts index 48ff88bc214..aac92648224 100644 --- a/packages/core/src/singleton/Analytics/types.ts +++ b/packages/core/src/singleton/Analytics/types.ts @@ -3,5 +3,8 @@ import { PinpointProviderConfig } from '../../providers/pinpoint/types'; import { KinesisProviderConfig } from '../../providers/kinesis/types'; +import { KinesisFirehoseProviderConfig } from '../../providers/kinesis-firehose/types'; -export type AnalyticsConfig = PinpointProviderConfig & KinesisProviderConfig; +export type AnalyticsConfig = PinpointProviderConfig & + KinesisProviderConfig & + KinesisFirehoseProviderConfig; From a2c4621c7c2871f757de1c11fd22428047a9ab8a Mon Sep 17 00:00:00 2001 From: Di Wu Date: Tue, 3 Oct 2023 15:22:05 -0700 Subject: [PATCH 11/20] feat(analytics): add record API for Analytics service provider Personalize (#12151) * feat(analytics): add record API for Analytics service provider Personalize * add unit test cases * resolve comments --- .../kinesis-firehose/apis/record.test.ts | 6 +- .../utils/getEventBuffer.test.ts | 13 +- .../providers/kinesis/apis/record.test.ts | 6 +- .../kinesis/utils/getEventBuffer.test.ts | 12 +- .../providers/personalize/apis/record.test.ts | 218 +++++++++++++++++ .../personalize/utils/cachedSession.test.ts | 115 +++++++++ .../personalize/utils/getEventBuffer.test.ts | 60 +++++ .../personalize/utils/resolveConfig.test.ts | 73 ++++++ .../__tests__/testUtils/mockConstants.test.ts | 9 +- .../src/errors/assertValidationError.ts | 12 +- packages/analytics/src/errors/validation.ts | 6 +- .../src/providers/personalize/apis/record.ts | 92 ++++++- .../src/providers/personalize/types/buffer.ts | 20 ++ .../src/providers/personalize/types/index.ts | 3 +- .../src/providers/personalize/types/inputs.ts | 9 +- .../personalize/utils/autoTrackMedia.ts | 231 ++++++++++++++++++ .../personalize/utils/cachedSession.ts | 67 +++++ .../providers/personalize/utils/constants.ts | 12 + .../personalize/utils/getEventBuffer.ts | 109 +++++++++ .../src/providers/personalize/utils/index.ts | 11 + .../personalize/utils/resolveConfig.ts | 41 ++++ .../src/utils/eventBuffer/EventBuffer.ts | 4 + .../aws-amplify/__tests__/exports.test.ts | 9 + packages/aws-amplify/package.json | 11 + .../src/providers/personalize/types/index.ts | 4 + .../personalize/types/personalize.ts | 11 + .../core/src/singleton/Analytics/types.ts | 4 +- 27 files changed, 1139 insertions(+), 29 deletions(-) create mode 100644 packages/analytics/__tests__/providers/personalize/apis/record.test.ts create mode 100644 packages/analytics/__tests__/providers/personalize/utils/cachedSession.test.ts create mode 100644 packages/analytics/__tests__/providers/personalize/utils/getEventBuffer.test.ts create mode 100644 packages/analytics/__tests__/providers/personalize/utils/resolveConfig.test.ts create mode 100644 packages/analytics/src/providers/personalize/types/buffer.ts create mode 100644 packages/analytics/src/providers/personalize/utils/autoTrackMedia.ts create mode 100644 packages/analytics/src/providers/personalize/utils/cachedSession.ts create mode 100644 packages/analytics/src/providers/personalize/utils/constants.ts create mode 100644 packages/analytics/src/providers/personalize/utils/getEventBuffer.ts create mode 100644 packages/analytics/src/providers/personalize/utils/index.ts create mode 100644 packages/analytics/src/providers/personalize/utils/resolveConfig.ts create mode 100644 packages/core/src/providers/personalize/types/index.ts create mode 100644 packages/core/src/providers/personalize/types/personalize.ts diff --git a/packages/analytics/__tests__/providers/kinesis-firehose/apis/record.test.ts b/packages/analytics/__tests__/providers/kinesis-firehose/apis/record.test.ts index 69ad736e59b..c66d83a0e38 100644 --- a/packages/analytics/__tests__/providers/kinesis-firehose/apis/record.test.ts +++ b/packages/analytics/__tests__/providers/kinesis-firehose/apis/record.test.ts @@ -7,7 +7,7 @@ import { } from '../../../../src/providers/kinesis-firehose/utils'; import { isAnalyticsEnabled, resolveCredentials } from '../../../../src/utils'; import { - mockConfig, + mockKinesisConfig, mockCredentialConfig, } from '../../../testUtils/mockConstants.test'; import { record } from '../../../../src/providers/kinesis-firehose'; @@ -33,7 +33,7 @@ describe('Analytics KinesisFirehose API: record', () => { beforeEach(() => { mockIsAnalyticsEnabled.mockReturnValue(true); - mockResolveConfig.mockReturnValue(mockConfig); + mockResolveConfig.mockReturnValue(mockKinesisConfig); mockResolveCredentials.mockReturnValue( Promise.resolve(mockCredentialConfig) ); @@ -56,7 +56,7 @@ describe('Analytics KinesisFirehose API: record', () => { expect(mockGetEventBuffer).toHaveBeenCalledTimes(1); expect(mockAppend).toBeCalledWith( expect.objectContaining({ - region: mockConfig.region, + region: mockKinesisConfig.region, streamName: mockRecordInput.streamName, event: mockRecordInput.data, retryCount: 0, diff --git a/packages/analytics/__tests__/providers/kinesis-firehose/utils/getEventBuffer.test.ts b/packages/analytics/__tests__/providers/kinesis-firehose/utils/getEventBuffer.test.ts index 76562df1598..2cd9031d996 100644 --- a/packages/analytics/__tests__/providers/kinesis-firehose/utils/getEventBuffer.test.ts +++ b/packages/analytics/__tests__/providers/kinesis-firehose/utils/getEventBuffer.test.ts @@ -5,8 +5,7 @@ import { getEventBuffer } from '../../../../src/providers/kinesis-firehose/utils import { EventBuffer } from '../../../../src/utils'; import { mockBufferConfig, - mockConfig, - mockCredentialConfig, + mockCredentialConfig, mockKinesisConfig, } from '../../../testUtils/mockConstants.test'; jest.mock('../../../../src/utils'); @@ -20,7 +19,7 @@ describe('KinesisFirehose Provider Util: getEventBuffer', () => { it("create a buffer if one doesn't exist", () => { const testBuffer = getEventBuffer({ - ...mockConfig, + ...mockKinesisConfig, ...mockCredentialConfig, }); @@ -33,11 +32,11 @@ describe('KinesisFirehose Provider Util: getEventBuffer', () => { it('returns an existing buffer instance', () => { const testBuffer1 = getEventBuffer({ - ...mockConfig, + ...mockKinesisConfig, ...mockCredentialConfig, }); const testBuffer2 = getEventBuffer({ - ...mockConfig, + ...mockKinesisConfig, ...mockCredentialConfig, }); expect(testBuffer1).toBe(testBuffer2); @@ -45,11 +44,11 @@ describe('KinesisFirehose Provider Util: getEventBuffer', () => { it('release other buffers & creates a new one if credential has changed', () => { const testBuffer1 = getEventBuffer({ - ...mockConfig, + ...mockKinesisConfig, ...mockCredentialConfig, }); const testBuffer2 = getEventBuffer({ - ...mockConfig, + ...mockKinesisConfig, ...mockCredentialConfig, identityId: 'identityId2', }); diff --git a/packages/analytics/__tests__/providers/kinesis/apis/record.test.ts b/packages/analytics/__tests__/providers/kinesis/apis/record.test.ts index 15891f876a5..e90ef762283 100644 --- a/packages/analytics/__tests__/providers/kinesis/apis/record.test.ts +++ b/packages/analytics/__tests__/providers/kinesis/apis/record.test.ts @@ -5,7 +5,7 @@ import { getEventBuffer } from '../../../../src/providers/kinesis/utils/getEvent import { resolveConfig } from '../../../../src/providers/kinesis/utils/resolveConfig'; import { isAnalyticsEnabled, resolveCredentials } from '../../../../src/utils'; import { - mockConfig, + mockKinesisConfig, mockCredentialConfig, } from '../../../testUtils/mockConstants.test'; import { record } from '../../../../src/providers/kinesis'; @@ -33,7 +33,7 @@ describe('Analytics Kinesis API: record', () => { beforeEach(() => { mockIsAnalyticsEnabled.mockReturnValue(true); - mockResolveConfig.mockReturnValue(mockConfig); + mockResolveConfig.mockReturnValue(mockKinesisConfig); mockResolveCredentials.mockReturnValue( Promise.resolve(mockCredentialConfig) ); @@ -56,7 +56,7 @@ describe('Analytics Kinesis API: record', () => { expect(mockGetEventBuffer).toHaveBeenCalledTimes(1); expect(mockAppend).toBeCalledWith( expect.objectContaining({ - region: mockConfig.region, + region: mockKinesisConfig.region, streamName: mockRecordInput.streamName, partitionKey: mockRecordInput.partitionKey, event: mockRecordInput.data, diff --git a/packages/analytics/__tests__/providers/kinesis/utils/getEventBuffer.test.ts b/packages/analytics/__tests__/providers/kinesis/utils/getEventBuffer.test.ts index 96556f3507b..3004b694e82 100644 --- a/packages/analytics/__tests__/providers/kinesis/utils/getEventBuffer.test.ts +++ b/packages/analytics/__tests__/providers/kinesis/utils/getEventBuffer.test.ts @@ -5,7 +5,7 @@ import { getEventBuffer } from '../../../../src/providers/kinesis/utils/getEvent import { EventBuffer } from '../../../../src/utils'; import { mockBufferConfig, - mockConfig, + mockKinesisConfig, mockCredentialConfig, } from '../../../testUtils/mockConstants.test'; @@ -20,7 +20,7 @@ describe('Kinesis Provider Util: getEventBuffer', () => { it("create a buffer if one doesn't exist", () => { const testBuffer = getEventBuffer({ - ...mockConfig, + ...mockKinesisConfig, ...mockCredentialConfig, }); @@ -33,11 +33,11 @@ describe('Kinesis Provider Util: getEventBuffer', () => { it('returns an existing buffer instance', () => { const testBuffer1 = getEventBuffer({ - ...mockConfig, + ...mockKinesisConfig, ...mockCredentialConfig, }); const testBuffer2 = getEventBuffer({ - ...mockConfig, + ...mockKinesisConfig, ...mockCredentialConfig, }); expect(testBuffer1).toBe(testBuffer2); @@ -45,11 +45,11 @@ describe('Kinesis Provider Util: getEventBuffer', () => { it('release other buffers & creates a new one if credential has changed', () => { const testBuffer1 = getEventBuffer({ - ...mockConfig, + ...mockKinesisConfig, ...mockCredentialConfig, }); const testBuffer2 = getEventBuffer({ - ...mockConfig, + ...mockKinesisConfig, ...mockCredentialConfig, identityId: 'identityId2', }); diff --git a/packages/analytics/__tests__/providers/personalize/apis/record.test.ts b/packages/analytics/__tests__/providers/personalize/apis/record.test.ts new file mode 100644 index 00000000000..bf044149c4e --- /dev/null +++ b/packages/analytics/__tests__/providers/personalize/apis/record.test.ts @@ -0,0 +1,218 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { + autoTrackMedia, + getEventBuffer, + resolveCachedSession, + resolveConfig, + updateCachedSession, +} from '../../../../src/providers/personalize/utils'; +import { isAnalyticsEnabled, resolveCredentials } from '../../../../src/utils'; +import { + mockCredentialConfig, + mockPersonalizeConfig, +} from '../../../testUtils/mockConstants.test'; +import { record } from '../../../../src/providers/personalize'; +import { ConsoleLogger as Logger } from '@aws-amplify/core/internals/utils'; +import { RecordInput as PersonalizeRecordInput } from '../../../../src/providers/personalize/types'; +import { + IDENTIFY_EVENT_TYPE, + MEDIA_AUTO_TRACK_EVENT_TYPE, +} from '../../../../src/providers/personalize/utils/constants'; + +jest.mock('../../../../src/utils'); +jest.mock('../../../../src/providers/personalize/utils'); + +describe('Analytics Personalize API: record', () => { + const mockRecordInput: PersonalizeRecordInput = { + eventType: 'eventType0', + properties: { + property0: 0, + property1: '1', + }, + }; + + const mockCachedSession = { + sessionId: 'sessionId0', + userId: 'userId0', + }; + + const mockResolveConfig = resolveConfig as jest.Mock; + const mockResolveCredentials = resolveCredentials as jest.Mock; + const mockIsAnalyticsEnabled = isAnalyticsEnabled as jest.Mock; + const mockResolveCachedSession = resolveCachedSession as jest.Mock; + const mockUpdateCachedSession = updateCachedSession as jest.Mock; + const mockAutoTrackMedia = autoTrackMedia as jest.Mock; + const mockGetEventBuffer = getEventBuffer as jest.Mock; + const mockAppend = jest.fn(); + const loggerWarnSpy = jest.spyOn(Logger.prototype, 'warn'); + const loggerDebugSpy = jest.spyOn(Logger.prototype, 'debug'); + const mockEventBuffer = { + append: mockAppend, + }; + beforeEach(() => { + mockIsAnalyticsEnabled.mockReturnValue(true); + mockResolveConfig.mockReturnValue(mockPersonalizeConfig); + mockResolveCachedSession.mockReturnValue(mockCachedSession); + mockResolveCredentials.mockReturnValue( + Promise.resolve(mockCredentialConfig) + ); + mockGetEventBuffer.mockImplementation(() => mockEventBuffer); + }); + + afterEach(() => { + mockResolveConfig.mockReset(); + mockResolveCredentials.mockReset(); + mockResolveCachedSession.mockReset(); + mockUpdateCachedSession.mockReset(); + mockAutoTrackMedia.mockReset(); + mockAppend.mockReset(); + mockGetEventBuffer.mockReset(); + mockIsAnalyticsEnabled.mockReset(); + }); + + it('append to event buffer if record provided', async () => { + record(mockRecordInput); + await new Promise(process.nextTick); + expect(mockGetEventBuffer).toHaveBeenCalledTimes(1); + expect(mockAppend).toBeCalledWith( + expect.objectContaining({ + trackingId: mockPersonalizeConfig.trackingId, + ...mockCachedSession, + event: mockRecordInput, + }) + ); + }); + + it('triggers updateCachedSession if eventType is identity event', async () => { + const newSession = { + sessionId: 'sessionId1', + userId: 'userId1', + }; + mockResolveCachedSession + .mockReturnValueOnce(mockCachedSession) + .mockReturnValueOnce(newSession); + + const updatedMockRecordInput = { + ...mockRecordInput, + eventType: IDENTIFY_EVENT_TYPE, + properties: { + ...mockRecordInput.properties, + userId: newSession.userId, + }, + }; + record(updatedMockRecordInput); + + await new Promise(process.nextTick); + expect(mockGetEventBuffer).toHaveBeenCalledTimes(1); + expect(mockUpdateCachedSession).toBeCalledWith( + newSession.userId, + mockCachedSession.sessionId, + mockCachedSession.userId + ); + expect(mockAppend).toBeCalledWith( + expect.objectContaining({ + trackingId: mockPersonalizeConfig.trackingId, + ...newSession, + event: updatedMockRecordInput, + }) + ); + }); + + it('triggers updateCachedSession if userId is non-empty in RecordInput', async () => { + const newSession = { + sessionId: 'sessionId1', + userId: 'userId1', + }; + mockResolveCachedSession + .mockReturnValueOnce(mockCachedSession) + .mockReturnValueOnce(newSession); + + const updatedMockRecordInput = { + ...mockRecordInput, + userId: newSession.userId, + }; + record(updatedMockRecordInput); + + await new Promise(process.nextTick); + expect(mockGetEventBuffer).toHaveBeenCalledTimes(1); + expect(mockUpdateCachedSession).toBeCalledWith( + newSession.userId, + mockCachedSession.sessionId, + mockCachedSession.userId + ); + expect(mockAppend).toBeCalledWith( + expect.objectContaining({ + trackingId: mockPersonalizeConfig.trackingId, + ...newSession, + event: mockRecordInput, + }) + ); + }); + + it(`triggers autoTrackMedia if eventType is ${MEDIA_AUTO_TRACK_EVENT_TYPE}`, async () => { + const updatedMockRecordInput = { + ...mockRecordInput, + eventType: MEDIA_AUTO_TRACK_EVENT_TYPE, + }; + record(updatedMockRecordInput); + + await new Promise(process.nextTick); + expect(mockGetEventBuffer).toHaveBeenCalledTimes(1); + expect(mockAutoTrackMedia).toBeCalledWith( + { + trackingId: mockPersonalizeConfig.trackingId, + ...mockCachedSession, + event: updatedMockRecordInput, + }, + mockEventBuffer + ); + expect(mockAppend).not.toBeCalled(); + }); + + it('flushEvents when buffer size is full', async () => { + const mockFlushAll = jest.fn(); + const mockGetLength = jest.fn(); + const updatedMockEventBuffer = { + ...mockEventBuffer, + flushAll: mockFlushAll, + }; + Object.defineProperty(updatedMockEventBuffer, 'length', { + get: mockGetLength, + }); + + mockGetLength.mockReturnValue(mockPersonalizeConfig.flushSize + 1); + mockGetEventBuffer.mockImplementation(() => updatedMockEventBuffer); + + record(mockRecordInput); + await new Promise(process.nextTick); + expect(mockGetEventBuffer).toHaveBeenCalledTimes(1); + expect(mockAppend).toBeCalledWith( + expect.objectContaining({ + trackingId: mockPersonalizeConfig.trackingId, + ...mockCachedSession, + event: mockRecordInput, + }) + ); + expect(mockGetLength).toHaveBeenCalledTimes(1); + expect(mockFlushAll).toHaveBeenCalledTimes(1); + }); + + it('logs an error when credentials can not be fetched', async () => { + mockResolveCredentials.mockRejectedValue(new Error('Mock Error')); + + record(mockRecordInput); + + await new Promise(process.nextTick); + expect(loggerWarnSpy).toBeCalledWith(expect.any(String), expect.any(Error)); + }); + + it('logs and skip the event recoding if Analytics plugin is not enabled', async () => { + mockIsAnalyticsEnabled.mockReturnValue(false); + record(mockRecordInput); + await new Promise(process.nextTick); + expect(loggerDebugSpy).toBeCalledWith(expect.any(String)); + expect(mockGetEventBuffer).not.toBeCalled(); + expect(mockAppend).not.toBeCalled(); + }); +}); diff --git a/packages/analytics/__tests__/providers/personalize/utils/cachedSession.test.ts b/packages/analytics/__tests__/providers/personalize/utils/cachedSession.test.ts new file mode 100644 index 00000000000..fbc7516fa18 --- /dev/null +++ b/packages/analytics/__tests__/providers/personalize/utils/cachedSession.test.ts @@ -0,0 +1,115 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Cache, BrowserStorageCache } from '@aws-amplify/core'; +import { isBrowser } from '@aws-amplify/core/internals/utils'; +import { + resolveCachedSession, + updateCachedSession, +} from '../../../../src/providers/personalize/utils'; + +jest.mock('@aws-amplify/core'); +jest.mock('@aws-amplify/core/internals/utils'); + +describe('Analytics service provider Personalize utils: cachedSession', () => { + const sessionIdCacheKey = '_awsct_sid.personalize'; + const userIdCacheKey = '_awsct_uid.personalize'; + const mockCache = Cache as jest.Mocked; + const mockIsBrowser = isBrowser as jest.Mock; + + const mockSession = { + sessionId: 'sessionId0', + userId: 'userId0', + }; + + const mockCachedStorage = { + [userIdCacheKey]: mockSession.userId, + [sessionIdCacheKey]: mockSession.sessionId, + }; + + beforeEach(() => { + mockCache.getItem.mockImplementation(key => mockCachedStorage[key]); + mockIsBrowser.mockReturnValue(false); + }); + + afterEach(() => { + mockIsBrowser.mockReset(); + mockCache.getItem.mockReset(); + mockCache.setItem.mockReset(); + }); + + it('resolve cached session from Cache', () => { + const result = resolveCachedSession('trackingId0'); + expect(result).toStrictEqual(mockSession); + }); + + it('create a new session if there is no cache', () => { + mockCache.getItem.mockImplementation(() => undefined); + const result = resolveCachedSession('trackingId0'); + expect(result.sessionId).not.toBe(mockSession.sessionId); + expect(result.sessionId.length).toBeGreaterThan(0); + expect(result.userId).toBe(undefined); + }); + + it('updateCachedSession create a new session if user has changed', () => { + updateCachedSession('newUserId', mockSession.sessionId, mockSession.userId); + expect(mockCache.setItem).toBeCalledTimes(2); + expect(mockCache.setItem).toHaveBeenNthCalledWith( + 1, + sessionIdCacheKey, + expect.any(String), + expect.any(Object) + ); + expect(mockCache.setItem).toHaveBeenNthCalledWith( + 2, + userIdCacheKey, + 'newUserId', + expect.any(Object) + ); + }); + + it('updateCachedSession create a new session if user is signed out', () => { + updateCachedSession(undefined, mockSession.sessionId, undefined); + expect(mockCache.setItem).toBeCalledTimes(2); + expect(mockCache.setItem).toHaveBeenNthCalledWith( + 1, + sessionIdCacheKey, + expect.any(String), + expect.any(Object) + ); + expect(mockCache.setItem).toHaveBeenNthCalledWith( + 2, + userIdCacheKey, + undefined, + expect.any(Object) + ); + }); + + it('updateCachedSession create a new session if no cached session', () => { + updateCachedSession('newUserId', undefined, mockSession.userId); + expect(mockCache.setItem).toBeCalledTimes(2); + expect(mockCache.setItem).toHaveBeenNthCalledWith( + 1, + sessionIdCacheKey, + expect.any(String), + expect.any(Object) + ); + expect(mockCache.setItem).toHaveBeenNthCalledWith( + 2, + userIdCacheKey, + 'newUserId', + expect.any(Object) + ); + }); + + it('updateCachedSession only updates userId if cached sessionId but no cached userId', () => { + updateCachedSession('newUserId', mockSession.sessionId, undefined); + expect(mockCache.setItem).toBeCalledTimes(1); + expect(mockCache.setItem).toHaveBeenNthCalledWith( + 1, + userIdCacheKey, + 'newUserId', + expect.any(Object) + ); + }); +}); diff --git a/packages/analytics/__tests__/providers/personalize/utils/getEventBuffer.test.ts b/packages/analytics/__tests__/providers/personalize/utils/getEventBuffer.test.ts new file mode 100644 index 00000000000..256a42a449f --- /dev/null +++ b/packages/analytics/__tests__/providers/personalize/utils/getEventBuffer.test.ts @@ -0,0 +1,60 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { EventBuffer } from '../../../../src/utils'; +import { + mockBufferConfig, + mockKinesisConfig, + mockCredentialConfig, +} from '../../../testUtils/mockConstants.test'; +import { getEventBuffer } from '../../../../src/providers/personalize/utils'; + +jest.mock('../../../../src/utils'); + +describe('Personalize Provider Util: getEventBuffer', () => { + const mockEventBuffer = EventBuffer as jest.Mock; + + afterEach(() => { + mockEventBuffer.mockReset(); + }); + + it("create a buffer if one doesn't exist", () => { + const testBuffer = getEventBuffer({ + ...mockKinesisConfig, + ...mockCredentialConfig, + }); + + expect(mockEventBuffer).toBeCalledWith( + mockBufferConfig, + expect.any(Function) + ); + expect(testBuffer).toBeInstanceOf(EventBuffer); + }); + + it('returns an existing buffer instance', () => { + const testBuffer1 = getEventBuffer({ + ...mockKinesisConfig, + ...mockCredentialConfig, + }); + const testBuffer2 = getEventBuffer({ + ...mockKinesisConfig, + ...mockCredentialConfig, + }); + expect(testBuffer1).toBe(testBuffer2); + }); + + it('release other buffers & creates a new one if credential has changed', () => { + const testBuffer1 = getEventBuffer({ + ...mockKinesisConfig, + ...mockCredentialConfig, + }); + const testBuffer2 = getEventBuffer({ + ...mockKinesisConfig, + ...mockCredentialConfig, + identityId: 'identityId2', + }); + + expect(testBuffer1.release).toHaveBeenCalledTimes(1); + expect(testBuffer1).not.toBe(testBuffer2); + }); +}); diff --git a/packages/analytics/__tests__/providers/personalize/utils/resolveConfig.test.ts b/packages/analytics/__tests__/providers/personalize/utils/resolveConfig.test.ts new file mode 100644 index 00000000000..093bc6079a6 --- /dev/null +++ b/packages/analytics/__tests__/providers/personalize/utils/resolveConfig.test.ts @@ -0,0 +1,73 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify } from '@aws-amplify/core'; +import { resolveConfig } from '../../../../src/providers/personalize/utils'; +import { + DEFAULT_PERSONALIZE_CONFIG, + PERSONALIZE_FLUSH_SIZE_MAX, +} from '../../../../src/providers/personalize/utils'; + +describe('Analytics Personalize Provider Util: resolveConfig', () => { + const providedConfig = { + region: 'us-east-1', + trackingId: 'trackingId0', + flushSize: 10, + flushInterval: 1000, + }; + + const getConfigSpy = jest.spyOn(Amplify, 'getConfig'); + + beforeEach(() => { + getConfigSpy.mockReset(); + }); + + it('returns required config', () => { + getConfigSpy.mockReturnValue({ + Analytics: { Personalize: providedConfig }, + }); + + expect(resolveConfig()).toStrictEqual({ + ...providedConfig, + bufferSize: providedConfig.flushSize + 1, + }); + }); + + it('use default config for optional fields', () => { + const requiredFields = { + region: 'us-east-1', + trackingId: 'trackingId1', + }; + getConfigSpy.mockReturnValue({ + Analytics: { Personalize: requiredFields }, + }); + + expect(resolveConfig()).toStrictEqual({ + ...DEFAULT_PERSONALIZE_CONFIG, + region: requiredFields.region, + trackingId: requiredFields.trackingId, + bufferSize: DEFAULT_PERSONALIZE_CONFIG.flushSize + 1, + }); + }); + + it('throws if region is missing', () => { + getConfigSpy.mockReturnValue({ + Analytics: { Personalize: { ...providedConfig, region: undefined } }, + }); + + expect(resolveConfig).toThrow(); + }); + + it('throws if flushSize is larger than max', () => { + getConfigSpy.mockReturnValue({ + Analytics: { + Personalize: { + ...providedConfig, + flushSize: PERSONALIZE_FLUSH_SIZE_MAX + 1, + }, + }, + }); + + expect(resolveConfig).toThrow(); + }); +}); diff --git a/packages/analytics/__tests__/testUtils/mockConstants.test.ts b/packages/analytics/__tests__/testUtils/mockConstants.test.ts index 002d557f746..333a4f1e291 100644 --- a/packages/analytics/__tests__/testUtils/mockConstants.test.ts +++ b/packages/analytics/__tests__/testUtils/mockConstants.test.ts @@ -7,11 +7,18 @@ export const mockBufferConfig = { flushInterval: 50, }; -export const mockConfig = { +export const mockKinesisConfig = { ...mockBufferConfig, region: 'us-east-1', }; +export const mockPersonalizeConfig = { + ...mockBufferConfig, + bufferSize: mockBufferConfig.flushSize + 1, + region: 'us-east-1', + trackingId: 'trackingId0', +}; + export const mockCredentialConfig = { credentials: { accessKeyId: 'accessKeyId0', diff --git a/packages/analytics/src/errors/assertValidationError.ts b/packages/analytics/src/errors/assertValidationError.ts index 8e251ba5a32..6eddaffd9ba 100644 --- a/packages/analytics/src/errors/assertValidationError.ts +++ b/packages/analytics/src/errors/assertValidationError.ts @@ -9,11 +9,17 @@ import { AnalyticsValidationErrorCode, validationErrorMap } from './validation'; */ export function assertValidationError( assertion: boolean, - name: AnalyticsValidationErrorCode + name: AnalyticsValidationErrorCode, + message?: string ): asserts assertion { - const { message, recoverySuggestion } = validationErrorMap[name]; + const { message: defaultMessage, recoverySuggestion } = + validationErrorMap[name]; if (!assertion) { - throw new AnalyticsError({ name, message, recoverySuggestion }); + throw new AnalyticsError({ + name, + message: message ?? defaultMessage, + recoverySuggestion, + }); } } diff --git a/packages/analytics/src/errors/validation.ts b/packages/analytics/src/errors/validation.ts index 9ff4312c1fc..65bcc89d263 100644 --- a/packages/analytics/src/errors/validation.ts +++ b/packages/analytics/src/errors/validation.ts @@ -8,6 +8,7 @@ export enum AnalyticsValidationErrorCode { NoCredentials = 'NoCredentials', NoEventName = 'NoEventName', NoRegion = 'NoRegion', + NoTrackingId = 'NoTrackingId', InvalidFlushSize = 'InvalidFlushSize', } @@ -26,6 +27,9 @@ export const validationErrorMap: AmplifyErrorMap = message: 'Missing region.', }, [AnalyticsValidationErrorCode.InvalidFlushSize]: { - message: 'Invalid FlushSize, it should smaller than BufferSize', + message: 'Invalid FlushSize, it should be smaller than BufferSize', + }, + [AnalyticsValidationErrorCode.NoTrackingId]: { + message: 'A trackingId is required to use Amazon Personalize', }, }; diff --git a/packages/analytics/src/providers/personalize/apis/record.ts b/packages/analytics/src/providers/personalize/apis/record.ts index a078e05ebff..971d894c4c2 100644 --- a/packages/analytics/src/providers/personalize/apis/record.ts +++ b/packages/analytics/src/providers/personalize/apis/record.ts @@ -2,7 +2,95 @@ // SPDX-License-Identifier: Apache-2.0 import { RecordInput } from '../types'; +import { + autoTrackMedia, + getEventBuffer, + resolveCachedSession, + resolveConfig, + updateCachedSession, +} from '../utils'; +import { isAnalyticsEnabled, resolveCredentials } from '../../../utils'; +import { ConsoleLogger } from '@aws-amplify/core/internals/utils'; +import { + IDENTIFY_EVENT_TYPE, + MEDIA_AUTO_TRACK_EVENT_TYPE, +} from '../utils/constants'; -export const record = (input: RecordInput): void => { - throw new Error('Not Yet Implemented'); +const logger = new ConsoleLogger('Personalize'); + +export const record = ({ + userId, + eventId, + eventType, + properties, +}: RecordInput): void => { + if (!isAnalyticsEnabled()) { + logger.debug('Analytics is disabled, event will not be recorded.'); + return; + } + + const { region, trackingId, bufferSize, flushSize, flushInterval } = + resolveConfig(); + resolveCredentials() + .then(({ credentials, identityId }) => { + const timestamp = Date.now(); + const { sessionId: cachedSessionId, userId: cachedUserId } = + resolveCachedSession(trackingId); + if (eventType === IDENTIFY_EVENT_TYPE) { + updateCachedSession( + typeof properties.userId === 'string' ? properties.userId : '', + cachedSessionId, + cachedUserId + ); + } else if (!!userId) { + updateCachedSession(userId, cachedSessionId, cachedUserId); + } + + const { sessionId: updatedSessionId, userId: updatedUserId } = + resolveCachedSession(trackingId); + + const eventBuffer = getEventBuffer({ + region, + flushSize, + flushInterval, + bufferSize, + credentials, + identityId, + }); + + if (eventType === MEDIA_AUTO_TRACK_EVENT_TYPE) { + autoTrackMedia( + { + trackingId, + sessionId: updatedSessionId, + userId: updatedUserId, + event: { + eventId, + eventType, + properties, + }, + }, + eventBuffer + ); + } else { + eventBuffer.append({ + trackingId, + sessionId: updatedSessionId, + userId: updatedUserId, + event: { + eventId, + eventType, + properties, + }, + timestamp, + }); + } + + if (eventBuffer.length >= bufferSize) { + eventBuffer.flushAll(); + } + }) + .catch(e => { + logger.warn('Failed to record event.', e); + }); }; diff --git a/packages/analytics/src/providers/personalize/types/buffer.ts b/packages/analytics/src/providers/personalize/types/buffer.ts new file mode 100644 index 00000000000..037adb0f9e0 --- /dev/null +++ b/packages/analytics/src/providers/personalize/types/buffer.ts @@ -0,0 +1,20 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PersonalizeEvent } from './'; +import { EventBufferConfig } from '../../../utils/eventBuffer'; +import { AuthSession } from '@aws-amplify/core/src/singleton/Auth/types'; + +export type PersonalizeBufferEvent = { + trackingId: string; + sessionId?: string; + userId?: string; + event: PersonalizeEvent; + timestamp: number; +}; + +export type PersonalizeBufferConfig = EventBufferConfig & { + region: string; + credentials: Required['credentials']; + identityId: AuthSession['identityId']; +}; diff --git a/packages/analytics/src/providers/personalize/types/index.ts b/packages/analytics/src/providers/personalize/types/index.ts index 0993221738f..7a852e3082e 100644 --- a/packages/analytics/src/providers/personalize/types/index.ts +++ b/packages/analytics/src/providers/personalize/types/index.ts @@ -1,4 +1,5 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -export { RecordInput } from './inputs'; +export { RecordInput, PersonalizeEvent } from './inputs'; +export { PersonalizeBufferEvent, PersonalizeBufferConfig } from './buffer'; diff --git a/packages/analytics/src/providers/personalize/types/inputs.ts b/packages/analytics/src/providers/personalize/types/inputs.ts index 43ff8eb704f..80e440c2873 100644 --- a/packages/analytics/src/providers/personalize/types/inputs.ts +++ b/packages/analytics/src/providers/personalize/types/inputs.ts @@ -1,4 +1,11 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -export type RecordInput = {}; +export type PersonalizeEvent = { + userId?: string; + eventId?: string; + eventType: string; + properties: Record; +}; + +export type RecordInput = PersonalizeEvent; diff --git a/packages/analytics/src/providers/personalize/utils/autoTrackMedia.ts b/packages/analytics/src/providers/personalize/utils/autoTrackMedia.ts new file mode 100644 index 00000000000..dbb30063aec --- /dev/null +++ b/packages/analytics/src/providers/personalize/utils/autoTrackMedia.ts @@ -0,0 +1,231 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { EventBuffer } from '../../../utils'; +import { PersonalizeBufferEvent, PersonalizeEvent } from '../types'; +import { ConsoleLogger, isBrowser } from '@aws-amplify/core/internals/utils'; + +enum HTML5_MEDIA_EVENT { + 'PLAY' = 'play', + 'PAUSE' = 'pause', + 'ENDED' = 'Ended', +} + +enum MEDIA_TYPE { + 'IFRAME' = 'IFRAME', + 'VIDEO' = 'VIDEO', + 'AUDIO' = 'AUDIO', +} + +enum EVENT_TYPE { + 'PLAY' = 'Play', + 'ENDED' = 'Ended', + 'PAUSE' = 'Pause', + 'TIME_WATCHED' = 'TimeWatched', +} + +interface IRecordEvent { + (eventType: string, properties: Record): void; +} + +type MediaAutoTrackConfig = { + trackingId: string; + sessionId: string; + userId?: string; + event: PersonalizeEvent; +}; + +const logger = new ConsoleLogger('MediaAutoTrack'); + +const startIframeAutoTracking = ( + element: HTMLElement, + recordEvent: IRecordEvent +) => { + let isPlaying = false; + let player: any; + const mediaProperties = (): Record => { + const duration = Number(parseFloat(player.getDuration()).toFixed(4)); + const currentTime = Number(parseFloat(player.getCurrentTime()).toFixed(4)); + return { + duration, + eventValue: Number((currentTime / duration).toFixed(4)), + }; + }; + + const scriptElement = document.createElement('script'); + scriptElement.type = 'text/javascript'; + scriptElement.src = 'https://www.youtube.com/iframe_api'; + document.body.append(scriptElement); + + const timer = setInterval(() => { + if (isPlaying && player) { + recordEvent(EVENT_TYPE.TIME_WATCHED, mediaProperties()); + } + }, 3_000); + + element.addEventListener('unload', () => clearInterval(timer)); + + // @ts-ignore + window.onYouTubeIframeAPIReady = () => { + // @ts-ignore + delete window.onYouTubeIframeAPIReady; + + // @ts-ignore + player = new window.YT.Player(element.id, { + events: { + onStateChange: (event: any) => { + const iframeEventMapping = { + 0: EVENT_TYPE.ENDED, + 1: EVENT_TYPE.PLAY, + 2: EVENT_TYPE.PAUSE, + }; + // @ts-ignore + const eventType = iframeEventMapping[event.data]; + switch (eventType) { + case EVENT_TYPE.ENDED: + case EVENT_TYPE.PAUSE: + isPlaying = false; + break; + case EVENT_TYPE.PLAY: + isPlaying = true; + break; + } + + if (eventType) { + recordEvent(eventType, mediaProperties()); + } + }, + }, + }); + }; +}; + +const startHTMLMediaAutoTracking = ( + element: HTMLMediaElement, + recordEvent: IRecordEvent +) => { + let isPlaying = false; + const mediaProperties = (): Record => ({ + duration: element.duration, + eventValue: Number((element.currentTime / element.duration).toFixed(4)), + }); + + const timer = setInterval(() => { + if (isPlaying) { + recordEvent(EVENT_TYPE.TIME_WATCHED, mediaProperties()); + } + }, 3_000); + + element.addEventListener('unload', () => clearInterval(timer)); + + element.addEventListener(HTML5_MEDIA_EVENT.PLAY, () => { + isPlaying = true; + recordEvent(EVENT_TYPE.PLAY, mediaProperties()); + }); + + element.addEventListener(HTML5_MEDIA_EVENT.PAUSE, () => { + isPlaying = false; + recordEvent(EVENT_TYPE.PAUSE, mediaProperties()); + }); + + element.addEventListener(HTML5_MEDIA_EVENT.ENDED, () => { + isPlaying = false; + recordEvent(EVENT_TYPE.ENDED, mediaProperties()); + }); +}; + +const checkElementLoaded = (interval: number, maxTries: number) => { + let retryCount = 0; + const wait = () => new Promise(r => setTimeout(r, interval)); + const check = async (elementId: string): Promise => { + if (retryCount >= maxTries) { + return false; + } + + const domElement = document.getElementById(elementId); + if (domElement && domElement.clientHeight) { + return true; + } else { + retryCount += 1; + await wait(); + return await check(elementId); + } + }; + return check; +}; + +const recordEvent = + ( + config: MediaAutoTrackConfig, + eventBuffer: EventBuffer + ): IRecordEvent => + (eventType: string, properties: Record) => { + // override eventType and merge properties + eventBuffer.append({ + ...config, + event: { + ...config.event, + eventType, + properties: { + ...config.event.properties, + ...properties, + }, + }, + timestamp: Date.now(), + }); + }; + +export const autoTrackMedia = async ( + config: MediaAutoTrackConfig, + eventBuffer: EventBuffer +) => { + const { eventType, properties } = config.event; + const { domElementId, ...otherProperties } = properties; + if (!isBrowser()) { + logger.debug(`${eventType} only for browser`); + return; + } + + if (typeof domElementId === 'string' && !domElementId) { + logger.debug( + "Missing domElementId field in 'properties' for MediaAutoTrack event type." + ); + return; + } + + const elementId = domElementId as string; + const isElementLoaded = await checkElementLoaded(500, 5)(elementId); + if (isElementLoaded) { + const autoTrackConfigWithoutDomElementId = { + ...config, + event: { + ...config.event, + properties: otherProperties, + }, + }; + + const element = document.getElementById(elementId); + switch (element?.tagName) { + case MEDIA_TYPE.IFRAME: + startIframeAutoTracking( + element, + recordEvent(autoTrackConfigWithoutDomElementId, eventBuffer) + ); + break; + case MEDIA_TYPE.VIDEO: + case MEDIA_TYPE.AUDIO: + if (element instanceof HTMLMediaElement) { + startHTMLMediaAutoTracking( + element, + recordEvent(autoTrackConfigWithoutDomElementId, eventBuffer) + ); + } + break; + default: + logger.debug(`Unsupported DOM element tag: ${element?.tagName}`); + break; + } + } else { + logger.debug('Cannot find the media element.'); + } +}; diff --git a/packages/analytics/src/providers/personalize/utils/cachedSession.ts b/packages/analytics/src/providers/personalize/utils/cachedSession.ts new file mode 100644 index 00000000000..8b844f92a3d --- /dev/null +++ b/packages/analytics/src/providers/personalize/utils/cachedSession.ts @@ -0,0 +1,67 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Cache } from '@aws-amplify/core'; +import { isBrowser } from '@aws-amplify/core/internals/utils'; +import { v4 as uuid } from 'uuid'; + +const PERSONALIZE_CACHE_USERID = '_awsct_uid'; +const PERSONALIZE_CACHE_SESSIONID = '_awsct_sid'; +const DEFAULT_CACHE_PREFIX = 'personalize'; +const DELIMITER = '.'; +const CACHE_EXPIRY_IN_DAYS = 7; + +const normalize = (key: string): string => + [key, isBrowser() ? window.location.host : DEFAULT_CACHE_PREFIX].join( + DELIMITER + ); + +const getCache = (key: string) => Cache.getItem(normalize(key)); + +const setCache = (key: string, value: unknown) => { + const expiredAt = new Date( + Date.now() + 3_600_000 * 24 * CACHE_EXPIRY_IN_DAYS + ); + Cache.setItem(normalize(key), value, { + expires: expiredAt.getTime(), + }); +}; + +export const resolveCachedSession = (trackingId: string) => { + let sessionId: string | undefined = getCache(PERSONALIZE_CACHE_SESSIONID); + if (!sessionId) { + sessionId = uuid(); + setCache(PERSONALIZE_CACHE_SESSIONID, sessionId); + } + + const userId: string | undefined = getCache(PERSONALIZE_CACHE_USERID); + + return { + sessionId, + userId, + }; +}; + +export const updateCachedSession = ( + newUserId?: string, + currentSessionId?: string, + currentUserId?: string +) => { + const isNoCachedSession = !currentSessionId; + const isSignOutCase = !newUserId && !currentUserId; + const isSwitchUserCase = + !!newUserId && !!currentUserId && newUserId !== currentUserId; + + const isRequireNewSession = + isNoCachedSession || isSignOutCase || isSwitchUserCase; + const isRequireUpdateSession = + !!currentSessionId && !currentUserId && !!newUserId; + + if (isRequireNewSession) { + const newSessionId = uuid(); + setCache(PERSONALIZE_CACHE_SESSIONID, newSessionId); + setCache(PERSONALIZE_CACHE_USERID, newUserId); + } else if (isRequireUpdateSession) { + setCache(PERSONALIZE_CACHE_USERID, newUserId); + } +}; diff --git a/packages/analytics/src/providers/personalize/utils/constants.ts b/packages/analytics/src/providers/personalize/utils/constants.ts new file mode 100644 index 00000000000..ec240857711 --- /dev/null +++ b/packages/analytics/src/providers/personalize/utils/constants.ts @@ -0,0 +1,12 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const DEFAULT_PERSONALIZE_CONFIG = { + flushSize: 5, + flushInterval: 5_000, +}; + +export const PERSONALIZE_FLUSH_SIZE_MAX = 10; + +export const IDENTIFY_EVENT_TYPE = 'Identify'; +export const MEDIA_AUTO_TRACK_EVENT_TYPE = 'MediaAutoTrack'; diff --git a/packages/analytics/src/providers/personalize/utils/getEventBuffer.ts b/packages/analytics/src/providers/personalize/utils/getEventBuffer.ts new file mode 100644 index 00000000000..7370f722114 --- /dev/null +++ b/packages/analytics/src/providers/personalize/utils/getEventBuffer.ts @@ -0,0 +1,109 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { EventBuffer, groupBy, IAnalyticsClient } from '../../../utils'; +import { PersonalizeBufferConfig, PersonalizeBufferEvent } from '../types'; +import { + PersonalizeEventsClient, + PutEventsCommand, +} from '@aws-sdk/client-personalize-events'; + +/** + * These Records hold cached event buffers and AWS clients. + * The hash key is determined by the region and session, + * consisting of a combined value comprising [region, sessionToken, identityId]. + * + * Only one active session should exist at any given moment. + * When a new session is initiated, the previous ones should be released. + * */ +const eventBufferMap: Record> = {}; +const cachedClients: Record = {}; + +const DELIMITER = '#'; + +const createPutEventsCommand = ( + ids: string, + events: PersonalizeBufferEvent[] +): PutEventsCommand => { + const [trackingId, sessionId, userId] = ids.split(DELIMITER); + return new PutEventsCommand({ + trackingId, + sessionId, + userId, + eventList: events.map(event => ({ + eventId: event.event.eventId, + eventType: event.event.eventType, + properties: JSON.stringify(event.event.properties), + sentAt: new Date(event.timestamp), + })), + }); +}; + +const submitEvents = async ( + events: PersonalizeBufferEvent[], + client: PersonalizeEventsClient +): Promise => { + const groupedByIds = Object.entries( + groupBy( + event => + [event.trackingId, event.sessionId, event.userId] + .filter(id => !!id) + .join(DELIMITER), + events + ) + ); + + const requests = groupedByIds + .map(([ids, events]) => createPutEventsCommand(ids, events)) + .map(command => client.send(command)); + + await Promise.allSettled(requests); + return Promise.resolve([]); +}; + +export const getEventBuffer = ({ + region, + flushSize, + flushInterval, + bufferSize, + credentials, + identityId, +}: PersonalizeBufferConfig): EventBuffer => { + const { sessionToken } = credentials; + const sessionIdentityKey = [region, sessionToken, identityId] + .filter(x => !!x) + .join('-'); + + if (!eventBufferMap[sessionIdentityKey]) { + const getClient = (): IAnalyticsClient => { + if (!cachedClients[sessionIdentityKey]) { + cachedClients[sessionIdentityKey] = new PersonalizeEventsClient({ + region, + credentials, + }); + } + return events => submitEvents(events, cachedClients[sessionIdentityKey]); + }; + + eventBufferMap[sessionIdentityKey] = + new EventBuffer( + { + bufferSize, + flushSize, + flushInterval, + }, + getClient + ); + + const releaseSessionKeys = Object.keys(eventBufferMap).filter( + key => key !== sessionIdentityKey + ); + for (const releaseSessionKey of releaseSessionKeys) { + eventBufferMap[releaseSessionKey].release(); + delete eventBufferMap[releaseSessionKey]; + delete cachedClients[releaseSessionKey]; + } + } + + return eventBufferMap[sessionIdentityKey]; +}; diff --git a/packages/analytics/src/providers/personalize/utils/index.ts b/packages/analytics/src/providers/personalize/utils/index.ts new file mode 100644 index 00000000000..5f0221468a7 --- /dev/null +++ b/packages/analytics/src/providers/personalize/utils/index.ts @@ -0,0 +1,11 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { getEventBuffer } from './getEventBuffer'; +export { resolveConfig } from './resolveConfig'; +export { autoTrackMedia } from './autoTrackMedia'; +export { resolveCachedSession, updateCachedSession } from './cachedSession'; +export { + DEFAULT_PERSONALIZE_CONFIG, + PERSONALIZE_FLUSH_SIZE_MAX, +} from './constants'; diff --git a/packages/analytics/src/providers/personalize/utils/resolveConfig.ts b/packages/analytics/src/providers/personalize/utils/resolveConfig.ts new file mode 100644 index 00000000000..0c4d9641a98 --- /dev/null +++ b/packages/analytics/src/providers/personalize/utils/resolveConfig.ts @@ -0,0 +1,41 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify } from '@aws-amplify/core'; +import { + AnalyticsValidationErrorCode, + assertValidationError, +} from '../../../errors'; +import { DEFAULT_PERSONALIZE_CONFIG, PERSONALIZE_FLUSH_SIZE_MAX } from './'; + +export const resolveConfig = () => { + const config = Amplify.getConfig().Analytics?.Personalize; + const { + region, + trackingId, + flushSize = DEFAULT_PERSONALIZE_CONFIG.flushSize, + flushInterval = DEFAULT_PERSONALIZE_CONFIG.flushInterval, + } = { + ...DEFAULT_PERSONALIZE_CONFIG, + ...config, + }; + + assertValidationError(!!region, AnalyticsValidationErrorCode.NoRegion); + assertValidationError( + !!trackingId, + AnalyticsValidationErrorCode.NoTrackingId + ); + assertValidationError( + flushSize <= PERSONALIZE_FLUSH_SIZE_MAX, + AnalyticsValidationErrorCode.InvalidFlushSize, + `FlushSize for Personalize should be less or equal than ${PERSONALIZE_FLUSH_SIZE_MAX}` + ); + + return { + region, + trackingId, + bufferSize: flushSize + 1, + flushSize, + flushInterval, + }; +}; diff --git a/packages/analytics/src/utils/eventBuffer/EventBuffer.ts b/packages/analytics/src/utils/eventBuffer/EventBuffer.ts index 15d0a5b1b88..04d7c134af3 100644 --- a/packages/analytics/src/utils/eventBuffer/EventBuffer.ts +++ b/packages/analytics/src/utils/eventBuffer/EventBuffer.ts @@ -46,6 +46,10 @@ export class EventBuffer { } } + public get length() { + return this.list.length; + } + private head(count: number) { return this.list.splice(0, count); } diff --git a/packages/aws-amplify/__tests__/exports.test.ts b/packages/aws-amplify/__tests__/exports.test.ts index 0d3b87e1b0b..e8bee9ad42f 100644 --- a/packages/aws-amplify/__tests__/exports.test.ts +++ b/packages/aws-amplify/__tests__/exports.test.ts @@ -11,6 +11,7 @@ import * as inAppMessagingTopLevelExports from '../src/in-app-messaging'; import * as inAppMessagingPinpointTopLevelExports from '../src/in-app-messaging/pinpoint'; import * as analyticsKinesisExports from '../src/analytics/kinesis'; import * as analyticsKinesisFirehoseExports from '../src/analytics/kinesis-firehose'; +import * as analyticsPersonalizeExports from '../src/analytics/personalize'; import * as storageTopLevelExports from '../src/storage'; import * as storageS3Exports from '../src/storage/s3'; @@ -79,6 +80,14 @@ describe('aws-amplify Exports', () => { ] `); }); + + it('should only export expected symbols from the Personalize provider', () => { + expect(Object.keys(analyticsPersonalizeExports)).toMatchInlineSnapshot(` + Array [ + "record", + ] + `); + }); }); describe('InAppMessaging exports', () => { diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index c94235e4f8a..24d167a7e4a 100644 --- a/packages/aws-amplify/package.json +++ b/packages/aws-amplify/package.json @@ -62,6 +62,11 @@ "import": "./lib-esm/analytics/kinesis-firehose/index.js", "require": "./lib/analytics/kinesis-firehose/index.js" }, + "./analytics/personalize": { + "types": "./lib-esm/analytics/personalize/index.d.ts", + "import": "./lib-esm/analytics/personalize/index.js", + "require": "./lib/analytics/personalize/index.js" + }, "./storage": { "types": "./lib-esm/storage/index.d.ts", "import": "./lib-esm/storage/index.js", @@ -138,9 +143,15 @@ "analytics/pinpoint": [ "./lib-esm/analytics/pinpoint/index.d.ts" ], + "analytics/kinesis": [ + "./lib-esm/analytics/kinesis/index.d.ts" + ], "analytics/kinesis-firehose": [ "./lib-esm/analytics/kinesis-firehose/index.d.ts" ], + "analytics/personalize": [ + "./lib-esm/analytics/personalize/index.d.ts" + ], "storage": [ "./lib-esm/storage/index.d.ts" ], diff --git a/packages/core/src/providers/personalize/types/index.ts b/packages/core/src/providers/personalize/types/index.ts new file mode 100644 index 00000000000..64897932a4a --- /dev/null +++ b/packages/core/src/providers/personalize/types/index.ts @@ -0,0 +1,4 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { PersonalizeProviderConfig } from './personalize'; diff --git a/packages/core/src/providers/personalize/types/personalize.ts b/packages/core/src/providers/personalize/types/personalize.ts new file mode 100644 index 00000000000..c85d884eca9 --- /dev/null +++ b/packages/core/src/providers/personalize/types/personalize.ts @@ -0,0 +1,11 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export type PersonalizeProviderConfig = { + Personalize?: { + trackingId: string; + region: string; + flushSize?: number; + flushInterval?: number; + }; +}; diff --git a/packages/core/src/singleton/Analytics/types.ts b/packages/core/src/singleton/Analytics/types.ts index aac92648224..618ef03790e 100644 --- a/packages/core/src/singleton/Analytics/types.ts +++ b/packages/core/src/singleton/Analytics/types.ts @@ -4,7 +4,9 @@ import { PinpointProviderConfig } from '../../providers/pinpoint/types'; import { KinesisProviderConfig } from '../../providers/kinesis/types'; import { KinesisFirehoseProviderConfig } from '../../providers/kinesis-firehose/types'; +import { PersonalizeProviderConfig } from '../../providers/personalize/types'; export type AnalyticsConfig = PinpointProviderConfig & KinesisProviderConfig & - KinesisFirehoseProviderConfig; + KinesisFirehoseProviderConfig & + PersonalizeProviderConfig; From c291da3a1f39df1565f007bab1cb90873e06c7e9 Mon Sep 17 00:00:00 2001 From: Di Wu Date: Tue, 3 Oct 2023 15:41:04 -0700 Subject: [PATCH 12/20] feat(analytics): add flushEvents API for service provider KDS (#12173) * feat(analytics): add flushEvents api for service provider KDS * resolve comments * update comment --- .../kinesis/apis/flushEvents.test.ts | 64 +++++++++++++++++++ .../src/providers/kinesis/apis/flushEvents.ts | 35 ++++++++++ .../src/providers/kinesis/apis/index.ts | 1 + .../analytics/src/providers/kinesis/index.ts | 2 +- .../aws-amplify/__tests__/exports.test.ts | 1 + 5 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 packages/analytics/__tests__/providers/kinesis/apis/flushEvents.test.ts create mode 100644 packages/analytics/src/providers/kinesis/apis/flushEvents.ts diff --git a/packages/analytics/__tests__/providers/kinesis/apis/flushEvents.test.ts b/packages/analytics/__tests__/providers/kinesis/apis/flushEvents.test.ts new file mode 100644 index 00000000000..75aaef41a4e --- /dev/null +++ b/packages/analytics/__tests__/providers/kinesis/apis/flushEvents.test.ts @@ -0,0 +1,64 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { resolveConfig } from '../../../../src/providers/kinesis/utils/resolveConfig'; +import { resolveCredentials } from '../../../../src/utils'; +import { + mockKinesisConfig, + mockCredentialConfig, +} from '../../../testUtils/mockConstants.test'; +import { getEventBuffer } from '../../../../src/providers/kinesis/utils/getEventBuffer'; +import { flushEvents } from '../../../../src/providers/kinesis/apis'; +import { ConsoleLogger } from '@aws-amplify/core/internals/utils'; + +jest.mock('../../../../src/utils'); +jest.mock('../../../../src/providers/kinesis/utils/getEventBuffer'); +jest.mock('../../../../src/providers/kinesis/utils/resolveConfig'); + +describe('Analytics Kinesis API: flushEvents', () => { + const mockResolveConfig = resolveConfig as jest.Mock; + const mockResolveCredentials = resolveCredentials as jest.Mock; + const mockGetEventBuffer = getEventBuffer as jest.Mock; + const mockFlushAll = jest.fn(); + const loggerWarnSpy = jest.spyOn(ConsoleLogger.prototype, 'warn'); + + beforeEach(() => { + mockResolveConfig.mockReturnValue(mockKinesisConfig); + mockResolveCredentials.mockReturnValue( + Promise.resolve(mockCredentialConfig) + ); + mockGetEventBuffer.mockImplementation(() => ({ + flushAll: mockFlushAll, + })); + }); + + afterEach(() => { + mockResolveConfig.mockReset(); + mockResolveCredentials.mockReset(); + mockFlushAll.mockReset(); + mockGetEventBuffer.mockReset(); + }); + + it('trigger flushAll on event buffer', async () => { + flushEvents(); + await new Promise(process.nextTick); + expect(mockResolveConfig).toHaveBeenCalledTimes(1); + expect(mockResolveCredentials).toHaveBeenCalledTimes(1); + expect(mockGetEventBuffer).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + ...mockKinesisConfig, + ...mockCredentialConfig, + }) + ); + expect(mockFlushAll).toHaveBeenCalledTimes(1); + }); + + it('logs an error when credentials can not be fetched', async () => { + mockResolveCredentials.mockRejectedValue(new Error('Mock Error')); + + flushEvents(); + await new Promise(process.nextTick); + expect(loggerWarnSpy).toBeCalledWith(expect.any(String), expect.any(Error)); + }); +}); diff --git a/packages/analytics/src/providers/kinesis/apis/flushEvents.ts b/packages/analytics/src/providers/kinesis/apis/flushEvents.ts new file mode 100644 index 00000000000..8d3b147bbf0 --- /dev/null +++ b/packages/analytics/src/providers/kinesis/apis/flushEvents.ts @@ -0,0 +1,35 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { resolveConfig } from '../utils/resolveConfig'; +import { resolveCredentials } from '../../../utils'; +import { getEventBuffer } from '../utils/getEventBuffer'; +import { ConsoleLogger } from '@aws-amplify/core/internals/utils'; + +const logger = new ConsoleLogger('Kinesis'); + +/** + * Flushes all buffered Kinesis events to the service. + * + * @note + * This API will make a best-effort attempt to flush events from the buffer. Events recorded immediately after invoking + * this API may not be included in the flush. + */ +export const flushEvents = () => { + const { region, flushSize, flushInterval, bufferSize, resendLimit } = + resolveConfig(); + resolveCredentials() + .then(({ credentials, identityId }) => + getEventBuffer({ + region, + flushSize, + flushInterval, + bufferSize, + credentials, + identityId, + resendLimit, + }) + ) + .then(eventBuffer => eventBuffer.flushAll()) + .catch(e => logger.warn('Failed to flush events.', e)); +}; diff --git a/packages/analytics/src/providers/kinesis/apis/index.ts b/packages/analytics/src/providers/kinesis/apis/index.ts index 73c543ba25f..8752a731011 100644 --- a/packages/analytics/src/providers/kinesis/apis/index.ts +++ b/packages/analytics/src/providers/kinesis/apis/index.ts @@ -2,3 +2,4 @@ // SPDX-License-Identifier: Apache-2.0 export { record } from './record'; +export { flushEvents } from './flushEvents'; diff --git a/packages/analytics/src/providers/kinesis/index.ts b/packages/analytics/src/providers/kinesis/index.ts index e52e5aafdac..57fdde85084 100644 --- a/packages/analytics/src/providers/kinesis/index.ts +++ b/packages/analytics/src/providers/kinesis/index.ts @@ -1,4 +1,4 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -export { record } from './apis'; +export { record, flushEvents } from './apis'; diff --git a/packages/aws-amplify/__tests__/exports.test.ts b/packages/aws-amplify/__tests__/exports.test.ts index e8bee9ad42f..5e2da1e0f1d 100644 --- a/packages/aws-amplify/__tests__/exports.test.ts +++ b/packages/aws-amplify/__tests__/exports.test.ts @@ -68,6 +68,7 @@ describe('aws-amplify Exports', () => { expect(Object.keys(analyticsKinesisExports)).toMatchInlineSnapshot(` Array [ "record", + "flushEvents", ] `); }); From 73911217a2a1eae5b94a7947fe02c1298a33099b Mon Sep 17 00:00:00 2001 From: Di Wu Date: Tue, 3 Oct 2023 15:44:18 -0700 Subject: [PATCH 13/20] feat(analytics): add flushEvents API for service provider KDF (#12174) * feat(analytics): add flushEvents API for service provider KDF * update comment --- .../kinesis-firehose/apis/flushEvents.test.ts | 65 +++++++++++++++++++ .../kinesis-firehose/apis/flushEvents.ts | 34 ++++++++++ .../providers/kinesis-firehose/apis/index.ts | 1 + .../src/providers/kinesis-firehose/index.ts | 2 +- .../aws-amplify/__tests__/exports.test.ts | 1 + 5 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 packages/analytics/__tests__/providers/kinesis-firehose/apis/flushEvents.test.ts create mode 100644 packages/analytics/src/providers/kinesis-firehose/apis/flushEvents.ts diff --git a/packages/analytics/__tests__/providers/kinesis-firehose/apis/flushEvents.test.ts b/packages/analytics/__tests__/providers/kinesis-firehose/apis/flushEvents.test.ts new file mode 100644 index 00000000000..d5b3629032e --- /dev/null +++ b/packages/analytics/__tests__/providers/kinesis-firehose/apis/flushEvents.test.ts @@ -0,0 +1,65 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + getEventBuffer, + resolveConfig, +} from '../../../../src/providers/kinesis-firehose/utils'; +import { resolveCredentials } from '../../../../src/utils'; +import { + mockKinesisConfig, + mockCredentialConfig, +} from '../../../testUtils/mockConstants.test'; +import { flushEvents } from '../../../../src/providers/kinesis-firehose/apis'; +import { ConsoleLogger } from '@aws-amplify/core/internals/utils'; + +jest.mock('../../../../src/utils'); +jest.mock('../../../../src/providers/kinesis-firehose/utils'); + +describe('Analytics Kinesis Firehose API: flushEvents', () => { + const mockResolveConfig = resolveConfig as jest.Mock; + const mockResolveCredentials = resolveCredentials as jest.Mock; + const mockGetEventBuffer = getEventBuffer as jest.Mock; + const mockFlushAll = jest.fn(); + const loggerWarnSpy = jest.spyOn(ConsoleLogger.prototype, 'warn'); + + beforeEach(() => { + mockResolveConfig.mockReturnValue(mockKinesisConfig); + mockResolveCredentials.mockReturnValue( + Promise.resolve(mockCredentialConfig) + ); + mockGetEventBuffer.mockImplementation(() => ({ + flushAll: mockFlushAll, + })); + }); + + afterEach(() => { + mockResolveConfig.mockReset(); + mockResolveCredentials.mockReset(); + mockFlushAll.mockReset(); + mockGetEventBuffer.mockReset(); + }); + + it('trigger flushAll on event buffer', async () => { + flushEvents(); + await new Promise(process.nextTick); + expect(mockResolveConfig).toHaveBeenCalledTimes(1); + expect(mockResolveCredentials).toHaveBeenCalledTimes(1); + expect(mockGetEventBuffer).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + ...mockKinesisConfig, + ...mockCredentialConfig, + }) + ); + expect(mockFlushAll).toHaveBeenCalledTimes(1); + }); + + it('logs an error when credentials can not be fetched', async () => { + mockResolveCredentials.mockRejectedValue(new Error('Mock Error')); + + flushEvents(); + await new Promise(process.nextTick); + expect(loggerWarnSpy).toBeCalledWith(expect.any(String), expect.any(Error)); + }); +}); diff --git a/packages/analytics/src/providers/kinesis-firehose/apis/flushEvents.ts b/packages/analytics/src/providers/kinesis-firehose/apis/flushEvents.ts new file mode 100644 index 00000000000..f23be86e86a --- /dev/null +++ b/packages/analytics/src/providers/kinesis-firehose/apis/flushEvents.ts @@ -0,0 +1,34 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getEventBuffer, resolveConfig } from '../utils'; +import { resolveCredentials } from '../../../utils'; +import { ConsoleLogger } from '@aws-amplify/core/internals/utils'; + +const logger = new ConsoleLogger('KinesisFirehose'); + +/** + * Flushes all buffered Kinesis events to the service. + * + * @note + * This API will make a best-effort attempt to flush events from the buffer. Events recorded immediately after invoking + * this API may not be included in the flush. + */ +export const flushEvents = () => { + const { region, flushSize, flushInterval, bufferSize, resendLimit } = + resolveConfig(); + resolveCredentials() + .then(({ credentials, identityId }) => + getEventBuffer({ + region, + flushSize, + flushInterval, + bufferSize, + credentials, + identityId, + resendLimit, + }) + ) + .then(eventBuffer => eventBuffer.flushAll()) + .catch(e => logger.warn('Failed to flush events.', e)); +}; diff --git a/packages/analytics/src/providers/kinesis-firehose/apis/index.ts b/packages/analytics/src/providers/kinesis-firehose/apis/index.ts index 73c543ba25f..8752a731011 100644 --- a/packages/analytics/src/providers/kinesis-firehose/apis/index.ts +++ b/packages/analytics/src/providers/kinesis-firehose/apis/index.ts @@ -2,3 +2,4 @@ // SPDX-License-Identifier: Apache-2.0 export { record } from './record'; +export { flushEvents } from './flushEvents'; diff --git a/packages/analytics/src/providers/kinesis-firehose/index.ts b/packages/analytics/src/providers/kinesis-firehose/index.ts index e52e5aafdac..57fdde85084 100644 --- a/packages/analytics/src/providers/kinesis-firehose/index.ts +++ b/packages/analytics/src/providers/kinesis-firehose/index.ts @@ -1,4 +1,4 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -export { record } from './apis'; +export { record, flushEvents } from './apis'; diff --git a/packages/aws-amplify/__tests__/exports.test.ts b/packages/aws-amplify/__tests__/exports.test.ts index 5e2da1e0f1d..fc31370c5f3 100644 --- a/packages/aws-amplify/__tests__/exports.test.ts +++ b/packages/aws-amplify/__tests__/exports.test.ts @@ -78,6 +78,7 @@ describe('aws-amplify Exports', () => { .toMatchInlineSnapshot(` Array [ "record", + "flushEvents", ] `); }); From 8447b3f255178bb3a3f5824fd964c8797dfe1942 Mon Sep 17 00:00:00 2001 From: Di Wu Date: Tue, 3 Oct 2023 15:53:45 -0700 Subject: [PATCH 14/20] feat(analytics): add flushEvents API for service provider Personalize (#12181) --- .../personalize/apis/flushEvents.test.ts | 66 +++++++++++++++++++ .../providers/personalize/apis/flushEvents.ts | 32 +++++++++ .../src/providers/personalize/apis/index.ts | 1 + .../src/providers/personalize/index.ts | 2 +- .../aws-amplify/__tests__/exports.test.ts | 1 + 5 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 packages/analytics/__tests__/providers/personalize/apis/flushEvents.test.ts create mode 100644 packages/analytics/src/providers/personalize/apis/flushEvents.ts diff --git a/packages/analytics/__tests__/providers/personalize/apis/flushEvents.test.ts b/packages/analytics/__tests__/providers/personalize/apis/flushEvents.test.ts new file mode 100644 index 00000000000..eff60b3f93b --- /dev/null +++ b/packages/analytics/__tests__/providers/personalize/apis/flushEvents.test.ts @@ -0,0 +1,66 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + getEventBuffer, + resolveConfig, +} from '../../../../src/providers/personalize/utils'; +import { resolveCredentials } from '../../../../src/utils'; +import { + mockCredentialConfig, + mockPersonalizeConfig, +} from '../../../testUtils/mockConstants.test'; +import { flushEvents } from '../../../../src/providers/personalize'; +import { ConsoleLogger } from '@aws-amplify/core/internals/utils'; + +jest.mock('../../../../src/utils'); +jest.mock('../../../../src/providers/personalize/utils'); + +describe('Analytics Personalize API: flushEvents', () => { + const mockResolveConfig = resolveConfig as jest.Mock; + const mockResolveCredentials = resolveCredentials as jest.Mock; + const mockGetEventBuffer = getEventBuffer as jest.Mock; + const mockFlushAll = jest.fn(); + const loggerWarnSpy = jest.spyOn(ConsoleLogger.prototype, 'warn'); + + beforeEach(() => { + mockResolveConfig.mockReturnValue(mockPersonalizeConfig); + mockResolveCredentials.mockReturnValue( + Promise.resolve(mockCredentialConfig) + ); + mockGetEventBuffer.mockImplementation(() => ({ + flushAll: mockFlushAll, + })); + }); + + afterEach(() => { + mockResolveConfig.mockReset(); + mockResolveCredentials.mockReset(); + mockFlushAll.mockReset(); + mockGetEventBuffer.mockReset(); + }); + + it('trigger flushAll on event buffer', async () => { + flushEvents(); + await new Promise(process.nextTick); + expect(mockResolveConfig).toHaveBeenCalledTimes(1); + expect(mockResolveCredentials).toHaveBeenCalledTimes(1); + const { trackingId, ...configWithoutTrackingId } = mockPersonalizeConfig; + expect(mockGetEventBuffer).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + ...configWithoutTrackingId, + ...mockCredentialConfig, + }) + ); + expect(mockFlushAll).toHaveBeenCalledTimes(1); + }); + + it('logs an error when credentials can not be fetched', async () => { + mockResolveCredentials.mockRejectedValue(new Error('Mock Error')); + + flushEvents(); + await new Promise(process.nextTick); + expect(loggerWarnSpy).toBeCalledWith(expect.any(String), expect.any(Error)); + }); +}); diff --git a/packages/analytics/src/providers/personalize/apis/flushEvents.ts b/packages/analytics/src/providers/personalize/apis/flushEvents.ts new file mode 100644 index 00000000000..f8ac14b7ef7 --- /dev/null +++ b/packages/analytics/src/providers/personalize/apis/flushEvents.ts @@ -0,0 +1,32 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getEventBuffer, resolveConfig } from '../utils'; +import { resolveCredentials } from '../../../utils'; +import { ConsoleLogger } from '@aws-amplify/core/internals/utils'; + +const logger = new ConsoleLogger('Personalize'); + +/** + * Flushes all buffered Personalize events to the service. + * + * @note + * This API will make a best-effort attempt to flush events from the buffer. Events recorded immediately after invoking + * this API may not be included in the flush. + */ +export const flushEvents = () => { + const { region, flushSize, bufferSize, flushInterval } = resolveConfig(); + resolveCredentials() + .then(({ credentials, identityId }) => + getEventBuffer({ + region, + flushSize, + flushInterval, + bufferSize, + credentials, + identityId, + }) + ) + .then(eventBuffer => eventBuffer.flushAll()) + .catch(e => logger.warn('Failed to flush events', e)); +}; diff --git a/packages/analytics/src/providers/personalize/apis/index.ts b/packages/analytics/src/providers/personalize/apis/index.ts index 73c543ba25f..8752a731011 100644 --- a/packages/analytics/src/providers/personalize/apis/index.ts +++ b/packages/analytics/src/providers/personalize/apis/index.ts @@ -2,3 +2,4 @@ // SPDX-License-Identifier: Apache-2.0 export { record } from './record'; +export { flushEvents } from './flushEvents'; diff --git a/packages/analytics/src/providers/personalize/index.ts b/packages/analytics/src/providers/personalize/index.ts index e52e5aafdac..57fdde85084 100644 --- a/packages/analytics/src/providers/personalize/index.ts +++ b/packages/analytics/src/providers/personalize/index.ts @@ -1,4 +1,4 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -export { record } from './apis'; +export { record, flushEvents } from './apis'; diff --git a/packages/aws-amplify/__tests__/exports.test.ts b/packages/aws-amplify/__tests__/exports.test.ts index fc31370c5f3..1827c1f110b 100644 --- a/packages/aws-amplify/__tests__/exports.test.ts +++ b/packages/aws-amplify/__tests__/exports.test.ts @@ -87,6 +87,7 @@ describe('aws-amplify Exports', () => { expect(Object.keys(analyticsPersonalizeExports)).toMatchInlineSnapshot(` Array [ "record", + "flushEvents", ] `); }); From 3e7c5261ae31645ebb203d3ecf1b0247403c7846 Mon Sep 17 00:00:00 2001 From: Di Wu Date: Wed, 4 Oct 2023 11:06:54 -0700 Subject: [PATCH 15/20] feat(analtics): add flushEvents for service provider Pinpoint (#12183) * feat(analtics): add flushEvents for service provider Pinpoint * resolve comment --- .../pinpoint/apis/flushEvents.test.ts | 63 +++++++++++++++++++ packages/analytics/src/index.ts | 1 + .../providers/pinpoint/apis/flushEvents.ts | 24 +++++++ .../src/providers/pinpoint/apis/index.ts | 1 + .../analytics/src/providers/pinpoint/index.ts | 2 +- .../aws-amplify/__tests__/exports.test.ts | 2 + .../pinpoint/apis/flushEvents.test.ts | 36 +++++++++++ .../providers/pinpoint/apis/flushEvents.ts | 29 +++++++++ .../core/src/providers/pinpoint/apis/index.ts | 1 + .../pinpoint/utils/PinpointEventBuffer.ts | 4 ++ 10 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 packages/analytics/__tests__/providers/pinpoint/apis/flushEvents.test.ts create mode 100644 packages/analytics/src/providers/pinpoint/apis/flushEvents.ts create mode 100644 packages/core/__tests__/providers/pinpoint/apis/flushEvents.test.ts create mode 100644 packages/core/src/providers/pinpoint/apis/flushEvents.ts diff --git a/packages/analytics/__tests__/providers/pinpoint/apis/flushEvents.test.ts b/packages/analytics/__tests__/providers/pinpoint/apis/flushEvents.test.ts new file mode 100644 index 00000000000..05a91f3bcb0 --- /dev/null +++ b/packages/analytics/__tests__/providers/pinpoint/apis/flushEvents.test.ts @@ -0,0 +1,63 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + resolveConfig, + resolveCredentials, +} from '../../../../src/providers/pinpoint/utils'; +import { config, credentials, identityId } from './testUtils/data'; +import { flushEvents } from '../../../../src/providers/pinpoint'; +import { flushEvents as pinpointFlushEvents } from '@aws-amplify/core/internals/providers/pinpoint'; +import { ConsoleLogger } from '@aws-amplify/core/internals/utils'; + +jest.mock('../../../../src/providers/pinpoint/utils'); +jest.mock('@aws-amplify/core/internals/providers/pinpoint'); + +describe('Pinpoint API: flushEvents', () => { + const mockResolveConfig = resolveConfig as jest.Mock; + const mockResolveCredentials = resolveCredentials as jest.Mock; + const mockPinpointFlushEvents = pinpointFlushEvents as jest.Mock; + const loggerWarnSpy = jest.spyOn(ConsoleLogger.prototype, 'warn'); + + beforeEach(() => { + mockResolveConfig.mockReturnValue(config); + mockResolveCredentials.mockReturnValue( + Promise.resolve({ + credentials, + identityId, + }) + ); + }); + + afterEach(() => { + mockResolveConfig.mockReset(); + mockResolveCredentials.mockReset(); + mockPinpointFlushEvents.mockReset(); + }); + + it('invokes the core flushEvents implementation', async () => { + flushEvents(); + + expect(mockResolveConfig).toBeCalledTimes(1); + expect(mockResolveCredentials).toBeCalledTimes(1); + + await new Promise(process.nextTick); + expect(mockPinpointFlushEvents).toBeCalledWith( + config.appId, + config.region, + credentials, + identityId + ); + }); + + it('logs an error when credentials can not be fetched', async () => { + mockResolveCredentials.mockRejectedValue(new Error('Mock Error')); + + flushEvents(); + + await new Promise(process.nextTick); + + expect(mockPinpointFlushEvents).not.toBeCalled(); + expect(loggerWarnSpy).toBeCalledWith(expect.any(String), expect.any(Error)); + }); +}); diff --git a/packages/analytics/src/index.ts b/packages/analytics/src/index.ts index 2f9bcf2d685..7f9732ce48a 100644 --- a/packages/analytics/src/index.ts +++ b/packages/analytics/src/index.ts @@ -6,6 +6,7 @@ export { identifyUser, RecordInput, IdentifyUserInput, + flushEvents, } from './providers/pinpoint'; export { enable, disable } from './apis'; export { AnalyticsError } from './errors'; diff --git a/packages/analytics/src/providers/pinpoint/apis/flushEvents.ts b/packages/analytics/src/providers/pinpoint/apis/flushEvents.ts new file mode 100644 index 00000000000..2e3fbe02b94 --- /dev/null +++ b/packages/analytics/src/providers/pinpoint/apis/flushEvents.ts @@ -0,0 +1,24 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { resolveConfig, resolveCredentials } from '../utils'; +import { flushEvents as flushEventsCore } from '@aws-amplify/core/internals/providers/pinpoint'; +import { ConsoleLogger } from '@aws-amplify/core/internals/utils'; + +const logger = new ConsoleLogger('Analytics'); + +/** + * Flushes all buffered Pinpoint events to the service. + * + * @note + * This API will make a best-effort attempt to flush events from the buffer. Events recorded immediately after invoking + * this API may not be included in the flush. + */ +export const flushEvents = () => { + const { appId, region } = resolveConfig(); + resolveCredentials() + .then(({ credentials, identityId }) => + flushEventsCore(appId, region, credentials, identityId) + ) + .catch(e => logger.warn('Failed to flush events', e)); +}; diff --git a/packages/analytics/src/providers/pinpoint/apis/index.ts b/packages/analytics/src/providers/pinpoint/apis/index.ts index 4d9ffaebfce..7c9583f8ca8 100644 --- a/packages/analytics/src/providers/pinpoint/apis/index.ts +++ b/packages/analytics/src/providers/pinpoint/apis/index.ts @@ -3,3 +3,4 @@ export { record } from './record'; export { identifyUser } from './identifyUser'; +export { flushEvents } from './flushEvents'; diff --git a/packages/analytics/src/providers/pinpoint/index.ts b/packages/analytics/src/providers/pinpoint/index.ts index c7ae7f368b1..e8c93d6fd90 100644 --- a/packages/analytics/src/providers/pinpoint/index.ts +++ b/packages/analytics/src/providers/pinpoint/index.ts @@ -1,5 +1,5 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -export { record, identifyUser } from './apis'; +export { record, identifyUser, flushEvents } from './apis'; export { RecordInput, IdentifyUserInput } from './types/inputs'; diff --git a/packages/aws-amplify/__tests__/exports.test.ts b/packages/aws-amplify/__tests__/exports.test.ts index 1827c1f110b..c81ec2b6d24 100644 --- a/packages/aws-amplify/__tests__/exports.test.ts +++ b/packages/aws-amplify/__tests__/exports.test.ts @@ -48,6 +48,7 @@ describe('aws-amplify Exports', () => { Array [ "record", "identifyUser", + "flushEvents", "enable", "disable", "AnalyticsError", @@ -60,6 +61,7 @@ describe('aws-amplify Exports', () => { Array [ "record", "identifyUser", + "flushEvents", ] `); }); diff --git a/packages/core/__tests__/providers/pinpoint/apis/flushEvents.test.ts b/packages/core/__tests__/providers/pinpoint/apis/flushEvents.test.ts new file mode 100644 index 00000000000..9f0549684c8 --- /dev/null +++ b/packages/core/__tests__/providers/pinpoint/apis/flushEvents.test.ts @@ -0,0 +1,36 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getEventBuffer } from '../../../../src/providers/pinpoint/utils/getEventBuffer'; +import { flushEvents } from '../../../../src/providers/pinpoint'; +import { appId, region, credentials, identityId } from '../testUtils/data'; + +jest.mock('../../../../src/providers/pinpoint/utils/getEventBuffer'); + +describe('Pinpoint Provider API: flushEvents', () => { + const mockGetEventBuffer = getEventBuffer as jest.Mock; + const mockFlushAll = jest.fn(); + beforeEach(() => { + mockGetEventBuffer.mockReturnValue({ + flushAll: mockFlushAll, + }); + }); + + afterEach(() => { + mockFlushAll.mockReset(); + mockGetEventBuffer.mockReset(); + }); + + it('invokes flushAll on pinpoint buffer', () => { + flushEvents(appId, region, credentials, identityId); + expect(mockGetEventBuffer).toBeCalledWith( + expect.objectContaining({ + appId, + region, + credentials, + identityId, + }) + ); + expect(mockFlushAll).toBeCalledTimes(1); + }); +}); diff --git a/packages/core/src/providers/pinpoint/apis/flushEvents.ts b/packages/core/src/providers/pinpoint/apis/flushEvents.ts new file mode 100644 index 00000000000..88e4cf386c1 --- /dev/null +++ b/packages/core/src/providers/pinpoint/apis/flushEvents.ts @@ -0,0 +1,29 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Credentials } from '@aws-sdk/types'; +import { getEventBuffer } from '../utils/getEventBuffer'; +import { + BUFFER_SIZE, + FLUSH_INTERVAL, + FLUSH_SIZE, + RESEND_LIMIT, +} from '../utils/constants'; + +export const flushEvents = ( + appId: string, + region: string, + credentials: Credentials, + identityId?: string +) => { + getEventBuffer({ + appId, + bufferSize: BUFFER_SIZE, + credentials, + flushInterval: FLUSH_INTERVAL, + flushSize: FLUSH_SIZE, + identityId, + region, + resendLimit: RESEND_LIMIT, + }).flushAll(); +}; diff --git a/packages/core/src/providers/pinpoint/apis/index.ts b/packages/core/src/providers/pinpoint/apis/index.ts index ba9abd3fa30..66abbe01f8d 100644 --- a/packages/core/src/providers/pinpoint/apis/index.ts +++ b/packages/core/src/providers/pinpoint/apis/index.ts @@ -3,3 +3,4 @@ export { updateEndpoint } from './updateEndpoint'; export { record } from './record'; +export { flushEvents } from './flushEvents'; diff --git a/packages/core/src/providers/pinpoint/utils/PinpointEventBuffer.ts b/packages/core/src/providers/pinpoint/utils/PinpointEventBuffer.ts index 60f8b0d59e7..66e77fc6148 100644 --- a/packages/core/src/providers/pinpoint/utils/PinpointEventBuffer.ts +++ b/packages/core/src/providers/pinpoint/utils/PinpointEventBuffer.ts @@ -65,6 +65,10 @@ export class PinpointEventBuffer { return this._config.identityId !== identityId; } + public flushAll() { + this._putEvents(this._buffer.splice(0, this._buffer.length)); + } + private _startLoop() { if (this._interval) { clearInterval(this._interval); From c48e88acfa2162a6fdbe56e604a6e8646ea0b068 Mon Sep 17 00:00:00 2001 From: Di Wu Date: Wed, 4 Oct 2023 13:01:33 -0700 Subject: [PATCH 16/20] fix(analytics): customize user-agent header for aws client (#12187) --- .../providers/pinpoint/apis/flushEvents.test.ts | 9 +++++++-- .../providers/kinesis-firehose/apis/flushEvents.ts | 11 +++++++++-- .../src/providers/kinesis-firehose/apis/record.ts | 12 ++++++++++-- .../kinesis-firehose/utils/getEventBuffer.ts | 2 ++ .../src/providers/kinesis/apis/flushEvents.ts | 11 +++++++++-- .../analytics/src/providers/kinesis/apis/record.ts | 12 ++++++++++-- .../src/providers/kinesis/utils/getEventBuffer.ts | 2 ++ .../src/providers/personalize/apis/flushEvents.ts | 11 +++++++++-- .../src/providers/personalize/apis/record.ts | 12 ++++++++++-- .../src/providers/personalize/types/buffer.ts | 9 +++++---- .../providers/personalize/utils/getEventBuffer.ts | 2 ++ .../src/providers/pinpoint/apis/flushEvents.ts | 14 ++++++++++++-- .../src/providers/pinpoint/apis/flushEvents.ts | 4 +++- 13 files changed, 90 insertions(+), 21 deletions(-) diff --git a/packages/analytics/__tests__/providers/pinpoint/apis/flushEvents.test.ts b/packages/analytics/__tests__/providers/pinpoint/apis/flushEvents.test.ts index 05a91f3bcb0..4a7fd733092 100644 --- a/packages/analytics/__tests__/providers/pinpoint/apis/flushEvents.test.ts +++ b/packages/analytics/__tests__/providers/pinpoint/apis/flushEvents.test.ts @@ -8,7 +8,11 @@ import { import { config, credentials, identityId } from './testUtils/data'; import { flushEvents } from '../../../../src/providers/pinpoint'; import { flushEvents as pinpointFlushEvents } from '@aws-amplify/core/internals/providers/pinpoint'; -import { ConsoleLogger } from '@aws-amplify/core/internals/utils'; +import { + AnalyticsAction, + ConsoleLogger, +} from '@aws-amplify/core/internals/utils'; +import { getAnalyticsUserAgentString } from '../../../../src/utils'; jest.mock('../../../../src/providers/pinpoint/utils'); jest.mock('@aws-amplify/core/internals/providers/pinpoint'); @@ -46,7 +50,8 @@ describe('Pinpoint API: flushEvents', () => { config.appId, config.region, credentials, - identityId + identityId, + getAnalyticsUserAgentString(AnalyticsAction.Record) ); }); diff --git a/packages/analytics/src/providers/kinesis-firehose/apis/flushEvents.ts b/packages/analytics/src/providers/kinesis-firehose/apis/flushEvents.ts index f23be86e86a..41a8d8adad3 100644 --- a/packages/analytics/src/providers/kinesis-firehose/apis/flushEvents.ts +++ b/packages/analytics/src/providers/kinesis-firehose/apis/flushEvents.ts @@ -2,8 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 import { getEventBuffer, resolveConfig } from '../utils'; -import { resolveCredentials } from '../../../utils'; -import { ConsoleLogger } from '@aws-amplify/core/internals/utils'; +import { + getAnalyticsUserAgentString, + resolveCredentials, +} from '../../../utils'; +import { + AnalyticsAction, + ConsoleLogger, +} from '@aws-amplify/core/internals/utils'; const logger = new ConsoleLogger('KinesisFirehose'); @@ -27,6 +33,7 @@ export const flushEvents = () => { credentials, identityId, resendLimit, + userAgentValue: getAnalyticsUserAgentString(AnalyticsAction.Record), }) ) .then(eventBuffer => eventBuffer.flushAll()) diff --git a/packages/analytics/src/providers/kinesis-firehose/apis/record.ts b/packages/analytics/src/providers/kinesis-firehose/apis/record.ts index b91ab73b56e..840866d34df 100644 --- a/packages/analytics/src/providers/kinesis-firehose/apis/record.ts +++ b/packages/analytics/src/providers/kinesis-firehose/apis/record.ts @@ -3,9 +3,16 @@ import { RecordInput } from '../types'; import { getEventBuffer, resolveConfig } from '../utils'; -import { isAnalyticsEnabled, resolveCredentials } from '../../../utils'; +import { + getAnalyticsUserAgentString, + isAnalyticsEnabled, + resolveCredentials, +} from '../../../utils'; import { fromUtf8 } from '@smithy/util-utf8'; -import { ConsoleLogger as Logger } from '@aws-amplify/core/internals/utils'; +import { + AnalyticsAction, + ConsoleLogger as Logger, +} from '@aws-amplify/core/internals/utils'; const logger = new Logger('KinesisFirehose'); @@ -29,6 +36,7 @@ export const record = ({ streamName, data }: RecordInput): void => { flushSize, flushInterval, resendLimit, + userAgentValue: getAnalyticsUserAgentString(AnalyticsAction.Record), }); buffer.append({ diff --git a/packages/analytics/src/providers/kinesis-firehose/utils/getEventBuffer.ts b/packages/analytics/src/providers/kinesis-firehose/utils/getEventBuffer.ts index ae840a53269..231dc4e5889 100644 --- a/packages/analytics/src/providers/kinesis-firehose/utils/getEventBuffer.ts +++ b/packages/analytics/src/providers/kinesis-firehose/utils/getEventBuffer.ts @@ -73,6 +73,7 @@ export const getEventBuffer = ({ flushSize, flushInterval, resendLimit, + userAgentValue, }: KinesisFirehoseEventBufferConfig): EventBuffer => { const { sessionToken } = credentials; const sessionIdentityKey = [region, sessionToken, identityId] @@ -85,6 +86,7 @@ export const getEventBuffer = ({ cachedClients[sessionIdentityKey] = new FirehoseClient({ region, credentials, + customUserAgent: userAgentValue, }); } diff --git a/packages/analytics/src/providers/kinesis/apis/flushEvents.ts b/packages/analytics/src/providers/kinesis/apis/flushEvents.ts index 8d3b147bbf0..ed664cb9e49 100644 --- a/packages/analytics/src/providers/kinesis/apis/flushEvents.ts +++ b/packages/analytics/src/providers/kinesis/apis/flushEvents.ts @@ -2,9 +2,15 @@ // SPDX-License-Identifier: Apache-2.0 import { resolveConfig } from '../utils/resolveConfig'; -import { resolveCredentials } from '../../../utils'; +import { + getAnalyticsUserAgentString, + resolveCredentials, +} from '../../../utils'; import { getEventBuffer } from '../utils/getEventBuffer'; -import { ConsoleLogger } from '@aws-amplify/core/internals/utils'; +import { + AnalyticsAction, + ConsoleLogger, +} from '@aws-amplify/core/internals/utils'; const logger = new ConsoleLogger('Kinesis'); @@ -28,6 +34,7 @@ export const flushEvents = () => { credentials, identityId, resendLimit, + userAgentValue: getAnalyticsUserAgentString(AnalyticsAction.Record), }) ) .then(eventBuffer => eventBuffer.flushAll()) diff --git a/packages/analytics/src/providers/kinesis/apis/record.ts b/packages/analytics/src/providers/kinesis/apis/record.ts index b6588fd2739..77d5f91c936 100644 --- a/packages/analytics/src/providers/kinesis/apis/record.ts +++ b/packages/analytics/src/providers/kinesis/apis/record.ts @@ -4,9 +4,16 @@ import { RecordInput } from '../types'; import { getEventBuffer } from '../utils/getEventBuffer'; import { resolveConfig } from '../utils/resolveConfig'; -import { isAnalyticsEnabled, resolveCredentials } from '../../../utils'; +import { + getAnalyticsUserAgentString, + isAnalyticsEnabled, + resolveCredentials, +} from '../../../utils'; import { fromUtf8 } from '@smithy/util-utf8'; -import { ConsoleLogger } from '@aws-amplify/core/internals/utils'; +import { + AnalyticsAction, + ConsoleLogger, +} from '@aws-amplify/core/internals/utils'; const logger = new ConsoleLogger('Kinesis'); @@ -34,6 +41,7 @@ export const record = ({ credentials, identityId, resendLimit, + userAgentValue: getAnalyticsUserAgentString(AnalyticsAction.Record), }); buffer.append({ diff --git a/packages/analytics/src/providers/kinesis/utils/getEventBuffer.ts b/packages/analytics/src/providers/kinesis/utils/getEventBuffer.ts index 6d0f0b86a2d..594199dd3f3 100644 --- a/packages/analytics/src/providers/kinesis/utils/getEventBuffer.ts +++ b/packages/analytics/src/providers/kinesis/utils/getEventBuffer.ts @@ -64,6 +64,7 @@ export const getEventBuffer = ({ credentials, identityId, resendLimit, + userAgentValue, }: KinesisEventBufferConfig): EventBuffer => { const { sessionToken } = credentials; const sessionIdentityKey = [region, sessionToken, identityId] @@ -76,6 +77,7 @@ export const getEventBuffer = ({ cachedClients[sessionIdentityKey] = new KinesisClient({ credentials, region, + customUserAgent: userAgentValue, }); } diff --git a/packages/analytics/src/providers/personalize/apis/flushEvents.ts b/packages/analytics/src/providers/personalize/apis/flushEvents.ts index f8ac14b7ef7..0d1b04b328d 100644 --- a/packages/analytics/src/providers/personalize/apis/flushEvents.ts +++ b/packages/analytics/src/providers/personalize/apis/flushEvents.ts @@ -2,8 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 import { getEventBuffer, resolveConfig } from '../utils'; -import { resolveCredentials } from '../../../utils'; -import { ConsoleLogger } from '@aws-amplify/core/internals/utils'; +import { + getAnalyticsUserAgentString, + resolveCredentials, +} from '../../../utils'; +import { + AnalyticsAction, + ConsoleLogger, +} from '@aws-amplify/core/internals/utils'; const logger = new ConsoleLogger('Personalize'); @@ -25,6 +31,7 @@ export const flushEvents = () => { bufferSize, credentials, identityId, + userAgentValue: getAnalyticsUserAgentString(AnalyticsAction.Record), }) ) .then(eventBuffer => eventBuffer.flushAll()) diff --git a/packages/analytics/src/providers/personalize/apis/record.ts b/packages/analytics/src/providers/personalize/apis/record.ts index 971d894c4c2..2d7dfa07b32 100644 --- a/packages/analytics/src/providers/personalize/apis/record.ts +++ b/packages/analytics/src/providers/personalize/apis/record.ts @@ -9,8 +9,15 @@ import { resolveConfig, updateCachedSession, } from '../utils'; -import { isAnalyticsEnabled, resolveCredentials } from '../../../utils'; -import { ConsoleLogger } from '@aws-amplify/core/internals/utils'; +import { + getAnalyticsUserAgentString, + isAnalyticsEnabled, + resolveCredentials, +} from '../../../utils'; +import { + AnalyticsAction, + ConsoleLogger, +} from '@aws-amplify/core/internals/utils'; import { IDENTIFY_EVENT_TYPE, MEDIA_AUTO_TRACK_EVENT_TYPE, @@ -56,6 +63,7 @@ export const record = ({ bufferSize, credentials, identityId, + userAgentValue: getAnalyticsUserAgentString(AnalyticsAction.Record), }); if (eventType === MEDIA_AUTO_TRACK_EVENT_TYPE) { diff --git a/packages/analytics/src/providers/personalize/types/buffer.ts b/packages/analytics/src/providers/personalize/types/buffer.ts index 037adb0f9e0..369035873a9 100644 --- a/packages/analytics/src/providers/personalize/types/buffer.ts +++ b/packages/analytics/src/providers/personalize/types/buffer.ts @@ -2,8 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { PersonalizeEvent } from './'; -import { EventBufferConfig } from '../../../utils/eventBuffer'; -import { AuthSession } from '@aws-amplify/core/src/singleton/Auth/types'; +import { EventBufferConfig } from '../../../utils'; +import { Credentials } from '@aws-sdk/types'; export type PersonalizeBufferEvent = { trackingId: string; @@ -15,6 +15,7 @@ export type PersonalizeBufferEvent = { export type PersonalizeBufferConfig = EventBufferConfig & { region: string; - credentials: Required['credentials']; - identityId: AuthSession['identityId']; + credentials: Credentials; + identityId?: string; + userAgentValue?: string; }; diff --git a/packages/analytics/src/providers/personalize/utils/getEventBuffer.ts b/packages/analytics/src/providers/personalize/utils/getEventBuffer.ts index 7370f722114..4699d0c6178 100644 --- a/packages/analytics/src/providers/personalize/utils/getEventBuffer.ts +++ b/packages/analytics/src/providers/personalize/utils/getEventBuffer.ts @@ -68,6 +68,7 @@ export const getEventBuffer = ({ bufferSize, credentials, identityId, + userAgentValue, }: PersonalizeBufferConfig): EventBuffer => { const { sessionToken } = credentials; const sessionIdentityKey = [region, sessionToken, identityId] @@ -80,6 +81,7 @@ export const getEventBuffer = ({ cachedClients[sessionIdentityKey] = new PersonalizeEventsClient({ region, credentials, + customUserAgent: userAgentValue, }); } return events => submitEvents(events, cachedClients[sessionIdentityKey]); diff --git a/packages/analytics/src/providers/pinpoint/apis/flushEvents.ts b/packages/analytics/src/providers/pinpoint/apis/flushEvents.ts index 2e3fbe02b94..4c7ab69b5c7 100644 --- a/packages/analytics/src/providers/pinpoint/apis/flushEvents.ts +++ b/packages/analytics/src/providers/pinpoint/apis/flushEvents.ts @@ -3,7 +3,11 @@ import { resolveConfig, resolveCredentials } from '../utils'; import { flushEvents as flushEventsCore } from '@aws-amplify/core/internals/providers/pinpoint'; -import { ConsoleLogger } from '@aws-amplify/core/internals/utils'; +import { + AnalyticsAction, + ConsoleLogger, +} from '@aws-amplify/core/internals/utils'; +import { getAnalyticsUserAgentString } from '../../../utils'; const logger = new ConsoleLogger('Analytics'); @@ -18,7 +22,13 @@ export const flushEvents = () => { const { appId, region } = resolveConfig(); resolveCredentials() .then(({ credentials, identityId }) => - flushEventsCore(appId, region, credentials, identityId) + flushEventsCore( + appId, + region, + credentials, + identityId, + getAnalyticsUserAgentString(AnalyticsAction.Record) + ) ) .catch(e => logger.warn('Failed to flush events', e)); }; diff --git a/packages/core/src/providers/pinpoint/apis/flushEvents.ts b/packages/core/src/providers/pinpoint/apis/flushEvents.ts index 88e4cf386c1..bbf8e46c9ac 100644 --- a/packages/core/src/providers/pinpoint/apis/flushEvents.ts +++ b/packages/core/src/providers/pinpoint/apis/flushEvents.ts @@ -14,7 +14,8 @@ export const flushEvents = ( appId: string, region: string, credentials: Credentials, - identityId?: string + identityId?: string, + userAgentValue?: string ) => { getEventBuffer({ appId, @@ -25,5 +26,6 @@ export const flushEvents = ( identityId, region, resendLimit: RESEND_LIMIT, + userAgentValue, }).flushAll(); }; From 1ef5e72c2702978e85d5cac750d28afb3430bbbf Mon Sep 17 00:00:00 2001 From: Di Wu Date: Wed, 4 Oct 2023 13:18:26 -0700 Subject: [PATCH 17/20] chore: update size limits --- packages/aws-amplify/package.json | 8 ++++---- packages/core/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index 24d167a7e4a..208dceab18f 100644 --- a/packages/aws-amplify/package.json +++ b/packages/aws-amplify/package.json @@ -272,13 +272,13 @@ "name": "[Auth] signUp (Cognito)", "path": "./lib-esm/auth/index.js", "import": "{ signUp }", - "limit": "10.92 kB" + "limit": "11.50 kB" }, { "name": "[Auth] resetPassword (Cognito)", "path": "./lib-esm/auth/index.js", "import": "{ resetPassword }", - "limit": "10.72 kB" + "limit": "11.30 kB" }, { "name": "[Auth] confirmResetPassword (Cognito)", @@ -302,7 +302,7 @@ "name": "[Auth] confirmSignUp (Cognito)", "path": "./lib-esm/auth/index.js", "import": "{ confirmSignUp }", - "limit": "10.71 kB" + "limit": "11.30 kB" }, { "name": "[Auth] confirmSignIn (Cognito)", @@ -344,7 +344,7 @@ "name": "[Auth] updateUserAttributes (Cognito)", "path": "./lib-esm/auth/index.js", "import": "{ updateUserAttributes }", - "limit": "10 kB" + "limit": "10.50 kB" }, { "name": "[Auth] getCurrentUser (Cognito)", diff --git a/packages/core/package.json b/packages/core/package.json index fdc00e3fe41..b216eb278af 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -86,7 +86,7 @@ "name": "Core (I18n)", "path": "./lib-esm/index.js", "import": "{ I18n }", - "limit": "4.82 kB" + "limit": "5.48 kB" }, { "name": "Custom clients (fetch handler)", From ac5fee6ddc177e623ddd681f493ecb960122f78b Mon Sep 17 00:00:00 2001 From: Di Wu Date: Wed, 4 Oct 2023 13:59:55 -0700 Subject: [PATCH 18/20] fix(analytics): add kinesis module export in package aws-amplify --- packages/aws-amplify/package.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index 208dceab18f..b2b71eca0da 100644 --- a/packages/aws-amplify/package.json +++ b/packages/aws-amplify/package.json @@ -57,6 +57,11 @@ "import": "./lib-esm/analytics/pinpoint/index.js", "require": "./lib/analytics/pinpoint/index.js" }, + "./analytics/kinesis": { + "types": "./lib-esm/analytics/kinesis/index.d.ts", + "import": "./lib-esm/analytics/kinesis/index.js", + "require": "./lib/analytics/kinesis/index.js" + }, "./analytics/kinesis-firehose": { "types": "./lib-esm/analytics/kinesis-firehose/index.d.ts", "import": "./lib-esm/analytics/kinesis-firehose/index.js", From 71584250cb94a6e483e0741604991a42a4418e59 Mon Sep 17 00:00:00 2001 From: Jim Blanchard Date: Wed, 4 Oct 2023 17:11:11 -0500 Subject: [PATCH 19/20] fix: Fix regression in InternalGraphQL client & broken tests (#12196) --- packages/api-graphql/__tests__/utils/expects.ts | 3 +++ packages/api-graphql/src/internals/InternalGraphQLAPI.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/api-graphql/__tests__/utils/expects.ts b/packages/api-graphql/__tests__/utils/expects.ts index 01f50593c63..07ad1009a47 100644 --- a/packages/api-graphql/__tests__/utils/expects.ts +++ b/packages/api-graphql/__tests__/utils/expects.ts @@ -12,6 +12,7 @@ export function expectMutation( item: Record ) { expect(spy).toHaveBeenCalledWith({ + abortController: expect.any(AbortController), url: new URL('https://localhost/graphql'), options: expect.objectContaining({ headers: expect.objectContaining({ 'X-Api-Key': 'FAKE-KEY' }), @@ -41,6 +42,7 @@ export function expectGet( item: Record ) { expect(spy).toHaveBeenCalledWith({ + abortController: expect.any(AbortController), url: new URL('https://localhost/graphql'), options: expect.objectContaining({ headers: expect.objectContaining({ 'X-Api-Key': 'FAKE-KEY' }), @@ -66,6 +68,7 @@ export function expectList( item: Record ) { expect(spy).toHaveBeenCalledWith({ + abortController: expect.any(AbortController), url: new URL('https://localhost/graphql'), options: expect.objectContaining({ headers: expect.objectContaining({ 'X-Api-Key': 'FAKE-KEY' }), diff --git a/packages/api-graphql/src/internals/InternalGraphQLAPI.ts b/packages/api-graphql/src/internals/InternalGraphQLAPI.ts index e6ec00da54d..5cd7199aece 100644 --- a/packages/api-graphql/src/internals/InternalGraphQLAPI.ts +++ b/packages/api-graphql/src/internals/InternalGraphQLAPI.ts @@ -264,7 +264,7 @@ export class InternalGraphQLAPIClass { abortController, }); - const result = { data: await responseBody.json() }; + const result = await responseBody.json(); response = result; } catch (err) { From 8d0489f0fafad9eb26fc4bd6be97ba13aa345448 Mon Sep 17 00:00:00 2001 From: AllanZhengYP Date: Wed, 4 Oct 2023 17:25:26 -0700 Subject: [PATCH 20/20] feat(api): REST API handlers (#12172) --------- Co-authored-by: Jim Blanchard Co-authored-by: Aaron S <94858815+stocaaro@users.noreply.github.com> --- packages/api-graphql/package.json | 5 +- .../{ => apis}/common/internalPost.test.ts | 10 +- .../__tests__/apis/common/publicApis.test.ts | 348 ++++++++++++++++++ packages/api-rest/__tests__/index.test.ts | 55 +++ packages/api-rest/__tests__/server.test.ts | 88 +++++ packages/api-rest/package.json | 5 +- packages/api-rest/server/package.json | 8 + .../api-rest/src/{ => apis}/common/handler.ts | 17 +- .../src/{ => apis}/common/internalPost.ts | 15 +- .../api-rest/src/apis/common/publicApis.ts | 99 +++++ packages/api-rest/src/apis/index.ts | 60 +++ packages/api-rest/src/apis/server.ts | 83 +++++ packages/api-rest/src/errors/validation.ts | 6 + packages/api-rest/src/index.ts | 1 + packages/api-rest/src/internals/index.ts | 7 +- packages/api-rest/src/internals/server.ts | 7 +- packages/api-rest/src/server.ts | 5 + packages/api-rest/src/types/index.ts | 27 +- .../src/utils/createCancellableOperation.ts | 56 ++- packages/api-rest/src/utils/index.native.ts | 13 + packages/api-rest/src/utils/index.ts | 4 +- packages/api-rest/src/utils/logger.ts | 6 + .../api-rest/src/utils/normalizeHeaders.ts | 2 +- .../{parseUrl.ts => parseSigningInfo.ts} | 27 +- packages/api-rest/src/utils/resolveApiUrl.ts | 47 +++ .../api-rest/src/utils/resolveCredentials.ts | 2 +- packages/api-rest/src/utils/serviceError.ts | 16 +- packages/api-rest/tsconfig.json | 5 +- packages/api/server/package.json | 8 + packages/api/src/index.ts | 10 + packages/api/src/server.ts | 12 + .../aws-amplify/__tests__/exports.test.ts | 19 + packages/aws-amplify/api/server/package.json | 7 + packages/aws-amplify/package.json | 6 + packages/aws-amplify/src/api/server.ts | 4 + packages/core/src/clients/types/http.ts | 2 +- yarn.lock | 9 +- 37 files changed, 1022 insertions(+), 79 deletions(-) rename packages/api-rest/__tests__/{ => apis}/common/internalPost.test.ts (96%) create mode 100644 packages/api-rest/__tests__/apis/common/publicApis.test.ts create mode 100644 packages/api-rest/__tests__/index.test.ts create mode 100644 packages/api-rest/__tests__/server.test.ts create mode 100644 packages/api-rest/server/package.json rename packages/api-rest/src/{ => apis}/common/handler.ts (84%) rename packages/api-rest/src/{ => apis}/common/internalPost.ts (67%) create mode 100644 packages/api-rest/src/apis/common/publicApis.ts create mode 100644 packages/api-rest/src/apis/index.ts create mode 100644 packages/api-rest/src/apis/server.ts create mode 100644 packages/api-rest/src/server.ts create mode 100644 packages/api-rest/src/utils/index.native.ts create mode 100644 packages/api-rest/src/utils/logger.ts rename packages/api-rest/src/utils/{parseUrl.ts => parseSigningInfo.ts} (55%) create mode 100644 packages/api-rest/src/utils/resolveApiUrl.ts create mode 100644 packages/api/server/package.json create mode 100644 packages/api/src/server.ts create mode 100644 packages/aws-amplify/api/server/package.json create mode 100644 packages/aws-amplify/src/api/server.ts diff --git a/packages/api-graphql/package.json b/packages/api-graphql/package.json index 1012ea5d342..e07780596de 100644 --- a/packages/api-graphql/package.json +++ b/packages/api-graphql/package.json @@ -52,11 +52,12 @@ "@aws-amplify/api-rest": "4.0.0", "@aws-amplify/auth": "6.0.0", "@aws-amplify/core": "6.0.0", - "@aws-sdk/types": "3.387.0", + "@aws-sdk/types": "3.387.0", "graphql": "15.8.0", "tslib": "^1.8.0", + "url": "0.11.0", "uuid": "^3.2.1", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1" }, "size-limit": [ { diff --git a/packages/api-rest/__tests__/common/internalPost.test.ts b/packages/api-rest/__tests__/apis/common/internalPost.test.ts similarity index 96% rename from packages/api-rest/__tests__/common/internalPost.test.ts rename to packages/api-rest/__tests__/apis/common/internalPost.test.ts index 87c53e2a31c..001c684c2fd 100644 --- a/packages/api-rest/__tests__/common/internalPost.test.ts +++ b/packages/api-rest/__tests__/apis/common/internalPost.test.ts @@ -12,8 +12,8 @@ import { post, cancel, updateRequestToBeCancellable, -} from '../../src/common/internalPost'; -import { RestApiError, isCancelError } from '../../src/errors'; +} from '../../../src/apis/common/internalPost'; +import { RestApiError, isCancelError } from '../../../src/errors'; jest.mock('@aws-amplify/core/internals/aws-client-utils'); @@ -39,7 +39,11 @@ const successResponse = { const apiGatewayUrl = new URL( 'https://123.execute-api.us-west-2.amazonaws.com' ); -const credentials = {}; +const credentials = { + accessKeyId: 'accessKeyId', + sessionToken: 'sessionToken', + secretAccessKey: 'secretAccessKey', +}; describe('internal post', () => { beforeEach(() => { diff --git a/packages/api-rest/__tests__/apis/common/publicApis.test.ts b/packages/api-rest/__tests__/apis/common/publicApis.test.ts new file mode 100644 index 00000000000..ac7ee59cc3b --- /dev/null +++ b/packages/api-rest/__tests__/apis/common/publicApis.test.ts @@ -0,0 +1,348 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AmplifyClassV6 } from '@aws-amplify/core'; +import { + authenticatedHandler, + parseJsonError, +} from '@aws-amplify/core/internals/aws-client-utils'; + +import { + get, + post, + put, + del, + head, + patch, +} from '../../../src/apis/common/publicApis'; +import { + RestApiError, + isCancelError, + validationErrorMap, + RestApiValidationErrorCode, +} from '../../../src/errors'; + +jest.mock('@aws-amplify/core/internals/aws-client-utils'); + +const mockAuthenticatedHandler = authenticatedHandler as jest.Mock; +const mockFetchAuthSession = jest.fn(); +let mockConfig = { + API: { + REST: { + restApi1: { + endpoint: 'https://123.execute-api.us-west-2.amazonaws.com/development', + region: 'us-west-2', + }, + invalidEndpoint: { + endpoint: '123', + }, + }, + }, +}; +const mockParseJsonError = parseJsonError as jest.Mock; +const mockRestHeaders = jest.fn(); +const mockGetConfig = jest.fn(); +const mockAmplifyInstance = { + Auth: { + fetchAuthSession: mockFetchAuthSession, + }, + getConfig: mockGetConfig, + libraryOptions: { + API: { + REST: { + headers: mockRestHeaders, + }, + }, + }, +} as any as AmplifyClassV6; +const credentials = { + accessKeyId: 'accessKeyId', + sessionToken: 'sessionToken', + secretAccessKey: 'secretAccessKey', +}; + +describe('public APIs', () => { + beforeEach(() => { + jest.resetAllMocks(); + mockFetchAuthSession.mockResolvedValue({ + credentials, + }); + mockAuthenticatedHandler.mockResolvedValue({ + statusCode: 200, + headers: { + 'response-header': 'response-header-value', + }, + body: { + blob: jest.fn(), + json: jest.fn().mockResolvedValue({ foo: 'bar' }), + text: jest.fn(), + }, + }); + mockGetConfig.mockReturnValue(mockConfig); + }); + const APIs = [ + { name: 'get', fn: get, method: 'GET' }, + { name: 'post', fn: post, method: 'POST' }, + { name: 'put', fn: put, method: 'PUT' }, + { name: 'del', fn: del, method: 'DELETE' }, + { name: 'head', fn: head, method: 'HEAD' }, + { name: 'patch', fn: patch, method: 'PATCH' }, + ]; + // TODO: use describe.each after upgrading Jest + APIs.forEach(({ name, fn, method }) => { + describe(name, () => { + it('should call authenticatedHandler with specified region from signingServiceInfo', async () => { + const response = await fn(mockAmplifyInstance, { + apiName: 'restApi1', + path: '/items', + options: { + withCredentials: true, + }, + }).response; + expect(mockAuthenticatedHandler).toBeCalledWith( + { + url: new URL( + 'https://123.execute-api.us-west-2.amazonaws.com/development/items' + ), + method, + headers: {}, + body: undefined, + }, + expect.objectContaining({ + region: 'us-west-2', + service: 'execute-api', + withCrossDomainCredentials: true, + }) + ); + expect(response.headers).toEqual({ + 'response-header': 'response-header-value', + }); + expect(response.statusCode).toBe(200); + if (fn !== head && fn !== del) { + // @ts-ignore HEAD and DELETE does not have a response body. + expect(await response.body.json()).toEqual({ foo: 'bar' }); + } + }); + + it('should support custom headers from library options', async () => { + mockRestHeaders.mockResolvedValue({ + 'custom-header': 'custom-value', + }); + await fn(mockAmplifyInstance, { + apiName: 'restApi1', + path: '/items', + }).response; + expect(mockAuthenticatedHandler).toBeCalledWith( + { + url: new URL( + 'https://123.execute-api.us-west-2.amazonaws.com/development/items' + ), + method, + headers: { + 'custom-header': 'custom-value', + }, + body: undefined, + }, + expect.objectContaining({ + region: 'us-west-2', + service: 'execute-api', + }) + ); + }); + + it('should support headers options', async () => { + await fn(mockAmplifyInstance, { + apiName: 'restApi1', + path: '/items', + options: { + headers: { + 'custom-header': 'custom-value', + }, + }, + }).response; + expect(mockAuthenticatedHandler).toBeCalledWith( + { + url: new URL( + 'https://123.execute-api.us-west-2.amazonaws.com/development/items' + ), + method, + headers: { + 'custom-header': 'custom-value', + }, + body: undefined, + }, + expect.objectContaining({ + region: 'us-west-2', + service: 'execute-api', + }) + ); + }); + + it('should support path parameters', async () => { + await fn(mockAmplifyInstance, { + apiName: 'restApi1', + path: '/items/123', + }).response; + expect(mockAuthenticatedHandler).toBeCalledWith( + expect.objectContaining({ + url: new URL( + 'https://123.execute-api.us-west-2.amazonaws.com/development/items/123' + ), + }), + expect.anything() + ); + }); + + it('should support queryParams options', async () => { + await fn(mockAmplifyInstance, { + apiName: 'restApi1', + path: '/items', + options: { + queryParams: { + param1: 'value1', + }, + }, + }).response; + expect(mockAuthenticatedHandler).toBeCalledWith( + expect.objectContaining({ + url: expect.objectContaining( + new URL( + 'https://123.execute-api.us-west-2.amazonaws.com/development/items?param1=value1' + ) + ), + }), + expect.anything() + ); + }); + + it('should support query parameters in path', async () => { + await fn(mockAmplifyInstance, { + apiName: 'restApi1', + path: '/items?param1=value1', + options: { + queryParams: { + foo: 'bar', + }, + }, + }).response; + expect(mockAuthenticatedHandler).toBeCalledWith( + expect.objectContaining({ + url: expect.objectContaining( + new URL( + 'https://123.execute-api.us-west-2.amazonaws.com/development/items?param1=value1&foo=bar' + ) + ), + }), + expect.anything() + ); + }); + + it('should throw if apiName is not configured', async () => { + expect.assertions(2); + try { + await fn(mockAmplifyInstance, { + apiName: 'nonExistentApi', + path: '/items', + }).response; + } catch (error) { + expect(error).toBeInstanceOf(RestApiError); + expect(error).toMatchObject( + validationErrorMap[RestApiValidationErrorCode.InvalidApiName] + ); + } + }); + + it('should throw if resolve URL is not valid', async () => { + expect.assertions(2); + try { + await fn(mockAmplifyInstance, { + apiName: 'invalidEndpoint', + path: '/items', + }).response; + } catch (error) { + expect(error).toBeInstanceOf(RestApiError); + expect(error).toMatchObject({ + ...validationErrorMap[RestApiValidationErrorCode.InvalidApiName], + recoverySuggestion: expect.stringContaining( + 'Please make sure the REST endpoint URL is a valid URL string.' + ), + }); + } + }); + + it('should throw if credentials are not available', async () => { + expect.assertions(2); + mockFetchAuthSession.mockResolvedValueOnce({}); + try { + await fn(mockAmplifyInstance, { + apiName: 'restApi1', + path: '/items', + }).response; + } catch (error) { + expect(error).toBeInstanceOf(RestApiError); + expect(error).toMatchObject( + validationErrorMap[RestApiValidationErrorCode.NoCredentials] + ); + } + }); + + it('should throw when response is not ok', async () => { + expect.assertions(2); + const errorResponse = { + statusCode: 400, + headers: {}, + body: { + blob: jest.fn(), + json: jest.fn(), + text: jest.fn(), + }, + }; + mockParseJsonError.mockResolvedValueOnce( + new RestApiError({ message: 'fooMessage', name: 'badRequest' }) + ); + mockAuthenticatedHandler.mockResolvedValueOnce(errorResponse); + try { + await fn(mockAmplifyInstance, { + apiName: 'restApi1', + path: '/items', + }).response; + fail('should throw RestApiError'); + } catch (error) { + expect(mockParseJsonError).toBeCalledWith(errorResponse); + expect(error).toEqual(expect.any(RestApiError)); + } + }); + + it('should support cancel', async () => { + expect.assertions(2); + const abortSpy = jest.spyOn(AbortController.prototype, 'abort'); + let underLyingHandlerReject; + mockAuthenticatedHandler.mockReset(); + mockAuthenticatedHandler.mockReturnValue( + new Promise((_, reject) => { + underLyingHandlerReject = reject; + }) + ); + abortSpy.mockImplementation(() => { + const mockAbortError = new Error('AbortError'); + mockAbortError.name = 'AbortError'; + underLyingHandlerReject(mockAbortError); + }); + + const { response, cancel } = fn(mockAmplifyInstance, { + apiName: 'restApi1', + path: '/items', + }); + const cancelMessage = 'cancelMessage'; + cancel(cancelMessage); + try { + await response; + fail('should throw cancel error'); + } catch (error) { + expect(isCancelError(error)).toBe(true); + expect(error.message).toBe(cancelMessage); + } + }); + }); + }); +}); diff --git a/packages/api-rest/__tests__/index.test.ts b/packages/api-rest/__tests__/index.test.ts new file mode 100644 index 00000000000..bca6409fc9f --- /dev/null +++ b/packages/api-rest/__tests__/index.test.ts @@ -0,0 +1,55 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify } from '@aws-amplify/core'; + +import { get, post, put, del, patch, head } from '../src/index'; +import { + get as commonGet, + post as commonPost, + put as commonPut, + del as commonDel, + patch as commonPatch, + head as commonHead, +} from '../src/apis/common/publicApis'; + +jest.mock('../src/apis/common/publicApis'); +jest.mock('@aws-amplify/core'); + +const input = { + apiName: 'apiName', + path: 'path', + options: {}, +}; + +describe('REST API handlers', () => { + it('get should call common get API with client-side Amplify singleton', async () => { + get(input); + expect(commonGet).toHaveBeenCalledWith(Amplify, input); + }); + + it('post should call common post API with client-side Amplify singleton', async () => { + post(input); + expect(commonPost).toHaveBeenCalledWith(Amplify, input); + }); + + it('put should call common put API with client-side Amplify singleton', async () => { + put(input); + expect(commonPut).toHaveBeenCalledWith(Amplify, input); + }); + + it('del should call common del API with client-side Amplify singleton', async () => { + del(input); + expect(commonDel).toHaveBeenCalledWith(Amplify, input); + }); + + it('patch should call common patch API with client-side Amplify singleton', async () => { + patch(input); + expect(commonPatch).toHaveBeenCalledWith(Amplify, input); + }); + + it('head should call common head API with client-side Amplify singleton', async () => { + head(input); + expect(commonHead).toHaveBeenCalledWith(Amplify, input); + }); +}); diff --git a/packages/api-rest/__tests__/server.test.ts b/packages/api-rest/__tests__/server.test.ts new file mode 100644 index 00000000000..8ad5ceda015 --- /dev/null +++ b/packages/api-rest/__tests__/server.test.ts @@ -0,0 +1,88 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getAmplifyServerContext } from '@aws-amplify/core/internals/adapter-core'; + +import { get, post, put, del, patch, head } from '../src/server'; +import { + get as commonGet, + post as commonPost, + put as commonPut, + del as commonDel, + patch as commonPatch, + head as commonHead, +} from '../src/apis/common/publicApis'; + +jest.mock('../src/apis/common/publicApis'); +jest.mock('@aws-amplify/core/internals/adapter-core'); + +const input = { + apiName: 'apiName', + path: 'path', + options: {}, +}; +const contextSpec = { token: { value: 'token' } } as any; +const mockGetAmplifyServerContext = getAmplifyServerContext as jest.Mock; + +describe('REST API handlers', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetAmplifyServerContext.mockReturnValue({ + amplify: 'mockedAmplifyServerSideContext', + }); + }); + + it('get should call common get API with server-side Amplify context', async () => { + get(contextSpec, input); + expect(mockGetAmplifyServerContext).toHaveBeenCalledWith(contextSpec); + expect(commonGet).toHaveBeenCalledWith( + 'mockedAmplifyServerSideContext', + input + ); + }); + + it('post should call common post API with server-side Amplify context', async () => { + post(contextSpec, input); + expect(mockGetAmplifyServerContext).toHaveBeenCalledWith(contextSpec); + expect(commonPost).toHaveBeenCalledWith( + 'mockedAmplifyServerSideContext', + input + ); + }); + + it('put should call common put API with server-side Amplify context', async () => { + put(contextSpec, input); + expect(mockGetAmplifyServerContext).toHaveBeenCalledWith(contextSpec); + expect(commonPut).toHaveBeenCalledWith( + 'mockedAmplifyServerSideContext', + input + ); + }); + + it('del should call common del API with server-side Amplify context', async () => { + del(contextSpec, input); + expect(mockGetAmplifyServerContext).toHaveBeenCalledWith(contextSpec); + expect(commonDel).toHaveBeenCalledWith( + 'mockedAmplifyServerSideContext', + input + ); + }); + + it('patch should call common patch API with server-side Amplify context', async () => { + patch(contextSpec, input); + expect(mockGetAmplifyServerContext).toHaveBeenCalledWith(contextSpec); + expect(commonPatch).toHaveBeenCalledWith( + 'mockedAmplifyServerSideContext', + input + ); + }); + + it('head should call common head API with server-side Amplify context', async () => { + head(contextSpec, input); + expect(mockGetAmplifyServerContext).toHaveBeenCalledWith(contextSpec); + expect(commonHead).toHaveBeenCalledWith( + 'mockedAmplifyServerSideContext', + input + ); + }); +}); diff --git a/packages/api-rest/package.json b/packages/api-rest/package.json index a0a725b2fde..3da034e6d9c 100644 --- a/packages/api-rest/package.json +++ b/packages/api-rest/package.json @@ -47,15 +47,14 @@ "src" ], "dependencies": { - "axios": "0.26.0", - "tslib": "^2.5.0", - "url": "0.11.0" + "tslib": "^2.5.0" }, "peerDependencies": { "@aws-amplify/core": "^6.0.0" }, "devDependencies": { "@aws-amplify/core": "6.0.0", + "@aws-amplify/react-native": "^1.0.0", "typescript": "5.0.2" }, "size-limit": [ diff --git a/packages/api-rest/server/package.json b/packages/api-rest/server/package.json new file mode 100644 index 00000000000..a4aeee5a26a --- /dev/null +++ b/packages/api-rest/server/package.json @@ -0,0 +1,8 @@ +{ + "name": "@aws-amplify/api-rest/server", + "types": "../lib-esm/server.d.ts", + "main": "../lib/server.js", + "module": "../lib-esm/server.js", + "react-native": "../lib-esm/server.js", + "sideEffects": false +} diff --git a/packages/api-rest/src/common/handler.ts b/packages/api-rest/src/apis/common/handler.ts similarity index 84% rename from packages/api-rest/src/common/handler.ts rename to packages/api-rest/src/apis/common/handler.ts index 275cc654119..a24eb9d32a5 100644 --- a/packages/api-rest/src/common/handler.ts +++ b/packages/api-rest/src/apis/common/handler.ts @@ -12,12 +12,12 @@ import { import { DocumentType } from '@aws-amplify/core/internals/utils'; import { - createCancellableOperation, parseRestApiServiceError, - parseUrl, + parseSigningInfo, resolveCredentials, -} from '../utils'; -import { normalizeHeaders } from '../utils/normalizeHeaders'; +} from '../../utils'; +import { normalizeHeaders } from '../../utils/normalizeHeaders'; +import { RestApiResponse } from '../../types'; type HandlerOptions = Omit & { body?: DocumentType | FormData; @@ -35,9 +35,8 @@ type SigningServiceInfo = { * @param amplify Amplify instance to to resolve credentials and tokens. Should use different instance in client-side * and SSR * @param options Options accepted from public API options when calling the handlers. - * @param signingServiceInfo Internal-only options for graphql client to overwrite the IAM signing service and region. - * MUST ONLY be used by internal post method consumed by GraphQL when auth mode is IAM. Otherwise IAM auth may not be - * used. + * @param signingServiceInfo Internal-only options enable IAM auth as well as to to overwrite the IAM signing service + * and region. If specified, and NONE of API Key header or Auth header is present, IAM auth will be used. * * @internal */ @@ -45,7 +44,7 @@ export const transferHandler = async ( amplify: AmplifyClassV6, options: HandlerOptions & { abortSignal: AbortSignal }, signingServiceInfo?: SigningServiceInfo -) => { +): Promise => { const { url, method, headers, body, withCredentials, abortSignal } = options; const resolvedBody = body ? body instanceof FormData @@ -78,7 +77,7 @@ export const transferHandler = async ( const isIamAuthApplicable = iamAuthApplicable(request, signingServiceInfo); if (isIamAuthApplicable) { - const signingInfoFromUrl = parseUrl(url); + const signingInfoFromUrl = parseSigningInfo(url); const signingService = signingServiceInfo?.service ?? signingInfoFromUrl.service; const signingRegion = diff --git a/packages/api-rest/src/common/internalPost.ts b/packages/api-rest/src/apis/common/internalPost.ts similarity index 67% rename from packages/api-rest/src/common/internalPost.ts rename to packages/api-rest/src/apis/common/internalPost.ts index b762511cf90..dd9d70724c1 100644 --- a/packages/api-rest/src/common/internalPost.ts +++ b/packages/api-rest/src/apis/common/internalPost.ts @@ -3,10 +3,21 @@ import { AmplifyClassV6 } from '@aws-amplify/core'; -import { InternalPostInput, RestApiResponse } from '../types'; +import { InternalPostInput, RestApiResponse } from '../../types'; import { transferHandler } from './handler'; -import { createCancellableOperation } from '../utils'; +import { createCancellableOperation } from '../../utils'; +/** + * This weak map provides functionality to cancel a request given the promise containing the `post` request. + * + * 1. For every GraphQL POST request, an abort controller is created and supplied to the request. + * 2. The promise fulfilled by GraphGL POST request is then mapped to that abort controller. + * 3. The promise is returned to the external caller. + * 4. The caller can either wait for the promise to fulfill or call `cancel(promise)` to cancel the request. + * 5. If `cancel(promise)` is called, then the corresponding abort controller is retrieved from the map below. + * 6. GraphQL POST request will be rejected with the error message provided during cancel. + * 7. Caller can check if the error is because of cancelling by calling `isCancelError(error)`. + */ const cancelTokenMap = new WeakMap, AbortController>(); /** diff --git a/packages/api-rest/src/apis/common/publicApis.ts b/packages/api-rest/src/apis/common/publicApis.ts new file mode 100644 index 00000000000..4c82663e2f7 --- /dev/null +++ b/packages/api-rest/src/apis/common/publicApis.ts @@ -0,0 +1,99 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AmplifyClassV6 } from '@aws-amplify/core'; +import { + GetInput, + GetOperation, + PostInput, + PostOperation, + PutInput, + PutOperation, + DeleteInput, + DeleteOperation, + HeadInput, + HeadOperation, + PatchInput, + PatchOperation, + ApiInput, + RestApiOptionsBase, +} from '../../types'; +import { + resolveApiUrl, + createCancellableOperation, + logger, + parseSigningInfo, +} from '../../utils'; +import { transferHandler } from './handler'; + +const publicHandler = ( + amplify: AmplifyClassV6, + options: ApiInput, + method: string +) => + createCancellableOperation(async abortSignal => { + const { apiName, options: apiOptions = {}, path: apiPath } = options; + const url = resolveApiUrl( + amplify, + apiName, + apiPath, + apiOptions?.queryParams + ); + const libraryOptionsHeaders = + await amplify.libraryOptions?.API?.REST?.headers?.({ + apiName, + }); + const { headers: invocationHeaders = {} } = apiOptions; + const headers = { + // custom headers from invocation options should precede library options + ...libraryOptionsHeaders, + ...invocationHeaders, + }; + const signingServiceInfo = parseSigningInfo(url, { + amplify, + apiName, + }); + logger.debug( + method, + url, + headers, + `IAM signing options: ${JSON.stringify(signingServiceInfo)}` + ); + return transferHandler( + amplify, + { + ...apiOptions, + url, + method, + headers, + abortSignal, + }, + signingServiceInfo + ); + }); + +export const get = (amplify: AmplifyClassV6, input: GetInput): GetOperation => + publicHandler(amplify, input, 'GET'); + +export const post = ( + amplify: AmplifyClassV6, + input: PostInput +): PostOperation => publicHandler(amplify, input, 'POST'); + +export const put = (amplify: AmplifyClassV6, input: PutInput): PutOperation => + publicHandler(amplify, input, 'PUT'); + +export const del = ( + amplify: AmplifyClassV6, + input: DeleteInput +): DeleteOperation => publicHandler(amplify, input, 'DELETE'); + +export const head = ( + amplify: AmplifyClassV6, + input: HeadInput +): HeadOperation => publicHandler(amplify, input, 'HEAD'); + +export const patch = ( + amplify: AmplifyClassV6, + input: PatchInput +): PatchOperation => publicHandler(amplify, input, 'PATCH'); diff --git a/packages/api-rest/src/apis/index.ts b/packages/api-rest/src/apis/index.ts new file mode 100644 index 00000000000..1e95d80f2ae --- /dev/null +++ b/packages/api-rest/src/apis/index.ts @@ -0,0 +1,60 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify } from '@aws-amplify/core'; +import { + get as commonGet, + post as commonPost, + put as commonPut, + del as commonDel, + head as commonHead, + patch as commonPatch, +} from './common/publicApis'; +import { + DeleteInput, + DeleteOperation, + GetInput, + GetOperation, + HeadInput, + HeadOperation, + PatchInput, + PatchOperation, + PostInput, + PostOperation, + PutInput, + PutOperation, +} from '../types'; + +/** + * GET HTTP request + */ +export const get = (input: GetInput): GetOperation => commonGet(Amplify, input); + +/** + * POST HTTP request + */ +export const post = (input: PostInput): PostOperation => + commonPost(Amplify, input); + +/** + * PUT HTTP request + */ +export const put = (input: PutInput): PutOperation => commonPut(Amplify, input); + +/** + * DELETE HTTP request + */ +export const del = (input: DeleteInput): DeleteOperation => + commonDel(Amplify, input); + +/** + * HEAD HTTP request + */ +export const head = (input: HeadInput): HeadOperation => + commonHead(Amplify, input); + +/** + * PATCH HTTP request + */ +export const patch = (input: PatchInput): PatchOperation => + commonPatch(Amplify, input); diff --git a/packages/api-rest/src/apis/server.ts b/packages/api-rest/src/apis/server.ts new file mode 100644 index 00000000000..763e0da28dc --- /dev/null +++ b/packages/api-rest/src/apis/server.ts @@ -0,0 +1,83 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + AmplifyServer, + getAmplifyServerContext, +} from '@aws-amplify/core/internals/adapter-core'; +import { + get as commonGet, + post as commonPost, + put as commonPut, + del as commonDel, + head as commonHead, + patch as commonPatch, +} from './common/publicApis'; +import { + DeleteInput, + DeleteOperation, + GetInput, + GetOperation, + HeadInput, + HeadOperation, + PatchInput, + PatchOperation, + PostInput, + PostOperation, + PutInput, + PutOperation, +} from '../types'; + +/** + * GET HTTP request (server-side) + */ +export const get = ( + contextSpec: AmplifyServer.ContextSpec, + input: GetInput +): GetOperation => + commonGet(getAmplifyServerContext(contextSpec).amplify, input); + +/** + * POST HTTP request (server-side) + */ +export const post = ( + contextSpec: AmplifyServer.ContextSpec, + input: PostInput +): PostOperation => + commonPost(getAmplifyServerContext(contextSpec).amplify, input); + +/** + * PUT HTTP request (server-side) + */ +export const put = ( + contextSpec: AmplifyServer.ContextSpec, + input: PutInput +): PutOperation => + commonPut(getAmplifyServerContext(contextSpec).amplify, input); + +/** + * DELETE HTTP request (server-side) + */ +export const del = ( + contextSpec: AmplifyServer.ContextSpec, + input: DeleteInput +): DeleteOperation => + commonDel(getAmplifyServerContext(contextSpec).amplify, input); + +/** + * HEAD HTTP request (server-side) + */ +export const head = ( + contextSpec: AmplifyServer.ContextSpec, + input: HeadInput +): HeadOperation => + commonHead(getAmplifyServerContext(contextSpec).amplify, input); + +/** + * PATCH HTTP request (server-side) + */ +export const patch = ( + contextSpec: AmplifyServer.ContextSpec, + input: PatchInput +): PatchOperation => + commonPatch(getAmplifyServerContext(contextSpec).amplify, input); diff --git a/packages/api-rest/src/errors/validation.ts b/packages/api-rest/src/errors/validation.ts index 60e9808815e..2f13fdb6831 100644 --- a/packages/api-rest/src/errors/validation.ts +++ b/packages/api-rest/src/errors/validation.ts @@ -5,10 +5,16 @@ import { AmplifyErrorMap } from '@aws-amplify/core/internals/utils'; export enum RestApiValidationErrorCode { NoCredentials = 'NoCredentials', + InvalidApiName = 'InvalidApiName', } export const validationErrorMap: AmplifyErrorMap = { [RestApiValidationErrorCode.NoCredentials]: { message: 'Credentials should not be empty.', }, + [RestApiValidationErrorCode.InvalidApiName]: { + message: 'API name is invalid.', + recoverySuggestion: + 'Check if the API name matches the one in your configuration or `aws-exports.js`', + }, }; diff --git a/packages/api-rest/src/index.ts b/packages/api-rest/src/index.ts index a738bcc46f1..9eb4a5f0ac9 100644 --- a/packages/api-rest/src/index.ts +++ b/packages/api-rest/src/index.ts @@ -2,3 +2,4 @@ // SPDX-License-Identifier: Apache-2.0 export { isCancelError } from './errors/CancelledError'; +export { get, post, put, del, head, patch } from './apis'; diff --git a/packages/api-rest/src/internals/index.ts b/packages/api-rest/src/internals/index.ts index a2709dc1b47..0076627c53c 100644 --- a/packages/api-rest/src/internals/index.ts +++ b/packages/api-rest/src/internals/index.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { Amplify } from '@aws-amplify/core'; -import { post as internalPost } from '../common/internalPost'; +import { post as internalPost } from '../apis/common/internalPost'; import { InternalPostInput } from '../types'; /** @@ -25,4 +25,7 @@ export const post = (input: InternalPostInput) => { return internalPost(Amplify, input); }; -export { cancel, updateRequestToBeCancellable } from '../common/internalPost'; +export { + cancel, + updateRequestToBeCancellable, +} from '../apis/common/internalPost'; diff --git a/packages/api-rest/src/internals/server.ts b/packages/api-rest/src/internals/server.ts index 06f4f49a85a..2cd55554e38 100644 --- a/packages/api-rest/src/internals/server.ts +++ b/packages/api-rest/src/internals/server.ts @@ -5,7 +5,7 @@ import { getAmplifyServerContext, } from '@aws-amplify/core/internals/adapter-core'; -import { post as internalPost } from '../common/internalPost'; +import { post as internalPost } from '../apis/common/internalPost'; import { InternalPostInput } from '../types'; /** @@ -31,4 +31,7 @@ export const post = ( return internalPost(getAmplifyServerContext(contextSpec).amplify, input); }; -export { cancel, updateRequestToBeCancellable } from '../common/internalPost'; +export { + cancel, + updateRequestToBeCancellable, +} from '../apis/common/internalPost'; diff --git a/packages/api-rest/src/server.ts b/packages/api-rest/src/server.ts new file mode 100644 index 00000000000..8fceea3baa1 --- /dev/null +++ b/packages/api-rest/src/server.ts @@ -0,0 +1,5 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { isCancelError } from './errors/CancelledError'; +export { get, post, put, del, head, patch } from './apis/server'; diff --git a/packages/api-rest/src/types/index.ts b/packages/api-rest/src/types/index.ts index 21577b792ef..d1a946d594e 100644 --- a/packages/api-rest/src/types/index.ts +++ b/packages/api-rest/src/types/index.ts @@ -2,12 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 import { DocumentType } from '@aws-amplify/core/internals/utils'; -export type GetOptions = RestApiOptionsBase; -export type PostOptions = RestApiOptionsBase; -export type PutOptions = RestApiOptionsBase; -export type PatchOptions = RestApiOptionsBase; -export type DeleteOptions = Omit; -export type HeadOptions = Omit; +export type GetInput = ApiInput; +export type PostInput = ApiInput; +export type PutInput = ApiInput; +export type PatchInput = ApiInput; +export type DeleteInput = ApiInput>; +export type HeadInput = ApiInput>; export type GetOperation = Operation; export type PostOperation = Operation; @@ -16,7 +16,10 @@ export type PatchOperation = Operation; export type DeleteOperation = Operation>; export type HeadOperation = Operation>; -type RestApiOptionsBase = { +/** + * @internal + */ +export type RestApiOptionsBase = { headers?: Headers; queryParams?: Record; body?: DocumentType | FormData; @@ -64,12 +67,20 @@ export interface RestApiResponse { } /** - * Input type of REST API. * @internal */ export type ApiInput = { + /** + * Name of the REST API configured in Amplify singleton. + */ apiName: string; + /** + * Path of the REST API. + */ path: string; + /** + * Options to overwrite the REST API call behavior. + */ options?: Options; }; diff --git a/packages/api-rest/src/utils/createCancellableOperation.ts b/packages/api-rest/src/utils/createCancellableOperation.ts index 53ca7f2565f..81a69fc6d1d 100644 --- a/packages/api-rest/src/utils/createCancellableOperation.ts +++ b/packages/api-rest/src/utils/createCancellableOperation.ts @@ -5,6 +5,7 @@ import { HttpResponse } from '@aws-amplify/core/internals/aws-client-utils'; import { CancelledError, RestApiError } from '../errors'; import { Operation } from '../types'; import { parseRestApiServiceError } from './serviceError'; +import { logger } from './logger'; /** * Create a cancellable operation conforming to the internal POST API interface. @@ -14,46 +15,60 @@ export function createCancellableOperation( handler: () => Promise, abortController: AbortController ): Promise; + /** * Create a cancellable operation conforming to the external REST API interface. * @internal */ export function createCancellableOperation( handler: (signal: AbortSignal) => Promise -): Promise; +): Operation; /** * @internal */ export function createCancellableOperation( - handler: (signal?: AbortSignal) => Promise, + handler: + | ((signal: AbortSignal) => Promise) + | (() => Promise), abortController?: AbortController ): Operation | Promise { const isInternalPost = ( - handler: (signal?: AbortSignal) => Promise + handler: + | ((signal: AbortSignal) => Promise) + | (() => Promise) ): handler is () => Promise => !!abortController; - const signal = abortController?.signal; + + // For creating a cancellable operation for public REST APIs, we need to create an AbortController + // internally. Whereas for internal POST APIs, we need to accept in the AbortController from the + // callers. + const publicApisAbortController = new AbortController(); + const publicApisAbortSignal = publicApisAbortController.signal; + const internalPostAbortSignal = abortController?.signal; + const job = async () => { try { const response = await (isInternalPost(handler) ? handler() - : handler(signal)); + : handler(publicApisAbortSignal)); + if (response.statusCode >= 300) { - throw parseRestApiServiceError(response)!; + throw await parseRestApiServiceError(response)!; } return response; - } catch (error) { - if (error.name === 'AbortError' || signal?.aborted === true) { - throw new CancelledError({ + } catch (error: any) { + const abortSignal = internalPostAbortSignal ?? publicApisAbortSignal; + if (error.name === 'AbortError' || abortSignal?.aborted === true) { + const cancelledError = new CancelledError({ name: error.name, - message: signal.reason ?? error.message, + message: abortSignal.reason ?? error.message, underlyingError: error, }); + logger.debug(error); + throw cancelledError; } - throw new RestApiError({ - ...error, - underlyingError: error, - }); + logger.debug(error); + throw error; } }; @@ -61,15 +76,18 @@ export function createCancellableOperation( return job(); } else { const cancel = (abortMessage?: string) => { - if (signal?.aborted === true) { + if (publicApisAbortSignal.aborted === true) { return; } - abortController?.abort(abortMessage); + publicApisAbortController.abort(abortMessage); // Abort reason is not widely support enough across runtimes and and browsers, so we set it // if it is not already set. - if (signal?.reason !== abortMessage) { - // @ts-expect-error reason is a readonly property - signal['reason'] = abortMessage; + if (publicApisAbortSignal.reason !== abortMessage) { + type AbortSignalWithReasonSupport = Omit & { + reason?: string; + }; + (publicApisAbortSignal as AbortSignalWithReasonSupport)['reason'] = + abortMessage; } }; return { response: job(), cancel }; diff --git a/packages/api-rest/src/utils/index.native.ts b/packages/api-rest/src/utils/index.native.ts new file mode 100644 index 00000000000..54f58b7a2ed --- /dev/null +++ b/packages/api-rest/src/utils/index.native.ts @@ -0,0 +1,13 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { loadUrlPolyfill } from '@aws-amplify/react-native'; + +loadUrlPolyfill(); + +export { createCancellableOperation } from './createCancellableOperation'; +export { resolveCredentials } from './resolveCredentials'; +export { parseSigningInfo } from './parseSigningInfo'; +export { parseRestApiServiceError } from './serviceError'; +export { resolveApiUrl } from './resolveApiUrl'; +export { logger } from './logger'; diff --git a/packages/api-rest/src/utils/index.ts b/packages/api-rest/src/utils/index.ts index 273de64be60..2e72b5bf24b 100644 --- a/packages/api-rest/src/utils/index.ts +++ b/packages/api-rest/src/utils/index.ts @@ -3,5 +3,7 @@ export { createCancellableOperation } from './createCancellableOperation'; export { resolveCredentials } from './resolveCredentials'; -export { parseUrl } from './parseUrl'; +export { parseSigningInfo } from './parseSigningInfo'; export { parseRestApiServiceError } from './serviceError'; +export { resolveApiUrl } from './resolveApiUrl'; +export { logger } from './logger'; diff --git a/packages/api-rest/src/utils/logger.ts b/packages/api-rest/src/utils/logger.ts new file mode 100644 index 00000000000..3a3119f1e44 --- /dev/null +++ b/packages/api-rest/src/utils/logger.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Logger } from '@aws-amplify/core/internals/utils'; + +export const logger = new Logger('RestApis'); diff --git a/packages/api-rest/src/utils/normalizeHeaders.ts b/packages/api-rest/src/utils/normalizeHeaders.ts index 050867c4444..1bd9e2d35a2 100644 --- a/packages/api-rest/src/utils/normalizeHeaders.ts +++ b/packages/api-rest/src/utils/normalizeHeaders.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -export const normalizeHeaders = (headers: Record) => { +export const normalizeHeaders = (headers?: Record) => { const normalizedHeaders: Record = {}; for (const key in headers) { normalizedHeaders[key.toLowerCase()] = headers[key]; diff --git a/packages/api-rest/src/utils/parseUrl.ts b/packages/api-rest/src/utils/parseSigningInfo.ts similarity index 55% rename from packages/api-rest/src/utils/parseUrl.ts rename to packages/api-rest/src/utils/parseSigningInfo.ts index 493d85f08a4..3414a6dd772 100644 --- a/packages/api-rest/src/utils/parseUrl.ts +++ b/packages/api-rest/src/utils/parseSigningInfo.ts @@ -1,6 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { AmplifyClassV6 } from '@aws-amplify/core'; import { APIG_HOSTNAME_PATTERN, DEFAULT_IAM_SIGNING_REGION, @@ -8,12 +9,24 @@ import { } from './constants'; /** - * Infer the signing service and region from the given URL. It supports raw API Gateway endpoint and AppSync endpoint. - * Custom domain is not supported. + * Infer the signing service and region from the given URL, and for REST API only, from the Amplify configuration. + * It supports raw API Gateway endpoint and AppSync endpoint. * * @internal */ -export const parseUrl = (url: URL) => { +export const parseSigningInfo = ( + url: URL, + restApiOptions?: { + amplify: AmplifyClassV6; + apiName: string; + } +) => { + const { + service: signingService = DEFAULT_REST_IAM_SIGNING_SERVICE, + region: signingRegion = DEFAULT_IAM_SIGNING_REGION, + } = + restApiOptions?.amplify.getConfig()?.API?.REST?.[restApiOptions?.apiName] ?? + {}; const { hostname } = url; const [, service, region] = APIG_HOSTNAME_PATTERN.exec(hostname) ?? []; if (service === DEFAULT_REST_IAM_SIGNING_SERVICE) { @@ -21,19 +34,19 @@ export const parseUrl = (url: URL) => { // @see: https://docs.aws.amazon.com/apigateway/latest/developerguide/how-to-call-api.html return { service, - region, + region: region ?? signingRegion, }; } else if (service === 'appsync-api') { // AppSync endpoint is internally supported because GraphQL operation will send request using POST handler. // example: https://xxxx.appsync-api.us-east-1.amazonaws.com/graphql return { service: 'appsync', - region, + region: region ?? signingRegion, }; } else { return { - service: DEFAULT_REST_IAM_SIGNING_SERVICE, - region: DEFAULT_IAM_SIGNING_REGION, + service: signingService, + region: signingRegion, }; } }; diff --git a/packages/api-rest/src/utils/resolveApiUrl.ts b/packages/api-rest/src/utils/resolveApiUrl.ts new file mode 100644 index 00000000000..48eda61e024 --- /dev/null +++ b/packages/api-rest/src/utils/resolveApiUrl.ts @@ -0,0 +1,47 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AmplifyClassV6 } from '@aws-amplify/core'; +import { + RestApiError, + RestApiValidationErrorCode, + assertValidationError, + validationErrorMap, +} from '../errors'; + +/** + * Resolve the REST API request URL by: + * 1. Loading the REST API endpoint from the Amplify configuration with corresponding API name. + * 2. Appending the path to the endpoint. + * 3. Merge the query parameters from path and the queryParameter argument which is taken from the public REST API + * options. + * 4. Validating the resulting URL string. + * + * @internal + */ +export const resolveApiUrl = ( + amplify: AmplifyClassV6, + apiName: string, + path: string, + queryParams?: Record +): URL => { + const urlStr = amplify.getConfig()?.API?.REST?.[apiName]?.endpoint; + assertValidationError(!!urlStr, RestApiValidationErrorCode.InvalidApiName); + try { + const url = new URL(urlStr + path); + if (queryParams) { + const mergedQueryParams = new URLSearchParams(url.searchParams); + Object.entries(queryParams).forEach(([key, value]) => { + mergedQueryParams.set(key, value); + }); + url.search = new URLSearchParams(mergedQueryParams).toString(); + } + return url; + } catch (error) { + throw new RestApiError({ + name: RestApiValidationErrorCode.InvalidApiName, + ...validationErrorMap[RestApiValidationErrorCode.InvalidApiName], + recoverySuggestion: `Please make sure the REST endpoint URL is a valid URL string. Got ${urlStr}`, + }); + } +}; diff --git a/packages/api-rest/src/utils/resolveCredentials.ts b/packages/api-rest/src/utils/resolveCredentials.ts index 97caee47633..155606937aa 100644 --- a/packages/api-rest/src/utils/resolveCredentials.ts +++ b/packages/api-rest/src/utils/resolveCredentials.ts @@ -10,7 +10,7 @@ import { RestApiValidationErrorCode, assertValidationError } from '../errors'; export const resolveCredentials = async (amplify: AmplifyClassV6) => { const { credentials } = await amplify.Auth.fetchAuthSession(); assertValidationError( - !!credentials, + !!credentials && !!credentials.accessKeyId && !!credentials.secretAccessKey, RestApiValidationErrorCode.NoCredentials ); return credentials; diff --git a/packages/api-rest/src/utils/serviceError.ts b/packages/api-rest/src/utils/serviceError.ts index 66f78923f56..1ab1fa40023 100644 --- a/packages/api-rest/src/utils/serviceError.ts +++ b/packages/api-rest/src/utils/serviceError.ts @@ -14,7 +14,7 @@ import { RestApiError } from '../errors'; */ export const buildRestApiServiceError = (error: Error): RestApiError => { const restApiError = new RestApiError({ - name: error.name, + name: error?.name, message: error.message, underlyingError: error, }); @@ -23,11 +23,13 @@ export const buildRestApiServiceError = (error: Error): RestApiError => { export const parseRestApiServiceError = async ( response?: HttpResponse -): Promise => { +): Promise<(RestApiError & MetadataBearer) | undefined> => { const parsedError = await parseJsonError(response); - return parsedError - ? Object.assign(buildRestApiServiceError(parsedError), { - $metadata: parsedError.$metadata, - }) - : undefined; + if (!parsedError) { + // Response is not an error. + return; + } + return Object.assign(buildRestApiServiceError(parsedError), { + $metadata: parsedError.$metadata, + }); }; diff --git a/packages/api-rest/tsconfig.json b/packages/api-rest/tsconfig.json index f3d5ed63841..f907c964821 100644 --- a/packages/api-rest/tsconfig.json +++ b/packages/api-rest/tsconfig.json @@ -2,9 +2,8 @@ "extends": "../tsconfig.base.json", "compilerOptions": { "importHelpers": true, - "strict": false, - "noImplicitAny": false, - "skipLibCheck": true + "strict": true, + "noImplicitAny": true }, "include": ["./src"] } diff --git a/packages/api/server/package.json b/packages/api/server/package.json new file mode 100644 index 00000000000..11ec51e821a --- /dev/null +++ b/packages/api/server/package.json @@ -0,0 +1,8 @@ +{ + "name": "@aws-amplify/api/server", + "types": "../lib-esm/server.d.ts", + "main": "../lib/server.js", + "module": "../lib-esm/server.js", + "react-native": "../lib-esm/server.js", + "sideEffects": false +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 0f967ae74df..61cdde260fb 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -13,3 +13,13 @@ export type { GraphQLResult } from '@aws-amplify/api-graphql'; const generateClient = API.generateClient; export { generateClient }; + +export { + get, + put, + post, + del, + head, + patch, + isCancelError, +} from '@aws-amplify/api-rest'; diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts new file mode 100644 index 00000000000..681f6cfe3db --- /dev/null +++ b/packages/api/src/server.ts @@ -0,0 +1,12 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { + get, + put, + post, + del, + head, + patch, + isCancelError, +} from '@aws-amplify/api-rest/server'; diff --git a/packages/aws-amplify/__tests__/exports.test.ts b/packages/aws-amplify/__tests__/exports.test.ts index c81ec2b6d24..8beba6816e5 100644 --- a/packages/aws-amplify/__tests__/exports.test.ts +++ b/packages/aws-amplify/__tests__/exports.test.ts @@ -3,6 +3,7 @@ import * as topLevelExports from '../src'; import * as utilsExports from '../src/utils'; +import * as apiTopLevelExports from '../src/api'; import * as authTopLevelExports from '../src/auth'; import * as authCognitoExports from '../src/auth/cognito'; import * as analyticsTopLevelExports from '../src/analytics'; @@ -222,4 +223,22 @@ describe('aws-amplify Exports', () => { `); }); }); + + describe('API exports', () => { + it('should only export expected symbols from the top-level', () => { + expect(Object.keys(apiTopLevelExports)).toMatchInlineSnapshot(` + Array [ + "GraphQLAuthError", + "generateClient", + "get", + "put", + "post", + "del", + "head", + "patch", + "isCancelError", + ] + `); + }); + }); }); diff --git a/packages/aws-amplify/api/server/package.json b/packages/aws-amplify/api/server/package.json new file mode 100644 index 00000000000..69b97ab3061 --- /dev/null +++ b/packages/aws-amplify/api/server/package.json @@ -0,0 +1,7 @@ +{ + "name": "aws-amplify/api/server", + "main": "../../lib/api/server.js", + "browser": "../../lib-esm/api/server.js", + "module": "../../lib-esm/api/server.js", + "typings": "../../lib-esm/api/server.d.ts" +} diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index b2b71eca0da..34e77ab388f 100644 --- a/packages/aws-amplify/package.json +++ b/packages/aws-amplify/package.json @@ -273,6 +273,12 @@ "import": "{ generateClient }", "limit": "70.00 kB" }, + { + "name": "[API] REST API handlers", + "path": "./lib-esm/api/index.js", + "import": "{ get, post, put, del, patch, head, isCancelError }", + "limit": "13.63 kB" + }, { "name": "[Auth] signUp (Cognito)", "path": "./lib-esm/auth/index.js", diff --git a/packages/aws-amplify/src/api/server.ts b/packages/aws-amplify/src/api/server.ts new file mode 100644 index 00000000000..17297054b53 --- /dev/null +++ b/packages/aws-amplify/src/api/server.ts @@ -0,0 +1,4 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export * from '@aws-amplify/api/server'; diff --git a/packages/core/src/clients/types/http.ts b/packages/core/src/clients/types/http.ts index 8d52dd4f363..a4c65e070e3 100644 --- a/packages/core/src/clients/types/http.ts +++ b/packages/core/src/clients/types/http.ts @@ -25,7 +25,7 @@ export interface HttpRequest extends Request { export type ResponseBodyMixin = Pick; export interface HttpResponse extends Response { - body: (ResponseBodyMixin & ReadableStream) | ResponseBodyMixin | null; + body: (ResponseBodyMixin & ReadableStream) | ResponseBodyMixin; statusCode: number; /** * @see {@link HttpRequest.headers} diff --git a/yarn.lock b/yarn.lock index bf60cb95c02..e6adbdf078b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4936,13 +4936,6 @@ aws4@^1.8.0: resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.12.0.tgz#ce1c9d143389679e253b314241ea9aa5cec980d3" integrity sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg== -axios@0.26.0: - version "0.26.0" - resolved "https://registry.yarnpkg.com/axios/-/axios-0.26.0.tgz#9a318f1c69ec108f8cd5f3c3d390366635e13928" - integrity sha512-lKoGLMYtHvFrPVt3r+RBMp9nh34N0M8zEfCWqdWZx6phynIEhQqAdydpyBAAG211zlhX9Rgu08cOamy6XjE5Og== - dependencies: - follow-redirects "^1.14.8" - axios@^1.0.0: version "1.5.1" resolved "https://registry.yarnpkg.com/axios/-/axios-1.5.1.tgz#11fbaa11fc35f431193a9564109c88c1f27b585f" @@ -7174,7 +7167,7 @@ fn.name@1.x.x: resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc" integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== -follow-redirects@^1.14.8, follow-redirects@^1.15.0: +follow-redirects@^1.15.0: version "1.15.3" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.3.tgz#fe2f3ef2690afce7e82ed0b44db08165b207123a" integrity sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==