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`] = ` +" + + + + Redirecting... + + + + +

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: Record = { + Google: 'Google', + Facebook: 'Facebook', + Amazon: 'LoginWithAmazon', + Apple: 'SignInWithApple', +}; + +export const PKCE_COOKIE_NAME = 'com.amplify.server_auth.pkce'; +export const STATE_COOKIE_NAME = 'com.amplify.server_auth.state'; +export const IS_SIGNING_OUT_COOKIE_NAME = + 'com.amplify.server_auth.isSigningOut'; +export const AUTH_FLOW_PROOF_COOKIE_EXPIRY = 10 * 60 * 1000; // 10 mins +export const OAUTH_GRANT_TYPE = 'authorization_code'; diff --git a/packages/adapter-nextjs/src/auth/createAuthRouteHandlersFactory.ts b/packages/adapter-nextjs/src/auth/createAuthRouteHandlersFactory.ts index 644c9210bb1..9d1aac2668c 100644 --- a/packages/adapter-nextjs/src/auth/createAuthRouteHandlersFactory.ts +++ b/packages/adapter-nextjs/src/auth/createAuthRouteHandlersFactory.ts @@ -41,6 +41,7 @@ export const createAuthRouteHandlersFactory = ({ const { Cognito: { + userPoolClientId, loginWith: { oauth: oAuthConfig }, }, } = resourcesConfig.Auth; @@ -50,12 +51,17 @@ export const createAuthRouteHandlersFactory = ({ request: NextRequest | NextApiRequest, contextOrResponse: AuthRoutesHandlerContext | NextApiResponse, handlerInput: CreateAuthRoutesHandlersInput, - ) => { + ): Promise => { if (isNextApiRequest(request) && isNextApiResponse(contextOrResponse)) { - handleAuthApiRouteRequestForPagesRouter({ + // In pages router the response is sent via calling `response.end()` or + // `response.send()`. The response is not returned from the handler. + // To ensure these two methods are called before the handler returns, + // we use `await` here. + await handleAuthApiRouteRequestForPagesRouter({ request, response: contextOrResponse, handlerInput, + userPoolClientId, oAuthConfig, setCookieOptions, origin, @@ -74,6 +80,7 @@ export const createAuthRouteHandlersFactory = ({ request, handlerContext: contextOrResponse, handlerInput, + userPoolClientId, oAuthConfig, setCookieOptions, origin, diff --git a/packages/adapter-nextjs/src/auth/handleAuthApiRouteRequestForAppRouter.ts b/packages/adapter-nextjs/src/auth/handleAuthApiRouteRequestForAppRouter.ts index 2a8d305b67d..af24955b71d 100644 --- a/packages/adapter-nextjs/src/auth/handleAuthApiRouteRequestForAppRouter.ts +++ b/packages/adapter-nextjs/src/auth/handleAuthApiRouteRequestForAppRouter.ts @@ -3,15 +3,29 @@ import { HandleAuthApiRouteRequestForAppRouter } from './types'; import { isSupportedAuthApiRoutePath } from './utils'; +import { + handleSignInCallbackRequest, + handleSignInSignUpRequest, + handleSignOutCallbackRequest, + handleSignOutRequest, +} from './handlers'; export const handleAuthApiRouteRequestForAppRouter: HandleAuthApiRouteRequestForAppRouter = - ({ request, handlerContext }) => { + async ({ + request, + handlerContext, + handlerInput, + userPoolClientId, + oAuthConfig, + origin, + setCookieOptions, + }) => { if (request.method !== 'GET') { return new Response(null, { status: 405 }); } const { slug } = handlerContext.params; - + // don't support [...slug] here if (slug === undefined || Array.isArray(slug)) { return new Response(null, { status: 400 }); } @@ -22,11 +36,50 @@ export const handleAuthApiRouteRequestForAppRouter: HandleAuthApiRouteRequestFor switch (slug) { case 'sign-up': + return handleSignInSignUpRequest({ + request, + userPoolClientId, + oAuthConfig, + customState: handlerInput.customState, + origin, + setCookieOptions, + type: 'signUp', + }); case 'sign-in': + return handleSignInSignUpRequest({ + request, + userPoolClientId, + oAuthConfig, + customState: handlerInput.customState, + origin, + setCookieOptions, + type: 'signIn', + }); case 'sign-out': + return handleSignOutRequest({ + userPoolClientId, + oAuthConfig, + origin, + setCookieOptions, + }); case 'sign-in-callback': + return handleSignInCallbackRequest({ + request, + handlerInput, + oAuthConfig, + origin, + setCookieOptions, + userPoolClientId, + }); case 'sign-out-callback': - default: - return new Response(null, { status: 501 }); + return handleSignOutCallbackRequest({ + request, + handlerInput, + oAuthConfig, + userPoolClientId, + setCookieOptions, + }); + // default: + // is unreachable by the guard of isSupportedAuthApiRoutePath() } }; diff --git a/packages/adapter-nextjs/src/auth/handleAuthApiRouteRequestForPagesRouter.ts b/packages/adapter-nextjs/src/auth/handleAuthApiRouteRequestForPagesRouter.ts index 3f4ace7b825..6fe7db9c9c9 100644 --- a/packages/adapter-nextjs/src/auth/handleAuthApiRouteRequestForPagesRouter.ts +++ b/packages/adapter-nextjs/src/auth/handleAuthApiRouteRequestForPagesRouter.ts @@ -3,9 +3,23 @@ import { HandleAuthApiRouteRequestForPagesRouter } from './types'; import { isSupportedAuthApiRoutePath } from './utils'; +import { + handleSignInCallbackRequestForPagesRouter, + handleSignInSignUpRequestForPagesRouter, + handleSignOutCallbackRequestForPagesRouter, + handleSignOutRequestForPagesRouter, +} from './handlers'; export const handleAuthApiRouteRequestForPagesRouter: HandleAuthApiRouteRequestForPagesRouter = - ({ request, response }) => { + async ({ + request, + response, + userPoolClientId, + oAuthConfig, + handlerInput, + origin, + setCookieOptions, + }) => { if (request.method !== 'GET') { response.status(405).end(); @@ -13,6 +27,7 @@ export const handleAuthApiRouteRequestForPagesRouter: HandleAuthApiRouteRequestF } const { slug } = request.query; + // don't support [...slug] here if (slug === undefined || Array.isArray(slug)) { response.status(400).end(); @@ -27,11 +42,69 @@ export const handleAuthApiRouteRequestForPagesRouter: HandleAuthApiRouteRequestF switch (slug) { case 'sign-up': - case 'sign-in': + handleSignInSignUpRequestForPagesRouter({ + request, + response, + userPoolClientId, + oAuthConfig, + customState: handlerInput.customState, + origin, + setCookieOptions, + type: 'signUp', + }); + break; + case 'sign-in': { + handleSignInSignUpRequestForPagesRouter({ + request, + response, + userPoolClientId, + oAuthConfig, + customState: handlerInput.customState, + origin, + setCookieOptions, + type: 'signIn', + }); + break; + } case 'sign-out': + handleSignOutRequestForPagesRouter({ + response, + userPoolClientId, + oAuthConfig, + origin, + setCookieOptions, + }); + break; case 'sign-in-callback': + // In pages router the response is sent via calling `response.end()` or + // `response.send()`. The response is not returned from the handler. + // To ensure these two methods are called before the handler returns, + // we use `await` here. + await handleSignInCallbackRequestForPagesRouter({ + request, + response, + handlerInput, + userPoolClientId, + oAuthConfig, + origin, + setCookieOptions, + }); + break; case 'sign-out-callback': - default: - response.status(501).end(); + // In pages router the response is sent via calling `response.end()` or + // `response.send()`. The response is not returned from the handler. + // To ensure these two methods are called before the handler returns, + // we use `await` here. + await handleSignOutCallbackRequestForPagesRouter({ + request, + response, + handlerInput, + oAuthConfig, + userPoolClientId, + setCookieOptions, + }); + break; + // default: + // is unreachable by the guard of isSupportedAuthApiRoutePath() } }; diff --git a/packages/adapter-nextjs/src/auth/handlers/handleSignInCallbackRequest.ts b/packages/adapter-nextjs/src/auth/handlers/handleSignInCallbackRequest.ts new file mode 100644 index 00000000000..dbca5764b70 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/handlers/handleSignInCallbackRequest.ts @@ -0,0 +1,84 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PKCE_COOKIE_NAME, STATE_COOKIE_NAME } from '../constant'; +import { + appendSetCookieHeaders, + createAuthFlowProofCookiesRemoveOptions, + createOnSignInCompletedRedirectIntermediate, + createSignInFlowProofCookies, + createTokenCookies, + createTokenCookiesSetOptions, + exchangeAuthNTokens, + getCookieValuesFromRequest, + resolveCodeAndStateFromUrl, + resolveRedirectSignInUrl, +} from '../utils'; + +import { HandleSignInCallbackRequest } from './types'; + +export const handleSignInCallbackRequest: HandleSignInCallbackRequest = async ({ + request, + handlerInput, + userPoolClientId, + oAuthConfig, + setCookieOptions, + origin, +}) => { + const { code, state } = resolveCodeAndStateFromUrl(request.url); + if (!code || !state) { + return new Response(null, { status: 400 }); + } + + const { [PKCE_COOKIE_NAME]: clientPkce, [STATE_COOKIE_NAME]: clientState } = + getCookieValuesFromRequest(request, [PKCE_COOKIE_NAME, STATE_COOKIE_NAME]); + if (!clientState || clientState !== state || !clientPkce) { + return new Response(null, { status: 400 }); + } + + const tokensPayload = await exchangeAuthNTokens({ + redirectUri: resolveRedirectSignInUrl(origin, oAuthConfig), + userPoolClientId, + oAuthConfig, + code, + codeVerifier: clientPkce, + }); + + if ('error' in tokensPayload) { + return new Response(tokensPayload.error, { status: 500 }); + } + + const headers = new Headers(); + appendSetCookieHeaders( + headers, + createTokenCookies({ + tokensPayload, + userPoolClientId, + }), + createTokenCookiesSetOptions(setCookieOptions), + ); + appendSetCookieHeaders( + headers, + createSignInFlowProofCookies({ state: '', pkce: '' }), + createAuthFlowProofCookiesRemoveOptions(setCookieOptions), + ); + + // When Cognito redirects back to `/sign-in-callback`, the referer is Cognito + // endpoint. If redirect end user to `redirectOnSignInComplete` from this point, + // the referer remains the same. + // When authN token cookies set as `sameSite: 'strict'`, this may cause the + // authN tokens cookies set with the redirect response not to be sent to the + // server. Hence, sending a html page with status 200 to the client, and perform + // the redirection on the client side. + headers.set('Content-Type', 'text/html'); + + return new Response( + createOnSignInCompletedRedirectIntermediate({ + redirectOnSignInComplete: handlerInput.redirectOnSignInComplete ?? '/', + }), + { + status: 200, + headers, + }, + ); +}; diff --git a/packages/adapter-nextjs/src/auth/handlers/handleSignInCallbackRequestForPagesRouter.ts b/packages/adapter-nextjs/src/auth/handlers/handleSignInCallbackRequestForPagesRouter.ts new file mode 100644 index 00000000000..d9717168f34 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/handlers/handleSignInCallbackRequestForPagesRouter.ts @@ -0,0 +1,93 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PKCE_COOKIE_NAME, STATE_COOKIE_NAME } from '../constant'; +import { + appendSetCookieHeadersToNextApiResponse, + createAuthFlowProofCookiesRemoveOptions, + createOnSignInCompletedRedirectIntermediate, + createSignInFlowProofCookies, + createTokenCookies, + createTokenCookiesSetOptions, + exchangeAuthNTokens, + getCookieValuesFromNextApiRequest, + resolveCodeAndStateFromUrl, + resolveRedirectSignInUrl, +} from '../utils'; + +import { HandleSignInCallbackRequestForPagesRouter } from './types'; + +export const handleSignInCallbackRequestForPagesRouter: HandleSignInCallbackRequestForPagesRouter = + async ({ + request, + response, + handlerInput, + userPoolClientId, + oAuthConfig, + setCookieOptions, + origin, + }) => { + const { code, state } = resolveCodeAndStateFromUrl(request.url!); + if (!code || !state) { + response.status(400).end(); + + return; + } + + const { [PKCE_COOKIE_NAME]: clientPkce, [STATE_COOKIE_NAME]: clientState } = + getCookieValuesFromNextApiRequest(request, [ + PKCE_COOKIE_NAME, + STATE_COOKIE_NAME, + ]); + + if (!clientState || clientState !== state || !clientPkce) { + response.status(400).end(); + + return; + } + + const tokensPayload = await exchangeAuthNTokens({ + redirectUri: resolveRedirectSignInUrl(origin, oAuthConfig), + userPoolClientId, + oAuthConfig, + code, + codeVerifier: clientPkce, + }); + + if ('error' in tokensPayload) { + response.status(500).send(tokensPayload.error); + + return; + } + + appendSetCookieHeadersToNextApiResponse( + response, + createTokenCookies({ + tokensPayload, + userPoolClientId, + }), + createTokenCookiesSetOptions(setCookieOptions), + ); + appendSetCookieHeadersToNextApiResponse( + response, + createSignInFlowProofCookies({ state: '', pkce: '' }), + createAuthFlowProofCookiesRemoveOptions(setCookieOptions), + ); + + // When Cognito redirects back to `/sign-in-callback`, the referer is Cognito + // endpoint. If redirect end user to `redirectOnSignInComplete` from this point, + // the referer remains the same. + // When authN token cookies set as `sameSite: 'strict'`, this may cause the + // authN tokens cookies set with the redirect response not to be sent to the + // server. Hence, sending a html page with status 200 to the client, and perform + // the redirection on the client side. + response + .appendHeader('Content-Type', 'text/html') + .status(200) + .send( + createOnSignInCompletedRedirectIntermediate({ + redirectOnSignInComplete: + handlerInput.redirectOnSignInComplete ?? '/', + }), + ); + }; diff --git a/packages/adapter-nextjs/src/auth/handlers/handleSignInSignUpRequest.ts b/packages/adapter-nextjs/src/auth/handlers/handleSignInSignUpRequest.ts new file mode 100644 index 00000000000..a87e7133ffb --- /dev/null +++ b/packages/adapter-nextjs/src/auth/handlers/handleSignInSignUpRequest.ts @@ -0,0 +1,53 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + appendSetCookieHeaders, + createAuthFlowProofCookiesSetOptions, + createAuthFlowProofs, + createAuthorizeEndpoint, + createSignInFlowProofCookies, + createSignUpEndpoint, + createUrlSearchParamsForSignInSignUp, +} from '../utils'; + +import { HandleSignInSignUpRequest } from './types'; + +export const handleSignInSignUpRequest: HandleSignInSignUpRequest = ({ + request, + userPoolClientId, + oAuthConfig, + customState, + origin, + setCookieOptions, + type, +}) => { + const { codeVerifier, state } = createAuthFlowProofs({ customState }); + const redirectUrlSearchParams = createUrlSearchParamsForSignInSignUp({ + url: request.url, + oAuthConfig, + userPoolClientId, + state, + origin, + codeVerifier, + }); + + const headers = new Headers(); + headers.set( + 'Location', + type === 'signIn' + ? createAuthorizeEndpoint(oAuthConfig.domain, redirectUrlSearchParams) + : createSignUpEndpoint(oAuthConfig.domain, redirectUrlSearchParams), + ); + + appendSetCookieHeaders( + headers, + createSignInFlowProofCookies({ state, pkce: codeVerifier.value }), + createAuthFlowProofCookiesSetOptions(setCookieOptions), + ); + + return new Response(null, { + status: 302, + headers, + }); +}; diff --git a/packages/adapter-nextjs/src/auth/handlers/handleSignInSignUpRequestForPagesRouter.ts b/packages/adapter-nextjs/src/auth/handlers/handleSignInSignUpRequestForPagesRouter.ts new file mode 100644 index 00000000000..065f28fbfa9 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/handlers/handleSignInSignUpRequestForPagesRouter.ts @@ -0,0 +1,49 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + appendSetCookieHeadersToNextApiResponse, + createAuthFlowProofCookiesSetOptions, + createAuthFlowProofs, + createAuthorizeEndpoint, + createSignInFlowProofCookies, + createSignUpEndpoint, + createUrlSearchParamsForSignInSignUp, +} from '../utils'; + +import { HandleSignInSignUpRequestForPagesRouter } from './types'; + +export const handleSignInSignUpRequestForPagesRouter: HandleSignInSignUpRequestForPagesRouter = + ({ + request, + response, + customState, + oAuthConfig, + userPoolClientId, + origin, + setCookieOptions, + type, + }) => { + const { codeVerifier, state } = createAuthFlowProofs({ customState }); + const redirectUrlSearchParams = createUrlSearchParamsForSignInSignUp({ + url: request.url!, + oAuthConfig, + userPoolClientId, + state, + origin, + codeVerifier, + }); + + appendSetCookieHeadersToNextApiResponse( + response, + createSignInFlowProofCookies({ state, pkce: codeVerifier.value }), + createAuthFlowProofCookiesSetOptions(setCookieOptions), + ); + + response.redirect( + 302, + type === 'signIn' + ? createAuthorizeEndpoint(oAuthConfig.domain, redirectUrlSearchParams) + : createSignUpEndpoint(oAuthConfig.domain, redirectUrlSearchParams), + ); + }; diff --git a/packages/adapter-nextjs/src/auth/handlers/handleSignOutCallbackRequest.ts b/packages/adapter-nextjs/src/auth/handlers/handleSignOutCallbackRequest.ts new file mode 100644 index 00000000000..d7c6d761d02 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/handlers/handleSignOutCallbackRequest.ts @@ -0,0 +1,93 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + AUTH_KEY_PREFIX, + createKeysForAuthStorage, +} from 'aws-amplify/adapter-core'; + +import { IS_SIGNING_OUT_COOKIE_NAME } from '../constant'; +import { + appendSetCookieHeaders, + createTokenCookiesRemoveOptions, + createTokenRemoveCookies, + getCookieValuesFromRequest, + revokeAuthNTokens, +} from '../utils'; + +import { HandleSignOutCallbackRequest } from './types'; + +export const handleSignOutCallbackRequest: HandleSignOutCallbackRequest = + async ({ + request, + handlerInput, + userPoolClientId, + oAuthConfig, + setCookieOptions, + }) => { + const { [IS_SIGNING_OUT_COOKIE_NAME]: isSigningOut } = + getCookieValuesFromRequest(request, [IS_SIGNING_OUT_COOKIE_NAME]); + if (!isSigningOut) { + return new Response(null, { status: 400 }); + } + + const lastAuthUserCookieName = `${AUTH_KEY_PREFIX}.${userPoolClientId}.LastAuthUser`; + const { [lastAuthUserCookieName]: username } = getCookieValuesFromRequest( + request, + [lastAuthUserCookieName], + ); + if (!username) { + return new Response(null, { + status: 302, + headers: new Headers({ + Location: handlerInput.redirectOnSignOutComplete ?? '/', + }), + }); + } + + const authCookiesKeys = createKeysForAuthStorage( + AUTH_KEY_PREFIX, + `${userPoolClientId}.${username}`, + ); + const { [authCookiesKeys.refreshToken]: refreshToken } = + getCookieValuesFromRequest(request, [authCookiesKeys.refreshToken]); + + if (!refreshToken) { + return new Response(null, { + status: 302, + headers: new Headers({ + Location: handlerInput.redirectOnSignOutComplete ?? '/', + }), + }); + } + + const result = await revokeAuthNTokens({ + refreshToken, + userPoolClientId, + endpointDomain: oAuthConfig.domain, + }); + + if (result.error) { + return new Response(result.error, { status: 500 }); + } + + const headers = new Headers(); + appendSetCookieHeaders( + headers, + [ + ...createTokenRemoveCookies([ + ...Object.values(authCookiesKeys), + lastAuthUserCookieName, + IS_SIGNING_OUT_COOKIE_NAME, + ]), + ], + createTokenCookiesRemoveOptions(setCookieOptions), + ); + + headers.set('Location', handlerInput.redirectOnSignOutComplete ?? '/'); + + return new Response(null, { + status: 302, + headers, + }); + }; diff --git a/packages/adapter-nextjs/src/auth/handlers/handleSignOutCallbackRequestForPagesRouter.ts b/packages/adapter-nextjs/src/auth/handlers/handleSignOutCallbackRequestForPagesRouter.ts new file mode 100644 index 00000000000..42afa805591 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/handlers/handleSignOutCallbackRequestForPagesRouter.ts @@ -0,0 +1,89 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + AUTH_KEY_PREFIX, + createKeysForAuthStorage, +} from 'aws-amplify/adapter-core'; + +import { IS_SIGNING_OUT_COOKIE_NAME } from '../constant'; +import { + appendSetCookieHeadersToNextApiResponse, + createTokenCookiesRemoveOptions, + createTokenRemoveCookies, + getCookieValuesFromNextApiRequest, + revokeAuthNTokens, +} from '../utils'; + +import { HandleSignOutCallbackRequestForPagesRouter } from './types'; + +export const handleSignOutCallbackRequestForPagesRouter: HandleSignOutCallbackRequestForPagesRouter = + async ({ + request, + response, + handlerInput, + userPoolClientId, + oAuthConfig, + setCookieOptions, + }) => { + const { [IS_SIGNING_OUT_COOKIE_NAME]: isSigningOut } = + getCookieValuesFromNextApiRequest(request, [IS_SIGNING_OUT_COOKIE_NAME]); + + if (!isSigningOut) { + response.status(400).end(); + + return; + } + + const lastAuthUserCookieName = `${AUTH_KEY_PREFIX}.${userPoolClientId}.LastAuthUser`; + const { [lastAuthUserCookieName]: username } = + getCookieValuesFromNextApiRequest(request, [lastAuthUserCookieName]); + + if (!username) { + response.redirect(302, handlerInput.redirectOnSignOutComplete ?? '/'); + + return; + } + + const authCookiesKeys = createKeysForAuthStorage( + AUTH_KEY_PREFIX, + `${userPoolClientId}.${username}`, + ); + + const { [authCookiesKeys.refreshToken]: refreshToken } = + getCookieValuesFromNextApiRequest(request, [ + authCookiesKeys.refreshToken, + ]); + + if (!refreshToken) { + response.redirect(302, handlerInput.redirectOnSignOutComplete ?? '/'); + + return; + } + + const result = await revokeAuthNTokens({ + refreshToken, + userPoolClientId, + endpointDomain: oAuthConfig.domain, + }); + + if (result.error) { + response.status(500).send(result.error); + + return; + } + + appendSetCookieHeadersToNextApiResponse( + response, + [ + ...createTokenRemoveCookies([ + ...Object.values(authCookiesKeys), + lastAuthUserCookieName, + IS_SIGNING_OUT_COOKIE_NAME, + ]), + ], + createTokenCookiesRemoveOptions(setCookieOptions), + ); + + response.redirect(302, handlerInput.redirectOnSignOutComplete ?? '/'); + }; diff --git a/packages/adapter-nextjs/src/auth/handlers/handleSignOutRequest.ts b/packages/adapter-nextjs/src/auth/handlers/handleSignOutRequest.ts new file mode 100644 index 00000000000..cb5c09dabaf --- /dev/null +++ b/packages/adapter-nextjs/src/auth/handlers/handleSignOutRequest.ts @@ -0,0 +1,40 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + appendSetCookieHeaders, + createAuthFlowProofCookiesSetOptions, + createLogoutEndpoint, + createSignOutFlowProofCookies, + resolveRedirectSignOutUrl, +} from '../utils'; + +import { HandleSignOutRequest } from './types'; + +export const handleSignOutRequest: HandleSignOutRequest = ({ + oAuthConfig, + userPoolClientId, + origin, + setCookieOptions, +}) => { + const urlSearchParams = new URLSearchParams({ + client_id: userPoolClientId, + logout_uri: resolveRedirectSignOutUrl(origin, oAuthConfig), + }); + + const headers = new Headers(); + headers.set( + 'Location', + createLogoutEndpoint(oAuthConfig.domain, urlSearchParams), + ); + appendSetCookieHeaders( + headers, + createSignOutFlowProofCookies(), + createAuthFlowProofCookiesSetOptions(setCookieOptions), + ); + + return new Response(null, { + status: 302, + headers, + }); +}; diff --git a/packages/adapter-nextjs/src/auth/handlers/handleSignOutRequestForPagesRouter.ts b/packages/adapter-nextjs/src/auth/handlers/handleSignOutRequestForPagesRouter.ts new file mode 100644 index 00000000000..bf4d21f8c64 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/handlers/handleSignOutRequestForPagesRouter.ts @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + appendSetCookieHeadersToNextApiResponse, + createAuthFlowProofCookiesSetOptions, + createLogoutEndpoint, + createSignOutFlowProofCookies, + resolveRedirectSignOutUrl, +} from '../utils'; + +import { HandleSignOutRequestForPagesRouter } from './types'; + +export const handleSignOutRequestForPagesRouter: HandleSignOutRequestForPagesRouter = + ({ response, oAuthConfig, userPoolClientId, origin, setCookieOptions }) => { + const urlSearchParams = new URLSearchParams({ + client_id: userPoolClientId, + logout_uri: resolveRedirectSignOutUrl(origin, oAuthConfig), + }); + + appendSetCookieHeadersToNextApiResponse( + response, + createSignOutFlowProofCookies(), + createAuthFlowProofCookiesSetOptions(setCookieOptions), + ); + + response.redirect( + 302, + createLogoutEndpoint(oAuthConfig.domain, urlSearchParams).toString(), + ); + }; diff --git a/packages/adapter-nextjs/src/auth/handlers/index.ts b/packages/adapter-nextjs/src/auth/handlers/index.ts new file mode 100644 index 00000000000..284c4f5202f --- /dev/null +++ b/packages/adapter-nextjs/src/auth/handlers/index.ts @@ -0,0 +1,11 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { handleSignInCallbackRequest } from './handleSignInCallbackRequest'; +export { handleSignInCallbackRequestForPagesRouter } from './handleSignInCallbackRequestForPagesRouter'; +export { handleSignInSignUpRequest } from './handleSignInSignUpRequest'; +export { handleSignInSignUpRequestForPagesRouter } from './handleSignInSignUpRequestForPagesRouter'; +export { handleSignOutCallbackRequest } from './handleSignOutCallbackRequest'; +export { handleSignOutCallbackRequestForPagesRouter } from './handleSignOutCallbackRequestForPagesRouter'; +export { handleSignOutRequest } from './handleSignOutRequest'; +export { handleSignOutRequestForPagesRouter } from './handleSignOutRequestForPagesRouter'; diff --git a/packages/adapter-nextjs/src/auth/handlers/types.ts b/packages/adapter-nextjs/src/auth/handlers/types.ts new file mode 100644 index 00000000000..7538611f68b --- /dev/null +++ b/packages/adapter-nextjs/src/auth/handlers/types.ts @@ -0,0 +1,88 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { OAuthConfig } from '@aws-amplify/core'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { CookieStorage } from 'aws-amplify/adapter-core'; + +import { CreateAuthRoutesHandlersInput } from '../types'; + +interface AuthApiRequestHandlerInputBase { + oAuthConfig: OAuthConfig; + origin: string; + userPoolClientId: string; + setCookieOptions: CookieStorage.SetCookieOptions; +} + +// handleSignInRequest +interface HandleSignInSignUpRequestInputBase + extends AuthApiRequestHandlerInputBase { + customState?: string; + type: 'signIn' | 'signUp'; +} +interface HandleSignInSignUpRequestInput + extends HandleSignInSignUpRequestInputBase { + request: Request; +} +interface HandleSignInSigUpRequestForPagesRouterInput + extends HandleSignInSignUpRequestInputBase { + request: NextApiRequest; + response: NextApiResponse; +} +export type HandleSignInSignUpRequest = ( + input: HandleSignInSignUpRequestInput, +) => Response; +export type HandleSignInSignUpRequestForPagesRouter = ( + input: HandleSignInSigUpRequestForPagesRouterInput, +) => void; + +// handleSignInCallbackRequest +interface HandleSignInCallbackRequestInput + extends AuthApiRequestHandlerInputBase { + request: Request; + handlerInput: CreateAuthRoutesHandlersInput; +} +interface HandleSignInCallbackRequestForPagesRouterInput + extends AuthApiRequestHandlerInputBase { + request: NextApiRequest; + response: NextApiResponse; + handlerInput: CreateAuthRoutesHandlersInput; +} +export type HandleSignInCallbackRequest = ( + input: HandleSignInCallbackRequestInput, +) => Promise; +export type HandleSignInCallbackRequestForPagesRouter = ( + input: HandleSignInCallbackRequestForPagesRouterInput, +) => Promise; + +// handleSignOutRequest +type handleSignOutRequestInput = AuthApiRequestHandlerInputBase; +interface handleSignOutRequestForPagesRouterInput + extends AuthApiRequestHandlerInputBase { + response: NextApiResponse; +} +export type HandleSignOutRequest = ( + input: handleSignOutRequestInput, +) => Response; +export type HandleSignOutRequestForPagesRouter = ( + input: handleSignOutRequestForPagesRouterInput, +) => void; + +// handleSignOutCallbackRequest +interface HandleSignOutCallbackRequestInput + extends Omit { + request: Request; + handlerInput: CreateAuthRoutesHandlersInput; +} +interface HandleSignOutCallbackRequestForPagesHandlerInput + extends Omit { + request: NextApiRequest; + response: NextApiResponse; + handlerInput: CreateAuthRoutesHandlersInput; +} +export type HandleSignOutCallbackRequest = ( + input: HandleSignOutCallbackRequestInput, +) => Promise; +export type HandleSignOutCallbackRequestForPagesRouter = ( + input: HandleSignOutCallbackRequestForPagesHandlerInput, +) => Promise; diff --git a/packages/adapter-nextjs/src/auth/types.ts b/packages/adapter-nextjs/src/auth/types.ts index b06744ba86a..3f7ce5ff559 100644 --- a/packages/adapter-nextjs/src/auth/types.ts +++ b/packages/adapter-nextjs/src/auth/types.ts @@ -81,6 +81,7 @@ export type CreateOAuthRouteHandlersFactory = ( interface HandleAuthApiRouteRequestInputBase { handlerInput: CreateAuthRoutesHandlersInput; + userPoolClientId: string; oAuthConfig: OAuthConfig; setCookieOptions: CookieStorage.SetCookieOptions; origin: string; @@ -100,8 +101,28 @@ interface HandleAuthApiRouteRequestForPagesRouterInput export type HandleAuthApiRouteRequestForAppRouter = ( input: HandleAuthApiRouteRequestForAppRouterInput, -) => Response; +) => Promise; export type HandleAuthApiRouteRequestForPagesRouter = ( input: HandleAuthApiRouteRequestForPagesRouterInput, -) => void; +) => Promise; + +export interface OAuthTokenResponsePayload { + access_token: string; + id_token: string; + refresh_token: string; + token_type: string; + expires_in: number; +} + +interface OAuthTokenResponseErrorPayload { + error: string; +} + +export type OAuthTokenExchangeResult = + | OAuthTokenResponsePayload + | OAuthTokenResponseErrorPayload; + +export interface OAuthTokenRevocationResult { + error?: string; +} diff --git a/packages/adapter-nextjs/src/auth/utils/appendSetCookieHeaders.ts b/packages/adapter-nextjs/src/auth/utils/appendSetCookieHeaders.ts new file mode 100644 index 00000000000..91af30f62ba --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/appendSetCookieHeaders.ts @@ -0,0 +1,18 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { CookieStorage } from 'aws-amplify/adapter-core'; + +import { serializeCookie } from '../../utils/cookie'; + +export const appendSetCookieHeaders = ( + headers: Headers, + cookies: { name: string; value: string }[], + setCookieOptions?: CookieStorage.SetCookieOptions, +): void => { + for (const { name, value } of cookies) { + headers.append( + 'Set-Cookie', + serializeCookie(name, value, setCookieOptions), + ); + } +}; diff --git a/packages/adapter-nextjs/src/auth/utils/appendSetCookieHeadersToNextApiResponse.ts b/packages/adapter-nextjs/src/auth/utils/appendSetCookieHeadersToNextApiResponse.ts new file mode 100644 index 00000000000..6f3918aaf30 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/appendSetCookieHeadersToNextApiResponse.ts @@ -0,0 +1,20 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { NextApiResponse } from 'next'; +import { CookieStorage } from 'aws-amplify/adapter-core'; + +import { serializeCookie } from '../../utils/cookie'; + +export const appendSetCookieHeadersToNextApiResponse = ( + response: NextApiResponse, + cookies: { name: string; value: string }[], + setCookieOptions?: CookieStorage.SetCookieOptions, +): void => { + for (const { name, value } of cookies) { + response.appendHeader( + 'Set-Cookie', + serializeCookie(name, value, setCookieOptions), + ); + } +}; diff --git a/packages/adapter-nextjs/src/auth/utils/authFlowProofCookies.ts b/packages/adapter-nextjs/src/auth/utils/authFlowProofCookies.ts new file mode 100644 index 00000000000..e2781e6d2a7 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/authFlowProofCookies.ts @@ -0,0 +1,54 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +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 '../constant'; + +export const createSignInFlowProofCookies = ({ + state, + pkce, +}: { + state: string; + pkce: string; +}) => [ + { + name: PKCE_COOKIE_NAME, + value: pkce, + }, + { + name: STATE_COOKIE_NAME, + value: state, + }, +]; + +export const createSignOutFlowProofCookies = () => [ + { + name: IS_SIGNING_OUT_COOKIE_NAME, + value: 'true', + }, +]; + +export const createAuthFlowProofCookiesSetOptions = ( + setCookieOptions: CookieStorage.SetCookieOptions, +) => ({ + domain: setCookieOptions?.domain, + path: '/', + httpOnly: true, + secure: true, + sameSite: 'lax' as const, + expires: new Date(Date.now() + AUTH_FLOW_PROOF_COOKIE_EXPIRY), +}); + +export const createAuthFlowProofCookiesRemoveOptions = ( + setCookieOptions: CookieStorage.SetCookieOptions, +) => ({ + domain: setCookieOptions?.domain, + path: '/', + expires: new Date('1970-01-01'), +}); diff --git a/packages/adapter-nextjs/src/auth/utils/authNTokens.ts b/packages/adapter-nextjs/src/auth/utils/authNTokens.ts new file mode 100644 index 00000000000..6e21701f568 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/authNTokens.ts @@ -0,0 +1,87 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { OAuthConfig } from '@aws-amplify/core'; + +import { OAUTH_GRANT_TYPE } from '../constant'; +import { OAuthTokenExchangeResult, OAuthTokenRevocationResult } from '../types'; + +import { + createUrlSearchParamsForTokenExchange, + createUrlSearchParamsForTokenRevocation, +} from './createUrlSearchParams'; +import { + createRevokeEndpoint, + createTokenEndpoint, +} from './cognitoHostedUIEndpoints'; + +export const exchangeAuthNTokens = async ({ + redirectUri, + userPoolClientId, + oAuthConfig, + code, + codeVerifier, +}: { + redirectUri: string; + userPoolClientId: string; + oAuthConfig: OAuthConfig; + code: string; + codeVerifier: string; +}): Promise => { + const searchParams = createUrlSearchParamsForTokenExchange({ + client_id: userPoolClientId, + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + grant_type: OAUTH_GRANT_TYPE, + }); + + const oAuthTokenEndpoint = createTokenEndpoint(oAuthConfig.domain); + const tokenExchangeResponse = await fetch(oAuthTokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Cache-Control': 'no-cache', + }, + body: searchParams.toString(), + }); + + // Exchanging an authorization code grant with PKCE for tokens with + // `grant_type=authorization_code` produces a stable shape of payload. + // Details see https://docs.aws.amazon.com/cognito/latest/developerguide/token-endpoint.html + // Possible errors: invalid_request|invalid_client|invalid_grant|unauthorized_client|unsupported_grant_type + // Should not happen unless configuration is wrong; + return (await tokenExchangeResponse.json()) as OAuthTokenExchangeResult; +}; + +export const revokeAuthNTokens = async ({ + userPoolClientId, + refreshToken, + endpointDomain, +}: { + userPoolClientId: string; + refreshToken: string; + endpointDomain: string; +}): Promise => { + const searchParams = createUrlSearchParamsForTokenRevocation({ + client_id: userPoolClientId, + token: refreshToken, + }); + const oAuthTokenRevocationEndpoint = createRevokeEndpoint(endpointDomain); + const response = await fetch(oAuthTokenRevocationEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Cache-Control': 'no-cache', + }, + body: searchParams.toString(), + }); + const contentLength = parseInt( + response.headers.get('Content-Length') ?? '0', + 10, + ); + + return contentLength === 0 + ? {} + : ((await response.json()) as OAuthTokenRevocationResult); +}; diff --git a/packages/adapter-nextjs/src/auth/utils/cognitoHostedUIEndpoints.ts b/packages/adapter-nextjs/src/auth/utils/cognitoHostedUIEndpoints.ts new file mode 100644 index 00000000000..377d6a72278 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/cognitoHostedUIEndpoints.ts @@ -0,0 +1,28 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const createAuthorizeEndpoint = ( + domain: string, + urlSearchParams: URLSearchParams, +): string => + new URL( + `https://${domain}/oauth2/authorize?${urlSearchParams.toString()}`, + ).toString(); + +export const createTokenEndpoint = (domain: string): string => + new URL(`https://${domain}/oauth2/token`).toString(); + +export const createRevokeEndpoint = (domain: string) => + new URL(`https://${domain}/oauth2/revoke`).toString(); + +export const createSignUpEndpoint = ( + domain: string, + urlSearchParams: URLSearchParams, +): string => + new URL(`https://${domain}/signup?${urlSearchParams.toString()}`).toString(); + +export const createLogoutEndpoint = ( + domain: string, + urlSearchParams: URLSearchParams, +): string => + new URL(`https://${domain}/logout?${urlSearchParams.toString()}`).toString(); diff --git a/packages/adapter-nextjs/src/auth/utils/createAuthFlowProofs.ts b/packages/adapter-nextjs/src/auth/utils/createAuthFlowProofs.ts new file mode 100644 index 00000000000..a9afdab98d8 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/createAuthFlowProofs.ts @@ -0,0 +1,22 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { urlSafeEncode } from '@aws-amplify/core/internals/utils'; +import { generateCodeVerifier, generateState } from 'aws-amplify/adapter-core'; + +export const createAuthFlowProofs = ({ + customState, +}: { + customState?: string; +}): { + codeVerifier: ReturnType; + state: string; +} => { + const codeVerifier = generateCodeVerifier(128); + const randomState = generateState(); + const state = customState + ? `${randomState}-${urlSafeEncode(customState)}` + : randomState; + + return { codeVerifier, state }; +}; diff --git a/packages/adapter-nextjs/src/auth/utils/createOnSignInCompletedRedirectIntermediate.ts b/packages/adapter-nextjs/src/auth/utils/createOnSignInCompletedRedirectIntermediate.ts new file mode 100644 index 00000000000..320068edae0 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/createOnSignInCompletedRedirectIntermediate.ts @@ -0,0 +1,25 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const createOnSignInCompletedRedirectIntermediate = ({ + redirectOnSignInComplete, +}: { + redirectOnSignInComplete: string; +}) => createHTML(redirectOnSignInComplete); + +// This HTML does the following: +// 1. redirect to `redirectTarget` using JavaScript on page load +// 2. redirect to `redirectTarget` relying on the meta tag if JavaScript is disabled +// 3. display a link to `redirectTarget` if the redirect does not happen +const createHTML = (redirectTarget: string) => ` + + + + Redirecting... + + + + +

