diff --git a/.changeset/wise-crews-think.md b/.changeset/wise-crews-think.md new file mode 100644 index 0000000000..69c0f92e3b --- /dev/null +++ b/.changeset/wise-crews-think.md @@ -0,0 +1,7 @@ +--- +'@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 946c5570d8..b31f98cf8c 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 667e60ca9d..2204f456ac 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 cfce6840c4..20da3ab2a2 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 a483c30715..83ce7e280c 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 b24607c6e3..b514b6d242 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 c192dc07a1..92accc33af 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 f156c6a24e..2005322d78 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 02f314621c..007edf5330 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 5af708fff9..f551314136 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 4aa313cb8e..e754a159c7 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 3d632e8215..2973e3d063 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 304af562c0..4cbe1bffdf 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 88898cfadc..aa71f25fc8 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; }