diff --git a/packages/adapter-nextjs/__tests__/auth/createAuthRouteHandlersFactory.test.ts b/packages/adapter-nextjs/__tests__/auth/createAuthRouteHandlersFactory.test.ts index cd15234f306..c97d0d964a1 100644 --- a/packages/adapter-nextjs/__tests__/auth/createAuthRouteHandlersFactory.test.ts +++ b/packages/adapter-nextjs/__tests__/auth/createAuthRouteHandlersFactory.test.ts @@ -150,6 +150,7 @@ describe('createAuthRoutesHandlersFactory', () => { oAuthConfig: mockAmplifyConfig.Auth!.Cognito!.loginWith!.oauth, setCookieOptions: mockRuntimeOptions.cookies, origin: 'https://example.com', + userPoolClientId: 'def', }); }); @@ -170,6 +171,7 @@ describe('createAuthRoutesHandlersFactory', () => { oAuthConfig: mockAmplifyConfig.Auth!.Cognito!.loginWith!.oauth, setCookieOptions: mockRuntimeOptions.cookies, origin: 'https://example.com', + userPoolClientId: 'def', }); }); @@ -211,6 +213,7 @@ describe('createAuthRoutesHandlersFactory', () => { oAuthConfig: mockAmplifyConfig.Auth!.Cognito!.loginWith!.oauth, setCookieOptions: {}, origin: 'https://example.com', + userPoolClientId: 'def', }); }); }); diff --git a/packages/adapter-nextjs/__tests__/auth/handleAuthApiRouteRequestForAppRouter.test.ts b/packages/adapter-nextjs/__tests__/auth/handleAuthApiRouteRequestForAppRouter.test.ts index 8ef1d143dbb..58fdb69e5e4 100644 --- a/packages/adapter-nextjs/__tests__/auth/handleAuthApiRouteRequestForAppRouter.test.ts +++ b/packages/adapter-nextjs/__tests__/auth/handleAuthApiRouteRequestForAppRouter.test.ts @@ -6,6 +6,23 @@ import { OAuthConfig } from '@aws-amplify/core'; import { handleAuthApiRouteRequestForAppRouter } from '../../src/auth/handleAuthApiRouteRequestForAppRouter'; import { CreateAuthRoutesHandlersInput } from '../../src/auth/types'; +import { + handleSignInCallbackRequest, + handleSignInSignUpRequest, + handleSignOutCallbackRequest, + handleSignOutRequest, +} from '../../src/auth/handlers'; + +jest.mock('../../src/auth/handlers'); + +const mockHandleSignInSignUpRequest = jest.mocked(handleSignInSignUpRequest); +const mockHandleSignOutRequest = jest.mocked(handleSignOutRequest); +const mockHandleSignInCallbackRequest = jest.mocked( + handleSignInCallbackRequest, +); +const mockHandleSignOutCallbackRequest = jest.mocked( + handleSignOutCallbackRequest, +); describe('handleAuthApiRouteRequestForAppRouter', () => { const testOrigin = 'https://example.com'; @@ -23,17 +40,18 @@ describe('handleAuthApiRouteRequestForAppRouter', () => { }; const _ = handleAuthApiRouteRequestForAppRouter; - it('returns a 405 response when input.request has an unsupported method', () => { + it('returns a 405 response when input.request has an unsupported method', async () => { const request = new NextRequest( new URL('https://example.com/api/auth/sign-in'), { method: 'POST', }, ); - const response = handleAuthApiRouteRequestForAppRouter({ + const response = await handleAuthApiRouteRequestForAppRouter({ request, handlerContext: testHandlerContext, handlerInput: testHandlerInput, + userPoolClientId: 'userPoolClientId', oAuthConfig: testOAuthConfig, setCookieOptions: {}, origin: testOrigin, @@ -42,17 +60,18 @@ describe('handleAuthApiRouteRequestForAppRouter', () => { expect(response.status).toBe(405); }); - it('returns a 400 response when handlerContext.params.slug is undefined', () => { + it('returns a 400 response when handlerContext.params.slug is undefined', async () => { const request = new NextRequest( new URL('https://example.com/api/auth/sign-in'), { method: 'GET', }, ); - const response = handleAuthApiRouteRequestForAppRouter({ + const response = await handleAuthApiRouteRequestForAppRouter({ request, handlerContext: { params: { slug: undefined } }, handlerInput: testHandlerInput, + userPoolClientId: 'userPoolClientId', oAuthConfig: testOAuthConfig, setCookieOptions: {}, origin: testOrigin, @@ -61,17 +80,18 @@ describe('handleAuthApiRouteRequestForAppRouter', () => { expect(response.status).toBe(400); }); - it('returns a 404 response when handlerContext.params.slug is not a supported path', () => { + it('returns a 404 response when handlerContext.params.slug is not a supported path', async () => { const request = new NextRequest( new URL('https://example.com/api/auth/exchange-token'), { method: 'GET', }, ); - const response = handleAuthApiRouteRequestForAppRouter({ + const response = await handleAuthApiRouteRequestForAppRouter({ request, handlerContext: { params: { slug: 'exchange-token' } }, handlerInput: testHandlerInput, + userPoolClientId: 'userPoolClientId', oAuthConfig: testOAuthConfig, setCookieOptions: {}, origin: testOrigin, @@ -80,23 +100,135 @@ describe('handleAuthApiRouteRequestForAppRouter', () => { expect(response.status).toBe(404); }); - // TODO(HuiSF): add use cases tests for each supported path when implemented - it('returns a 501 response when handlerContext.params.slug is a supported path', () => { - const request = new NextRequest( - new URL('https://example.com/api/auth/sign-in'), + test.each([ + ['sign-in', 'signIn'], + ['sign-up', 'signUp'], + ])( + `calls handleSignInSignUpRequest with correct params when handlerContext.params.slug is %s`, + async (slug, expectedType) => { + const mockRequest = new NextRequest( + new URL('https://example.com/api/auth/sign-in'), + { + method: 'GET', + }, + ); + const mockResponse = new Response(null, { status: 302 }); + + mockHandleSignInSignUpRequest.mockReturnValueOnce(mockResponse); + + const response = await handleAuthApiRouteRequestForAppRouter({ + request: mockRequest, + handlerContext: { params: { slug } }, + handlerInput: testHandlerInput, + userPoolClientId: 'userPoolClientId', + oAuthConfig: testOAuthConfig, + setCookieOptions: {}, + origin: testOrigin, + }); + + expect(response).toBe(mockResponse); + expect(mockHandleSignInSignUpRequest).toHaveBeenCalledWith({ + request: mockRequest, + userPoolClientId: 'userPoolClientId', + oAuthConfig: testOAuthConfig, + customState: testHandlerInput.customState, + origin: testOrigin, + setCookieOptions: {}, + type: expectedType, + }); + }, + ); + + it('calls handleSignOutRequest with correct params when handlerContext.params.slug is sign-out', async () => { + const mockRequest = new NextRequest( + new URL('https://example.com/api/auth/sign-out'), { method: 'GET', }, ); - const response = handleAuthApiRouteRequestForAppRouter({ - request, - handlerContext: { params: { slug: 'sign-in' } }, + const mockResponse = new Response(null, { status: 302 }); + + mockHandleSignOutRequest.mockReturnValueOnce(mockResponse); + + const response = await handleAuthApiRouteRequestForAppRouter({ + request: mockRequest, + handlerContext: { params: { slug: 'sign-out' } }, + handlerInput: testHandlerInput, + userPoolClientId: 'userPoolClientId', + oAuthConfig: testOAuthConfig, + setCookieOptions: {}, + origin: testOrigin, + }); + + expect(response).toBe(mockResponse); + expect(mockHandleSignOutRequest).toHaveBeenCalledWith({ + userPoolClientId: 'userPoolClientId', + oAuthConfig: testOAuthConfig, + origin: testOrigin, + setCookieOptions: {}, + }); + }); + + it('calls handleSignInCallbackRequest with correct params when handlerContext.params.slug is sign-in-callback', async () => { + const mockRequest = new NextRequest( + new URL('https://example.com/api/auth/sign-in-callback'), + { + method: 'GET', + }, + ); + const mockResponse = new Response(null, { status: 302 }); + + mockHandleSignInCallbackRequest.mockResolvedValueOnce(mockResponse); + + const response = await handleAuthApiRouteRequestForAppRouter({ + request: mockRequest, + handlerContext: { params: { slug: 'sign-in-callback' } }, + handlerInput: testHandlerInput, + userPoolClientId: 'userPoolClientId', + oAuthConfig: testOAuthConfig, + setCookieOptions: {}, + origin: testOrigin, + }); + + expect(response).toBe(mockResponse); + expect(mockHandleSignInCallbackRequest).toHaveBeenCalledWith({ + request: mockRequest, + handlerInput: testHandlerInput, + oAuthConfig: testOAuthConfig, + origin: testOrigin, + setCookieOptions: {}, + userPoolClientId: 'userPoolClientId', + }); + }); + + it('calls handleSignOutCallbackRequest with correct params when handlerContext.params.slug is sign-out-callback', async () => { + const mockRequest = new NextRequest( + new URL('https://example.com/api/auth/sign-out-callback'), + { + method: 'GET', + }, + ); + const mockResponse = new Response(null, { status: 302 }); + + mockHandleSignOutCallbackRequest.mockResolvedValueOnce(mockResponse); + + const response = await handleAuthApiRouteRequestForAppRouter({ + request: mockRequest, + handlerContext: { params: { slug: 'sign-out-callback' } }, handlerInput: testHandlerInput, + userPoolClientId: 'userPoolClientId', oAuthConfig: testOAuthConfig, setCookieOptions: {}, origin: testOrigin, }); - expect(response.status).toBe(501); + expect(response).toBe(mockResponse); + expect(mockHandleSignOutCallbackRequest).toHaveBeenCalledWith({ + request: mockRequest, + handlerInput: testHandlerInput, + oAuthConfig: testOAuthConfig, + setCookieOptions: {}, + userPoolClientId: 'userPoolClientId', + }); }); }); diff --git a/packages/adapter-nextjs/__tests__/auth/handleAuthApiRouteRequestForPagesRouter.test.ts b/packages/adapter-nextjs/__tests__/auth/handleAuthApiRouteRequestForPagesRouter.test.ts index 06741e5a8d1..3561c8e8153 100644 --- a/packages/adapter-nextjs/__tests__/auth/handleAuthApiRouteRequestForPagesRouter.test.ts +++ b/packages/adapter-nextjs/__tests__/auth/handleAuthApiRouteRequestForPagesRouter.test.ts @@ -1,7 +1,31 @@ import { OAuthConfig } from '@aws-amplify/core'; +import { NextApiRequest } from 'next'; import { handleAuthApiRouteRequestForPagesRouter } from '../../src/auth/handleAuthApiRouteRequestForPagesRouter'; import { CreateAuthRoutesHandlersInput } from '../../src/auth/types'; +import { + handleSignInCallbackRequestForPagesRouter, + handleSignInSignUpRequestForPagesRouter, + handleSignOutCallbackRequestForPagesRouter, + handleSignOutRequestForPagesRouter, +} from '../../src/auth/handlers'; + +import { createMockNextApiResponse } from './testUtils'; + +jest.mock('../../src/auth/handlers'); + +const mockHandleSignInSignUpRequestForPagesRouter = jest.mocked( + handleSignInSignUpRequestForPagesRouter, +); +const mockHandleSignOutRequestForPagesRouter = jest.mocked( + handleSignOutRequestForPagesRouter, +); +const mockHandleSignInCallbackRequestForPagesRouter = jest.mocked( + handleSignInCallbackRequestForPagesRouter, +); +const mockHandleSignOutCallbackRequestForPagesRouter = jest.mocked( + handleSignOutCallbackRequestForPagesRouter, +); describe('handleAuthApiRouteRequestForPagesRouter', () => { const testOrigin = 'https://example.com'; @@ -17,87 +41,192 @@ describe('handleAuthApiRouteRequestForPagesRouter', () => { scopes: ['openid', 'email'], }; const testSetCookieOptions = {}; + const { + mockResponseAppendHeader, + mockResponseEnd, + mockResponseStatus, + mockResponseSend, + mockResponseRedirect, + mockResponse, + } = createMockNextApiResponse(); + + afterEach(() => { + mockResponseAppendHeader.mockClear(); + mockResponseEnd.mockClear(); + mockResponseStatus.mockClear(); + mockResponseSend.mockClear(); + mockResponseRedirect.mockClear(); + }); it('sets response.status(405) when request has an unsupported method', () => { - const mockEnd = jest.fn(); - const mockStatus = jest.fn(() => ({ end: mockEnd })); const mockRequest = { method: 'POST' } as any; - const mockResponse = { status: mockStatus } as any; handleAuthApiRouteRequestForPagesRouter({ request: mockRequest, response: mockResponse, handlerInput: testHandlerInput, + userPoolClientId: 'userPoolClientId', oAuthConfig: testOAuthConfig, setCookieOptions: testSetCookieOptions, origin: testOrigin, }); - expect(mockStatus).toHaveBeenCalledWith(405); - expect(mockEnd).toHaveBeenCalled(); + expect(mockResponseStatus).toHaveBeenCalledWith(405); + expect(mockResponseEnd).toHaveBeenCalled(); }); it('sets response.status(400) when request.query.slug is undefined', () => { - const mockEnd = jest.fn(); - const mockStatus = jest.fn(() => ({ end: mockEnd })); const mockRequest = { method: 'GET', query: {} } as any; - const mockResponse = { status: mockStatus } as any; handleAuthApiRouteRequestForPagesRouter({ request: mockRequest, response: mockResponse, + userPoolClientId: 'userPoolClientId', handlerInput: testHandlerInput, oAuthConfig: testOAuthConfig, setCookieOptions: testSetCookieOptions, origin: testOrigin, }); - expect(mockStatus).toHaveBeenCalledWith(400); - expect(mockEnd).toHaveBeenCalled(); + expect(mockResponseStatus).toHaveBeenCalledWith(400); + expect(mockResponseEnd).toHaveBeenCalled(); }); it('sets response.status(404) when request.query.slug is is not a supported path', () => { - const mockEnd = jest.fn(); - const mockStatus = jest.fn(() => ({ end: mockEnd })); const mockRequest = { method: 'GET', query: { slug: 'exchange-token' }, } as any; - const mockResponse = { status: mockStatus } as any; handleAuthApiRouteRequestForPagesRouter({ request: mockRequest, response: mockResponse, handlerInput: testHandlerInput, + userPoolClientId: 'userPoolClientId', oAuthConfig: testOAuthConfig, setCookieOptions: testSetCookieOptions, origin: testOrigin, }); - expect(mockStatus).toHaveBeenCalledWith(404); - expect(mockEnd).toHaveBeenCalled(); + expect(mockResponseStatus).toHaveBeenCalledWith(404); + expect(mockResponseEnd).toHaveBeenCalled(); }); - // TODO(HuiSF): add use cases tests for each supported path when implemented - it('sets response.status(501) when handlerContext.params.slug is a supported path', () => { - const mockEnd = jest.fn(); - const mockStatus = jest.fn(() => ({ end: mockEnd })); + test.each([ + ['sign-in', 'signIn'], + ['sign-up', 'signUp'], + ])( + `calls handleSignInSignUpRequestForPagesRouter with correct params when handlerContext.params.slug is %s`, + async (slug, expectedType) => { + const mockRequest = { + url: 'https://example.com/api/auth/sign-in', + method: 'GET', + query: { slug }, + } as unknown as NextApiRequest; + + await handleAuthApiRouteRequestForPagesRouter({ + request: mockRequest, + response: mockResponse, + handlerInput: testHandlerInput, + userPoolClientId: 'userPoolClientId', + oAuthConfig: testOAuthConfig, + setCookieOptions: {}, + origin: testOrigin, + }); + + expect(mockHandleSignInSignUpRequestForPagesRouter).toHaveBeenCalledWith({ + request: mockRequest, + response: mockResponse, + userPoolClientId: 'userPoolClientId', + oAuthConfig: testOAuthConfig, + customState: testHandlerInput.customState, + origin: testOrigin, + setCookieOptions: {}, + type: expectedType, + }); + }, + ); + + it('calls handleSignOutRequest with correct params when handlerContext.params.slug is sign-out', async () => { const mockRequest = { + url: 'https://example.com/api/auth/sign-in', method: 'GET', - query: { slug: 'sign-in' }, - } as any; - const mockResponse = { status: mockStatus } as any; + query: { slug: 'sign-out' }, + } as unknown as NextApiRequest; - handleAuthApiRouteRequestForPagesRouter({ + await handleAuthApiRouteRequestForPagesRouter({ request: mockRequest, response: mockResponse, handlerInput: testHandlerInput, + userPoolClientId: 'userPoolClientId', oAuthConfig: testOAuthConfig, - setCookieOptions: testSetCookieOptions, + setCookieOptions: {}, + origin: testOrigin, + }); + + expect(mockHandleSignOutRequestForPagesRouter).toHaveBeenCalledWith({ + response: mockResponse, + userPoolClientId: 'userPoolClientId', + oAuthConfig: testOAuthConfig, + origin: testOrigin, + setCookieOptions: {}, + }); + }); + + it('calls handleSignInCallbackRequest with correct params when handlerContext.params.slug is sign-in-callback', async () => { + const mockRequest = { + url: 'https://example.com/api/auth/sign-in', + method: 'GET', + query: { slug: 'sign-in-callback' }, + } as unknown as NextApiRequest; + + await handleAuthApiRouteRequestForPagesRouter({ + request: mockRequest, + response: mockResponse, + handlerInput: testHandlerInput, + userPoolClientId: 'userPoolClientId', + oAuthConfig: testOAuthConfig, + setCookieOptions: {}, + origin: testOrigin, + }); + + expect(mockHandleSignInCallbackRequestForPagesRouter).toHaveBeenCalledWith({ + request: mockRequest, + response: mockResponse, + handlerInput: testHandlerInput, + oAuthConfig: testOAuthConfig, + origin: testOrigin, + setCookieOptions: {}, + userPoolClientId: 'userPoolClientId', + }); + }); + + it('calls handleSignOutCallbackRequest with correct params when handlerContext.params.slug is sign-out-callback', async () => { + const mockRequest = { + url: 'https://example.com/api/auth/sign-in', + method: 'GET', + query: { slug: 'sign-out-callback' }, + } as unknown as NextApiRequest; + + await handleAuthApiRouteRequestForPagesRouter({ + request: mockRequest, + response: mockResponse, + handlerInput: testHandlerInput, + userPoolClientId: 'userPoolClientId', + oAuthConfig: testOAuthConfig, + setCookieOptions: {}, origin: testOrigin, }); - expect(mockStatus).toHaveBeenCalledWith(501); - expect(mockEnd).toHaveBeenCalled(); + expect(mockHandleSignOutCallbackRequestForPagesRouter).toHaveBeenCalledWith( + { + request: mockRequest, + response: mockResponse, + handlerInput: testHandlerInput, + oAuthConfig: testOAuthConfig, + setCookieOptions: {}, + userPoolClientId: 'userPoolClientId', + }, + ); }); }); diff --git a/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInCallbackRequest.test.ts b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInCallbackRequest.test.ts new file mode 100644 index 00000000000..8cd13a079cb --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInCallbackRequest.test.ts @@ -0,0 +1,306 @@ +/** + * @jest-environment node + */ +import { NextRequest } from 'next/server.js'; +import { OAuthConfig } from '@aws-amplify/core'; +import { CookieStorage } from 'aws-amplify/adapter-core'; + +import { handleSignInCallbackRequest } from '../../../src/auth/handlers/handleSignInCallbackRequest'; +import { + appendSetCookieHeaders, + createAuthFlowProofCookiesRemoveOptions, + createOnSignInCompletedRedirectIntermediate, + createSignInFlowProofCookies, + createTokenCookies, + createTokenCookiesSetOptions, + exchangeAuthNTokens, + getCookieValuesFromRequest, + resolveCodeAndStateFromUrl, + resolveRedirectSignInUrl, +} from '../../../src/auth/utils'; +import { CreateAuthRoutesHandlersInput } from '../../../src/auth/types'; +import { + PKCE_COOKIE_NAME, + STATE_COOKIE_NAME, +} from '../../../src/auth/constant'; + +jest.mock('../../../src/auth/utils'); + +const mockAppendSetCookieHeaders = jest.mocked(appendSetCookieHeaders); +const mockCreateAuthFlowProofCookiesRemoveOptions = jest.mocked( + createAuthFlowProofCookiesRemoveOptions, +); +const mockCreateOnSignInCompletedRedirectIntermediate = jest.mocked( + createOnSignInCompletedRedirectIntermediate, +); +const mockCreateSignInFlowProofCookies = jest.mocked( + createSignInFlowProofCookies, +); +const mockCreateTokenCookies = jest.mocked(createTokenCookies); +const mockCreateTokenCookiesSetOptions = jest.mocked( + createTokenCookiesSetOptions, +); +const mockExchangeAuthNTokens = jest.mocked(exchangeAuthNTokens); +const mockGetCookieValuesFromRequest = jest.mocked(getCookieValuesFromRequest); +const mockResolveCodeAndStateFromUrl = jest.mocked(resolveCodeAndStateFromUrl); +const mockResolveRedirectSignInUrl = jest.mocked(resolveRedirectSignInUrl); + +describe('handleSignInCallbackRequest', () => { + const mockHandlerInput: CreateAuthRoutesHandlersInput = { + redirectOnSignInComplete: '/home', + redirectOnSignOutComplete: '/sign-in', + }; + const mockUserPoolClientId = 'userPoolClientId'; + const mockOAuthConfig = {} as OAuthConfig; + const mockSetCookieOptions = {} as CookieStorage.SetCookieOptions; + const mockOrigin = 'https://example.com'; + + afterEach(() => { + mockAppendSetCookieHeaders.mockClear(); + mockCreateAuthFlowProofCookiesRemoveOptions.mockClear(); + mockCreateOnSignInCompletedRedirectIntermediate.mockClear(); + mockCreateSignInFlowProofCookies.mockClear(); + mockCreateTokenCookies.mockClear(); + mockCreateTokenCookiesSetOptions.mockClear(); + mockExchangeAuthNTokens.mockClear(); + mockGetCookieValuesFromRequest.mockClear(); + mockResolveCodeAndStateFromUrl.mockClear(); + mockResolveRedirectSignInUrl.mockClear(); + }); + + test.each([ + [null, 'state'], + ['state', null], + ])( + 'returns a 400 response when request.url contains query params: code=%s, state=%s', + async (code, state) => { + mockResolveCodeAndStateFromUrl.mockReturnValueOnce({ + code, + state, + }); + const url = 'https://example.com/api/auth/sign-in-callback'; + const request = new NextRequest(new URL(url)); + + const response = await handleSignInCallbackRequest({ + request, + handlerInput: mockHandlerInput, + userPoolClientId: mockUserPoolClientId, + oAuthConfig: mockOAuthConfig, + setCookieOptions: mockSetCookieOptions, + origin: mockOrigin, + }); + + expect(response.status).toBe(400); + expect(mockResolveCodeAndStateFromUrl).toHaveBeenCalledWith(url); + }, + ); + + test.each([ + ['client state cookie is missing', undefined, 'state', 'pkce'], + [ + 'client cookie state a different value from the state query parameter', + 'state_different', + 'state', + 'pkce', + ], + ['client pkce cookie is missing', 'state', 'state', undefined], + ])( + `returns a 400 response when %s`, + async (_, clientState, state, clientPkce) => { + mockResolveCodeAndStateFromUrl.mockReturnValueOnce({ + code: 'not_important_for_this_test', + state, + }); + mockGetCookieValuesFromRequest.mockReturnValueOnce({ + [STATE_COOKIE_NAME]: clientState, + [PKCE_COOKIE_NAME]: clientPkce, + }); + + const url = `https://example.com/api/auth/sign-in-callback?state=${state}&code=not_important_for_this_test`; + const request = new NextRequest(new URL(url)); + + const response = await handleSignInCallbackRequest({ + request, + handlerInput: mockHandlerInput, + userPoolClientId: mockUserPoolClientId, + oAuthConfig: mockOAuthConfig, + setCookieOptions: mockSetCookieOptions, + origin: mockOrigin, + }); + + expect(response.status).toBe(400); + expect(mockResolveCodeAndStateFromUrl).toHaveBeenCalledWith(url); + expect(mockGetCookieValuesFromRequest).toHaveBeenCalledWith(request, [ + PKCE_COOKIE_NAME, + STATE_COOKIE_NAME, + ]); + }, + ); + + it('returns a 500 response when exchangeAuthNTokens returns an error', async () => { + const mockCode = 'code'; + const mockPkce = 'pkce'; + const mockSignInCallbackUrl = + 'https://example.com/api/auth/sign-in-callback'; + const mockError = 'invalid_grant'; + mockResolveCodeAndStateFromUrl.mockReturnValueOnce({ + code: mockCode, + state: 'not_important_for_this_test', + }); + mockGetCookieValuesFromRequest.mockReturnValueOnce({ + [STATE_COOKIE_NAME]: 'not_important_for_this_test', + [PKCE_COOKIE_NAME]: mockPkce, + }); + mockResolveRedirectSignInUrl.mockReturnValueOnce(mockSignInCallbackUrl); + mockExchangeAuthNTokens.mockResolvedValueOnce({ + error: mockError, + }); + + const response = await handleSignInCallbackRequest({ + request: new NextRequest(new URL(mockSignInCallbackUrl)), + handlerInput: mockHandlerInput, + userPoolClientId: mockUserPoolClientId, + oAuthConfig: mockOAuthConfig, + setCookieOptions: mockSetCookieOptions, + origin: mockOrigin, + }); + + expect(response.status).toBe(500); + expect(await response.text()).toBe(mockError); + + expect(mockExchangeAuthNTokens).toHaveBeenCalledWith({ + redirectUri: mockSignInCallbackUrl, + userPoolClientId: mockUserPoolClientId, + oAuthConfig: mockOAuthConfig, + code: mockCode, + codeVerifier: mockPkce, + }); + }); + + test.each([ + [ + mockHandlerInput, + mockHandlerInput.redirectOnSignInComplete!, + `redirect to ${mockHandlerInput.redirectOnSignInComplete}`, + ], + [ + { ...mockHandlerInput, redirectOnSignInComplete: undefined }, + '/', + `redirect to /`, + ], + ] as [CreateAuthRoutesHandlersInput, string, string][])( + 'returns a 200 response with expected redirect target: with handlerInput=%p, expectedFinalRedirect=%s, generates expected html=%s', + async (handlerInput, expectedFinalRedirect, expectedHtml) => { + const mockCode = 'code'; + const mockPkce = 'pkce'; + const mockSignInCallbackUrl = + 'https://example.com/api/auth/sign-in-callback'; + const mockExchangeTokenPayload = { + access_token: 'access_token', + id_token: 'id_token', + refresh_token: 'refresh_token', + token_type: 'Bearer', + expires_in: 3600, + }; + const mockCreateTokenCookiesResult = [ + { name: 'mock-cookie-1', value: 'value-1' }, + ]; + mockCreateTokenCookies.mockReturnValueOnce(mockCreateTokenCookiesResult); + const mockCreateTokenCookiesSetOptionsResult = { + domain: 'example.com', + path: '/', + secure: true, + httpOnly: true, + sameSite: 'strict' as const, + expires: new Date('2024-9-17'), + }; + mockCreateTokenCookiesSetOptions.mockReturnValueOnce( + mockCreateTokenCookiesSetOptionsResult, + ); + const mockCreateSignInFlowProofCookiesResult = [ + { name: 'mock-cookie-2', value: 'value-2' }, + ]; + mockCreateSignInFlowProofCookies.mockReturnValueOnce( + mockCreateSignInFlowProofCookiesResult, + ); + const mockCreateAuthFlowProofCookiesRemoveOptionsResult = { + domain: 'example.com', + path: '/', + expires: new Date('1970-1-1'), + }; + mockCreateAuthFlowProofCookiesRemoveOptions.mockReturnValueOnce( + mockCreateAuthFlowProofCookiesRemoveOptionsResult, + ); + mockResolveCodeAndStateFromUrl.mockReturnValueOnce({ + code: mockCode, + state: 'not_important_for_this_test', + }); + mockGetCookieValuesFromRequest.mockReturnValueOnce({ + [STATE_COOKIE_NAME]: 'not_important_for_this_test', + [PKCE_COOKIE_NAME]: mockPkce, + }); + mockResolveRedirectSignInUrl.mockReturnValueOnce(mockSignInCallbackUrl); + mockExchangeAuthNTokens.mockResolvedValueOnce(mockExchangeTokenPayload); + mockAppendSetCookieHeaders.mockImplementationOnce(headers => { + headers.append('Set-cookie', 'mock-cookie-1'); + headers.append('Set-cookie', 'mock-cookie-2'); + }); + mockCreateOnSignInCompletedRedirectIntermediate.mockImplementationOnce( + ({ redirectOnSignInComplete }) => + `redirect to ${redirectOnSignInComplete}`, + ); + + const response = await handleSignInCallbackRequest({ + request: new NextRequest(new URL(mockSignInCallbackUrl)), + handlerInput, + userPoolClientId: mockUserPoolClientId, + oAuthConfig: mockOAuthConfig, + setCookieOptions: mockSetCookieOptions, + origin: mockOrigin, + }); + + // verify the response + expect(response.status).toBe(200); + expect(response.headers.get('Set-Cookie')).toBe( + 'mock-cookie-1, mock-cookie-2', + ); + expect(response.headers.get('Content-Type')).toBe('text/html'); + expect(await response.text()).toBe(expectedHtml); + + // verify calls to the dependencies + expect(mockCreateTokenCookies).toHaveBeenCalledWith({ + tokensPayload: mockExchangeTokenPayload, + userPoolClientId: mockUserPoolClientId, + }); + expect(mockCreateTokenCookiesSetOptions).toHaveBeenCalledWith( + mockSetCookieOptions, + ); + expect(mockCreateSignInFlowProofCookies).toHaveBeenCalledWith({ + state: '', + pkce: '', + }); + expect(mockCreateAuthFlowProofCookiesRemoveOptions).toHaveBeenCalledWith( + mockSetCookieOptions, + ); + + expect(mockAppendSetCookieHeaders).toHaveBeenCalledTimes(2); + expect(mockAppendSetCookieHeaders).toHaveBeenNthCalledWith( + 1, + expect.any(Headers), + mockCreateTokenCookiesResult, + mockCreateTokenCookiesSetOptionsResult, + ); + expect(mockAppendSetCookieHeaders).toHaveBeenNthCalledWith( + 2, + expect.any(Headers), + mockCreateSignInFlowProofCookiesResult, + mockCreateAuthFlowProofCookiesRemoveOptionsResult, + ); + expect( + mockCreateOnSignInCompletedRedirectIntermediate, + ).toHaveBeenCalledWith({ + redirectOnSignInComplete: expectedFinalRedirect, + }); + }, + ); +}); diff --git a/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInCallbackRequestForPagesRouter.test.ts b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInCallbackRequestForPagesRouter.test.ts new file mode 100644 index 00000000000..992ceca7ae4 --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInCallbackRequestForPagesRouter.test.ts @@ -0,0 +1,343 @@ +/** + * @jest-environment node + */ +import { OAuthConfig } from '@aws-amplify/core'; +import { CookieStorage } from 'aws-amplify/adapter-core'; +import { NextApiRequest } from 'next'; + +import { handleSignInCallbackRequestForPagesRouter } from '../../../src/auth/handlers/handleSignInCallbackRequestForPagesRouter'; +import { + appendSetCookieHeadersToNextApiResponse, + createAuthFlowProofCookiesRemoveOptions, + createOnSignInCompletedRedirectIntermediate, + createSignInFlowProofCookies, + createTokenCookies, + createTokenCookiesSetOptions, + exchangeAuthNTokens, + getCookieValuesFromNextApiRequest, + resolveCodeAndStateFromUrl, + resolveRedirectSignInUrl, +} from '../../../src/auth/utils'; +import { CreateAuthRoutesHandlersInput } from '../../../src/auth/types'; +import { + PKCE_COOKIE_NAME, + STATE_COOKIE_NAME, +} from '../../../src/auth/constant'; +import { createMockNextApiResponse } from '../testUtils'; + +jest.mock('../../../src/auth/utils'); + +const mockAppendSetCookieHeadersToNextApiResponse = jest.mocked( + appendSetCookieHeadersToNextApiResponse, +); +const mockCreateAuthFlowProofCookiesRemoveOptions = jest.mocked( + createAuthFlowProofCookiesRemoveOptions, +); +const mockCreateOnSignInCompletedRedirectIntermediate = jest.mocked( + createOnSignInCompletedRedirectIntermediate, +); +const mockCreateSignInFlowProofCookies = jest.mocked( + createSignInFlowProofCookies, +); +const mockCreateTokenCookies = jest.mocked(createTokenCookies); +const mockCreateTokenCookiesSetOptions = jest.mocked( + createTokenCookiesSetOptions, +); +const mockExchangeAuthNTokens = jest.mocked(exchangeAuthNTokens); +const mockGetCookieValuesFromNextApiRequest = jest.mocked( + getCookieValuesFromNextApiRequest, +); +const mockResolveCodeAndStateFromUrl = jest.mocked(resolveCodeAndStateFromUrl); +const mockResolveRedirectSignInUrl = jest.mocked(resolveRedirectSignInUrl); + +describe('handleSignInCallbackRequest', () => { + const mockHandlerInput: CreateAuthRoutesHandlersInput = { + redirectOnSignInComplete: '/home', + redirectOnSignOutComplete: '/sign-in', + }; + const mockUserPoolClientId = 'userPoolClientId'; + const mockOAuthConfig = {} as OAuthConfig; + const mockSetCookieOptions = {} as CookieStorage.SetCookieOptions; + const mockOrigin = 'https://example.com'; + const { + mockResponseAppendHeader, + mockResponseEnd, + mockResponseStatus, + mockResponseSend, + mockResponse, + } = createMockNextApiResponse(); + + afterEach(() => { + mockAppendSetCookieHeadersToNextApiResponse.mockClear(); + mockCreateAuthFlowProofCookiesRemoveOptions.mockClear(); + mockCreateOnSignInCompletedRedirectIntermediate.mockClear(); + mockCreateSignInFlowProofCookies.mockClear(); + mockCreateTokenCookies.mockClear(); + mockCreateTokenCookiesSetOptions.mockClear(); + mockExchangeAuthNTokens.mockClear(); + mockGetCookieValuesFromNextApiRequest.mockClear(); + mockResolveCodeAndStateFromUrl.mockClear(); + mockResolveRedirectSignInUrl.mockClear(); + + mockResponseAppendHeader.mockClear(); + mockResponseEnd.mockClear(); + mockResponseStatus.mockClear(); + mockResponseSend.mockClear(); + }); + + test.each([ + [null, 'state'], + ['state', null], + ])( + 'returns a 400 response when request.url contains query params: code=%s, state=%s', + async (code, state) => { + mockResolveCodeAndStateFromUrl.mockReturnValueOnce({ + code, + state, + }); + const url = '/api/auth/sign-in-callback'; + const mockRequest = { + query: { code, state }, + url: '/api/auth/sign-in-callback', + } as unknown as NextApiRequest; + + await handleSignInCallbackRequestForPagesRouter({ + request: mockRequest, + response: mockResponse, + handlerInput: mockHandlerInput, + userPoolClientId: mockUserPoolClientId, + oAuthConfig: mockOAuthConfig, + setCookieOptions: mockSetCookieOptions, + origin: mockOrigin, + }); + + expect(mockResponseStatus).toHaveBeenCalledWith(400); + expect(mockResponseEnd).toHaveBeenCalled(); + expect(mockResolveCodeAndStateFromUrl).toHaveBeenCalledWith(url); + }, + ); + + test.each([ + ['client state cookie is missing', undefined, 'state', 'pkce'], + [ + 'client cookie state a different value from the state query parameter', + 'state_different', + 'state', + 'pkce', + ], + ['client pkce cookie is missing', 'state', 'state', undefined], + ])( + `returns a 400 response when %s`, + async (_, clientState, state, clientPkce) => { + mockResolveCodeAndStateFromUrl.mockReturnValueOnce({ + code: 'not_important_for_this_test', + state, + }); + mockGetCookieValuesFromNextApiRequest.mockReturnValueOnce({ + [STATE_COOKIE_NAME]: clientState, + [PKCE_COOKIE_NAME]: clientPkce, + }); + + const url = `/api/auth/sign-in-callback?state=${state}&code=not_important_for_this_test`; + const mockRequest = { + query: { state }, + url, + } as unknown as NextApiRequest; + + await handleSignInCallbackRequestForPagesRouter({ + request: mockRequest, + response: mockResponse, + handlerInput: mockHandlerInput, + userPoolClientId: mockUserPoolClientId, + oAuthConfig: mockOAuthConfig, + setCookieOptions: mockSetCookieOptions, + origin: mockOrigin, + }); + + expect(mockResponseStatus).toHaveBeenCalledWith(400); + expect(mockResponseEnd).toHaveBeenCalled(); + expect(mockResolveCodeAndStateFromUrl).toHaveBeenCalledWith(url); + expect(mockGetCookieValuesFromNextApiRequest).toHaveBeenCalledWith( + mockRequest, + [PKCE_COOKIE_NAME, STATE_COOKIE_NAME], + ); + }, + ); + + it('returns a 500 response when exchangeAuthNTokens returns an error', async () => { + const mockCode = 'code'; + const mockPkce = 'pkce'; + const mockSignInCallbackUrl = + 'https://example.com/api/auth/sign-in-callback'; + const mockError = 'invalid_grant'; + const mockRequest = { + query: {}, + url: '/api/auth/sign-in-callback', + } as unknown as NextApiRequest; + mockResolveCodeAndStateFromUrl.mockReturnValueOnce({ + code: mockCode, + state: 'not_important_for_this_test', + }); + mockGetCookieValuesFromNextApiRequest.mockReturnValueOnce({ + [STATE_COOKIE_NAME]: 'not_important_for_this_test', + [PKCE_COOKIE_NAME]: mockPkce, + }); + mockResolveRedirectSignInUrl.mockReturnValueOnce(mockSignInCallbackUrl); + mockExchangeAuthNTokens.mockResolvedValueOnce({ + error: mockError, + }); + + await handleSignInCallbackRequestForPagesRouter({ + request: mockRequest, + response: mockResponse, + handlerInput: mockHandlerInput, + userPoolClientId: mockUserPoolClientId, + oAuthConfig: mockOAuthConfig, + setCookieOptions: mockSetCookieOptions, + origin: mockOrigin, + }); + + expect(mockResponseStatus).toHaveBeenCalledWith(500); + expect(mockResponseSend).toHaveBeenCalledWith(mockError); + + expect(mockExchangeAuthNTokens).toHaveBeenCalledWith({ + redirectUri: mockSignInCallbackUrl, + userPoolClientId: mockUserPoolClientId, + oAuthConfig: mockOAuthConfig, + code: mockCode, + codeVerifier: mockPkce, + }); + }); + + test.each([ + [ + mockHandlerInput, + mockHandlerInput.redirectOnSignInComplete!, + `redirect to ${mockHandlerInput.redirectOnSignInComplete}`, + ], + [ + { ...mockHandlerInput, redirectOnSignInComplete: undefined }, + '/', + `redirect to /`, + ], + ] as [CreateAuthRoutesHandlersInput, string, string][])( + 'returns a 200 response with expected redirect target: with handlerInput=%p, expectedFinalRedirect=%s, generates expected html=%s', + async (handlerInput, expectedFinalRedirect, expectedHtml) => { + const mockCode = 'code'; + const mockPkce = 'pkce'; + const mockSignInCallbackUrl = + 'https://example.com/api/auth/sign-in-callback'; + const mockRequest = { + query: {}, + url: '/api/auth/sign-in-callback', + } as unknown as NextApiRequest; + const mockExchangeTokenPayload = { + access_token: 'access_token', + id_token: 'id_token', + refresh_token: 'refresh_token', + token_type: 'Bearer', + expires_in: 3600, + }; + const mockCreateTokenCookiesResult = [ + { name: 'mock-cookie-1', value: 'value-1' }, + ]; + mockCreateTokenCookies.mockReturnValueOnce(mockCreateTokenCookiesResult); + const mockCreateTokenCookiesSetOptionsResult = { + domain: 'example.com', + path: '/', + secure: true, + httpOnly: true, + sameSite: 'strict' as const, + expires: new Date('2024-9-17'), + }; + mockCreateTokenCookiesSetOptions.mockReturnValueOnce( + mockCreateTokenCookiesSetOptionsResult, + ); + const mockCreateSignInFlowProofCookiesResult = [ + { name: 'mock-cookie-2', value: 'value-2' }, + ]; + mockCreateSignInFlowProofCookies.mockReturnValueOnce( + mockCreateSignInFlowProofCookiesResult, + ); + const mockCreateAuthFlowProofCookiesRemoveOptionsResult = { + domain: 'example.com', + path: '/', + expires: new Date('1970-1-1'), + }; + mockCreateAuthFlowProofCookiesRemoveOptions.mockReturnValueOnce( + mockCreateAuthFlowProofCookiesRemoveOptionsResult, + ); + mockResolveCodeAndStateFromUrl.mockReturnValueOnce({ + code: mockCode, + state: 'not_important_for_this_test', + }); + mockGetCookieValuesFromNextApiRequest.mockReturnValueOnce({ + [STATE_COOKIE_NAME]: 'not_important_for_this_test', + [PKCE_COOKIE_NAME]: mockPkce, + }); + mockResolveRedirectSignInUrl.mockReturnValueOnce(mockSignInCallbackUrl); + mockExchangeAuthNTokens.mockResolvedValueOnce(mockExchangeTokenPayload); + mockAppendSetCookieHeadersToNextApiResponse.mockImplementationOnce( + response => { + response.appendHeader('Set-cookie', 'mock-cookie-1'); + response.appendHeader('Set-cookie', 'mock-cookie-2'); + }, + ); + mockCreateOnSignInCompletedRedirectIntermediate.mockImplementationOnce( + ({ redirectOnSignInComplete }) => + `redirect to ${redirectOnSignInComplete}`, + ); + + await handleSignInCallbackRequestForPagesRouter({ + request: mockRequest, + response: mockResponse, + handlerInput, + userPoolClientId: mockUserPoolClientId, + oAuthConfig: mockOAuthConfig, + setCookieOptions: mockSetCookieOptions, + origin: mockOrigin, + }); + + // verify the response + expect(mockResponseAppendHeader).toHaveBeenCalledTimes(3); + expect(mockResponseAppendHeader).toHaveBeenNthCalledWith( + 1, + 'Set-cookie', + 'mock-cookie-1', + ); + expect(mockResponseAppendHeader).toHaveBeenNthCalledWith( + 2, + 'Set-cookie', + 'mock-cookie-2', + ); + expect(mockResponseAppendHeader).toHaveBeenNthCalledWith( + 3, + 'Content-Type', + 'text/html', + ); + expect(mockResponseSend).toHaveBeenCalledWith(expectedHtml); + + // verify calls to the dependencies + expect(mockCreateTokenCookies).toHaveBeenCalledWith({ + tokensPayload: mockExchangeTokenPayload, + userPoolClientId: mockUserPoolClientId, + }); + expect(mockCreateTokenCookiesSetOptions).toHaveBeenCalledWith( + mockSetCookieOptions, + ); + expect(mockCreateSignInFlowProofCookies).toHaveBeenCalledWith({ + state: '', + pkce: '', + }); + expect(mockCreateAuthFlowProofCookiesRemoveOptions).toHaveBeenCalledWith( + mockSetCookieOptions, + ); + + expect( + mockCreateOnSignInCompletedRedirectIntermediate, + ).toHaveBeenCalledWith({ + redirectOnSignInComplete: expectedFinalRedirect, + }); + }, + ); +}); diff --git a/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInSignUpRequest.test.ts b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInSignUpRequest.test.ts new file mode 100644 index 00000000000..b60355ecfaf --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInSignUpRequest.test.ts @@ -0,0 +1,155 @@ +/** + * @jest-environment node + */ +import { OAuthConfig } from '@aws-amplify/core'; +import { CookieStorage } from 'aws-amplify/adapter-core'; + +import { handleSignInSignUpRequest } from '../../../src/auth/handlers/handleSignInSignUpRequest'; +import { + appendSetCookieHeaders, + createAuthFlowProofCookiesSetOptions, + createAuthFlowProofs, + createAuthorizeEndpoint, + createSignInFlowProofCookies, + createSignUpEndpoint, + createUrlSearchParamsForSignInSignUp, +} from '../../../src/auth/utils'; + +jest.mock('../../../src/auth/utils'); + +const mockAppendSetCookieHeaders = jest.mocked(appendSetCookieHeaders); +const mockCreateAuthFlowProofCookiesSetOptions = jest.mocked( + createAuthFlowProofCookiesSetOptions, +); +const mockCreateAuthFlowProofs = jest.mocked(createAuthFlowProofs); +const mockCreateAuthorizeEndpoint = jest.mocked(createAuthorizeEndpoint); +const mockCreateSignInFlowProofCookies = jest.mocked( + createSignInFlowProofCookies, +); +const mockCreateSignUpEndpoint = jest.mocked(createSignUpEndpoint); +const mockCreateUrlSearchParamsForSignInSignUp = jest.mocked( + createUrlSearchParamsForSignInSignUp, +); + +describe('handleSignInSignUpRequest', () => { + const mockCustomState = 'mockCustomState'; + const mockUserPoolClientId = 'mockUserPoolClientId'; + const mockOAuthConfig = { domain: 'mockDomain' } as unknown as OAuthConfig; + const mockOrigin = 'https://example.com'; + const mockSetCookieOptions: CookieStorage.SetCookieOptions = { + domain: '.example.com', + }; + const mockToCodeChallenge = jest.fn(() => 'mockCodeChallenge'); + + afterEach(() => { + mockAppendSetCookieHeaders.mockClear(); + mockCreateAuthFlowProofCookiesSetOptions.mockClear(); + mockCreateAuthFlowProofs.mockClear(); + mockCreateAuthorizeEndpoint.mockClear(); + mockCreateSignInFlowProofCookies.mockClear(); + mockCreateSignUpEndpoint.mockClear(); + mockCreateUrlSearchParamsForSignInSignUp.mockClear(); + mockToCodeChallenge.mockClear(); + }); + + test.each(['signIn' as const, 'signUp' as const])( + 'when type is %s it calls dependencies with correct params and returns a 302 response', + async type => { + const mockCreateAuthFlowProofsResult = { + codeVerifier: { + value: 'mockCodeVerifier', + method: 'S256' as const, + toCodeChallenge: jest.fn(), + }, + state: 'mockState', + }; + mockCreateAuthFlowProofs.mockReturnValueOnce( + mockCreateAuthFlowProofsResult, + ); + const mockCreateUrlSearchParamsForSignInSignUpResult = + new URLSearchParams([['value', 'isNotImportant']]); + mockCreateUrlSearchParamsForSignInSignUp.mockReturnValueOnce( + mockCreateUrlSearchParamsForSignInSignUpResult, + ); + mockCreateAuthorizeEndpoint.mockReturnValueOnce( + 'https://id.amazoncognito.com/oauth2/authorize', + ); + mockCreateSignUpEndpoint.mockReturnValueOnce( + 'https://id.amazoncognito.com/signup', + ); + const mockCreateSignInFlowProofCookiesResult = [ + { name: 'mockCookieName', value: 'mockValue' }, + ]; + mockCreateSignInFlowProofCookies.mockReturnValueOnce( + mockCreateSignInFlowProofCookiesResult, + ); + const mockCreateAuthFlowProofCookiesSetOptionsResult = { + domain: '.example.com', + path: '/', + httpOnly: true, + secure: true, + sameSite: 'lax' as const, + expires: new Date(), + }; + mockCreateAuthFlowProofCookiesSetOptions.mockReturnValueOnce( + mockCreateAuthFlowProofCookiesSetOptionsResult, + ); + mockAppendSetCookieHeaders.mockImplementationOnce(headers => { + headers.set('Set-Cookie', 'mockCookieName=mockValue'); + }); + const mockRequest = new Request('https://example.com/api/auth/sign-in'); + + const response = await handleSignInSignUpRequest({ + request: mockRequest, + userPoolClientId: mockUserPoolClientId, + oAuthConfig: mockOAuthConfig, + customState: mockCustomState, + origin: mockOrigin, + setCookieOptions: mockSetCookieOptions, + type, + }); + + // verify the returned response + expect(response.status).toBe(302); + expect(response.headers.get('Location')).toBe( + type === 'signIn' + ? 'https://id.amazoncognito.com/oauth2/authorize' + : 'https://id.amazoncognito.com/signup', + ); + expect(response.headers.get('Set-Cookie')).toBe( + 'mockCookieName=mockValue', + ); + + // verify the dependencies were called with correct params + expect(mockCreateAuthFlowProofs).toHaveBeenCalledWith({ + customState: mockCustomState, + }); + expect(mockCreateUrlSearchParamsForSignInSignUp).toHaveBeenCalledWith({ + url: mockRequest.url, + oAuthConfig: mockOAuthConfig, + userPoolClientId: mockUserPoolClientId, + state: mockCreateAuthFlowProofsResult.state, + origin: mockOrigin, + codeVerifier: mockCreateAuthFlowProofsResult.codeVerifier, + }); + + if (type === 'signIn') { + expect(mockCreateAuthorizeEndpoint).toHaveBeenCalledWith( + mockOAuthConfig.domain, + mockCreateUrlSearchParamsForSignInSignUpResult, + ); + } else { + expect(mockCreateSignUpEndpoint).toHaveBeenCalledWith( + mockOAuthConfig.domain, + mockCreateUrlSearchParamsForSignInSignUpResult, + ); + } + + expect(mockAppendSetCookieHeaders).toHaveBeenCalledWith( + expect.any(Headers), + mockCreateSignInFlowProofCookiesResult, + mockCreateAuthFlowProofCookiesSetOptionsResult, + ); + }, + ); +}); diff --git a/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInSignUpRequestForPagesRouter.test.ts b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInSignUpRequestForPagesRouter.test.ts new file mode 100644 index 00000000000..35184f0cca7 --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInSignUpRequestForPagesRouter.test.ts @@ -0,0 +1,180 @@ +/** + * @jest-environment node + */ +import { OAuthConfig } from '@aws-amplify/core'; +import { CookieStorage } from 'aws-amplify/adapter-core'; +import { NextApiRequest } from 'next'; + +import { handleSignInSignUpRequestForPagesRouter } from '../../../src/auth/handlers/handleSignInSignUpRequestForPagesRouter'; +import { + appendSetCookieHeadersToNextApiResponse, + createAuthFlowProofCookiesSetOptions, + createAuthFlowProofs, + createAuthorizeEndpoint, + createSignInFlowProofCookies, + createSignUpEndpoint, + createUrlSearchParamsForSignInSignUp, +} from '../../../src/auth/utils'; +import { createMockNextApiResponse } from '../testUtils'; + +jest.mock('../../../src/auth/utils'); + +const mockAppendSetCookieHeadersToNextApiResponse = jest.mocked( + appendSetCookieHeadersToNextApiResponse, +); +const mockCreateAuthFlowProofCookiesSetOptions = jest.mocked( + createAuthFlowProofCookiesSetOptions, +); +const mockCreateAuthFlowProofs = jest.mocked(createAuthFlowProofs); +const mockCreateAuthorizeEndpoint = jest.mocked(createAuthorizeEndpoint); +const mockCreateSignInFlowProofCookies = jest.mocked( + createSignInFlowProofCookies, +); +const mockCreateSignUpEndpoint = jest.mocked(createSignUpEndpoint); +const mockCreateUrlSearchParamsForSignInSignUp = jest.mocked( + createUrlSearchParamsForSignInSignUp, +); + +describe('handleSignInSignUpRequest', () => { + const mockCustomState = 'mockCustomState'; + const mockUserPoolClientId = 'mockUserPoolClientId'; + const mockOAuthConfig = { domain: 'mockDomain' } as unknown as OAuthConfig; + const mockOrigin = 'https://example.com'; + const mockSetCookieOptions: CookieStorage.SetCookieOptions = { + domain: '.example.com', + }; + const mockToCodeChallenge = jest.fn(() => 'mockCodeChallenge'); + + const { + mockResponseAppendHeader, + mockResponseEnd, + mockResponseStatus, + mockResponseSend, + mockResponseRedirect, + mockResponse, + } = createMockNextApiResponse(); + + afterEach(() => { + mockAppendSetCookieHeadersToNextApiResponse.mockClear(); + mockCreateAuthFlowProofCookiesSetOptions.mockClear(); + mockCreateAuthFlowProofs.mockClear(); + mockCreateAuthorizeEndpoint.mockClear(); + mockCreateSignInFlowProofCookies.mockClear(); + mockCreateSignUpEndpoint.mockClear(); + mockCreateUrlSearchParamsForSignInSignUp.mockClear(); + mockToCodeChallenge.mockClear(); + + mockResponseAppendHeader.mockClear(); + mockResponseEnd.mockClear(); + mockResponseStatus.mockClear(); + mockResponseSend.mockClear(); + mockResponseRedirect.mockClear(); + }); + + test.each(['signIn' as const, 'signUp' as const])( + 'when type is %s it calls dependencies with correct params and returns a 302 response', + async type => { + const mockCreateAuthFlowProofsResult = { + codeVerifier: { + value: 'mockCodeVerifier', + method: 'S256' as const, + toCodeChallenge: jest.fn(), + }, + state: 'mockState', + }; + mockCreateAuthFlowProofs.mockReturnValueOnce( + mockCreateAuthFlowProofsResult, + ); + const mockCreateUrlSearchParamsForSignInSignUpResult = + new URLSearchParams([['value', 'isNotImportant']]); + mockCreateUrlSearchParamsForSignInSignUp.mockReturnValueOnce( + mockCreateUrlSearchParamsForSignInSignUpResult, + ); + mockCreateAuthorizeEndpoint.mockReturnValueOnce( + 'https://id.amazoncognito.com/oauth2/authorize', + ); + mockCreateSignUpEndpoint.mockReturnValueOnce( + 'https://id.amazoncognito.com/signup', + ); + const mockCreateSignInFlowProofCookiesResult = [ + { name: 'mockCookieName', value: 'mockValue' }, + ]; + mockCreateSignInFlowProofCookies.mockReturnValueOnce( + mockCreateSignInFlowProofCookiesResult, + ); + const mockCreateAuthFlowProofCookiesSetOptionsResult = { + domain: '.example.com', + path: '/', + httpOnly: true, + secure: true, + sameSite: 'lax' as const, + expires: new Date(), + }; + mockCreateAuthFlowProofCookiesSetOptions.mockReturnValueOnce( + mockCreateAuthFlowProofCookiesSetOptionsResult, + ); + mockAppendSetCookieHeadersToNextApiResponse.mockImplementationOnce( + response => { + response.appendHeader('Set-Cookie', 'mockCookieName=mockValue'); + }, + ); + const mockRequest = { + url: 'https://example.com/api/auth/sign-in', + } as unknown as NextApiRequest; + + handleSignInSignUpRequestForPagesRouter({ + request: mockRequest, + response: mockResponse, + userPoolClientId: mockUserPoolClientId, + oAuthConfig: mockOAuthConfig, + customState: mockCustomState, + origin: mockOrigin, + setCookieOptions: mockSetCookieOptions, + type, + }); + + // verify the returned response + expect(mockResponseRedirect).toHaveBeenCalledWith( + 302, + type === 'signIn' + ? 'https://id.amazoncognito.com/oauth2/authorize' + : 'https://id.amazoncognito.com/signup', + ); + expect(mockResponseAppendHeader).toHaveBeenCalledWith( + 'Set-Cookie', + 'mockCookieName=mockValue', + ); + + // verify the dependencies were called with correct params + expect(mockCreateAuthFlowProofs).toHaveBeenCalledWith({ + customState: mockCustomState, + }); + expect(mockCreateUrlSearchParamsForSignInSignUp).toHaveBeenCalledWith({ + url: mockRequest.url, + oAuthConfig: mockOAuthConfig, + userPoolClientId: mockUserPoolClientId, + state: mockCreateAuthFlowProofsResult.state, + origin: mockOrigin, + codeVerifier: mockCreateAuthFlowProofsResult.codeVerifier, + }); + + if (type === 'signIn') { + expect(mockCreateAuthorizeEndpoint).toHaveBeenCalledWith( + mockOAuthConfig.domain, + mockCreateUrlSearchParamsForSignInSignUpResult, + ); + } else { + expect(mockCreateSignUpEndpoint).toHaveBeenCalledWith( + mockOAuthConfig.domain, + mockCreateUrlSearchParamsForSignInSignUpResult, + ); + } + + expect(mockAppendSetCookieHeadersToNextApiResponse).toHaveBeenCalledWith( + mockResponse, + mockCreateSignInFlowProofCookiesResult, + mockCreateAuthFlowProofCookiesSetOptionsResult, + ); + }, + ); +}); diff --git a/packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutCallbackRequest.test.ts b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutCallbackRequest.test.ts new file mode 100644 index 00000000000..dcd7db7f8cf --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutCallbackRequest.test.ts @@ -0,0 +1,289 @@ +/** + * @jest-environment node + */ +import { OAuthConfig } from '@aws-amplify/core'; +import { + AUTH_KEY_PREFIX, + CookieStorage, + createKeysForAuthStorage, +} from 'aws-amplify/adapter-core'; + +import { IS_SIGNING_OUT_COOKIE_NAME } from '../../../src/auth/constant'; +import { handleSignOutCallbackRequest } from '../../../src/auth/handlers/handleSignOutCallbackRequest'; +import { CreateAuthRoutesHandlersInput } from '../../../src/auth/types'; +import { + appendSetCookieHeaders, + createTokenCookiesRemoveOptions, + createTokenRemoveCookies, + getCookieValuesFromRequest, + revokeAuthNTokens, +} from '../../../src/auth/utils'; + +jest.mock('aws-amplify/adapter-core', () => ({ + ...jest.requireActual('aws-amplify/adapter-core'), + createKeysForAuthStorage: jest.fn(), +})); +jest.mock('../../../src/auth/utils'); + +const mockAppendSetCookieHeaders = jest.mocked(appendSetCookieHeaders); +const mockCreateTokenCookiesRemoveOptions = jest.mocked( + createTokenCookiesRemoveOptions, +); +const mockCreateTokenRemoveCookies = jest.mocked(createTokenRemoveCookies); +const mockGetCookieValuesFromRequest = jest.mocked(getCookieValuesFromRequest); +const mockRevokeAuthNTokens = jest.mocked(revokeAuthNTokens); +const mockCreateKeysForAuthStorage = jest.mocked(createKeysForAuthStorage); + +describe('handleSignOutCallbackRequest', () => { + const mockRequest = new Request( + 'https://example.com/api/auth/sign-out-callback', + ); + const mockHandlerInput: CreateAuthRoutesHandlersInput = {}; + const mockUserPoolClientId = 'userPoolClientId'; + const mockOAuthConfig = { domain: 'example.com' } as unknown as OAuthConfig; + const mockSetCookieOptions: CookieStorage.SetCookieOptions = { + domain: '.example.com', + }; + + afterEach(() => { + mockAppendSetCookieHeaders.mockClear(); + mockCreateTokenCookiesRemoveOptions.mockClear(); + mockCreateTokenRemoveCookies.mockClear(); + mockGetCookieValuesFromRequest.mockClear(); + mockRevokeAuthNTokens.mockClear(); + }); + + it(`returns a 400 response when the request does not have the "${IS_SIGNING_OUT_COOKIE_NAME}" cookie`, async () => { + mockGetCookieValuesFromRequest.mockReturnValueOnce({}); + + const response = await handleSignOutCallbackRequest({ + request: mockRequest, + handlerInput: mockHandlerInput, + userPoolClientId: mockUserPoolClientId, + oAuthConfig: mockOAuthConfig, + setCookieOptions: mockSetCookieOptions, + }); + + // verify the response + expect(response.status).toBe(400); + + // verify the calls to dependencies + expect(mockGetCookieValuesFromRequest).toHaveBeenCalledWith(mockRequest, [ + IS_SIGNING_OUT_COOKIE_NAME, + ]); + }); + + it('returns a 302 response to redirect to handlerInput.redirectOnSignOutComplete when the request cookies do not have a username', async () => { + mockGetCookieValuesFromRequest + .mockReturnValueOnce({ + [IS_SIGNING_OUT_COOKIE_NAME]: 'true', + }) + .mockReturnValueOnce({}); + + const response = await handleSignOutCallbackRequest({ + request: mockRequest, + handlerInput: mockHandlerInput, + userPoolClientId: mockUserPoolClientId, + oAuthConfig: mockOAuthConfig, + setCookieOptions: mockSetCookieOptions, + }); + + // verify the response + expect(response.status).toBe(302); + expect(response.headers.get('Location')).toBe('/'); + + // verify the calls to dependencies + expect(mockGetCookieValuesFromRequest).toHaveBeenCalledWith(mockRequest, [ + IS_SIGNING_OUT_COOKIE_NAME, + ]); + expect(mockGetCookieValuesFromRequest).toHaveBeenCalledWith(mockRequest, [ + `${AUTH_KEY_PREFIX}.${mockUserPoolClientId}.LastAuthUser`, + ]); + }); + + it('returns a 302 response to redirect to handlerInput.redirectOnSignOutComplete when the request cookies do not have a refresh token', async () => { + mockGetCookieValuesFromRequest + .mockReturnValueOnce({ + [IS_SIGNING_OUT_COOKIE_NAME]: 'true', + }) + .mockReturnValueOnce({ + [`${AUTH_KEY_PREFIX}.${mockUserPoolClientId}.LastAuthUser`]: 'a_user', + }) + .mockReturnValueOnce({}); + mockCreateKeysForAuthStorage.mockReturnValueOnce({ + refreshToken: 'mock_refresh_token_cookie_name', + } as any); + + const response = await handleSignOutCallbackRequest({ + request: mockRequest, + handlerInput: mockHandlerInput, + userPoolClientId: mockUserPoolClientId, + oAuthConfig: mockOAuthConfig, + setCookieOptions: mockSetCookieOptions, + }); + + // verify the response + expect(response.status).toBe(302); + expect(response.headers.get('Location')).toBe('/'); + + // verify the calls to dependencies + expect(mockGetCookieValuesFromRequest).toHaveBeenCalledWith(mockRequest, [ + IS_SIGNING_OUT_COOKIE_NAME, + ]); + expect(mockGetCookieValuesFromRequest).toHaveBeenCalledWith(mockRequest, [ + `${AUTH_KEY_PREFIX}.${mockUserPoolClientId}.LastAuthUser`, + ]); + expect(mockGetCookieValuesFromRequest).toHaveBeenCalledWith(mockRequest, [ + 'mock_refresh_token_cookie_name', + ]); + }); + + it('returns a 500 response when revoke token call returns an error', async () => { + mockGetCookieValuesFromRequest + .mockReturnValueOnce({ + [IS_SIGNING_OUT_COOKIE_NAME]: 'true', + }) + .mockReturnValueOnce({ + [`${AUTH_KEY_PREFIX}.${mockUserPoolClientId}.LastAuthUser`]: 'a_user', + }) + .mockReturnValueOnce({ + mock_refresh_token_cookie_name: 'mock_refresh_token', + }); + mockCreateKeysForAuthStorage.mockReturnValueOnce({ + refreshToken: 'mock_refresh_token_cookie_name', + } as any); + mockRevokeAuthNTokens.mockResolvedValueOnce({ error: 'invalid_request' }); + + const response = await handleSignOutCallbackRequest({ + request: mockRequest, + handlerInput: mockHandlerInput, + userPoolClientId: mockUserPoolClientId, + oAuthConfig: mockOAuthConfig, + setCookieOptions: mockSetCookieOptions, + }); + + // verify the response + expect(response.status).toBe(500); + expect(await response.text()).toBe('invalid_request'); + + // verify the calls to dependencies + expect(mockGetCookieValuesFromRequest).toHaveBeenCalledWith(mockRequest, [ + IS_SIGNING_OUT_COOKIE_NAME, + ]); + expect(mockGetCookieValuesFromRequest).toHaveBeenCalledWith(mockRequest, [ + `${AUTH_KEY_PREFIX}.${mockUserPoolClientId}.LastAuthUser`, + ]); + expect(mockGetCookieValuesFromRequest).toHaveBeenCalledWith(mockRequest, [ + 'mock_refresh_token_cookie_name', + ]); + expect(mockRevokeAuthNTokens).toHaveBeenCalledWith({ + refreshToken: 'mock_refresh_token', + userPoolClientId: mockUserPoolClientId, + endpointDomain: mockOAuthConfig.domain, + }); + }); + + test.each([ + [mockHandlerInput, '/'], + [ + { ...mockHandlerInput, redirectOnSignOutComplete: '/sign-in' }, + '/sign-in', + ], + ] as [CreateAuthRoutesHandlersInput, string][])( + `returns a 302 response with expected redirect location: with handlerInput: %p, expected redirect location: %s`, + async (handlerInput, expectedFinalRedirect) => { + mockGetCookieValuesFromRequest + .mockReturnValueOnce({ + [IS_SIGNING_OUT_COOKIE_NAME]: 'true', + }) + .mockReturnValueOnce({ + [`${AUTH_KEY_PREFIX}.${mockUserPoolClientId}.LastAuthUser`]: 'a_user', + }) + .mockReturnValueOnce({ + mock_refresh_token_cookie_name: 'mock_refresh_token', + }); + const mockCreateKeysForAuthStorageResult = { + refreshToken: 'mock_refresh_token_cookie_name', + } as any; + mockCreateKeysForAuthStorage.mockReturnValueOnce( + mockCreateKeysForAuthStorageResult, + ); + mockRevokeAuthNTokens.mockResolvedValueOnce({}); + const mockCreateTokenRemoveCookiesResult = [ + { + name: 'mock_cookie1', + value: '', + }, + { + name: 'mock_cookie1', + value: '', + }, + ]; + mockCreateTokenRemoveCookies.mockReturnValueOnce( + mockCreateTokenRemoveCookiesResult, + ); + const mockCreateTokenCookiesRemoveOptionsResult = { + domain: mockSetCookieOptions.domain, + path: '/', + expires: new Date('1970-01-01'), + }; + mockCreateTokenCookiesRemoveOptions.mockReturnValueOnce( + mockCreateTokenCookiesRemoveOptionsResult, + ); + mockAppendSetCookieHeaders.mockImplementationOnce(headers => { + headers.append( + 'Set-Cookie', + 'mock_cookie1=; Domain=.example.com; Path=/', + ); + headers.append( + 'Set-Cookie', + 'mock_cookie2=; Domain=.example.com; Path=/', + ); + }); + + const response = await handleSignOutCallbackRequest({ + request: mockRequest, + handlerInput, + userPoolClientId: mockUserPoolClientId, + oAuthConfig: mockOAuthConfig, + setCookieOptions: mockSetCookieOptions, + }); + + // verify the calls to dependencies + expect(response.status).toBe(302); + expect(response.headers.get('Location')).toBe(expectedFinalRedirect); + expect(response.headers.get('Set-Cookie')).toBe( + 'mock_cookie1=; Domain=.example.com; Path=/, mock_cookie2=; Domain=.example.com; Path=/', + ); + + // verify the calls to dependencies + expect(mockGetCookieValuesFromRequest).toHaveBeenCalledWith(mockRequest, [ + IS_SIGNING_OUT_COOKIE_NAME, + ]); + expect(mockGetCookieValuesFromRequest).toHaveBeenCalledWith(mockRequest, [ + `${AUTH_KEY_PREFIX}.${mockUserPoolClientId}.LastAuthUser`, + ]); + expect(mockGetCookieValuesFromRequest).toHaveBeenCalledWith(mockRequest, [ + 'mock_refresh_token_cookie_name', + ]); + expect(mockRevokeAuthNTokens).toHaveBeenCalledWith({ + refreshToken: 'mock_refresh_token', + userPoolClientId: mockUserPoolClientId, + endpointDomain: mockOAuthConfig.domain, + }); + expect(mockCreateTokenRemoveCookies).toHaveBeenCalledWith([ + ...Object.values(mockCreateKeysForAuthStorageResult), + `${AUTH_KEY_PREFIX}.${mockUserPoolClientId}.LastAuthUser`, + IS_SIGNING_OUT_COOKIE_NAME, + ]); + expect(mockCreateTokenCookiesRemoveOptions).toHaveBeenCalledWith( + mockSetCookieOptions, + ); + expect(mockAppendSetCookieHeaders).toHaveBeenCalledWith( + expect.any(Headers), + mockCreateTokenRemoveCookiesResult, + mockCreateTokenCookiesRemoveOptionsResult, + ); + }, + ); +}); diff --git a/packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutCallbackRequestForPagesRouter.test.ts b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutCallbackRequestForPagesRouter.test.ts new file mode 100644 index 00000000000..e007474e438 --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutCallbackRequestForPagesRouter.test.ts @@ -0,0 +1,337 @@ +/** + * @jest-environment node + */ +import { OAuthConfig } from '@aws-amplify/core'; +import { + AUTH_KEY_PREFIX, + CookieStorage, + createKeysForAuthStorage, +} from 'aws-amplify/adapter-core'; +import { NextApiRequest } from 'next'; + +import { IS_SIGNING_OUT_COOKIE_NAME } from '../../../src/auth/constant'; +import { handleSignOutCallbackRequestForPagesRouter } from '../../../src/auth/handlers/handleSignOutCallbackRequestForPagesRouter'; +import { CreateAuthRoutesHandlersInput } from '../../../src/auth/types'; +import { + appendSetCookieHeadersToNextApiResponse, + createTokenCookiesRemoveOptions, + createTokenRemoveCookies, + getCookieValuesFromNextApiRequest, + revokeAuthNTokens, +} from '../../../src/auth/utils'; +import { createMockNextApiResponse } from '../testUtils'; + +jest.mock('aws-amplify/adapter-core', () => ({ + ...jest.requireActual('aws-amplify/adapter-core'), + createKeysForAuthStorage: jest.fn(), +})); +jest.mock('../../../src/auth/utils'); + +const mockAppendSetCookieHeadersToNextApiResponse = jest.mocked( + appendSetCookieHeadersToNextApiResponse, +); +const mockCreateTokenCookiesRemoveOptions = jest.mocked( + createTokenCookiesRemoveOptions, +); +const mockCreateTokenRemoveCookies = jest.mocked(createTokenRemoveCookies); +const mockGetCookieValuesFromNextApiRequest = jest.mocked( + getCookieValuesFromNextApiRequest, +); +const mockRevokeAuthNTokens = jest.mocked(revokeAuthNTokens); +const mockCreateKeysForAuthStorage = jest.mocked(createKeysForAuthStorage); + +describe('handleSignOutCallbackRequest', () => { + const mockRequest = { + cookies: {}, + } as unknown as NextApiRequest; + const mockHandlerInput: CreateAuthRoutesHandlersInput = {}; + const mockUserPoolClientId = 'userPoolClientId'; + const mockOAuthConfig = { domain: 'example.com' } as unknown as OAuthConfig; + const mockSetCookieOptions: CookieStorage.SetCookieOptions = { + domain: '.example.com', + }; + const { + mockResponseAppendHeader, + mockResponseEnd, + mockResponseStatus, + mockResponseSend, + mockResponseRedirect, + mockResponse, + } = createMockNextApiResponse(); + + afterEach(() => { + mockAppendSetCookieHeadersToNextApiResponse.mockClear(); + mockCreateTokenCookiesRemoveOptions.mockClear(); + mockCreateTokenRemoveCookies.mockClear(); + mockGetCookieValuesFromNextApiRequest.mockClear(); + mockRevokeAuthNTokens.mockClear(); + + mockResponseAppendHeader.mockClear(); + mockResponseEnd.mockClear(); + mockResponseStatus.mockClear(); + mockResponseSend.mockClear(); + mockResponseRedirect.mockClear(); + }); + + it(`returns a 400 response when the request does not have the "${IS_SIGNING_OUT_COOKIE_NAME}" cookie`, async () => { + mockGetCookieValuesFromNextApiRequest.mockReturnValueOnce({}); + + await handleSignOutCallbackRequestForPagesRouter({ + request: mockRequest, + response: mockResponse, + handlerInput: mockHandlerInput, + userPoolClientId: mockUserPoolClientId, + oAuthConfig: mockOAuthConfig, + setCookieOptions: mockSetCookieOptions, + }); + + // verify the response + expect(mockResponseStatus).toHaveBeenCalledWith(400); + expect(mockResponseEnd).toHaveBeenCalled(); + + // verify the calls to dependencies + expect(mockGetCookieValuesFromNextApiRequest).toHaveBeenCalledWith( + mockRequest, + [IS_SIGNING_OUT_COOKIE_NAME], + ); + }); + + it('returns a 302 response to redirect to handlerInput.redirectOnSignOutComplete when the request cookies do not have a username', async () => { + mockGetCookieValuesFromNextApiRequest + .mockReturnValueOnce({ + [IS_SIGNING_OUT_COOKIE_NAME]: 'true', + }) + .mockReturnValueOnce({}); + + await handleSignOutCallbackRequestForPagesRouter({ + request: mockRequest, + response: mockResponse, + handlerInput: mockHandlerInput, + userPoolClientId: mockUserPoolClientId, + oAuthConfig: mockOAuthConfig, + setCookieOptions: mockSetCookieOptions, + }); + + // verify the response + expect(mockResponseRedirect).toHaveBeenCalledWith(302, '/'); + + // verify the calls to dependencies + expect(mockGetCookieValuesFromNextApiRequest).toHaveBeenCalledWith( + mockRequest, + [IS_SIGNING_OUT_COOKIE_NAME], + ); + expect(mockGetCookieValuesFromNextApiRequest).toHaveBeenCalledWith( + mockRequest, + [`${AUTH_KEY_PREFIX}.${mockUserPoolClientId}.LastAuthUser`], + ); + }); + + it('returns a 302 response to redirect to handlerInput.redirectOnSignOutComplete when the request cookies do not have a refresh token', async () => { + mockGetCookieValuesFromNextApiRequest + .mockReturnValueOnce({ + [IS_SIGNING_OUT_COOKIE_NAME]: 'true', + }) + .mockReturnValueOnce({ + [`${AUTH_KEY_PREFIX}.${mockUserPoolClientId}.LastAuthUser`]: 'a_user', + }) + .mockReturnValueOnce({}); + mockCreateKeysForAuthStorage.mockReturnValueOnce({ + refreshToken: 'mock_refresh_token_cookie_name', + } as any); + + await handleSignOutCallbackRequestForPagesRouter({ + request: mockRequest, + response: mockResponse, + handlerInput: mockHandlerInput, + userPoolClientId: mockUserPoolClientId, + oAuthConfig: mockOAuthConfig, + setCookieOptions: mockSetCookieOptions, + }); + + // verify the response + expect(mockResponseRedirect).toHaveBeenCalledWith(302, '/'); + + // verify the calls to dependencies + expect(mockGetCookieValuesFromNextApiRequest).toHaveBeenCalledWith( + mockRequest, + [IS_SIGNING_OUT_COOKIE_NAME], + ); + expect(mockGetCookieValuesFromNextApiRequest).toHaveBeenCalledWith( + mockRequest, + [`${AUTH_KEY_PREFIX}.${mockUserPoolClientId}.LastAuthUser`], + ); + expect(mockGetCookieValuesFromNextApiRequest).toHaveBeenCalledWith( + mockRequest, + ['mock_refresh_token_cookie_name'], + ); + }); + + it('returns a 500 response when revoke token call returns an error', async () => { + mockGetCookieValuesFromNextApiRequest + .mockReturnValueOnce({ + [IS_SIGNING_OUT_COOKIE_NAME]: 'true', + }) + .mockReturnValueOnce({ + [`${AUTH_KEY_PREFIX}.${mockUserPoolClientId}.LastAuthUser`]: 'a_user', + }) + .mockReturnValueOnce({ + mock_refresh_token_cookie_name: 'mock_refresh_token', + }); + mockCreateKeysForAuthStorage.mockReturnValueOnce({ + refreshToken: 'mock_refresh_token_cookie_name', + } as any); + mockRevokeAuthNTokens.mockResolvedValueOnce({ error: 'invalid_request' }); + + await handleSignOutCallbackRequestForPagesRouter({ + request: mockRequest, + response: mockResponse, + handlerInput: mockHandlerInput, + userPoolClientId: mockUserPoolClientId, + oAuthConfig: mockOAuthConfig, + setCookieOptions: mockSetCookieOptions, + }); + + // verify the response + expect(mockResponseStatus).toHaveBeenCalledWith(500); + expect(mockResponseSend).toHaveBeenCalledWith('invalid_request'); + + // verify the calls to dependencies + expect(mockGetCookieValuesFromNextApiRequest).toHaveBeenCalledWith( + mockRequest, + [IS_SIGNING_OUT_COOKIE_NAME], + ); + expect(mockGetCookieValuesFromNextApiRequest).toHaveBeenCalledWith( + mockRequest, + [`${AUTH_KEY_PREFIX}.${mockUserPoolClientId}.LastAuthUser`], + ); + expect(mockGetCookieValuesFromNextApiRequest).toHaveBeenCalledWith( + mockRequest, + ['mock_refresh_token_cookie_name'], + ); + expect(mockRevokeAuthNTokens).toHaveBeenCalledWith({ + refreshToken: 'mock_refresh_token', + userPoolClientId: mockUserPoolClientId, + endpointDomain: mockOAuthConfig.domain, + }); + }); + + test.each([ + [mockHandlerInput, '/'], + [ + { ...mockHandlerInput, redirectOnSignOutComplete: '/sign-in' }, + '/sign-in', + ], + ] as [CreateAuthRoutesHandlersInput, string][])( + `returns a 302 response with expected redirect location: with handlerInput: %p, expected redirect location: %s`, + async (handlerInput, expectedFinalRedirect) => { + mockGetCookieValuesFromNextApiRequest + .mockReturnValueOnce({ + [IS_SIGNING_OUT_COOKIE_NAME]: 'true', + }) + .mockReturnValueOnce({ + [`${AUTH_KEY_PREFIX}.${mockUserPoolClientId}.LastAuthUser`]: 'a_user', + }) + .mockReturnValueOnce({ + mock_refresh_token_cookie_name: 'mock_refresh_token', + }); + const mockCreateKeysForAuthStorageResult = { + refreshToken: 'mock_refresh_token_cookie_name', + } as any; + mockCreateKeysForAuthStorage.mockReturnValueOnce( + mockCreateKeysForAuthStorageResult, + ); + mockRevokeAuthNTokens.mockResolvedValueOnce({}); + const mockCreateTokenRemoveCookiesResult = [ + { + name: 'mock_cookie1', + value: '', + }, + { + name: 'mock_cookie1', + value: '', + }, + ]; + mockCreateTokenRemoveCookies.mockReturnValueOnce( + mockCreateTokenRemoveCookiesResult, + ); + const mockCreateTokenCookiesRemoveOptionsResult = { + domain: mockSetCookieOptions.domain, + path: '/', + expires: new Date('1970-01-01'), + }; + mockCreateTokenCookiesRemoveOptions.mockReturnValueOnce( + mockCreateTokenCookiesRemoveOptionsResult, + ); + mockAppendSetCookieHeadersToNextApiResponse.mockImplementationOnce( + response => { + response.appendHeader( + 'Set-Cookie', + 'mock_cookie1=; Domain=.example.com; Path=/', + ); + response.appendHeader( + 'Set-Cookie', + 'mock_cookie2=; Domain=.example.com; Path=/', + ); + }, + ); + + await handleSignOutCallbackRequestForPagesRouter({ + request: mockRequest, + response: mockResponse, + handlerInput, + userPoolClientId: mockUserPoolClientId, + oAuthConfig: mockOAuthConfig, + setCookieOptions: mockSetCookieOptions, + }); + + // verify the response + expect(mockResponseRedirect).toHaveBeenCalledWith( + 302, + expectedFinalRedirect, + ); + expect(mockResponseAppendHeader).toHaveBeenCalledTimes(2); + expect(mockResponseAppendHeader).toHaveBeenNthCalledWith( + 1, + 'Set-Cookie', + 'mock_cookie1=; Domain=.example.com; Path=/', + ); + expect(mockResponseAppendHeader).toHaveBeenNthCalledWith( + 2, + 'Set-Cookie', + 'mock_cookie2=; Domain=.example.com; Path=/', + ); + + // verify the calls to dependencies + expect(mockGetCookieValuesFromNextApiRequest).toHaveBeenCalledWith( + mockRequest, + [IS_SIGNING_OUT_COOKIE_NAME], + ); + expect(mockGetCookieValuesFromNextApiRequest).toHaveBeenCalledWith( + mockRequest, + [`${AUTH_KEY_PREFIX}.${mockUserPoolClientId}.LastAuthUser`], + ); + expect(mockGetCookieValuesFromNextApiRequest).toHaveBeenCalledWith( + mockRequest, + ['mock_refresh_token_cookie_name'], + ); + expect(mockRevokeAuthNTokens).toHaveBeenCalledWith({ + refreshToken: 'mock_refresh_token', + userPoolClientId: mockUserPoolClientId, + endpointDomain: mockOAuthConfig.domain, + }); + expect(mockCreateTokenRemoveCookies).toHaveBeenCalledWith([ + ...Object.values(mockCreateKeysForAuthStorageResult), + `${AUTH_KEY_PREFIX}.${mockUserPoolClientId}.LastAuthUser`, + IS_SIGNING_OUT_COOKIE_NAME, + ]); + expect(mockCreateTokenCookiesRemoveOptions).toHaveBeenCalledWith( + mockSetCookieOptions, + ); + expect(mockAppendSetCookieHeadersToNextApiResponse).toHaveBeenCalledWith( + mockResponse, + mockCreateTokenRemoveCookiesResult, + mockCreateTokenCookiesRemoveOptionsResult, + ); + }, + ); +}); diff --git a/packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutRequest.test.ts b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutRequest.test.ts new file mode 100644 index 00000000000..a56acf5205b --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutRequest.test.ts @@ -0,0 +1,105 @@ +/** + * @jest-environment node + */ +import { OAuthConfig } from '@aws-amplify/core'; + +import { handleSignOutRequest } from '../../../src/auth/handlers/handleSignOutRequest'; +import { + appendSetCookieHeaders, + createAuthFlowProofCookiesSetOptions, + createLogoutEndpoint, + createSignOutFlowProofCookies, + resolveRedirectSignOutUrl, +} from '../../../src/auth/utils'; + +jest.mock('../../../src/auth/utils'); + +const mockAppendSetCookieHeaders = jest.mocked(appendSetCookieHeaders); +const mockCreateAuthFlowProofCookiesSetOptions = jest.mocked( + createAuthFlowProofCookiesSetOptions, +); +const mockCreateLogoutEndpoint = jest.mocked(createLogoutEndpoint); +const mockCreateSignOutFlowProofCookies = jest.mocked( + createSignOutFlowProofCookies, +); +const mockResolveRedirectSignOutUrl = jest.mocked(resolveRedirectSignOutUrl); + +describe('handleSignOutRequest', () => { + afterEach(() => { + mockAppendSetCookieHeaders.mockClear(); + mockCreateAuthFlowProofCookiesSetOptions.mockClear(); + mockCreateLogoutEndpoint.mockClear(); + mockCreateSignOutFlowProofCookies.mockClear(); + mockResolveRedirectSignOutUrl.mockClear(); + }); + + it('returns a 302 response with the correct headers and cookies', async () => { + const mockOAuthConfig = { domain: 'mockDomain' } as unknown as OAuthConfig; + const mockUserPoolClientId = 'mockUserPoolClientId'; + const mockOrigin = 'https://example.com'; + const mockSetCookieOptions = { domain: '.example.com' }; + + mockResolveRedirectSignOutUrl.mockReturnValueOnce( + 'https://example.com/sign-out', + ); + mockCreateLogoutEndpoint.mockReturnValueOnce( + 'https://id.amazoncognito.com/logout', + ); + const mockCreateSignOutFlowProofCookiesResult = [ + { + name: 'mockName', + value: 'mockValue', + }, + ]; + mockCreateSignOutFlowProofCookies.mockReturnValueOnce( + mockCreateSignOutFlowProofCookiesResult, + ); + const mockCreateAuthFlowProofCookiesSetOptionsResult = { + domain: mockSetCookieOptions.domain, + path: '/', + httpOnly: true, + secure: true, + sameSite: 'lax' as const, + expires: new Date('2024-09-18'), + }; + mockCreateAuthFlowProofCookiesSetOptions.mockReturnValueOnce( + mockCreateAuthFlowProofCookiesSetOptionsResult, + ); + mockAppendSetCookieHeaders.mockImplementationOnce(headers => { + headers.append('Set-Cookie', 'mockName=mockValue'); + }); + + const response = await handleSignOutRequest({ + oAuthConfig: mockOAuthConfig, + userPoolClientId: mockUserPoolClientId, + origin: mockOrigin, + setCookieOptions: mockSetCookieOptions, + }); + + // verify the response + expect(response.status).toBe(302); + expect(response.headers.get('Location')).toBe( + 'https://id.amazoncognito.com/logout', + ); + expect(response.headers.get('Set-Cookie')).toBe('mockName=mockValue'); + + // verify calls to dependencies + expect(mockResolveRedirectSignOutUrl).toHaveBeenCalledWith( + mockOrigin, + mockOAuthConfig, + ); + expect(mockCreateLogoutEndpoint).toHaveBeenCalledWith( + mockOAuthConfig.domain, + expect.any(URLSearchParams), + ); + expect(mockCreateSignOutFlowProofCookies).toHaveBeenCalled(); + expect(mockCreateAuthFlowProofCookiesSetOptions).toHaveBeenCalledWith( + mockSetCookieOptions, + ); + expect(mockAppendSetCookieHeaders).toHaveBeenCalledWith( + expect.any(Headers), + mockCreateSignOutFlowProofCookiesResult, + mockCreateAuthFlowProofCookiesSetOptionsResult, + ); + }); +}); diff --git a/packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutRequestForPagesRouter.test.ts b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutRequestForPagesRouter.test.ts new file mode 100644 index 00000000000..10c4cd66e4f --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutRequestForPagesRouter.test.ts @@ -0,0 +1,124 @@ +/** + * @jest-environment node + */ +import { OAuthConfig } from '@aws-amplify/core'; + +import { handleSignOutRequestForPagesRouter } from '../../../src/auth/handlers/handleSignOutRequestForPagesRouter'; +import { + appendSetCookieHeadersToNextApiResponse, + createAuthFlowProofCookiesSetOptions, + createLogoutEndpoint, + createSignOutFlowProofCookies, + resolveRedirectSignOutUrl, +} from '../../../src/auth/utils'; +import { createMockNextApiResponse } from '../testUtils'; + +jest.mock('../../../src/auth/utils'); + +const mockAppendSetCookieHeadersToNextApiResponse = jest.mocked( + appendSetCookieHeadersToNextApiResponse, +); +const mockCreateAuthFlowProofCookiesSetOptions = jest.mocked( + createAuthFlowProofCookiesSetOptions, +); +const mockCreateLogoutEndpoint = jest.mocked(createLogoutEndpoint); +const mockCreateSignOutFlowProofCookies = jest.mocked( + createSignOutFlowProofCookies, +); +const mockResolveRedirectSignOutUrl = jest.mocked(resolveRedirectSignOutUrl); + +describe('handleSignOutRequest', () => { + const { + mockResponseAppendHeader, + mockResponseEnd, + mockResponseStatus, + mockResponseSend, + mockResponseRedirect, + mockResponse, + } = createMockNextApiResponse(); + + afterEach(() => { + mockAppendSetCookieHeadersToNextApiResponse.mockClear(); + mockCreateAuthFlowProofCookiesSetOptions.mockClear(); + mockCreateLogoutEndpoint.mockClear(); + mockCreateSignOutFlowProofCookies.mockClear(); + mockResolveRedirectSignOutUrl.mockClear(); + + mockResponseAppendHeader.mockClear(); + mockResponseEnd.mockClear(); + mockResponseStatus.mockClear(); + mockResponseSend.mockClear(); + mockResponseRedirect.mockClear(); + }); + + it('returns a 302 response with the correct headers and cookies', () => { + const mockOAuthConfig = { domain: 'mockDomain' } as unknown as OAuthConfig; + const mockUserPoolClientId = 'mockUserPoolClientId'; + const mockOrigin = 'https://example.com'; + const mockSetCookieOptions = { domain: '.example.com' }; + + mockResolveRedirectSignOutUrl.mockReturnValueOnce( + 'https://example.com/sign-out', + ); + mockCreateLogoutEndpoint.mockReturnValueOnce( + 'https://id.amazoncognito.com/logout', + ); + const mockCreateSignOutFlowProofCookiesResult = [ + { + name: 'mockName', + value: 'mockValue', + }, + ]; + mockCreateSignOutFlowProofCookies.mockReturnValueOnce( + mockCreateSignOutFlowProofCookiesResult, + ); + const mockCreateAuthFlowProofCookiesSetOptionsResult = { + domain: mockSetCookieOptions.domain, + path: '/', + httpOnly: true, + secure: true, + sameSite: 'lax' as const, + expires: new Date('2024-09-18'), + }; + mockCreateAuthFlowProofCookiesSetOptions.mockReturnValueOnce( + mockCreateAuthFlowProofCookiesSetOptionsResult, + ); + mockAppendSetCookieHeadersToNextApiResponse.mockImplementationOnce( + response => { + response.appendHeader('Set-Cookie', 'mockName=mockValue'); + }, + ); + + handleSignOutRequestForPagesRouter({ + response: mockResponse, + oAuthConfig: mockOAuthConfig, + userPoolClientId: mockUserPoolClientId, + origin: mockOrigin, + setCookieOptions: mockSetCookieOptions, + }); + + // verify the response + expect(mockResponseRedirect).toHaveBeenCalledWith( + 302, + 'https://id.amazoncognito.com/logout', + ); + expect(mockResponseAppendHeader).toHaveBeenCalledWith( + 'Set-Cookie', + 'mockName=mockValue', + ); + + // verify calls to dependencies + expect(mockResolveRedirectSignOutUrl).toHaveBeenCalledWith( + mockOrigin, + mockOAuthConfig, + ); + expect(mockCreateLogoutEndpoint).toHaveBeenCalledWith( + mockOAuthConfig.domain, + expect.any(URLSearchParams), + ); + expect(mockCreateSignOutFlowProofCookies).toHaveBeenCalled(); + expect(mockCreateAuthFlowProofCookiesSetOptions).toHaveBeenCalledWith( + mockSetCookieOptions, + ); + }); +}); diff --git a/packages/adapter-nextjs/__tests__/auth/testUtils.ts b/packages/adapter-nextjs/__tests__/auth/testUtils.ts new file mode 100644 index 00000000000..f66acd68df5 --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/testUtils.ts @@ -0,0 +1,28 @@ +import { NextApiResponse } from 'next'; + +export const createMockNextApiResponse = () => { + const mockResponseAppendHeader = jest.fn(); + const mockResponseEnd = jest.fn(); + const mockResponseStatus = jest.fn(); + const mockResponseSend = jest.fn(); + const mockResponseRedirect = jest.fn(); + const mockResponse = { + appendHeader: mockResponseAppendHeader, + status: mockResponseStatus, + send: mockResponseSend, + redirect: mockResponseRedirect, + end: mockResponseEnd, + } as unknown as NextApiResponse; + + mockResponseAppendHeader.mockImplementation(() => mockResponse); + mockResponseStatus.mockImplementation(() => mockResponse); + + return { + mockResponseAppendHeader, + mockResponseEnd, + mockResponseStatus, + mockResponseSend, + mockResponseRedirect, + mockResponse, + }; +}; diff --git a/packages/adapter-nextjs/__tests__/auth/utils/__snapshots__/createOnSignInCompletedRedirectIntermediate.test.ts.snap b/packages/adapter-nextjs/__tests__/auth/utils/__snapshots__/createOnSignInCompletedRedirectIntermediate.test.ts.snap new file mode 100644 index 00000000000..3b6fc307c1e --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/__snapshots__/createOnSignInCompletedRedirectIntermediate.test.ts.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`createOnSignInCompletedRedirectIntermediate returns html with script that redirects to the redirectUrl 1`] = ` +" + + +
+If you are not redirected automatically, follow this link to the new page.
+ +" +`; diff --git a/packages/adapter-nextjs/__tests__/auth/utils/appendSetCookieHeaders.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/appendSetCookieHeaders.test.ts new file mode 100644 index 00000000000..95fd51408df --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/appendSetCookieHeaders.test.ts @@ -0,0 +1,27 @@ +import { CookieStorage } from 'aws-amplify/adapter-core'; + +import { appendSetCookieHeaders } from '../../../src/auth/utils'; + +describe('appendSetCookieHeaders', () => { + it('appends Set-Cookie headers to the headers object', () => { + const headers = new Headers(); + const cookies = [ + { name: 'cookie1', value: 'value1' }, + { name: 'cookie2', value: 'value2' }, + ]; + const setCookieOptions: CookieStorage.SetCookieOptions = { + domain: 'example.com', + sameSite: 'strict', + path: '/', + }; + + appendSetCookieHeaders(headers, cookies, setCookieOptions); + + expect(headers.get('Set-Cookie')).toEqual( + [ + 'cookie1=value1;Domain=example.com;SameSite=strict;Path=/', + 'cookie2=value2;Domain=example.com;SameSite=strict;Path=/', + ].join(', '), + ); + }); +}); diff --git a/packages/adapter-nextjs/__tests__/auth/utils/appendSetCookieHeadersToNextApiResponse.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/appendSetCookieHeadersToNextApiResponse.test.ts new file mode 100644 index 00000000000..4eb8f9c1172 --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/appendSetCookieHeadersToNextApiResponse.test.ts @@ -0,0 +1,40 @@ +import { CookieStorage } from 'aws-amplify/adapter-core'; +import { NextApiResponse } from 'next'; + +import { appendSetCookieHeadersToNextApiResponse } from '../../../src/auth/utils'; + +describe('appendSetCookieHeadersToNextApiResponse', () => { + it('appends Set-Cookie headers to the response.headers object', () => { + const mockAppendHeader = jest.fn(); + const mockNextApiResponse = { + appendHeader: mockAppendHeader, + } as unknown as NextApiResponse; + const cookies = [ + { name: 'cookie1', value: 'value1' }, + { name: 'cookie2', value: 'value2' }, + ]; + const setCookieOptions: CookieStorage.SetCookieOptions = { + domain: 'example.com', + sameSite: 'strict', + path: '/', + }; + + appendSetCookieHeadersToNextApiResponse( + mockNextApiResponse, + cookies, + setCookieOptions, + ); + + expect(mockAppendHeader).toHaveBeenCalledTimes(2); + expect(mockAppendHeader).toHaveBeenNthCalledWith( + 1, + 'Set-Cookie', + 'cookie1=value1;Domain=example.com;SameSite=strict;Path=/', + ); + expect(mockAppendHeader).toHaveBeenNthCalledWith( + 2, + 'Set-Cookie', + 'cookie2=value2;Domain=example.com;SameSite=strict;Path=/', + ); + }); +}); diff --git a/packages/adapter-nextjs/__tests__/auth/utils/authFlowProofCookies.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/authFlowProofCookies.test.ts new file mode 100644 index 00000000000..6dffd7f2444 --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/authFlowProofCookies.test.ts @@ -0,0 +1,84 @@ +import { CookieStorage } from 'aws-amplify/adapter-core'; + +import { + AUTH_FLOW_PROOF_COOKIE_EXPIRY, + IS_SIGNING_OUT_COOKIE_NAME, + PKCE_COOKIE_NAME, + STATE_COOKIE_NAME, +} from '../../../src/auth/constant'; +import { + createAuthFlowProofCookiesRemoveOptions, + createAuthFlowProofCookiesSetOptions, + createSignInFlowProofCookies, + createSignOutFlowProofCookies, +} from '../../../src/auth/utils/authFlowProofCookies'; + +describe('createSignInFlowProofCookies', () => { + it('returns PKCE and state cookies', () => { + const state = 'state'; + const pkce = 'pkce'; + const cookies = createSignInFlowProofCookies({ state, pkce }); + expect(cookies.sort()).toEqual( + [ + { name: PKCE_COOKIE_NAME, value: pkce }, + { name: STATE_COOKIE_NAME, value: state }, + ].sort(), + ); + }); +}); + +describe('createSignOutFlowProofCookies', () => { + it('returns IS_SIGNING_OUT cookie', () => { + const cookies = createSignOutFlowProofCookies(); + expect(cookies).toEqual([ + { name: IS_SIGNING_OUT_COOKIE_NAME, value: 'true' }, + ]); + }); +}); + +describe('createAuthFlowProofCookiesSetOptions', () => { + let nowSpy: jest.SpyInstance; + + beforeAll(() => { + nowSpy = jest.spyOn(Date, 'now').mockReturnValue(0); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it('returns expected cookie serialization options with specified parameters', () => { + const setCookieOptions: CookieStorage.SetCookieOptions = { + domain: '.example.com', + sameSite: 'strict', + }; + + const options = createAuthFlowProofCookiesSetOptions(setCookieOptions); + + expect(nowSpy).toHaveBeenCalled(); + expect(options).toEqual({ + domain: setCookieOptions?.domain, + path: '/', + httpOnly: true, + secure: true, + sameSite: 'lax' as const, + expires: new Date(0 + AUTH_FLOW_PROOF_COOKIE_EXPIRY), + }); + }); +}); + +describe('createAuthFlowProofCookiesRemoveOptions', () => { + it('returns expected cookie removal options with specified parameters', () => { + const setCookieOptions: CookieStorage.SetCookieOptions = { + domain: '.example.com', + }; + + const options = createAuthFlowProofCookiesRemoveOptions(setCookieOptions); + + expect(options).toEqual({ + domain: setCookieOptions?.domain, + path: '/', + expires: new Date('1970-01-01'), + }); + }); +}); diff --git a/packages/adapter-nextjs/__tests__/auth/utils/authNTokens.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/authNTokens.test.ts new file mode 100644 index 00000000000..54f8874497c --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/authNTokens.test.ts @@ -0,0 +1,199 @@ +import { OAuthConfig } from '@aws-amplify/core'; + +import { OAuthTokenExchangeResult } from '../../../src/auth/types'; +import { + exchangeAuthNTokens, + revokeAuthNTokens, +} from '../../../src/auth/utils'; + +const mockFetchFunc = jest.fn(); +const mockFetch = () => { + const originalFetch = global.fetch; + global.fetch = mockFetchFunc; + + return originalFetch; +}; + +const unMockFetch = (originalFetch: typeof global.fetch) => { + global.fetch = originalFetch; +}; + +// The following tests also covered the following functions exported from `src/auth/utils/cognitoHostedUIEndpoints.ts`: +// - createTokenEndpoint +// - createRevokeEndpoint +describe('exchangeAuthNTokens', () => { + let originalFetch: typeof global.fetch; + + beforeAll(() => { + originalFetch = mockFetch(); + }); + + afterEach(() => { + mockFetchFunc.mockClear(); + }); + + afterAll(() => { + unMockFetch(originalFetch); + }); + + it('returns OAuthTokenExchangeResult when token exchange succeeded', async () => { + const mockResult: OAuthTokenExchangeResult = { + access_token: 'access_token', + id_token: 'id_token', + refresh_token: 'refresh_token', + token_type: 'token_type', + expires_in: 3600, + }; + const mockJson = jest.fn().mockResolvedValueOnce(mockResult); + const mockUserPoolClientId = 'userPoolClientId'; + const mockRedirectUri = 'https://example.com'; + const mockOAuthConfig = { + domain: 'aaa.amazoncongito.com', + } as unknown as OAuthConfig; + const mockCode = 'code'; + const mockCodeVerifier = 'codeVerifier'; + + mockFetchFunc.mockResolvedValue({ + json: mockJson, + }); + + const result = await exchangeAuthNTokens({ + redirectUri: mockRedirectUri, + userPoolClientId: mockUserPoolClientId, + oAuthConfig: mockOAuthConfig, + code: mockCode, + codeVerifier: mockCodeVerifier, + }); + + expect(result).toEqual(mockResult); + expect(mockFetchFunc).toHaveBeenCalledWith( + `https://${mockOAuthConfig.domain}/oauth2/token`, + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Cache-Control': 'no-cache', + }, + body: new URLSearchParams({ + client_id: mockUserPoolClientId, + code: mockCode, + redirect_uri: mockRedirectUri, + code_verifier: mockCodeVerifier, + grant_type: 'authorization_code', + }).toString(), + }), + ); + }); + + it('returns OAuthTokenExchangeResult with error when token exchange encountered error', async () => { + const mockResult = { + error: 'invalid_request', + }; + const mockJson = jest.fn().mockResolvedValueOnce(mockResult); + const mockUserPoolClientId = 'userPoolClientId'; + const mockRedirectUri = 'https://example.com'; + const mockOAuthConfig = { + domain: 'aaa.amazoncongito.com', + } as unknown as OAuthConfig; + const mockCode = 'code'; + const mockCodeVerifier = 'codeVerifier'; + + mockFetchFunc.mockResolvedValue({ + json: mockJson, + }); + + const result = await exchangeAuthNTokens({ + redirectUri: mockRedirectUri, + userPoolClientId: mockUserPoolClientId, + oAuthConfig: mockOAuthConfig, + code: mockCode, + codeVerifier: mockCodeVerifier, + }); + + expect(mockJson).toHaveBeenCalled(); + expect(result).toEqual(mockResult); + }); +}); + +describe('revokeAuthNTokens', () => { + let originalFetch: typeof global.fetch; + + beforeAll(() => { + originalFetch = mockFetch(); + }); + + afterEach(() => { + mockFetchFunc.mockClear(); + }); + + afterAll(() => { + unMockFetch(originalFetch); + }); + + it('returns OAuthTokenRevocationResult when token revocation succeeded', async () => { + const mockResponse = { + headers: { + get: jest.fn(), + }, + }; + const mockUserPoolClientId = 'userPoolClientId'; + const mockOAuthConfig = { + domain: 'aaa.amazoncongito.com', + } as unknown as OAuthConfig; + const mockRefreshToken = 'refreshToken'; + mockFetchFunc.mockResolvedValueOnce(mockResponse); + + const result = await revokeAuthNTokens({ + userPoolClientId: mockUserPoolClientId, + refreshToken: mockRefreshToken, + endpointDomain: mockOAuthConfig.domain, + }); + + expect(result).toEqual({}); + expect(mockFetchFunc).toHaveBeenCalledWith( + `https://${mockOAuthConfig.domain}/oauth2/revoke`, + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Cache-Control': 'no-cache', + }, + body: new URLSearchParams({ + client_id: mockUserPoolClientId, + token: mockRefreshToken, + }).toString(), + }), + ); + }); + + it('returns OAuthTokenRevocationResult with error when token revocation encountered error', async () => { + const mockJson = jest.fn().mockResolvedValueOnce({ + error: 'invalid_request', + }); + const mockResponse = { + headers: { + get: jest.fn(() => 20), + }, + json: mockJson, + }; + const mockUserPoolClientId = 'userPoolClientId'; + const mockOAuthConfig = { + domain: 'aaa.amazoncongito.com', + } as unknown as OAuthConfig; + const mockRefreshToken = 'refreshToken'; + const mockResult = { + error: 'invalid_request', + }; + + mockFetchFunc.mockResolvedValueOnce(mockResponse); + + const result = await revokeAuthNTokens({ + userPoolClientId: mockUserPoolClientId, + refreshToken: mockRefreshToken, + endpointDomain: mockOAuthConfig.domain, + }); + + expect(mockJson).toHaveBeenCalled(); + expect(result).toEqual(mockResult); + }); +}); diff --git a/packages/adapter-nextjs/__tests__/auth/utils/cognitoHostedUIEndpoints.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/cognitoHostedUIEndpoints.test.ts new file mode 100644 index 00000000000..94a8e34838c --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/cognitoHostedUIEndpoints.test.ts @@ -0,0 +1,51 @@ +import * as cognitoHostedUIEndpoints from '../../../src/auth/utils/cognitoHostedUIEndpoints'; + +describe('cognitoHostedUIEndpoints', () => { + const urlSearchParamsForCreateAuthorizeEndpoint = new URLSearchParams({ + client_id: 'mockUserPoolClientId', + redirect_uri: 'https://example.com/api/authsign-in-callback', + state: 'mockState', + }); + const urlSearchParamsForCreateSignUpEndpoint = + urlSearchParamsForCreateAuthorizeEndpoint; + const urlSearchParamsForCreateLogoutEndpoint = new URLSearchParams({ + logout_uri: 'https://example.com/sign-in', + client_id: 'mockUserPoolClientId', + }); + + const testCase = [ + [ + 'createAuthorizeEndpoint', + `https://id.amazoncognito.com/oauth2/authorize?${urlSearchParamsForCreateAuthorizeEndpoint.toString()}`, + ['id.amazoncognito.com', urlSearchParamsForCreateAuthorizeEndpoint], + ], + [ + 'createTokenEndpoint', + 'https://id.amazoncognito.com/oauth2/token', + ['id.amazoncognito.com'], + ], + [ + 'createRevokeEndpoint', + 'https://id.amazoncognito.com/oauth2/revoke', + ['id.amazoncognito.com'], + ], + [ + 'createSignUpEndpoint', + `https://id.amazoncognito.com/signup?${urlSearchParamsForCreateSignUpEndpoint.toString()}`, + ['id.amazoncognito.com', urlSearchParamsForCreateSignUpEndpoint], + ], + [ + 'createLogoutEndpoint', + `https://id.amazoncognito.com/logout?${urlSearchParamsForCreateLogoutEndpoint.toString()}`, + ['id.amazoncognito.com', urlSearchParamsForCreateLogoutEndpoint], + ], + ] as [keyof typeof cognitoHostedUIEndpoints, string, any][]; + + test.each(testCase)( + 'factory %s returns expected url: %s', + (fn, expected, args) => { + // eslint-disable-next-line import/namespace + expect(cognitoHostedUIEndpoints[fn].apply(null, args)).toBe(expected); + }, + ); +}); diff --git a/packages/adapter-nextjs/__tests__/auth/utils/createAuthFlowProofs.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/createAuthFlowProofs.test.ts new file mode 100644 index 00000000000..a99fea3772b --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/createAuthFlowProofs.test.ts @@ -0,0 +1,69 @@ +import { urlSafeEncode } from '@aws-amplify/core/internals/utils'; +import { generateCodeVerifier, generateState } from 'aws-amplify/adapter-core'; + +import { createAuthFlowProofs } from '../../../src/auth/utils'; + +jest.mock('@aws-amplify/core/internals/utils'); +jest.mock('aws-amplify/adapter-core'); + +const mockUrlSafeEncode = jest.mocked(urlSafeEncode); +const mockGenerateCodeVerifier = jest.mocked(generateCodeVerifier); +const mockGenerateState = jest.mocked(generateState); + +describe('createAuthFlowProofs', () => { + beforeAll(() => { + mockUrlSafeEncode.mockImplementation(value => `encoded-${value}`); + }); + + afterEach(() => { + mockUrlSafeEncode.mockClear(); + mockGenerateCodeVerifier.mockClear(); + mockGenerateState.mockClear(); + }); + + it('invokes generateCodeVerifier and generateState then returns codeVerifier and state', () => { + mockGenerateCodeVerifier.mockReturnValueOnce({ + value: 'value', + method: 'S256', + toCodeChallenge: jest.fn(), + }); + mockGenerateState.mockReturnValueOnce('state'); + + const result = createAuthFlowProofs({}); + + expect(result).toEqual( + expect.objectContaining({ + codeVerifier: { + value: 'value', + method: 'S256', + toCodeChallenge: expect.any(Function), + }, + state: 'state', + }), + ); + expect(mockUrlSafeEncode).not.toHaveBeenCalled(); + }); + + it('invokes generateCodeVerifier and generateState then returns codeVerifier and state with customState', () => { + mockGenerateCodeVerifier.mockReturnValueOnce({ + value: 'value', + method: 'S256', + toCodeChallenge: jest.fn(), + }); + mockGenerateState.mockReturnValueOnce('state'); + + const result = createAuthFlowProofs({ customState: 'customState' }); + + expect(result).toEqual( + expect.objectContaining({ + codeVerifier: { + value: 'value', + method: 'S256', + toCodeChallenge: expect.any(Function), + }, + state: 'state-encoded-customState', + }), + ); + expect(mockUrlSafeEncode).toHaveBeenCalledWith('customState'); + }); +}); diff --git a/packages/adapter-nextjs/__tests__/auth/utils/createOnSignInCompletedRedirectIntermediate.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/createOnSignInCompletedRedirectIntermediate.test.ts new file mode 100644 index 00000000000..d70a4f47285 --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/createOnSignInCompletedRedirectIntermediate.test.ts @@ -0,0 +1,12 @@ +import { createOnSignInCompletedRedirectIntermediate } from '../../../src/auth/utils/createOnSignInCompletedRedirectIntermediate'; + +describe('createOnSignInCompletedRedirectIntermediate', () => { + it('returns html with script that redirects to the redirectUrl', () => { + const redirectUrl = 'https://example.com'; + const result = createOnSignInCompletedRedirectIntermediate({ + redirectOnSignInComplete: redirectUrl, + }); + + expect(result).toMatchSnapshot(); + }); +}); diff --git a/packages/adapter-nextjs/__tests__/auth/utils/createUrlSearchParams.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/createUrlSearchParams.test.ts new file mode 100644 index 00000000000..2d78fc8984c --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/createUrlSearchParams.test.ts @@ -0,0 +1,88 @@ +import { + createUrlSearchParamsForSignInSignUp, + createUrlSearchParamsForTokenExchange, + createUrlSearchParamsForTokenRevocation, +} from '../../../src/auth/utils/createUrlSearchParams'; + +describe('createUrlSearchParamsForSignInSignUp', () => { + const oAuthConfig = { + domain: 'example.com', + responseType: 'code' as const, + scopes: ['openid'], + redirectSignIn: ['https://example.com/signin'], + redirectSignOut: ['https://example.com/signout'], + }; + const userPoolClientId = 'userPoolClientId'; + const state = 'state'; + const origin = `https://${oAuthConfig.domain}`; + const codeVerifier = { + toCodeChallenge: () => 'code_challenge', + method: 'S256' as const, + value: 'code_verifier', + }; + + it('returns URLSearchParams with the correct values', () => { + const url = 'https://example.com'; + + const result = createUrlSearchParamsForSignInSignUp({ + url, + oAuthConfig, + userPoolClientId, + state, + origin, + codeVerifier, + }); + + expect(result.toString()).toBe( + 'redirect_uri=https%3A%2F%2Fexample.com%2Fsignin&response_type=code&client_id=userPoolClientId&scope=openid&state=state&code_challenge=code_challenge&code_challenge_method=S256', + ); + }); + + it('returns URLSearchParams with the correct values when identity provider is resolved', () => { + const url = 'https://example.com?provider=Google'; + + const result = createUrlSearchParamsForSignInSignUp({ + url, + oAuthConfig, + userPoolClientId, + state, + origin, + codeVerifier, + }); + + expect(result.toString()).toBe( + 'redirect_uri=https%3A%2F%2Fexample.com%2Fsignin&response_type=code&client_id=userPoolClientId&scope=openid&state=state&code_challenge=code_challenge&code_challenge_method=S256&identity_provider=Google', + ); + }); +}); + +describe('createUrlSearchParamsForTokenExchange', () => { + it('returns URLSearchParams with the correct values', () => { + const input = { + code: 'code', + client_id: 'client_id', + redirect_uri: 'redirect_uri', + code_verifier: 'code_verifier', + grant_type: 'grant_type', + }; + + const result = createUrlSearchParamsForTokenExchange(input); + + expect(result.toString()).toBe( + 'code=code&client_id=client_id&redirect_uri=redirect_uri&code_verifier=code_verifier&grant_type=grant_type', + ); + }); +}); + +describe('createUrlSearchParamsForTokenRevocation', () => { + it('returns URLSearchParams with the correct values', () => { + const input = { + token: 'refresh_token', + client_id: 'client_id', + }; + + const result = createUrlSearchParamsForTokenRevocation(input); + + expect(result.toString()).toBe('token=refresh_token&client_id=client_id'); + }); +}); diff --git a/packages/adapter-nextjs/__tests__/auth/utils/getAccessTokenUsernameAndClockDrift.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/getAccessTokenUsernameAndClockDrift.test.ts new file mode 100644 index 00000000000..44f7935684f --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/getAccessTokenUsernameAndClockDrift.test.ts @@ -0,0 +1,48 @@ +import { decodeJWT } from '@aws-amplify/core'; + +import { getAccessTokenUsernameAndClockDrift } from '../../../src/auth/utils/getAccessTokenUsernameAndClockDrift'; + +jest.mock('@aws-amplify/core'); + +const mockDecodeJWT = jest.mocked(decodeJWT); + +describe('getAccessTokenUsernameAndClockDrift', () => { + let dateNowSpy: jest.SpyInstance; + + beforeAll(() => { + dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(0); + }); + + afterAll(() => { + dateNowSpy.mockRestore(); + }); + + it('should return username and clock drift', () => { + mockDecodeJWT.mockReturnValueOnce({ + payload: { + username: 'a_user', + iat: 1, + }, + }); + + expect(getAccessTokenUsernameAndClockDrift('accessToken')).toEqual( + expect.objectContaining({ + username: 'a_user', + clockDrift: 1000, + }), + ); + }); + + it('should return default username and clock drift when username is not present in the payload', () => { + mockDecodeJWT.mockReturnValueOnce({ + payload: {}, + }); + + expect(getAccessTokenUsernameAndClockDrift('accessToken')).toEqual( + expect.objectContaining({ + username: 'username', + clockDrift: 0, + }), + ); + }); +}); diff --git a/packages/adapter-nextjs/__tests__/auth/utils/getCookieValuesFromNextApiRequest.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/getCookieValuesFromNextApiRequest.test.ts new file mode 100644 index 00000000000..87c38163fd3 --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/getCookieValuesFromNextApiRequest.test.ts @@ -0,0 +1,25 @@ +import { NextApiRequest } from 'next'; + +import { getCookieValuesFromNextApiRequest } from '../../../src/auth/utils'; + +describe('getCookieValuesFromNextApiRequest', () => { + it('returns cookie values from the request', () => { + const mockRequest = { + cookies: { + cookie1: 'value1', + }, + } as unknown as NextApiRequest; + + const result = getCookieValuesFromNextApiRequest(mockRequest, [ + 'cookie1', + 'non-exist-cookie', + ]); + + expect(result).toEqual( + expect.objectContaining({ + cookie1: 'value1', + 'non-exist-cookie': undefined, + }), + ); + }); +}); diff --git a/packages/adapter-nextjs/__tests__/auth/utils/getCookieValuesFromRequest.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/getCookieValuesFromRequest.test.ts new file mode 100644 index 00000000000..2de718d3324 --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/getCookieValuesFromRequest.test.ts @@ -0,0 +1,44 @@ +import { getCookieValuesFromRequest } from '../../../src/auth/utils'; + +describe('getCookieValuesFromRequest', () => { + it('returns cookie values from the request', () => { + const mockHeadersGet = jest + .fn() + .mockReturnValue('cookie1=value1; cookie2=value2'); + const mockRequest = { + headers: { + get: mockHeadersGet, + }, + } as unknown as Request; + + const result = getCookieValuesFromRequest(mockRequest, [ + 'cookie1', + 'cookie2', + 'non-exist-cookie', + ]); + + expect(result).toEqual( + expect.objectContaining({ + cookie1: 'value1', + cookie2: 'value2', + 'non-exist-cookie': undefined, + }), + ); + + expect(mockHeadersGet).toHaveBeenCalledWith('Cookie'); + }); + + it('returns empty object when cookie header is not present', () => { + const mockHeadersGet = jest.fn().mockReturnValue(null); + const mockRequest = { + headers: { + get: mockHeadersGet, + }, + } as unknown as Request; + + const result = getCookieValuesFromRequest(mockRequest, ['cookie1']); + + expect(result).toEqual({}); + expect(mockHeadersGet).toHaveBeenCalledWith('Cookie'); + }); +}); diff --git a/packages/adapter-nextjs/__tests__/auth/utils/getSearchParamValueFromUrl.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/getSearchParamValueFromUrl.test.ts new file mode 100644 index 00000000000..6b2917da049 --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/getSearchParamValueFromUrl.test.ts @@ -0,0 +1,24 @@ +import { getSearchParamValueFromUrl } from '../../../src/auth/utils/getSearchParamValueFromUrl'; + +describe('getSearchParamValueFromUrl', () => { + it('returns the value of the specified search parameter from a full url', () => { + const url = 'https://example.com?param1=value1¶m2=value2'; + const result = getSearchParamValueFromUrl(url, 'param1'); + + expect(result).toBe('value1'); + }); + + it('returns the value of the specified search parameter from a relative url', () => { + const url = '/some-path?param1=value1¶m2=value2'; + const result = getSearchParamValueFromUrl(url, 'param2'); + + expect(result).toBe('value2'); + }); + + it('returns null when there are no search parameter is not present in the url', () => { + const url = '/some-path'; + const result = getSearchParamValueFromUrl(url, 'param3'); + + expect(result).toBeNull(); + }); +}); diff --git a/packages/adapter-nextjs/__tests__/auth/utils/resolveCodeAndStateFromUrl.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/resolveCodeAndStateFromUrl.test.ts new file mode 100644 index 00000000000..6b3194107ae --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/resolveCodeAndStateFromUrl.test.ts @@ -0,0 +1,13 @@ +import { resolveCodeAndStateFromUrl } from '../../../src/auth/utils/resolveCodeAndStateFromUrl'; + +describe('resolveCodeAndStateFromUrl', () => { + it('returns the code and state from the url', () => { + const url = 'https://example.com?code=123&state=456'; + const result = resolveCodeAndStateFromUrl(url); + + expect(result).toEqual({ + code: '123', + state: '456', + }); + }); +}); diff --git a/packages/adapter-nextjs/__tests__/auth/utils/resolveIdentityProviderFromUrl.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/resolveIdentityProviderFromUrl.test.ts new file mode 100644 index 00000000000..0f64f537b4d --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/resolveIdentityProviderFromUrl.test.ts @@ -0,0 +1,22 @@ +import { resolveIdentityProviderFromUrl } from '../../../src/auth/utils/resolveIdentityProviderFromUrl'; + +describe('resolveIdentityProviderFromUrl', () => { + test.each([ + ['https://example.com?provider=Google', 'Google'], + ['https://example.com?provider=Facebook', 'Facebook'], + ['https://example.com?provider=Amazon', 'LoginWithAmazon'], + ['https://example.com?provider=Apple', 'SignInWithApple'], + ['https://example.com?provider=google', 'Google'], + ['https://example.com?provider=facebook', 'Facebook'], + ['https://example.com?provider=amazon', 'LoginWithAmazon'], + ['https://example.com?provider=apple', 'SignInWithApple'], + ['https://example.com?provider=unknown', 'unknown'], + ['https://example.com', null], + ['https://example.com?provider=', null], + ['https://example.com?provider=Google&other=param', 'Google'], + ])('when the url is %s it returns %s', (input, expectedResult) => { + const result = resolveIdentityProviderFromUrl(input); + + expect(result).toBe(expectedResult); + }); +}); diff --git a/packages/adapter-nextjs/__tests__/auth/utils/resolveRedirectUrl.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/resolveRedirectUrl.test.ts new file mode 100644 index 00000000000..a6994a12a4c --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/resolveRedirectUrl.test.ts @@ -0,0 +1,46 @@ +import { OAuthConfig } from '@aws-amplify/core'; + +import { + resolveRedirectSignInUrl, + resolveRedirectSignOutUrl, +} from '../../../src/auth/utils/resolveRedirectUrl'; + +const oAuthConfig: OAuthConfig = { + domain: 'example.com', + redirectSignIn: ['https://example.com/sign-in'], + redirectSignOut: ['https://example.com/sign-out'], + responseType: 'code', + scopes: ['openid', 'email'], +}; + +describe('resolveRedirectSignInUrl', () => { + it('returns the redirect url when the redirect url is found by the specified origin', () => { + const origin = 'https://example.com'; + const result = resolveRedirectSignInUrl(origin, oAuthConfig); + + expect(result).toBe('https://example.com/sign-in'); + }); + + it('throws an error when the redirect url is not found by the specified origin', () => { + const origin = 'https://other-site.com'; + expect(() => resolveRedirectSignInUrl(origin, oAuthConfig)).toThrow( + 'No valid redirectSignIn url found in the OAuth config.', + ); + }); +}); + +describe('resolveRedirectSignOutUrl', () => { + it('returns the redirect url when the redirect url is found by the specified origin', () => { + const origin = 'https://example.com'; + const result = resolveRedirectSignOutUrl(origin, oAuthConfig); + + expect(result).toBe('https://example.com/sign-out'); + }); + + it('throws an error when the redirect url is not found by the specified origin', () => { + const origin = 'https://other-site.com'; + expect(() => resolveRedirectSignOutUrl(origin, oAuthConfig)).toThrow( + 'No valid redirectSignOut url found in the OAuth config.', + ); + }); +}); diff --git a/packages/adapter-nextjs/__tests__/auth/utils/tokenCookies.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/tokenCookies.test.ts new file mode 100644 index 00000000000..45052089cc8 --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/tokenCookies.test.ts @@ -0,0 +1,141 @@ +import { + AUTH_KEY_PREFIX, + CookieStorage, + DEFAULT_COOKIE_EXPIRY, +} from 'aws-amplify/adapter-core'; + +import { OAuthTokenResponsePayload } from '../../../src/auth/types'; +import { + createTokenCookies, + createTokenCookiesRemoveOptions, + createTokenCookiesSetOptions, + createTokenRemoveCookies, + getAccessTokenUsernameAndClockDrift, +} from '../../../src/auth/utils'; + +jest.mock('../../../src/auth/utils/getAccessTokenUsernameAndClockDrift'); + +const mockGetAccessTokenUsernameAndClockDrift = jest.mocked( + getAccessTokenUsernameAndClockDrift, +); + +describe('createTokenCookies', () => { + const mockUserName = 'a_user'; + beforeAll(() => { + mockGetAccessTokenUsernameAndClockDrift.mockReturnValue({ + username: mockUserName, + clockDrift: -42, + }); + }); + + it('returns a set of cookies with correct names and values derived from the input', () => { + const mockTokensPayload: OAuthTokenResponsePayload = { + access_token: 'access_token', + id_token: 'id_token', + refresh_token: 'refresh_token', + token_type: 'token_type', + expires_in: 3600, + }; + const mockUserPoolClientId = 'user-pool-client-id'; + const expectedCookieNamePrefix = `${AUTH_KEY_PREFIX}.${mockUserPoolClientId}.${mockUserName}`; + + const result = createTokenCookies({ + tokensPayload: mockTokensPayload, + userPoolClientId: mockUserPoolClientId, + }); + + expect(result.sort()).toEqual( + [ + { + name: `${expectedCookieNamePrefix}.accessToken`, + value: 'access_token', + }, + { + name: `${expectedCookieNamePrefix}.idToken`, + value: 'id_token', + }, + { + name: `${expectedCookieNamePrefix}.refreshToken`, + value: 'refresh_token', + }, + { + name: `${expectedCookieNamePrefix}.clockDrift`, + value: '-42', + }, + { + name: `${AUTH_KEY_PREFIX}.${mockUserPoolClientId}.LastAuthUser`, + value: mockUserName, + }, + ].sort(), + ); + }); +}); + +describe('createTokenRemoveCookies', () => { + it('returns an array of cookies with empty values', () => { + const result = createTokenRemoveCookies(['cookie1', 'cookie2', 'cookie3']); + + expect(result.sort()).toEqual( + [ + { name: 'cookie1', value: '' }, + { name: 'cookie2', value: '' }, + { name: 'cookie3', value: '' }, + ].sort(), + ); + }); +}); + +describe('createTokenCookiesSetOptions', () => { + it('returns an object with the correct cookie options', () => { + const mockSetCookieOptions: CookieStorage.SetCookieOptions = { + domain: '.example.com', + sameSite: 'strict', + expires: new Date('2024-09-17'), + }; + + const result = createTokenCookiesSetOptions(mockSetCookieOptions); + + expect(result).toEqual({ + domain: mockSetCookieOptions.domain, + path: '/', + httpOnly: true, + secure: true, + sameSite: 'strict', + expires: mockSetCookieOptions.expires, + }); + }); + + it('returns an object with the default expiry and sameSite properties', () => { + const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(0); + const result = createTokenCookiesSetOptions({}); + + expect(result).toEqual({ + domain: undefined, + path: '/', + httpOnly: true, + secure: true, + sameSite: 'strict', + expires: new Date(0 + DEFAULT_COOKIE_EXPIRY), + }); + + dateNowSpy.mockRestore(); + }); +}); + +describe('createTokenCookiesRemoveOptions', () => { + it('returns an object with the correct cookie options', () => { + const mockSetCookieOptions: CookieStorage.SetCookieOptions = { + domain: '.example.com', + sameSite: 'strict', + expires: new Date('2024-09-17'), + }; + + const result = createTokenCookiesRemoveOptions(mockSetCookieOptions); + + expect(result).toEqual({ + domain: mockSetCookieOptions?.domain, + path: '/', + expires: new Date('1970-01-01'), + }); + }); +}); diff --git a/packages/adapter-nextjs/src/auth/constant.ts b/packages/adapter-nextjs/src/auth/constant.ts index 273838b00b7..94d3cfa2d8c 100644 --- a/packages/adapter-nextjs/src/auth/constant.ts +++ b/packages/adapter-nextjs/src/auth/constant.ts @@ -3,10 +3,24 @@ import { SupportedRoutePaths } from './types'; -export const supportedRoutePaths: SupportedRoutePaths[] = [ +export const SUPPORTED_ROUTES: SupportedRoutePaths[] = [ 'sign-in', 'sign-in-callback', 'sign-up', 'sign-out', 'sign-out-callback', ]; + +export const COGNITO_IDENTITY_PROVIDERS: RecordIf you are not redirected automatically, follow this link to the new page.
+ +`; diff --git a/packages/adapter-nextjs/src/auth/utils/createUrlSearchParams.ts b/packages/adapter-nextjs/src/auth/utils/createUrlSearchParams.ts new file mode 100644 index 00000000000..fc584dd60f4 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/createUrlSearchParams.ts @@ -0,0 +1,59 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { OAuthConfig } from '@aws-amplify/core'; +import { generateCodeVerifier } from 'aws-amplify/adapter-core'; + +import { resolveIdentityProviderFromUrl } from './resolveIdentityProviderFromUrl'; +import { resolveRedirectSignInUrl } from './resolveRedirectUrl'; + +export const createUrlSearchParamsForSignInSignUp = ({ + url, + oAuthConfig, + userPoolClientId, + state, + origin, + codeVerifier, +}: { + url: string; + oAuthConfig: OAuthConfig; + userPoolClientId: string; + state: string; + origin: string; + codeVerifier: ReturnType