If 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; +}): URLSearchParams => { + const resolvedProvider = resolveIdentityProviderFromUrl(url); + + const redirectUrlSearchParams = new URLSearchParams({ + redirect_uri: resolveRedirectSignInUrl(origin, oAuthConfig), + response_type: oAuthConfig.responseType, + client_id: userPoolClientId, + scope: oAuthConfig.scopes.join(' '), + state, + code_challenge: codeVerifier.toCodeChallenge(), + code_challenge_method: codeVerifier.method, + }); + + if (resolvedProvider) { + redirectUrlSearchParams.append('identity_provider', resolvedProvider); + } + + return redirectUrlSearchParams; +}; + +export const createUrlSearchParamsForTokenExchange = (input: { + code: string; + client_id: string; + redirect_uri: string; + code_verifier: string; + grant_type: string; +}): URLSearchParams => createUrlSearchParamsFromObject(input); + +export const createUrlSearchParamsForTokenRevocation = (input: { + token: string; + client_id: string; +}): URLSearchParams => createUrlSearchParamsFromObject(input); + +const createUrlSearchParamsFromObject = ( + input: Record, +): URLSearchParams => new URLSearchParams(input); diff --git a/packages/adapter-nextjs/src/auth/utils/getAccessTokenUsernameAndClockDrift.ts b/packages/adapter-nextjs/src/auth/utils/getAccessTokenUsernameAndClockDrift.ts new file mode 100644 index 00000000000..81498f5e791 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/getAccessTokenUsernameAndClockDrift.ts @@ -0,0 +1,21 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { decodeJWT } from '@aws-amplify/core'; + +export const getAccessTokenUsernameAndClockDrift = ( + accessToken: string, +): { + username: string; + clockDrift: number; +} => { + const decoded = decodeJWT(accessToken); + const issuedAt = (decoded.payload.iat ?? 0) * 1000; + const clockDrift = issuedAt > 0 ? issuedAt - Date.now() : 0; + const username = (decoded.payload.username as string) ?? 'username'; + + return { + username, + clockDrift, + }; +}; diff --git a/packages/adapter-nextjs/src/auth/utils/getCookieValuesFromNextApiRequest.ts b/packages/adapter-nextjs/src/auth/utils/getCookieValuesFromNextApiRequest.ts new file mode 100644 index 00000000000..d770d1ca57d --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/getCookieValuesFromNextApiRequest.ts @@ -0,0 +1,20 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { NextApiRequest } from 'next'; + +export const getCookieValuesFromNextApiRequest = ( + request: NextApiRequest, + cookieNames: CookieNames, +): { + [key in CookieNames[number]]?: string | undefined; +} => { + const result: Record = {}; + for (const cookieName of cookieNames) { + result[cookieName] = request.cookies[cookieName]; + } + + return result as { + [key in CookieNames[number]]?: string | undefined; + }; +}; diff --git a/packages/adapter-nextjs/src/auth/utils/getCookieValuesFromRequest.ts b/packages/adapter-nextjs/src/auth/utils/getCookieValuesFromRequest.ts new file mode 100644 index 00000000000..563d59773ff --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/getCookieValuesFromRequest.ts @@ -0,0 +1,36 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const getCookieValuesFromRequest = ( + request: Request, + cookieNames: CookieNames, +): { + [key in CookieNames[number]]?: string | undefined; +} => { + const cookieHeader = request.headers.get('Cookie'); + + if (!cookieHeader) { + return {}; + } + + const cookieValues: Record = cookieHeader + .split(';') + .map(cookie => cookie.trim().split('=')) + .reduce( + (result, [key, value]) => { + result[key] = value; + + return result; + }, + {} as Record, + ); + + const result: Record = {}; + for (const cookieName of cookieNames) { + result[cookieName] = cookieValues[cookieName]; + } + + return result as { + [key in CookieNames[number]]?: string | undefined; + }; +}; diff --git a/packages/adapter-nextjs/src/auth/utils/getSearchParamValueFromUrl.ts b/packages/adapter-nextjs/src/auth/utils/getSearchParamValueFromUrl.ts new file mode 100644 index 00000000000..f3f5e78ca42 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/getSearchParamValueFromUrl.ts @@ -0,0 +1,23 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const getSearchParamValueFromUrl = ( + urlStr: string, + paramName: string, +): string | null => { + try { + return new URL(urlStr).searchParams.get(paramName); + } catch (error) { + // In Next.js Pages Router the request object is an instance of IncomingMessage + // whose url property may contain only the path part of the URL + query params. + // In this case, we need to parse the URL manually + if (urlStr.includes('?')) { + const queryParams = urlStr.split('?')[1]; + if (queryParams) { + return new URLSearchParams(queryParams).get(paramName); + } + } + + return null; + } +}; diff --git a/packages/adapter-nextjs/src/auth/utils/index.ts b/packages/adapter-nextjs/src/auth/utils/index.ts index ed2708c0038..86d3da0b1a1 100644 --- a/packages/adapter-nextjs/src/auth/utils/index.ts +++ b/packages/adapter-nextjs/src/auth/utils/index.ts @@ -1,6 +1,28 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +export { appendSetCookieHeaders } from './appendSetCookieHeaders'; +export { exchangeAuthNTokens, revokeAuthNTokens } from './authNTokens'; +export { appendSetCookieHeadersToNextApiResponse } from './appendSetCookieHeadersToNextApiResponse'; +export { + createSignInFlowProofCookies, + createSignOutFlowProofCookies, + createAuthFlowProofCookiesSetOptions, + createAuthFlowProofCookiesRemoveOptions, +} from './authFlowProofCookies'; +export { createAuthFlowProofs } from './createAuthFlowProofs'; +export { createOnSignInCompletedRedirectIntermediate } from './createOnSignInCompletedRedirectIntermediate'; +export { createUrlSearchParamsForSignInSignUp } from './createUrlSearchParams'; +export { + createAuthorizeEndpoint, + createSignUpEndpoint, + createLogoutEndpoint, + createTokenEndpoint, + createRevokeEndpoint, +} from './cognitoHostedUIEndpoints'; +export { getAccessTokenUsernameAndClockDrift } from './getAccessTokenUsernameAndClockDrift'; +export { getCookieValuesFromNextApiRequest } from './getCookieValuesFromNextApiRequest'; +export { getCookieValuesFromRequest } from './getCookieValuesFromRequest'; export { isAuthRoutesHandlersContext, isNextApiRequest, @@ -8,3 +30,16 @@ export { isNextRequest, } from './handlerParametersTypeAssertions'; export { isSupportedAuthApiRoutePath } from './isSupportedAuthApiRoutePath'; +export { resolveCodeAndStateFromUrl } from './resolveCodeAndStateFromUrl'; +export { resolveIdentityProviderFromUrl } from './resolveIdentityProviderFromUrl'; +export { + resolveRedirectSignInUrl, + resolveRedirectSignOutUrl, +} from './resolveRedirectUrl'; + +export { + createTokenCookies, + createTokenRemoveCookies, + createTokenCookiesSetOptions, + createTokenCookiesRemoveOptions, +} from './tokenCookies'; diff --git a/packages/adapter-nextjs/src/auth/utils/isSupportedAuthApiRoutePath.ts b/packages/adapter-nextjs/src/auth/utils/isSupportedAuthApiRoutePath.ts index a14dd0f135c..c783718d59a 100644 --- a/packages/adapter-nextjs/src/auth/utils/isSupportedAuthApiRoutePath.ts +++ b/packages/adapter-nextjs/src/auth/utils/isSupportedAuthApiRoutePath.ts @@ -1,14 +1,13 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { supportedRoutePaths } from '../constant'; +import { SUPPORTED_ROUTES } from '../constant'; import { SupportedRoutePaths } from '../types'; export function isSupportedAuthApiRoutePath( path?: string, ): path is SupportedRoutePaths { return ( - path !== undefined && - supportedRoutePaths.includes(path as SupportedRoutePaths) + path !== undefined && SUPPORTED_ROUTES.includes(path as SupportedRoutePaths) ); } diff --git a/packages/adapter-nextjs/src/auth/utils/resolveCodeAndStateFromUrl.ts b/packages/adapter-nextjs/src/auth/utils/resolveCodeAndStateFromUrl.ts new file mode 100644 index 00000000000..3f6f7f20916 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/resolveCodeAndStateFromUrl.ts @@ -0,0 +1,14 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getSearchParamValueFromUrl } from './getSearchParamValueFromUrl'; + +export const resolveCodeAndStateFromUrl = ( + urlStr: string, +): { + code: string | null; + state: string | null; +} => ({ + state: getSearchParamValueFromUrl(urlStr, 'state'), + code: getSearchParamValueFromUrl(urlStr, 'code'), +}); diff --git a/packages/adapter-nextjs/src/auth/utils/resolveIdentityProviderFromUrl.ts b/packages/adapter-nextjs/src/auth/utils/resolveIdentityProviderFromUrl.ts new file mode 100644 index 00000000000..f3897b365db --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/resolveIdentityProviderFromUrl.ts @@ -0,0 +1,20 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { COGNITO_IDENTITY_PROVIDERS } from '../constant'; + +import { getSearchParamValueFromUrl } from './getSearchParamValueFromUrl'; + +export const resolveIdentityProviderFromUrl = (urlStr: string): string | null => + resolveProvider(getSearchParamValueFromUrl(urlStr, 'provider')); + +const resolveProvider = (provider: string | null): string | null => { + if (!provider) { + return null; + } + + return COGNITO_IDENTITY_PROVIDERS[capitalize(provider)] ?? provider; +}; + +const capitalize = (value: string) => + `${value[0].toUpperCase()}${value.substring(1)}`; diff --git a/packages/adapter-nextjs/src/auth/utils/resolveRedirectUrl.ts b/packages/adapter-nextjs/src/auth/utils/resolveRedirectUrl.ts new file mode 100644 index 00000000000..c13297fc3a4 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/resolveRedirectUrl.ts @@ -0,0 +1,41 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { OAuthConfig } from '@aws-amplify/core'; +import { AmplifyServerContextError } from '@aws-amplify/core/internals/adapter-core'; + +export const resolveRedirectSignInUrl = ( + origin: string, + oAuthConfig: OAuthConfig, +) => { + const redirectUrl = oAuthConfig.redirectSignIn.find(url => + url.startsWith(origin), + ); + + if (!redirectUrl) { + throw createError('redirectSignIn'); + } + + return redirectUrl; +}; + +export const resolveRedirectSignOutUrl = ( + origin: string, + oAuthConfig: OAuthConfig, +) => { + const redirectUrl = oAuthConfig.redirectSignOut.find(url => + url.startsWith(origin), + ); + + if (!redirectUrl) { + throw createError('redirectSignOut'); + } + + return redirectUrl; +}; + +const createError = (urlType: string): AmplifyServerContextError => + new AmplifyServerContextError({ + message: `No valid ${urlType} url found in the OAuth config.`, + recoverySuggestion: `Check the OAuth config and ensure the ${urlType} url is valid.`, + }); diff --git a/packages/adapter-nextjs/src/auth/utils/tokenCookies.ts b/packages/adapter-nextjs/src/auth/utils/tokenCookies.ts new file mode 100644 index 00000000000..88fbc5bb8a7 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/tokenCookies.ts @@ -0,0 +1,75 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + AUTH_KEY_PREFIX, + CookieStorage, + DEFAULT_COOKIE_EXPIRY, + createKeysForAuthStorage, +} from 'aws-amplify/adapter-core'; + +import { OAuthTokenResponsePayload } from '../types'; + +import { getAccessTokenUsernameAndClockDrift } from './getAccessTokenUsernameAndClockDrift'; + +export const createTokenCookies = ({ + tokensPayload, + userPoolClientId, +}: { + tokensPayload: OAuthTokenResponsePayload; + userPoolClientId: string; +}) => { + const { access_token, id_token, refresh_token } = tokensPayload; + const { username, clockDrift } = + getAccessTokenUsernameAndClockDrift(access_token); + const authCookiesKeys = createKeysForAuthStorage( + AUTH_KEY_PREFIX, + `${userPoolClientId}.${username}`, + ); + + return [ + { + name: authCookiesKeys.accessToken, + value: access_token, + }, + { + name: authCookiesKeys.idToken, + value: id_token, + }, + { + name: authCookiesKeys.refreshToken, + value: refresh_token, + }, + { + name: authCookiesKeys.clockDrift, + value: String(clockDrift), + }, + { + name: `${AUTH_KEY_PREFIX}.${userPoolClientId}.LastAuthUser`, + value: username, + }, + ]; +}; + +export const createTokenRemoveCookies = (keys: string[]) => + keys.map(key => ({ name: key, value: '' })); + +export const createTokenCookiesSetOptions = ( + setCookieOptions: CookieStorage.SetCookieOptions, +) => ({ + domain: setCookieOptions?.domain, + path: '/', + httpOnly: true, + secure: true, + sameSite: setCookieOptions.sameSite ?? 'strict', + expires: + setCookieOptions?.expires ?? new Date(Date.now() + DEFAULT_COOKIE_EXPIRY), +}); + +export const createTokenCookiesRemoveOptions = ( + setCookieOptions: CookieStorage.SetCookieOptions, +) => ({ + domain: setCookieOptions?.domain, + path: '/', + expires: new Date('1970-01-01'), +}); diff --git a/packages/adapter-nextjs/src/utils/cookie/ensureEncodedForJSCookie.ts b/packages/adapter-nextjs/src/utils/cookie/ensureEncodedForJSCookie.ts new file mode 100644 index 00000000000..16e080efd26 --- /dev/null +++ b/packages/adapter-nextjs/src/utils/cookie/ensureEncodedForJSCookie.ts @@ -0,0 +1,11 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Ensures the cookie names are encoded in order to look up the cookie store +// that is manipulated by js-cookie on the client side. +// Details of the js-cookie encoding behavior see: +// https://github.com/js-cookie/js-cookie#encoding +// The implementation is borrowed from js-cookie without escaping `[()]` as +// we are not using those chars in the auth keys. +export const ensureEncodedForJSCookie = (name: string): string => + encodeURIComponent(name).replace(/%(2[346B]|5E|60|7C)/g, decodeURIComponent); diff --git a/packages/adapter-nextjs/src/utils/cookie/index.ts b/packages/adapter-nextjs/src/utils/cookie/index.ts new file mode 100644 index 00000000000..ce32d118c7c --- /dev/null +++ b/packages/adapter-nextjs/src/utils/cookie/index.ts @@ -0,0 +1,5 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { ensureEncodedForJSCookie } from './ensureEncodedForJSCookie'; +export { serializeCookie } from './serializeCookie'; diff --git a/packages/adapter-nextjs/src/utils/cookie/serializeCookie.ts b/packages/adapter-nextjs/src/utils/cookie/serializeCookie.ts new file mode 100644 index 00000000000..f8341e3bcce --- /dev/null +++ b/packages/adapter-nextjs/src/utils/cookie/serializeCookie.ts @@ -0,0 +1,38 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CookieStorage } from 'aws-amplify/adapter-core'; + +export const serializeCookie = ( + name: string, + value: string, + options?: CookieStorage.SetCookieOptions, +): string => + `${name}=${value};${options ? serializeSetCookieOptions(options) : ''}`; + +const serializeSetCookieOptions = ( + options: CookieStorage.SetCookieOptions, +): string => { + const { expires, domain, httpOnly, sameSite, secure, path } = options; + const serializedOptions: string[] = []; + if (domain) { + serializedOptions.push(`Domain=${domain}`); + } + if (expires) { + serializedOptions.push(`Expires=${expires.toUTCString()}`); + } + if (httpOnly) { + serializedOptions.push(`HttpOnly`); + } + if (sameSite) { + serializedOptions.push(`SameSite=${sameSite}`); + } + if (secure) { + serializedOptions.push(`Secure`); + } + if (path) { + serializedOptions.push(`Path=${path}`); + } + + return serializedOptions.join(';'); +}; diff --git a/packages/adapter-nextjs/src/utils/createCookieStorageAdapterFromNextServerContext.ts b/packages/adapter-nextjs/src/utils/createCookieStorageAdapterFromNextServerContext.ts index e3f99cbf96c..f4189915280 100644 --- a/packages/adapter-nextjs/src/utils/createCookieStorageAdapterFromNextServerContext.ts +++ b/packages/adapter-nextjs/src/utils/createCookieStorageAdapterFromNextServerContext.ts @@ -9,6 +9,8 @@ import { import { NextServer } from '../types'; +import { ensureEncodedForJSCookie, serializeCookie } from './cookie'; + export const DATE_IN_THE_PAST = new Date(0); export const createCookieStorageAdapterFromNextServerContext = ( @@ -190,9 +192,7 @@ const createCookieStorageAdapterFromGetServerSidePropsContext = ( response.appendHeader( 'Set-Cookie', - `${encodedName}=${value};${ - options ? serializeSetCookieOptions(options) : '' - }`, + serializeCookie(encodedName, value, options), ); }, delete(name) { @@ -219,9 +219,7 @@ const createMutableCookieStoreFromHeaders = ( const setFunc: CookieStorage.Adapter['set'] = (name, value, options) => { headers.append( 'Set-Cookie', - `${ensureEncodedForJSCookie(name)}=${value};${ - options ? serializeSetCookieOptions(options) : '' - }`, + serializeCookie(ensureEncodedForJSCookie(name), value, options), ); }; const deleteFunc: CookieStorage.Adapter['delete'] = name => { @@ -239,42 +237,6 @@ const createMutableCookieStoreFromHeaders = ( }; }; -const serializeSetCookieOptions = ( - options: CookieStorage.SetCookieOptions, -): string => { - const { expires, domain, httpOnly, sameSite, secure, path } = options; - const serializedOptions: string[] = []; - if (domain) { - serializedOptions.push(`Domain=${domain}`); - } - if (expires) { - serializedOptions.push(`Expires=${expires.toUTCString()}`); - } - if (httpOnly) { - serializedOptions.push(`HttpOnly`); - } - if (sameSite) { - serializedOptions.push(`SameSite=${sameSite}`); - } - if (secure) { - serializedOptions.push(`Secure`); - } - if (path) { - serializedOptions.push(`Path=${path}`); - } - - return serializedOptions.join(';'); -}; - -// Ensures the cookie names are encoded in order to look up the cookie store -// that is manipulated by js-cookie on the client side. -// Details of the js-cookie encoding behavior see: -// https://github.com/js-cookie/js-cookie#encoding -// The implementation is borrowed from js-cookie without escaping `[()]` as -// we are not using those chars in the auth keys. -const ensureEncodedForJSCookie = (name: string): string => - encodeURIComponent(name).replace(/%(2[346B]|5E|60|7C)/g, decodeURIComponent); - const getExistingSetCookieValues = ( values: number | string | string[] | undefined, ): string[] => diff --git a/packages/adapter-nextjs/tsconfig.json b/packages/adapter-nextjs/tsconfig.json index e58570f395f..3abdd7fb47a 100755 --- a/packages/adapter-nextjs/tsconfig.json +++ b/packages/adapter-nextjs/tsconfig.json @@ -2,7 +2,13 @@ "extends": "../../tsconfig.json", "compilerOptions": { "allowSyntheticDefaultImports": true, - "alwaysStrict": true + "alwaysStrict": true, + "lib": [ + "esnext" + ] }, - "include": ["./src", "__tests__"] + "include": [ + "./src", + "__tests__" + ] }