diff --git a/packages/storage/__tests__/providers/s3/apis/utils/resolveS3ConfigAndInput.test.ts b/packages/storage/__tests__/providers/s3/apis/utils/resolveS3ConfigAndInput.test.ts index e26cb63b6c7..efae2febaaf 100644 --- a/packages/storage/__tests__/providers/s3/apis/utils/resolveS3ConfigAndInput.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/utils/resolveS3ConfigAndInput.test.ts @@ -9,6 +9,11 @@ import { StorageValidationErrorCode, validationErrorMap, } from '../../../../../src/errors/types/validation'; +import { + CallbackPathStorageInput, + DeprecatedStorageInput, +} from '../../../../../src/providers/s3/utils/resolveS3ConfigAndInput'; +import { INVALID_STORAGE_INPUT } from '../../../../../src/errors/constants'; jest.mock('@aws-amplify/core', () => ({ ConsoleLogger: jest.fn(), @@ -76,13 +81,11 @@ describe('resolveS3ConfigAndInput', () => { } }); - it('should throw if identityId is not available', async () => { + it('should not throw if identityId is not available', async () => { mockFetchAuthSession.mockResolvedValueOnce({ credentials, }); - await expect(resolveS3ConfigAndInput(Amplify, {})).rejects.toMatchObject( - validationErrorMap[StorageValidationErrorCode.NoIdentityId], - ); + expect(async () => resolveS3ConfigAndInput(Amplify, {})).not.toThrow(); }); it('should resolve bucket from S3 config', async () => { @@ -179,7 +182,7 @@ describe('resolveS3ConfigAndInput', () => { it('should resolve prefix with given access level', async () => { mockDefaultResolvePrefix.mockResolvedValueOnce('prefix'); const { keyPrefix } = await resolveS3ConfigAndInput(Amplify, { - accessLevel: 'someLevel' as any, + options: { accessLevel: 'someLevel' as any }, }); expect(mockDefaultResolvePrefix).toHaveBeenCalledWith({ accessLevel: 'someLevel', @@ -214,4 +217,91 @@ describe('resolveS3ConfigAndInput', () => { }); expect(keyPrefix).toEqual('prefix'); }); + + describe('with locationCredentialsProvider', () => { + const mockLocationCredentialsProvider = jest + .fn() + .mockReturnValue({ credentials }); + it('should resolve credentials without Amplify singleton', async () => { + mockGetConfig.mockReturnValue({ + Storage: { + S3: { + bucket, + region, + }, + }, + }); + const { s3Config } = await resolveS3ConfigAndInput(Amplify, { + options: { + locationCredentialsProvider: mockLocationCredentialsProvider, + }, + }); + + if (typeof s3Config.credentials === 'function') { + const result = await s3Config.credentials(); + expect(mockLocationCredentialsProvider).toHaveBeenCalled(); + expect(result).toEqual(credentials); + } else { + throw new Error('Expect credentials to be a function'); + } + }); + + it('should not throw when path is pass as a string', async () => { + const { s3Config } = await resolveS3ConfigAndInput(Amplify, { + path: 'my-path', + options: { + locationCredentialsProvider: mockLocationCredentialsProvider, + }, + }); + + if (typeof s3Config.credentials === 'function') { + const result = await s3Config.credentials(); + expect(mockLocationCredentialsProvider).toHaveBeenCalled(); + expect(result).toEqual(credentials); + } else { + throw new Error('Expect credentials to be a function'); + } + }); + + describe('with deprecated or callback paths as inputs', () => { + const key = 'mock-value'; + const prefix = 'mock-value'; + const path = () => 'path'; + const deprecatedInputs: DeprecatedStorageInput[] = [ + { prefix }, + { key }, + { + source: { key }, + destination: { key }, + }, + ]; + const callbackPathInputs: CallbackPathStorageInput[] = [ + { path }, + { + destination: { path }, + source: { path }, + }, + ]; + + const testCases = [...deprecatedInputs, ...callbackPathInputs]; + + it.each(testCases)('should throw when input is %s', async input => { + const { s3Config } = await resolveS3ConfigAndInput(Amplify, { + ...input, + options: { + locationCredentialsProvider: mockLocationCredentialsProvider, + }, + }); + if (typeof s3Config.credentials === 'function') { + await expect(s3Config.credentials()).rejects.toThrow( + expect.objectContaining({ + name: INVALID_STORAGE_INPUT, + }), + ); + } else { + throw new Error('Expect credentials to be a function'); + } + }); + }); + }); }); diff --git a/packages/storage/src/errors/constants.ts b/packages/storage/src/errors/constants.ts new file mode 100644 index 00000000000..ca127c2e623 --- /dev/null +++ b/packages/storage/src/errors/constants.ts @@ -0,0 +1,4 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const INVALID_STORAGE_INPUT = 'InvalidStorageInput'; diff --git a/packages/storage/src/providers/s3/apis/downloadData.ts b/packages/storage/src/providers/s3/apis/downloadData.ts index 009e75ae95b..32f0c558642 100644 --- a/packages/storage/src/providers/s3/apis/downloadData.ts +++ b/packages/storage/src/providers/s3/apis/downloadData.ts @@ -115,7 +115,7 @@ const downloadDataJob = > => { const { options: downloadDataOptions } = downloadDataInput; const { bucket, keyPrefix, s3Config, identityId } = - await resolveS3ConfigAndInput(Amplify, downloadDataOptions); + await resolveS3ConfigAndInput(Amplify, downloadDataInput); const { inputType, objectKey } = validateStorageOperationInput( downloadDataInput, identityId, diff --git a/packages/storage/src/providers/s3/apis/internal/copy.ts b/packages/storage/src/providers/s3/apis/internal/copy.ts index 0e1fc01eadf..44906a816dc 100644 --- a/packages/storage/src/providers/s3/apis/internal/copy.ts +++ b/packages/storage/src/providers/s3/apis/internal/copy.ts @@ -40,8 +40,10 @@ const copyWithPath = async ( input: CopyWithPathInput, ): Promise => { const { source, destination } = input; - const { s3Config, bucket, identityId } = - await resolveS3ConfigAndInput(amplify); + const { s3Config, bucket, identityId } = await resolveS3ConfigAndInput( + amplify, + input, + ); assertValidationError(!!source.path, StorageValidationErrorCode.NoSourcePath); assertValidationError( @@ -92,10 +94,13 @@ export const copyWithKey = async ( s3Config, bucket, keyPrefix: sourceKeyPrefix, - } = await resolveS3ConfigAndInput(amplify, input.source); + } = await resolveS3ConfigAndInput(amplify, { + ...input, + options: input.source, + }); const { keyPrefix: destinationKeyPrefix } = await resolveS3ConfigAndInput( amplify, - input.destination, + { ...input, options: input.destination }, ); // resolveS3ConfigAndInput does not make extra API calls or storage access if called repeatedly. // TODO(ashwinkumar6) V6-logger: warn `You may copy files from another user if the source level is "protected", currently it's ${srcLevel}` diff --git a/packages/storage/src/providers/s3/apis/internal/getProperties.ts b/packages/storage/src/providers/s3/apis/internal/getProperties.ts index 915f02db495..ac04b2dbe6e 100644 --- a/packages/storage/src/providers/s3/apis/internal/getProperties.ts +++ b/packages/storage/src/providers/s3/apis/internal/getProperties.ts @@ -24,9 +24,8 @@ export const getProperties = async ( input: GetPropertiesInput | GetPropertiesWithPathInput, action?: StorageAction, ): Promise => { - const { options: getPropertiesOptions } = input; const { s3Config, bucket, keyPrefix, identityId } = - await resolveS3ConfigAndInput(amplify, getPropertiesOptions); + await resolveS3ConfigAndInput(amplify, input); const { inputType, objectKey } = validateStorageOperationInput( input, identityId, diff --git a/packages/storage/src/providers/s3/apis/internal/getUrl.ts b/packages/storage/src/providers/s3/apis/internal/getUrl.ts index e8440ce80eb..7ea49a448c9 100644 --- a/packages/storage/src/providers/s3/apis/internal/getUrl.ts +++ b/packages/storage/src/providers/s3/apis/internal/getUrl.ts @@ -31,7 +31,7 @@ export const getUrl = async ( ): Promise => { const { options: getUrlOptions } = input; const { s3Config, keyPrefix, bucket, identityId } = - await resolveS3ConfigAndInput(amplify, getUrlOptions); + await resolveS3ConfigAndInput(amplify, input); const { inputType, objectKey } = validateStorageOperationInput( input, identityId, diff --git a/packages/storage/src/providers/s3/apis/internal/list.ts b/packages/storage/src/providers/s3/apis/internal/list.ts index 6f074858738..7a1ab47007a 100644 --- a/packages/storage/src/providers/s3/apis/internal/list.ts +++ b/packages/storage/src/providers/s3/apis/internal/list.ts @@ -58,7 +58,7 @@ export const list = async ( bucket, keyPrefix: generatedPrefix, identityId, - } = await resolveS3ConfigAndInput(amplify, options); + } = await resolveS3ConfigAndInput(amplify, input); const { inputType, objectKey } = validateStorageOperationInputWithPrefix( input, diff --git a/packages/storage/src/providers/s3/apis/internal/remove.ts b/packages/storage/src/providers/s3/apis/internal/remove.ts index e2a9377f39e..d73a13346e4 100644 --- a/packages/storage/src/providers/s3/apis/internal/remove.ts +++ b/packages/storage/src/providers/s3/apis/internal/remove.ts @@ -23,9 +23,8 @@ export const remove = async ( amplify: AmplifyClassV6, input: RemoveInput | RemoveWithPathInput, ): Promise => { - const { options = {} } = input ?? {}; const { s3Config, keyPrefix, bucket, identityId } = - await resolveS3ConfigAndInput(amplify, options); + await resolveS3ConfigAndInput(amplify, input); const { inputType, objectKey } = validateStorageOperationInput( input, diff --git a/packages/storage/src/providers/s3/apis/uploadData/multipart/uploadHandlers.ts b/packages/storage/src/providers/s3/apis/uploadData/multipart/uploadHandlers.ts index 886a769648b..00d3903914c 100644 --- a/packages/storage/src/providers/s3/apis/uploadData/multipart/uploadHandlers.ts +++ b/packages/storage/src/providers/s3/apis/uploadData/multipart/uploadHandlers.ts @@ -73,7 +73,7 @@ export const getMultipartUploadHandlers = ( const { options: uploadDataOptions, data } = uploadDataInput; const resolvedS3Options = await resolveS3ConfigAndInput( Amplify, - uploadDataOptions, + uploadDataInput, ); abortController = new AbortController(); diff --git a/packages/storage/src/providers/s3/apis/uploadData/putObjectJob.ts b/packages/storage/src/providers/s3/apis/uploadData/putObjectJob.ts index 76f9ebf5638..b7c930ea563 100644 --- a/packages/storage/src/providers/s3/apis/uploadData/putObjectJob.ts +++ b/packages/storage/src/providers/s3/apis/uploadData/putObjectJob.ts @@ -29,7 +29,7 @@ export const putObjectJob = async (): Promise => { const { options: uploadDataOptions, data } = uploadDataInput; const { bucket, keyPrefix, s3Config, isObjectLockEnabled, identityId } = - await resolveS3ConfigAndInput(Amplify, uploadDataOptions); + await resolveS3ConfigAndInput(Amplify, uploadDataInput); const { inputType, objectKey } = validateStorageOperationInput( uploadDataInput, identityId, diff --git a/packages/storage/src/providers/s3/types/inputs.ts b/packages/storage/src/providers/s3/types/inputs.ts index f7bd6c5db44..3158cc0fbba 100644 --- a/packages/storage/src/providers/s3/types/inputs.ts +++ b/packages/storage/src/providers/s3/types/inputs.ts @@ -35,6 +35,8 @@ import { UploadDataOptionsWithPath, } from '../types'; +import { LocationCredentialsProvider } from './options'; + // TODO: support use accelerate endpoint option /** * @deprecated Use {@link CopyWithPathInput} instead. @@ -47,7 +49,9 @@ export type CopyInput = StorageCopyInputWithKey< /** * Input type with path for S3 copy API. */ -export type CopyWithPathInput = StorageCopyInputWithPath; +export type CopyWithPathInput = StorageCopyInputWithPath<{ + locationCredentialsProvider?: LocationCredentialsProvider; +}>; /** * @deprecated Use {@link GetPropertiesWithPathInput} instead. diff --git a/packages/storage/src/providers/s3/utils/resolveIdentityId.ts b/packages/storage/src/providers/s3/utils/resolveIdentityId.ts new file mode 100644 index 00000000000..c4831ae88c4 --- /dev/null +++ b/packages/storage/src/providers/s3/utils/resolveIdentityId.ts @@ -0,0 +1,11 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { StorageValidationErrorCode } from '../../../errors/types/validation'; +import { assertValidationError } from '../../../errors/utils/assertValidationError'; + +export const resolveIdentityId = (identityId?: string): string => { + assertValidationError(!!identityId, StorageValidationErrorCode.NoIdentityId); + + return identityId; +}; diff --git a/packages/storage/src/providers/s3/utils/resolveS3ConfigAndInput.ts b/packages/storage/src/providers/s3/utils/resolveS3ConfigAndInput.ts index ae7a185c93c..af6b75f36ec 100644 --- a/packages/storage/src/providers/s3/utils/resolveS3ConfigAndInput.ts +++ b/packages/storage/src/providers/s3/utils/resolveS3ConfigAndInput.ts @@ -6,7 +6,18 @@ import { AmplifyClassV6, StorageAccessLevel } from '@aws-amplify/core'; import { assertValidationError } from '../../../errors/utils/assertValidationError'; import { StorageValidationErrorCode } from '../../../errors/types/validation'; import { resolvePrefix as defaultPrefixResolver } from '../../../utils/resolvePrefix'; -import { ResolvedS3Config } from '../types/options'; +import { + LocationCredentialsProvider, + ResolvedS3Config, +} from '../types/options'; +import { + StorageOperationInputWithKey, + StorageOperationInputWithPath, + StorageOperationInputWithPrefix, +} from '../../../types/inputs'; +import { StorageError } from '../../../errors/StorageError'; +import { CopyInput, CopyWithPathInput } from '../types'; +import { INVALID_STORAGE_INPUT } from '../../../errors/constants'; import { DEFAULT_ACCESS_LEVEL, LOCAL_TESTING_S3_ENDPOINT } from './constants'; @@ -14,6 +25,7 @@ interface S3ApiOptions { accessLevel?: StorageAccessLevel; targetIdentityId?: string; useAccelerateEndpoint?: boolean; + locationCredentialsProvider?: LocationCredentialsProvider; } interface ResolvedS3ConfigAndInput { @@ -23,6 +35,16 @@ interface ResolvedS3ConfigAndInput { isObjectLockEnabled?: boolean; identityId?: string; } +export type DeprecatedStorageInput = + | StorageOperationInputWithKey + | StorageOperationInputWithPrefix + | CopyInput; + +export type CallbackPathStorageInput = + | StorageOperationInputWithPath + | CopyWithPathInput; + +type StorageInput = DeprecatedStorageInput | CallbackPathStorageInput; /** * resolve the common input options for S3 API handlers from Amplify configuration and library options. @@ -37,14 +59,14 @@ interface ResolvedS3ConfigAndInput { */ export const resolveS3ConfigAndInput = async ( amplify: AmplifyClassV6, - apiOptions?: S3ApiOptions, + apiInput?: StorageInput & { options?: S3ApiOptions }, ): Promise => { + const { options: apiOptions } = apiInput ?? {}; /** * IdentityId is always cached in memory so we can safely make calls here. It * should be stable even for unauthenticated users, regardless of credentials. */ const { identityId } = await amplify.Auth.fetchAuthSession(); - assertValidationError(!!identityId, StorageValidationErrorCode.NoIdentityId); /** * A credentials provider function instead of a static credentials object is @@ -53,7 +75,13 @@ export const resolveS3ConfigAndInput = async ( * credentials if they are expired. */ const credentialsProvider = async () => { - const { credentials } = await amplify.Auth.fetchAuthSession(); + if (isLocationCredentialsProvider(apiOptions)) { + assertStorageInput(apiInput); + } + + const { credentials } = isLocationCredentialsProvider(apiOptions) + ? await apiOptions.locationCredentialsProvider() + : await amplify.Auth.fetchAuthSession(); assertValidationError( !!credentials, StorageValidationErrorCode.NoCredentials, @@ -101,3 +129,63 @@ export const resolveS3ConfigAndInput = async ( isObjectLockEnabled, }; }; + +const isLocationCredentialsProvider = ( + options?: S3ApiOptions, +): options is S3ApiOptions & { + locationCredentialsProvider: LocationCredentialsProvider; +} => { + return !!options?.locationCredentialsProvider; +}; + +const isInputWithCallbackPath = (input?: CallbackPathStorageInput) => { + return ( + ((input as StorageOperationInputWithPath)?.path && + typeof (input as StorageOperationInputWithPath).path === 'function') || + ((input as CopyWithPathInput)?.destination?.path && + typeof (input as CopyWithPathInput).destination?.path === 'function') || + ((input as CopyWithPathInput)?.source?.path && + typeof (input as CopyWithPathInput).source?.path === 'function') + ); +}; + +const isDeprecatedInput = ( + input?: StorageInput, +): input is DeprecatedStorageInput => { + return ( + isInputWithKey(input) || + isInputWithPrefix(input) || + isInputWithCopySourceOrDestination(input) + ); +}; +const assertStorageInput = (input?: StorageInput) => { + if (isDeprecatedInput(input) || isInputWithCallbackPath(input)) { + throw new StorageError({ + name: INVALID_STORAGE_INPUT, + message: 'The input needs to have a path as a string value.', + recoverySuggestion: + 'Please provide a valid path as a string value for the input.', + }); + } +}; + +const isInputWithKey = ( + input?: StorageInput, +): input is StorageOperationInputWithKey => { + return !!(typeof (input as StorageOperationInputWithKey).key === 'string'); +}; +const isInputWithPrefix = ( + input?: StorageInput, +): input is StorageOperationInputWithPrefix => { + return !!( + typeof (input as StorageOperationInputWithPrefix).prefix === 'string' + ); +}; +const isInputWithCopySourceOrDestination = ( + input?: StorageInput, +): input is CopyInput => { + return !!( + typeof (input as CopyInput).source?.key === 'string' || + typeof (input as CopyInput).destination?.key === 'string' + ); +}; diff --git a/packages/storage/src/providers/s3/utils/validateStorageOperationInput.ts b/packages/storage/src/providers/s3/utils/validateStorageOperationInput.ts index 585701c81e9..fa423b45913 100644 --- a/packages/storage/src/providers/s3/utils/validateStorageOperationInput.ts +++ b/packages/storage/src/providers/s3/utils/validateStorageOperationInput.ts @@ -7,6 +7,7 @@ import { StorageValidationErrorCode } from '../../../errors/types/validation'; import { isInputWithPath } from './isInputWithPath'; import { STORAGE_INPUT_KEY, STORAGE_INPUT_PATH } from './constants'; +import { resolveIdentityId } from './resolveIdentityId'; export const validateStorageOperationInput = ( input: Input, @@ -22,7 +23,10 @@ export const validateStorageOperationInput = ( if (isInputWithPath(input)) { const { path } = input; - const objectKey = typeof path === 'string' ? path : path({ identityId }); + const objectKey = + typeof path === 'string' + ? path + : path({ identityId: resolveIdentityId(identityId) }); assertValidationError( !objectKey.startsWith('/'), diff --git a/packages/storage/src/providers/s3/utils/validateStorageOperationInputWithPrefix.ts b/packages/storage/src/providers/s3/utils/validateStorageOperationInputWithPrefix.ts index da1068af010..1c2efce19f7 100644 --- a/packages/storage/src/providers/s3/utils/validateStorageOperationInputWithPrefix.ts +++ b/packages/storage/src/providers/s3/utils/validateStorageOperationInputWithPrefix.ts @@ -9,6 +9,7 @@ import { assertValidationError } from '../../../errors/utils/assertValidationErr import { StorageValidationErrorCode } from '../../../errors/types/validation'; import { STORAGE_INPUT_PATH, STORAGE_INPUT_PREFIX } from './constants'; +import { resolveIdentityId } from './resolveIdentityId'; // Local assertion function with StorageOperationInputWithPrefixPath as Input const _isInputWithPath = ( @@ -28,7 +29,10 @@ export const validateStorageOperationInputWithPrefix = ( ); if (_isInputWithPath(input)) { const { path } = input; - const objectKey = typeof path === 'string' ? path : path({ identityId }); + const objectKey = + typeof path === 'string' + ? path + : path({ identityId: resolveIdentityId(identityId) }); // Assert on no leading slash in the path parameter assertValidationError( diff --git a/packages/storage/src/types/inputs.ts b/packages/storage/src/types/inputs.ts index 403a2a14332..4e369824ca4 100644 --- a/packages/storage/src/types/inputs.ts +++ b/packages/storage/src/types/inputs.ts @@ -90,7 +90,8 @@ export interface StorageCopyInputWithKey< destination: DestinationOptions; } -export interface StorageCopyInputWithPath { +export interface StorageCopyInputWithPath + extends StorageOperationOptionsInput { source: StorageOperationInputWithPath; destination: StorageOperationInputWithPath; }