diff --git a/.github/integ-config/integ-all.yml b/.github/integ-config/integ-all.yml index 4fc85ee6ff4..c154aca382f 100644 --- a/.github/integ-config/integ-all.yml +++ b/.github/integ-config/integ-all.yml @@ -1011,3 +1011,12 @@ tests: browser: [chrome] env: NEXT_PUBLIC_BACKEND_CONFIG: pwl-webauthn + - test_name: integ_next_refresh_token_auth + desc: 'refresh token auth' + framework: next + category: auth + sample_name: [mfa] + spec: refresh-token-auth + browser: *minimal_browser_list + env: + NEXT_PUBLIC_BACKEND_CONFIG: misc-tokenref diff --git a/packages/adapter-nextjs/src/api/generateServerClient.ts b/packages/adapter-nextjs/src/api/generateServerClient.ts index e1c5ab09816..45c3e2bf278 100644 --- a/packages/adapter-nextjs/src/api/generateServerClient.ts +++ b/packages/adapter-nextjs/src/api/generateServerClient.ts @@ -12,9 +12,10 @@ import { V6ClientSSRRequest, } from '@aws-amplify/api-graphql'; import { - GraphQLAuthMode, - parseAmplifyConfig, -} from '@aws-amplify/core/internals/utils'; + CommonPublicClientOptions, + DefaultCommonClientOptions, +} from '@aws-amplify/api-graphql/internals'; +import { parseAmplifyConfig } from '@aws-amplify/core/internals/utils'; import { NextServer } from '../types'; @@ -23,14 +24,10 @@ import { createServerRunnerForAPI } from './createServerRunnerForAPI'; interface CookiesClientParams { cookies: NextServer.ServerComponentContext['cookies']; config: NextServer.CreateServerRunnerInput['config']; - authMode?: GraphQLAuthMode; - authToken?: string; } interface ReqClientParams { config: NextServer.CreateServerRunnerInput['config']; - authMode?: GraphQLAuthMode; - authToken?: string; } /** @@ -44,13 +41,10 @@ interface ReqClientParams { */ export function generateServerClientUsingCookies< T extends Record = never, ->({ - config, - cookies, - authMode, - authToken, -}: CookiesClientParams): V6ClientSSRCookies { - if (typeof cookies !== 'function') { + Options extends CommonPublicClientOptions & + CookiesClientParams = DefaultCommonClientOptions & CookiesClientParams, +>(options: Options): V6ClientSSRCookies { + if (typeof options.cookies !== 'function') { throw new AmplifyServerContextError({ message: 'generateServerClientUsingCookies is only compatible with the `cookies` Dynamic Function available in Server Components.', @@ -61,24 +55,25 @@ export function generateServerClientUsingCookies< } const { runWithAmplifyServerContext, resourcesConfig } = - createServerRunnerForAPI({ config }); + createServerRunnerForAPI({ config: options.config }); // This function reference gets passed down to InternalGraphQLAPI.ts.graphql // where this._graphql is passed in as the `fn` argument // causing it to always get invoked inside `runWithAmplifyServerContext` const getAmplify = (fn: (amplify: any) => Promise) => runWithAmplifyServerContext({ - nextServerContext: { cookies }, + nextServerContext: { cookies: options.cookies }, operation: contextSpec => fn(getAmplifyServerContext(contextSpec).amplify), }); - return generateClientWithAmplifyInstance>({ + const { cookies: _cookies, config: _config, ...params } = options; + + return generateClientWithAmplifyInstance>({ amplify: getAmplify, config: resourcesConfig, - authMode, - authToken, - }); + ...params, + } as any); // TS can't narrow the type here. } /** @@ -99,12 +94,15 @@ export function generateServerClientUsingCookies< */ export function generateServerClientUsingReqRes< T extends Record = never, ->({ config, authMode, authToken }: ReqClientParams): V6ClientSSRRequest { - const amplifyConfig = parseAmplifyConfig(config); + Options extends CommonPublicClientOptions & + ReqClientParams = DefaultCommonClientOptions & ReqClientParams, +>(options: Options): V6ClientSSRRequest { + const amplifyConfig = parseAmplifyConfig(options.config); + + const { config: _config, ...params } = options; return generateClient({ config: amplifyConfig, - authMode, - authToken, - }); + ...params, + }) as any; } diff --git a/packages/api-graphql/__tests__/internals/generateClient.test.ts b/packages/api-graphql/__tests__/internals/generateClient.test.ts index 94ba7bcafe4..8708cae9991 100644 --- a/packages/api-graphql/__tests__/internals/generateClient.test.ts +++ b/packages/api-graphql/__tests__/internals/generateClient.test.ts @@ -270,7 +270,14 @@ describe('generateClient', () => { const client = generateClient({ amplify: Amplify }); const spy = jest.fn(() => from([graphqlMessage])); - (raw.GraphQLAPI as any).appSyncRealTime = { subscribe: spy }; + (raw.GraphQLAPI as any).appSyncRealTime = { + get() { + return { subscribe: spy } + }, + set() { + // not needed for test mock + } + }; expect(normalizePostGraphqlCalls(spy)).toMatchSnapshot(); @@ -497,7 +504,14 @@ describe('generateClient', () => { const client = generateClient({ amplify: Amplify }); const spy = jest.fn(() => from([graphqlMessage])); - (raw.GraphQLAPI as any).appSyncRealTime = { subscribe: spy }; + (raw.GraphQLAPI as any).appSyncRealTime = { + get() { + return { subscribe: spy } + }, + set() { + // not needed for test mock + } + }; client.models.Note.onCreate({ filter: graphqlVariables.filter, @@ -531,7 +545,14 @@ describe('generateClient', () => { }); const spy = jest.fn(() => from([graphqlMessage])); - (raw.GraphQLAPI as any).appSyncRealTime = { subscribe: spy }; + (raw.GraphQLAPI as any).appSyncRealTime = { + get() { + return { subscribe: spy } + }, + set() { + // not needed for test mock + } + }; client.models.Note.onCreate({ filter: graphqlVariables.filter, @@ -561,7 +582,14 @@ describe('generateClient', () => { const client = generateClient({ amplify: Amplify }); const spy = jest.fn(() => from([graphqlMessage])); - (raw.GraphQLAPI as any).appSyncRealTime = { subscribe: spy }; + (raw.GraphQLAPI as any).appSyncRealTime = { + get() { + return { subscribe: spy } + }, + set() { + // not needed for test mock + } + }; client.models.Note.onCreate({ filter: graphqlVariables.filter, @@ -583,7 +611,14 @@ describe('generateClient', () => { const client = generateClient({ amplify: Amplify }); const spy = jest.fn(() => from([graphqlMessage])); - (raw.GraphQLAPI as any).appSyncRealTime = { subscribe: spy }; + (raw.GraphQLAPI as any).appSyncRealTime = { + get() { + return { subscribe: spy } + }, + set() { + // not needed for test mock + } + }; client.models.Note.onCreate({ filter: graphqlVariables.filter, @@ -711,7 +746,14 @@ describe('generateClient', () => { const client = generateClient({ amplify: Amplify }); const spy = jest.fn(() => from([graphqlMessage])); - (raw.GraphQLAPI as any).appSyncRealTime = { subscribe: spy }; + (raw.GraphQLAPI as any).appSyncRealTime = { + get() { + return { subscribe: spy } + }, + set() { + // not needed for test mock + } + }; client.models.Note.onCreate({ filter: graphqlVariables.filter, diff --git a/packages/api-graphql/src/internals/InternalGraphQLAPI.ts b/packages/api-graphql/src/internals/InternalGraphQLAPI.ts index f7d1a60d556..a1d93bd6cd9 100644 --- a/packages/api-graphql/src/internals/InternalGraphQLAPI.ts +++ b/packages/api-graphql/src/internals/InternalGraphQLAPI.ts @@ -52,7 +52,7 @@ export class InternalGraphQLAPIClass { /** * @private */ - private appSyncRealTime = new AWSAppSyncRealTimeProvider(); + private appSyncRealTime = new Map(); private _api = { post, @@ -88,7 +88,14 @@ export class InternalGraphQLAPIClass { amplify: | AmplifyClassV6 | ((fn: (amplify: any) => Promise) => Promise), - { query: paramQuery, variables = {}, authMode, authToken }: GraphQLOptions, + { + query: paramQuery, + variables = {}, + authMode, + authToken, + endpoint, + apiKey, + }: GraphQLOptions, additionalHeaders?: CustomHeaders, customUserAgentDetails?: CustomUserAgentDetails, ): Observable> | Promise> { @@ -115,7 +122,7 @@ export class InternalGraphQLAPIClass { if (isAmplifyInstance(amplify)) { responsePromise = this._graphql( amplify, - { query, variables, authMode }, + { query, variables, authMode, apiKey, endpoint }, headers, abortController, customUserAgentDetails, @@ -127,7 +134,7 @@ export class InternalGraphQLAPIClass { const wrapper = async (amplifyInstance: AmplifyClassV6) => { const result = await this._graphql( amplifyInstance, - { query, variables, authMode }, + { query, variables, authMode, apiKey, endpoint }, headers, abortController, customUserAgentDetails, @@ -152,7 +159,7 @@ export class InternalGraphQLAPIClass { case 'subscription': return this._graphqlSubscribe( amplify as AmplifyClassV6, - { query, variables, authMode }, + { query, variables, authMode, apiKey, endpoint }, headers, customUserAgentDetails, authToken, @@ -164,7 +171,13 @@ export class InternalGraphQLAPIClass { private async _graphql( amplify: AmplifyClassV6, - { query, variables, authMode: explicitAuthMode }: GraphQLOptions, + { + query, + variables, + authMode: authModeOverride, + endpoint: endpointOverride, + apiKey: apiKeyOverride, + }: GraphQLOptions, additionalHeaders: CustomHeaders = {}, abortController: AbortController, customUserAgentDetails?: CustomUserAgentDetails, @@ -179,7 +192,7 @@ export class InternalGraphQLAPIClass { defaultAuthMode, } = resolveConfig(amplify); - const initialAuthMode = explicitAuthMode || defaultAuthMode || 'iam'; + const initialAuthMode = authModeOverride || defaultAuthMode || 'iam'; // identityPool is an alias for iam. TODO: remove 'iam' in v7 const authMode = initialAuthMode === 'identityPool' ? 'iam' : initialAuthMode; @@ -205,7 +218,7 @@ export class InternalGraphQLAPIClass { const requestOptions: RequestOptions = { method: 'POST', url: new AmplifyUrl( - customEndpoint || appSyncGraphqlEndpoint || '', + endpointOverride || customEndpoint || appSyncGraphqlEndpoint || '', ).toString(), queryString: print(query as DocumentNode), }; @@ -226,7 +239,7 @@ export class InternalGraphQLAPIClass { const authHeaders = await headerBasedAuth( amplify, authMode, - apiKey, + apiKeyOverride ?? apiKey, additionalCustomHeaders, ); @@ -282,7 +295,8 @@ export class InternalGraphQLAPIClass { }; } - const endpoint = customEndpoint || appSyncGraphqlEndpoint; + const endpoint = + endpointOverride || customEndpoint || appSyncGraphqlEndpoint; if (!endpoint) { throw createGraphQLResultWithError(new GraphQLApiError(NO_ENDPOINT)); @@ -341,7 +355,13 @@ export class InternalGraphQLAPIClass { private _graphqlSubscribe( amplify: AmplifyClassV6, - { query, variables, authMode: explicitAuthMode }: GraphQLOptions, + { + query, + variables, + authMode: authModeOverride, + apiKey: apiKeyOverride, + endpoint, + }: GraphQLOptions, additionalHeaders: CustomHeaders = {}, customUserAgentDetails?: CustomUserAgentDetails, authToken?: string, @@ -349,7 +369,7 @@ export class InternalGraphQLAPIClass { const config = resolveConfig(amplify); const initialAuthMode = - explicitAuthMode || config?.defaultAuthMode || 'iam'; + authModeOverride || config?.defaultAuthMode || 'iam'; // identityPool is an alias for iam. TODO: remove 'iam' in v7 const authMode = initialAuthMode === 'identityPool' ? 'iam' : initialAuthMode; @@ -364,15 +384,26 @@ export class InternalGraphQLAPIClass { */ const { headers: libraryConfigHeaders } = resolveLibraryOptions(amplify); - return this.appSyncRealTime + const appSyncGraphqlEndpoint = endpoint ?? config?.endpoint; + + // TODO: This could probably be an exception. But, lots of tests rely on + // attempting to connect to nowhere. So, I'm treating as the opposite of + // a Chesterton's fence for now. (A fence I shouldn't build, because I don't + // know why somethings depends on its absence!) + const memoKey = appSyncGraphqlEndpoint ?? 'none'; + const realtimeProvider = + this.appSyncRealTime.get(memoKey) ?? new AWSAppSyncRealTimeProvider(); + this.appSyncRealTime.set(memoKey, realtimeProvider); + + return realtimeProvider .subscribe( { query: print(query as DocumentNode), variables, - appSyncGraphqlEndpoint: config?.endpoint, + appSyncGraphqlEndpoint, region: config?.region, authenticationType: authMode, - apiKey: config?.apiKey, + apiKey: apiKeyOverride ?? config?.apiKey, additionalHeaders, authToken, libraryConfigHeaders, diff --git a/packages/api-graphql/src/internals/generateClient.ts b/packages/api-graphql/src/internals/generateClient.ts index 2831753424b..82c0a37fac4 100644 --- a/packages/api-graphql/src/internals/generateClient.ts +++ b/packages/api-graphql/src/internals/generateClient.ts @@ -13,8 +13,10 @@ import { import { V6Client, __amplify, + __apiKey, __authMode, __authToken, + __endpoint, __headers, getInternals, } from '../types'; @@ -33,13 +35,16 @@ import { ClientGenerationParams } from './types'; * @param params * @returns */ -export function generateClient = never>( - params: ClientGenerationParams, -): V6Client { +export function generateClient< + T extends Record = never, + Options extends ClientGenerationParams = ClientGenerationParams, +>(params: Options): V6Client { const client = { [__amplify]: params.amplify, [__authMode]: params.authMode, [__authToken]: params.authToken, + [__apiKey]: 'apiKey' in params ? params.apiKey : undefined, + [__endpoint]: 'endpoint' in params ? params.endpoint : undefined, [__headers]: params.headers, graphql, cancel, @@ -53,22 +58,37 @@ export function generateClient = never>( const apiGraphqlConfig = params.amplify.getConfig().API?.GraphQL; - if (isApiGraphQLConfig(apiGraphqlConfig)) { - addSchemaToClient(client, apiGraphqlConfig, getInternals); - } else { - // This happens when the `Amplify.configure()` call gets evaluated after the `generateClient()` call. - // - // Cause: when the `generateClient()` and the `Amplify.configure()` calls are located in - // different source files, script bundlers may randomly arrange their orders in the production - // bundle. - // - // With the current implementation, the `client.models` instance created by `generateClient()` - // will be rebuilt on every `Amplify.configure()` call that's provided with a valid GraphQL - // provider configuration. - // - // TODO: revisit, and reverify this approach when enabling multiple clients for multi-endpoints - // configuration. - generateModelsPropertyOnAmplifyConfigure(client); + if (client[__endpoint]) { + if (!client[__authMode]) { + throw new Error( + 'generateClient() requires an explicit `authMode` when `endpoint` is provided.', + ); + } + if (client[__authMode] === 'apiKey' && !client[__apiKey]) { + throw new Error( + "generateClient() requires an explicit `apiKey` when `endpoint` is provided and `authMode = 'apiKey'`.", + ); + } + } + + if (!client[__endpoint]) { + if (isApiGraphQLConfig(apiGraphqlConfig)) { + addSchemaToClient(client, apiGraphqlConfig, getInternals); + } else { + // This happens when the `Amplify.configure()` call gets evaluated after the `generateClient()` call. + // + // Cause: when the `generateClient()` and the `Amplify.configure()` calls are located in + // different source files, script bundlers may randomly arrange their orders in the production + // bundle. + // + // With the current implementation, the `client.models` instance created by `generateClient()` + // will be rebuilt on every `Amplify.configure()` call that's provided with a valid GraphQL + // provider configuration. + // + // TODO: revisit, and reverify this approach when enabling multiple clients for multi-endpoints + // configuration. + generateModelsPropertyOnAmplifyConfigure(client); + } } return client as any; diff --git a/packages/api-graphql/src/internals/index.ts b/packages/api-graphql/src/internals/index.ts index b6422196706..cf48d0bed4f 100644 --- a/packages/api-graphql/src/internals/index.ts +++ b/packages/api-graphql/src/internals/index.ts @@ -7,3 +7,4 @@ export { export { graphql, cancel, isCancelError } from './v6'; export { generateClient } from './generateClient'; +export { CommonPublicClientOptions, DefaultCommonClientOptions } from './types'; diff --git a/packages/api-graphql/src/internals/server/generateClientWithAmplifyInstance.ts b/packages/api-graphql/src/internals/server/generateClientWithAmplifyInstance.ts index 8a9927d543c..eb3dc63effc 100644 --- a/packages/api-graphql/src/internals/server/generateClientWithAmplifyInstance.ts +++ b/packages/api-graphql/src/internals/server/generateClientWithAmplifyInstance.ts @@ -9,8 +9,10 @@ import { V6ClientSSRCookies, V6ClientSSRRequest, __amplify, + __apiKey, __authMode, __authToken, + __endpoint, __headers, getInternals, } from '../../types'; @@ -40,6 +42,8 @@ export function generateClientWithAmplifyInstance< [__amplify]: params.amplify, [__authMode]: params.authMode, [__authToken]: params.authToken, + [__apiKey]: 'apiKey' in params ? params.apiKey : undefined, + [__endpoint]: 'endpoint' in params ? params.endpoint : undefined, [__headers]: params.headers, graphql, cancel, @@ -48,7 +52,20 @@ export function generateClientWithAmplifyInstance< const apiGraphqlConfig = params.config?.API?.GraphQL; - if (isApiGraphQLConfig(apiGraphqlConfig)) { + if (client[__endpoint]) { + if (!client[__authMode]) { + throw new Error( + 'generateClient() requires an explicit `authMode` when `endpoint` is provided.', + ); + } + if (client[__authMode] === 'apiKey' && !client[__apiKey]) { + throw new Error( + "generateClient() requires an explicit `apiKey` when `endpoint` is provided and `authMode = 'apiKey'`.", + ); + } + } + + if (!client[__endpoint] && isApiGraphQLConfig(apiGraphqlConfig)) { addSchemaToClientWithInstance(client, params, getInternals); } diff --git a/packages/api-graphql/src/internals/types.ts b/packages/api-graphql/src/internals/types.ts index edb0ab2599f..c4983d4c85f 100644 --- a/packages/api-graphql/src/internals/types.ts +++ b/packages/api-graphql/src/internals/types.ts @@ -13,11 +13,30 @@ export type ClientGenerationParams = { amplify: AmplifyClassV6; } & CommonPublicClientOptions; -/** - * Common options that can be used on public `generateClient()` interfaces. - */ -export interface CommonPublicClientOptions { +export interface DefaultCommonClientOptions { + endpoint?: never; authMode?: GraphQLAuthMode; + apiKey?: string; authToken?: string; headers?: CustomHeaders; } + +/** + * Common options that can be used on public `generateClient()` interfaces. + */ +export type CommonPublicClientOptions = + | DefaultCommonClientOptions + | { + endpoint: string; + authMode: 'apiKey'; + apiKey: string; + authToken?: string; + headers?: CustomHeaders; + } + | { + endpoint: string; + authMode: Exclude; + apiKey?: string; + authToken?: string; + headers?: CustomHeaders; + }; diff --git a/packages/api-graphql/src/internals/v6.ts b/packages/api-graphql/src/internals/v6.ts index 553707be092..5cfa6670480 100644 --- a/packages/api-graphql/src/internals/v6.ts +++ b/packages/api-graphql/src/internals/v6.ts @@ -4,6 +4,7 @@ import { CustomHeaders } from '@aws-amplify/data-schema/runtime'; import { GraphQLAPI } from '../GraphQLAPI'; import { + CommonPublicClientOptions, GraphQLOptions, GraphQLOptionsV6, GraphQLResponseV6, @@ -98,15 +99,51 @@ import { export function graphql< FALLBACK_TYPES = unknown, TYPED_GQL_STRING extends string = string, + Options extends CommonPublicClientOptions = object, >( this: V6Client, - options: GraphQLOptionsV6, + options: GraphQLOptionsV6, additionalHeaders?: CustomHeaders, ): GraphQLResponseV6 { // inject client-level auth - const internals = getInternals(this as any); - options.authMode = options.authMode || internals.authMode; + const internals = getInternals(this); + + /** + * The custom `endpoint` specific to the client + */ + const clientEndpoint = internals.endpoint; + + /** + * The `authMode` specific to the client. + */ + const clientAuthMode = internals.authMode; + + /** + * The `apiKey` specific to the client. + */ + const clientApiKey = internals.apiKey; + + /** + * The most specific `authMode` wins. Setting an `endpoint` value without also + * setting an `authMode` value is invalid. This helps to prevent customers apps + * from unexpectedly sending auth details to endpoints the auth details do not + * belong to. + * + * This is especially pronounced for `apiKey`. When both an `endpoint` *and* + * `authMode: 'apiKey'` are provided, an explicit `apiKey` override is required + * to prevent inadvertent sending of an API's `apiKey` to an endpoint is does + * not belong to. + */ + options.authMode = options.authMode || clientAuthMode; + options.apiKey = options.apiKey ?? clientApiKey; options.authToken = options.authToken || internals.authToken; + + if (clientEndpoint && options.authMode === 'apiKey' && !options.apiKey) { + throw new Error( + "graphql() requires an explicit `apiKey` for a custom `endpoint` when `authMode = 'apiKey'`.", + ); + } + const headers = additionalHeaders || internals.headers; /** @@ -114,11 +151,13 @@ export function graphql< * Neither of these can actually be validated at runtime. Hence, we don't perform * any validation or type-guarding here. */ - const result = GraphQLAPI.graphql( // TODO: move V6Client back into this package? internals.amplify as any, - options as GraphQLOptions, + { + ...options, + endpoint: clientEndpoint, + } as GraphQLOptions, headers, ); diff --git a/packages/api-graphql/src/server/generateClient.ts b/packages/api-graphql/src/server/generateClient.ts index 09a60595231..2144866604e 100644 --- a/packages/api-graphql/src/server/generateClient.ts +++ b/packages/api-graphql/src/server/generateClient.ts @@ -5,6 +5,7 @@ import { AmplifyServer, getAmplifyServerContext, } from '@aws-amplify/core/internals/adapter-core'; +import { ResourcesConfig } from '@aws-amplify/core'; import { CustomHeaders } from '@aws-amplify/data-schema/runtime'; import { generateClientWithAmplifyInstance } from '../internals/server'; @@ -33,38 +34,35 @@ import { * }), * }); */ -export function generateClient = never>({ - config, - authMode, - authToken, -}: GenerateServerClientParams): V6ClientSSRRequest { +export function generateClient< + T extends Record = never, + Options extends GenerateServerClientParams = { config: ResourcesConfig }, +>(options: Options): V6ClientSSRRequest { // passing `null` instance because each (future model) method must retrieve a valid instance // from server context const client = generateClientWithAmplifyInstance>({ amplify: null, - config, - authMode, - authToken, + ...options, }); // TODO: improve this and the next type - const prevGraphql = client.graphql as unknown as GraphQLMethod; + const prevGraphql = client.graphql as unknown as GraphQLMethod; const wrappedGraphql = ( contextSpec: AmplifyServer.ContextSpec, - options: GraphQLOptionsV6, + innerOptions: GraphQLOptionsV6, additionalHeaders?: CustomHeaders, ) => { const amplifyInstance = getAmplifyServerContext(contextSpec).amplify; return prevGraphql.call( { [__amplify]: amplifyInstance }, - options, - additionalHeaders as any, + innerOptions, + additionalHeaders, ); }; - client.graphql = wrappedGraphql as unknown as GraphQLMethodSSR; + client.graphql = wrappedGraphql as unknown as GraphQLMethodSSR; return client; } diff --git a/packages/api-graphql/src/types/index.ts b/packages/api-graphql/src/types/index.ts index d642fff3bd1..2fa4ce08800 100644 --- a/packages/api-graphql/src/types/index.ts +++ b/packages/api-graphql/src/types/index.ts @@ -18,22 +18,26 @@ import { } from '@aws-amplify/core/internals/utils'; import { AmplifyServer } from '@aws-amplify/core/internals/adapter-core'; +import { CommonPublicClientOptions } from '../internals/types'; + export { OperationTypeNode } from 'graphql'; export { CONTROL_MSG, ConnectionState } from './PubSub'; export { SelectionSet } from '@aws-amplify/data-schema/runtime'; -export { CommonPublicClientOptions } from '../internals/types'; +export type { CommonPublicClientOptions }; /** * Loose/Unknown options for raw GraphQLAPICategory `graphql()`. */ export interface GraphQLOptions { query: string | DocumentNode; + endpoint?: string; variables?: Record; authMode?: GraphQLAuthMode; authToken?: string; + apiKey?: string; /** * @deprecated This property should not be used */ @@ -209,19 +213,68 @@ export type GraphQLOperation = Source | string; * API V6 `graphql({options})` type that can leverage branded graphql `query` * objects and fallback types. */ -export interface GraphQLOptionsV6< +export type GraphQLOptionsV6< FALLBACK_TYPES = unknown, TYPED_GQL_STRING extends string = string, -> extends Record { - query: TYPED_GQL_STRING | DocumentNode; - variables?: GraphQLVariablesV6; - authMode?: GraphQLAuthMode; - authToken?: string; - /** - * @deprecated This property should not be used - */ - userAgentSuffix?: string; -} + Options extends CommonPublicClientOptions = object, +> = Options['endpoint'] extends string + ? Options['apiKey'] extends string + ? { + query: TYPED_GQL_STRING | DocumentNode; + variables?: GraphQLVariablesV6; + authMode?: GraphQLAuthMode; + apiKey?: string; + authToken?: string; + /** + * @deprecated This property should not be used + */ + userAgentSuffix?: string; + } + : + | { + query: TYPED_GQL_STRING | DocumentNode; + variables?: GraphQLVariablesV6; + authMode?: never; + apiKey?: never; + authToken?: string; + /** + * @deprecated This property should not be used + */ + userAgentSuffix?: string; + } + | { + query: TYPED_GQL_STRING | DocumentNode; + variables?: GraphQLVariablesV6; + authMode: 'apiKey'; + apiKey: string; + authToken?: string; + /** + * @deprecated This property should not be used + */ + userAgentSuffix?: string; + } + | { + query: TYPED_GQL_STRING | DocumentNode; + variables?: GraphQLVariablesV6; + authMode: Exclude; + apiKey?: never; + authToken?: string; + /** + * @deprecated This property should not be used + */ + userAgentSuffix?: string; + } + : { + query: TYPED_GQL_STRING | DocumentNode; + variables?: GraphQLVariablesV6; + authMode?: GraphQLAuthMode; + apiKey?: string; + authToken?: string; + /** + * @deprecated This property should not be used + */ + userAgentSuffix?: string; + }; /** * Result type for `graphql()` operations that don't include any specific @@ -369,15 +422,22 @@ export type GeneratedSubscription = string & { export const __amplify = Symbol('amplify'); export const __authMode = Symbol('authMode'); export const __authToken = Symbol('authToken'); +export const __apiKey = Symbol('apiKey'); export const __headers = Symbol('headers'); +export const __endpoint = Symbol('endpoint'); -export function getInternals(client: BaseClient): ClientInternals { +export function getInternals(client: BaseClient): ClientInternals & { + apiKey?: string; + endpoint?: string; +} { const c = client as any; return { amplify: c[__amplify], + apiKey: c[__apiKey], authMode: c[__authMode], authToken: c[__authToken], + endpoint: c[__endpoint], headers: c[__headers], } as any; } @@ -387,38 +447,47 @@ export type ClientWithModels = | V6ClientSSRRequest | V6ClientSSRCookies; -export type V6Client = never> = { - graphql: GraphQLMethod; +export type V6Client< + T extends Record = never, + Options extends CommonPublicClientOptions = object, +> = { + graphql: GraphQLMethod; cancel(promise: Promise, message?: string): boolean; isCancelError(error: any): boolean; } & ClientExtensions; -export type V6ClientSSRRequest = never> = { - graphql: GraphQLMethodSSR; +export type V6ClientSSRRequest< + T extends Record = never, + Options extends CommonPublicClientOptions = object, +> = { + graphql: GraphQLMethodSSR; cancel(promise: Promise, message?: string): boolean; isCancelError(error: any): boolean; } & ClientExtensionsSSRRequest; -export type V6ClientSSRCookies = never> = { - graphql: GraphQLMethod; +export type V6ClientSSRCookies< + T extends Record = never, + Options extends CommonPublicClientOptions = object, +> = { + graphql: GraphQLMethod; cancel(promise: Promise, message?: string): boolean; isCancelError(error: any): boolean; } & ClientExtensionsSSRCookies; -export type GraphQLMethod = < +export type GraphQLMethod = < FALLBACK_TYPES = unknown, TYPED_GQL_STRING extends string = string, >( - options: GraphQLOptionsV6, + options: GraphQLOptionsV6, additionalHeaders?: CustomHeaders | undefined, ) => GraphQLResponseV6; -export type GraphQLMethodSSR = < +export type GraphQLMethodSSR = < FALLBACK_TYPES = unknown, TYPED_GQL_STRING extends string = string, >( contextSpec: AmplifyServer.ContextSpec, - options: GraphQLOptionsV6, + options: GraphQLOptionsV6, additionalHeaders?: CustomHeaders | undefined, ) => GraphQLResponseV6; @@ -450,8 +519,6 @@ export interface AuthModeParams extends Record { authToken?: string; } -export interface GenerateServerClientParams { +export type GenerateServerClientParams = { config: ResourcesConfig; - authMode?: GraphQLAuthMode; - authToken?: string; -} +} & CommonPublicClientOptions; diff --git a/packages/api/__mocks__/@aws-amplify/api-rest/internals/index.ts b/packages/api/__mocks__/@aws-amplify/api-rest/internals/index.ts deleted file mode 100644 index e53f52b1f93..00000000000 --- a/packages/api/__mocks__/@aws-amplify/api-rest/internals/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const cancel = jest.fn(() => true); diff --git a/packages/api/__tests__/API.test.ts b/packages/api/__tests__/API.test.ts index ab95bf9dcc9..21b0b77cec8 100644 --- a/packages/api/__tests__/API.test.ts +++ b/packages/api/__tests__/API.test.ts @@ -1,81 +1,1148 @@ -import { ResourcesConfig } from 'aws-amplify'; -import { InternalGraphQLAPIClass } from '@aws-amplify/api-graphql/internals'; +import { enableFetchMocks } from 'jest-fetch-mock'; +import { Amplify } from '@aws-amplify/core'; +import { GraphQLAPI } from '@aws-amplify/api-graphql'; import { generateClient, CONNECTION_STATE_CHANGE } from '@aws-amplify/api'; -import { AmplifyClassV6 } from '@aws-amplify/core'; -// import { runWithAmplifyServerContext } from 'aws-amplify/internals/adapter-core'; - -const serverManagedFields = { - id: 'some-id', - owner: 'wirejobviously', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), +import { generateServerClientUsingCookies, generateServerClientUsingReqRes } from '@aws-amplify/adapter-nextjs/api'; +import { generateClientWithAmplifyInstance } from '@aws-amplify/api/internals'; +import { Observable } from 'rxjs'; +import { decodeJWT } from '@aws-amplify/core'; + +// Make global `Request` available. (Necessary for using `adapter-nextjs` clients.) +enableFetchMocks(); + +type AuthMode = + | 'apiKey' + | 'oidc' + | 'userPool' + | 'iam' + | 'identityPool' + | 'lambda' + | 'none'; + +const DEFAULT_AUTH_MODE = 'apiKey'; +const DEFAULT_API_KEY = 'FAKE-KEY'; +const CUSTOM_API_KEY = 'CUSTOM-API-KEY'; + +const DEFAULT_ENDPOINT = 'https://a-default-appsync-endpoint.local/graphql'; +const CUSTOM_ENDPOINT = 'https://a-custom-appsync-endpoint.local/graphql'; + +/** + * Validly parsable JWT string. (Borrowed from Auth tests.) + */ +const DEFAULT_AUTH_TOKEN = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE3MTAyOTMxMzB9.YzDpgJsrB3z-ZU1XxMcXSQsMbgCzwH_e-_76rnfehh0'; + +const _postSpy = jest.spyOn((GraphQLAPI as any)._api, 'post'); +const _subspy = jest.fn(); + +/** + * Should be called on every subscription, ensuring that realtime provider instances + * are re-used for each distinct endpoint. + */ +const _setProviderSpy = jest.fn(); + +(GraphQLAPI as any).appSyncRealTime = { + get() { + return { subscribe: _subspy } + }, + set: _setProviderSpy }; -describe('API generateClient', () => { +/** + * Validates that a specific "post" occurred (against `_postSpy`). + * + * @param options + */ +function expectPost({ + endpoint, + authMode, + apiKeyOverride, + authTokenOverride, +}: { + endpoint: string; + authMode: AuthMode; + apiKeyOverride: string | undefined; + authTokenOverride: string | undefined; +}) { + // + // Grabbing the call and asserting on the object is significantly simpler for some + // of the is-unknown-or-absent types of assertions we need. + // + // It is also incidentally much simpler for most the other assertions too ... + // + const postOptions = _postSpy.mock.calls[0][1] as { + // just the things we care about + url: URL; + options: { + headers: Record; + }; + }; + + expect(postOptions.url.toString()).toEqual(endpoint); + + if (authMode === 'apiKey') { + expect(postOptions.options.headers['X-Api-Key']).toEqual( + apiKeyOverride ?? DEFAULT_API_KEY, + ); + } else { + expect(postOptions.options.headers['X-Api-Key']).toBeUndefined(); + } + + if (['oidc', 'userPool'].includes(authMode)) { + expect(postOptions.options.headers['Authorization']).toEqual( + authTokenOverride ?? DEFAULT_AUTH_TOKEN, + ); + } else { + expect(postOptions.options.headers['Authorization']).toBeUndefined(); + } +} + +/** + * Validates that a specific subscription occurred (against `_subSpy`). + * + * @param options + */ +function expectSubscription({ + endpoint, + authMode, + apiKeyOverride, + authTokenOverride, +}: { + endpoint: string; + authMode: AuthMode; + apiKeyOverride: string | undefined; + authTokenOverride: string | undefined; +}) { + // `authMode` is provided to appsync provider, which then determines how to + // handle auth internally. + expect(_subspy).toHaveBeenCalledWith( + expect.objectContaining({ + appSyncGraphqlEndpoint: endpoint, + authenticationType: authMode, + + // appsync provider only receive an authToken if it has been explicitly overridden. + authToken: authTokenOverride, + + // appsync provider already receive an apiKey. + // (but it should not send it unless authMode is apiKey.) + apiKey: apiKeyOverride ?? DEFAULT_API_KEY, + }), + expect.anything(), + ); + expect(_setProviderSpy).toHaveBeenCalledWith(endpoint, expect.anything()); +} + +/** + * Validates that a specific operation was submitted to the correct underlying + * execution mechanism (post or AppSyncRealtime). + * + * @param param0 + */ +function expectOp({ + op, + endpoint, + authMode, + apiKeyOverride, + authTokenOverride, +}: { + op: 'subscription' | 'query'; + endpoint: string; + authMode: AuthMode; + apiKeyOverride?: string | undefined; + authTokenOverride?: string | undefined; +}) { + const doExpect = op === 'subscription' ? expectSubscription : expectPost; + doExpect({ endpoint, authMode, apiKeyOverride, authTokenOverride }); // test pass ... umm ... +} + +function prepareMocks() { + Amplify.configure( + { + API: { + GraphQL: { + defaultAuthMode: DEFAULT_AUTH_MODE, + apiKey: DEFAULT_API_KEY, + endpoint: DEFAULT_ENDPOINT, + region: 'north-pole-7', + }, + }, + Auth: { + Cognito: { + userPoolId: 'north-pole-7:santas-little-helpers', + identityPoolId: 'north-pole-7:santas-average-sized-helpers', + userPoolClientId: 'the-mrs-claus-oversight-committee', + }, + }, + }, + { + Auth: { + credentialsProvider: { + getCredentialsAndIdentityId: async arg => ({ + credentials: { + accessKeyId: 'accessKeyIdValue', + secretAccessKey: 'secretAccessKeyValue', + sessionToken: 'sessionTokenValue', + expiration: new Date(123), + }, + identityId: 'mrs-clause-naturally', + }), + clearCredentialsAndIdentityId: async () => {}, + }, + tokenProvider: { + getTokens: async () => ({ + accessToken: decodeJWT(DEFAULT_AUTH_TOKEN), + }), + }, + }, + }, + ); + _postSpy.mockReturnValue({ + body: { + json() { + return JSON.stringify({ + data: { + someOperation: { + someField: 'some value', + }, + }, + }); + }, + }, + }); + _subspy.mockReturnValue(new Observable()); +} + +describe('generateClient (web)', () => { + beforeEach(() => { + prepareMocks() + }); + afterEach(() => { jest.clearAllMocks(); }); - test('client-side client.graphql', async () => { - jest.spyOn(AmplifyClassV6.prototype, 'getConfig').mockImplementation(() => { - return { - API: { GraphQL: { endpoint: 'test', defaultAuthMode: 'none' } }, - }; + for (const op of ['query', 'subscription'] as const) { + const opType = op === 'subscription' ? 'sub' : 'qry'; + + describe(`[${opType}] without a custom endpoint`, () => { + test("does not require `authMode` or `apiKey` override", () => { + expect(() => { generateClient() }).not.toThrow(); + }); + + test("does not require `authMode` or `apiKey` override in client.graphql()", async () => { + const client = generateClient(); + + await client.graphql({ query: `${op} A { queryA { a b c } }` }); + + expectOp({ + op, + endpoint: DEFAULT_ENDPOINT, + authMode: DEFAULT_AUTH_MODE, + }); + }); + + test("allows `authMode` override in client", async () => { + const client = generateClient({ + authMode: 'userPool', + }); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + }); + + expectOp({ + op, + endpoint: DEFAULT_ENDPOINT, + authMode: 'userPool', + }); + }); + + test("allows `authMode` override in `client.graphql()`", async () => { + const client = generateClient(); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + authMode: 'userPool', + }); + + expectOp({ + op, + endpoint: DEFAULT_ENDPOINT, + authMode: 'userPool', + }); + }); + + test("allows `apiKey` override in `client.graphql()`", async () => { + const client = generateClient(); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + apiKey: CUSTOM_API_KEY, + }); + + expectOp({ + op, + endpoint: DEFAULT_ENDPOINT, + authMode: 'apiKey', + apiKeyOverride: CUSTOM_API_KEY + }); + }); + + test("allows `authMode` + `apiKey` override in `client.graphql()`", async () => { + const client = generateClient({ + authMode: 'userPool' + }); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + authMode: 'apiKey', + apiKey: CUSTOM_API_KEY, + }); + + expectOp({ + op, + endpoint: DEFAULT_ENDPOINT, + authMode: 'apiKey', + apiKeyOverride: CUSTOM_API_KEY + }); + }); }); - const spy = jest - .spyOn(InternalGraphQLAPIClass.prototype, 'graphql') - .mockResolvedValue('grapqhqlResponse' as any); - const client = generateClient(); - expect(await client.graphql({ query: 'query' })).toBe('grapqhqlResponse'); - expect(spy).toHaveBeenCalledWith( - { Auth: {}, libraryOptions: {}, resourcesConfig: {} }, - { query: 'query' }, - undefined, - { - action: '1', - category: 'api', - }, - ); + + describe(`[${opType}] with a custom endpoint`, () => { + test("requires `authMode` override", () => { + // @ts-expect-error + expect(() => generateClient({ + endpoint: CUSTOM_ENDPOINT + })).toThrow() + }) + + test("requires `apiKey` with `authMode: 'apiKey'` override in client", async () => { + expect(() => { + generateClient({ + endpoint: CUSTOM_ENDPOINT, + // @ts-expect-error + authMode: 'apiKey', + }) + }).toThrow(); + }); + + test("allows `authMode` override in client", async () => { + const client = generateClient({ + endpoint: CUSTOM_ENDPOINT, + authMode: 'userPool', + }); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + }); + + expectOp({ + op, + endpoint: CUSTOM_ENDPOINT, + authMode: 'userPool', + }); + }); + + test("allows `authMode: 'none'` override in client.graphql()", async () => { + const client = generateClient({ + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + }); + + expectOp({ + op, + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + }); + + test("allows `authMode: 'apiKey'` + `apiKey` override in client", async () => { + const client = generateClient({ + endpoint: CUSTOM_ENDPOINT, + authMode: 'apiKey', + apiKey: CUSTOM_API_KEY + }); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + }); + + expectOp({ + op, + endpoint: CUSTOM_ENDPOINT, + authMode: 'apiKey', + apiKeyOverride: CUSTOM_API_KEY + }); + }); + + test("allows `authMode` override in client.graphql()", async () => { + const client = generateClient({ + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + authMode: 'userPool' + }); + + expectOp({ + op, + endpoint: CUSTOM_ENDPOINT, + authMode: 'userPool', + }); + }); + + test("requires `apiKey` with `authMode: 'apiKey'` override in client.graphql()", async () => { + const client = generateClient({ + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + + // @ts-expect-error + expect(() => client.graphql({ + query: `${op} A { queryA { a b c } }`, + authMode: 'apiKey' + })).toThrow() + }); + + test("allows `authMode: 'apiKey'` + `apiKey` override in client.graphql()", async () => { + const client = generateClient({ + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + authMode: 'apiKey', + apiKey: CUSTOM_API_KEY + }); + + expectOp({ + op, + endpoint: CUSTOM_ENDPOINT, + authMode: 'apiKey', + apiKeyOverride: CUSTOM_API_KEY + }); + }); + }) + }; +}); + +describe('generateClient (cookie client)', () => { + + /** + * NOTICE + * + * Cookie client is largely a pass-thru to `generateClientWithAmplifyInstance`. + * + * These tests intend to cover narrowing rules on the public surface. Behavior is + * tested in the `SSR common` describe block. + */ + + beforeEach(() => { + prepareMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + const cookies = () => ({ + get() { return undefined }, + getAll() { return [] }, + has() { return false }, + }) as any; + + describe('typings', () => { + /** + * Static / Type tests only. + * + * (No executed intended or expected.) + */ + + describe('without a custom endpoint', () => { + test("do not require `authMode` or `apiKey` override", () => { + // expect no type error + () => generateServerClientUsingCookies({ + config: Amplify.getConfig(), + cookies + }); + }); + + test("do not require `authMode` or `apiKey` override in client.graphql()", () => { + async () => { + const client = generateServerClientUsingCookies({ + config: Amplify.getConfig(), + cookies + }); + await client.graphql({ query: `query A { queryA { a b c } }` }); + } + }); + + test("allows `authMode` override in client", () => { + async () => { + const client = generateServerClientUsingCookies({ + config: Amplify.getConfig(), + cookies, + authMode: 'userPool', + }); + + await client.graphql({ + query: `query A { queryA { a b c } }`, + }); + } + }); + + test("allow `authMode` override in `client.graphql()`", () => { + async () => { + const client = generateServerClientUsingCookies({ + config: Amplify.getConfig(), + cookies, + }); + + await client.graphql({ + query: `query A { queryA { a b c } }`, + authMode: 'userPool', + }); + } + }); + + test("allows `apiKey` override in `client.graphql()`", () => { + async () => { + const client = generateServerClientUsingCookies({ + config: Amplify.getConfig(), + cookies, + }); + + await client.graphql({ + query: `query A { queryA { a b c } }`, + apiKey: CUSTOM_API_KEY, + }); + } + }); + + test("allows `authMode` + `apiKey` override in `client.graphql()`", () => { + async () => { + const client = generateServerClientUsingCookies({ + config: Amplify.getConfig(), + cookies, + authMode: 'userPool' + }); + + await client.graphql({ + query: `query A { queryA { a b c } }`, + authMode: 'apiKey', + apiKey: CUSTOM_API_KEY, + }); + } + }); + }) + + describe('with a custom endpoint', () => { + test("requires `authMode` override", () => { + // @ts-expect-error + () => generateServerClientUsingCookies({ + config: Amplify.getConfig(), + cookies, + endpoint: CUSTOM_ENDPOINT + }); + }) + + test("requires `apiKey` with `authMode: 'apiKey'` override in client", () => { + // @ts-expect-error + () => generateServerClientUsingCookies({ + config: Amplify.getConfig(), + cookies, + endpoint: CUSTOM_ENDPOINT, + authMode: 'apiKey', + }); + }); + + test("allows `authMode` override in client", () => { + async () => { + const client = generateServerClientUsingCookies({ + config: Amplify.getConfig(), + cookies, + endpoint: CUSTOM_ENDPOINT, + authMode: 'userPool', + }); + + await client.graphql({ + query: `query A { queryA { a b c } }`, + }); + } + }); + + test("allows `authMode: 'none'` override in client.graphql()", () => { + async () => { + const client = generateServerClientUsingCookies({ + config: Amplify.getConfig(), + cookies, + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + + await client.graphql({ + query: `query A { queryA { a b c } }`, + }); + } + }); + + test("allows `authMode: 'apiKey'` + `apiKey` override in client", () => { + async () => { + const client = generateServerClientUsingCookies({ + config: Amplify.getConfig(), + cookies, + endpoint: CUSTOM_ENDPOINT, + authMode: 'apiKey', + apiKey: CUSTOM_API_KEY + }); + + await client.graphql({ + query: `query A { queryA { a b c } }`, + }); + } + }); + + test("allows `authMode` override in client.graphql()", () => { + async () => { + const client = generateServerClientUsingCookies({ + config: Amplify.getConfig(), + cookies, + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + + await client.graphql({ + query: `query A { queryA { a b c } }`, + authMode: 'userPool' + }); + } + }); + + test("requires `apiKey` with `authMode: 'apiKey'` override in client.graphql()", () => { + async () => { + const client = generateServerClientUsingCookies({ + config: Amplify.getConfig(), + cookies, + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + + // @ts-expect-error + await client.graphql({ + query: `query A { queryA { a b c } }`, + authMode: 'apiKey' + }); + } + }); + + test("allows `authMode: 'apiKey'` + `apiKey` override in client.graphql()", () => { + async () => { + const client = generateServerClientUsingCookies({ + config: Amplify.getConfig(), + cookies, + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + + await client.graphql({ + query: `query A { queryA { a b c } }`, + authMode: 'apiKey', + apiKey: CUSTOM_API_KEY + }); + } + }); + }) + + }); +}); + +describe('generateClient (req/res client)', () => { + + /** + * NOTICE + * + * ReqRes client is largely a pass-thru to `server/generateClient`, which is a pass-thru + * to `generateClientWithAmplifyInstance` (with add Amplify instance). + * + * These tests intend to cover narrowing rules on the public surface. Behavior is + * tested in the `SSR common` describe block. + */ + + beforeEach(() => { + prepareMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); }); - test('CONNECTION_STATE_CHANGE importable as a value, not a type', async () => { - expect(CONNECTION_STATE_CHANGE).toBe('ConnectionStateChange'); + const cookies = () => ({ + get() { return undefined }, + getAll() { return [] }, + has() { return false }, + }) as any; + + const contextSpec = {} as any; + + describe('typings', () => { + /** + * Static / Type tests only. + * + * (No executed intended or expected.) + */ + + describe('without a custom endpoint', () => { + test("do not require `authMode` or `apiKey` override", () => { + // expect no type error + () => generateServerClientUsingReqRes({ + config: Amplify.getConfig(), + }); + }); + + test("do not require `authMode` or `apiKey` override in client.graphql()", () => { + async () => { + const client = generateServerClientUsingReqRes({ + config: Amplify.getConfig(), + }); + await client.graphql(contextSpec, { query: `query A { queryA { a b c } }` }); + } + }); + + test("allows `authMode` override in client", () => { + async () => { + const client = generateServerClientUsingReqRes({ + config: Amplify.getConfig(), + authMode: 'userPool', + }); + + await client.graphql(contextSpec, { + query: `query A { queryA { a b c } }`, + }); + } + }); + + test("allow `authMode` override in `client.graphql()`", () => { + async () => { + const client = generateServerClientUsingReqRes({ + config: Amplify.getConfig(), + }); + + await client.graphql(contextSpec, { + query: `query A { queryA { a b c } }`, + authMode: 'userPool', + }); + } + }); + + test("allows `apiKey` override in `client.graphql()`", () => { + async () => { + const client = generateServerClientUsingReqRes({ + config: Amplify.getConfig(), + }); + + await client.graphql(contextSpec, { + query: `query A { queryA { a b c } }`, + apiKey: CUSTOM_API_KEY, + }); + } + }); + + test("allows `authMode` + `apiKey` override in `client.graphql()`", () => { + async () => { + const client = generateServerClientUsingReqRes({ + config: Amplify.getConfig(), + authMode: 'userPool' + }); + + await client.graphql(contextSpec, { + query: `query A { queryA { a b c } }`, + authMode: 'apiKey', + apiKey: CUSTOM_API_KEY, + }); + } + }); + }) + + describe('with a custom endpoint', () => { + test("requires `authMode` override", () => { + // @ts-expect-error + () => generateServerClientUsingReqRes({ + config: Amplify.getConfig(), + endpoint: CUSTOM_ENDPOINT + }); + }) + + test("requires `apiKey` with `authMode: 'apiKey'` override in client", () => { + // @ts-expect-error + () => generateServerClientUsingReqRes({ + config: Amplify.getConfig(), + endpoint: CUSTOM_ENDPOINT, + authMode: 'apiKey', + }); + }); + + test("allows `authMode` override in client", () => { + async () => { + const client = generateServerClientUsingReqRes({ + config: Amplify.getConfig(), + endpoint: CUSTOM_ENDPOINT, + authMode: 'userPool', + }); + + await client.graphql(contextSpec, { + query: `query A { queryA { a b c } }`, + }); + } + }); + + test("allows `authMode: 'none'` override in client.graphql()", () => { + async () => { + const client = generateServerClientUsingReqRes({ + config: Amplify.getConfig(), + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + + await client.graphql(contextSpec, { + query: `query A { queryA { a b c } }`, + }); + } + }); + + test("allows `authMode: 'apiKey'` + `apiKey` override in client", () => { + async () => { + const client = generateServerClientUsingReqRes({ + config: Amplify.getConfig(), + endpoint: CUSTOM_ENDPOINT, + authMode: 'apiKey', + apiKey: CUSTOM_API_KEY + }); + + await client.graphql(contextSpec, { + query: `query A { queryA { a b c } }`, + }); + } + }); + + test("allows `authMode` override in client.graphql()", () => { + async () => { + const client = generateServerClientUsingReqRes({ + config: Amplify.getConfig(), + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + + await client.graphql(contextSpec, { + query: `query A { queryA { a b c } }`, + authMode: 'userPool' + }); + } + }); + + test("requires `apiKey` with `authMode: 'apiKey'` override in client.graphql()", () => { + async () => { + const client = generateServerClientUsingReqRes({ + config: Amplify.getConfig(), + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + + // @ts-expect-error + await client.graphql(contextSpec, { + query: `query A { queryA { a b c } }`, + authMode: 'apiKey' + }); + } + }); + + test("allows `authMode: 'apiKey'` + `apiKey` override in client.graphql()", () => { + async () => { + const client = generateServerClientUsingReqRes({ + config: Amplify.getConfig(), + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + + await client.graphql(contextSpec, { + query: `query A { queryA { a b c } }`, + authMode: 'apiKey', + apiKey: CUSTOM_API_KEY + }); + } + }); + }) + }); - // test('server-side client.graphql', async () => { - // const config: ResourcesConfig = { - // API: { - // GraphQL: { - // apiKey: 'adsf', - // customEndpoint: undefined, - // customEndpointRegion: undefined, - // defaultAuthMode: 'apiKey', - // endpoint: 'https://0.0.0.0/graphql', - // region: 'us-east-1', - // }, - // }, - // }; - - // const query = `query Q { - // getWidget { - // __typename id owner createdAt updatedAt someField - // } - // }`; - - // const spy = jest - // .spyOn(InternalGraphQLAPIClass.prototype, 'graphql') - // .mockResolvedValue('grapqhqlResponse' as any); - - // await runWithAmplifyServerContext(config, {}, ctx => { - // const client = generateClientSSR(ctx); - // return client.graphql({ query }) as any; - // }); - - // expect(spy).toHaveBeenCalledWith( - // expect.objectContaining({ - // resourcesConfig: config, - // }), - // { query }, - // undefined - // ); - // }); }); + +describe('SSR common', () => { + /** + * NOTICE + * + * This tests the runtime validation behavior common to both SSR clients. + * + * 1. Cookie client uses `generateClientWithAmplifyInstance` directly. + * 2. ReqRest client uses `server/generateClient`. + * 3. `server/generateClient` is a pass-thru to `generateClientWithAmplifyInstance` that + * injects an `Amplify` instance. + * + * The runtime validations we need to check funnel through `generateClientWithAmplifyInstance`. + */ + + beforeEach(() => { + prepareMocks(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + for (const op of ['query', 'subscription'] as const) { + const opType = op === 'subscription' ? 'sub' : 'qry'; + + describe(`[${opType}] without a custom endpoint`, () => { + test("does not require `authMode` or `apiKey` override", () => { + expect(() => generateClientWithAmplifyInstance({ + amplify: Amplify as any, + config: Amplify.getConfig(), + })).not.toThrow(); + }); + + test("does not require `authMode` or `apiKey` override in client.graphql()", async () => { + const client = generateClientWithAmplifyInstance({ + amplify: Amplify as any, + config: Amplify.getConfig(), + }); + + await client.graphql({ query: `${op} A { queryA { a b c } }` }); + + expectOp({ + op, + endpoint: DEFAULT_ENDPOINT, + authMode: DEFAULT_AUTH_MODE, + }); + }); + + test("allows `authMode` override in client", async () => { + const client = generateClientWithAmplifyInstance({ + amplify: Amplify as any, + config: Amplify.getConfig(), + authMode: 'userPool', + }); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + }); + + expectOp({ + op, + endpoint: DEFAULT_ENDPOINT, + authMode: 'userPool', + }); + }); + + test("allows `authMode` override in `client.graphql()`", async () => { + const client = generateClientWithAmplifyInstance({ + amplify: Amplify as any, + config: Amplify.getConfig(), + }); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + authMode: 'userPool', + }); + + expectOp({ + op, + endpoint: DEFAULT_ENDPOINT, + authMode: 'userPool', + }); + }); + + test("allows `apiKey` override in `client.graphql()`", async () => { + const client = generateClientWithAmplifyInstance({ + amplify: Amplify as any, + config: Amplify.getConfig(), + }); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + apiKey: CUSTOM_API_KEY, + }); + + expectOp({ + op, + endpoint: DEFAULT_ENDPOINT, + authMode: 'apiKey', + apiKeyOverride: CUSTOM_API_KEY + }); + }); + + test("allows `authMode` + `apiKey` override in `client.graphql()`", async () => { + const client = generateClientWithAmplifyInstance({ + amplify: Amplify as any, + config: Amplify.getConfig(), + authMode: 'userPool' + }); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + authMode: 'apiKey', + apiKey: CUSTOM_API_KEY, + }); + + expectOp({ + op, + endpoint: DEFAULT_ENDPOINT, + authMode: 'apiKey', + apiKeyOverride: CUSTOM_API_KEY + }); + }); + }); + + describe(`[${opType}] with a custom endpoint`, () => { + test("requires `authMode` override", () => { + // @ts-expect-error + expect(() => generateClientWithAmplifyInstance({ + amplify: Amplify as any, + config: Amplify.getConfig(), + endpoint: CUSTOM_ENDPOINT + })).toThrow() + }) + + test("requires `apiKey` with `authMode: 'apiKey'` override in client", async () => { + // @ts-expect-error + expect(() => generateClientWithAmplifyInstance({ + amplify: Amplify as any, + config: Amplify.getConfig(), + endpoint: CUSTOM_ENDPOINT, + authMode: 'apiKey', + })).toThrow(); + }); + + test("allows `authMode` override in client", async () => { + const client = generateClientWithAmplifyInstance({ + amplify: Amplify as any, + config: Amplify.getConfig(), + endpoint: CUSTOM_ENDPOINT, + authMode: 'userPool', + }); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + }); + + expectOp({ + op, + endpoint: CUSTOM_ENDPOINT, + authMode: 'userPool', + }); + }); + + test("allows `authMode: 'none'` override in client.graphql()", async () => { + const client = generateClientWithAmplifyInstance({ + amplify: Amplify as any, + config: Amplify.getConfig(), + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + }); + + expectOp({ + op, + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + }); + + test("allows `authMode: 'apiKey'` + `apiKey` override in client", async () => { + const client = generateClientWithAmplifyInstance({ + amplify: Amplify as any, + config: Amplify.getConfig(), + endpoint: CUSTOM_ENDPOINT, + authMode: 'apiKey', + apiKey: CUSTOM_API_KEY + }); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + }); + + expectOp({ + op, + endpoint: CUSTOM_ENDPOINT, + authMode: 'apiKey', + apiKeyOverride: CUSTOM_API_KEY + }); + }); + + test("allows `authMode` override in client.graphql()", async () => { + const client = generateClientWithAmplifyInstance({ + amplify: Amplify as any, + config: {}, + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + authMode: 'userPool' + }); + + expectOp({ + op, + endpoint: CUSTOM_ENDPOINT, + authMode: 'userPool', + }); + }); + + test("requires `apiKey` with `authMode: 'apiKey'` override in client.graphql()", async () => { + // no TS expect error here. types for `generateClientWithAmplifyInstance` have been simplified + // because they are not customer-facing. + const client = generateClientWithAmplifyInstance({ + amplify: Amplify as any, + config: Amplify.getConfig(), + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + + expect(() => client.graphql({ + query: `${op} A { queryA { a b c } }`, + authMode: 'apiKey' + })).toThrow() + }); + + test("allows `authMode: 'apiKey'` + `apiKey` override in client.graphql()", async () => { + const client = generateClientWithAmplifyInstance({ + amplify: Amplify as any, + config: Amplify.getConfig(), + endpoint: CUSTOM_ENDPOINT, + authMode: 'none', + }); + + await client.graphql({ + query: `${op} A { queryA { a b c } }`, + authMode: 'apiKey', + apiKey: CUSTOM_API_KEY + }); + + expectOp({ + op, + endpoint: CUSTOM_ENDPOINT, + authMode: 'apiKey', + apiKeyOverride: CUSTOM_API_KEY + }); + }); + }) + }; +}) diff --git a/packages/api/__tests__/SSR.test.ts b/packages/api/__tests__/SSR.test.ts new file mode 100644 index 00000000000..5b1b79d8dff --- /dev/null +++ b/packages/api/__tests__/SSR.test.ts @@ -0,0 +1,106 @@ +import { enableFetchMocks } from 'jest-fetch-mock'; +import { Amplify, ResourcesConfig } from 'aws-amplify'; + +// allows SSR function to be invoked without catastrophically failing out of the gate. +enableFetchMocks(); + +const generateClientWithAmplifyInstanceSpy = jest.fn(); +jest.mock('@aws-amplify/api/internals', () => ({ + generateClientWithAmplifyInstance: generateClientWithAmplifyInstanceSpy +})); + +const generateClientSpy = jest.fn(); +jest.mock('aws-amplify/api/server', () => ({ + generateClient: generateClientSpy +})); + +const { + generateServerClientUsingCookies, + generateServerClientUsingReqRes, +} = require('@aws-amplify/adapter-nextjs/api'); + +describe('SSR internals', () => { + beforeEach(() => { + Amplify.configure( + { + API: { + GraphQL: { + defaultAuthMode: 'apiKey', + apiKey: 'a-key', + endpoint: 'https://an-endpoint.local/graphql', + region: 'north-pole-7', + }, + }, + Auth: { + Cognito: { + userPoolId: 'north-pole-7:santas-little-helpers', + identityPoolId: 'north-pole-7:santas-average-sized-helpers', + userPoolClientId: 'the-mrs-claus-oversight-committee', + }, + }, + } + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const cookies = () => ({ + get() { return undefined }, + getAll() { return [] }, + has() { return false }, + }) as any; + + test('generateServerClientUsingCookies passes through to generateClientWithAmplifyInstance', () => { + generateClientWithAmplifyInstanceSpy.mockReturnValue('generateClientWithAmplifyInstance client'); + + const options = { + config: Amplify.getConfig(), + cookies: cookies, // must be a function to internal sanity checks + authMode: "authMode value", + authToken: "authToken value", + apiKey: "apiKey value", + endpoint: "endpoint value", + headers: "headers value" + } as any; + + const { + config: _config, // config is replaced with resources config + cookies: _cookies, // cookies are not sent + ...params + } = options; + + const client = generateServerClientUsingCookies(options); + + expect(generateClientWithAmplifyInstanceSpy).toHaveBeenCalledWith( + expect.objectContaining(params) + ); + expect(client).toEqual('generateClientWithAmplifyInstance client'); + }); + + test('generateServerClientUsingReqRes passes through to generateClientSpy', () => { + generateClientSpy.mockReturnValue('generateClientSpy client'); + + const options = { + config: Amplify.getConfig(), + authMode: "authMode value", + authToken: "authToken value", + apiKey: "apiKey value", + endpoint: "endpoint value", + headers: "headers value" + } as any; + + const { + config: _config, // config is replaced with resources config + ...params + } = options; + + const client = generateServerClientUsingReqRes(options); + + expect(generateClientSpy).toHaveBeenCalledWith( + expect.objectContaining(params) + ); + expect(client).toEqual('generateClientSpy client'); + }); +}) \ No newline at end of file diff --git a/packages/api/package.json b/packages/api/package.json index af5973b651e..42398cee1e4 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -68,7 +68,8 @@ }, "homepage": "https://aws-amplify.github.io/", "devDependencies": { - "typescript": "5.0.2" + "typescript": "5.0.2", + "jest-fetch-mock": "3.0.3" }, "files": [ "dist/cjs", diff --git a/packages/api/src/API.ts b/packages/api/src/API.ts index 8aee0fc3334..292c0baf6bc 100644 --- a/packages/api/src/API.ts +++ b/packages/api/src/API.ts @@ -1,7 +1,11 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { CommonPublicClientOptions, V6Client } from '@aws-amplify/api-graphql'; -import { generateClient as internalGenerateClient } from '@aws-amplify/api-graphql/internals'; +import { V6Client } from '@aws-amplify/api-graphql'; +import { + CommonPublicClientOptions, + DefaultCommonClientOptions, + generateClient as internalGenerateClient, +} from '@aws-amplify/api-graphql/internals'; import { Amplify } from '@aws-amplify/core'; /** @@ -10,11 +14,12 @@ import { Amplify } from '@aws-amplify/core'; * @returns {@link V6Client} * @throws {@link Error} - Throws error when client cannot be generated due to configuration issues. */ -export function generateClient = never>( - options: CommonPublicClientOptions = {}, -): V6Client { +export function generateClient< + T extends Record = never, + Options extends CommonPublicClientOptions = DefaultCommonClientOptions, +>(options?: Options): V6Client { return internalGenerateClient({ - ...options, + ...(options || ({} as any)), amplify: Amplify, - }) as unknown as V6Client; + }) as unknown as V6Client; } diff --git a/packages/auth/src/client/utils/store/signInStore.ts b/packages/auth/src/client/utils/store/signInStore.ts index 80b27f2a1c2..ceb86815693 100644 --- a/packages/auth/src/client/utils/store/signInStore.ts +++ b/packages/auth/src/client/utils/store/signInStore.ts @@ -24,8 +24,9 @@ type SignInAction = | { type: 'SET_SIGN_IN_SESSION'; value?: string } | { type: 'RESET_STATE' }; -// Minutes until stored session invalidates -const MS_TO_EXPIRY = 3 * 60 * 1000; // 3 mins +// Minutes until stored session invalidates is defaulted to 3 minutes +// to maintain parity with Amazon Cognito user pools API behavior +const MS_TO_EXPIRY = 3 * 60 * 1000; const TGT_STATE = 'CognitoSignInState'; const SIGN_IN_STATE_KEYS = { username: `${TGT_STATE}.username`, @@ -104,7 +105,7 @@ const getDefaultState = (): SignInState => ({ signInSession: undefined, }); -// Hydrate signInStore from syncSessionStorage +// Hydrate signInStore from syncSessionStorage if the session has not expired const getInitialState = (): SignInState => { const expiry = syncSessionStorage.getItem(SIGN_IN_STATE_KEYS.expiry); diff --git a/packages/auth/src/providers/cognito/apis/confirmSignIn.ts b/packages/auth/src/providers/cognito/apis/confirmSignIn.ts index 9a9af8e75b5..3edb0e9eab0 100644 --- a/packages/auth/src/providers/cognito/apis/confirmSignIn.ts +++ b/packages/auth/src/providers/cognito/apis/confirmSignIn.ts @@ -76,11 +76,11 @@ export async function confirmSignIn( This most likely occurred due to: 1. signIn was not called before confirmSignIn. 2. signIn threw an exception. - 3. page was refreshed during the sign in flow. + 3. page was refreshed during the sign in flow and session has expired. `, recoverySuggestion: 'Make sure a successful call to signIn is made before calling confirmSignIn' + - 'and that the page is not refreshed until the sign in process is done.', + 'and that the session has not expired.', }); try { diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index 29aed577acd..0ac8b34655e 100644 --- a/packages/aws-amplify/package.json +++ b/packages/aws-amplify/package.json @@ -293,7 +293,7 @@ "name": "[Analytics] record (Pinpoint)", "path": "./dist/esm/analytics/index.mjs", "import": "{ record }", - "limit": "17.60 kB" + "limit": "17.63 kB" }, { "name": "[Analytics] record (Kinesis)", @@ -317,7 +317,7 @@ "name": "[Analytics] identifyUser (Pinpoint)", "path": "./dist/esm/analytics/index.mjs", "import": "{ identifyUser }", - "limit": "16.10 kB" + "limit": "16.14 kB" }, { "name": "[Analytics] enable", @@ -335,7 +335,7 @@ "name": "[API] generateClient (AppSync)", "path": "./dist/esm/api/index.mjs", "import": "{ generateClient }", - "limit": "44.23 kB" + "limit": "45.47 kB" }, { "name": "[API] REST API handlers", @@ -353,13 +353,13 @@ "name": "[Auth] resetPassword (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ resetPassword }", - "limit": "12.68 kB" + "limit": "12.71 kB" }, { "name": "[Auth] confirmResetPassword (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ confirmResetPassword }", - "limit": "12.63 kB" + "limit": "12.66 kB" }, { "name": "[Auth] signIn (Cognito)", @@ -371,7 +371,7 @@ "name": "[Auth] resendSignUpCode (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ resendSignUpCode }", - "limit": "12.64 kB" + "limit": "12.68 kB" }, { "name": "[Auth] confirmSignUp (Cognito)", @@ -383,31 +383,31 @@ "name": "[Auth] confirmSignIn (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ confirmSignIn }", - "limit": "28.60 kB" + "limit": "28.62 kB" }, { "name": "[Auth] updateMFAPreference (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ updateMFAPreference }", - "limit": "12.11 kB" + "limit": "12.15 kB" }, { "name": "[Auth] fetchMFAPreference (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ fetchMFAPreference }", - "limit": "12.14 kB" + "limit": "12.18 kB" }, { "name": "[Auth] verifyTOTPSetup (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ verifyTOTPSetup }", - "limit": "12.99 kB" + "limit": "13.02 kB" }, { "name": "[Auth] updatePassword (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ updatePassword }", - "limit": "12.99 kB" + "limit": "13.03 kB" }, { "name": "[Auth] setUpTOTP (Cognito)", @@ -419,7 +419,7 @@ "name": "[Auth] updateUserAttributes (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ updateUserAttributes }", - "limit": "12.21 kB" + "limit": "12.24 kB" }, { "name": "[Auth] getCurrentUser (Cognito)", @@ -431,7 +431,7 @@ "name": "[Auth] confirmUserAttribute (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ confirmUserAttribute }", - "limit": "12.98 kB" + "limit": "13.02 kB" }, { "name": "[Auth] signInWithRedirect (Cognito)", @@ -443,13 +443,13 @@ "name": "[Auth] fetchUserAttributes (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ fetchUserAttributes }", - "limit": "12.03 kB" + "limit": "12.07 kB" }, { "name": "[Auth] Basic Auth Flow (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ signIn, signOut, fetchAuthSession, confirmSignIn }", - "limit": "30.87 kB" + "limit": "30.89 kB" }, { "name": "[Auth] OAuth Auth Flow (Cognito)", @@ -461,19 +461,19 @@ "name": "[Auth] Associate WebAuthN Credential (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ associateWebAuthnCredential }", - "limit": "13.55 kB" + "limit": "13.59 kB" }, { "name": "[Auth] List WebAuthN Credentials (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ listWebAuthnCredentials }", - "limit": "12.14 kB" + "limit": "12.18 kB" }, { "name": "[Auth] Delete WebAuthN Credential (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ deleteWebAuthnCredential }", - "limit": "12.01 kB" + "limit": "12.04 kB" }, { "name": "[Storage] copy (S3)", @@ -503,7 +503,7 @@ "name": "[Storage] list (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ list }", - "limit": "16.74 kB" + "limit": "16.77 kB" }, { "name": "[Storage] remove (S3)", @@ -515,7 +515,7 @@ "name": "[Storage] uploadData (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ uploadData }", - "limit": "22.84 kB" + "limit": "22.87 kB" } ] } diff --git a/packages/core/__tests__/parseAmplifyOutputs.test.ts b/packages/core/__tests__/parseAmplifyOutputs.test.ts index 9ae814334c1..d5302b847a1 100644 --- a/packages/core/__tests__/parseAmplifyOutputs.test.ts +++ b/packages/core/__tests__/parseAmplifyOutputs.test.ts @@ -318,6 +318,17 @@ describe('parseAmplifyOutputs tests', () => { }, }, }, + { + name: 'bucket-2', + bucket_name: 'storage-bucket-test-2', + aws_region: 'us-west-2', + paths: { + 'public/*': { + guest: ['get', 'list', 'write', 'delete'], + authenticated: ['get', 'list', 'write', 'delete'], + }, + }, + }, ], }, }; @@ -344,6 +355,16 @@ describe('parseAmplifyOutputs tests', () => { }, }, }, + 'bucket-2': { + bucketName: 'storage-bucket-test-2', + region: 'us-west-2', + paths: { + 'public/*': { + authenticated: ['get', 'list', 'write', 'delete'], + guest: ['get', 'list', 'write', 'delete'], + }, + }, + }, }, }, }, diff --git a/packages/core/__tests__/utils/deviceName/getDeviceName.test.ts b/packages/core/__tests__/utils/deviceName/getDeviceName.test.ts new file mode 100644 index 00000000000..bd8bccb37fc --- /dev/null +++ b/packages/core/__tests__/utils/deviceName/getDeviceName.test.ts @@ -0,0 +1,72 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { getDeviceName } from '../../../src/utils/deviceName'; + +describe('getDeviceName()', () => { + const userAgentData = { + platform: 'device-platform', + platformVersion: 'device-platform-version', + architecture: 'device-architecture', + model: 'device-model', + fullVersionList: [ + { brand: 'device-brand-1', version: 'device-version-1' }, + { brand: 'device-brand-2', version: 'device-version-2' }, + ], + }; + const userAgent = `product/version`; + + const mockGetHighEntropyValues = jest.fn(); + const navigatorSpy = jest.spyOn(window, 'navigator', 'get'); + + afterEach(() => { + mockGetHighEntropyValues.mockReset(); + navigatorSpy.mockReset(); + }); + + it('should return a device name based on user agent data', async () => { + mockGetHighEntropyValues.mockResolvedValue(userAgentData); + navigatorSpy.mockReturnValue({ + userAgentData: { + getHighEntropyValues: mockGetHighEntropyValues, + }, + } as any); + + const { architecture, fullVersionList, model, platform, platformVersion } = + userAgentData; + const [brand1, brand2] = fullVersionList; + expect(await getDeviceName()).toBe( + `${platform} ` + + `${platformVersion} ` + + `${architecture} ` + + `${model} ` + + `${platform} ` + + `${brand1.brand}/${brand1.version};` + + `${brand2.brand}/${brand2.version}`, + ); + }); + + it('should fall back to user agent if no user agent data', async () => { + navigatorSpy.mockReturnValue({ userAgent } as any); + + expect(await getDeviceName()).toBe(userAgent); + }); + + it('should fall back to user agent if deviceName resolves to empty string', async () => { + // simulated devices in Chrome can return empty strings + mockGetHighEntropyValues.mockResolvedValue({ + platform: '', + platformVersion: '', + architecture: '', + model: '', + fullVersionList: [], + }); + navigatorSpy.mockReturnValue({ + userAgentData: { + getHighEntropyValues: mockGetHighEntropyValues, + }, + userAgent, + } as any); + + expect(await getDeviceName()).toBe(userAgent); + }); +}); diff --git a/packages/core/__tests__/utils/isTokenExpired.test.ts b/packages/core/__tests__/utils/isTokenExpired.test.ts new file mode 100644 index 00000000000..04a61b297ae --- /dev/null +++ b/packages/core/__tests__/utils/isTokenExpired.test.ts @@ -0,0 +1,56 @@ +import { isTokenExpired } from '../../src/libraryUtils'; + +describe('isTokenExpired', () => { + const mockDate = Date.now(); + jest.spyOn(Date, 'now').mockImplementation(() => mockDate); + + it('should return true when token is expired', () => { + const result = isTokenExpired({ + expiresAt: Date.now() - 1, + clockDrift: 0, + tolerance: 0, + }); + + expect(result).toBe(true); + }); + + it('should return false when token is not expired', () => { + const result = isTokenExpired({ + expiresAt: Date.now() + 1, + clockDrift: 0, + tolerance: 0, + }); + + expect(result).toBe(false); + }); + + it('should return false when expiration time is within tolerance', () => { + const result = isTokenExpired({ + expiresAt: Date.now() + 5001, // more than 5 seconds remaining until expiration + clockDrift: 0, + tolerance: 5000, + }); + + expect(result).toBe(false); + }); + + it('should return true when expiration time is outside tolerance', () => { + const result = isTokenExpired({ + expiresAt: Date.now() + 4999, // less than 5 seconds remaining until expiration + clockDrift: 0, + tolerance: 5000, + }); + + expect(result).toBe(true); + }); + + it('should return false when expiration time is equal to tolerance', () => { + const result = isTokenExpired({ + expiresAt: Date.now() + 5000, // exactly 5 seconds remaining until expiration + clockDrift: 0, + tolerance: 5000, + }); + + expect(result).toBe(false); + }); +}); diff --git a/packages/core/src/libraryUtils.ts b/packages/core/src/libraryUtils.ts index 6c03928c5a4..7e073d153b8 100644 --- a/packages/core/src/libraryUtils.ts +++ b/packages/core/src/libraryUtils.ts @@ -11,6 +11,7 @@ export { generateRandomString, isBrowser, isNonRetryableError, + isTokenExpired, isWebWorker, jitteredBackoff, jitteredExponentialRetry, @@ -38,7 +39,6 @@ export { assertIdentityPoolIdConfig, assertOAuthConfig, } from './singleton/Auth/utils'; -export { isTokenExpired } from './singleton/Auth'; export { AssociationBelongsTo, AssociationHasMany, diff --git a/packages/core/src/parseAmplifyOutputs.ts b/packages/core/src/parseAmplifyOutputs.ts index 0fcbd0eebee..346ee54a239 100644 --- a/packages/core/src/parseAmplifyOutputs.ts +++ b/packages/core/src/parseAmplifyOutputs.ts @@ -383,10 +383,22 @@ function createBucketInfoMap( ); } + const sanitizedPaths = paths + ? Object.entries(paths).reduce< + Record> + >((acc, [key, value]) => { + if (value !== undefined) { + acc[key] = value; + } + + return acc; + }, {}) + : undefined; + mappedBuckets[name] = { bucketName, region, - paths, + paths: sanitizedPaths, }; }, ); diff --git a/packages/core/src/singleton/AmplifyOutputs/types.ts b/packages/core/src/singleton/AmplifyOutputs/types.ts index 4146037a9ce..4ff761a2f08 100644 --- a/packages/core/src/singleton/AmplifyOutputs/types.ts +++ b/packages/core/src/singleton/AmplifyOutputs/types.ts @@ -53,7 +53,7 @@ export interface AmplifyOutputsStorageBucketProperties { /** Region for the bucket */ aws_region: string; /** Paths to object with access permissions */ - paths?: Record>; + paths?: Partial>>; } export interface AmplifyOutputsStorageProperties { /** Default region for Storage */ diff --git a/packages/core/src/singleton/Auth/index.ts b/packages/core/src/singleton/Auth/index.ts index 245cc704b0c..42d8cb3bddd 100644 --- a/packages/core/src/singleton/Auth/index.ts +++ b/packages/core/src/singleton/Auth/index.ts @@ -9,18 +9,6 @@ import { LibraryAuthOptions, } from './types'; -export function isTokenExpired({ - expiresAt, - clockDrift, -}: { - expiresAt: number; - clockDrift: number; -}): boolean { - const currentTime = Date.now(); - - return currentTime + clockDrift > expiresAt; -} - export class AuthClass { private authConfig?: AuthConfig; private authOptions?: LibraryAuthOptions; diff --git a/packages/core/src/utils/deviceName/getDeviceName.ts b/packages/core/src/utils/deviceName/getDeviceName.ts index 3916ba7b36e..3c3638f3904 100644 --- a/packages/core/src/utils/deviceName/getDeviceName.ts +++ b/packages/core/src/utils/deviceName/getDeviceName.ts @@ -48,5 +48,5 @@ export const getDeviceName = async (): Promise => { .filter(value => value) .join(' '); - return deviceName; + return deviceName || navigator.userAgent; }; diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 9b713d2066e..96e981f4efe 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -16,3 +16,4 @@ export { urlSafeDecode } from './urlSafeDecode'; export { urlSafeEncode } from './urlSafeEncode'; export { deepFreeze } from './deepFreeze'; export { deDupeAsyncFunction } from './deDupeAsyncFunction'; +export { isTokenExpired } from './isTokenExpired'; diff --git a/packages/core/src/utils/isTokenExpired.ts b/packages/core/src/utils/isTokenExpired.ts new file mode 100644 index 00000000000..67b5b00f915 --- /dev/null +++ b/packages/core/src/utils/isTokenExpired.ts @@ -0,0 +1,16 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export function isTokenExpired({ + expiresAt, + clockDrift, + tolerance = 5000, +}: { + expiresAt: number; + clockDrift: number; + tolerance?: number; +}): boolean { + const currentTime = Date.now(); + + return currentTime + clockDrift + tolerance > expiresAt; +}