diff --git a/.github/integ-config/integ-all.yml b/.github/integ-config/integ-all.yml index f049dde518e..3c37683608b 100644 --- a/.github/integ-config/integ-all.yml +++ b/.github/integ-config/integ-all.yml @@ -929,3 +929,64 @@ tests: browser: [chrome] env: NEXT_PUBLIC_BACKEND_CONFIG: mfa-setup + - test_name: integ_next_passwordless_auto_sign_in + desc: 'passwordless auto sign in with session' + framework: next + category: auth + sample_name: [mfa] + spec: passwordless/auto-sign-in + # browser: *minimal_browser_list + browser: [chrome] + env: + NEXT_PUBLIC_BACKEND_CONFIG: pwl-autosignin + - test_name: integ_next_passwordless_first_factor_selection + desc: 'passwordless sign in with first factor selection' + framework: next + category: auth + sample_name: [mfa] + spec: passwordless/first-factor-selection + # browser: *minimal_browser_list + browser: [chrome] + env: + NEXT_PUBLIC_BACKEND_CONFIG: pwl-ffselect + - test_name: integ_next_passwordless_preferred_challenge + desc: 'passwordless sign in with preferred challenge' + framework: next + category: auth + sample_name: [mfa] + spec: passwordless/preferred-challenge + # browser: *minimal_browser_list + browser: [chrome] + env: + NEXT_PUBLIC_BACKEND_CONFIG: pwl-prefchal + - test_name: integ_next_passwordless_sign_up + desc: 'passwordless sign up' + framework: next + category: auth + sample_name: [mfa] + spec: passwordless/sign-up + # browser: *minimal_browser_list + browser: [chrome] + env: + NEXT_PUBLIC_BACKEND_CONFIG: pwl-signup + - test_name: integ_next_passwordless_misc + desc: 'passwordless miscellaneous flows' + framework: next + category: auth + sample_name: [mfa] + spec: passwordless/miscellaneous + # browser: *minimal_browser_list + browser: [chrome] + env: + NEXT_PUBLIC_BACKEND_CONFIG: pwl-misc + - test_name: integ_next_passwordless_webauthn + desc: 'passwordless webauthn sign in and lifecycle management' + framework: next + category: auth + sample_name: [mfa] + spec: passwordless/webauthn + # chrome only + # https://chromedevtools.github.io/devtools-protocol/tot/WebAuthn/ + browser: [chrome] + env: + NEXT_PUBLIC_BACKEND_CONFIG: pwl-webauthn diff --git a/packages/auth/__tests__/client/apis/associateWebAuthnCredential.test.ts b/packages/auth/__tests__/client/apis/associateWebAuthnCredential.test.ts new file mode 100644 index 00000000000..bae6e6ec77f --- /dev/null +++ b/packages/auth/__tests__/client/apis/associateWebAuthnCredential.test.ts @@ -0,0 +1,197 @@ +import { Amplify, fetchAuthSession } from '@aws-amplify/core'; +import { decodeJWT } from '@aws-amplify/core/internals/utils'; + +import { + createCompleteWebAuthnRegistrationClient, + createStartWebAuthnRegistrationClient, +} from '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; +import { + PasskeyError, + PasskeyErrorCode, +} from '../../../src/client/utils/passkey/errors'; +import { associateWebAuthnCredential } from '../../../src/client/apis/associateWebAuthnCredential'; +import { + passkeyCredentialCreateOptions, + passkeyRegistrationResult, +} from '../../mockData'; +import { serializePkcWithAttestationToJson } from '../../../src/client/utils/passkey/serde'; +import * as utils from '../../../src/client/utils'; +import { getIsPasskeySupported } from '../../../src/client/utils/passkey/getIsPasskeySupported'; +import { setUpGetConfig } from '../../providers/cognito/testUtils/setUpGetConfig'; +import { mockAccessToken } from '../../providers/cognito/testUtils/data'; +import { + assertCredentialIsPkcWithAuthenticatorAssertionResponse, + assertCredentialIsPkcWithAuthenticatorAttestationResponse, +} from '../../../src/client/utils/passkey/types'; + +jest.mock('@aws-amplify/core', () => ({ + ...(jest.createMockFromModule('@aws-amplify/core') as object), + Amplify: { getConfig: jest.fn(() => ({})) }, +})); +jest.mock('@aws-amplify/core/internals/utils', () => ({ + ...jest.requireActual('@aws-amplify/core/internals/utils'), + isBrowser: jest.fn(() => false), +})); +jest.mock( + '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider', +); +jest.mock('../../../src/providers/cognito/factories'); + +jest.mock('../../../src/client/utils/passkey/getIsPasskeySupported'); +jest.mock('../../../src/client/utils/passkey/types', () => ({ + ...jest.requireActual('../../../src/client/utils/passkey/types'), + assertCredentialIsPkcWithAuthenticatorAssertionResponse: jest.fn(), + assertCredentialIsPkcWithAuthenticatorAttestationResponse: jest.fn(), +})); + +Object.assign(navigator, { + credentials: { + create: jest.fn(), + }, +}); + +describe('associateWebAuthnCredential', () => { + const navigatorCredentialsCreateSpy = jest.spyOn( + navigator.credentials, + 'create', + ); + const registerPasskeySpy = jest.spyOn(utils, 'registerPasskey'); + + const mockFetchAuthSession = jest.mocked(fetchAuthSession); + + const mockGetIsPasskeySupported = jest.mocked(getIsPasskeySupported); + + const mockStartWebAuthnRegistration = jest.fn(); + const mockCreateStartWebAuthnRegistrationClient = jest.mocked( + createStartWebAuthnRegistrationClient, + ); + + const mockCompleteWebAuthnRegistration = jest.fn(); + const mockCreateCompleteWebAuthnRegistrationClient = jest.mocked( + createCompleteWebAuthnRegistrationClient, + ); + + const mockAssertCredentialIsPkcWithAuthenticatorAssertionResponse = + jest.mocked(assertCredentialIsPkcWithAuthenticatorAssertionResponse); + const mockAssertCredentialIsPkcWithAuthenticatorAttestationResponse = + jest.mocked(assertCredentialIsPkcWithAuthenticatorAttestationResponse); + + beforeAll(() => { + setUpGetConfig(Amplify); + mockFetchAuthSession.mockResolvedValue({ + tokens: { accessToken: decodeJWT(mockAccessToken) }, + }); + mockCreateStartWebAuthnRegistrationClient.mockReturnValue( + mockStartWebAuthnRegistration, + ); + mockCreateCompleteWebAuthnRegistrationClient.mockReturnValue( + mockCompleteWebAuthnRegistration, + ); + mockCompleteWebAuthnRegistration.mockImplementation(() => ({ + CredentialId: '12345', + })); + + navigatorCredentialsCreateSpy.mockResolvedValue(passkeyRegistrationResult); + + mockGetIsPasskeySupported.mockReturnValue(true); + mockAssertCredentialIsPkcWithAuthenticatorAssertionResponse.mockImplementation( + () => undefined, + ); + mockAssertCredentialIsPkcWithAuthenticatorAttestationResponse.mockImplementation( + () => undefined, + ); + }); + + afterEach(() => { + mockFetchAuthSession.mockClear(); + mockStartWebAuthnRegistration.mockClear(); + navigatorCredentialsCreateSpy.mockClear(); + }); + + it('should pass the correct service options when retrieving credential creation options', async () => { + mockStartWebAuthnRegistration.mockImplementation(() => ({ + CredentialCreationOptions: passkeyCredentialCreateOptions, + })); + + await associateWebAuthnCredential(); + + expect(mockStartWebAuthnRegistration).toHaveBeenCalledWith( + { + region: 'us-west-2', + userAgentValue: expect.any(String), + }, + { + AccessToken: mockAccessToken, + }, + ); + }); + + it('should pass the correct service options when verifying a credential', async () => { + mockStartWebAuthnRegistration.mockImplementation(() => ({ + CredentialCreationOptions: passkeyCredentialCreateOptions, + })); + + await associateWebAuthnCredential(); + + expect(mockCompleteWebAuthnRegistration).toHaveBeenCalledWith( + { + region: 'us-west-2', + userAgentValue: expect.any(String), + }, + { + AccessToken: mockAccessToken, + Credential: serializePkcWithAttestationToJson( + passkeyRegistrationResult, + ), + }, + ); + }); + + it('should call the registerPasskey function with correct input', async () => { + mockStartWebAuthnRegistration.mockImplementation(() => ({ + CredentialCreationOptions: passkeyCredentialCreateOptions, + })); + + await associateWebAuthnCredential(); + + expect(registerPasskeySpy).toHaveBeenCalledWith( + passkeyCredentialCreateOptions, + ); + + expect(navigatorCredentialsCreateSpy).toHaveBeenCalled(); + }); + + it('should throw an error when service returns empty credential creation options', async () => { + expect.assertions(2); + + mockStartWebAuthnRegistration.mockImplementation(() => ({ + CredentialCreationOptions: undefined, + })); + + try { + await associateWebAuthnCredential(); + } catch (error: any) { + expect(error).toBeInstanceOf(PasskeyError); + expect(error.name).toBe( + PasskeyErrorCode.InvalidPasskeyRegistrationOptions, + ); + } + }); + + it('should throw an error when passkeys are not supported', async () => { + expect.assertions(2); + + mockStartWebAuthnRegistration.mockImplementation(() => ({ + CredentialCreationOptions: passkeyCredentialCreateOptions, + })); + + mockGetIsPasskeySupported.mockReturnValue(false); + + try { + await associateWebAuthnCredential(); + } catch (error: any) { + expect(error).toBeInstanceOf(PasskeyError); + expect(error.name).toBe(PasskeyErrorCode.PasskeyNotSupported); + } + }); +}); diff --git a/packages/auth/__tests__/client/flows/shared/handlePasswordSRP.test.ts b/packages/auth/__tests__/client/flows/shared/handlePasswordSRP.test.ts new file mode 100644 index 00000000000..de71d7a071b --- /dev/null +++ b/packages/auth/__tests__/client/flows/shared/handlePasswordSRP.test.ts @@ -0,0 +1,390 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createInitiateAuthClient } from '../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; +import { createCognitoUserPoolEndpointResolver } from '../../../../src/providers/cognito/factories'; +import { getAuthenticationHelper } from '../../../../src/providers/cognito/utils/srp'; +import { getUserContextData } from '../../../../src/providers/cognito/utils/userContextData'; +import { handlePasswordSRP } from '../../../../src/client/flows/shared/handlePasswordSRP'; +import * as signInHelpers from '../../../../src/providers/cognito/utils/signInHelpers'; + +// Mock dependencies +jest.mock( + '../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider', +); +jest.mock('../../../../src/providers/cognito/factories'); +jest.mock('../../../../src/providers/cognito/utils/srp'); +jest.mock('../../../../src/providers/cognito/utils/userContextData'); +jest.mock('../../../../src/providers/cognito/utils/signInHelpers', () => ({ + ...jest.requireActual( + '../../../../src/providers/cognito/utils/signInHelpers', + ), + setActiveSignInUsername: jest.fn(), + handlePasswordVerifierChallenge: jest.fn(), + retryOnResourceNotFoundException: jest.fn(), +})); + +describe('handlePasswordSRP', () => { + const mockConfig = { + userPoolId: 'us-west-2_testpool', + userPoolClientId: 'test-client-id', + userPoolEndpoint: 'test-endpoint', + }; + + const mockInitiateAuth = jest.fn(); + const mockCreateEndpointResolver = jest.fn(); + const mockAuthenticationHelper = { + A: { toString: () => '123456' }, + }; + const mockTokenOrchestrator = { + getDeviceMetadata: jest.fn(), + clearDeviceMetadata: jest.fn(), + } as any; + + beforeEach(() => { + jest.clearAllMocks(); + (createInitiateAuthClient as jest.Mock).mockReturnValue(mockInitiateAuth); + (createCognitoUserPoolEndpointResolver as jest.Mock).mockReturnValue( + mockCreateEndpointResolver, + ); + (getAuthenticationHelper as jest.Mock).mockResolvedValue( + mockAuthenticationHelper, + ); + (getUserContextData as jest.Mock).mockReturnValue({ + UserContextData: 'test', + }); + ( + signInHelpers.retryOnResourceNotFoundException as jest.Mock + ).mockImplementation((fn, args) => fn(...args)); + mockInitiateAuth.mockResolvedValue({ + ChallengeParameters: { USERNAME: 'testuser' }, + Session: 'test-session', + }); + }); + + test('should handle USER_SRP_AUTH flow without preferred challenge', async () => { + const username = 'testuser'; + const password = 'testpassword'; + + await handlePasswordSRP({ + username, + password, + clientMetadata: undefined, + config: mockConfig, + tokenOrchestrator: mockTokenOrchestrator, + authFlow: 'USER_SRP_AUTH', + }); + + expect(mockInitiateAuth).toHaveBeenCalledWith( + { + region: 'us-west-2', + userAgentValue: expect.any(String), + }, + { + AuthFlow: 'USER_SRP_AUTH', + AuthParameters: { + USERNAME: username, + SRP_A: '123456', + }, + ClientId: mockConfig.userPoolClientId, + ClientMetadata: undefined, + UserContextData: { UserContextData: 'test' }, + }, + ); + }); + + test('should handle USER_AUTH flow with preferred challenge', async () => { + const username = 'testuser'; + const password = 'testpassword'; + const preferredChallenge = 'PASSWORD_SRP'; + + await handlePasswordSRP({ + username, + password, + clientMetadata: undefined, + config: mockConfig, + tokenOrchestrator: mockTokenOrchestrator, + authFlow: 'USER_AUTH', + preferredChallenge, + }); + + expect(mockInitiateAuth).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + AuthFlow: 'USER_AUTH', + AuthParameters: { + USERNAME: username, + SRP_A: '123456', + PREFERRED_CHALLENGE: preferredChallenge, + }, + }), + ); + }); + + test('should not add PREFERRED_CHALLENGE for USER_SRP_AUTH even if provided', async () => { + const username = 'testuser'; + const password = 'testpassword'; + const preferredChallenge = 'PASSWORD_SRP'; + + await handlePasswordSRP({ + username, + password, + clientMetadata: undefined, + config: mockConfig, + tokenOrchestrator: mockTokenOrchestrator, + authFlow: 'USER_SRP_AUTH', + preferredChallenge, + }); + + expect(mockInitiateAuth).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + AuthParameters: { + USERNAME: username, + SRP_A: '123456', + }, + }), + ); + }); + + test('should handle PASSWORD_VERIFIER challenge response', async () => { + const username = 'testuser'; + const password = 'testpassword'; + const session = 'test-session'; + const challengeParameters = { + USERNAME: username, + SRP_B: 'srpB', + SALT: 'salt', + SECRET_BLOCK: 'secret', + }; + + mockInitiateAuth.mockResolvedValueOnce({ + ChallengeName: 'PASSWORD_VERIFIER', + ChallengeParameters: challengeParameters, + Session: session, + }); + + await handlePasswordSRP({ + username, + password, + clientMetadata: undefined, + config: mockConfig, + tokenOrchestrator: mockTokenOrchestrator, + authFlow: 'USER_AUTH', + }); + + expect(signInHelpers.retryOnResourceNotFoundException).toHaveBeenCalledWith( + signInHelpers.handlePasswordVerifierChallenge, + [ + password, + challengeParameters, + undefined, + session, + mockAuthenticationHelper, + mockConfig, + mockTokenOrchestrator, + ], + username, + mockTokenOrchestrator, + ); + }); + + test('should return response directly when not PASSWORD_VERIFIER challenge', async () => { + const username = 'testuser'; + const mockResponse = { + ChallengeName: 'CUSTOM_CHALLENGE', + Session: 'test-session', + ChallengeParameters: { USERNAME: username }, + }; + mockInitiateAuth.mockResolvedValueOnce(mockResponse); + + const result = await handlePasswordSRP({ + username, + password: 'testpassword', + clientMetadata: undefined, + config: mockConfig, + tokenOrchestrator: mockTokenOrchestrator, + authFlow: 'USER_AUTH', + }); + + expect(result).toEqual(mockResponse); + expect( + signInHelpers.retryOnResourceNotFoundException, + ).not.toHaveBeenCalled(); + }); + + test('should handle client metadata when provided', async () => { + const username = 'testuser'; + const password = 'testpassword'; + const clientMetadata = { client: 'test' }; + + await handlePasswordSRP({ + username, + password, + clientMetadata, + config: mockConfig, + tokenOrchestrator: mockTokenOrchestrator, + authFlow: 'USER_SRP_AUTH', + }); + + expect(mockInitiateAuth).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + ClientMetadata: clientMetadata, + }), + ); + }); + + test('should set active username from challenge parameters', async () => { + const username = 'testuser'; + const challengeUsername = 'challengeuser'; + const password = 'testpassword'; + + mockInitiateAuth.mockResolvedValueOnce({ + ChallengeParameters: { USERNAME: challengeUsername }, + Session: 'test-session', + }); + + await handlePasswordSRP({ + username, + password, + clientMetadata: undefined, + config: mockConfig, + tokenOrchestrator: mockTokenOrchestrator, + authFlow: 'USER_SRP_AUTH', + }); + + expect(signInHelpers.setActiveSignInUsername).toHaveBeenCalledWith( + challengeUsername, + ); + }); + + test('should call handlePasswordVerifierChallenge with correct parameters', async () => { + const username = 'testuser'; + const password = 'testpassword'; + const session = 'test-session'; + const challengeParameters = { + USERNAME: username, + SRP_B: 'srpB', + SALT: 'salt', + SECRET_BLOCK: 'secret', + }; + + mockInitiateAuth.mockResolvedValueOnce({ + ChallengeName: 'PASSWORD_VERIFIER', + ChallengeParameters: challengeParameters, + Session: session, + }); + + await handlePasswordSRP({ + username, + password, + clientMetadata: undefined, + config: mockConfig, + tokenOrchestrator: mockTokenOrchestrator, + authFlow: 'USER_SRP_AUTH', + }); + + expect(signInHelpers.retryOnResourceNotFoundException).toHaveBeenCalledWith( + signInHelpers.handlePasswordVerifierChallenge, + [ + password, + challengeParameters, + undefined, + session, + mockAuthenticationHelper, + mockConfig, + mockTokenOrchestrator, + ], + username, + mockTokenOrchestrator, + ); + }); + + test('should handle userPoolId without second part after underscore', async () => { + const username = 'testuser'; + const password = 'testpassword'; + + const configWithEmptyPool = { + ...mockConfig, + userPoolId: 'us-west-2_', // Valid region format but empty after underscore + }; + + await handlePasswordSRP({ + username, + password, + clientMetadata: undefined, + config: configWithEmptyPool, + tokenOrchestrator: mockTokenOrchestrator, + authFlow: 'USER_SRP_AUTH', + }); + + expect(getAuthenticationHelper).toHaveBeenCalledWith(''); + }); + + test('should use original username when ChallengeParameters is undefined', async () => { + const username = 'testuser'; + const password = 'testpassword'; + + mockInitiateAuth.mockResolvedValueOnce({ + ChallengeName: 'PASSWORD_VERIFIER', + Session: 'test-session', + // ChallengeParameters is undefined + }); + + await handlePasswordSRP({ + username, + password, + clientMetadata: undefined, + config: mockConfig, + tokenOrchestrator: mockTokenOrchestrator, + authFlow: 'USER_AUTH', + }); + + expect(signInHelpers.setActiveSignInUsername).toHaveBeenCalledWith( + username, + ); + }); + + test('should not add PREFERRED_CHALLENGE for USER_AUTH when preferredChallenge is undefined', async () => { + const username = 'testuser'; + const password = 'testpassword'; + + await handlePasswordSRP({ + username, + password, + clientMetadata: undefined, + config: mockConfig, + tokenOrchestrator: mockTokenOrchestrator, + authFlow: 'USER_AUTH', + // preferredChallenge is undefined + }); + + expect(mockInitiateAuth).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + AuthParameters: { + USERNAME: username, + SRP_A: '123456', + // Should not include PREFERRED_CHALLENGE + }, + }), + ); + }); + + test('should throw error when initiateAuth fails', async () => { + const error = new Error('Auth failed'); + mockInitiateAuth.mockRejectedValueOnce(error); + + await expect( + handlePasswordSRP({ + username: 'testuser', + password: 'testpassword', + clientMetadata: undefined, + config: mockConfig, + tokenOrchestrator: mockTokenOrchestrator, + authFlow: 'USER_SRP_AUTH', + }), + ).rejects.toThrow('Auth failed'); + }); +}); diff --git a/packages/auth/__tests__/client/flows/userAuth/handleSelectChallenge.test.ts b/packages/auth/__tests__/client/flows/userAuth/handleSelectChallenge.test.ts new file mode 100644 index 00000000000..8447bbb6963 --- /dev/null +++ b/packages/auth/__tests__/client/flows/userAuth/handleSelectChallenge.test.ts @@ -0,0 +1,175 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createRespondToAuthChallengeClient } from '../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; +import { createCognitoUserPoolEndpointResolver } from '../../../../src/providers/cognito/factories'; +import { initiateSelectedChallenge } from '../../../../src/client/flows/userAuth/handleSelectChallenge'; +import { RespondToAuthChallengeCommandOutput } from '../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider/types'; + +// Mock dependencies +jest.mock( + '../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider', +); +jest.mock('../../../../src/providers/cognito/factories'); + +describe('initiateSelectedChallenge', () => { + const mockConfig = { + userPoolId: 'us-west-2_testpool', + userPoolClientId: 'test-client-id', + userPoolEndpoint: 'test-endpoint', + }; + + const mockRespondToAuthChallenge = jest.fn(); + const mockCreateEndpointResolver = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (createRespondToAuthChallengeClient as jest.Mock).mockReturnValue( + mockRespondToAuthChallenge, + ); + (createCognitoUserPoolEndpointResolver as jest.Mock).mockReturnValue( + mockCreateEndpointResolver, + ); + mockRespondToAuthChallenge.mockResolvedValue({ + ChallengeName: 'CUSTOM_CHALLENGE', + Session: 'test-session', + }); + }); + + test('should handle basic challenge selection', async () => { + const username = 'testuser'; + const session = 'test-session'; + const selectedChallenge = 'EMAIL_OTP'; + + await initiateSelectedChallenge({ + username, + session, + selectedChallenge, + config: mockConfig, + }); + + expect(mockRespondToAuthChallenge).toHaveBeenCalledWith( + { + region: 'us-west-2', + userAgentValue: expect.any(String), + }, + { + ChallengeName: 'SELECT_CHALLENGE', + ChallengeResponses: { + USERNAME: username, + ANSWER: selectedChallenge, + }, + ClientId: mockConfig.userPoolClientId, + Session: session, + ClientMetadata: undefined, + }, + ); + }); + + test('should include client metadata when provided', async () => { + const username = 'testuser'; + const session = 'test-session'; + const selectedChallenge = 'EMAIL_OTP'; + const clientMetadata = { client: 'test' }; + + await initiateSelectedChallenge({ + username, + session, + selectedChallenge, + config: mockConfig, + clientMetadata, + }); + + expect(mockRespondToAuthChallenge).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + ClientMetadata: clientMetadata, + }), + ); + }); + + test('should return the response from respondToAuthChallenge', async () => { + const mockResponse: RespondToAuthChallengeCommandOutput = { + ChallengeName: 'EMAIL_OTP', + Session: 'new-session', + ChallengeParameters: { + CODE_DELIVERY_DELIVERY_MEDIUM: 'EMAIL', + CODE_DELIVERY_DESTINATION: 'test@example.com', + }, + $metadata: {}, + }; + mockRespondToAuthChallenge.mockResolvedValueOnce(mockResponse); + + const result = await initiateSelectedChallenge({ + username: 'testuser', + session: 'test-session', + selectedChallenge: 'EMAIL_OTP', + config: mockConfig, + }); + + expect(result).toEqual(mockResponse); + }); + + test('should throw error when respondToAuthChallenge fails', async () => { + const error = new Error('Auth challenge failed'); + mockRespondToAuthChallenge.mockRejectedValueOnce(error); + + await expect( + initiateSelectedChallenge({ + username: 'testuser', + session: 'test-session', + selectedChallenge: 'EMAIL_OTP', + config: mockConfig, + }), + ).rejects.toThrow('Auth challenge failed'); + }); + + test('should support different challenge types', async () => { + const testCases = ['EMAIL_OTP', 'SMS_OTP', 'PASSWORD', 'TOTP']; + + for (const challengeType of testCases) { + await initiateSelectedChallenge({ + username: 'testuser', + session: 'test-session', + selectedChallenge: challengeType, + config: mockConfig, + }); + + expect(mockRespondToAuthChallenge).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ + ChallengeResponses: { + USERNAME: 'testuser', + ANSWER: challengeType, + }, + }), + ); + } + }); + + test('should use correct endpoint and region from config', async () => { + const customConfig = { + userPoolId: 'eu-west-1_custompool', + userPoolClientId: 'custom-client-id', + userPoolEndpoint: 'custom-endpoint', + }; + + await initiateSelectedChallenge({ + username: 'testuser', + session: 'test-session', + selectedChallenge: 'EMAIL_OTP', + config: customConfig, + }); + + expect(createCognitoUserPoolEndpointResolver).toHaveBeenCalledWith({ + endpointOverride: customConfig.userPoolEndpoint, + }); + + expect(mockRespondToAuthChallenge).toHaveBeenCalledWith( + expect.objectContaining({ + region: 'eu-west-1', + }), + expect.anything(), + ); + }); +}); diff --git a/packages/auth/__tests__/client/flows/userAuth/handleSelectChallengeWithPassword.test.ts b/packages/auth/__tests__/client/flows/userAuth/handleSelectChallengeWithPassword.test.ts new file mode 100644 index 00000000000..78322b59536 --- /dev/null +++ b/packages/auth/__tests__/client/flows/userAuth/handleSelectChallengeWithPassword.test.ts @@ -0,0 +1,191 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createRespondToAuthChallengeClient } from '../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; +import { createCognitoUserPoolEndpointResolver } from '../../../../src/providers/cognito/factories'; +import { getUserContextData } from '../../../../src/providers/cognito/utils/userContextData'; +import { handleSelectChallengeWithPassword } from '../../../../src/client/flows/userAuth/handleSelectChallengeWithPassword'; +import * as signInHelpers from '../../../../src/providers/cognito/utils/signInHelpers'; + +// Mock dependencies +jest.mock( + '../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider', +); +jest.mock('../../../../src/providers/cognito/factories'); +jest.mock('../../../../src/providers/cognito/utils/userContextData'); +jest.mock('../../../../src/providers/cognito/utils/signInHelpers', () => ({ + ...jest.requireActual( + '../../../../src/providers/cognito/utils/signInHelpers', + ), + setActiveSignInUsername: jest.fn(), +})); + +describe('handlePasswordChallenge', () => { + const mockConfig = { + userPoolId: 'us-west-2_testpool', + userPoolClientId: 'test-client-id', + userPoolEndpoint: 'test-endpoint', + }; + + const mockRespondToAuthChallenge = jest.fn(); + const mockCreateEndpointResolver = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (createRespondToAuthChallengeClient as jest.Mock).mockReturnValue( + mockRespondToAuthChallenge, + ); + (createCognitoUserPoolEndpointResolver as jest.Mock).mockReturnValue( + mockCreateEndpointResolver, + ); + (getUserContextData as jest.Mock).mockReturnValue({ + UserContextData: 'test', + }); + mockRespondToAuthChallenge.mockResolvedValue({ + ChallengeName: 'CUSTOM_CHALLENGE', + Session: 'test-session', + }); + }); + + test('should handle basic password challenge flow', async () => { + const username = 'testuser'; + const password = 'testpassword'; + const session = 'test-session'; + + await handleSelectChallengeWithPassword( + username, + password, + undefined, + mockConfig, + session, + ); + + expect(mockRespondToAuthChallenge).toHaveBeenCalledWith( + { + region: 'us-west-2', + userAgentValue: expect.any(String), + }, + { + ChallengeName: 'SELECT_CHALLENGE', + ChallengeResponses: { + ANSWER: 'PASSWORD', + USERNAME: username, + PASSWORD: password, + }, + ClientId: mockConfig.userPoolClientId, + ClientMetadata: undefined, + Session: session, + UserContextData: { UserContextData: 'test' }, + }, + ); + }); + + test('should handle client metadata when provided', async () => { + const username = 'testuser'; + const password = 'testpassword'; + const session = 'test-session'; + const clientMetadata = { client: 'test' }; + + await handleSelectChallengeWithPassword( + username, + password, + clientMetadata, + mockConfig, + session, + ); + + expect(mockRespondToAuthChallenge).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + ClientMetadata: clientMetadata, + }), + ); + }); + + test('should set active username from challenge parameters when available', async () => { + const username = 'testuser'; + const challengeUsername = 'challengeuser'; + const password = 'testpassword'; + const session = 'test-session'; + + mockRespondToAuthChallenge.mockResolvedValueOnce({ + ChallengeName: 'CUSTOM_CHALLENGE', + Session: 'test-session', + ChallengeParameters: { + USERNAME: challengeUsername, + }, + }); + + await handleSelectChallengeWithPassword( + username, + password, + undefined, + mockConfig, + session, + ); + + expect(signInHelpers.setActiveSignInUsername).toHaveBeenCalledWith( + challengeUsername, + ); + }); + + test('should set active username as original username when challenge parameters are missing', async () => { + const username = 'testuser'; + const password = 'testpassword'; + const session = 'test-session'; + + mockRespondToAuthChallenge.mockResolvedValueOnce({ + ChallengeName: 'CUSTOM_CHALLENGE', + Session: 'test-session', + ChallengeParameters: {}, + }); + + await handleSelectChallengeWithPassword( + username, + password, + undefined, + mockConfig, + session, + ); + + expect(signInHelpers.setActiveSignInUsername).toHaveBeenCalledWith( + username, + ); + }); + + test('should throw error when respondToAuthChallenge fails', async () => { + const error = new Error('Auth challenge failed'); + mockRespondToAuthChallenge.mockRejectedValueOnce(error); + + await expect( + handleSelectChallengeWithPassword( + 'testuser', + 'testpassword', + undefined, + mockConfig, + 'test-session', + ), + ).rejects.toThrow('Auth challenge failed'); + }); + + test('should return the response from respondToAuthChallenge', async () => { + const mockResponse = { + ChallengeName: 'CUSTOM_CHALLENGE', + Session: 'new-session', + ChallengeParameters: { + USERNAME: 'testuser', + }, + }; + mockRespondToAuthChallenge.mockResolvedValueOnce(mockResponse); + + const result = await handleSelectChallengeWithPassword( + 'testuser', + 'testpassword', + undefined, + mockConfig, + 'test-session', + ); + + expect(result).toEqual(mockResponse); + }); +}); diff --git a/packages/auth/__tests__/client/flows/userAuth/handleSelectChallengeWithPasswordSRP.test.ts b/packages/auth/__tests__/client/flows/userAuth/handleSelectChallengeWithPasswordSRP.test.ts new file mode 100644 index 00000000000..b89414c3ae1 --- /dev/null +++ b/packages/auth/__tests__/client/flows/userAuth/handleSelectChallengeWithPasswordSRP.test.ts @@ -0,0 +1,262 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createRespondToAuthChallengeClient } from '../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; +import { getAuthenticationHelper } from '../../../../src/providers/cognito/utils/srp'; +import { getUserContextData } from '../../../../src/providers/cognito/utils/userContextData'; +import { handleSelectChallengeWithPasswordSRP } from '../../../../src/client/flows/userAuth/handleSelectChallengeWithPasswordSRP'; +import * as signInHelpers from '../../../../src/providers/cognito/utils/signInHelpers'; + +// Mock dependencies +jest.mock( + '../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider', +); +jest.mock('../../../../src/providers/cognito/factories'); +jest.mock('../../../../src/providers/cognito/utils/srp'); +jest.mock('../../../../src/providers/cognito/utils/userContextData'); +jest.mock('../../../../src/providers/cognito/utils/signInHelpers', () => ({ + ...jest.requireActual( + '../../../../src/providers/cognito/utils/signInHelpers', + ), + setActiveSignInUsername: jest.fn(), + handlePasswordVerifierChallenge: jest.fn(), + retryOnResourceNotFoundException: jest.fn(), +})); + +describe('handleSelectChallengeWithPasswordSRP', () => { + const mockConfig = { + userPoolId: 'us-west-2_testpool', + userPoolClientId: 'test-client-id', + userPoolEndpoint: 'test-endpoint', + }; + + const mockTokenOrchestrator = { + getDeviceMetadata: jest.fn(), + clearDeviceMetadata: jest.fn(), + } as any; + + const mockRespondToAuthChallenge = jest.fn(); + const mockAuthenticationHelper = { + A: { toString: () => '123456' }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + (createRespondToAuthChallengeClient as jest.Mock).mockReturnValue( + mockRespondToAuthChallenge, + ); + (getAuthenticationHelper as jest.Mock).mockResolvedValue( + mockAuthenticationHelper, + ); + (getUserContextData as jest.Mock).mockReturnValue({ + UserContextData: 'test', + }); + mockRespondToAuthChallenge.mockResolvedValue({ + ChallengeName: 'CUSTOM_CHALLENGE', + Session: 'test-session', + }); + }); + + test('should handle basic SRP challenge flow', async () => { + const username = 'testuser'; + const password = 'testpassword'; + const session = 'test-session'; + + await handleSelectChallengeWithPasswordSRP( + username, + password, + undefined, + mockConfig, + session, + mockTokenOrchestrator, + ); + + expect(mockRespondToAuthChallenge).toHaveBeenCalledWith( + { + region: 'us-west-2', + userAgentValue: expect.any(String), + }, + { + ChallengeName: 'SELECT_CHALLENGE', + ChallengeResponses: { + ANSWER: 'PASSWORD_SRP', + USERNAME: username, + SRP_A: '123456', + }, + ClientId: mockConfig.userPoolClientId, + ClientMetadata: undefined, + Session: session, + UserContextData: { UserContextData: 'test' }, + }, + ); + }); + + test('should handle PASSWORD_VERIFIER challenge', async () => { + const username = 'testuser'; + const password = 'testpassword'; + const session = 'test-session'; + + const verifierResponse = { + ChallengeName: 'PASSWORD_VERIFIER', + Session: 'new-session', + ChallengeParameters: { + USERNAME: username, + SRP_B: 'srpB', + SALT: 'salt', + SECRET_BLOCK: 'secret', + }, + }; + + mockRespondToAuthChallenge.mockResolvedValueOnce(verifierResponse); + ( + signInHelpers.retryOnResourceNotFoundException as jest.Mock + ).mockImplementation((fn, args) => fn(...args)); + ( + signInHelpers.handlePasswordVerifierChallenge as jest.Mock + ).mockResolvedValue({ + AuthenticationResult: { AccessToken: 'token' }, + }); + + await handleSelectChallengeWithPasswordSRP( + username, + password, + undefined, + mockConfig, + session, + mockTokenOrchestrator, + ); + + expect(signInHelpers.retryOnResourceNotFoundException).toHaveBeenCalledWith( + signInHelpers.handlePasswordVerifierChallenge, + [ + password, + verifierResponse.ChallengeParameters, + undefined, + verifierResponse.Session, + mockAuthenticationHelper, + mockConfig, + mockTokenOrchestrator, + ], + username, + mockTokenOrchestrator, + ); + }); + + test('should handle client metadata when provided', async () => { + const username = 'testuser'; + const password = 'testpassword'; + const session = 'test-session'; + const clientMetadata = { client: 'test' }; + + await handleSelectChallengeWithPasswordSRP( + username, + password, + clientMetadata, + mockConfig, + session, + mockTokenOrchestrator, + ); + + expect(mockRespondToAuthChallenge).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + ClientMetadata: clientMetadata, + }), + ); + }); + + test('should set active username from challenge parameters when available', async () => { + const username = 'testuser'; + const challengeUsername = 'challengeuser'; + const password = 'testpassword'; + const session = 'test-session'; + + mockRespondToAuthChallenge.mockResolvedValueOnce({ + ChallengeName: 'CUSTOM_CHALLENGE', + Session: 'test-session', + ChallengeParameters: { + USERNAME: challengeUsername, + }, + }); + + await handleSelectChallengeWithPasswordSRP( + username, + password, + undefined, + mockConfig, + session, + mockTokenOrchestrator, + ); + + expect(signInHelpers.setActiveSignInUsername).toHaveBeenCalledWith( + challengeUsername, + ); + }); + + test('should use original username when ChallengeParameters is undefined', async () => { + const username = 'testuser'; + const password = 'testpassword'; + const session = 'test-session'; + + // Mock response without ChallengeParameters + mockRespondToAuthChallenge.mockResolvedValueOnce({ + ChallengeName: 'CUSTOM_CHALLENGE', + Session: 'test-session', + ChallengeParameters: undefined, + }); + + await handleSelectChallengeWithPasswordSRP( + username, + password, + undefined, + mockConfig, + session, + mockTokenOrchestrator, + ); + + // Verify it falls back to the original username + expect(signInHelpers.setActiveSignInUsername).toHaveBeenCalledWith( + username, + ); + }); + + test('should handle userPoolId without second part after underscore', async () => { + const username = 'testuser'; + const password = 'testpassword'; + const session = 'test-session'; + + // Create a new config with a userPoolId that has the region but nothing after underscore + const invalidPoolConfig = { + ...mockConfig, + userPoolId: 'us-west-2_', // Valid region format but empty after underscore + }; + + await handleSelectChallengeWithPasswordSRP( + username, + password, + undefined, + invalidPoolConfig, + session, + mockTokenOrchestrator, + ); + + // Verify getAuthenticationHelper was called with empty string + expect(getAuthenticationHelper).toHaveBeenCalledWith(''); + }); + + test('should throw error when respondToAuthChallenge fails', async () => { + const error = new Error('Auth challenge failed'); + mockRespondToAuthChallenge.mockRejectedValueOnce(error); + + await expect( + handleSelectChallengeWithPasswordSRP( + 'testuser', + 'testpassword', + undefined, + mockConfig, + 'test-session', + mockTokenOrchestrator, + ), + ).rejects.toThrow('Auth challenge failed'); + }); +}); diff --git a/packages/auth/__tests__/client/flows/userAuth/handleUserAuthFlow.test.ts b/packages/auth/__tests__/client/flows/userAuth/handleUserAuthFlow.test.ts new file mode 100644 index 00000000000..6e8185b3051 --- /dev/null +++ b/packages/auth/__tests__/client/flows/userAuth/handleUserAuthFlow.test.ts @@ -0,0 +1,212 @@ +import { Amplify } from '@aws-amplify/core'; + +import { createInitiateAuthClient } from '../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; +import { createCognitoUserPoolEndpointResolver } from '../../../../src/providers/cognito/factories'; +import { InitiateAuthCommandOutput } from '../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider/types'; +import { getUserContextData } from '../../../../src/providers/cognito/utils/userContextData'; +import { handleUserAuthFlow } from '../../../../src/client/flows/userAuth/handleUserAuthFlow'; + +// Mock dependencies +jest.mock('@aws-amplify/core/internals/utils', () => ({ + ...jest.requireActual('@aws-amplify/core/internals/utils'), + isBrowser: jest.fn(() => false), +})); +jest.mock('../../../../src/providers/cognito/utils/dispatchSignedInHubEvent'); +jest.mock( + '../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider', +); +jest.mock('../../../../src/providers/cognito/factories'); +jest.mock('../../../../src/providers/cognito/utils/userContextData', () => ({ + getUserContextData: jest.fn(), +})); +jest.mock('../../../../src/providers/cognito/utils/signInHelpers', () => { + return jest.requireActual( + '../../../../src/providers/cognito/utils/signInHelpers', + ); +}); + +const authConfig = { + Cognito: { + userPoolClientId: '111111-aaaaa-42d8-891d-ee81a1549398', + userPoolId: 'us-west-2_zzzzz', + }, +}; + +Amplify.configure({ + Auth: authConfig, +}); + +describe('handleUserAuthFlow', () => { + const mockConfig = { + userPoolId: 'us-west-2_testpool', + userPoolClientId: 'test-client-id', + userPoolEndpoint: 'test-endpoint', + }; + + const mockInitiateAuth = jest.fn(); + const mockCreateEndpointResolver = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (createInitiateAuthClient as jest.Mock).mockReturnValue(mockInitiateAuth); + (createCognitoUserPoolEndpointResolver as jest.Mock).mockReturnValue( + mockCreateEndpointResolver, + ); + (getUserContextData as jest.Mock).mockReturnValue({ + UserContextData: 'test', + }); + mockInitiateAuth.mockResolvedValue({ + ChallengeName: 'CUSTOM_CHALLENGE', + Session: 'test-session', + }); + }); + + test('should handle basic auth flow without preferred challenge', async () => { + const username = 'testuser'; + + await handleUserAuthFlow({ + username, + config: mockConfig, + tokenOrchestrator: expect.anything(), + }); + + // Verify initiateAuth was called with correct parameters + expect(mockInitiateAuth).toHaveBeenCalledWith( + { + region: 'us-west-2', + userAgentValue: expect.any(String), + }, + { + AuthFlow: 'USER_AUTH', + AuthParameters: { USERNAME: username }, + ClientId: mockConfig.userPoolClientId, + ClientMetadata: undefined, + UserContextData: { UserContextData: 'test' }, + }, + ); + }); + + test('should handle PASSWORD preferred challenge', async () => { + const username = 'testuser'; + const password = 'testpassword'; + + await handleUserAuthFlow({ + username, + password, + config: mockConfig, + tokenOrchestrator: expect.anything(), + preferredChallenge: 'PASSWORD', + }); + + // Verify initiateAuth was called with password + expect(mockInitiateAuth).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + AuthParameters: { + USERNAME: username, + PASSWORD: password, + PREFERRED_CHALLENGE: 'PASSWORD', + }, + }), + ); + }); + + test('should handle EMAIL_OTP preferred challenge', async () => { + const username = 'testuser'; + + await handleUserAuthFlow({ + username, + config: mockConfig, + tokenOrchestrator: expect.anything(), + preferredChallenge: 'EMAIL_OTP', + }); + + // Verify initiateAuth was called with EMAIL_OTP challenge + expect(mockInitiateAuth).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + AuthParameters: { + USERNAME: username, + PREFERRED_CHALLENGE: 'EMAIL_OTP', + }, + }), + ); + }); + + test('should include client metadata when provided', async () => { + const username = 'testuser'; + const clientMetadata = { client: 'test' }; + + await handleUserAuthFlow({ + username, + config: mockConfig, + tokenOrchestrator: expect.anything(), + clientMetadata, + }); + + // Verify client metadata was passed + expect(mockInitiateAuth).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + ClientMetadata: clientMetadata, + }), + ); + }); + + test('should handle auth response with challenges', async () => { + const mockResponse: InitiateAuthCommandOutput = { + ChallengeName: 'CUSTOM_CHALLENGE', + Session: 'test-session', + ChallengeParameters: { + USERNAME: 'testuser', + }, + $metadata: {}, + }; + mockInitiateAuth.mockResolvedValueOnce(mockResponse); + + const result = await handleUserAuthFlow({ + username: 'testuser', + config: mockConfig, + tokenOrchestrator: expect.anything(), + }); + + expect(result).toEqual(mockResponse); + }); + + test('should throw validation error for PASSWORD_SRP challenge without password', async () => { + await expect( + handleUserAuthFlow({ + username: 'testuser', + config: mockConfig, + tokenOrchestrator: expect.anything(), + preferredChallenge: 'PASSWORD_SRP', + // password is undefined + }), + ).rejects.toThrow('password is required to signIn'); + }); + + test('should throw validation error for PASSWORD challenge without password', async () => { + await expect( + handleUserAuthFlow({ + username: 'testuser', + config: mockConfig, + tokenOrchestrator: expect.anything(), + preferredChallenge: 'PASSWORD', + // password is undefined + }), + ).rejects.toThrow('password is required to signIn'); + }); + + test('should throw error when initiateAuth fails', async () => { + const error = new Error('Auth failed'); + mockInitiateAuth.mockRejectedValueOnce(error); + + await expect( + handleUserAuthFlow({ + username: 'testuser', + config: mockConfig, + tokenOrchestrator: expect.anything(), + }), + ).rejects.toThrow('Auth failed'); + }); +}); diff --git a/packages/auth/__tests__/client/utils/passkey.test.ts b/packages/auth/__tests__/client/utils/passkey.test.ts new file mode 100644 index 00000000000..c4fff5f891a --- /dev/null +++ b/packages/auth/__tests__/client/utils/passkey.test.ts @@ -0,0 +1,49 @@ +import { + deserializeJsonToPkcCreationOptions, + serializePkcWithAttestationToJson, +} from '../../../src/client/utils/passkey/serde'; +import { + passkeyRegistrationRequest, + passkeyRegistrationRequestJson, + passkeyRegistrationResult, + passkeyRegistrationResultJson, +} from '../../mockData'; + +describe('passkey', () => { + it('serializes pkc into correct json format', () => { + expect( + JSON.stringify( + serializePkcWithAttestationToJson(passkeyRegistrationResult), + ), + ).toBe(JSON.stringify(passkeyRegistrationResultJson)); + }); + + it('deserializes json into correct pkc format', () => { + const deserialized = deserializeJsonToPkcCreationOptions( + passkeyRegistrationRequestJson, + ); + + expect(deserialized.challenge.byteLength).toEqual( + passkeyRegistrationRequest.challenge.byteLength, + ); + expect(deserialized.user.id.byteLength).toEqual( + passkeyRegistrationRequest.user.id.byteLength, + ); + + expect(deserialized).toEqual( + expect.objectContaining({ + rp: expect.any(Object), + user: { + id: expect.any(ArrayBuffer), + name: expect.any(String), + displayName: expect.any(String), + }, + challenge: expect.any(ArrayBuffer), + pubKeyCredParams: expect.any(Array), + timeout: expect.any(Number), + excludeCredentials: expect.any(Array), + authenticatorSelection: expect.any(Object), + }), + ); + }); +}); diff --git a/packages/auth/__tests__/foundation/apis/deleteWebAuthnCredential.test.ts b/packages/auth/__tests__/foundation/apis/deleteWebAuthnCredential.test.ts new file mode 100644 index 00000000000..c4726e93692 --- /dev/null +++ b/packages/auth/__tests__/foundation/apis/deleteWebAuthnCredential.test.ts @@ -0,0 +1,62 @@ +import { Amplify } from '@aws-amplify/core'; +import { decodeJWT } from '@aws-amplify/core/internals/utils'; + +import { createDeleteWebAuthnCredentialClient } from '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; +import { DeleteWebAuthnCredentialInput } from '../../../src'; +import { setUpGetConfig } from '../../providers/cognito/testUtils/setUpGetConfig'; +import { mockAccessToken } from '../../providers/cognito/testUtils/data'; +import { deleteWebAuthnCredential } from '../../../src/foundation/apis'; + +jest.mock('@aws-amplify/core', () => ({ + ...(jest.createMockFromModule('@aws-amplify/core') as object), + Amplify: { + getConfig: jest.fn(), + Auth: { + fetchAuthSession: jest.fn(() => ({ + tokens: { accessToken: decodeJWT(mockAccessToken) }, + })), + }, + }, +})); +jest.mock('@aws-amplify/core/internals/utils', () => ({ + ...jest.requireActual('@aws-amplify/core/internals/utils'), + isBrowser: jest.fn(() => false), +})); +jest.mock( + '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider', +); +jest.mock('../../../src/providers/cognito/factories'); + +describe('deleteWebAuthnCredential', () => { + const mockDeleteWebAuthnCredential = jest.fn(); + const mockCreateDeleteWebAuthnCredentialClient = jest.mocked( + createDeleteWebAuthnCredentialClient, + ); + + beforeAll(() => { + setUpGetConfig(Amplify); + + mockCreateDeleteWebAuthnCredentialClient.mockReturnValue( + mockDeleteWebAuthnCredential, + ); + }); + + it('should pass correct service options when deleting a credential', async () => { + const input: DeleteWebAuthnCredentialInput = { + credentialId: 'dummyId', + }; + + await deleteWebAuthnCredential(Amplify, input); + + expect(mockDeleteWebAuthnCredential).toHaveBeenCalledWith( + { + region: 'us-west-2', + userAgentValue: expect.any(String), + }, + { + AccessToken: mockAccessToken, + CredentialId: input.credentialId, + }, + ); + }); +}); diff --git a/packages/auth/__tests__/foundation/apis/listWebAuthnCredentials.test.ts b/packages/auth/__tests__/foundation/apis/listWebAuthnCredentials.test.ts new file mode 100644 index 00000000000..f0708aa06e2 --- /dev/null +++ b/packages/auth/__tests__/foundation/apis/listWebAuthnCredentials.test.ts @@ -0,0 +1,150 @@ +import { Amplify } from '@aws-amplify/core'; +import { decodeJWT } from '@aws-amplify/core/internals/utils'; + +import { createListWebAuthnCredentialsClient } from '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; +import { ListWebAuthnCredentialsInput } from '../../../src'; +import { mockUserCredentials } from '../../mockData'; +import { setUpGetConfig } from '../../providers/cognito/testUtils/setUpGetConfig'; +import { mockAccessToken } from '../../providers/cognito/testUtils/data'; +import { listWebAuthnCredentials } from '../../../src/foundation/apis'; + +jest.mock('@aws-amplify/core', () => ({ + ...(jest.createMockFromModule('@aws-amplify/core') as object), + Amplify: { + getConfig: jest.fn(), + Auth: { + fetchAuthSession: jest.fn(() => ({ + tokens: { accessToken: decodeJWT(mockAccessToken) }, + })), + }, + }, +})); +jest.mock('@aws-amplify/core/internals/utils', () => ({ + ...jest.requireActual('@aws-amplify/core/internals/utils'), + isBrowser: jest.fn(() => false), +})); +jest.mock( + '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider', +); +jest.mock('../../../src/providers/cognito/factories'); + +describe('listWebAuthnCredentials', () => { + const mockListWebAuthnCredentials = jest.fn(); + const mockCreateListWebAuthnCredentialsClient = jest.mocked( + createListWebAuthnCredentialsClient, + ); + + beforeAll(() => { + setUpGetConfig(Amplify); + + mockCreateListWebAuthnCredentialsClient.mockReturnValue( + mockListWebAuthnCredentials, + ); + + mockListWebAuthnCredentials.mockImplementation((in1, in2) => { + return Promise.resolve({ + Credentials: mockUserCredentials.slice(0, in2.MaxResults), + NextToken: + in2.MaxResults < mockUserCredentials.length + ? 'dummyNextToken' + : undefined, + }); + }); + }); + + it('should pass correct service options when listing credentials', async () => { + await listWebAuthnCredentials(Amplify); + + expect(mockListWebAuthnCredentials).toHaveBeenCalledWith( + { + region: 'us-west-2', + userAgentValue: expect.any(String), + }, + { + AccessToken: mockAccessToken, + }, + ); + }); + + it('should pass correct service options and output correctly with input', async () => { + const input: ListWebAuthnCredentialsInput = { + pageSize: 3, + }; + + const { credentials, nextToken } = await listWebAuthnCredentials( + Amplify, + input, + ); + + expect(mockListWebAuthnCredentials).toHaveBeenCalledWith( + { + region: 'us-west-2', + userAgentValue: expect.any(String), + }, + { + AccessToken: mockAccessToken, + MaxResults: 3, + }, + ); + + expect(credentials.length).toEqual(2); + expect(credentials).toMatchObject([ + { + credentialId: '12345', + friendlyCredentialName: 'mycred', + relyingPartyId: '11111', + authenticatorAttachment: 'platform', + authenticatorTransports: ['usb', 'nfc'], + createdAt: new Date('2024-02-29T01:23:45.000Z'), + }, + { + credentialId: '22345', + friendlyCredentialName: 'mycred2', + relyingPartyId: '11111', + authenticatorAttachment: 'platform', + authenticatorTransports: ['usb', 'nfc'], + createdAt: new Date('2020-02-29T01:23:45.000Z'), + }, + ]); + + expect(nextToken).toBe(undefined); + }); + + it('should pass correct service options and output correctly with input that requires nextToken', async () => { + const input: ListWebAuthnCredentialsInput = { + pageSize: 1, + nextToken: 'exampleToken', + }; + + const { credentials, nextToken } = await listWebAuthnCredentials( + Amplify, + input, + ); + + expect(mockListWebAuthnCredentials).toHaveBeenCalledWith( + { + region: 'us-west-2', + userAgentValue: expect.any(String), + }, + { + AccessToken: mockAccessToken, + MaxResults: 1, + NextToken: 'exampleToken', + }, + ); + + expect(credentials.length).toEqual(1); + expect(credentials).toMatchObject([ + { + credentialId: '12345', + friendlyCredentialName: 'mycred', + relyingPartyId: '11111', + authenticatorAttachment: 'platform', + authenticatorTransports: ['usb', 'nfc'], + createdAt: new Date('2024-02-29T01:23:45.000Z'), + }, + ]); + + expect(nextToken).toBe('dummyNextToken'); + }); +}); diff --git a/packages/auth/__tests__/foundation/convert/base64url.test.ts b/packages/auth/__tests__/foundation/convert/base64url.test.ts new file mode 100644 index 00000000000..72bebbf590a --- /dev/null +++ b/packages/auth/__tests__/foundation/convert/base64url.test.ts @@ -0,0 +1,32 @@ +import { + convertArrayBufferToBase64Url, + convertBase64UrlToArrayBuffer, +} from '../../../src/foundation/convert'; + +describe('base64url', () => { + it('converts ArrayBuffer values to base64url', () => { + expect(convertArrayBufferToBase64Url(new Uint8Array([]))).toBe(''); + expect(convertArrayBufferToBase64Url(new Uint8Array([0]))).toBe('AA'); + expect(convertArrayBufferToBase64Url(new Uint8Array([1, 2, 3]))).toBe( + 'AQID', + ); + }); + it('converts base64url values to ArrayBuffer', () => { + expect( + convertArrayBufferToBase64Url(convertBase64UrlToArrayBuffer('')), + ).toBe(convertArrayBufferToBase64Url(new Uint8Array([]))); + expect( + convertArrayBufferToBase64Url(convertBase64UrlToArrayBuffer('AA')), + ).toBe(convertArrayBufferToBase64Url(new Uint8Array([0]))); + expect( + convertArrayBufferToBase64Url(convertBase64UrlToArrayBuffer('AQID')), + ).toBe(convertArrayBufferToBase64Url(new Uint8Array([1, 2, 3]))); + }); + + it('converts base64url to ArrayBuffer and back without data loss', () => { + const input = '_h7NMedx8qUAz_yHKhgHt74P2UrTU_qcB4_ToULz12M'; + expect( + convertArrayBufferToBase64Url(convertBase64UrlToArrayBuffer(input)), + ).toBe(input); + }); +}); diff --git a/packages/auth/__tests__/foundation/factories/serviceClients/cognitoIdentityProvider/createSignUpClient.test.ts b/packages/auth/__tests__/foundation/factories/serviceClients/cognitoIdentityProvider/createSignUpClient.test.ts new file mode 100644 index 00000000000..4c949522c97 --- /dev/null +++ b/packages/auth/__tests__/foundation/factories/serviceClients/cognitoIdentityProvider/createSignUpClient.test.ts @@ -0,0 +1,53 @@ +import { composeServiceApi } from '@aws-amplify/core/internals/aws-client-utils/composers'; + +import { DEFAULT_SERVICE_CLIENT_API_CONFIG } from '../../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider/constants'; +import { createSignUpClient } from '../../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; +import { createSignUpClientDeserializer } from '../../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider/createSignUpClient'; +import { AuthError } from '../../../../../src/errors/AuthError'; +import { AuthValidationErrorCode } from '../../../../../src/errors/types/validation'; +import { validationErrorMap } from '../../../../../src/common/AuthErrorStrings'; + +import { + mockServiceClientAPIConfig, + mockSignUpClientEmptySignUpPasswordResponse, +} from './testUtils/data'; + +jest.mock('@aws-amplify/core/internals/aws-client-utils/composers', () => ({ + ...jest.requireActual( + '@aws-amplify/core/internals/aws-client-utils/composers', + ), + composeServiceApi: jest.fn(), +})); + +describe('createSignUpClient', () => { + const mockComposeServiceApi = jest.mocked(composeServiceApi); + + it('factory should invoke composeServiceApi with expected parameters', () => { + createSignUpClient(mockServiceClientAPIConfig); + + expect(mockComposeServiceApi).toHaveBeenCalledWith( + expect.any(Function), + expect.any(Function), + expect.any(Function), + expect.objectContaining({ + ...DEFAULT_SERVICE_CLIENT_API_CONFIG, + ...mockServiceClientAPIConfig, + }), + ); + }); + + it('createSignUpDeserializer should throw expected error when', () => { + const deserializer = createSignUpClientDeserializer(); + + expect( + deserializer(mockSignUpClientEmptySignUpPasswordResponse), + ).rejects.toThrow( + new AuthError({ + name: AuthValidationErrorCode.EmptySignUpPassword, + message: + validationErrorMap[AuthValidationErrorCode.EmptySignUpPassword] + .message, + }), + ); + }); +}); diff --git a/packages/auth/__tests__/foundation/factories/serviceClients/cognitoIdentityProvider/index.test.ts b/packages/auth/__tests__/foundation/factories/serviceClients/cognitoIdentityProvider/index.test.ts index f9105f3a43d..8cf31b2cbd3 100644 --- a/packages/auth/__tests__/foundation/factories/serviceClients/cognitoIdentityProvider/index.test.ts +++ b/packages/auth/__tests__/foundation/factories/serviceClients/cognitoIdentityProvider/index.test.ts @@ -24,7 +24,9 @@ describe('service clients', () => { test.each(serviceClientFactories)( 'factory `%s` should invoke composeServiceApi with expected parameters', serviceClientFactory => { - serviceClients[serviceClientFactory](mockServiceClientAPIConfig); + serviceClients[serviceClientFactory as keyof typeof serviceClients]( + mockServiceClientAPIConfig, + ); expect(mockComposeServiceApi).toHaveBeenCalledWith( expect.any(Function), diff --git a/packages/auth/__tests__/foundation/factories/serviceClients/cognitoIdentityProvider/testUtils/data.ts b/packages/auth/__tests__/foundation/factories/serviceClients/cognitoIdentityProvider/testUtils/data.ts index 33a9a3d5534..0cea5ec340d 100644 --- a/packages/auth/__tests__/foundation/factories/serviceClients/cognitoIdentityProvider/testUtils/data.ts +++ b/packages/auth/__tests__/foundation/factories/serviceClients/cognitoIdentityProvider/testUtils/data.ts @@ -1,3 +1,5 @@ +import { HttpResponse } from '@aws-amplify/core/internals/aws-client-utils'; + import { ServiceClientFactoryInput } from '../../../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider/types'; export const mockServiceClientAPIConfig: ServiceClientFactoryInput = { @@ -5,3 +7,19 @@ export const mockServiceClientAPIConfig: ServiceClientFactoryInput = { ServiceClientFactoryInput['endpointResolver'] >, }; + +export const mockSignUpClientEmptySignUpPasswordResponse: HttpResponse = { + statusCode: 400, + body: { + json: () => + Promise.resolve({ + message: + "1 validation error detected: Value at 'password'failed to satisfy constraint: Member must not be null", + }), + blob: () => Promise.resolve(new Blob()), + text: () => Promise.resolve(''), + }, + headers: { + 'x-amzn-errortype': 'InvalidParameterException', + }, +}; diff --git a/packages/auth/__tests__/mockData.ts b/packages/auth/__tests__/mockData.ts index 9edfd45a197..8fa6834ec5f 100644 --- a/packages/auth/__tests__/mockData.ts +++ b/packages/auth/__tests__/mockData.ts @@ -1,3 +1,12 @@ +import { + PasskeyCreateOptionsJson, + PasskeyCreateResultJson, + PasskeyGetOptionsJson, + PasskeyGetResultJson, + PkcWithAuthenticatorAssertionResponse, + PkcWithAuthenticatorAttestationResponse, +} from '../src/client/utils/passkey/types'; + // device tracking mock device data export const mockDeviceArray = [ { @@ -180,3 +189,231 @@ export const mockAuthConfigWithOAuth = { }, }, }; + +export const passkeyCredentialCreateOptions = { + rp: { id: 'localhost', name: 'localhost' }, + user: { + id: 'M2M0NjMyMGItYzYwZS00YTIxLTlkNjQtNTgyOWJmZWRlMWM0', + name: 'james', + displayName: '', + }, + challenge: 'zsBch6DlNLUb6SgRdzHysw', + pubKeyCredParams: [ + { type: 'public-key', alg: -7 }, + { type: 'public-key', alg: -257 }, + ], + timeout: 60000, + excludeCredentials: [ + { + type: 'public-key', + id: 'VWxodmRFMUtjbEJZVWs1NE9IaHhOblZUTTBsUVJWSXRTbWhhUkdwZldHaDBSbVpmUmxKamFWRm5XUQ', + }, + { + type: 'public-key', + id: 'WDJnM1RrMWxaSGc0Y1ZWQmVsOTVTRXRvWjBoME56UlFNbFZ5VkZWZmNXTkNORjlVYjFWTWVqRXlUUQ', + }, + ], + authenticatorSelection: { + requireResidentKey: true, + residentKey: 'required', + userVerification: 'required', + }, +}; + +export const passkeyRegistrationResultJson: PasskeyCreateResultJson = { + type: 'public-key', + id: 'vJCit9S2cglAvvW3txQ-OWRBb-NyhxaLOvRRisnr1aE', + rawId: 'vJCit9S2cglAvvW3txQ-OQ', + clientExtensionResults: {}, + response: { + clientDataJSON: 'vJCit9S2cglAvvW3txQ-OQ', + attestationObject: 'vJCit9S2cglAvvW3txQ-OQ', + transports: ['internal'], + publicKeyAlgorithm: -7, + authenticatorData: 'vJCit9S2cglAvvW3txQ-OQ', + publicKey: 'vJCit9S2cglAvvW3txQ-OQ', + }, + authenticatorAttachment: 'platform', +}; +export const passkeyRegistrationResult: PkcWithAuthenticatorAttestationResponse = + { + type: 'public-key', + id: 'vJCit9S2cglAvvW3txQ-OWRBb-NyhxaLOvRRisnr1aE', + rawId: new Uint8Array([ + 188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, 57, + ]), + getClientExtensionResults: () => ({}), + authenticatorAttachment: 'platform', + response: { + clientDataJSON: new Uint8Array([ + 188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, + 57, + ]), + attestationObject: new Uint8Array([ + 188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, + 57, + ]), + getPublicKey: () => + new Uint8Array([ + 188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, + 57, + ]), + getPublicKeyAlgorithm: () => -7, + getAuthenticatorData: () => + new Uint8Array([ + 188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, + 57, + ]), + getTransports: () => ['internal'], + }, + }; + +export const passkeyRegistrationRequest: PublicKeyCredentialCreationOptions = { + rp: { id: 'localhost', name: 'localhost' }, + user: { + id: new Uint8Array([ + 188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, 57, + ]), + name: 'james', + displayName: '', + }, + challenge: new Uint8Array([ + 188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, 57, + ]), + pubKeyCredParams: [ + { type: 'public-key' as any, alg: -7 }, + { type: 'public-key' as any, alg: -257 }, + ], + timeout: 60000, + excludeCredentials: [ + { + type: 'public-key' as any, + id: new Uint8Array([ + 188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, + 57, + ]), + }, + ], + authenticatorSelection: { + requireResidentKey: true, + residentKey: 'required' as any, + userVerification: 'required' as any, + }, +}; + +export const passkeyRegistrationRequestJson: PasskeyCreateOptionsJson = { + rp: { id: 'localhost', name: 'localhost' }, + user: { + id: 'vJCit9S2cglAvvW3txQ-OQ', + name: 'james', + displayName: '', + }, + challenge: 'vJCit9S2cglAvvW3txQ-OQ', + pubKeyCredParams: [ + { type: 'public-key', alg: -7 }, + { type: 'public-key', alg: -257 }, + ], + timeout: 60000, + excludeCredentials: [ + { + type: 'public-key', + id: 'vJCit9S2cglAvvW3txQ-OQ', + }, + ], + authenticatorSelection: { + requireResidentKey: true, + residentKey: 'required', + userVerification: 'required', + }, +}; + +export const passkeyCredentialRequestOptions = + '{"hints":[],"attestation":"none","attestationFormats":[],"challenge":"9DAxgg4vPiaxvAxc-JbMuw","timeout":180000,"rpId":"localhost","allowCredentials":[{"id":"1oG8PrTycHFuWdHAjIelCnsVx7XsrGIL44Whwr_8F8k","type":"public-key"}],"userVerification":"required"}'; + +export const passkeyGetOptionsJson: PasskeyGetOptionsJson = { + challenge: 'vJCit9S2cglAvvW3txQ-OQ', + rpId: 'localhost', + timeout: 180000, + allowCredentials: [ + { + id: 'vJCit9S2cglAvvW3txQ-OQ', + type: 'public-key', + }, + ], + userVerification: 'required', +}; + +export const passkeyGetOptions: PublicKeyCredentialRequestOptions = { + challenge: new Uint8Array([ + 188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, 57, + ]), + rpId: 'localhost', + timeout: 180000, + allowCredentials: [ + { + id: new Uint8Array([ + 188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, + 57, + ]), + type: 'public-key', + }, + ], + userVerification: 'required', +}; + +export const passkeyGetResultJson: PasskeyGetResultJson = { + id: 'vJCit9S2cglAvvW3txQ-OQ', + rawId: 'vJCit9S2cglAvvW3txQ-OQ', + type: 'public-key', + clientExtensionResults: {}, + response: { + clientDataJSON: 'vJCit9S2cglAvvW3txQ-OQ', + authenticatorData: 'vJCit9S2cglAvvW3txQ-OQ', + signature: 'vJCit9S2cglAvvW3txQ-OQ', + userHandle: 'vJCit9S2cglAvvW3txQ-OQ', + }, + authenticatorAttachment: 'platform', +}; + +export const passkeyGetResult: PkcWithAuthenticatorAssertionResponse = { + type: 'public-key', + id: 'vJCit9S2cglAvvW3txQ-OQ', + rawId: new Uint8Array([ + 188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, 57, + ]), + getClientExtensionResults: () => ({}), + authenticatorAttachment: 'platform', + response: { + authenticatorData: new Uint8Array([ + 188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, 57, + ]), + clientDataJSON: new Uint8Array([ + 188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, 57, + ]), + signature: new Uint8Array([ + 188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, 57, + ]), + userHandle: new Uint8Array([ + 188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, 57, + ]), + }, +}; + +export const mockUserCredentials = [ + { + CredentialId: '12345', + FriendlyCredentialName: 'mycred', + RelyingPartyId: '11111', + AuthenticatorAttachment: 'platform', + AuthenticatorTransports: ['usb', 'nfc'], + CreatedAt: 1709169825, + }, + { + CredentialId: '22345', + FriendlyCredentialName: 'mycred2', + RelyingPartyId: '11111', + AuthenticatorAttachment: 'platform', + AuthenticatorTransports: ['usb', 'nfc'], + CreatedAt: 1582939425, + }, +]; diff --git a/packages/auth/__tests__/providers/cognito/autoSignIn.test.ts b/packages/auth/__tests__/providers/cognito/autoSignIn.test.ts index d787c2cdedf..05389b40773 100644 --- a/packages/auth/__tests__/providers/cognito/autoSignIn.test.ts +++ b/packages/auth/__tests__/providers/cognito/autoSignIn.test.ts @@ -5,13 +5,25 @@ import { Amplify } from 'aws-amplify'; import { cognitoUserPoolsTokenProvider, + confirmSignUp, signUp, } from '../../../src/providers/cognito'; -import { autoSignIn } from '../../../src/providers/cognito/apis/autoSignIn'; +import { + autoSignIn, + resetAutoSignIn, +} from '../../../src/providers/cognito/apis/autoSignIn'; import * as initiateAuthHelpers from '../../../src/providers/cognito/utils/signInHelpers'; -import { AuthError } from '../../../src/errors/AuthError'; -import { createSignUpClient } from '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; +import { + createConfirmSignUpClient, + createSignUpClient, +} from '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; import { RespondToAuthChallengeCommandOutput } from '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider/types'; +import { autoSignInStore } from '../../../src/client/utils/store'; +import { AuthError } from '../../../src'; +import { cacheCognitoTokens } from '../../../src/providers/cognito/tokenProvider/cacheTokens'; +import { dispatchSignedInHubEvent } from '../../../src/providers/cognito/utils/dispatchSignedInHubEvent'; +import { handleUserAuthFlow } from '../../../src/client/flows/userAuth/handleUserAuthFlow'; +import { AUTO_SIGN_IN_EXCEPTION } from '../../../src/errors/constants'; import { authAPITestParams } from './testUtils/authApiTestParams'; @@ -23,6 +35,9 @@ jest.mock('@aws-amplify/core/internals/utils', () => ({ jest.mock( '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider', ); +jest.mock('../../../src/providers/cognito/tokenProvider/cacheTokens'); +jest.mock('../../../src/providers/cognito/utils/dispatchSignedInHubEvent'); +jest.mock('../../../src/client/flows/userAuth/handleUserAuthFlow'); const authConfig = { Cognito: { @@ -34,63 +49,233 @@ cognitoUserPoolsTokenProvider.setAuthConfig(authConfig); Amplify.configure({ Auth: authConfig, }); -describe('Auto sign-in API Happy Path Cases:', () => { - let handleUserSRPAuthFlowSpy: jest.SpyInstance; +const { user1 } = authAPITestParams; + +describe('autoSignIn()', () => { const mockSignUp = jest.fn(); const mockCreateSignUpClient = jest.mocked(createSignUpClient); - const { user1 } = authAPITestParams; - beforeEach(async () => { - mockSignUp.mockResolvedValueOnce({ UserConfirmed: true }); - mockCreateSignUpClient.mockReturnValueOnce(mockSignUp); + const mockConfirmSignUp = jest.fn(); + const mockCreateConfirmSignUpClient = jest.mocked(createConfirmSignUpClient); + + const mockCacheCognitoTokens = jest.mocked(cacheCognitoTokens); + const mockDispatchSignedInHubEvent = jest.mocked(dispatchSignedInHubEvent); + + const handleUserSRPAuthFlowSpy = jest + .spyOn(initiateAuthHelpers, 'handleUserSRPAuthFlow') + .mockImplementationOnce( + async (): Promise => + authAPITestParams.RespondToAuthChallengeCommandOutput, + ); + + const mockHandleUserAuthFlow = jest.mocked(handleUserAuthFlow); + // to get around debounce on autoSignIn() APIs + jest.useFakeTimers(); + + describe('handleUserSRPAuthFlow', () => { + beforeEach(() => { + mockCreateSignUpClient.mockReturnValueOnce(mockSignUp); + mockSignUp.mockReturnValueOnce({ UserConfirmed: true }); + }); + + afterEach(() => { + mockSignUp.mockClear(); + mockCreateSignUpClient.mockClear(); + handleUserSRPAuthFlowSpy.mockClear(); + + resetAutoSignIn(); + }); + + afterAll(() => { + mockSignUp.mockReset(); + mockCreateSignUpClient.mockReset(); + handleUserSRPAuthFlowSpy.mockReset(); + jest.runAllTimers(); + }); + + it('autoSignIn() should throw an error when not enabled', async () => { + expect(autoSignInStore.getState()).toMatchObject({ active: false }); + expect(autoSignIn()).rejects.toThrow( + new AuthError({ + name: AUTO_SIGN_IN_EXCEPTION, + message: + 'The autoSignIn flow has not started, or has been cancelled/completed.', + }), + ); + }); - handleUserSRPAuthFlowSpy = jest - .spyOn(initiateAuthHelpers, 'handleUserSRPAuthFlow') - .mockImplementationOnce( + it('signUp should enable autoSignIn and return COMPLETE_AUTO_SIGN_IN step', async () => { + expect(autoSignInStore.getState()).toMatchObject({ active: false }); + const resp = await signUp({ + username: user1.username, + password: user1.password, + options: { + userAttributes: { email: user1.email }, + autoSignIn: true, + }, + }); + expect(resp).toEqual({ + isSignUpComplete: true, + nextStep: { + signUpStep: 'COMPLETE_AUTO_SIGN_IN', + }, + }); + expect(mockSignUp).toHaveBeenCalledTimes(1); + expect(autoSignInStore.getState().username).toBe(user1.username); + }); + + it('autoSignIn() should resolve to a SignInOutput', async () => { + expect(autoSignInStore.getState()).toMatchObject({ active: false }); + await signUp({ + username: user1.username, + password: user1.password, + options: { + userAttributes: { email: user1.email }, + autoSignIn: true, + }, + }); + const signInOutput = await autoSignIn(); + expect(signInOutput).toEqual(authAPITestParams.signInResult()); + expect(handleUserSRPAuthFlowSpy).toHaveBeenCalledTimes(1); + expect(autoSignInStore.getState()).toMatchObject({ active: false }); + }); + }); + + describe('handleUserAuthFlow', () => { + beforeEach(() => { + mockCreateSignUpClient.mockReturnValueOnce(mockSignUp); + mockSignUp.mockReturnValueOnce({ UserConfirmed: false }); + + mockCreateConfirmSignUpClient.mockReturnValueOnce(mockConfirmSignUp); + mockConfirmSignUp.mockReturnValueOnce({ Session: 'ASDFGHJKL' }); + + mockHandleUserAuthFlow.mockImplementationOnce( async (): Promise => authAPITestParams.RespondToAuthChallengeCommandOutput, ); - }); + }); - afterEach(() => { - mockSignUp.mockClear(); - mockCreateSignUpClient.mockClear(); - handleUserSRPAuthFlowSpy.mockClear(); - }); + afterEach(() => { + mockSignUp.mockClear(); + mockConfirmSignUp.mockClear(); + mockCreateSignUpClient.mockClear(); + mockHandleUserAuthFlow.mockClear(); + mockCreateConfirmSignUpClient.mockClear(); - test('signUp should enable autoSignIn and return COMPLETE_AUTO_SIGN_IN step', async () => { - const resp = await signUp({ - username: user1.username, - password: user1.password, - options: { - userAttributes: { email: user1.email }, - autoSignIn: true, - }, + resetAutoSignIn(); }); - expect(resp).toEqual({ - isSignUpComplete: true, - nextStep: { - signUpStep: 'COMPLETE_AUTO_SIGN_IN', - }, + + afterAll(() => { + mockSignUp.mockReset(); + mockConfirmSignUp.mockReset(); + mockCreateSignUpClient.mockReset(); + mockCreateConfirmSignUpClient.mockReset(); + mockHandleUserAuthFlow.mockReset(); + jest.runAllTimers(); }); - expect(mockSignUp).toHaveBeenCalledTimes(1); - }); - test('Auto sign-in should resolve to a signIn output', async () => { - const signInOutput = await autoSignIn(); - expect(signInOutput).toEqual(authAPITestParams.signInResult()); - expect(handleUserSRPAuthFlowSpy).toHaveBeenCalledTimes(1); - }); -}); + it('autoSignIn() should throw an error when not enabled', async () => { + expect(autoSignIn()).rejects.toThrow( + new AuthError({ + name: AUTO_SIGN_IN_EXCEPTION, + message: + 'The autoSignIn flow has not started, or has been cancelled/completed.', + }), + ); + }); + + it('signUp() should begin autoSignIn flow and return CONFIRM_SIGN_UP next step', async () => { + expect(autoSignInStore.getState()).toMatchObject({ active: false }); + + const signUpResult = await signUp({ + username: user1.username, + password: user1.password, + options: { + userAttributes: { email: user1.email }, + autoSignIn: { + authFlowType: 'USER_AUTH', + }, + }, + }); + + expect(signUpResult.nextStep.signUpStep).toBe('CONFIRM_SIGN_UP'); + expect(mockSignUp).toHaveBeenCalledTimes(1); + expect(autoSignInStore.getState()).toMatchObject({ + active: true, + username: user1.username, + }); + }); + + it('signUp() & confirmSignUp() should populate autoSignIn flow state and return COMPLETE_AUTO_SIGN_IN next step', async () => { + expect(autoSignInStore.getState()).toMatchObject({ active: false }); + + await signUp({ + username: user1.username, + password: user1.password, + options: { + userAttributes: { email: user1.email }, + autoSignIn: { + authFlowType: 'USER_AUTH', + }, + }, + }); -describe('Auto sign-in API Error Path Cases:', () => { - test('autoSignIn should throw an error when autoSignIn is not enabled', async () => { - try { - await autoSignIn(); - } catch (error: any) { - expect(error).toBeInstanceOf(AuthError); - expect(error.name).toBe('AutoSignInException'); - } + const confirmSignUpResult = await confirmSignUp({ + username: user1.username, + confirmationCode: '123456', + }); + + expect(confirmSignUpResult.nextStep.signUpStep).toBe( + 'COMPLETE_AUTO_SIGN_IN', + ); + expect(autoSignInStore.getState()).toMatchObject({ + active: true, + username: user1.username, + session: 'ASDFGHJKL', + }); + }); + + it('autoSignIn() should resolve to SignInOutput', async () => { + mockCacheCognitoTokens.mockResolvedValue(undefined); + mockDispatchSignedInHubEvent.mockResolvedValue(undefined); + + expect(autoSignInStore.getState()).toMatchObject({ active: false }); + + await signUp({ + username: user1.username, + password: user1.password, + options: { + userAttributes: { email: user1.email }, + autoSignIn: { + authFlowType: 'USER_AUTH', + }, + }, + }); + + await confirmSignUp({ + username: user1.username, + confirmationCode: '123456', + }); + + expect(autoSignInStore.getState()).toMatchObject({ + active: true, + username: user1.username, + session: 'ASDFGHJKL', + }); + + const autoSignInResult = await autoSignIn(); + + expect(mockHandleUserAuthFlow).toHaveBeenCalledTimes(1); + expect(mockHandleUserAuthFlow).toHaveBeenCalledWith( + expect.objectContaining({ + username: user1.username, + session: 'ASDFGHJKL', + }), + ); + expect(autoSignInResult.isSignedIn).toBe(true); + expect(autoSignInResult.nextStep.signInStep).toBe('DONE'); + expect(autoSignInStore.getState()).toMatchObject({ active: false }); + }); }); }); diff --git a/packages/auth/__tests__/providers/cognito/confirmSignInErrorCases.test.ts b/packages/auth/__tests__/providers/cognito/confirmSignInErrorCases.test.ts index 39e4fdd8c81..ce786ece3cb 100644 --- a/packages/auth/__tests__/providers/cognito/confirmSignInErrorCases.test.ts +++ b/packages/auth/__tests__/providers/cognito/confirmSignInErrorCases.test.ts @@ -4,7 +4,7 @@ import { AuthError } from '../../../src/errors/AuthError'; import { AuthValidationErrorCode } from '../../../src/errors/types/validation'; import { confirmSignIn } from '../../../src/providers/cognito/apis/confirmSignIn'; import { RespondToAuthChallengeException } from '../../../src/providers/cognito/types/errors'; -import { signInStore } from '../../../src/providers/cognito/utils/signInStore'; +import { signInStore } from '../../../src/client/utils/store'; import { AuthErrorCodes } from '../../../src/common/AuthErrorStrings'; import { createRespondToAuthChallengeClient } from '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; @@ -16,7 +16,7 @@ jest.mock('@aws-amplify/core', () => ({ ...(jest.createMockFromModule('@aws-amplify/core') as object), Amplify: { getConfig: jest.fn(() => ({})) }, })); -jest.mock('../../../src/providers/cognito/utils/signInStore'); +jest.mock('../../../src/client/utils/store'); jest.mock( '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider', ); diff --git a/packages/auth/__tests__/providers/cognito/signInStateManagement.test.ts b/packages/auth/__tests__/providers/cognito/signInStateManagement.test.ts index 80006cbf675..73e3cdc6eea 100644 --- a/packages/auth/__tests__/providers/cognito/signInStateManagement.test.ts +++ b/packages/auth/__tests__/providers/cognito/signInStateManagement.test.ts @@ -5,7 +5,7 @@ import { Amplify } from '@aws-amplify/core'; import { getCurrentUser, signIn } from '../../../src/providers/cognito'; import * as signInHelpers from '../../../src/providers/cognito/utils/signInHelpers'; -import { signInStore } from '../../../src/providers/cognito/utils/signInStore'; +import { signInStore } from '../../../src/client/utils/store'; import { cognitoUserPoolsTokenProvider } from '../../../src/providers/cognito/tokenProvider'; import { RespondToAuthChallengeCommandOutput } from '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider/types'; diff --git a/packages/auth/__tests__/providers/cognito/signInWithSRP.test.ts b/packages/auth/__tests__/providers/cognito/signInWithSRP.test.ts index 36c8d3c118a..9dd1b2dd606 100644 --- a/packages/auth/__tests__/providers/cognito/signInWithSRP.test.ts +++ b/packages/auth/__tests__/providers/cognito/signInWithSRP.test.ts @@ -210,7 +210,7 @@ describe('signIn API happy path cases', () => { setDeviceKeys(); handleUserSRPAuthflowSpy.mockRestore(); mockInitiateAuth.mockResolvedValueOnce({ - ChallengeName: 'SRP_AUTH', + ChallengeName: 'PASSWORD_VERIFIER', Session: '1234234232', $metadata: {}, ChallengeParameters: { @@ -279,7 +279,7 @@ describe('Cognito ASF', () => { beforeEach(() => { mockInitiateAuth.mockResolvedValueOnce({ - ChallengeName: 'SRP_AUTH', + ChallengeName: 'PASSWORD_VERIFIER', Session: '1234234232', $metadata: {}, ChallengeParameters: { diff --git a/packages/auth/__tests__/providers/cognito/signInWithUserAuth.test.ts b/packages/auth/__tests__/providers/cognito/signInWithUserAuth.test.ts new file mode 100644 index 00000000000..66a080ecebd --- /dev/null +++ b/packages/auth/__tests__/providers/cognito/signInWithUserAuth.test.ts @@ -0,0 +1,189 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { Amplify } from '@aws-amplify/core'; +import { AmplifyErrorCode } from '@aws-amplify/core/internals/utils'; + +import { signInWithUserAuth } from '../../../src/providers/cognito/apis/signInWithUserAuth'; +import { cognitoUserPoolsTokenProvider } from '../../../src/providers/cognito/tokenProvider'; +import { InitiateAuthCommandOutput } from '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider/types'; + +jest.mock('../../../src/providers/cognito/utils/signInHelpers', () => ({ + ...jest.requireActual('../../../src/providers/cognito/utils/signInHelpers'), + cleanActiveSignInState: jest.fn(), + setActiveSignInState: jest.fn(), + getNewDeviceMetadata: jest.fn(), + getActiveSignInUsername: jest.fn(username => username), +})); +jest.mock('../../../src/providers/cognito/tokenProvider/cacheTokens', () => ({ + cacheCognitoTokens: jest.fn(), +})); +jest.mock('../../../src/client/flows/userAuth/handleUserAuthFlow'); +jest.mock('../../../src/providers/cognito/utils/dispatchSignedInHubEvent'); +jest.mock('../../../src/providers/cognito/utils/srp', () => { + return { + ...jest.requireActual('../../../src/providers/cognito/utils/srp'), + getAuthenticationHelper: jest.fn(() => ({ + A: { toString: jest.fn() }, + getPasswordAuthenticationKey: jest.fn(), + })), + getSignatureString: jest.fn(), + }; +}); +jest.mock('@aws-amplify/core/internals/utils', () => ({ + ...jest.requireActual('@aws-amplify/core/internals/utils'), + isBrowser: jest.fn(() => false), +})); +jest.mock( + '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider', +); + +const authConfig = { + Cognito: { + userPoolClientId: '111111-aaaaa-42d8-891d-ee81a1549398', + userPoolId: 'us-west-2_zzzzz', + }, +}; + +cognitoUserPoolsTokenProvider.setAuthConfig(authConfig); +Amplify.configure({ + Auth: authConfig, +}); + +describe('signInWithUserAuth API tests', () => { + // Update how we get the mock + const { handleUserAuthFlow } = jest.requireMock( + '../../../src/client/flows/userAuth/handleUserAuthFlow', + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('signInWithUserAuth should return a SignInResult when SELECT_CHALLENGE is returned', async () => { + const mockResponse: InitiateAuthCommandOutput = { + ChallengeName: 'SELECT_CHALLENGE', + Session: 'mockSession', + ChallengeParameters: {}, + AvailableChallenges: ['EMAIL_OTP', 'SMS_OTP'] as any, + $metadata: {}, + }; + handleUserAuthFlow.mockResolvedValue(mockResponse); + + const result = await signInWithUserAuth({ + username: 'testuser', + }); + + expect(result).toEqual({ + isSignedIn: false, + nextStep: { + signInStep: 'CONTINUE_SIGN_IN_WITH_FIRST_FACTOR_SELECTION', + availableChallenges: ['EMAIL_OTP', 'SMS_OTP'], + }, + }); + expect(handleUserAuthFlow).toHaveBeenCalledWith({ + username: 'testuser', + clientMetadata: undefined, + config: authConfig.Cognito, + tokenOrchestrator: expect.anything(), + preferredChallenge: undefined, + password: undefined, + }); + }); + + test('signInWithUserAuth should handle preferred challenge', async () => { + const mockResponse: InitiateAuthCommandOutput = { + ChallengeName: 'EMAIL_OTP', + Session: 'mockSession', + ChallengeParameters: { + CODE_DELIVERY_DELIVERY_MEDIUM: 'EMAIL', + CODE_DELIVERY_DESTINATION: 'y*****.com', + }, + $metadata: {}, + }; + handleUserAuthFlow.mockResolvedValue(mockResponse); + + const result = await signInWithUserAuth({ + username: 'testuser', + options: { preferredChallenge: 'EMAIL_OTP' }, + }); + + expect(result).toEqual({ + isSignedIn: false, + nextStep: { + signInStep: 'CONFIRM_SIGN_IN_WITH_EMAIL_CODE', + codeDeliveryDetails: { + deliveryMedium: 'EMAIL', + destination: 'y*****.com', + }, + }, + }); + expect(handleUserAuthFlow).toHaveBeenCalledWith({ + username: 'testuser', + clientMetadata: undefined, + config: authConfig.Cognito, + tokenOrchestrator: expect.anything(), + preferredChallenge: 'EMAIL_OTP', + password: undefined, + }); + }); + + test('should throw validation error for empty username', async () => { + await expect( + signInWithUserAuth({ + username: '', // empty username + }), + ).rejects.toThrow('username is required to signIn'); + }); + + test('should handle successful authentication result', async () => { + const mockResponse: InitiateAuthCommandOutput = { + AuthenticationResult: { + AccessToken: 'mockAccessToken', + RefreshToken: 'mockRefreshToken', + IdToken: 'mockIdToken', + NewDeviceMetadata: { + DeviceKey: 'deviceKey', + DeviceGroupKey: 'deviceGroupKey', + }, + }, + $metadata: {}, + }; + handleUserAuthFlow.mockResolvedValue(mockResponse); + + const result = await signInWithUserAuth({ + username: 'testuser', + }); + + expect(result).toEqual({ + isSignedIn: true, + nextStep: { signInStep: 'DONE' }, + }); + }); + + test('should handle service error with sign in result', async () => { + const error = new Error('PasswordResetRequiredException'); + error.name = 'PasswordResetRequiredException'; + handleUserAuthFlow.mockRejectedValue(error); + + const result = await signInWithUserAuth({ + username: 'testuser', + }); + + expect(result).toEqual({ + isSignedIn: false, + nextStep: { signInStep: 'RESET_PASSWORD' }, + }); + }); + + test('should throw error when service error has no sign in result', async () => { + const error = new Error('Unknown error'); + error.name = 'UnknownError'; + handleUserAuthFlow.mockRejectedValue(error); + + await expect( + signInWithUserAuth({ + username: 'testuser', + }), + ).rejects.toThrow(AmplifyErrorCode.Unknown); + }); +}); diff --git a/packages/auth/__tests__/providers/cognito/signUp.test.ts b/packages/auth/__tests__/providers/cognito/signUp.test.ts index cb2b9b84d64..3b3f9bab4c5 100644 --- a/packages/auth/__tests__/providers/cognito/signUp.test.ts +++ b/packages/auth/__tests__/providers/cognito/signUp.test.ts @@ -244,6 +244,25 @@ describe('signUp', () => { expect(mockSignUp).toHaveBeenCalledTimes(1); (window as any).AmazonCognitoAdvancedSecurityData = undefined; }); + + it('should not throw an error when password is empty', async () => { + await signUp({ username: user1.username, password: '' }); + expect(mockSignUp).toHaveBeenCalledWith( + { + region: 'us-west-2', + userAgentValue: expect.any(String), + }, + { + ClientMetadata: undefined, + Password: undefined, + UserAttributes: undefined, + Username: user1.username, + ValidationData: undefined, + ClientId: '111111-aaaaa-42d8-891d-ee81a1549398', + }, + ); + expect(mockSignUp).toHaveBeenCalledTimes(1); + }); }); describe('Error Path Cases:', () => { @@ -265,16 +284,6 @@ describe('signUp', () => { } }); - it('should throw an error when password is empty', async () => { - expect.assertions(2); - try { - await signUp({ username: user1.username, password: '' }); - } catch (error: any) { - expect(error).toBeInstanceOf(AuthError); - expect(error.name).toBe(AuthValidationErrorCode.EmptySignUpPassword); - } - }); - it('should throw an error when service returns an error response', async () => { expect.assertions(2); mockSignUp.mockImplementation(() => { diff --git a/packages/auth/__tests__/providers/cognito/utils/signInHelpers/getSignInResult.test.ts b/packages/auth/__tests__/providers/cognito/utils/signInHelpers/getSignInResult.test.ts new file mode 100644 index 00000000000..366b925bffd --- /dev/null +++ b/packages/auth/__tests__/providers/cognito/utils/signInHelpers/getSignInResult.test.ts @@ -0,0 +1,92 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify } from '@aws-amplify/core'; + +import { ChallengeName } from '../../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider/types'; +import { getSignInResult } from '../../../../../src/providers/cognito/utils/signInHelpers'; +import { AuthSignInOutput } from '../../../../../src/types'; +import { setUpGetConfig } from '../../testUtils/setUpGetConfig'; +import { createAssociateSoftwareTokenClient } from '../../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; + +jest.mock('@aws-amplify/core', () => ({ + ...(jest.createMockFromModule('@aws-amplify/core') as object), + Amplify: { getConfig: jest.fn(() => ({})) }, +})); +jest.mock( + '../../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider', +); +const basicGetSignInResultTestCases: [ + ChallengeName, + AuthSignInOutput['nextStep']['signInStep'], +][] = [ + ['CUSTOM_CHALLENGE', 'CONFIRM_SIGN_IN_WITH_CUSTOM_CHALLENGE'], + ['SELECT_CHALLENGE', 'CONTINUE_SIGN_IN_WITH_FIRST_FACTOR_SELECTION'], + ['PASSWORD', 'CONFIRM_SIGN_IN_WITH_PASSWORD'], + ['PASSWORD_SRP', 'CONFIRM_SIGN_IN_WITH_PASSWORD'], + ['SOFTWARE_TOKEN_MFA', 'CONFIRM_SIGN_IN_WITH_TOTP_CODE'], + ['SMS_MFA', 'CONFIRM_SIGN_IN_WITH_SMS_CODE'], + ['SMS_OTP', 'CONFIRM_SIGN_IN_WITH_SMS_CODE'], + ['SELECT_MFA_TYPE', 'CONTINUE_SIGN_IN_WITH_MFA_SELECTION'], + ['NEW_PASSWORD_REQUIRED', 'CONFIRM_SIGN_IN_WITH_NEW_PASSWORD_REQUIRED'], +]; + +describe('getSignInResult', () => { + const mockCreateAssociateSoftwareTokenClient = jest.mocked( + createAssociateSoftwareTokenClient, + ); + const mockAssociateSoftwareToken = jest.fn(() => + Promise.resolve({ Session: '123456', SecretCode: 'TEST', $metadata: {} }), + ); + + beforeAll(() => { + setUpGetConfig(Amplify); + mockCreateAssociateSoftwareTokenClient.mockReturnValue( + mockAssociateSoftwareToken, + ); + }); + + it.each(basicGetSignInResultTestCases)( + 'should return the correct sign in step for challenge %s', + async (challengeName, signInStep) => { + const { nextStep } = await getSignInResult({ + challengeName, + challengeParameters: {}, + }); + + expect(nextStep.signInStep).toBe(signInStep); + }, + ); + + it('should return the correct sign in step for challenge MFA_SETUP when multiple available', async () => { + const { nextStep } = await getSignInResult({ + challengeName: 'MFA_SETUP', + challengeParameters: { + MFAS_CAN_SETUP: '["SOFTWARE_TOKEN_MFA", "EMAIL_OTP"]', + }, + }); + expect(nextStep.signInStep).toBe( + 'CONTINUE_SIGN_IN_WITH_MFA_SETUP_SELECTION', + ); + }); + + it('should return the correct sign in step for challenge MFA_SETUP when only totp available', async () => { + const { nextStep } = await getSignInResult({ + challengeName: 'MFA_SETUP', + challengeParameters: { + MFAS_CAN_SETUP: '["SOFTWARE_TOKEN_MFA"]', + }, + }); + expect(nextStep.signInStep).toBe('CONTINUE_SIGN_IN_WITH_TOTP_SETUP'); + }); + + it('should return the correct sign in step for challenge MFA_SETUP when only email available', async () => { + const { nextStep } = await getSignInResult({ + challengeName: 'MFA_SETUP', + challengeParameters: { + MFAS_CAN_SETUP: '["EMAIL_OTP"]', + }, + }); + expect(nextStep.signInStep).toBe('CONTINUE_SIGN_IN_WITH_EMAIL_SETUP'); + }); +}); diff --git a/packages/auth/__tests__/providers/cognito/utils/signInHelpers/handleWebAuthnSignInResult.test.ts b/packages/auth/__tests__/providers/cognito/utils/signInHelpers/handleWebAuthnSignInResult.test.ts new file mode 100644 index 00000000000..dc9a7c2296a --- /dev/null +++ b/packages/auth/__tests__/providers/cognito/utils/signInHelpers/handleWebAuthnSignInResult.test.ts @@ -0,0 +1,174 @@ +import { Amplify } from '@aws-amplify/core'; + +import { signInStore } from '../../../../../src/client/utils/store'; +import { authAPITestParams } from '../../testUtils/authApiTestParams'; +import { setUpGetConfig } from '../../testUtils/setUpGetConfig'; +import { createRespondToAuthChallengeClient } from '../../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; +import { handleWebAuthnSignInResult } from '../../../../../src/client/flows/userAuth/handleWebAuthnSignInResult'; +import { + passkeyCredentialRequestOptions, + passkeyGetResult, + passkeyGetResultJson, +} from '../../../../mockData'; +import { AuthError } from '../../../../../src/errors/AuthError'; +import { AuthErrorCodes } from '../../../../../src/common/AuthErrorStrings'; +import { cacheCognitoTokens } from '../../../../../src/providers/cognito/tokenProvider/cacheTokens'; +import { dispatchSignedInHubEvent } from '../../../../../src/providers/cognito/utils/dispatchSignedInHubEvent'; +import { getIsPasskeySupported } from '../../../../../src/client/utils/passkey/getIsPasskeySupported'; +import { + assertCredentialIsPkcWithAuthenticatorAssertionResponse, + assertCredentialIsPkcWithAuthenticatorAttestationResponse, +} from '../../../../../src/client/utils/passkey/types'; + +jest.mock('@aws-amplify/core', () => ({ + ...(jest.createMockFromModule('@aws-amplify/core') as object), + Amplify: { getConfig: jest.fn(() => ({})) }, +})); +jest.mock('../../../../../src/client/utils/store'); +jest.mock( + '../../../../../src/foundation/factories/serviceClients/cognitoIdentityProvider', +); +jest.mock('../../../../../src/providers/cognito/factories'); +jest.mock('../../../../../src/providers/cognito/tokenProvider/cacheTokens'); +jest.mock( + '../../../../../src/providers/cognito/utils/dispatchSignedInHubEvent', +); +jest.mock('../../../../../src/client/utils/passkey/getIsPasskeySupported'); +jest.mock('../../../../../src/client/utils/passkey/types'); + +Object.assign(navigator, { + credentials: { + get: jest.fn(), + }, +}); +describe('handleWebAuthnSignInResult', () => { + const navigatorCredentialsGetSpy = jest.spyOn(navigator.credentials, 'get'); + const mockStoreGetState = jest.mocked(signInStore.getState); + const mockRespondToAuthChallenge = jest.fn(); + const mockCreateRespondToAuthChallengeClient = jest.mocked( + createRespondToAuthChallengeClient, + ); + const mockGetIsPasskeySupported = jest.mocked(getIsPasskeySupported); + + const mockCacheCognitoTokens = jest.mocked(cacheCognitoTokens); + const mockDispatchSignedInHubEvent = jest.mocked(dispatchSignedInHubEvent); + + const challengeName = 'WEB_AUTHN'; + const signInSession = '123456'; + const { username } = authAPITestParams.user1; + const challengeParameters: Record = { + CREDENTIAL_REQUEST_OPTIONS: passkeyCredentialRequestOptions, + }; + + const mockAssertCredentialIsPkcWithAuthenticatorAssertionResponse = + jest.mocked(assertCredentialIsPkcWithAuthenticatorAssertionResponse); + const mockAssertCredentialIsPkcWithAuthenticatorAttestationResponse = + jest.mocked(assertCredentialIsPkcWithAuthenticatorAttestationResponse); + + beforeAll(() => { + setUpGetConfig(Amplify); + mockGetIsPasskeySupported.mockReturnValue(true); + mockAssertCredentialIsPkcWithAuthenticatorAssertionResponse.mockImplementation( + () => undefined, + ); + mockAssertCredentialIsPkcWithAuthenticatorAttestationResponse.mockImplementation( + () => undefined, + ); + }); + + beforeEach(() => { + mockCreateRespondToAuthChallengeClient.mockReturnValueOnce( + mockRespondToAuthChallenge, + ); + navigatorCredentialsGetSpy.mockResolvedValue(passkeyGetResult); + }); + + afterEach(() => { + mockRespondToAuthChallenge.mockReset(); + mockCreateRespondToAuthChallengeClient.mockClear(); + }); + + it('should throw an error when username is not available in state', async () => { + mockStoreGetState.mockReturnValue({ + challengeName, + signInSession, + }); + expect.assertions(2); + try { + await handleWebAuthnSignInResult(challengeParameters); + } catch (error: any) { + expect(error).toBeInstanceOf(AuthError); + expect(error.name).toBe(AuthErrorCodes.SignInException); + } + }); + it('should throw an error when CREDENTIAL_REQUEST_OPTIONS is empty', async () => { + expect.assertions(2); + try { + await handleWebAuthnSignInResult({}); + } catch (error: any) { + expect(error).toBeInstanceOf(AuthError); + expect(error.name).toBe(AuthErrorCodes.SignInException); + } + }); + + it('should throw an error when challenge name is not WEB_AUTHN', async () => { + mockStoreGetState.mockReturnValue({ + signInSession, + username, + challengeName: 'SMS_MFA', + }); + expect.assertions(2); + try { + await handleWebAuthnSignInResult(challengeParameters); + } catch (error: any) { + expect(error).toBeInstanceOf(AuthError); + expect(error.name).toBe(AuthErrorCodes.SignInException); + } + }); + + it('should call RespondToAuthChallenge with correct values', async () => { + mockStoreGetState.mockReturnValue({ + username, + challengeName, + signInSession, + }); + try { + await handleWebAuthnSignInResult(challengeParameters); + } catch (error: any) { + // __ we don't care about this error + } + expect(mockRespondToAuthChallenge).toHaveBeenCalledWith( + { + region: 'us-west-2', + userAgentValue: expect.any(String), + }, + { + ChallengeName: 'WEB_AUTHN', + ChallengeResponses: { + USERNAME: username, + CREDENTIAL: JSON.stringify(passkeyGetResultJson), + }, + ClientId: expect.any(String), + Session: signInSession, + }, + ); + }); + + it('should return nextStep DONE after authentication', async () => { + mockStoreGetState.mockReturnValue({ + username, + challengeName, + signInSession, + }); + mockRespondToAuthChallenge.mockResolvedValue( + authAPITestParams.RespondToAuthChallengeCommandOutput, + ); + mockCacheCognitoTokens.mockResolvedValue(undefined); + mockDispatchSignedInHubEvent.mockResolvedValue(undefined); + + const result = await handleWebAuthnSignInResult(challengeParameters); + + expect(result.isSignedIn).toBe(true); + expect(result.nextStep.signInStep).toBe('DONE'); + }); +}); diff --git a/packages/auth/__tests__/providers/cognito/utils/signUpHelpers/autoSignInUserConfirmed.test.ts b/packages/auth/__tests__/providers/cognito/utils/signUpHelpers/autoSignInUserConfirmed.test.ts new file mode 100644 index 00000000000..98c02e16e5f --- /dev/null +++ b/packages/auth/__tests__/providers/cognito/utils/signUpHelpers/autoSignInUserConfirmed.test.ts @@ -0,0 +1,65 @@ +import { autoSignInUserConfirmed } from '../../../../../src/providers/cognito/utils/signUpHelpers'; +import { authAPITestParams } from '../../testUtils/authApiTestParams'; +import { signInWithUserAuth } from '../../../../../src/providers/cognito/apis/signInWithUserAuth'; +import { signIn } from '../../../../../src/providers/cognito/apis/signIn'; +import { SignInInput } from '../../../../../src/providers/cognito/types/inputs'; + +jest.mock('@aws-amplify/core/internals/utils', () => ({ + ...jest.requireActual('@aws-amplify/core/internals/utils'), + isBrowser: jest.fn(() => false), +})); + +const { user1 } = authAPITestParams; + +jest.mock('../../../../../src/providers/cognito/apis/signInWithUserAuth'); +jest.mock('../../../../../src/providers/cognito/apis/signIn'); + +describe('autoSignInUserConfirmed()', () => { + const mockSignInWithUserAuth = jest.mocked(signInWithUserAuth); + const mockSignIn = jest.mocked(signIn); + + jest.useFakeTimers(); + + afterEach(() => { + jest.runAllTimers(); + }); + + beforeEach(() => { + mockSignInWithUserAuth.mockReset(); + mockSignIn.mockReset(); + }); + + beforeAll(() => { + mockSignInWithUserAuth.mockImplementation(jest.fn()); + mockSignIn.mockImplementation(jest.fn()); + }); + + it('should call the correct API with authFlowType USER_AUTH', () => { + const signInInput: SignInInput = { + username: user1.username, + options: { + authFlowType: 'USER_AUTH', + }, + }; + + autoSignInUserConfirmed(signInInput)(); + + expect(mockSignInWithUserAuth).toHaveBeenCalledTimes(1); + expect(mockSignInWithUserAuth).toHaveBeenCalledWith(signInInput); + + expect(mockSignIn).not.toHaveBeenCalled(); + }); + + it('should call the correct API with default authFlowType', () => { + const signInInput: SignInInput = { + username: user1.username, + }; + + autoSignInUserConfirmed(signInInput)(); + + expect(mockSignInWithUserAuth).not.toHaveBeenCalled(); + + expect(mockSignIn).toHaveBeenCalledTimes(1); + expect(mockSignIn).toHaveBeenCalledWith(signInInput); + }); +}); diff --git a/packages/auth/src/client/apis/associateWebAuthnCredential.ts b/packages/auth/src/client/apis/associateWebAuthnCredential.ts new file mode 100644 index 00000000000..caf8307f447 --- /dev/null +++ b/packages/auth/src/client/apis/associateWebAuthnCredential.ts @@ -0,0 +1,94 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify, fetchAuthSession } from '@aws-amplify/core'; +import { + AuthAction, + assertTokenProviderConfig, +} from '@aws-amplify/core/internals/utils'; + +import { + CompleteWebAuthnRegistrationException, + StartWebAuthnRegistrationException, +} from '../../foundation/factories/serviceClients/cognitoIdentityProvider/types'; +import { assertAuthTokens } from '../../providers/cognito/utils/types'; +import { createCognitoUserPoolEndpointResolver } from '../../providers/cognito/factories'; +import { getRegionFromUserPoolId } from '../../foundation/parsers'; +import { getAuthUserAgentValue } from '../../utils'; +import { registerPasskey } from '../utils'; +import { + createCompleteWebAuthnRegistrationClient, + createStartWebAuthnRegistrationClient, +} from '../../foundation/factories/serviceClients/cognitoIdentityProvider'; +import { PasskeyError } from '../utils/passkey/errors'; +import { AuthError } from '../../errors/AuthError'; +import { assertValidCredentialCreationOptions } from '../utils/passkey/types'; + +/** + * Registers a new passkey for an authenticated user + * + * @returns Promise + * @throws - {@link PasskeyError}: + * - Thrown when intermediate state is invalid + * @throws - {@link AuthError}: + * - Thrown when user is unauthenticated + * @throws - {@link StartWebAuthnRegistrationException} + * - Thrown due to a service error retrieving WebAuthn registration options + * @throws - {@link CompleteWebAuthnRegistrationException} + * - Thrown due to a service error when verifying WebAuthn registration result + */ +export async function associateWebAuthnCredential(): Promise { + const authConfig = Amplify.getConfig().Auth?.Cognito; + + assertTokenProviderConfig(authConfig); + + const { userPoolEndpoint, userPoolId } = authConfig; + + const { tokens } = await fetchAuthSession(); + + assertAuthTokens(tokens); + + const startWebAuthnRegistration = createStartWebAuthnRegistrationClient({ + endpointResolver: createCognitoUserPoolEndpointResolver({ + endpointOverride: userPoolEndpoint, + }), + }); + + const { CredentialCreationOptions: credentialCreationOptions } = + await startWebAuthnRegistration( + { + region: getRegionFromUserPoolId(userPoolId), + userAgentValue: getAuthUserAgentValue( + AuthAction.StartWebAuthnRegistration, + ), + }, + { + AccessToken: tokens.accessToken.toString(), + }, + ); + + assertValidCredentialCreationOptions(credentialCreationOptions); + + const cred = await registerPasskey(credentialCreationOptions); + + const completeWebAuthnRegistration = createCompleteWebAuthnRegistrationClient( + { + endpointResolver: createCognitoUserPoolEndpointResolver({ + endpointOverride: userPoolEndpoint, + }), + }, + ); + + await completeWebAuthnRegistration( + { + region: getRegionFromUserPoolId(userPoolId), + userAgentValue: getAuthUserAgentValue( + AuthAction.CompleteWebAuthnRegistration, + ), + }, + { + AccessToken: tokens.accessToken.toString(), + Credential: cred, + }, + ); +} diff --git a/packages/auth/src/client/apis/deleteWebAuthnCredential.ts b/packages/auth/src/client/apis/deleteWebAuthnCredential.ts new file mode 100644 index 00000000000..5e17d71fe38 --- /dev/null +++ b/packages/auth/src/client/apis/deleteWebAuthnCredential.ts @@ -0,0 +1,24 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify } from '@aws-amplify/core'; + +import { DeleteWebAuthnCredentialException } from '../../foundation/factories/serviceClients/cognitoIdentityProvider/types'; +import { DeleteWebAuthnCredentialInput } from '../../foundation/types'; +import { AuthError } from '../../errors/AuthError'; +import { deleteWebAuthnCredential as deleteWebAuthnCredentialFoundation } from '../../foundation/apis'; + +/** + * Delete a registered credential for an authenticated user by credentialId + * @param {DeleteWebAuthnCredentialInput} input The delete input parameters including the credentialId + * @returns Promise + * @throws - {@link AuthError}: + * - Thrown when user is unauthenticated + * @throws - {@link DeleteWebAuthnCredentialException} + * - Thrown due to a service error when deleting a WebAuthn credential + */ +export async function deleteWebAuthnCredential( + input: DeleteWebAuthnCredentialInput, +): Promise { + return deleteWebAuthnCredentialFoundation(Amplify, input); +} diff --git a/packages/auth/src/client/apis/index.ts b/packages/auth/src/client/apis/index.ts new file mode 100644 index 00000000000..dd3d1acb548 --- /dev/null +++ b/packages/auth/src/client/apis/index.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { associateWebAuthnCredential } from './associateWebAuthnCredential'; +export { listWebAuthnCredentials } from './listWebAuthnCredentials'; +export { deleteWebAuthnCredential } from './deleteWebAuthnCredential'; diff --git a/packages/auth/src/client/apis/listWebAuthnCredentials.ts b/packages/auth/src/client/apis/listWebAuthnCredentials.ts new file mode 100644 index 00000000000..91ee2b2310f --- /dev/null +++ b/packages/auth/src/client/apis/listWebAuthnCredentials.ts @@ -0,0 +1,28 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify } from '@aws-amplify/core'; + +import { ListWebAuthnCredentialsException } from '../../foundation/factories/serviceClients/cognitoIdentityProvider/types'; +import { + ListWebAuthnCredentialsInput, + ListWebAuthnCredentialsOutput, +} from '../../foundation/types'; +import { AuthError } from '../../errors/AuthError'; +import { listWebAuthnCredentials as listWebAuthnCredentialsFoundation } from '../../foundation/apis'; + +/** + * Lists registered credentials for an authenticated user + * + * @param {ListWebAuthnCredentialsInput} input The list input parameters including page size and next token. + * @returns Promise + * @throws - {@link AuthError}: + * - Thrown when user is unauthenticated + * @throws - {@link ListWebAuthnCredentialsException} + * - Thrown due to a service error when listing WebAuthn credentials + */ +export async function listWebAuthnCredentials( + input?: ListWebAuthnCredentialsInput, +): Promise { + return listWebAuthnCredentialsFoundation(Amplify, input); +} diff --git a/packages/auth/src/client/flows/shared/handlePasswordSRP.ts b/packages/auth/src/client/flows/shared/handlePasswordSRP.ts new file mode 100644 index 00000000000..77e298867df --- /dev/null +++ b/packages/auth/src/client/flows/shared/handlePasswordSRP.ts @@ -0,0 +1,123 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CognitoUserPoolConfig } from '@aws-amplify/core'; +import { AuthAction } from '@aws-amplify/core/internals/utils'; + +import { getUserContextData } from '../../../providers/cognito/utils/userContextData'; +import { AuthTokenOrchestrator } from '../../../providers/cognito/tokenProvider/types'; +import { AuthFlowType, ClientMetadata } from '../../../providers/cognito/types'; +import { + ChallengeParameters, + InitiateAuthCommandInput, + RespondToAuthChallengeCommandOutput, +} from '../../../foundation/factories/serviceClients/cognitoIdentityProvider/types'; +import { getAuthenticationHelper } from '../../../providers/cognito/utils/srp'; +import { + handlePasswordVerifierChallenge, + retryOnResourceNotFoundException, + setActiveSignInUsername, +} from '../../../providers/cognito/utils/signInHelpers'; +import { createInitiateAuthClient } from '../../../foundation/factories/serviceClients/cognitoIdentityProvider'; +import { createCognitoUserPoolEndpointResolver } from '../../../providers/cognito/factories'; +import { getRegionFromUserPoolId } from '../../../foundation/parsers'; +import { getAuthUserAgentValue } from '../../../utils'; +import { AuthFactorType } from '../../../providers/cognito/types/models'; + +interface HandlePasswordSRPInput { + username: string; + password: string; + clientMetadata: ClientMetadata | undefined; + config: CognitoUserPoolConfig; + tokenOrchestrator: AuthTokenOrchestrator; + authFlow: AuthFlowType; + preferredChallenge?: AuthFactorType; +} + +/** + * Handles the Password SRP (Secure Remote Password) authentication flow. + * This function can be used with both USER_SRP_AUTH and USER_AUTH flows. + * + * @param {Object} params - The parameters for the Password SRP authentication + * @param {string} params.username - The username for authentication + * @param {string} params.password - The user's password + * @param {ClientMetadata} [params.clientMetadata] - Optional metadata to be sent with auth requests + * @param {CognitoUserPoolConfig} params.config - Cognito User Pool configuration + * @param {AuthTokenOrchestrator} params.tokenOrchestrator - Token orchestrator for managing auth tokens + * @param {AuthFlowType} params.authFlow - The type of authentication flow ('USER_SRP_AUTH' or 'USER_AUTH') + * @param {AuthFactorType} [params.preferredChallenge] - Optional preferred challenge type when using USER_AUTH flow + * + * @returns {Promise} The authentication response + */ +export async function handlePasswordSRP({ + username, + password, + clientMetadata, + config, + tokenOrchestrator, + authFlow, + preferredChallenge, +}: HandlePasswordSRPInput): Promise { + const { userPoolId, userPoolClientId, userPoolEndpoint } = config; + const userPoolName = userPoolId?.split('_')[1] || ''; + const authenticationHelper = await getAuthenticationHelper(userPoolName); + + const authParameters: Record = { + USERNAME: username, + SRP_A: authenticationHelper.A.toString(16), + }; + + if (authFlow === 'USER_AUTH' && preferredChallenge) { + authParameters.PREFERRED_CHALLENGE = preferredChallenge; + } + + const UserContextData = getUserContextData({ + username, + userPoolId, + userPoolClientId, + }); + + const jsonReq: InitiateAuthCommandInput = { + AuthFlow: authFlow, + AuthParameters: authParameters, + ClientMetadata: clientMetadata, + ClientId: userPoolClientId, + UserContextData, + }; + + const initiateAuth = createInitiateAuthClient({ + endpointResolver: createCognitoUserPoolEndpointResolver({ + endpointOverride: userPoolEndpoint, + }), + }); + + const resp = await initiateAuth( + { + region: getRegionFromUserPoolId(userPoolId), + userAgentValue: getAuthUserAgentValue(AuthAction.SignIn), + }, + jsonReq, + ); + + const { ChallengeParameters: challengeParameters, Session: session } = resp; + const activeUsername = challengeParameters?.USERNAME ?? username; + setActiveSignInUsername(activeUsername); + if (resp.ChallengeName === 'PASSWORD_VERIFIER') { + return retryOnResourceNotFoundException( + handlePasswordVerifierChallenge, + [ + password, + challengeParameters as ChallengeParameters, + clientMetadata, + session, + authenticationHelper, + config, + tokenOrchestrator, + ], + activeUsername, + tokenOrchestrator, + ); + } + + return resp; +} diff --git a/packages/auth/src/client/flows/userAuth/handleSelectChallenge.ts b/packages/auth/src/client/flows/userAuth/handleSelectChallenge.ts new file mode 100644 index 00000000000..3ea0af6efd2 --- /dev/null +++ b/packages/auth/src/client/flows/userAuth/handleSelectChallenge.ts @@ -0,0 +1,62 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CognitoUserPoolConfig } from '@aws-amplify/core'; +import { AuthAction } from '@aws-amplify/core/internals/utils'; + +import { ClientMetadata } from '../../../providers/cognito/types'; +import { createRespondToAuthChallengeClient } from '../../../foundation/factories/serviceClients/cognitoIdentityProvider'; +import { createCognitoUserPoolEndpointResolver } from '../../../providers/cognito/factories'; +import { getRegionFromUserPoolId } from '../../../foundation/parsers'; +import { getAuthUserAgentValue } from '../../../utils'; +import { RespondToAuthChallengeCommandOutput } from '../../../foundation/factories/serviceClients/cognitoIdentityProvider/types'; + +/** + * Handles the SELECT_CHALLENGE response for authentication. + * Initiates the selected authentication challenge based on user choice. + * + * @param {Object} params - The parameters for handling the selected challenge + * @param {string} params.username - The username for authentication + * @param {string} params.session - The current authentication session token + * @param {string} params.selectedChallenge - The challenge type selected by the user + * @param {CognitoUserPoolConfig} params.config - Cognito User Pool configuration + * @param {ClientMetadata} [params.clientMetadata] - Optional metadata to be sent with auth requests + * + * @returns {Promise} The challenge response + */ +export async function initiateSelectedChallenge({ + username, + session, + selectedChallenge, + config, + clientMetadata, +}: { + username: string; + session: string; + selectedChallenge: string; + config: CognitoUserPoolConfig; + clientMetadata?: ClientMetadata; +}): Promise { + const respondToAuthChallenge = createRespondToAuthChallengeClient({ + endpointResolver: createCognitoUserPoolEndpointResolver({ + endpointOverride: config.userPoolEndpoint, + }), + }); + + return respondToAuthChallenge( + { + region: getRegionFromUserPoolId(config.userPoolId), + userAgentValue: getAuthUserAgentValue(AuthAction.ConfirmSignIn), + }, + { + ChallengeName: 'SELECT_CHALLENGE', + ChallengeResponses: { + USERNAME: username, + ANSWER: selectedChallenge, + }, + ClientId: config.userPoolClientId, + Session: session, + ClientMetadata: clientMetadata, + }, + ); +} diff --git a/packages/auth/src/client/flows/userAuth/handleSelectChallengeWithPassword.ts b/packages/auth/src/client/flows/userAuth/handleSelectChallengeWithPassword.ts new file mode 100644 index 00000000000..50858764c79 --- /dev/null +++ b/packages/auth/src/client/flows/userAuth/handleSelectChallengeWithPassword.ts @@ -0,0 +1,74 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CognitoUserPoolConfig } from '@aws-amplify/core'; +import { AuthAction } from '@aws-amplify/core/internals/utils'; + +import { ClientMetadata } from '../../../providers/cognito/types'; +import { createRespondToAuthChallengeClient } from '../../../foundation/factories/serviceClients/cognitoIdentityProvider'; +import { createCognitoUserPoolEndpointResolver } from '../../../providers/cognito/factories'; +import { getRegionFromUserPoolId } from '../../../foundation/parsers'; +import { getAuthUserAgentValue } from '../../../utils'; +import { getUserContextData } from '../../../providers/cognito/utils/userContextData'; +import { RespondToAuthChallengeCommandOutput } from '../../../foundation/factories/serviceClients/cognitoIdentityProvider/types'; +import { setActiveSignInUsername } from '../../../providers/cognito/utils/signInHelpers'; + +/** + * Handles the SELECT_CHALLENGE response specifically for Password authentication. + * This function combines the SELECT_CHALLENGE flow with standard password authentication. + * + * @param {string} username - The username for authentication + * @param {string} password - The user's password + * @param {ClientMetadata} [clientMetadata] - Optional metadata to be sent with auth requests + * @param {CognitoUserPoolConfig} config - Cognito User Pool configuration + * @param {string} session - The current authentication session token + * + * @returns {Promise} The challenge response + */ +export async function handleSelectChallengeWithPassword( + username: string, + password: string, + clientMetadata: ClientMetadata | undefined, + config: CognitoUserPoolConfig, + session: string, +): Promise { + const { userPoolId, userPoolClientId, userPoolEndpoint } = config; + + const authParameters: Record = { + ANSWER: 'PASSWORD', + USERNAME: username, + PASSWORD: password, + }; + + const userContextData = getUserContextData({ + username, + userPoolId, + userPoolClientId, + }); + + const respondToAuthChallenge = createRespondToAuthChallengeClient({ + endpointResolver: createCognitoUserPoolEndpointResolver({ + endpointOverride: userPoolEndpoint, + }), + }); + + const response = await respondToAuthChallenge( + { + region: getRegionFromUserPoolId(userPoolId), + userAgentValue: getAuthUserAgentValue(AuthAction.ConfirmSignIn), + }, + { + ChallengeName: 'SELECT_CHALLENGE', + ChallengeResponses: authParameters, + ClientId: userPoolClientId, + ClientMetadata: clientMetadata, + Session: session, + UserContextData: userContextData, + }, + ); + + const activeUsername = response.ChallengeParameters?.USERNAME ?? username; + setActiveSignInUsername(activeUsername); + + return response; +} diff --git a/packages/auth/src/client/flows/userAuth/handleSelectChallengeWithPasswordSRP.ts b/packages/auth/src/client/flows/userAuth/handleSelectChallengeWithPasswordSRP.ts new file mode 100644 index 00000000000..1a463e60a68 --- /dev/null +++ b/packages/auth/src/client/flows/userAuth/handleSelectChallengeWithPasswordSRP.ts @@ -0,0 +1,105 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CognitoUserPoolConfig } from '@aws-amplify/core'; +import { AuthAction } from '@aws-amplify/core/internals/utils'; + +import { AuthTokenOrchestrator } from '../../../providers/cognito/tokenProvider/types'; +import { + ChallengeParameters, + RespondToAuthChallengeCommandOutput, +} from '../../../foundation/factories/serviceClients/cognitoIdentityProvider/types'; +import { ClientMetadata } from '../../../providers/cognito/types'; +import { createRespondToAuthChallengeClient } from '../../../foundation/factories/serviceClients/cognitoIdentityProvider'; +import { createCognitoUserPoolEndpointResolver } from '../../../providers/cognito/factories'; +import { getRegionFromUserPoolId } from '../../../foundation/parsers'; +import { getAuthUserAgentValue } from '../../../utils'; +import { getAuthenticationHelper } from '../../../providers/cognito/utils/srp'; +import { getUserContextData } from '../../../providers/cognito/utils/userContextData'; +import { + handlePasswordVerifierChallenge, + retryOnResourceNotFoundException, + setActiveSignInUsername, +} from '../../../providers/cognito/utils/signInHelpers'; + +/** + * Handles the SELECT_CHALLENGE response specifically for Password SRP authentication. + * This function combines the SELECT_CHALLENGE flow with Password SRP protocol. + * + * @param {string} username - The username for authentication + * @param {string} password - The user's password + * @param {ClientMetadata} [clientMetadata] - Optional metadata to be sent with auth requests + * @param {CognitoUserPoolConfig} config - Cognito User Pool configuration + * @param {string} session - The current authentication session token + * @param {AuthTokenOrchestrator} tokenOrchestrator - Token orchestrator for managing auth tokens + * + * @returns {Promise} The challenge response + */ +export async function handleSelectChallengeWithPasswordSRP( + username: string, + password: string, + clientMetadata: ClientMetadata | undefined, + config: CognitoUserPoolConfig, + session: string, + tokenOrchestrator: AuthTokenOrchestrator, +): Promise { + const { userPoolId, userPoolClientId, userPoolEndpoint } = config; + const userPoolName = userPoolId.split('_')[1] || ''; + + const authenticationHelper = await getAuthenticationHelper(userPoolName); + + const authParameters: Record = { + ANSWER: 'PASSWORD_SRP', + USERNAME: username, + SRP_A: authenticationHelper.A.toString(16), + }; + + const userContextData = getUserContextData({ + username, + userPoolId, + userPoolClientId, + }); + + const respondToAuthChallenge = createRespondToAuthChallengeClient({ + endpointResolver: createCognitoUserPoolEndpointResolver({ + endpointOverride: userPoolEndpoint, + }), + }); + + const response = await respondToAuthChallenge( + { + region: getRegionFromUserPoolId(userPoolId), + userAgentValue: getAuthUserAgentValue(AuthAction.ConfirmSignIn), + }, + { + ChallengeName: 'SELECT_CHALLENGE', + ChallengeResponses: authParameters, + ClientId: userPoolClientId, + ClientMetadata: clientMetadata, + Session: session, + UserContextData: userContextData, + }, + ); + + const activeUsername = response.ChallengeParameters?.USERNAME ?? username; + setActiveSignInUsername(activeUsername); + + if (response.ChallengeName === 'PASSWORD_VERIFIER') { + return retryOnResourceNotFoundException( + handlePasswordVerifierChallenge, + [ + password, + response.ChallengeParameters as ChallengeParameters, + clientMetadata, + response.Session, + authenticationHelper, + config, + tokenOrchestrator, + ], + activeUsername, + tokenOrchestrator, + ); + } + + return response; +} diff --git a/packages/auth/src/client/flows/userAuth/handleUserAuthFlow.ts b/packages/auth/src/client/flows/userAuth/handleUserAuthFlow.ts new file mode 100644 index 00000000000..753ac66db04 --- /dev/null +++ b/packages/auth/src/client/flows/userAuth/handleUserAuthFlow.ts @@ -0,0 +1,125 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CognitoUserPoolConfig } from '@aws-amplify/core'; +import { AuthAction } from '@aws-amplify/core/internals/utils'; + +import { getUserContextData } from '../../../providers/cognito/utils/userContextData'; +import { AuthTokenOrchestrator } from '../../../providers/cognito/tokenProvider/types'; +import { AuthFactorType } from '../../../providers/cognito/types/models'; +import { + InitiateAuthCommandInput, + InitiateAuthCommandOutput, +} from '../../../foundation/factories/serviceClients/cognitoIdentityProvider/types'; +import { createInitiateAuthClient } from '../../../foundation/factories/serviceClients/cognitoIdentityProvider'; +import { createCognitoUserPoolEndpointResolver } from '../../../providers/cognito/factories'; +import { getRegionFromUserPoolId } from '../../../foundation/parsers'; +import { getAuthUserAgentValue } from '../../../utils'; +import { handlePasswordSRP } from '../shared/handlePasswordSRP'; +import { assertValidationError } from '../../../errors/utils/assertValidationError'; +import { AuthValidationErrorCode } from '../../../errors/types/validation'; +import { setActiveSignInUsername } from '../../../providers/cognito/utils/signInHelpers'; + +export interface HandleUserAuthFlowInput { + username: string; + config: CognitoUserPoolConfig; + tokenOrchestrator: AuthTokenOrchestrator; + clientMetadata?: Record; + preferredChallenge?: AuthFactorType; + password?: string; + session?: string; +} + +/** + * Handles user authentication flow with configurable challenge preferences. + * Supports AuthFactorType challenges through the USER_AUTH flow. + * + * @param {HandleUserAuthFlowInput} params - Authentication flow parameters + * @param {string} params.username - The username for authentication + * @param {Record} [params.clientMetadata] - Optional metadata to pass to authentication service + * @param {CognitoUserPoolConfig} params.config - Cognito User Pool configuration + * @param {AuthTokenOrchestrator} params.tokenOrchestrator - Manages authentication tokens and device tracking + * @param {AuthFactorType} [params.preferredChallenge] - Optional preferred authentication method + * @param {string} [params.password] - Required when preferredChallenge is 'PASSWORD' or 'PASSWORD_SRP' + * + * @returns {Promise} The authentication response from Cognito + */ +export async function handleUserAuthFlow({ + username, + clientMetadata, + config, + tokenOrchestrator, + preferredChallenge, + password, + session, +}: HandleUserAuthFlowInput) { + const { userPoolId, userPoolClientId, userPoolEndpoint } = config; + const UserContextData = getUserContextData({ + username, + userPoolId, + userPoolClientId, + }); + const authParameters: Record = { USERNAME: username }; + + if (preferredChallenge) { + if (preferredChallenge === 'PASSWORD_SRP') { + assertValidationError( + !!password, + AuthValidationErrorCode.EmptySignInPassword, + ); + + return handlePasswordSRP({ + username, + password, + clientMetadata, + config, + tokenOrchestrator, + authFlow: 'USER_AUTH', + preferredChallenge, + }); + } + + if (preferredChallenge === 'PASSWORD') { + assertValidationError( + !!password, + AuthValidationErrorCode.EmptySignInPassword, + ); + authParameters.PASSWORD = password; + } + + authParameters.PREFERRED_CHALLENGE = preferredChallenge; + } + + const jsonReq: InitiateAuthCommandInput = { + AuthFlow: 'USER_AUTH', + AuthParameters: authParameters, + ClientMetadata: clientMetadata, + ClientId: userPoolClientId, + UserContextData, + }; + + if (session) { + jsonReq.Session = session; + } + + const initiateAuth = createInitiateAuthClient({ + endpointResolver: createCognitoUserPoolEndpointResolver({ + endpointOverride: userPoolEndpoint, + }), + }); + + const response = await initiateAuth( + { + region: getRegionFromUserPoolId(userPoolId), + userAgentValue: getAuthUserAgentValue(AuthAction.SignIn), + }, + jsonReq, + ); + + // Set the active username immediately after successful authentication attempt + // If a user starts a new sign-in while another sign-in is incomplete, + // this ensures we're tracking the correct user for subsequent auth challenges. + setActiveSignInUsername(username); + + return response; +} diff --git a/packages/auth/src/client/flows/userAuth/handleWebAuthnSignInResult.ts b/packages/auth/src/client/flows/userAuth/handleWebAuthnSignInResult.ts new file mode 100644 index 00000000000..2e67a52a5ab --- /dev/null +++ b/packages/auth/src/client/flows/userAuth/handleWebAuthnSignInResult.ts @@ -0,0 +1,130 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify } from '@aws-amplify/core'; +import { + AuthAction, + assertTokenProviderConfig, +} from '@aws-amplify/core/internals/utils'; + +import { AuthErrorCodes } from '../../../common/AuthErrorStrings'; +import { createRespondToAuthChallengeClient } from '../../../foundation/factories/serviceClients/cognitoIdentityProvider'; +import { + ChallengeName, + ChallengeParameters, +} from '../../../foundation/factories/serviceClients/cognitoIdentityProvider/types'; +import { getRegionFromUserPoolId } from '../../../foundation/parsers'; +import { createCognitoUserPoolEndpointResolver } from '../../../providers/cognito/factories'; +import { cacheCognitoTokens } from '../../../providers/cognito/tokenProvider/cacheTokens'; +import { dispatchSignedInHubEvent } from '../../../providers/cognito/utils/dispatchSignedInHubEvent'; +import { + getNewDeviceMetadata, + getSignInResult, +} from '../../../providers/cognito/utils/signInHelpers'; +import { + cleanActiveSignInState, + setActiveSignInState, + signInStore, +} from '../../../client/utils/store'; +import { AuthSignInOutput } from '../../../types'; +import { getAuthUserAgentValue } from '../../../utils'; +import { getPasskey } from '../../utils/passkey'; +import { + PasskeyErrorCode, + assertPasskeyError, +} from '../../utils/passkey/errors'; +import { AuthError } from '../../../errors/AuthError'; + +export async function handleWebAuthnSignInResult( + challengeParameters: ChallengeParameters, +): Promise { + const authConfig = Amplify.getConfig().Auth?.Cognito; + assertTokenProviderConfig(authConfig); + const { username, signInSession, signInDetails, challengeName } = + signInStore.getState(); + + if (challengeName !== 'WEB_AUTHN' || !username) { + throw new AuthError({ + name: AuthErrorCodes.SignInException, + message: 'Unable to proceed due to invalid sign in state.', + }); + } + + const { CREDENTIAL_REQUEST_OPTIONS: credentialRequestOptions } = + challengeParameters; + + assertPasskeyError( + !!credentialRequestOptions, + PasskeyErrorCode.InvalidPasskeyAuthenticationOptions, + ); + + const cred = await getPasskey(JSON.parse(credentialRequestOptions)); + + const respondToAuthChallenge = createRespondToAuthChallengeClient({ + endpointResolver: createCognitoUserPoolEndpointResolver({ + endpointOverride: authConfig.userPoolEndpoint, + }), + }); + + const { + ChallengeName: nextChallengeName, + ChallengeParameters: nextChallengeParameters, + AuthenticationResult: authenticationResult, + Session: nextSession, + } = await respondToAuthChallenge( + { + region: getRegionFromUserPoolId(authConfig.userPoolId), + userAgentValue: getAuthUserAgentValue(AuthAction.ConfirmSignIn), + }, + { + ChallengeName: 'WEB_AUTHN', + ChallengeResponses: { + USERNAME: username, + CREDENTIAL: JSON.stringify(cred), + }, + ClientId: authConfig.userPoolClientId, + Session: signInSession, + }, + ); + + setActiveSignInState({ + signInSession: nextSession, + username, + challengeName: nextChallengeName as ChallengeName, + signInDetails, + }); + + if (authenticationResult) { + await cacheCognitoTokens({ + ...authenticationResult, + username, + NewDeviceMetadata: await getNewDeviceMetadata({ + userPoolId: authConfig.userPoolId, + userPoolEndpoint: authConfig.userPoolEndpoint, + newDeviceMetadata: authenticationResult.NewDeviceMetadata, + accessToken: authenticationResult.AccessToken, + }), + signInDetails, + }); + cleanActiveSignInState(); + await dispatchSignedInHubEvent(); + + return { + isSignedIn: true, + nextStep: { signInStep: 'DONE' }, + }; + } + + if (nextChallengeName === 'WEB_AUTHN') { + throw new AuthError({ + name: AuthErrorCodes.SignInException, + message: + 'Sequential WEB_AUTHN challenges returned from underlying service cannot be handled.', + }); + } + + return getSignInResult({ + challengeName: nextChallengeName as ChallengeName, + challengeParameters: nextChallengeParameters as ChallengeParameters, + }); +} diff --git a/packages/auth/src/client/utils/index.ts b/packages/auth/src/client/utils/index.ts new file mode 100644 index 00000000000..ef0913b2b8d --- /dev/null +++ b/packages/auth/src/client/utils/index.ts @@ -0,0 +1,4 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { registerPasskey } from './passkey'; diff --git a/packages/auth/src/client/utils/passkey/errors.ts b/packages/auth/src/client/utils/passkey/errors.ts new file mode 100644 index 00000000000..288cb14e810 --- /dev/null +++ b/packages/auth/src/client/utils/passkey/errors.ts @@ -0,0 +1,214 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + AmplifyError, + AmplifyErrorCode, + AmplifyErrorMap, + AmplifyErrorParams, + AssertionFunction, + createAssertionFunction, +} from '@aws-amplify/core/internals/utils'; + +export class PasskeyError extends AmplifyError { + constructor(params: AmplifyErrorParams) { + super(params); + + // Hack for making the custom error class work when transpiled to es5 + // TODO: Delete the following 2 lines after we change the build target to >= es2015 + this.constructor = PasskeyError; + Object.setPrototypeOf(this, PasskeyError.prototype); + } +} + +export enum PasskeyErrorCode { + // not supported + PasskeyNotSupported = 'PasskeyNotSupported', + // duplicate passkey + PasskeyAlreadyExists = 'PasskeyAlreadyExists', + // misconfigurations + InvalidPasskeyRegistrationOptions = 'InvalidPasskeyRegistrationOptions', + InvalidPasskeyAuthenticationOptions = 'InvalidPasskeyAuthenticationOptions', + RelyingPartyMismatch = 'RelyingPartyMismatch', + // failed credential creation / retrieval + PasskeyRegistrationFailed = 'PasskeyRegistrationFailed', + PasskeyRetrievalFailed = 'PasskeyRetrievalFailed', + // cancel / aborts + PasskeyRegistrationCanceled = 'PasskeyRegistrationCanceled', + PasskeyAuthenticationCanceled = 'PasskeyAuthenticationCanceled', + PasskeyOperationAborted = 'PasskeyOperationAborted', +} + +const notSupportedRecoverySuggestion = + 'Passkeys may not be supported on this device. Ensure your application is running in a secure context (HTTPS) and Web Authentication API is supported.'; +const abortOrCancelRecoverySuggestion = + 'User may have canceled the ceremony or another interruption has occurred. Check underlying error for details.'; +const misconfigurationRecoverySuggestion = + 'Ensure your user pool is configured to support the WEB_AUTHN as an authentication factor.'; + +const passkeyErrorMap: AmplifyErrorMap = { + [PasskeyErrorCode.PasskeyNotSupported]: { + message: 'Passkeys may not be supported on this device.', + recoverySuggestion: notSupportedRecoverySuggestion, + }, + [PasskeyErrorCode.InvalidPasskeyRegistrationOptions]: { + message: 'Invalid passkey registration options.', + recoverySuggestion: misconfigurationRecoverySuggestion, + }, + [PasskeyErrorCode.InvalidPasskeyAuthenticationOptions]: { + message: 'Invalid passkey authentication options.', + recoverySuggestion: misconfigurationRecoverySuggestion, + }, + [PasskeyErrorCode.PasskeyRegistrationFailed]: { + message: 'Device failed to create passkey.', + recoverySuggestion: notSupportedRecoverySuggestion, + }, + [PasskeyErrorCode.PasskeyRetrievalFailed]: { + message: 'Device failed to retrieve passkey.', + recoverySuggestion: + 'Passkeys may not be available on this device. Try an alternative authentication factor like PASSWORD, EMAIL_OTP, or SMS_OTP.', + }, + [PasskeyErrorCode.PasskeyAlreadyExists]: { + message: 'Passkey already exists in authenticator.', + recoverySuggestion: + 'Proceed with existing passkey or try again after deleting the credential.', + }, + [PasskeyErrorCode.PasskeyRegistrationCanceled]: { + message: 'Passkey registration ceremony has been canceled.', + recoverySuggestion: abortOrCancelRecoverySuggestion, + }, + [PasskeyErrorCode.PasskeyAuthenticationCanceled]: { + message: 'Passkey authentication ceremony has been canceled.', + recoverySuggestion: abortOrCancelRecoverySuggestion, + }, + [PasskeyErrorCode.PasskeyOperationAborted]: { + message: 'Passkey operation has been aborted.', + recoverySuggestion: abortOrCancelRecoverySuggestion, + }, + [PasskeyErrorCode.RelyingPartyMismatch]: { + message: 'Relying party does not match current domain.', + recoverySuggestion: + 'Ensure relying party identifier matches current domain.', + }, +}; + +export const assertPasskeyError: AssertionFunction = + createAssertionFunction(passkeyErrorMap, PasskeyError); + +/** + * Handle Passkey Authentication Errors + * https://w3c.github.io/webauthn/#sctn-get-request-exceptions + * + * @param err unknown + * @returns PasskeyError + */ + +export const handlePasskeyAuthenticationError = ( + err: unknown, +): PasskeyError => { + if (err instanceof PasskeyError) { + return err; + } + + if (err instanceof Error) { + if (err.name === 'NotAllowedError') { + const { message, recoverySuggestion } = + passkeyErrorMap[PasskeyErrorCode.PasskeyAuthenticationCanceled]; + + return new PasskeyError({ + name: PasskeyErrorCode.PasskeyAuthenticationCanceled, + message, + recoverySuggestion, + underlyingError: err, + }); + } + } + + return handlePasskeyError(err); +}; + +/** + * Handle Passkey Registration Errors + * https://w3c.github.io/webauthn/#sctn-create-request-exceptions + * + * @param err unknown + * @returns PasskeyError + */ +export const handlePasskeyRegistrationError = (err: unknown): PasskeyError => { + if (err instanceof PasskeyError) { + return err; + } + + if (err instanceof Error) { + // Duplicate Passkey + if (err.name === 'InvalidStateError') { + const { message, recoverySuggestion } = + passkeyErrorMap[PasskeyErrorCode.PasskeyAlreadyExists]; + + return new PasskeyError({ + name: PasskeyErrorCode.PasskeyAlreadyExists, + message, + recoverySuggestion, + underlyingError: err, + }); + } + + // User Cancels Ceremony / Generic Catch All + if (err.name === 'NotAllowedError') { + const { message, recoverySuggestion } = + passkeyErrorMap[PasskeyErrorCode.PasskeyRegistrationCanceled]; + + return new PasskeyError({ + name: PasskeyErrorCode.PasskeyRegistrationCanceled, + message, + recoverySuggestion, + underlyingError: err, + }); + } + } + + return handlePasskeyError(err); +}; + +/** + * Handles Overlapping Passkey Errors Between Registration & Authentication + * https://w3c.github.io/webauthn/#sctn-create-request-exceptions + * https://w3c.github.io/webauthn/#sctn-get-request-exceptions + * + * @param err unknown + * @returns PasskeyError + */ +const handlePasskeyError = (err: unknown): PasskeyError => { + if (err instanceof Error) { + // Passkey Operation Aborted + if (err.name === 'AbortError') { + const { message, recoverySuggestion } = + passkeyErrorMap[PasskeyErrorCode.PasskeyOperationAborted]; + + return new PasskeyError({ + name: PasskeyErrorCode.PasskeyOperationAborted, + message, + recoverySuggestion, + underlyingError: err, + }); + } + // Relying Party / Domain Mismatch + if (err.name === 'SecurityError') { + const { message, recoverySuggestion } = + passkeyErrorMap[PasskeyErrorCode.RelyingPartyMismatch]; + + return new PasskeyError({ + name: PasskeyErrorCode.RelyingPartyMismatch, + message, + recoverySuggestion, + underlyingError: err, + }); + } + } + + return new PasskeyError({ + name: AmplifyErrorCode.Unknown, + message: 'An unknown error has occurred.', + underlyingError: err, + }); +}; diff --git a/packages/auth/src/client/utils/passkey/getIsPasskeySupported.native.ts b/packages/auth/src/client/utils/passkey/getIsPasskeySupported.native.ts new file mode 100644 index 00000000000..a6090da47ce --- /dev/null +++ b/packages/auth/src/client/utils/passkey/getIsPasskeySupported.native.ts @@ -0,0 +1,8 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PlatformNotSupportedError } from '@aws-amplify/core/internals/utils'; + +export const getIsPasskeySupported = () => { + throw new PlatformNotSupportedError(); +}; diff --git a/packages/auth/src/client/utils/passkey/getIsPasskeySupported.ts b/packages/auth/src/client/utils/passkey/getIsPasskeySupported.ts new file mode 100644 index 00000000000..c0d3674a8a4 --- /dev/null +++ b/packages/auth/src/client/utils/passkey/getIsPasskeySupported.ts @@ -0,0 +1,18 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { isBrowser } from '@aws-amplify/core/internals/utils'; + +/** + * Determines if passkey is supported in current context + * Will return false if executed in non-secure context + * @returns boolean + */ +export const getIsPasskeySupported = (): boolean => { + return ( + isBrowser() && + window.isSecureContext && + 'credentials' in navigator && + typeof window.PublicKeyCredential === 'function' + ); +}; diff --git a/packages/auth/src/client/utils/passkey/getPasskey.native.ts b/packages/auth/src/client/utils/passkey/getPasskey.native.ts new file mode 100644 index 00000000000..96f6662b590 --- /dev/null +++ b/packages/auth/src/client/utils/passkey/getPasskey.native.ts @@ -0,0 +1,8 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PlatformNotSupportedError } from '@aws-amplify/core/internals/utils'; + +export const getPasskey = async () => { + throw new PlatformNotSupportedError(); +}; diff --git a/packages/auth/src/client/utils/passkey/getPasskey.ts b/packages/auth/src/client/utils/passkey/getPasskey.ts new file mode 100644 index 00000000000..8a3ee7f3d6e --- /dev/null +++ b/packages/auth/src/client/utils/passkey/getPasskey.ts @@ -0,0 +1,40 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + PasskeyErrorCode, + assertPasskeyError, + handlePasskeyAuthenticationError, +} from './errors'; +import { getIsPasskeySupported } from './getIsPasskeySupported'; +import { + deserializeJsonToPkcGetOptions, + serializePkcWithAssertionToJson, +} from './serde'; +import { + PasskeyGetOptionsJson, + assertCredentialIsPkcWithAuthenticatorAssertionResponse, +} from './types'; + +export const getPasskey = async (input: PasskeyGetOptionsJson) => { + try { + const isPasskeySupported = getIsPasskeySupported(); + + assertPasskeyError( + isPasskeySupported, + PasskeyErrorCode.PasskeyNotSupported, + ); + + const passkeyGetOptions = deserializeJsonToPkcGetOptions(input); + + const credential = await navigator.credentials.get({ + publicKey: passkeyGetOptions, + }); + + assertCredentialIsPkcWithAuthenticatorAssertionResponse(credential); + + return serializePkcWithAssertionToJson(credential); + } catch (err: unknown) { + throw handlePasskeyAuthenticationError(err); + } +}; diff --git a/packages/auth/src/client/utils/passkey/index.ts b/packages/auth/src/client/utils/passkey/index.ts new file mode 100644 index 00000000000..7f7d12728b7 --- /dev/null +++ b/packages/auth/src/client/utils/passkey/index.ts @@ -0,0 +1,5 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { registerPasskey } from './registerPasskey'; +export { getPasskey } from './getPasskey'; diff --git a/packages/auth/src/client/utils/passkey/registerPasskey.native.ts b/packages/auth/src/client/utils/passkey/registerPasskey.native.ts new file mode 100644 index 00000000000..15ab00dc290 --- /dev/null +++ b/packages/auth/src/client/utils/passkey/registerPasskey.native.ts @@ -0,0 +1,8 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PlatformNotSupportedError } from '@aws-amplify/core/internals/utils'; + +export const registerPasskey = async () => { + throw new PlatformNotSupportedError(); +}; diff --git a/packages/auth/src/client/utils/passkey/registerPasskey.ts b/packages/auth/src/client/utils/passkey/registerPasskey.ts new file mode 100644 index 00000000000..88ae3eacf2e --- /dev/null +++ b/packages/auth/src/client/utils/passkey/registerPasskey.ts @@ -0,0 +1,48 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + PasskeyCreateOptionsJson, + PasskeyCreateResultJson, + assertCredentialIsPkcWithAuthenticatorAttestationResponse, +} from './types'; +import { + deserializeJsonToPkcCreationOptions, + serializePkcWithAttestationToJson, +} from './serde'; +import { + PasskeyErrorCode, + assertPasskeyError, + handlePasskeyRegistrationError, +} from './errors'; +import { getIsPasskeySupported } from './getIsPasskeySupported'; + +/** + * Registers a new passkey for user + * @param input - PasskeyCreateOptionsJson + * @returns serialized PasskeyCreateResult + */ +export const registerPasskey = async ( + input: PasskeyCreateOptionsJson, +): Promise => { + try { + const isPasskeySupported = getIsPasskeySupported(); + + assertPasskeyError( + isPasskeySupported, + PasskeyErrorCode.PasskeyNotSupported, + ); + + const passkeyCreationOptions = deserializeJsonToPkcCreationOptions(input); + + const credential = await navigator.credentials.create({ + publicKey: passkeyCreationOptions, + }); + + assertCredentialIsPkcWithAuthenticatorAttestationResponse(credential); + + return serializePkcWithAttestationToJson(credential); + } catch (err) { + throw handlePasskeyRegistrationError(err); + } +}; diff --git a/packages/auth/src/client/utils/passkey/serde.ts b/packages/auth/src/client/utils/passkey/serde.ts new file mode 100644 index 00000000000..ae672a22a06 --- /dev/null +++ b/packages/auth/src/client/utils/passkey/serde.ts @@ -0,0 +1,151 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + convertArrayBufferToBase64Url, + convertBase64UrlToArrayBuffer, +} from '../../../foundation/convert'; + +import { + PasskeyCreateOptionsJson, + PasskeyCreateResultJson, + PasskeyGetOptionsJson, + PasskeyGetResultJson, + PkcAssertionResponse, + PkcAttestationResponse, + PkcWithAuthenticatorAssertionResponse, + PkcWithAuthenticatorAttestationResponse, +} from './types'; + +/** + * Deserializes Public Key Credential Creation Options JSON + * @param input PasskeyCreateOptionsJson + * @returns PublicKeyCredentialCreationOptions + */ +export const deserializeJsonToPkcCreationOptions = ( + input: PasskeyCreateOptionsJson, +): PublicKeyCredentialCreationOptions => { + const userIdBuffer = convertBase64UrlToArrayBuffer(input.user.id); + const challengeBuffer = convertBase64UrlToArrayBuffer(input.challenge); + const excludeCredentialsWithBuffer = (input.excludeCredentials || []).map( + excludeCred => ({ + ...excludeCred, + id: convertBase64UrlToArrayBuffer(excludeCred.id), + }), + ); + + return { + ...input, + excludeCredentials: excludeCredentialsWithBuffer, + challenge: challengeBuffer, + user: { + ...input.user, + id: userIdBuffer, + }, + }; +}; + +/** + * Serializes a Public Key Credential With Attestation to JSON + * @param input PasskeyCreateResult + * @returns PasskeyCreateResultJson + */ +export const serializePkcWithAttestationToJson = ( + input: PkcWithAuthenticatorAttestationResponse, +): PasskeyCreateResultJson => { + const response: PkcAttestationResponse = { + clientDataJSON: convertArrayBufferToBase64Url( + input.response.clientDataJSON, + ), + attestationObject: convertArrayBufferToBase64Url( + input.response.attestationObject, + ), + transports: input.response.getTransports(), + publicKeyAlgorithm: input.response.getPublicKeyAlgorithm(), + authenticatorData: convertArrayBufferToBase64Url( + input.response.getAuthenticatorData(), + ), + }; + + const publicKey = input.response.getPublicKey(); + + if (publicKey) { + response.publicKey = convertArrayBufferToBase64Url(publicKey); + } + + const resultJson: PasskeyCreateResultJson = { + type: input.type, + id: input.id, + rawId: convertArrayBufferToBase64Url(input.rawId), + clientExtensionResults: input.getClientExtensionResults(), + response, + }; + + if (input.authenticatorAttachment) { + resultJson.authenticatorAttachment = input.authenticatorAttachment; + } + + return resultJson; +}; + +/** + * Deserializes Public Key Credential Get Options JSON + * @param input PasskeyGetOptionsJson + * @returns PublicKeyCredentialRequestOptions + */ +export const deserializeJsonToPkcGetOptions = ( + input: PasskeyGetOptionsJson, +): PublicKeyCredentialRequestOptions => { + const challengeBuffer = convertBase64UrlToArrayBuffer(input.challenge); + const allowedCredentialsWithBuffer = (input.allowCredentials || []).map( + allowedCred => ({ + ...allowedCred, + id: convertBase64UrlToArrayBuffer(allowedCred.id), + }), + ); + + return { + ...input, + challenge: challengeBuffer, + allowCredentials: allowedCredentialsWithBuffer, + }; +}; + +/** + * Serializes a Public Key Credential With Attestation to JSON + * @param input PasskeyGetResult + * @returns PasskeyGetResultJson + */ +export const serializePkcWithAssertionToJson = ( + input: PkcWithAuthenticatorAssertionResponse, +): PasskeyGetResultJson => { + const response: PkcAssertionResponse = { + clientDataJSON: convertArrayBufferToBase64Url( + input.response.clientDataJSON, + ), + authenticatorData: convertArrayBufferToBase64Url( + input.response.authenticatorData, + ), + signature: convertArrayBufferToBase64Url(input.response.signature), + }; + + if (input.response.userHandle) { + response.userHandle = convertArrayBufferToBase64Url( + input.response.userHandle, + ); + } + + const resultJson: PasskeyGetResultJson = { + id: input.id, + rawId: convertArrayBufferToBase64Url(input.rawId), + type: input.type, + clientExtensionResults: input.getClientExtensionResults(), + response, + }; + + if (input.authenticatorAttachment) { + resultJson.authenticatorAttachment = input.authenticatorAttachment; + } + + return resultJson; +}; diff --git a/packages/auth/src/client/utils/passkey/types/index.ts b/packages/auth/src/client/utils/passkey/types/index.ts new file mode 100644 index 00000000000..2c9df968218 --- /dev/null +++ b/packages/auth/src/client/utils/passkey/types/index.ts @@ -0,0 +1,61 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PasskeyErrorCode, assertPasskeyError } from '../errors'; + +/** + * Passkey Create Types + */ + +export { + PkcAttestationResponse, + PasskeyCreateOptionsJson, + PasskeyCreateResultJson, + assertValidCredentialCreationOptions, +} from './shared'; + +export type PkcWithAuthenticatorAttestationResponse = Omit< + PublicKeyCredential, + 'response' +> & { + response: AuthenticatorAttestationResponse; +}; + +export function assertCredentialIsPkcWithAuthenticatorAttestationResponse( + credential: any, +): asserts credential is PkcWithAuthenticatorAttestationResponse { + assertPasskeyError( + credential && + credential instanceof PublicKeyCredential && + credential.response instanceof AuthenticatorAttestationResponse, + PasskeyErrorCode.PasskeyRegistrationFailed, + ); +} + +/** + * Passkey Get Types + */ + +export { + PkcAssertionResponse, + PasskeyGetOptionsJson, + PasskeyGetResultJson, +} from './shared'; + +export type PkcWithAuthenticatorAssertionResponse = Omit< + PublicKeyCredential, + 'response' +> & { + response: AuthenticatorAssertionResponse; +}; + +export function assertCredentialIsPkcWithAuthenticatorAssertionResponse( + credential: any, +): asserts credential is PkcWithAuthenticatorAssertionResponse { + assertPasskeyError( + credential && + credential instanceof PublicKeyCredential && + credential.response instanceof AuthenticatorAssertionResponse, + PasskeyErrorCode.PasskeyRetrievalFailed, + ); +} diff --git a/packages/auth/src/client/utils/passkey/types/shared.ts b/packages/auth/src/client/utils/passkey/types/shared.ts new file mode 100644 index 00000000000..847118d7e25 --- /dev/null +++ b/packages/auth/src/client/utils/passkey/types/shared.ts @@ -0,0 +1,131 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PasskeyErrorCode, assertPasskeyError } from '../errors'; + +type PasskeyTransport = 'ble' | 'hybrid' | 'internal' | 'nfc' | 'usb'; +type UserVerificationRequirement = 'discouraged' | 'preferred' | 'required'; +type AttestationConveyancePreference = + | 'direct' + | 'enterprise' + | 'indirect' + | 'none'; + +interface PkcDescriptor { + type: 'public-key'; + id: T; + transports?: PasskeyTransport[]; +} + +/** + * Passkey Create Types + */ +export interface PasskeyCreateOptionsJson { + challenge: string; + rp: { + id: string; + name: string; + }; + user: { + id: string; + name: string; + displayName: string; + }; + pubKeyCredParams: { + alg: number; + type: 'public-key'; + }[]; + timeout?: number; + excludeCredentials?: PkcDescriptor[]; + authenticatorSelection?: { + requireResidentKey: boolean; + residentKey: UserVerificationRequirement; + userVerification: UserVerificationRequirement; + }; + attestation?: AttestationConveyancePreference; + extensions?: { + appid?: string; + appidExclude?: string; + credProps?: boolean; + }; +} + +export interface PkcAttestationResponse { + clientDataJSON: T; + attestationObject: T; + transports: string[]; + publicKey?: string; + publicKeyAlgorithm: number; + authenticatorData: T; +} +export interface PasskeyCreateResult { + id: string; + rawId: ArrayBuffer; + type: 'public-key'; + response: PkcAttestationResponse; +} + +export interface PasskeyCreateResultJson { + id: string; + rawId: string; + type: string; + clientExtensionResults: { + appId?: boolean; + credProps?: { rk?: boolean }; + hmacCreateSecret?: boolean; + }; + authenticatorAttachment?: string; + response: PkcAttestationResponse; +} + +export function assertValidCredentialCreationOptions( + credentialCreationOptions: any, +): asserts credentialCreationOptions is PasskeyCreateOptionsJson { + assertPasskeyError( + [ + !!credentialCreationOptions, + !!credentialCreationOptions?.challenge, + !!credentialCreationOptions?.user, + !!credentialCreationOptions?.rp, + !!credentialCreationOptions?.pubKeyCredParams, + ].every(Boolean), + PasskeyErrorCode.InvalidPasskeyRegistrationOptions, + ); +} + +/** + * Passkey Get Types + */ +export interface PasskeyGetOptionsJson { + challenge: string; + rpId: string; + timeout: number; + allowCredentials: PkcDescriptor[]; + userVerification: UserVerificationRequirement; +} + +export interface PkcAssertionResponse { + authenticatorData: T; + clientDataJSON: T; + signature: T; + userHandle?: T; +} + +export interface PasskeyGetResult { + id: string; + rawId: ArrayBuffer; + type: 'public-key'; + response: PkcAssertionResponse; +} +export interface PasskeyGetResultJson { + id: string; + rawId: string; + type: string; + clientExtensionResults: { + appId?: boolean; + credProps?: { rk?: boolean }; + hmacCreateSecret?: boolean; + }; + authenticatorAttachment?: string; + response: PkcAssertionResponse; +} diff --git a/packages/auth/src/client/utils/store/autoSignInStore.ts b/packages/auth/src/client/utils/store/autoSignInStore.ts new file mode 100644 index 00000000000..2cd93f62bc8 --- /dev/null +++ b/packages/auth/src/client/utils/store/autoSignInStore.ts @@ -0,0 +1,64 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Reducer, Store } from './types'; + +type AutoSignInAction = + | { type: 'START' } + | { type: 'SET_USERNAME'; value: string } + | { type: 'SET_SESSION'; value?: string } + | { type: 'RESET' }; + +interface AutoSignInState { + active: boolean; + username?: string; + session?: string; +} + +function defaultState(): AutoSignInState { + return { + active: false, + }; +} + +const autoSignInReducer: Reducer = ( + state: AutoSignInState, + action: AutoSignInAction, +): AutoSignInState => { + switch (action.type) { + case 'SET_USERNAME': + return { + ...state, + username: action.value, + }; + case 'SET_SESSION': + return { + ...state, + session: action.value, + }; + case 'START': + return { + ...state, + active: true, + }; + case 'RESET': + return defaultState(); + default: + return state; + } +}; + +const createAutoSignInStore: Store = ( + reducer: Reducer, +) => { + let currentState = reducer(defaultState(), { type: 'RESET' }); + + return { + getState: () => currentState, + dispatch: action => { + currentState = reducer(currentState, action); + }, + }; +}; + +export const autoSignInStore = createAutoSignInStore(autoSignInReducer); diff --git a/packages/auth/src/client/utils/store/index.ts b/packages/auth/src/client/utils/store/index.ts new file mode 100644 index 00000000000..b1070020ec5 --- /dev/null +++ b/packages/auth/src/client/utils/store/index.ts @@ -0,0 +1,5 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export * from './autoSignInStore'; +export * from './signInStore'; diff --git a/packages/auth/src/providers/cognito/utils/signInStore.ts b/packages/auth/src/client/utils/store/signInStore.ts similarity index 87% rename from packages/auth/src/providers/cognito/utils/signInStore.ts rename to packages/auth/src/client/utils/store/signInStore.ts index fd07cb15e6d..94311ce2b74 100644 --- a/packages/auth/src/providers/cognito/utils/signInStore.ts +++ b/packages/auth/src/client/utils/store/signInStore.ts @@ -1,9 +1,11 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { CognitoAuthSignInDetails } from '../types'; +import { CognitoAuthSignInDetails } from '../../../providers/cognito/types'; import { ChallengeName } from '../../../foundation/factories/serviceClients/cognitoIdentityProvider/types'; +import { Reducer, Store } from './types'; + // TODO: replace all of this implementation with state machines interface SignInState { username?: string; @@ -19,13 +21,6 @@ type SignInAction = | { type: 'SET_CHALLENGE_NAME'; value?: ChallengeName } | { type: 'SET_SIGN_IN_SESSION'; value?: string }; -type Store = (reducer: Reducer) => { - getState(): ReturnType>; - dispatch(action: Action): void; -}; - -type Reducer = (state: State, action: Action) => State; - const signInReducer: Reducer = (state, action) => { switch (action.type) { case 'SET_SIGN_IN_SESSION': diff --git a/packages/auth/src/client/utils/store/types.ts b/packages/auth/src/client/utils/store/types.ts new file mode 100644 index 00000000000..bce088ebf2a --- /dev/null +++ b/packages/auth/src/client/utils/store/types.ts @@ -0,0 +1,9 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export type Store = (reducer: Reducer) => { + getState(): ReturnType>; + dispatch(action: Action): void; +}; + +export type Reducer = (state: State, action: Action) => State; diff --git a/packages/auth/src/foundation/apis/deleteWebAuthnCredential.ts b/packages/auth/src/foundation/apis/deleteWebAuthnCredential.ts new file mode 100644 index 00000000000..c47b13ea303 --- /dev/null +++ b/packages/auth/src/foundation/apis/deleteWebAuthnCredential.ts @@ -0,0 +1,45 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AmplifyClassV6 } from '@aws-amplify/core'; +import { + AuthAction, + assertTokenProviderConfig, +} from '@aws-amplify/core/internals/utils'; + +import { assertAuthTokens } from '../../providers/cognito/utils/types'; +import { createCognitoUserPoolEndpointResolver } from '../../providers/cognito/factories'; +import { getRegionFromUserPoolId } from '../parsers'; +import { getAuthUserAgentValue } from '../../utils'; +import { createDeleteWebAuthnCredentialClient } from '../factories/serviceClients/cognitoIdentityProvider'; +import { DeleteWebAuthnCredentialInput } from '../types'; + +export async function deleteWebAuthnCredential( + amplify: AmplifyClassV6, + input: DeleteWebAuthnCredentialInput, +): Promise { + const authConfig = amplify.getConfig().Auth?.Cognito; + assertTokenProviderConfig(authConfig); + const { userPoolEndpoint, userPoolId } = authConfig; + const { tokens } = await amplify.Auth.fetchAuthSession(); + assertAuthTokens(tokens); + + const deleteWebAuthnCredentialResult = createDeleteWebAuthnCredentialClient({ + endpointResolver: createCognitoUserPoolEndpointResolver({ + endpointOverride: userPoolEndpoint, + }), + }); + + await deleteWebAuthnCredentialResult( + { + region: getRegionFromUserPoolId(userPoolId), + userAgentValue: getAuthUserAgentValue( + AuthAction.DeleteWebAuthnCredential, + ), + }, + { + AccessToken: tokens.accessToken.toString(), + CredentialId: input.credentialId, + }, + ); +} diff --git a/packages/auth/src/foundation/apis/index.ts b/packages/auth/src/foundation/apis/index.ts new file mode 100644 index 00000000000..59d61c0cc16 --- /dev/null +++ b/packages/auth/src/foundation/apis/index.ts @@ -0,0 +1,5 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { listWebAuthnCredentials } from './listWebAuthnCredentials'; +export { deleteWebAuthnCredential } from './deleteWebAuthnCredential'; diff --git a/packages/auth/src/foundation/apis/listWebAuthnCredentials.ts b/packages/auth/src/foundation/apis/listWebAuthnCredentials.ts new file mode 100644 index 00000000000..5016833bdc6 --- /dev/null +++ b/packages/auth/src/foundation/apis/listWebAuthnCredentials.ts @@ -0,0 +1,68 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AmplifyClassV6 } from '@aws-amplify/core'; +import { + AuthAction, + assertTokenProviderConfig, +} from '@aws-amplify/core/internals/utils'; + +import { assertAuthTokens } from '../../providers/cognito/utils/types'; +import { createCognitoUserPoolEndpointResolver } from '../../providers/cognito/factories'; +import { getRegionFromUserPoolId } from '../parsers'; +import { getAuthUserAgentValue } from '../../utils'; +import { createListWebAuthnCredentialsClient } from '../factories/serviceClients/cognitoIdentityProvider'; +import { + AuthWebAuthnCredential, + ListWebAuthnCredentialsInput, + ListWebAuthnCredentialsOutput, +} from '../types'; + +export async function listWebAuthnCredentials( + amplify: AmplifyClassV6, + input?: ListWebAuthnCredentialsInput, +): Promise { + const authConfig = amplify.getConfig().Auth?.Cognito; + assertTokenProviderConfig(authConfig); + const { userPoolEndpoint, userPoolId } = authConfig; + + const { tokens } = await amplify.Auth.fetchAuthSession(); + assertAuthTokens(tokens); + + const listWebAuthnCredentialsResult = createListWebAuthnCredentialsClient({ + endpointResolver: createCognitoUserPoolEndpointResolver({ + endpointOverride: userPoolEndpoint, + }), + }); + + const { Credentials: commandCredentials = [], NextToken: nextToken } = + await listWebAuthnCredentialsResult( + { + region: getRegionFromUserPoolId(userPoolId), + userAgentValue: getAuthUserAgentValue( + AuthAction.ListWebAuthnCredentials, + ), + }, + { + AccessToken: tokens.accessToken.toString(), + MaxResults: input?.pageSize, + NextToken: input?.nextToken, + }, + ); + + const credentials: AuthWebAuthnCredential[] = commandCredentials.map( + item => ({ + credentialId: item.CredentialId, + friendlyCredentialName: item.FriendlyCredentialName, + relyingPartyId: item.RelyingPartyId, + authenticatorAttachment: item.AuthenticatorAttachment, + authenticatorTransports: item.AuthenticatorTransports, + createdAt: item.CreatedAt ? new Date(item.CreatedAt * 1000) : undefined, + }), + ); + + return { + credentials, + nextToken, + }; +} diff --git a/packages/auth/src/foundation/convert/base64url/convertArrayBufferToBase64Url.ts b/packages/auth/src/foundation/convert/base64url/convertArrayBufferToBase64Url.ts new file mode 100644 index 00000000000..981437f5a0a --- /dev/null +++ b/packages/auth/src/foundation/convert/base64url/convertArrayBufferToBase64Url.ts @@ -0,0 +1,18 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { base64Encoder } from '@aws-amplify/core/internals/utils'; + +// https://datatracker.ietf.org/doc/html/rfc4648#page-7 + +/** + * Converts an ArrayBuffer to a base64url encoded string + * @param buffer - the ArrayBuffer instance of a Uint8Array + * @returns string - a base64url encoded string + */ +export const convertArrayBufferToBase64Url = (buffer: ArrayBuffer): string => { + return base64Encoder.convert(new Uint8Array(buffer), { + urlSafe: true, + skipPadding: true, + }); +}; diff --git a/packages/auth/src/foundation/convert/base64url/convertBase64UrlToArrayBuffer.ts b/packages/auth/src/foundation/convert/base64url/convertBase64UrlToArrayBuffer.ts new file mode 100644 index 00000000000..987d57eff66 --- /dev/null +++ b/packages/auth/src/foundation/convert/base64url/convertBase64UrlToArrayBuffer.ts @@ -0,0 +1,18 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { base64Decoder } from '@aws-amplify/core/internals/utils'; + +/** + * Converts a base64url encoded string to an ArrayBuffer + * @param base64url - a base64url encoded string + * @returns ArrayBuffer + */ +export const convertBase64UrlToArrayBuffer = ( + base64url: string, +): ArrayBuffer => { + return Uint8Array.from( + base64Decoder.convert(base64url, { urlSafe: true }), + x => x.charCodeAt(0), + ).buffer; +}; diff --git a/packages/auth/src/foundation/convert/base64url/index.ts b/packages/auth/src/foundation/convert/base64url/index.ts new file mode 100644 index 00000000000..c4804b38a17 --- /dev/null +++ b/packages/auth/src/foundation/convert/base64url/index.ts @@ -0,0 +1,5 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { convertArrayBufferToBase64Url } from './convertArrayBufferToBase64Url'; +export { convertBase64UrlToArrayBuffer } from './convertBase64UrlToArrayBuffer'; diff --git a/packages/auth/src/foundation/convert/index.ts b/packages/auth/src/foundation/convert/index.ts new file mode 100644 index 00000000000..7fea0c7c87c --- /dev/null +++ b/packages/auth/src/foundation/convert/index.ts @@ -0,0 +1,7 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { + convertArrayBufferToBase64Url, + convertBase64UrlToArrayBuffer, +} from './base64url'; diff --git a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createCompleteWebAuthnRegistrationClient.ts b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createCompleteWebAuthnRegistrationClient.ts new file mode 100644 index 00000000000..f86ad95da2f --- /dev/null +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createCompleteWebAuthnRegistrationClient.ts @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { composeServiceApi } from '@aws-amplify/core/internals/aws-client-utils/composers'; + +import { + CompleteWebAuthnRegistrationCommandInput, + CompleteWebAuthnRegistrationCommandOutput, + ServiceClientFactoryInput, +} from './types'; +import { cognitoUserPoolTransferHandler } from './shared/handler'; +import { + createUserPoolDeserializer, + createUserPoolSerializer, +} from './shared/serde'; +import { DEFAULT_SERVICE_CLIENT_API_CONFIG } from './constants'; + +export const createCompleteWebAuthnRegistrationClient = ( + config: ServiceClientFactoryInput, +) => + composeServiceApi( + cognitoUserPoolTransferHandler, + createUserPoolSerializer( + 'CompleteWebAuthnRegistration', + ), + createUserPoolDeserializer(), + { + ...DEFAULT_SERVICE_CLIENT_API_CONFIG, + ...config, + }, + ); diff --git a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createDeleteWebAuthnCredentialClient.ts b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createDeleteWebAuthnCredentialClient.ts new file mode 100644 index 00000000000..6e399cc3f39 --- /dev/null +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createDeleteWebAuthnCredentialClient.ts @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { composeServiceApi } from '@aws-amplify/core/internals/aws-client-utils/composers'; + +import { + DeleteWebAuthnCredentialCommandInput, + DeleteWebAuthnCredentialCommandOutput, + ServiceClientFactoryInput, +} from './types'; +import { cognitoUserPoolTransferHandler } from './shared/handler'; +import { + createUserPoolDeserializer, + createUserPoolSerializer, +} from './shared/serde'; +import { DEFAULT_SERVICE_CLIENT_API_CONFIG } from './constants'; + +export const createDeleteWebAuthnCredentialClient = ( + config: ServiceClientFactoryInput, +) => + composeServiceApi( + cognitoUserPoolTransferHandler, + createUserPoolSerializer( + 'DeleteWebAuthnCredential', + ), + createUserPoolDeserializer(), + { + ...DEFAULT_SERVICE_CLIENT_API_CONFIG, + ...config, + }, + ); diff --git a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createListWebAuthnCredentialsClient.ts b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createListWebAuthnCredentialsClient.ts new file mode 100644 index 00000000000..60a864eb2e8 --- /dev/null +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createListWebAuthnCredentialsClient.ts @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { composeServiceApi } from '@aws-amplify/core/internals/aws-client-utils/composers'; + +import { + ListWebAuthnCredentialsCommandInput, + ListWebAuthnCredentialsCommandOutput, + ServiceClientFactoryInput, +} from './types'; +import { cognitoUserPoolTransferHandler } from './shared/handler'; +import { + createUserPoolDeserializer, + createUserPoolSerializer, +} from './shared/serde'; +import { DEFAULT_SERVICE_CLIENT_API_CONFIG } from './constants'; + +export const createListWebAuthnCredentialsClient = ( + config: ServiceClientFactoryInput, +) => + composeServiceApi( + cognitoUserPoolTransferHandler, + createUserPoolSerializer( + 'ListWebAuthnCredentials', + ), + createUserPoolDeserializer(), + { + ...DEFAULT_SERVICE_CLIENT_API_CONFIG, + ...config, + }, + ); diff --git a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createSignUpClient.ts b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createSignUpClient.ts index e77676bab1d..ca9a2fda127 100644 --- a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createSignUpClient.ts +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createSignUpClient.ts @@ -1,24 +1,61 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import { composeServiceApi } from '@aws-amplify/core/internals/aws-client-utils/composers'; +import { + HttpResponse, + parseJsonBody, + parseJsonError, +} from '@aws-amplify/core/internals/aws-client-utils'; + +import { validationErrorMap } from '../../../../common/AuthErrorStrings'; +import { AuthError } from '../../../../errors/AuthError'; +import { AuthValidationErrorCode } from '../../../../errors/types/validation'; +import { assertServiceError } from '../../../../errors/utils/assertServiceError'; +import { SignUpException } from '../../../../providers/cognito/types/errors'; +import { createUserPoolSerializer } from './shared/serde'; +import { cognitoUserPoolTransferHandler } from './shared/handler'; +import { DEFAULT_SERVICE_CLIENT_API_CONFIG } from './constants'; import { ServiceClientFactoryInput, SignUpCommandInput, SignUpCommandOutput, } from './types'; -import { DEFAULT_SERVICE_CLIENT_API_CONFIG } from './constants'; -import { cognitoUserPoolTransferHandler } from './shared/handler'; -import { - createUserPoolDeserializer, - createUserPoolSerializer, -} from './shared/serde'; + +export const createSignUpClientDeserializer = + (): ((response: HttpResponse) => Promise) => + async (response: HttpResponse): Promise => { + if (response.statusCode >= 300) { + const error = await parseJsonError(response); + assertServiceError(error); + + if ( + // Missing Password Error + // 1 validation error detected: Value at 'password'failed to satisfy constraint: Member must not be null + error.name === SignUpException.InvalidParameterException && + /'password'/.test(error.message) && + /Member must not be null/.test(error.message) + ) { + const name = AuthValidationErrorCode.EmptySignUpPassword; + const { message, recoverySuggestion } = validationErrorMap[name]; + throw new AuthError({ + name, + message, + recoverySuggestion, + }); + } + + throw new AuthError({ name: error.name, message: error.message }); + } + + return parseJsonBody(response); + }; export const createSignUpClient = (config: ServiceClientFactoryInput) => composeServiceApi( cognitoUserPoolTransferHandler, createUserPoolSerializer('SignUp'), - createUserPoolDeserializer(), + createSignUpClientDeserializer(), { ...DEFAULT_SERVICE_CLIENT_API_CONFIG, ...config, diff --git a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createStartWebAuthnRegistrationClient.ts b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createStartWebAuthnRegistrationClient.ts new file mode 100644 index 00000000000..453efccd8f0 --- /dev/null +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createStartWebAuthnRegistrationClient.ts @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { composeServiceApi } from '@aws-amplify/core/internals/aws-client-utils/composers'; + +import { + ServiceClientFactoryInput, + StartWebAuthnRegistrationCommandInput, + StartWebAuthnRegistrationCommandOutput, +} from './types'; +import { cognitoUserPoolTransferHandler } from './shared/handler'; +import { + createUserPoolDeserializer, + createUserPoolSerializer, +} from './shared/serde'; +import { DEFAULT_SERVICE_CLIENT_API_CONFIG } from './constants'; + +export const createStartWebAuthnRegistrationClient = ( + config: ServiceClientFactoryInput, +) => + composeServiceApi( + cognitoUserPoolTransferHandler, + createUserPoolSerializer( + 'StartWebAuthnRegistration', + ), + createUserPoolDeserializer(), + { + ...DEFAULT_SERVICE_CLIENT_API_CONFIG, + ...config, + }, + ); diff --git a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/index.ts b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/index.ts index 2b93cd09150..c8db070223e 100644 --- a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/index.ts +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/index.ts @@ -24,3 +24,7 @@ export { createVerifyUserAttributeClient } from './createVerifyUserAttributeClie export { createUpdateDeviceStatusClient } from './createUpdateDeviceStatusClient'; export { createListDevicesClient } from './createListDevicesClient'; export { createDeleteUserAttributesClient } from './createDeleteUserAttributesClient'; +export { createStartWebAuthnRegistrationClient } from './createStartWebAuthnRegistrationClient'; +export { createCompleteWebAuthnRegistrationClient } from './createCompleteWebAuthnRegistrationClient'; +export { createListWebAuthnCredentialsClient } from './createListWebAuthnCredentialsClient'; +export { createDeleteWebAuthnCredentialClient } from './createDeleteWebAuthnCredentialClient'; diff --git a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/shared/serde/createUserPoolSerializer.ts b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/shared/serde/createUserPoolSerializer.ts index 81f22df9312..070789e898f 100644 --- a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/shared/serde/createUserPoolSerializer.ts +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/shared/serde/createUserPoolSerializer.ts @@ -30,7 +30,11 @@ type ClientOperation = | 'DeleteUserAttributes' | 'UpdateDeviceStatus' | 'ListDevices' - | 'RevokeToken'; + | 'RevokeToken' + | 'StartWebAuthnRegistration' + | 'CompleteWebAuthnRegistration' + | 'ListWebAuthnCredentials' + | 'DeleteWebAuthnCredential'; export const createUserPoolSerializer = (operation: ClientOperation) => diff --git a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/errors.ts b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/errors.ts new file mode 100644 index 00000000000..3a45bd9abbf --- /dev/null +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/errors.ts @@ -0,0 +1,43 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export enum StartWebAuthnRegistrationException { + ForbiddenException = 'ForbiddenException', + InternalErrorException = 'InternalErrorException', + InvalidParameterException = 'InvalidParameterException', + LimitExceededException = 'LimitExceededException', + NotAuthorizedException = 'NotAuthorizedException', + TooManyRequestsException = 'TooManyRequestsException', + WebAuthnNotEnabledException = 'WebAuthnNotEnabledException', + WebAuthnConfigurationMissingException = 'WebAuthnConfigurationMissingException', +} + +export enum CompleteWebAuthnRegistrationException { + ForbiddenException = 'ForbiddenException', + InternalErrorException = 'InternalErrorException', + InvalidParameterException = 'InvalidParameterException', + LimitExceededException = 'LimitExceededException', + NotAuthorizedException = 'NotAuthorizedException', + TooManyRequestsException = 'TooManyRequestsException', + WebAuthnNotEnabledException = 'WebAuthnNotEnabledException', + WebAuthnChallengeNotFoundException = 'WebAuthnChallengeNotFoundException', + WebAuthnRelyingPartyMismatchException = 'WebAuthnRelyingPartyMismatchException', + WebAuthnClientMismatchException = 'WebAuthnClientMismatchException', + WebAuthnOriginNotAllowedException = 'WebAuthnOriginNotAllowedException', + WebAuthnCredentialNotSupportedException = 'WebAuthnCredentialNotSupportedException', +} + +export enum ListWebAuthnCredentialsException { + ForbiddenException = 'ForbiddenException', + InternalErrorException = 'InternalErrorException', + InvalidParameterException = 'InvalidParameterException', + NotAuthorizedException = 'NotAuthorizedException', +} + +export enum DeleteWebAuthnCredentialException { + ForbiddenException = 'ForbiddenException', + InternalErrorException = 'InternalErrorException', + InvalidParameterException = 'InvalidParameterException', + NotAuthorizedException = 'NotAuthorizedException', + ResourceNotFoundException = 'ResourceNotFoundException', +} diff --git a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/index.ts b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/index.ts index 3374c6b6194..f39d3141184 100644 --- a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/index.ts +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/index.ts @@ -1,4 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 + export * from './sdk'; export * from './serviceClient'; +export * from './errors'; diff --git a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/sdk.ts b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/sdk.ts index f7a1d4a483a..f4c5edd9f4d 100644 --- a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/sdk.ts +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/sdk.ts @@ -7,16 +7,21 @@ import { MetadataBearer as __MetadataBearer } from '@aws-sdk/types'; export type ChallengeName = | 'SMS_MFA' + | 'SMS_OTP' | 'SOFTWARE_TOKEN_MFA' | 'EMAIL_OTP' | 'SELECT_MFA_TYPE' + | 'SELECT_CHALLENGE' | 'MFA_SETUP' + | 'PASSWORD' + | 'PASSWORD_SRP' | 'PASSWORD_VERIFIER' | 'CUSTOM_CHALLENGE' | 'DEVICE_SRP_AUTH' | 'DEVICE_PASSWORD_VERIFIER' | 'ADMIN_NO_SRP_AUTH' - | 'NEW_PASSWORD_REQUIRED'; + | 'NEW_PASSWORD_REQUIRED' + | 'WEB_AUTHN'; export type ChallengeParameters = { CODE_DELIVERY_DESTINATION?: string; @@ -27,6 +32,7 @@ export type ChallengeParameters = { PASSWORD_CLAIM_SIGNATURE?: string; MFAS_CAN_CHOOSE?: string; MFAS_CAN_SETUP?: string; + CREDENTIAL_REQUEST_OPTIONS?: string; } & Record; export type CognitoMFAType = 'SMS_MFA' | 'SOFTWARE_TOKEN_MFA' | 'EMAIL_OTP'; @@ -56,6 +62,10 @@ declare enum ChallengeNameType { SELECT_MFA_TYPE = 'SELECT_MFA_TYPE', SMS_MFA = 'SMS_MFA', SOFTWARE_TOKEN_MFA = 'SOFTWARE_TOKEN_MFA', + PASSWORD = 'PASSWORD', + PASSWORD_SRP = 'PASSWORD_SRP', + WEB_AUTHN = 'WEB_AUTHN', + SMS_OTP = 'SMS_OTP', EMAIL_OTP = 'EMAIL_OTP', } declare enum DeliveryMediumType { @@ -696,7 +706,15 @@ export interface ConfirmSignUpRequest { /** *

