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/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; }