From 79cccbc5932216f188ced0f8a94aa21e088a2fe8 Mon Sep 17 00:00:00 2001 From: Hui Zhao <10602282+HuiSF@users.noreply.github.com> Date: Thu, 30 Jan 2025 15:07:17 -0800 Subject: [PATCH] fix(adapter-nextjs): wrong cookie attributes get set sometimes (#14169) --- .../createAuthRouteHandlersFactory.test.ts | 39 +++-- ...dleAuthApiRouteRequestForAppRouter.test.ts | 3 - .../handleSignInCallbackRequest.test.ts | 3 - ...ignInCallbackRequestForPagesRouter.test.ts | 3 - .../handleSignInSignUpRequest.test.ts | 3 - ...eSignInSignUpRequestForPagesRouter.test.ts | 3 - .../handleSignOutCallbackRequest.test.ts | 3 - ...gnOutCallbackRequestForPagesRouter.test.ts | 3 - .../handlers/handleSignOutRequest.test.ts | 3 - ...handleSignOutRequestForPagesRouter.test.ts | 3 - .../auth/utils/hasActiveUserSession.test.ts | 3 - .../__tests__/auth/utils/origin.test.ts | 6 + .../__tests__/auth/utils/predicates.test.ts | 3 - .../__tests__/auth/utils/tokenCookies.test.ts | 18 +++ .../__tests__/createServerRunner.test.ts | 128 ++++++++++++++-- ...torageAdapterFromNextServerContext.test.ts | 140 ++++++++++++++++-- .../__tests__/utils/globalSettings.test.ts | 48 ++++++ packages/adapter-nextjs/jest.config.js | 1 + .../src/api/createServerRunnerForAPI.ts | 3 +- packages/adapter-nextjs/src/auth/constant.ts | 13 ++ .../auth/createAuthRouteHandlersFactory.ts | 4 +- packages/adapter-nextjs/src/auth/types.ts | 2 +- .../adapter-nextjs/src/auth/utils/index.ts | 1 + .../adapter-nextjs/src/auth/utils/origin.ts | 55 ++++--- .../src/auth/utils/tokenCookies.ts | 10 +- .../adapter-nextjs/src/createServerRunner.ts | 16 +- .../adapter-nextjs/src/types/NextServer.ts | 9 ++ ...okieStorageAdapterFromNextServerContext.ts | 56 ++++++- .../createRunWithAmplifyServerContext.ts | 30 +++- .../src/utils/globalSettings.ts | 30 ++++ packages/adapter-nextjs/src/utils/index.ts | 1 + 31 files changed, 535 insertions(+), 108 deletions(-) create mode 100644 packages/adapter-nextjs/__tests__/utils/globalSettings.test.ts create mode 100644 packages/adapter-nextjs/src/utils/globalSettings.ts diff --git a/packages/adapter-nextjs/__tests__/auth/createAuthRouteHandlersFactory.test.ts b/packages/adapter-nextjs/__tests__/auth/createAuthRouteHandlersFactory.test.ts index 324bb20c869..c30f9bad014 100644 --- a/packages/adapter-nextjs/__tests__/auth/createAuthRouteHandlersFactory.test.ts +++ b/packages/adapter-nextjs/__tests__/auth/createAuthRouteHandlersFactory.test.ts @@ -20,6 +20,7 @@ import { isNextRequest, isValidOrigin, } from '../../src/auth/utils'; +import { globalSettings } from '../../src/utils'; jest.mock('aws-amplify/adapter-core/internals', () => ({ ...jest.requireActual('aws-amplify/adapter-core/internals'), @@ -29,6 +30,20 @@ jest.mock('aws-amplify/adapter-core/internals', () => ({ jest.mock('../../src/auth/handleAuthApiRouteRequestForAppRouter'); jest.mock('../../src/auth/handleAuthApiRouteRequestForPagesRouter'); jest.mock('../../src/auth/utils'); +jest.mock('../../src/utils', () => ({ + globalSettings: { + isServerSideAuthEnabled: jest.fn(() => true), + enableServerSideAuth: jest.fn(), + setRuntimeOptions: jest.fn(), + getRuntimeOptions: jest.fn(() => ({ + cookies: { + sameSite: 'strict', + }, + })), + isSSLOrigin: jest.fn(() => true), + setIsSSLOrigin: jest.fn(), + }, +})); const mockAmplifyConfig: ResourcesConfig = { Auth: { @@ -49,11 +64,6 @@ const mockAmplifyConfig: ResourcesConfig = { }, }; -const mockRuntimeOptions: NextServer.CreateServerRunnerRuntimeOptions = { - cookies: { - sameSite: 'strict', - }, -}; const mockAssertTokenProviderConfig = jest.mocked(assertTokenProviderConfig); const mockAssertOAuthConfig = jest.mocked(assertOAuthConfig); const mockHandleAuthApiRouteRequestForAppRouter = jest.mocked( @@ -83,9 +93,9 @@ describe('createAuthRoutesHandlersFactory', () => { it('throws an error if the AMPLIFY_APP_ORIGIN environment variable is not defined', () => { const throwingFunc = createAuthRouteHandlersFactory({ config: mockAmplifyConfig, - runtimeOptions: mockRuntimeOptions, amplifyAppOrigin: undefined, runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + globalSettings, }); expect(() => throwingFunc()).toThrow( 'Could not find the AMPLIFY_APP_ORIGIN environment variable.', @@ -96,9 +106,9 @@ describe('createAuthRoutesHandlersFactory', () => { mockIsValidOrigin.mockReturnValueOnce(false); const throwingFunc = createAuthRouteHandlersFactory({ config: mockAmplifyConfig, - runtimeOptions: mockRuntimeOptions, amplifyAppOrigin: 'domain-without-protocol.com', runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + globalSettings, }); expect(() => throwingFunc()).toThrow( 'AMPLIFY_APP_ORIGIN environment variable contains an invalid origin string.', @@ -108,9 +118,9 @@ describe('createAuthRoutesHandlersFactory', () => { it('calls config assertion functions to validate the Auth configuration', () => { const func = createAuthRouteHandlersFactory({ config: mockAmplifyConfig, - runtimeOptions: mockRuntimeOptions, amplifyAppOrigin: AMPLIFY_APP_ORIGIN, runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + globalSettings, }); func(); @@ -128,9 +138,9 @@ describe('createAuthRoutesHandlersFactory', () => { const testCreateAuthRoutesHandlersFactoryInput: CreateAuthRouteHandlersFactoryInput = { config: mockAmplifyConfig, - runtimeOptions: mockRuntimeOptions, amplifyAppOrigin: AMPLIFY_APP_ORIGIN, runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + globalSettings, }; const testCreateAuthRoutesHandlersInput: CreateAuthRoutesHandlersInput = { customState: 'random-state', @@ -168,7 +178,9 @@ describe('createAuthRoutesHandlersFactory', () => { response: param2, handlerInput: testCreateAuthRoutesHandlersInput, oAuthConfig: mockAmplifyConfig.Auth!.Cognito!.loginWith!.oauth, - setCookieOptions: mockRuntimeOptions.cookies, + setCookieOptions: { + sameSite: 'strict', + }, origin: 'https://example.com', userPoolClientId: 'def', runWithAmplifyServerContext: mockRunWithAmplifyServerContext, @@ -190,7 +202,9 @@ describe('createAuthRoutesHandlersFactory', () => { handlerContext: context, handlerInput: testCreateAuthRoutesHandlersInput, oAuthConfig: mockAmplifyConfig.Auth!.Cognito!.loginWith!.oauth, - setCookieOptions: mockRuntimeOptions.cookies, + setCookieOptions: { + sameSite: 'strict', + }, origin: 'https://example.com', userPoolClientId: 'def', runWithAmplifyServerContext: mockRunWithAmplifyServerContext, @@ -211,11 +225,12 @@ describe('createAuthRoutesHandlersFactory', () => { }); it('uses default values for parameters that have values as undefined', async () => { + (globalSettings.getRuntimeOptions as jest.Mock).mockReturnValueOnce({}); const createAuthRoutesHandlers = createAuthRouteHandlersFactory({ config: mockAmplifyConfig, - runtimeOptions: undefined, amplifyAppOrigin: AMPLIFY_APP_ORIGIN, runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + globalSettings, }); const handlerWithDefaultParamValues = createAuthRoutesHandlers(/* undefined */); diff --git a/packages/adapter-nextjs/__tests__/auth/handleAuthApiRouteRequestForAppRouter.test.ts b/packages/adapter-nextjs/__tests__/auth/handleAuthApiRouteRequestForAppRouter.test.ts index 4ce2bf7c77b..188d6c043fe 100644 --- a/packages/adapter-nextjs/__tests__/auth/handleAuthApiRouteRequestForAppRouter.test.ts +++ b/packages/adapter-nextjs/__tests__/auth/handleAuthApiRouteRequestForAppRouter.test.ts @@ -1,6 +1,3 @@ -/** - * @jest-environment node - */ import { NextRequest } from 'next/server'; import { OAuthConfig } from 'aws-amplify/adapter-core/internals'; diff --git a/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInCallbackRequest.test.ts b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInCallbackRequest.test.ts index e796e03f444..61a1f920c02 100644 --- a/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInCallbackRequest.test.ts +++ b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInCallbackRequest.test.ts @@ -1,6 +1,3 @@ -/** - * @jest-environment node - */ import { NextRequest } from 'next/server.js'; import { OAuthConfig } from 'aws-amplify/adapter-core/internals'; import { CookieStorage } from 'aws-amplify/adapter-core'; diff --git a/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInCallbackRequestForPagesRouter.test.ts b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInCallbackRequestForPagesRouter.test.ts index aaf120cdee1..fbeea49fba3 100644 --- a/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInCallbackRequestForPagesRouter.test.ts +++ b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInCallbackRequestForPagesRouter.test.ts @@ -1,6 +1,3 @@ -/** - * @jest-environment node - */ import { OAuthConfig } from 'aws-amplify/adapter-core/internals'; import { CookieStorage } from 'aws-amplify/adapter-core'; import { NextApiRequest } from 'next'; diff --git a/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInSignUpRequest.test.ts b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInSignUpRequest.test.ts index 9f956059951..2b0dc1d8328 100644 --- a/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInSignUpRequest.test.ts +++ b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInSignUpRequest.test.ts @@ -1,6 +1,3 @@ -/** - * @jest-environment node - */ import { OAuthConfig } from 'aws-amplify/adapter-core/internals'; import { CookieStorage } from 'aws-amplify/adapter-core'; diff --git a/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInSignUpRequestForPagesRouter.test.ts b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInSignUpRequestForPagesRouter.test.ts index e295b674ab6..b5c65661894 100644 --- a/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInSignUpRequestForPagesRouter.test.ts +++ b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInSignUpRequestForPagesRouter.test.ts @@ -1,6 +1,3 @@ -/** - * @jest-environment node - */ import { OAuthConfig } from 'aws-amplify/adapter-core/internals'; import { CookieStorage } from 'aws-amplify/adapter-core'; import { NextApiRequest } from 'next'; diff --git a/packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutCallbackRequest.test.ts b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutCallbackRequest.test.ts index 24e68fc7c4a..b805d6bef47 100644 --- a/packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutCallbackRequest.test.ts +++ b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutCallbackRequest.test.ts @@ -1,6 +1,3 @@ -/** - * @jest-environment node - */ import { OAuthConfig } from 'aws-amplify/adapter-core/internals'; import { AUTH_KEY_PREFIX, diff --git a/packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutCallbackRequestForPagesRouter.test.ts b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutCallbackRequestForPagesRouter.test.ts index 4df4ee1ebbe..f86feccff28 100644 --- a/packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutCallbackRequestForPagesRouter.test.ts +++ b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutCallbackRequestForPagesRouter.test.ts @@ -1,6 +1,3 @@ -/** - * @jest-environment node - */ import { OAuthConfig } from 'aws-amplify/adapter-core/internals'; import { AUTH_KEY_PREFIX, diff --git a/packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutRequest.test.ts b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutRequest.test.ts index 5285d577a85..5cb942a8bb7 100644 --- a/packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutRequest.test.ts +++ b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutRequest.test.ts @@ -1,6 +1,3 @@ -/** - * @jest-environment node - */ import { OAuthConfig } from 'aws-amplify/adapter-core/internals'; import { handleSignOutRequest } from '../../../src/auth/handlers/handleSignOutRequest'; diff --git a/packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutRequestForPagesRouter.test.ts b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutRequestForPagesRouter.test.ts index a8489f0d4b0..67b3cfe6d21 100644 --- a/packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutRequestForPagesRouter.test.ts +++ b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutRequestForPagesRouter.test.ts @@ -1,6 +1,3 @@ -/** - * @jest-environment node - */ import { OAuthConfig } from 'aws-amplify/adapter-core/internals'; import { handleSignOutRequestForPagesRouter } from '../../../src/auth/handlers/handleSignOutRequestForPagesRouter'; diff --git a/packages/adapter-nextjs/__tests__/auth/utils/hasActiveUserSession.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/hasActiveUserSession.test.ts index c4c73140178..2006db665b2 100644 --- a/packages/adapter-nextjs/__tests__/auth/utils/hasActiveUserSession.test.ts +++ b/packages/adapter-nextjs/__tests__/auth/utils/hasActiveUserSession.test.ts @@ -1,6 +1,3 @@ -/** - * @jest-environment node - */ import { getCurrentUser } from 'aws-amplify/auth/server'; import { NextRequest } from 'next/server'; import { AuthUser } from 'aws-amplify/auth'; diff --git a/packages/adapter-nextjs/__tests__/auth/utils/origin.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/origin.test.ts index 0aeee50ef29..ca01a208ae2 100644 --- a/packages/adapter-nextjs/__tests__/auth/utils/origin.test.ts +++ b/packages/adapter-nextjs/__tests__/auth/utils/origin.test.ts @@ -42,6 +42,9 @@ describe('isValidOrigin', () => { ['https://exam_ple.com', false], ['https://example.com?query=param', false], ['https://example.com:80/path#fragment', false], + ['yea, I am not a origin, so?', false], + [undefined, false], + ['', false], ] as [string, boolean][])('validates origin %s as %s', (origin, expected) => { expect(isValidOrigin(origin)).toBe(expected); }); @@ -53,6 +56,9 @@ describe('isSSLOrigin', () => { ['http://localhost', false], ['http://localhost:3000', false], ['https:// some-app.com', false], + ['https://some-app.com:', false], + [undefined, false], + ['', false], ])('check origin SSL %s status as %s', (origin, expected) => { expect(isSSLOrigin(origin)).toBe(expected); }); diff --git a/packages/adapter-nextjs/__tests__/auth/utils/predicates.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/predicates.test.ts index e991fafabd4..c166579712e 100644 --- a/packages/adapter-nextjs/__tests__/auth/utils/predicates.test.ts +++ b/packages/adapter-nextjs/__tests__/auth/utils/predicates.test.ts @@ -1,6 +1,3 @@ -/** - * @jest-environment node - */ import { NextRequest } from 'next/server.js'; import { diff --git a/packages/adapter-nextjs/__tests__/auth/utils/tokenCookies.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/tokenCookies.test.ts index 99f01e9d1b1..57602534a40 100644 --- a/packages/adapter-nextjs/__tests__/auth/utils/tokenCookies.test.ts +++ b/packages/adapter-nextjs/__tests__/auth/utils/tokenCookies.test.ts @@ -11,6 +11,7 @@ import { createTokenCookiesSetOptions, createTokenRemoveCookies, getAccessTokenUsername, + isServerSideAuthAllowedCookie, } from '../../../src/auth/utils'; jest.mock('../../../src/auth/utils/getAccessTokenUsername'); @@ -149,3 +150,20 @@ describe('createTokenCookiesRemoveOptions', () => { }); }); }); + +describe('isServerSideAuthAllowedCookie', () => { + test.each([ + ['CognitoIdentityServiceProvider.1234.aaaa.clockDrift', false], + ['CognitoIdentityServiceProvider.1234.aaaa.deviceKey', false], + ['CognitoIdentityServiceProvider.1234.aaaa.clientMetadata', false], + ['CognitoIdentityServiceProvider.1234.aaaa.oAuthMetadata', false], + ['CognitoIdentityServiceProvider.1234.aaaa', false], + ['CognitoIdentityServiceProvider.1234', false], + ['CognitoIdentityServiceProvider.1234.aaaa.refreshToken', true], + ['CognitoIdentityServiceProvider.1234.aaaa.accessToken', true], + ['CognitoIdentityServiceProvider.1234.aaaa.idToken', true], + ['CognitoIdentityServiceProvider.1234.aaaa.LastAuthUser', true], + ])('returns %s for %s', (cookieName, expected) => { + expect(isServerSideAuthAllowedCookie(cookieName)).toBe(expected); + }); +}); diff --git a/packages/adapter-nextjs/__tests__/createServerRunner.test.ts b/packages/adapter-nextjs/__tests__/createServerRunner.test.ts index c36116f2ccb..2289b51cdcb 100644 --- a/packages/adapter-nextjs/__tests__/createServerRunner.test.ts +++ b/packages/adapter-nextjs/__tests__/createServerRunner.test.ts @@ -39,6 +39,18 @@ jest.mock('../src/utils/createTokenValidator', () => ({ })), })); +const mockGetRuntimeOptions = jest.fn(() => ({})); +const mockIsServerSideAuthEnabled = jest.fn(() => false); +const mockGlobalSettingsIsSSLOrigin = jest.fn(() => false); +const mockGlobalSettings: NextServer.GlobalSettings = { + isServerSideAuthEnabled: mockIsServerSideAuthEnabled, + enableServerSideAuth: jest.fn(), + setRuntimeOptions: jest.fn(), + getRuntimeOptions: mockGetRuntimeOptions, + isSSLOrigin: mockGlobalSettingsIsSSLOrigin, + setIsSSLOrigin: jest.fn(), +}; + describe('createServerRunner', () => { let createServerRunner: NextServer.CreateServerRunner; let createRunWithAmplifyServerContextSpy: any; @@ -56,6 +68,14 @@ describe('createServerRunner', () => { const mockCreateUserPoolsTokenProvider = jest.fn(); const mockRunWithAmplifyServerContextCore = jest.fn(); const mockCreateAuthRouteHandlersFactory = jest.fn(() => jest.fn()); + const mockIsSSLOriginUtil = jest.fn(() => true); + const mockIsValidOrigin = jest.fn(origin => !!origin); + + beforeAll(() => { + jest.doMock('../src/utils/globalSettings', () => ({ + globalSettings: mockGlobalSettings, + })); + }); beforeEach(() => { process.env = modifiedProcessEnv; @@ -69,6 +89,7 @@ describe('createServerRunner', () => { createUserPoolsTokenProvider: mockCreateUserPoolsTokenProvider, runWithAmplifyServerContext: mockRunWithAmplifyServerContextCore, })); + jest.doMock('aws-amplify/utils', () => ({ ...jest.requireActual('aws-amplify/utils'), parseAmplifyConfig: mockParseAmplifyConfig, @@ -81,6 +102,11 @@ describe('createServerRunner', () => { createAuthRouteHandlersFactory: mockCreateAuthRouteHandlersFactory, })); + jest.doMock('../src/auth/utils', () => ({ + isSSLOrigin: mockIsSSLOriginUtil, + isValidOrigin: mockIsValidOrigin, + })); + ({ createServerRunner } = require('../src')); mockCreateAuthRouteHandlersFactory.mockReturnValue(jest.fn()); @@ -88,13 +114,8 @@ describe('createServerRunner', () => { afterEach(() => { process.env = originalProcessEnv; - createRunWithAmplifyServerContextSpy.mockClear(); - mockParseAmplifyConfig.mockClear(); - mockCreateAWSCredentialsAndIdentityIdProvider.mockClear(); - mockCreateKeyValueStorageFromCookieStorageAdapter.mockClear(); - mockCreateUserPoolsTokenProvider.mockClear(); - mockRunWithAmplifyServerContextCore.mockClear(); - mockCreateAuthRouteHandlersFactory.mockClear(); + + jest.clearAllMocks(); }); it('calls parseAmplifyConfig when the config object is imported from amplify configuration file', () => { @@ -114,15 +135,34 @@ describe('createServerRunner', () => { expect(mockCreateAuthRouteHandlersFactory).toHaveBeenCalledWith({ config: mockAmplifyConfig, - runtimeOptions: undefined, amplifyAppOrigin: AMPLIFY_APP_ORIGIN, runWithAmplifyServerContext: expect.any(Function), + globalSettings: mockGlobalSettings, }); expect(result).toMatchObject({ createAuthRouteHandlers: expect.any(Function), }); }); + describe('when AMPLIFY_APP_ORIGIN is not set', () => { + it('it does NOT call globalSettings.setIsSSLOrigin() and isValidOrigin()', () => { + delete process.env.AMPLIFY_APP_ORIGIN; + createServerRunner({ config: mockAmplifyConfig }); + expect(mockIsValidOrigin).toHaveBeenCalledWith(undefined); + expect(mockGlobalSettings.setIsSSLOrigin).not.toHaveBeenCalled(); + process.env.AMPLIFY_APP_ORIGIN = AMPLIFY_APP_ORIGIN; + }); + }); + + describe('when AMPLIFY_APP_ORIGIN is set with a https origin', () => { + it('it calls globalSettings.setIsSSLOrigin(), isValidOrigin() and globalSettings.enableServerSideAuth', () => { + createServerRunner({ config: mockAmplifyConfig }); + expect(mockIsValidOrigin).toHaveBeenCalledWith(AMPLIFY_APP_ORIGIN); + expect(mockGlobalSettings.setIsSSLOrigin).toHaveBeenCalledWith(true); + expect(mockGlobalSettings.enableServerSideAuth).toHaveBeenCalled(); + }); + }); + describe('runWithAmplifyServerContext', () => { describe('when amplifyConfig.Auth is not defined', () => { it('should call runWithAmplifyServerContextCore without Auth library options', () => { @@ -150,6 +190,7 @@ describe('createServerRunner', () => { expect(createRunWithAmplifyServerContextSpy).toHaveBeenCalledWith({ config: mockAmplifyConfigWithoutAuth, tokenValidator: undefined, + globalSettings: mockGlobalSettings, }); }); }); @@ -178,6 +219,7 @@ describe('createServerRunner', () => { tokenValidator: expect.objectContaining({ getItem: expect.any(Function), }), + globalSettings: mockGlobalSettings, }); }); }); @@ -228,6 +270,7 @@ describe('createServerRunner', () => { tokenValidator: expect.objectContaining({ getItem: expect.any(Function), }), + globalSettings: mockGlobalSettings, }); }); @@ -238,6 +281,9 @@ describe('createServerRunner', () => { sameSite: 'lax', expires: new Date('2024-09-05'), }; + mockGetRuntimeOptions.mockReturnValueOnce({ + cookies: testCookiesOptions, + }); mockCreateKeyValueStorageFromCookieStorageAdapter.mockReturnValueOnce( mockCookieStorageAdapter, ); @@ -257,15 +303,63 @@ describe('createServerRunner', () => { expect( mockCreateKeyValueStorageFromCookieStorageAdapter, - ).toHaveBeenCalledWith( - expect.any(Object), - expect.any(Object), - testCookiesOptions, + ).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), { + ...testCookiesOptions, + path: '/', + }); + }); + + it('should call createKeyValueStorageFromCookieStorageAdapter with enforced and default server auth cookie attributes', async () => { + mockIsServerSideAuthEnabled.mockReturnValueOnce(true); + mockGlobalSettingsIsSSLOrigin.mockReturnValueOnce(true); + mockCreateKeyValueStorageFromCookieStorageAdapter.mockReturnValueOnce( + mockCookieStorageAdapter, ); - // modify by reference should not affect the original configuration - testCookiesOptions.sameSite = 'strict'; - runWithAmplifyServerContext({ + const { runWithAmplifyServerContext } = createServerRunner({ + config: mockAmplifyConfig, + }); + + await runWithAmplifyServerContext({ + nextServerContext: + mockNextServerContext as unknown as NextServer.Context, + operation: jest.fn(), + }); + + expect( + mockCreateKeyValueStorageFromCookieStorageAdapter, + ).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), { + httpOnly: true, + path: '/', + sameSite: 'strict', + secure: true, + }); + }); + + it('should call createKeyValueStorageFromCookieStorageAdapter with specified runtimeOptions.cookies with enforced server auth cookie attributes', async () => { + const testCookiesOptions: NextServer.CreateServerRunnerRuntimeOptions['cookies'] = + { + domain: '.example.com', + sameSite: 'lax', + expires: new Date('2024-09-05'), + }; + mockGetRuntimeOptions.mockReturnValueOnce({ + cookies: testCookiesOptions, + }); + mockIsServerSideAuthEnabled.mockReturnValueOnce(true); + mockGlobalSettingsIsSSLOrigin.mockReturnValueOnce(true); + mockCreateKeyValueStorageFromCookieStorageAdapter.mockReturnValueOnce( + mockCookieStorageAdapter, + ); + + const { runWithAmplifyServerContext } = createServerRunner({ + config: mockAmplifyConfig, + runtimeOptions: { + cookies: testCookiesOptions, + }, + }); + + await runWithAmplifyServerContext({ nextServerContext: mockNextServerContext as unknown as NextServer.Context, operation: jest.fn(), @@ -275,7 +369,9 @@ describe('createServerRunner', () => { mockCreateKeyValueStorageFromCookieStorageAdapter, ).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), { ...testCookiesOptions, - sameSite: 'lax', + path: '/', + httpOnly: true, + secure: true, }); }); }); diff --git a/packages/adapter-nextjs/__tests__/utils/createCookieStorageAdapterFromNextServerContext.test.ts b/packages/adapter-nextjs/__tests__/utils/createCookieStorageAdapterFromNextServerContext.test.ts index c3243de92f4..f98d8e10880 100644 --- a/packages/adapter-nextjs/__tests__/utils/createCookieStorageAdapterFromNextServerContext.test.ts +++ b/packages/adapter-nextjs/__tests__/utils/createCookieStorageAdapterFromNextServerContext.test.ts @@ -13,6 +13,7 @@ import { DATE_IN_THE_PAST, createCookieStorageAdapterFromNextServerContext, } from '../../src/utils/createCookieStorageAdapterFromNextServerContext'; +import { isServerSideAuthAllowedCookie } from '../../src/auth/utils'; // Make global Request available during test enableFetchMocks(); @@ -20,8 +21,12 @@ enableFetchMocks(); jest.mock('next/headers', () => ({ cookies: jest.fn(), })); +jest.mock('../../src/auth/utils'); const mockNextCookiesFunc = cookies as jest.Mock; +const mockIsServerSideAuthAllowedCookie = jest.mocked( + isServerSideAuthAllowedCookie, +); describe('createCookieStorageAdapterFromNextServerContext', () => { const mockGetFunc = jest.fn(); @@ -40,8 +45,8 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { const mockKeyWithEncoding = 'test%40email.com'; const mockValue = 'fabCookie'; - beforeEach(() => { - jest.resetAllMocks(); + afterEach(() => { + jest.clearAllMocks(); }); describe('cookieStorageAdapter created from NextRequest and NextResponse', () => { @@ -54,6 +59,7 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { let result: CookieStorage.Adapter; beforeAll(async () => { + mockIsServerSideAuthAllowedCookie.mockReturnValue(true); jest.spyOn(request, 'cookies', 'get').mockImplementation( () => ({ @@ -120,6 +126,30 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { encodeURIComponent(mockKeyWithEncoding), ); }); + + test('set() and delete() methods do NOT take effects when ignoreNonServerSideCookies is passed as true and the cookie is not one of the server-side auth cookie', async () => { + mockIsServerSideAuthAllowedCookie.mockReturnValueOnce(false); + const testCookieName = + 'CognitoIdentityServiceProvider.4epnu2hld0q0ig2dtd426bv7ab.123.clockDrift'; + const adapterWithIgnore = + await createCookieStorageAdapterFromNextServerContext( + mockContext, + true, + ); + + adapterWithIgnore.set(testCookieName, 'value'); + expect(mockSetFunc).not.toHaveBeenCalled(); + expect(mockIsServerSideAuthAllowedCookie).toHaveBeenCalledWith( + testCookieName, + ); + + mockIsServerSideAuthAllowedCookie.mockReturnValueOnce(false); + adapterWithIgnore.delete(testCookieName); + expect(mockDeleteFunc).not.toHaveBeenCalled(); + expect(mockIsServerSideAuthAllowedCookie).toHaveBeenCalledWith( + testCookieName, + ); + }); }); describe('cookieStorageAdapter created from NextRequest and Response', () => { @@ -130,7 +160,7 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { response, } as any; - let result: CookieStorage.Adapter; + let adapter: CookieStorage.Adapter; beforeAll(async () => { jest.spyOn(request, 'cookies', 'get').mockImplementation( @@ -147,7 +177,7 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { }) as any, ); - result = + adapter = await createCookieStorageAdapterFromNextServerContext(mockContext); }); @@ -162,24 +192,24 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { }; it('gets cookie by calling `get` method of the underlying cookie store', () => { - result.get(mockKey); + adapter.get(mockKey); expect(mockGetFunc).toHaveBeenCalledWith(mockKey); }); it('gets cookie by calling `get` method of the underlying cookie store with a encoded cookie name', () => { - result.get(mockKeyWithEncoding); + adapter.get(mockKeyWithEncoding); expect(mockGetFunc).toHaveBeenCalledWith( encodeURIComponent(mockKeyWithEncoding), ); }); it('gets all cookies by calling `getAll` method of the underlying cookie store', () => { - result.getAll(); + adapter.getAll(); expect(mockGetAllFunc).toHaveBeenCalled(); }); it('sets cookie by calling the `set` method of the underlying cookie store with options', () => { - result.set(mockKey, mockValue, mockSerializeOptions); + adapter.set(mockKey, mockValue, mockSerializeOptions); expect(mockAppend).toHaveBeenCalledWith( 'Set-Cookie', `${mockKey}=${mockValue};Domain=${ @@ -191,7 +221,7 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { }); it('sets cookie by calling the `set` method of the underlying cookie store with options and a encoded cookie name', () => { - result.set(mockKeyWithEncoding, mockValue, mockSerializeOptions); + adapter.set(mockKeyWithEncoding, mockValue, mockSerializeOptions); expect(mockAppend).toHaveBeenCalledWith( 'Set-Cookie', `${encodeURIComponent(mockKeyWithEncoding)}=${mockValue};Domain=${ @@ -203,7 +233,7 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { }); it('sets cookie by calling the `set` method of the underlying cookie store without options', () => { - result.set(mockKey, mockValue, undefined); + adapter.set(mockKey, mockValue, undefined); expect(mockAppend).toHaveBeenCalledWith( 'Set-Cookie', `${mockKey}=${mockValue};`, @@ -211,7 +241,7 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { }); it('sets cookie by calling the `set` method of the underlying cookie store with options that do not need to be serialized', () => { - result.set(mockKey, mockValue, { + adapter.set(mockKey, mockValue, { httpOnly: false, sameSite: false, secure: false, @@ -223,7 +253,7 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { }); it('deletes cookie by calling the `delete` method of the underlying cookie store', () => { - result.delete(mockKey); + adapter.delete(mockKey); expect(mockAppend).toHaveBeenCalledWith( 'Set-Cookie', `${mockKey}=;Expires=${DATE_IN_THE_PAST.toUTCString()}`, @@ -231,7 +261,7 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { }); it('deletes cookie by calling the `delete` method of the underlying cookie store with a encoded cookie name', () => { - result.delete(mockKeyWithEncoding); + adapter.delete(mockKeyWithEncoding); expect(mockAppend).toHaveBeenCalledWith( 'Set-Cookie', `${encodeURIComponent( @@ -239,13 +269,37 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { )}=;Expires=${DATE_IN_THE_PAST.toUTCString()}`, ); }); + + test('set() and delete() methods do NOT take effects when ignoreNonServerSideCookies is passed as true and the cookie is not one of the server-side auth cookie', async () => { + mockIsServerSideAuthAllowedCookie.mockReturnValueOnce(false); + const testCookieName = + 'CognitoIdentityServiceProvider.4epnu2hld0q0ig2dtd426bv7ab.123.clockDrift'; + const adapterWithIgnore = + await createCookieStorageAdapterFromNextServerContext( + mockContext, + true, + ); + + adapterWithIgnore.set(testCookieName, 'value'); + expect(mockAppend).not.toHaveBeenCalled(); + expect(mockIsServerSideAuthAllowedCookie).toHaveBeenCalledWith( + testCookieName, + ); + + mockIsServerSideAuthAllowedCookie.mockReturnValueOnce(false); + adapterWithIgnore.delete(testCookieName); + expect(mockAppend).not.toHaveBeenCalled(); + expect(mockIsServerSideAuthAllowedCookie).toHaveBeenCalledWith( + testCookieName, + ); + }); }); describe('cookieStorageAdapter created from Next cookies function', () => { let result: CookieStorage.Adapter; beforeAll(async () => { - mockNextCookiesFunc.mockReturnValueOnce(mockNextCookiesFuncReturn); + mockNextCookiesFunc.mockReturnValue(mockNextCookiesFuncReturn); result = await createCookieStorageAdapterFromNextServerContext({ cookies, @@ -298,6 +352,30 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { encodeURIComponent(mockKeyWithEncoding), ); }); + + test('set() and delete() methods do NOT take effects when ignoreNonServerSideCookies is passed as true and the cookie is not one of the server-side auth cookie', async () => { + mockIsServerSideAuthAllowedCookie.mockReturnValueOnce(false); + const testCookieName = + 'CognitoIdentityServiceProvider.4epnu2hld0q0ig2dtd426bv7ab.123.clockDrift'; + const adapterWithIgnore = + await createCookieStorageAdapterFromNextServerContext( + { cookies }, + true, + ); + + adapterWithIgnore.set(testCookieName, 'value'); + expect(mockNextCookiesFuncReturn.set).not.toHaveBeenCalled(); + expect(mockIsServerSideAuthAllowedCookie).toHaveBeenCalledWith( + testCookieName, + ); + + mockIsServerSideAuthAllowedCookie.mockReturnValueOnce(false); + adapterWithIgnore.delete(testCookieName); + expect(mockNextCookiesFuncReturn.delete).not.toHaveBeenCalled(); + expect(mockIsServerSideAuthAllowedCookie).toHaveBeenCalledWith( + testCookieName, + ); + }); }); describe('cookieStorageAdapter created from IncomingMessage and ServerResponse as the Pages Router context', () => { @@ -462,6 +540,40 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { result.delete('CognitoIdentityServiceProvider.1234.identityId'); expect(appendHeaderSpy).not.toHaveBeenCalled(); }); + + test('set() and delete() methods do NOT take effects when ignoreNonServerSideCookies is passed as true and the cookie is not one of the server-side auth cookie', async () => { + const testCookieName = + 'CognitoIdentityServiceProvider.4epnu2hld0q0ig2dtd426bv7ab.123.clockDrift'; + + const request = new IncomingMessage(new Socket()); + const response = new ServerResponse(request); + const appendHeaderSpy = jest.spyOn(response, 'appendHeader'); + + Object.defineProperty(request, 'cookies', { + get() { + return { + [testCookieName]: 'value', + }; + }, + }); + + const adapterWithIgnore = + await createCookieStorageAdapterFromNextServerContext( + { + request: request as any, + response, + }, + true, + ); + + mockIsServerSideAuthAllowedCookie.mockReturnValueOnce(false); + adapterWithIgnore.set(testCookieName, 'value'); + expect(appendHeaderSpy).not.toHaveBeenCalled(); + + mockIsServerSideAuthAllowedCookie.mockReturnValueOnce(false); + adapterWithIgnore.delete(testCookieName); + expect(appendHeaderSpy).not.toHaveBeenCalled(); + }); }); it('should throw error when no cookie storage adapter is created from the context', () => { diff --git a/packages/adapter-nextjs/__tests__/utils/globalSettings.test.ts b/packages/adapter-nextjs/__tests__/utils/globalSettings.test.ts new file mode 100644 index 00000000000..962c18094fe --- /dev/null +++ b/packages/adapter-nextjs/__tests__/utils/globalSettings.test.ts @@ -0,0 +1,48 @@ +import { globalSettings } from '../../src/utils/globalSettings'; + +describe('globalSettings', () => { + describe('with default globalSettings', () => { + test('isServerSideAuthEnabled should return false', () => { + expect(globalSettings.isServerSideAuthEnabled()).toBe(false); + }); + + test('isSSLOrigin should return false', () => { + expect(globalSettings.isSSLOrigin()).toBe(false); + }); + + test('getRuntimeOptions should return empty object', () => { + expect(globalSettings.getRuntimeOptions()).toEqual({}); + }); + }); + + test('enableServerSideAuth should set isServerSideAuthEnabled to true', () => { + globalSettings.enableServerSideAuth(); + expect(globalSettings.isServerSideAuthEnabled()).toBe(true); + }); + + test('setIsSSLOrigin should set isSSLOrigin to true', () => { + globalSettings.setIsSSLOrigin(true); + expect(globalSettings.isSSLOrigin()).toBe(true); + }); + + test('setRuntimeOptions should set runtimeOptions', () => { + const runtimeOptions = { cookies: { domain: 'example.com' } }; + globalSettings.setRuntimeOptions(runtimeOptions); + + expect(globalSettings.getRuntimeOptions()).toEqual(runtimeOptions); + }); + + test('setRuntimeOptions should set runtimeOptions by copying the object rather than set the object reference', () => { + const runtimeOptions = { cookies: { domain: 'example.com' } }; + globalSettings.setRuntimeOptions(runtimeOptions); + + // change a property of runtimeOptions.cookies + runtimeOptions.cookies.domain = 'example2.com'; + + // originally set runtimeOptions should not be changed + expect(globalSettings.getRuntimeOptions()).not.toEqual(runtimeOptions); + expect(globalSettings.getRuntimeOptions()).toEqual({ + cookies: { domain: 'example.com' }, + }); + }); +}); diff --git a/packages/adapter-nextjs/jest.config.js b/packages/adapter-nextjs/jest.config.js index c6406b36105..8ef7dbcc243 100644 --- a/packages/adapter-nextjs/jest.config.js +++ b/packages/adapter-nextjs/jest.config.js @@ -1,5 +1,6 @@ module.exports = { ...require('../../jest.config'), + testEnvironment: 'node', coverageThreshold: { global: { branches: 88, diff --git a/packages/adapter-nextjs/src/api/createServerRunnerForAPI.ts b/packages/adapter-nextjs/src/api/createServerRunnerForAPI.ts index 62b4c8eb40f..500c2279649 100644 --- a/packages/adapter-nextjs/src/api/createServerRunnerForAPI.ts +++ b/packages/adapter-nextjs/src/api/createServerRunnerForAPI.ts @@ -4,7 +4,7 @@ import { ResourcesConfig } from 'aws-amplify'; import { parseAmplifyConfig } from 'aws-amplify/utils'; -import { createRunWithAmplifyServerContext } from '../utils'; +import { createRunWithAmplifyServerContext, globalSettings } from '../utils'; import { NextServer } from '../types'; export const createServerRunnerForAPI = ({ @@ -20,6 +20,7 @@ export const createServerRunnerForAPI = ({ return { runWithAmplifyServerContext: createRunWithAmplifyServerContext({ config: amplifyConfig, + globalSettings, }), resourcesConfig: amplifyConfig, }; diff --git a/packages/adapter-nextjs/src/auth/constant.ts b/packages/adapter-nextjs/src/auth/constant.ts index 9fadab6c81f..296e7d1b5f8 100644 --- a/packages/adapter-nextjs/src/auth/constant.ts +++ b/packages/adapter-nextjs/src/auth/constant.ts @@ -36,3 +36,16 @@ export const OAUTH_GRANT_TYPE = 'authorization_code'; export const SIGN_IN_TIMEOUT_ERROR_CODE = 'timeout'; export const SIGN_IN_TIMEOUT_ERROR_MESSAGE = 'Sign in has to be completed within 5 minutes.'; +export const DEFAULT_SERVER_SIDE_AUTH_SET_COOKIE_OPTIONS = { + sameSite: 'strict' as const, +}; +export const ENFORCED_SERVER_SIDE_AUTH_SET_COOKIE_OPTIONS = { + httpOnly: true, +}; + +export const SERVER_AUTH_ALLOWED_AMPLIFY_AUTH_KEY_SUFFIX = [ + '.accessToken', + '.idToken', + '.refreshToken', + '.LastAuthUser', +]; diff --git a/packages/adapter-nextjs/src/auth/createAuthRouteHandlersFactory.ts b/packages/adapter-nextjs/src/auth/createAuthRouteHandlersFactory.ts index 01cba409954..46e95cbdc83 100644 --- a/packages/adapter-nextjs/src/auth/createAuthRouteHandlersFactory.ts +++ b/packages/adapter-nextjs/src/auth/createAuthRouteHandlersFactory.ts @@ -29,9 +29,9 @@ import { handleAuthApiRouteRequestForPagesRouter } from './handleAuthApiRouteReq export const createAuthRouteHandlersFactory = ({ config: resourcesConfig, - runtimeOptions = {}, amplifyAppOrigin, runWithAmplifyServerContext, + globalSettings, }: CreateAuthRouteHandlersFactoryInput): InternalCreateAuthRouteHandlers => { const handleRequest = async ({ request, @@ -120,7 +120,7 @@ export const createAuthRouteHandlersFactory = ({ const { userPoolClientId } = resourcesConfig.Auth.Cognito; const { oauth: oAuthConfig } = resourcesConfig.Auth.Cognito.loginWith; - const { cookies: setCookieOptions = {} } = runtimeOptions; + const setCookieOptions = globalSettings.getRuntimeOptions().cookies ?? {}; // The call-site of this returned function is the Next.js API route file return (request, contextOrResponse) => diff --git a/packages/adapter-nextjs/src/auth/types.ts b/packages/adapter-nextjs/src/auth/types.ts index 52b1cd715f0..a6e4e685e46 100644 --- a/packages/adapter-nextjs/src/auth/types.ts +++ b/packages/adapter-nextjs/src/auth/types.ts @@ -83,8 +83,8 @@ export type CreateAuthRouteHandlers = ( export interface CreateAuthRouteHandlersFactoryInput { config: ResourcesConfig; - runtimeOptions: NextServer.CreateServerRunnerRuntimeOptions | undefined; amplifyAppOrigin?: string; + globalSettings: NextServer.GlobalSettings; runWithAmplifyServerContext: NextServer.RunOperationWithContext; } diff --git a/packages/adapter-nextjs/src/auth/utils/index.ts b/packages/adapter-nextjs/src/auth/utils/index.ts index 572a18d5184..55c64d0563c 100644 --- a/packages/adapter-nextjs/src/auth/utils/index.ts +++ b/packages/adapter-nextjs/src/auth/utils/index.ts @@ -49,4 +49,5 @@ export { createTokenRemoveCookies, createTokenCookiesSetOptions, createTokenCookiesRemoveOptions, + isServerSideAuthAllowedCookie, } from './tokenCookies'; diff --git a/packages/adapter-nextjs/src/auth/utils/origin.ts b/packages/adapter-nextjs/src/auth/utils/origin.ts index 39813ba4388..7a9f0646927 100644 --- a/packages/adapter-nextjs/src/auth/utils/origin.ts +++ b/packages/adapter-nextjs/src/auth/utils/origin.ts @@ -5,29 +5,48 @@ const originRegex = /^(http:\/\/localhost(:\d{1,5})?)|(https?:\/\/[a-z0-9-]+(\.[a-z0-9-]+)*(:\d{1,5})?)$/; -export const isValidOrigin = (origin: string): boolean => { - try { - const url = new URL(origin); - - if (url.protocol === 'http:' && url.hostname !== 'localhost') { - console.warn( - 'HTTP origin detected. This is insecure and should only be used for local development.', - ); - } - - return ( - (url.protocol === 'http:' || url.protocol === 'https:') && - originRegex.test(origin) +export const isValidOrigin = (origin: string | undefined): boolean => { + const url = createUrlObjectOrUndefined(origin); + + if (!url) { + return false; + } + + if ( + url.protocol === 'http:' && + url.hostname !== 'localhost' && + url.hostname !== '127.0.0.1' + ) { + console.warn( + 'HTTP origin detected. This is insecure and should only be used for local development.', ); - } catch { + } + + return url.protocol === 'http:' || url.protocol === 'https:'; +}; + +export const isSSLOrigin = (origin: string | undefined): boolean => { + const url = createUrlObjectOrUndefined(origin); + + if (!url) { return false; } + + return url.protocol === 'https:'; }; -export const isSSLOrigin = (origin: string): boolean => { - if (isValidOrigin(origin)) { - return origin.startsWith('https://'); +const createUrlObjectOrUndefined = ( + url: string | undefined, +): URL | undefined => { + if (!url) { + return undefined; + } + + // we don't allow format such as `https://localhost:` (without the port number) which is valid in URL constructor + if (!originRegex.test(url)) { + return undefined; } - return false; + // the `originRegex` ensured a string that can be parsed by URL constructor + return new URL(url); }; diff --git a/packages/adapter-nextjs/src/auth/utils/tokenCookies.ts b/packages/adapter-nextjs/src/auth/utils/tokenCookies.ts index dbe07c20805..70ab5ee2981 100644 --- a/packages/adapter-nextjs/src/auth/utils/tokenCookies.ts +++ b/packages/adapter-nextjs/src/auth/utils/tokenCookies.ts @@ -9,7 +9,10 @@ import { } from 'aws-amplify/adapter-core'; import { OAuthTokenResponsePayload } from '../types'; -import { REMOVE_COOKIE_MAX_AGE } from '../constant'; +import { + REMOVE_COOKIE_MAX_AGE, + SERVER_AUTH_ALLOWED_AMPLIFY_AUTH_KEY_SUFFIX, +} from '../constant'; import { getAccessTokenUsername } from './getAccessTokenUsername'; @@ -79,3 +82,8 @@ export const createTokenCookiesRemoveOptions = ( path: '/', maxAge: REMOVE_COOKIE_MAX_AGE, // Expire immediately (remove the cookie) }); + +export const isServerSideAuthAllowedCookie = (cookieName: string) => + SERVER_AUTH_ALLOWED_AMPLIFY_AUTH_KEY_SUFFIX.some(suffix => + cookieName.endsWith(suffix), + ); diff --git a/packages/adapter-nextjs/src/createServerRunner.ts b/packages/adapter-nextjs/src/createServerRunner.ts index 67248affde6..2e604f18580 100644 --- a/packages/adapter-nextjs/src/createServerRunner.ts +++ b/packages/adapter-nextjs/src/createServerRunner.ts @@ -5,10 +5,11 @@ import { ResourcesConfig } from 'aws-amplify'; import { KeyValueStorageMethodValidator } from 'aws-amplify/adapter-core/internals'; import { parseAmplifyConfig } from 'aws-amplify/utils'; -import { createRunWithAmplifyServerContext } from './utils'; +import { createRunWithAmplifyServerContext, globalSettings } from './utils'; import { NextServer } from './types'; import { createTokenValidator } from './utils/createTokenValidator'; import { createAuthRouteHandlersFactory } from './auth'; +import { isSSLOrigin, isValidOrigin } from './auth/utils'; /** * Creates the `runWithAmplifyServerContext` function to run Amplify server side APIs in an isolated request context. @@ -35,6 +36,15 @@ export const createServerRunner: NextServer.CreateServerRunner = ({ const amplifyConfig = parseAmplifyConfig(config); const amplifyAppOrigin = process.env.AMPLIFY_APP_ORIGIN; + globalSettings.setRuntimeOptions(runtimeOptions ?? {}); + + if (isValidOrigin(amplifyAppOrigin)) { + globalSettings.setIsSSLOrigin(isSSLOrigin(amplifyAppOrigin)); + + // update the isServerSideAuthEnabled flag of the globalSettings to true + globalSettings.enableServerSideAuth(); + } + let tokenValidator: KeyValueStorageMethodValidator | undefined; if (amplifyConfig?.Auth) { const { Cognito } = amplifyConfig.Auth; @@ -47,15 +57,15 @@ export const createServerRunner: NextServer.CreateServerRunner = ({ const runWithAmplifyServerContext = createRunWithAmplifyServerContext({ config: amplifyConfig, tokenValidator, - runtimeOptions, + globalSettings, }); return { runWithAmplifyServerContext, createAuthRouteHandlers: createAuthRouteHandlersFactory({ config: amplifyConfig, - runtimeOptions, amplifyAppOrigin, + globalSettings, runWithAmplifyServerContext, }), }; diff --git a/packages/adapter-nextjs/src/types/NextServer.ts b/packages/adapter-nextjs/src/types/NextServer.ts index 2373d0b9ac2..3bdfb594fb5 100644 --- a/packages/adapter-nextjs/src/types/NextServer.ts +++ b/packages/adapter-nextjs/src/types/NextServer.ts @@ -98,4 +98,13 @@ export declare namespace NextServer { export type CreateServerRunner = ( input: CreateServerRunnerInput, ) => CreateServerRunnerOutput; + + export interface GlobalSettings { + isServerSideAuthEnabled(): boolean; + enableServerSideAuth(): void; + setRuntimeOptions(runtimeOptions: CreateServerRunnerRuntimeOptions): void; + getRuntimeOptions(): CreateServerRunnerRuntimeOptions; + setIsSSLOrigin(isSSLOrigin: boolean): void; + isSSLOrigin(): boolean; + } } diff --git a/packages/adapter-nextjs/src/utils/createCookieStorageAdapterFromNextServerContext.ts b/packages/adapter-nextjs/src/utils/createCookieStorageAdapterFromNextServerContext.ts index 636066f235e..05410361b19 100644 --- a/packages/adapter-nextjs/src/utils/createCookieStorageAdapterFromNextServerContext.ts +++ b/packages/adapter-nextjs/src/utils/createCookieStorageAdapterFromNextServerContext.ts @@ -8,6 +8,7 @@ import { } from 'aws-amplify/adapter-core/internals'; import { NextServer } from '../types'; +import { isServerSideAuthAllowedCookie } from '../auth/utils'; import { ensureEncodedForJSCookie, serializeCookie } from './cookie'; @@ -15,6 +16,7 @@ export const DATE_IN_THE_PAST = new Date(0); export const createCookieStorageAdapterFromNextServerContext = async ( context: NextServer.Context, + ignoreNonServerSideCookies = false, ): Promise => { const { request: req, response: res } = context as Partial; @@ -30,7 +32,11 @@ export const createCookieStorageAdapterFromNextServerContext = async ( Object.prototype.toString.call(req.cookies) === '[object Object]' && typeof res.setHeader === 'function' ) { - return createCookieStorageAdapterFromGetServerSidePropsContext(req, res); + return createCookieStorageAdapterFromGetServerSidePropsContext( + req, + res, + ignoreNonServerSideCookies, + ); } const { request, response } = context as Partial< @@ -49,11 +55,13 @@ export const createCookieStorageAdapterFromNextServerContext = async ( return createCookieStorageAdapterFromNextRequestAndNextResponse( request, response, + ignoreNonServerSideCookies, ); } else { return createCookieStorageAdapterFromNextRequestAndHttpResponse( request, response, + ignoreNonServerSideCookies, ); } } @@ -63,7 +71,10 @@ export const createCookieStorageAdapterFromNextServerContext = async ( >; if (typeof cookies === 'function') { - return createCookieStorageAdapterFromNextCookies(cookies); + return createCookieStorageAdapterFromNextCookies( + cookies, + ignoreNonServerSideCookies, + ); } // This should not happen normally. @@ -76,6 +87,7 @@ export const createCookieStorageAdapterFromNextServerContext = async ( const createCookieStorageAdapterFromNextRequestAndNextResponse = ( request: NextRequest, response: NextResponse, + ignoreNonServerSideCookies: boolean, ): CookieStorage.Adapter => { const readonlyCookieStore = request.cookies; const mutableCookieStore = response.cookies; @@ -86,9 +98,15 @@ const createCookieStorageAdapterFromNextRequestAndNextResponse = ( }, getAll: readonlyCookieStore.getAll.bind(readonlyCookieStore), set(name, value, options) { + if (shouldIgnoreCookie(ignoreNonServerSideCookies, name)) { + return; + } mutableCookieStore.set(ensureEncodedForJSCookie(name), value, options); }, delete(name) { + if (shouldIgnoreCookie(ignoreNonServerSideCookies, name)) { + return; + } mutableCookieStore.delete(ensureEncodedForJSCookie(name)); }, }; @@ -97,10 +115,12 @@ const createCookieStorageAdapterFromNextRequestAndNextResponse = ( const createCookieStorageAdapterFromNextRequestAndHttpResponse = ( request: NextRequest, response: Response, + ignoreNonServerSideCookies: boolean, ): CookieStorage.Adapter => { const readonlyCookieStore = request.cookies; const mutableCookieStore = createMutableCookieStoreFromHeaders( response.headers, + ignoreNonServerSideCookies, ); return { @@ -114,6 +134,7 @@ const createCookieStorageAdapterFromNextRequestAndHttpResponse = ( const createCookieStorageAdapterFromNextCookies = async ( cookies: NextServer.ServerComponentContext['cookies'], + ignoreNonServerSideCookies: boolean, ): Promise => { const cookieStore = await cookies(); @@ -123,6 +144,10 @@ const createCookieStorageAdapterFromNextCookies = async ( // We have no way to detect which one is returned, so we try to call set and delete // and safely ignore the error if it is thrown. const setFunc: CookieStorage.Adapter['set'] = (name, value, options) => { + if (shouldIgnoreCookie(ignoreNonServerSideCookies, name)) { + return; + } + try { cookieStore.set(ensureEncodedForJSCookie(name), value, options); } catch { @@ -131,6 +156,10 @@ const createCookieStorageAdapterFromNextCookies = async ( }; const deleteFunc: CookieStorage.Adapter['delete'] = name => { + if (shouldIgnoreCookie(ignoreNonServerSideCookies, name)) { + return; + } + try { cookieStore.delete(ensureEncodedForJSCookie(name)); } catch { @@ -151,6 +180,7 @@ const createCookieStorageAdapterFromNextCookies = async ( const createCookieStorageAdapterFromGetServerSidePropsContext = ( request: NextServer.GetServerSidePropsContext['request'], response: NextServer.GetServerSidePropsContext['response'], + ignoreNonServerSideCookies: boolean, ): CookieStorage.Adapter => { const cookiesMap = { ...request.cookies }; const allCookies = Object.entries(cookiesMap).map(([name, value]) => ({ @@ -173,6 +203,9 @@ const createCookieStorageAdapterFromGetServerSidePropsContext = ( return allCookies; }, set(name, value, options) { + if (shouldIgnoreCookie(ignoreNonServerSideCookies, name)) { + return; + } const encodedName = ensureEncodedForJSCookie(name); const existingValues = getExistingSetCookieValues( @@ -196,6 +229,10 @@ const createCookieStorageAdapterFromGetServerSidePropsContext = ( ); }, delete(name) { + if (shouldIgnoreCookie(ignoreNonServerSideCookies, name)) { + return; + } + const encodedName = ensureEncodedForJSCookie(name); const setCookieValue = `${encodedName}=;Expires=${DATE_IN_THE_PAST.toUTCString()}`; const existingValues = getExistingSetCookieValues( @@ -215,14 +252,23 @@ const createCookieStorageAdapterFromGetServerSidePropsContext = ( const createMutableCookieStoreFromHeaders = ( headers: Headers, + ignoreNonServerSideCookies: boolean, ): Pick => { const setFunc: CookieStorage.Adapter['set'] = (name, value, options) => { + if (shouldIgnoreCookie(ignoreNonServerSideCookies, name)) { + return; + } + headers.append( 'Set-Cookie', serializeCookie(ensureEncodedForJSCookie(name), value, options), ); }; const deleteFunc: CookieStorage.Adapter['delete'] = name => { + if (shouldIgnoreCookie(ignoreNonServerSideCookies, name)) { + return; + } + headers.append( 'Set-Cookie', `${ensureEncodedForJSCookie( @@ -241,3 +287,9 @@ const getExistingSetCookieValues = ( values: number | string | string[] | undefined, ): string[] => values === undefined ? [] : Array.isArray(values) ? values : [String(values)]; + +const shouldIgnoreCookie = ( + ignoreNonServerSideCookies: boolean, + cookieName: string, +): boolean => + ignoreNonServerSideCookies && !isServerSideAuthAllowedCookie(cookieName); diff --git a/packages/adapter-nextjs/src/utils/createRunWithAmplifyServerContext.ts b/packages/adapter-nextjs/src/utils/createRunWithAmplifyServerContext.ts index 3c8adb4299b..e5744056619 100644 --- a/packages/adapter-nextjs/src/utils/createRunWithAmplifyServerContext.ts +++ b/packages/adapter-nextjs/src/utils/createRunWithAmplifyServerContext.ts @@ -12,21 +12,40 @@ import { } from 'aws-amplify/adapter-core'; import { NextServer } from '../types'; +import { + DEFAULT_SERVER_SIDE_AUTH_SET_COOKIE_OPTIONS, + ENFORCED_SERVER_SIDE_AUTH_SET_COOKIE_OPTIONS, +} from '../auth/constant'; import { createCookieStorageAdapterFromNextServerContext } from './createCookieStorageAdapterFromNextServerContext'; export const createRunWithAmplifyServerContext = ({ config: resourcesConfig, tokenValidator, - runtimeOptions = {}, + globalSettings, }: { config: ResourcesConfig; tokenValidator?: KeyValueStorageMethodValidator; - runtimeOptions?: NextServer.CreateServerRunnerRuntimeOptions; + globalSettings: NextServer.GlobalSettings; }) => { - const setCookieOptions = { - ...runtimeOptions.cookies, + const isServerSideAuthEnabled = globalSettings.isServerSideAuthEnabled(); + const isSSLOrigin = globalSettings.isSSLOrigin(); + const setCookieOptions = globalSettings.getRuntimeOptions().cookies ?? {}; + + const mergedSetCookieOptions = { + // default options when not specified + ...(isServerSideAuthEnabled && DEFAULT_SERVER_SIDE_AUTH_SET_COOKIE_OPTIONS), + // user-specified options + ...setCookieOptions, + // enforced options when server-side auth is enabled + ...(isServerSideAuthEnabled && { + ...ENFORCED_SERVER_SIDE_AUTH_SET_COOKIE_OPTIONS, + secure: isSSLOrigin, + }), + // only support root path + path: '/', }; + const runWithAmplifyServerContext: NextServer.RunOperationWithContext = async ({ nextServerContext, operation }) => { // When the Auth config is presented, attempt to create a Amplify server @@ -42,9 +61,10 @@ export const createRunWithAmplifyServerContext = ({ : createKeyValueStorageFromCookieStorageAdapter( await createCookieStorageAdapterFromNextServerContext( nextServerContext, + isServerSideAuthEnabled, ), tokenValidator, - setCookieOptions, + mergedSetCookieOptions, ); const credentialsProvider = createAWSCredentialsAndIdentityIdProvider( resourcesConfig.Auth, diff --git a/packages/adapter-nextjs/src/utils/globalSettings.ts b/packages/adapter-nextjs/src/utils/globalSettings.ts new file mode 100644 index 00000000000..d15c10709ee --- /dev/null +++ b/packages/adapter-nextjs/src/utils/globalSettings.ts @@ -0,0 +1,30 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { NextServer } from '../types'; + +let isServerSideAuthEnabled = false; +let runtimeOptions: NextServer.CreateServerRunnerRuntimeOptions = {}; +let isSSLOrigin = false; + +export const globalSettings: NextServer.GlobalSettings = { + enableServerSideAuth() { + isServerSideAuthEnabled = true; + }, + isServerSideAuthEnabled() { + return isServerSideAuthEnabled; + }, + setRuntimeOptions(options: NextServer.CreateServerRunnerRuntimeOptions) { + // make a copy instead of set the reference + runtimeOptions = structuredClone(options); + }, + getRuntimeOptions() { + return runtimeOptions; + }, + setIsSSLOrigin(value: boolean) { + isSSLOrigin = value; + }, + isSSLOrigin() { + return isSSLOrigin; + }, +}; diff --git a/packages/adapter-nextjs/src/utils/index.ts b/packages/adapter-nextjs/src/utils/index.ts index 3427284caa8..45b7928f804 100644 --- a/packages/adapter-nextjs/src/utils/index.ts +++ b/packages/adapter-nextjs/src/utils/index.ts @@ -3,3 +3,4 @@ export { createRunWithAmplifyServerContext } from './createRunWithAmplifyServerContext'; export { isValidCognitoToken } from './isValidCognitoToken'; +export { globalSettings } from './globalSettings';