Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
94abc50
feat(passwordess-auth-construct): Passwordless Sign Up
Dec 18, 2023
dbf23d6
Refactor and increase unit test coverage
Dec 18, 2023
55fddad
Remove unnecessary comment
Dec 18, 2023
1acf7c6
Refactor invalid user logic for custom auth
Dec 18, 2023
d6fd1f7
Remove unused code
Dec 18, 2023
b2e0b14
Fix lint issues
Dec 18, 2023
a3e6774
Fix lint issues
Dec 18, 2023
1932f5c
Refactor CognitoUserService
Dec 19, 2023
db3e0a0
Fix lint warnings
Dec 19, 2023
7f4ae4b
Rename handler and update cognito client init
Dec 19, 2023
a0bbe6b
update test casting
Dec 19, 2023
86fe786
Update packages/passwordless-auth-construct/src/services/cognito_user…
Dec 19, 2023
6848547
Remove unused region
Dec 19, 2023
feff9d6
disable eslint rule
Dec 19, 2023
85513ad
fix auth-construct unit test
Dec 19, 2023
d342196
refactor auth-service passwordless signup
Dec 19, 2023
269208c
Update packages/passwordless-auth-construct/src/custom-auth/custom_au…
Dec 21, 2023
d1fe61b
Apply suggestions from code review
Dec 21, 2023
5336308
Addressing PR comments
Dec 23, 2023
4ee9ebb
Addressing PR comments
Dec 23, 2023
4091889
Remove verifyChallenge prop
Dec 29, 2023
ffecf63
Add type for CognitoUserAttribute
Dec 29, 2023
30ec454
Rename prop to signUpEnabled and check create user errors
Dec 29, 2023
335f2a2
Update error message for Invalid JSON
Dec 29, 2023
29e3f88
Add comment about lambda construct for execution role
Dec 29, 2023
d15c31e
Validate error for JSON parse
Dec 29, 2023
830b3ff
Adds gh issue for pending items
Jan 11, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .eslint_dictionary.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"amplifyconfiguration",
"appleid",
"appsync",
"apigateway",
"argv",
"arn",
"arns",
Expand All @@ -19,6 +20,7 @@
"codegen",
"cognito",
"commonjs",
"cors",
"ctor",
"darwin",
"datastore",
Expand Down Expand Up @@ -87,6 +89,7 @@
"pathname",
"pipelined",
"posix",
"preflight",
"readdir",
"readline",
"readonly",
Expand Down
10 changes: 10 additions & 0 deletions packages/auth-construct/src/construct.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,11 @@ void describe('Auth construct', () => {
Name: 'email',
Required: true,
},
{
AttributeDataType: 'String',
Mutable: true,
Name: 'passwordless_sign_up',
},
],
});
});
Expand Down Expand Up @@ -436,6 +441,11 @@ void describe('Auth construct', () => {
Name: 'family_name',
Required: true,
},
{
AttributeDataType: 'String',
Mutable: true,
Name: 'passwordless_sign_up',
},
],
});
});
Expand Down
5 changes: 5 additions & 0 deletions packages/auth-construct/src/construct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,11 @@ export class AmplifyAuth
phoneNumber: DEFAULTS.IS_REQUIRED_ATTRIBUTE.phoneNumber(phoneEnabled),
...(props.userAttributes ? props.userAttributes : {}),
},
customAttributes: {
// TODO confirm this attribute before release
// https://github.com/aws-amplify/amplify-backend/issues/911
passwordless_sign_up: new cognito.StringAttribute({ mutable: true }),
},
selfSignUpEnabled: DEFAULTS.ALLOW_SELF_SIGN_UP,
mfa: this.getMFAMode(props.multifactor),
mfaMessage: this.getMFAMessage(props.multifactor),
Expand Down
1 change: 1 addition & 0 deletions packages/passwordless-auth-construct/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export type OtpAuthOptions = {
export type PasswordlessAuthProps = {
magicLink?: MagicLinkAuthOptions;
otp?: OtpAuthOptions;
signUpEnabled?: boolean;
};

// @public
Expand Down
5 changes: 5 additions & 0 deletions packages/passwordless-auth-construct/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,8 @@ export enum CognitoMetadataKeys {
REDIRECT_URI = 'Amplify.Passwordless.redirectUri',
SIGN_IN_METHOD = 'Amplify.Passwordless.signInMethod',
}

/**
* Custom user Attribute name for passwordless sign up feature
*/
export const PASSWORDLESS_SIGN_UP_ATTR_NAME = 'custom:passwordless_sign_up';
16 changes: 14 additions & 2 deletions packages/passwordless-auth-construct/src/construct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { AmplifyOtpAuth } from './otp/construct.js';
import { AmplifyMagicLinkAuth } from './magic-link/construct.js';
import { PolicyStatement } from 'aws-cdk-lib/aws-iam';
import { Aws } from 'aws-cdk-lib';
import { AmplifyPasswordlessSignUp } from './sign-up/construct.js';

const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
Expand Down Expand Up @@ -137,7 +138,7 @@ export class AmplifyPasswordlessAuth extends Construct {
{
defineAuthChallenge,
createAuthChallenge,
verifyAuthChallengeResponse,
verifyAuthChallengeResponse: verifyAuthChallengeResponse,
},
props.otp
);
Expand All @@ -151,11 +152,22 @@ export class AmplifyPasswordlessAuth extends Construct {
{
defineAuthChallenge,
createAuthChallenge,
verifyAuthChallengeResponse,
verifyAuthChallengeResponse: verifyAuthChallengeResponse,
},
props.magicLink
);
}

