From 3dbc288a6379781f8c90d322b5bb133f29ac24c5 Mon Sep 17 00:00:00 2001 From: James Jarvis Date: Mon, 7 Oct 2024 11:11:03 -0700 Subject: [PATCH 01/25] chore: disable dependency review --- .github/workflows/pr.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index f7e0982cc9c..72e0b51af1d 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -36,9 +36,9 @@ jobs: tsc-compliance-test: needs: prebuild uses: ./.github/workflows/callable-test-tsc-compliance.yml - dependency-review: - needs: prebuild - uses: ./.github/workflows/callable-dependency-review.yml + # dependency-review: + # needs: prebuild + # uses: ./.github/workflows/callable-dependency-review.yml all-unit-tests-pass: name: Unit and Bundle tests have passed needs: @@ -47,7 +47,7 @@ jobs: - license-test - github-actions-test - tsc-compliance-test - - dependency-review + # - dependency-review runs-on: ubuntu-latest if: success() # only run when all checks have passed # store success output flag for ci job From a39c93ffc1fca7b70323442db686c3ce2dc2a010 Mon Sep 17 00:00:00 2001 From: James Jarvis Date: Mon, 30 Sep 2024 17:21:48 -0700 Subject: [PATCH 02/25] feat(auth): associateWebAuthnCredential API (#1) --- .../apis/associateWebAuthnCredential.test.ts | 177 ++++++++++++++++++ .../__tests__/client/utils/passkey.test.ts | 47 +++++ .../foundation/convert/base64url.test.ts | 32 ++++ packages/auth/__tests__/mockData.ts | 94 ++++++++++ .../apis/associateWebAuthnCredential.ts | 105 +++++++++++ packages/auth/src/client/apis/index.ts | 4 + packages/auth/src/client/types/index.ts | 4 + packages/auth/src/client/types/outputs.ts | 9 + packages/auth/src/client/utils/index.ts | 4 + .../auth/src/client/utils/passkey/errors.ts | 46 +++++ .../passkey/getIsPasskeySupported.native.ts | 8 + .../utils/passkey/getIsPasskeySupported.ts | 13 ++ .../auth/src/client/utils/passkey/index.ts | 4 + .../utils/passkey/registerPasskey.native.ts | 8 + .../client/utils/passkey/registerPasskey.ts | 31 +++ .../auth/src/client/utils/passkey/serde.ts | 65 +++++++ .../auth/src/client/utils/passkey/types.ts | 81 ++++++++ .../convertArrayBufferToBase64Url.ts | 18 ++ .../convertBase64UrlToArrayBuffer.ts | 18 ++ .../src/foundation/convert/base64url/index.ts | 5 + packages/auth/src/foundation/convert/index.ts | 7 + ...ateGetWebAuthnRegistrationOptionsClient.ts | 31 +++ ...eVerifyWebAuthnRegistrationResultClient.ts | 31 +++ .../cognitoIdentityProvider/index.ts | 2 + .../shared/serde/createUserPoolSerializer.ts | 4 +- .../cognitoIdentityProvider/types/errors.ts | 25 +++ .../cognitoIdentityProvider/types/index.ts | 2 + .../cognitoIdentityProvider/types/sdk.ts | 33 ++++ packages/auth/src/index.ts | 3 + .../aws-amplify/__tests__/exports.test.ts | 1 + packages/aws-amplify/package.json | 6 + .../utils/convert/base64Decoder.test.ts | 10 + packages/core/src/Platform/types.ts | 2 + .../src/utils/convert/base64/base64Decoder.ts | 12 +- packages/core/src/utils/convert/types.ts | 8 +- 35 files changed, 945 insertions(+), 5 deletions(-) create mode 100644 packages/auth/__tests__/client/apis/associateWebAuthnCredential.test.ts create mode 100644 packages/auth/__tests__/client/utils/passkey.test.ts create mode 100644 packages/auth/__tests__/foundation/convert/base64url.test.ts create mode 100644 packages/auth/src/client/apis/associateWebAuthnCredential.ts create mode 100644 packages/auth/src/client/apis/index.ts create mode 100644 packages/auth/src/client/types/index.ts create mode 100644 packages/auth/src/client/types/outputs.ts create mode 100644 packages/auth/src/client/utils/index.ts create mode 100644 packages/auth/src/client/utils/passkey/errors.ts create mode 100644 packages/auth/src/client/utils/passkey/getIsPasskeySupported.native.ts create mode 100644 packages/auth/src/client/utils/passkey/getIsPasskeySupported.ts create mode 100644 packages/auth/src/client/utils/passkey/index.ts create mode 100644 packages/auth/src/client/utils/passkey/registerPasskey.native.ts create mode 100644 packages/auth/src/client/utils/passkey/registerPasskey.ts create mode 100644 packages/auth/src/client/utils/passkey/serde.ts create mode 100644 packages/auth/src/client/utils/passkey/types.ts create mode 100644 packages/auth/src/foundation/convert/base64url/convertArrayBufferToBase64Url.ts create mode 100644 packages/auth/src/foundation/convert/base64url/convertBase64UrlToArrayBuffer.ts create mode 100644 packages/auth/src/foundation/convert/base64url/index.ts create mode 100644 packages/auth/src/foundation/convert/index.ts create mode 100644 packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createGetWebAuthnRegistrationOptionsClient.ts create mode 100644 packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createVerifyWebAuthnRegistrationResultClient.ts create mode 100644 packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/errors.ts 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..b5b25047998 --- /dev/null +++ b/packages/auth/__tests__/client/apis/associateWebAuthnCredential.test.ts @@ -0,0 +1,177 @@ +import { Amplify, fetchAuthSession } from '@aws-amplify/core'; +import { decodeJWT } from '@aws-amplify/core/internals/utils'; + +import { + createGetWebAuthnRegistrationOptionsClient, + createVerifyWebAuthnRegistrationResultClient, +} 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 { serializePkcToJson } 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'; + +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'); + +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 mockGetWebAuthnRegistrationOptions = jest.fn(); + const mockCreateGetWebAuthnRegistrationOptionsClient = jest.mocked( + createGetWebAuthnRegistrationOptionsClient, + ); + + const mockVerifyWebAuthnRegistrationResult = jest.fn(); + const mockCreateVerifyWebAuthnRegistrationResultClient = jest.mocked( + createVerifyWebAuthnRegistrationResultClient, + ); + + beforeAll(() => { + setUpGetConfig(Amplify); + mockFetchAuthSession.mockResolvedValue({ + tokens: { accessToken: decodeJWT(mockAccessToken) }, + }); + mockCreateGetWebAuthnRegistrationOptionsClient.mockReturnValue( + mockGetWebAuthnRegistrationOptions, + ); + mockCreateVerifyWebAuthnRegistrationResultClient.mockReturnValue( + mockVerifyWebAuthnRegistrationResult, + ); + mockVerifyWebAuthnRegistrationResult.mockImplementation(() => ({ + CredentialId: '12345', + })); + + navigatorCredentialsCreateSpy.mockResolvedValue(passkeyRegistrationResult); + + mockGetIsPasskeySupported.mockReturnValue(true); + }); + + afterEach(() => { + mockFetchAuthSession.mockClear(); + mockGetWebAuthnRegistrationOptions.mockReset(); + navigatorCredentialsCreateSpy.mockClear(); + }); + + it('should pass the correct service options when retrieving credential creation options', async () => { + mockGetWebAuthnRegistrationOptions.mockImplementation(() => ({ + CredentialCreationOptions: passkeyCredentialCreateOptions, + })); + + await associateWebAuthnCredential(); + + expect(mockGetWebAuthnRegistrationOptions).toHaveBeenCalledWith( + { + region: 'us-west-2', + userAgentValue: expect.any(String), + }, + { + AccessToken: mockAccessToken, + }, + ); + }); + + it('should pass the correct service options when verifying a credential', async () => { + mockGetWebAuthnRegistrationOptions.mockImplementation(() => ({ + CredentialCreationOptions: passkeyCredentialCreateOptions, + })); + + await associateWebAuthnCredential(); + + expect(mockVerifyWebAuthnRegistrationResult).toHaveBeenCalledWith( + { + region: 'us-west-2', + userAgentValue: expect.any(String), + }, + { + AccessToken: mockAccessToken, + Credential: JSON.stringify( + serializePkcToJson(passkeyRegistrationResult), + ), + }, + ); + }); + + it('should call the registerPasskey function with correct input', async () => { + mockGetWebAuthnRegistrationOptions.mockImplementation(() => ({ + CredentialCreationOptions: passkeyCredentialCreateOptions, + })); + + await associateWebAuthnCredential(); + + expect(registerPasskeySpy).toHaveBeenCalledWith( + JSON.parse(passkeyCredentialCreateOptions), + ); + + expect(navigatorCredentialsCreateSpy).toHaveBeenCalled(); + }); + + it('should throw an error when service returns empty credential creation options', async () => { + expect.assertions(2); + + mockGetWebAuthnRegistrationOptions.mockImplementation(() => ({ + CredentialCreationOptions: undefined, + })); + + try { + await associateWebAuthnCredential(); + } catch (error: any) { + expect(error).toBeInstanceOf(PasskeyError); + expect(error.name).toBe( + PasskeyErrorCode.InvalidCredentialCreationOptions, + ); + } + }); + + it('should throw an error when passkeys are not supported', async () => { + expect.assertions(2); + + mockGetWebAuthnRegistrationOptions.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/utils/passkey.test.ts b/packages/auth/__tests__/client/utils/passkey.test.ts new file mode 100644 index 00000000000..ed2042d53d3 --- /dev/null +++ b/packages/auth/__tests__/client/utils/passkey.test.ts @@ -0,0 +1,47 @@ +import { + deserializeJsonToPkcCreationOptions, + serializePkcToJson, +} 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(serializePkcToJson(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/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__/mockData.ts b/packages/auth/__tests__/mockData.ts index 9edfd45a197..f7166939431 100644 --- a/packages/auth/__tests__/mockData.ts +++ b/packages/auth/__tests__/mockData.ts @@ -1,3 +1,10 @@ +import { + PasskeyCreateOptions, + PasskeyCreateOptionsJson, + PasskeyCreateResult, + PasskeyCreateResultJson, +} from '../src/client/utils/passkey/types'; + // device tracking mock device data export const mockDeviceArray = [ { @@ -180,3 +187,90 @@ 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', + response: { + clientDataJSON: 'vJCit9S2cglAvvW3txQ-OQ', + attestationObject: 'vJCit9S2cglAvvW3txQ-OQ', + }, +}; +export const passkeyRegistrationResult: PasskeyCreateResult = { + 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, + ]), + 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, + ]), + }, +}; + +export const passkeyRegistrationRequest: PasskeyCreateOptions = { + 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', + }, +}; diff --git a/packages/auth/src/client/apis/associateWebAuthnCredential.ts b/packages/auth/src/client/apis/associateWebAuthnCredential.ts new file mode 100644 index 00000000000..fd8c14777c2 --- /dev/null +++ b/packages/auth/src/client/apis/associateWebAuthnCredential.ts @@ -0,0 +1,105 @@ +// 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 { + GetWebAuthnRegistrationOptionsException, + VerifyWebAuthnRegistrationResultException, +} 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 { + createGetWebAuthnRegistrationOptionsClient, + createVerifyWebAuthnRegistrationResultClient, +} from '../../foundation/factories/serviceClients/cognitoIdentityProvider'; +import { + PasskeyError, + PasskeyErrorCode, + assertPasskeyError, +} from '../utils/passkey/errors'; +import { AssociateWebAuthnCredentialOutput } from '../types'; +import { AuthError } from '../../errors/AuthError'; + +/** + * 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 GetWebAuthnRegistrationOptionsException} + * - Thrown due to a service error retrieving WebAuthn registration options + * @throws - {@link VerifyWebAuthnRegistrationResultException} + * - 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 getWebAuthnRegistrationOptions = + createGetWebAuthnRegistrationOptionsClient({ + endpointResolver: createCognitoUserPoolEndpointResolver({ + endpointOverride: userPoolEndpoint, + }), + }); + + const { CredentialCreationOptions: credentialCreationOptions } = + await getWebAuthnRegistrationOptions( + { + region: getRegionFromUserPoolId(userPoolId), + userAgentValue: getAuthUserAgentValue( + AuthAction.GetWebAuthnRegistrationOptions, + ), + }, + { + AccessToken: tokens.accessToken.toString(), + }, + ); + + assertPasskeyError( + !!credentialCreationOptions, + PasskeyErrorCode.InvalidCredentialCreationOptions, + ); + + const cred = await registerPasskey(JSON.parse(credentialCreationOptions)); + + const verifyWebAuthnRegistrationResult = + createVerifyWebAuthnRegistrationResultClient({ + endpointResolver: createCognitoUserPoolEndpointResolver({ + endpointOverride: userPoolEndpoint, + }), + }); + + await verifyWebAuthnRegistrationResult( + { + region: getRegionFromUserPoolId(userPoolId), + userAgentValue: getAuthUserAgentValue( + AuthAction.VerifyWebAuthnRegistrationResult, + ), + }, + { + AccessToken: tokens.accessToken.toString(), + Credential: JSON.stringify(cred), + }, + ); + + return { + credentialId: cred.id, + }; +} diff --git a/packages/auth/src/client/apis/index.ts b/packages/auth/src/client/apis/index.ts new file mode 100644 index 00000000000..0bc604f0beb --- /dev/null +++ b/packages/auth/src/client/apis/index.ts @@ -0,0 +1,4 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { associateWebAuthnCredential } from './associateWebAuthnCredential'; diff --git a/packages/auth/src/client/types/index.ts b/packages/auth/src/client/types/index.ts new file mode 100644 index 00000000000..dfd1cc80fa2 --- /dev/null +++ b/packages/auth/src/client/types/index.ts @@ -0,0 +1,4 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { AssociateWebAuthnCredentialOutput } from './outputs'; diff --git a/packages/auth/src/client/types/outputs.ts b/packages/auth/src/client/types/outputs.ts new file mode 100644 index 00000000000..075fa89f6cf --- /dev/null +++ b/packages/auth/src/client/types/outputs.ts @@ -0,0 +1,9 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Output type for Cognito associateWebAuthnCredential API. + */ +export interface AssociateWebAuthnCredentialOutput { + credentialId?: string; +} 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..53232e47ed8 --- /dev/null +++ b/packages/auth/src/client/utils/passkey/errors.ts @@ -0,0 +1,46 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + AmplifyError, + 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 { + PasskeyNotSupported = 'PasskeyNotSupported', + InvalidCredentialCreationOptions = 'InvalidCredentialCreationOptions', + PasskeyRegistrationFailed = 'PasskeyRegistrationFailed', +} + +const passkeyErrorMap: AmplifyErrorMap = { + [PasskeyErrorCode.PasskeyNotSupported]: { + message: 'Passkey not supported on device', + recoverySuggestion: + 'Ensure your application is running in a secure context (HTTPS)', + }, + [PasskeyErrorCode.InvalidCredentialCreationOptions]: { + message: 'Invalid credential creation options', + recoverySuggestion: + 'Ensure your user pool is configured to support WebAuthN passkey registration', + }, + [PasskeyErrorCode.PasskeyRegistrationFailed]: { + message: 'Device failed to create credentials', + }, +}; + +export const assertPasskeyError: AssertionFunction = + createAssertionFunction(passkeyErrorMap, PasskeyError); 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..1934d7f86f2 --- /dev/null +++ b/packages/auth/src/client/utils/passkey/getIsPasskeySupported.ts @@ -0,0 +1,13 @@ +// 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; +}; 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..6dd936d9fc3 --- /dev/null +++ b/packages/auth/src/client/utils/passkey/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 './registerPasskey'; 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..7dbbebc4d0e --- /dev/null +++ b/packages/auth/src/client/utils/passkey/registerPasskey.ts @@ -0,0 +1,31 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PasskeyCreateOptionsJson, PasskeyCreateResult } from './types'; +import { + deserializeJsonToPkcCreationOptions, + serializePkcToJson, +} from './serde'; +import { PasskeyErrorCode, assertPasskeyError } from './errors'; +import { getIsPasskeySupported } from './getIsPasskeySupported'; + +/** + * Registers a new passkey for user + * @param input - PasskeyCreateOptions + * @returns serialized PasskeyCreateResult + */ +export const registerPasskey = async (input: PasskeyCreateOptionsJson) => { + const isPasskeySupported = getIsPasskeySupported(); + + assertPasskeyError(isPasskeySupported, PasskeyErrorCode.PasskeyNotSupported); + + const passkeyCreationOptions = deserializeJsonToPkcCreationOptions(input); + + const credential = (await navigator.credentials.create({ + publicKey: passkeyCreationOptions, + })) as PasskeyCreateResult | null; + + assertPasskeyError(!!credential, PasskeyErrorCode.PasskeyRegistrationFailed); + + return serializePkcToJson(credential); +}; 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..5d34a7a1973 --- /dev/null +++ b/packages/auth/src/client/utils/passkey/serde.ts @@ -0,0 +1,65 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + convertArrayBufferToBase64Url, + convertBase64UrlToArrayBuffer, +} from '../../../foundation/convert'; + +import { + PasskeyCreateOptions, + PasskeyCreateOptionsJson, + PasskeyCreateResult, + PasskeyCreateResultJson, +} from './types'; + +/** + * Deserializes Public Key Credential JSON + * @param input PasskeyCreateOptionsJson + * @returns PasskeyCreateOptions + */ +export const deserializeJsonToPkcCreationOptions = ( + input: PasskeyCreateOptionsJson, +): PasskeyCreateOptions => { + 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 to JSON + * @param input PasskeyCreateResult + * @returns PasskeyCreateResultJson + */ +export const serializePkcToJson = ( + input: PasskeyCreateResult, +): PasskeyCreateResultJson => { + return { + type: input.type, + id: input.id, + rawId: convertArrayBufferToBase64Url(input.rawId), + response: { + clientDataJSON: convertArrayBufferToBase64Url( + input.response.clientDataJSON, + ), + attestationObject: convertArrayBufferToBase64Url( + input.response.attestationObject, + ), + }, + }; +}; diff --git a/packages/auth/src/client/utils/passkey/types.ts b/packages/auth/src/client/utils/passkey/types.ts new file mode 100644 index 00000000000..429717b0589 --- /dev/null +++ b/packages/auth/src/client/utils/passkey/types.ts @@ -0,0 +1,81 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +type PasskeyTransport = 'ble' | 'hybrid' | 'internal' | 'nfc' | 'usb'; +type UserVerificationRequirement = 'discouraged' | 'preferred' | 'required'; + +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: { + type: 'public-key'; + id: string; + transports?: PasskeyTransport[]; + }[]; + authenticatorSelection: { + requireResidentKey: boolean; + residentKey: UserVerificationRequirement; + userVerification: UserVerificationRequirement; + }; +} + +export interface PasskeyCreateOptions { + challenge: ArrayBuffer; + rp: { + id: string; + name: string; + }; + user: { + id: ArrayBuffer; + name: string; + displayName: string; + }; + pubKeyCredParams: { + alg: number; + type: 'public-key'; + }[]; + timeout: number; + excludeCredentials: { + type: 'public-key'; + id: ArrayBuffer; + transports?: PasskeyTransport[]; + }[]; + authenticatorSelection: { + requireResidentKey: boolean; + residentKey: UserVerificationRequirement; + userVerification: UserVerificationRequirement; + }; +} + +export interface PasskeyCreateResult { + id: string; + rawId: ArrayBuffer; + type: 'public-key'; + response: { + clientDataJSON: ArrayBuffer; + attestationObject: ArrayBuffer; + }; +} + +export interface PasskeyCreateResultJson { + id: string; + rawId: string; + type: 'public-key'; + response: { + clientDataJSON: string; + attestationObject: string; + }; +} 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/createGetWebAuthnRegistrationOptionsClient.ts b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createGetWebAuthnRegistrationOptionsClient.ts new file mode 100644 index 00000000000..0add6750a1d --- /dev/null +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createGetWebAuthnRegistrationOptionsClient.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 { + GetWebAuthnRegistrationOptionsCommandInput, + GetWebAuthnRegistrationOptionsCommandOutput, + ServiceClientFactoryInput, +} from './types'; +import { cognitoUserPoolTransferHandler } from './shared/handler'; +import { + createUserPoolDeserializer, + createUserPoolSerializer, +} from './shared/serde'; +import { DEFAULT_SERVICE_CLIENT_API_CONFIG } from './constants'; + +export const createGetWebAuthnRegistrationOptionsClient = ( + config: ServiceClientFactoryInput, +) => + composeServiceApi( + cognitoUserPoolTransferHandler, + createUserPoolSerializer( + 'GetWebAuthnRegistrationOptions', + ), + createUserPoolDeserializer(), + { + ...DEFAULT_SERVICE_CLIENT_API_CONFIG, + ...config, + }, + ); diff --git a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createVerifyWebAuthnRegistrationResultClient.ts b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createVerifyWebAuthnRegistrationResultClient.ts new file mode 100644 index 00000000000..db694ed6dfe --- /dev/null +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createVerifyWebAuthnRegistrationResultClient.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, + VerifyWebAuthnRegistrationResultCommandInput, + VerifyWebAuthnRegistrationResultCommandOutput, +} from './types'; +import { cognitoUserPoolTransferHandler } from './shared/handler'; +import { + createUserPoolDeserializer, + createUserPoolSerializer, +} from './shared/serde'; +import { DEFAULT_SERVICE_CLIENT_API_CONFIG } from './constants'; + +export const createVerifyWebAuthnRegistrationResultClient = ( + config: ServiceClientFactoryInput, +) => + composeServiceApi( + cognitoUserPoolTransferHandler, + createUserPoolSerializer( + 'VerifyWebAuthnRegistrationResult', + ), + 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..b720162c07a 100644 --- a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/index.ts +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/index.ts @@ -24,3 +24,5 @@ export { createVerifyUserAttributeClient } from './createVerifyUserAttributeClie export { createUpdateDeviceStatusClient } from './createUpdateDeviceStatusClient'; export { createListDevicesClient } from './createListDevicesClient'; export { createDeleteUserAttributesClient } from './createDeleteUserAttributesClient'; +export { createGetWebAuthnRegistrationOptionsClient } from './createGetWebAuthnRegistrationOptionsClient'; +export { createVerifyWebAuthnRegistrationResultClient } from './createVerifyWebAuthnRegistrationResultClient'; 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..8f2ed0cbd27 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,9 @@ type ClientOperation = | 'DeleteUserAttributes' | 'UpdateDeviceStatus' | 'ListDevices' - | 'RevokeToken'; + | 'RevokeToken' + | 'GetWebAuthnRegistrationOptions' + | 'VerifyWebAuthnRegistrationResult'; 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..c411f9cda33 --- /dev/null +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/errors.ts @@ -0,0 +1,25 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export enum GetWebAuthnRegistrationOptionsException { + ForbiddenException = 'ForbiddenException', + InternalErrorException = 'InternalErrorException', + InvalidParameterException = 'InvalidParameterException', + InvalidWebAuthnConfigurationException = 'InvalidWebAuthnConfigurationException', + LimitExceededException = 'LimitExceededException', + NotAuthorizedException = 'NotAuthorizedException', + TooManyRequestsException = 'TooManyRequestsException', + WebAuthnNotEnabledException = 'WebAuthnNotEnabledException', +} + +export enum VerifyWebAuthnRegistrationResultException { + CredentialAlreadyExistsException = 'CredentialAlreadyExistsException', + ForbiddenException = 'ForbiddenException', + InternalErrorException = 'InternalErrorException', + InvalidParameterException = 'InvalidParameterException', + NotAuthorizedException = 'NotAuthorizedException', + TooManyRequestsException = 'TooManyRequestsException', + WebAuthnAuthenticatorSelectionMismatchException = 'WebAuthnAuthenticatorSelectionMismatchException', + WebAuthnChallengeMismatchException = 'WebAuthnChallengeMismatchException', + WebAuthnRelyingPartyMismatchException = 'WebAuthnRelyingPartyMismatchException', +} 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..ff7821ec55e 100644 --- a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/sdk.ts +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/sdk.ts @@ -1732,3 +1732,36 @@ export interface DeleteUserAttributesRequest { */ export type DeleteUserAttributesResponse = Record; export {}; + +/** + *