Represents the response from the server for the registration confirmation.

*/ -export type ConfirmSignUpResponse = Record; +export interface ConfirmSignUpResponse { + /** + *

Your ConfirmSignUp request might produce a challenge that your user must + * respond to, for example a one-time code. The Session parameter tracks the + * session in the flow of challenge responses and requests. Include this parameter in + * RespondToAuthChallenge API requests.

+ */ + Session?: string; +} export type DeleteUserCommandInput = DeleteUserRequest; export interface DeleteUserCommandOutput extends DeleteUserResponse, @@ -1083,6 +1101,13 @@ export interface InitiateAuthRequest { *

Contextual data such as the user's device fingerprint, IP address, or location used for evaluating the risk of an unexpected event by Amazon Cognito advanced security.

*/ UserContextData?: UserContextDataType; + + /** + *

The optional session ID from a ConfirmSignUp API + * request. You can sign in a user directly from the sign-up process with the + * USER_AUTH authentication flow.

+ */ + Session?: string; } /** *

Initiates the authentication response.

@@ -1133,8 +1158,10 @@ export interface InitiateAuthResponse { */ ChallengeName?: ChallengeNameType | string; /** - *

The session that should pass both ways in challenge-response calls to the service. If the caller must pass another challenge, they return a session with other challenge parameters. This session - * should be passed as it is to the next RespondToAuthChallenge API call.

+ *

The session that should pass both ways in challenge-response calls to the service. If + * the caller must pass another challenge, they return a session with other challenge + * parameters. Include this session identifier in a RespondToAuthChallenge API + * request.

*/ Session?: string; /** @@ -1145,9 +1172,15 @@ export interface InitiateAuthResponse { ChallengeParameters?: Record; /** *

The result of the authentication response. This result is only returned if the caller doesn't need to pass another challenge. If the caller does need to pass another challenge before it gets - * tokens, ChallengeName, ChallengeParameters, and Session are returned.

+ * tokens, ChallengeName, ChallengeParameters, AvailableChallenges, and Session are returned.

*/ AuthenticationResult?: AuthenticationResultType; + /** + *

This response parameter prompts a user to select from multiple available challenges + * that they can complete authentication with. For example, they might be able to continue + * with passwordless authentication or with a one-time password from an SMS message.

+ */ + AvailableChallenges?: ChallengeNameType[]; } export type ListDevicesCommandInput = ListDevicesRequest; export interface ListDevicesCommandOutput @@ -1527,6 +1560,13 @@ export interface SignUpResponse { *

The UUID of the authenticated user. This isn't the same as username.

*/ UserSub: string | undefined; + + /** + *

A session Id that you can pass to ConfirmSignUp when you want to + * immediately sign in your user with the USER_AUTH flow after they complete + * sign-up.

+ */ + Session?: string; } /** *

The type used for enabling software token MFA at the user level. If an MFA type is activated for a user, the user will be prompted for MFA during all sign-in attempts, unless device tracking @@ -1732,3 +1772,99 @@ export interface DeleteUserAttributesRequest { */ export type DeleteUserAttributesResponse = Record; export {}; + +export interface StartWebAuthnRegistrationRequest { + /** + * A valid access token that Amazon Cognito issued to the user whose passkey metadata you want to + * generate. + */ + AccessToken: string | undefined; +} + +export interface StartWebAuthnRegistrationResponse { + /** + * The information that a user can provide in their request to register with their + * passkey provider. + */ + CredentialCreationOptions: Record | undefined; +} + +export type StartWebAuthnRegistrationCommandInput = + StartWebAuthnRegistrationRequest; + +export interface StartWebAuthnRegistrationCommandOutput + extends StartWebAuthnRegistrationResponse, + __MetadataBearer {} + +export interface CompleteWebAuthnRegistrationRequest { + /** + * A valid access token that Amazon Cognito issued to the user whose passkey registration you want + * to verify. This information informs your user pool of the details of the user's + * successful registration with their passkey provider. + */ + AccessToken: string | undefined; + + /** + * A RegistrationResponseJSON public-key credential response from the + * user's passkey provider. + */ + Credential: Record | undefined; +} + +export type CompleteWebAuthnRegistrationResponse = Record; + +export type CompleteWebAuthnRegistrationCommandInput = + CompleteWebAuthnRegistrationRequest; + +export interface CompleteWebAuthnRegistrationCommandOutput + extends CompleteWebAuthnRegistrationResponse, + __MetadataBearer {} + +/** + *

The request to list WebAuthN credentials.

+ */ +export interface ListWebAuthnCredentialsInput { + AccessToken: string | undefined; + NextToken?: string; + MaxResults?: number; +} + +export interface WebAuthnCredentialDescription { + CredentialId: string | undefined; + FriendlyCredentialName: string | undefined; + RelyingPartyId: string | undefined; + AuthenticatorAttachment?: string; + AuthenticatorTransports: string[] | undefined; + CreatedAt: number | undefined; +} + +/** + *

The response containing the list of WebAuthN credentials.

+ */ +export interface ListWebAuthnCredentialsOutput { + Credentials: WebAuthnCredentialDescription[] | undefined; + NextToken?: string; +} + +export type ListWebAuthnCredentialsCommandInput = ListWebAuthnCredentialsInput; + +export interface ListWebAuthnCredentialsCommandOutput + extends ListWebAuthnCredentialsOutput, + __MetadataBearer {} + +/** + * The request to delete a WebAuthN credential. + */ +export interface DeleteWebAuthnCredentialInput { + AccessToken: string | undefined; + CredentialId: string | undefined; +} + +export type DeleteWebAuthnCredentialOutput = Record; + +export type DeleteWebAuthnCredentialCommandInput = + DeleteWebAuthnCredentialInput; + +export interface DeleteWebAuthnCredentialCommandOutput + extends DeleteWebAuthnCredentialOutput, + __MetadataBearer {} diff --git a/packages/auth/src/foundation/types/index.ts b/packages/auth/src/foundation/types/index.ts new file mode 100644 index 00000000000..cafbcffab9b --- /dev/null +++ b/packages/auth/src/foundation/types/index.ts @@ -0,0 +1,9 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { + ListWebAuthnCredentialsInput, + DeleteWebAuthnCredentialInput, +} from './inputs'; +export { ListWebAuthnCredentialsOutput } from './outputs'; +export { AuthWebAuthnCredential } from './models'; diff --git a/packages/auth/src/foundation/types/inputs.ts b/packages/auth/src/foundation/types/inputs.ts new file mode 100644 index 00000000000..6699cccd714 --- /dev/null +++ b/packages/auth/src/foundation/types/inputs.ts @@ -0,0 +1,14 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Input type for Cognito listWebAuthnCredentials API. + */ +export interface ListWebAuthnCredentialsInput { + pageSize?: number; + nextToken?: string; +} + +export interface DeleteWebAuthnCredentialInput { + credentialId: string; +} diff --git a/packages/auth/src/foundation/types/models.ts b/packages/auth/src/foundation/types/models.ts new file mode 100644 index 00000000000..3183f305c4b --- /dev/null +++ b/packages/auth/src/foundation/types/models.ts @@ -0,0 +1,14 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Shape of a WebAuthn credential + */ +export interface AuthWebAuthnCredential { + credentialId: string | undefined; + friendlyCredentialName: string | undefined; + relyingPartyId: string | undefined; + authenticatorAttachment?: string; + authenticatorTransports: string[] | undefined; + createdAt: Date | undefined; +} diff --git a/packages/auth/src/foundation/types/outputs.ts b/packages/auth/src/foundation/types/outputs.ts new file mode 100644 index 00000000000..13604174687 --- /dev/null +++ b/packages/auth/src/foundation/types/outputs.ts @@ -0,0 +1,12 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AuthWebAuthnCredential } from './models'; + +/** + * Output type for Cognito listWebAuthnCredentials API. + */ +export interface ListWebAuthnCredentialsOutput { + credentials: AuthWebAuthnCredential[]; + nextToken?: string; +} diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 799492edb39..0ca9948aa9b 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -87,3 +87,17 @@ export { AuthTokens, JWT, } from '@aws-amplify/core'; + +export { associateWebAuthnCredential } from './client/apis'; + +export { + listWebAuthnCredentials, + deleteWebAuthnCredential, +} from './client/apis'; + +export { + AuthWebAuthnCredential, + DeleteWebAuthnCredentialInput, + ListWebAuthnCredentialsInput, + ListWebAuthnCredentialsOutput, +} from './foundation/types'; diff --git a/packages/auth/src/providers/cognito/apis/autoSignIn.ts b/packages/auth/src/providers/cognito/apis/autoSignIn.ts index d10b4a8c820..6186ac159c9 100644 --- a/packages/auth/src/providers/cognito/apis/autoSignIn.ts +++ b/packages/auth/src/providers/cognito/apis/autoSignIn.ts @@ -1,6 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { autoSignInStore } from '../../../client/utils/store'; import { AuthError } from '../../../errors/AuthError'; import { AUTO_SIGN_IN_EXCEPTION } from '../../../errors/constants'; import { AutoSignInCallback } from '../../../types/models'; @@ -114,6 +115,9 @@ export function setAutoSignIn(callback: AutoSignInCallback) { * * @internal */ -export function resetAutoSignIn() { - autoSignIn = initialAutoSignIn; +export function resetAutoSignIn(resetCallback = true) { + if (resetCallback) { + autoSignIn = initialAutoSignIn; + } + autoSignInStore.dispatch({ type: 'RESET' }); } diff --git a/packages/auth/src/providers/cognito/apis/confirmSignIn.ts b/packages/auth/src/providers/cognito/apis/confirmSignIn.ts index 2b577f1a1a9..ea2582a6d47 100644 --- a/packages/auth/src/providers/cognito/apis/confirmSignIn.ts +++ b/packages/auth/src/providers/cognito/apis/confirmSignIn.ts @@ -14,7 +14,7 @@ import { cleanActiveSignInState, setActiveSignInState, signInStore, -} from '../utils/signInStore'; +} from '../../../client/utils/store'; import { AuthError } from '../../../errors/AuthError'; import { getNewDeviceMetadata, diff --git a/packages/auth/src/providers/cognito/apis/confirmSignUp.ts b/packages/auth/src/providers/cognito/apis/confirmSignUp.ts index 92adf180210..c9633531908 100644 --- a/packages/auth/src/providers/cognito/apis/confirmSignUp.ts +++ b/packages/auth/src/providers/cognito/apis/confirmSignUp.ts @@ -14,15 +14,13 @@ import { AuthValidationErrorCode } from '../../../errors/types/validation'; import { ConfirmSignUpException } from '../types/errors'; import { getRegionFromUserPoolId } from '../../../foundation/parsers'; import { AutoSignInEventData } from '../types/models'; -import { - isAutoSignInStarted, - isAutoSignInUserUsingConfirmSignUp, - setAutoSignInStarted, -} from '../utils/signUpHelpers'; import { getAuthUserAgentValue } from '../../../utils'; import { getUserContextData } from '../utils/userContextData'; import { createConfirmSignUpClient } from '../../../foundation/factories/serviceClients/cognitoIdentityProvider'; import { createCognitoUserPoolEndpointResolver } from '../factories'; +import { autoSignInStore } from '../../../client/utils/store'; + +import { resetAutoSignIn } from './autoSignIn'; /** * Confirms a new user account. @@ -65,7 +63,7 @@ export async function confirmSignUp( }), }); - await confirmSignUpClient( + const { Session: session } = await confirmSignUpClient( { region: getRegionFromUserPoolId(authConfig.userPoolId), userAgentValue: getAuthUserAgentValue(AuthAction.ConfirmSignUp), @@ -88,16 +86,20 @@ export async function confirmSignUp( signUpStep: 'DONE', }, }; + const autoSignInStoreState = autoSignInStore.getState(); if ( - !isAutoSignInStarted() || - !isAutoSignInUserUsingConfirmSignUp(username) + !autoSignInStoreState.active || + autoSignInStoreState.username !== username ) { resolve(signUpOut); + resetAutoSignIn(); return; } + autoSignInStore.dispatch({ type: 'SET_SESSION', value: session }); + const stopListener = HubInternal.listen( 'auth-internal', ({ payload }) => { @@ -109,7 +111,6 @@ export async function confirmSignUp( signUpStep: 'COMPLETE_AUTO_SIGN_IN', }, }); - setAutoSignInStarted(false); stopListener(); } }, diff --git a/packages/auth/src/providers/cognito/apis/signIn.ts b/packages/auth/src/providers/cognito/apis/signIn.ts index 10a9be79214..7fc23cfcc67 100644 --- a/packages/auth/src/providers/cognito/apis/signIn.ts +++ b/packages/auth/src/providers/cognito/apis/signIn.ts @@ -13,6 +13,8 @@ import { signInWithCustomAuth } from './signInWithCustomAuth'; import { signInWithCustomSRPAuth } from './signInWithCustomSRPAuth'; import { signInWithSRP } from './signInWithSRP'; import { signInWithUserPassword } from './signInWithUserPassword'; +import { signInWithUserAuth } from './signInWithUserAuth'; +import { resetAutoSignIn } from './autoSignIn'; /** * Signs a user in @@ -26,6 +28,12 @@ import { signInWithUserPassword } from './signInWithUserPassword'; * @throws AuthTokenConfigException - Thrown when the token provider config is invalid. */ export async function signIn(input: SignInInput): Promise { + // Here we want to reset the store but not reassign the callback. + // The callback is reset when the underlying promise resolves or rejects. + // With the advent of session based sign in, this guarantees that the signIn API initiates a new auth flow, + // regardless of whether it is called for a user currently engaged in an active auto sign in session. + resetAutoSignIn(false); + const authFlowType = input.options?.authFlowType; await assertUserNotAuthenticated(); switch (authFlowType) { @@ -37,6 +45,8 @@ export async function signIn(input: SignInInput): Promise { return signInWithCustomAuth(input); case 'CUSTOM_WITH_SRP': return signInWithCustomSRPAuth(input); + case 'USER_AUTH': + return signInWithUserAuth(input); default: return signInWithSRP(input); } diff --git a/packages/auth/src/providers/cognito/apis/signInWithCustomAuth.ts b/packages/auth/src/providers/cognito/apis/signInWithCustomAuth.ts index a666fba0acb..348c60870ae 100644 --- a/packages/auth/src/providers/cognito/apis/signInWithCustomAuth.ts +++ b/packages/auth/src/providers/cognito/apis/signInWithCustomAuth.ts @@ -24,7 +24,7 @@ import { import { cleanActiveSignInState, setActiveSignInState, -} from '../utils/signInStore'; +} from '../../../client/utils/store'; import { cacheCognitoTokens } from '../tokenProvider/cacheTokens'; import { ChallengeName, diff --git a/packages/auth/src/providers/cognito/apis/signInWithCustomSRPAuth.ts b/packages/auth/src/providers/cognito/apis/signInWithCustomSRPAuth.ts index a22f98b3804..4966cfaa9fa 100644 --- a/packages/auth/src/providers/cognito/apis/signInWithCustomSRPAuth.ts +++ b/packages/auth/src/providers/cognito/apis/signInWithCustomSRPAuth.ts @@ -26,7 +26,7 @@ import { import { cleanActiveSignInState, setActiveSignInState, -} from '../utils/signInStore'; +} from '../../../client/utils/store'; import { cacheCognitoTokens } from '../tokenProvider/cacheTokens'; import { ChallengeName, diff --git a/packages/auth/src/providers/cognito/apis/signInWithSRP.ts b/packages/auth/src/providers/cognito/apis/signInWithSRP.ts index 9bb8d4deca7..4cff40e7cd7 100644 --- a/packages/auth/src/providers/cognito/apis/signInWithSRP.ts +++ b/packages/auth/src/providers/cognito/apis/signInWithSRP.ts @@ -30,11 +30,13 @@ import { import { cleanActiveSignInState, setActiveSignInState, -} from '../utils/signInStore'; +} from '../../../client/utils/store'; import { cacheCognitoTokens } from '../tokenProvider/cacheTokens'; import { tokenOrchestrator } from '../tokenProvider'; import { dispatchSignedInHubEvent } from '../utils/dispatchSignedInHubEvent'; +import { resetAutoSignIn } from './autoSignIn'; + /** * Signs a user in * @@ -104,6 +106,8 @@ export async function signInWithSRP( await dispatchSignedInHubEvent(); + resetAutoSignIn(); + return { isSignedIn: true, nextStep: { signInStep: 'DONE' }, @@ -116,6 +120,7 @@ export async function signInWithSRP( }); } catch (error) { cleanActiveSignInState(); + resetAutoSignIn(); assertServiceError(error); const result = getSignInResultFromError(error.name); if (result) return result; diff --git a/packages/auth/src/providers/cognito/apis/signInWithUserAuth.ts b/packages/auth/src/providers/cognito/apis/signInWithUserAuth.ts new file mode 100644 index 00000000000..9ac1223a105 --- /dev/null +++ b/packages/auth/src/providers/cognito/apis/signInWithUserAuth.ts @@ -0,0 +1,141 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Amplify } from '@aws-amplify/core'; +import { assertTokenProviderConfig } from '@aws-amplify/core/internals/utils'; + +import { AuthValidationErrorCode } from '../../../errors/types/validation'; +import { assertValidationError } from '../../../errors/utils/assertValidationError'; +import { assertServiceError } from '../../../errors/utils/assertServiceError'; +import { + ChallengeName, + ChallengeParameters, +} from '../../../foundation/factories/serviceClients/cognitoIdentityProvider/types'; +import { + InitiateAuthException, + RespondToAuthChallengeException, +} from '../types/errors'; +import { + getActiveSignInUsername, + getNewDeviceMetadata, + getSignInResult, + getSignInResultFromError, +} from '../utils/signInHelpers'; +import { + CognitoAuthSignInDetails, + SignInWithUserAuthInput, + SignInWithUserAuthOutput, +} from '../types'; +import { + autoSignInStore, + cleanActiveSignInState, + setActiveSignInState, +} from '../../../client/utils/store'; +import { cacheCognitoTokens } from '../tokenProvider/cacheTokens'; +import { dispatchSignedInHubEvent } from '../utils/dispatchSignedInHubEvent'; +import { tokenOrchestrator } from '../tokenProvider'; +import { + HandleUserAuthFlowInput, + handleUserAuthFlow, +} from '../../../client/flows/userAuth/handleUserAuthFlow'; + +import { resetAutoSignIn } from './autoSignIn'; + +/** + * Signs a user in through a registered email or phone number without a password by by receiving and entering an OTP. + * + * @param input - The SignInWithUserAuthInput object + * @returns SignInWithUserAuthOutput + * @throws service: {@link InitiateAuthException }, {@link RespondToAuthChallengeException } - Cognito service errors + * thrown during the sign-in process. + * @throws validation: {@link AuthValidationErrorCode } - Validation errors thrown when either username or password -- needs to change + * are not defined. + * @throws AuthTokenConfigException - Thrown when the token provider config is invalid. + */ +export async function signInWithUserAuth( + input: SignInWithUserAuthInput, +): Promise { + const { username, password, options } = input; + const authConfig = Amplify.getConfig().Auth?.Cognito; + const signInDetails: CognitoAuthSignInDetails = { + loginId: username, + authFlowType: 'USER_AUTH', + }; + assertTokenProviderConfig(authConfig); + const clientMetaData = options?.clientMetadata; + const preferredChallenge = options?.preferredChallenge; + + assertValidationError( + !!username, + AuthValidationErrorCode.EmptySignInUsername, + ); + + try { + const handleUserAuthFlowInput: HandleUserAuthFlowInput = { + username, + config: authConfig, + tokenOrchestrator, + clientMetadata: clientMetaData, + preferredChallenge, + password, + }; + + const autoSignInStoreState = autoSignInStore.getState(); + if ( + autoSignInStoreState.active && + autoSignInStoreState.username === username + ) { + handleUserAuthFlowInput.session = autoSignInStoreState.session; + } + + const response = await handleUserAuthFlow(handleUserAuthFlowInput); + + const activeUsername = getActiveSignInUsername(username); + + setActiveSignInState({ + signInSession: response.Session, + username: activeUsername, + challengeName: response.ChallengeName as ChallengeName, + signInDetails, + }); + + if (response.AuthenticationResult) { + cleanActiveSignInState(); + await cacheCognitoTokens({ + username: activeUsername, + ...response.AuthenticationResult, + NewDeviceMetadata: await getNewDeviceMetadata({ + userPoolId: authConfig.userPoolId, + userPoolEndpoint: authConfig.userPoolEndpoint, + newDeviceMetadata: response.AuthenticationResult.NewDeviceMetadata, + accessToken: response.AuthenticationResult.AccessToken, + }), + signInDetails, + }); + await dispatchSignedInHubEvent(); + + resetAutoSignIn(); + + return { + isSignedIn: true, + nextStep: { signInStep: 'DONE' }, + }; + } + + return getSignInResult({ + challengeName: response.ChallengeName as ChallengeName, + challengeParameters: response.ChallengeParameters as ChallengeParameters, + availableChallenges: + 'AvailableChallenges' in response + ? (response.AvailableChallenges as ChallengeName[]) + : undefined, + }); + } catch (error) { + cleanActiveSignInState(); + resetAutoSignIn(); + assertServiceError(error); + const result = getSignInResultFromError(error.name); + if (result) return result; + throw error; + } +} diff --git a/packages/auth/src/providers/cognito/apis/signInWithUserPassword.ts b/packages/auth/src/providers/cognito/apis/signInWithUserPassword.ts index 071f54f8313..0cd3acd88d3 100644 --- a/packages/auth/src/providers/cognito/apis/signInWithUserPassword.ts +++ b/packages/auth/src/providers/cognito/apis/signInWithUserPassword.ts @@ -28,11 +28,13 @@ import { import { cleanActiveSignInState, setActiveSignInState, -} from '../utils/signInStore'; +} from '../../../client/utils/store'; import { cacheCognitoTokens } from '../tokenProvider/cacheTokens'; import { tokenOrchestrator } from '../tokenProvider'; import { dispatchSignedInHubEvent } from '../utils/dispatchSignedInHubEvent'; +import { resetAutoSignIn } from './autoSignIn'; + /** * Signs a user in using USER_PASSWORD_AUTH AuthFlowType * @@ -84,6 +86,7 @@ export async function signInWithUserPassword( signInDetails, }); if (AuthenticationResult) { + cleanActiveSignInState(); await cacheCognitoTokens({ ...AuthenticationResult, username: activeUsername, @@ -95,10 +98,11 @@ export async function signInWithUserPassword( }), signInDetails, }); - cleanActiveSignInState(); await dispatchSignedInHubEvent(); + resetAutoSignIn(); + return { isSignedIn: true, nextStep: { signInStep: 'DONE' }, @@ -111,6 +115,7 @@ export async function signInWithUserPassword( }); } catch (error) { cleanActiveSignInState(); + resetAutoSignIn(); assertServiceError(error); const result = getSignInResultFromError(error.name); if (result) return result; diff --git a/packages/auth/src/providers/cognito/apis/signUp.ts b/packages/auth/src/providers/cognito/apis/signUp.ts index 3ec246648f5..2861541243c 100644 --- a/packages/auth/src/providers/cognito/apis/signUp.ts +++ b/packages/auth/src/providers/cognito/apis/signUp.ts @@ -19,15 +19,13 @@ import { autoSignInUserConfirmed, autoSignInWhenUserIsConfirmedWithLink, handleCodeAutoSignIn, - isAutoSignInStarted, - isSignUpComplete, - setAutoSignInStarted, - setUsernameUsedForAutoSignIn, } from '../utils/signUpHelpers'; import { getUserContextData } from '../utils/userContextData'; import { getAuthUserAgentValue } from '../../../utils'; import { createSignUpClient } from '../../../foundation/factories/serviceClients/cognitoIdentityProvider'; import { createCognitoUserPoolEndpointResolver } from '../factories'; +import { SignUpCommandInput } from '../../../foundation/factories/serviceClients/cognitoIdentityProvider/types'; +import { autoSignInStore } from '../../../client/utils/store'; import { setAutoSignIn } from './autoSignIn'; @@ -52,10 +50,6 @@ export async function signUp(input: SignUpInput): Promise { !!username, AuthValidationErrorCode.EmptySignUpUsername, ); - assertValidationError( - !!password, - AuthValidationErrorCode.EmptySignUpPassword, - ); const signInServiceOptions = typeof autoSignIn !== 'boolean' ? autoSignIn : undefined; @@ -68,10 +62,6 @@ export async function signUp(input: SignUpInput): Promise { if (signInServiceOptions?.authFlowType !== 'CUSTOM_WITHOUT_SRP') { signInInput.password = password; } - if (signInServiceOptions || autoSignIn === true) { - setUsernameUsedForAutoSignIn(username); - setAutoSignInStarted(true); - } const { userPoolId, userPoolClientId, userPoolEndpoint } = authConfig; const signUpClient = createSignUpClient({ @@ -79,87 +69,106 @@ export async function signUp(input: SignUpInput): Promise { endpointOverride: userPoolEndpoint, }), }); - const clientOutput = await signUpClient( + + const signUpClientInput: SignUpCommandInput = { + Username: username, + Password: undefined, + UserAttributes: + options?.userAttributes && toAttributeType(options?.userAttributes), + ClientMetadata: clientMetadata, + ValidationData: validationData && toAttributeType(validationData), + ClientId: userPoolClientId, + UserContextData: getUserContextData({ + username, + userPoolId, + userPoolClientId, + }), + }; + + if (password) { + signUpClientInput.Password = password; + } + + const { + UserSub: userId, + CodeDeliveryDetails: cdd, + UserConfirmed: userConfirmed, + Session: session, + } = await signUpClient( { region: getRegionFromUserPoolId(userPoolId), userAgentValue: getAuthUserAgentValue(AuthAction.SignUp), }, - { - Username: username, - Password: password, - UserAttributes: - options?.userAttributes && toAttributeType(options?.userAttributes), - ClientMetadata: clientMetadata, - ValidationData: validationData && toAttributeType(validationData), - ClientId: userPoolClientId, - UserContextData: getUserContextData({ - username, - userPoolId, - userPoolClientId, - }), - }, + signUpClientInput, ); - const { UserSub, CodeDeliveryDetails } = clientOutput; - if (isSignUpComplete(clientOutput) && isAutoSignInStarted()) { - setAutoSignIn(autoSignInUserConfirmed(signInInput)); + if (signInServiceOptions || autoSignIn === true) { + autoSignInStore.dispatch({ type: 'START' }); + autoSignInStore.dispatch({ type: 'SET_USERNAME', value: username }); + autoSignInStore.dispatch({ type: 'SET_SESSION', value: session }); + } + + const codeDeliveryDetails = { + destination: cdd?.Destination, + deliveryMedium: cdd?.DeliveryMedium as AuthDeliveryMedium, + attributeName: cdd?.AttributeName as AuthVerifiableAttributeKey, + }; + + const isSignUpComplete = !!userConfirmed; + const isAutoSignInStarted = autoSignInStore.getState().active; + + // Sign Up Complete + // No Confirm Sign In Step Required + if (isSignUpComplete) { + if (isAutoSignInStarted) { + setAutoSignIn(autoSignInUserConfirmed(signInInput)); + + return { + isSignUpComplete: true, + nextStep: { + signUpStep: 'COMPLETE_AUTO_SIGN_IN', + }, + userId, + }; + } - return { - isSignUpComplete: true, - nextStep: { - signUpStep: 'COMPLETE_AUTO_SIGN_IN', - }, - userId: UserSub, - }; - } else if (isSignUpComplete(clientOutput) && !isAutoSignInStarted()) { return { isSignUpComplete: true, nextStep: { signUpStep: 'DONE', }, - userId: UserSub, + userId, }; - } else if ( - !isSignUpComplete(clientOutput) && - isAutoSignInStarted() && - signUpVerificationMethod === 'code' - ) { - handleCodeAutoSignIn(signInInput); - } else if ( - !isSignUpComplete(clientOutput) && - isAutoSignInStarted() && - signUpVerificationMethod === 'link' - ) { - setAutoSignIn(autoSignInWhenUserIsConfirmedWithLink(signInInput)); + } - return { - isSignUpComplete: false, - nextStep: { - signUpStep: 'COMPLETE_AUTO_SIGN_IN', - codeDeliveryDetails: { - deliveryMedium: - CodeDeliveryDetails?.DeliveryMedium as AuthDeliveryMedium, - destination: CodeDeliveryDetails?.Destination as string, - attributeName: - CodeDeliveryDetails?.AttributeName as AuthVerifiableAttributeKey, + // Sign Up Not Complete + // Confirm Sign Up Step Required + if (isAutoSignInStarted) { + // Confirmation Via Link Occurs In Separate Context + // AutoSignIn Fn Will Initiate Polling Once Executed + if (signUpVerificationMethod === 'link') { + setAutoSignIn(autoSignInWhenUserIsConfirmedWithLink(signInInput)); + + return { + isSignUpComplete: false, + nextStep: { + signUpStep: 'COMPLETE_AUTO_SIGN_IN', + codeDeliveryDetails, }, - }, - userId: UserSub, - }; + userId, + }; + } + // Confirmation Via Code Occurs In Same Context + // AutoSignIn Next Step Will Be Returned From Confirm Sign Up + handleCodeAutoSignIn(signInInput); } return { isSignUpComplete: false, nextStep: { signUpStep: 'CONFIRM_SIGN_UP', - codeDeliveryDetails: { - deliveryMedium: - CodeDeliveryDetails?.DeliveryMedium as AuthDeliveryMedium, - destination: CodeDeliveryDetails?.Destination as string, - attributeName: - CodeDeliveryDetails?.AttributeName as AuthVerifiableAttributeKey, - }, + codeDeliveryDetails, }, - userId: UserSub, + userId, }; } diff --git a/packages/auth/src/providers/cognito/types/index.ts b/packages/auth/src/providers/cognito/types/index.ts index 0b72451e925..eda7dfb1ee4 100644 --- a/packages/auth/src/providers/cognito/types/index.ts +++ b/packages/auth/src/providers/cognito/types/index.ts @@ -39,6 +39,7 @@ export { SignInWithCustomAuthInput, SignInWithCustomSRPAuthInput, SignInWithSRPInput, + SignInWithUserAuthInput, SignInWithUserPasswordInput, SignInWithRedirectInput, SignOutInput, @@ -65,6 +66,7 @@ export { SignInOutput, SignInWithCustomAuthOutput, SignInWithSRPOutput, + SignInWithUserAuthOutput, SignInWithUserPasswordOutput, SignInWithCustomSRPAuthOutput, SignUpOutput, diff --git a/packages/auth/src/providers/cognito/types/inputs.ts b/packages/auth/src/providers/cognito/types/inputs.ts index 13952bf53e9..57aef3cb353 100644 --- a/packages/auth/src/providers/cognito/types/inputs.ts +++ b/packages/auth/src/providers/cognito/types/inputs.ts @@ -92,6 +92,11 @@ export type SignInWithCustomSRPAuthInput = AuthSignInInput; */ export type SignInWithSRPInput = AuthSignInInput; +/** + * Input type for Cognito signInWithUserAuth API. + */ +export type SignInWithUserAuthInput = AuthSignInInput; + /** * Input type for Cognito signInWithUserPasswordInput API. */ diff --git a/packages/auth/src/providers/cognito/types/models.ts b/packages/auth/src/providers/cognito/types/models.ts index 3341d439918..a65d738127d 100644 --- a/packages/auth/src/providers/cognito/types/models.ts +++ b/packages/auth/src/providers/cognito/types/models.ts @@ -18,8 +18,11 @@ import { SignUpOutput } from './outputs'; /** * Cognito supported AuthFlowTypes that may be passed as part of the Sign In request. + * USER_AUTH is a superset that can handle both USER_SRP_AUTH and USER_PASSWORD_AUTH, + * providing flexibility for future authentication methods. */ export type AuthFlowType = + | 'USER_AUTH' | 'USER_SRP_AUTH' | 'CUSTOM_WITH_SRP' | 'CUSTOM_WITHOUT_SRP' @@ -38,6 +41,16 @@ export const cognitoHostedUIIdentityProviderMap: Record = */ export type ClientMetadata = Record; +/** + * Allowed values for preferredChallenge + */ +export type AuthFactorType = + | 'WEB_AUTHN' + | 'EMAIL_OTP' + | 'SMS_OTP' + | 'PASSWORD' + | 'PASSWORD_SRP'; + /** * The user attribute types available for Cognito. */ diff --git a/packages/auth/src/providers/cognito/types/options.ts b/packages/auth/src/providers/cognito/types/options.ts index 52b4536297f..ae04219cccb 100644 --- a/packages/auth/src/providers/cognito/types/options.ts +++ b/packages/auth/src/providers/cognito/types/options.ts @@ -8,7 +8,12 @@ import { AuthUserAttributes, } from '../../../types'; -import { AuthFlowType, ClientMetadata, ValidationData } from './models'; +import { + AuthFactorType, + AuthFlowType, + ClientMetadata, + ValidationData, +} from './models'; /** * Options specific to Cognito Confirm Reset Password. @@ -37,6 +42,7 @@ export type ResetPasswordOptions = AuthServiceOptions & { export type SignInOptions = AuthServiceOptions & { authFlowType?: AuthFlowType; clientMetadata?: ClientMetadata; + preferredChallenge?: AuthFactorType; }; /** diff --git a/packages/auth/src/providers/cognito/types/outputs.ts b/packages/auth/src/providers/cognito/types/outputs.ts index 595b9009998..381d4de167e 100644 --- a/packages/auth/src/providers/cognito/types/outputs.ts +++ b/packages/auth/src/providers/cognito/types/outputs.ts @@ -73,6 +73,11 @@ export type SignInWithCustomAuthOutput = AuthSignInOutput; */ export type SignInWithSRPOutput = AuthSignInOutput; +/** + * Output type for Cognito signInWithUserAuth API. + */ +export type SignInWithUserAuthOutput = AuthSignInOutput; + /** * Output type for Cognito signInWithUserPassword API. */ diff --git a/packages/auth/src/providers/cognito/utils/signInHelpers.ts b/packages/auth/src/providers/cognito/utils/signInHelpers.ts index d3bce2aa6f2..d4123c475c1 100644 --- a/packages/auth/src/providers/cognito/utils/signInHelpers.ts +++ b/packages/auth/src/providers/cognito/utils/signInHelpers.ts @@ -50,8 +50,13 @@ import { RespondToAuthChallengeCommandOutput, } from '../../../foundation/factories/serviceClients/cognitoIdentityProvider/types'; import { getRegionFromUserPoolId } from '../../../foundation/parsers'; +import { handleWebAuthnSignInResult } from '../../../client/flows/userAuth/handleWebAuthnSignInResult'; +import { handlePasswordSRP } from '../../../client/flows/shared/handlePasswordSRP'; +import { initiateSelectedChallenge } from '../../../client/flows/userAuth/handleSelectChallenge'; +import { handleSelectChallengeWithPassword } from '../../../client/flows/userAuth/handleSelectChallengeWithPassword'; +import { handleSelectChallengeWithPasswordSRP } from '../../../client/flows/userAuth/handleSelectChallengeWithPasswordSRP'; +import { signInStore } from '../../../client/utils/store'; -import { signInStore } from './signInStore'; import { assertDeviceMetadata } from './types'; import { getAuthenticationHelper, @@ -433,60 +438,14 @@ export async function handleUserSRPAuthFlow( config: CognitoUserPoolConfig, tokenOrchestrator: AuthTokenOrchestrator, ): Promise { - const { userPoolId, userPoolClientId, userPoolEndpoint } = config; - const userPoolName = userPoolId?.split('_')[1] || ''; - const authenticationHelper = await getAuthenticationHelper(userPoolName); - - const authParameters: Record = { - USERNAME: username, - SRP_A: authenticationHelper.A.toString(16), - }; - - const UserContextData = getUserContextData({ + return handlePasswordSRP({ username, - userPoolId, - userPoolClientId, - }); - - const jsonReq: InitiateAuthCommandInput = { - AuthFlow: 'USER_SRP_AUTH', - AuthParameters: authParameters, - ClientMetadata: clientMetadata, - ClientId: userPoolClientId, - UserContextData, - }; - - const initiateAuth = createInitiateAuthClient({ - endpointResolver: createCognitoUserPoolEndpointResolver({ - endpointOverride: userPoolEndpoint, - }), - }); - - const resp = await initiateAuth( - { - region: getRegionFromUserPoolId(userPoolId), - userAgentValue: getAuthUserAgentValue(AuthAction.SignIn), - }, - jsonReq, - ); - const { ChallengeParameters: challengeParameters, Session: session } = resp; - const activeUsername = challengeParameters?.USERNAME ?? username; - setActiveSignInUsername(activeUsername); - - return retryOnResourceNotFoundException( - handlePasswordVerifierChallenge, - [ - password, - challengeParameters as ChallengeParameters, - clientMetadata, - session, - authenticationHelper, - config, - tokenOrchestrator, - ], - activeUsername, + password, + clientMetadata, + config, tokenOrchestrator, - ); + authFlow: 'USER_SRP_AUTH', + }); } export async function handleCustomAuthFlowWithoutSRP( @@ -812,8 +771,9 @@ export async function handlePasswordVerifierChallenge( export async function getSignInResult(params: { challengeName: ChallengeName; challengeParameters: ChallengeParameters; + availableChallenges?: ChallengeName[]; }): Promise { - const { challengeName, challengeParameters } = params; + const { challengeName, challengeParameters, availableChallenges } = params; const authConfig = Amplify.getConfig().Auth?.Cognito; assertTokenProviderConfig(authConfig); @@ -909,6 +869,7 @@ export async function getSignInResult(params: { ), }, }; + case 'SMS_OTP': case 'SMS_MFA': return { isSignedIn: false, @@ -940,6 +901,25 @@ export async function getSignInResult(params: { }, }, }; + + case 'WEB_AUTHN': + return handleWebAuthnSignInResult(challengeParameters); + case 'PASSWORD': + case 'PASSWORD_SRP': + return { + isSignedIn: false, + nextStep: { + signInStep: 'CONFIRM_SIGN_IN_WITH_PASSWORD', + }, + }; + case 'SELECT_CHALLENGE': + return { + isSignedIn: false, + nextStep: { + signInStep: 'CONTINUE_SIGN_IN_WITH_FIRST_FACTOR_SELECTION', + availableChallenges, + }, + }; case 'ADMIN_NO_SRP_AUTH': break; case 'DEVICE_PASSWORD_VERIFIER': @@ -1027,6 +1007,26 @@ export async function handleChallengeName( const deviceName = options?.friendlyDeviceName; switch (challengeName) { + case 'WEB_AUTHN': + case 'SELECT_CHALLENGE': + if ( + challengeResponse === 'PASSWORD_SRP' || + challengeResponse === 'PASSWORD' + ) { + return { + ChallengeName: challengeResponse, + Session: session, + $metadata: {}, + }; + } + + return initiateSelectedChallenge({ + username, + session, + selectedChallenge: challengeResponse, + config, + clientMetadata, + }); case 'SELECT_MFA_TYPE': return handleSelectMFATypeChallenge({ challengeResponse, @@ -1071,6 +1071,7 @@ export async function handleChallengeName( ); case 'SMS_MFA': case 'SOFTWARE_TOKEN_MFA': + case 'SMS_OTP': case 'EMAIL_OTP': return handleMFAChallenge({ challengeName, @@ -1080,6 +1081,23 @@ export async function handleChallengeName( username, config, }); + case 'PASSWORD': + return handleSelectChallengeWithPassword( + username, + challengeResponse, + clientMetadata, + config, + session, + ); + case 'PASSWORD_SRP': + return handleSelectChallengeWithPasswordSRP( + username, + challengeResponse, // This is the actual password + clientMetadata, + config, + session, + tokenOrchestrator, + ); } // TODO: remove this error message for production apps throw new AuthError({ @@ -1260,7 +1278,7 @@ export async function handleMFAChallenge({ }: HandleAuthChallengeRequest & { challengeName: Extract< ChallengeName, - 'EMAIL_OTP' | 'SMS_MFA' | 'SOFTWARE_TOKEN_MFA' + 'EMAIL_OTP' | 'SMS_MFA' | 'SOFTWARE_TOKEN_MFA' | 'SMS_OTP' >; }) { const { userPoolId, userPoolClientId, userPoolEndpoint } = config; @@ -1277,6 +1295,10 @@ export async function handleMFAChallenge({ challengeResponses.SMS_MFA_CODE = challengeResponse; } + if (challengeName === 'SMS_OTP') { + challengeResponses.SMS_OTP_CODE = challengeResponse; + } + if (challengeName === 'SOFTWARE_TOKEN_MFA') { challengeResponses.SOFTWARE_TOKEN_MFA_CODE = challengeResponse; } diff --git a/packages/auth/src/providers/cognito/utils/signUpHelpers.ts b/packages/auth/src/providers/cognito/utils/signUpHelpers.ts index 8ab2943ce2a..9bebcf4be82 100644 --- a/packages/auth/src/providers/cognito/utils/signUpHelpers.ts +++ b/packages/auth/src/providers/cognito/utils/signUpHelpers.ts @@ -10,7 +10,7 @@ import { AutoSignInCallback } from '../../../types/models'; import { AuthError } from '../../../errors/AuthError'; import { resetAutoSignIn, setAutoSignIn } from '../apis/autoSignIn'; import { AUTO_SIGN_IN_EXCEPTION } from '../../../errors/constants'; -import { SignUpCommandOutput } from '../../../foundation/factories/serviceClients/cognitoIdentityProvider/types'; +import { signInWithUserAuth } from '../apis/signInWithUserAuth'; const MAX_AUTOSIGNIN_POLLING_MS = 3 * 60 * 1000; @@ -36,7 +36,6 @@ export function handleCodeAutoSignIn(signInInput: SignInInput) { // This will stop the listener if confirmSignUp is not resolved. const timeOutId = setTimeout(() => { stopHubListener(); - setAutoSignInStarted(false); clearTimeout(timeOutId); resetAutoSignIn(); }, MAX_AUTOSIGNIN_POLLING_MS); @@ -74,7 +73,6 @@ function handleAutoSignInWithLink( const maxTime = MAX_AUTOSIGNIN_POLLING_MS; if (elapsedTime > maxTime) { clearInterval(autoSignInPollingIntervalId); - setAutoSignInStarted(false); reject( new AuthError({ name: AUTO_SIGN_IN_EXCEPTION, @@ -90,12 +88,10 @@ function handleAutoSignInWithLink( if (signInOutput.nextStep.signInStep !== 'CONFIRM_SIGN_UP') { resolve(signInOutput); clearInterval(autoSignInPollingIntervalId); - setAutoSignInStarted(false); resetAutoSignIn(); } } catch (error) { clearInterval(autoSignInPollingIntervalId); - setAutoSignInStarted(false); reject(error); resetAutoSignIn(); } @@ -108,31 +104,6 @@ const debouncedAutoSignWithCodeOrUserConfirmed = debounce( 300, ); -let autoSignInStarted = false; - -let usernameUsedForAutoSignIn: string | undefined; - -export function setUsernameUsedForAutoSignIn(username?: string) { - usernameUsedForAutoSignIn = username; -} -export function isAutoSignInUserUsingConfirmSignUp(username: string) { - return usernameUsedForAutoSignIn === username; -} - -export function isAutoSignInStarted(): boolean { - return autoSignInStarted; -} -export function setAutoSignInStarted(value: boolean) { - if (value === false) { - setUsernameUsedForAutoSignIn(undefined); - } - autoSignInStarted = value; -} - -export function isSignUpComplete(output: SignUpCommandOutput): boolean { - return !!output.UserConfirmed; -} - export function autoSignInWhenUserIsConfirmedWithLink( signInInput: SignInInput, ): AutoSignInCallback { @@ -148,7 +119,11 @@ async function handleAutoSignInWithCodeOrUserConfirmed( reject: (reason?: any) => void, ) { try { - const output = await signIn(signInInput); + const output = + signInInput?.options?.authFlowType === 'USER_AUTH' + ? await signInWithUserAuth(signInInput) + : await signIn(signInInput); + resolve(output); resetAutoSignIn(); } catch (error) { diff --git a/packages/auth/src/types/inputs.ts b/packages/auth/src/types/inputs.ts index 6e152cdc1e5..c2947b4650a 100644 --- a/packages/auth/src/types/inputs.ts +++ b/packages/auth/src/types/inputs.ts @@ -75,7 +75,7 @@ export interface AuthSignInWithRedirectInput { * The parameters for constructing a Sign Up input. * * @param username - a standard username, potentially an email/phone number - * @param password - the user's password + * @param password - the user's password, may be required depending on your Cognito User Pool configuration * @param options - optional parameters for the Sign Up process, including user attributes */ export interface AuthSignUpInput< @@ -83,7 +83,7 @@ export interface AuthSignUpInput< AuthSignUpOptions = AuthSignUpOptions, > { username: string; - password: string; + password?: string; options?: ServiceOptions; } diff --git a/packages/auth/src/types/models.ts b/packages/auth/src/types/models.ts index e08b7bce5f9..1655de572e8 100644 --- a/packages/auth/src/types/models.ts +++ b/packages/auth/src/types/models.ts @@ -3,6 +3,7 @@ import { AuthStandardAttributeKey } from '@aws-amplify/core/internals/utils'; +import { ChallengeName } from '../foundation/factories/serviceClients/cognitoIdentityProvider/types'; import { SignInOutput } from '../providers/cognito'; /** @@ -217,6 +218,16 @@ export interface DoneSignInStep { signInStep: 'DONE'; } +// New interfaces for USER_AUTH flow +export interface ContinueSignInWithFirstFactorSelection { + signInStep: 'CONTINUE_SIGN_IN_WITH_FIRST_FACTOR_SELECTION'; + availableChallenges?: ChallengeName[]; +} + +export interface ConfirmSignInWithPassword { + signInStep: 'CONFIRM_SIGN_IN_WITH_PASSWORD'; +} + export type AuthNextSignInStep< UserAttributeKey extends AuthUserAttributeKey = AuthUserAttributeKey, > = @@ -229,6 +240,8 @@ export type AuthNextSignInStep< | ContinueSignInWithTOTPSetup | ContinueSignInWithEmailSetup | ContinueSignInWithMFASetupSelection + | ContinueSignInWithFirstFactorSelection + | ConfirmSignInWithPassword | ConfirmSignUpStep | ResetPasswordStep | DoneSignInStep; diff --git a/packages/aws-amplify/__tests__/exports.test.ts b/packages/aws-amplify/__tests__/exports.test.ts index 16218f13871..0225e72d868 100644 --- a/packages/aws-amplify/__tests__/exports.test.ts +++ b/packages/aws-amplify/__tests__/exports.test.ts @@ -180,6 +180,9 @@ describe('aws-amplify Exports', () => { 'autoSignIn', 'fetchAuthSession', 'decodeJWT', + 'associateWebAuthnCredential', + 'listWebAuthnCredentials', + 'deleteWebAuthnCredential', ].sort(), ); }); diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index a8567555ee8..44303b6c0e8 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.59 kB" + "limit": "17.60 kB" }, { "name": "[Analytics] record (Kinesis)", @@ -317,7 +317,7 @@ "name": "[Analytics] identifyUser (Pinpoint)", "path": "./dist/esm/analytics/index.mjs", "import": "{ identifyUser }", - "limit": "16.09 kB" + "limit": "16.10 kB" }, { "name": "[Analytics] enable", @@ -335,7 +335,7 @@ "name": "[API] generateClient (AppSync)", "path": "./dist/esm/api/index.mjs", "import": "{ generateClient }", - "limit": "44.21 kB" + "limit": "44.23 kB" }, { "name": "[API] REST API handlers", @@ -353,13 +353,13 @@ "name": "[Auth] resetPassword (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ resetPassword }", - "limit": "12.66 kB" + "limit": "12.68 kB" }, { "name": "[Auth] confirmResetPassword (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ confirmResetPassword }", - "limit": "12.60 kB" + "limit": "12.63 kB" }, { "name": "[Auth] signIn (Cognito)", @@ -371,7 +371,7 @@ "name": "[Auth] resendSignUpCode (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ resendSignUpCode }", - "limit": "12.61 kB" + "limit": "12.64 kB" }, { "name": "[Auth] confirmSignUp (Cognito)", @@ -389,25 +389,25 @@ "name": "[Auth] updateMFAPreference (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ updateMFAPreference }", - "limit": "12.07 kB" + "limit": "12.11 kB" }, { "name": "[Auth] fetchMFAPreference (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ fetchMFAPreference }", - "limit": "12.1 kB" + "limit": "12.14 kB" }, { "name": "[Auth] verifyTOTPSetup (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ verifyTOTPSetup }", - "limit": "12.94 kB" + "limit": "12.99 kB" }, { "name": "[Auth] updatePassword (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ updatePassword }", - "limit": "12.96 kB" + "limit": "12.99 kB" }, { "name": "[Auth] setUpTOTP (Cognito)", @@ -419,7 +419,7 @@ "name": "[Auth] updateUserAttributes (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ updateUserAttributes }", - "limit": "12.19 kB" + "limit": "12.21 kB" }, { "name": "[Auth] getCurrentUser (Cognito)", @@ -431,7 +431,7 @@ "name": "[Auth] confirmUserAttribute (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ confirmUserAttribute }", - "limit": "12.93 kB" + "limit": "12.98 kB" }, { "name": "[Auth] signInWithRedirect (Cognito)", @@ -443,13 +443,13 @@ "name": "[Auth] fetchUserAttributes (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ fetchUserAttributes }", - "limit": "12.01 kB" + "limit": "12.03 kB" }, { "name": "[Auth] Basic Auth Flow (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ signIn, signOut, fetchAuthSession, confirmSignIn }", - "limit": "30.23 kB" + "limit": "30.56 kB" }, { "name": "[Auth] OAuth Auth Flow (Cognito)", @@ -457,6 +457,24 @@ "import": "{ signInWithRedirect, signOut, fetchAuthSession }", "limit": "21.64 kB" }, + { + "name": "[Auth] Associate WebAuthN Credential (Cognito)", + "path": "./dist/esm/auth/index.mjs", + "import": "{ associateWebAuthnCredential }", + "limit": "13.55 kB" + }, + { + "name": "[Auth] List WebAuthN Credentials (Cognito)", + "path": "./dist/esm/auth/index.mjs", + "import": "{ listWebAuthnCredentials }", + "limit": "12.14 kB" + }, + { + "name": "[Auth] Delete WebAuthN Credential (Cognito)", + "path": "./dist/esm/auth/index.mjs", + "import": "{ deleteWebAuthnCredential }", + "limit": "12.01 kB" + }, { "name": "[Storage] copy (S3)", "path": "./dist/esm/storage/index.mjs", @@ -485,7 +503,7 @@ "name": "[Storage] list (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ list }", - "limit": "16.69 kB" + "limit": "16.74 kB" }, { "name": "[Storage] remove (S3)", diff --git a/packages/core/__tests__/utils/convert/base64Decoder.test.ts b/packages/core/__tests__/utils/convert/base64Decoder.test.ts index 675db4e09a4..088d44a6f00 100644 --- a/packages/core/__tests__/utils/convert/base64Decoder.test.ts +++ b/packages/core/__tests__/utils/convert/base64Decoder.test.ts @@ -26,4 +26,14 @@ describe('base64Decoder (non-native)', () => { expect(mockGetAtob).toHaveBeenCalled(); expect(mockAtob).toHaveBeenCalledWith('test'); }); + + it('makes the result url safe if urlSafe is true', () => { + const mockInput = 'test-test_test'; + const mockOutput = 'test+test/test'; + + base64Decoder.convert(mockInput, { urlSafe: true }); + + expect(mockGetAtob).toHaveBeenCalled(); + expect(mockAtob).toHaveBeenCalledWith(mockOutput); + }); }); diff --git a/packages/core/src/Platform/types.ts b/packages/core/src/Platform/types.ts index 488c37efe8b..fd6057c9704 100644 --- a/packages/core/src/Platform/types.ts +++ b/packages/core/src/Platform/types.ts @@ -90,6 +90,10 @@ export enum AuthAction { FetchDevices = '34', SendUserAttributeVerificationCode = '35', SignInWithRedirect = '36', + StartWebAuthnRegistration = '37', + CompleteWebAuthnRegistration = '38', + ListWebAuthnCredentials = '39', + DeleteWebAuthnCredential = '40', } export enum DataStoreAction { Subscribe = '1', diff --git a/packages/core/src/singleton/Auth/types.ts b/packages/core/src/singleton/Auth/types.ts index 987ac966a59..03265710990 100644 --- a/packages/core/src/singleton/Auth/types.ts +++ b/packages/core/src/singleton/Auth/types.ts @@ -259,6 +259,7 @@ interface AWSAuthSignInDetails { * @deprecated */ type AuthFlowType = + | 'USER_AUTH' | 'USER_SRP_AUTH' | 'CUSTOM_WITH_SRP' | 'CUSTOM_WITHOUT_SRP' diff --git a/packages/core/src/utils/convert/base64/base64Decoder.ts b/packages/core/src/utils/convert/base64/base64Decoder.ts index 216e5fc5e5e..a18a0fd4c82 100644 --- a/packages/core/src/utils/convert/base64/base64Decoder.ts +++ b/packages/core/src/utils/convert/base64/base64Decoder.ts @@ -5,7 +5,15 @@ import { getAtob } from '../../globalHelpers'; import { Base64Decoder } from '../types'; export const base64Decoder: Base64Decoder = { - convert(input) { - return getAtob()(input); + convert(input, options) { + let inputStr = input; + + // urlSafe character replacement options conform to the base64 url spec + // https://datatracker.ietf.org/doc/html/rfc4648#page-7 + if (options?.urlSafe) { + inputStr = inputStr.replace(/-/g, '+').replace(/_/g, '/'); + } + + return getAtob()(inputStr); }, }; diff --git a/packages/core/src/utils/convert/types.ts b/packages/core/src/utils/convert/types.ts index 7a1c4d4d86d..1582aa1cb77 100644 --- a/packages/core/src/utils/convert/types.ts +++ b/packages/core/src/utils/convert/types.ts @@ -1,11 +1,15 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -export interface Base64EncoderConvertOptions { +interface Base64ConvertOptions { urlSafe: boolean; +} +export interface Base64EncoderConvertOptions extends Base64ConvertOptions { skipPadding?: boolean; } +export type Base64DecoderConvertOptions = Base64ConvertOptions; + export interface Base64Encoder { convert( input: Uint8Array | string, @@ -14,5 +18,5 @@ export interface Base64Encoder { } export interface Base64Decoder { - convert(input: string): string; + convert(input: string, options?: Base64DecoderConvertOptions): string; } diff --git a/scripts/dts-bundler/s3-control.d.ts b/scripts/dts-bundler/s3-control.d.ts index e6d727c5fba..1c5443611a7 100644 --- a/scripts/dts-bundler/s3-control.d.ts +++ b/scripts/dts-bundler/s3-control.d.ts @@ -1,8 +1,8 @@ import { - ListCallerAccessGrantsCommandInput, - ListCallerAccessGrantsCommandOutput, GetDataAccessCommandInput, GetDataAccessCommandOutput, + ListCallerAccessGrantsCommandInput, + ListCallerAccessGrantsCommandOutput, } from '@aws-sdk/client-s3-control'; export {