From 72feab751f6fcc9971202e7b74c6bef21fdf89bc Mon Sep 17 00:00:00 2001 From: Hui Zhao Date: Wed, 8 May 2024 10:38:33 -0700 Subject: [PATCH] tag release the experiment --- packages/adapter-nextjs/client/package.json | 7 + packages/adapter-nextjs/package.json | 6 + .../src/api/createServerRunnerForAPI.ts | 8 +- .../createTokenExchangeRouteHandlerFactory.ts | 63 +++++++ .../createHttpOnlyCookieBasedAuthProviders.ts | 57 ++++++ .../httpOnlyCookieBasedAuthProviders/index.ts | 3 + packages/adapter-nextjs/src/auth/types.ts | 26 +++ packages/adapter-nextjs/src/client/index.ts | 1 + .../adapter-nextjs/src/createServerRunner.ts | 19 ++ .../createGetOAuthInitiationRouteFactory.ts | 30 +++ .../oauth/createOAuthRouteHandlerFactory.ts | 74 ++++++++ packages/adapter-nextjs/src/oauth/index.ts | 4 + packages/adapter-nextjs/src/oauth/types.ts | 59 ++++++ .../src/oauth/utils/completeOAuthFlow.ts | 174 ++++++++++++++++++ .../src/oauth/utils/getRedirectUrl.ts | 23 +++ .../src/oauth/utils/initOAuthFlow.ts | 108 +++++++++++ .../adapter-nextjs/src/types/NextServer.ts | 28 ++- .../createRunWithAmplifyServerContext.ts | 3 + packages/auth/src/index.ts | 2 + .../credentialsProvider/IdentityIdStore.ts | 4 + .../credentialsProvider.ts | 16 ++ .../cognito/credentialsProvider/types.ts | 1 + packages/auth/src/providers/cognito/index.ts | 10 + .../cognito/tokenProvider/TokenStore.ts | 2 +- .../cognito/utils/oauth/completeOAuthFlow.ts | 4 +- .../providers/cognito/utils/oauth/index.ts | 1 + .../cognito/utils/oauth/validateState.ts | 8 +- .../aws-amplify/src/adapter-core/index.ts | 10 + ...KeyValueStorageFromCookieStorageAdapter.ts | 2 + packages/aws-amplify/src/initSingleton.ts | 8 + packages/core/src/libraryUtils.ts | 1 + .../src/utils/contextAwareRunner/index.ts | 1 + .../contextAwareRunner/runInBrowserContext.ts | 12 ++ 33 files changed, 767 insertions(+), 8 deletions(-) create mode 100644 packages/adapter-nextjs/client/package.json create mode 100644 packages/adapter-nextjs/src/auth/createTokenExchangeRouteHandlerFactory.ts create mode 100644 packages/adapter-nextjs/src/auth/httpOnlyCookieBasedAuthProviders/createHttpOnlyCookieBasedAuthProviders.ts create mode 100644 packages/adapter-nextjs/src/auth/httpOnlyCookieBasedAuthProviders/index.ts create mode 100644 packages/adapter-nextjs/src/auth/types.ts create mode 100644 packages/adapter-nextjs/src/client/index.ts create mode 100644 packages/adapter-nextjs/src/oauth/createGetOAuthInitiationRouteFactory.ts create mode 100644 packages/adapter-nextjs/src/oauth/createOAuthRouteHandlerFactory.ts create mode 100644 packages/adapter-nextjs/src/oauth/index.ts create mode 100644 packages/adapter-nextjs/src/oauth/types.ts create mode 100644 packages/adapter-nextjs/src/oauth/utils/completeOAuthFlow.ts create mode 100644 packages/adapter-nextjs/src/oauth/utils/getRedirectUrl.ts create mode 100644 packages/adapter-nextjs/src/oauth/utils/initOAuthFlow.ts create mode 100644 packages/core/src/utils/contextAwareRunner/index.ts create mode 100644 packages/core/src/utils/contextAwareRunner/runInBrowserContext.ts diff --git a/packages/adapter-nextjs/client/package.json b/packages/adapter-nextjs/client/package.json new file mode 100644 index 00000000000..11b7f8cac12 --- /dev/null +++ b/packages/adapter-nextjs/client/package.json @@ -0,0 +1,7 @@ +{ + "name": "@aws-amplify/adapter-nextjs/client", + "main": "../dist/cjs/client/index.js", + "browser": "../dist/esm/client/index.mjs", + "module": "../dist/esm/client/index.mjs", + "typings": "../dist/esm/client/index.d.ts" +} diff --git a/packages/adapter-nextjs/package.json b/packages/adapter-nextjs/package.json index 783e91d6340..d33c91f4e33 100644 --- a/packages/adapter-nextjs/package.json +++ b/packages/adapter-nextjs/package.json @@ -8,6 +8,7 @@ "next": ">=14.2.3 <15.0.0" }, "dependencies": { + "client-only": "0.0.1", "cookie": "0.5.0" }, "devDependencies": { @@ -42,6 +43,11 @@ "import": "./dist/esm/api/index.mjs", "require": "./dist/cjs/api/index.js" }, + "./client": { + "types": "./dist/esm/client/index.d.ts", + "import": "./dist/esm/client/index.mjs", + "require": "./dist/cjs/client/index.js" + }, "./package.json": "./package.json" }, "files": [ diff --git a/packages/adapter-nextjs/src/api/createServerRunnerForAPI.ts b/packages/adapter-nextjs/src/api/createServerRunnerForAPI.ts index 3c5ae6ad97a..529d3bed2b1 100644 --- a/packages/adapter-nextjs/src/api/createServerRunnerForAPI.ts +++ b/packages/adapter-nextjs/src/api/createServerRunnerForAPI.ts @@ -9,7 +9,13 @@ import { NextServer } from '../types'; export const createServerRunnerForAPI = ({ config, -}: NextServer.CreateServerRunnerInput): NextServer.CreateServerRunnerOutput & { +}: Omit): Omit< + NextServer.CreateServerRunnerOutput, + | 'createOAuthRouteHandler' + | 'getOAuthInitiationRoute' + | 'createTokenExchangeRouteHandler' + | 'origin' +> & { resourcesConfig: ResourcesConfig; } => { const amplifyConfig = parseAmplifyConfig(config); diff --git a/packages/adapter-nextjs/src/auth/createTokenExchangeRouteHandlerFactory.ts b/packages/adapter-nextjs/src/auth/createTokenExchangeRouteHandlerFactory.ts new file mode 100644 index 00000000000..c0b642d4bd8 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/createTokenExchangeRouteHandlerFactory.ts @@ -0,0 +1,63 @@ +import { NextRequest } from 'next/server'; +import { cookies } from 'next/headers'; +import { fetchAuthSession } from 'aws-amplify/auth/server'; + +import { createRunWithAmplifyServerContext } from '../utils'; + +import { + CreateTokenExchangeRouteHandlerFactory, + CreateTokenExchangeRouteHandlerInput, +} from './types'; + +export const createTokenExchangeRouteHandlerFactory: CreateTokenExchangeRouteHandlerFactory = + input => { + const runWithAmplifyServerContext = + createRunWithAmplifyServerContext(input); + const { origin } = input; + + const handleRequest = async ( + _: NextRequest, + __: CreateTokenExchangeRouteHandlerInput, + ) => { + const userSession = await runWithAmplifyServerContext({ + nextServerContext: { cookies }, + operation: contextSpec => fetchAuthSession(contextSpec), + }); + + const clockDrift = cookies() + .getAll() + .find(cookie => cookie.name.endsWith('.clockDrift'))?.value; + + return new Response( + JSON.stringify({ + ...userSession, + tokens: { + accessToken: userSession.tokens?.accessToken.toString(), + idToken: userSession.tokens?.idToken?.toString(), + }, + username: userSession.tokens?.accessToken.payload.username, + clockDrift, + userSession, + }), + { + headers: { + 'content-type': 'application/json', + 'Access-Control-Allow-Origin': origin, + 'Access-Control-Allow-Methods': 'POST', + }, + }, + ); + }; + + return handlerInput => ({ + async POST(request) { + try { + return await handleRequest(request, handlerInput); + } catch (error) { + const { onError } = handlerInput; + + onError(error as Error); + } + }, + }); + }; diff --git a/packages/adapter-nextjs/src/auth/httpOnlyCookieBasedAuthProviders/createHttpOnlyCookieBasedAuthProviders.ts b/packages/adapter-nextjs/src/auth/httpOnlyCookieBasedAuthProviders/createHttpOnlyCookieBasedAuthProviders.ts new file mode 100644 index 00000000000..a102cb37cbb --- /dev/null +++ b/packages/adapter-nextjs/src/auth/httpOnlyCookieBasedAuthProviders/createHttpOnlyCookieBasedAuthProviders.ts @@ -0,0 +1,57 @@ +import { LibraryOptions, sharedInMemoryStorage } from '@aws-amplify/core'; +import { runInBrowserContext } from '@aws-amplify/core/internals/utils'; +import { + cognitoCredentialsProvider, + cognitoUserPoolsTokenProvider, +} from 'aws-amplify/auth/cognito'; + +export const createHttpOnlyCookieBasedAuthProviders = ({ + authTokenExchangeRoute, +}: { + authTokenExchangeRoute: string; +}): LibraryOptions['Auth'] => { + cognitoUserPoolsTokenProvider.setKeyValueStorage(sharedInMemoryStorage); + + runInBrowserContext(() => { + refreshSession({ + authTokenExchangeRoute, + tokenProvider: cognitoUserPoolsTokenProvider, + credentialsProvider: cognitoCredentialsProvider, + }); + }); + + return { + tokenProvider: cognitoUserPoolsTokenProvider, + credentialsProvider: cognitoCredentialsProvider, + }; +}; + +const refreshSession = async ({ + authTokenExchangeRoute, + tokenProvider, + credentialsProvider, +}: { + authTokenExchangeRoute: string; + tokenProvider: typeof cognitoUserPoolsTokenProvider; + credentialsProvider: typeof cognitoCredentialsProvider; +}) => { + const response = await fetch(authTokenExchangeRoute, { method: 'POST' }); + const session = await response.json(); + + tokenProvider.tokenOrchestrator.setTokens({ + tokens: { + accessToken: session.tokens.accessToken, + idToken: session.tokens.idToken, + clockDrift: session.clockDrift, + username: session.username, + }, + }); + + credentialsProvider.setIdentityIdCredentials( + { + credentials: session.credentials, + identityId: session.identityId, + }, + session.tokens.idToken, + ); +}; diff --git a/packages/adapter-nextjs/src/auth/httpOnlyCookieBasedAuthProviders/index.ts b/packages/adapter-nextjs/src/auth/httpOnlyCookieBasedAuthProviders/index.ts new file mode 100644 index 00000000000..8029c841e3d --- /dev/null +++ b/packages/adapter-nextjs/src/auth/httpOnlyCookieBasedAuthProviders/index.ts @@ -0,0 +1,3 @@ +import 'client-only'; + +export { createHttpOnlyCookieBasedAuthProviders } from './createHttpOnlyCookieBasedAuthProviders'; diff --git a/packages/adapter-nextjs/src/auth/types.ts b/packages/adapter-nextjs/src/auth/types.ts new file mode 100644 index 00000000000..147c962cd02 --- /dev/null +++ b/packages/adapter-nextjs/src/auth/types.ts @@ -0,0 +1,26 @@ +import { ResourcesConfig } from 'aws-amplify'; +import { NextRequest } from 'next/server'; + +import { NextServer } from '../types'; + +interface CreateTokenExchangeRouteHandlerFactoryInput { + config: ResourcesConfig; + origin: string; + setAuthCookieOptions?: NextServer.SetCookieOptions; +} + +interface CreateOAuthRouteHandlerOutput { + POST(request: NextRequest): Promise; +} + +export interface CreateTokenExchangeRouteHandlerInput { + onError(error: Error): void; +} + +export type CreateTokenExchangeRouteHandler = ( + input: CreateTokenExchangeRouteHandlerInput, +) => CreateOAuthRouteHandlerOutput; + +export type CreateTokenExchangeRouteHandlerFactory = ( + input: CreateTokenExchangeRouteHandlerFactoryInput, +) => CreateTokenExchangeRouteHandler; diff --git a/packages/adapter-nextjs/src/client/index.ts b/packages/adapter-nextjs/src/client/index.ts new file mode 100644 index 00000000000..182614eec0a --- /dev/null +++ b/packages/adapter-nextjs/src/client/index.ts @@ -0,0 +1 @@ +export { createHttpOnlyCookieBasedAuthProviders } from '../auth/httpOnlyCookieBasedAuthProviders'; diff --git a/packages/adapter-nextjs/src/createServerRunner.ts b/packages/adapter-nextjs/src/createServerRunner.ts index 576356fba3e..a9b479b6357 100644 --- a/packages/adapter-nextjs/src/createServerRunner.ts +++ b/packages/adapter-nextjs/src/createServerRunner.ts @@ -6,6 +6,9 @@ import { parseAmplifyConfig } from '@aws-amplify/core/internals/utils'; import { createRunWithAmplifyServerContext } from './utils'; import { NextServer } from './types'; +import { createOAuthRouteHandlerFactory } from './oauth'; +import { createTokenExchangeRouteHandlerFactory } from './auth/createTokenExchangeRouteHandlerFactory'; +import { createGetOAuthInitiationRouteFactory } from './oauth/createGetOAuthInitiationRouteFactory'; /** * Creates the `runWithAmplifyServerContext` function to run Amplify server side APIs in an isolated request context. @@ -27,12 +30,28 @@ import { NextServer } from './types'; */ export const createServerRunner: NextServer.CreateServerRunner = ({ config, + origin, + setAuthCookieOptions, }) => { const amplifyConfig = parseAmplifyConfig(config); return { runWithAmplifyServerContext: createRunWithAmplifyServerContext({ config: amplifyConfig, + setAuthCookieOptions, + }), + createOAuthRouteHandler: createOAuthRouteHandlerFactory({ + config: amplifyConfig, + setAuthCookieOptions, + }), + getOAuthInitiationRoute: createGetOAuthInitiationRouteFactory({ + config: amplifyConfig, + origin, + }), + createTokenExchangeRouteHandler: createTokenExchangeRouteHandlerFactory({ + config: amplifyConfig, + origin, + setAuthCookieOptions, }), }; }; diff --git a/packages/adapter-nextjs/src/oauth/createGetOAuthInitiationRouteFactory.ts b/packages/adapter-nextjs/src/oauth/createGetOAuthInitiationRouteFactory.ts new file mode 100644 index 00000000000..bcc1a9759da --- /dev/null +++ b/packages/adapter-nextjs/src/oauth/createGetOAuthInitiationRouteFactory.ts @@ -0,0 +1,30 @@ +import { + assertOAuthConfig, + assertTokenProviderConfig, +} from '@aws-amplify/core/internals/utils'; + +import { + CreateGetOAuthInitiationRouteFactory, + GetOAuthInitiationRoute, +} from './types'; +import { getRedirectUrl } from './utils/getRedirectUrl'; + +export const createGetOAuthInitiationRouteFactory: CreateGetOAuthInitiationRouteFactory = + ({ config: resourcesConfig, origin }) => { + assertTokenProviderConfig(resourcesConfig.Auth?.Cognito); + assertOAuthConfig(resourcesConfig.Auth.Cognito); + + const { Cognito: cognitoUserPoolConfig } = resourcesConfig.Auth; + const redirectUrl = getRedirectUrl( + origin, + cognitoUserPoolConfig.loginWith.oauth, + ); + + const getOAuthInitiationRoute: GetOAuthInitiationRoute = input => { + const { provider } = input; + + return `${redirectUrl}?init=true&provider=${provider}`; + }; + + return getOAuthInitiationRoute; + }; diff --git a/packages/adapter-nextjs/src/oauth/createOAuthRouteHandlerFactory.ts b/packages/adapter-nextjs/src/oauth/createOAuthRouteHandlerFactory.ts new file mode 100644 index 00000000000..198dfc65ec9 --- /dev/null +++ b/packages/adapter-nextjs/src/oauth/createOAuthRouteHandlerFactory.ts @@ -0,0 +1,74 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + assertOAuthConfig, + assertTokenProviderConfig, +} from '@aws-amplify/core/internals/utils'; +import { NextRequest } from 'next/server'; +import { AuthError } from '@aws-amplify/auth'; + +import { + CreateOAuthRouteHandler, + CreateOAuthRouteHandlerFactory, + CreateOAuthRouteHandlerInput, +} from './types'; +import { initOAuthFlow } from './utils/initOAuthFlow'; +import { completeOAuthFlow } from './utils/completeOAuthFlow'; + +export const createOAuthRouteHandlerFactory: CreateOAuthRouteHandlerFactory = ({ + config: resourcesConfig, + setAuthCookieOptions, +}): CreateOAuthRouteHandler => { + assertTokenProviderConfig(resourcesConfig.Auth?.Cognito); + assertOAuthConfig(resourcesConfig.Auth.Cognito); + + const { Cognito: cognitoUserPoolConfig } = resourcesConfig.Auth; + + const handleRequest = async ( + request: NextRequest, + { + customState, + redirectOnAuthComplete, + onError, + }: CreateOAuthRouteHandlerInput, + ): Promise => { + const { searchParams } = request.nextUrl; + + // when request url has `init` query param - initiate oauth flow + if (searchParams.has('init')) { + return initOAuthFlow({ + setAuthCookieOptions, + request, + customState, + cognitoUserPoolConfig, + oAuthConfig: cognitoUserPoolConfig.loginWith.oauth, + }); + } + + if (searchParams.has('code') && searchParams.has('state')) { + return completeOAuthFlow({ + request, + redirectOnComplete: redirectOnAuthComplete, + setAuthCookieOptions, + customState, + cognitoUserPoolConfig, + oAuthConfig: cognitoUserPoolConfig.loginWith.oauth, + }); + } + + onError(new Error('Invalid point (update me)')); + }; + + return handlerInput => ({ + async GET(request) { + try { + return await handleRequest(request, handlerInput); + } catch (error) { + const { onError } = handlerInput; + + onError(error as AuthError); + } + }, + }); +}; diff --git a/packages/adapter-nextjs/src/oauth/index.ts b/packages/adapter-nextjs/src/oauth/index.ts new file mode 100644 index 00000000000..530d86651fe --- /dev/null +++ b/packages/adapter-nextjs/src/oauth/index.ts @@ -0,0 +1,4 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { createOAuthRouteHandlerFactory } from './createOAuthRouteHandlerFactory'; diff --git a/packages/adapter-nextjs/src/oauth/types.ts b/packages/adapter-nextjs/src/oauth/types.ts new file mode 100644 index 00000000000..7a059c69b4f --- /dev/null +++ b/packages/adapter-nextjs/src/oauth/types.ts @@ -0,0 +1,59 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AuthError, AuthProvider } from '@aws-amplify/auth'; +import { ResourcesConfig } from 'aws-amplify'; +import { NextRequest } from 'next/server'; + +import { NextServer } from '../types'; + +export interface CreateOAuthRouteHandlerInput { + /** A custom state identifying an OAuth flow. */ + customState?: string; + + /** The path to redirect to when an OAuth flow completes. */ + redirectOnAuthComplete: string; + + /** + * A callback function to be called with a {@link AuthError} object that thrown + * from an inflight OAuth flow when error occurs. You need to return a + * {@link Response} object to redirect end user away from the API route + * you set up, for example, redirect back to the sign in page by + * `return NextResponse.redirect('/sign-in')`. + */ + onError(error: AuthError): void; +} + +interface CreateOAuthRouteHandlerOutput { + GET(request: NextRequest): Promise; +} + +export type CreateOAuthRouteHandler = ( + input: CreateOAuthRouteHandlerInput, +) => CreateOAuthRouteHandlerOutput; + +interface CreateOAuthRouteHandlerFactoryInput { + config: ResourcesConfig; + setAuthCookieOptions?: NextServer.SetCookieOptions; +} + +export type CreateOAuthRouteHandlerFactory = ( + input: CreateOAuthRouteHandlerFactoryInput, +) => CreateOAuthRouteHandler; + +export type GetOAuthInitiationRoute = (input: { + provider: + | AuthProvider + | { + custom: string; + }; +}) => string; + +interface CreateGetOAuthInitiationRouteFactoryInput { + config: ResourcesConfig; + origin: string; +} + +export type CreateGetOAuthInitiationRouteFactory = ( + input: CreateGetOAuthInitiationRouteFactoryInput, +) => GetOAuthInitiationRoute; diff --git a/packages/adapter-nextjs/src/oauth/utils/completeOAuthFlow.ts b/packages/adapter-nextjs/src/oauth/utils/completeOAuthFlow.ts new file mode 100644 index 00000000000..c5423c4c2b6 --- /dev/null +++ b/packages/adapter-nextjs/src/oauth/utils/completeOAuthFlow.ts @@ -0,0 +1,174 @@ +import { + CognitoUserPoolConfig, + OAuthConfig, + decodeJWT, +} from '@aws-amplify/core'; +import { NextRequest, NextResponse } from 'next/server'; +import { + createKeyValueStorageFromCookieStorageAdapter, + validateState, +} from 'aws-amplify/adapter-core'; +import { + AuthenticationResultType, + CognitoAuthSignInDetails, + DefaultOAuthStore, + DefaultTokenStore, + DeviceMetadata, + TokenOrchestrator, +} from '@aws-amplify/auth/cognito'; + +import { NextServer } from '../../types'; +import { createCookieStorageAdapterFromNextServerContext } from '../../utils/createCookieStorageAdapterFromNextServerContext'; + +import { getRedirectUrl } from './getRedirectUrl'; + +export const completeOAuthFlow = async ({ + request, + redirectOnComplete, + cognitoUserPoolConfig, + oAuthConfig, + setAuthCookieOptions, +}: { + request: NextRequest; + customState: string | undefined; + redirectOnComplete: string; + cognitoUserPoolConfig: CognitoUserPoolConfig; + oAuthConfig: OAuthConfig; + setAuthCookieOptions?: NextServer.SetCookieOptions; +}): Promise => { + const { searchParams } = request.nextUrl; + const code = searchParams.get('code')!; + const state = searchParams.get('state')!; + + const oAuthTokenEndpoint = `https://${oAuthConfig.domain}/oauth2/token`; + + const response = NextResponse.redirect( + new URL(redirectOnComplete, request.url), + ); + + const keyValueStorage = createKeyValueStorageFromCookieStorageAdapter( + createCookieStorageAdapterFromNextServerContext({ + request, + response, + }), + setAuthCookieOptions, + ); + const oAuthStore = new DefaultOAuthStore(keyValueStorage); + oAuthStore.setAuthConfig(cognitoUserPoolConfig); + + await validateState(oAuthStore, state); + + const authTokenStore = new DefaultTokenStore(); + authTokenStore.setAuthConfig({ Cognito: cognitoUserPoolConfig }); + authTokenStore.setKeyValueStorage(keyValueStorage); + const tokenOrchestrator = new TokenOrchestrator(); + tokenOrchestrator.setAuthConfig({ Cognito: cognitoUserPoolConfig }); + tokenOrchestrator.setAuthTokenStore(authTokenStore); + + const codeVerifier = await oAuthStore.loadPKCE(); + + const oAuthTokenBody = { + grant_type: 'authorization_code', + code, + client_id: cognitoUserPoolConfig.userPoolClientId, + // TODO(Hui): request.nextUrl.origin should be generic and not use Next specifics + redirect_uri: getRedirectUrl(request.nextUrl.origin, oAuthConfig), + ...(codeVerifier ? { code_verifier: codeVerifier } : {}), + }; + + const body = Object.entries(oAuthTokenBody) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join('&'); + + const tokenExchangeResponse = await fetch(oAuthTokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + }); + + const { + access_token, + refresh_token: refreshToken, + id_token, + error, + error_message: errorMessage, + token_type, + expires_in, + } = await tokenExchangeResponse.json(); + + if (error) { + throw new Error(errorMessage ?? error); + } + + const username = + (access_token && decodeJWT(access_token).payload.username) ?? 'username'; + + await writeTokensToStorage( + { + username, + AccessToken: access_token, + IdToken: id_token, + RefreshToken: refreshToken, + TokenType: token_type, + ExpiresIn: expires_in, + }, + tokenOrchestrator, + ); + + await oAuthStore.clearOAuthData(); + + return response; +}; + +const writeTokensToStorage = async ( + payload: AuthenticationResultType & { + NewDeviceMetadata?: DeviceMetadata; + username: string; + signInDetails?: CognitoAuthSignInDetails; + }, + tokenOrchestrator: TokenOrchestrator, +) => { + if (!payload.AccessToken) { + return; + } + + const accessToken = decodeJWT(payload.AccessToken); + const accessTokenIssuedAtInMillis = (accessToken.payload.iat || 0) * 1000; + const currentTime = new Date().getTime(); + const clockDrift = + accessTokenIssuedAtInMillis > 0 + ? accessTokenIssuedAtInMillis - currentTime + : 0; + let idToken; + let refreshToken: string | undefined; + let deviceMetadata; + + if (payload.RefreshToken) { + refreshToken = payload.RefreshToken; + } + + if (payload.IdToken) { + idToken = decodeJWT(payload.IdToken); + } + + if (payload?.NewDeviceMetadata) { + deviceMetadata = payload.NewDeviceMetadata; + } + + const tokens: any = { + accessToken, + idToken, + refreshToken, + clockDrift, + deviceMetadata, + username: payload.username, + }; + + if (payload?.signInDetails) { + tokens.signInDetails = payload.signInDetails; + } + + await tokenOrchestrator.setTokens({ tokens }); +}; diff --git a/packages/adapter-nextjs/src/oauth/utils/getRedirectUrl.ts b/packages/adapter-nextjs/src/oauth/utils/getRedirectUrl.ts new file mode 100644 index 00000000000..b54efb0bc15 --- /dev/null +++ b/packages/adapter-nextjs/src/oauth/utils/getRedirectUrl.ts @@ -0,0 +1,23 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AuthError } from '@aws-amplify/auth'; +import { OAuthConfig } from '@aws-amplify/core'; + +export const getRedirectUrl = (origin: string, oAuthConfig: OAuthConfig) => { + const redirectUrl = oAuthConfig.redirectSignIn.find(url => + url.startsWith(origin), + ); + + if (!redirectUrl) { + throw new AuthError({ + name: 'InvalidRedirectException', + message: + 'signInRedirect or signOutRedirect had an invalid format or was not found.', + recoverySuggestion: + 'Please make sure the signIn/Out redirect in your oauth config is valid.', + }); + } + + return redirectUrl; +}; diff --git a/packages/adapter-nextjs/src/oauth/utils/initOAuthFlow.ts b/packages/adapter-nextjs/src/oauth/utils/initOAuthFlow.ts new file mode 100644 index 00000000000..9ef9c3825ab --- /dev/null +++ b/packages/adapter-nextjs/src/oauth/utils/initOAuthFlow.ts @@ -0,0 +1,108 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + AuthProvider, + cognitoHostedUIIdentityProviderMap, + createKeyValueStorageFromCookieStorageAdapter, + generateCodeVerifier, + generateState, +} from 'aws-amplify/adapter-core'; +import { NextRequest, NextResponse } from 'next/server.js'; +import { urlSafeEncode } from '@aws-amplify/core/internals/utils'; +import { CognitoUserPoolConfig, OAuthConfig } from '@aws-amplify/core'; +import { DefaultOAuthStore } from '@aws-amplify/auth/cognito'; + +import { createCookieStorageAdapterFromNextServerContext } from '../../utils/createCookieStorageAdapterFromNextServerContext'; +import { NextServer } from '../../types'; + +import { getRedirectUrl } from './getRedirectUrl'; + +export const initOAuthFlow = async ({ + request, + customState, + cognitoUserPoolConfig, + oAuthConfig, + setAuthCookieOptions, +}: { + request: NextRequest; + customState: string | undefined; + cognitoUserPoolConfig: CognitoUserPoolConfig; + oAuthConfig: OAuthConfig; + setAuthCookieOptions?: NextServer.SetCookieOptions; +}): Promise => { + const { searchParams } = request.nextUrl; + const specifiedProvider = searchParams.get('provider'); + const provider = getProvider(specifiedProvider); + const randomState = generateState(); + const state = customState + ? `${randomState}-${urlSafeEncode(customState)}` + : randomState; + const scope = oAuthConfig.scopes.join(' '); + + const redirectUrlSearchParams = new URLSearchParams({ + redirect_uri: getRedirectUrl(request.nextUrl.origin, oAuthConfig), + response_type: oAuthConfig.responseType, + client_id: cognitoUserPoolConfig.userPoolClientId!, + identity_provider: provider, + scope, + state, + }); + + let peckKey: string | undefined; + + if (oAuthConfig.responseType === 'code') { + const { value, method, toCodeChallenge } = generateCodeVerifier(128); + + peckKey = value; + redirectUrlSearchParams.append('code_challenge', toCodeChallenge()); + redirectUrlSearchParams.append('code_challenge_method', method); + } + + const redirectUrl = new URL( + `https://${ + oAuthConfig.domain + }/oauth2/authorize?${redirectUrlSearchParams.toString()}`, + ); + + const response = NextResponse.redirect(redirectUrl); + const keyValueStorage = createKeyValueStorageFromCookieStorageAdapter( + createCookieStorageAdapterFromNextServerContext({ + request, + response, + }), + setAuthCookieOptions, + ); + const oauthStore = new DefaultOAuthStore(keyValueStorage); + oauthStore.setAuthConfig(cognitoUserPoolConfig); + oauthStore.storeOAuthState(state); + peckKey && oauthStore.storePKCE(peckKey); + + return response; +}; + +const getProvider = (provider: string | null): string => { + if (typeof provider === 'string') { + return resolveProvider(provider); + } + + return 'COGNITO'; +}; + +const resolveProvider = (provider: string): string => { + try { + assertAuthProvider(provider); + + return cognitoHostedUIIdentityProviderMap[provider]; + } catch (_) { + return provider; + } +}; + +function assertAuthProvider( + provider: string, +): asserts provider is AuthProvider { + if (!['Amazon', 'Apple', 'Facebook', 'Google'].includes(provider)) { + throw new Error('No valid provider specified.'); + } +} diff --git a/packages/adapter-nextjs/src/types/NextServer.ts b/packages/adapter-nextjs/src/types/NextServer.ts index 5c3d093b795..22ff18eb076 100644 --- a/packages/adapter-nextjs/src/types/NextServer.ts +++ b/packages/adapter-nextjs/src/types/NextServer.ts @@ -4,11 +4,22 @@ 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 { + AmplifyOutputs, + CookieStorage, + LegacyConfig, +} from 'aws-amplify/adapter-core'; import { AmplifyServer } from '@aws-amplify/core/internals/adapter-core'; import { ResourcesConfig } from '@aws-amplify/core'; +import { + CreateOAuthRouteHandler, + GetOAuthInitiationRoute, +} from '../oauth/types'; +import { CreateTokenExchangeRouteHandler } from '../auth/types'; + export declare namespace NextServer { + export type SetCookieOptions = CookieStorage.SetCookieOptions; /** * This context is normally available in the following: * - Next App Router [middleware](https://nextjs.org/docs/app/building-your-application/routing/middleware) @@ -74,11 +85,26 @@ export declare namespace NextServer { ) => Promise; export interface CreateServerRunnerInput { + /** + * The Amplify resources config. Typically imported from `amplify_outputs.json` (Gen2) + * or `amplifyconfiguration.json` (Gen1). + */ config: ResourcesConfig | LegacyConfig | AmplifyOutputs; + /** + * The origin of your Next app. + */ + origin: string; + /** + * Configures attributes of Set-Cookie. + */ + setAuthCookieOptions?: SetCookieOptions; } export interface CreateServerRunnerOutput { runWithAmplifyServerContext: RunOperationWithContext; + createOAuthRouteHandler: CreateOAuthRouteHandler; + getOAuthInitiationRoute: GetOAuthInitiationRoute; + createTokenExchangeRouteHandler: CreateTokenExchangeRouteHandler; } export type CreateServerRunner = ( diff --git a/packages/adapter-nextjs/src/utils/createRunWithAmplifyServerContext.ts b/packages/adapter-nextjs/src/utils/createRunWithAmplifyServerContext.ts index 787e934c11b..1fabc88c3d9 100644 --- a/packages/adapter-nextjs/src/utils/createRunWithAmplifyServerContext.ts +++ b/packages/adapter-nextjs/src/utils/createRunWithAmplifyServerContext.ts @@ -15,8 +15,10 @@ import { createCookieStorageAdapterFromNextServerContext } from './createCookieS export const createRunWithAmplifyServerContext = ({ config: resourcesConfig, + setAuthCookieOptions, }: { config: ResourcesConfig; + setAuthCookieOptions?: NextServer.SetCookieOptions; }) => { const runWithAmplifyServerContext: NextServer.RunOperationWithContext = async ({ nextServerContext, operation }) => { @@ -34,6 +36,7 @@ export const createRunWithAmplifyServerContext = ({ createCookieStorageAdapterFromNextServerContext( nextServerContext, ), + setAuthCookieOptions, ); const credentialsProvider = createAWSCredentialsAndIdentityIdProvider( resourcesConfig.Auth, diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 799492edb39..60ac4ca806e 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -72,6 +72,8 @@ export { UpdateUserAttributeOutput, FetchDevicesOutput, } from './providers/cognito'; +export { cognitoHostedUIIdentityProviderMap } from './providers/cognito/types/models'; +export { AuthProvider } from './types/inputs'; export { AuthError } from './errors/AuthError'; diff --git a/packages/auth/src/providers/cognito/credentialsProvider/IdentityIdStore.ts b/packages/auth/src/providers/cognito/credentialsProvider/IdentityIdStore.ts index ec4bc9ef6b4..d5fb06a552f 100644 --- a/packages/auth/src/providers/cognito/credentialsProvider/IdentityIdStore.ts +++ b/packages/auth/src/providers/cognito/credentialsProvider/IdentityIdStore.ts @@ -82,6 +82,10 @@ export class DefaultIdentityIdStore implements IdentityIdStore { this._primaryIdentityId = undefined; await this.keyValueStorage.removeItem(this._authKeys.identityId); } + + setPrimaryIdentityId(identity: Identity & { type: 'primary' }) { + this._primaryIdentityId = identity.id; + } } const createKeysForAuthStorage = (provider: string, identifier: string) => { diff --git a/packages/auth/src/providers/cognito/credentialsProvider/credentialsProvider.ts b/packages/auth/src/providers/cognito/credentialsProvider/credentialsProvider.ts index fd02d349513..172d9aefb73 100644 --- a/packages/auth/src/providers/cognito/credentialsProvider/credentialsProvider.ts +++ b/packages/auth/src/providers/cognito/credentialsProvider/credentialsProvider.ts @@ -248,4 +248,20 @@ export class CognitoAWSCredentialsAndIdentityIdProvider this._credentialsAndIdentityId.associatedIdToken ); } + + setIdentityIdCredentials(payload: CredentialsAndIdentityId, idToken: string) { + this._nextCredentialsRefresh = + typeof payload.credentials.expiration === 'string' + ? new Date(payload.credentials.expiration).getTime() + : 0; + this._identityIdStore.setPrimaryIdentityId({ + id: payload.identityId!, + type: 'primary', + }); + this._credentialsAndIdentityId = { + ...payload, + isAuthenticatedCreds: true, + associatedIdToken: idToken, + }; + } } diff --git a/packages/auth/src/providers/cognito/credentialsProvider/types.ts b/packages/auth/src/providers/cognito/credentialsProvider/types.ts index 9ef7dea018c..260b25b7b87 100644 --- a/packages/auth/src/providers/cognito/credentialsProvider/types.ts +++ b/packages/auth/src/providers/cognito/credentialsProvider/types.ts @@ -12,4 +12,5 @@ export interface IdentityIdStore { loadIdentityId(): Promise; storeIdentityId(identity: Identity): Promise; clearIdentityId(): Promise; + setPrimaryIdentityId(identity: Identity & { type: 'primary' }): void; } diff --git a/packages/auth/src/providers/cognito/index.ts b/packages/auth/src/providers/cognito/index.ts index 9229b376083..33936ea87d4 100644 --- a/packages/auth/src/providers/cognito/index.ts +++ b/packages/auth/src/providers/cognito/index.ts @@ -81,3 +81,13 @@ export { DefaultTokenStore, refreshAuthTokens, } from './tokenProvider'; +export { + generateState, + getRedirectUrl, + generateCodeVerifier, + validateState, +} from './utils/oauth'; +export { DefaultOAuthStore } from './utils/signInWithRedirectStore'; +export { AuthenticationResultType } from './utils/clients/CognitoIdentityProvider/types'; +export { DeviceMetadata } from './tokenProvider/types'; +export { CognitoAuthSignInDetails } from './types'; diff --git a/packages/auth/src/providers/cognito/tokenProvider/TokenStore.ts b/packages/auth/src/providers/cognito/tokenProvider/TokenStore.ts index 53ac3228d85..305a9816796 100644 --- a/packages/auth/src/providers/cognito/tokenProvider/TokenStore.ts +++ b/packages/auth/src/providers/cognito/tokenProvider/TokenStore.ts @@ -101,7 +101,7 @@ export class DefaultTokenStore implements AuthTokenStore { this.getLastAuthUserKey(), lastAuthUser, ); - const authKeys = await this.getAuthKeys(); + const authKeys = await this.getAuthKeys(lastAuthUser); await this.getKeyValueStorage().setItem( authKeys.accessToken, tokens.accessToken.toString(), diff --git a/packages/auth/src/providers/cognito/utils/oauth/completeOAuthFlow.ts b/packages/auth/src/providers/cognito/utils/oauth/completeOAuthFlow.ts index d9ebc5976a8..5b20cba2ca6 100644 --- a/packages/auth/src/providers/cognito/utils/oauth/completeOAuthFlow.ts +++ b/packages/auth/src/providers/cognito/utils/oauth/completeOAuthFlow.ts @@ -90,7 +90,7 @@ const handleCodeFlow = async ({ } // may throw error is being caught in attemptCompleteOAuthFlow.ts - const validatedState = await validateState(state); + const validatedState = await validateState(oAuthStore, state); const oAuthTokenEndpoint = 'https://' + domain + '/oauth2/token'; @@ -199,7 +199,7 @@ const handleImplicitFlow = async ({ throw createOAuthError('No access token returned from OAuth flow.'); } - const validatedState = await validateState(state); + const validatedState = await validateState(oAuthStore, state); const username = (access_token && decodeJWT(access_token).payload.username) ?? 'username'; 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/auth/src/providers/cognito/utils/oauth/validateState.ts b/packages/auth/src/providers/cognito/utils/oauth/validateState.ts index d52a322bf71..db1b172ab15 100644 --- a/packages/auth/src/providers/cognito/utils/oauth/validateState.ts +++ b/packages/auth/src/providers/cognito/utils/oauth/validateState.ts @@ -3,8 +3,7 @@ import { AuthError } from '../../../../errors/AuthError'; import { AuthErrorTypes } from '../../../../types/Auth'; - -import { oAuthStore } from './oAuthStore'; +import { DefaultOAuthStore } from '../signInWithRedirectStore'; export const flowCancelledMessage = '`signInWithRedirect` has been canceled.'; export const validationFailedMessage = @@ -12,7 +11,10 @@ export const validationFailedMessage = export const validationRecoverySuggestion = 'Try to initiate an OAuth flow from Amplify'; -export const validateState = async (state?: string | null): Promise => { +export const validateState = async ( + oAuthStore: DefaultOAuthStore, + state?: string | null, +): Promise => { const savedState = await oAuthStore.loadOAuthState(); // This is because savedState only exists if the flow was initiated by Amplify diff --git a/packages/aws-amplify/src/adapter-core/index.ts b/packages/aws-amplify/src/adapter-core/index.ts index 755f8c12b42..5e82cfbde7d 100644 --- a/packages/aws-amplify/src/adapter-core/index.ts +++ b/packages/aws-amplify/src/adapter-core/index.ts @@ -15,3 +15,13 @@ export { AmplifyServer, CookieStorage, } from '@aws-amplify/core/internals/adapter-core'; +export { + cognitoHostedUIIdentityProviderMap, + AuthProvider, +} from '@aws-amplify/auth'; +export { + generateState, + getRedirectUrl, + generateCodeVerifier, + validateState, +} from '@aws-amplify/auth/cognito'; diff --git a/packages/aws-amplify/src/adapter-core/storageFactories/createKeyValueStorageFromCookieStorageAdapter.ts b/packages/aws-amplify/src/adapter-core/storageFactories/createKeyValueStorageFromCookieStorageAdapter.ts index dffd9bc4752..aec2681a5a1 100644 --- a/packages/aws-amplify/src/adapter-core/storageFactories/createKeyValueStorageFromCookieStorageAdapter.ts +++ b/packages/aws-amplify/src/adapter-core/storageFactories/createKeyValueStorageFromCookieStorageAdapter.ts @@ -18,6 +18,7 @@ const ONE_YEAR_IN_MS = 365 * 24 * 60 * 60 * 1000; */ export const createKeyValueStorageFromCookieStorageAdapter = ( cookieStorageAdapter: CookieStorage.Adapter, + setCookieOptions: CookieStorage.SetCookieOptions = {}, ): KeyValueStorageInterface => { return { setItem(key, value) { @@ -25,6 +26,7 @@ export const createKeyValueStorageFromCookieStorageAdapter = ( cookieStorageAdapter.set(key, value, { ...defaultSetCookieOptions, expires: new Date(Date.now() + ONE_YEAR_IN_MS), + ...setCookieOptions, }); return Promise.resolve(); diff --git a/packages/aws-amplify/src/initSingleton.ts b/packages/aws-amplify/src/initSingleton.ts index b5de7deb56a..245599f7f9b 100644 --- a/packages/aws-amplify/src/initSingleton.ts +++ b/packages/aws-amplify/src/initSingleton.ts @@ -48,6 +48,14 @@ export const DefaultAmplify = { // If Auth options are provided, always just configure as is. // Otherwise, we can assume no Auth libraryOptions were provided from here on. if (libraryOptions?.Auth) { + if ( + libraryOptions.Auth.tokenProvider === cognitoUserPoolsTokenProvider && + libraryOptions.Auth.credentialsProvider === cognitoCredentialsProvider + ) { + cognitoUserPoolsTokenProvider.setAuthConfig( + resolvedResourceConfig.Auth, + ); + } Amplify.configure(resolvedResourceConfig, libraryOptions); return; diff --git a/packages/core/src/libraryUtils.ts b/packages/core/src/libraryUtils.ts index 717bfc7805a..a1548b00259 100644 --- a/packages/core/src/libraryUtils.ts +++ b/packages/core/src/libraryUtils.ts @@ -30,6 +30,7 @@ export { AmplifyUrl, AmplifyUrlSearchParams } from './utils/amplifyUrl'; export { parseAmplifyConfig } from './utils/parseAmplifyConfig'; export { getClientInfo } from './utils'; export { getDeviceName } from './utils/deviceName'; +export { runInBrowserContext } from './utils/contextAwareRunner'; // Auth utilities export { diff --git a/packages/core/src/utils/contextAwareRunner/index.ts b/packages/core/src/utils/contextAwareRunner/index.ts new file mode 100644 index 00000000000..f2386dbd6d0 --- /dev/null +++ b/packages/core/src/utils/contextAwareRunner/index.ts @@ -0,0 +1 @@ +export { runInBrowserContext } from './runInBrowserContext'; diff --git a/packages/core/src/utils/contextAwareRunner/runInBrowserContext.ts b/packages/core/src/utils/contextAwareRunner/runInBrowserContext.ts new file mode 100644 index 00000000000..b0a5cf32816 --- /dev/null +++ b/packages/core/src/utils/contextAwareRunner/runInBrowserContext.ts @@ -0,0 +1,12 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { isBrowser } from '../isBrowser'; + +export const runInBrowserContext = (fn: () => void) => { + if (!isBrowser()) { + return; + } + + fn(); +};