// Configure Sign Up without password
if (props.signUpEnabled) {
new AmplifyPasswordlessSignUp(scope, `${id}-signup-passwordless`, {
userPool: auth.resources.userPool,
verifyExecutionRole:
// According to cdk docs there always is a default role https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_lambda-readme.html#execution-role
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
verifyAuthChallengeResponse.role!,
});
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,17 @@ import {
VerifyAuthChallengeResponseTriggerEvent,
} from 'aws-lambda';
import { CustomAuthService } from './custom_auth_service.js';
import { CognitoUserService } from '../services/cognito_user_service.js';
import { CognitoIdentityProviderClient } from '@aws-sdk/client-cognito-identity-provider';

/**
* A mock client for issuing send command successful
*/
class MockCognitoClient extends CognitoIdentityProviderClient {
send(): Promise<void> {
return Promise.resolve();
}
}

// The custom auth session from the initial Cognito InitiateAuth call.
const initialSession: ChallengeResult = {
Expand Down Expand Up @@ -63,9 +74,12 @@ const mockChallengeService: ChallengeService = {
},
};

const customAuthService = new CustomAuthService({
getService: () => mockChallengeService,
});
const customAuthService = new CustomAuthService(
{
getService: () => mockChallengeService,
},
new CognitoUserService(new MockCognitoClient())
);

void describe('defineAuthChallenge', () => {
/**
Expand Down Expand Up @@ -255,9 +269,12 @@ void describe('createAuthChallenge', () => {
throw new Error('missing required metadata.');
},
};
const customAuthService = new CustomAuthService({
getService: () => challengeService,
});
const customAuthService = new CustomAuthService(
{
getService: () => challengeService,
},
new CognitoUserService(new MockCognitoClient())
);
const metadata = {
[CognitoMetadataKeys.SIGN_IN_METHOD]: 'OTP',
[CognitoMetadataKeys.ACTION]: 'REQUEST',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,13 @@ import {
PasswordlessAuthChallengeParams,
RespondToAutChallengeParams,
SignInMethod,
UserService,
} from '../types.js';
import { CognitoMetadataKeys } from '../constants.js';
import {
CognitoMetadataKeys,
PASSWORDLESS_SIGN_UP_ATTR_NAME,
} from '../constants.js';
import { DeliveryMediumType } from '@aws-sdk/client-cognito-identity-provider';

/**
* A class containing the Cognito Auth triggers used for Custom Auth.
Expand All @@ -21,8 +26,12 @@ export class CustomAuthService {
/**
* Creates a new CustomAuthService instance.
* @param challengeServiceFactory - A factory for creating challenge services.
* @param userService - A service to be used with Cognito User Pools operations
*/
constructor(private challengeServiceFactory: ChallengeServiceFactory) {}
constructor(
private challengeServiceFactory: ChallengeServiceFactory,
private userService: UserService
) {}

/**
* The Define Auth Challenge lambda handler.
Expand Down Expand Up @@ -135,10 +144,8 @@ export class CustomAuthService {
const { deliveryMedium, attributeName } =
this.validateDeliveryCodeDetails(event);

const { isVerified, destination } = this.validateDestination(
deliveryMedium,
event
);
const { destination, isFirstSignInAttempt, isVerified } =
this.validateDestination(deliveryMedium, event);

const challengeService = this.challengeServiceFactory.getService(method);

Expand All @@ -148,10 +155,12 @@ export class CustomAuthService {
challengeService.validateCreateAuthChallengeEvent(event);
}

// If the user is not found or if the attribute requested for challenge
// delivery is not verified, return a fake successful response to prevent
// user enumeration
if (event.request.userNotFound || !isVerified) {
// If the user is found and if the attribute requested for challenge
// delivery is verified or if the user was created via passwordless sign up (first sign in attempt)
const validUser =
!event.request.userNotFound && (isVerified || isFirstSignInAttempt);

if (!validUser) {
logger.info(
'User not found or user does not have a verified phone/email.'
);
Expand Down Expand Up @@ -210,9 +219,59 @@ export class CustomAuthService {

const method = this.validateSignInMethod(signInMethod);

return this.challengeServiceFactory
const verifyResult = await this.challengeServiceFactory
.getService(method)
.verifyChallenge(event);

const answerCorrect = verifyResult.response.answerCorrect;
logger.debug(
`Challenge response is ${answerCorrect ? 'correct' : 'incorrect'}`
);

let passwordlessConfiguration: Record<string, string> | undefined;

try {
passwordlessConfiguration =
event.request.userAttributes[PASSWORDLESS_SIGN_UP_ATTR_NAME] &&
JSON.parse(
event.request.userAttributes[PASSWORDLESS_SIGN_UP_ATTR_NAME]
);
} catch (err) {
// best effort to parse passwordless_sign_up attribute
if (err instanceof SyntaxError) {
// user attribute has incorrect value
logger.error(
`User attribute ${PASSWORDLESS_SIGN_UP_ATTR_NAME} has invalid value`
);
} else {
// this should not happen
throw err;
}
}

if (!passwordlessConfiguration?.allowSignInAttempt || !answerCorrect) {
return verifyResult;
}

const attributeName =
passwordlessConfiguration.deliveryMedium === DeliveryMediumType.SMS
? 'phone_number_verified'
: 'email_verified';

const attributeVerified =
event.request.userAttributes[attributeName] === 'true';

// Only update verified attribute the first time
if (answerCorrect && !attributeVerified) {
logger.debug(`Updating user attribute to verified: ${attributeName}`);
await this.userService.markAsVerifiedAndDeletePasswordlessAttribute({
username: event.userName,
attributeName,
userPoolId: event.userPoolId,
});
}

return verifyResult;
};

/**
Expand Down Expand Up @@ -280,31 +339,67 @@ export class CustomAuthService {
* @param deliveryMedium - The delivery medium for the challenge.
* @param event - The Create Auth Challenge event.
* @returns An object that contains a boolean indicating if the destination is
* verified, and the destination.
* verified, and the destination and if is the first attempt for passwordless signUp
*/
private validateDestination = (
deliveryMedium: DeliveryMedium,
event: CreateAuthChallengeTriggerEvent
):
| { isVerified: true; destination: string }
| { isVerified: false; destination: undefined } => {
): {
isFirstSignInAttempt: boolean;
isVerified: boolean;
destination: string;
} => {
const {
email,
phone_number: phoneNumber,
email_verified: emailVerified,
phone_number_verified: phoneNumberVerified,
[PASSWORDLESS_SIGN_UP_ATTR_NAME]: passwordlessSignUp,
} = event.request.userAttributes;

let isFirstSignInAttempt = false;
try {
const passwordlessConfiguration =
passwordlessSignUp && JSON.parse(passwordlessSignUp);
if (
passwordlessConfiguration?.allowSignInAttempt &&
passwordlessConfiguration?.deliveryMedium === deliveryMedium
) {
isFirstSignInAttempt = true;
}
} catch (err) {
// best effort to parse passwordless_sign_up attribute if the user was created via passwordless sign up
if (err instanceof SyntaxError) {
// user attribute has incorrect value
logger.error(
`User attribute ${PASSWORDLESS_SIGN_UP_ATTR_NAME} has invalid value`
);
} else {
// this should not happen
throw err;
}
}

if (
deliveryMedium === 'SMS' &&
(!phoneNumber || phoneNumberVerified !== 'true')
) {
return { isVerified: false, destination: undefined };
return {
isFirstSignInAttempt,
isVerified: false,
destination: phoneNumber,
};
}
if (deliveryMedium === 'EMAIL' && (!email || emailVerified !== 'true')) {
return { isVerified: false, destination: undefined };
return {
isFirstSignInAttempt: isFirstSignInAttempt,
isVerified: false,
destination: email,
};
}

return {
isFirstSignInAttempt,
isVerified: true,
destination: deliveryMedium === 'SMS' ? phoneNumber : email,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { KMSService } from '../services/kms_service.js';
import { MagicLinkStorageService } from '../magic-link/magic_link_storage_service.js';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { CognitoUserService } from '../services/cognito_user_service.js';
import { CognitoIdentityProviderClient } from '@aws-sdk/client-cognito-identity-provider';

const { otpConfig, magicLinkConfig, snsConfig, sesConfig } =
new PasswordlessConfig(process.env);
Expand Down Expand Up @@ -49,5 +51,9 @@ const challengeServiceFactory = new ChallengeServiceFactory([
magicLinkChallengeService,
]);

const cognitoUserService = new CognitoUserService(
new CognitoIdentityProviderClient()
);

export const { defineAuthChallenge, createAuthChallenge, verifyAuthChallenge } =
new CustomAuthService(challengeServiceFactory);
new CustomAuthService(challengeServiceFactory, cognitoUserService);
Loading