amazon-web-servicesauthenticationoauth-2.0amazon-cognitofederated-identity

Prevent users from signing up on their own with federated identity providers (FIP) but allow sign in with a FIP if added by an administrator


I've set up a user pool in Amazon Cognito for my web application. The application is not meant to be public and only specific users are allowed to sign in. The policies of that user pool in the Amazon Console allow only administrators to create new users.

I've implemented sign in through Facebook and Google. Cognito does indeed let users sign into the application with these federated identity providers, which is great. However, it seems that anybody with a Facebook or Google account can sign themselves up now.

So, on one hand, people can not create their own user with regular Cognito credentials but, on the other hand, they can create a new user in Cognito if they use a federated identity provider.

Is there a way to restrict signing into my application with Facebook or Google to only users that already exist in the user pool? That way, administrators would still be able to control who exactly can access the application. I would like to use the email shared by the federated identity provider to check if they are allowed to sign in.

The application is set up with CloudFront. I've written a Lambda that intercepts origin requests to check for tokens in cookies and authorize access based on the validity of the access token.

I would like to avoid writing additional code to prevent users to sign themselves up with Facebook or Google but if there is no other way, I'll update the Lambda.


Solution

  • So, here is the pre sign-up Lambda trigger I ended up writing. I took the time to use async/await instead of Promises. It works nicely, except that there is a documented bug where Cognito forces users who use external identity providers for the first time to sign up and then sign in again (so they see the auth page twice) before they can access the application. I have an idea on how to fix this but in the meantime the Lambda below does what I wanted. Also, it turns out that the ID that comes from Login With Amazon is not using the correct case, so I had to re-format that ID by hand, which is unfortunate. Makes me feel like the implementation of the triggers for Cognito is a bit buggy.

    const PROVIDER_MAP = new Map([
        ['facebook', 'Facebook'],
        ['google', 'Google'],
        ['loginwithamazon', 'LoginWithAmazon'],
        ['signinwithapple', 'SignInWithApple']
    ]);
    
    async function getFirstCognitoUserWithSameEmail(event) {
        const { region, userPoolId, request } = event;
    
        const AWS = require('aws-sdk');
        const cognito = new AWS.CognitoIdentityServiceProvider({
            region
        });
    
        const parameters = {
            UserPoolId: userPoolId,
            AttributesToGet: ['sub', 'email'], // We don't really need these attributes
            Filter: `email = "${request.userAttributes.email}"` // Unfortunately, only one filter can be applied at once
        };
    
        const listUserQuery = await cognito.listUsers(parameters).promise();
    
        if (!listUserQuery || !listUserQuery.Users) {
            return { error: 'Could not get list of users.' };
        }
    
        const { Users: users } = listUserQuery;
    
        const cognitoUsers = users.filter(
            user => user.UserStatus !== 'EXTERNAL_PROVIDER' && user.Enabled
        );
    
        if (cognitoUsers.length === 0) {
            console.log('No existing enabled Cognito user with same email address found.');
            return {
                error: 'User is not allowed to sign up.'
            };
        }
    
        if (cognitoUsers.length > 1) {
            cognitoUsers.sort((a, b) =>
                a.UserCreateDate > b.UserCreateDate ? 1 : -1
            );
        }
    
        console.log(
            `Found ${cognitoUsers.length} enabled Cognito user(s) with same email address.`
        );
    
        return { user: cognitoUsers[0], error: null };
    }
    
    // Only external users get linked with Cognito users by design
    async function linkExternalUserToCognitoUser(event, existingUsername) {
        const { userName, region, userPoolId } = event;
    
        const [
            externalIdentityProviderName,
            externalIdentityUserId
        ] = userName.split('_');
    
        if (!externalIdentityProviderName || !externalIdentityUserId) {
            console.error(
                'Invalid identity provider name or external user ID. Should look like facebook_123456789.'
            );
            return { error: 'Invalid external user data.' };
        }
    
        const providerName = PROVIDER_MAP.get(externalIdentityProviderName);
    
        let userId = externalIdentityUserId;
        if (providerName === PROVIDER_MAP.get('loginwithamazon')) {
            // Amazon IDs look like amzn1.account.ABC123DEF456
            const [part1, part2, amazonId] = userId.split('.');
            const upperCaseAmazonId = amazonId.toUpperCase();
            userId = `${part1}.${part2}.${upperCaseAmazonId}`;
        }
    
        const AWS = require('aws-sdk');
        const cognito = new AWS.CognitoIdentityServiceProvider({
            region
        });
    
        console.log(`Linking ${userName} (ID: ${userId}).`);
    
        const parameters = {
            // Existing user in the user pool to be linked to the external identity provider user account.
            DestinationUser: {
                ProviderAttributeValue: existingUsername,
                ProviderName: 'Cognito'
            },
            // An external identity provider account for a user who does not currently exist yet in the user pool.
            SourceUser: {
                ProviderAttributeName: 'Cognito_Subject',
                ProviderAttributeValue: userId,
                ProviderName: providerName // Facebook, Google, Login with Amazon, Sign in with Apple
            },
            UserPoolId: userPoolId
        };
    
        // See https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_AdminLinkProviderForUser.html
        await cognito.adminLinkProviderForUser(parameters).promise();
    
        console.log('Successfully linked external identity to user.');
    
        // TODO: Update the user created for the external identity and update the "email verified" flag to true. This should take care of the bug where users have to sign in twice when they sign up with an identity provider for the first time to access the website.
        // Bug is documented here: https://forums.aws.amazon.com/thread.jspa?threadID=267154&start=25&tstart=0
    
        return { error: null };
    }
    
    module.exports = async (event, context, callback) => {
        // See event structure at https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-identity-pools-working-with-aws-lambda-triggers.html
        const { triggerSource } = event;
    
        switch (triggerSource) {
            default: {
                return callback(null, event);
            }
            case 'PreSignUp_ExternalProvider': {
                try {
                    const {
                        user,
                        error: getUserError
                    } = await getFirstCognitoUserWithSameEmail(event);
    
                    if (getUserError) {
                        console.error(getUserError);
                        return callback(getUserError, null);
                    }
    
                    const {
                        error: linkUserError
                    } = await linkExternalUserToCognitoUser(event, user.Username);
    
                    if (linkUserError) {
                        console.error(linkUserError);
                        return callback(linkUserError, null);
                    }
    
                    return callback(null, event);
                } catch (error) {
                    const errorMessage =
                        'An error occurred while signing up user from an external identity provider.';
                    console.error(errorMessage, error);
    
                    return callback(errorMessage, null);
                }
            }
        }
    };