From acd2e4167b39562165470304ede1289ec330e975 Mon Sep 17 00:00:00 2001 From: Hui Zhao Date: Wed, 18 Sep 2024 16:56:20 -0700 Subject: [PATCH 1/5] chore(auth): export necessary utilities and types to support server-side auth --- packages/auth/src/providers/cognito/index.ts | 8 ++++++++ .../src/providers/cognito/tokenProvider/TokenStore.ts | 7 ++++--- .../auth/src/providers/cognito/tokenProvider/constants.ts | 4 ++++ .../auth/src/providers/cognito/tokenProvider/index.ts | 3 ++- packages/auth/src/providers/cognito/utils/oauth/index.ts | 1 + 5 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 packages/auth/src/providers/cognito/tokenProvider/constants.ts diff --git a/packages/auth/src/providers/cognito/index.ts b/packages/auth/src/providers/cognito/index.ts index 1af8daf8225..fc248aaa1de 100644 --- a/packages/auth/src/providers/cognito/index.ts +++ b/packages/auth/src/providers/cognito/index.ts @@ -81,4 +81,12 @@ export { DefaultTokenStore, refreshAuthTokens, refreshAuthTokensWithoutDedupe, + createKeysForAuthStorage, + AUTH_KEY_PREFIX, } from './tokenProvider'; +export { + generateState, + getRedirectUrl, + generateCodeVerifier, + validateState, +} from './utils/oauth'; diff --git a/packages/auth/src/providers/cognito/tokenProvider/TokenStore.ts b/packages/auth/src/providers/cognito/tokenProvider/TokenStore.ts index 74d6b9400c6..c17943d91bf 100644 --- a/packages/auth/src/providers/cognito/tokenProvider/TokenStore.ts +++ b/packages/auth/src/providers/cognito/tokenProvider/TokenStore.ts @@ -17,11 +17,12 @@ import { OAuthMetadata, } from './types'; import { TokenProviderErrorCode, assert } from './errorHelpers'; +import { AUTH_KEY_PREFIX } from './constants'; export class DefaultTokenStore implements AuthTokenStore { private authConfig?: AuthConfig; keyValueStorage?: KeyValueStorageInterface; - private name = 'CognitoIdentityServiceProvider'; // To be backwards compatible with V5, no migration needed + getKeyValueStorage(): KeyValueStorageInterface { if (!this.keyValueStorage) { throw new AuthError({ @@ -205,7 +206,7 @@ export class DefaultTokenStore implements AuthTokenStore { const lastAuthUser = username ?? (await this.getLastAuthUser()); return createKeysForAuthStorage( - this.name, + AUTH_KEY_PREFIX, `${this.authConfig.Cognito.userPoolClientId}.${lastAuthUser}`, ); } @@ -214,7 +215,7 @@ export class DefaultTokenStore implements AuthTokenStore { assertTokenProviderConfig(this.authConfig?.Cognito); const identifier = this.authConfig.Cognito.userPoolClientId; - return `${this.name}.${identifier}.LastAuthUser`; + return `${AUTH_KEY_PREFIX}.${identifier}.LastAuthUser`; } async getLastAuthUser(): Promise { diff --git a/packages/auth/src/providers/cognito/tokenProvider/constants.ts b/packages/auth/src/providers/cognito/tokenProvider/constants.ts new file mode 100644 index 00000000000..1ed75a3dfd0 --- /dev/null +++ b/packages/auth/src/providers/cognito/tokenProvider/constants.ts @@ -0,0 +1,4 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const AUTH_KEY_PREFIX = 'CognitoIdentityServiceProvider'; diff --git a/packages/auth/src/providers/cognito/tokenProvider/index.ts b/packages/auth/src/providers/cognito/tokenProvider/index.ts index 4d4cb8d581e..3d136f138c4 100644 --- a/packages/auth/src/providers/cognito/tokenProvider/index.ts +++ b/packages/auth/src/providers/cognito/tokenProvider/index.ts @@ -5,10 +5,11 @@ export { refreshAuthTokens, refreshAuthTokensWithoutDedupe, } from '../utils/refreshAuthTokens'; -export { DefaultTokenStore } from './TokenStore'; +export { DefaultTokenStore, createKeysForAuthStorage } from './TokenStore'; export { TokenOrchestrator } from './TokenOrchestrator'; export { CognitoUserPoolTokenProviderType } from './types'; export { cognitoUserPoolsTokenProvider, tokenOrchestrator, } from './tokenProvider'; +export { AUTH_KEY_PREFIX } from './constants'; diff --git a/packages/auth/src/providers/cognito/utils/oauth/index.ts b/packages/auth/src/providers/cognito/utils/oauth/index.ts index 93488e44c3a..170cb9fe722 100644 --- a/packages/auth/src/providers/cognito/utils/oauth/index.ts +++ b/packages/auth/src/providers/cognito/utils/oauth/index.ts @@ -8,3 +8,4 @@ export { getRedirectUrl } from './getRedirectUrl'; export { handleFailure } from './handleFailure'; export { completeOAuthFlow } from './completeOAuthFlow'; export { oAuthStore } from './oAuthStore'; +export { validateState } from './validateState'; From fc1f867a341bce17c776bb88df411f9329596df9 Mon Sep 17 00:00:00 2001 From: Hui Zhao Date: Wed, 18 Sep 2024 16:58:04 -0700 Subject: [PATCH 2/5] chore(aws-amplify): export necessary utilities to support server-side auth --- packages/aws-amplify/__tests__/exports.test.ts | 6 ++++++ packages/aws-amplify/jest.config.js | 4 ++++ packages/aws-amplify/src/adapter-core/constants.ts | 4 ++++ packages/aws-amplify/src/adapter-core/index.ts | 9 +++++++++ .../createKeyValueStorageFromCookieStorageAdapter.ts | 5 +++-- 5 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 packages/aws-amplify/src/adapter-core/constants.ts diff --git a/packages/aws-amplify/__tests__/exports.test.ts b/packages/aws-amplify/__tests__/exports.test.ts index 0225e72d868..9e0015afb9f 100644 --- a/packages/aws-amplify/__tests__/exports.test.ts +++ b/packages/aws-amplify/__tests__/exports.test.ts @@ -190,6 +190,8 @@ describe('aws-amplify Exports', () => { it('should only export expected symbols from the Cognito provider', () => { expect(Object.keys(authCognitoExports).sort()).toEqual( [ + 'AUTH_KEY_PREFIX', + 'createKeysForAuthStorage', 'signUp', 'resetPassword', 'confirmResetPassword', @@ -204,7 +206,10 @@ describe('aws-amplify Exports', () => { 'setUpTOTP', 'updateUserAttributes', 'updateUserAttribute', + 'generateCodeVerifier', + 'generateState', 'getCurrentUser', + 'getRedirectUrl', 'confirmUserAttribute', 'signInWithRedirect', 'fetchUserAttributes', @@ -224,6 +229,7 @@ describe('aws-amplify Exports', () => { 'DefaultTokenStore', 'refreshAuthTokens', 'refreshAuthTokensWithoutDedupe', + 'validateState', ].sort(), ); }); diff --git a/packages/aws-amplify/jest.config.js b/packages/aws-amplify/jest.config.js index 5254f524623..fa397b3b531 100644 --- a/packages/aws-amplify/jest.config.js +++ b/packages/aws-amplify/jest.config.js @@ -8,6 +8,10 @@ module.exports = { statements: 91, }, }, + coveragePathIgnorePatterns: [ + 'src/adapter-core/index.ts', + 'src/utils/index.ts', + ], moduleNameMapper: { uuid: require.resolve('uuid'), }, diff --git a/packages/aws-amplify/src/adapter-core/constants.ts b/packages/aws-amplify/src/adapter-core/constants.ts new file mode 100644 index 00000000000..d96711d3454 --- /dev/null +++ b/packages/aws-amplify/src/adapter-core/constants.ts @@ -0,0 +1,4 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const DEFAULT_COOKIE_EXPIRY = 365 * 24 * 60 * 60 * 1000; // 1 year in milliseconds diff --git a/packages/aws-amplify/src/adapter-core/index.ts b/packages/aws-amplify/src/adapter-core/index.ts index 755f8c12b42..0bb85581dc0 100644 --- a/packages/aws-amplify/src/adapter-core/index.ts +++ b/packages/aws-amplify/src/adapter-core/index.ts @@ -15,3 +15,12 @@ export { AmplifyServer, CookieStorage, } from '@aws-amplify/core/internals/adapter-core'; +export { + generateState, + getRedirectUrl, + generateCodeVerifier, + validateState, + createKeysForAuthStorage, + AUTH_KEY_PREFIX, +} from '@aws-amplify/auth/cognito'; +export { DEFAULT_COOKIE_EXPIRY } from './constants'; diff --git a/packages/aws-amplify/src/adapter-core/storageFactories/createKeyValueStorageFromCookieStorageAdapter.ts b/packages/aws-amplify/src/adapter-core/storageFactories/createKeyValueStorageFromCookieStorageAdapter.ts index a8bd0b4b656..de9ee037895 100644 --- a/packages/aws-amplify/src/adapter-core/storageFactories/createKeyValueStorageFromCookieStorageAdapter.ts +++ b/packages/aws-amplify/src/adapter-core/storageFactories/createKeyValueStorageFromCookieStorageAdapter.ts @@ -7,13 +7,14 @@ import { KeyValueStorageMethodValidator, } from '@aws-amplify/core/internals/adapter-core'; +import { DEFAULT_COOKIE_EXPIRY } from '../constants'; + export const defaultSetCookieOptions: CookieStorage.SetCookieOptions = { // TODO: allow configure with a public interface sameSite: 'lax', secure: true, path: '/', }; -const ONE_YEAR_IN_MS = 365 * 24 * 60 * 60 * 1000; /** * Creates a Key Value storage interface using the `cookieStorageAdapter` as the @@ -36,7 +37,7 @@ export const createKeyValueStorageFromCookieStorageAdapter = ( // TODO(HuiSF): follow up the default CookieSerializeOptions values cookieStorageAdapter.set(key, value, { ...defaultSetCookieOptions, - expires: new Date(Date.now() + ONE_YEAR_IN_MS), + expires: new Date(Date.now() + DEFAULT_COOKIE_EXPIRY), ...setCookieOptions, }); From ff48e7a72d2c18124a4be3e19c2ad53a5c920b0e Mon Sep 17 00:00:00 2001 From: Hui Zhao Date: Wed, 18 Sep 2024 16:59:11 -0700 Subject: [PATCH 3/5] feat(adapter-nextjs): server-side auth api route integrating cognito hosted ui --- .../createAuthRouteHandlersFactory.test.ts | 3 + ...dleAuthApiRouteRequestForAppRouter.test.ts | 160 +++++++- ...eAuthApiRouteRequestForPagesRouter.test.ts | 173 +++++++-- .../handleSignInCallbackRequest.test.ts | 306 ++++++++++++++++ ...ignInCallbackRequestForPagesRouter.test.ts | 343 ++++++++++++++++++ .../handleSignInSignUpRequest.test.ts | 155 ++++++++ ...eSignInSignUpRequestForPagesRouter.test.ts | 180 +++++++++ .../handleSignOutCallbackRequest.test.ts | 289 +++++++++++++++ ...gnOutCallbackRequestForPagesRouter.test.ts | 337 +++++++++++++++++ .../handlers/handleSignOutRequest.test.ts | 105 ++++++ ...handleSignOutRequestForPagesRouter.test.ts | 124 +++++++ .../__tests__/auth/testUtils.ts | 28 ++ ...CompletedRedirectIntermediate.test.ts.snap | 16 + .../auth/utils/appendSetCookieHeaders.test.ts | 27 ++ ...dSetCookieHeadersToNextApiResponse.test.ts | 40 ++ .../auth/utils/authFlowProofCookies.test.ts | 84 +++++ .../__tests__/auth/utils/authNTokens.test.ts | 199 ++++++++++ .../utils/cognitoHostedUIEndpoints.test.ts | 51 +++ .../auth/utils/createAuthFlowProofs.test.ts | 69 ++++ ...ignInCompletedRedirectIntermediate.test.ts | 12 + .../auth/utils/createUrlSearchParams.test.ts | 88 +++++ ...etAccessTokenUsernameAndClockDrift.test.ts | 48 +++ .../getCookieValuesFromNextApiRequest.test.ts | 25 ++ .../utils/getCookieValuesFromRequest.test.ts | 44 +++ .../utils/getSearchParamValueFromUrl.test.ts | 24 ++ .../utils/resolveCodeAndStateFromUrl.test.ts | 13 + .../resolveIdentityProviderFromUrl.test.ts | 22 ++ .../auth/utils/resolveRedirectUrl.test.ts | 46 +++ .../__tests__/auth/utils/tokenCookies.test.ts | 141 +++++++ packages/adapter-nextjs/src/auth/constant.ts | 16 +- .../auth/createAuthRouteHandlersFactory.ts | 11 +- .../handleAuthApiRouteRequestForAppRouter.ts | 61 +++- ...handleAuthApiRouteRequestForPagesRouter.ts | 81 ++++- .../handlers/handleSignInCallbackRequest.ts | 84 +++++ ...ndleSignInCallbackRequestForPagesRouter.ts | 93 +++++ .../handlers/handleSignInSignUpRequest.ts | 53 +++ ...handleSignInSignUpRequestForPagesRouter.ts | 49 +++ .../handlers/handleSignOutCallbackRequest.ts | 93 +++++ ...dleSignOutCallbackRequestForPagesRouter.ts | 89 +++++ .../src/auth/handlers/handleSignOutRequest.ts | 40 ++ .../handleSignOutRequestForPagesRouter.ts | 31 ++ .../adapter-nextjs/src/auth/handlers/index.ts | 11 + .../adapter-nextjs/src/auth/handlers/types.ts | 88 +++++ packages/adapter-nextjs/src/auth/types.ts | 25 +- .../src/auth/utils/appendSetCookieHeaders.ts | 18 + ...appendSetCookieHeadersToNextApiResponse.ts | 20 + .../src/auth/utils/authFlowProofCookies.ts | 54 +++ .../src/auth/utils/authNTokens.ts | 87 +++++ .../auth/utils/cognitoHostedUIEndpoints.ts | 28 ++ .../src/auth/utils/createAuthFlowProofs.ts | 22 ++ ...teOnSignInCompletedRedirectIntermediate.ts | 25 ++ .../src/auth/utils/createUrlSearchParams.ts | 59 +++ .../getAccessTokenUsernameAndClockDrift.ts | 21 ++ .../getCookieValuesFromNextApiRequest.ts | 20 + .../auth/utils/getCookieValuesFromRequest.ts | 36 ++ .../auth/utils/getSearchParamValueFromUrl.ts | 23 ++ .../adapter-nextjs/src/auth/utils/index.ts | 35 ++ .../auth/utils/isSupportedAuthApiRoutePath.ts | 4 +- .../auth/utils/resolveCodeAndStateFromUrl.ts | 14 + .../utils/resolveIdentityProviderFromUrl.ts | 20 + .../src/auth/utils/resolveRedirectUrl.ts | 41 +++ .../src/auth/utils/tokenCookies.ts | 75 ++++ .../utils/cookie/ensureEncodedForJSCookie.ts | 11 + .../adapter-nextjs/src/utils/cookie/index.ts | 5 + .../src/utils/cookie/serializeCookie.ts | 38 ++ ...okieStorageAdapterFromNextServerContext.ts | 46 +-- packages/adapter-nextjs/tsconfig.json | 10 +- 67 files changed, 4595 insertions(+), 94 deletions(-) create mode 100644 packages/adapter-nextjs/__tests__/auth/handlers/handleSignInCallbackRequest.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/handlers/handleSignInCallbackRequestForPagesRouter.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/handlers/handleSignInSignUpRequest.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/handlers/handleSignInSignUpRequestForPagesRouter.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutCallbackRequest.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutCallbackRequestForPagesRouter.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutRequest.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutRequestForPagesRouter.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/testUtils.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/utils/__snapshots__/createOnSignInCompletedRedirectIntermediate.test.ts.snap create mode 100644 packages/adapter-nextjs/__tests__/auth/utils/appendSetCookieHeaders.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/utils/appendSetCookieHeadersToNextApiResponse.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/utils/authFlowProofCookies.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/utils/authNTokens.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/utils/cognitoHostedUIEndpoints.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/utils/createAuthFlowProofs.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/utils/createOnSignInCompletedRedirectIntermediate.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/utils/createUrlSearchParams.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/utils/getAccessTokenUsernameAndClockDrift.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/utils/getCookieValuesFromNextApiRequest.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/utils/getCookieValuesFromRequest.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/utils/getSearchParamValueFromUrl.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/utils/resolveCodeAndStateFromUrl.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/utils/resolveIdentityProviderFromUrl.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/utils/resolveRedirectUrl.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/utils/tokenCookies.test.ts create mode 100644 packages/adapter-nextjs/src/auth/handlers/handleSignInCallbackRequest.ts create mode 100644 packages/adapter-nextjs/src/auth/handlers/handleSignInCallbackRequestForPagesRouter.ts create mode 100644 packages/adapter-nextjs/src/auth/handlers/handleSignInSignUpRequest.ts create mode 100644 packages/adapter-nextjs/src/auth/handlers/handleSignInSignUpRequestForPagesRouter.ts create mode 100644 packages/adapter-nextjs/src/auth/handlers/handleSignOutCallbackRequest.ts create mode 100644 packages/adapter-nextjs/src/auth/handlers/handleSignOutCallbackRequestForPagesRouter.ts create mode 100644 packages/adapter-nextjs/src/auth/handlers/handleSignOutRequest.ts create mode 100644 packages/adapter-nextjs/src/auth/handlers/handleSignOutRequestForPagesRouter.ts create mode 100644 packages/adapter-nextjs/src/auth/handlers/index.ts create mode 100644 packages/adapter-nextjs/src/auth/handlers/types.ts create mode 100644 packages/adapter-nextjs/src/auth/utils/appendSetCookieHeaders.ts create mode 100644 packages/adapter-nextjs/src/auth/utils/appendSetCookieHeadersToNextApiResponse.ts create mode 100644 packages/adapter-nextjs/src/auth/utils/authFlowProofCookies.ts create mode 100644 packages/adapter-nextjs/src/auth/utils/authNTokens.ts create mode 100644 packages/adapter-nextjs/src/auth/utils/cognitoHostedUIEndpoints.ts create mode 100644 packages/adapter-nextjs/src/auth/utils/createAuthFlowProofs.ts create mode 100644 packages/adapter-nextjs/src/auth/utils/createOnSignInCompletedRedirectIntermediate.ts create mode 100644 packages/adapter-nextjs/src/auth/utils/createUrlSearchParams.ts create mode 100644 packages/adapter-nextjs/src/auth/utils/getAccessTokenUsernameAndClockDrift.ts create mode 100644 packages/adapter-nextjs/src/auth/utils/getCookieValuesFromNextApiRequest.ts create mode 100644 packages/adapter-nextjs/src/auth/utils/getCookieValuesFromRequest.ts create mode 100644 packages/adapter-nextjs/src/auth/utils/getSearchParamValueFromUrl.ts create mode 100644 packages/adapter-nextjs/src/auth/utils/resolveCodeAndStateFromUrl.ts create mode 100644 packages/adapter-nextjs/src/auth/utils/resolveIdentityProviderFromUrl.ts create mode 100644 packages/adapter-nextjs/src/auth/utils/resolveRedirectUrl.ts create mode 100644 packages/adapter-nextjs/src/auth/utils/tokenCookies.ts create mode 100644 packages/adapter-nextjs/src/utils/cookie/ensureEncodedForJSCookie.ts create mode 100644 packages/adapter-nextjs/src/utils/cookie/index.ts create mode 100644 packages/adapter-nextjs/src/utils/cookie/serializeCookie.ts diff --git a/packages/adapter-nextjs/__tests__/auth/createAuthRouteHandlersFactory.test.ts b/packages/adapter-nextjs/__tests__/auth/createAuthRouteHandlersFactory.test.ts index e199e919537..e744fb0ca82 100644 --- a/packages/adapter-nextjs/__tests__/auth/createAuthRouteHandlersFactory.test.ts +++ b/packages/adapter-nextjs/__tests__/auth/createAuthRouteHandlersFactory.test.ts @@ -137,6 +137,7 @@ describe('createAuthRoutesHandlersFactory', () => { oAuthConfig: mockAmplifyConfig.Auth!.Cognito!.loginWith!.oauth, setCookieOptions: mockRuntimeOptions.cookies, origin: 'https://example.com', + userPoolClientId: 'def', }); }); @@ -157,6 +158,7 @@ describe('createAuthRoutesHandlersFactory', () => { oAuthConfig: mockAmplifyConfig.Auth!.Cognito!.loginWith!.oauth, setCookieOptions: mockRuntimeOptions.cookies, origin: 'https://example.com', + userPoolClientId: 'def', }); }); @@ -199,6 +201,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 3cdb9a9f4b3..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,45 +41,55 @@ describe('handleAuthApiRouteRequestForPagesRouter', () => { scopes: ['openid', 'email'], }; const testSetCookieOptions = {}; - const mockEnd = jest.fn(); - const mockStatus = jest.fn(() => ({ end: mockEnd })); + const { + mockResponseAppendHeader, + mockResponseEnd, + mockResponseStatus, + mockResponseSend, + mockResponseRedirect, + mockResponse, + } = createMockNextApiResponse(); afterEach(() => { - mockEnd.mockClear(); + mockResponseAppendHeader.mockClear(); + mockResponseEnd.mockClear(); + mockResponseStatus.mockClear(); + mockResponseSend.mockClear(); + mockResponseRedirect.mockClear(); }); it('sets response.status(405) when request has an unsupported method', () => { 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 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', () => { @@ -63,39 +97,136 @@ describe('handleAuthApiRouteRequestForPagesRouter', () => { 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', () => { + 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 d2774e7dc73..a3de55c9701 100644 --- a/packages/adapter-nextjs/src/auth/createAuthRouteHandlersFactory.ts +++ b/packages/adapter-nextjs/src/auth/createAuthRouteHandlersFactory.ts @@ -39,6 +39,7 @@ export const createAuthRouteHandlersFactory = ({ assertTokenProviderConfig(resourcesConfig.Auth?.Cognito); assertOAuthConfig(resourcesConfig.Auth.Cognito); + const { userPoolClientId } = resourcesConfig.Auth.Cognito; const { oauth: oAuthConfig } = resourcesConfig.Auth.Cognito.loginWith; const { cookies: setCookieOptions = {} } = runtimeOptions; @@ -46,12 +47,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: amplifyAppOrigin, @@ -70,6 +76,7 @@ export const createAuthRouteHandlersFactory = ({ request, handlerContext: contextOrResponse, handlerInput, + userPoolClientId, oAuthConfig, setCookieOptions, origin: amplifyAppOrigin, 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 9ba8ff6af00..5d1edddc0d2 100644 --- a/packages/adapter-nextjs/src/auth/types.ts +++ b/packages/adapter-nextjs/src/auth/types.ts @@ -82,6 +82,7 @@ export type CreateOAuthRouteHandlersFactory = ( interface HandleAuthApiRouteRequestInputBase { handlerInput: CreateAuthRoutesHandlersInput; + userPoolClientId: string; oAuthConfig: OAuthConfig; setCookieOptions: CookieStorage.SetCookieOptions; origin: string; @@ -101,8 +102,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 638cb60e526..184074a9896 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 './predicates'; 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 6b75b234b54..06ccfee1a93 100644 --- a/packages/adapter-nextjs/src/auth/utils/isSupportedAuthApiRoutePath.ts +++ b/packages/adapter-nextjs/src/auth/utils/isSupportedAuthApiRoutePath.ts @@ -1,11 +1,11 @@ // 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 supportedRoutePaths.includes(path as SupportedRoutePaths); + return 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 c36776d3ad1..4b37a1dfa89 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 = async ( @@ -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__" + ] } From 56dd74123b35a9c3e72d0faa292e5e81d2eb6ef7 Mon Sep 17 00:00:00 2001 From: Hui Zhao Date: Fri, 20 Dec 2024 17:46:32 -0800 Subject: [PATCH 4/5] chore(adapter-nextjs): resolve comments --- .../handleSignInCallbackRequest.test.ts | 17 ++++--- ...ignInCallbackRequestForPagesRouter.test.ts | 12 ++--- ...CompleteRedirectIntermediate.test.ts.snap} | 0 ...ignInCompleteRedirectIntermediate.test.ts} | 4 +- .../resolveIdentityProviderFromUrl.test.ts | 1 + .../__tests__/auth/utils/tokenCookies.test.ts | 46 +++++++++---------- .../handlers/handleSignInCallbackRequest.ts | 6 +-- ...ndleSignInCallbackRequestForPagesRouter.ts | 6 +-- ...handleSignInSignUpRequestForPagesRouter.ts | 8 ++-- .../handleSignOutRequestForPagesRouter.ts | 2 +- ...teOnSignInCompleteRedirectIntermediate.ts} | 2 +- .../src/auth/utils/createUrlSearchParams.ts | 8 +--- .../getCookieValuesFromNextApiRequest.ts | 16 ++++--- .../auth/utils/getCookieValuesFromRequest.ts | 11 ++--- .../auth/utils/getSearchParamValueFromUrl.ts | 19 +++----- .../adapter-nextjs/src/auth/utils/index.ts | 2 +- .../utils/resolveIdentityProviderFromUrl.ts | 2 +- 17 files changed, 77 insertions(+), 85 deletions(-) rename packages/adapter-nextjs/__tests__/auth/utils/__snapshots__/{createOnSignInCompletedRedirectIntermediate.test.ts.snap => createOnSignInCompleteRedirectIntermediate.test.ts.snap} (100%) rename packages/adapter-nextjs/__tests__/auth/utils/{createOnSignInCompletedRedirectIntermediate.test.ts => createOnSignInCompleteRedirectIntermediate.test.ts} (58%) rename packages/adapter-nextjs/src/auth/utils/{createOnSignInCompletedRedirectIntermediate.ts => createOnSignInCompleteRedirectIntermediate.ts} (93%) diff --git a/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInCallbackRequest.test.ts b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInCallbackRequest.test.ts index 8cd13a079cb..4a7dea1bd87 100644 --- a/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInCallbackRequest.test.ts +++ b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInCallbackRequest.test.ts @@ -9,7 +9,7 @@ import { handleSignInCallbackRequest } from '../../../src/auth/handlers/handleSi import { appendSetCookieHeaders, createAuthFlowProofCookiesRemoveOptions, - createOnSignInCompletedRedirectIntermediate, + createOnSignInCompleteRedirectIntermediate, createSignInFlowProofCookies, createTokenCookies, createTokenCookiesSetOptions, @@ -30,8 +30,8 @@ const mockAppendSetCookieHeaders = jest.mocked(appendSetCookieHeaders); const mockCreateAuthFlowProofCookiesRemoveOptions = jest.mocked( createAuthFlowProofCookiesRemoveOptions, ); -const mockCreateOnSignInCompletedRedirectIntermediate = jest.mocked( - createOnSignInCompletedRedirectIntermediate, +const mockCreateOnSignInCompleteRedirectIntermediate = jest.mocked( + createOnSignInCompleteRedirectIntermediate, ); const mockCreateSignInFlowProofCookies = jest.mocked( createSignInFlowProofCookies, @@ -58,7 +58,7 @@ describe('handleSignInCallbackRequest', () => { afterEach(() => { mockAppendSetCookieHeaders.mockClear(); mockCreateAuthFlowProofCookiesRemoveOptions.mockClear(); - mockCreateOnSignInCompletedRedirectIntermediate.mockClear(); + mockCreateOnSignInCompleteRedirectIntermediate.mockClear(); mockCreateSignInFlowProofCookies.mockClear(); mockCreateTokenCookies.mockClear(); mockCreateTokenCookiesSetOptions.mockClear(); @@ -188,6 +188,11 @@ describe('handleSignInCallbackRequest', () => { '/', `redirect to /`, ], + [ + { ...mockHandlerInput, redirectOnSignInComplete: '' }, + '/', + `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) => { @@ -245,7 +250,7 @@ describe('handleSignInCallbackRequest', () => { headers.append('Set-cookie', 'mock-cookie-1'); headers.append('Set-cookie', 'mock-cookie-2'); }); - mockCreateOnSignInCompletedRedirectIntermediate.mockImplementationOnce( + mockCreateOnSignInCompleteRedirectIntermediate.mockImplementationOnce( ({ redirectOnSignInComplete }) => `redirect to ${redirectOnSignInComplete}`, ); @@ -297,7 +302,7 @@ describe('handleSignInCallbackRequest', () => { mockCreateAuthFlowProofCookiesRemoveOptionsResult, ); expect( - mockCreateOnSignInCompletedRedirectIntermediate, + mockCreateOnSignInCompleteRedirectIntermediate, ).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 index 992ceca7ae4..918e1c255ca 100644 --- a/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInCallbackRequestForPagesRouter.test.ts +++ b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInCallbackRequestForPagesRouter.test.ts @@ -9,7 +9,7 @@ import { handleSignInCallbackRequestForPagesRouter } from '../../../src/auth/han import { appendSetCookieHeadersToNextApiResponse, createAuthFlowProofCookiesRemoveOptions, - createOnSignInCompletedRedirectIntermediate, + createOnSignInCompleteRedirectIntermediate, createSignInFlowProofCookies, createTokenCookies, createTokenCookiesSetOptions, @@ -33,8 +33,8 @@ const mockAppendSetCookieHeadersToNextApiResponse = jest.mocked( const mockCreateAuthFlowProofCookiesRemoveOptions = jest.mocked( createAuthFlowProofCookiesRemoveOptions, ); -const mockCreateOnSignInCompletedRedirectIntermediate = jest.mocked( - createOnSignInCompletedRedirectIntermediate, +const mockCreateOnSignInCompleteRedirectIntermediate = jest.mocked( + createOnSignInCompleteRedirectIntermediate, ); const mockCreateSignInFlowProofCookies = jest.mocked( createSignInFlowProofCookies, @@ -70,7 +70,7 @@ describe('handleSignInCallbackRequest', () => { afterEach(() => { mockAppendSetCookieHeadersToNextApiResponse.mockClear(); mockCreateAuthFlowProofCookiesRemoveOptions.mockClear(); - mockCreateOnSignInCompletedRedirectIntermediate.mockClear(); + mockCreateOnSignInCompleteRedirectIntermediate.mockClear(); mockCreateSignInFlowProofCookies.mockClear(); mockCreateTokenCookies.mockClear(); mockCreateTokenCookiesSetOptions.mockClear(); @@ -283,7 +283,7 @@ describe('handleSignInCallbackRequest', () => { response.appendHeader('Set-cookie', 'mock-cookie-2'); }, ); - mockCreateOnSignInCompletedRedirectIntermediate.mockImplementationOnce( + mockCreateOnSignInCompleteRedirectIntermediate.mockImplementationOnce( ({ redirectOnSignInComplete }) => `redirect to ${redirectOnSignInComplete}`, ); @@ -334,7 +334,7 @@ describe('handleSignInCallbackRequest', () => { ); expect( - mockCreateOnSignInCompletedRedirectIntermediate, + mockCreateOnSignInCompleteRedirectIntermediate, ).toHaveBeenCalledWith({ redirectOnSignInComplete: expectedFinalRedirect, }); diff --git a/packages/adapter-nextjs/__tests__/auth/utils/__snapshots__/createOnSignInCompletedRedirectIntermediate.test.ts.snap b/packages/adapter-nextjs/__tests__/auth/utils/__snapshots__/createOnSignInCompleteRedirectIntermediate.test.ts.snap similarity index 100% rename from packages/adapter-nextjs/__tests__/auth/utils/__snapshots__/createOnSignInCompletedRedirectIntermediate.test.ts.snap rename to packages/adapter-nextjs/__tests__/auth/utils/__snapshots__/createOnSignInCompleteRedirectIntermediate.test.ts.snap diff --git a/packages/adapter-nextjs/__tests__/auth/utils/createOnSignInCompletedRedirectIntermediate.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/createOnSignInCompleteRedirectIntermediate.test.ts similarity index 58% rename from packages/adapter-nextjs/__tests__/auth/utils/createOnSignInCompletedRedirectIntermediate.test.ts rename to packages/adapter-nextjs/__tests__/auth/utils/createOnSignInCompleteRedirectIntermediate.test.ts index d70a4f47285..0dcb8292689 100644 --- a/packages/adapter-nextjs/__tests__/auth/utils/createOnSignInCompletedRedirectIntermediate.test.ts +++ b/packages/adapter-nextjs/__tests__/auth/utils/createOnSignInCompleteRedirectIntermediate.test.ts @@ -1,9 +1,9 @@ -import { createOnSignInCompletedRedirectIntermediate } from '../../../src/auth/utils/createOnSignInCompletedRedirectIntermediate'; +import { createOnSignInCompleteRedirectIntermediate } from '../../../src/auth/utils/createOnSignInCompleteRedirectIntermediate'; describe('createOnSignInCompletedRedirectIntermediate', () => { it('returns html with script that redirects to the redirectUrl', () => { const redirectUrl = 'https://example.com'; - const result = createOnSignInCompletedRedirectIntermediate({ + const result = createOnSignInCompleteRedirectIntermediate({ redirectOnSignInComplete: redirectUrl, }); diff --git a/packages/adapter-nextjs/__tests__/auth/utils/resolveIdentityProviderFromUrl.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/resolveIdentityProviderFromUrl.test.ts index 0f64f537b4d..baab3c17d0e 100644 --- a/packages/adapter-nextjs/__tests__/auth/utils/resolveIdentityProviderFromUrl.test.ts +++ b/packages/adapter-nextjs/__tests__/auth/utils/resolveIdentityProviderFromUrl.test.ts @@ -3,6 +3,7 @@ import { resolveIdentityProviderFromUrl } from '../../../src/auth/utils/resolveI describe('resolveIdentityProviderFromUrl', () => { test.each([ ['https://example.com?provider=Google', 'Google'], + ['https://example.com?provider=GOogLe', 'Google'], ['https://example.com?provider=Facebook', 'Facebook'], ['https://example.com?provider=Amazon', 'LoginWithAmazon'], ['https://example.com?provider=Apple', 'SignInWithApple'], diff --git a/packages/adapter-nextjs/__tests__/auth/utils/tokenCookies.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/tokenCookies.test.ts index 45052089cc8..926d10e798f 100644 --- a/packages/adapter-nextjs/__tests__/auth/utils/tokenCookies.test.ts +++ b/packages/adapter-nextjs/__tests__/auth/utils/tokenCookies.test.ts @@ -44,30 +44,28 @@ describe('createTokenCookies', () => { 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(), - ); + expect(result).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, + }, + ]); }); }); diff --git a/packages/adapter-nextjs/src/auth/handlers/handleSignInCallbackRequest.ts b/packages/adapter-nextjs/src/auth/handlers/handleSignInCallbackRequest.ts index dbca5764b70..e1689c8f73d 100644 --- a/packages/adapter-nextjs/src/auth/handlers/handleSignInCallbackRequest.ts +++ b/packages/adapter-nextjs/src/auth/handlers/handleSignInCallbackRequest.ts @@ -5,7 +5,7 @@ import { PKCE_COOKIE_NAME, STATE_COOKIE_NAME } from '../constant'; import { appendSetCookieHeaders, createAuthFlowProofCookiesRemoveOptions, - createOnSignInCompletedRedirectIntermediate, + createOnSignInCompleteRedirectIntermediate, createSignInFlowProofCookies, createTokenCookies, createTokenCookiesSetOptions, @@ -73,8 +73,8 @@ export const handleSignInCallbackRequest: HandleSignInCallbackRequest = async ({ headers.set('Content-Type', 'text/html'); return new Response( - createOnSignInCompletedRedirectIntermediate({ - redirectOnSignInComplete: handlerInput.redirectOnSignInComplete ?? '/', + createOnSignInCompleteRedirectIntermediate({ + redirectOnSignInComplete: handlerInput.redirectOnSignInComplete || '/', }), { status: 200, diff --git a/packages/adapter-nextjs/src/auth/handlers/handleSignInCallbackRequestForPagesRouter.ts b/packages/adapter-nextjs/src/auth/handlers/handleSignInCallbackRequestForPagesRouter.ts index d9717168f34..e7210530759 100644 --- a/packages/adapter-nextjs/src/auth/handlers/handleSignInCallbackRequestForPagesRouter.ts +++ b/packages/adapter-nextjs/src/auth/handlers/handleSignInCallbackRequestForPagesRouter.ts @@ -5,7 +5,7 @@ import { PKCE_COOKIE_NAME, STATE_COOKIE_NAME } from '../constant'; import { appendSetCookieHeadersToNextApiResponse, createAuthFlowProofCookiesRemoveOptions, - createOnSignInCompletedRedirectIntermediate, + createOnSignInCompleteRedirectIntermediate, createSignInFlowProofCookies, createTokenCookies, createTokenCookiesSetOptions, @@ -85,9 +85,9 @@ export const handleSignInCallbackRequestForPagesRouter: HandleSignInCallbackRequ .appendHeader('Content-Type', 'text/html') .status(200) .send( - createOnSignInCompletedRedirectIntermediate({ + createOnSignInCompleteRedirectIntermediate({ redirectOnSignInComplete: - handlerInput.redirectOnSignInComplete ?? '/', + handlerInput.redirectOnSignInComplete || '/', }), ); }; diff --git a/packages/adapter-nextjs/src/auth/handlers/handleSignInSignUpRequestForPagesRouter.ts b/packages/adapter-nextjs/src/auth/handlers/handleSignInSignUpRequestForPagesRouter.ts index 065f28fbfa9..056ddceab0f 100644 --- a/packages/adapter-nextjs/src/auth/handlers/handleSignInSignUpRequestForPagesRouter.ts +++ b/packages/adapter-nextjs/src/auth/handlers/handleSignInSignUpRequestForPagesRouter.ts @@ -40,10 +40,10 @@ export const handleSignInSignUpRequestForPagesRouter: HandleSignInSignUpRequestF createAuthFlowProofCookiesSetOptions(setCookieOptions), ); - response.redirect( - 302, + const redirectUrl = type === 'signIn' ? createAuthorizeEndpoint(oAuthConfig.domain, redirectUrlSearchParams) - : createSignUpEndpoint(oAuthConfig.domain, redirectUrlSearchParams), - ); + : createSignUpEndpoint(oAuthConfig.domain, redirectUrlSearchParams); + + response.redirect(302, redirectUrl); }; diff --git a/packages/adapter-nextjs/src/auth/handlers/handleSignOutRequestForPagesRouter.ts b/packages/adapter-nextjs/src/auth/handlers/handleSignOutRequestForPagesRouter.ts index bf4d21f8c64..6b12bd84e2e 100644 --- a/packages/adapter-nextjs/src/auth/handlers/handleSignOutRequestForPagesRouter.ts +++ b/packages/adapter-nextjs/src/auth/handlers/handleSignOutRequestForPagesRouter.ts @@ -26,6 +26,6 @@ export const handleSignOutRequestForPagesRouter: HandleSignOutRequestForPagesRou response.redirect( 302, - createLogoutEndpoint(oAuthConfig.domain, urlSearchParams).toString(), + createLogoutEndpoint(oAuthConfig.domain, urlSearchParams), ); }; diff --git a/packages/adapter-nextjs/src/auth/utils/createOnSignInCompletedRedirectIntermediate.ts b/packages/adapter-nextjs/src/auth/utils/createOnSignInCompleteRedirectIntermediate.ts similarity index 93% rename from packages/adapter-nextjs/src/auth/utils/createOnSignInCompletedRedirectIntermediate.ts rename to packages/adapter-nextjs/src/auth/utils/createOnSignInCompleteRedirectIntermediate.ts index 320068edae0..dea9f18f3da 100644 --- a/packages/adapter-nextjs/src/auth/utils/createOnSignInCompletedRedirectIntermediate.ts +++ b/packages/adapter-nextjs/src/auth/utils/createOnSignInCompleteRedirectIntermediate.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -export const createOnSignInCompletedRedirectIntermediate = ({ +export const createOnSignInCompleteRedirectIntermediate = ({ redirectOnSignInComplete, }: { redirectOnSignInComplete: string; diff --git a/packages/adapter-nextjs/src/auth/utils/createUrlSearchParams.ts b/packages/adapter-nextjs/src/auth/utils/createUrlSearchParams.ts index fc584dd60f4..f01b318b38c 100644 --- a/packages/adapter-nextjs/src/auth/utils/createUrlSearchParams.ts +++ b/packages/adapter-nextjs/src/auth/utils/createUrlSearchParams.ts @@ -47,13 +47,9 @@ export const createUrlSearchParamsForTokenExchange = (input: { redirect_uri: string; code_verifier: string; grant_type: string; -}): URLSearchParams => createUrlSearchParamsFromObject(input); +}): URLSearchParams => new URLSearchParams(input); export const createUrlSearchParamsForTokenRevocation = (input: { token: string; client_id: string; -}): URLSearchParams => createUrlSearchParamsFromObject(input); - -const createUrlSearchParamsFromObject = ( - input: Record, -): URLSearchParams => new URLSearchParams(input); +}): URLSearchParams => new URLSearchParams(input); diff --git a/packages/adapter-nextjs/src/auth/utils/getCookieValuesFromNextApiRequest.ts b/packages/adapter-nextjs/src/auth/utils/getCookieValuesFromNextApiRequest.ts index d770d1ca57d..c625e392797 100644 --- a/packages/adapter-nextjs/src/auth/utils/getCookieValuesFromNextApiRequest.ts +++ b/packages/adapter-nextjs/src/auth/utils/getCookieValuesFromNextApiRequest.ts @@ -3,18 +3,20 @@ import { NextApiRequest } from 'next'; -export const getCookieValuesFromNextApiRequest = ( +export const getCookieValuesFromNextApiRequest = < + CookieNames extends string[], + R = { + [key in CookieNames[number]]?: string | undefined; + }, +>( request: NextApiRequest, cookieNames: CookieNames, -): { - [key in CookieNames[number]]?: string | undefined; -} => { +): R => { const result: Record = {}; + for (const cookieName of cookieNames) { result[cookieName] = request.cookies[cookieName]; } - return result as { - [key in CookieNames[number]]?: string | undefined; - }; + return result as R; }; diff --git a/packages/adapter-nextjs/src/auth/utils/getCookieValuesFromRequest.ts b/packages/adapter-nextjs/src/auth/utils/getCookieValuesFromRequest.ts index 563d59773ff..44a69e6c97b 100644 --- a/packages/adapter-nextjs/src/auth/utils/getCookieValuesFromRequest.ts +++ b/packages/adapter-nextjs/src/auth/utils/getCookieValuesFromRequest.ts @@ -16,14 +16,11 @@ export const getCookieValuesFromRequest = ( const cookieValues: Record = cookieHeader .split(';') .map(cookie => cookie.trim().split('=')) - .reduce( - (result, [key, value]) => { - result[key] = value; + .reduce>((result, [key, value]) => { + result[key] = value; - return result; - }, - {} as Record, - ); + return result; + }, {}); const result: Record = {}; for (const cookieName of cookieNames) { diff --git a/packages/adapter-nextjs/src/auth/utils/getSearchParamValueFromUrl.ts b/packages/adapter-nextjs/src/auth/utils/getSearchParamValueFromUrl.ts index f3f5e78ca42..b368fa409f5 100644 --- a/packages/adapter-nextjs/src/auth/utils/getSearchParamValueFromUrl.ts +++ b/packages/adapter-nextjs/src/auth/utils/getSearchParamValueFromUrl.ts @@ -5,19 +5,12 @@ 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); - } + if (urlStr.includes('?')) { + const queryParams = urlStr.split('?')[1]; + if (queryParams) { + return new URLSearchParams(queryParams).get(paramName); } - - return null; } + + return null; }; diff --git a/packages/adapter-nextjs/src/auth/utils/index.ts b/packages/adapter-nextjs/src/auth/utils/index.ts index 184074a9896..a82d25f3dca 100644 --- a/packages/adapter-nextjs/src/auth/utils/index.ts +++ b/packages/adapter-nextjs/src/auth/utils/index.ts @@ -11,7 +11,7 @@ export { createAuthFlowProofCookiesRemoveOptions, } from './authFlowProofCookies'; export { createAuthFlowProofs } from './createAuthFlowProofs'; -export { createOnSignInCompletedRedirectIntermediate } from './createOnSignInCompletedRedirectIntermediate'; +export { createOnSignInCompleteRedirectIntermediate } from './createOnSignInCompleteRedirectIntermediate'; export { createUrlSearchParamsForSignInSignUp } from './createUrlSearchParams'; export { createAuthorizeEndpoint, diff --git a/packages/adapter-nextjs/src/auth/utils/resolveIdentityProviderFromUrl.ts b/packages/adapter-nextjs/src/auth/utils/resolveIdentityProviderFromUrl.ts index f3897b365db..ca9ca690ca3 100644 --- a/packages/adapter-nextjs/src/auth/utils/resolveIdentityProviderFromUrl.ts +++ b/packages/adapter-nextjs/src/auth/utils/resolveIdentityProviderFromUrl.ts @@ -17,4 +17,4 @@ const resolveProvider = (provider: string | null): string | null => { }; const capitalize = (value: string) => - `${value[0].toUpperCase()}${value.substring(1)}`; + `${value[0].toUpperCase()}${value.substring(1).toLowerCase()}`; From 97957f71ce1484fcbdfb526e3192727672496f5c Mon Sep 17 00:00:00 2001 From: Hui Zhao Date: Thu, 2 Jan 2025 13:25:05 -0800 Subject: [PATCH 5/5] refactor(adapter-nextjs): remove redundant username fallback --- .../getAccessTokenUsernameAndClockDrift.test.ts | 13 ------------- .../utils/getAccessTokenUsernameAndClockDrift.ts | 2 +- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/packages/adapter-nextjs/__tests__/auth/utils/getAccessTokenUsernameAndClockDrift.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/getAccessTokenUsernameAndClockDrift.test.ts index 44f7935684f..3a5b4b29f5c 100644 --- a/packages/adapter-nextjs/__tests__/auth/utils/getAccessTokenUsernameAndClockDrift.test.ts +++ b/packages/adapter-nextjs/__tests__/auth/utils/getAccessTokenUsernameAndClockDrift.test.ts @@ -32,17 +32,4 @@ describe('getAccessTokenUsernameAndClockDrift', () => { }), ); }); - - 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/src/auth/utils/getAccessTokenUsernameAndClockDrift.ts b/packages/adapter-nextjs/src/auth/utils/getAccessTokenUsernameAndClockDrift.ts index 81498f5e791..3d0cac4677f 100644 --- a/packages/adapter-nextjs/src/auth/utils/getAccessTokenUsernameAndClockDrift.ts +++ b/packages/adapter-nextjs/src/auth/utils/getAccessTokenUsernameAndClockDrift.ts @@ -12,7 +12,7 @@ export const getAccessTokenUsernameAndClockDrift = ( 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'; + const username = decoded.payload.username as string; return { username,