From 729ab3eeac70e3e0343a188f4331c36aa2a34561 Mon Sep 17 00:00:00 2001 From: Michael Sober Date: Tue, 26 Aug 2025 13:50:11 +0200 Subject: [PATCH 1/3] feat: add rest auth mode --- .../__tests__/apis/common/publicApis.test.ts | 77 +++++++++++++++++++ .../api-rest/src/apis/common/publicApis.ts | 1 + .../src/apis/common/transferHandler.ts | 21 ++++- packages/api-rest/src/types/index.ts | 4 + 4 files changed, 100 insertions(+), 3 deletions(-) diff --git a/packages/api-rest/__tests__/apis/common/publicApis.test.ts b/packages/api-rest/__tests__/apis/common/publicApis.test.ts index 014e12e9b30..b291110bc00 100644 --- a/packages/api-rest/__tests__/apis/common/publicApis.test.ts +++ b/packages/api-rest/__tests__/apis/common/publicApis.test.ts @@ -620,5 +620,82 @@ describe('public APIs', () => { }); }); }); + + describe('authMode option', () => { + it('should skip credential resolution and remove headers when authMode is "none"', async () => { + mockFetchAuthSession.mockClear(); + + await fn(mockAmplifyInstance, { + apiName: 'restApi1', + path: '/public', + options: { + authMode: 'none', + headers: { + authorization: 'Bearer token', + 'x-api-key': 'test-key', + }, + }, + }).response; + + expect(mockFetchAuthSession).not.toHaveBeenCalled(); + expect(mockUnauthenticatedHandler).toHaveBeenCalled(); + expect(mockAuthenticatedHandler).not.toHaveBeenCalled(); + + // Verify headers were removed + const callArgs = mockUnauthenticatedHandler.mock.calls[0][0]; + expect(callArgs.headers.authorization).toBeUndefined(); + expect(callArgs.headers['x-api-key']).toBeUndefined(); + }); + + it('should resolve credentials and remove conflicting headers when authMode is "iam"', async () => { + mockFetchAuthSession.mockResolvedValue({ + credentials: { + accessKeyId: 'test-key', + secretAccessKey: 'test-secret', + }, + }); + + await fn(mockAmplifyInstance, { + apiName: 'restApi1', + path: '/private', + options: { + authMode: 'iam', + headers: { + 'x-api-key': 'should-be-removed', + }, + }, + }).response; + + expect(mockFetchAuthSession).toHaveBeenCalled(); + expect(mockAuthenticatedHandler).toHaveBeenCalled(); + + // Verify conflicting headers were removed + const callArgs = mockAuthenticatedHandler.mock.calls[0][0]; + expect(callArgs.headers['x-api-key']).toBeUndefined(); + }); + + it('should maintain default behavior and keep headers when no authMode specified', async () => { + mockFetchAuthSession.mockResolvedValue({ + credentials: null, + }); + + await fn(mockAmplifyInstance, { + apiName: 'restApi1', + path: '/endpoint', + options: { + headers: { + 'x-api-key': 'should-be-kept', + }, + }, + }).response; + + expect(mockFetchAuthSession).toHaveBeenCalled(); + expect(mockUnauthenticatedHandler).toHaveBeenCalled(); + + // Verify headers were kept (no authMode = no header removal) + const callArgs = mockUnauthenticatedHandler.mock.calls[0][0]; + expect(callArgs.headers['x-api-key']).toBe('should-be-kept'); + }); + }); }); }); diff --git a/packages/api-rest/src/apis/common/publicApis.ts b/packages/api-rest/src/apis/common/publicApis.ts index d2eaa16e110..c5cfcf43562 100644 --- a/packages/api-rest/src/apis/common/publicApis.ts +++ b/packages/api-rest/src/apis/common/publicApis.ts @@ -71,6 +71,7 @@ const publicHandler = ( method, headers, abortSignal, + authMode: apiOptions.authMode, }, isIamAuthApplicableForRest, signingServiceInfo, diff --git a/packages/api-rest/src/apis/common/transferHandler.ts b/packages/api-rest/src/apis/common/transferHandler.ts index 84a1d1f799c..e4c29ac13f3 100644 --- a/packages/api-rest/src/apis/common/transferHandler.ts +++ b/packages/api-rest/src/apis/common/transferHandler.ts @@ -20,7 +20,11 @@ import { parseSigningInfo, } from '../../utils'; import { resolveHeaders } from '../../utils/resolveHeaders'; -import { RestApiResponse, SigningServiceInfo } from '../../types'; +import { + RestApiAuthMode, + RestApiResponse, + SigningServiceInfo, +} from '../../types'; import { authenticatedHandler } from './baseHandlers/authenticatedHandler'; import { unauthenticatedHandler } from './baseHandlers/unauthenticatedHandler'; @@ -30,6 +34,7 @@ type HandlerOptions = Omit & { headers?: Headers; withCredentials?: boolean; retryStrategy?: RetryStrategy; + authMode?: RestApiAuthMode; }; type RetryDecider = RetryOptions['retryDecider']; @@ -84,10 +89,20 @@ export const transferHandler = async ( abortSignal, }; - const isIamAuthApplicable = iamAuthApplicable(request, signingServiceInfo); + if (options.authMode) { + // remove conflicting headers to ensure either none or iam auth will be used + delete request.headers.authorization; + delete request.headers['x-api-key']; + } + + let credentials: AWSCredentials | null = null; + if (options.authMode !== 'none') { + credentials = await resolveCredentials(amplify); + } let response: RestApiResponse; - const credentials = await resolveCredentials(amplify); + const isIamAuthApplicable = iamAuthApplicable(request, signingServiceInfo); + if (isIamAuthApplicable && credentials) { const signingInfoFromUrl = parseSigningInfo(url); const signingService = diff --git a/packages/api-rest/src/types/index.ts b/packages/api-rest/src/types/index.ts index f011f717a94..7bd04088d25 100644 --- a/packages/api-rest/src/types/index.ts +++ b/packages/api-rest/src/types/index.ts @@ -16,6 +16,9 @@ export type PatchOperation = Operation; export type DeleteOperation = Operation; export type HeadOperation = Operation>; +// Add this type definition +export type RestApiAuthMode = 'none' | 'iam'; + /** * @internal */ @@ -41,6 +44,7 @@ export interface RestApiOptionsBase { * @default ` { strategy: 'jittered-exponential-backoff' } ` */ retryStrategy?: RetryStrategy; + authMode?: RestApiAuthMode; } type Headers = Record; From 93c6ebca003d256daf5b600b2fc2616608b1c306 Mon Sep 17 00:00:00 2001 From: Michael Sober Date: Thu, 28 Aug 2025 11:58:44 +0200 Subject: [PATCH 2/3] feat: renamed parameter and added library option --- .../__tests__/apis/common/publicApis.test.ts | 61 ++++++++++--------- .../api-rest/src/apis/common/publicApis.ts | 2 +- .../src/apis/common/transferHandler.ts | 13 ++-- packages/api-rest/src/types/index.ts | 5 +- packages/core/src/singleton/API/types.ts | 4 ++ 5 files changed, 43 insertions(+), 42 deletions(-) diff --git a/packages/api-rest/__tests__/apis/common/publicApis.test.ts b/packages/api-rest/__tests__/apis/common/publicApis.test.ts index b291110bc00..bae321c720b 100644 --- a/packages/api-rest/__tests__/apis/common/publicApis.test.ts +++ b/packages/api-rest/__tests__/apis/common/publicApis.test.ts @@ -621,33 +621,24 @@ describe('public APIs', () => { }); }); - describe('authMode option', () => { - it('should skip credential resolution and remove headers when authMode is "none"', async () => { + describe('authFallback option', () => { + it('should skip credential resolution when authFallback is "none"', async () => { mockFetchAuthSession.mockClear(); await fn(mockAmplifyInstance, { apiName: 'restApi1', path: '/public', options: { - authMode: 'none', - headers: { - authorization: 'Bearer token', - 'x-api-key': 'test-key', - }, + authFallback: 'none', }, }).response; expect(mockFetchAuthSession).not.toHaveBeenCalled(); expect(mockUnauthenticatedHandler).toHaveBeenCalled(); expect(mockAuthenticatedHandler).not.toHaveBeenCalled(); - - // Verify headers were removed - const callArgs = mockUnauthenticatedHandler.mock.calls[0][0]; - expect(callArgs.headers.authorization).toBeUndefined(); - expect(callArgs.headers['x-api-key']).toBeUndefined(); }); - it('should resolve credentials and remove conflicting headers when authMode is "iam"', async () => { + it('should resolve credentials when authFallback is "iam"', async () => { mockFetchAuthSession.mockResolvedValue({ credentials: { accessKeyId: 'test-key', @@ -659,22 +650,15 @@ describe('public APIs', () => { apiName: 'restApi1', path: '/private', options: { - authMode: 'iam', - headers: { - 'x-api-key': 'should-be-removed', - }, + authFallback: 'iam', }, }).response; expect(mockFetchAuthSession).toHaveBeenCalled(); expect(mockAuthenticatedHandler).toHaveBeenCalled(); - - // Verify conflicting headers were removed - const callArgs = mockAuthenticatedHandler.mock.calls[0][0]; - expect(callArgs.headers['x-api-key']).toBeUndefined(); }); - it('should maintain default behavior and keep headers when no authMode specified', async () => { + it('should maintain default behavior when no authFallback specified', async () => { mockFetchAuthSession.mockResolvedValue({ credentials: null, }); @@ -682,19 +666,36 @@ describe('public APIs', () => { await fn(mockAmplifyInstance, { apiName: 'restApi1', path: '/endpoint', - options: { - headers: { - 'x-api-key': 'should-be-kept', - }, - }, }).response; expect(mockFetchAuthSession).toHaveBeenCalled(); expect(mockUnauthenticatedHandler).toHaveBeenCalled(); + }); + + it('should use global authFallback configuration when no local authFallback is specified', async () => { + const mockAmplifyWithGlobalConfig = { + ...mockAmplifyInstance, + libraryOptions: { + ...mockAmplifyInstance.libraryOptions, + API: { + ...mockAmplifyInstance.libraryOptions?.API, + REST: { + authFallback: 'none' as const, + }, + }, + }, + } as any as AmplifyClassV6; + + mockFetchAuthSession.mockClear(); + + await fn(mockAmplifyWithGlobalConfig, { + apiName: 'restApi1', + path: '/public', + }).response; - // Verify headers were kept (no authMode = no header removal) - const callArgs = mockUnauthenticatedHandler.mock.calls[0][0]; - expect(callArgs.headers['x-api-key']).toBe('should-be-kept'); + expect(mockFetchAuthSession).not.toHaveBeenCalled(); + expect(mockUnauthenticatedHandler).toHaveBeenCalled(); + expect(mockAuthenticatedHandler).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/api-rest/src/apis/common/publicApis.ts b/packages/api-rest/src/apis/common/publicApis.ts index c5cfcf43562..7ed0b998c32 100644 --- a/packages/api-rest/src/apis/common/publicApis.ts +++ b/packages/api-rest/src/apis/common/publicApis.ts @@ -71,7 +71,7 @@ const publicHandler = ( method, headers, abortSignal, - authMode: apiOptions.authMode, + authFallback: apiOptions.authFallback, }, isIamAuthApplicableForRest, signingServiceInfo, diff --git a/packages/api-rest/src/apis/common/transferHandler.ts b/packages/api-rest/src/apis/common/transferHandler.ts index e4c29ac13f3..d8d6bf51a29 100644 --- a/packages/api-rest/src/apis/common/transferHandler.ts +++ b/packages/api-rest/src/apis/common/transferHandler.ts @@ -21,7 +21,7 @@ import { } from '../../utils'; import { resolveHeaders } from '../../utils/resolveHeaders'; import { - RestApiAuthMode, + RestApiAuthFallback, RestApiResponse, SigningServiceInfo, } from '../../types'; @@ -34,7 +34,7 @@ type HandlerOptions = Omit & { headers?: Headers; withCredentials?: boolean; retryStrategy?: RetryStrategy; - authMode?: RestApiAuthMode; + authFallback?: RestApiAuthFallback; }; type RetryDecider = RetryOptions['retryDecider']; @@ -89,14 +89,11 @@ export const transferHandler = async ( abortSignal, }; - if (options.authMode) { - // remove conflicting headers to ensure either none or iam auth will be used - delete request.headers.authorization; - delete request.headers['x-api-key']; - } + const authFallback = + options.authFallback ?? amplify?.libraryOptions?.API?.REST?.authFallback; let credentials: AWSCredentials | null = null; - if (options.authMode !== 'none') { + if (authFallback !== 'none') { credentials = await resolveCredentials(amplify); } diff --git a/packages/api-rest/src/types/index.ts b/packages/api-rest/src/types/index.ts index 7bd04088d25..bb90aac187b 100644 --- a/packages/api-rest/src/types/index.ts +++ b/packages/api-rest/src/types/index.ts @@ -16,8 +16,7 @@ export type PatchOperation = Operation; export type DeleteOperation = Operation; export type HeadOperation = Operation>; -// Add this type definition -export type RestApiAuthMode = 'none' | 'iam'; +export type RestApiAuthFallback = 'none' | 'iam'; /** * @internal @@ -44,7 +43,7 @@ export interface RestApiOptionsBase { * @default ` { strategy: 'jittered-exponential-backoff' } ` */ retryStrategy?: RetryStrategy; - authMode?: RestApiAuthMode; + authFallback?: RestApiAuthFallback; } type Headers = Record; diff --git a/packages/core/src/singleton/API/types.ts b/packages/core/src/singleton/API/types.ts index 1fceab2b5c3..724ff5079e0 100644 --- a/packages/core/src/singleton/API/types.ts +++ b/packages/core/src/singleton/API/types.ts @@ -25,6 +25,10 @@ export interface LibraryAPIOptions { * @default ` { strategy: 'jittered-exponential-backoff' } ` */ retryStrategy?: RetryStrategy; + /** + * Default auth fallback for REST API calls when no explicit auth is provided. + */ + authFallback?: 'none' | 'iam'; }; } From d9ced79b4acd7fcb3b45d5a38174f118d0f0dbcd Mon Sep 17 00:00:00 2001 From: Michael Sober Date: Wed, 3 Sep 2025 11:31:18 +0200 Subject: [PATCH 3/3] fix: change parameter name and type --- .../__tests__/apis/common/publicApis.test.ts | 16 ++++++++-------- packages/api-rest/src/apis/common/publicApis.ts | 2 +- .../api-rest/src/apis/common/transferHandler.ts | 16 +++++++--------- packages/api-rest/src/types/index.ts | 10 ++++++---- packages/core/src/libraryUtils.ts | 1 + packages/core/src/singleton/API/types.ts | 6 ++++-- 6 files changed, 27 insertions(+), 24 deletions(-) diff --git a/packages/api-rest/__tests__/apis/common/publicApis.test.ts b/packages/api-rest/__tests__/apis/common/publicApis.test.ts index bae321c720b..b4b48e6a3ba 100644 --- a/packages/api-rest/__tests__/apis/common/publicApis.test.ts +++ b/packages/api-rest/__tests__/apis/common/publicApis.test.ts @@ -621,15 +621,15 @@ describe('public APIs', () => { }); }); - describe('authFallback option', () => { - it('should skip credential resolution when authFallback is "none"', async () => { + describe('defaultAuthMode option', () => { + it('should skip credential resolution when defaultAuthMode is "none"', async () => { mockFetchAuthSession.mockClear(); await fn(mockAmplifyInstance, { apiName: 'restApi1', path: '/public', options: { - authFallback: 'none', + defaultAuthMode: 'none', }, }).response; @@ -638,7 +638,7 @@ describe('public APIs', () => { expect(mockAuthenticatedHandler).not.toHaveBeenCalled(); }); - it('should resolve credentials when authFallback is "iam"', async () => { + it('should resolve credentials when defaultAuthMode is "iam"', async () => { mockFetchAuthSession.mockResolvedValue({ credentials: { accessKeyId: 'test-key', @@ -650,7 +650,7 @@ describe('public APIs', () => { apiName: 'restApi1', path: '/private', options: { - authFallback: 'iam', + defaultAuthMode: 'iam', }, }).response; @@ -658,7 +658,7 @@ describe('public APIs', () => { expect(mockAuthenticatedHandler).toHaveBeenCalled(); }); - it('should maintain default behavior when no authFallback specified', async () => { + it('should maintain default behavior when no defaultAuthMode specified', async () => { mockFetchAuthSession.mockResolvedValue({ credentials: null, }); @@ -672,7 +672,7 @@ describe('public APIs', () => { expect(mockUnauthenticatedHandler).toHaveBeenCalled(); }); - it('should use global authFallback configuration when no local authFallback is specified', async () => { + it('should use global defaultAuthMode configuration when no local defaultAuthMode is specified', async () => { const mockAmplifyWithGlobalConfig = { ...mockAmplifyInstance, libraryOptions: { @@ -680,7 +680,7 @@ describe('public APIs', () => { API: { ...mockAmplifyInstance.libraryOptions?.API, REST: { - authFallback: 'none' as const, + defaultAuthMode: 'none' as const, }, }, }, diff --git a/packages/api-rest/src/apis/common/publicApis.ts b/packages/api-rest/src/apis/common/publicApis.ts index 7ed0b998c32..24f9411ad2a 100644 --- a/packages/api-rest/src/apis/common/publicApis.ts +++ b/packages/api-rest/src/apis/common/publicApis.ts @@ -71,7 +71,7 @@ const publicHandler = ( method, headers, abortSignal, - authFallback: apiOptions.authFallback, + defaultAuthMode: apiOptions.defaultAuthMode, }, isIamAuthApplicableForRest, signingServiceInfo, diff --git a/packages/api-rest/src/apis/common/transferHandler.ts b/packages/api-rest/src/apis/common/transferHandler.ts index d8d6bf51a29..c81889c55d4 100644 --- a/packages/api-rest/src/apis/common/transferHandler.ts +++ b/packages/api-rest/src/apis/common/transferHandler.ts @@ -11,6 +11,7 @@ import { import { AWSCredentials, DocumentType, + RESTAuthMode, RetryStrategy, } from '@aws-amplify/core/internals/utils'; @@ -20,11 +21,7 @@ import { parseSigningInfo, } from '../../utils'; import { resolveHeaders } from '../../utils/resolveHeaders'; -import { - RestApiAuthFallback, - RestApiResponse, - SigningServiceInfo, -} from '../../types'; +import { RestApiResponse, SigningServiceInfo } from '../../types'; import { authenticatedHandler } from './baseHandlers/authenticatedHandler'; import { unauthenticatedHandler } from './baseHandlers/unauthenticatedHandler'; @@ -34,7 +31,7 @@ type HandlerOptions = Omit & { headers?: Headers; withCredentials?: boolean; retryStrategy?: RetryStrategy; - authFallback?: RestApiAuthFallback; + defaultAuthMode?: RESTAuthMode; }; type RetryDecider = RetryOptions['retryDecider']; @@ -89,11 +86,12 @@ export const transferHandler = async ( abortSignal, }; - const authFallback = - options.authFallback ?? amplify?.libraryOptions?.API?.REST?.authFallback; + const defaultAuthMode = + options.defaultAuthMode ?? + amplify?.libraryOptions?.API?.REST?.defaultAuthMode; let credentials: AWSCredentials | null = null; - if (authFallback !== 'none') { + if (defaultAuthMode !== 'none') { credentials = await resolveCredentials(amplify); } diff --git a/packages/api-rest/src/types/index.ts b/packages/api-rest/src/types/index.ts index bb90aac187b..27d13cbc315 100644 --- a/packages/api-rest/src/types/index.ts +++ b/packages/api-rest/src/types/index.ts @@ -1,6 +1,10 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { DocumentType, RetryStrategy } from '@aws-amplify/core/internals/utils'; +import { + DocumentType, + RESTAuthMode, + RetryStrategy, +} from '@aws-amplify/core/internals/utils'; export type GetInput = ApiInput; export type PostInput = ApiInput; @@ -16,8 +20,6 @@ export type PatchOperation = Operation; export type DeleteOperation = Operation; export type HeadOperation = Operation>; -export type RestApiAuthFallback = 'none' | 'iam'; - /** * @internal */ @@ -43,7 +45,7 @@ export interface RestApiOptionsBase { * @default ` { strategy: 'jittered-exponential-backoff' } ` */ retryStrategy?: RetryStrategy; - authFallback?: RestApiAuthFallback; + defaultAuthMode?: RESTAuthMode; } type Headers = Record; diff --git a/packages/core/src/libraryUtils.ts b/packages/core/src/libraryUtils.ts index bba46ba7ce9..ea1c6c7c7a5 100644 --- a/packages/core/src/libraryUtils.ts +++ b/packages/core/src/libraryUtils.ts @@ -48,6 +48,7 @@ export { AssociationHasOne, DocumentType, GraphQLAuthMode, + RESTAuthMode, ModelFieldType, NonModelFieldType, ModelIntrospectionSchema, diff --git a/packages/core/src/singleton/API/types.ts b/packages/core/src/singleton/API/types.ts index 724ff5079e0..26ca13fdc38 100644 --- a/packages/core/src/singleton/API/types.ts +++ b/packages/core/src/singleton/API/types.ts @@ -26,9 +26,9 @@ export interface LibraryAPIOptions { */ retryStrategy?: RetryStrategy; /** - * Default auth fallback for REST API calls when no explicit auth is provided. + * Default auth mode for REST API calls when no explicit auth is provided. */ - authFallback?: 'none' | 'iam'; + defaultAuthMode?: RESTAuthMode; }; } @@ -142,6 +142,8 @@ export type GraphQLAuthMode = | 'lambda' | 'none'; +export type RESTAuthMode = 'none' | 'iam'; + /** * Type representing a plain JavaScript object that can be serialized to JSON. */