The request to retrieve WebAuthN registration options.

+ */ +export interface GetWebAuthnRegistrationOptionsRequest { + AccessToken?: string; +} +/** + *

The response containing WebAuthN registration options.

+ */ +export interface GetWebAuthnRegistrationOptionsResponse { + CredentialCreationOptions?: string; +} + +export type GetWebAuthnRegistrationOptionsCommandInput = + GetWebAuthnRegistrationOptionsRequest; + +export interface GetWebAuthnRegistrationOptionsCommandOutput + extends GetWebAuthnRegistrationOptionsResponse, + __MetadataBearer {} + +/** + *

The request to verify a WebAuthN credential.

+ */ +export interface VerifyWebAuthnRegistrationResultRequest { + AccessToken?: string; + Credential?: string; +} + +export type VerifyWebAuthnRegistrationResultCommandInput = + VerifyWebAuthnRegistrationResultRequest; + +export type VerifyWebAuthnRegistrationResultCommandOutput = __MetadataBearer; diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 799492edb39..9a27b96c8ac 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -87,3 +87,6 @@ export { AuthTokens, JWT, } from '@aws-amplify/core'; + +export { associateWebAuthnCredential } from './client/apis'; +export { AssociateWebAuthnCredentialOutput } from './client/types'; diff --git a/packages/aws-amplify/__tests__/exports.test.ts b/packages/aws-amplify/__tests__/exports.test.ts index 0a354d6cf11..3a4cdbc4b09 100644 --- a/packages/aws-amplify/__tests__/exports.test.ts +++ b/packages/aws-amplify/__tests__/exports.test.ts @@ -180,6 +180,7 @@ describe('aws-amplify Exports', () => { 'autoSignIn', 'fetchAuthSession', 'decodeJWT', + 'associateWebAuthnCredential', ].sort(), ); }); diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index e45fedb59ee..13de87640cd 100644 --- a/packages/aws-amplify/package.json +++ b/packages/aws-amplify/package.json @@ -457,6 +457,12 @@ "import": "{ signInWithRedirect, signOut, fetchAuthSession }", "limit": "21.66 kB" }, + { + "name": "[Auth] Associate WebAuthN Credential (Cognito)", + "path": "./dist/esm/auth/index.mjs", + "import": "{ associateWebAuthnCredential }", + "limit": "12.70 kB" + }, { "name": "[Storage] copy (S3)", "path": "./dist/esm/storage/index.mjs", 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 d5c60a84241..723936c46f4 100644 --- a/packages/core/src/Platform/types.ts +++ b/packages/core/src/Platform/types.ts @@ -90,6 +90,8 @@ export enum AuthAction { FetchDevices = '34', SendUserAttributeVerificationCode = '35', SignInWithRedirect = '36', + GetWebAuthnRegistrationOptions = '37', + VerifyWebAuthnRegistrationResult = '38', } export enum DataStoreAction { Subscribe = '1', 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; } From 048eff1382758fb3bcc9565c61ab16a08b322044 Mon Sep 17 00:00:00 2001 From: James Jarvis Date: Mon, 28 Oct 2024 14:55:56 -0700 Subject: [PATCH 03/25] feat(auth): signIn with a webauthn credential (#3) wip --- .../apis/associateWebAuthnCredential.test.ts | 20 +- .../__tests__/client/utils/passkey.test.ts | 10 +- packages/auth/__tests__/mockData.ts | 129 +++++++++++-- .../handleWebAuthnSignInResult.test.ts | 174 ++++++++++++++++++ .../apis/associateWebAuthnCredential.ts | 7 +- .../userAuth/handleWebAuthnSignInResult.ts | 130 +++++++++++++ packages/auth/src/client/types/index.ts | 4 - packages/auth/src/client/types/outputs.ts | 9 - .../auth/src/client/utils/passkey/errors.ts | 22 ++- .../client/utils/passkey/getPasskey.native.ts | 8 + .../src/client/utils/passkey/getPasskey.ts | 29 +++ .../auth/src/client/utils/passkey/index.ts | 1 + .../client/utils/passkey/registerPasskey.ts | 17 +- .../auth/src/client/utils/passkey/serde.ts | 120 ++++++++++-- .../auth/src/client/utils/passkey/types.ts | 81 -------- .../src/client/utils/passkey/types/index.ts | 60 ++++++ .../src/client/utils/passkey/types/shared.ts | 103 +++++++++++ .../cognitoIdentityProvider/types/sdk.ts | 24 +-- packages/auth/src/index.ts | 1 - .../providers/cognito/utils/signInHelpers.ts | 4 + packages/aws-amplify/package.json | 2 +- 21 files changed, 792 insertions(+), 163 deletions(-) create mode 100644 packages/auth/__tests__/providers/cognito/utils/signInHelpers/handleWebAuthnSignInResult.test.ts create mode 100644 packages/auth/src/client/flows/userAuth/handleWebAuthnSignInResult.ts delete mode 100644 packages/auth/src/client/types/index.ts delete mode 100644 packages/auth/src/client/types/outputs.ts create mode 100644 packages/auth/src/client/utils/passkey/getPasskey.native.ts create mode 100644 packages/auth/src/client/utils/passkey/getPasskey.ts delete mode 100644 packages/auth/src/client/utils/passkey/types.ts create mode 100644 packages/auth/src/client/utils/passkey/types/index.ts create mode 100644 packages/auth/src/client/utils/passkey/types/shared.ts diff --git a/packages/auth/__tests__/client/apis/associateWebAuthnCredential.test.ts b/packages/auth/__tests__/client/apis/associateWebAuthnCredential.test.ts index b5b25047998..e306a5b14f7 100644 --- a/packages/auth/__tests__/client/apis/associateWebAuthnCredential.test.ts +++ b/packages/auth/__tests__/client/apis/associateWebAuthnCredential.test.ts @@ -14,11 +14,15 @@ import { passkeyCredentialCreateOptions, passkeyRegistrationResult, } from '../../mockData'; -import { serializePkcToJson } from '../../../src/client/utils/passkey/serde'; +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), @@ -34,6 +38,7 @@ jest.mock( jest.mock('../../../src/providers/cognito/factories'); jest.mock('../../../src/client/utils/passkey/getIsPasskeySupported'); +jest.mock('../../../src/client/utils/passkey/types'); Object.assign(navigator, { credentials: { @@ -62,6 +67,11 @@ describe('associateWebAuthnCredential', () => { createVerifyWebAuthnRegistrationResultClient, ); + const mockAssertCredentialIsPkcWithAuthenticatorAssertionResponse = + jest.mocked(assertCredentialIsPkcWithAuthenticatorAssertionResponse); + const mockAssertCredentialIsPkcWithAuthenticatorAttestationResponse = + jest.mocked(assertCredentialIsPkcWithAuthenticatorAttestationResponse); + beforeAll(() => { setUpGetConfig(Amplify); mockFetchAuthSession.mockResolvedValue({ @@ -80,6 +90,12 @@ describe('associateWebAuthnCredential', () => { navigatorCredentialsCreateSpy.mockResolvedValue(passkeyRegistrationResult); mockGetIsPasskeySupported.mockReturnValue(true); + mockAssertCredentialIsPkcWithAuthenticatorAssertionResponse.mockImplementation( + () => undefined, + ); + mockAssertCredentialIsPkcWithAuthenticatorAttestationResponse.mockImplementation( + () => undefined, + ); }); afterEach(() => { @@ -121,7 +137,7 @@ describe('associateWebAuthnCredential', () => { { AccessToken: mockAccessToken, Credential: JSON.stringify( - serializePkcToJson(passkeyRegistrationResult), + serializePkcWithAttestationToJson(passkeyRegistrationResult), ), }, ); diff --git a/packages/auth/__tests__/client/utils/passkey.test.ts b/packages/auth/__tests__/client/utils/passkey.test.ts index ed2042d53d3..c4fff5f891a 100644 --- a/packages/auth/__tests__/client/utils/passkey.test.ts +++ b/packages/auth/__tests__/client/utils/passkey.test.ts @@ -1,6 +1,6 @@ import { deserializeJsonToPkcCreationOptions, - serializePkcToJson, + serializePkcWithAttestationToJson, } from '../../../src/client/utils/passkey/serde'; import { passkeyRegistrationRequest, @@ -11,9 +11,11 @@ import { describe('passkey', () => { it('serializes pkc into correct json format', () => { - expect(JSON.stringify(serializePkcToJson(passkeyRegistrationResult))).toBe( - JSON.stringify(passkeyRegistrationResultJson), - ); + expect( + JSON.stringify( + serializePkcWithAttestationToJson(passkeyRegistrationResult), + ), + ).toBe(JSON.stringify(passkeyRegistrationResultJson)); }); it('deserializes json into correct pkc format', () => { diff --git a/packages/auth/__tests__/mockData.ts b/packages/auth/__tests__/mockData.ts index f7166939431..d33b3ab11df 100644 --- a/packages/auth/__tests__/mockData.ts +++ b/packages/auth/__tests__/mockData.ts @@ -1,8 +1,10 @@ import { - PasskeyCreateOptions, PasskeyCreateOptionsJson, - PasskeyCreateResult, PasskeyCreateResultJson, + PasskeyGetOptionsJson, + PasskeyGetResultJson, + PkcWithAuthenticatorAssertionResponse, + PkcWithAuthenticatorAttestationResponse, } from '../src/client/utils/passkey/types'; // device tracking mock device data @@ -195,28 +197,51 @@ 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: PasskeyCreateResult = { - 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, - ]), - response: { - clientDataJSON: new Uint8Array([ - 188, 144, 162, 183, 212, 182, 114, 9, 64, 190, 245, 183, 183, 20, 62, 57, - ]), - attestationObject: new Uint8Array([ +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: PasskeyCreateOptions = { +export const passkeyRegistrationRequest: PublicKeyCredentialCreationOptions = { rp: { id: 'localhost', name: 'localhost' }, user: { id: new Uint8Array([ @@ -274,3 +299,75 @@ export const passkeyRegistrationRequestJson: PasskeyCreateOptionsJson = { 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, + ]), + }, +}; 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..c6cf5047fac --- /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/providers/cognito/utils/signInStore'; +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/providers/cognito/utils/signInStore'); +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/src/client/apis/associateWebAuthnCredential.ts b/packages/auth/src/client/apis/associateWebAuthnCredential.ts index fd8c14777c2..0c4b4a5f6e5 100644 --- a/packages/auth/src/client/apis/associateWebAuthnCredential.ts +++ b/packages/auth/src/client/apis/associateWebAuthnCredential.ts @@ -25,7 +25,6 @@ import { PasskeyErrorCode, assertPasskeyError, } from '../utils/passkey/errors'; -import { AssociateWebAuthnCredentialOutput } from '../types'; import { AuthError } from '../../errors/AuthError'; /** @@ -41,7 +40,7 @@ import { AuthError } from '../../errors/AuthError'; * @throws - {@link VerifyWebAuthnRegistrationResultException} * - Thrown due to a service error when verifying WebAuthn registration result */ -export async function associateWebAuthnCredential(): Promise { +export async function associateWebAuthnCredential(): Promise { const authConfig = Amplify.getConfig().Auth?.Cognito; assertTokenProviderConfig(authConfig); @@ -98,8 +97,4 @@ export async function associateWebAuthnCredential(): 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.InvalidCredentialRequestOptions, + ); + + 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/types/index.ts b/packages/auth/src/client/types/index.ts deleted file mode 100644 index dfd1cc80fa2..00000000000 --- a/packages/auth/src/client/types/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -export { AssociateWebAuthnCredentialOutput } from './outputs'; diff --git a/packages/auth/src/client/types/outputs.ts b/packages/auth/src/client/types/outputs.ts deleted file mode 100644 index 075fa89f6cf..00000000000 --- a/packages/auth/src/client/types/outputs.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -/** - * Output type for Cognito associateWebAuthnCredential API. - */ -export interface AssociateWebAuthnCredentialOutput { - credentialId?: string; -} diff --git a/packages/auth/src/client/utils/passkey/errors.ts b/packages/auth/src/client/utils/passkey/errors.ts index 53232e47ed8..b746b2e8e1a 100644 --- a/packages/auth/src/client/utils/passkey/errors.ts +++ b/packages/auth/src/client/utils/passkey/errors.ts @@ -23,22 +23,36 @@ export class PasskeyError extends AmplifyError { export enum PasskeyErrorCode { PasskeyNotSupported = 'PasskeyNotSupported', InvalidCredentialCreationOptions = 'InvalidCredentialCreationOptions', + InvalidCredentialRequestOptions = 'InvalidCredentialRequestOptions', PasskeyRegistrationFailed = 'PasskeyRegistrationFailed', + PasskeyRetrievalFailed = 'PasskeyRetrievalFailed', } const passkeyErrorMap: AmplifyErrorMap = { [PasskeyErrorCode.PasskeyNotSupported]: { - message: 'Passkey not supported on device', + message: 'Passkey not supported on this device.', recoverySuggestion: - 'Ensure your application is running in a secure context (HTTPS)', + 'Ensure your application is running in a secure context (HTTPS).', }, [PasskeyErrorCode.InvalidCredentialCreationOptions]: { - message: 'Invalid credential creation options', + message: 'Invalid credential creation options.', recoverySuggestion: 'Ensure your user pool is configured to support WebAuthN passkey registration', }, + [PasskeyErrorCode.InvalidCredentialRequestOptions]: { + message: 'Invalid credential request options.', + recoverySuggestion: + 'User pool may not be configured to support WEB_AUTHN authentication factor.', + }, [PasskeyErrorCode.PasskeyRegistrationFailed]: { - message: 'Device failed to create credentials', + message: 'Device failed to create credentials.', + recoverySuggestion: + 'Credentials may not be supported on this device. Ensure your browser is up to date and the Web Authentication API is supported.', + }, + [PasskeyErrorCode.PasskeyRetrievalFailed]: { + message: 'Device failed to retrieve credentials.', + recoverySuggestion: + 'Credentials may not be available on this device. Try an alternative authentication factor like PASSWORD, EMAIL_OTP, or SMS_OTP.', }, }; 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..5afdec24b77 --- /dev/null +++ b/packages/auth/src/client/utils/passkey/getPasskey.ts @@ -0,0 +1,29 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PasskeyErrorCode, assertPasskeyError } from './errors'; +import { getIsPasskeySupported } from './getIsPasskeySupported'; +import { + deserializeJsonToPkcGetOptions, + serializePkcWithAssertionToJson, +} from './serde'; +import { + PasskeyGetOptionsJson, + assertCredentialIsPkcWithAuthenticatorAssertionResponse, +} from './types'; + +export const getPasskey = async (input: PasskeyGetOptionsJson) => { + const isPasskeySupported = getIsPasskeySupported(); + + assertPasskeyError(isPasskeySupported, PasskeyErrorCode.PasskeyNotSupported); + + const passkeyGetOptions = deserializeJsonToPkcGetOptions(input); + + const credential = await navigator.credentials.get({ + publicKey: passkeyGetOptions, + }); + + assertCredentialIsPkcWithAuthenticatorAssertionResponse(credential); + + return serializePkcWithAssertionToJson(credential); +}; diff --git a/packages/auth/src/client/utils/passkey/index.ts b/packages/auth/src/client/utils/passkey/index.ts index 6dd936d9fc3..7f7d12728b7 100644 --- a/packages/auth/src/client/utils/passkey/index.ts +++ b/packages/auth/src/client/utils/passkey/index.ts @@ -2,3 +2,4 @@ // SPDX-License-Identifier: Apache-2.0 export { registerPasskey } from './registerPasskey'; +export { getPasskey } from './getPasskey'; diff --git a/packages/auth/src/client/utils/passkey/registerPasskey.ts b/packages/auth/src/client/utils/passkey/registerPasskey.ts index 7dbbebc4d0e..761bd4d1f1d 100644 --- a/packages/auth/src/client/utils/passkey/registerPasskey.ts +++ b/packages/auth/src/client/utils/passkey/registerPasskey.ts @@ -1,17 +1,20 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { PasskeyCreateOptionsJson, PasskeyCreateResult } from './types'; +import { + PasskeyCreateOptionsJson, + assertCredentialIsPkcWithAuthenticatorAttestationResponse, +} from './types'; import { deserializeJsonToPkcCreationOptions, - serializePkcToJson, + serializePkcWithAttestationToJson, } from './serde'; import { PasskeyErrorCode, assertPasskeyError } from './errors'; import { getIsPasskeySupported } from './getIsPasskeySupported'; /** * Registers a new passkey for user - * @param input - PasskeyCreateOptions + * @param input - PasskeyCreateOptionsJson * @returns serialized PasskeyCreateResult */ export const registerPasskey = async (input: PasskeyCreateOptionsJson) => { @@ -21,11 +24,11 @@ export const registerPasskey = async (input: PasskeyCreateOptionsJson) => { const passkeyCreationOptions = deserializeJsonToPkcCreationOptions(input); - const credential = (await navigator.credentials.create({ + const credential = await navigator.credentials.create({ publicKey: passkeyCreationOptions, - })) as PasskeyCreateResult | null; + }); - assertPasskeyError(!!credential, PasskeyErrorCode.PasskeyRegistrationFailed); + assertCredentialIsPkcWithAuthenticatorAttestationResponse(credential); - return serializePkcToJson(credential); + return serializePkcWithAttestationToJson(credential); }; diff --git a/packages/auth/src/client/utils/passkey/serde.ts b/packages/auth/src/client/utils/passkey/serde.ts index 5d34a7a1973..ae672a22a06 100644 --- a/packages/auth/src/client/utils/passkey/serde.ts +++ b/packages/auth/src/client/utils/passkey/serde.ts @@ -7,20 +7,24 @@ import { } from '../../../foundation/convert'; import { - PasskeyCreateOptions, PasskeyCreateOptionsJson, - PasskeyCreateResult, PasskeyCreateResultJson, + PasskeyGetOptionsJson, + PasskeyGetResultJson, + PkcAssertionResponse, + PkcAttestationResponse, + PkcWithAuthenticatorAssertionResponse, + PkcWithAuthenticatorAttestationResponse, } from './types'; /** - * Deserializes Public Key Credential JSON + * Deserializes Public Key Credential Creation Options JSON * @param input PasskeyCreateOptionsJson - * @returns PasskeyCreateOptions + * @returns PublicKeyCredentialCreationOptions */ export const deserializeJsonToPkcCreationOptions = ( input: PasskeyCreateOptionsJson, -): PasskeyCreateOptions => { +): PublicKeyCredentialCreationOptions => { const userIdBuffer = convertBase64UrlToArrayBuffer(input.user.id); const challengeBuffer = convertBase64UrlToArrayBuffer(input.challenge); const excludeCredentialsWithBuffer = (input.excludeCredentials || []).map( @@ -42,24 +46,106 @@ export const deserializeJsonToPkcCreationOptions = ( }; /** - * Serializes a Public Key Credential to JSON + * Serializes a Public Key Credential With Attestation to JSON * @param input PasskeyCreateResult * @returns PasskeyCreateResultJson */ -export const serializePkcToJson = ( - input: PasskeyCreateResult, +export const serializePkcWithAttestationToJson = ( + input: PkcWithAuthenticatorAttestationResponse, ): PasskeyCreateResultJson => { - return { + 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), - response: { - clientDataJSON: convertArrayBufferToBase64Url( - input.response.clientDataJSON, - ), - attestationObject: convertArrayBufferToBase64Url( - input.response.attestationObject, - ), - }, + 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.ts b/packages/auth/src/client/utils/passkey/types.ts deleted file mode 100644 index 429717b0589..00000000000 --- a/packages/auth/src/client/utils/passkey/types.ts +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -type PasskeyTransport = 'ble' | 'hybrid' | 'internal' | 'nfc' | 'usb'; -type UserVerificationRequirement = 'discouraged' | 'preferred' | 'required'; - -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: { - type: 'public-key'; - id: string; - transports?: PasskeyTransport[]; - }[]; - authenticatorSelection: { - requireResidentKey: boolean; - residentKey: UserVerificationRequirement; - userVerification: UserVerificationRequirement; - }; -} - -export interface PasskeyCreateOptions { - challenge: ArrayBuffer; - rp: { - id: string; - name: string; - }; - user: { - id: ArrayBuffer; - name: string; - displayName: string; - }; - pubKeyCredParams: { - alg: number; - type: 'public-key'; - }[]; - timeout: number; - excludeCredentials: { - type: 'public-key'; - id: ArrayBuffer; - transports?: PasskeyTransport[]; - }[]; - authenticatorSelection: { - requireResidentKey: boolean; - residentKey: UserVerificationRequirement; - userVerification: UserVerificationRequirement; - }; -} - -export interface PasskeyCreateResult { - id: string; - rawId: ArrayBuffer; - type: 'public-key'; - response: { - clientDataJSON: ArrayBuffer; - attestationObject: ArrayBuffer; - }; -} - -export interface PasskeyCreateResultJson { - id: string; - rawId: string; - type: 'public-key'; - response: { - clientDataJSON: string; - attestationObject: string; - }; -} 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..1025a6623cf --- /dev/null +++ b/packages/auth/src/client/utils/passkey/types/index.ts @@ -0,0 +1,60 @@ +// 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, +} 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..b233aa0059b --- /dev/null +++ b/packages/auth/src/client/utils/passkey/types/shared.ts @@ -0,0 +1,103 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +type PasskeyTransport = 'ble' | 'hybrid' | 'internal' | 'nfc' | 'usb'; +type UserVerificationRequirement = 'discouraged' | 'preferred' | 'required'; + +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; + }; +} + +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; +} + +/** + * 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/foundation/factories/serviceClients/cognitoIdentityProvider/types/sdk.ts b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/sdk.ts index ff7821ec55e..ee42be76fe5 100644 --- a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/sdk.ts +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/sdk.ts @@ -16,7 +16,8 @@ export type ChallengeName = | '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 +28,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'; @@ -1736,32 +1738,32 @@ export {}; /** *

The request to retrieve WebAuthN registration options.

*/ -export interface GetWebAuthnRegistrationOptionsRequest { - AccessToken?: string; +export interface GetWebAuthnRegistrationOptionsInput { + AccessToken: string | undefined; } /** *

The response containing WebAuthN registration options.

*/ -export interface GetWebAuthnRegistrationOptionsResponse { - CredentialCreationOptions?: string; +export interface GetWebAuthnRegistrationOptionsOutput { + CredentialCreationOptions: string | undefined; } export type GetWebAuthnRegistrationOptionsCommandInput = - GetWebAuthnRegistrationOptionsRequest; + GetWebAuthnRegistrationOptionsInput; export interface GetWebAuthnRegistrationOptionsCommandOutput - extends GetWebAuthnRegistrationOptionsResponse, + extends GetWebAuthnRegistrationOptionsOutput, __MetadataBearer {} /** *

The request to verify a WebAuthN credential.

*/ -export interface VerifyWebAuthnRegistrationResultRequest { - AccessToken?: string; - Credential?: string; +export interface VerifyWebAuthnRegistrationResultInput { + AccessToken: string | undefined; + Credential: string | undefined; } export type VerifyWebAuthnRegistrationResultCommandInput = - VerifyWebAuthnRegistrationResultRequest; + VerifyWebAuthnRegistrationResultInput; export type VerifyWebAuthnRegistrationResultCommandOutput = __MetadataBearer; diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 9a27b96c8ac..c9e7a8ad71a 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -89,4 +89,3 @@ export { } from '@aws-amplify/core'; export { associateWebAuthnCredential } from './client/apis'; -export { AssociateWebAuthnCredentialOutput } from './client/types'; diff --git a/packages/auth/src/providers/cognito/utils/signInHelpers.ts b/packages/auth/src/providers/cognito/utils/signInHelpers.ts index d3bce2aa6f2..6d71e517da8 100644 --- a/packages/auth/src/providers/cognito/utils/signInHelpers.ts +++ b/packages/auth/src/providers/cognito/utils/signInHelpers.ts @@ -50,6 +50,7 @@ import { RespondToAuthChallengeCommandOutput, } from '../../../foundation/factories/serviceClients/cognitoIdentityProvider/types'; import { getRegionFromUserPoolId } from '../../../foundation/parsers'; +import { handleWebAuthnSignInResult } from '../../../client/flows/userAuth/handleWebAuthnSignInResult'; import { signInStore } from './signInStore'; import { assertDeviceMetadata } from './types'; @@ -940,6 +941,9 @@ export async function getSignInResult(params: { }, }, }; + + case 'WEB_AUTHN': + return handleWebAuthnSignInResult(challengeParameters); case 'ADMIN_NO_SRP_AUTH': break; case 'DEVICE_PASSWORD_VERIFIER': diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index 13de87640cd..187f2e175e7 100644 --- a/packages/aws-amplify/package.json +++ b/packages/aws-amplify/package.json @@ -461,7 +461,7 @@ "name": "[Auth] Associate WebAuthN Credential (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ associateWebAuthnCredential }", - "limit": "12.70 kB" + "limit": "12.95 kB" }, { "name": "[Storage] copy (S3)", From 622b641ef35b747ba14ba05144915c3d0a93366f Mon Sep 17 00:00:00 2001 From: Parker Scanlon <69879391+scanlonp@users.noreply.github.com> Date: Tue, 29 Oct 2024 16:11:59 -0700 Subject: [PATCH 04/25] feat(auth): listWebAuthnCredentials API (#6) --- .../apis/listWebAuthnCredentials.test.ts | 147 ++++++++++++++++++ packages/auth/__tests__/mockData.ts | 19 +++ packages/auth/src/foundation/apis/index.ts | 4 + .../apis/listWebAuthnCredentials.ts | 77 +++++++++ .../createListWebAuthnCredentialsClient.ts | 31 ++++ .../cognitoIdentityProvider/index.ts | 1 + .../shared/serde/createUserPoolSerializer.ts | 3 +- .../cognitoIdentityProvider/types/errors.ts | 7 + .../cognitoIdentityProvider/types/sdk.ts | 32 ++++ packages/auth/src/foundation/types/index.ts | 6 + packages/auth/src/foundation/types/inputs.ts | 10 ++ packages/auth/src/foundation/types/models.ts | 14 ++ packages/auth/src/foundation/types/outputs.ts | 12 ++ packages/auth/src/index.ts | 8 + .../aws-amplify/__tests__/exports.test.ts | 1 + packages/aws-amplify/package.json | 6 + packages/core/src/Platform/types.ts | 1 + 17 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 packages/auth/__tests__/foundation/apis/listWebAuthnCredentials.test.ts create mode 100644 packages/auth/src/foundation/apis/index.ts create mode 100644 packages/auth/src/foundation/apis/listWebAuthnCredentials.ts create mode 100644 packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createListWebAuthnCredentialsClient.ts create mode 100644 packages/auth/src/foundation/types/index.ts create mode 100644 packages/auth/src/foundation/types/inputs.ts create mode 100644 packages/auth/src/foundation/types/models.ts create mode 100644 packages/auth/src/foundation/types/outputs.ts 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..1d562904440 --- /dev/null +++ b/packages/auth/__tests__/foundation/apis/listWebAuthnCredentials.test.ts @@ -0,0 +1,147 @@ +import { Amplify, fetchAuthSession } from '@aws-amplify/core'; +import { decodeJWT } from '@aws-amplify/core/internals/utils'; + +import { createListWebAuthnCredentialsClient } from '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; +import { + ListWebAuthnCredentialsInput, + listWebAuthnCredentials, +} from '../../../src/'; +import { mockUserCredentials } from '../../mockData'; +import { setUpGetConfig } from '../../providers/cognito/testUtils/setUpGetConfig'; +import { mockAccessToken } from '../../providers/cognito/testUtils/data'; + +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'); + +describe('listWebAuthnCredentials', () => { + const mockFetchAuthSession = jest.mocked(fetchAuthSession); + + const mockListWebAuthnCredentials = jest.fn(); + const mockCreateListWebAuthnCredentialsClient = jest.mocked( + createListWebAuthnCredentialsClient, + ); + + beforeAll(() => { + setUpGetConfig(Amplify); + mockFetchAuthSession.mockResolvedValue({ + tokens: { accessToken: decodeJWT(mockAccessToken) }, + }); + mockCreateListWebAuthnCredentialsClient.mockReturnValue( + mockListWebAuthnCredentials, + ); + + mockListWebAuthnCredentials.mockImplementation((in1, in2) => { + return Promise.resolve({ + Credentials: mockUserCredentials.slice(0, in2.MaxResults), + NextToken: + in2.MaxResults < mockUserCredentials.length + ? 'dummyNextToken' + : undefined, + }); + }); + }); + + afterEach(() => { + mockFetchAuthSession.mockClear(); + }); + + it('should pass correct service options when listing credentials', async () => { + await listWebAuthnCredentials(); + + 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(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(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__/mockData.ts b/packages/auth/__tests__/mockData.ts index d33b3ab11df..cc72cddaf0f 100644 --- a/packages/auth/__tests__/mockData.ts +++ b/packages/auth/__tests__/mockData.ts @@ -371,3 +371,22 @@ export const passkeyGetResult: PkcWithAuthenticatorAssertionResponse = { ]), }, }; + +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/src/foundation/apis/index.ts b/packages/auth/src/foundation/apis/index.ts new file mode 100644 index 00000000000..4c9839af18d --- /dev/null +++ b/packages/auth/src/foundation/apis/index.ts @@ -0,0 +1,4 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { listWebAuthnCredentials } from './listWebAuthnCredentials'; diff --git a/packages/auth/src/foundation/apis/listWebAuthnCredentials.ts b/packages/auth/src/foundation/apis/listWebAuthnCredentials.ts new file mode 100644 index 00000000000..16ffed9c152 --- /dev/null +++ b/packages/auth/src/foundation/apis/listWebAuthnCredentials.ts @@ -0,0 +1,77 @@ +// 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 { ListWebAuthnCredentialsException } from '../factories/serviceClients/cognitoIdentityProvider/types'; +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'; +import { AuthError } from '../../errors/AuthError'; + +/** + * Lists registered credentials for an authenticated user + * + * @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 { + const authConfig = Amplify.getConfig().Auth?.Cognito; + assertTokenProviderConfig(authConfig); + const { userPoolEndpoint, userPoolId } = authConfig; + const { tokens } = await 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/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/index.ts b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/index.ts index b720162c07a..2814d944766 100644 --- a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/index.ts +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/index.ts @@ -26,3 +26,4 @@ export { createListDevicesClient } from './createListDevicesClient'; export { createDeleteUserAttributesClient } from './createDeleteUserAttributesClient'; export { createGetWebAuthnRegistrationOptionsClient } from './createGetWebAuthnRegistrationOptionsClient'; export { createVerifyWebAuthnRegistrationResultClient } from './createVerifyWebAuthnRegistrationResultClient'; +export { createListWebAuthnCredentialsClient } from './createListWebAuthnCredentialsClient'; 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 8f2ed0cbd27..a28c6597071 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 @@ -32,7 +32,8 @@ type ClientOperation = | 'ListDevices' | 'RevokeToken' | 'GetWebAuthnRegistrationOptions' - | 'VerifyWebAuthnRegistrationResult'; + | 'VerifyWebAuthnRegistrationResult' + | 'ListWebAuthnCredentials'; 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 index c411f9cda33..dca576d8057 100644 --- a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/errors.ts +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/errors.ts @@ -23,3 +23,10 @@ export enum VerifyWebAuthnRegistrationResultException { WebAuthnChallengeMismatchException = 'WebAuthnChallengeMismatchException', WebAuthnRelyingPartyMismatchException = 'WebAuthnRelyingPartyMismatchException', } + +export enum ListWebAuthnCredentialsException { + ForbiddenException = 'ForbiddenException', + InternalErrorException = 'InternalErrorException', + InvalidParameterException = 'InvalidParameterException', + NotAuthorizedException = 'NotAuthorizedException', +} 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 ee42be76fe5..94598c8edd1 100644 --- a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/sdk.ts +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/sdk.ts @@ -1767,3 +1767,35 @@ export type VerifyWebAuthnRegistrationResultCommandInput = VerifyWebAuthnRegistrationResultInput; export type VerifyWebAuthnRegistrationResultCommandOutput = __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 {} diff --git a/packages/auth/src/foundation/types/index.ts b/packages/auth/src/foundation/types/index.ts new file mode 100644 index 00000000000..e87371e9f11 --- /dev/null +++ b/packages/auth/src/foundation/types/index.ts @@ -0,0 +1,6 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { ListWebAuthnCredentialsInput } 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..c61b261b14a --- /dev/null +++ b/packages/auth/src/foundation/types/inputs.ts @@ -0,0 +1,10 @@ +// 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; +} 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 c9e7a8ad71a..47369b8aaa9 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -89,3 +89,11 @@ export { } from '@aws-amplify/core'; export { associateWebAuthnCredential } from './client/apis'; + +export { listWebAuthnCredentials } from './foundation/apis'; + +export { + AuthWebAuthnCredential, + ListWebAuthnCredentialsInput, + ListWebAuthnCredentialsOutput, +} from './foundation/types'; diff --git a/packages/aws-amplify/__tests__/exports.test.ts b/packages/aws-amplify/__tests__/exports.test.ts index 3a4cdbc4b09..4d6667d504d 100644 --- a/packages/aws-amplify/__tests__/exports.test.ts +++ b/packages/aws-amplify/__tests__/exports.test.ts @@ -181,6 +181,7 @@ describe('aws-amplify Exports', () => { 'fetchAuthSession', 'decodeJWT', 'associateWebAuthnCredential', + 'listWebAuthnCredentials', ].sort(), ); }); diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index 187f2e175e7..f3db4e94a1b 100644 --- a/packages/aws-amplify/package.json +++ b/packages/aws-amplify/package.json @@ -463,6 +463,12 @@ "import": "{ associateWebAuthnCredential }", "limit": "12.95 kB" }, + { + "name": "[Auth] List WebAuthN Credentials (Cognito)", + "path": "./dist/esm/auth/index.mjs", + "import": "{ listWebAuthnCredentials }", + "limit": "12.00 kB" + }, { "name": "[Storage] copy (S3)", "path": "./dist/esm/storage/index.mjs", diff --git a/packages/core/src/Platform/types.ts b/packages/core/src/Platform/types.ts index 723936c46f4..8c01453f5b7 100644 --- a/packages/core/src/Platform/types.ts +++ b/packages/core/src/Platform/types.ts @@ -92,6 +92,7 @@ export enum AuthAction { SignInWithRedirect = '36', GetWebAuthnRegistrationOptions = '37', VerifyWebAuthnRegistrationResult = '38', + ListWebAuthnCredentials = '39', } export enum DataStoreAction { Subscribe = '1', From f0f2c7c50087b30f31b744b517e78e60da88594a Mon Sep 17 00:00:00 2001 From: Parker Scanlon <69879391+scanlonp@users.noreply.github.com> Date: Tue, 29 Oct 2024 16:30:19 -0700 Subject: [PATCH 05/25] feat(auth): deleteWebAuthnCredential API (#8) --- .../apis/deleteWebAuthnCredential.test.ts | 65 +++++++++++++++++++ .../apis/deleteWebAuthnCredential.ts | 55 ++++++++++++++++ packages/auth/src/foundation/apis/index.ts | 1 + .../createDeleteWebAuthnCredentialClient.ts | 31 +++++++++ .../cognitoIdentityProvider/index.ts | 1 + .../shared/serde/createUserPoolSerializer.ts | 3 +- .../cognitoIdentityProvider/types/errors.ts | 7 ++ .../cognitoIdentityProvider/types/sdk.ts | 17 +++++ packages/auth/src/foundation/types/index.ts | 5 +- packages/auth/src/foundation/types/inputs.ts | 4 ++ packages/auth/src/index.ts | 6 +- .../aws-amplify/__tests__/exports.test.ts | 1 + packages/aws-amplify/package.json | 8 ++- packages/core/src/Platform/types.ts | 1 + 14 files changed, 201 insertions(+), 4 deletions(-) create mode 100644 packages/auth/__tests__/foundation/apis/deleteWebAuthnCredential.test.ts create mode 100644 packages/auth/src/foundation/apis/deleteWebAuthnCredential.ts create mode 100644 packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createDeleteWebAuthnCredentialClient.ts 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..a1eaaae117d --- /dev/null +++ b/packages/auth/__tests__/foundation/apis/deleteWebAuthnCredential.test.ts @@ -0,0 +1,65 @@ +import { Amplify, fetchAuthSession } from '@aws-amplify/core'; +import { decodeJWT } from '@aws-amplify/core/internals/utils'; + +import { createDeleteWebAuthnCredentialClient } from '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; +import { + DeleteWebAuthnCredentialInput, + deleteWebAuthnCredential, +} from '../../../src/'; +import { setUpGetConfig } from '../../providers/cognito/testUtils/setUpGetConfig'; +import { mockAccessToken } from '../../providers/cognito/testUtils/data'; + +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'); + +describe('deleteWebAuthnCredential', () => { + const mockFetchAuthSession = jest.mocked(fetchAuthSession); + + const mockDeleteWebAuthnCredential = jest.fn(); + const mockCreateDeleteWebAuthnCredentialClient = jest.mocked( + createDeleteWebAuthnCredentialClient, + ); + + beforeAll(() => { + setUpGetConfig(Amplify); + mockFetchAuthSession.mockResolvedValue({ + tokens: { accessToken: decodeJWT(mockAccessToken) }, + }); + mockCreateDeleteWebAuthnCredentialClient.mockReturnValue( + mockDeleteWebAuthnCredential, + ); + }); + + afterEach(() => { + mockFetchAuthSession.mockClear(); + }); + + it('should pass correct service options when deleting a credential', async () => { + const input: DeleteWebAuthnCredentialInput = { + credentialId: 'dummyId', + }; + + await deleteWebAuthnCredential(input); + + expect(mockDeleteWebAuthnCredential).toHaveBeenCalledWith( + { + region: 'us-west-2', + userAgentValue: expect.any(String), + }, + { + AccessToken: mockAccessToken, + CredentialId: input.credentialId, + }, + ); + }); +}); diff --git a/packages/auth/src/foundation/apis/deleteWebAuthnCredential.ts b/packages/auth/src/foundation/apis/deleteWebAuthnCredential.ts new file mode 100644 index 00000000000..872422813eb --- /dev/null +++ b/packages/auth/src/foundation/apis/deleteWebAuthnCredential.ts @@ -0,0 +1,55 @@ +// 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 { DeleteWebAuthnCredentialException } from '../factories/serviceClients/cognitoIdentityProvider/types'; +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'; +import { AuthError } from '../../errors/AuthError'; + +/** + * Delete a registered credential for an authenticated user by 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 { + const authConfig = Amplify.getConfig().Auth?.Cognito; + assertTokenProviderConfig(authConfig); + const { userPoolEndpoint, userPoolId } = authConfig; + const { tokens } = await 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 index 4c9839af18d..59d61c0cc16 100644 --- a/packages/auth/src/foundation/apis/index.ts +++ b/packages/auth/src/foundation/apis/index.ts @@ -2,3 +2,4 @@ // SPDX-License-Identifier: Apache-2.0 export { listWebAuthnCredentials } from './listWebAuthnCredentials'; +export { deleteWebAuthnCredential } from './deleteWebAuthnCredential'; 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/index.ts b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/index.ts index 2814d944766..9b8eff2a714 100644 --- a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/index.ts +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/index.ts @@ -27,3 +27,4 @@ export { createDeleteUserAttributesClient } from './createDeleteUserAttributesCl export { createGetWebAuthnRegistrationOptionsClient } from './createGetWebAuthnRegistrationOptionsClient'; export { createVerifyWebAuthnRegistrationResultClient } from './createVerifyWebAuthnRegistrationResultClient'; 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 a28c6597071..d7371239af4 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 @@ -33,7 +33,8 @@ type ClientOperation = | 'RevokeToken' | 'GetWebAuthnRegistrationOptions' | 'VerifyWebAuthnRegistrationResult' - | 'ListWebAuthnCredentials'; + | '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 index dca576d8057..944a71d1e01 100644 --- a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/errors.ts +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/errors.ts @@ -30,3 +30,10 @@ export enum ListWebAuthnCredentialsException { InvalidParameterException = 'InvalidParameterException', NotAuthorizedException = 'NotAuthorizedException', } + +export enum DeleteWebAuthnCredentialException { + ForbiddenException = 'ForbiddenException', + InternalErrorException = 'InternalErrorException', + InvalidParameterException = 'InvalidParameterException', + NotAuthorizedException = 'NotAuthorizedException', +} 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 94598c8edd1..46154d56b31 100644 --- a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/sdk.ts +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/sdk.ts @@ -1799,3 +1799,20 @@ 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 index e87371e9f11..cafbcffab9b 100644 --- a/packages/auth/src/foundation/types/index.ts +++ b/packages/auth/src/foundation/types/index.ts @@ -1,6 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -export { ListWebAuthnCredentialsInput } from './inputs'; +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 index c61b261b14a..6699cccd714 100644 --- a/packages/auth/src/foundation/types/inputs.ts +++ b/packages/auth/src/foundation/types/inputs.ts @@ -8,3 +8,7 @@ export interface ListWebAuthnCredentialsInput { pageSize?: number; nextToken?: string; } + +export interface DeleteWebAuthnCredentialInput { + credentialId: string; +} diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 47369b8aaa9..886bd2ff2d7 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -90,10 +90,14 @@ export { export { associateWebAuthnCredential } from './client/apis'; -export { listWebAuthnCredentials } from './foundation/apis'; +export { + listWebAuthnCredentials, + deleteWebAuthnCredential, +} from './foundation/apis'; export { AuthWebAuthnCredential, + DeleteWebAuthnCredentialInput, ListWebAuthnCredentialsInput, ListWebAuthnCredentialsOutput, } from './foundation/types'; diff --git a/packages/aws-amplify/__tests__/exports.test.ts b/packages/aws-amplify/__tests__/exports.test.ts index 4d6667d504d..c9cb6c405be 100644 --- a/packages/aws-amplify/__tests__/exports.test.ts +++ b/packages/aws-amplify/__tests__/exports.test.ts @@ -182,6 +182,7 @@ describe('aws-amplify Exports', () => { 'decodeJWT', 'associateWebAuthnCredential', 'listWebAuthnCredentials', + 'deleteWebAuthnCredential', ].sort(), ); }); diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index f3db4e94a1b..a170da11610 100644 --- a/packages/aws-amplify/package.json +++ b/packages/aws-amplify/package.json @@ -469,11 +469,17 @@ "import": "{ listWebAuthnCredentials }", "limit": "12.00 kB" }, + { + "name": "[Auth] Delete WebAuthN Credential (Cognito)", + "path": "./dist/esm/auth/index.mjs", + "import": "{ deleteWebAuthnCredential }", + "limit": "11.90 kB" + }, { "name": "[Storage] copy (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ copy }", - "limit": "15.03 kB" + "limit": "15.06 kB" }, { "name": "[Storage] downloadData (S3)", diff --git a/packages/core/src/Platform/types.ts b/packages/core/src/Platform/types.ts index 8c01453f5b7..3a91b5d1191 100644 --- a/packages/core/src/Platform/types.ts +++ b/packages/core/src/Platform/types.ts @@ -93,6 +93,7 @@ export enum AuthAction { GetWebAuthnRegistrationOptions = '37', VerifyWebAuthnRegistrationResult = '38', ListWebAuthnCredentials = '39', + DeleteWebAuthnCredential = '40', } export enum DataStoreAction { Subscribe = '1', From 036a4a66a661ad9dee75636c5c3aaed4357f52a8 Mon Sep 17 00:00:00 2001 From: yuhengshs Date: Mon, 7 Oct 2024 10:34:29 -0700 Subject: [PATCH 06/25] feat(auth): Added signInWithUserAuth for password-less Sign-In (#2) --- .../flows/shared/handlePasswordSRP.test.ts | 390 ++++++++++++++++++ .../userAuth/handleSelectChallenge.test.ts | 175 ++++++++ .../handleSelectChallengeWithPassword.test.ts | 191 +++++++++ ...ndleSelectChallengeWithPasswordSRP.test.ts | 262 ++++++++++++ .../flows/userAuth/handleUserAuthFlow.test.ts | 212 ++++++++++ .../providers/cognito/signInWithSRP.test.ts | 4 +- .../cognito/signInWithUserAuth.test.ts | 189 +++++++++ .../handleWebAuthnSignInResult.test.ts | 4 +- .../client/flows/shared/handlePasswordSRP.ts | 123 ++++++ .../flows/userAuth/handleSelectChallenge.ts | 62 +++ .../handleSelectChallengeWithPassword.ts | 74 ++++ .../handleSelectChallengeWithPasswordSRP.ts | 105 +++++ .../flows/userAuth/handleUserAuthFlow.ts | 111 +++++ .../userAuth/handleWebAuthnSignInResult.ts | 2 +- .../cognitoIdentityProvider/types/sdk.ts | 22 +- .../auth/src/providers/cognito/apis/signIn.ts | 3 + .../cognito/apis/signInWithUserAuth.ts | 121 ++++++ .../auth/src/providers/cognito/types/index.ts | 2 + .../src/providers/cognito/types/inputs.ts | 5 + .../src/providers/cognito/types/models.ts | 13 + .../src/providers/cognito/types/options.ts | 8 +- .../src/providers/cognito/types/outputs.ts | 5 + .../providers/cognito/utils/signInHelpers.ts | 125 +++--- packages/auth/src/types/models.ts | 13 + packages/core/src/singleton/Auth/types.ts | 1 + 25 files changed, 2159 insertions(+), 63 deletions(-) create mode 100644 packages/auth/__tests__/client/flows/shared/handlePasswordSRP.test.ts create mode 100644 packages/auth/__tests__/client/flows/userAuth/handleSelectChallenge.test.ts create mode 100644 packages/auth/__tests__/client/flows/userAuth/handleSelectChallengeWithPassword.test.ts create mode 100644 packages/auth/__tests__/client/flows/userAuth/handleSelectChallengeWithPasswordSRP.test.ts create mode 100644 packages/auth/__tests__/client/flows/userAuth/handleUserAuthFlow.test.ts create mode 100644 packages/auth/__tests__/providers/cognito/signInWithUserAuth.test.ts create mode 100644 packages/auth/src/client/flows/shared/handlePasswordSRP.ts create mode 100644 packages/auth/src/client/flows/userAuth/handleSelectChallenge.ts create mode 100644 packages/auth/src/client/flows/userAuth/handleSelectChallengeWithPassword.ts create mode 100644 packages/auth/src/client/flows/userAuth/handleSelectChallengeWithPasswordSRP.ts create mode 100644 packages/auth/src/client/flows/userAuth/handleUserAuthFlow.ts create mode 100644 packages/auth/src/providers/cognito/apis/signInWithUserAuth.ts 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__/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/utils/signInHelpers/handleWebAuthnSignInResult.test.ts b/packages/auth/__tests__/providers/cognito/utils/signInHelpers/handleWebAuthnSignInResult.test.ts index c6cf5047fac..dc9a7c2296a 100644 --- a/packages/auth/__tests__/providers/cognito/utils/signInHelpers/handleWebAuthnSignInResult.test.ts +++ b/packages/auth/__tests__/providers/cognito/utils/signInHelpers/handleWebAuthnSignInResult.test.ts @@ -1,6 +1,6 @@ import { Amplify } from '@aws-amplify/core'; -import { signInStore } from '../../../../../src/providers/cognito/utils/signInStore'; +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'; @@ -24,7 +24,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/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..221118f99a2 --- /dev/null +++ b/packages/auth/src/client/flows/userAuth/handleUserAuthFlow.ts @@ -0,0 +1,111 @@ +// 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'; + +interface HandleUserAuthFlowInput { + username: string; + config: CognitoUserPoolConfig; + tokenOrchestrator: AuthTokenOrchestrator; + clientMetadata?: Record; + preferredChallenge?: AuthFactorType; + password?: 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, +}: 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, + }; + + const initiateAuth = createInitiateAuthClient({ + endpointResolver: createCognitoUserPoolEndpointResolver({ + endpointOverride: userPoolEndpoint, + }), + }); + + return initiateAuth( + { + region: getRegionFromUserPoolId(userPoolId), + userAgentValue: getAuthUserAgentValue(AuthAction.SignIn), + }, + jsonReq, + ); +} diff --git a/packages/auth/src/client/flows/userAuth/handleWebAuthnSignInResult.ts b/packages/auth/src/client/flows/userAuth/handleWebAuthnSignInResult.ts index eae8bf2ef9b..2364c22291a 100644 --- a/packages/auth/src/client/flows/userAuth/handleWebAuthnSignInResult.ts +++ b/packages/auth/src/client/flows/userAuth/handleWebAuthnSignInResult.ts @@ -25,7 +25,7 @@ import { cleanActiveSignInState, setActiveSignInState, signInStore, -} from '../../../providers/cognito/utils/signInStore'; +} from '../../../client/utils/store'; import { AuthSignInOutput } from '../../../types'; import { getAuthUserAgentValue } from '../../../utils'; import { getPasskey } from '../../utils/passkey'; 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 46154d56b31..966e93f01f3 100644 --- a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/sdk.ts +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/sdk.ts @@ -7,10 +7,14 @@ 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' @@ -58,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 { @@ -1135,8 +1143,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; /** @@ -1147,9 +1157,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 diff --git a/packages/auth/src/providers/cognito/apis/signIn.ts b/packages/auth/src/providers/cognito/apis/signIn.ts index 10a9be79214..c1677f971d5 100644 --- a/packages/auth/src/providers/cognito/apis/signIn.ts +++ b/packages/auth/src/providers/cognito/apis/signIn.ts @@ -13,6 +13,7 @@ import { signInWithCustomAuth } from './signInWithCustomAuth'; import { signInWithCustomSRPAuth } from './signInWithCustomSRPAuth'; import { signInWithSRP } from './signInWithSRP'; import { signInWithUserPassword } from './signInWithUserPassword'; +import { signInWithUserAuth } from './signInWithUserAuth'; /** * Signs a user in @@ -37,6 +38,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/signInWithUserAuth.ts b/packages/auth/src/providers/cognito/apis/signInWithUserAuth.ts new file mode 100644 index 00000000000..3090c4289e3 --- /dev/null +++ b/packages/auth/src/providers/cognito/apis/signInWithUserAuth.ts @@ -0,0 +1,121 @@ +// 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 { + cleanActiveSignInState, + setActiveSignInState, +} from '../utils/signInStore'; +import { cacheCognitoTokens } from '../tokenProvider/cacheTokens'; +import { dispatchSignedInHubEvent } from '../utils/dispatchSignedInHubEvent'; +import { tokenOrchestrator } from '../tokenProvider'; +import { handleUserAuthFlow } from '../../../client/flows/userAuth/handleUserAuthFlow'; + +/** + * 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 response = await handleUserAuthFlow({ + username, + config: authConfig, + tokenOrchestrator, + clientMetadata: clientMetaData, + preferredChallenge, + password, + }); + + 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(); + + 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(); + assertServiceError(error); + const result = getSignInResultFromError(error.name); + if (result) return result; + throw error; + } +} 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 6d71e517da8..8589edaee34 100644 --- a/packages/auth/src/providers/cognito/utils/signInHelpers.ts +++ b/packages/auth/src/providers/cognito/utils/signInHelpers.ts @@ -51,6 +51,10 @@ import { } 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 './signInStore'; import { assertDeviceMetadata } from './types'; @@ -434,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( @@ -813,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); @@ -944,6 +903,22 @@ 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': @@ -1031,6 +1006,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, @@ -1075,6 +1070,7 @@ export async function handleChallengeName( ); case 'SMS_MFA': case 'SOFTWARE_TOKEN_MFA': + case 'SMS_OTP': case 'EMAIL_OTP': return handleMFAChallenge({ challengeName, @@ -1084,6 +1080,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({ @@ -1264,7 +1277,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; @@ -1281,6 +1294,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/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/core/src/singleton/Auth/types.ts b/packages/core/src/singleton/Auth/types.ts index fd7bc788472..8c662ec5620 100644 --- a/packages/core/src/singleton/Auth/types.ts +++ b/packages/core/src/singleton/Auth/types.ts @@ -255,6 +255,7 @@ interface AWSAuthSignInDetails { * @deprecated */ type AuthFlowType = + | 'USER_AUTH' | 'USER_SRP_AUTH' | 'CUSTOM_WITH_SRP' | 'CUSTOM_WITHOUT_SRP' From 9a8ab675dada7d79dd3ce1fff6348f1ccfb3f9a9 Mon Sep 17 00:00:00 2001 From: James Jarvis Date: Thu, 31 Oct 2024 13:15:57 -0700 Subject: [PATCH 07/25] feat(auth): Add USER_AUTH flow in Sign Up logic (#11) --- .../createSignUpClient.test.ts | 53 +++++++ .../cognitoIdentityProvider/index.test.ts | 4 +- .../cognitoIdentityProvider/testUtils/data.ts | 18 +++ .../providers/cognito/signUp.test.ts | 29 ++-- .../createSignUpClient.ts | 51 ++++++- .../cognitoIdentityProvider/types/sdk.ts | 24 ++- .../providers/cognito/apis/confirmSignUp.ts | 8 +- .../auth/src/providers/cognito/apis/signUp.ts | 140 +++++++++--------- .../providers/cognito/utils/signUpHelpers.ts | 9 +- packages/auth/src/types/inputs.ts | 2 +- 10 files changed, 241 insertions(+), 97 deletions(-) create mode 100644 packages/auth/__tests__/foundation/factories/serviceClients/cognitoIdentityProvider/createSignUpClient.test.ts 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__/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/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/types/sdk.ts b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/sdk.ts index 966e93f01f3..320bc6baab0 100644 --- a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/sdk.ts +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/sdk.ts @@ -706,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, @@ -1093,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.

@@ -1545,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 diff --git a/packages/auth/src/providers/cognito/apis/confirmSignUp.ts b/packages/auth/src/providers/cognito/apis/confirmSignUp.ts index 92adf180210..33e67dfdca8 100644 --- a/packages/auth/src/providers/cognito/apis/confirmSignUp.ts +++ b/packages/auth/src/providers/cognito/apis/confirmSignUp.ts @@ -15,8 +15,8 @@ import { ConfirmSignUpException } from '../types/errors'; import { getRegionFromUserPoolId } from '../../../foundation/parsers'; import { AutoSignInEventData } from '../types/models'; import { - isAutoSignInStarted, - isAutoSignInUserUsingConfirmSignUp, + getIsAutoSignInStarted, + getIsAutoSignInUserUsingConfirmSignUp, setAutoSignInStarted, } from '../utils/signUpHelpers'; import { getAuthUserAgentValue } from '../../../utils'; @@ -90,8 +90,8 @@ export async function confirmSignUp( }; if ( - !isAutoSignInStarted() || - !isAutoSignInUserUsingConfirmSignUp(username) + !getIsAutoSignInStarted() || + !getIsAutoSignInUserUsingConfirmSignUp(username) ) { resolve(signUpOut); diff --git a/packages/auth/src/providers/cognito/apis/signUp.ts b/packages/auth/src/providers/cognito/apis/signUp.ts index 3ec246648f5..270e5ce18f7 100644 --- a/packages/auth/src/providers/cognito/apis/signUp.ts +++ b/packages/auth/src/providers/cognito/apis/signUp.ts @@ -18,9 +18,8 @@ import { toAttributeType } from '../utils/apiHelpers'; import { autoSignInUserConfirmed, autoSignInWhenUserIsConfirmedWithLink, + getIsAutoSignInStarted, handleCodeAutoSignIn, - isAutoSignInStarted, - isSignUpComplete, setAutoSignInStarted, setUsernameUsedForAutoSignIn, } from '../utils/signUpHelpers'; @@ -28,6 +27,7 @@ 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 { setAutoSignIn } from './autoSignIn'; @@ -52,10 +52,6 @@ export async function signUp(input: SignUpInput): Promise { !!username, AuthValidationErrorCode.EmptySignUpUsername, ); - assertValidationError( - !!password, - AuthValidationErrorCode.EmptySignUpPassword, - ); const signInServiceOptions = typeof autoSignIn !== 'boolean' ? autoSignIn : undefined; @@ -79,87 +75,99 @@ 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, + } = 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)); + const codeDeliveryDetails = { + destination: cdd?.Destination, + deliveryMedium: cdd?.DeliveryMedium as AuthDeliveryMedium, + attributeName: cdd?.AttributeName as AuthVerifiableAttributeKey, + }; + + const isSignUpComplete = !!userConfirmed; + const isAutoSignInStarted = getIsAutoSignInStarted(); + + // 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/utils/signUpHelpers.ts b/packages/auth/src/providers/cognito/utils/signUpHelpers.ts index 8ab2943ce2a..8a488ffa55c 100644 --- a/packages/auth/src/providers/cognito/utils/signUpHelpers.ts +++ b/packages/auth/src/providers/cognito/utils/signUpHelpers.ts @@ -10,7 +10,6 @@ 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'; const MAX_AUTOSIGNIN_POLLING_MS = 3 * 60 * 1000; @@ -115,11 +114,11 @@ let usernameUsedForAutoSignIn: string | undefined; export function setUsernameUsedForAutoSignIn(username?: string) { usernameUsedForAutoSignIn = username; } -export function isAutoSignInUserUsingConfirmSignUp(username: string) { +export function getIsAutoSignInUserUsingConfirmSignUp(username: string) { return usernameUsedForAutoSignIn === username; } -export function isAutoSignInStarted(): boolean { +export function getIsAutoSignInStarted(): boolean { return autoSignInStarted; } export function setAutoSignInStarted(value: boolean) { @@ -129,10 +128,6 @@ export function setAutoSignInStarted(value: boolean) { autoSignInStarted = value; } -export function isSignUpComplete(output: SignUpCommandOutput): boolean { - return !!output.UserConfirmed; -} - export function autoSignInWhenUserIsConfirmedWithLink( signInInput: SignInInput, ): AutoSignInCallback { diff --git a/packages/auth/src/types/inputs.ts b/packages/auth/src/types/inputs.ts index 6e152cdc1e5..a7189912cd0 100644 --- a/packages/auth/src/types/inputs.ts +++ b/packages/auth/src/types/inputs.ts @@ -83,7 +83,7 @@ export interface AuthSignUpInput< AuthSignUpOptions = AuthSignUpOptions, > { username: string; - password: string; + password?: string; options?: ServiceOptions; } From 6e4f3946958d7a523e65b8b087c18f44ba664508 Mon Sep 17 00:00:00 2001 From: James Jarvis Date: Thu, 31 Oct 2024 15:08:45 -0700 Subject: [PATCH 08/25] feat(auth): enable autoSignIn support for passwordless (#7) --- .../providers/cognito/autoSignIn.test.ts | 281 +++++++++++++++--- .../cognito/confirmSignInErrorCases.test.ts | 4 +- .../cognito/signInStateManagement.test.ts | 2 +- .../flows/userAuth/handleUserAuthFlow.ts | 8 +- .../src/client/utils/store/autoSignInStore.ts | 64 ++++ packages/auth/src/client/utils/store/index.ts | 5 + .../utils/store}/signInStore.ts | 11 +- packages/auth/src/client/utils/store/types.ts | 9 + .../providers/cognito/apis/confirmSignIn.ts | 2 +- .../providers/cognito/apis/confirmSignUp.ts | 22 +- .../cognito/apis/signInWithCustomAuth.ts | 2 +- .../cognito/apis/signInWithCustomSRPAuth.ts | 2 +- .../providers/cognito/apis/signInWithSRP.ts | 9 +- .../cognito/apis/signInWithUserAuth.ts | 29 +- .../cognito/apis/signInWithUserPassword.ts | 9 +- .../auth/src/providers/cognito/apis/signUp.ts | 10 +- .../providers/cognito/utils/signInHelpers.ts | 2 +- .../providers/cognito/utils/signUpHelpers.ts | 30 +- 18 files changed, 392 insertions(+), 109 deletions(-) create mode 100644 packages/auth/src/client/utils/store/autoSignInStore.ts create mode 100644 packages/auth/src/client/utils/store/index.ts rename packages/auth/src/{providers/cognito/utils => client/utils/store}/signInStore.ts (87%) create mode 100644 packages/auth/src/client/utils/store/types.ts diff --git a/packages/auth/__tests__/providers/cognito/autoSignIn.test.ts b/packages/auth/__tests__/providers/cognito/autoSignIn.test.ts index d787c2cdedf..19a163dff5f 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,235 @@ 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(); + + autoSignInStore.dispatch({ type: 'RESET' }); + 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, - }, + autoSignInStore.dispatch({ type: 'RESET' }); + 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/src/client/flows/userAuth/handleUserAuthFlow.ts b/packages/auth/src/client/flows/userAuth/handleUserAuthFlow.ts index 221118f99a2..24ba20500a6 100644 --- a/packages/auth/src/client/flows/userAuth/handleUserAuthFlow.ts +++ b/packages/auth/src/client/flows/userAuth/handleUserAuthFlow.ts @@ -19,13 +19,14 @@ import { handlePasswordSRP } from '../shared/handlePasswordSRP'; import { assertValidationError } from '../../../errors/utils/assertValidationError'; import { AuthValidationErrorCode } from '../../../errors/types/validation'; -interface HandleUserAuthFlowInput { +export interface HandleUserAuthFlowInput { username: string; config: CognitoUserPoolConfig; tokenOrchestrator: AuthTokenOrchestrator; clientMetadata?: Record; preferredChallenge?: AuthFactorType; password?: string; + session?: string; } /** @@ -49,6 +50,7 @@ export async function handleUserAuthFlow({ tokenOrchestrator, preferredChallenge, password, + session, }: HandleUserAuthFlowInput) { const { userPoolId, userPoolClientId, userPoolEndpoint } = config; const UserContextData = getUserContextData({ @@ -95,6 +97,10 @@ export async function handleUserAuthFlow({ UserContextData, }; + if (session) { + jsonReq.Session = session; + } + const initiateAuth = createInitiateAuthClient({ endpointResolver: createCognitoUserPoolEndpointResolver({ endpointOverride: userPoolEndpoint, 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..c7c6d3db1ac --- /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/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 33e67dfdca8..d8092983b47 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 { - getIsAutoSignInStarted, - getIsAutoSignInUserUsingConfirmSignUp, - 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,23 @@ export async function confirmSignUp( signUpStep: 'DONE', }, }; + const autoSignInStoreState = autoSignInStore.getState(); if ( - !getIsAutoSignInStarted() || - !getIsAutoSignInUserUsingConfirmSignUp(username) + !autoSignInStoreState.active || + autoSignInStoreState.username !== username ) { resolve(signUpOut); + autoSignInStore.dispatch({ type: 'RESET' }); + resetAutoSignIn(); return; } + if (session) { + autoSignInStore.dispatch({ type: 'SET_SESSION', value: session }); + } + const stopListener = HubInternal.listen( 'auth-internal', ({ payload }) => { @@ -109,7 +114,6 @@ export async function confirmSignUp( signUpStep: 'COMPLETE_AUTO_SIGN_IN', }, }); - setAutoSignInStarted(false); stopListener(); } }, 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..43ecc94ed6a 100644 --- a/packages/auth/src/providers/cognito/apis/signInWithSRP.ts +++ b/packages/auth/src/providers/cognito/apis/signInWithSRP.ts @@ -28,13 +28,16 @@ import { SignInWithSRPOutput, } from '../types'; import { + autoSignInStore, 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 * @@ -90,6 +93,8 @@ export async function signInWithSRP( }); if (AuthenticationResult) { cleanActiveSignInState(); + autoSignInStore.dispatch({ type: 'RESET' }); + resetAutoSignIn(); await cacheCognitoTokens({ username: activeUsername, ...AuthenticationResult, @@ -116,6 +121,8 @@ export async function signInWithSRP( }); } catch (error) { cleanActiveSignInState(); + autoSignInStore.dispatch({ type: 'RESET' }); + 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 index 3090c4289e3..9eb731fc593 100644 --- a/packages/auth/src/providers/cognito/apis/signInWithUserAuth.ts +++ b/packages/auth/src/providers/cognito/apis/signInWithUserAuth.ts @@ -27,13 +27,19 @@ import { SignInWithUserAuthOutput, } from '../types'; import { + autoSignInStore, cleanActiveSignInState, setActiveSignInState, -} from '../utils/signInStore'; +} from '../../../client/utils/store'; import { cacheCognitoTokens } from '../tokenProvider/cacheTokens'; import { dispatchSignedInHubEvent } from '../utils/dispatchSignedInHubEvent'; import { tokenOrchestrator } from '../tokenProvider'; -import { handleUserAuthFlow } from '../../../client/flows/userAuth/handleUserAuthFlow'; +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. @@ -65,16 +71,27 @@ export async function signInWithUserAuth( ); try { - const response = await handleUserAuthFlow({ + 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, @@ -84,6 +101,8 @@ export async function signInWithUserAuth( if (response.AuthenticationResult) { cleanActiveSignInState(); + autoSignInStore.dispatch({ type: 'RESET' }); + resetAutoSignIn(); await cacheCognitoTokens({ username: activeUsername, ...response.AuthenticationResult, @@ -113,6 +132,8 @@ export async function signInWithUserAuth( }); } catch (error) { cleanActiveSignInState(); + autoSignInStore.dispatch({ type: 'RESET' }); + resetAutoSignIn(); assertServiceError(error); const result = getSignInResultFromError(error.name); if (result) return result; diff --git a/packages/auth/src/providers/cognito/apis/signInWithUserPassword.ts b/packages/auth/src/providers/cognito/apis/signInWithUserPassword.ts index 071f54f8313..488829179c8 100644 --- a/packages/auth/src/providers/cognito/apis/signInWithUserPassword.ts +++ b/packages/auth/src/providers/cognito/apis/signInWithUserPassword.ts @@ -26,13 +26,16 @@ import { SignInWithUserPasswordOutput, } from '../types'; import { + autoSignInStore, 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 * @@ -96,6 +99,8 @@ export async function signInWithUserPassword( signInDetails, }); cleanActiveSignInState(); + autoSignInStore.dispatch({ type: 'RESET' }); + resetAutoSignIn(); await dispatchSignedInHubEvent(); @@ -111,6 +116,8 @@ export async function signInWithUserPassword( }); } catch (error) { cleanActiveSignInState(); + autoSignInStore.dispatch({ type: 'RESET' }); + 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 270e5ce18f7..dce1aa8d70f 100644 --- a/packages/auth/src/providers/cognito/apis/signUp.ts +++ b/packages/auth/src/providers/cognito/apis/signUp.ts @@ -18,16 +18,14 @@ import { toAttributeType } from '../utils/apiHelpers'; import { autoSignInUserConfirmed, autoSignInWhenUserIsConfirmedWithLink, - getIsAutoSignInStarted, handleCodeAutoSignIn, - 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'; @@ -65,8 +63,8 @@ export async function signUp(input: SignUpInput): Promise { signInInput.password = password; } if (signInServiceOptions || autoSignIn === true) { - setUsernameUsedForAutoSignIn(username); - setAutoSignInStarted(true); + autoSignInStore.dispatch({ type: 'START' }); + autoSignInStore.dispatch({ type: 'SET_USERNAME', value: username }); } const { userPoolId, userPoolClientId, userPoolEndpoint } = authConfig; @@ -114,7 +112,7 @@ export async function signUp(input: SignUpInput): Promise { }; const isSignUpComplete = !!userConfirmed; - const isAutoSignInStarted = getIsAutoSignInStarted(); + const isAutoSignInStarted = autoSignInStore.getState().active; // Sign Up Complete // No Confirm Sign In Step Required diff --git a/packages/auth/src/providers/cognito/utils/signInHelpers.ts b/packages/auth/src/providers/cognito/utils/signInHelpers.ts index 8589edaee34..0c924875f9c 100644 --- a/packages/auth/src/providers/cognito/utils/signInHelpers.ts +++ b/packages/auth/src/providers/cognito/utils/signInHelpers.ts @@ -55,8 +55,8 @@ import { handlePasswordSRP } from '../../../client/flows/shared/handlePasswordSR 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, diff --git a/packages/auth/src/providers/cognito/utils/signUpHelpers.ts b/packages/auth/src/providers/cognito/utils/signUpHelpers.ts index 8a488ffa55c..47c57c135c0 100644 --- a/packages/auth/src/providers/cognito/utils/signUpHelpers.ts +++ b/packages/auth/src/providers/cognito/utils/signUpHelpers.ts @@ -10,6 +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 { autoSignInStore } from '../../../client/utils/store'; const MAX_AUTOSIGNIN_POLLING_MS = 3 * 60 * 1000; @@ -35,7 +36,7 @@ export function handleCodeAutoSignIn(signInInput: SignInInput) { // This will stop the listener if confirmSignUp is not resolved. const timeOutId = setTimeout(() => { stopHubListener(); - setAutoSignInStarted(false); + autoSignInStore.dispatch({ type: 'RESET' }); clearTimeout(timeOutId); resetAutoSignIn(); }, MAX_AUTOSIGNIN_POLLING_MS); @@ -73,7 +74,6 @@ function handleAutoSignInWithLink( const maxTime = MAX_AUTOSIGNIN_POLLING_MS; if (elapsedTime > maxTime) { clearInterval(autoSignInPollingIntervalId); - setAutoSignInStarted(false); reject( new AuthError({ name: AUTO_SIGN_IN_EXCEPTION, @@ -83,19 +83,20 @@ function handleAutoSignInWithLink( }), ); resetAutoSignIn(); + autoSignInStore.dispatch({ type: 'RESET' }); } else { try { const signInOutput = await signIn(signInInput); if (signInOutput.nextStep.signInStep !== 'CONFIRM_SIGN_UP') { resolve(signInOutput); clearInterval(autoSignInPollingIntervalId); - setAutoSignInStarted(false); + autoSignInStore.dispatch({ type: 'RESET' }); resetAutoSignIn(); } } catch (error) { clearInterval(autoSignInPollingIntervalId); - setAutoSignInStarted(false); reject(error); + autoSignInStore.dispatch({ type: 'RESET' }); resetAutoSignIn(); } } @@ -107,27 +108,6 @@ const debouncedAutoSignWithCodeOrUserConfirmed = debounce( 300, ); -let autoSignInStarted = false; - -let usernameUsedForAutoSignIn: string | undefined; - -export function setUsernameUsedForAutoSignIn(username?: string) { - usernameUsedForAutoSignIn = username; -} -export function getIsAutoSignInUserUsingConfirmSignUp(username: string) { - return usernameUsedForAutoSignIn === username; -} - -export function getIsAutoSignInStarted(): boolean { - return autoSignInStarted; -} -export function setAutoSignInStarted(value: boolean) { - if (value === false) { - setUsernameUsedForAutoSignIn(undefined); - } - autoSignInStarted = value; -} - export function autoSignInWhenUserIsConfirmedWithLink( signInInput: SignInInput, ): AutoSignInCallback { From b6e092c5e3ab4988f7ff802cc710679849ed435c Mon Sep 17 00:00:00 2001 From: James Jarvis Date: Mon, 4 Nov 2024 17:07:09 -0800 Subject: [PATCH 09/25] tmp disable code ql --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5b5b8865166..ce532d0c795 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -5,7 +5,7 @@ on: push: branches: ['*'] pull_request: - branches: ['main', 'release', 'next/main', 'next/release'] + branches: ['release', 'next/main', 'next/release'] schedule: # Run every Tuesday at midnight GMT - cron: '0 0 * * 2' From c838e1fabd00ffc4bdb82ad9b70e7565aec4512e Mon Sep 17 00:00:00 2001 From: James Jarvis Date: Tue, 5 Nov 2024 13:14:03 -0800 Subject: [PATCH 10/25] handle SMS_OTP sign in result --- packages/auth/src/providers/cognito/utils/signInHelpers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/auth/src/providers/cognito/utils/signInHelpers.ts b/packages/auth/src/providers/cognito/utils/signInHelpers.ts index 0c924875f9c..d4123c475c1 100644 --- a/packages/auth/src/providers/cognito/utils/signInHelpers.ts +++ b/packages/auth/src/providers/cognito/utils/signInHelpers.ts @@ -869,6 +869,7 @@ export async function getSignInResult(params: { ), }, }; + case 'SMS_OTP': case 'SMS_MFA': return { isSignedIn: false, From 0589210afca9a3e9e7110c378cc081c88a893d38 Mon Sep 17 00:00:00 2001 From: James Jarvis Date: Tue, 5 Nov 2024 13:23:30 -0800 Subject: [PATCH 11/25] cache session from signup and confirmsignup both --- .../auth/src/client/utils/store/autoSignInStore.ts | 2 +- .../auth/src/providers/cognito/apis/confirmSignUp.ts | 4 +--- packages/auth/src/providers/cognito/apis/signUp.ts | 11 +++++++---- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/auth/src/client/utils/store/autoSignInStore.ts b/packages/auth/src/client/utils/store/autoSignInStore.ts index c7c6d3db1ac..2cd93f62bc8 100644 --- a/packages/auth/src/client/utils/store/autoSignInStore.ts +++ b/packages/auth/src/client/utils/store/autoSignInStore.ts @@ -6,7 +6,7 @@ import { Reducer, Store } from './types'; type AutoSignInAction = | { type: 'START' } | { type: 'SET_USERNAME'; value: string } - | { type: 'SET_SESSION'; value: string } + | { type: 'SET_SESSION'; value?: string } | { type: 'RESET' }; interface AutoSignInState { diff --git a/packages/auth/src/providers/cognito/apis/confirmSignUp.ts b/packages/auth/src/providers/cognito/apis/confirmSignUp.ts index d8092983b47..41cb1f7a141 100644 --- a/packages/auth/src/providers/cognito/apis/confirmSignUp.ts +++ b/packages/auth/src/providers/cognito/apis/confirmSignUp.ts @@ -99,9 +99,7 @@ export async function confirmSignUp( return; } - if (session) { - autoSignInStore.dispatch({ type: 'SET_SESSION', value: session }); - } + autoSignInStore.dispatch({ type: 'SET_SESSION', value: session }); const stopListener = HubInternal.listen( 'auth-internal', diff --git a/packages/auth/src/providers/cognito/apis/signUp.ts b/packages/auth/src/providers/cognito/apis/signUp.ts index dce1aa8d70f..2861541243c 100644 --- a/packages/auth/src/providers/cognito/apis/signUp.ts +++ b/packages/auth/src/providers/cognito/apis/signUp.ts @@ -62,10 +62,6 @@ export async function signUp(input: SignUpInput): Promise { if (signInServiceOptions?.authFlowType !== 'CUSTOM_WITHOUT_SRP') { signInInput.password = password; } - if (signInServiceOptions || autoSignIn === true) { - autoSignInStore.dispatch({ type: 'START' }); - autoSignInStore.dispatch({ type: 'SET_USERNAME', value: username }); - } const { userPoolId, userPoolClientId, userPoolEndpoint } = authConfig; const signUpClient = createSignUpClient({ @@ -97,6 +93,7 @@ export async function signUp(input: SignUpInput): Promise { UserSub: userId, CodeDeliveryDetails: cdd, UserConfirmed: userConfirmed, + Session: session, } = await signUpClient( { region: getRegionFromUserPoolId(userPoolId), @@ -105,6 +102,12 @@ export async function signUp(input: SignUpInput): Promise { signUpClientInput, ); + 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, From 9a3faf2cec4bdaeab03a478f126c3da50a72fbb3 Mon Sep 17 00:00:00 2001 From: James Jarvis Date: Tue, 5 Nov 2024 15:24:09 -0800 Subject: [PATCH 12/25] add getSignInResult test --- .../signInHelpers/getSignInResult.test.ts | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 packages/auth/__tests__/providers/cognito/utils/signInHelpers/getSignInResult.test.ts 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'); + }); +}); From a7cad01d26b50a2959d1a43f3259610537d59e37 Mon Sep 17 00:00:00 2001 From: James Jarvis Date: Wed, 6 Nov 2024 11:06:29 -0800 Subject: [PATCH 13/25] bundle size tests --- packages/aws-amplify/package.json | 40 +++++++++++++++---------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index a170da11610..11957b39dc1 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.41 kB" + "limit": "17.46 kB" }, { "name": "[Analytics] record (Kinesis)", @@ -317,7 +317,7 @@ "name": "[Analytics] identifyUser (Pinpoint)", "path": "./dist/esm/analytics/index.mjs", "import": "{ identifyUser }", - "limit": "15.91 kB" + "limit": "15.95 kB" }, { "name": "[Analytics] enable", @@ -353,13 +353,13 @@ "name": "[Auth] resetPassword (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ resetPassword }", - "limit": "12.57 kB" + "limit": "12.61 kB" }, { "name": "[Auth] confirmResetPassword (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ confirmResetPassword }", - "limit": "12.51 kB" + "limit": "12.56 kB" }, { "name": "[Auth] signIn (Cognito)", @@ -371,7 +371,7 @@ "name": "[Auth] resendSignUpCode (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ resendSignUpCode }", - "limit": "12.53 kB" + "limit": "12.58 kB" }, { "name": "[Auth] confirmSignUp (Cognito)", @@ -389,7 +389,7 @@ "name": "[Auth] updateMFAPreference (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ updateMFAPreference }", - "limit": "12.00 kB" + "limit": "12.04 kB" }, { "name": "[Auth] fetchMFAPreference (Cognito)", @@ -401,13 +401,13 @@ "name": "[Auth] verifyTOTPSetup (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ verifyTOTPSetup }", - "limit": "12.86 kB" + "limit": "12.91 kB" }, { "name": "[Auth] updatePassword (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ updatePassword }", - "limit": "12.87 kB" + "limit": "12.92 kB" }, { "name": "[Auth] setUpTOTP (Cognito)", @@ -419,7 +419,7 @@ "name": "[Auth] updateUserAttributes (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ updateUserAttributes }", - "limit": "12.1 kB" + "limit": "12.14 kB" }, { "name": "[Auth] getCurrentUser (Cognito)", @@ -431,7 +431,7 @@ "name": "[Auth] confirmUserAttribute (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ confirmUserAttribute }", - "limit": "12.86 kB" + "limit": "12.91 kB" }, { "name": "[Auth] signInWithRedirect (Cognito)", @@ -443,7 +443,7 @@ "name": "[Auth] fetchUserAttributes (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ fetchUserAttributes }", - "limit": "11.93 kB" + "limit": "11.97 kB" }, { "name": "[Auth] Basic Auth Flow (Cognito)", @@ -461,19 +461,19 @@ "name": "[Auth] Associate WebAuthN Credential (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ associateWebAuthnCredential }", - "limit": "12.95 kB" + "limit": "13.05 kB" }, { "name": "[Auth] List WebAuthN Credentials (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ listWebAuthnCredentials }", - "limit": "12.00 kB" + "limit": "12.10 kB" }, { "name": "[Auth] Delete WebAuthN Credential (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ deleteWebAuthnCredential }", - "limit": "11.90 kB" + "limit": "11.95 kB" }, { "name": "[Storage] copy (S3)", @@ -485,37 +485,37 @@ "name": "[Storage] downloadData (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ downloadData }", - "limit": "15.62 kB" + "limit": "15.66 kB" }, { "name": "[Storage] getProperties (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ getProperties }", - "limit": "14.89 kB" + "limit": "14.95 kB" }, { "name": "[Storage] getUrl (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ getUrl }", - "limit": "16.11 kB" + "limit": "16.15 kB" }, { "name": "[Storage] list (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ list }", - "limit": "15.55 kB" + "limit": "15.60 kB" }, { "name": "[Storage] remove (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ remove }", - "limit": "14.75 kB" + "limit": "14.80 kB" }, { "name": "[Storage] uploadData (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ uploadData }", - "limit": "20.08 kB" + "limit": "20.15 kB" } ] } From cac2f2818162505890c9edb2b959f68698b63239 Mon Sep 17 00:00:00 2001 From: James Jarvis Date: Thu, 7 Nov 2024 09:26:27 -0800 Subject: [PATCH 14/25] feat(passwordless): refactor to support new Cognito API changes (#14) * refactor to support new APIs * bundle size updates --- .../apis/associateWebAuthnCredential.test.ts | 54 +++++++++--------- packages/auth/__tests__/mockData.ts | 31 +++++++++- .../apis/associateWebAuthnCredential.ts | 46 +++++++-------- .../client/utils/passkey/registerPasskey.ts | 5 +- .../src/client/utils/passkey/types/index.ts | 1 + .../src/client/utils/passkey/types/shared.ts | 33 ++++++++++- ...eateCompleteWebAuthnRegistrationClient.ts} | 12 ++-- ... createStartWebAuthnRegistrationClient.ts} | 12 ++-- .../cognitoIdentityProvider/index.ts | 4 +- .../shared/serde/createUserPoolSerializer.ts | 4 +- .../cognitoIdentityProvider/types/sdk.ts | 56 ++++++++++++------- packages/aws-amplify/package.json | 2 +- packages/core/src/Platform/types.ts | 4 +- 13 files changed, 167 insertions(+), 97 deletions(-) rename packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/{createGetWebAuthnRegistrationOptionsClient.ts => createCompleteWebAuthnRegistrationClient.ts} (65%) rename packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/{createVerifyWebAuthnRegistrationResultClient.ts => createStartWebAuthnRegistrationClient.ts} (65%) diff --git a/packages/auth/__tests__/client/apis/associateWebAuthnCredential.test.ts b/packages/auth/__tests__/client/apis/associateWebAuthnCredential.test.ts index e306a5b14f7..a9ca5d13585 100644 --- a/packages/auth/__tests__/client/apis/associateWebAuthnCredential.test.ts +++ b/packages/auth/__tests__/client/apis/associateWebAuthnCredential.test.ts @@ -2,8 +2,8 @@ import { Amplify, fetchAuthSession } from '@aws-amplify/core'; import { decodeJWT } from '@aws-amplify/core/internals/utils'; import { - createGetWebAuthnRegistrationOptionsClient, - createVerifyWebAuthnRegistrationResultClient, + createCompleteWebAuthnRegistrationClient, + createStartWebAuthnRegistrationClient, } from '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; import { PasskeyError, @@ -38,7 +38,11 @@ jest.mock( jest.mock('../../../src/providers/cognito/factories'); jest.mock('../../../src/client/utils/passkey/getIsPasskeySupported'); -jest.mock('../../../src/client/utils/passkey/types'); +jest.mock('../../../src/client/utils/passkey/types', () => ({ + ...jest.requireActual('../../../src/client/utils/passkey/types'), + assertCredentialIsPkcWithAuthenticatorAssertionResponse: jest.fn(), + assertCredentialIsPkcWithAuthenticatorAttestationResponse: jest.fn(), +})); Object.assign(navigator, { credentials: { @@ -57,14 +61,14 @@ describe('associateWebAuthnCredential', () => { const mockGetIsPasskeySupported = jest.mocked(getIsPasskeySupported); - const mockGetWebAuthnRegistrationOptions = jest.fn(); - const mockCreateGetWebAuthnRegistrationOptionsClient = jest.mocked( - createGetWebAuthnRegistrationOptionsClient, + const mockStartWebAuthnRegistration = jest.fn(); + const mockCreateStartWebAuthnRegistrationClient = jest.mocked( + createStartWebAuthnRegistrationClient, ); - const mockVerifyWebAuthnRegistrationResult = jest.fn(); - const mockCreateVerifyWebAuthnRegistrationResultClient = jest.mocked( - createVerifyWebAuthnRegistrationResultClient, + const mockCompleteWebAuthnRegistration = jest.fn(); + const mockCreateCompleteWebAuthnRegistrationClient = jest.mocked( + createCompleteWebAuthnRegistrationClient, ); const mockAssertCredentialIsPkcWithAuthenticatorAssertionResponse = @@ -77,13 +81,13 @@ describe('associateWebAuthnCredential', () => { mockFetchAuthSession.mockResolvedValue({ tokens: { accessToken: decodeJWT(mockAccessToken) }, }); - mockCreateGetWebAuthnRegistrationOptionsClient.mockReturnValue( - mockGetWebAuthnRegistrationOptions, + mockCreateStartWebAuthnRegistrationClient.mockReturnValue( + mockStartWebAuthnRegistration, ); - mockCreateVerifyWebAuthnRegistrationResultClient.mockReturnValue( - mockVerifyWebAuthnRegistrationResult, + mockCreateCompleteWebAuthnRegistrationClient.mockReturnValue( + mockCompleteWebAuthnRegistration, ); - mockVerifyWebAuthnRegistrationResult.mockImplementation(() => ({ + mockCompleteWebAuthnRegistration.mockImplementation(() => ({ CredentialId: '12345', })); @@ -100,18 +104,18 @@ describe('associateWebAuthnCredential', () => { afterEach(() => { mockFetchAuthSession.mockClear(); - mockGetWebAuthnRegistrationOptions.mockReset(); + mockStartWebAuthnRegistration.mockClear(); navigatorCredentialsCreateSpy.mockClear(); }); it('should pass the correct service options when retrieving credential creation options', async () => { - mockGetWebAuthnRegistrationOptions.mockImplementation(() => ({ + mockStartWebAuthnRegistration.mockImplementation(() => ({ CredentialCreationOptions: passkeyCredentialCreateOptions, })); await associateWebAuthnCredential(); - expect(mockGetWebAuthnRegistrationOptions).toHaveBeenCalledWith( + expect(mockStartWebAuthnRegistration).toHaveBeenCalledWith( { region: 'us-west-2', userAgentValue: expect.any(String), @@ -123,35 +127,35 @@ describe('associateWebAuthnCredential', () => { }); it('should pass the correct service options when verifying a credential', async () => { - mockGetWebAuthnRegistrationOptions.mockImplementation(() => ({ + mockStartWebAuthnRegistration.mockImplementation(() => ({ CredentialCreationOptions: passkeyCredentialCreateOptions, })); await associateWebAuthnCredential(); - expect(mockVerifyWebAuthnRegistrationResult).toHaveBeenCalledWith( + expect(mockCompleteWebAuthnRegistration).toHaveBeenCalledWith( { region: 'us-west-2', userAgentValue: expect.any(String), }, { AccessToken: mockAccessToken, - Credential: JSON.stringify( - serializePkcWithAttestationToJson(passkeyRegistrationResult), + Credential: serializePkcWithAttestationToJson( + passkeyRegistrationResult, ), }, ); }); it('should call the registerPasskey function with correct input', async () => { - mockGetWebAuthnRegistrationOptions.mockImplementation(() => ({ + mockStartWebAuthnRegistration.mockImplementation(() => ({ CredentialCreationOptions: passkeyCredentialCreateOptions, })); await associateWebAuthnCredential(); expect(registerPasskeySpy).toHaveBeenCalledWith( - JSON.parse(passkeyCredentialCreateOptions), + passkeyCredentialCreateOptions, ); expect(navigatorCredentialsCreateSpy).toHaveBeenCalled(); @@ -160,7 +164,7 @@ describe('associateWebAuthnCredential', () => { it('should throw an error when service returns empty credential creation options', async () => { expect.assertions(2); - mockGetWebAuthnRegistrationOptions.mockImplementation(() => ({ + mockStartWebAuthnRegistration.mockImplementation(() => ({ CredentialCreationOptions: undefined, })); @@ -177,7 +181,7 @@ describe('associateWebAuthnCredential', () => { it('should throw an error when passkeys are not supported', async () => { expect.assertions(2); - mockGetWebAuthnRegistrationOptions.mockImplementation(() => ({ + mockStartWebAuthnRegistration.mockImplementation(() => ({ CredentialCreationOptions: passkeyCredentialCreateOptions, })); diff --git a/packages/auth/__tests__/mockData.ts b/packages/auth/__tests__/mockData.ts index cc72cddaf0f..8fa6834ec5f 100644 --- a/packages/auth/__tests__/mockData.ts +++ b/packages/auth/__tests__/mockData.ts @@ -190,8 +190,35 @@ 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 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', diff --git a/packages/auth/src/client/apis/associateWebAuthnCredential.ts b/packages/auth/src/client/apis/associateWebAuthnCredential.ts index 0c4b4a5f6e5..09adf0b4ec8 100644 --- a/packages/auth/src/client/apis/associateWebAuthnCredential.ts +++ b/packages/auth/src/client/apis/associateWebAuthnCredential.ts @@ -17,15 +17,12 @@ import { getRegionFromUserPoolId } from '../../foundation/parsers'; import { getAuthUserAgentValue } from '../../utils'; import { registerPasskey } from '../utils'; import { - createGetWebAuthnRegistrationOptionsClient, - createVerifyWebAuthnRegistrationResultClient, + createCompleteWebAuthnRegistrationClient, + createStartWebAuthnRegistrationClient, } from '../../foundation/factories/serviceClients/cognitoIdentityProvider'; -import { - PasskeyError, - PasskeyErrorCode, - assertPasskeyError, -} from '../utils/passkey/errors'; +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 @@ -51,19 +48,18 @@ export async function associateWebAuthnCredential(): Promise { assertAuthTokens(tokens); - const getWebAuthnRegistrationOptions = - createGetWebAuthnRegistrationOptionsClient({ - endpointResolver: createCognitoUserPoolEndpointResolver({ - endpointOverride: userPoolEndpoint, - }), - }); + const startWebAuthnRegistration = createStartWebAuthnRegistrationClient({ + endpointResolver: createCognitoUserPoolEndpointResolver({ + endpointOverride: userPoolEndpoint, + }), + }); const { CredentialCreationOptions: credentialCreationOptions } = - await getWebAuthnRegistrationOptions( + await startWebAuthnRegistration( { region: getRegionFromUserPoolId(userPoolId), userAgentValue: getAuthUserAgentValue( - AuthAction.GetWebAuthnRegistrationOptions, + AuthAction.StartWebAuthnRegistration, ), }, { @@ -71,30 +67,28 @@ export async function associateWebAuthnCredential(): Promise { }, ); - assertPasskeyError( - !!credentialCreationOptions, - PasskeyErrorCode.InvalidCredentialCreationOptions, - ); + assertValidCredentialCreationOptions(credentialCreationOptions); - const cred = await registerPasskey(JSON.parse(credentialCreationOptions)); + const cred = await registerPasskey(credentialCreationOptions); - const verifyWebAuthnRegistrationResult = - createVerifyWebAuthnRegistrationResultClient({ + const completeWebAuthnRegistration = createCompleteWebAuthnRegistrationClient( + { endpointResolver: createCognitoUserPoolEndpointResolver({ endpointOverride: userPoolEndpoint, }), - }); + }, + ); - await verifyWebAuthnRegistrationResult( + await completeWebAuthnRegistration( { region: getRegionFromUserPoolId(userPoolId), userAgentValue: getAuthUserAgentValue( - AuthAction.VerifyWebAuthnRegistrationResult, + AuthAction.CompleteWebAuthnRegistration, ), }, { AccessToken: tokens.accessToken.toString(), - Credential: JSON.stringify(cred), + Credential: cred, }, ); } diff --git a/packages/auth/src/client/utils/passkey/registerPasskey.ts b/packages/auth/src/client/utils/passkey/registerPasskey.ts index 761bd4d1f1d..e1e522aacb5 100644 --- a/packages/auth/src/client/utils/passkey/registerPasskey.ts +++ b/packages/auth/src/client/utils/passkey/registerPasskey.ts @@ -3,6 +3,7 @@ import { PasskeyCreateOptionsJson, + PasskeyCreateResultJson, assertCredentialIsPkcWithAuthenticatorAttestationResponse, } from './types'; import { @@ -17,7 +18,9 @@ import { getIsPasskeySupported } from './getIsPasskeySupported'; * @param input - PasskeyCreateOptionsJson * @returns serialized PasskeyCreateResult */ -export const registerPasskey = async (input: PasskeyCreateOptionsJson) => { +export const registerPasskey = async ( + input: PasskeyCreateOptionsJson, +): Promise => { const isPasskeySupported = getIsPasskeySupported(); assertPasskeyError(isPasskeySupported, PasskeyErrorCode.PasskeyNotSupported); diff --git a/packages/auth/src/client/utils/passkey/types/index.ts b/packages/auth/src/client/utils/passkey/types/index.ts index 1025a6623cf..2c9df968218 100644 --- a/packages/auth/src/client/utils/passkey/types/index.ts +++ b/packages/auth/src/client/utils/passkey/types/index.ts @@ -11,6 +11,7 @@ export { PkcAttestationResponse, PasskeyCreateOptionsJson, PasskeyCreateResultJson, + assertValidCredentialCreationOptions, } from './shared'; export type PkcWithAuthenticatorAttestationResponse = Omit< diff --git a/packages/auth/src/client/utils/passkey/types/shared.ts b/packages/auth/src/client/utils/passkey/types/shared.ts index b233aa0059b..648ae89f4bb 100644 --- a/packages/auth/src/client/utils/passkey/types/shared.ts +++ b/packages/auth/src/client/utils/passkey/types/shared.ts @@ -1,8 +1,15 @@ // 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'; @@ -28,13 +35,19 @@ export interface PasskeyCreateOptionsJson { alg: number; type: 'public-key'; }[]; - timeout: number; - excludeCredentials: PkcDescriptor[]; - authenticatorSelection: { + timeout?: number; + excludeCredentials?: PkcDescriptor[]; + authenticatorSelection?: { requireResidentKey: boolean; residentKey: UserVerificationRequirement; userVerification: UserVerificationRequirement; }; + attestation?: AttestationConveyancePreference; + extensions?: { + appid?: string; + appidExclude?: string; + credProps?: boolean; + }; } export interface PkcAttestationResponse { @@ -65,6 +78,20 @@ export interface PasskeyCreateResultJson { response: PkcAttestationResponse; } +export function assertValidCredentialCreationOptions( + credentialCreationOptions: any, +): asserts credentialCreationOptions is PasskeyCreateOptionsJson { + assertPasskeyError( + [ + !!credentialCreationOptions, + !!credentialCreationOptions?.user, + !!credentialCreationOptions?.rp, + !!credentialCreationOptions?.pubKeyCredParams, + ].every(Boolean), + PasskeyErrorCode.InvalidCredentialCreationOptions, + ); +} + /** * Passkey Get Types */ diff --git a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createGetWebAuthnRegistrationOptionsClient.ts b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createCompleteWebAuthnRegistrationClient.ts similarity index 65% rename from packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createGetWebAuthnRegistrationOptionsClient.ts rename to packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createCompleteWebAuthnRegistrationClient.ts index 0add6750a1d..f86ad95da2f 100644 --- a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createGetWebAuthnRegistrationOptionsClient.ts +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createCompleteWebAuthnRegistrationClient.ts @@ -4,8 +4,8 @@ import { composeServiceApi } from '@aws-amplify/core/internals/aws-client-utils/composers'; import { - GetWebAuthnRegistrationOptionsCommandInput, - GetWebAuthnRegistrationOptionsCommandOutput, + CompleteWebAuthnRegistrationCommandInput, + CompleteWebAuthnRegistrationCommandOutput, ServiceClientFactoryInput, } from './types'; import { cognitoUserPoolTransferHandler } from './shared/handler'; @@ -15,15 +15,15 @@ import { } from './shared/serde'; import { DEFAULT_SERVICE_CLIENT_API_CONFIG } from './constants'; -export const createGetWebAuthnRegistrationOptionsClient = ( +export const createCompleteWebAuthnRegistrationClient = ( config: ServiceClientFactoryInput, ) => composeServiceApi( cognitoUserPoolTransferHandler, - createUserPoolSerializer( - 'GetWebAuthnRegistrationOptions', + createUserPoolSerializer( + 'CompleteWebAuthnRegistration', ), - createUserPoolDeserializer(), + createUserPoolDeserializer(), { ...DEFAULT_SERVICE_CLIENT_API_CONFIG, ...config, diff --git a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createVerifyWebAuthnRegistrationResultClient.ts b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createStartWebAuthnRegistrationClient.ts similarity index 65% rename from packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createVerifyWebAuthnRegistrationResultClient.ts rename to packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createStartWebAuthnRegistrationClient.ts index db694ed6dfe..453efccd8f0 100644 --- a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createVerifyWebAuthnRegistrationResultClient.ts +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/createStartWebAuthnRegistrationClient.ts @@ -5,8 +5,8 @@ import { composeServiceApi } from '@aws-amplify/core/internals/aws-client-utils/ import { ServiceClientFactoryInput, - VerifyWebAuthnRegistrationResultCommandInput, - VerifyWebAuthnRegistrationResultCommandOutput, + StartWebAuthnRegistrationCommandInput, + StartWebAuthnRegistrationCommandOutput, } from './types'; import { cognitoUserPoolTransferHandler } from './shared/handler'; import { @@ -15,15 +15,15 @@ import { } from './shared/serde'; import { DEFAULT_SERVICE_CLIENT_API_CONFIG } from './constants'; -export const createVerifyWebAuthnRegistrationResultClient = ( +export const createStartWebAuthnRegistrationClient = ( config: ServiceClientFactoryInput, ) => composeServiceApi( cognitoUserPoolTransferHandler, - createUserPoolSerializer( - 'VerifyWebAuthnRegistrationResult', + createUserPoolSerializer( + 'StartWebAuthnRegistration', ), - createUserPoolDeserializer(), + 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 9b8eff2a714..c8db070223e 100644 --- a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/index.ts +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/index.ts @@ -24,7 +24,7 @@ export { createVerifyUserAttributeClient } from './createVerifyUserAttributeClie export { createUpdateDeviceStatusClient } from './createUpdateDeviceStatusClient'; export { createListDevicesClient } from './createListDevicesClient'; export { createDeleteUserAttributesClient } from './createDeleteUserAttributesClient'; -export { createGetWebAuthnRegistrationOptionsClient } from './createGetWebAuthnRegistrationOptionsClient'; -export { createVerifyWebAuthnRegistrationResultClient } from './createVerifyWebAuthnRegistrationResultClient'; +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 d7371239af4..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 @@ -31,8 +31,8 @@ type ClientOperation = | 'UpdateDeviceStatus' | 'ListDevices' | 'RevokeToken' - | 'GetWebAuthnRegistrationOptions' - | 'VerifyWebAuthnRegistrationResult' + | 'StartWebAuthnRegistration' + | 'CompleteWebAuthnRegistration' | 'ListWebAuthnCredentials' | 'DeleteWebAuthnCredential'; 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 320bc6baab0..f4c5edd9f4d 100644 --- a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/sdk.ts +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/sdk.ts @@ -1773,38 +1773,52 @@ export interface DeleteUserAttributesRequest { export type DeleteUserAttributesResponse = Record; export {}; -/** - *

The request to retrieve WebAuthN registration options.

- */ -export interface GetWebAuthnRegistrationOptionsInput { +export interface StartWebAuthnRegistrationRequest { + /** + * A valid access token that Amazon Cognito issued to the user whose passkey metadata you want to + * generate. + */ AccessToken: string | undefined; } -/** - *

The response containing WebAuthN registration options.

- */ -export interface GetWebAuthnRegistrationOptionsOutput { - CredentialCreationOptions: 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 GetWebAuthnRegistrationOptionsCommandInput = - GetWebAuthnRegistrationOptionsInput; +export type StartWebAuthnRegistrationCommandInput = + StartWebAuthnRegistrationRequest; -export interface GetWebAuthnRegistrationOptionsCommandOutput - extends GetWebAuthnRegistrationOptionsOutput, +export interface StartWebAuthnRegistrationCommandOutput + extends StartWebAuthnRegistrationResponse, __MetadataBearer {} -/** - *

The request to verify a WebAuthN credential.

- */ -export interface VerifyWebAuthnRegistrationResultInput { +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; - Credential: string | undefined; + + /** + * A RegistrationResponseJSON public-key credential response from the + * user's passkey provider. + */ + Credential: Record | undefined; } -export type VerifyWebAuthnRegistrationResultCommandInput = - VerifyWebAuthnRegistrationResultInput; +export type CompleteWebAuthnRegistrationResponse = Record; -export type VerifyWebAuthnRegistrationResultCommandOutput = __MetadataBearer; +export type CompleteWebAuthnRegistrationCommandInput = + CompleteWebAuthnRegistrationRequest; + +export interface CompleteWebAuthnRegistrationCommandOutput + extends CompleteWebAuthnRegistrationResponse, + __MetadataBearer {} /** *

The request to list WebAuthN credentials.

diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index 11957b39dc1..39f43d7b142 100644 --- a/packages/aws-amplify/package.json +++ b/packages/aws-amplify/package.json @@ -461,7 +461,7 @@ "name": "[Auth] Associate WebAuthN Credential (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ associateWebAuthnCredential }", - "limit": "13.05 kB" + "limit": "13.07 kB" }, { "name": "[Auth] List WebAuthN Credentials (Cognito)", diff --git a/packages/core/src/Platform/types.ts b/packages/core/src/Platform/types.ts index 3a91b5d1191..351f04603c3 100644 --- a/packages/core/src/Platform/types.ts +++ b/packages/core/src/Platform/types.ts @@ -90,8 +90,8 @@ export enum AuthAction { FetchDevices = '34', SendUserAttributeVerificationCode = '35', SignInWithRedirect = '36', - GetWebAuthnRegistrationOptions = '37', - VerifyWebAuthnRegistrationResult = '38', + StartWebAuthnRegistration = '37', + CompleteWebAuthnRegistration = '38', ListWebAuthnCredentials = '39', DeleteWebAuthnCredential = '40', } From c5b42e06e929078319f3fdd941c8886d1088ee60 Mon Sep 17 00:00:00 2001 From: James Jarvis Date: Mon, 11 Nov 2024 20:14:00 -0800 Subject: [PATCH 15/25] update exception mapping (#15) --- .../client/apis/associateWebAuthnCredential.ts | 8 ++++---- .../cognitoIdentityProvider/types/errors.ts | 16 ++++++++++------ 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/auth/src/client/apis/associateWebAuthnCredential.ts b/packages/auth/src/client/apis/associateWebAuthnCredential.ts index 09adf0b4ec8..d95dca05651 100644 --- a/packages/auth/src/client/apis/associateWebAuthnCredential.ts +++ b/packages/auth/src/client/apis/associateWebAuthnCredential.ts @@ -8,8 +8,8 @@ import { } from '@aws-amplify/core/internals/utils'; import { - GetWebAuthnRegistrationOptionsException, - VerifyWebAuthnRegistrationResultException, + CompleteWebAuthnRegistrationException, + StartWebAuthnRegistrationException, } from '../../foundation/factories/serviceClients/cognitoIdentityProvider/types'; import { assertAuthTokens } from '../../providers/cognito/utils/types'; import { createCognitoUserPoolEndpointResolver } from '../../providers/cognito/factories'; @@ -32,9 +32,9 @@ import { assertValidCredentialCreationOptions } from '../utils/passkey/types'; * - Thrown when intermediate state is invalid * @throws - {@link AuthError}: * - Thrown when user is unauthenticated - * @throws - {@link GetWebAuthnRegistrationOptionsException} + * @throws - {@link StartWebAuthnRegistrationException} * - Thrown due to a service error retrieving WebAuthn registration options - * @throws - {@link VerifyWebAuthnRegistrationResultException} + * @throws - {@link CompleteWebAuthnRegistrationException} * - Thrown due to a service error when verifying WebAuthn registration result */ export async function associateWebAuthnCredential(): Promise { diff --git a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/errors.ts b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/errors.ts index 944a71d1e01..3a45bd9abbf 100644 --- a/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/errors.ts +++ b/packages/auth/src/foundation/factories/serviceClients/cognitoIdentityProvider/types/errors.ts @@ -1,27 +1,30 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -export enum GetWebAuthnRegistrationOptionsException { +export enum StartWebAuthnRegistrationException { ForbiddenException = 'ForbiddenException', InternalErrorException = 'InternalErrorException', InvalidParameterException = 'InvalidParameterException', - InvalidWebAuthnConfigurationException = 'InvalidWebAuthnConfigurationException', LimitExceededException = 'LimitExceededException', NotAuthorizedException = 'NotAuthorizedException', TooManyRequestsException = 'TooManyRequestsException', WebAuthnNotEnabledException = 'WebAuthnNotEnabledException', + WebAuthnConfigurationMissingException = 'WebAuthnConfigurationMissingException', } -export enum VerifyWebAuthnRegistrationResultException { - CredentialAlreadyExistsException = 'CredentialAlreadyExistsException', +export enum CompleteWebAuthnRegistrationException { ForbiddenException = 'ForbiddenException', InternalErrorException = 'InternalErrorException', InvalidParameterException = 'InvalidParameterException', + LimitExceededException = 'LimitExceededException', NotAuthorizedException = 'NotAuthorizedException', TooManyRequestsException = 'TooManyRequestsException', - WebAuthnAuthenticatorSelectionMismatchException = 'WebAuthnAuthenticatorSelectionMismatchException', - WebAuthnChallengeMismatchException = 'WebAuthnChallengeMismatchException', + WebAuthnNotEnabledException = 'WebAuthnNotEnabledException', + WebAuthnChallengeNotFoundException = 'WebAuthnChallengeNotFoundException', WebAuthnRelyingPartyMismatchException = 'WebAuthnRelyingPartyMismatchException', + WebAuthnClientMismatchException = 'WebAuthnClientMismatchException', + WebAuthnOriginNotAllowedException = 'WebAuthnOriginNotAllowedException', + WebAuthnCredentialNotSupportedException = 'WebAuthnCredentialNotSupportedException', } export enum ListWebAuthnCredentialsException { @@ -36,4 +39,5 @@ export enum DeleteWebAuthnCredentialException { InternalErrorException = 'InternalErrorException', InvalidParameterException = 'InvalidParameterException', NotAuthorizedException = 'NotAuthorizedException', + ResourceNotFoundException = 'ResourceNotFoundException', } From b6b474c1bcdd8f3a0d37bcb1af7253c3cd86c207 Mon Sep 17 00:00:00 2001 From: James Jarvis Date: Tue, 12 Nov 2024 10:49:29 -0800 Subject: [PATCH 16/25] feat(auth): passwordless webauthn ceremony errors (#16) * update exception mapping * update passkey error handling * update tests * bundle size tests * simplify language * refine error messages --- .../apis/associateWebAuthnCredential.test.ts | 2 +- .../userAuth/handleWebAuthnSignInResult.ts | 2 +- .../auth/src/client/utils/passkey/errors.ts | 190 ++++++++++++++++-- .../src/client/utils/passkey/getPasskey.ts | 29 ++- .../client/utils/passkey/registerPasskey.ts | 29 ++- .../src/client/utils/passkey/types/shared.ts | 2 +- packages/aws-amplify/package.json | 4 +- 7 files changed, 217 insertions(+), 41 deletions(-) diff --git a/packages/auth/__tests__/client/apis/associateWebAuthnCredential.test.ts b/packages/auth/__tests__/client/apis/associateWebAuthnCredential.test.ts index a9ca5d13585..bae6e6ec77f 100644 --- a/packages/auth/__tests__/client/apis/associateWebAuthnCredential.test.ts +++ b/packages/auth/__tests__/client/apis/associateWebAuthnCredential.test.ts @@ -173,7 +173,7 @@ describe('associateWebAuthnCredential', () => { } catch (error: any) { expect(error).toBeInstanceOf(PasskeyError); expect(error.name).toBe( - PasskeyErrorCode.InvalidCredentialCreationOptions, + PasskeyErrorCode.InvalidPasskeyRegistrationOptions, ); } }); diff --git a/packages/auth/src/client/flows/userAuth/handleWebAuthnSignInResult.ts b/packages/auth/src/client/flows/userAuth/handleWebAuthnSignInResult.ts index 2364c22291a..2e67a52a5ab 100644 --- a/packages/auth/src/client/flows/userAuth/handleWebAuthnSignInResult.ts +++ b/packages/auth/src/client/flows/userAuth/handleWebAuthnSignInResult.ts @@ -55,7 +55,7 @@ export async function handleWebAuthnSignInResult( assertPasskeyError( !!credentialRequestOptions, - PasskeyErrorCode.InvalidCredentialRequestOptions, + PasskeyErrorCode.InvalidPasskeyAuthenticationOptions, ); const cred = await getPasskey(JSON.parse(credentialRequestOptions)); diff --git a/packages/auth/src/client/utils/passkey/errors.ts b/packages/auth/src/client/utils/passkey/errors.ts index b746b2e8e1a..2b91c3fdb28 100644 --- a/packages/auth/src/client/utils/passkey/errors.ts +++ b/packages/auth/src/client/utils/passkey/errors.ts @@ -3,6 +3,7 @@ import { AmplifyError, + AmplifyErrorCode, AmplifyErrorMap, AmplifyErrorParams, AssertionFunction, @@ -21,40 +22,193 @@ export class PasskeyError extends AmplifyError { } export enum PasskeyErrorCode { + // not supported PasskeyNotSupported = 'PasskeyNotSupported', - InvalidCredentialCreationOptions = 'InvalidCredentialCreationOptions', - InvalidCredentialRequestOptions = 'InvalidCredentialRequestOptions', + // 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 configured to support the WEB_AUTHN as an authentication factor.'; + const passkeyErrorMap: AmplifyErrorMap = { [PasskeyErrorCode.PasskeyNotSupported]: { - message: 'Passkey not supported on this device.', - recoverySuggestion: - 'Ensure your application is running in a secure context (HTTPS).', + message: 'Passkeys may not be supported on this device.', + recoverySuggestion: notSupportedRecoverySuggestion, }, - [PasskeyErrorCode.InvalidCredentialCreationOptions]: { - message: 'Invalid credential creation options.', - recoverySuggestion: - 'Ensure your user pool is configured to support WebAuthN passkey registration', + [PasskeyErrorCode.InvalidPasskeyRegistrationOptions]: { + message: 'Invalid passkey registration options.', + recoverySuggestion: misconfigurationRecoverySuggestion, }, - [PasskeyErrorCode.InvalidCredentialRequestOptions]: { - message: 'Invalid credential request options.', - recoverySuggestion: - 'User pool may not be configured to support WEB_AUTHN authentication factor.', + [PasskeyErrorCode.InvalidPasskeyAuthenticationOptions]: { + message: 'Invalid passkey authentication options.', + recoverySuggestion: misconfigurationRecoverySuggestion, }, [PasskeyErrorCode.PasskeyRegistrationFailed]: { - message: 'Device failed to create credentials.', - recoverySuggestion: - 'Credentials may not be supported on this device. Ensure your browser is up to date and the Web Authentication API is supported.', + message: 'Device failed to create passkey.', + recoverySuggestion: notSupportedRecoverySuggestion, }, [PasskeyErrorCode.PasskeyRetrievalFailed]: { - message: 'Device failed to retrieve credentials.', + 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: - 'Credentials may not be available on this device. Try an alternative authentication factor like PASSWORD, EMAIL_OTP, or SMS_OTP.', + '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/getPasskey.ts b/packages/auth/src/client/utils/passkey/getPasskey.ts index 5afdec24b77..8a3ee7f3d6e 100644 --- a/packages/auth/src/client/utils/passkey/getPasskey.ts +++ b/packages/auth/src/client/utils/passkey/getPasskey.ts @@ -1,7 +1,11 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { PasskeyErrorCode, assertPasskeyError } from './errors'; +import { + PasskeyErrorCode, + assertPasskeyError, + handlePasskeyAuthenticationError, +} from './errors'; import { getIsPasskeySupported } from './getIsPasskeySupported'; import { deserializeJsonToPkcGetOptions, @@ -13,17 +17,24 @@ import { } from './types'; export const getPasskey = async (input: PasskeyGetOptionsJson) => { - const isPasskeySupported = getIsPasskeySupported(); + try { + const isPasskeySupported = getIsPasskeySupported(); - assertPasskeyError(isPasskeySupported, PasskeyErrorCode.PasskeyNotSupported); + assertPasskeyError( + isPasskeySupported, + PasskeyErrorCode.PasskeyNotSupported, + ); - const passkeyGetOptions = deserializeJsonToPkcGetOptions(input); + const passkeyGetOptions = deserializeJsonToPkcGetOptions(input); - const credential = await navigator.credentials.get({ - publicKey: passkeyGetOptions, - }); + const credential = await navigator.credentials.get({ + publicKey: passkeyGetOptions, + }); - assertCredentialIsPkcWithAuthenticatorAssertionResponse(credential); + assertCredentialIsPkcWithAuthenticatorAssertionResponse(credential); - return serializePkcWithAssertionToJson(credential); + return serializePkcWithAssertionToJson(credential); + } catch (err: unknown) { + throw handlePasskeyAuthenticationError(err); + } }; diff --git a/packages/auth/src/client/utils/passkey/registerPasskey.ts b/packages/auth/src/client/utils/passkey/registerPasskey.ts index e1e522aacb5..88ae3eacf2e 100644 --- a/packages/auth/src/client/utils/passkey/registerPasskey.ts +++ b/packages/auth/src/client/utils/passkey/registerPasskey.ts @@ -10,7 +10,11 @@ import { deserializeJsonToPkcCreationOptions, serializePkcWithAttestationToJson, } from './serde'; -import { PasskeyErrorCode, assertPasskeyError } from './errors'; +import { + PasskeyErrorCode, + assertPasskeyError, + handlePasskeyRegistrationError, +} from './errors'; import { getIsPasskeySupported } from './getIsPasskeySupported'; /** @@ -21,17 +25,24 @@ import { getIsPasskeySupported } from './getIsPasskeySupported'; export const registerPasskey = async ( input: PasskeyCreateOptionsJson, ): Promise => { - const isPasskeySupported = getIsPasskeySupported(); + try { + const isPasskeySupported = getIsPasskeySupported(); - assertPasskeyError(isPasskeySupported, PasskeyErrorCode.PasskeyNotSupported); + assertPasskeyError( + isPasskeySupported, + PasskeyErrorCode.PasskeyNotSupported, + ); - const passkeyCreationOptions = deserializeJsonToPkcCreationOptions(input); + const passkeyCreationOptions = deserializeJsonToPkcCreationOptions(input); - const credential = await navigator.credentials.create({ - publicKey: passkeyCreationOptions, - }); + const credential = await navigator.credentials.create({ + publicKey: passkeyCreationOptions, + }); - assertCredentialIsPkcWithAuthenticatorAttestationResponse(credential); + assertCredentialIsPkcWithAuthenticatorAttestationResponse(credential); - return serializePkcWithAttestationToJson(credential); + return serializePkcWithAttestationToJson(credential); + } catch (err) { + throw handlePasskeyRegistrationError(err); + } }; diff --git a/packages/auth/src/client/utils/passkey/types/shared.ts b/packages/auth/src/client/utils/passkey/types/shared.ts index 648ae89f4bb..f684b08e9a6 100644 --- a/packages/auth/src/client/utils/passkey/types/shared.ts +++ b/packages/auth/src/client/utils/passkey/types/shared.ts @@ -88,7 +88,7 @@ export function assertValidCredentialCreationOptions( !!credentialCreationOptions?.rp, !!credentialCreationOptions?.pubKeyCredParams, ].every(Boolean), - PasskeyErrorCode.InvalidCredentialCreationOptions, + PasskeyErrorCode.InvalidPasskeyRegistrationOptions, ); } diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index 39f43d7b142..8cf65e99f59 100644 --- a/packages/aws-amplify/package.json +++ b/packages/aws-amplify/package.json @@ -449,7 +449,7 @@ "name": "[Auth] Basic Auth Flow (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ signIn, signOut, fetchAuthSession, confirmSignIn }", - "limit": "30.23 kB" + "limit": "30.34 kB" }, { "name": "[Auth] OAuth Auth Flow (Cognito)", @@ -461,7 +461,7 @@ "name": "[Auth] Associate WebAuthN Credential (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ associateWebAuthnCredential }", - "limit": "13.07 kB" + "limit": "13.50 kB" }, { "name": "[Auth] List WebAuthN Credentials (Cognito)", From 19519f2ac75468dbc9b1662df42aafa5955bfded Mon Sep 17 00:00:00 2001 From: James Jarvis Date: Tue, 19 Nov 2024 12:44:28 -0800 Subject: [PATCH 17/25] fix(auth): clear auto sign in store on sign in (#18) * fix(auth): clear auto sign in store on sign in * add unit test --- .../autoSignInUserConfirmed.test.ts | 65 +++++++++++++++++++ .../auth/src/providers/cognito/apis/signIn.ts | 2 + .../providers/cognito/utils/signUpHelpers.ts | 7 +- 3 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 packages/auth/__tests__/providers/cognito/utils/signUpHelpers/autoSignInUserConfirmed.test.ts 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/providers/cognito/apis/signIn.ts b/packages/auth/src/providers/cognito/apis/signIn.ts index c1677f971d5..0dd23cec92d 100644 --- a/packages/auth/src/providers/cognito/apis/signIn.ts +++ b/packages/auth/src/providers/cognito/apis/signIn.ts @@ -8,6 +8,7 @@ import { import { assertUserNotAuthenticated } from '../utils/signInHelpers'; import { SignInInput, SignInOutput } from '../types'; import { AuthValidationErrorCode } from '../../../errors/types/validation'; +import { autoSignInStore } from '../../../client/utils/store'; import { signInWithCustomAuth } from './signInWithCustomAuth'; import { signInWithCustomSRPAuth } from './signInWithCustomSRPAuth'; @@ -27,6 +28,7 @@ import { signInWithUserAuth } from './signInWithUserAuth'; * @throws AuthTokenConfigException - Thrown when the token provider config is invalid. */ export async function signIn(input: SignInInput): Promise { + autoSignInStore.dispatch({ type: 'RESET' }); const authFlowType = input.options?.authFlowType; await assertUserNotAuthenticated(); switch (authFlowType) { diff --git a/packages/auth/src/providers/cognito/utils/signUpHelpers.ts b/packages/auth/src/providers/cognito/utils/signUpHelpers.ts index 47c57c135c0..0725f9046f1 100644 --- a/packages/auth/src/providers/cognito/utils/signUpHelpers.ts +++ b/packages/auth/src/providers/cognito/utils/signUpHelpers.ts @@ -11,6 +11,7 @@ import { AuthError } from '../../../errors/AuthError'; import { resetAutoSignIn, setAutoSignIn } from '../apis/autoSignIn'; import { AUTO_SIGN_IN_EXCEPTION } from '../../../errors/constants'; import { autoSignInStore } from '../../../client/utils/store'; +import { signInWithUserAuth } from '../apis/signInWithUserAuth'; const MAX_AUTOSIGNIN_POLLING_MS = 3 * 60 * 1000; @@ -123,7 +124,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) { From 58a86502ca2172e349d8e054cc1a9e8981a10ba0 Mon Sep 17 00:00:00 2001 From: James Jarvis Date: Tue, 19 Nov 2024 13:56:45 -0800 Subject: [PATCH 18/25] feat(auth): refactor foundational APIs to not access singleton (#17) * enable ssr list and delete web authn credentials * update unit tests * add foundation tests * revert: expose server APIs --- .../apis/deleteWebAuthnCredential.test.ts | 29 +++++++------- .../apis/listWebAuthnCredentials.test.ts | 39 ++++++++++--------- .../client/apis/deleteWebAuthnCredential.ts | 24 ++++++++++++ packages/auth/src/client/apis/index.ts | 2 + .../client/apis/listWebAuthnCredentials.ts | 28 +++++++++++++ .../apis/deleteWebAuthnCredential.ts | 18 ++------- .../apis/listWebAuthnCredentials.ts | 19 +++------ packages/auth/src/index.ts | 2 +- 8 files changed, 98 insertions(+), 63 deletions(-) create mode 100644 packages/auth/src/client/apis/deleteWebAuthnCredential.ts create mode 100644 packages/auth/src/client/apis/listWebAuthnCredentials.ts diff --git a/packages/auth/__tests__/foundation/apis/deleteWebAuthnCredential.test.ts b/packages/auth/__tests__/foundation/apis/deleteWebAuthnCredential.test.ts index a1eaaae117d..c4726e93692 100644 --- a/packages/auth/__tests__/foundation/apis/deleteWebAuthnCredential.test.ts +++ b/packages/auth/__tests__/foundation/apis/deleteWebAuthnCredential.test.ts @@ -1,17 +1,22 @@ -import { Amplify, fetchAuthSession } from '@aws-amplify/core'; +import { Amplify } from '@aws-amplify/core'; import { decodeJWT } from '@aws-amplify/core/internals/utils'; import { createDeleteWebAuthnCredentialClient } from '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; -import { - DeleteWebAuthnCredentialInput, - deleteWebAuthnCredential, -} from '../../../src/'; +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(() => ({})) }, + 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'), @@ -23,8 +28,6 @@ jest.mock( jest.mock('../../../src/providers/cognito/factories'); describe('deleteWebAuthnCredential', () => { - const mockFetchAuthSession = jest.mocked(fetchAuthSession); - const mockDeleteWebAuthnCredential = jest.fn(); const mockCreateDeleteWebAuthnCredentialClient = jest.mocked( createDeleteWebAuthnCredentialClient, @@ -32,24 +35,18 @@ describe('deleteWebAuthnCredential', () => { beforeAll(() => { setUpGetConfig(Amplify); - mockFetchAuthSession.mockResolvedValue({ - tokens: { accessToken: decodeJWT(mockAccessToken) }, - }); + mockCreateDeleteWebAuthnCredentialClient.mockReturnValue( mockDeleteWebAuthnCredential, ); }); - afterEach(() => { - mockFetchAuthSession.mockClear(); - }); - it('should pass correct service options when deleting a credential', async () => { const input: DeleteWebAuthnCredentialInput = { credentialId: 'dummyId', }; - await deleteWebAuthnCredential(input); + await deleteWebAuthnCredential(Amplify, input); expect(mockDeleteWebAuthnCredential).toHaveBeenCalledWith( { diff --git a/packages/auth/__tests__/foundation/apis/listWebAuthnCredentials.test.ts b/packages/auth/__tests__/foundation/apis/listWebAuthnCredentials.test.ts index 1d562904440..f0708aa06e2 100644 --- a/packages/auth/__tests__/foundation/apis/listWebAuthnCredentials.test.ts +++ b/packages/auth/__tests__/foundation/apis/listWebAuthnCredentials.test.ts @@ -1,18 +1,23 @@ -import { Amplify, fetchAuthSession } from '@aws-amplify/core'; +import { Amplify } from '@aws-amplify/core'; import { decodeJWT } from '@aws-amplify/core/internals/utils'; import { createListWebAuthnCredentialsClient } from '../../../src/foundation/factories/serviceClients/cognitoIdentityProvider'; -import { - ListWebAuthnCredentialsInput, - listWebAuthnCredentials, -} from '../../../src/'; +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(() => ({})) }, + 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'), @@ -24,8 +29,6 @@ jest.mock( jest.mock('../../../src/providers/cognito/factories'); describe('listWebAuthnCredentials', () => { - const mockFetchAuthSession = jest.mocked(fetchAuthSession); - const mockListWebAuthnCredentials = jest.fn(); const mockCreateListWebAuthnCredentialsClient = jest.mocked( createListWebAuthnCredentialsClient, @@ -33,9 +36,7 @@ describe('listWebAuthnCredentials', () => { beforeAll(() => { setUpGetConfig(Amplify); - mockFetchAuthSession.mockResolvedValue({ - tokens: { accessToken: decodeJWT(mockAccessToken) }, - }); + mockCreateListWebAuthnCredentialsClient.mockReturnValue( mockListWebAuthnCredentials, ); @@ -51,12 +52,8 @@ describe('listWebAuthnCredentials', () => { }); }); - afterEach(() => { - mockFetchAuthSession.mockClear(); - }); - it('should pass correct service options when listing credentials', async () => { - await listWebAuthnCredentials(); + await listWebAuthnCredentials(Amplify); expect(mockListWebAuthnCredentials).toHaveBeenCalledWith( { @@ -74,7 +71,10 @@ describe('listWebAuthnCredentials', () => { pageSize: 3, }; - const { credentials, nextToken } = await listWebAuthnCredentials(input); + const { credentials, nextToken } = await listWebAuthnCredentials( + Amplify, + input, + ); expect(mockListWebAuthnCredentials).toHaveBeenCalledWith( { @@ -116,7 +116,10 @@ describe('listWebAuthnCredentials', () => { nextToken: 'exampleToken', }; - const { credentials, nextToken } = await listWebAuthnCredentials(input); + const { credentials, nextToken } = await listWebAuthnCredentials( + Amplify, + input, + ); expect(mockListWebAuthnCredentials).toHaveBeenCalledWith( { 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 index 0bc604f0beb..dd3d1acb548 100644 --- a/packages/auth/src/client/apis/index.ts +++ b/packages/auth/src/client/apis/index.ts @@ -2,3 +2,5 @@ // 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/foundation/apis/deleteWebAuthnCredential.ts b/packages/auth/src/foundation/apis/deleteWebAuthnCredential.ts index 872422813eb..c47b13ea303 100644 --- a/packages/auth/src/foundation/apis/deleteWebAuthnCredential.ts +++ b/packages/auth/src/foundation/apis/deleteWebAuthnCredential.ts @@ -1,37 +1,27 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { Amplify, fetchAuthSession } from '@aws-amplify/core'; +import { AmplifyClassV6 } from '@aws-amplify/core'; import { AuthAction, assertTokenProviderConfig, } from '@aws-amplify/core/internals/utils'; -import { DeleteWebAuthnCredentialException } from '../factories/serviceClients/cognitoIdentityProvider/types'; 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'; -import { AuthError } from '../../errors/AuthError'; -/** - * Delete a registered credential for an authenticated user by 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( + amplify: AmplifyClassV6, input: DeleteWebAuthnCredentialInput, ): Promise { - const authConfig = Amplify.getConfig().Auth?.Cognito; + const authConfig = amplify.getConfig().Auth?.Cognito; assertTokenProviderConfig(authConfig); const { userPoolEndpoint, userPoolId } = authConfig; - const { tokens } = await fetchAuthSession(); + const { tokens } = await amplify.Auth.fetchAuthSession(); assertAuthTokens(tokens); const deleteWebAuthnCredentialResult = createDeleteWebAuthnCredentialClient({ diff --git a/packages/auth/src/foundation/apis/listWebAuthnCredentials.ts b/packages/auth/src/foundation/apis/listWebAuthnCredentials.ts index 16ffed9c152..5016833bdc6 100644 --- a/packages/auth/src/foundation/apis/listWebAuthnCredentials.ts +++ b/packages/auth/src/foundation/apis/listWebAuthnCredentials.ts @@ -1,13 +1,12 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { Amplify, fetchAuthSession } from '@aws-amplify/core'; +import { AmplifyClassV6 } from '@aws-amplify/core'; import { AuthAction, assertTokenProviderConfig, } from '@aws-amplify/core/internals/utils'; -import { ListWebAuthnCredentialsException } from '../factories/serviceClients/cognitoIdentityProvider/types'; import { assertAuthTokens } from '../../providers/cognito/utils/types'; import { createCognitoUserPoolEndpointResolver } from '../../providers/cognito/factories'; import { getRegionFromUserPoolId } from '../parsers'; @@ -18,24 +17,16 @@ import { ListWebAuthnCredentialsInput, ListWebAuthnCredentialsOutput, } from '../types'; -import { AuthError } from '../../errors/AuthError'; -/** - * Lists registered credentials for an authenticated user - * - * @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( + amplify: AmplifyClassV6, input?: ListWebAuthnCredentialsInput, ): Promise { - const authConfig = Amplify.getConfig().Auth?.Cognito; + const authConfig = amplify.getConfig().Auth?.Cognito; assertTokenProviderConfig(authConfig); const { userPoolEndpoint, userPoolId } = authConfig; - const { tokens } = await fetchAuthSession(); + + const { tokens } = await amplify.Auth.fetchAuthSession(); assertAuthTokens(tokens); const listWebAuthnCredentialsResult = createListWebAuthnCredentialsClient({ diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 886bd2ff2d7..0ca9948aa9b 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -93,7 +93,7 @@ export { associateWebAuthnCredential } from './client/apis'; export { listWebAuthnCredentials, deleteWebAuthnCredential, -} from './foundation/apis'; +} from './client/apis'; export { AuthWebAuthnCredential, From 34c57433659914c1cd84bf58835bbbac43020ab5 Mon Sep 17 00:00:00 2001 From: James Jarvis Date: Wed, 20 Nov 2024 15:39:04 -0800 Subject: [PATCH 19/25] feat(auth): passwordless - enable test specs / push trigger (#19) * enable test specs / push trigger * check for PublicKeyCredential * bundle size tests * fix recovery suggestion language * align assertion with expected type * fix tsdocs --- .github/integ-config/integ-all.yml | 56 +++++++++++++++++++ .github/workflows/push-integ-test.yml | 2 +- .../apis/associateWebAuthnCredential.ts | 2 +- .../auth/src/client/utils/passkey/errors.ts | 2 +- .../utils/passkey/getIsPasskeySupported.ts | 7 ++- .../src/client/utils/passkey/types/shared.ts | 1 + packages/aws-amplify/package.json | 2 +- 7 files changed, 67 insertions(+), 5 deletions(-) diff --git a/.github/integ-config/integ-all.yml b/.github/integ-config/integ-all.yml index 94a2d85a157..36ad0a20235 100644 --- a/.github/integ-config/integ-all.yml +++ b/.github/integ-config/integ-all.yml @@ -915,3 +915,59 @@ 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 + 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 + 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 + 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 + 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 + 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/.github/workflows/push-integ-test.yml b/.github/workflows/push-integ-test.yml index 03e43dd2865..680aa6b7d06 100644 --- a/.github/workflows/push-integ-test.yml +++ b/.github/workflows/push-integ-test.yml @@ -8,7 +8,7 @@ concurrency: on: push: branches: - - replace-with-your-branch + - feat/passwordless jobs: e2e: diff --git a/packages/auth/src/client/apis/associateWebAuthnCredential.ts b/packages/auth/src/client/apis/associateWebAuthnCredential.ts index d95dca05651..caf8307f447 100644 --- a/packages/auth/src/client/apis/associateWebAuthnCredential.ts +++ b/packages/auth/src/client/apis/associateWebAuthnCredential.ts @@ -27,7 +27,7 @@ import { assertValidCredentialCreationOptions } from '../utils/passkey/types'; /** * Registers a new passkey for an authenticated user * - * @returns Promise + * @returns Promise * @throws - {@link PasskeyError}: * - Thrown when intermediate state is invalid * @throws - {@link AuthError}: diff --git a/packages/auth/src/client/utils/passkey/errors.ts b/packages/auth/src/client/utils/passkey/errors.ts index 2b91c3fdb28..288cb14e810 100644 --- a/packages/auth/src/client/utils/passkey/errors.ts +++ b/packages/auth/src/client/utils/passkey/errors.ts @@ -44,7 +44,7 @@ const notSupportedRecoverySuggestion = const abortOrCancelRecoverySuggestion = 'User may have canceled the ceremony or another interruption has occurred. Check underlying error for details.'; const misconfigurationRecoverySuggestion = - 'Ensure your user pool configured to support the WEB_AUTHN as an authentication factor.'; + 'Ensure your user pool is configured to support the WEB_AUTHN as an authentication factor.'; const passkeyErrorMap: AmplifyErrorMap = { [PasskeyErrorCode.PasskeyNotSupported]: { diff --git a/packages/auth/src/client/utils/passkey/getIsPasskeySupported.ts b/packages/auth/src/client/utils/passkey/getIsPasskeySupported.ts index 1934d7f86f2..c0d3674a8a4 100644 --- a/packages/auth/src/client/utils/passkey/getIsPasskeySupported.ts +++ b/packages/auth/src/client/utils/passkey/getIsPasskeySupported.ts @@ -9,5 +9,10 @@ import { isBrowser } from '@aws-amplify/core/internals/utils'; * @returns boolean */ export const getIsPasskeySupported = (): boolean => { - return isBrowser() && window.isSecureContext && 'credentials' in navigator; + return ( + isBrowser() && + window.isSecureContext && + 'credentials' in navigator && + typeof window.PublicKeyCredential === 'function' + ); }; diff --git a/packages/auth/src/client/utils/passkey/types/shared.ts b/packages/auth/src/client/utils/passkey/types/shared.ts index f684b08e9a6..847118d7e25 100644 --- a/packages/auth/src/client/utils/passkey/types/shared.ts +++ b/packages/auth/src/client/utils/passkey/types/shared.ts @@ -84,6 +84,7 @@ export function assertValidCredentialCreationOptions( assertPasskeyError( [ !!credentialCreationOptions, + !!credentialCreationOptions?.challenge, !!credentialCreationOptions?.user, !!credentialCreationOptions?.rp, !!credentialCreationOptions?.pubKeyCredParams, diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index 8cf65e99f59..5e06af1eecb 100644 --- a/packages/aws-amplify/package.json +++ b/packages/aws-amplify/package.json @@ -449,7 +449,7 @@ "name": "[Auth] Basic Auth Flow (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ signIn, signOut, fetchAuthSession, confirmSignIn }", - "limit": "30.34 kB" + "limit": "30.35 kB" }, { "name": "[Auth] OAuth Auth Flow (Cognito)", From 6fadcdfcfc765af168d9c7aef13085fd71f26558 Mon Sep 17 00:00:00 2001 From: James Jarvis Date: Wed, 20 Nov 2024 16:02:44 -0800 Subject: [PATCH 20/25] bundle size updates --- packages/aws-amplify/package.json | 36 +++++++++++++++---------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index 8b45df6c12e..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.35 kB" + "limit": "30.56 kB" }, { "name": "[Auth] OAuth Auth Flow (Cognito)", @@ -461,19 +461,19 @@ "name": "[Auth] Associate WebAuthN Credential (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ associateWebAuthnCredential }", - "limit": "13.50 kB" + "limit": "13.55 kB" }, { "name": "[Auth] List WebAuthN Credentials (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ listWebAuthnCredentials }", - "limit": "12.10 kB" + "limit": "12.14 kB" }, { "name": "[Auth] Delete WebAuthN Credential (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ deleteWebAuthnCredential }", - "limit": "11.95 kB" + "limit": "12.01 kB" }, { "name": "[Storage] copy (S3)", @@ -503,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)", From b25316677f4c6a6bb6f0b0eade4c75c5709be006 Mon Sep 17 00:00:00 2001 From: James Jarvis Date: Fri, 22 Nov 2024 10:16:14 -0800 Subject: [PATCH 21/25] fix(auth): passwordless pr feedback (#22) * callout in ts docs for password requirement * unify callback and store reset for autosignin * comment for clarity --- .../auth/__tests__/providers/cognito/autoSignIn.test.ts | 2 -- packages/auth/src/providers/cognito/apis/autoSignIn.ts | 8 ++++++-- .../auth/src/providers/cognito/apis/confirmSignUp.ts | 1 - packages/auth/src/providers/cognito/apis/signIn.ts | 9 +++++++-- .../auth/src/providers/cognito/apis/signInWithSRP.ts | 6 ++---- .../src/providers/cognito/apis/signInWithUserAuth.ts | 5 ++--- .../src/providers/cognito/apis/signInWithUserPassword.ts | 8 +++----- .../auth/src/providers/cognito/utils/signUpHelpers.ts | 5 ----- packages/auth/src/types/inputs.ts | 2 +- 9 files changed, 21 insertions(+), 25 deletions(-) diff --git a/packages/auth/__tests__/providers/cognito/autoSignIn.test.ts b/packages/auth/__tests__/providers/cognito/autoSignIn.test.ts index 19a163dff5f..05389b40773 100644 --- a/packages/auth/__tests__/providers/cognito/autoSignIn.test.ts +++ b/packages/auth/__tests__/providers/cognito/autoSignIn.test.ts @@ -84,7 +84,6 @@ describe('autoSignIn()', () => { mockCreateSignUpClient.mockClear(); handleUserSRPAuthFlowSpy.mockClear(); - autoSignInStore.dispatch({ type: 'RESET' }); resetAutoSignIn(); }); @@ -164,7 +163,6 @@ describe('autoSignIn()', () => { mockHandleUserAuthFlow.mockClear(); mockCreateConfirmSignUpClient.mockClear(); - autoSignInStore.dispatch({ type: 'RESET' }); resetAutoSignIn(); }); 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/confirmSignUp.ts b/packages/auth/src/providers/cognito/apis/confirmSignUp.ts index 41cb1f7a141..c9633531908 100644 --- a/packages/auth/src/providers/cognito/apis/confirmSignUp.ts +++ b/packages/auth/src/providers/cognito/apis/confirmSignUp.ts @@ -93,7 +93,6 @@ export async function confirmSignUp( autoSignInStoreState.username !== username ) { resolve(signUpOut); - autoSignInStore.dispatch({ type: 'RESET' }); resetAutoSignIn(); return; diff --git a/packages/auth/src/providers/cognito/apis/signIn.ts b/packages/auth/src/providers/cognito/apis/signIn.ts index 0dd23cec92d..7fc23cfcc67 100644 --- a/packages/auth/src/providers/cognito/apis/signIn.ts +++ b/packages/auth/src/providers/cognito/apis/signIn.ts @@ -8,13 +8,13 @@ import { import { assertUserNotAuthenticated } from '../utils/signInHelpers'; import { SignInInput, SignInOutput } from '../types'; import { AuthValidationErrorCode } from '../../../errors/types/validation'; -import { autoSignInStore } from '../../../client/utils/store'; 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 @@ -28,7 +28,12 @@ import { signInWithUserAuth } from './signInWithUserAuth'; * @throws AuthTokenConfigException - Thrown when the token provider config is invalid. */ export async function signIn(input: SignInInput): Promise { - autoSignInStore.dispatch({ type: 'RESET' }); + // 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) { diff --git a/packages/auth/src/providers/cognito/apis/signInWithSRP.ts b/packages/auth/src/providers/cognito/apis/signInWithSRP.ts index 43ecc94ed6a..4cff40e7cd7 100644 --- a/packages/auth/src/providers/cognito/apis/signInWithSRP.ts +++ b/packages/auth/src/providers/cognito/apis/signInWithSRP.ts @@ -28,7 +28,6 @@ import { SignInWithSRPOutput, } from '../types'; import { - autoSignInStore, cleanActiveSignInState, setActiveSignInState, } from '../../../client/utils/store'; @@ -93,8 +92,6 @@ export async function signInWithSRP( }); if (AuthenticationResult) { cleanActiveSignInState(); - autoSignInStore.dispatch({ type: 'RESET' }); - resetAutoSignIn(); await cacheCognitoTokens({ username: activeUsername, ...AuthenticationResult, @@ -109,6 +106,8 @@ export async function signInWithSRP( await dispatchSignedInHubEvent(); + resetAutoSignIn(); + return { isSignedIn: true, nextStep: { signInStep: 'DONE' }, @@ -121,7 +120,6 @@ export async function signInWithSRP( }); } catch (error) { cleanActiveSignInState(); - autoSignInStore.dispatch({ type: 'RESET' }); resetAutoSignIn(); assertServiceError(error); const result = getSignInResultFromError(error.name); diff --git a/packages/auth/src/providers/cognito/apis/signInWithUserAuth.ts b/packages/auth/src/providers/cognito/apis/signInWithUserAuth.ts index 9eb731fc593..9ac1223a105 100644 --- a/packages/auth/src/providers/cognito/apis/signInWithUserAuth.ts +++ b/packages/auth/src/providers/cognito/apis/signInWithUserAuth.ts @@ -101,8 +101,6 @@ export async function signInWithUserAuth( if (response.AuthenticationResult) { cleanActiveSignInState(); - autoSignInStore.dispatch({ type: 'RESET' }); - resetAutoSignIn(); await cacheCognitoTokens({ username: activeUsername, ...response.AuthenticationResult, @@ -116,6 +114,8 @@ export async function signInWithUserAuth( }); await dispatchSignedInHubEvent(); + resetAutoSignIn(); + return { isSignedIn: true, nextStep: { signInStep: 'DONE' }, @@ -132,7 +132,6 @@ export async function signInWithUserAuth( }); } catch (error) { cleanActiveSignInState(); - autoSignInStore.dispatch({ type: 'RESET' }); resetAutoSignIn(); assertServiceError(error); const result = getSignInResultFromError(error.name); diff --git a/packages/auth/src/providers/cognito/apis/signInWithUserPassword.ts b/packages/auth/src/providers/cognito/apis/signInWithUserPassword.ts index 488829179c8..0cd3acd88d3 100644 --- a/packages/auth/src/providers/cognito/apis/signInWithUserPassword.ts +++ b/packages/auth/src/providers/cognito/apis/signInWithUserPassword.ts @@ -26,7 +26,6 @@ import { SignInWithUserPasswordOutput, } from '../types'; import { - autoSignInStore, cleanActiveSignInState, setActiveSignInState, } from '../../../client/utils/store'; @@ -87,6 +86,7 @@ export async function signInWithUserPassword( signInDetails, }); if (AuthenticationResult) { + cleanActiveSignInState(); await cacheCognitoTokens({ ...AuthenticationResult, username: activeUsername, @@ -98,12 +98,11 @@ export async function signInWithUserPassword( }), signInDetails, }); - cleanActiveSignInState(); - autoSignInStore.dispatch({ type: 'RESET' }); - resetAutoSignIn(); await dispatchSignedInHubEvent(); + resetAutoSignIn(); + return { isSignedIn: true, nextStep: { signInStep: 'DONE' }, @@ -116,7 +115,6 @@ export async function signInWithUserPassword( }); } catch (error) { cleanActiveSignInState(); - autoSignInStore.dispatch({ type: 'RESET' }); resetAutoSignIn(); assertServiceError(error); const result = getSignInResultFromError(error.name); diff --git a/packages/auth/src/providers/cognito/utils/signUpHelpers.ts b/packages/auth/src/providers/cognito/utils/signUpHelpers.ts index 0725f9046f1..9bebcf4be82 100644 --- a/packages/auth/src/providers/cognito/utils/signUpHelpers.ts +++ b/packages/auth/src/providers/cognito/utils/signUpHelpers.ts @@ -10,7 +10,6 @@ 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 { autoSignInStore } from '../../../client/utils/store'; import { signInWithUserAuth } from '../apis/signInWithUserAuth'; const MAX_AUTOSIGNIN_POLLING_MS = 3 * 60 * 1000; @@ -37,7 +36,6 @@ export function handleCodeAutoSignIn(signInInput: SignInInput) { // This will stop the listener if confirmSignUp is not resolved. const timeOutId = setTimeout(() => { stopHubListener(); - autoSignInStore.dispatch({ type: 'RESET' }); clearTimeout(timeOutId); resetAutoSignIn(); }, MAX_AUTOSIGNIN_POLLING_MS); @@ -84,20 +82,17 @@ function handleAutoSignInWithLink( }), ); resetAutoSignIn(); - autoSignInStore.dispatch({ type: 'RESET' }); } else { try { const signInOutput = await signIn(signInInput); if (signInOutput.nextStep.signInStep !== 'CONFIRM_SIGN_UP') { resolve(signInOutput); clearInterval(autoSignInPollingIntervalId); - autoSignInStore.dispatch({ type: 'RESET' }); resetAutoSignIn(); } } catch (error) { clearInterval(autoSignInPollingIntervalId); reject(error); - autoSignInStore.dispatch({ type: 'RESET' }); resetAutoSignIn(); } } diff --git a/packages/auth/src/types/inputs.ts b/packages/auth/src/types/inputs.ts index a7189912cd0..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< From b66249dc1be6a3dc4ee323655fd4266ca10bbb6e Mon Sep 17 00:00:00 2001 From: James Jarvis Date: Fri, 22 Nov 2024 10:17:07 -0800 Subject: [PATCH 22/25] enable integ tests --- .github/workflows/codeql.yml | 2 +- .github/workflows/pr.yml | 8 ++++---- .github/workflows/push-integ-test.yml | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ce532d0c795..5b5b8865166 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -5,7 +5,7 @@ on: push: branches: ['*'] pull_request: - branches: ['release', 'next/main', 'next/release'] + branches: ['main', 'release', 'next/main', 'next/release'] schedule: # Run every Tuesday at midnight GMT - cron: '0 0 * * 2' diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 72e0b51af1d..f7e0982cc9c 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -36,9 +36,9 @@ jobs: tsc-compliance-test: needs: prebuild uses: ./.github/workflows/callable-test-tsc-compliance.yml - # dependency-review: - # needs: prebuild - # uses: ./.github/workflows/callable-dependency-review.yml + dependency-review: + needs: prebuild + uses: ./.github/workflows/callable-dependency-review.yml all-unit-tests-pass: name: Unit and Bundle tests have passed needs: @@ -47,7 +47,7 @@ jobs: - license-test - github-actions-test - tsc-compliance-test - # - dependency-review + - dependency-review runs-on: ubuntu-latest if: success() # only run when all checks have passed # store success output flag for ci job diff --git a/.github/workflows/push-integ-test.yml b/.github/workflows/push-integ-test.yml index 03e43dd2865..680aa6b7d06 100644 --- a/.github/workflows/push-integ-test.yml +++ b/.github/workflows/push-integ-test.yml @@ -8,7 +8,7 @@ concurrency: on: push: branches: - - replace-with-your-branch + - feat/passwordless jobs: e2e: From 8b5320d6ccb0dcb05cd87635eaa20c5cd0ff8ad7 Mon Sep 17 00:00:00 2001 From: yuhengshs Date: Fri, 22 Nov 2024 13:44:17 -0800 Subject: [PATCH 23/25] fix: set active username after auth attempt to maintain consistent user context --- .../src/client/flows/userAuth/handleUserAuthFlow.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/auth/src/client/flows/userAuth/handleUserAuthFlow.ts b/packages/auth/src/client/flows/userAuth/handleUserAuthFlow.ts index 24ba20500a6..753ac66db04 100644 --- a/packages/auth/src/client/flows/userAuth/handleUserAuthFlow.ts +++ b/packages/auth/src/client/flows/userAuth/handleUserAuthFlow.ts @@ -18,6 +18,7 @@ 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; @@ -107,11 +108,18 @@ export async function handleUserAuthFlow({ }), }); - return initiateAuth( + 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; } From 385168661430c27d1ac6cfc06518f995ec8907eb Mon Sep 17 00:00:00 2001 From: James Jarvis Date: Fri, 22 Nov 2024 13:47:24 -0800 Subject: [PATCH 24/25] temporarily run single test spec per environment --- .github/integ-config/integ-all.yml | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/integ-config/integ-all.yml b/.github/integ-config/integ-all.yml index 00334c347e5..3c37683608b 100644 --- a/.github/integ-config/integ-all.yml +++ b/.github/integ-config/integ-all.yml @@ -935,7 +935,8 @@ tests: category: auth sample_name: [mfa] spec: passwordless/auto-sign-in - browser: *minimal_browser_list + # browser: *minimal_browser_list + browser: [chrome] env: NEXT_PUBLIC_BACKEND_CONFIG: pwl-autosignin - test_name: integ_next_passwordless_first_factor_selection @@ -944,7 +945,8 @@ tests: category: auth sample_name: [mfa] spec: passwordless/first-factor-selection - browser: *minimal_browser_list + # browser: *minimal_browser_list + browser: [chrome] env: NEXT_PUBLIC_BACKEND_CONFIG: pwl-ffselect - test_name: integ_next_passwordless_preferred_challenge @@ -953,7 +955,8 @@ tests: category: auth sample_name: [mfa] spec: passwordless/preferred-challenge - browser: *minimal_browser_list + # browser: *minimal_browser_list + browser: [chrome] env: NEXT_PUBLIC_BACKEND_CONFIG: pwl-prefchal - test_name: integ_next_passwordless_sign_up @@ -962,7 +965,8 @@ tests: category: auth sample_name: [mfa] spec: passwordless/sign-up - browser: *minimal_browser_list + # browser: *minimal_browser_list + browser: [chrome] env: NEXT_PUBLIC_BACKEND_CONFIG: pwl-signup - test_name: integ_next_passwordless_misc @@ -971,7 +975,8 @@ tests: category: auth sample_name: [mfa] spec: passwordless/miscellaneous - browser: *minimal_browser_list + # browser: *minimal_browser_list + browser: [chrome] env: NEXT_PUBLIC_BACKEND_CONFIG: pwl-misc - test_name: integ_next_passwordless_webauthn From d2d3fc56619d61bc987cfb2d62c4ab3f9521450d Mon Sep 17 00:00:00 2001 From: James Jarvis Date: Fri, 22 Nov 2024 16:45:00 -0800 Subject: [PATCH 25/25] reset push integ yml --- .github/workflows/push-integ-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/push-integ-test.yml b/.github/workflows/push-integ-test.yml index 680aa6b7d06..03e43dd2865 100644 --- a/.github/workflows/push-integ-test.yml +++ b/.github/workflows/push-integ-test.yml @@ -8,7 +8,7 @@ concurrency: on: push: branches: - - feat/passwordless + - replace-with-your-branch jobs: e2e: