diff --git a/.github/integ-config/integ-all.yml b/.github/integ-config/integ-all.yml index 4759997ee29..4fc85ee6ff4 100644 --- a/.github/integ-config/integ-all.yml +++ b/.github/integ-config/integ-all.yml @@ -609,7 +609,23 @@ tests: sample_name: [subdomains] spec: subdomains browser: [chrome] - + - test_name: integ_next_custom_auth + desc: 'Sign-in with Custom Auth flow' + framework: next + category: auth + sample_name: [custom-auth] + spec: custom-auth + browser: *minimal_browser_list + - test_name: integ_next_auth_sign_in_with_sms_mfa + desc: 'Resumable sign in with SMS MFA flow' + framework: next + category: auth + sample_name: [mfa] + spec: sign-in-resumable-mfa + browser: *minimal_browser_list + env: + NEXT_PUBLIC_BACKEND_CONFIG: resum-signin + # DISABLED Angular/Vue tests: # TODO: delete tests or add custom ui logic to support them. diff --git a/.github/workflows/callable-canary-sampleapp-tests.yml b/.github/workflows/callable-canary-sampleapp-tests.yml index 0ff50fa567a..149d0d52f86 100644 --- a/.github/workflows/callable-canary-sampleapp-tests.yml +++ b/.github/workflows/callable-canary-sampleapp-tests.yml @@ -114,7 +114,7 @@ jobs: working-directory: amplify-js-samples-staging/samples/next/auth/new-next-app - name: Copy files from samples staging repo run: | - rm -r ./samples/next/auth/new-next-app + rm -r ./samples/next/auth/new-next-app cp -r ./samples/next/auth/auth-rsc ./samples/next/auth/new-next-app working-directory: amplify-js-samples-staging - name: Copy test file from samples staging repo @@ -124,10 +124,8 @@ jobs: - name: Install dependencies run: npm install working-directory: amplify-js-samples-staging/samples/next/auth/new-next-app - - name: Install amplify - run: | - npm install -g npm@latest - npm install aws-amplify -legacy-peer-deps + - name: Install latest stable amplify version + run: npm install aws-amplify@latest @aws-amplify/adapter-nextjs@latest working-directory: amplify-js-samples-staging/samples/next/auth/new-next-app - name: Start application and run test run: | @@ -186,15 +184,14 @@ jobs: working-directory: amplify-js-samples-staging/samples/javascript/datastore - name: Install amplify run: | - npm install -g npm@latest - npm install aws-amplify -legacy-peer-deps + npm install aws-amplify@latest working-directory: amplify-js-samples-staging/samples/javascript/datastore/new-javascript-app - name: Remove existing src folder run: rm -rf src working-directory: amplify-js-samples-staging/samples/javascript/datastore/new-javascript-app - name: Copy files from samples staging repo run: | - rm -r ./samples/javascript/datastore/new-javascript-app + rm -r ./samples/javascript/datastore/new-javascript-app cp -r ./samples/javascript/datastore/basic-crud ./samples/javascript/datastore/new-javascript-app working-directory: amplify-js-samples-staging - name: Copy test file from samples staging repo diff --git a/packages/adapter-nextjs/__tests__/createServerRunner.test.ts b/packages/adapter-nextjs/__tests__/createServerRunner.test.ts index 3509a4b49c3..258f101cb25 100644 --- a/packages/adapter-nextjs/__tests__/createServerRunner.test.ts +++ b/packages/adapter-nextjs/__tests__/createServerRunner.test.ts @@ -20,7 +20,6 @@ const mockAmplifyConfig: ResourcesConfig = { }, }, }; - jest.mock( '../src/utils/createCookieStorageAdapterFromNextServerContext', () => ({ @@ -30,6 +29,7 @@ jest.mock( describe('createServerRunner', () => { let createServerRunner: any; + let createRunWithAmplifyServerContextSpy: any; const mockParseAmplifyConfig = jest.fn(); const mockCreateAWSCredentialsAndIdentityIdProvider = jest.fn(); @@ -50,11 +50,16 @@ describe('createServerRunner', () => { jest.doMock('@aws-amplify/core/internals/utils', () => ({ parseAmplifyConfig: mockParseAmplifyConfig, })); + createRunWithAmplifyServerContextSpy = jest.spyOn( + require('../src/utils/createRunWithAmplifyServerContext'), + 'createRunWithAmplifyServerContext', + ); ({ createServerRunner } = require('../src')); }); afterEach(() => { + createRunWithAmplifyServerContextSpy.mockClear(); mockParseAmplifyConfig.mockClear(); mockCreateAWSCredentialsAndIdentityIdProvider.mockClear(); mockCreateKeyValueStorageFromCookieStorageAdapter.mockClear(); @@ -98,6 +103,10 @@ describe('createServerRunner', () => { {}, operation, ); + expect(createRunWithAmplifyServerContextSpy).toHaveBeenCalledWith({ + config: mockAmplifyConfigWithoutAuth, + tokenValidator: undefined, + }); }); }); @@ -120,6 +129,12 @@ describe('createServerRunner', () => { mockAmplifyConfig.Auth, sharedInMemoryStorage, ); + expect(createRunWithAmplifyServerContextSpy).toHaveBeenCalledWith({ + config: mockAmplifyConfig, + tokenValidator: expect.objectContaining({ + getItem: expect.any(Function), + }), + }); }); }); @@ -162,6 +177,12 @@ describe('createServerRunner', () => { mockAmplifyConfig.Auth, mockCookieStorageAdapter, ); + expect(createRunWithAmplifyServerContextSpy).toHaveBeenCalledWith({ + config: mockAmplifyConfig, + tokenValidator: expect.objectContaining({ + getItem: expect.any(Function), + }), + }); }); }); }); diff --git a/packages/adapter-nextjs/__tests__/utils/createTokenValidator.test.ts b/packages/adapter-nextjs/__tests__/utils/createTokenValidator.test.ts index cc2c43c1568..adead7b59de 100644 --- a/packages/adapter-nextjs/__tests__/utils/createTokenValidator.test.ts +++ b/packages/adapter-nextjs/__tests__/utils/createTokenValidator.test.ts @@ -1,85 +1,108 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { CognitoJwtVerifier } from 'aws-jwt-verify'; + import { isValidCognitoToken } from '../../src/utils/isValidCognitoToken'; import { createTokenValidator } from '../../src/utils/createTokenValidator'; +import { JwtVerifier } from '../../src/types'; +jest.mock('aws-jwt-verify'); jest.mock('../../src/utils/isValidCognitoToken'); -const mockIsValidCognitoToken = isValidCognitoToken as jest.Mock; - -const userPoolId = 'userPoolId'; -const userPoolClientId = 'clientId'; -const tokenValidatorInput = { - userPoolId, - userPoolClientId, -}; -const accessToken = { - key: 'CognitoIdentityServiceProvider.clientId.usersub.accessToken', - value: - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMTEiLCJpc3MiOiJodHRwc', -}; -const idToken = { - key: 'CognitoIdentityServiceProvider.clientId.usersub.idToken', - value: 'eyJzdWIiOiIxMTEiLCJpc3MiOiJodHRwc.XAiOiJKV1QiLCJhbGciOiJIUzI1NiJ', -}; - -const tokenValidator = createTokenValidator({ - userPoolId, - userPoolClientId, -}); +describe('createTokenValidator', () => { + const userPoolId = 'userPoolId'; + const userPoolClientId = 'clientId'; + const accessToken = { + key: 'CognitoIdentityServiceProvider.clientId.usersub.accessToken', + value: 'access-token-value', + }; + const idToken = { + key: 'CognitoIdentityServiceProvider.clientId.usersub.idToken', + value: 'id-token-value', + }; + + const mockIsValidCognitoToken = jest.mocked(isValidCognitoToken); + const mockCognitoJwtVerifier = { + create: jest.mocked(CognitoJwtVerifier.create), + }; -describe('Validator', () => { afterEach(() => { - jest.resetAllMocks(); - }); - it('should return a validator', () => { - expect(createTokenValidator(tokenValidatorInput)).toBeDefined(); + mockIsValidCognitoToken.mockClear(); }); - it('should return true for non-token keys', async () => { - const result = await tokenValidator.getItem?.('mockKey', 'mockValue'); - expect(result).toBe(true); - expect(mockIsValidCognitoToken).toHaveBeenCalledTimes(0); + it('should return a token validator', () => { + expect( + createTokenValidator({ + userPoolId, + userPoolClientId, + }), + ).toStrictEqual({ + getItem: expect.any(Function), + }); }); - it('should return true for valid accessToken', async () => { - mockIsValidCognitoToken.mockImplementation(() => Promise.resolve(true)); - - const result = await tokenValidator.getItem?.( - accessToken.key, - accessToken.value, - ); - - expect(result).toBe(true); - expect(mockIsValidCognitoToken).toHaveBeenCalledTimes(1); - expect(mockIsValidCognitoToken).toHaveBeenCalledWith({ - userPoolId, - clientId: userPoolClientId, - token: accessToken.value, - tokenType: 'access', + describe('created token validator', () => { + afterEach(() => { + mockCognitoJwtVerifier.create.mockReset(); }); - }); - it('should return true for valid idToken', async () => { - mockIsValidCognitoToken.mockImplementation(() => Promise.resolve(true)); - - const result = await tokenValidator.getItem?.(idToken.key, idToken.value); - expect(result).toBe(true); - expect(mockIsValidCognitoToken).toHaveBeenCalledTimes(1); - expect(mockIsValidCognitoToken).toHaveBeenCalledWith({ - userPoolId, - clientId: userPoolClientId, - token: idToken.value, - tokenType: 'id', + it('should return true if key is not for access or id tokens', async () => { + const tokenValidator = createTokenValidator({ + userPoolId, + userPoolClientId, + }); + + expect(await tokenValidator.getItem?.('key', 'value')).toBe(true); + expect(mockIsValidCognitoToken).not.toHaveBeenCalled(); }); - }); - it('should return false if invalid tokenType is access', async () => { - mockIsValidCognitoToken.mockImplementation(() => Promise.resolve(false)); + it('should return false if validator created without user pool or client ids', async () => { + const tokenValidator = createTokenValidator({}); - const result = await tokenValidator.getItem?.(idToken.key, idToken.value); - expect(result).toBe(false); - expect(mockIsValidCognitoToken).toHaveBeenCalledTimes(1); + expect( + await tokenValidator.getItem?.(accessToken.key, accessToken.value), + ).toBe(false); + expect(await tokenValidator.getItem?.(idToken.key, idToken.value)).toBe( + false, + ); + expect(mockIsValidCognitoToken).not.toHaveBeenCalled(); + }); + + describe.each([ + { tokenUse: 'access', token: accessToken }, + { tokenUse: 'id', token: idToken }, + ])('$tokenUse token verifier', ({ tokenUse, token }) => { + const mockTokenVerifier = {} as JwtVerifier; + const tokenValidator = createTokenValidator({ + userPoolId, + userPoolClientId, + }); + + beforeAll(() => { + mockCognitoJwtVerifier.create.mockReturnValue(mockTokenVerifier); + }); + + it('should create a jwt verifier and use it to validate', async () => { + await tokenValidator.getItem?.(token.key, token.value); + + expect(mockCognitoJwtVerifier.create).toHaveBeenCalledWith({ + userPoolId, + clientId: userPoolClientId, + tokenUse, + }); + expect(mockIsValidCognitoToken).toHaveBeenCalledWith({ + token: token.value, + verifier: mockTokenVerifier, + }); + }); + + it('should not re-create the jwt verifier', async () => { + await tokenValidator.getItem?.(token.key, token.value); + + expect(mockCognitoJwtVerifier.create).not.toHaveBeenCalled(); + expect(mockIsValidCognitoToken).toHaveBeenCalled(); + }); + }); }); }); diff --git a/packages/adapter-nextjs/__tests__/utils/isValidCognitoToken.test.ts b/packages/adapter-nextjs/__tests__/utils/isValidCognitoToken.test.ts index 21015652781..8255eaa8b56 100644 --- a/packages/adapter-nextjs/__tests__/utils/isValidCognitoToken.test.ts +++ b/packages/adapter-nextjs/__tests__/utils/isValidCognitoToken.test.ts @@ -1,94 +1,57 @@ -import { CognitoJwtVerifier } from 'aws-jwt-verify'; import { JwtExpiredError } from 'aws-jwt-verify/error'; import { isValidCognitoToken } from '../../src/utils/isValidCognitoToken'; - -jest.mock('aws-jwt-verify', () => { - return { - CognitoJwtVerifier: { - create: jest.fn(), - }, - }; -}); - -const mockedCreate = CognitoJwtVerifier.create as jest.MockedFunction< - typeof CognitoJwtVerifier.create ->; +import { JwtVerifier } from '../../src/types'; describe('isValidCognitoToken', () => { const token = 'mocked-token'; - const userPoolId = 'us-east-1_test'; - const clientId = 'client-id-test'; - const tokenType = 'id'; beforeEach(() => { jest.clearAllMocks(); }); it('should return true for a valid token', async () => { - const mockVerifier: any = { - verify: jest.fn().mockResolvedValue({}), + // @ts-expect-error - partial mock + const mockVerifier: JwtVerifier = { + verify: jest.fn().mockResolvedValue(null), }; - mockedCreate.mockReturnValue(mockVerifier); - const isValid = await isValidCognitoToken({ - token, - userPoolId, - clientId, - tokenType, - }); - expect(isValid).toBe(true); - expect(CognitoJwtVerifier.create).toHaveBeenCalledWith({ - userPoolId, - clientId, - tokenUse: tokenType, - }); + expect( + await isValidCognitoToken({ + token, + verifier: mockVerifier, + }), + ).toBe(true); expect(mockVerifier.verify).toHaveBeenCalledWith(token); }); - it('should return true for a token that has valid signature and expired', async () => { - const mockVerifier: any = { + it('should return true for a token that has valid signature but is expired', async () => { + // @ts-expect-error - partial mock + const mockVerifier: JwtVerifier = { verify: jest .fn() - .mockRejectedValue( - new JwtExpiredError('Token expired', 'mocked-token'), - ), + .mockRejectedValue(new JwtExpiredError('Token expired', token)), }; - mockedCreate.mockReturnValue(mockVerifier); - const isValid = await isValidCognitoToken({ - token, - userPoolId, - clientId, - tokenType, - }); - expect(isValid).toBe(true); - expect(CognitoJwtVerifier.create).toHaveBeenCalledWith({ - userPoolId, - clientId, - tokenUse: tokenType, - }); - expect(mockVerifier.verify).toHaveBeenCalledWith(token); + expect( + await isValidCognitoToken({ + token, + verifier: mockVerifier, + }), + ).toBe(true); }); it('should return false for an invalid token', async () => { - const mockVerifier: any = { - verify: jest.fn().mockRejectedValue(new Error('Invalid token')), + // @ts-expect-error - partial mock + const mockVerifier: JwtVerifier = { + verify: jest.fn().mockRejectedValue(null), }; - mockedCreate.mockReturnValue(mockVerifier); - const isValid = await isValidCognitoToken({ - token, - userPoolId, - clientId, - tokenType, - }); - expect(isValid).toBe(false); - expect(CognitoJwtVerifier.create).toHaveBeenCalledWith({ - userPoolId, - clientId, - tokenUse: tokenType, - }); - expect(mockVerifier.verify).toHaveBeenCalledWith(token); + expect( + await isValidCognitoToken({ + token, + verifier: mockVerifier, + }), + ).toBe(false); }); }); diff --git a/packages/adapter-nextjs/src/createServerRunner.ts b/packages/adapter-nextjs/src/createServerRunner.ts index 576356fba3e..b5025000d2f 100644 --- a/packages/adapter-nextjs/src/createServerRunner.ts +++ b/packages/adapter-nextjs/src/createServerRunner.ts @@ -2,10 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 import { ResourcesConfig } from 'aws-amplify'; +import { KeyValueStorageMethodValidator } from '@aws-amplify/core/internals/adapter-core'; import { parseAmplifyConfig } from '@aws-amplify/core/internals/utils'; import { createRunWithAmplifyServerContext } from './utils'; import { NextServer } from './types'; +import { createTokenValidator } from './utils/createTokenValidator'; /** * Creates the `runWithAmplifyServerContext` function to run Amplify server side APIs in an isolated request context. @@ -30,9 +32,19 @@ export const createServerRunner: NextServer.CreateServerRunner = ({ }) => { const amplifyConfig = parseAmplifyConfig(config); + let tokenValidator: KeyValueStorageMethodValidator | undefined; + if (amplifyConfig?.Auth) { + const { Cognito } = amplifyConfig.Auth; + tokenValidator = createTokenValidator({ + userPoolId: Cognito?.userPoolId, + userPoolClientId: Cognito?.userPoolClientId, + }); + } + return { runWithAmplifyServerContext: createRunWithAmplifyServerContext({ config: amplifyConfig, + tokenValidator, }), }; }; diff --git a/packages/adapter-nextjs/src/types/index.ts b/packages/adapter-nextjs/src/types/index.ts index f4fe2ef087f..bbc627f0d90 100644 --- a/packages/adapter-nextjs/src/types/index.ts +++ b/packages/adapter-nextjs/src/types/index.ts @@ -1,4 +1,8 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { CognitoJwtVerifier } from 'aws-jwt-verify'; + export { NextServer } from './NextServer'; + +export type JwtVerifier = ReturnType; diff --git a/packages/adapter-nextjs/src/utils/createRunWithAmplifyServerContext.ts b/packages/adapter-nextjs/src/utils/createRunWithAmplifyServerContext.ts index 497f56f33dc..3eaea7f362d 100644 --- a/packages/adapter-nextjs/src/utils/createRunWithAmplifyServerContext.ts +++ b/packages/adapter-nextjs/src/utils/createRunWithAmplifyServerContext.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { ResourcesConfig, sharedInMemoryStorage } from '@aws-amplify/core'; +import { KeyValueStorageMethodValidator } from '@aws-amplify/core/internals/adapter-core'; import { createAWSCredentialsAndIdentityIdProvider, createKeyValueStorageFromCookieStorageAdapter, @@ -11,13 +12,14 @@ import { import { NextServer } from '../types'; -import { createTokenValidator } from './createTokenValidator'; import { createCookieStorageAdapterFromNextServerContext } from './createCookieStorageAdapterFromNextServerContext'; export const createRunWithAmplifyServerContext = ({ config: resourcesConfig, + tokenValidator, }: { config: ResourcesConfig; + tokenValidator?: KeyValueStorageMethodValidator; }) => { const runWithAmplifyServerContext: NextServer.RunOperationWithContext = async ({ nextServerContext, operation }) => { @@ -35,11 +37,7 @@ export const createRunWithAmplifyServerContext = ({ await createCookieStorageAdapterFromNextServerContext( nextServerContext, ), - createTokenValidator({ - userPoolId: resourcesConfig?.Auth.Cognito?.userPoolId, - userPoolClientId: - resourcesConfig?.Auth.Cognito?.userPoolClientId, - }), + tokenValidator, ); const credentialsProvider = createAWSCredentialsAndIdentityIdProvider( resourcesConfig.Auth, diff --git a/packages/adapter-nextjs/src/utils/createTokenValidator.ts b/packages/adapter-nextjs/src/utils/createTokenValidator.ts index 8f504985c7c..800cd87c62f 100644 --- a/packages/adapter-nextjs/src/utils/createTokenValidator.ts +++ b/packages/adapter-nextjs/src/utils/createTokenValidator.ts @@ -2,6 +2,9 @@ // SPDX-License-Identifier: Apache-2.0 import { KeyValueStorageMethodValidator } from '@aws-amplify/core/internals/adapter-core'; +import { CognitoJwtVerifier } from 'aws-jwt-verify'; + +import { JwtVerifier } from '../types'; import { isValidCognitoToken } from './isValidCognitoToken'; @@ -9,6 +12,7 @@ interface CreateTokenValidatorInput { userPoolId?: string; userPoolClientId?: string; } + /** * Creates a validator object for validating methods in a KeyValueStorage. */ @@ -16,23 +20,42 @@ export const createTokenValidator = ({ userPoolId, userPoolClientId: clientId, }: CreateTokenValidatorInput): KeyValueStorageMethodValidator => { + let idTokenVerifier: JwtVerifier; + let accessTokenVerifier: JwtVerifier; + return { // validate access, id tokens getItem: async (key: string, value: string): Promise => { - const tokenType = key.includes('.accessToken') - ? 'access' - : key.includes('.idToken') - ? 'id' - : null; - if (!tokenType) return true; + const isAccessToken = key.includes('.accessToken'); + const isIdToken = key.includes('.idToken'); + + if (!isAccessToken && !isIdToken) { + return true; + } + + if (!userPoolId || !clientId) { + return false; + } + + if (isAccessToken && !accessTokenVerifier) { + accessTokenVerifier = CognitoJwtVerifier.create({ + userPoolId, + tokenUse: 'access', + clientId, + }); + } - if (!userPoolId || !clientId) return false; + if (isIdToken && !idTokenVerifier) { + idTokenVerifier = CognitoJwtVerifier.create({ + userPoolId, + tokenUse: 'id', + clientId, + }); + } return isValidCognitoToken({ - clientId, - userPoolId, - tokenType, token: value, + verifier: isAccessToken ? accessTokenVerifier : idTokenVerifier, }); }, }; diff --git a/packages/adapter-nextjs/src/utils/isValidCognitoToken.ts b/packages/adapter-nextjs/src/utils/isValidCognitoToken.ts index 567dd95bc93..b196ad9b00d 100644 --- a/packages/adapter-nextjs/src/utils/isValidCognitoToken.ts +++ b/packages/adapter-nextjs/src/utils/isValidCognitoToken.ts @@ -1,32 +1,25 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { CognitoJwtVerifier } from 'aws-jwt-verify'; import { JwtExpiredError } from 'aws-jwt-verify/error'; +import { JwtVerifier } from '../types'; + /** * Verifies a Cognito JWT token for its validity. * * @param input - An object containing: * - token: The JWT token as a string that needs to be verified. - * - userPoolId: The ID of the AWS Cognito User Pool to which the token belongs. - * - clientId: The Client ID associated with the Cognito User Pool. + * - verifier: The JWT verifier which will verify the token. * @internal */ export const isValidCognitoToken = async (input: { token: string; - userPoolId: string; - clientId: string; - tokenType: 'id' | 'access'; + verifier: JwtVerifier; }): Promise => { - const { userPoolId, clientId, tokenType, token } = input; + const { token, verifier } = input; try { - const verifier = CognitoJwtVerifier.create({ - userPoolId, - tokenUse: tokenType, - clientId, - }); await verifier.verify(token); return true; diff --git a/packages/auth/__tests__/providers/cognito/signInResumable.test.ts b/packages/auth/__tests__/providers/cognito/signInResumable.test.ts new file mode 100644 index 00000000000..7bc3a8d324a --- /dev/null +++ b/packages/auth/__tests__/providers/cognito/signInResumable.test.ts @@ -0,0 +1,269 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { Amplify, syncSessionStorage } from '@aws-amplify/core'; + +import { + resetActiveSignInState, + setActiveSignInState, + signInStore, +} from '../../../src/client/utils/store/signInStore'; +import { cognitoUserPoolsTokenProvider } from '../../../src/providers/cognito/tokenProvider'; +import { + ChallengeName, + RespondToAuthChallengeCommandOutput, +} from '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider/types'; +import * as signInHelpers from '../../../src/providers/cognito/utils/signInHelpers'; +import { signIn } from '../../../src/providers/cognito'; + +import { setUpGetConfig } from './testUtils/setUpGetConfig'; +import { authAPITestParams } from './testUtils/authApiTestParams'; + +const signInStoreImplementation = require('../../../src/client/utils/store/signInStore'); + +jest.mock('@aws-amplify/core/internals/utils'); +jest.mock('../../../src/providers/cognito/apis/getCurrentUser'); +jest.mock('@aws-amplify/core', () => ({ + ...(jest.createMockFromModule('@aws-amplify/core') as object), + Amplify: { + getConfig: jest.fn(() => ({})), + ADD_OAUTH_LISTENER: jest.fn(() => ({})), + }, + syncSessionStorage: { + setItem: jest.fn((key, value) => { + window.sessionStorage.setItem(key, value); + }), + getItem: jest.fn((key: string) => { + return window.sessionStorage.getItem(key); + }), + removeItem: jest.fn((key: string) => { + window.sessionStorage.removeItem(key); + }), + }, +})); + +const signInStateKeys: Record = { + username: 'CognitoSignInState.username', + challengeName: 'CognitoSignInState.challengeName', + signInSession: 'CognitoSignInState.signInSession', + expiry: 'CognitoSignInState.expiry', +}; + +const user1: Record = { + username: 'joonchoi', + challengeName: 'CUSTOM_CHALLENGE', + signInSession: '888577-ltfgo-42d8-891d-666l858766g7', + expiry: '1234567', +}; + +const populateValidTestSyncStorage = () => { + syncSessionStorage.setItem(signInStateKeys.username, user1.username); + syncSessionStorage.setItem( + signInStateKeys.signInSession, + user1.signInSession, + ); + syncSessionStorage.setItem( + signInStateKeys.challengeName, + user1.challengeName, + ); + syncSessionStorage.setItem( + signInStateKeys.expiry, + (new Date().getTime() + 9999999).toString(), + ); + + signInStore.dispatch({ + type: 'SET_INITIAL_STATE', + }); +}; + +const populateInvalidTestSyncStorage = () => { + syncSessionStorage.setItem(signInStateKeys.username, user1.username); + syncSessionStorage.setItem( + signInStateKeys.signInSession, + user1.signInSession, + ); + syncSessionStorage.setItem( + signInStateKeys.challengeName, + user1.challengeName, + ); + syncSessionStorage.setItem( + signInStateKeys.expiry, + (new Date().getTime() - 99999).toString(), + ); + + signInStore.dispatch({ + type: 'SET_INITIAL_STATE', + }); +}; + +describe('signInStore', () => { + const authConfig = { + Cognito: { + userPoolClientId: '123456-abcde-42d8-891d-666l858766g7', + userPoolId: 'us-west-7_ampjc', + }, + }; + + const session = '1234234232'; + const challengeName = 'SMS_MFA'; + const { username } = authAPITestParams.user1; + const { password } = authAPITestParams.user1; + + beforeEach(() => { + cognitoUserPoolsTokenProvider.setAuthConfig(authConfig); + }); + + beforeAll(() => { + setUpGetConfig(Amplify); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + test('LocalSignInState is empty after initialization', async () => { + const localSignInState = signInStore.getState(); + + expect(localSignInState).toEqual({ + challengeName: undefined, + signInSession: undefined, + username: undefined, + }); + resetActiveSignInState(); + }); + + test('State is set after calling setActiveSignInState', async () => { + const persistSignInStateSpy = jest.spyOn( + signInStoreImplementation, + 'persistSignInState', + ); + setActiveSignInState(user1); + const localSignInState = signInStore.getState(); + + expect(localSignInState).toEqual(user1); + expect(persistSignInStateSpy).toHaveBeenCalledTimes(1); + expect(persistSignInStateSpy).toHaveBeenCalledWith(user1); + resetActiveSignInState(); + }); + + test('State is updated after calling SignIn', async () => { + const handleUserSRPAuthflowSpy = jest + .spyOn(signInHelpers, 'handleUserSRPAuthFlow') + .mockImplementationOnce( + async (): Promise => ({ + ChallengeName: challengeName, + Session: session, + $metadata: {}, + ChallengeParameters: { + CODE_DELIVERY_DELIVERY_MEDIUM: 'SMS', + CODE_DELIVERY_DESTINATION: '*******9878', + }, + }), + ); + + await signIn({ + username, + password, + }); + const newLocalSignInState = signInStore.getState(); + + expect(handleUserSRPAuthflowSpy).toHaveBeenCalledTimes(1); + expect(newLocalSignInState).toEqual({ + challengeName, + signInSession: session, + username, + signInDetails: { + loginId: username, + authFlowType: 'USER_SRP_AUTH', + }, + }); + handleUserSRPAuthflowSpy.mockClear(); + }); + + test('The stored sign-in state should be rehydrated if the sign-in session is still valid.', () => { + populateValidTestSyncStorage(); + + const localSignInState = signInStore.getState(); + + expect(localSignInState).toEqual({ + username: user1.username, + challengeName: user1.challengeName, + signInSession: user1.signInSession, + }); + resetActiveSignInState(); + }); + + test('sign-in store should return undefined state when the sign-in session is expired', async () => { + populateInvalidTestSyncStorage(); + + const localSignInState = signInStore.getState(); + + expect(localSignInState).toEqual({ + username: undefined, + challengeName: undefined, + signInSession: undefined, + }); + resetActiveSignInState(); + }); + + test('State SignInSession is updated after dispatching custom session value', () => { + const persistSignInStateSpy = jest.spyOn( + signInStoreImplementation, + 'persistSignInState', + ); + const newSignInSessionID = '135790-dodge-2468-9aaa-kersh23lad00'; + + populateValidTestSyncStorage(); + + const localSignInState = signInStore.getState(); + expect(localSignInState).toEqual({ + username: user1.username, + challengeName: user1.challengeName, + signInSession: user1.signInSession, + }); + + signInStore.dispatch({ + type: 'SET_SIGN_IN_SESSION', + value: newSignInSessionID, + }); + + expect(persistSignInStateSpy).toHaveBeenCalledTimes(1); + expect(persistSignInStateSpy).toHaveBeenCalledWith({ + signInSession: newSignInSessionID, + }); + const newLocalSignInState = signInStore.getState(); + expect(newLocalSignInState).toEqual({ + username: user1.username, + challengeName: user1.challengeName, + signInSession: newSignInSessionID, + }); + }); + + test('State Challenge Name is updated after dispatching custom challenge name', () => { + const newChallengeName = 'RANDOM_CHALLENGE' as ChallengeName; + + populateValidTestSyncStorage(); + + const localSignInState = signInStore.getState(); + expect(localSignInState).toEqual({ + username: user1.username, + challengeName: user1.challengeName, + signInSession: user1.signInSession, + }); + + signInStore.dispatch({ + type: 'SET_CHALLENGE_NAME', + value: newChallengeName, + }); + + const newLocalSignInState = signInStore.getState(); + expect(newLocalSignInState).toEqual({ + username: user1.username, + challengeName: newChallengeName, + signInSession: user1.signInSession, + }); + }); +}); diff --git a/packages/auth/__tests__/providers/cognito/signInStateManagement.test.ts b/packages/auth/__tests__/providers/cognito/signInStateManagement.test.ts index 73e3cdc6eea..bf0735f8f07 100644 --- a/packages/auth/__tests__/providers/cognito/signInStateManagement.test.ts +++ b/packages/auth/__tests__/providers/cognito/signInStateManagement.test.ts @@ -5,7 +5,7 @@ import { Amplify } from '@aws-amplify/core'; import { getCurrentUser, signIn } from '../../../src/providers/cognito'; import * as signInHelpers from '../../../src/providers/cognito/utils/signInHelpers'; -import { signInStore } from '../../../src/client/utils/store'; +import { signInStore } from '../../../src/client/utils/store/signInStore'; import { cognitoUserPoolsTokenProvider } from '../../../src/providers/cognito/tokenProvider'; import { RespondToAuthChallengeCommandOutput } from '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider/types'; @@ -30,6 +30,7 @@ describe('local sign-in state management tests', () => { beforeEach(() => { cognitoUserPoolsTokenProvider.setAuthConfig(authConfig); + signInStore.dispatch({ type: 'RESET_STATE' }); }); test('local state management should return state after signIn returns a ChallengeName', async () => { diff --git a/packages/auth/__tests__/providers/cognito/signInWithRedirect.test.ts b/packages/auth/__tests__/providers/cognito/signInWithRedirect.test.ts index 8f91323319f..486d4fd9a81 100644 --- a/packages/auth/__tests__/providers/cognito/signInWithRedirect.test.ts +++ b/packages/auth/__tests__/providers/cognito/signInWithRedirect.test.ts @@ -42,7 +42,20 @@ jest.mock('@aws-amplify/core', () => { getConfig: jest.fn(() => mockAuthConfigWithOAuth), [ACTUAL_ADD_OAUTH_LISTENER]: jest.fn(), }, - ConsoleLogger: jest.fn(), + ConsoleLogger: jest.fn().mockImplementation(() => { + return { warn: jest.fn() }; + }), + syncSessionStorage: { + setItem: jest.fn((key, value) => { + window.sessionStorage.setItem(key, value); + }), + getItem: jest.fn((key: string) => { + return window.sessionStorage.getItem(key); + }), + removeItem: jest.fn((key: string) => { + window.sessionStorage.removeItem(key); + }), + }, }; }); diff --git a/packages/auth/src/client/flows/userAuth/handleWebAuthnSignInResult.ts b/packages/auth/src/client/flows/userAuth/handleWebAuthnSignInResult.ts index 2e67a52a5ab..ee3bc6e6b6c 100644 --- a/packages/auth/src/client/flows/userAuth/handleWebAuthnSignInResult.ts +++ b/packages/auth/src/client/flows/userAuth/handleWebAuthnSignInResult.ts @@ -21,11 +21,7 @@ import { getNewDeviceMetadata, getSignInResult, } from '../../../providers/cognito/utils/signInHelpers'; -import { - cleanActiveSignInState, - setActiveSignInState, - signInStore, -} from '../../../client/utils/store'; +import { setActiveSignInState, signInStore } from '../../../client/utils/store'; import { AuthSignInOutput } from '../../../types'; import { getAuthUserAgentValue } from '../../../utils'; import { getPasskey } from '../../utils/passkey'; @@ -106,7 +102,7 @@ export async function handleWebAuthnSignInResult( }), signInDetails, }); - cleanActiveSignInState(); + signInStore.dispatch({ type: 'RESET_STATE' }); await dispatchSignedInHubEvent(); return { diff --git a/packages/auth/src/client/utils/store/signInStore.ts b/packages/auth/src/client/utils/store/signInStore.ts index 94311ce2b74..80b27f2a1c2 100644 --- a/packages/auth/src/client/utils/store/signInStore.ts +++ b/packages/auth/src/client/utils/store/signInStore.ts @@ -1,6 +1,8 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { syncSessionStorage } from '@aws-amplify/core'; + import { CognitoAuthSignInDetails } from '../../../providers/cognito/types'; import { ChallengeName } from '../../../foundation/factories/serviceClients/cognitoIdentityProvider/types'; @@ -19,46 +21,116 @@ type SignInAction = | { type: 'SET_SIGN_IN_STATE'; value: SignInState } | { type: 'SET_USERNAME'; value?: string } | { type: 'SET_CHALLENGE_NAME'; value?: ChallengeName } - | { type: 'SET_SIGN_IN_SESSION'; value?: string }; + | { type: 'SET_SIGN_IN_SESSION'; value?: string } + | { type: 'RESET_STATE' }; + +// Minutes until stored session invalidates +const MS_TO_EXPIRY = 3 * 60 * 1000; // 3 mins +const TGT_STATE = 'CognitoSignInState'; +const SIGN_IN_STATE_KEYS = { + username: `${TGT_STATE}.username`, + challengeName: `${TGT_STATE}.challengeName`, + signInSession: `${TGT_STATE}.signInSession`, + expiry: `${TGT_STATE}.expiry`, +}; const signInReducer: Reducer = (state, action) => { switch (action.type) { case 'SET_SIGN_IN_SESSION': + persistSignInState({ signInSession: action.value }); + return { ...state, signInSession: action.value, }; + case 'SET_SIGN_IN_STATE': + persistSignInState(action.value); + return { ...action.value, }; + case 'SET_CHALLENGE_NAME': + persistSignInState({ challengeName: action.value }); + return { ...state, challengeName: action.value, }; + case 'SET_USERNAME': + persistSignInState({ username: action.value }); + return { ...state, username: action.value, }; + case 'SET_INITIAL_STATE': - return defaultState(); + return getInitialState(); + + case 'RESET_STATE': + clearPersistedSignInState(); + + return getDefaultState(); + + // this state is never reachable default: return state; } }; -function defaultState(): SignInState { +const isExpired = (expiryDate: string | null): boolean => { + const expiryTimestamp = Number(expiryDate); + const currentTimestamp = Date.now(); + + return expiryTimestamp <= currentTimestamp; +}; + +export const resetActiveSignInState = () => { + signInStore.dispatch({ type: 'RESET_STATE' }); +}; + +const clearPersistedSignInState = () => { + for (const stateKey of Object.values(SIGN_IN_STATE_KEYS)) { + syncSessionStorage.removeItem(stateKey); + } +}; + +const getDefaultState = (): SignInState => ({ + username: undefined, + challengeName: undefined, + signInSession: undefined, +}); + +// Hydrate signInStore from syncSessionStorage +const getInitialState = (): SignInState => { + const expiry = syncSessionStorage.getItem(SIGN_IN_STATE_KEYS.expiry); + + if (!expiry || isExpired(expiry)) { + clearPersistedSignInState(); + + return getDefaultState(); + } + const username = + syncSessionStorage.getItem(SIGN_IN_STATE_KEYS.username) ?? undefined; + + const challengeName = (syncSessionStorage.getItem( + SIGN_IN_STATE_KEYS.challengeName, + ) ?? undefined) as ChallengeName; + const signInSession = + syncSessionStorage.getItem(SIGN_IN_STATE_KEYS.signInSession) ?? undefined; + return { - username: undefined, - challengeName: undefined, - signInSession: undefined, + username, + challengeName, + signInSession, }; -} +}; const createStore: Store = reducer => { - let currentState = reducer(defaultState(), { type: 'SET_INITIAL_STATE' }); + let currentState = reducer(getDefaultState(), { type: 'SET_INITIAL_STATE' }); return { getState: () => currentState, @@ -77,6 +149,23 @@ export function setActiveSignInState(state: SignInState): void { }); } -export function cleanActiveSignInState(): void { - signInStore.dispatch({ type: 'SET_INITIAL_STATE' }); -} +// Save local state into Session Storage +export const persistSignInState = ({ + challengeName, + signInSession, + username, +}: SignInState) => { + username && syncSessionStorage.setItem(SIGN_IN_STATE_KEYS.username, username); + challengeName && + syncSessionStorage.setItem(SIGN_IN_STATE_KEYS.challengeName, challengeName); + + if (signInSession) { + syncSessionStorage.setItem(SIGN_IN_STATE_KEYS.signInSession, signInSession); + + // Updates expiry when session is passed + syncSessionStorage.setItem( + SIGN_IN_STATE_KEYS.expiry, + String(Date.now() + MS_TO_EXPIRY), + ); + } +}; diff --git a/packages/auth/src/providers/cognito/apis/confirmSignIn.ts b/packages/auth/src/providers/cognito/apis/confirmSignIn.ts index ae62578be5e..9a9af8e75b5 100644 --- a/packages/auth/src/providers/cognito/apis/confirmSignIn.ts +++ b/packages/auth/src/providers/cognito/apis/confirmSignIn.ts @@ -11,7 +11,7 @@ import { } from '../types/errors'; import { ConfirmSignInInput, ConfirmSignInOutput } from '../types'; import { - cleanActiveSignInState, + resetActiveSignInState, setActiveSignInState, signInStore, } from '../../../client/utils/store'; @@ -120,7 +120,7 @@ export async function confirmSignIn( }), signInDetails, }); - cleanActiveSignInState(); + resetActiveSignInState(); await dispatchSignedInHubEvent(); diff --git a/packages/auth/src/providers/cognito/apis/signInWithCustomAuth.ts b/packages/auth/src/providers/cognito/apis/signInWithCustomAuth.ts index 5911266475b..3ee2de1302c 100644 --- a/packages/auth/src/providers/cognito/apis/signInWithCustomAuth.ts +++ b/packages/auth/src/providers/cognito/apis/signInWithCustomAuth.ts @@ -22,9 +22,9 @@ import { SignInWithCustomAuthOutput, } from '../types'; import { - cleanActiveSignInState, + resetActiveSignInState, setActiveSignInState, -} from '../../../client/utils/store'; +} from '../../../client/utils/store/signInStore'; import { cacheCognitoTokens } from '../tokenProvider/cacheTokens'; import { ChallengeName, @@ -95,7 +95,7 @@ export async function signInWithCustomAuth( }), signInDetails, }); - cleanActiveSignInState(); + resetActiveSignInState(); await dispatchSignedInHubEvent(); @@ -110,7 +110,7 @@ export async function signInWithCustomAuth( challengeParameters: retiredChallengeParameters as ChallengeParameters, }); } catch (error) { - cleanActiveSignInState(); + resetActiveSignInState(); assertServiceError(error); const result = getSignInResultFromError(error.name); if (result) return result; diff --git a/packages/auth/src/providers/cognito/apis/signInWithCustomSRPAuth.ts b/packages/auth/src/providers/cognito/apis/signInWithCustomSRPAuth.ts index 4966cfaa9fa..35eb7f29419 100644 --- a/packages/auth/src/providers/cognito/apis/signInWithCustomSRPAuth.ts +++ b/packages/auth/src/providers/cognito/apis/signInWithCustomSRPAuth.ts @@ -24,9 +24,9 @@ import { SignInWithCustomSRPAuthOutput, } from '../types'; import { - cleanActiveSignInState, + resetActiveSignInState, setActiveSignInState, -} from '../../../client/utils/store'; +} from '../../../client/utils/store/signInStore'; import { cacheCognitoTokens } from '../tokenProvider/cacheTokens'; import { ChallengeName, @@ -100,7 +100,7 @@ export async function signInWithCustomSRPAuth( }), signInDetails, }); - cleanActiveSignInState(); + resetActiveSignInState(); await dispatchSignedInHubEvent(); @@ -115,7 +115,7 @@ export async function signInWithCustomSRPAuth( challengeParameters: handledChallengeParameters as ChallengeParameters, }); } catch (error) { - cleanActiveSignInState(); + resetActiveSignInState(); assertServiceError(error); const result = getSignInResultFromError(error.name); if (result) return result; diff --git a/packages/auth/src/providers/cognito/apis/signInWithSRP.ts b/packages/auth/src/providers/cognito/apis/signInWithSRP.ts index aa92e3e6012..05c79cf35a0 100644 --- a/packages/auth/src/providers/cognito/apis/signInWithSRP.ts +++ b/packages/auth/src/providers/cognito/apis/signInWithSRP.ts @@ -28,9 +28,9 @@ import { SignInWithSRPOutput, } from '../types'; import { - cleanActiveSignInState, + resetActiveSignInState, setActiveSignInState, -} from '../../../client/utils/store'; +} from '../../../client/utils/store/signInStore'; import { cacheCognitoTokens } from '../tokenProvider/cacheTokens'; import { tokenOrchestrator } from '../tokenProvider'; import { dispatchSignedInHubEvent } from '../utils/dispatchSignedInHubEvent'; @@ -102,7 +102,7 @@ export async function signInWithSRP( }), signInDetails, }); - cleanActiveSignInState(); + resetActiveSignInState(); await dispatchSignedInHubEvent(); @@ -119,7 +119,7 @@ export async function signInWithSRP( challengeParameters: handledChallengeParameters as ChallengeParameters, }); } catch (error) { - cleanActiveSignInState(); + resetActiveSignInState(); resetAutoSignIn(); assertServiceError(error); const result = getSignInResultFromError(error.name); diff --git a/packages/auth/src/providers/cognito/apis/signInWithUserAuth.ts b/packages/auth/src/providers/cognito/apis/signInWithUserAuth.ts index 94165046864..4f653f46c94 100644 --- a/packages/auth/src/providers/cognito/apis/signInWithUserAuth.ts +++ b/packages/auth/src/providers/cognito/apis/signInWithUserAuth.ts @@ -26,11 +26,11 @@ import { SignInWithUserAuthInput, SignInWithUserAuthOutput, } from '../types'; +import { autoSignInStore } from '../../../client/utils/store'; import { - autoSignInStore, - cleanActiveSignInState, + resetActiveSignInState, setActiveSignInState, -} from '../../../client/utils/store'; +} from '../../../client/utils/store/signInStore'; import { cacheCognitoTokens } from '../tokenProvider/cacheTokens'; import { dispatchSignedInHubEvent } from '../utils/dispatchSignedInHubEvent'; import { tokenOrchestrator } from '../tokenProvider'; @@ -111,7 +111,7 @@ export async function signInWithUserAuth( }), signInDetails, }); - cleanActiveSignInState(); + resetActiveSignInState(); await dispatchSignedInHubEvent(); @@ -132,7 +132,7 @@ export async function signInWithUserAuth( : undefined, }); } catch (error) { - cleanActiveSignInState(); + resetActiveSignInState(); resetAutoSignIn(); assertServiceError(error); const result = getSignInResultFromError(error.name); diff --git a/packages/auth/src/providers/cognito/apis/signInWithUserPassword.ts b/packages/auth/src/providers/cognito/apis/signInWithUserPassword.ts index 8027af90c71..56e1c1af9f5 100644 --- a/packages/auth/src/providers/cognito/apis/signInWithUserPassword.ts +++ b/packages/auth/src/providers/cognito/apis/signInWithUserPassword.ts @@ -26,9 +26,9 @@ import { SignInWithUserPasswordOutput, } from '../types'; import { - cleanActiveSignInState, + resetActiveSignInState, setActiveSignInState, -} from '../../../client/utils/store'; +} from '../../../client/utils/store/signInStore'; import { cacheCognitoTokens } from '../tokenProvider/cacheTokens'; import { tokenOrchestrator } from '../tokenProvider'; import { dispatchSignedInHubEvent } from '../utils/dispatchSignedInHubEvent'; @@ -97,7 +97,7 @@ export async function signInWithUserPassword( }), signInDetails, }); - cleanActiveSignInState(); + resetActiveSignInState(); await dispatchSignedInHubEvent(); @@ -114,7 +114,7 @@ export async function signInWithUserPassword( challengeParameters: retriedChallengeParameters as ChallengeParameters, }); } catch (error) { - cleanActiveSignInState(); + resetActiveSignInState(); resetAutoSignIn(); assertServiceError(error); const result = getSignInResultFromError(error.name); diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index cadaa7d905c..9d90fade9d2 100644 --- a/packages/aws-amplify/package.json +++ b/packages/aws-amplify/package.json @@ -383,7 +383,7 @@ "name": "[Auth] confirmSignIn (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ confirmSignIn }", - "limit": "28.46 kB" + "limit": "28.60 kB" }, { "name": "[Auth] updateMFAPreference (Cognito)", @@ -449,7 +449,7 @@ "name": "[Auth] Basic Auth Flow (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ signIn, signOut, fetchAuthSession, confirmSignIn }", - "limit": "30.56 kB" + "limit": "30.87 kB" }, { "name": "[Auth] OAuth Auth Flow (Cognito)", @@ -515,7 +515,7 @@ "name": "[Storage] uploadData (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ uploadData }", - "limit": "22.81 kB" + "limit": "22.84 kB" } ] } diff --git a/packages/aws-amplify/src/adapter-core/storageFactories/createKeyValueStorageFromCookieStorageAdapter.ts b/packages/aws-amplify/src/adapter-core/storageFactories/createKeyValueStorageFromCookieStorageAdapter.ts index dc6bd80fccf..b20ddb961a6 100644 --- a/packages/aws-amplify/src/adapter-core/storageFactories/createKeyValueStorageFromCookieStorageAdapter.ts +++ b/packages/aws-amplify/src/adapter-core/storageFactories/createKeyValueStorageFromCookieStorageAdapter.ts @@ -23,7 +23,7 @@ const ONE_YEAR_IN_MS = 365 * 24 * 60 * 60 * 1000; */ export const createKeyValueStorageFromCookieStorageAdapter = ( cookieStorageAdapter: CookieStorage.Adapter, - validatorMap?: KeyValueStorageMethodValidator, + validator?: KeyValueStorageMethodValidator, ): KeyValueStorageInterface => { return { setItem(key, value) { @@ -44,8 +44,8 @@ export const createKeyValueStorageFromCookieStorageAdapter = ( const cookie = cookieStorageAdapter.get(key); const value = cookie?.value ?? null; - if (value && validatorMap?.getItem) { - const isValid = await validatorMap.getItem(key, value); + if (value && validator?.getItem) { + const isValid = await validator.getItem(key, value); if (!isValid) return null; } diff --git a/packages/core/__tests__/storage/SyncSessionStorage.test.ts b/packages/core/__tests__/storage/SyncSessionStorage.test.ts new file mode 100644 index 00000000000..fb56565d598 --- /dev/null +++ b/packages/core/__tests__/storage/SyncSessionStorage.test.ts @@ -0,0 +1,69 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { SyncSessionStorage } from '../../src/storage/SyncSessionStorage'; + +describe('SyncSessionStorage', () => { + let sessionStorage: SyncSessionStorage; + const signInStateKeys: Record = { + username: 'CognitoSignInState.username', + challengeName: 'CognitoSignInState.challengeName', + signInSession: 'CognitoSignInState.signInSession', + expiry: 'CognitoSignInState.expiry', + }; + + const user1 = { + username: 'joonchoi', + challengeName: 'CUSTOM_CHALLENGE', + signInSession: '888577-ltfgo-42d8-891d-666l858766g7', + expiry: '1234567', + }; + + beforeEach(() => { + sessionStorage = new SyncSessionStorage(); + }); + + it('can set and retrieve item by key', () => { + sessionStorage.setItem(signInStateKeys.username, user1.username); + + expect(sessionStorage.getItem(signInStateKeys.username)).toBe( + user1.username, + ); + }); + + it('can override item by setting with the same key', () => { + const newUserName = 'joonchoi+test'; + sessionStorage.setItem(signInStateKeys.username, user1.username); + expect(sessionStorage.getItem(signInStateKeys.username)).toBe( + user1.username, + ); + sessionStorage.setItem(signInStateKeys.username, newUserName); + + expect(sessionStorage.getItem(signInStateKeys.username)).toBe(newUserName); + }); + + it('can remove item by key', () => { + const newUserName = 'joonchoi+tobedeleted'; + sessionStorage.setItem(signInStateKeys.username, newUserName); + expect(sessionStorage.getItem(signInStateKeys.username)).toBe(newUserName); + sessionStorage.removeItem(signInStateKeys.username); + expect(sessionStorage.getItem(signInStateKeys.username)).toBeNull(); + }); + + it('clears all items', () => { + sessionStorage.setItem(signInStateKeys.username, user1.username); + sessionStorage.setItem(signInStateKeys.signInSession, user1.signInSession); + + sessionStorage.clear(); + + expect(sessionStorage.getItem(signInStateKeys.username)).toBeNull(); + expect(sessionStorage.getItem(signInStateKeys.signInSession)).toBeNull(); + }); + + it('will not throw if trying to delete a non existing key', () => { + const badKey = 'nonExistingKey'; + + expect(() => { + sessionStorage.removeItem(badKey); + }).not.toThrow(); + }); +}); diff --git a/packages/core/__tests__/storage/storage-mechanisms-node-runtime.test.ts b/packages/core/__tests__/storage/storage-mechanisms-node-runtime.test.ts deleted file mode 100644 index 0566237962b..00000000000 --- a/packages/core/__tests__/storage/storage-mechanisms-node-runtime.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * @jest-environment node - */ - -import { AmplifyError, AmplifyErrorCode } from '../../src/libraryUtils'; -import { defaultStorage, sessionStorage } from '../../src/storage'; - -const key = 'k'; -const value = 'value'; - -describe('test mechanisms', () => { - test('test defaultStorage operations in node environment', async () => { - try { - await defaultStorage.setItem(key, value); - } catch (error: any) { - expect(error).toBeInstanceOf(AmplifyError); - expect(error.name).toBe(AmplifyErrorCode.PlatformNotSupported); - } - }); - - test('test sessionStorage operations in node environment', async () => { - try { - await sessionStorage.setItem(key, value); - } catch (error: any) { - expect(error).toBeInstanceOf(AmplifyError); - expect(error.name).toBe(AmplifyErrorCode.PlatformNotSupported); - } - }); -}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f17968928c6..2ead3026682 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -59,6 +59,7 @@ export { CookieStorage, defaultStorage, sessionStorage, + syncSessionStorage, sharedInMemoryStorage, } from './storage'; export { KeyValueStorageInterface } from './types'; diff --git a/packages/core/src/storage/SyncKeyValueStorage.ts b/packages/core/src/storage/SyncKeyValueStorage.ts new file mode 100644 index 00000000000..33885f2af31 --- /dev/null +++ b/packages/core/src/storage/SyncKeyValueStorage.ts @@ -0,0 +1,59 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PlatformNotSupportedError } from '../errors'; +import { SyncStorage } from '../types'; + +/** + * @internal + */ +export class SyncKeyValueStorage implements SyncStorage { + _storage?: Storage; + + constructor(storage?: Storage) { + this._storage = storage; + } + + get storage() { + if (!this._storage) throw new PlatformNotSupportedError(); + + return this._storage; + } + + /** + * This is used to set a specific item in storage + * @param {string} key - the key for the item + * @param {object} value - the value + * @returns {string} value that was set + */ + setItem(key: string, value: string) { + this.storage.setItem(key, value); + } + + /** + * This is used to get a specific key from storage + * @param {string} key - the key for the item + * This is used to clear the storage + * @returns {string} the data item + */ + getItem(key: string) { + return this.storage.getItem(key); + } + + /** + * This is used to remove an item from storage + * @param {string} key - the key being set + * @returns {string} value - value that was deleted + */ + removeItem(key: string) { + this.storage.removeItem(key); + } + + /** + * This is used to clear the storage + * @returns {string} nothing + */ + clear() { + this.storage.clear(); + } +} diff --git a/packages/core/src/storage/SyncSessionStorage.ts b/packages/core/src/storage/SyncSessionStorage.ts new file mode 100644 index 00000000000..83224bfc878 --- /dev/null +++ b/packages/core/src/storage/SyncSessionStorage.ts @@ -0,0 +1,14 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { SyncKeyValueStorage } from './SyncKeyValueStorage'; +import { getSessionStorageWithFallback } from './utils'; + +/** + * @internal + */ +export class SyncSessionStorage extends SyncKeyValueStorage { + constructor() { + super(getSessionStorageWithFallback()); + } +} diff --git a/packages/core/src/storage/index.ts b/packages/core/src/storage/index.ts index 29fba0b3dea..72219167521 100644 --- a/packages/core/src/storage/index.ts +++ b/packages/core/src/storage/index.ts @@ -5,9 +5,11 @@ import { DefaultStorage } from './DefaultStorage'; import { InMemoryStorage } from './InMemoryStorage'; import { KeyValueStorage } from './KeyValueStorage'; import { SessionStorage } from './SessionStorage'; +import { SyncSessionStorage } from './SyncSessionStorage'; export { CookieStorage } from './CookieStorage'; export const defaultStorage = new DefaultStorage(); export const sessionStorage = new SessionStorage(); +export const syncSessionStorage = new SyncSessionStorage(); export const sharedInMemoryStorage = new KeyValueStorage(new InMemoryStorage()); diff --git a/packages/core/src/types/storage.ts b/packages/core/src/types/storage.ts index d664a95b446..79a0c63e74b 100644 --- a/packages/core/src/types/storage.ts +++ b/packages/core/src/types/storage.ts @@ -21,3 +21,10 @@ export interface CookieStorageData { secure?: boolean; sameSite?: SameSite; } + +export interface SyncStorage { + setItem(key: string, value: string): void; + getItem(key: string): string | null; + removeItem(key: string): void; + clear(): void; +}