diff --git a/packages/adapter-nextjs/__tests__/utils/createTokenValidator.test.ts b/packages/adapter-nextjs/__tests__/utils/createTokenValidator.test.ts new file mode 100644 index 00000000000..574e14dec8e --- /dev/null +++ b/packages/adapter-nextjs/__tests__/utils/createTokenValidator.test.ts @@ -0,0 +1,88 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { isValidCognitoToken } from '@aws-amplify/core/internals/utils'; + +import { createTokenValidator } from '../../src/utils/createTokenValidator'; + +jest.mock('@aws-amplify/core/internals/utils', () => ({ + ...jest.requireActual('@aws-amplify/core/internals/utils'), + isValidCognitoToken: jest.fn(), +})); +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('Validator', () => { + afterEach(() => { + jest.resetAllMocks(); + }); + it('should return a validator', () => { + expect(createTokenValidator(tokenValidatorInput)).toBeDefined(); + }); + + 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 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', + }); + }); + + 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 false if invalid tokenType is access', async () => { + mockIsValidCognitoToken.mockImplementation(() => Promise.resolve(false)); + + const result = await tokenValidator.getItem?.(idToken.key, idToken.value); + expect(result).toBe(false); + expect(mockIsValidCognitoToken).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/adapter-nextjs/src/utils/createRunWithAmplifyServerContext.ts b/packages/adapter-nextjs/src/utils/createRunWithAmplifyServerContext.ts index 787e934c11b..3d20f19cd67 100644 --- a/packages/adapter-nextjs/src/utils/createRunWithAmplifyServerContext.ts +++ b/packages/adapter-nextjs/src/utils/createRunWithAmplifyServerContext.ts @@ -11,6 +11,7 @@ import { import { NextServer } from '../types'; +import { createTokenValidator } from './createTokenValidator'; import { createCookieStorageAdapterFromNextServerContext } from './createCookieStorageAdapterFromNextServerContext'; export const createRunWithAmplifyServerContext = ({ @@ -34,6 +35,11 @@ export const createRunWithAmplifyServerContext = ({ createCookieStorageAdapterFromNextServerContext( nextServerContext, ), + createTokenValidator({ + userPoolId: resourcesConfig?.Auth.Cognito?.userPoolId, + userPoolClientId: + resourcesConfig?.Auth.Cognito?.userPoolClientId, + }), ); const credentialsProvider = createAWSCredentialsAndIdentityIdProvider( resourcesConfig.Auth, diff --git a/packages/adapter-nextjs/src/utils/createTokenValidator.ts b/packages/adapter-nextjs/src/utils/createTokenValidator.ts new file mode 100644 index 00000000000..290d47cb1a3 --- /dev/null +++ b/packages/adapter-nextjs/src/utils/createTokenValidator.ts @@ -0,0 +1,38 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { isValidCognitoToken } from '@aws-amplify/core/internals/utils'; +import { KeyValueStorageMethodValidator } from '@aws-amplify/core/internals/adapter-core'; + +interface CreateTokenValidatorInput { + userPoolId?: string; + userPoolClientId?: string; +} +/** + * Creates a validator object for validating methods in a KeyValueStorage. + */ +export const createTokenValidator = ({ + userPoolId, + userPoolClientId: clientId, +}: CreateTokenValidatorInput): KeyValueStorageMethodValidator => { + 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; + + if (!userPoolId || !clientId) return false; + + return isValidCognitoToken({ + clientId, + userPoolId, + tokenType, + token: value, + }); + }, + }; +}; diff --git a/packages/aws-amplify/__tests__/adapterCore/storageFactories/createKeyValueStorageFromCookieStorageAdapter.test.ts b/packages/aws-amplify/__tests__/adapterCore/storageFactories/createKeyValueStorageFromCookieStorageAdapter.test.ts index b3d1e24022e..eae5ffe22a8 100644 --- a/packages/aws-amplify/__tests__/adapterCore/storageFactories/createKeyValueStorageFromCookieStorageAdapter.test.ts +++ b/packages/aws-amplify/__tests__/adapterCore/storageFactories/createKeyValueStorageFromCookieStorageAdapter.test.ts @@ -84,5 +84,44 @@ describe('keyValueStorage', () => { }).toThrow('This method has not implemented.'); }); }); + + describe('in conjunction with token validator', () => { + const testKey = 'testKey'; + const testValue = 'testValue'; + + beforeEach(() => { + mockCookiesStorageAdapter.get.mockReturnValueOnce({ + name: testKey, + value: testValue, + }); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return item successfully if validation passes when getting item', async () => { + const getItemValidator = jest.fn().mockImplementation(() => true); + const keyValueStorage = createKeyValueStorageFromCookieStorageAdapter( + mockCookiesStorageAdapter, + { getItem: getItemValidator }, + ); + + const value = await keyValueStorage.getItem(testKey); + expect(value).toBe(testValue); + expect(getItemValidator).toHaveBeenCalledTimes(1); + }); + + it('should return null if validation fails when getting item', async () => { + const getItemValidator = jest.fn().mockImplementation(() => false); + const keyValueStorage = createKeyValueStorageFromCookieStorageAdapter( + mockCookiesStorageAdapter, + { getItem: getItemValidator }, + ); + + const value = await keyValueStorage.getItem(testKey); + expect(value).toBe(null); + expect(getItemValidator).toHaveBeenCalledTimes(1); + }); + }); }); }); diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index 89e14cd5863..1e997950754 100644 --- a/packages/aws-amplify/package.json +++ b/packages/aws-amplify/package.json @@ -293,31 +293,31 @@ "name": "[Analytics] record (Pinpoint)", "path": "./dist/esm/analytics/index.mjs", "import": "{ record }", - "limit": "17.08 kB" + "limit": "17.23 kB" }, { "name": "[Analytics] record (Kinesis)", "path": "./dist/esm/analytics/kinesis/index.mjs", "import": "{ record }", - "limit": "48.56 kB" + "limit": "48.67 kB" }, { "name": "[Analytics] record (Kinesis Firehose)", "path": "./dist/esm/analytics/kinesis-firehose/index.mjs", "import": "{ record }", - "limit": "45.68 kB" + "limit": "45.81 kB" }, { "name": "[Analytics] record (Personalize)", "path": "./dist/esm/analytics/personalize/index.mjs", "import": "{ record }", - "limit": "49.50 kB" + "limit": "49.63 kB" }, { "name": "[Analytics] identifyUser (Pinpoint)", "path": "./dist/esm/analytics/index.mjs", "import": "{ identifyUser }", - "limit": "15.57 kB" + "limit": "15.73 kB" }, { "name": "[Analytics] enable", @@ -335,7 +335,7 @@ "name": "[API] generateClient (AppSync)", "path": "./dist/esm/api/index.mjs", "import": "{ generateClient }", - "limit": "40.09 kB" + "limit": "40.19 kB" }, { "name": "[API] REST API handlers", @@ -353,13 +353,13 @@ "name": "[Auth] resetPassword (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ resetPassword }", - "limit": "12.44 kB" + "limit": "12.57 kB" }, { "name": "[Auth] confirmResetPassword (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ confirmResetPassword }", - "limit": "12.39 kB" + "limit": "12.51 kB" }, { "name": "[Auth] signIn (Cognito)", @@ -371,7 +371,7 @@ "name": "[Auth] resendSignUpCode (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ resendSignUpCode }", - "limit": "12.40 kB" + "limit": "12.53 kB" }, { "name": "[Auth] confirmSignUp (Cognito)", @@ -383,31 +383,31 @@ "name": "[Auth] confirmSignIn (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ confirmSignIn }", - "limit": "28.26 kB" + "limit": "28.42 kB" }, { "name": "[Auth] updateMFAPreference (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ updateMFAPreference }", - "limit": "11.74 kB" + "limit": "11.87 kB" }, { "name": "[Auth] fetchMFAPreference (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ fetchMFAPreference }", - "limit": "11.78 kB" + "limit": "11.90 kB" }, { "name": "[Auth] verifyTOTPSetup (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ verifyTOTPSetup }", - "limit": "12.59 kB" + "limit": "12.74 kB" }, { "name": "[Auth] updatePassword (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ updatePassword }", - "limit": "12.63 kB" + "limit": "12.76 kB" }, { "name": "[Auth] setUpTOTP (Cognito)", @@ -419,85 +419,85 @@ "name": "[Auth] updateUserAttributes (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ updateUserAttributes }", - "limit": "11.87 kB" + "limit": "11.99 kB" }, { "name": "[Auth] getCurrentUser (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ getCurrentUser }", - "limit": "7.75 kB" + "limit": "7.89 kB" }, { "name": "[Auth] confirmUserAttribute (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ confirmUserAttribute }", - "limit": "12.61 kB" + "limit": "12.74 kB" }, { "name": "[Auth] signInWithRedirect (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ signInWithRedirect }", - "limit": "21.10 kB" + "limit": "21.18 kB" }, { "name": "[Auth] fetchUserAttributes (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ fetchUserAttributes }", - "limit": "11.69 kB" + "limit": "11.81 kB" }, { "name": "[Auth] Basic Auth Flow (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ signIn, signOut, fetchAuthSession, confirmSignIn }", - "limit": "30.06 kB" + "limit": "30.19 kB" }, { "name": "[Auth] OAuth Auth Flow (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ signInWithRedirect, signOut, fetchAuthSession }", - "limit": "21.47 kB" + "limit": "21.61 kB" }, { "name": "[Storage] copy (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ copy }", - "limit": "14.54 kB" + "limit": "14.65 kB" }, { "name": "[Storage] downloadData (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ downloadData }", - "limit": "15.17 kB" + "limit": "15.28 kB" }, { "name": "[Storage] getProperties (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ getProperties }", - "limit": "14.43 kB" + "limit": "14.54 kB" }, { "name": "[Storage] getUrl (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ getUrl }", - "limit": "15.51 kB" + "limit": "15.63 kB" }, { "name": "[Storage] list (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ list }", - "limit": "14.94 kB" + "limit": "15.05 kB" }, { "name": "[Storage] remove (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ remove }", - "limit": "14.29 kB" + "limit": "14.40 kB" }, { "name": "[Storage] uploadData (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ uploadData }", - "limit": "19.64 kB" + "limit": "19.74 kB" } ] } diff --git a/packages/aws-amplify/src/adapter-core/storageFactories/createKeyValueStorageFromCookieStorageAdapter.ts b/packages/aws-amplify/src/adapter-core/storageFactories/createKeyValueStorageFromCookieStorageAdapter.ts index dffd9bc4752..9cfd141c47c 100644 --- a/packages/aws-amplify/src/adapter-core/storageFactories/createKeyValueStorageFromCookieStorageAdapter.ts +++ b/packages/aws-amplify/src/adapter-core/storageFactories/createKeyValueStorageFromCookieStorageAdapter.ts @@ -2,7 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 import { KeyValueStorageInterface } from '@aws-amplify/core'; -import { CookieStorage } from '@aws-amplify/core/internals/adapter-core'; +import { + CookieStorage, + KeyValueStorageMethodValidator, +} from '@aws-amplify/core/internals/adapter-core'; export const defaultSetCookieOptions: CookieStorage.SetCookieOptions = { // TODO: allow configure with a public interface @@ -18,6 +21,7 @@ const ONE_YEAR_IN_MS = 365 * 24 * 60 * 60 * 1000; */ export const createKeyValueStorageFromCookieStorageAdapter = ( cookieStorageAdapter: CookieStorage.Adapter, + validatorMap?: KeyValueStorageMethodValidator, ): KeyValueStorageInterface => { return { setItem(key, value) { @@ -29,10 +33,16 @@ export const createKeyValueStorageFromCookieStorageAdapter = ( return Promise.resolve(); }, - getItem(key) { + async getItem(key) { const cookie = cookieStorageAdapter.get(key); + const value = cookie?.value ?? null; - return Promise.resolve(cookie?.value ?? null); + if (value && validatorMap?.getItem) { + const isValid = await validatorMap.getItem(key, value); + if (!isValid) return null; + } + + return value; }, removeItem(key) { cookieStorageAdapter.delete(key); diff --git a/packages/core/__tests__/utils/isValidCognitoToken.test.ts b/packages/core/__tests__/utils/isValidCognitoToken.test.ts new file mode 100644 index 00000000000..3b08b389ceb --- /dev/null +++ b/packages/core/__tests__/utils/isValidCognitoToken.test.ts @@ -0,0 +1,68 @@ +import { CognitoJwtVerifier } from 'aws-jwt-verify'; + +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 +>; + +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({}), + }; + 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); + }); + + it('should return false for an invalid token', async () => { + const mockVerifier: any = { + verify: jest.fn().mockRejectedValue(new Error('Invalid token')), + }; + 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); + }); +}); diff --git a/packages/core/package.json b/packages/core/package.json index 4575f026981..30a05f39f42 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -54,6 +54,7 @@ "@aws-sdk/types": "3.398.0", "@smithy/util-hex-encoding": "2.0.0", "@types/uuid": "^9.0.0", + "aws-jwt-verify": "^4.0.1", "js-cookie": "^3.0.5", "rxjs": "^7.8.1", "tslib": "^2.5.0", diff --git a/packages/core/src/adapterCore/index.ts b/packages/core/src/adapterCore/index.ts index 88abe3e4bba..ddeb6480fb5 100644 --- a/packages/core/src/adapterCore/index.ts +++ b/packages/core/src/adapterCore/index.ts @@ -7,5 +7,6 @@ export { destroyAmplifyServerContext, AmplifyServer, CookieStorage, + KeyValueStorageMethodValidator, } from './serverContext'; export { AmplifyServerContextError } from './error'; diff --git a/packages/core/src/adapterCore/serverContext/index.ts b/packages/core/src/adapterCore/serverContext/index.ts index 0a69fb6c9d8..5d7477b0a1c 100644 --- a/packages/core/src/adapterCore/serverContext/index.ts +++ b/packages/core/src/adapterCore/serverContext/index.ts @@ -7,4 +7,8 @@ export { getAmplifyServerContext, } from './serverContext'; -export { AmplifyServer, CookieStorage } from './types'; +export { + AmplifyServer, + CookieStorage, + KeyValueStorageMethodValidator, +} from './types'; diff --git a/packages/core/src/adapterCore/serverContext/types/KeyValueStorageMethodValidator.ts b/packages/core/src/adapterCore/serverContext/types/KeyValueStorageMethodValidator.ts new file mode 100644 index 00000000000..fe69d511b8d --- /dev/null +++ b/packages/core/src/adapterCore/serverContext/types/KeyValueStorageMethodValidator.ts @@ -0,0 +1,10 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { KeyValueStorageInterface } from '../../../types/storage'; + +export type KeyValueStorageMethodValidator = Partial< + Record +>; + +type ValidatorFunction = (...args: any[]) => Promise; diff --git a/packages/core/src/adapterCore/serverContext/types/index.ts b/packages/core/src/adapterCore/serverContext/types/index.ts index 0c73229ee50..80b35fdf74b 100644 --- a/packages/core/src/adapterCore/serverContext/types/index.ts +++ b/packages/core/src/adapterCore/serverContext/types/index.ts @@ -7,3 +7,4 @@ type AmplifyServerContextSpec = AmplifyServer.ContextSpec; export { AmplifyServerContextSpec, AmplifyServer }; export { CookieStorage } from './cookieStorage'; +export { KeyValueStorageMethodValidator } from './KeyValueStorageMethodValidator'; diff --git a/packages/core/src/libraryUtils.ts b/packages/core/src/libraryUtils.ts index 717bfc7805a..1f770843e80 100644 --- a/packages/core/src/libraryUtils.ts +++ b/packages/core/src/libraryUtils.ts @@ -70,6 +70,7 @@ export { AWSCredentials, } from './singleton/Auth/types'; export { haveCredentialsChanged } from './utils/haveCredentialsChanged'; +export { isValidCognitoToken } from './utils/isValidCognitoToken'; // Platform & user-agent utilities export { diff --git a/packages/core/src/utils/isValidCognitoToken.ts b/packages/core/src/utils/isValidCognitoToken.ts new file mode 100644 index 00000000000..2f4c6742df0 --- /dev/null +++ b/packages/core/src/utils/isValidCognitoToken.ts @@ -0,0 +1,37 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CognitoJwtVerifier } from 'aws-jwt-verify'; + +/** + * 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. + * @internal + */ +export const isValidCognitoToken = async (input: { + token: string; + userPoolId: string; + clientId: string; + tokenType: 'id' | 'access'; +}): Promise => { + const { userPoolId, clientId, tokenType, token } = input; + + try { + const verifier = CognitoJwtVerifier.create({ + userPoolId, + tokenUse: tokenType, + clientId, + }); + await verifier.verify(token); + + return true; + } catch (error) { + // TODO (ashwinkumar6): surface invalid cognito token error to customer + // TODO: clear invalid tokens from Storage + return false; + } +}; diff --git a/packages/interactions/package.json b/packages/interactions/package.json index 8387d89a7bc..de4bfcd87fc 100644 --- a/packages/interactions/package.json +++ b/packages/interactions/package.json @@ -89,19 +89,19 @@ "name": "Interactions (default to Lex v2)", "path": "./dist/esm/index.mjs", "import": "{ Interactions }", - "limit": "52.52 kB" + "limit": "52.64 kB" }, { "name": "Interactions (Lex v2)", "path": "./dist/esm/lex-v2/index.mjs", "import": "{ Interactions }", - "limit": "52.52 kB" + "limit": "52.64 kB" }, { "name": "Interactions (Lex v1)", "path": "./dist/esm/lex-v1/index.mjs", "import": "{ Interactions }", - "limit": "47.33 kB" + "limit": "47.45 kB" } ] }