From 825d338021964a48ffab07d7b5961c5afa63a50e Mon Sep 17 00:00:00 2001 From: Hui Zhao <10602282+HuiSF@users.noreply.github.com> Date: Tue, 11 Feb 2025 09:43:48 -0800 Subject: [PATCH] feat: support server-side auth flows with Cognito managed login (#14168) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(adapter-nextjs): add runtimeOptions.cookies to createServerRunner (#13788) * feat(aws-amplify|adapter-nextjs): add runtimeOptions.cookies to createServerRunner * chore: resolve comments * chore(adapter-nextjs): adapt the latest impl. changes * feat(adapter-nextjs): add createAuthRouteHandlers to createServerRunner (#13801) * feat(aws-amplify|adapter-nextjs): add runtimeOptions.cookies to createServerRunner * feat(adapter-nextjs): add createAuthRouteHandlers to createServerRunner * chore(adapter-nextjs): resolve comments * chore(adapter-nextjs): remove unnecessary check * feat(adapter-nextjs): server-side auth flows integrating cognito hosted UI (#13827) * chore(auth): export necessary utilities and types to support server-side auth * chore(aws-amplify): export necessary utilities to support server-side auth * feat(adapter-nextjs): server-side auth api route integrating cognito hosted ui * chore(adapter-nextjs): resolve comments * refactor(adapter-nextjs): remove redundant username fallback * feat(adapter-nextjs): add user has signed in check before initiating sign-in and sign-up (#13839) * feat(adapter-nextjs): add user has signed in check before initiating sign-in and sign-up * chore(adapter-nextjs): rename hasUserSignedIn to hasActiveUserSession * fix(adapter-nextjs): make createAuthRouteHandlers interface work in both App and Pages routers (#13840) * feat(adapter-nextjs): set cookie secure: false with non-SSL domain (#13841) * feat(adapter-nextjs): allow cookie secure: false with non-SSL domain * fix(adapter-nextjs): wrong naming and impl. of isSSLOrigin * chore(adapter-nextjs): resolve comment * refactor(adapter-nextjs): use maxAge attribute to set cookie from server to avoid clock drift (#14103) * fix(adapter-nextjs): wrong use of nullish coalescing (#14112) * refactor(adapter-nextjs): remove redundant clockDrift cookie (#14114) refactor(adapter-nextjs): remove redundant clockDrift cookie ⤵️ Reasons: 1. token exachange is happening on a server - and production server rarely has wrong system time 2. when setting token cookies from server, it uses Max-Age header which is relative to the client system time. Clock drift became irrelevant 3. surely we can argue sever system time can go wrong too, however, a Next.js app API route can be executed on different servers (load balancing), there is no source of truth to generate a clock drift value * chore: enable tag publishing for server-auth (#14115) * fix(adapter-nextjs): wrong spot for checking app origin and auth config (#14119) * fix(adapter-nextjs): not await params async API in Next.js 15 (#14125) * feat(adapter-nextjs): surface redirect error and sign-in timeout error (#14116) * feat(adapter-nextjs): surface redirect error and sign-in timeout error * feat(adapter-nextjs): expose both error and errorDescription * chore(adapter-nextjs): remove unnecessary undefined fallback * chore(adapter-nextjs): add warning re: using http in production (#14134) * fix(core): generateRandomString uses Math.random() (#14132) * fix(core): generateRandomString uses Math.random() * chore(core): use better test to test actual logic * chore(aws-amplify/adapter-nextjs): remove extraneous deps (#14141) * fix(adapter-nextjs): removing only tokens and LastAuthUser cookies (#14152) * fix(adapter-nextjs): wrong cookie attributes get set sometimes (#14169) * chore: add E2E tests for next.js server auth * chore: disable tag release * fix(aws-amplify|api): internals export paths --- .github/integ-config/integ-all.yml | 22 +- .github/workflows/callable-e2e-test.yml | 4 +- .github/workflows/callable-e2e-tests.yml | 2 +- .../api/generateServerClient.test.ts | 10 +- .../createAuthRouteHandlersFactory.test.ts | 260 +++++++++++ ...dleAuthApiRouteRequestForAppRouter.test.ts | 300 +++++++++++++ ...eAuthApiRouteRequestForPagesRouter.test.ts | 295 +++++++++++++ .../handleSignInCallbackRequest.test.ts | 364 ++++++++++++++++ ...ignInCallbackRequestForPagesRouter.test.ts | 408 ++++++++++++++++++ .../handleSignInSignUpRequest.test.ts | 168 ++++++++ ...eSignInSignUpRequestForPagesRouter.test.ts | 193 +++++++++ .../handleSignOutCallbackRequest.test.ts | 304 +++++++++++++ ...gnOutCallbackRequestForPagesRouter.test.ts | 352 +++++++++++++++ .../handlers/handleSignOutRequest.test.ts | 114 +++++ ...handleSignOutRequestForPagesRouter.test.ts | 132 ++++++ .../auth/handlers/mockImplementation.ts | 18 + .../signInCallbackErrorCombinations.ts | 66 +++ .../__tests__/auth/testUtils.ts | 28 ++ ...nCompleteRedirectIntermediate.test.ts.snap | 16 + .../auth/utils/appendSetCookieHeaders.test.ts | 27 ++ ...dSetCookieHeadersToNextApiResponse.test.ts | 40 ++ .../auth/utils/authFlowProofCookies.test.ts | 93 ++++ .../__tests__/auth/utils/authNTokens.test.ts | 199 +++++++++ .../utils/cognitoHostedUIEndpoints.test.ts | 50 +++ .../auth/utils/createAuthFlowProofs.test.ts | 69 +++ .../createErrorSearchParamsString.test.ts | 33 ++ ...SignInCompleteRedirectIntermediate.test.ts | 12 + .../auth/utils/createUrlSearchParams.test.ts | 88 ++++ .../auth/utils/getAccessTokenUsername.test.ts | 30 ++ .../getCookieValuesFromNextApiRequest.test.ts | 25 ++ .../utils/getCookieValuesFromRequest.test.ts | 44 ++ .../auth/utils/getRedirectOrDefault.test.ts | 11 + .../utils/getSearchParamValueFromUrl.test.ts | 24 ++ .../auth/utils/hasActiveUserSession.test.ts | 125 ++++++ .../utils/isSupportedAuthApiRoutePath.test.ts | 15 + .../__tests__/auth/utils/origin.test.ts | 65 +++ .../auth/utils/parseSignInCallbackUrl.test.ts | 16 + .../__tests__/auth/utils/predicates.test.ts | 42 ++ .../resolveIdentityProviderFromUrl.test.ts | 23 + .../auth/utils/resolveRedirectUrl.test.ts | 46 ++ .../__tests__/auth/utils/tokenCookies.test.ts | 169 ++++++++ .../__tests__/createServerRunner.test.ts | 242 +++++++++-- ...torageAdapterFromNextServerContext.test.ts | 145 ++++++- .../__tests__/utils/globalSettings.test.ts | 48 +++ packages/adapter-nextjs/jest.config.js | 1 + .../src/api/createServerRunnerForAPI.ts | 12 +- .../src/api/generateServerClient.ts | 20 +- packages/adapter-nextjs/src/api/index.ts | 2 +- packages/adapter-nextjs/src/auth/constant.ts | 51 +++ .../auth/createAuthRouteHandlersFactory.ts | 137 ++++++ .../handleAuthApiRouteRequestForAppRouter.ts | 124 ++++++ ...handleAuthApiRouteRequestForPagesRouter.ts | 146 +++++++ .../handlers/handleSignInCallbackRequest.ts | 127 ++++++ ...ndleSignInCallbackRequestForPagesRouter.ts | 132 ++++++ .../handlers/handleSignInSignUpRequest.ts | 56 +++ ...handleSignInSignUpRequestForPagesRouter.ts | 52 +++ .../handlers/handleSignOutCallbackRequest.ts | 103 +++++ ...dleSignOutCallbackRequestForPagesRouter.ts | 101 +++++ .../src/auth/handlers/handleSignOutRequest.ts | 43 ++ .../handleSignOutRequestForPagesRouter.ts | 34 ++ .../adapter-nextjs/src/auth/handlers/index.ts | 11 + .../adapter-nextjs/src/auth/handlers/types.ts | 88 ++++ packages/adapter-nextjs/src/auth/index.ts | 4 + packages/adapter-nextjs/src/auth/types.ts | 143 ++++++ .../src/auth/utils/appendSetCookieHeaders.ts | 18 + ...appendSetCookieHeadersToNextApiResponse.ts | 20 + .../src/auth/utils/authFlowProofCookies.ts | 56 +++ .../src/auth/utils/authNTokens.ts | 87 ++++ .../auth/utils/cognitoHostedUIEndpoints.ts | 28 ++ .../src/auth/utils/createAuthFlowProofs.ts | 22 + .../utils/createErrorSearchParamsString.ts | 22 + ...ateOnSignInCompleteRedirectIntermediate.ts | 25 ++ .../src/auth/utils/createUrlSearchParams.ts | 55 +++ .../src/auth/utils/getAccessTokenUsername.ts | 7 + .../getCookieValuesFromNextApiRequest.ts | 22 + .../auth/utils/getCookieValuesFromRequest.ts | 33 ++ .../src/auth/utils/getRedirectOrDefault.ts | 5 + .../auth/utils/getSearchParamValueFromUrl.ts | 16 + .../src/auth/utils/hasActiveUserSession.ts | 56 +++ .../adapter-nextjs/src/auth/utils/index.ts | 53 +++ .../auth/utils/isSupportedAuthApiRoutePath.ts | 11 + .../adapter-nextjs/src/auth/utils/origin.ts | 52 +++ .../src/auth/utils/parseSignInCallbackUrl.ts | 18 + .../src/auth/utils/predicates.ts | 46 ++ .../utils/resolveIdentityProviderFromUrl.ts | 20 + .../src/auth/utils/resolveRedirectUrl.ts | 43 ++ .../src/auth/utils/tokenCookies.ts | 89 ++++ .../adapter-nextjs/src/createServerRunner.ts | 32 +- .../adapter-nextjs/src/types/NextServer.ts | 27 +- .../utils/cookie/ensureEncodedForJSCookie.ts | 11 + .../adapter-nextjs/src/utils/cookie/index.ts | 5 + .../src/utils/cookie/serializeCookie.ts | 41 ++ ...okieStorageAdapterFromNextServerContext.ts | 104 +++-- .../createRunWithAmplifyServerContext.ts | 31 +- .../src/utils/createTokenValidator.ts | 2 +- .../src/utils/globalSettings.ts | 30 ++ packages/adapter-nextjs/src/utils/index.ts | 1 + packages/adapter-nextjs/tsconfig.json | 10 +- packages/api/src/internals/index.ts | 8 + packages/auth/src/providers/cognito/index.ts | 8 + .../cognito/tokenProvider/TokenStore.ts | 7 +- .../cognito/tokenProvider/constants.ts | 4 + .../providers/cognito/tokenProvider/index.ts | 3 +- .../providers/cognito/utils/oauth/index.ts | 1 + ...lueStorageFromCookieStorageAdapter.test.ts | 67 ++- .../aws-amplify/__tests__/exports.test.ts | 6 + .../adapter-core/internals/package.json | 7 + .../aws-amplify/api/internals/package.json | 7 + packages/aws-amplify/jest.config.js | 4 + packages/aws-amplify/package.json | 17 + .../aws-amplify/src/adapter-core/constants.ts | 4 + .../aws-amplify/src/adapter-core/index.ts | 9 + .../aws-amplify/src/adapter-core/internals.ts | 17 + ...KeyValueStorageFromCookieStorageAdapter.ts | 18 +- packages/aws-amplify/src/api/internals.ts | 10 + .../utils/generateRandomString.test.ts | 60 ++- .../core/src/utils/generateRandomString.ts | 15 +- 117 files changed, 7332 insertions(+), 162 deletions(-) create mode 100644 packages/adapter-nextjs/__tests__/auth/createAuthRouteHandlersFactory.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/handleAuthApiRouteRequestForAppRouter.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/handleAuthApiRouteRequestForPagesRouter.test.ts 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/handlers/mockImplementation.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/handlers/signInCallbackErrorCombinations.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/testUtils.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/utils/__snapshots__/createOnSignInCompleteRedirectIntermediate.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/createErrorSearchParamsString.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/utils/createOnSignInCompleteRedirectIntermediate.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/utils/createUrlSearchParams.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/utils/getAccessTokenUsername.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/getRedirectOrDefault.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/utils/getSearchParamValueFromUrl.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/utils/hasActiveUserSession.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/utils/isSupportedAuthApiRoutePath.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/utils/origin.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/utils/parseSignInCallbackUrl.test.ts create mode 100644 packages/adapter-nextjs/__tests__/auth/utils/predicates.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/__tests__/utils/globalSettings.test.ts create mode 100644 packages/adapter-nextjs/src/auth/constant.ts create mode 100644 packages/adapter-nextjs/src/auth/createAuthRouteHandlersFactory.ts create mode 100644 packages/adapter-nextjs/src/auth/handleAuthApiRouteRequestForAppRouter.ts create mode 100644 packages/adapter-nextjs/src/auth/handleAuthApiRouteRequestForPagesRouter.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/index.ts create mode 100644 packages/adapter-nextjs/src/auth/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/createErrorSearchParamsString.ts create mode 100644 packages/adapter-nextjs/src/auth/utils/createOnSignInCompleteRedirectIntermediate.ts create mode 100644 packages/adapter-nextjs/src/auth/utils/createUrlSearchParams.ts create mode 100644 packages/adapter-nextjs/src/auth/utils/getAccessTokenUsername.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/getRedirectOrDefault.ts create mode 100644 packages/adapter-nextjs/src/auth/utils/getSearchParamValueFromUrl.ts create mode 100644 packages/adapter-nextjs/src/auth/utils/hasActiveUserSession.ts create mode 100644 packages/adapter-nextjs/src/auth/utils/index.ts create mode 100644 packages/adapter-nextjs/src/auth/utils/isSupportedAuthApiRoutePath.ts create mode 100644 packages/adapter-nextjs/src/auth/utils/origin.ts create mode 100644 packages/adapter-nextjs/src/auth/utils/parseSignInCallbackUrl.ts create mode 100644 packages/adapter-nextjs/src/auth/utils/predicates.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 create mode 100644 packages/adapter-nextjs/src/utils/globalSettings.ts create mode 100644 packages/auth/src/providers/cognito/tokenProvider/constants.ts create mode 100644 packages/aws-amplify/adapter-core/internals/package.json create mode 100644 packages/aws-amplify/api/internals/package.json create mode 100644 packages/aws-amplify/src/adapter-core/constants.ts create mode 100644 packages/aws-amplify/src/adapter-core/internals.ts create mode 100644 packages/aws-amplify/src/api/internals.ts diff --git a/.github/integ-config/integ-all.yml b/.github/integ-config/integ-all.yml index 6f467fcd84a..ae929e7def0 100644 --- a/.github/integ-config/integ-all.yml +++ b/.github/integ-config/integ-all.yml @@ -878,7 +878,7 @@ tests: sample_name: next-use-cases-14 spec: next-use-cases yarn_script: ci:next-use-cases-test - yarn_script_args: 14 + yarn_script_args: '14' browser: [chrome] - test_name: integ_next-use-cases-15 desc: 'Next.js use cases tests with v15' @@ -887,7 +887,25 @@ tests: sample_name: next-use-cases-15 spec: next-use-cases yarn_script: ci:next-use-cases-test - yarn_script_args: 15 + yarn_script_args: '15' + browser: [chrome] + - test_name: integ_next-use-cases-server-auth-14 + desc: 'Next.js server-side auth use cases tests with v14' + framework: next + category: ssr-adapter + sample_name: next-use-cases-server-auth-14 + spec: next-use-cases-server-auth + yarn_script: ci:next-use-cases-test + yarn_script_args: server-auth-14 + browser: [chrome] + - test_name: integ_next-use-cases-server-auth-15 + desc: 'Next.js server-side auth use cases tests with v15' + framework: next + category: ssr-adapter + sample_name: next-use-cases-server-auth-15 + spec: next-use-cases-server-auth + yarn_script: ci:next-use-cases-test + yarn_script_args: server-auth-15 browser: [chrome] - test_name: integ_next_mfa_req_email desc: 'mfa required with email sign in attribute' diff --git a/.github/workflows/callable-e2e-test.yml b/.github/workflows/callable-e2e-test.yml index 0a5002fa211..4de5e34c30b 100644 --- a/.github/workflows/callable-e2e-test.yml +++ b/.github/workflows/callable-e2e-test.yml @@ -39,7 +39,7 @@ on: type: string yarn_script_args: required: false - type: number + type: string env: required: false type: string @@ -141,7 +141,7 @@ jobs: $E2E_YARN_SCRIPT \ -n $E2E_RETRY_COUNT else - yarn "$E2E_YARN_SCRIPT" "$E2E_YARN_SCRIPT_ARGS" + yarn "$E2E_YARN_SCRIPT" "$E2E_YARN_SCRIPT_ARGS" "$E2E_SPEC" fi - name: Upload artifact uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 diff --git a/.github/workflows/callable-e2e-tests.yml b/.github/workflows/callable-e2e-tests.yml index bdb174e32a7..2dc95a286bf 100644 --- a/.github/workflows/callable-e2e-tests.yml +++ b/.github/workflows/callable-e2e-tests.yml @@ -44,7 +44,7 @@ jobs: timeout_minutes: ${{ matrix.integ-config.timeout_minutes || 35 }} retry_count: ${{ matrix.integ-config.retry_count || 3 }} yarn_script: ${{ matrix.integ-config.yarn_script || '' }} - yarn_script_args: ${{ matrix.integ-config.yarn_script_args || 15 }} + yarn_script_args: ${{ matrix.integ-config.yarn_script_args || '15' }} env: ${{ matrix.integ-config.env && toJSON(matrix.integ-config.env) || '{}' }} # e2e-test-runner-headless: diff --git a/packages/adapter-nextjs/__tests__/api/generateServerClient.test.ts b/packages/adapter-nextjs/__tests__/api/generateServerClient.test.ts index d60112a9c2c..8ae7e4eda34 100644 --- a/packages/adapter-nextjs/__tests__/api/generateServerClient.test.ts +++ b/packages/adapter-nextjs/__tests__/api/generateServerClient.test.ts @@ -1,5 +1,5 @@ -import { ResourcesConfig } from '@aws-amplify/core'; -import { parseAmplifyConfig } from '@aws-amplify/core/internals/utils'; +import { ResourcesConfig } from 'aws-amplify'; +import { parseAmplifyConfig } from 'aws-amplify/utils'; import { generateServerClientUsingCookies, @@ -34,8 +34,8 @@ jest.mock('../../src/utils', () => ({ createRunWithAmplifyServerContext: jest.fn(() => jest.fn()), createCookieStorageAdapterFromNextServerContext: jest.fn(), })); -jest.mock('@aws-amplify/core/internals/utils', () => ({ - ...jest.requireActual('@aws-amplify/core/internals/utils'), +jest.mock('aws-amplify/utils', () => ({ + ...jest.requireActual('aws-amplify/utils'), parseAmplifyConfig: jest.fn(() => mockAmplifyConfig), })); @@ -95,7 +95,7 @@ describe('generateServerClient', () => { graphql: mockGraphql, })); - jest.mock('@aws-amplify/core/internals/adapter-core', () => ({ + jest.mock('aws-amplify/adapter-core/internals', () => ({ getAmplifyServerContext: jest.fn(), })); diff --git a/packages/adapter-nextjs/__tests__/auth/createAuthRouteHandlersFactory.test.ts b/packages/adapter-nextjs/__tests__/auth/createAuthRouteHandlersFactory.test.ts new file mode 100644 index 00000000000..c30f9bad014 --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/createAuthRouteHandlersFactory.test.ts @@ -0,0 +1,260 @@ +import { ResourcesConfig } from 'aws-amplify'; +import { + assertOAuthConfig, + assertTokenProviderConfig, +} from 'aws-amplify/adapter-core/internals'; + +import { createAuthRouteHandlersFactory } from '../../src/auth/createAuthRouteHandlersFactory'; +import { handleAuthApiRouteRequestForAppRouter } from '../../src/auth/handleAuthApiRouteRequestForAppRouter'; +import { handleAuthApiRouteRequestForPagesRouter } from '../../src/auth/handleAuthApiRouteRequestForPagesRouter'; +import { NextServer } from '../../src'; +import { + AuthRouteHandlers, + CreateAuthRouteHandlersFactoryInput, + CreateAuthRoutesHandlersInput, +} from '../../src/auth/types'; +import { + isAuthRoutesHandlersContext, + isNextApiRequest, + isNextApiResponse, + isNextRequest, + isValidOrigin, +} from '../../src/auth/utils'; +import { globalSettings } from '../../src/utils'; + +jest.mock('aws-amplify/adapter-core/internals', () => ({ + ...jest.requireActual('aws-amplify/adapter-core/internals'), + assertOAuthConfig: jest.fn(), + assertTokenProviderConfig: jest.fn(), +})); +jest.mock('../../src/auth/handleAuthApiRouteRequestForAppRouter'); +jest.mock('../../src/auth/handleAuthApiRouteRequestForPagesRouter'); +jest.mock('../../src/auth/utils'); +jest.mock('../../src/utils', () => ({ + globalSettings: { + isServerSideAuthEnabled: jest.fn(() => true), + enableServerSideAuth: jest.fn(), + setRuntimeOptions: jest.fn(), + getRuntimeOptions: jest.fn(() => ({ + cookies: { + sameSite: 'strict', + }, + })), + isSSLOrigin: jest.fn(() => true), + setIsSSLOrigin: jest.fn(), + }, +})); + +const mockAmplifyConfig: ResourcesConfig = { + Auth: { + Cognito: { + identityPoolId: '123', + userPoolId: 'abc', + userPoolClientId: 'def', + loginWith: { + oauth: { + domain: 'example.com', + responseType: 'code', + redirectSignIn: ['https://example.com/signin'], + redirectSignOut: ['https://example.com/signout'], + scopes: ['openid', 'email'], + }, + }, + }, + }, +}; + +const mockAssertTokenProviderConfig = jest.mocked(assertTokenProviderConfig); +const mockAssertOAuthConfig = jest.mocked(assertOAuthConfig); +const mockHandleAuthApiRouteRequestForAppRouter = jest.mocked( + handleAuthApiRouteRequestForAppRouter, +); +const mockHandleAuthApiRouteRequestForPagesRouter = jest.mocked( + handleAuthApiRouteRequestForPagesRouter, +); +const mockIsNextApiRequest = jest.mocked(isNextApiRequest); +const mockIsNextApiResponse = jest.mocked(isNextApiResponse); +const mockIsNextRequest = jest.mocked(isNextRequest); +const mockIsAuthRoutesHandlersContext = jest.mocked( + isAuthRoutesHandlersContext, +); +const mockIsValidOrigin = jest.mocked(isValidOrigin); +const mockRunWithAmplifyServerContext = + jest.fn() as jest.MockedFunction; + +describe('createAuthRoutesHandlersFactory', () => { + const AMPLIFY_APP_ORIGIN = 'https://example.com'; + + beforeAll(() => { + mockIsValidOrigin.mockReturnValue(true); + }); + + describe('the created createAuthRouteHandlers function', () => { + it('throws an error if the AMPLIFY_APP_ORIGIN environment variable is not defined', () => { + const throwingFunc = createAuthRouteHandlersFactory({ + config: mockAmplifyConfig, + amplifyAppOrigin: undefined, + runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + globalSettings, + }); + expect(() => throwingFunc()).toThrow( + 'Could not find the AMPLIFY_APP_ORIGIN environment variable.', + ); + }); + + it('throws an error if the AMPLIFY_APP_ORIGIN environment variable is invalid', () => { + mockIsValidOrigin.mockReturnValueOnce(false); + const throwingFunc = createAuthRouteHandlersFactory({ + config: mockAmplifyConfig, + amplifyAppOrigin: 'domain-without-protocol.com', + runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + globalSettings, + }); + expect(() => throwingFunc()).toThrow( + 'AMPLIFY_APP_ORIGIN environment variable contains an invalid origin string.', + ); + }); + + it('calls config assertion functions to validate the Auth configuration', () => { + const func = createAuthRouteHandlersFactory({ + config: mockAmplifyConfig, + amplifyAppOrigin: AMPLIFY_APP_ORIGIN, + runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + globalSettings, + }); + + func(); + + expect(mockAssertTokenProviderConfig).toHaveBeenCalledWith( + mockAmplifyConfig.Auth?.Cognito, + ); + expect(mockAssertOAuthConfig).toHaveBeenCalledWith( + mockAmplifyConfig.Auth!.Cognito, + ); + }); + }); + + describe('the created route handler function', () => { + const testCreateAuthRoutesHandlersFactoryInput: CreateAuthRouteHandlersFactoryInput = + { + config: mockAmplifyConfig, + amplifyAppOrigin: AMPLIFY_APP_ORIGIN, + runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + globalSettings, + }; + const testCreateAuthRoutesHandlersInput: CreateAuthRoutesHandlersInput = { + customState: 'random-state', + redirectOnSignInComplete: '/home', + redirectOnSignOutComplete: '/login', + }; + let handler: AuthRouteHandlers; + + beforeAll(() => { + const createAuthRoutesHandlers = createAuthRouteHandlersFactory( + testCreateAuthRoutesHandlersFactoryInput, + ); + handler = createAuthRoutesHandlers(testCreateAuthRoutesHandlersInput); + }); + + afterEach(() => { + mockIsAuthRoutesHandlersContext.mockReset(); + mockIsNextApiRequest.mockReset(); + mockIsNextApiResponse.mockReset(); + mockIsNextRequest.mockReset(); + }); + + it('calls handleAuthApiRouteRequestForPagesRouter when 1st param is a NextApiRequest and 2nd param is a NextApiResponse', async () => { + const param1 = {} as any; + const param2 = {} as any; + mockIsNextApiRequest.mockReturnValueOnce(true); + mockIsNextApiResponse.mockReturnValueOnce(true); + mockIsNextRequest.mockReturnValueOnce(false); + mockIsAuthRoutesHandlersContext.mockReturnValueOnce(false); + + await handler(param1, param2); + + expect(mockHandleAuthApiRouteRequestForPagesRouter).toHaveBeenCalledWith({ + request: param1, + response: param2, + handlerInput: testCreateAuthRoutesHandlersInput, + oAuthConfig: mockAmplifyConfig.Auth!.Cognito!.loginWith!.oauth, + setCookieOptions: { + sameSite: 'strict', + }, + origin: 'https://example.com', + userPoolClientId: 'def', + runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + }); + }); + + it('calls handleAuthApiRouteRequestForAppRouter when 1st param is a NextRequest and the 2nd param is a AuthRoutesHandlersContext', async () => { + const request = {} as any; + const context = {} as any; + mockIsNextApiRequest.mockReturnValueOnce(false); + mockIsNextApiResponse.mockReturnValueOnce(false); + mockIsNextRequest.mockReturnValueOnce(true); + mockIsAuthRoutesHandlersContext.mockReturnValueOnce(true); + + await handler(request, context); + + expect(mockHandleAuthApiRouteRequestForAppRouter).toHaveBeenCalledWith({ + request, + handlerContext: context, + handlerInput: testCreateAuthRoutesHandlersInput, + oAuthConfig: mockAmplifyConfig.Auth!.Cognito!.loginWith!.oauth, + setCookieOptions: { + sameSite: 'strict', + }, + origin: 'https://example.com', + userPoolClientId: 'def', + runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + }); + }); + + it('throws an error when the request and context/response combination is invalid', () => { + const request = {} as any; + const context = {} as any; + mockIsNextApiRequest.mockReturnValueOnce(false); + mockIsNextApiResponse.mockReturnValueOnce(false); + mockIsNextRequest.mockReturnValueOnce(false); + mockIsAuthRoutesHandlersContext.mockReturnValueOnce(false); + + expect(handler(request, context)).rejects.toThrow( + 'Invalid request and context/response combination. The request cannot be handled.', + ); + }); + + it('uses default values for parameters that have values as undefined', async () => { + (globalSettings.getRuntimeOptions as jest.Mock).mockReturnValueOnce({}); + const createAuthRoutesHandlers = createAuthRouteHandlersFactory({ + config: mockAmplifyConfig, + amplifyAppOrigin: AMPLIFY_APP_ORIGIN, + runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + globalSettings, + }); + const handlerWithDefaultParamValues = + createAuthRoutesHandlers(/* undefined */); + + const request = {} as any; + const response = {} as any; + + mockIsNextApiRequest.mockReturnValueOnce(true); + mockIsNextApiResponse.mockReturnValueOnce(true); + mockIsNextRequest.mockReturnValueOnce(false); + mockIsAuthRoutesHandlersContext.mockReturnValueOnce(false); + + await handlerWithDefaultParamValues(request, response); + + expect(handleAuthApiRouteRequestForPagesRouter).toHaveBeenCalledWith({ + request, + response, + handlerInput: {}, + oAuthConfig: mockAmplifyConfig.Auth!.Cognito!.loginWith!.oauth, + setCookieOptions: {}, + origin: 'https://example.com', + userPoolClientId: 'def', + runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + }); + }); + }); +}); diff --git a/packages/adapter-nextjs/__tests__/auth/handleAuthApiRouteRequestForAppRouter.test.ts b/packages/adapter-nextjs/__tests__/auth/handleAuthApiRouteRequestForAppRouter.test.ts new file mode 100644 index 00000000000..188d6c043fe --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/handleAuthApiRouteRequestForAppRouter.test.ts @@ -0,0 +1,300 @@ +import { NextRequest } from 'next/server'; +import { OAuthConfig } from 'aws-amplify/adapter-core/internals'; + +import { handleAuthApiRouteRequestForAppRouter } from '../../src/auth/handleAuthApiRouteRequestForAppRouter'; +import { CreateAuthRoutesHandlersInput } from '../../src/auth/types'; +import { + handleSignInCallbackRequest, + handleSignInSignUpRequest, + handleSignOutCallbackRequest, + handleSignOutRequest, +} from '../../src/auth/handlers'; +import { NextServer } from '../../src'; +import { + getRedirectOrDefault, + hasActiveUserSessionWithAppRouter, + isSupportedAuthApiRoutePath, +} from '../../src/auth/utils'; + +jest.mock('../../src/auth/handlers'); +jest.mock('../../src/auth/utils'); + +const mockHandleSignInSignUpRequest = jest.mocked(handleSignInSignUpRequest); +const mockHandleSignOutRequest = jest.mocked(handleSignOutRequest); +const mockHandleSignInCallbackRequest = jest.mocked( + handleSignInCallbackRequest, +); +const mockHandleSignOutCallbackRequest = jest.mocked( + handleSignOutCallbackRequest, +); +const mockHasUserSignedInWithAppRouter = jest.mocked( + hasActiveUserSessionWithAppRouter, +); +const mockIsSupportedAuthApiRoutePath = jest.mocked( + isSupportedAuthApiRoutePath, +); +const mockRunWithAmplifyServerContext = + jest.fn() as jest.MockedFunction; +const mockGetRedirectOrDefault = jest.mocked(getRedirectOrDefault); + +describe('handleAuthApiRouteRequestForAppRouter', () => { + const testOrigin = 'https://example.com'; + const testHandlerInput: CreateAuthRoutesHandlersInput = { + redirectOnSignInComplete: '/home', + redirectOnSignOutComplete: 'sign-in', + }; + const testHandlerContext = { params: { slug: 'sign-in' } }; + const testOAuthConfig: OAuthConfig = { + domain: 'example.com', + redirectSignIn: ['https://example.com/signin'], + redirectSignOut: ['https://example.com/signout'], + responseType: 'code', + scopes: ['openid', 'email'], + }; + const _ = handleAuthApiRouteRequestForAppRouter; + + beforeAll(() => { + mockHasUserSignedInWithAppRouter.mockResolvedValue(false); + mockIsSupportedAuthApiRoutePath.mockReturnValue(true); + mockGetRedirectOrDefault.mockImplementation( + (redirect: string | undefined) => redirect || '/', + ); + }); + + afterEach(() => { + mockGetRedirectOrDefault.mockClear(); + }); + + 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 = await handleAuthApiRouteRequestForAppRouter({ + request, + handlerContext: testHandlerContext, + handlerInput: testHandlerInput, + userPoolClientId: 'userPoolClientId', + oAuthConfig: testOAuthConfig, + setCookieOptions: {}, + origin: testOrigin, + runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + }); + + expect(response.status).toBe(405); + }); + + 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 = await handleAuthApiRouteRequestForAppRouter({ + request, + handlerContext: { params: { slug: undefined } }, + handlerInput: testHandlerInput, + userPoolClientId: 'userPoolClientId', + oAuthConfig: testOAuthConfig, + setCookieOptions: {}, + origin: testOrigin, + runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + }); + + expect(response.status).toBe(400); + }); + + 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', + }, + ); + + mockIsSupportedAuthApiRoutePath.mockReturnValueOnce(false); + + const response = await handleAuthApiRouteRequestForAppRouter({ + request, + handlerContext: { params: { slug: 'exchange-token' } }, + handlerInput: testHandlerInput, + userPoolClientId: 'userPoolClientId', + oAuthConfig: testOAuthConfig, + setCookieOptions: {}, + origin: testOrigin, + runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + }); + + expect(response.status).toBe(404); + }); + + 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, + runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + }); + + expect(response).toBe(mockResponse); + expect(mockHandleSignInSignUpRequest).toHaveBeenCalledWith({ + request: mockRequest, + userPoolClientId: 'userPoolClientId', + oAuthConfig: testOAuthConfig, + customState: testHandlerInput.customState, + origin: testOrigin, + setCookieOptions: {}, + type: expectedType, + }); + }, + ); + + test.each([['sign-in'], ['sign-up']])( + `calls hasUserSignedInWithAppRouter with correct params when handlerContext.params.slug is %s, and when it returns true, the handler returns a 302 response`, + async slug => { + mockHasUserSignedInWithAppRouter.mockResolvedValueOnce(true); + const mockRequest = new NextRequest( + new URL('https://example.com/api/auth/sign-in'), + { + method: 'GET', + }, + ); + + const response = await handleAuthApiRouteRequestForAppRouter({ + request: mockRequest, + handlerContext: { params: { slug } }, + handlerInput: { + ...testHandlerInput, + redirectOnSignInComplete: undefined, + }, + userPoolClientId: 'userPoolClientId', + oAuthConfig: testOAuthConfig, + setCookieOptions: {}, + origin: testOrigin, + runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + }); + + expect(response.status).toBe(302); + expect(response.headers.get('Location')).toBe('/'); + expect(mockGetRedirectOrDefault).toHaveBeenCalledWith(undefined); + }, + ); + + 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 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, + runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + }); + + 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, + runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + }); + + 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, + runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + }); + + 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 new file mode 100644 index 00000000000..d513a63f3db --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/handleAuthApiRouteRequestForPagesRouter.test.ts @@ -0,0 +1,295 @@ +import { OAuthConfig } from 'aws-amplify/adapter-core/internals'; +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 { NextServer } from '../../src'; +import { + getRedirectOrDefault, + hasActiveUserSessionWithPagesRouter, + isSupportedAuthApiRoutePath, +} from '../../src/auth/utils'; + +import { createMockNextApiResponse } from './testUtils'; + +jest.mock('../../src/auth/handlers'); +jest.mock('../../src/auth/utils'); + +const mockHandleSignInSignUpRequestForPagesRouter = jest.mocked( + handleSignInSignUpRequestForPagesRouter, +); +const mockHandleSignOutRequestForPagesRouter = jest.mocked( + handleSignOutRequestForPagesRouter, +); +const mockHandleSignInCallbackRequestForPagesRouter = jest.mocked( + handleSignInCallbackRequestForPagesRouter, +); +const mockHandleSignOutCallbackRequestForPagesRouter = jest.mocked( + handleSignOutCallbackRequestForPagesRouter, +); +const mockIsSupportedAuthApiRoutePath = jest.mocked( + isSupportedAuthApiRoutePath, +); +const mockHasUserSignedInWithPagesRouter = jest.mocked( + hasActiveUserSessionWithPagesRouter, +); +const mockRunWithAmplifyServerContext = + jest.fn() as jest.MockedFunction; +const mockGetRedirectOrDefault = jest.mocked(getRedirectOrDefault); + +describe('handleAuthApiRouteRequestForPagesRouter', () => { + const testOrigin = 'https://example.com'; + const testHandlerInput: CreateAuthRoutesHandlersInput = { + redirectOnSignInComplete: '/home', + redirectOnSignOutComplete: 'sign-in', + }; + const testOAuthConfig: OAuthConfig = { + domain: 'example.com', + redirectSignIn: ['https://example.com/signin'], + redirectSignOut: ['https://example.com/signout'], + responseType: 'code', + scopes: ['openid', 'email'], + }; + const testSetCookieOptions = {}; + const { + mockResponseAppendHeader, + mockResponseEnd, + mockResponseStatus, + mockResponseSend, + mockResponseRedirect, + mockResponse, + } = createMockNextApiResponse(); + + beforeAll(() => { + mockHasUserSignedInWithPagesRouter.mockResolvedValue(false); + mockIsSupportedAuthApiRoutePath.mockReturnValue(true); + mockGetRedirectOrDefault.mockImplementation( + (redirect: string | undefined) => redirect || '/', + ); + }); + + afterEach(() => { + mockResponseAppendHeader.mockClear(); + mockResponseEnd.mockClear(); + mockResponseStatus.mockClear(); + mockResponseSend.mockClear(); + mockResponseRedirect.mockClear(); + mockGetRedirectOrDefault.mockClear(); + }); + + it('sets response.status(405) when request has an unsupported method', () => { + const mockRequest = { method: 'POST' } as any; + + handleAuthApiRouteRequestForPagesRouter({ + request: mockRequest, + response: mockResponse, + handlerInput: testHandlerInput, + userPoolClientId: 'userPoolClientId', + oAuthConfig: testOAuthConfig, + setCookieOptions: testSetCookieOptions, + origin: testOrigin, + runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + }); + + 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; + + handleAuthApiRouteRequestForPagesRouter({ + request: mockRequest, + response: mockResponse, + userPoolClientId: 'userPoolClientId', + handlerInput: testHandlerInput, + oAuthConfig: testOAuthConfig, + setCookieOptions: testSetCookieOptions, + origin: testOrigin, + runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + }); + + expect(mockResponseStatus).toHaveBeenCalledWith(400); + expect(mockResponseEnd).toHaveBeenCalled(); + }); + + it('sets response.status(404) when request.query.slug is is not a supported path', () => { + const mockRequest = { + method: 'GET', + query: { slug: 'exchange-token' }, + } as any; + + mockIsSupportedAuthApiRoutePath.mockReturnValueOnce(false); + + handleAuthApiRouteRequestForPagesRouter({ + request: mockRequest, + response: mockResponse, + handlerInput: testHandlerInput, + userPoolClientId: 'userPoolClientId', + oAuthConfig: testOAuthConfig, + setCookieOptions: testSetCookieOptions, + origin: testOrigin, + runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + }); + + expect(mockResponseStatus).toHaveBeenCalledWith(404); + expect(mockResponseEnd).toHaveBeenCalled(); + }); + + 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, + runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + }); + + expect(mockHandleSignInSignUpRequestForPagesRouter).toHaveBeenCalledWith({ + request: mockRequest, + response: mockResponse, + userPoolClientId: 'userPoolClientId', + oAuthConfig: testOAuthConfig, + customState: testHandlerInput.customState, + origin: testOrigin, + setCookieOptions: {}, + type: expectedType, + }); + }, + ); + + test.each([['sign-in'], ['sign-up']])( + `calls hasUserSignedInWithPagesRouter with correct params when handlerContext.params.slug is %s, and when it returns true, the handler returns a 302 response`, + async slug => { + mockHasUserSignedInWithPagesRouter.mockResolvedValueOnce(true); + 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, + redirectOnSignInComplete: undefined, + }, + userPoolClientId: 'userPoolClientId', + oAuthConfig: testOAuthConfig, + setCookieOptions: {}, + origin: testOrigin, + runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + }); + + expect(mockResponseRedirect).toHaveBeenCalledWith(302, '/'); + expect(mockGetRedirectOrDefault).toHaveBeenCalledWith(undefined); + }, + ); + + 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-out' }, + } as unknown as NextApiRequest; + + await handleAuthApiRouteRequestForPagesRouter({ + request: mockRequest, + response: mockResponse, + handlerInput: testHandlerInput, + userPoolClientId: 'userPoolClientId', + oAuthConfig: testOAuthConfig, + setCookieOptions: {}, + origin: testOrigin, + runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + }); + + 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, + runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + }); + + 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, + runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + }); + + 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..61a1f920c02 --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInCallbackRequest.test.ts @@ -0,0 +1,364 @@ +import { NextRequest } from 'next/server.js'; +import { OAuthConfig } from 'aws-amplify/adapter-core/internals'; +import { CookieStorage } from 'aws-amplify/adapter-core'; + +import { handleSignInCallbackRequest } from '../../../src/auth/handlers/handleSignInCallbackRequest'; +import { + appendSetCookieHeaders, + createAuthFlowProofCookiesRemoveOptions, + createErrorSearchParamsString, + createOnSignInCompleteRedirectIntermediate, + createSignInFlowProofCookies, + createTokenCookies, + createTokenCookiesSetOptions, + exchangeAuthNTokens, + getCookieValuesFromRequest, + getRedirectOrDefault, + parseSignInCallbackUrl, + resolveRedirectSignInUrl, +} from '../../../src/auth/utils'; +import { CreateAuthRoutesHandlersInput } from '../../../src/auth/types'; +import { + PKCE_COOKIE_NAME, + SIGN_IN_TIMEOUT_ERROR_CODE, + SIGN_IN_TIMEOUT_ERROR_MESSAGE, + STATE_COOKIE_NAME, +} from '../../../src/auth/constant'; + +import { + ERROR_CLIENT_COOKIE_COMBINATIONS, + ERROR_URL_PARAMS_COMBINATIONS, +} from './signInCallbackErrorCombinations'; +import { mockCreateErrorSearchParamsStringImplementation } from './mockImplementation'; + +jest.mock('../../../src/auth/utils'); + +const mockAppendSetCookieHeaders = jest.mocked(appendSetCookieHeaders); +const mockCreateAuthFlowProofCookiesRemoveOptions = jest.mocked( + createAuthFlowProofCookiesRemoveOptions, +); +const mockCreateOnSignInCompleteRedirectIntermediate = jest.mocked( + createOnSignInCompleteRedirectIntermediate, +); +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 mockParseSignInCallbackUrl = jest.mocked(parseSignInCallbackUrl); +const mockResolveRedirectSignInUrl = jest.mocked(resolveRedirectSignInUrl); +const mockGetRedirectOrDefault = jest.mocked(getRedirectOrDefault); +const mockCreateErrorSearchParamsString = jest.mocked( + createErrorSearchParamsString, +); + +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'; + + beforeAll(() => { + mockGetRedirectOrDefault.mockImplementation( + (redirect: string | undefined) => redirect || '/', + ); + mockCreateErrorSearchParamsString.mockImplementation( + mockCreateErrorSearchParamsStringImplementation, + ); + }); + + afterEach(() => { + mockAppendSetCookieHeaders.mockClear(); + mockCreateAuthFlowProofCookiesRemoveOptions.mockClear(); + mockCreateOnSignInCompleteRedirectIntermediate.mockClear(); + mockCreateSignInFlowProofCookies.mockClear(); + mockCreateTokenCookies.mockClear(); + mockCreateTokenCookiesSetOptions.mockClear(); + mockExchangeAuthNTokens.mockClear(); + mockGetCookieValuesFromRequest.mockClear(); + mockParseSignInCallbackUrl.mockClear(); + mockResolveRedirectSignInUrl.mockClear(); + mockCreateErrorSearchParamsString.mockClear(); + }); + + test.each(ERROR_URL_PARAMS_COMBINATIONS)( + 'returns a $expectedStatus response when request.url contains query params: code=$code, state=$state, error=$error, error_description=$errorDescription', + async ({ + code, + state, + error, + errorDescription, + expectedStatus, + expectedRedirect, + }) => { + mockParseSignInCallbackUrl.mockReturnValueOnce({ + code, + state, + error, + errorDescription, + }); + 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(expectedStatus); + expect(mockParseSignInCallbackUrl).toHaveBeenCalledWith(url); + + if (expectedStatus === 302) { + expect(response.headers.get('Location')).toBe(expectedRedirect); + } + + if (error || errorDescription) { + expect(mockCreateErrorSearchParamsString).toHaveBeenCalledWith({ + error, + errorDescription, + }); + } + }, + ); + + test.each(ERROR_CLIENT_COOKIE_COMBINATIONS)( + `returns a $expectedStatus response when client cookies are: state=$state, pkce=$pkce and expected state value is 'state_b'`, + async ({ state, pkce, expectedStatus, expectedRedirect }) => { + mockParseSignInCallbackUrl.mockReturnValueOnce({ + code: 'not_important_for_this_test', + state: 'not_important_for_this_test', + error: null, + errorDescription: null, + }); + mockGetCookieValuesFromRequest.mockReturnValueOnce({ + [STATE_COOKIE_NAME]: state, + [PKCE_COOKIE_NAME]: pkce, + }); + + const url = `https://example.com/api/auth/sign-in-callback?state=state_b&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(expectedStatus); + expect(mockParseSignInCallbackUrl).toHaveBeenCalledWith(url); + expect(mockGetCookieValuesFromRequest).toHaveBeenCalledWith(request, [ + PKCE_COOKIE_NAME, + STATE_COOKIE_NAME, + ]); + + if (expectedStatus === 302) { + expect(mockGetRedirectOrDefault).toHaveBeenCalledWith( + mockHandlerInput.redirectOnSignOutComplete, + ); + expect(response.headers.get('Location')).toBe(expectedRedirect); + } + + if (!state || !pkce) { + expect(mockCreateErrorSearchParamsString).toHaveBeenCalledWith({ + error: SIGN_IN_TIMEOUT_ERROR_CODE, + errorDescription: SIGN_IN_TIMEOUT_ERROR_MESSAGE, + }); + } + }, + ); + + 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'; + mockParseSignInCallbackUrl.mockReturnValueOnce({ + code: mockCode, + state: 'not_important_for_this_test', + error: null, + errorDescription: null, + }); + 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 /`, + ], + [ + { ...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) => { + 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'), + maxAge: 3600, + }; + mockCreateTokenCookiesSetOptions.mockReturnValueOnce( + mockCreateTokenCookiesSetOptionsResult, + ); + const mockCreateSignInFlowProofCookiesResult = [ + { name: 'mock-cookie-2', value: 'value-2' }, + ]; + mockCreateSignInFlowProofCookies.mockReturnValueOnce( + mockCreateSignInFlowProofCookiesResult, + ); + const mockCreateAuthFlowProofCookiesRemoveOptionsResult = { + domain: 'example.com', + path: '/', + maxAge: -1, + }; + mockCreateAuthFlowProofCookiesRemoveOptions.mockReturnValueOnce( + mockCreateAuthFlowProofCookiesRemoveOptionsResult, + ); + mockParseSignInCallbackUrl.mockReturnValueOnce({ + code: mockCode, + state: 'not_important_for_this_test', + error: null, + errorDescription: null, + }); + 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'); + }); + mockCreateOnSignInCompleteRedirectIntermediate.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( + mockCreateOnSignInCompleteRedirectIntermediate, + ).toHaveBeenCalledWith({ + redirectOnSignInComplete: expectedFinalRedirect, + }); + expect(mockGetRedirectOrDefault).toHaveBeenCalledWith( + handlerInput.redirectOnSignInComplete, + ); + }, + ); +}); 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..fbeea49fba3 --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInCallbackRequestForPagesRouter.test.ts @@ -0,0 +1,408 @@ +import { OAuthConfig } from 'aws-amplify/adapter-core/internals'; +import { CookieStorage } from 'aws-amplify/adapter-core'; +import { NextApiRequest } from 'next'; + +import { handleSignInCallbackRequestForPagesRouter } from '../../../src/auth/handlers/handleSignInCallbackRequestForPagesRouter'; +import { + appendSetCookieHeadersToNextApiResponse, + createAuthFlowProofCookiesRemoveOptions, + createErrorSearchParamsString, + createOnSignInCompleteRedirectIntermediate, + createSignInFlowProofCookies, + createTokenCookies, + createTokenCookiesSetOptions, + exchangeAuthNTokens, + getCookieValuesFromNextApiRequest, + getRedirectOrDefault, + parseSignInCallbackUrl, + resolveRedirectSignInUrl, +} from '../../../src/auth/utils'; +import { CreateAuthRoutesHandlersInput } from '../../../src/auth/types'; +import { + PKCE_COOKIE_NAME, + SIGN_IN_TIMEOUT_ERROR_CODE, + SIGN_IN_TIMEOUT_ERROR_MESSAGE, + STATE_COOKIE_NAME, +} from '../../../src/auth/constant'; +import { createMockNextApiResponse } from '../testUtils'; + +import { + ERROR_CLIENT_COOKIE_COMBINATIONS, + ERROR_URL_PARAMS_COMBINATIONS, +} from './signInCallbackErrorCombinations'; +import { mockCreateErrorSearchParamsStringImplementation } from './mockImplementation'; + +jest.mock('../../../src/auth/utils'); + +const mockAppendSetCookieHeadersToNextApiResponse = jest.mocked( + appendSetCookieHeadersToNextApiResponse, +); +const mockCreateAuthFlowProofCookiesRemoveOptions = jest.mocked( + createAuthFlowProofCookiesRemoveOptions, +); +const mockCreateOnSignInCompleteRedirectIntermediate = jest.mocked( + createOnSignInCompleteRedirectIntermediate, +); +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 mockParseSignInCallbackUrl = jest.mocked(parseSignInCallbackUrl); +const mockResolveRedirectSignInUrl = jest.mocked(resolveRedirectSignInUrl); +const mockGetRedirectOrDefault = jest.mocked(getRedirectOrDefault); +const mockCreateErrorSearchParamsString = jest.mocked( + createErrorSearchParamsString, +); + +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, + mockResponseRedirect, + mockResponse, + } = createMockNextApiResponse(); + + beforeAll(() => { + mockGetRedirectOrDefault.mockImplementation( + (redirect: string | undefined) => redirect || '/', + ); + mockCreateErrorSearchParamsString.mockImplementation( + mockCreateErrorSearchParamsStringImplementation, + ); + }); + + afterEach(() => { + mockAppendSetCookieHeadersToNextApiResponse.mockClear(); + mockCreateAuthFlowProofCookiesRemoveOptions.mockClear(); + mockCreateOnSignInCompleteRedirectIntermediate.mockClear(); + mockCreateSignInFlowProofCookies.mockClear(); + mockCreateTokenCookies.mockClear(); + mockCreateTokenCookiesSetOptions.mockClear(); + mockExchangeAuthNTokens.mockClear(); + mockGetCookieValuesFromNextApiRequest.mockClear(); + mockParseSignInCallbackUrl.mockClear(); + mockResolveRedirectSignInUrl.mockClear(); + mockGetRedirectOrDefault.mockClear(); + + mockResponseAppendHeader.mockClear(); + mockResponseEnd.mockClear(); + mockResponseStatus.mockClear(); + mockResponseSend.mockClear(); + mockResponseRedirect.mockClear(); + }); + + test.each(ERROR_URL_PARAMS_COMBINATIONS)( + 'returns a $expectedStatus response when request.url contains query params: code=$code, state=$state, error=$error, error_description=$errorDescription', + async ({ + code, + state, + error, + errorDescription, + expectedStatus, + expectedRedirect, + }) => { + mockParseSignInCallbackUrl.mockReturnValueOnce({ + code, + state, + error, + errorDescription, + }); + 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, + }); + + if (expectedStatus === 400) { + expect(mockResponseStatus).toHaveBeenCalledWith(expectedStatus); + expect(mockResponseEnd).toHaveBeenCalled(); + } else { + expect(mockGetRedirectOrDefault).toHaveBeenCalledWith( + mockHandlerInput.redirectOnSignOutComplete, + ); + expect(mockResponseRedirect).toHaveBeenCalledWith( + expectedStatus, + expectedRedirect, + ); + } + expect(mockParseSignInCallbackUrl).toHaveBeenCalledWith(url); + + if (error || errorDescription) { + expect(mockCreateErrorSearchParamsString).toHaveBeenCalledWith({ + error, + errorDescription, + }); + } + }, + ); + + test.each(ERROR_CLIENT_COOKIE_COMBINATIONS)( + `returns a $expectedStatus response when client cookies are: state=$state, pkce=$pkce and expected state value is 'state_b'`, + async ({ state, pkce, expectedStatus, expectedRedirect }) => { + mockParseSignInCallbackUrl.mockReturnValueOnce({ + code: 'not_important_for_this_test', + state: 'not_important_for_this_test', + error: null, + errorDescription: null, + }); + mockGetCookieValuesFromNextApiRequest.mockReturnValueOnce({ + [STATE_COOKIE_NAME]: state, + [PKCE_COOKIE_NAME]: pkce, + }); + const expectedState = 'state_b'; + + const url = `/api/auth/sign-in-callback?state=${expectedState}&code=not_important_for_this_test`; + const mockRequest = { + query: { state: expectedState }, + url, + } as unknown as NextApiRequest; + + await handleSignInCallbackRequestForPagesRouter({ + request: mockRequest, + response: mockResponse, + handlerInput: mockHandlerInput, + userPoolClientId: mockUserPoolClientId, + oAuthConfig: mockOAuthConfig, + setCookieOptions: mockSetCookieOptions, + origin: mockOrigin, + }); + + if (expectedStatus === 400) { + expect(mockResponseStatus).toHaveBeenCalledWith(expectedStatus); + expect(mockResponseEnd).toHaveBeenCalled(); + } else { + expect(mockGetRedirectOrDefault).toHaveBeenCalledWith( + mockHandlerInput.redirectOnSignOutComplete, + ); + expect(mockResponseRedirect).toHaveBeenCalledWith( + expectedStatus, + expectedRedirect, + ); + } + expect(mockParseSignInCallbackUrl).toHaveBeenCalledWith(url); + expect(mockGetCookieValuesFromNextApiRequest).toHaveBeenCalledWith( + mockRequest, + [PKCE_COOKIE_NAME, STATE_COOKIE_NAME], + ); + + if (!state || !pkce) { + expect(mockCreateErrorSearchParamsString).toHaveBeenCalledWith({ + error: SIGN_IN_TIMEOUT_ERROR_CODE, + errorDescription: SIGN_IN_TIMEOUT_ERROR_MESSAGE, + }); + } + }, + ); + + 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; + mockParseSignInCallbackUrl.mockReturnValueOnce({ + code: mockCode, + state: 'not_important_for_this_test', + error: null, + errorDescription: null, + }); + 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'), + maxAge: 3600, + }; + mockCreateTokenCookiesSetOptions.mockReturnValueOnce( + mockCreateTokenCookiesSetOptionsResult, + ); + const mockCreateSignInFlowProofCookiesResult = [ + { name: 'mock-cookie-2', value: 'value-2' }, + ]; + mockCreateSignInFlowProofCookies.mockReturnValueOnce( + mockCreateSignInFlowProofCookiesResult, + ); + const mockCreateAuthFlowProofCookiesRemoveOptionsResult = { + domain: 'example.com', + path: '/', + maxAge: -1, + }; + mockCreateAuthFlowProofCookiesRemoveOptions.mockReturnValueOnce( + mockCreateAuthFlowProofCookiesRemoveOptionsResult, + ); + mockParseSignInCallbackUrl.mockReturnValueOnce({ + code: mockCode, + state: 'not_important_for_this_test', + error: null, + errorDescription: null, + }); + 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'); + }, + ); + mockCreateOnSignInCompleteRedirectIntermediate.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( + mockCreateOnSignInCompleteRedirectIntermediate, + ).toHaveBeenCalledWith({ + redirectOnSignInComplete: expectedFinalRedirect, + }); + expect(getRedirectOrDefault).toHaveBeenCalledWith( + handlerInput.redirectOnSignInComplete, + ); + }, + ); +}); 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..2b0dc1d8328 --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInSignUpRequest.test.ts @@ -0,0 +1,168 @@ +import { OAuthConfig } from 'aws-amplify/adapter-core/internals'; +import { CookieStorage } from 'aws-amplify/adapter-core'; + +import { handleSignInSignUpRequest } from '../../../src/auth/handlers/handleSignInSignUpRequest'; +import { + appendSetCookieHeaders, + createAuthFlowProofCookiesSetOptions, + createAuthFlowProofs, + createAuthorizeEndpoint, + createSignInFlowProofCookies, + createSignUpEndpoint, + createUrlSearchParamsForSignInSignUp, + isSSLOrigin, +} 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, +); +const mockIsSSLOrigin = jest.mocked(isSSLOrigin); + +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'); + + beforeAll(() => { + mockIsSSLOrigin.mockReturnValue(true); + }); + + afterEach(() => { + mockAppendSetCookieHeaders.mockClear(); + mockCreateAuthFlowProofCookiesSetOptions.mockClear(); + mockCreateAuthFlowProofs.mockClear(); + mockCreateAuthorizeEndpoint.mockClear(); + mockCreateSignInFlowProofCookies.mockClear(); + mockCreateSignUpEndpoint.mockClear(); + mockCreateUrlSearchParamsForSignInSignUp.mockClear(); + mockToCodeChallenge.mockClear(); + mockIsSSLOrigin.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(), + maxAge: 3600, + }; + 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(mockCreateAuthFlowProofCookiesSetOptions).toHaveBeenCalledWith( + mockSetCookieOptions, + { + secure: true, + }, + ); + + expect(mockAppendSetCookieHeaders).toHaveBeenCalledWith( + expect.any(Headers), + mockCreateSignInFlowProofCookiesResult, + mockCreateAuthFlowProofCookiesSetOptionsResult, + ); + expect(isSSLOrigin).toHaveBeenCalledWith(mockOrigin); + }, + ); +}); 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..b5c65661894 --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignInSignUpRequestForPagesRouter.test.ts @@ -0,0 +1,193 @@ +import { OAuthConfig } from 'aws-amplify/adapter-core/internals'; +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, + isSSLOrigin, +} 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, +); +const mockIsSSLOrigin = jest.mocked(isSSLOrigin); + +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(); + + beforeAll(() => { + mockIsSSLOrigin.mockReturnValue(true); + }); + + afterEach(() => { + mockAppendSetCookieHeadersToNextApiResponse.mockClear(); + mockCreateAuthFlowProofCookiesSetOptions.mockClear(); + mockCreateAuthFlowProofs.mockClear(); + mockCreateAuthorizeEndpoint.mockClear(); + mockCreateSignInFlowProofCookies.mockClear(); + mockCreateSignUpEndpoint.mockClear(); + mockCreateUrlSearchParamsForSignInSignUp.mockClear(); + mockToCodeChallenge.mockClear(); + mockIsSSLOrigin.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(), + maxAge: 3600, + }; + 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(mockCreateAuthFlowProofCookiesSetOptions).toHaveBeenCalledWith( + mockSetCookieOptions, + { + secure: true, + }, + ); + + expect(mockAppendSetCookieHeadersToNextApiResponse).toHaveBeenCalledWith( + mockResponse, + mockCreateSignInFlowProofCookiesResult, + mockCreateAuthFlowProofCookiesSetOptionsResult, + ); + expect(isSSLOrigin).toHaveBeenCalledWith(mockOrigin); + }, + ); +}); 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..b805d6bef47 --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutCallbackRequest.test.ts @@ -0,0 +1,304 @@ +import { OAuthConfig } from 'aws-amplify/adapter-core/internals'; +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, + getRedirectOrDefault, + 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); +const mockGetRedirectOrDefault = jest.mocked(getRedirectOrDefault); + +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', + }; + + beforeAll(() => { + mockGetRedirectOrDefault.mockImplementation( + (redirect: string | undefined) => redirect || '/', + ); + }); + + afterEach(() => { + mockAppendSetCookieHeaders.mockClear(); + mockCreateTokenCookiesRemoveOptions.mockClear(); + mockCreateTokenRemoveCookies.mockClear(); + mockGetCookieValuesFromRequest.mockClear(); + mockRevokeAuthNTokens.mockClear(); + mockGetRedirectOrDefault.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('/'); + expect(mockGetRedirectOrDefault).toHaveBeenCalledWith(undefined); + + // 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 = { + accessToken: 'mock_access_token_cookie_name', + idToken: 'mock_id_token_cookie_name', + refreshToken: 'mock_refresh_token_cookie_name', + deviceKey: 'shouldNotIncludeMe', + } 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: '/', + maxAge: -1, + }; + 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([ + mockCreateKeysForAuthStorageResult.accessToken, + mockCreateKeysForAuthStorageResult.idToken, + mockCreateKeysForAuthStorageResult.refreshToken, + `${AUTH_KEY_PREFIX}.${mockUserPoolClientId}.LastAuthUser`, + IS_SIGNING_OUT_COOKIE_NAME, + ]); + expect(mockCreateTokenCookiesRemoveOptions).toHaveBeenCalledWith( + mockSetCookieOptions, + ); + expect(mockAppendSetCookieHeaders).toHaveBeenCalledWith( + expect.any(Headers), + mockCreateTokenRemoveCookiesResult, + mockCreateTokenCookiesRemoveOptionsResult, + ); + expect(mockGetRedirectOrDefault).toHaveBeenCalledWith( + handlerInput.redirectOnSignOutComplete, + ); + }, + ); +}); 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..f86feccff28 --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutCallbackRequestForPagesRouter.test.ts @@ -0,0 +1,352 @@ +import { OAuthConfig } from 'aws-amplify/adapter-core/internals'; +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, + getRedirectOrDefault, + 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); +const mockGetRedirectOrDefault = jest.mocked(getRedirectOrDefault); + +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(); + + beforeAll(() => { + mockGetRedirectOrDefault.mockImplementation( + (redirect: string | undefined) => redirect || '/', + ); + }); + + afterEach(() => { + mockAppendSetCookieHeadersToNextApiResponse.mockClear(); + mockCreateTokenCookiesRemoveOptions.mockClear(); + mockCreateTokenRemoveCookies.mockClear(); + mockGetCookieValuesFromNextApiRequest.mockClear(); + mockRevokeAuthNTokens.mockClear(); + mockGetRedirectOrDefault.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, '/'); + expect(mockGetRedirectOrDefault).toHaveBeenCalledWith(undefined); + + // 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 = { + accessToken: 'mock_access_token_cookie_name', + idToken: 'mock_id_token_cookie_name', + refreshToken: 'mock_refresh_token_cookie_name', + deviceKey: 'shouldNotIncludeMe', + } 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: '/', + maxAge: -1, + }; + 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([ + mockCreateKeysForAuthStorageResult.accessToken, + mockCreateKeysForAuthStorageResult.idToken, + mockCreateKeysForAuthStorageResult.refreshToken, + `${AUTH_KEY_PREFIX}.${mockUserPoolClientId}.LastAuthUser`, + IS_SIGNING_OUT_COOKIE_NAME, + ]); + expect(mockCreateTokenCookiesRemoveOptions).toHaveBeenCalledWith( + mockSetCookieOptions, + ); + expect(mockAppendSetCookieHeadersToNextApiResponse).toHaveBeenCalledWith( + mockResponse, + mockCreateTokenRemoveCookiesResult, + mockCreateTokenCookiesRemoveOptionsResult, + ); + expect(mockGetRedirectOrDefault).toHaveBeenCalledWith( + handlerInput.redirectOnSignOutComplete, + ); + }, + ); +}); 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..5cb942a8bb7 --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutRequest.test.ts @@ -0,0 +1,114 @@ +import { OAuthConfig } from 'aws-amplify/adapter-core/internals'; + +import { handleSignOutRequest } from '../../../src/auth/handlers/handleSignOutRequest'; +import { + appendSetCookieHeaders, + createAuthFlowProofCookiesSetOptions, + createLogoutEndpoint, + createSignOutFlowProofCookies, + isSSLOrigin, + 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); +const mockIsSSLOrigin = jest.mocked(isSSLOrigin); + +describe('handleSignOutRequest', () => { + beforeAll(() => { + mockIsSSLOrigin.mockReturnValue(true); + }); + + afterEach(() => { + mockAppendSetCookieHeaders.mockClear(); + mockCreateAuthFlowProofCookiesSetOptions.mockClear(); + mockCreateLogoutEndpoint.mockClear(); + mockCreateSignOutFlowProofCookies.mockClear(); + mockResolveRedirectSignOutUrl.mockClear(); + mockIsSSLOrigin.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'), + maxAge: 3600, + }; + 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(mockIsSSLOrigin).toHaveBeenCalledWith(mockOrigin); + expect(mockCreateAuthFlowProofCookiesSetOptions).toHaveBeenCalledWith( + mockSetCookieOptions, + { + secure: true, + }, + ); + 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..67b3cfe6d21 --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/handlers/handleSignOutRequestForPagesRouter.test.ts @@ -0,0 +1,132 @@ +import { OAuthConfig } from 'aws-amplify/adapter-core/internals'; + +import { handleSignOutRequestForPagesRouter } from '../../../src/auth/handlers/handleSignOutRequestForPagesRouter'; +import { + appendSetCookieHeadersToNextApiResponse, + createAuthFlowProofCookiesSetOptions, + createLogoutEndpoint, + createSignOutFlowProofCookies, + isSSLOrigin, + 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); +const mockIsSSLOrigin = jest.mocked(isSSLOrigin); + +describe('handleSignOutRequest', () => { + const { + mockResponseAppendHeader, + mockResponseEnd, + mockResponseStatus, + mockResponseSend, + mockResponseRedirect, + mockResponse, + } = createMockNextApiResponse(); + + beforeAll(() => { + mockIsSSLOrigin.mockReturnValue(true); + }); + + 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'), + maxAge: 3600, + }; + 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(mockIsSSLOrigin).toHaveBeenCalledWith(mockOrigin); + expect(mockCreateAuthFlowProofCookiesSetOptions).toHaveBeenCalledWith( + mockSetCookieOptions, + { + secure: true, + }, + ); + }); +}); diff --git a/packages/adapter-nextjs/__tests__/auth/handlers/mockImplementation.ts b/packages/adapter-nextjs/__tests__/auth/handlers/mockImplementation.ts new file mode 100644 index 00000000000..dbe6960dcbb --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/handlers/mockImplementation.ts @@ -0,0 +1,18 @@ +import { createErrorSearchParamsString } from '../../../src/auth/utils'; + +export const mockCreateErrorSearchParamsStringImplementation: typeof createErrorSearchParamsString = + ({ error, errorDescription }) => { + if (error && errorDescription) { + return 'hasErrorAndErrorDescription'; + } + + if (error) { + return 'hasError'; + } + + if (errorDescription) { + return 'hasErrorDescription'; + } + + return ''; + }; diff --git a/packages/adapter-nextjs/__tests__/auth/handlers/signInCallbackErrorCombinations.ts b/packages/adapter-nextjs/__tests__/auth/handlers/signInCallbackErrorCombinations.ts new file mode 100644 index 00000000000..af018e18ecb --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/handlers/signInCallbackErrorCombinations.ts @@ -0,0 +1,66 @@ +export const ERROR_URL_PARAMS_COMBINATIONS = [ + { + code: null, + state: 'state', + error: null, + errorDescription: null, + expectedStatus: 400, + }, + { + code: 'code', + state: null, + error: null, + errorDescription: null, + expectedStatus: 400, + }, + { + code: null, + state: null, + error: null, + errorDescription: 'errorDescription', + expectedStatus: 302, + expectedRedirect: '/sign-in?hasErrorDescription', + }, + { + code: null, + state: null, + error: 'error', + errorDescription: null, + expectedStatus: 302, + expectedRedirect: '/sign-in?hasError', + }, + { + code: null, + state: null, + error: 'error', + errorDescription: 'errorDescription', + expectedStatus: 302, + expectedRedirect: '/sign-in?hasErrorAndErrorDescription', + }, +]; + +export const ERROR_CLIENT_COOKIE_COMBINATIONS = [ + { + state: 'state_a', + pkce: 'pkce', + expectedStatus: 400, + }, + { + state: undefined, + pkce: undefined, + expectedStatus: 302, + expectedRedirect: '/sign-in?hasErrorAndErrorDescription', + }, + { + state: undefined, + pkce: 'pkce', + expectedStatus: 302, + expectedRedirect: '/sign-in?hasErrorAndErrorDescription', + }, + { + state: 'state', + pkce: undefined, + expectedStatus: 302, + expectedRedirect: '/sign-in?hasErrorAndErrorDescription', + }, +]; 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__/createOnSignInCompleteRedirectIntermediate.test.ts.snap b/packages/adapter-nextjs/__tests__/auth/utils/__snapshots__/createOnSignInCompleteRedirectIntermediate.test.ts.snap new file mode 100644 index 00000000000..3b6fc307c1e --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/__snapshots__/createOnSignInCompleteRedirectIntermediate.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..e25b0617d2d --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/authFlowProofCookies.test.ts @@ -0,0 +1,93 @@ +import { CookieStorage } from 'aws-amplify/adapter-core'; + +import { + AUTH_FLOW_PROOF_MAX_AGE, + 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', () => { + it('returns expected cookie serialization options with specified parameters', () => { + const setCookieOptions: CookieStorage.SetCookieOptions = { + domain: '.example.com', + sameSite: 'strict', + }; + + const options = createAuthFlowProofCookiesSetOptions(setCookieOptions); + + expect(options).toEqual({ + domain: setCookieOptions?.domain, + path: '/', + httpOnly: true, + secure: true, + sameSite: 'lax' as const, + maxAge: AUTH_FLOW_PROOF_MAX_AGE, + }); + }); + + it('returns expected cookie serialization options with specified parameters with overridden secure attribute', () => { + const setCookieOptions: CookieStorage.SetCookieOptions = { + domain: '.example.com', + sameSite: 'strict', + }; + + const options = createAuthFlowProofCookiesSetOptions(setCookieOptions, { + secure: false, + }); + + expect(options).toEqual({ + domain: setCookieOptions?.domain, + path: '/', + httpOnly: true, + secure: false, + sameSite: 'lax' as const, + maxAge: AUTH_FLOW_PROOF_MAX_AGE, + }); + }); +}); + +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: '/', + maxAge: -1, + }); + }); +}); 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..d7f6605d468 --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/authNTokens.test.ts @@ -0,0 +1,199 @@ +import { OAuthConfig } from 'aws-amplify/adapter-core/internals'; + +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..6d8928fd105 --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/cognitoHostedUIEndpoints.test.ts @@ -0,0 +1,50 @@ +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) => { + 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..c490dad0419 --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/createAuthFlowProofs.test.ts @@ -0,0 +1,69 @@ +import { urlSafeEncode } from 'aws-amplify/adapter-core/internals'; +import { generateCodeVerifier, generateState } from 'aws-amplify/adapter-core'; + +import { createAuthFlowProofs } from '../../../src/auth/utils'; + +jest.mock('aws-amplify/adapter-core/internals'); +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/createErrorSearchParamsString.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/createErrorSearchParamsString.test.ts new file mode 100644 index 00000000000..a806bf4a9be --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/createErrorSearchParamsString.test.ts @@ -0,0 +1,33 @@ +import { createErrorSearchParamsString } from '../../../src/auth/utils/createErrorSearchParamsString'; + +describe('createErrorSearchParamsString', () => { + test.each([ + { + error: null, + errorDescription: null, + expected: '', + }, + { + error: 'error', + errorDescription: null, + expected: 'error=error', + }, + { + error: null, + errorDescription: 'errorDescription', + expected: 'error_description=errorDescription', + }, + { + error: 'error', + errorDescription: 'errorDescription', + expected: 'error=error&error_description=errorDescription', + }, + ])( + `returns $expected when called with error: $error and errorDescription: $errorDescription`, + ({ error, errorDescription, expected }) => { + const result = createErrorSearchParamsString({ error, errorDescription }); + + expect(result).toEqual(expected); + }, + ); +}); diff --git a/packages/adapter-nextjs/__tests__/auth/utils/createOnSignInCompleteRedirectIntermediate.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/createOnSignInCompleteRedirectIntermediate.test.ts new file mode 100644 index 00000000000..0dcb8292689 --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/createOnSignInCompleteRedirectIntermediate.test.ts @@ -0,0 +1,12 @@ +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 = createOnSignInCompleteRedirectIntermediate({ + 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/getAccessTokenUsername.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/getAccessTokenUsername.test.ts new file mode 100644 index 00000000000..8f108551fb6 --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/getAccessTokenUsername.test.ts @@ -0,0 +1,30 @@ +import { decodeJWT } from 'aws-amplify/adapter-core/internals'; + +import { getAccessTokenUsername } from '../../../src/auth/utils/getAccessTokenUsername'; + +jest.mock('aws-amplify/adapter-core/internals'); + +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(getAccessTokenUsername('accessToken')).toStrictEqual('a_user'); + }); +}); 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/getRedirectOrDefault.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/getRedirectOrDefault.test.ts new file mode 100644 index 00000000000..3c6f6cdc60d --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/getRedirectOrDefault.test.ts @@ -0,0 +1,11 @@ +import { getRedirectOrDefault } from '../../../src/auth/utils/getRedirectOrDefault'; + +describe('getRedirectOrDefault', () => { + test.each([ + [undefined, '/'], + ['', '/'], + ['/home', '/home'], + ])('when input redirect is `%s` returns `%s`', (input, output) => { + expect(getRedirectOrDefault(input)).toBe(output); + }); +}); 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/hasActiveUserSession.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/hasActiveUserSession.test.ts new file mode 100644 index 00000000000..2006db665b2 --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/hasActiveUserSession.test.ts @@ -0,0 +1,125 @@ +import { getCurrentUser } from 'aws-amplify/auth/server'; +import { NextRequest } from 'next/server'; +import { AuthUser } from 'aws-amplify/auth'; +import { NextApiRequest } from 'next'; + +import { + hasActiveUserSessionWithAppRouter, + hasActiveUserSessionWithPagesRouter, +} from '../../../src/auth/utils/hasActiveUserSession'; +import { NextServer } from '../../../src/types'; +import { createMockNextApiResponse } from '../testUtils'; + +jest.mock('aws-amplify/auth/server'); + +const mockRunWithAmplifyServerContext = + jest.fn() as jest.MockedFunction; +const mockGetCurrentUser = jest.mocked(getCurrentUser); + +describe('hasUserSignedIn', () => { + const mockContextSpec = { token: { value: Symbol('mock') } }; + const mockCurrentUserResult: AuthUser = { + userId: 'mockUserId', + username: 'mockUsername', + }; + + beforeAll(() => { + mockRunWithAmplifyServerContext.mockImplementation( + async ({ nextServerContext: _, operation }) => { + return operation(mockContextSpec); + }, + ); + mockGetCurrentUser.mockResolvedValue(mockCurrentUserResult); + }); + + afterEach(() => { + mockRunWithAmplifyServerContext.mockClear(); + mockGetCurrentUser.mockClear(); + }); + + describe('hasUserSignedInWithAppRouter', () => { + const mockRequest = new NextRequest('https://example.com/api/auth/sign-in'); + + it('invokes server getCurrentUser() with expected parameter within the injected runWithAmplifyServerContext function', async () => { + await hasActiveUserSessionWithAppRouter({ + request: mockRequest, + runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + }); + + expect(mockRunWithAmplifyServerContext).toHaveBeenCalledWith({ + nextServerContext: { + request: mockRequest, + response: expect.any(Response), + }, + operation: expect.any(Function), + }); + expect(mockGetCurrentUser).toHaveBeenCalledWith(mockContextSpec); + }); + + it('returns true when getCurrentUser() resolves (returned auth tokens)', async () => { + const result = await hasActiveUserSessionWithAppRouter({ + request: mockRequest, + runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + }); + + expect(result).toBe(true); + }); + + it('returns false when getCurrentUser() rejects (no auth tokens)', async () => { + mockGetCurrentUser.mockRejectedValueOnce(new Error('No current user')); + + const result = await hasActiveUserSessionWithAppRouter({ + request: mockRequest, + runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + }); + + expect(result).toBe(false); + }); + }); + + describe('hasUserSignedInWithPagesRouter', () => { + const mockRequest = { + url: 'https://example.com/api/auth/sign-in', + } as unknown as NextApiRequest; + const { mockResponse } = createMockNextApiResponse(); + + it('invokes server getCurrentUser() with expected parameter within the injected runWithAmplifyServerContext function', async () => { + await hasActiveUserSessionWithPagesRouter({ + request: mockRequest, + response: mockResponse, + runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + }); + + expect(mockRunWithAmplifyServerContext).toHaveBeenCalledWith({ + nextServerContext: { + request: mockRequest, + response: mockResponse, + }, + operation: expect.any(Function), + }); + expect(mockGetCurrentUser).toHaveBeenCalledWith(mockContextSpec); + }); + + it('returns true when getCurrentUser() resolves (returned auth tokens)', async () => { + const result = await hasActiveUserSessionWithPagesRouter({ + request: mockRequest, + response: mockResponse, + runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + }); + + expect(result).toBe(true); + }); + + it('returns false when getCurrentUser() rejects (no auth tokens)', async () => { + mockGetCurrentUser.mockRejectedValueOnce(new Error('No current user')); + + const result = await hasActiveUserSessionWithPagesRouter({ + request: mockRequest, + response: mockResponse, + runWithAmplifyServerContext: mockRunWithAmplifyServerContext, + }); + + expect(result).toBe(false); + }); + }); +}); diff --git a/packages/adapter-nextjs/__tests__/auth/utils/isSupportedAuthApiRoutePath.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/isSupportedAuthApiRoutePath.test.ts new file mode 100644 index 00000000000..ad54d7422bd --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/isSupportedAuthApiRoutePath.test.ts @@ -0,0 +1,15 @@ +import { isSupportedAuthApiRoutePath } from '../../../src/auth/utils/isSupportedAuthApiRoutePath'; + +describe('isSupportedAuthApiRoutePath', () => { + test.each([ + ['sign-in', true], + ['sign-in-callback', true], + ['sign-up', true], + ['sign-out', true], + ['sign-out-callback', true], + ['fancy-route', false], + [undefined, false], + ])('when call with %s it returns %s', (input, expectedResult) => { + expect(isSupportedAuthApiRoutePath(input)).toBe(expectedResult); + }); +}); diff --git a/packages/adapter-nextjs/__tests__/auth/utils/origin.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/origin.test.ts new file mode 100644 index 00000000000..ca01a208ae2 --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/origin.test.ts @@ -0,0 +1,65 @@ +import { isSSLOrigin, isValidOrigin } from '../../../src/auth/utils/origin'; + +describe('isValidOrigin', () => { + test.each([ + // Valid origins + ['http://example.com', true], + ['https://example.com', true], + ['http://www.example.com', true], + ['https://subdomain.example.com', true], + ['http://example.com:8080', true], + ['https://example.com:443', true], + ['http://localhost', true], + ['http://localhost:3000', true], + ['https://localhost:8080', true], + ['http://127.0.0.1', true], + ['http://127.0.0.1:8000', true], + + // Invalid origins + ['http://example.com/path', false], + ['https://example.com/path/to/resource', false], + ['http://example.com:8080/path', false], + ['ftp://example.com', false], + ['example.com', false], + ['http:/example.com', false], + ['https:example.com', false], + ['http://', false], + ['https://', false], + ['localhost', false], + ['http:localhost', false], + ['https://localhost:', false], + ['http://127.0.0.1:', false], + ['https://.com', false], + ['http://example.', false], + ['https://example.com:abc', false], + ['http:// example.com', false], + ['https://exam ple.com', false], + ['http://exa mple.com:8080', false], + ['https://example.com:8080:8081', false], + ['http://example.com:80:80', false], + ['https://.example.com', false], + ['http://example..com', false], + ['https://exam_ple.com', false], + ['https://example.com?query=param', false], + ['https://example.com:80/path#fragment', false], + ['yea, I am not a origin, so?', false], + [undefined, false], + ['', false], + ] as [string, boolean][])('validates origin %s as %s', (origin, expected) => { + expect(isValidOrigin(origin)).toBe(expected); + }); +}); + +describe('isSSLOrigin', () => { + test.each([ + ['https://some-app.com', true], + ['http://localhost', false], + ['http://localhost:3000', false], + ['https:// some-app.com', false], + ['https://some-app.com:', false], + [undefined, false], + ['', false], + ])('check origin SSL %s status as %s', (origin, expected) => { + expect(isSSLOrigin(origin)).toBe(expected); + }); +}); diff --git a/packages/adapter-nextjs/__tests__/auth/utils/parseSignInCallbackUrl.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/parseSignInCallbackUrl.test.ts new file mode 100644 index 00000000000..157b9667f03 --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/parseSignInCallbackUrl.test.ts @@ -0,0 +1,16 @@ +import { parseSignInCallbackUrl } from '../../../src/auth/utils/parseSignInCallbackUrl'; + +describe('parseSignInCallbackUrl', () => { + it('returns the code and state from the url', () => { + const url = + 'https://example.com?code=123&state=456&error=789&error_description=abc'; + const result = parseSignInCallbackUrl(url); + + expect(result).toEqual({ + code: '123', + state: '456', + error: '789', + errorDescription: 'abc', + }); + }); +}); diff --git a/packages/adapter-nextjs/__tests__/auth/utils/predicates.test.ts b/packages/adapter-nextjs/__tests__/auth/utils/predicates.test.ts new file mode 100644 index 00000000000..c166579712e --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/predicates.test.ts @@ -0,0 +1,42 @@ +import { NextRequest } from 'next/server.js'; + +import { + isAuthRoutesHandlersContext, + isNextApiRequest, + isNextApiResponse, + isNextRequest, +} from '../../../src/auth/utils/predicates'; + +describe('isAuthRoutesHandlersContext', () => { + test.each([ + [{}, false], + [{ params: {} }, true], + [{ params: { slug: 'sign-in' } }, true], + [{ params: Promise.resolve({ slug: 'sign-in' }) }, true], + ] as [object, boolean][])( + 'when call with %o it returns %s', + (input, expectedResult) => { + expect(isAuthRoutesHandlersContext(input)).toBe(expectedResult); + }, + ); +}); + +describe('isNextApiRequest', () => { + it('returns true when the request object has a query property', () => { + expect(isNextApiRequest({ query: {} })).toBe(true); + }); +}); + +describe('isNextApiResponse', () => { + it('returns true when the response object has a redirect method', () => { + expect(isNextApiResponse({ redirect: jest.fn() })).toBe(true); + }); +}); + +describe('isNextRequest', () => { + it('returns true when the request object is an instance of Request and has a nextUrl property', () => { + const request = new NextRequest(new URL('https://example.com')); + + expect(isNextRequest(request)).toBe(true); + }); +}); 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..baab3c17d0e --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/resolveIdentityProviderFromUrl.test.ts @@ -0,0 +1,23 @@ +import { resolveIdentityProviderFromUrl } from '../../../src/auth/utils/resolveIdentityProviderFromUrl'; + +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'], + ['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..63e37751a1c --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/resolveRedirectUrl.test.ts @@ -0,0 +1,46 @@ +import { OAuthConfig } from 'aws-amplify/adapter-core/internals'; + +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..57602534a40 --- /dev/null +++ b/packages/adapter-nextjs/__tests__/auth/utils/tokenCookies.test.ts @@ -0,0 +1,169 @@ +import { + AUTH_KEY_PREFIX, + CookieStorage, + DEFAULT_AUTH_TOKEN_COOKIES_MAX_AGE, +} from 'aws-amplify/adapter-core'; + +import { OAuthTokenResponsePayload } from '../../../src/auth/types'; +import { + createTokenCookies, + createTokenCookiesRemoveOptions, + createTokenCookiesSetOptions, + createTokenRemoveCookies, + getAccessTokenUsername, + isServerSideAuthAllowedCookie, +} from '../../../src/auth/utils'; + +jest.mock('../../../src/auth/utils/getAccessTokenUsername'); + +const mockGetAccessTokenUsername = jest.mocked(getAccessTokenUsername); + +describe('createTokenCookies', () => { + const mockUserName = 'a_user'; + beforeAll(() => { + mockGetAccessTokenUsername.mockReturnValue(mockUserName); + }); + + 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).toEqual([ + { + name: `${expectedCookieNamePrefix}.accessToken`, + value: 'access_token', + }, + { + name: `${expectedCookieNamePrefix}.idToken`, + value: 'id_token', + }, + { + name: `${expectedCookieNamePrefix}.refreshToken`, + value: 'refresh_token', + }, + { + name: `${AUTH_KEY_PREFIX}.${mockUserPoolClientId}.LastAuthUser`, + value: mockUserName, + }, + ]); + }); +}); + +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', + maxAge: DEFAULT_AUTH_TOKEN_COOKIES_MAX_AGE, + }); + + dateNowSpy.mockRestore(); + }); + + it('returns an object with the correct cookie options with overridden secure attribute', () => { + const mockSetCookieOptions: CookieStorage.SetCookieOptions = { + domain: '.example.com', + sameSite: 'strict', + expires: new Date('2024-09-17'), + }; + + const result = createTokenCookiesSetOptions(mockSetCookieOptions, { + secure: false, + }); + + expect(result).toEqual({ + domain: mockSetCookieOptions.domain, + path: '/', + httpOnly: true, + secure: false, + sameSite: 'strict', + expires: mockSetCookieOptions.expires, + }); + }); +}); + +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: '/', + maxAge: -1, + }); + }); +}); + +describe('isServerSideAuthAllowedCookie', () => { + test.each([ + ['CognitoIdentityServiceProvider.1234.aaaa.clockDrift', false], + ['CognitoIdentityServiceProvider.1234.aaaa.deviceKey', false], + ['CognitoIdentityServiceProvider.1234.aaaa.clientMetadata', false], + ['CognitoIdentityServiceProvider.1234.aaaa.oAuthMetadata', false], + ['CognitoIdentityServiceProvider.1234.aaaa', false], + ['CognitoIdentityServiceProvider.1234', false], + ['CognitoIdentityServiceProvider.1234.aaaa.refreshToken', true], + ['CognitoIdentityServiceProvider.1234.aaaa.accessToken', true], + ['CognitoIdentityServiceProvider.1234.aaaa.idToken', true], + ['CognitoIdentityServiceProvider.1234.aaaa.LastAuthUser', true], + ])('returns %s for %s', (cookieName, expected) => { + expect(isServerSideAuthAllowedCookie(cookieName)).toBe(expected); + }); +}); diff --git a/packages/adapter-nextjs/__tests__/createServerRunner.test.ts b/packages/adapter-nextjs/__tests__/createServerRunner.test.ts index 258f101cb25..2289b51cdcb 100644 --- a/packages/adapter-nextjs/__tests__/createServerRunner.test.ts +++ b/packages/adapter-nextjs/__tests__/createServerRunner.test.ts @@ -1,7 +1,8 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { ResourcesConfig, sharedInMemoryStorage } from '@aws-amplify/core'; +import { ResourcesConfig } from 'aws-amplify'; +import { sharedInMemoryStorage } from 'aws-amplify/utils'; import { NextServer } from '../src/types'; @@ -23,21 +24,62 @@ const mockAmplifyConfig: ResourcesConfig = { jest.mock( '../src/utils/createCookieStorageAdapterFromNextServerContext', () => ({ - createCookieStorageAdapterFromNextServerContext: jest.fn(), + createCookieStorageAdapterFromNextServerContext: jest.fn(() => ({ + get: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + getAll: jest.fn(), + })), }), ); +jest.mock('../src/utils/createTokenValidator', () => ({ + createTokenValidator: jest.fn(() => ({ + getItem: jest.fn(), + })), +})); + +const mockGetRuntimeOptions = jest.fn(() => ({})); +const mockIsServerSideAuthEnabled = jest.fn(() => false); +const mockGlobalSettingsIsSSLOrigin = jest.fn(() => false); +const mockGlobalSettings: NextServer.GlobalSettings = { + isServerSideAuthEnabled: mockIsServerSideAuthEnabled, + enableServerSideAuth: jest.fn(), + setRuntimeOptions: jest.fn(), + getRuntimeOptions: mockGetRuntimeOptions, + isSSLOrigin: mockGlobalSettingsIsSSLOrigin, + setIsSSLOrigin: jest.fn(), +}; + describe('createServerRunner', () => { - let createServerRunner: any; + let createServerRunner: NextServer.CreateServerRunner; let createRunWithAmplifyServerContextSpy: any; - const mockParseAmplifyConfig = jest.fn(); + const AMPLIFY_APP_ORIGIN = 'https://test.com'; + const originalProcessEnv = { ...process.env }; + const modifiedProcessEnv = { + ...originalProcessEnv, + AMPLIFY_APP_ORIGIN, + }; + + const mockParseAmplifyConfig = jest.fn(config => config); const mockCreateAWSCredentialsAndIdentityIdProvider = jest.fn(); const mockCreateKeyValueStorageFromCookieStorageAdapter = jest.fn(); const mockCreateUserPoolsTokenProvider = jest.fn(); const mockRunWithAmplifyServerContextCore = jest.fn(); + const mockCreateAuthRouteHandlersFactory = jest.fn(() => jest.fn()); + const mockIsSSLOriginUtil = jest.fn(() => true); + const mockIsValidOrigin = jest.fn(origin => !!origin); + + beforeAll(() => { + jest.doMock('../src/utils/globalSettings', () => ({ + globalSettings: mockGlobalSettings, + })); + }); beforeEach(() => { + process.env = modifiedProcessEnv; + jest.resetModules(); jest.doMock('aws-amplify/adapter-core', () => ({ createAWSCredentialsAndIdentityIdProvider: @@ -47,24 +89,33 @@ describe('createServerRunner', () => { createUserPoolsTokenProvider: mockCreateUserPoolsTokenProvider, runWithAmplifyServerContext: mockRunWithAmplifyServerContextCore, })); - jest.doMock('@aws-amplify/core/internals/utils', () => ({ + + jest.doMock('aws-amplify/utils', () => ({ + ...jest.requireActual('aws-amplify/utils'), parseAmplifyConfig: mockParseAmplifyConfig, })); createRunWithAmplifyServerContextSpy = jest.spyOn( require('../src/utils/createRunWithAmplifyServerContext'), 'createRunWithAmplifyServerContext', ); + jest.doMock('../src/auth', () => ({ + createAuthRouteHandlersFactory: mockCreateAuthRouteHandlersFactory, + })); + + jest.doMock('../src/auth/utils', () => ({ + isSSLOrigin: mockIsSSLOriginUtil, + isValidOrigin: mockIsValidOrigin, + })); ({ createServerRunner } = require('../src')); + + mockCreateAuthRouteHandlersFactory.mockReturnValue(jest.fn()); }); afterEach(() => { - createRunWithAmplifyServerContextSpy.mockClear(); - mockParseAmplifyConfig.mockClear(); - mockCreateAWSCredentialsAndIdentityIdProvider.mockClear(); - mockCreateKeyValueStorageFromCookieStorageAdapter.mockClear(); - mockCreateUserPoolsTokenProvider.mockClear(); - mockRunWithAmplifyServerContextCore.mockClear(); + process.env = originalProcessEnv; + + jest.clearAllMocks(); }); it('calls parseAmplifyConfig when the config object is imported from amplify configuration file', () => { @@ -79,6 +130,39 @@ describe('createServerRunner', () => { }); }); + it('returns createAuthRoutesHandlers function', () => { + const result = createServerRunner({ config: mockAmplifyConfig }); + + expect(mockCreateAuthRouteHandlersFactory).toHaveBeenCalledWith({ + config: mockAmplifyConfig, + amplifyAppOrigin: AMPLIFY_APP_ORIGIN, + runWithAmplifyServerContext: expect.any(Function), + globalSettings: mockGlobalSettings, + }); + expect(result).toMatchObject({ + createAuthRouteHandlers: expect.any(Function), + }); + }); + + describe('when AMPLIFY_APP_ORIGIN is not set', () => { + it('it does NOT call globalSettings.setIsSSLOrigin() and isValidOrigin()', () => { + delete process.env.AMPLIFY_APP_ORIGIN; + createServerRunner({ config: mockAmplifyConfig }); + expect(mockIsValidOrigin).toHaveBeenCalledWith(undefined); + expect(mockGlobalSettings.setIsSSLOrigin).not.toHaveBeenCalled(); + process.env.AMPLIFY_APP_ORIGIN = AMPLIFY_APP_ORIGIN; + }); + }); + + describe('when AMPLIFY_APP_ORIGIN is set with a https origin', () => { + it('it calls globalSettings.setIsSSLOrigin(), isValidOrigin() and globalSettings.enableServerSideAuth', () => { + createServerRunner({ config: mockAmplifyConfig }); + expect(mockIsValidOrigin).toHaveBeenCalledWith(AMPLIFY_APP_ORIGIN); + expect(mockGlobalSettings.setIsSSLOrigin).toHaveBeenCalledWith(true); + expect(mockGlobalSettings.enableServerSideAuth).toHaveBeenCalled(); + }); + }); + describe('runWithAmplifyServerContext', () => { describe('when amplifyConfig.Auth is not defined', () => { it('should call runWithAmplifyServerContextCore without Auth library options', () => { @@ -106,6 +190,7 @@ describe('createServerRunner', () => { expect(createRunWithAmplifyServerContextSpy).toHaveBeenCalledWith({ config: mockAmplifyConfigWithoutAuth, tokenValidator: undefined, + globalSettings: mockGlobalSettings, }); }); }); @@ -134,31 +219,34 @@ describe('createServerRunner', () => { tokenValidator: expect.objectContaining({ getItem: expect.any(Function), }), + globalSettings: mockGlobalSettings, }); }); }); describe('when nextServerContext is not null', () => { + const mockNextServerContext = { + req: { + headers: { + cookie: 'cookie', + }, + }, + res: { + setHeader: jest.fn(), + }, + }; + const mockCookieStorageAdapter = { + get: jest.fn(), + set: jest.fn(), + remove: jest.fn(), + }; + it('should create auth providers with cookie storage adapter', async () => { const operation = jest.fn(); - const mockCookieStorageAdapter = { - get: jest.fn(), - set: jest.fn(), - remove: jest.fn(), - }; + mockCreateKeyValueStorageFromCookieStorageAdapter.mockReturnValueOnce( mockCookieStorageAdapter, ); - const mockNextServerContext = { - req: { - headers: { - cookie: 'cookie', - }, - }, - res: { - setHeader: jest.fn(), - }, - }; const { runWithAmplifyServerContext } = createServerRunner({ config: mockAmplifyConfig, }); @@ -182,6 +270,108 @@ describe('createServerRunner', () => { tokenValidator: expect.objectContaining({ getItem: expect.any(Function), }), + globalSettings: mockGlobalSettings, + }); + }); + + it('should call createKeyValueStorageFromCookieStorageAdapter with specified runtimeOptions.cookies', async () => { + const testCookiesOptions: NextServer.CreateServerRunnerRuntimeOptions['cookies'] = + { + domain: '.example.com', + sameSite: 'lax', + expires: new Date('2024-09-05'), + }; + mockGetRuntimeOptions.mockReturnValueOnce({ + cookies: testCookiesOptions, + }); + mockCreateKeyValueStorageFromCookieStorageAdapter.mockReturnValueOnce( + mockCookieStorageAdapter, + ); + + const { runWithAmplifyServerContext } = createServerRunner({ + config: mockAmplifyConfig, + runtimeOptions: { + cookies: testCookiesOptions, + }, + }); + + await runWithAmplifyServerContext({ + nextServerContext: + mockNextServerContext as unknown as NextServer.Context, + operation: jest.fn(), + }); + + expect( + mockCreateKeyValueStorageFromCookieStorageAdapter, + ).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), { + ...testCookiesOptions, + path: '/', + }); + }); + + it('should call createKeyValueStorageFromCookieStorageAdapter with enforced and default server auth cookie attributes', async () => { + mockIsServerSideAuthEnabled.mockReturnValueOnce(true); + mockGlobalSettingsIsSSLOrigin.mockReturnValueOnce(true); + mockCreateKeyValueStorageFromCookieStorageAdapter.mockReturnValueOnce( + mockCookieStorageAdapter, + ); + + const { runWithAmplifyServerContext } = createServerRunner({ + config: mockAmplifyConfig, + }); + + await runWithAmplifyServerContext({ + nextServerContext: + mockNextServerContext as unknown as NextServer.Context, + operation: jest.fn(), + }); + + expect( + mockCreateKeyValueStorageFromCookieStorageAdapter, + ).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), { + httpOnly: true, + path: '/', + sameSite: 'strict', + secure: true, + }); + }); + + it('should call createKeyValueStorageFromCookieStorageAdapter with specified runtimeOptions.cookies with enforced server auth cookie attributes', async () => { + const testCookiesOptions: NextServer.CreateServerRunnerRuntimeOptions['cookies'] = + { + domain: '.example.com', + sameSite: 'lax', + expires: new Date('2024-09-05'), + }; + mockGetRuntimeOptions.mockReturnValueOnce({ + cookies: testCookiesOptions, + }); + mockIsServerSideAuthEnabled.mockReturnValueOnce(true); + mockGlobalSettingsIsSSLOrigin.mockReturnValueOnce(true); + mockCreateKeyValueStorageFromCookieStorageAdapter.mockReturnValueOnce( + mockCookieStorageAdapter, + ); + + const { runWithAmplifyServerContext } = createServerRunner({ + config: mockAmplifyConfig, + runtimeOptions: { + cookies: testCookiesOptions, + }, + }); + + await runWithAmplifyServerContext({ + nextServerContext: + mockNextServerContext as unknown as NextServer.Context, + operation: jest.fn(), + }); + + expect( + mockCreateKeyValueStorageFromCookieStorageAdapter, + ).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), { + ...testCookiesOptions, + path: '/', + httpOnly: true, + secure: true, }); }); }); diff --git a/packages/adapter-nextjs/__tests__/utils/createCookieStorageAdapterFromNextServerContext.test.ts b/packages/adapter-nextjs/__tests__/utils/createCookieStorageAdapterFromNextServerContext.test.ts index c81383ea804..f98d8e10880 100644 --- a/packages/adapter-nextjs/__tests__/utils/createCookieStorageAdapterFromNextServerContext.test.ts +++ b/packages/adapter-nextjs/__tests__/utils/createCookieStorageAdapterFromNextServerContext.test.ts @@ -13,6 +13,7 @@ import { DATE_IN_THE_PAST, createCookieStorageAdapterFromNextServerContext, } from '../../src/utils/createCookieStorageAdapterFromNextServerContext'; +import { isServerSideAuthAllowedCookie } from '../../src/auth/utils'; // Make global Request available during test enableFetchMocks(); @@ -20,8 +21,12 @@ enableFetchMocks(); jest.mock('next/headers', () => ({ cookies: jest.fn(), })); +jest.mock('../../src/auth/utils'); const mockNextCookiesFunc = cookies as jest.Mock; +const mockIsServerSideAuthAllowedCookie = jest.mocked( + isServerSideAuthAllowedCookie, +); describe('createCookieStorageAdapterFromNextServerContext', () => { const mockGetFunc = jest.fn(); @@ -40,8 +45,8 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { const mockKeyWithEncoding = 'test%40email.com'; const mockValue = 'fabCookie'; - beforeEach(() => { - jest.resetAllMocks(); + afterEach(() => { + jest.clearAllMocks(); }); describe('cookieStorageAdapter created from NextRequest and NextResponse', () => { @@ -54,6 +59,7 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { let result: CookieStorage.Adapter; beforeAll(async () => { + mockIsServerSideAuthAllowedCookie.mockReturnValue(true); jest.spyOn(request, 'cookies', 'get').mockImplementation( () => ({ @@ -120,6 +126,30 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { encodeURIComponent(mockKeyWithEncoding), ); }); + + test('set() and delete() methods do NOT take effects when ignoreNonServerSideCookies is passed as true and the cookie is not one of the server-side auth cookie', async () => { + mockIsServerSideAuthAllowedCookie.mockReturnValueOnce(false); + const testCookieName = + 'CognitoIdentityServiceProvider.4epnu2hld0q0ig2dtd426bv7ab.123.clockDrift'; + const adapterWithIgnore = + await createCookieStorageAdapterFromNextServerContext( + mockContext, + true, + ); + + adapterWithIgnore.set(testCookieName, 'value'); + expect(mockSetFunc).not.toHaveBeenCalled(); + expect(mockIsServerSideAuthAllowedCookie).toHaveBeenCalledWith( + testCookieName, + ); + + mockIsServerSideAuthAllowedCookie.mockReturnValueOnce(false); + adapterWithIgnore.delete(testCookieName); + expect(mockDeleteFunc).not.toHaveBeenCalled(); + expect(mockIsServerSideAuthAllowedCookie).toHaveBeenCalledWith( + testCookieName, + ); + }); }); describe('cookieStorageAdapter created from NextRequest and Response', () => { @@ -130,7 +160,7 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { response, } as any; - let result: CookieStorage.Adapter; + let adapter: CookieStorage.Adapter; beforeAll(async () => { jest.spyOn(request, 'cookies', 'get').mockImplementation( @@ -147,7 +177,7 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { }) as any, ); - result = + adapter = await createCookieStorageAdapterFromNextServerContext(mockContext); }); @@ -158,51 +188,52 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { httpOnly: true, secure: true, path: '/a-path', + maxAge: 3600, }; it('gets cookie by calling `get` method of the underlying cookie store', () => { - result.get(mockKey); + adapter.get(mockKey); expect(mockGetFunc).toHaveBeenCalledWith(mockKey); }); it('gets cookie by calling `get` method of the underlying cookie store with a encoded cookie name', () => { - result.get(mockKeyWithEncoding); + adapter.get(mockKeyWithEncoding); expect(mockGetFunc).toHaveBeenCalledWith( encodeURIComponent(mockKeyWithEncoding), ); }); it('gets all cookies by calling `getAll` method of the underlying cookie store', () => { - result.getAll(); + adapter.getAll(); expect(mockGetAllFunc).toHaveBeenCalled(); }); it('sets cookie by calling the `set` method of the underlying cookie store with options', () => { - result.set(mockKey, mockValue, mockSerializeOptions); + adapter.set(mockKey, mockValue, mockSerializeOptions); expect(mockAppend).toHaveBeenCalledWith( 'Set-Cookie', `${mockKey}=${mockValue};Domain=${ mockSerializeOptions.domain };Expires=${mockSerializeOptions.expires.toUTCString()};HttpOnly;SameSite=${ mockSerializeOptions.sameSite - };Secure;Path=${mockSerializeOptions.path}`, + };Secure;Path=${mockSerializeOptions.path};Max-Age=${mockSerializeOptions.maxAge}`, ); }); it('sets cookie by calling the `set` method of the underlying cookie store with options and a encoded cookie name', () => { - result.set(mockKeyWithEncoding, mockValue, mockSerializeOptions); + adapter.set(mockKeyWithEncoding, mockValue, mockSerializeOptions); expect(mockAppend).toHaveBeenCalledWith( 'Set-Cookie', `${encodeURIComponent(mockKeyWithEncoding)}=${mockValue};Domain=${ mockSerializeOptions.domain };Expires=${mockSerializeOptions.expires.toUTCString()};HttpOnly;SameSite=${ mockSerializeOptions.sameSite - };Secure;Path=${mockSerializeOptions.path}`, + };Secure;Path=${mockSerializeOptions.path};Max-Age=${mockSerializeOptions.maxAge}`, ); }); it('sets cookie by calling the `set` method of the underlying cookie store without options', () => { - result.set(mockKey, mockValue, undefined); + adapter.set(mockKey, mockValue, undefined); expect(mockAppend).toHaveBeenCalledWith( 'Set-Cookie', `${mockKey}=${mockValue};`, @@ -210,7 +241,7 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { }); it('sets cookie by calling the `set` method of the underlying cookie store with options that do not need to be serialized', () => { - result.set(mockKey, mockValue, { + adapter.set(mockKey, mockValue, { httpOnly: false, sameSite: false, secure: false, @@ -222,7 +253,7 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { }); it('deletes cookie by calling the `delete` method of the underlying cookie store', () => { - result.delete(mockKey); + adapter.delete(mockKey); expect(mockAppend).toHaveBeenCalledWith( 'Set-Cookie', `${mockKey}=;Expires=${DATE_IN_THE_PAST.toUTCString()}`, @@ -230,7 +261,7 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { }); it('deletes cookie by calling the `delete` method of the underlying cookie store with a encoded cookie name', () => { - result.delete(mockKeyWithEncoding); + adapter.delete(mockKeyWithEncoding); expect(mockAppend).toHaveBeenCalledWith( 'Set-Cookie', `${encodeURIComponent( @@ -238,13 +269,37 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { )}=;Expires=${DATE_IN_THE_PAST.toUTCString()}`, ); }); + + test('set() and delete() methods do NOT take effects when ignoreNonServerSideCookies is passed as true and the cookie is not one of the server-side auth cookie', async () => { + mockIsServerSideAuthAllowedCookie.mockReturnValueOnce(false); + const testCookieName = + 'CognitoIdentityServiceProvider.4epnu2hld0q0ig2dtd426bv7ab.123.clockDrift'; + const adapterWithIgnore = + await createCookieStorageAdapterFromNextServerContext( + mockContext, + true, + ); + + adapterWithIgnore.set(testCookieName, 'value'); + expect(mockAppend).not.toHaveBeenCalled(); + expect(mockIsServerSideAuthAllowedCookie).toHaveBeenCalledWith( + testCookieName, + ); + + mockIsServerSideAuthAllowedCookie.mockReturnValueOnce(false); + adapterWithIgnore.delete(testCookieName); + expect(mockAppend).not.toHaveBeenCalled(); + expect(mockIsServerSideAuthAllowedCookie).toHaveBeenCalledWith( + testCookieName, + ); + }); }); describe('cookieStorageAdapter created from Next cookies function', () => { let result: CookieStorage.Adapter; beforeAll(async () => { - mockNextCookiesFunc.mockReturnValueOnce(mockNextCookiesFuncReturn); + mockNextCookiesFunc.mockReturnValue(mockNextCookiesFuncReturn); result = await createCookieStorageAdapterFromNextServerContext({ cookies, @@ -297,6 +352,30 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { encodeURIComponent(mockKeyWithEncoding), ); }); + + test('set() and delete() methods do NOT take effects when ignoreNonServerSideCookies is passed as true and the cookie is not one of the server-side auth cookie', async () => { + mockIsServerSideAuthAllowedCookie.mockReturnValueOnce(false); + const testCookieName = + 'CognitoIdentityServiceProvider.4epnu2hld0q0ig2dtd426bv7ab.123.clockDrift'; + const adapterWithIgnore = + await createCookieStorageAdapterFromNextServerContext( + { cookies }, + true, + ); + + adapterWithIgnore.set(testCookieName, 'value'); + expect(mockNextCookiesFuncReturn.set).not.toHaveBeenCalled(); + expect(mockIsServerSideAuthAllowedCookie).toHaveBeenCalledWith( + testCookieName, + ); + + mockIsServerSideAuthAllowedCookie.mockReturnValueOnce(false); + adapterWithIgnore.delete(testCookieName); + expect(mockNextCookiesFuncReturn.delete).not.toHaveBeenCalled(); + expect(mockIsServerSideAuthAllowedCookie).toHaveBeenCalledWith( + testCookieName, + ); + }); }); describe('cookieStorageAdapter created from IncomingMessage and ServerResponse as the Pages Router context', () => { @@ -461,6 +540,40 @@ describe('createCookieStorageAdapterFromNextServerContext', () => { result.delete('CognitoIdentityServiceProvider.1234.identityId'); expect(appendHeaderSpy).not.toHaveBeenCalled(); }); + + test('set() and delete() methods do NOT take effects when ignoreNonServerSideCookies is passed as true and the cookie is not one of the server-side auth cookie', async () => { + const testCookieName = + 'CognitoIdentityServiceProvider.4epnu2hld0q0ig2dtd426bv7ab.123.clockDrift'; + + const request = new IncomingMessage(new Socket()); + const response = new ServerResponse(request); + const appendHeaderSpy = jest.spyOn(response, 'appendHeader'); + + Object.defineProperty(request, 'cookies', { + get() { + return { + [testCookieName]: 'value', + }; + }, + }); + + const adapterWithIgnore = + await createCookieStorageAdapterFromNextServerContext( + { + request: request as any, + response, + }, + true, + ); + + mockIsServerSideAuthAllowedCookie.mockReturnValueOnce(false); + adapterWithIgnore.set(testCookieName, 'value'); + expect(appendHeaderSpy).not.toHaveBeenCalled(); + + mockIsServerSideAuthAllowedCookie.mockReturnValueOnce(false); + adapterWithIgnore.delete(testCookieName); + expect(appendHeaderSpy).not.toHaveBeenCalled(); + }); }); it('should throw error when no cookie storage adapter is created from the context', () => { diff --git a/packages/adapter-nextjs/__tests__/utils/globalSettings.test.ts b/packages/adapter-nextjs/__tests__/utils/globalSettings.test.ts new file mode 100644 index 00000000000..962c18094fe --- /dev/null +++ b/packages/adapter-nextjs/__tests__/utils/globalSettings.test.ts @@ -0,0 +1,48 @@ +import { globalSettings } from '../../src/utils/globalSettings'; + +describe('globalSettings', () => { + describe('with default globalSettings', () => { + test('isServerSideAuthEnabled should return false', () => { + expect(globalSettings.isServerSideAuthEnabled()).toBe(false); + }); + + test('isSSLOrigin should return false', () => { + expect(globalSettings.isSSLOrigin()).toBe(false); + }); + + test('getRuntimeOptions should return empty object', () => { + expect(globalSettings.getRuntimeOptions()).toEqual({}); + }); + }); + + test('enableServerSideAuth should set isServerSideAuthEnabled to true', () => { + globalSettings.enableServerSideAuth(); + expect(globalSettings.isServerSideAuthEnabled()).toBe(true); + }); + + test('setIsSSLOrigin should set isSSLOrigin to true', () => { + globalSettings.setIsSSLOrigin(true); + expect(globalSettings.isSSLOrigin()).toBe(true); + }); + + test('setRuntimeOptions should set runtimeOptions', () => { + const runtimeOptions = { cookies: { domain: 'example.com' } }; + globalSettings.setRuntimeOptions(runtimeOptions); + + expect(globalSettings.getRuntimeOptions()).toEqual(runtimeOptions); + }); + + test('setRuntimeOptions should set runtimeOptions by copying the object rather than set the object reference', () => { + const runtimeOptions = { cookies: { domain: 'example.com' } }; + globalSettings.setRuntimeOptions(runtimeOptions); + + // change a property of runtimeOptions.cookies + runtimeOptions.cookies.domain = 'example2.com'; + + // originally set runtimeOptions should not be changed + expect(globalSettings.getRuntimeOptions()).not.toEqual(runtimeOptions); + expect(globalSettings.getRuntimeOptions()).toEqual({ + cookies: { domain: 'example.com' }, + }); + }); +}); diff --git a/packages/adapter-nextjs/jest.config.js b/packages/adapter-nextjs/jest.config.js index c6406b36105..8ef7dbcc243 100644 --- a/packages/adapter-nextjs/jest.config.js +++ b/packages/adapter-nextjs/jest.config.js @@ -1,5 +1,6 @@ module.exports = { ...require('../../jest.config'), + testEnvironment: 'node', coverageThreshold: { global: { branches: 88, diff --git a/packages/adapter-nextjs/src/api/createServerRunnerForAPI.ts b/packages/adapter-nextjs/src/api/createServerRunnerForAPI.ts index 3c5ae6ad97a..500c2279649 100644 --- a/packages/adapter-nextjs/src/api/createServerRunnerForAPI.ts +++ b/packages/adapter-nextjs/src/api/createServerRunnerForAPI.ts @@ -1,15 +1,18 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { ResourcesConfig } from '@aws-amplify/core'; -import { parseAmplifyConfig } from '@aws-amplify/core/internals/utils'; +import { ResourcesConfig } from 'aws-amplify'; +import { parseAmplifyConfig } from 'aws-amplify/utils'; -import { createRunWithAmplifyServerContext } from '../utils'; +import { createRunWithAmplifyServerContext, globalSettings } from '../utils'; import { NextServer } from '../types'; export const createServerRunnerForAPI = ({ config, -}: NextServer.CreateServerRunnerInput): NextServer.CreateServerRunnerOutput & { +}: NextServer.CreateServerRunnerInput): Omit< + NextServer.CreateServerRunnerOutput, + 'createAuthRouteHandlers' +> & { resourcesConfig: ResourcesConfig; } => { const amplifyConfig = parseAmplifyConfig(config); @@ -17,6 +20,7 @@ export const createServerRunnerForAPI = ({ return { runWithAmplifyServerContext: createRunWithAmplifyServerContext({ config: amplifyConfig, + globalSettings, }), resourcesConfig: amplifyConfig, }; diff --git a/packages/adapter-nextjs/src/api/generateServerClient.ts b/packages/adapter-nextjs/src/api/generateServerClient.ts index 45c3e2bf278..88d8f830b23 100644 --- a/packages/adapter-nextjs/src/api/generateServerClient.ts +++ b/packages/adapter-nextjs/src/api/generateServerClient.ts @@ -1,21 +1,19 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { generateClientWithAmplifyInstance } from '@aws-amplify/api/internals'; -import { generateClient } from 'aws-amplify/api/server'; -import { - AmplifyServerContextError, - getAmplifyServerContext, -} from '@aws-amplify/core/internals/adapter-core'; import { + CommonPublicClientOptions, + DefaultCommonClientOptions, V6ClientSSRCookies, V6ClientSSRRequest, -} from '@aws-amplify/api-graphql'; + generateClientWithAmplifyInstance, +} from 'aws-amplify/api/internals'; +import { generateClient } from 'aws-amplify/api/server'; import { - CommonPublicClientOptions, - DefaultCommonClientOptions, -} from '@aws-amplify/api-graphql/internals'; -import { parseAmplifyConfig } from '@aws-amplify/core/internals/utils'; + AmplifyServerContextError, + getAmplifyServerContext, +} from 'aws-amplify/adapter-core/internals'; +import { parseAmplifyConfig } from 'aws-amplify/utils'; import { NextServer } from '../types'; diff --git a/packages/adapter-nextjs/src/api/index.ts b/packages/adapter-nextjs/src/api/index.ts index 6ac4837d7c3..3e44406aa17 100644 --- a/packages/adapter-nextjs/src/api/index.ts +++ b/packages/adapter-nextjs/src/api/index.ts @@ -4,7 +4,7 @@ import { V6ClientSSRCookies, V6ClientSSRRequest, -} from '@aws-amplify/api-graphql'; +} from 'aws-amplify/api/internals'; export { generateServerClientUsingReqRes, diff --git a/packages/adapter-nextjs/src/auth/constant.ts b/packages/adapter-nextjs/src/auth/constant.ts new file mode 100644 index 00000000000..296e7d1b5f8 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/constant.ts @@ -0,0 +1,51 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { SupportedRoutePaths } from './types'; + +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'; + +// The 5 minutes is from the Cognito Social Identity Provider settings, see: +// https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-social-idp.html +export const AUTH_FLOW_PROOF_MAX_AGE = 5 * 60; + +// -1 to remove the cookie immediately (0 ==> session cookie as observed) +export const REMOVE_COOKIE_MAX_AGE = -1; + +// With server-side auth flow, we don't support the less secure implicit flow +export const OAUTH_GRANT_TYPE = 'authorization_code'; + +export const SIGN_IN_TIMEOUT_ERROR_CODE = 'timeout'; +export const SIGN_IN_TIMEOUT_ERROR_MESSAGE = + 'Sign in has to be completed within 5 minutes.'; +export const DEFAULT_SERVER_SIDE_AUTH_SET_COOKIE_OPTIONS = { + sameSite: 'strict' as const, +}; +export const ENFORCED_SERVER_SIDE_AUTH_SET_COOKIE_OPTIONS = { + httpOnly: true, +}; + +export const SERVER_AUTH_ALLOWED_AMPLIFY_AUTH_KEY_SUFFIX = [ + '.accessToken', + '.idToken', + '.refreshToken', + '.LastAuthUser', +]; diff --git a/packages/adapter-nextjs/src/auth/createAuthRouteHandlersFactory.ts b/packages/adapter-nextjs/src/auth/createAuthRouteHandlersFactory.ts new file mode 100644 index 00000000000..46e95cbdc83 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/createAuthRouteHandlersFactory.ts @@ -0,0 +1,137 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { NextRequest } from 'next/server'; +import { + AmplifyServerContextError, + CookieStorage, + OAuthConfig, + assertOAuthConfig, + assertTokenProviderConfig, +} from 'aws-amplify/adapter-core/internals'; +import { NextApiRequest, NextApiResponse } from 'next'; + +import { + AuthRoutesHandlerContext, + CreateAuthRouteHandlersFactoryInput, + CreateAuthRoutesHandlersInput, + InternalCreateAuthRouteHandlers, +} from './types'; +import { + isAuthRoutesHandlersContext, + isNextApiRequest, + isNextApiResponse, + isNextRequest, + isValidOrigin, +} from './utils'; +import { handleAuthApiRouteRequestForAppRouter } from './handleAuthApiRouteRequestForAppRouter'; +import { handleAuthApiRouteRequestForPagesRouter } from './handleAuthApiRouteRequestForPagesRouter'; + +export const createAuthRouteHandlersFactory = ({ + config: resourcesConfig, + amplifyAppOrigin, + runWithAmplifyServerContext, + globalSettings, +}: CreateAuthRouteHandlersFactoryInput): InternalCreateAuthRouteHandlers => { + const handleRequest = async ({ + request, + contextOrResponse, + handlerInput, + userPoolClientId, + oAuthConfig, + setCookieOptions, + origin, + }: { + request: NextRequest | NextApiRequest; + contextOrResponse: AuthRoutesHandlerContext | NextApiResponse; + handlerInput: CreateAuthRoutesHandlersInput; + userPoolClientId: string; + oAuthConfig: OAuthConfig; + setCookieOptions: CookieStorage.SetCookieOptions; + origin: string; + }): Promise => { + if (isNextApiRequest(request) && isNextApiResponse(contextOrResponse)) { + // 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, + runWithAmplifyServerContext, + }); + + // In the Pages Router, the final response is handled by contextOrResponse + return; + } + + if ( + isNextRequest(request) && + isAuthRoutesHandlersContext(contextOrResponse) + ) { + // In the App Router, the final response is constructed and returned + return handleAuthApiRouteRequestForAppRouter({ + request, + handlerContext: contextOrResponse, + handlerInput, + userPoolClientId, + oAuthConfig, + setCookieOptions, + origin, + runWithAmplifyServerContext, + }); + } + + // this should not be happening + throw new Error( + 'Invalid request and context/response combination. The request cannot be handled.', + ); + }; + + return (createAuthRoutesHandlersInput = {}) => { + // origin validation should happen when createAuthRouteHandlers is being called to create + // Auth API routes. + if (!amplifyAppOrigin) { + throw new AmplifyServerContextError({ + message: 'Could not find the AMPLIFY_APP_ORIGIN environment variable.', + recoverySuggestion: + 'Add the AMPLIFY_APP_ORIGIN environment variable to the `.env` file of your Next.js project.', + }); + } + + if (!isValidOrigin(amplifyAppOrigin)) { + throw new AmplifyServerContextError({ + message: + 'AMPLIFY_APP_ORIGIN environment variable contains an invalid origin string.', + recoverySuggestion: + 'Ensure the AMPLIFY_APP_ORIGIN environment variable is a valid origin string.', + }); + } + + // OAuth config validation should happen when createAuthRouteHandlers is being called to create + // Auth API routes. + assertTokenProviderConfig(resourcesConfig.Auth?.Cognito); + assertOAuthConfig(resourcesConfig.Auth.Cognito); + + const { userPoolClientId } = resourcesConfig.Auth.Cognito; + const { oauth: oAuthConfig } = resourcesConfig.Auth.Cognito.loginWith; + const setCookieOptions = globalSettings.getRuntimeOptions().cookies ?? {}; + + // The call-site of this returned function is the Next.js API route file + return (request, contextOrResponse) => + handleRequest({ + request, + contextOrResponse, + handlerInput: createAuthRoutesHandlersInput, + userPoolClientId, + oAuthConfig, + setCookieOptions, + origin: amplifyAppOrigin, + }); + }; +}; diff --git a/packages/adapter-nextjs/src/auth/handleAuthApiRouteRequestForAppRouter.ts b/packages/adapter-nextjs/src/auth/handleAuthApiRouteRequestForAppRouter.ts new file mode 100644 index 00000000000..dc96199a9d7 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/handleAuthApiRouteRequestForAppRouter.ts @@ -0,0 +1,124 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { HandleAuthApiRouteRequestForAppRouter } from './types'; +import { + getRedirectOrDefault, + hasActiveUserSessionWithAppRouter, + isSupportedAuthApiRoutePath, +} from './utils'; +import { + handleSignInCallbackRequest, + handleSignInSignUpRequest, + handleSignOutCallbackRequest, + handleSignOutRequest, +} from './handlers'; + +export const handleAuthApiRouteRequestForAppRouter: HandleAuthApiRouteRequestForAppRouter = + async ({ + request, + handlerContext, + handlerInput, + userPoolClientId, + oAuthConfig, + origin, + setCookieOptions, + runWithAmplifyServerContext, + }) => { + if (request.method !== 'GET') { + return new Response(null, { status: 405 }); + } + + const { slug } = await handlerContext.params; + // don't support [...slug] here + if (slug === undefined || Array.isArray(slug)) { + return new Response(null, { status: 400 }); + } + + if (!isSupportedAuthApiRoutePath(slug)) { + return new Response(null, { status: 404 }); + } + + switch (slug) { + case 'sign-up': { + const hasActiveUserSession = await hasActiveUserSessionWithAppRouter({ + request, + runWithAmplifyServerContext, + }); + + if (hasActiveUserSession) { + return new Response(null, { + status: 302, + headers: new Headers({ + Location: getRedirectOrDefault( + handlerInput.redirectOnSignInComplete, + ), + }), + }); + } + + return handleSignInSignUpRequest({ + request, + userPoolClientId, + oAuthConfig, + customState: handlerInput.customState, + origin, + setCookieOptions, + type: 'signUp', + }); + } + case 'sign-in': { + const hasActiveUserSession = await hasActiveUserSessionWithAppRouter({ + request, + runWithAmplifyServerContext, + }); + + if (hasActiveUserSession) { + return new Response(null, { + status: 302, + headers: new Headers({ + Location: getRedirectOrDefault( + handlerInput.redirectOnSignInComplete, + ), + }), + }); + } + + 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': + 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 new file mode 100644 index 00000000000..014f68721bc --- /dev/null +++ b/packages/adapter-nextjs/src/auth/handleAuthApiRouteRequestForPagesRouter.ts @@ -0,0 +1,146 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { HandleAuthApiRouteRequestForPagesRouter } from './types'; +import { + getRedirectOrDefault, + hasActiveUserSessionWithPagesRouter, + isSupportedAuthApiRoutePath, +} from './utils'; +import { + handleSignInCallbackRequestForPagesRouter, + handleSignInSignUpRequestForPagesRouter, + handleSignOutCallbackRequestForPagesRouter, + handleSignOutRequestForPagesRouter, +} from './handlers'; + +export const handleAuthApiRouteRequestForPagesRouter: HandleAuthApiRouteRequestForPagesRouter = + async ({ + request, + response, + userPoolClientId, + oAuthConfig, + handlerInput, + origin, + setCookieOptions, + runWithAmplifyServerContext, + }) => { + if (request.method !== 'GET') { + response.status(405).end(); + + return; + } + + const { slug } = request.query; + // don't support [...slug] here + if (slug === undefined || Array.isArray(slug)) { + response.status(400).end(); + + return; + } + + if (!isSupportedAuthApiRoutePath(slug)) { + response.status(404).end(); + + return; + } + + switch (slug) { + case 'sign-up': { + const hasActiveUserSession = await hasActiveUserSessionWithPagesRouter({ + request, + response, + runWithAmplifyServerContext, + }); + + if (hasActiveUserSession) { + response.redirect( + 302, + getRedirectOrDefault(handlerInput.redirectOnSignInComplete), + ); + + return; + } + + handleSignInSignUpRequestForPagesRouter({ + request, + response, + userPoolClientId, + oAuthConfig, + customState: handlerInput.customState, + origin, + setCookieOptions, + type: 'signUp', + }); + break; + } + case 'sign-in': { + const hasActiveUserSession = await hasActiveUserSessionWithPagesRouter({ + request, + response, + runWithAmplifyServerContext, + }); + + if (hasActiveUserSession) { + response.redirect( + 302, + getRedirectOrDefault(handlerInput.redirectOnSignInComplete), + ); + + return; + } + + 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': + // 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..91c1ab27af0 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/handlers/handleSignInCallbackRequest.ts @@ -0,0 +1,127 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + PKCE_COOKIE_NAME, + SIGN_IN_TIMEOUT_ERROR_CODE, + SIGN_IN_TIMEOUT_ERROR_MESSAGE, + STATE_COOKIE_NAME, +} from '../constant'; +import { + appendSetCookieHeaders, + createAuthFlowProofCookiesRemoveOptions, + createErrorSearchParamsString, + createOnSignInCompleteRedirectIntermediate, + createSignInFlowProofCookies, + createTokenCookies, + createTokenCookiesSetOptions, + exchangeAuthNTokens, + getCookieValuesFromRequest, + getRedirectOrDefault, + parseSignInCallbackUrl, + resolveRedirectSignInUrl, +} from '../utils'; + +import { HandleSignInCallbackRequest } from './types'; + +export const handleSignInCallbackRequest: HandleSignInCallbackRequest = async ({ + request, + handlerInput, + userPoolClientId, + oAuthConfig, + setCookieOptions, + origin, +}) => { + const { code, state, error, errorDescription } = parseSignInCallbackUrl( + request.url, + ); + + if (errorDescription || error) { + const searchParamsString = createErrorSearchParamsString({ + error, + errorDescription, + }); + + return new Response(null, { + status: 302, + headers: new Headers({ + location: `${getRedirectOrDefault(handlerInput.redirectOnSignOutComplete)}?${searchParamsString}`, + }), + }); + } + + 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]); + + // The state and pkce cookies are removed from cookie store after 5 minutes + if (!clientState || !clientPkce) { + const searchParamsString = createErrorSearchParamsString({ + error: SIGN_IN_TIMEOUT_ERROR_CODE, + errorDescription: SIGN_IN_TIMEOUT_ERROR_MESSAGE, + }); + + return new Response(null, { + status: 302, + headers: new Headers({ + location: `${getRedirectOrDefault(handlerInput.redirectOnSignOutComplete)}?${searchParamsString}`, + }), + }); + } + + // Most likely the cookie has been tampered + if (clientState !== state) { + 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( + createOnSignInCompleteRedirectIntermediate({ + redirectOnSignInComplete: getRedirectOrDefault( + 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..15685032500 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/handlers/handleSignInCallbackRequestForPagesRouter.ts @@ -0,0 +1,132 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + PKCE_COOKIE_NAME, + SIGN_IN_TIMEOUT_ERROR_CODE, + SIGN_IN_TIMEOUT_ERROR_MESSAGE, + STATE_COOKIE_NAME, +} from '../constant'; +import { + appendSetCookieHeadersToNextApiResponse, + createAuthFlowProofCookiesRemoveOptions, + createErrorSearchParamsString, + createOnSignInCompleteRedirectIntermediate, + createSignInFlowProofCookies, + createTokenCookies, + createTokenCookiesSetOptions, + exchangeAuthNTokens, + getCookieValuesFromNextApiRequest, + getRedirectOrDefault, + parseSignInCallbackUrl, + resolveRedirectSignInUrl, +} from '../utils'; + +import { HandleSignInCallbackRequestForPagesRouter } from './types'; + +export const handleSignInCallbackRequestForPagesRouter: HandleSignInCallbackRequestForPagesRouter = + async ({ + request, + response, + handlerInput, + userPoolClientId, + oAuthConfig, + setCookieOptions, + origin, + }) => { + const { code, state, error, errorDescription } = parseSignInCallbackUrl( + request.url!, + ); + + if (errorDescription || error) { + const searchParamsString = createErrorSearchParamsString({ + error, + errorDescription, + }); + response.redirect( + 302, + `${getRedirectOrDefault(handlerInput.redirectOnSignOutComplete)}?${searchParamsString}`, + ); + + return; + } + + 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, + ]); + + // The state and pkce cookies are removed from cookie store after 5 minutes + if (!clientState || !clientPkce) { + const searchParamsString = createErrorSearchParamsString({ + error: SIGN_IN_TIMEOUT_ERROR_CODE, + errorDescription: SIGN_IN_TIMEOUT_ERROR_MESSAGE, + }); + response.redirect( + 302, + `${getRedirectOrDefault(handlerInput.redirectOnSignOutComplete)}?${searchParamsString}`, + ); + + return; + } + + // Most likely the cookie has been tampered + if (clientState !== state) { + 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( + createOnSignInCompleteRedirectIntermediate({ + redirectOnSignInComplete: getRedirectOrDefault( + 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..c17f9c7a61d --- /dev/null +++ b/packages/adapter-nextjs/src/auth/handlers/handleSignInSignUpRequest.ts @@ -0,0 +1,56 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + appendSetCookieHeaders, + createAuthFlowProofCookiesSetOptions, + createAuthFlowProofs, + createAuthorizeEndpoint, + createSignInFlowProofCookies, + createSignUpEndpoint, + createUrlSearchParamsForSignInSignUp, + isSSLOrigin, +} 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, { + secure: isSSLOrigin(origin), + }), + ); + + 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..e743ac55c99 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/handlers/handleSignInSignUpRequestForPagesRouter.ts @@ -0,0 +1,52 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + appendSetCookieHeadersToNextApiResponse, + createAuthFlowProofCookiesSetOptions, + createAuthFlowProofs, + createAuthorizeEndpoint, + createSignInFlowProofCookies, + createSignUpEndpoint, + createUrlSearchParamsForSignInSignUp, + isSSLOrigin, +} 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, { + secure: isSSLOrigin(origin), + }), + ); + + const redirectUrl = + type === 'signIn' + ? createAuthorizeEndpoint(oAuthConfig.domain, redirectUrlSearchParams) + : createSignUpEndpoint(oAuthConfig.domain, redirectUrlSearchParams); + + response.redirect(302, redirectUrl); + }; 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..f69369ac812 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/handlers/handleSignOutCallbackRequest.ts @@ -0,0 +1,103 @@ +// 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, + getRedirectOrDefault, + 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: getRedirectOrDefault( + 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: getRedirectOrDefault( + 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([ + authCookiesKeys.accessToken, + authCookiesKeys.idToken, + authCookiesKeys.refreshToken, + lastAuthUserCookieName, + IS_SIGNING_OUT_COOKIE_NAME, + ]), + ], + createTokenCookiesRemoveOptions(setCookieOptions), + ); + + headers.set( + 'Location', + getRedirectOrDefault(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..c4298dbbca3 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/handlers/handleSignOutCallbackRequestForPagesRouter.ts @@ -0,0 +1,101 @@ +// 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, + getRedirectOrDefault, + 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, + getRedirectOrDefault(handlerInput.redirectOnSignOutComplete), + ); + + return; + } + + const authCookiesKeys = createKeysForAuthStorage( + AUTH_KEY_PREFIX, + `${userPoolClientId}.${username}`, + ); + + const { [authCookiesKeys.refreshToken]: refreshToken } = + getCookieValuesFromNextApiRequest(request, [ + authCookiesKeys.refreshToken, + ]); + + if (!refreshToken) { + response.redirect( + 302, + getRedirectOrDefault(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([ + authCookiesKeys.accessToken, + authCookiesKeys.idToken, + authCookiesKeys.refreshToken, + lastAuthUserCookieName, + IS_SIGNING_OUT_COOKIE_NAME, + ]), + ], + createTokenCookiesRemoveOptions(setCookieOptions), + ); + + response.redirect( + 302, + getRedirectOrDefault(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..177e5df397c --- /dev/null +++ b/packages/adapter-nextjs/src/auth/handlers/handleSignOutRequest.ts @@ -0,0 +1,43 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + appendSetCookieHeaders, + createAuthFlowProofCookiesSetOptions, + createLogoutEndpoint, + createSignOutFlowProofCookies, + isSSLOrigin, + 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, { + secure: isSSLOrigin(origin), + }), + ); + + 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..5239d90d728 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/handlers/handleSignOutRequestForPagesRouter.ts @@ -0,0 +1,34 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + appendSetCookieHeadersToNextApiResponse, + createAuthFlowProofCookiesSetOptions, + createLogoutEndpoint, + createSignOutFlowProofCookies, + isSSLOrigin, + 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, { + secure: isSSLOrigin(origin), + }), + ); + + response.redirect( + 302, + createLogoutEndpoint(oAuthConfig.domain, urlSearchParams), + ); + }; 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..4a835bdde11 --- /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/adapter-core/internals'; +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/index.ts b/packages/adapter-nextjs/src/auth/index.ts new file mode 100644 index 00000000000..2b2ded295d8 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/index.ts @@ -0,0 +1,4 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { createAuthRouteHandlersFactory } from './createAuthRouteHandlersFactory'; diff --git a/packages/adapter-nextjs/src/auth/types.ts b/packages/adapter-nextjs/src/auth/types.ts new file mode 100644 index 00000000000..a6e4e685e46 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/types.ts @@ -0,0 +1,143 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { ResourcesConfig } from 'aws-amplify'; +import { NextRequest } from 'next/server'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { OAuthConfig } from 'aws-amplify/adapter-core/internals'; +import { CookieStorage } from 'aws-amplify/adapter-core'; + +import { NextServer } from '../types'; + +export type SupportedRoutePaths = + | 'sign-in' + | 'sign-up' + | 'sign-in-callback' + | 'sign-out' + | 'sign-out-callback'; + +/** + * The Auth API routes can be handled by the {@link AuthRouteHandlers}. + */ +export interface AuthRouteHandlerParams { + slug: string | string[] | undefined; +} + +export interface AuthRoutesHandlerContext { + // In Next.js 15 params is an async API, so it can be a promise. + params: AuthRouteHandlerParams | Promise; +} + +/** + * The handler function for handling the GET requests sent to the Auth API routes. + * Only `GET` method gets handled, otherwise it rejects. + * + * @param request - `request` can be the following: + * 1. a `NextRequest` when the handler is used in the App Router of Next.js + * 2. a `NextApiRequest` when the handler is used in the Pages Router of Next.js + * @param contextOrRequest - `contextOrRequest` can be the following: + * 1. a {@link AuthRoutesHandlerContext} when the handler is used in the App + * Router of Next.js + * 2. a `NextApiResponse` when the handler is used in the Pages Router of Next.js + * + * @returns a `Promise` of `Response` when used in the App Router of Next.js, or + * returns `undefined` when used in the Pages Router of Next.js. + */ +export type AuthRouteHandlers = ( + request: NextRequest | NextApiRequest, + contextOrRequest: AuthRoutesHandlerContext | NextApiResponse, +) => // Next.js API route handler is required to be an async function. +Promise; + +export interface CreateAuthRoutesHandlersInput { + /** + * The customer's custom state added as the redirect URL query parameter + * for preventing CSRF attacks + */ + customState?: string; + /** + * The app route redirect to when a sign-in flow completes. Defaults to the + * root of the app if not provided. + */ + redirectOnSignInComplete?: string; + /** + * The app route redirect to when a sign-out flow completes. Defaults to the + * root of the app if not provided. + */ + redirectOnSignOutComplete?: string; +} + +export type InternalCreateAuthRouteHandlers = ( + input?: CreateAuthRoutesHandlersInput, +) => AuthRouteHandlers; + +export type CreateAuthRouteHandlers = ( + input?: CreateAuthRoutesHandlersInput, +) => + | AuthRouteHandlers + // Forcing the handler interface to be any to ensure the single handler can + // work in both App Router `routes.ts` and Pages router. The former has a + // restrict handler function interface type check. The parameters types are + // properly typed internally, and runtime validation is place. + | any; + +export interface CreateAuthRouteHandlersFactoryInput { + config: ResourcesConfig; + amplifyAppOrigin?: string; + globalSettings: NextServer.GlobalSettings; + runWithAmplifyServerContext: NextServer.RunOperationWithContext; +} + +export type CreateOAuthRouteHandlersFactory = ( + input: CreateAuthRouteHandlersFactoryInput, +) => CreateAuthRouteHandlers; + +interface HandleAuthApiRouteRequestInputBase { + handlerInput: CreateAuthRoutesHandlersInput; + userPoolClientId: string; + oAuthConfig: OAuthConfig; + setCookieOptions: CookieStorage.SetCookieOptions; + origin: string; +} + +interface HandleAuthApiRouteRequestForAppRouterInput + extends HandleAuthApiRouteRequestInputBase { + request: NextRequest; + handlerContext: AuthRoutesHandlerContext; + runWithAmplifyServerContext: NextServer.RunOperationWithContext; +} + +interface HandleAuthApiRouteRequestForPagesRouterInput + extends HandleAuthApiRouteRequestInputBase { + request: NextApiRequest; + response: NextApiResponse; + runWithAmplifyServerContext: NextServer.RunOperationWithContext; +} + +export type HandleAuthApiRouteRequestForAppRouter = ( + input: HandleAuthApiRouteRequestForAppRouterInput, +) => Promise; + +export type HandleAuthApiRouteRequestForPagesRouter = ( + input: HandleAuthApiRouteRequestForPagesRouterInput, +) => 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..fd28daf52d8 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/authFlowProofCookies.ts @@ -0,0 +1,56 @@ +// 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_MAX_AGE, + IS_SIGNING_OUT_COOKIE_NAME, + PKCE_COOKIE_NAME, + REMOVE_COOKIE_MAX_AGE, + 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, + overrides?: Pick, +) => ({ + domain: setCookieOptions?.domain, + path: '/', + httpOnly: true, + secure: overrides?.secure ?? true, + sameSite: 'lax' as const, + maxAge: AUTH_FLOW_PROOF_MAX_AGE, +}); + +export const createAuthFlowProofCookiesRemoveOptions = ( + setCookieOptions: CookieStorage.SetCookieOptions, +) => ({ + domain: setCookieOptions?.domain, + path: '/', + maxAge: REMOVE_COOKIE_MAX_AGE, +}); 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..2217d16bb8b --- /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/adapter-core/internals'; + +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..6c534fee862 --- /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/adapter-core/internals'; +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/createErrorSearchParamsString.ts b/packages/adapter-nextjs/src/auth/utils/createErrorSearchParamsString.ts new file mode 100644 index 00000000000..746acc2adc4 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/createErrorSearchParamsString.ts @@ -0,0 +1,22 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const createErrorSearchParamsString = ({ + error, + errorDescription, +}: { + error: string | null; + errorDescription: string | null; +}): string => { + const errorParams = new URLSearchParams(); + + if (error) { + errorParams.set('error', error); + } + + if (errorDescription) { + errorParams.set('error_description', errorDescription); + } + + return errorParams.toString(); +}; diff --git a/packages/adapter-nextjs/src/auth/utils/createOnSignInCompleteRedirectIntermediate.ts b/packages/adapter-nextjs/src/auth/utils/createOnSignInCompleteRedirectIntermediate.ts new file mode 100644 index 00000000000..dea9f18f3da --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/createOnSignInCompleteRedirectIntermediate.ts @@ -0,0 +1,25 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const createOnSignInCompleteRedirectIntermediate = ({ + 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..23b90077e6d --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/createUrlSearchParams.ts @@ -0,0 +1,55 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { OAuthConfig } from 'aws-amplify/adapter-core/internals'; +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 => new URLSearchParams(input); + +export const createUrlSearchParamsForTokenRevocation = (input: { + token: string; + client_id: string; +}): URLSearchParams => new URLSearchParams(input); diff --git a/packages/adapter-nextjs/src/auth/utils/getAccessTokenUsername.ts b/packages/adapter-nextjs/src/auth/utils/getAccessTokenUsername.ts new file mode 100644 index 00000000000..8a77c210cff --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/getAccessTokenUsername.ts @@ -0,0 +1,7 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { decodeJWT } from 'aws-amplify/adapter-core/internals'; + +export const getAccessTokenUsername = (accessToken: string): string => + decodeJWT(accessToken).payload.username as string; 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..c625e392797 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/getCookieValuesFromNextApiRequest.ts @@ -0,0 +1,22 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { NextApiRequest } from 'next'; + +export const getCookieValuesFromNextApiRequest = < + CookieNames extends string[], + R = { + [key in CookieNames[number]]?: string | undefined; + }, +>( + request: NextApiRequest, + cookieNames: CookieNames, +): R => { + const result: Record = {}; + + for (const cookieName of cookieNames) { + result[cookieName] = request.cookies[cookieName]; + } + + return result as R; +}; 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..44a69e6c97b --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/getCookieValuesFromRequest.ts @@ -0,0 +1,33 @@ +// 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; + }, {}); + + 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/getRedirectOrDefault.ts b/packages/adapter-nextjs/src/auth/utils/getRedirectOrDefault.ts new file mode 100644 index 00000000000..0be30a78601 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/getRedirectOrDefault.ts @@ -0,0 +1,5 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const getRedirectOrDefault = (redirect: string | undefined): string => + redirect || '/'; 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..b368fa409f5 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/getSearchParamValueFromUrl.ts @@ -0,0 +1,16 @@ +// 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 => { + 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/hasActiveUserSession.ts b/packages/adapter-nextjs/src/auth/utils/hasActiveUserSession.ts new file mode 100644 index 00000000000..b9e6bd0f8cf --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/hasActiveUserSession.ts @@ -0,0 +1,56 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { NextRequest } from 'next/server'; +import { getCurrentUser } from 'aws-amplify/auth/server'; +import { NextApiRequest, NextApiResponse } from 'next'; + +import { NextServer } from '../../types'; + +export const hasActiveUserSessionWithAppRouter = async ({ + request, + runWithAmplifyServerContext, +}: { + request: NextRequest; + runWithAmplifyServerContext: NextServer.RunOperationWithContext; +}): Promise => { + const dummyResponse = new Response(); + + try { + await runWithAmplifyServerContext({ + nextServerContext: { request, response: dummyResponse }, + operation(contextSpec) { + return getCurrentUser(contextSpec); + }, + }); + + return true; + } catch (_) { + // `getCurrentUser()` throws if there is no valid token + return false; + } +}; + +export const hasActiveUserSessionWithPagesRouter = async ({ + request, + response, + runWithAmplifyServerContext, +}: { + request: NextApiRequest; + response: NextApiResponse; + runWithAmplifyServerContext: NextServer.RunOperationWithContext; +}): Promise => { + try { + await runWithAmplifyServerContext({ + nextServerContext: { request, response }, + operation(contextSpec) { + return getCurrentUser(contextSpec); + }, + }); + + return true; + } catch (_) { + // `getCurrentUser()` throws if there is no valid token + return false; + } +}; diff --git a/packages/adapter-nextjs/src/auth/utils/index.ts b/packages/adapter-nextjs/src/auth/utils/index.ts new file mode 100644 index 00000000000..55c64d0563c --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/index.ts @@ -0,0 +1,53 @@ +// 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 { createErrorSearchParamsString } from './createErrorSearchParamsString'; +export { createOnSignInCompleteRedirectIntermediate } from './createOnSignInCompleteRedirectIntermediate'; +export { createUrlSearchParamsForSignInSignUp } from './createUrlSearchParams'; +export { + createAuthorizeEndpoint, + createSignUpEndpoint, + createLogoutEndpoint, + createTokenEndpoint, + createRevokeEndpoint, +} from './cognitoHostedUIEndpoints'; +export { getAccessTokenUsername } from './getAccessTokenUsername'; +export { getCookieValuesFromNextApiRequest } from './getCookieValuesFromNextApiRequest'; +export { getCookieValuesFromRequest } from './getCookieValuesFromRequest'; +export { getRedirectOrDefault } from './getRedirectOrDefault'; +export { + isAuthRoutesHandlersContext, + isNextApiRequest, + isNextApiResponse, + isNextRequest, +} from './predicates'; +export { + hasActiveUserSessionWithAppRouter, + hasActiveUserSessionWithPagesRouter, +} from './hasActiveUserSession'; +export { isSupportedAuthApiRoutePath } from './isSupportedAuthApiRoutePath'; +export { isValidOrigin, isSSLOrigin } from './origin'; +export { parseSignInCallbackUrl } from './parseSignInCallbackUrl'; +export { resolveIdentityProviderFromUrl } from './resolveIdentityProviderFromUrl'; +export { + resolveRedirectSignInUrl, + resolveRedirectSignOutUrl, +} from './resolveRedirectUrl'; + +export { + createTokenCookies, + createTokenRemoveCookies, + createTokenCookiesSetOptions, + createTokenCookiesRemoveOptions, + isServerSideAuthAllowedCookie, +} from './tokenCookies'; diff --git a/packages/adapter-nextjs/src/auth/utils/isSupportedAuthApiRoutePath.ts b/packages/adapter-nextjs/src/auth/utils/isSupportedAuthApiRoutePath.ts new file mode 100644 index 00000000000..06ccfee1a93 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/isSupportedAuthApiRoutePath.ts @@ -0,0 +1,11 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { SUPPORTED_ROUTES } from '../constant'; +import { SupportedRoutePaths } from '../types'; + +export function isSupportedAuthApiRoutePath( + path?: string, +): path is SupportedRoutePaths { + return SUPPORTED_ROUTES.includes(path as SupportedRoutePaths); +} diff --git a/packages/adapter-nextjs/src/auth/utils/origin.ts b/packages/adapter-nextjs/src/auth/utils/origin.ts new file mode 100644 index 00000000000..7a9f0646927 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/origin.ts @@ -0,0 +1,52 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// a regular expression that validates the origin string to be any valid origin, and allowing local development localhost +const originRegex = + /^(http:\/\/localhost(:\d{1,5})?)|(https?:\/\/[a-z0-9-]+(\.[a-z0-9-]+)*(:\d{1,5})?)$/; + +export const isValidOrigin = (origin: string | undefined): boolean => { + const url = createUrlObjectOrUndefined(origin); + + if (!url) { + return false; + } + + if ( + url.protocol === 'http:' && + url.hostname !== 'localhost' && + url.hostname !== '127.0.0.1' + ) { + console.warn( + 'HTTP origin detected. This is insecure and should only be used for local development.', + ); + } + + return url.protocol === 'http:' || url.protocol === 'https:'; +}; + +export const isSSLOrigin = (origin: string | undefined): boolean => { + const url = createUrlObjectOrUndefined(origin); + + if (!url) { + return false; + } + + return url.protocol === 'https:'; +}; + +const createUrlObjectOrUndefined = ( + url: string | undefined, +): URL | undefined => { + if (!url) { + return undefined; + } + + // we don't allow format such as `https://localhost:` (without the port number) which is valid in URL constructor + if (!originRegex.test(url)) { + return undefined; + } + + // the `originRegex` ensured a string that can be parsed by URL constructor + return new URL(url); +}; diff --git a/packages/adapter-nextjs/src/auth/utils/parseSignInCallbackUrl.ts b/packages/adapter-nextjs/src/auth/utils/parseSignInCallbackUrl.ts new file mode 100644 index 00000000000..f3918f22157 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/parseSignInCallbackUrl.ts @@ -0,0 +1,18 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getSearchParamValueFromUrl } from './getSearchParamValueFromUrl'; + +export const parseSignInCallbackUrl = ( + urlStr: string, +): { + code: string | null; + state: string | null; + error: string | null; + errorDescription: string | null; +} => ({ + state: getSearchParamValueFromUrl(urlStr, 'state'), + code: getSearchParamValueFromUrl(urlStr, 'code'), + error: getSearchParamValueFromUrl(urlStr, 'error'), + errorDescription: getSearchParamValueFromUrl(urlStr, 'error_description'), +}); diff --git a/packages/adapter-nextjs/src/auth/utils/predicates.ts b/packages/adapter-nextjs/src/auth/utils/predicates.ts new file mode 100644 index 00000000000..b7a612752db --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/predicates.ts @@ -0,0 +1,46 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { NextRequest } from 'next/server'; +import { NextApiRequest, NextApiResponse } from 'next'; + +import { AuthRoutesHandlerContext } from '../types'; + +// NextRequest is the 1st parameter type for the API route handlers in the App Router +export function isNextRequest(request: object): request is NextRequest { + // NextRequest extends the Web Request API with additional convenience methods. + // Details: https://nextjs.org/docs/app/api-reference/functions/next-request#nexturl + return request instanceof Request && 'nextUrl' in request; +} + +// AuthRoutesHandlersContext is the 2nd parameter type for the API route handlers in the App Router +export function isAuthRoutesHandlersContext( + context: object, +): context is AuthRoutesHandlerContext { + return ( + 'params' in context && + context.params !== undefined && + context.params !== null + ); +} + +// NextApiRequest is the 1st parameter type for the API route handlers in the Pages Router +export function isNextApiRequest(request: object): request is NextApiRequest { + // Can't use `IncomingMessage` to validate the request is an instance of `NextApiRequest` + // as `import from 'http'` breaks the Next.js build. + // The `query` property is a convenience method added to the underlying `IncomingMessage`. + return ( + 'query' in request && + Object.prototype.toString.call(request.query) === '[object Object]' + ); +} + +// NextApiResponse is the 2nd parameter type for the API route handlers in the Pages Router +export function isNextApiResponse( + response: object, +): response is NextApiResponse { + // Can't use `ServerResponse` to validate the request is an instance of `NextApiResponse` + // as `import from 'http'` breaks the Next.js build. + // The `redirect` method is a convenience method added to the underlying `ServerResponse`. + return 'redirect' in response && typeof response.redirect === 'function'; +} 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..ca9ca690ca3 --- /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).toLowerCase()}`; 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..7f7dc29547c --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/resolveRedirectUrl.ts @@ -0,0 +1,43 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + AmplifyServerContextError, + OAuthConfig, +} from 'aws-amplify/adapter-core/internals'; + +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..70ab5ee2981 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/utils/tokenCookies.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, + CookieStorage, + DEFAULT_AUTH_TOKEN_COOKIES_MAX_AGE, + createKeysForAuthStorage, +} from 'aws-amplify/adapter-core'; + +import { OAuthTokenResponsePayload } from '../types'; +import { + REMOVE_COOKIE_MAX_AGE, + SERVER_AUTH_ALLOWED_AMPLIFY_AUTH_KEY_SUFFIX, +} from '../constant'; + +import { getAccessTokenUsername } from './getAccessTokenUsername'; + +export const createTokenCookies = ({ + tokensPayload, + userPoolClientId, +}: { + tokensPayload: OAuthTokenResponsePayload; + userPoolClientId: string; +}) => { + const { access_token, id_token, refresh_token } = tokensPayload; + const username = getAccessTokenUsername(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: `${AUTH_KEY_PREFIX}.${userPoolClientId}.LastAuthUser`, + value: username, + }, + ]; +}; + +export const createTokenRemoveCookies = (keys: string[]) => + keys.map(key => ({ name: key, value: '' })); + +export const createTokenCookiesSetOptions = ( + { domain, sameSite, expires, maxAge }: CookieStorage.SetCookieOptions, + overrides?: Pick, +) => { + const result = { + domain, + path: '/', + httpOnly: true, + secure: overrides?.secure ?? true, + sameSite: sameSite ?? 'strict', + expires, + maxAge, + }; + + // when expires and maxAge both are not specified, we set a default maxAge + if (!result.expires && !result.maxAge) { + result.maxAge = DEFAULT_AUTH_TOKEN_COOKIES_MAX_AGE; + } + + return result; +}; + +export const createTokenCookiesRemoveOptions = ( + setCookieOptions: CookieStorage.SetCookieOptions, +) => ({ + domain: setCookieOptions?.domain, + path: '/', + maxAge: REMOVE_COOKIE_MAX_AGE, // Expire immediately (remove the cookie) +}); + +export const isServerSideAuthAllowedCookie = (cookieName: string) => + SERVER_AUTH_ALLOWED_AMPLIFY_AUTH_KEY_SUFFIX.some(suffix => + cookieName.endsWith(suffix), + ); diff --git a/packages/adapter-nextjs/src/createServerRunner.ts b/packages/adapter-nextjs/src/createServerRunner.ts index b5025000d2f..2e604f18580 100644 --- a/packages/adapter-nextjs/src/createServerRunner.ts +++ b/packages/adapter-nextjs/src/createServerRunner.ts @@ -2,12 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 import { ResourcesConfig } from 'aws-amplify'; -import { KeyValueStorageMethodValidator } from '@aws-amplify/core/internals/adapter-core'; -import { parseAmplifyConfig } from '@aws-amplify/core/internals/utils'; +import { KeyValueStorageMethodValidator } from 'aws-amplify/adapter-core/internals'; +import { parseAmplifyConfig } from 'aws-amplify/utils'; -import { createRunWithAmplifyServerContext } from './utils'; +import { createRunWithAmplifyServerContext, globalSettings } from './utils'; import { NextServer } from './types'; import { createTokenValidator } from './utils/createTokenValidator'; +import { createAuthRouteHandlersFactory } from './auth'; +import { isSSLOrigin, isValidOrigin } from './auth/utils'; /** * Creates the `runWithAmplifyServerContext` function to run Amplify server side APIs in an isolated request context. @@ -29,8 +31,19 @@ import { createTokenValidator } from './utils/createTokenValidator'; */ export const createServerRunner: NextServer.CreateServerRunner = ({ config, + runtimeOptions, }) => { const amplifyConfig = parseAmplifyConfig(config); + const amplifyAppOrigin = process.env.AMPLIFY_APP_ORIGIN; + + globalSettings.setRuntimeOptions(runtimeOptions ?? {}); + + if (isValidOrigin(amplifyAppOrigin)) { + globalSettings.setIsSSLOrigin(isSSLOrigin(amplifyAppOrigin)); + + // update the isServerSideAuthEnabled flag of the globalSettings to true + globalSettings.enableServerSideAuth(); + } let tokenValidator: KeyValueStorageMethodValidator | undefined; if (amplifyConfig?.Auth) { @@ -41,10 +54,19 @@ export const createServerRunner: NextServer.CreateServerRunner = ({ }); } + const runWithAmplifyServerContext = createRunWithAmplifyServerContext({ + config: amplifyConfig, + tokenValidator, + globalSettings, + }); + return { - runWithAmplifyServerContext: createRunWithAmplifyServerContext({ + runWithAmplifyServerContext, + createAuthRouteHandlers: createAuthRouteHandlersFactory({ config: amplifyConfig, - tokenValidator, + amplifyAppOrigin, + globalSettings, + runWithAmplifyServerContext, }), }; }; diff --git a/packages/adapter-nextjs/src/types/NextServer.ts b/packages/adapter-nextjs/src/types/NextServer.ts index 5c3d093b795..3bdfb594fb5 100644 --- a/packages/adapter-nextjs/src/types/NextServer.ts +++ b/packages/adapter-nextjs/src/types/NextServer.ts @@ -5,8 +5,13 @@ import { GetServerSidePropsContext as NextGetServerSidePropsContext } from 'next import { NextRequest, NextResponse } from 'next/server.js'; import { cookies } from 'next/headers.js'; import { AmplifyOutputs, LegacyConfig } from 'aws-amplify/adapter-core'; -import { AmplifyServer } from '@aws-amplify/core/internals/adapter-core'; -import { ResourcesConfig } from '@aws-amplify/core'; +import { + AmplifyServer, + CookieStorage, +} from 'aws-amplify/adapter-core/internals'; +import { ResourcesConfig } from 'aws-amplify'; + +import { CreateAuthRouteHandlers } from '../auth/types'; export declare namespace NextServer { /** @@ -73,15 +78,33 @@ export declare namespace NextServer { input: RunWithContextInput, ) => Promise; + export interface CreateServerRunnerRuntimeOptions { + cookies?: Pick< + CookieStorage.SetCookieOptions, + 'domain' | 'expires' | 'sameSite' | 'maxAge' + >; + } + export interface CreateServerRunnerInput { config: ResourcesConfig | LegacyConfig | AmplifyOutputs; + runtimeOptions?: CreateServerRunnerRuntimeOptions; } export interface CreateServerRunnerOutput { runWithAmplifyServerContext: RunOperationWithContext; + createAuthRouteHandlers: CreateAuthRouteHandlers; } export type CreateServerRunner = ( input: CreateServerRunnerInput, ) => CreateServerRunnerOutput; + + export interface GlobalSettings { + isServerSideAuthEnabled(): boolean; + enableServerSideAuth(): void; + setRuntimeOptions(runtimeOptions: CreateServerRunnerRuntimeOptions): void; + getRuntimeOptions(): CreateServerRunnerRuntimeOptions; + setIsSSLOrigin(isSSLOrigin: boolean): void; + isSSLOrigin(): boolean; + } } diff --git a/packages/adapter-nextjs/src/utils/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..67bece4fa2e --- /dev/null +++ b/packages/adapter-nextjs/src/utils/cookie/serializeCookie.ts @@ -0,0 +1,41 @@ +// 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, maxAge } = 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}`); + } + if (maxAge) { + serializedOptions.push(`Max-Age=${maxAge}`); + } + + return serializedOptions.join(';'); +}; diff --git a/packages/adapter-nextjs/src/utils/createCookieStorageAdapterFromNextServerContext.ts b/packages/adapter-nextjs/src/utils/createCookieStorageAdapterFromNextServerContext.ts index c36776d3ad1..05410361b19 100644 --- a/packages/adapter-nextjs/src/utils/createCookieStorageAdapterFromNextServerContext.ts +++ b/packages/adapter-nextjs/src/utils/createCookieStorageAdapterFromNextServerContext.ts @@ -5,14 +5,18 @@ import { NextRequest, NextResponse } from 'next/server.js'; import { AmplifyServerContextError, CookieStorage, -} from '@aws-amplify/core/internals/adapter-core'; +} from 'aws-amplify/adapter-core/internals'; import { NextServer } from '../types'; +import { isServerSideAuthAllowedCookie } from '../auth/utils'; + +import { ensureEncodedForJSCookie, serializeCookie } from './cookie'; export const DATE_IN_THE_PAST = new Date(0); export const createCookieStorageAdapterFromNextServerContext = async ( context: NextServer.Context, + ignoreNonServerSideCookies = false, ): Promise => { const { request: req, response: res } = context as Partial; @@ -28,7 +32,11 @@ export const createCookieStorageAdapterFromNextServerContext = async ( Object.prototype.toString.call(req.cookies) === '[object Object]' && typeof res.setHeader === 'function' ) { - return createCookieStorageAdapterFromGetServerSidePropsContext(req, res); + return createCookieStorageAdapterFromGetServerSidePropsContext( + req, + res, + ignoreNonServerSideCookies, + ); } const { request, response } = context as Partial< @@ -47,11 +55,13 @@ export const createCookieStorageAdapterFromNextServerContext = async ( return createCookieStorageAdapterFromNextRequestAndNextResponse( request, response, + ignoreNonServerSideCookies, ); } else { return createCookieStorageAdapterFromNextRequestAndHttpResponse( request, response, + ignoreNonServerSideCookies, ); } } @@ -61,7 +71,10 @@ export const createCookieStorageAdapterFromNextServerContext = async ( >; if (typeof cookies === 'function') { - return createCookieStorageAdapterFromNextCookies(cookies); + return createCookieStorageAdapterFromNextCookies( + cookies, + ignoreNonServerSideCookies, + ); } // This should not happen normally. @@ -74,6 +87,7 @@ export const createCookieStorageAdapterFromNextServerContext = async ( const createCookieStorageAdapterFromNextRequestAndNextResponse = ( request: NextRequest, response: NextResponse, + ignoreNonServerSideCookies: boolean, ): CookieStorage.Adapter => { const readonlyCookieStore = request.cookies; const mutableCookieStore = response.cookies; @@ -84,9 +98,15 @@ const createCookieStorageAdapterFromNextRequestAndNextResponse = ( }, getAll: readonlyCookieStore.getAll.bind(readonlyCookieStore), set(name, value, options) { + if (shouldIgnoreCookie(ignoreNonServerSideCookies, name)) { + return; + } mutableCookieStore.set(ensureEncodedForJSCookie(name), value, options); }, delete(name) { + if (shouldIgnoreCookie(ignoreNonServerSideCookies, name)) { + return; + } mutableCookieStore.delete(ensureEncodedForJSCookie(name)); }, }; @@ -95,10 +115,12 @@ const createCookieStorageAdapterFromNextRequestAndNextResponse = ( const createCookieStorageAdapterFromNextRequestAndHttpResponse = ( request: NextRequest, response: Response, + ignoreNonServerSideCookies: boolean, ): CookieStorage.Adapter => { const readonlyCookieStore = request.cookies; const mutableCookieStore = createMutableCookieStoreFromHeaders( response.headers, + ignoreNonServerSideCookies, ); return { @@ -112,6 +134,7 @@ const createCookieStorageAdapterFromNextRequestAndHttpResponse = ( const createCookieStorageAdapterFromNextCookies = async ( cookies: NextServer.ServerComponentContext['cookies'], + ignoreNonServerSideCookies: boolean, ): Promise => { const cookieStore = await cookies(); @@ -121,6 +144,10 @@ const createCookieStorageAdapterFromNextCookies = async ( // We have no way to detect which one is returned, so we try to call set and delete // and safely ignore the error if it is thrown. const setFunc: CookieStorage.Adapter['set'] = (name, value, options) => { + if (shouldIgnoreCookie(ignoreNonServerSideCookies, name)) { + return; + } + try { cookieStore.set(ensureEncodedForJSCookie(name), value, options); } catch { @@ -129,6 +156,10 @@ const createCookieStorageAdapterFromNextCookies = async ( }; const deleteFunc: CookieStorage.Adapter['delete'] = name => { + if (shouldIgnoreCookie(ignoreNonServerSideCookies, name)) { + return; + } + try { cookieStore.delete(ensureEncodedForJSCookie(name)); } catch { @@ -149,6 +180,7 @@ const createCookieStorageAdapterFromNextCookies = async ( const createCookieStorageAdapterFromGetServerSidePropsContext = ( request: NextServer.GetServerSidePropsContext['request'], response: NextServer.GetServerSidePropsContext['response'], + ignoreNonServerSideCookies: boolean, ): CookieStorage.Adapter => { const cookiesMap = { ...request.cookies }; const allCookies = Object.entries(cookiesMap).map(([name, value]) => ({ @@ -171,6 +203,9 @@ const createCookieStorageAdapterFromGetServerSidePropsContext = ( return allCookies; }, set(name, value, options) { + if (shouldIgnoreCookie(ignoreNonServerSideCookies, name)) { + return; + } const encodedName = ensureEncodedForJSCookie(name); const existingValues = getExistingSetCookieValues( @@ -190,12 +225,14 @@ const createCookieStorageAdapterFromGetServerSidePropsContext = ( response.appendHeader( 'Set-Cookie', - `${encodedName}=${value};${ - options ? serializeSetCookieOptions(options) : '' - }`, + serializeCookie(encodedName, value, options), ); }, delete(name) { + if (shouldIgnoreCookie(ignoreNonServerSideCookies, name)) { + return; + } + const encodedName = ensureEncodedForJSCookie(name); const setCookieValue = `${encodedName}=;Expires=${DATE_IN_THE_PAST.toUTCString()}`; const existingValues = getExistingSetCookieValues( @@ -215,16 +252,23 @@ const createCookieStorageAdapterFromGetServerSidePropsContext = ( const createMutableCookieStoreFromHeaders = ( headers: Headers, + ignoreNonServerSideCookies: boolean, ): Pick => { const setFunc: CookieStorage.Adapter['set'] = (name, value, options) => { + if (shouldIgnoreCookie(ignoreNonServerSideCookies, name)) { + return; + } + headers.append( 'Set-Cookie', - `${ensureEncodedForJSCookie(name)}=${value};${ - options ? serializeSetCookieOptions(options) : '' - }`, + serializeCookie(ensureEncodedForJSCookie(name), value, options), ); }; const deleteFunc: CookieStorage.Adapter['delete'] = name => { + if (shouldIgnoreCookie(ignoreNonServerSideCookies, name)) { + return; + } + headers.append( 'Set-Cookie', `${ensureEncodedForJSCookie( @@ -239,43 +283,13 @@ 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[] => values === undefined ? [] : Array.isArray(values) ? values : [String(values)]; + +const shouldIgnoreCookie = ( + ignoreNonServerSideCookies: boolean, + cookieName: string, +): boolean => + ignoreNonServerSideCookies && !isServerSideAuthAllowedCookie(cookieName); diff --git a/packages/adapter-nextjs/src/utils/createRunWithAmplifyServerContext.ts b/packages/adapter-nextjs/src/utils/createRunWithAmplifyServerContext.ts index 3eaea7f362d..e5744056619 100644 --- a/packages/adapter-nextjs/src/utils/createRunWithAmplifyServerContext.ts +++ b/packages/adapter-nextjs/src/utils/createRunWithAmplifyServerContext.ts @@ -1,8 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { ResourcesConfig, sharedInMemoryStorage } from '@aws-amplify/core'; -import { KeyValueStorageMethodValidator } from '@aws-amplify/core/internals/adapter-core'; +import { ResourcesConfig } from 'aws-amplify'; +import { sharedInMemoryStorage } from 'aws-amplify/utils'; +import { KeyValueStorageMethodValidator } from 'aws-amplify/adapter-core/internals'; import { createAWSCredentialsAndIdentityIdProvider, createKeyValueStorageFromCookieStorageAdapter, @@ -11,16 +12,40 @@ import { } from 'aws-amplify/adapter-core'; import { NextServer } from '../types'; +import { + DEFAULT_SERVER_SIDE_AUTH_SET_COOKIE_OPTIONS, + ENFORCED_SERVER_SIDE_AUTH_SET_COOKIE_OPTIONS, +} from '../auth/constant'; import { createCookieStorageAdapterFromNextServerContext } from './createCookieStorageAdapterFromNextServerContext'; export const createRunWithAmplifyServerContext = ({ config: resourcesConfig, tokenValidator, + globalSettings, }: { config: ResourcesConfig; tokenValidator?: KeyValueStorageMethodValidator; + globalSettings: NextServer.GlobalSettings; }) => { + const isServerSideAuthEnabled = globalSettings.isServerSideAuthEnabled(); + const isSSLOrigin = globalSettings.isSSLOrigin(); + const setCookieOptions = globalSettings.getRuntimeOptions().cookies ?? {}; + + const mergedSetCookieOptions = { + // default options when not specified + ...(isServerSideAuthEnabled && DEFAULT_SERVER_SIDE_AUTH_SET_COOKIE_OPTIONS), + // user-specified options + ...setCookieOptions, + // enforced options when server-side auth is enabled + ...(isServerSideAuthEnabled && { + ...ENFORCED_SERVER_SIDE_AUTH_SET_COOKIE_OPTIONS, + secure: isSSLOrigin, + }), + // only support root path + path: '/', + }; + const runWithAmplifyServerContext: NextServer.RunOperationWithContext = async ({ nextServerContext, operation }) => { // When the Auth config is presented, attempt to create a Amplify server @@ -36,8 +61,10 @@ export const createRunWithAmplifyServerContext = ({ : createKeyValueStorageFromCookieStorageAdapter( await createCookieStorageAdapterFromNextServerContext( nextServerContext, + isServerSideAuthEnabled, ), tokenValidator, + mergedSetCookieOptions, ); const credentialsProvider = createAWSCredentialsAndIdentityIdProvider( resourcesConfig.Auth, diff --git a/packages/adapter-nextjs/src/utils/createTokenValidator.ts b/packages/adapter-nextjs/src/utils/createTokenValidator.ts index 800cd87c62f..30185e1c03e 100644 --- a/packages/adapter-nextjs/src/utils/createTokenValidator.ts +++ b/packages/adapter-nextjs/src/utils/createTokenValidator.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { KeyValueStorageMethodValidator } from '@aws-amplify/core/internals/adapter-core'; +import { KeyValueStorageMethodValidator } from 'aws-amplify/adapter-core/internals'; import { CognitoJwtVerifier } from 'aws-jwt-verify'; import { JwtVerifier } from '../types'; diff --git a/packages/adapter-nextjs/src/utils/globalSettings.ts b/packages/adapter-nextjs/src/utils/globalSettings.ts new file mode 100644 index 00000000000..d15c10709ee --- /dev/null +++ b/packages/adapter-nextjs/src/utils/globalSettings.ts @@ -0,0 +1,30 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { NextServer } from '../types'; + +let isServerSideAuthEnabled = false; +let runtimeOptions: NextServer.CreateServerRunnerRuntimeOptions = {}; +let isSSLOrigin = false; + +export const globalSettings: NextServer.GlobalSettings = { + enableServerSideAuth() { + isServerSideAuthEnabled = true; + }, + isServerSideAuthEnabled() { + return isServerSideAuthEnabled; + }, + setRuntimeOptions(options: NextServer.CreateServerRunnerRuntimeOptions) { + // make a copy instead of set the reference + runtimeOptions = structuredClone(options); + }, + getRuntimeOptions() { + return runtimeOptions; + }, + setIsSSLOrigin(value: boolean) { + isSSLOrigin = value; + }, + isSSLOrigin() { + return isSSLOrigin; + }, +}; diff --git a/packages/adapter-nextjs/src/utils/index.ts b/packages/adapter-nextjs/src/utils/index.ts index 3427284caa8..45b7928f804 100644 --- a/packages/adapter-nextjs/src/utils/index.ts +++ b/packages/adapter-nextjs/src/utils/index.ts @@ -3,3 +3,4 @@ export { createRunWithAmplifyServerContext } from './createRunWithAmplifyServerContext'; export { isValidCognitoToken } from './isValidCognitoToken'; +export { globalSettings } from './globalSettings'; 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__" + ] } diff --git a/packages/api/src/internals/index.ts b/packages/api/src/internals/index.ts index ef48721dbbb..f7a0fdacc78 100644 --- a/packages/api/src/internals/index.ts +++ b/packages/api/src/internals/index.ts @@ -2,3 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 export { InternalAPI, InternalAPIClass } from './InternalAPI'; export { generateClientWithAmplifyInstance } from '@aws-amplify/api-graphql/internals/server'; +export { + V6ClientSSRCookies, + V6ClientSSRRequest, +} from '@aws-amplify/api-graphql'; +export { + CommonPublicClientOptions, + DefaultCommonClientOptions, +} from '@aws-amplify/api-graphql/internals'; 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 8501d96f7cf..dae3ec26ff7 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({ @@ -211,7 +212,7 @@ export class DefaultTokenStore implements AuthTokenStore { const lastAuthUser = username ?? (await this.getLastAuthUser()); return createKeysForAuthStorage( - this.name, + AUTH_KEY_PREFIX, `${this.authConfig.Cognito.userPoolClientId}.${lastAuthUser}`, ); } @@ -220,7 +221,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'; diff --git a/packages/aws-amplify/__tests__/adapterCore/storageFactories/createKeyValueStorageFromCookieStorageAdapter.test.ts b/packages/aws-amplify/__tests__/adapterCore/storageFactories/createKeyValueStorageFromCookieStorageAdapter.test.ts index f2b900bfa87..4e014ecab29 100644 --- a/packages/aws-amplify/__tests__/adapterCore/storageFactories/createKeyValueStorageFromCookieStorageAdapter.test.ts +++ b/packages/aws-amplify/__tests__/adapterCore/storageFactories/createKeyValueStorageFromCookieStorageAdapter.test.ts @@ -1,7 +1,11 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { createKeyValueStorageFromCookieStorageAdapter } from '../../../src/adapter-core'; +import { + CookieStorage, + DEFAULT_AUTH_TOKEN_COOKIES_MAX_AGE, + createKeyValueStorageFromCookieStorageAdapter, +} from '../../../src/adapter-core'; import { defaultSetCookieOptions } from '../../../src/adapter-core/storageFactories/createKeyValueStorageFromCookieStorageAdapter'; const mockCookiesStorageAdapter = { @@ -12,6 +16,13 @@ const mockCookiesStorageAdapter = { }; describe('keyValueStorage', () => { + afterEach(() => { + mockCookiesStorageAdapter.delete.mockClear(); + mockCookiesStorageAdapter.get.mockClear(); + mockCookiesStorageAdapter.set.mockClear(); + mockCookiesStorageAdapter.getAll.mockClear(); + }); + describe('createKeyValueStorageFromCookiesStorageAdapter', () => { it('should return a key value storage', () => { const keyValueStorage = createKeyValueStorageFromCookieStorageAdapter( @@ -35,7 +46,7 @@ describe('keyValueStorage', () => { testValue, { ...defaultSetCookieOptions, - expires: expect.any(Date), + maxAge: DEFAULT_AUTH_TOKEN_COOKIES_MAX_AGE, }, ); }); @@ -50,7 +61,7 @@ describe('keyValueStorage', () => { testValue, { ...defaultSetCookieOptions, - expires: expect.any(Date), + maxAge: DEFAULT_AUTH_TOKEN_COOKIES_MAX_AGE, }, ); }); @@ -64,7 +75,7 @@ describe('keyValueStorage', () => { testValue, { ...defaultSetCookieOptions, - expires: expect.any(Date), + maxAge: DEFAULT_AUTH_TOKEN_COOKIES_MAX_AGE, }, ); }); @@ -100,6 +111,54 @@ describe('keyValueStorage', () => { }); }); + describe('passing setCookieOptions parameter', () => { + it('sets item with specified setCookieOptions', async () => { + const testSetCookieOptions: CookieStorage.SetCookieOptions = { + httpOnly: true, + sameSite: 'strict', + maxAge: 3600, + }; + const keyValueStorage = createKeyValueStorageFromCookieStorageAdapter( + mockCookiesStorageAdapter, + undefined, + testSetCookieOptions, + ); + + keyValueStorage.setItem('testKey', 'testValue'); + expect(mockCookiesStorageAdapter.set).toHaveBeenCalledWith( + 'testKey', + 'testValue', + { + ...defaultSetCookieOptions, + ...testSetCookieOptions, + }, + ); + }); + + it('sets default maxAge when expires and maxAges are not provided', async () => { + const testSetCookieOptions: CookieStorage.SetCookieOptions = { + httpOnly: true, + sameSite: 'strict', + }; + const keyValueStorage = createKeyValueStorageFromCookieStorageAdapter( + mockCookiesStorageAdapter, + undefined, + testSetCookieOptions, + ); + + keyValueStorage.setItem('testKey', 'testValue'); + expect(mockCookiesStorageAdapter.set).toHaveBeenCalledWith( + 'testKey', + 'testValue', + { + ...defaultSetCookieOptions, + ...testSetCookieOptions, + maxAge: DEFAULT_AUTH_TOKEN_COOKIES_MAX_AGE, + }, + ); + }); + }); + describe('in conjunction with token validator', () => { const testKey = 'testKey'; const testValue = 'testValue'; 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/adapter-core/internals/package.json b/packages/aws-amplify/adapter-core/internals/package.json new file mode 100644 index 00000000000..afd44aeac94 --- /dev/null +++ b/packages/aws-amplify/adapter-core/internals/package.json @@ -0,0 +1,7 @@ +{ + "name": "aws-amplify/adapter-core/internals", + "types": "../../dist/esm/adapter-core/internals.d.ts", + "main": "../../dist/cjs/adapter-core/internals.js", + "module": "../../dist/esm/adapter-core/internals.mjs", + "sideEffects": false +} diff --git a/packages/aws-amplify/api/internals/package.json b/packages/aws-amplify/api/internals/package.json new file mode 100644 index 00000000000..39fff765dd4 --- /dev/null +++ b/packages/aws-amplify/api/internals/package.json @@ -0,0 +1,7 @@ +{ + "name": "aws-amplify/api/internals", + "main": "../../dist/cjs/api/internals.js", + "browser": "../../dist/esm/api/internals.mjs", + "module": "../../dist/esm/api/internals.mjs", + "typings": "../../dist/esm/api/internals.d.ts" +} 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/package.json b/packages/aws-amplify/package.json index 89b5f2557e7..b0ea67e6141 100644 --- a/packages/aws-amplify/package.json +++ b/packages/aws-amplify/package.json @@ -31,6 +31,12 @@ "require": "./dist/cjs/api/index.js", "react-native": "./src/api/index.ts" }, + "./api/internals": { + "types": "./dist/esm/api/internals.d.ts", + "import": "./dist/esm/api/internals.mjs", + "require": "./dist/cjs/api/internals.js", + "react-native": "./src/api/internals.ts" + }, "./api/server": { "types": "./dist/esm/api/server.d.ts", "import": "./dist/esm/api/server.mjs", @@ -155,6 +161,11 @@ "import": "./dist/esm/adapter-core/index.mjs", "require": "./dist/cjs/adapter-core/index.js" }, + "./adapter-core/internals": { + "types": "./dist/esm/adapter-core/internals.d.ts", + "import": "./dist/esm/adapter-core/internals.mjs", + "require": "./dist/cjs/adapter-core/internals.js" + }, "./package.json": "./package.json" }, "typesVersions": { @@ -168,6 +179,9 @@ "api/server": [ "./dist/esm/api/server.d.ts" ], + "api/server/internals": [ + "./dist/esm/api/internals.d.ts" + ], "utils": [ "./dist/esm/utils/index.d.ts" ], @@ -227,6 +241,9 @@ ], "adapter-core": [ "./dist/esm/adapter-core/index.d.ts" + ], + "adapter-core/internals": [ + "./dist/esm/adapter-core/internals.d.ts" ] } }, 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..1dae590158c --- /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_AUTH_TOKEN_COOKIES_MAX_AGE = 365 * 24 * 60 * 60; // 1 year in seconds diff --git a/packages/aws-amplify/src/adapter-core/index.ts b/packages/aws-amplify/src/adapter-core/index.ts index 755f8c12b42..10a477963d3 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_AUTH_TOKEN_COOKIES_MAX_AGE } from './constants'; diff --git a/packages/aws-amplify/src/adapter-core/internals.ts b/packages/aws-amplify/src/adapter-core/internals.ts new file mode 100644 index 00000000000..326d21530fc --- /dev/null +++ b/packages/aws-amplify/src/adapter-core/internals.ts @@ -0,0 +1,17 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { + KeyValueStorageMethodValidator, + AmplifyServerContextError, + getAmplifyServerContext, + AmplifyServer, + CookieStorage, +} from '@aws-amplify/core/internals/adapter-core'; +export { OAuthConfig } from '@aws-amplify/core'; +export { + assertOAuthConfig, + assertTokenProviderConfig, + urlSafeEncode, + decodeJWT, +} from '@aws-amplify/core/internals/utils'; diff --git a/packages/aws-amplify/src/adapter-core/storageFactories/createKeyValueStorageFromCookieStorageAdapter.ts b/packages/aws-amplify/src/adapter-core/storageFactories/createKeyValueStorageFromCookieStorageAdapter.ts index b20ddb961a6..00bc9bb2739 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_AUTH_TOKEN_COOKIES_MAX_AGE } 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 @@ -24,6 +25,7 @@ const ONE_YEAR_IN_MS = 365 * 24 * 60 * 60 * 1000; export const createKeyValueStorageFromCookieStorageAdapter = ( cookieStorageAdapter: CookieStorage.Adapter, validator?: KeyValueStorageMethodValidator, + setCookieOptions: CookieStorage.SetCookieOptions = {}, ): KeyValueStorageInterface => { return { setItem(key, value) { @@ -32,11 +34,17 @@ export const createKeyValueStorageFromCookieStorageAdapter = ( // SetCookie: key=value;expires=Date.now() + 365 days;path=/;secure=true cookieStorageAdapter.delete(key); - // TODO(HuiSF): follow up the default CookieSerializeOptions values - cookieStorageAdapter.set(key, value, { + const mergedCookieOptions = { ...defaultSetCookieOptions, - expires: new Date(Date.now() + ONE_YEAR_IN_MS), - }); + ...setCookieOptions, + }; + + // when expires and maxAge both are not specified, we set a default maxAge + if (!mergedCookieOptions.expires && !mergedCookieOptions.maxAge) { + mergedCookieOptions.maxAge = DEFAULT_AUTH_TOKEN_COOKIES_MAX_AGE; + } + + cookieStorageAdapter.set(key, value, mergedCookieOptions); return Promise.resolve(); }, diff --git a/packages/aws-amplify/src/api/internals.ts b/packages/aws-amplify/src/api/internals.ts new file mode 100644 index 00000000000..cc42358fb21 --- /dev/null +++ b/packages/aws-amplify/src/api/internals.ts @@ -0,0 +1,10 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { + generateClientWithAmplifyInstance, + V6ClientSSRCookies, + V6ClientSSRRequest, + CommonPublicClientOptions, + DefaultCommonClientOptions, +} from '@aws-amplify/api/internals'; diff --git a/packages/core/__tests__/utils/generateRandomString.test.ts b/packages/core/__tests__/utils/generateRandomString.test.ts index d17cb37c9d7..d9676502b56 100644 --- a/packages/core/__tests__/utils/generateRandomString.test.ts +++ b/packages/core/__tests__/utils/generateRandomString.test.ts @@ -3,28 +3,52 @@ import { generateRandomString } from '../../src/libraryUtils'; -describe('generateRandomString()', () => { - it('generates a string with the specified length', () => { - let counter = 0; - while (counter++ < 50) { - expect(generateRandomString(20).length).toEqual(20); - } - }); +describe('generateRandomString', () => { + const mockRandomValues = new Uint8Array([ + 144, 247, 102, 114, 51, 221, 175, 4, 120, 255, 176, 200, 83, 164, 117, 73, + 29, 118, 5, 58, 78, 227, 239, 199, 187, 43, 26, 73, 211, 38, 79, 208, + ]); + const getRandomValuesSpy = jest.spyOn(crypto, 'getRandomValues'); - it('generates correct string', () => { - const mathRandomSpy = jest.spyOn(Math, 'random'); - let counter = 1; - mathRandomSpy.mockImplementation(() => { - const returnValue = counter; - counter += 5; + beforeAll(() => { + // Mock crypto.getRandomValues + getRandomValuesSpy.mockImplementation(bufferView => { + if (!bufferView) { + return null; + } + const array = new Uint8Array(bufferView.buffer); + for (let i = 0; i < array.byteLength; i++) { + array[i] = mockRandomValues[i]; + } - return parseFloat(`0.${returnValue}`); + return array; }); + }); - const result1 = generateRandomString(10); - counter = 1; - const result2 = generateRandomString(20); + afterEach(() => { + getRandomValuesSpy.mockClear(); + }); + + it('generates a string of the specified length', () => { + const expectedLength = 10; + expect(generateRandomString(expectedLength)).toHaveLength(expectedLength); + }); + + it('calls crypto.getRandomValues with Uint8Array', () => { + generateRandomString(5); + expect(crypto.getRandomValues).toHaveBeenCalledWith(expect.any(Uint8Array)); + }); + + it('uses only characters from the charset', () => { + const result = generateRandomString(20); + const charset = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + expect(result).toMatch(new RegExp(`^[${charset}]+$`)); + }); - expect(result2.substring(0, 10)).toEqual(result1); + it('generates random strings with calculated indexes (num % STATE_CHARSET.length)', () => { + expect(generateRandomString(32)).toStrictEqual( + 'U9o0zjzE6H0OVo3Ld4F6Qp1NBraLZmRW', + ); }); }); diff --git a/packages/core/src/utils/generateRandomString.ts b/packages/core/src/utils/generateRandomString.ts index 1610d3eeb26..71d6097aecd 100644 --- a/packages/core/src/utils/generateRandomString.ts +++ b/packages/core/src/utils/generateRandomString.ts @@ -1,16 +1,19 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { getCrypto } from './globalHelpers'; + export const generateRandomString = (length: number) => { const STATE_CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - let result = ''; + const result: string[] = []; + const randomNums = new Uint8Array(length); + + getCrypto().getRandomValues(randomNums); - for (let i = 0; i < length; i++) { - result += STATE_CHARSET.charAt( - Math.floor(Math.random() * STATE_CHARSET.length), - ); + for (const num of randomNums) { + result.push(STATE_CHARSET[num % STATE_CHARSET.length]); } - return result; + return result.join(''); };