From c99c4d230c4844f4b9ce73a9b41119938a556c96 Mon Sep 17 00:00:00 2001 From: Ahmed Hamouda Date: Mon, 17 Nov 2025 23:55:15 +0100 Subject: [PATCH 1/2] feat: add passwordless authentication support --- .changeset/wise-crews-think.md | 8 + .eslint_dictionary.json | 5 + package-lock.json | 23 +- packages/auth-construct/API.md | 12 + packages/auth-construct/package.json | 1 + packages/auth-construct/src/construct.test.ts | 186 ++++++++++ packages/auth-construct/src/construct.ts | 130 +++++++ packages/auth-construct/src/index.ts | 2 + packages/auth-construct/src/types.ts | 107 ++++++ packages/auth-construct/tsconfig.json | 1 + packages/backend-auth/src/factory.test.ts | 325 +++++++++++++++++ packages/backend-auth/src/factory.ts | 133 +++++++ .../src/translate_auth_props.test.ts | 343 ++++++++++++++++++ .../backend-auth/src/translate_auth_props.ts | 16 + .../passwordless_auth.deployment.test.ts | 4 + .../sandbox/passwordless_auth.sandbox.test.ts | 4 + .../passwordless_auth_project.ts | 276 ++++++++++++++ 17 files changed, 1565 insertions(+), 11 deletions(-) create mode 100644 .changeset/wise-crews-think.md create mode 100644 packages/integration-tests/src/test-e2e/deployment/passwordless_auth.deployment.test.ts create mode 100644 packages/integration-tests/src/test-e2e/sandbox/passwordless_auth.sandbox.test.ts create mode 100644 packages/integration-tests/src/test-project-setup/passwordless_auth_project.ts diff --git a/.changeset/wise-crews-think.md b/.changeset/wise-crews-think.md new file mode 100644 index 00000000000..97a16105c09 --- /dev/null +++ b/.changeset/wise-crews-think.md @@ -0,0 +1,8 @@ +--- +'@aws-amplify/integration-tests': minor +'@aws-amplify/auth-construct': minor +'@aws-amplify/backend-auth': minor +'@aws-amplify/backend': minor +--- + +Added support for passwordless authentication diff --git a/.eslint_dictionary.json b/.eslint_dictionary.json index 946c5570d81..b31f98cf8cf 100644 --- a/.eslint_dictionary.json +++ b/.eslint_dictionary.json @@ -6,6 +6,7 @@ "aggregator", "amazonaws", "amazoncognito", + "amplifyapp", "amplifyconfiguration", "ampx", "anonymize", @@ -18,8 +19,10 @@ "argv", "arn", "arns", + "authn", "aws", "backends", + "biometric", "birthdate", "bundler", "callee", @@ -140,6 +143,7 @@ "orchestrator", "outdir", "passthrough", + "passwordless", "pathname", "pipelined", "pnpm", @@ -217,6 +221,7 @@ "verifier", "versioned", "versioning", + "webauthn", "whoami", "wildcard", "wildcards", diff --git a/package-lock.json b/package-lock.json index 667e60ca9d0..2204f456ac1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49307,11 +49307,12 @@ }, "packages/auth-construct": { "name": "@aws-amplify/auth-construct", - "version": "1.8.2", + "version": "1.9.0", "license": "Apache-2.0", "dependencies": { "@aws-amplify/backend-output-schemas": "^1.7.1", "@aws-amplify/backend-output-storage": "^1.3.2", + "@aws-amplify/platform-core": "^1.10.2", "@aws-amplify/plugin-types": "^1.11.1", "@aws-sdk/util-arn-parser": "^3.723.0" }, @@ -49322,17 +49323,17 @@ }, "packages/backend": { "name": "@aws-amplify/backend", - "version": "1.17.0", + "version": "1.18.0", "license": "Apache-2.0", "dependencies": { - "@aws-amplify/backend-auth": "^1.7.2", + "@aws-amplify/backend-auth": "^1.8.0", "@aws-amplify/backend-data": "^1.6.2", - "@aws-amplify/backend-function": "^1.15.0", + "@aws-amplify/backend-function": "^1.15.1", "@aws-amplify/backend-output-schemas": "^1.7.1", "@aws-amplify/backend-output-storage": "^1.3.2", "@aws-amplify/backend-secret": "^1.4.1", "@aws-amplify/backend-storage": "^1.4.2", - "@aws-amplify/client-config": "^1.8.1", + "@aws-amplify/client-config": "^1.9.0", "@aws-amplify/data-schema": "^1.13.4", "@aws-amplify/platform-core": "^1.10.1", "@aws-amplify/plugin-types": "^1.11.1", @@ -49367,10 +49368,10 @@ }, "packages/backend-auth": { "name": "@aws-amplify/backend-auth", - "version": "1.7.2", + "version": "1.8.0", "license": "Apache-2.0", "dependencies": { - "@aws-amplify/auth-construct": "^1.8.2", + "@aws-amplify/auth-construct": "^1.9.0", "@aws-amplify/backend-output-schemas": "^1.7.1", "@aws-amplify/backend-output-storage": "^1.3.2", "@aws-amplify/plugin-types": "^1.11.1" @@ -49458,7 +49459,7 @@ }, "packages/backend-function": { "name": "@aws-amplify/backend-function", - "version": "1.15.0", + "version": "1.15.1", "license": "Apache-2.0", "dependencies": { "@aws-amplify/backend-output-schemas": "^1.7.1", @@ -49697,7 +49698,7 @@ }, "packages/client-config": { "name": "@aws-amplify/client-config", - "version": "1.8.1", + "version": "1.9.0", "license": "Apache-2.0", "dependencies": { "@aws-amplify/backend-output-schemas": "^1.7.1", @@ -52104,12 +52105,12 @@ }, "packages/seed": { "name": "@aws-amplify/seed", - "version": "1.0.2", + "version": "1.1.0", "license": "Apache-2.0", "dependencies": { "@aws-amplify/backend-secret": "^1.4.1", "@aws-amplify/cli-core": "^2.2.2", - "@aws-amplify/client-config": "^1.8.1", + "@aws-amplify/client-config": "^1.9.0", "@aws-amplify/platform-core": "^1.10.1", "@aws-amplify/plugin-types": "^1.11.1", "@aws-sdk/client-cognito-identity-provider": "^3.750.0", diff --git a/packages/auth-construct/API.md b/packages/auth-construct/API.md index cfce6840c45..20da3ab2a28 100644 --- a/packages/auth-construct/API.md +++ b/packages/auth-construct/API.md @@ -45,6 +45,7 @@ export type AuthProps = { loginWith: { email?: EmailLogin; phone?: PhoneNumberLogin; + webAuthn?: WebAuthnLogin; externalProviders?: ExternalProviderOptions; }; senders?: { @@ -108,6 +109,7 @@ export type EmailLoginSettings = (VerificationEmailWithLink | VerificationEmailW emailBody?: (username: () => string, code: () => string) => string; smsMessage?: (username: () => string, code: () => string) => string; }; + otpLogin?: boolean; }; // @public @@ -178,6 +180,7 @@ export type OidcProviderProps = Omit string) => string; + otpLogin?: boolean; }; // @public @@ -217,6 +220,15 @@ export type VerificationEmailWithLink = { verificationEmailSubject?: string; }; +// @public +export type WebAuthnLogin = true | WebAuthnOptions; + +// @public +export type WebAuthnOptions = { + relyingPartyId: string; + userVerification?: 'required' | 'preferred'; +}; + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/auth-construct/package.json b/packages/auth-construct/package.json index a483c307152..83ce7e280c6 100644 --- a/packages/auth-construct/package.json +++ b/packages/auth-construct/package.json @@ -25,6 +25,7 @@ "dependencies": { "@aws-amplify/backend-output-schemas": "^1.7.1", "@aws-amplify/backend-output-storage": "^1.3.2", + "@aws-amplify/platform-core": "^1.10.2", "@aws-amplify/plugin-types": "^1.11.1", "@aws-sdk/util-arn-parser": "^3.723.0" }, diff --git a/packages/auth-construct/src/construct.test.ts b/packages/auth-construct/src/construct.test.ts index b24607c6e3c..b514b6d242d 100644 --- a/packages/auth-construct/src/construct.test.ts +++ b/packages/auth-construct/src/construct.test.ts @@ -3144,4 +3144,190 @@ void describe('Auth construct', () => { UserPoolName: Match.absent(), }); }); + + void describe('passwordless authentication', () => { + void it('configures email OTP when otpLogin is enabled', () => { + const app = new App(); + const stack = new Stack(app); + new AmplifyAuth(stack, 'test', { + loginWith: { + email: { + otpLogin: true, + }, + }, + }); + const template = Template.fromStack(stack); + template.hasResourceProperties('AWS::Cognito::UserPool', { + Policies: { + SignInPolicy: { + AllowedFirstAuthFactors: ['PASSWORD', 'EMAIL_OTP'], + }, + }, + }); + template.hasResourceProperties('AWS::Cognito::UserPoolClient', { + ExplicitAuthFlows: Match.arrayWith(['ALLOW_USER_AUTH']), + }); + }); + + void it('configures SMS OTP when otpLogin is enabled', () => { + const app = new App(); + const stack = new Stack(app); + new AmplifyAuth(stack, 'test', { + loginWith: { + phone: { + otpLogin: true, + }, + }, + }); + const template = Template.fromStack(stack); + template.hasResourceProperties('AWS::Cognito::UserPool', { + Policies: { + SignInPolicy: { + AllowedFirstAuthFactors: ['PASSWORD', 'SMS_OTP'], + }, + }, + }); + template.hasResourceProperties('AWS::Cognito::UserPoolClient', { + ExplicitAuthFlows: Match.arrayWith(['ALLOW_USER_AUTH']), + }); + }); + + void it('configures WebAuthn with default settings', () => { + const app = new App(); + const stack = new Stack(app); + stack.node.setContext('amplify-backend-type', 'sandbox'); + new AmplifyAuth(stack, 'test', { + loginWith: { + email: true, + webAuthn: true, + }, + }); + const template = Template.fromStack(stack); + template.hasResourceProperties('AWS::Cognito::UserPool', { + Policies: { + SignInPolicy: { + AllowedFirstAuthFactors: ['PASSWORD', 'WEB_AUTHN'], + }, + }, + WebAuthnRelyingPartyID: 'localhost', + WebAuthnUserVerification: 'preferred', + }); + template.hasResourceProperties('AWS::Cognito::UserPoolClient', { + ExplicitAuthFlows: Match.arrayWith(['ALLOW_USER_AUTH']), + }); + }); + + void it('configures WebAuthn with custom settings', () => { + const app = new App(); + const stack = new Stack(app); + new AmplifyAuth(stack, 'test', { + loginWith: { + email: true, + webAuthn: { + relyingPartyId: 'example.com', + userVerification: 'required', + }, + }, + }); + const template = Template.fromStack(stack); + template.hasResourceProperties('AWS::Cognito::UserPool', { + Policies: { + SignInPolicy: { + AllowedFirstAuthFactors: ['PASSWORD', 'WEB_AUTHN'], + }, + }, + WebAuthnRelyingPartyID: 'example.com', + WebAuthnUserVerification: 'required', + }); + }); + + void it('configures all passwordless factors together', () => { + const app = new App(); + const stack = new Stack(app); + new AmplifyAuth(stack, 'test', { + loginWith: { + email: { + otpLogin: true, + }, + phone: { + otpLogin: true, + }, + webAuthn: { + relyingPartyId: 'example.com', + }, + }, + }); + const template = Template.fromStack(stack); + template.hasResourceProperties('AWS::Cognito::UserPool', { + Policies: { + SignInPolicy: { + AllowedFirstAuthFactors: [ + 'PASSWORD', + 'EMAIL_OTP', + 'SMS_OTP', + 'WEB_AUTHN', + ], + }, + }, + WebAuthnRelyingPartyID: 'example.com', + WebAuthnUserVerification: 'preferred', + }); + template.hasResourceProperties('AWS::Cognito::UserPoolClient', { + ExplicitAuthFlows: Match.arrayWith(['ALLOW_USER_AUTH']), + }); + }); + + void it('resolves AUTO to localhost in sandbox mode', () => { + const app = new App(); + const stack = new Stack(app); + stack.node.setContext('amplify-backend-type', 'sandbox'); + new AmplifyAuth(stack, 'test', { + loginWith: { + email: true, + webAuthn: true, + }, + }); + const template = Template.fromStack(stack); + template.hasResourceProperties('AWS::Cognito::UserPool', { + WebAuthnRelyingPartyID: 'localhost', + }); + }); + + void it('resolves AUTO to Amplify domain in branch mode', () => { + const app = new App(); + const stack = new Stack(app); + stack.node.setContext('amplify-backend-type', 'branch'); + stack.node.setContext('amplify-backend-namespace', 'testProjectName'); + stack.node.setContext('amplify-backend-name', 'main'); + new AmplifyAuth(stack, 'test', { + loginWith: { + email: true, + webAuthn: true, + }, + }); + const template = Template.fromStack(stack); + template.hasResourceProperties('AWS::Cognito::UserPool', { + WebAuthnRelyingPartyID: 'main.testProjectName.amplifyapp.com', + }); + }); + + void it('does not configure passwordless when not enabled', () => { + const app = new App(); + const stack = new Stack(app); + new AmplifyAuth(stack, 'test', { + loginWith: { + email: true, + }, + }); + const template = Template.fromStack(stack); + template.hasResourceProperties('AWS::Cognito::UserPool', { + Policies: { + PasswordPolicy: Match.objectLike({}), + SignInPolicy: Match.absent(), + }, + WebAuthnRelyingPartyID: Match.absent(), + WebAuthnUserVerification: Match.absent(), + }); + }); + }); }); diff --git a/packages/auth-construct/src/construct.ts b/packages/auth-construct/src/construct.ts index c192dc07a15..92accc33af1 100644 --- a/packages/auth-construct/src/construct.ts +++ b/packages/auth-construct/src/construct.ts @@ -55,6 +55,7 @@ import { } from '@aws-amplify/backend-output-storage'; import * as path from 'path'; import { IKey, Key } from 'aws-cdk-lib/aws-kms'; +import { CDKContextKey } from '@aws-amplify/platform-core'; type DefaultRoles = { auth: Role; unAuth: Role }; type IdentityProviderSetupResult = { @@ -196,6 +197,8 @@ export class AmplifyAuth ); } + this.applyPasswordlessConfiguration(this.userPool, props); + // UserPool - External Providers (Oauth, SAML, OIDC) and User Pool Domain this.providerSetupResult = this.setupExternalProviders( this.userPool, @@ -213,6 +216,8 @@ export class AmplifyAuth }, ); + this.applyUserAuthFlow(userPoolClient, props); + // Identity Pool const { identityPool, @@ -1129,6 +1134,131 @@ export class AmplifyAuth return result; }; + /** + * Apply passwordless authentication configuration to the UserPool. + * Configures Email OTP, SMS OTP, and WebAuthn (passkeys) based on props. + */ + private applyPasswordlessConfiguration = ( + userPool: UserPool, + props: AuthProps, + ): void => { + const cfnUserPool = userPool.node.findChild('Resource') as CfnUserPool; + if (!(cfnUserPool instanceof CfnUserPool)) { + throw Error('Could not find CfnUserPool resource in stack.'); + } + + const emailOtpEnabled = + typeof props.loginWith.email === 'object' && + props.loginWith.email.otpLogin === true; + const smsOtpEnabled = + typeof props.loginWith.phone === 'object' && + props.loginWith.phone.otpLogin === true; + const webAuthnEnabled = props.loginWith.webAuthn !== undefined; + + if (!emailOtpEnabled && !smsOtpEnabled && !webAuthnEnabled) { + return; + } + + // PASSWORD is always included per Cognito requirements + const allowedFirstAuthFactors: string[] = ['PASSWORD']; + + if (emailOtpEnabled) { + allowedFirstAuthFactors.push('EMAIL_OTP'); + } + + if (smsOtpEnabled) { + allowedFirstAuthFactors.push('SMS_OTP'); + } + + if (webAuthnEnabled) { + allowedFirstAuthFactors.push('WEB_AUTHN'); + } + + cfnUserPool.addPropertyOverride('Policies.SignInPolicy', { + AllowedFirstAuthFactors: allowedFirstAuthFactors, + }); + + if (webAuthnEnabled) { + const webAuthnConfig = props.loginWith.webAuthn!; + let relyingPartyId: string; + let userVerification: string; + + if (webAuthnConfig === true) { + relyingPartyId = this.resolveRelyingPartyId('AUTO'); + userVerification = 'preferred'; + } else { + relyingPartyId = this.resolveRelyingPartyId( + webAuthnConfig.relyingPartyId, + ); + userVerification = webAuthnConfig.userVerification ?? 'preferred'; + } + + cfnUserPool.addPropertyOverride('WebAuthnRelyingPartyID', relyingPartyId); + cfnUserPool.addPropertyOverride( + 'WebAuthnUserVerification', + userVerification, + ); + } + }; + + /** + * Resolve the relying party ID for WebAuthn configuration. + * Handles AUTO resolution based on deployment context. + */ + private resolveRelyingPartyId = (relyingPartyId: string): string => { + if (relyingPartyId !== 'AUTO') { + return relyingPartyId; + } + + const deploymentType = this.node.tryGetContext( + CDKContextKey.DEPLOYMENT_TYPE, + ); + + if (deploymentType === 'branch') { + const appId = this.node.tryGetContext(CDKContextKey.BACKEND_NAMESPACE); + const branchName = this.node.tryGetContext(CDKContextKey.BACKEND_NAME); + + if (appId && branchName) { + return `${branchName}.${appId}.amplifyapp.com`; + } + } + + return 'localhost'; + }; + + /** + * Apply USER_AUTH flow to UserPoolClient when passwordless factors are enabled. + */ + private applyUserAuthFlow = ( + userPoolClient: UserPoolClient, + props: AuthProps, + ): void => { + const emailOtpEnabled = + typeof props.loginWith.email === 'object' && + props.loginWith.email.otpLogin === true; + const smsOtpEnabled = + typeof props.loginWith.phone === 'object' && + props.loginWith.phone.otpLogin === true; + const webAuthnEnabled = props.loginWith.webAuthn !== undefined; + + const hasPasswordlessFactors = + emailOtpEnabled || smsOtpEnabled || webAuthnEnabled; + + if (!hasPasswordlessFactors) { + return; + } + + const cfnUserPoolClient = userPoolClient.node.findChild( + 'Resource', + ) as CfnUserPoolClient; + if (!(cfnUserPoolClient instanceof CfnUserPoolClient)) { + throw Error('Could not find CfnUserPoolClient resource in stack.'); + } + + const existingFlows = cfnUserPoolClient.explicitAuthFlows || []; + cfnUserPoolClient.explicitAuthFlows = [...existingFlows, 'ALLOW_USER_AUTH']; + }; + /** * Stores auth output using the provided strategy */ diff --git a/packages/auth-construct/src/index.ts b/packages/auth-construct/src/index.ts index f156c6a24ea..2005322d784 100644 --- a/packages/auth-construct/src/index.ts +++ b/packages/auth-construct/src/index.ts @@ -30,6 +30,8 @@ export { CustomEmailSender, CustomSmsSender, UserPoolSnsOptions, + WebAuthnLogin, + WebAuthnOptions, } from './types.js'; export { AmplifyAuth } from './construct.js'; export { triggerEvents } from './trigger_events.js'; diff --git a/packages/auth-construct/src/types.ts b/packages/auth-construct/src/types.ts index 02f314621ce..007edf53303 100644 --- a/packages/auth-construct/src/types.ts +++ b/packages/auth-construct/src/types.ts @@ -77,6 +77,24 @@ export type EmailLoginSettings = ( */ smsMessage?: (username: () => string, code: () => string) => string; }; + /** + * Enable email OTP (one-time password) login for passwordless authentication. + * + * When enabled, users can sign in by receiving a one-time code via email + * instead of using a password. + * @default false + * @example + * // Enable email OTP login + * defineAuth({ + * loginWith: { + * email: { + * otpLogin: true + * } + * } + * }) + * @see https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-settings-email-phone-verification.html + */ + otpLogin?: boolean; }; /** * Email login options. @@ -103,6 +121,24 @@ export type PhoneNumberLogin = * (code) => `The verification code to your new account is ${createCode()}` */ verificationMessage?: (createCode: () => string) => string; + /** + * Enable SMS OTP (one-time password) login for passwordless authentication. + * + * When enabled, users can sign in by receiving a one-time code via SMS + * instead of using a password. + * @default false + * @example + * // Enable SMS OTP login + * defineAuth({ + * loginWith: { + * phone: { + * otpLogin: true + * } + * } + * }) + * @see https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-settings-email-phone-verification.html + */ + otpLogin?: boolean; }; /** @@ -433,6 +469,62 @@ export type UserPoolSnsOptions = { readonly snsRegion?: string; }; +/** + * WebAuthn (passkey) login configuration for passwordless authentication. + * @example + * // Simple configuration (uses default settings) + * webAuthn: true + * @example + * // Custom configuration + * webAuthn: { + * relyingPartyId: 'example.com', + * userVerification: 'required' + * } + * @see https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-passkeys.html + */ +export type WebAuthnLogin = true | WebAuthnOptions; + +/** + * WebAuthn configuration options for passkey authentication. + * + * Configure advanced settings for WebAuthn passkey authentication, including + * the relying party identifier and user verification requirements. + * @see https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-passkeys.html + */ +export type WebAuthnOptions = { + /** + * The relying party identifier (domain) for WebAuthn credentials. + * + * **WARNING:** Changing this value after deployment invalidates all existing + * passkeys. Users will need to re-register their passkeys. + * + * **Supported values:** + * + * - `'AUTO'` - Automatically resolves based on deployment context + * - `'localhost'` - For local development and testing + * - Custom domain - Your production domain (e.g., `'example.com'`, `'app.example.com'`) + * @example + * // Automatic resolution + * relyingPartyId: 'AUTO' + * @example + * // Custom domain + * relyingPartyId: 'example.com' + */ + relyingPartyId: string; + + /** + * User verification requirement for WebAuthn authentication. + * @default 'preferred' + * @example + * // Require biometric or PIN verification + * userVerification: 'required' + * @example + * // Prefer verification but allow fallback + * userVerification: 'preferred' + */ + userVerification?: 'required' | 'preferred'; +}; + /** * Input props for the AmplifyAuth construct */ @@ -459,6 +551,21 @@ export type AuthProps = { * If settings are provided, phone number login will be enabled with the specified settings. */ phone?: PhoneNumberLogin; + /** + * Enable WebAuthn (passkey) authentication for passwordless login. + * @default undefined (WebAuthn not enabled) + * @example + * // Simple passwordless with email OTP and passkeys + * defineAuth({ + * loginWith: { + * email: { + * otpLogin: true + * }, + * webAuthn: true + * } + * }) + */ + webAuthn?: WebAuthnLogin; /** * Configure OAuth, OIDC, and SAML login providers */ diff --git a/packages/auth-construct/tsconfig.json b/packages/auth-construct/tsconfig.json index 5af708fff9e..f5513141366 100644 --- a/packages/auth-construct/tsconfig.json +++ b/packages/auth-construct/tsconfig.json @@ -12,6 +12,7 @@ "references": [ { "path": "../backend-output-schemas" }, { "path": "../backend-output-storage" }, + { "path": "../platform-core" }, { "path": "../plugin-types" } ] } diff --git a/packages/backend-auth/src/factory.test.ts b/packages/backend-auth/src/factory.test.ts index 4aa313cb8e1..e754a159c7e 100644 --- a/packages/backend-auth/src/factory.test.ts +++ b/packages/backend-auth/src/factory.test.ts @@ -622,6 +622,331 @@ void describe('AmplifyAuthFactory', () => { }, }); }); + + void it('preserves existing auth flows when passwordless not configured', () => { + resetFactoryCount(); + const authFactory = defineAuth({ + loginWith: { email: true }, + }); + const backendAuth = authFactory.getInstance(getInstanceProps); + const template = Template.fromStack(backendAuth.stack); + + template.hasResourceProperties('AWS::Cognito::UserPoolClient', { + ExplicitAuthFlows: Match.arrayWith(['ALLOW_REFRESH_TOKEN_AUTH']), + }); + + const clientResources = template.findResources( + 'AWS::Cognito::UserPoolClient', + ); + const clientProps = Object.values(clientResources)[0].Properties; + assert.ok(!clientProps.ExplicitAuthFlows.includes('ALLOW_USER_AUTH')); + }); + + void describe('Passwordless Validation', () => { + void it('accepts email OTP configuration', () => { + resetFactoryCount(); + const authFactory = defineAuth({ + loginWith: { + email: { otpLogin: true }, + }, + }); + assert.doesNotThrow(() => authFactory.getInstance(getInstanceProps)); + }); + + void it('accepts SMS OTP configuration', () => { + resetFactoryCount(); + const authFactory = defineAuth({ + loginWith: { + phone: { otpLogin: true }, + }, + }); + assert.doesNotThrow(() => authFactory.getInstance(getInstanceProps)); + }); + + void it('accepts WebAuthn with email', () => { + resetFactoryCount(); + const authFactory = defineAuth({ + loginWith: { + email: true, + webAuthn: true, + }, + }); + assert.doesNotThrow(() => authFactory.getInstance(getInstanceProps)); + }); + + void it('accepts WebAuthn with phone', () => { + resetFactoryCount(); + const authFactory = defineAuth({ + loginWith: { + phone: true, + webAuthn: true, + }, + }); + assert.doesNotThrow(() => authFactory.getInstance(getInstanceProps)); + }); + + void it('accepts localhost relying party ID', () => { + resetFactoryCount(); + const authFactory = defineAuth({ + loginWith: { + email: true, + webAuthn: { relyingPartyId: 'localhost' }, + }, + }); + assert.doesNotThrow(() => authFactory.getInstance(getInstanceProps)); + }); + + void it('accepts AUTO relying party ID', () => { + resetFactoryCount(); + const authFactory = defineAuth({ + loginWith: { + email: true, + webAuthn: { relyingPartyId: 'AUTO' }, + }, + }); + assert.doesNotThrow(() => authFactory.getInstance(getInstanceProps)); + }); + + void it('accepts valid domain formats', () => { + resetFactoryCount(); + const authFactory = defineAuth({ + loginWith: { + email: true, + webAuthn: { relyingPartyId: 'example.com' }, + }, + }); + assert.doesNotThrow(() => authFactory.getInstance(getInstanceProps)); + }); + + void it('accepts MFA OPTIONAL with passwordless', () => { + resetFactoryCount(); + const authFactory = defineAuth({ + loginWith: { + email: { otpLogin: true }, + }, + multifactor: { mode: 'OPTIONAL', sms: true }, + }); + assert.doesNotThrow(() => authFactory.getInstance(getInstanceProps)); + }); + + void it('accepts MFA OFF with passwordless', () => { + resetFactoryCount(); + const authFactory = defineAuth({ + loginWith: { + email: { otpLogin: true }, + }, + multifactor: { mode: 'OFF' }, + }); + assert.doesNotThrow(() => authFactory.getInstance(getInstanceProps)); + }); + + void it('rejects WebAuthn-only configuration', () => { + resetFactoryCount(); + assert.throws( + () => { + defineAuth({ + loginWith: { webAuthn: true }, + }); + }, + (error: AmplifyUserError) => { + assert.strictEqual(error.name, 'InvalidPasswordlessConfigError'); + assert.ok(error.details?.includes('Passkeys (WebAuthn) require')); + return true; + }, + ); + }); + + void it('rejects relying party ID with protocol', () => { + resetFactoryCount(); + assert.throws( + () => { + defineAuth({ + loginWith: { + email: true, + webAuthn: { relyingPartyId: 'http://example.com' }, + }, + }); + }, + (error: AmplifyUserError) => { + assert.strictEqual(error.name, 'InvalidPasswordlessConfigError'); + assert.ok(error.details?.includes('Invalid relying party ID')); + return true; + }, + ); + }); + + void it('rejects invalid domain format', () => { + resetFactoryCount(); + assert.throws( + () => { + defineAuth({ + loginWith: { + email: true, + webAuthn: { relyingPartyId: 'my app.com' }, + }, + }); + }, + (error: AmplifyUserError) => { + assert.strictEqual(error.name, 'InvalidPasswordlessConfigError'); + assert.ok(error.details?.includes('Invalid relying party ID')); + return true; + }, + ); + }); + + void it('rejects MFA REQUIRED with passwordless', () => { + resetFactoryCount(); + assert.throws( + () => { + defineAuth({ + loginWith: { + email: { otpLogin: true }, + }, + multifactor: { mode: 'REQUIRED', sms: true }, + }); + }, + (error: AmplifyUserError) => { + assert.strictEqual(error.name, 'InvalidPasswordlessConfigError'); + assert.ok(error.details?.includes('Passwordless authentication')); + assert.ok(error.details?.includes('MFA')); + return true; + }, + ); + }); + + void it('processes email-only authentication without otpLogin', () => { + resetFactoryCount(); + const authFactory = defineAuth({ + loginWith: { email: true }, + }); + const backendAuth = authFactory.getInstance(getInstanceProps); + const template = Template.fromStack(backendAuth.stack); + + template.resourceCountIs('AWS::Cognito::UserPool', 1); + template.hasResourceProperties('AWS::Cognito::UserPool', { + UsernameAttributes: ['email'], + AutoVerifiedAttributes: ['email'], + }); + + template.hasResourceProperties('AWS::Cognito::UserPool', { + Policies: Match.objectLike({ + SignInPolicy: Match.absent(), + }), + WebAuthnRelyingPartyId: Match.absent(), + WebAuthnUserVerification: Match.absent(), + }); + }); + + void it('processes phone-only authentication without otpLogin', () => { + resetFactoryCount(); + const authFactory = defineAuth({ + loginWith: { phone: true }, + }); + const backendAuth = authFactory.getInstance(getInstanceProps); + const template = Template.fromStack(backendAuth.stack); + + template.resourceCountIs('AWS::Cognito::UserPool', 1); + template.hasResourceProperties('AWS::Cognito::UserPool', { + UsernameAttributes: ['phone_number'], + AutoVerifiedAttributes: ['phone_number'], + }); + + template.hasResourceProperties('AWS::Cognito::UserPool', { + Policies: Match.objectLike({ + SignInPolicy: Match.absent(), + }), + WebAuthnRelyingPartyId: Match.absent(), + WebAuthnUserVerification: Match.absent(), + }); + }); + + void it('processes email + phone authentication without otpLogin', () => { + resetFactoryCount(); + const authFactory = defineAuth({ + loginWith: { email: true, phone: true }, + }); + const backendAuth = authFactory.getInstance(getInstanceProps); + const template = Template.fromStack(backendAuth.stack); + + template.resourceCountIs('AWS::Cognito::UserPool', 1); + template.hasResourceProperties('AWS::Cognito::UserPool', { + UsernameAttributes: ['email', 'phone_number'], + AutoVerifiedAttributes: ['email', 'phone_number'], + }); + + template.hasResourceProperties('AWS::Cognito::UserPool', { + Policies: Match.objectLike({ + SignInPolicy: Match.absent(), + }), + WebAuthnRelyingPartyId: Match.absent(), + WebAuthnUserVerification: Match.absent(), + }); + }); + + void it('defaults otpLogin to false when not specified for email', () => { + resetFactoryCount(); + const authFactory = defineAuth({ + loginWith: { + email: { + verificationEmailStyle: 'CODE', + verificationEmailSubject: 'Verify your email', + verificationEmailBody: (code) => `Your code is ${code()}`, + }, + }, + }); + const backendAuth = authFactory.getInstance(getInstanceProps); + const template = Template.fromStack(backendAuth.stack); + + template.hasResourceProperties('AWS::Cognito::UserPool', { + UsernameAttributes: ['email'], + AutoVerifiedAttributes: ['email'], + }); + + template.hasResourceProperties('AWS::Cognito::UserPool', { + Policies: Match.objectLike({ + SignInPolicy: Match.absent(), + }), + }); + }); + + void it('defaults otpLogin to false when not specified for phone', () => { + resetFactoryCount(); + const authFactory = defineAuth({ + loginWith: { + phone: { + verificationMessage: (code) => `Your code is ${code()}`, + }, + }, + }); + const backendAuth = authFactory.getInstance(getInstanceProps); + const template = Template.fromStack(backendAuth.stack); + + template.hasResourceProperties('AWS::Cognito::UserPool', { + UsernameAttributes: ['phone_number'], + AutoVerifiedAttributes: ['phone_number'], + }); + + template.hasResourceProperties('AWS::Cognito::UserPool', { + Policies: Match.objectLike({ + SignInPolicy: Match.absent(), + }), + }); + }); + + void it('does not configure WebAuthn when not specified', () => { + resetFactoryCount(); + const authFactory = defineAuth({ + loginWith: { email: true }, + }); + const backendAuth = authFactory.getInstance(getInstanceProps); + const template = Template.fromStack(backendAuth.stack); + + template.hasResourceProperties('AWS::Cognito::UserPool', { + WebAuthnRelyingPartyId: Match.absent(), + WebAuthnUserVerification: Match.absent(), + }); + }); + }); }); const upperCaseFirstChar = (str: string) => { diff --git a/packages/backend-auth/src/factory.ts b/packages/backend-auth/src/factory.ts index 3d632e82151..2973e3d0630 100644 --- a/packages/backend-auth/src/factory.ts +++ b/packages/backend-auth/src/factory.ts @@ -112,6 +112,23 @@ export class AmplifyAuthFactory implements ConstructFactory { }); } AmplifyAuthFactory.factoryCount++; + + const validationResult = validatePasswordlessConfig(props); + + if (validationResult.warnings && validationResult.warnings.length > 0) { + process.stderr.write('\nWARNINGS:\n'); + validationResult.warnings.forEach((warning) => { + process.stderr.write(` • ${warning}\n`); + }); + } + + if (!validationResult.valid) { + throw new AmplifyUserError('InvalidPasswordlessConfigError', { + message: 'Invalid passwordless authentication configuration', + details: validationResult.errors.join('\n\n'), + resolution: 'Fix the configuration errors listed above', + }); + } } /** @@ -257,6 +274,122 @@ const roleNameIsAuthRoleName = (roleName: string): roleName is AuthRoleName => { ); }; +type ValidationResult = { + valid: boolean; + errors: string[]; + warnings?: string[]; +}; + +/** + * Validates the format of a WebAuthn relying party ID. + * @param relyingPartyId - The relying party ID to validate + * @returns Error message if invalid, undefined if valid + */ +const validateRelyingPartyId = (relyingPartyId: string): string | undefined => { + if (relyingPartyId === 'AUTO' || relyingPartyId === 'localhost') { + return undefined; + } + + if (relyingPartyId.includes('://')) { + return `Invalid relying party ID: "${relyingPartyId}". Must be a valid domain without protocol (e.g., "example.com"), "localhost" for development, or "AUTO" for Amplify Hosting. + +Examples: + - Valid: "example.com", "app.example.com", "localhost", "AUTO" + - Invalid: "http://example.com", "https://example.com"`; + } + + // Must contain at least one dot or be a single word (for local domains) + const domainPattern = + /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/; + + if (!domainPattern.test(relyingPartyId)) { + return `Invalid relying party ID: "${relyingPartyId}". Must be a valid domain format. + +Examples: + - Valid: "example.com", "app.example.com", "localhost", "AUTO" + - Invalid: "example", "192.168.1.1", "my app.com"`; + } + + return undefined; +}; + +/** + * Validates passwordless authentication configuration. + * @param props - The auth configuration props to validate + * @returns Validation result with errors and warnings + */ +const validatePasswordlessConfig = ( + props: AmplifyAuthProps, +): ValidationResult => { + const errors: string[] = []; + const warnings: string[] = []; + + const { loginWith, multifactor } = props; + + const emailEnabled = !!loginWith.email; + const phoneEnabled = !!loginWith.phone; + const emailOtpEnabled = + typeof loginWith.email === 'object' && loginWith.email.otpLogin === true; + const smsOtpEnabled = + typeof loginWith.phone === 'object' && loginWith.phone.otpLogin === true; + const webAuthnEnabled = !!loginWith.webAuthn; + + let webAuthnRelyingPartyId: string | undefined; + if (webAuthnEnabled && typeof loginWith.webAuthn === 'object') { + webAuthnRelyingPartyId = loginWith.webAuthn.relyingPartyId; + } + + const anyPasswordlessEnabled = + emailOtpEnabled || smsOtpEnabled || webAuthnEnabled; + + if (!emailEnabled && !phoneEnabled && !loginWith.externalProviders) { + errors.push( + 'At least one authentication method must be enabled. Configure email, phone, or external providers in loginWith.', + ); + } + + if (webAuthnEnabled && !emailEnabled && !phoneEnabled) { + errors.push( + `Passkeys (WebAuthn) require at least one sign-up method (email or phone). + +Email OTP and SMS OTP are valid passwordless sign-up methods. +Passkeys can only be registered after initial account creation. + +Resolution: Add email or phone to loginWith configuration.`, + ); + } + + if (webAuthnEnabled && webAuthnRelyingPartyId) { + const relyingPartyIdError = validateRelyingPartyId(webAuthnRelyingPartyId); + if (relyingPartyIdError) { + errors.push(relyingPartyIdError); + } else if ( + webAuthnRelyingPartyId !== 'AUTO' && + webAuthnRelyingPartyId !== 'localhost' + ) { + // Warning about immutability for custom domains + warnings.push( + `WebAuthn relying party ID is set to "${webAuthnRelyingPartyId}". Changing this value after deployment will invalidate all existing passkeys. Users will need to re-register their passkeys.`, + ); + } + } + + if (anyPasswordlessEnabled && multifactor?.mode === 'REQUIRED') { + errors.push( + `Passwordless authentication (Email OTP, SMS OTP, WebAuthn) cannot be used when MFA is set to REQUIRED. Amazon Cognito does not support combining MFA with passwordless authentication methods. + +Resolution: Choose either passwordless authentication or MFA, not both. +Note: WebAuthn passkeys with user verification can provide similar security to MFA without requiring separate MFA configuration.`, + ); + } + + return { + valid: errors.length === 0, + errors, + warnings: warnings.length > 0 ? warnings : undefined, + }; +}; + /** * Provide the settings that will be used for authentication. */ diff --git a/packages/backend-auth/src/translate_auth_props.test.ts b/packages/backend-auth/src/translate_auth_props.test.ts index 304af562c09..4cbe1bffdfe 100644 --- a/packages/backend-auth/src/translate_auth_props.test.ts +++ b/packages/backend-auth/src/translate_auth_props.test.ts @@ -184,4 +184,347 @@ void describe('translateToAuthConstructLoginWith', () => { }; assert.deepStrictEqual(translated, expected); }); + + void it('translates email OTP configuration', () => { + const loginWith: AuthLoginWithFactoryProps = { + email: { + verificationEmailStyle: 'CODE', + otpLogin: true, + }, + }; + + const translated = translateToAuthConstructLoginWith( + loginWith, + backendResolver, + ); + + const expected: AuthProps['loginWith'] = { + email: { + verificationEmailStyle: 'CODE', + otpLogin: true, + }, + }; + assert.deepStrictEqual(translated, expected); + }); + + void it('translates SMS OTP configuration', () => { + const phoneConfig = { + verificationMessage: (createCode: () => string) => + `text${createCode()}text2`, + otpLogin: true, + }; + const loginWith: AuthLoginWithFactoryProps = { + phone: phoneConfig, + }; + + const translated = translateToAuthConstructLoginWith( + loginWith, + backendResolver, + ); + + const expected: AuthProps['loginWith'] = { + phone: { + verificationMessage: phoneConfig.verificationMessage, + otpLogin: true, + }, + }; + assert.deepStrictEqual(translated, expected); + }); + + void it('translates WebAuthn boolean to default configuration', () => { + const loginWith: AuthLoginWithFactoryProps = { + email: true, + webAuthn: true, + }; + + const translated = translateToAuthConstructLoginWith( + loginWith, + backendResolver, + ); + + const expected: AuthProps['loginWith'] = { + email: true, + webAuthn: { + relyingPartyId: 'AUTO', + userVerification: 'preferred', + }, + }; + assert.deepStrictEqual(translated, expected); + }); + + void it('translates WebAuthn custom configuration with all properties', () => { + const loginWith: AuthLoginWithFactoryProps = { + email: true, + webAuthn: { + relyingPartyId: 'example.com', + userVerification: 'required', + }, + }; + + const translated = translateToAuthConstructLoginWith( + loginWith, + backendResolver, + ); + + const expected: AuthProps['loginWith'] = { + email: true, + webAuthn: { + relyingPartyId: 'example.com', + userVerification: 'required', + }, + }; + assert.deepStrictEqual(translated, expected); + }); + + void it('translates WebAuthn custom configuration with partial properties', () => { + const loginWith: AuthLoginWithFactoryProps = { + email: true, + webAuthn: { + relyingPartyId: 'localhost', + }, + }; + + const translated = translateToAuthConstructLoginWith( + loginWith, + backendResolver, + ); + + const expected: AuthProps['loginWith'] = { + email: true, + webAuthn: { + relyingPartyId: 'localhost', + userVerification: 'preferred', + }, + }; + assert.deepStrictEqual(translated, expected); + }); + + void it('translates combined passwordless factors', () => { + const phoneConfig = { + verificationMessage: (createCode: () => string) => + `text${createCode()}text2`, + otpLogin: true, + }; + const loginWith: AuthLoginWithFactoryProps = { + email: { + verificationEmailStyle: 'CODE', + otpLogin: true, + }, + phone: phoneConfig, + webAuthn: { + relyingPartyId: 'example.com', + userVerification: 'required', + }, + }; + + const translated = translateToAuthConstructLoginWith( + loginWith, + backendResolver, + ); + + const expected: AuthProps['loginWith'] = { + email: { + verificationEmailStyle: 'CODE', + otpLogin: true, + }, + phone: { + verificationMessage: phoneConfig.verificationMessage, + otpLogin: true, + }, + webAuthn: { + relyingPartyId: 'example.com', + userVerification: 'required', + }, + }; + assert.deepStrictEqual(translated, expected); + }); + + void it('translates combined passwordless with WebAuthn boolean', () => { + const phoneConfig = { + verificationMessage: (createCode: () => string) => + `text${createCode()}text2`, + otpLogin: true, + }; + const loginWith: AuthLoginWithFactoryProps = { + email: { + verificationEmailStyle: 'CODE', + otpLogin: true, + }, + phone: phoneConfig, + webAuthn: true, + }; + + const translated = translateToAuthConstructLoginWith( + loginWith, + backendResolver, + ); + + const expected: AuthProps['loginWith'] = { + email: { + verificationEmailStyle: 'CODE', + otpLogin: true, + }, + phone: { + verificationMessage: phoneConfig.verificationMessage, + otpLogin: true, + }, + webAuthn: { + relyingPartyId: 'AUTO', + userVerification: 'preferred', + }, + }; + assert.deepStrictEqual(translated, expected); + }); + + void it('does not add otpLogin when not specified for email', () => { + const emailBody = (code: () => string) => `Your code is ${code()}`; + const loginWith: AuthLoginWithFactoryProps = { + email: { + verificationEmailStyle: 'CODE', + verificationEmailSubject: 'Verify your email', + verificationEmailBody: emailBody, + }, + }; + + const translated = translateToAuthConstructLoginWith( + loginWith, + backendResolver, + ); + + assert.strictEqual( + (translated.email as Record)?.otpLogin, + undefined, + ); + assert.deepStrictEqual(translated, { + email: { + verificationEmailStyle: 'CODE', + verificationEmailSubject: 'Verify your email', + verificationEmailBody: emailBody, + }, + }); + }); + + void it('does not add otpLogin when not specified for phone', () => { + const phoneConfig = { + verificationMessage: (createCode: () => string) => + `text${createCode()}text2`, + }; + const loginWith: AuthLoginWithFactoryProps = { + phone: phoneConfig, + }; + + const translated = translateToAuthConstructLoginWith( + loginWith, + backendResolver, + ); + + assert.strictEqual( + (translated.phone as Record)?.otpLogin, + undefined, + ); + assert.deepStrictEqual(translated, { + phone: { + verificationMessage: phoneConfig.verificationMessage, + }, + }); + }); + + void it('does not add webAuthn when not specified', () => { + const loginWith: AuthLoginWithFactoryProps = { + email: true, + phone: true, + }; + + const translated = translateToAuthConstructLoginWith( + loginWith, + backendResolver, + ); + + assert.strictEqual(translated.webAuthn, undefined); + assert.deepStrictEqual(translated, { + email: true, + phone: true, + }); + }); + + void it('preserves existing configuration without adding passwordless properties', () => { + const loginWith: AuthLoginWithFactoryProps = { + email: { + verificationEmailStyle: 'CODE', + }, + phone: { + verificationMessage: (createCode: () => string) => + `text${createCode()}text2`, + }, + }; + + const translated = translateToAuthConstructLoginWith( + loginWith, + backendResolver, + ); + + assert.strictEqual( + (translated.email as Record)?.otpLogin, + undefined, + ); + assert.strictEqual( + (translated.phone as Record)?.otpLogin, + undefined, + ); + assert.strictEqual(translated.webAuthn, undefined); + }); + + void it('translates external providers without adding passwordless properties', () => { + const loginWith: AuthLoginWithFactoryProps = { + email: true, + externalProviders: { + google: { + clientId: new TestBackendSecret(googleClientId), + clientSecret: new TestBackendSecret(googleClientSecret), + }, + callbackUrls: callbackUrls, + logoutUrls: logoutUrls, + }, + }; + + const translated = translateToAuthConstructLoginWith( + loginWith, + backendResolver, + ); + + assert.strictEqual( + (translated.email as Record)?.otpLogin, + undefined, + ); + assert.strictEqual(translated.webAuthn, undefined); + assert.ok(translated.externalProviders); + }); + + void it('handles email as boolean without adding otpLogin', () => { + const loginWith: AuthLoginWithFactoryProps = { + email: true, + }; + + const translated = translateToAuthConstructLoginWith( + loginWith, + backendResolver, + ); + + assert.strictEqual(translated.email, true); + assert.strictEqual(translated.webAuthn, undefined); + }); + + void it('handles phone as boolean without adding otpLogin', () => { + const loginWith: AuthLoginWithFactoryProps = { + phone: true, + }; + + const translated = translateToAuthConstructLoginWith( + loginWith, + backendResolver, + ); + + assert.strictEqual(translated.phone, true); + assert.strictEqual(translated.webAuthn, undefined); + }); }); diff --git a/packages/backend-auth/src/translate_auth_props.ts b/packages/backend-auth/src/translate_auth_props.ts index 88898cfadc0..aa71f25fc89 100644 --- a/packages/backend-auth/src/translate_auth_props.ts +++ b/packages/backend-auth/src/translate_auth_props.ts @@ -33,6 +33,22 @@ export const translateToAuthConstructLoginWith = ( ): AuthProps['loginWith'] => { const result: AuthProps['loginWith'] = authFactoryLoginWith as AuthProps['loginWith']; + + if (authFactoryLoginWith.webAuthn !== undefined) { + if (authFactoryLoginWith.webAuthn === true) { + result.webAuthn = { + relyingPartyId: 'AUTO', + userVerification: 'preferred', + }; + } else { + result.webAuthn = { + relyingPartyId: authFactoryLoginWith.webAuthn.relyingPartyId ?? 'AUTO', + userVerification: + authFactoryLoginWith.webAuthn.userVerification ?? 'preferred', + }; + } + } + if (!authFactoryLoginWith.externalProviders) { return result; } diff --git a/packages/integration-tests/src/test-e2e/deployment/passwordless_auth.deployment.test.ts b/packages/integration-tests/src/test-e2e/deployment/passwordless_auth.deployment.test.ts new file mode 100644 index 00000000000..c083b9ac471 --- /dev/null +++ b/packages/integration-tests/src/test-e2e/deployment/passwordless_auth.deployment.test.ts @@ -0,0 +1,4 @@ +import { PasswordlessAuthTestProjectCreator } from '../../test-project-setup/passwordless_auth_project.js'; +import { defineDeploymentTest } from './deployment.test.template.js'; + +defineDeploymentTest(new PasswordlessAuthTestProjectCreator()); diff --git a/packages/integration-tests/src/test-e2e/sandbox/passwordless_auth.sandbox.test.ts b/packages/integration-tests/src/test-e2e/sandbox/passwordless_auth.sandbox.test.ts new file mode 100644 index 00000000000..28732dbb7c9 --- /dev/null +++ b/packages/integration-tests/src/test-e2e/sandbox/passwordless_auth.sandbox.test.ts @@ -0,0 +1,4 @@ +import { defineSandboxTest } from './sandbox.test.template.js'; +import { PasswordlessAuthTestProjectCreator } from '../../test-project-setup/passwordless_auth_project.js'; + +defineSandboxTest(new PasswordlessAuthTestProjectCreator()); diff --git a/packages/integration-tests/src/test-project-setup/passwordless_auth_project.ts b/packages/integration-tests/src/test-project-setup/passwordless_auth_project.ts new file mode 100644 index 00000000000..065f3aaeb7c --- /dev/null +++ b/packages/integration-tests/src/test-project-setup/passwordless_auth_project.ts @@ -0,0 +1,276 @@ +import { TestProjectBase } from './test_project_base.js'; +import fsp from 'fs/promises'; +import { createEmptyAmplifyProject } from './create_empty_amplify_project.js'; +import { CloudFormationClient } from '@aws-sdk/client-cloudformation'; +import { TestProjectCreator } from './test_project_creator.js'; +import { AmplifyClient } from '@aws-sdk/client-amplify'; +import { + AuthFactorType, + CognitoIdentityProviderClient, + DescribeUserPoolClientCommand, + DescribeUserPoolCommand, +} from '@aws-sdk/client-cognito-identity-provider'; +import { e2eToolingClientConfig } from '../e2e_tooling_client_config.js'; +import { DeployedResourcesFinder } from '../find_deployed_resource.js'; +import { BackendIdentifier } from '@aws-amplify/plugin-types'; +import assert from 'node:assert'; + +/** + * Creates passwordless authentication test projects with different configurations + */ +export class PasswordlessAuthTestProjectCreator implements TestProjectCreator { + readonly name = 'passwordless-auth'; + + /** + * Creates project creator. + */ + constructor( + private readonly cfnClient: CloudFormationClient = new CloudFormationClient( + e2eToolingClientConfig, + ), + private readonly amplifyClient: AmplifyClient = new AmplifyClient( + e2eToolingClientConfig, + ), + private readonly cognitoClient: CognitoIdentityProviderClient = new CognitoIdentityProviderClient( + e2eToolingClientConfig, + ), + private readonly resourceFinder: DeployedResourcesFinder = new DeployedResourcesFinder(), + ) {} + + /** + * Creates a test project with email OTP enabled + */ + createEmailOtpProject = async ( + e2eProjectDir: string, + ): Promise => { + return this.createProjectWithConfig( + e2eProjectDir, + 'email-otp', + { email: { otpLogin: true } }, + ['EMAIL_OTP' as AuthFactorType], + ); + }; + + /** + * Creates a test project with SMS OTP enabled + */ + createSmsOtpProject = async ( + e2eProjectDir: string, + ): Promise => { + return this.createProjectWithConfig( + e2eProjectDir, + 'sms-otp', + { phone: { otpLogin: true } }, + ['SMS_OTP' as AuthFactorType], + ); + }; + + /** + * Creates a test project with WebAuthn enabled + */ + createWebAuthnProject = async ( + e2eProjectDir: string, + ): Promise => { + return this.createProjectWithConfig( + e2eProjectDir, + 'webauthn', + { email: true, webAuthn: true }, + ['WEB_AUTHN' as AuthFactorType], + ); + }; + + /** + * Creates a test project with all passwordless factors enabled + */ + createCombinedFactorsProject = async ( + e2eProjectDir: string, + ): Promise => { + return this.createProjectWithConfig( + e2eProjectDir, + 'combined-factors', + { + email: { otpLogin: true }, + phone: { otpLogin: true }, + webAuthn: true, + }, + [ + 'EMAIL_OTP' as AuthFactorType, + 'SMS_OTP' as AuthFactorType, + 'WEB_AUTHN' as AuthFactorType, + ], + ); + }; + + /** + * Default createProject implementation (creates email OTP project) + */ + createProject = async (e2eProjectDir: string): Promise => { + return this.createEmailOtpProject(e2eProjectDir); + }; + + /** + * Creates a test project with the specified passwordless configuration + */ + private createProjectWithConfig = async ( + e2eProjectDir: string, + projectSuffix: string, + loginWithConfig: Record, + expectedAuthFactors: AuthFactorType[], + ): Promise => { + const { projectName, projectRoot, projectAmplifyDir } = + await createEmptyAmplifyProject( + `${this.name}-${projectSuffix}`, + e2eProjectDir, + ); + + const project = new PasswordlessAuthTestProject( + projectName, + projectRoot, + projectAmplifyDir, + this.cfnClient, + this.amplifyClient, + this.cognitoClient, + this.resourceFinder, + expectedAuthFactors, + ); + + await fsp.mkdir(projectAmplifyDir, { recursive: true }); + await fsp.mkdir(`${projectAmplifyDir}/auth`, { recursive: true }); + + const loginWithStr = JSON.stringify(loginWithConfig, null, 2) + .split('\n') + .map((line, index) => (index === 0 ? line : ` ${line}`)) + .join('\n'); + + const authResourceContent = `import { defineAuth } from '@aws-amplify/backend'; + +export const auth = defineAuth({ + loginWith: ${loginWithStr} +}); +`; + + await fsp.writeFile( + `${projectAmplifyDir}/auth/resource.ts`, + authResourceContent, + ); + + const backendContent = `import { defineBackend } from '@aws-amplify/backend'; +import { auth } from './auth/resource.js'; + +defineBackend({ + auth +}); +`; + + await fsp.writeFile(`${projectAmplifyDir}/backend.ts`, backendContent); + + return project; + }; +} + +/** + * Passwordless authentication test project + */ +class PasswordlessAuthTestProject extends TestProjectBase { + readonly sourceProjectAmplifyDirURL: URL = new URL( + '../test-projects/passwordless-auth/', + import.meta.url, + ); + + private expectedAuthFactors: AuthFactorType[] = []; + + /** + * Create a test project instance. + */ + constructor( + name: string, + projectDirPath: string, + projectAmplifyDirPath: string, + cfnClient: CloudFormationClient, + amplifyClient: AmplifyClient, + private readonly cognitoClient: CognitoIdentityProviderClient, + private readonly resourceFinder: DeployedResourcesFinder, + expectedAuthFactors: AuthFactorType[], + ) { + super( + name, + projectDirPath, + projectAmplifyDirPath, + cfnClient, + amplifyClient, + ); + this.expectedAuthFactors = expectedAuthFactors; + } + + override async assertPostDeployment( + backendId: BackendIdentifier, + ): Promise { + await super.assertPostDeployment(backendId); + + const userPools = await this.resourceFinder.findByBackendIdentifier( + backendId, + 'AWS::Cognito::UserPool', + ); + assert.strictEqual(userPools.length, 1, 'Expected exactly one User Pool'); + + const userPoolId = userPools[0]; + assert.ok(userPoolId, 'User Pool ID should exist'); + + const userPoolResponse = await this.cognitoClient.send( + new DescribeUserPoolCommand({ UserPoolId: userPoolId }), + ); + + const userPool = userPoolResponse.UserPool; + assert.ok(userPool, 'User Pool should exist'); + + const signInPolicy = userPool.Policies?.SignInPolicy; + assert.ok(signInPolicy, 'SignInPolicy should exist'); + assert.ok( + signInPolicy.AllowedFirstAuthFactors, + 'AllowedFirstAuthFactors should exist', + ); + + for (const factor of this.expectedAuthFactors) { + assert.ok( + signInPolicy.AllowedFirstAuthFactors.includes(factor), + `AllowedFirstAuthFactors should include ${factor}`, + ); + } + + // PASSWORD should always be included + assert.ok( + signInPolicy.AllowedFirstAuthFactors.includes( + 'PASSWORD' as AuthFactorType, + ), + 'AllowedFirstAuthFactors should always include PASSWORD', + ); + + const userPoolClients = await this.resourceFinder.findByBackendIdentifier( + backendId, + 'AWS::Cognito::UserPoolClient', + ); + assert.strictEqual( + userPoolClients.length, + 1, + 'Expected exactly one User Pool Client', + ); + + const userPoolClientId = userPoolClients[0]; + assert.ok(userPoolClientId, 'User Pool Client ID should exist'); + + const clientResponse = await this.cognitoClient.send( + new DescribeUserPoolClientCommand({ + UserPoolId: userPoolId, + ClientId: userPoolClientId, + }), + ); + + const client = clientResponse.UserPoolClient; + assert.ok(client, 'User Pool Client should exist'); + assert.ok(client.ExplicitAuthFlows, 'ExplicitAuthFlows should exist'); + assert.ok( + client.ExplicitAuthFlows.includes('ALLOW_USER_AUTH'), + 'ExplicitAuthFlows should include ALLOW_USER_AUTH', + ); + } +} From 9fc12a8c5ccc2aff370c29070dabb1f5d669ed67 Mon Sep 17 00:00:00 2001 From: Ahmed Hamouda Date: Tue, 18 Nov 2025 15:48:56 +0100 Subject: [PATCH 2/2] chore: remove unnecessary e2e tests --- .changeset/wise-crews-think.md | 1 - .../passwordless_auth.deployment.test.ts | 4 - .../sandbox/passwordless_auth.sandbox.test.ts | 4 - .../passwordless_auth_project.ts | 276 ------------------ 4 files changed, 285 deletions(-) delete mode 100644 packages/integration-tests/src/test-e2e/deployment/passwordless_auth.deployment.test.ts delete mode 100644 packages/integration-tests/src/test-e2e/sandbox/passwordless_auth.sandbox.test.ts delete mode 100644 packages/integration-tests/src/test-project-setup/passwordless_auth_project.ts diff --git a/.changeset/wise-crews-think.md b/.changeset/wise-crews-think.md index 97a16105c09..69c0f92e3bd 100644 --- a/.changeset/wise-crews-think.md +++ b/.changeset/wise-crews-think.md @@ -1,5 +1,4 @@ --- -'@aws-amplify/integration-tests': minor '@aws-amplify/auth-construct': minor '@aws-amplify/backend-auth': minor '@aws-amplify/backend': minor diff --git a/packages/integration-tests/src/test-e2e/deployment/passwordless_auth.deployment.test.ts b/packages/integration-tests/src/test-e2e/deployment/passwordless_auth.deployment.test.ts deleted file mode 100644 index c083b9ac471..00000000000 --- a/packages/integration-tests/src/test-e2e/deployment/passwordless_auth.deployment.test.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PasswordlessAuthTestProjectCreator } from '../../test-project-setup/passwordless_auth_project.js'; -import { defineDeploymentTest } from './deployment.test.template.js'; - -defineDeploymentTest(new PasswordlessAuthTestProjectCreator()); diff --git a/packages/integration-tests/src/test-e2e/sandbox/passwordless_auth.sandbox.test.ts b/packages/integration-tests/src/test-e2e/sandbox/passwordless_auth.sandbox.test.ts deleted file mode 100644 index 28732dbb7c9..00000000000 --- a/packages/integration-tests/src/test-e2e/sandbox/passwordless_auth.sandbox.test.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { defineSandboxTest } from './sandbox.test.template.js'; -import { PasswordlessAuthTestProjectCreator } from '../../test-project-setup/passwordless_auth_project.js'; - -defineSandboxTest(new PasswordlessAuthTestProjectCreator()); diff --git a/packages/integration-tests/src/test-project-setup/passwordless_auth_project.ts b/packages/integration-tests/src/test-project-setup/passwordless_auth_project.ts deleted file mode 100644 index 065f3aaeb7c..00000000000 --- a/packages/integration-tests/src/test-project-setup/passwordless_auth_project.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { TestProjectBase } from './test_project_base.js'; -import fsp from 'fs/promises'; -import { createEmptyAmplifyProject } from './create_empty_amplify_project.js'; -import { CloudFormationClient } from '@aws-sdk/client-cloudformation'; -import { TestProjectCreator } from './test_project_creator.js'; -import { AmplifyClient } from '@aws-sdk/client-amplify'; -import { - AuthFactorType, - CognitoIdentityProviderClient, - DescribeUserPoolClientCommand, - DescribeUserPoolCommand, -} from '@aws-sdk/client-cognito-identity-provider'; -import { e2eToolingClientConfig } from '../e2e_tooling_client_config.js'; -import { DeployedResourcesFinder } from '../find_deployed_resource.js'; -import { BackendIdentifier } from '@aws-amplify/plugin-types'; -import assert from 'node:assert'; - -/** - * Creates passwordless authentication test projects with different configurations - */ -export class PasswordlessAuthTestProjectCreator implements TestProjectCreator { - readonly name = 'passwordless-auth'; - - /** - * Creates project creator. - */ - constructor( - private readonly cfnClient: CloudFormationClient = new CloudFormationClient( - e2eToolingClientConfig, - ), - private readonly amplifyClient: AmplifyClient = new AmplifyClient( - e2eToolingClientConfig, - ), - private readonly cognitoClient: CognitoIdentityProviderClient = new CognitoIdentityProviderClient( - e2eToolingClientConfig, - ), - private readonly resourceFinder: DeployedResourcesFinder = new DeployedResourcesFinder(), - ) {} - - /** - * Creates a test project with email OTP enabled - */ - createEmailOtpProject = async ( - e2eProjectDir: string, - ): Promise => { - return this.createProjectWithConfig( - e2eProjectDir, - 'email-otp', - { email: { otpLogin: true } }, - ['EMAIL_OTP' as AuthFactorType], - ); - }; - - /** - * Creates a test project with SMS OTP enabled - */ - createSmsOtpProject = async ( - e2eProjectDir: string, - ): Promise => { - return this.createProjectWithConfig( - e2eProjectDir, - 'sms-otp', - { phone: { otpLogin: true } }, - ['SMS_OTP' as AuthFactorType], - ); - }; - - /** - * Creates a test project with WebAuthn enabled - */ - createWebAuthnProject = async ( - e2eProjectDir: string, - ): Promise => { - return this.createProjectWithConfig( - e2eProjectDir, - 'webauthn', - { email: true, webAuthn: true }, - ['WEB_AUTHN' as AuthFactorType], - ); - }; - - /** - * Creates a test project with all passwordless factors enabled - */ - createCombinedFactorsProject = async ( - e2eProjectDir: string, - ): Promise => { - return this.createProjectWithConfig( - e2eProjectDir, - 'combined-factors', - { - email: { otpLogin: true }, - phone: { otpLogin: true }, - webAuthn: true, - }, - [ - 'EMAIL_OTP' as AuthFactorType, - 'SMS_OTP' as AuthFactorType, - 'WEB_AUTHN' as AuthFactorType, - ], - ); - }; - - /** - * Default createProject implementation (creates email OTP project) - */ - createProject = async (e2eProjectDir: string): Promise => { - return this.createEmailOtpProject(e2eProjectDir); - }; - - /** - * Creates a test project with the specified passwordless configuration - */ - private createProjectWithConfig = async ( - e2eProjectDir: string, - projectSuffix: string, - loginWithConfig: Record, - expectedAuthFactors: AuthFactorType[], - ): Promise => { - const { projectName, projectRoot, projectAmplifyDir } = - await createEmptyAmplifyProject( - `${this.name}-${projectSuffix}`, - e2eProjectDir, - ); - - const project = new PasswordlessAuthTestProject( - projectName, - projectRoot, - projectAmplifyDir, - this.cfnClient, - this.amplifyClient, - this.cognitoClient, - this.resourceFinder, - expectedAuthFactors, - ); - - await fsp.mkdir(projectAmplifyDir, { recursive: true }); - await fsp.mkdir(`${projectAmplifyDir}/auth`, { recursive: true }); - - const loginWithStr = JSON.stringify(loginWithConfig, null, 2) - .split('\n') - .map((line, index) => (index === 0 ? line : ` ${line}`)) - .join('\n'); - - const authResourceContent = `import { defineAuth } from '@aws-amplify/backend'; - -export const auth = defineAuth({ - loginWith: ${loginWithStr} -}); -`; - - await fsp.writeFile( - `${projectAmplifyDir}/auth/resource.ts`, - authResourceContent, - ); - - const backendContent = `import { defineBackend } from '@aws-amplify/backend'; -import { auth } from './auth/resource.js'; - -defineBackend({ - auth -}); -`; - - await fsp.writeFile(`${projectAmplifyDir}/backend.ts`, backendContent); - - return project; - }; -} - -/** - * Passwordless authentication test project - */ -class PasswordlessAuthTestProject extends TestProjectBase { - readonly sourceProjectAmplifyDirURL: URL = new URL( - '../test-projects/passwordless-auth/', - import.meta.url, - ); - - private expectedAuthFactors: AuthFactorType[] = []; - - /** - * Create a test project instance. - */ - constructor( - name: string, - projectDirPath: string, - projectAmplifyDirPath: string, - cfnClient: CloudFormationClient, - amplifyClient: AmplifyClient, - private readonly cognitoClient: CognitoIdentityProviderClient, - private readonly resourceFinder: DeployedResourcesFinder, - expectedAuthFactors: AuthFactorType[], - ) { - super( - name, - projectDirPath, - projectAmplifyDirPath, - cfnClient, - amplifyClient, - ); - this.expectedAuthFactors = expectedAuthFactors; - } - - override async assertPostDeployment( - backendId: BackendIdentifier, - ): Promise { - await super.assertPostDeployment(backendId); - - const userPools = await this.resourceFinder.findByBackendIdentifier( - backendId, - 'AWS::Cognito::UserPool', - ); - assert.strictEqual(userPools.length, 1, 'Expected exactly one User Pool'); - - const userPoolId = userPools[0]; - assert.ok(userPoolId, 'User Pool ID should exist'); - - const userPoolResponse = await this.cognitoClient.send( - new DescribeUserPoolCommand({ UserPoolId: userPoolId }), - ); - - const userPool = userPoolResponse.UserPool; - assert.ok(userPool, 'User Pool should exist'); - - const signInPolicy = userPool.Policies?.SignInPolicy; - assert.ok(signInPolicy, 'SignInPolicy should exist'); - assert.ok( - signInPolicy.AllowedFirstAuthFactors, - 'AllowedFirstAuthFactors should exist', - ); - - for (const factor of this.expectedAuthFactors) { - assert.ok( - signInPolicy.AllowedFirstAuthFactors.includes(factor), - `AllowedFirstAuthFactors should include ${factor}`, - ); - } - - // PASSWORD should always be included - assert.ok( - signInPolicy.AllowedFirstAuthFactors.includes( - 'PASSWORD' as AuthFactorType, - ), - 'AllowedFirstAuthFactors should always include PASSWORD', - ); - - const userPoolClients = await this.resourceFinder.findByBackendIdentifier( - backendId, - 'AWS::Cognito::UserPoolClient', - ); - assert.strictEqual( - userPoolClients.length, - 1, - 'Expected exactly one User Pool Client', - ); - - const userPoolClientId = userPoolClients[0]; - assert.ok(userPoolClientId, 'User Pool Client ID should exist'); - - const clientResponse = await this.cognitoClient.send( - new DescribeUserPoolClientCommand({ - UserPoolId: userPoolId, - ClientId: userPoolClientId, - }), - ); - - const client = clientResponse.UserPoolClient; - assert.ok(client, 'User Pool Client should exist'); - assert.ok(client.ExplicitAuthFlows, 'ExplicitAuthFlows should exist'); - assert.ok( - client.ExplicitAuthFlows.includes('ALLOW_USER_AUTH'), - 'ExplicitAuthFlows should include ALLOW_USER_AUTH', - ); - } -}