Skip to content

Missing implementation of Cognito Username/Alias Attributes #4249

Description

@leothomas

First of all, a big thank you for this great tool!

I think I've stumbled upon a missing or incorrect implementation in the Cognito user-pool users. According to the cognito docs when signing up and signing in, there are 2 configuration options (on the user pool) that enable users to use alternative identifiers (phone number, email, etc):

  1. Using the AliasAttributes field. In this case the user must supply a Username unique to the user pool and can then sign in with their username or any of the attributes they've specified in the AliasAttributes field (provided the email/phone number has been verified)
  2. Using the UsernameAttributes field. In this case the user does not supply a username, and instead signs up directly with an email or phone number. The user pool generates a GUID for that the user (which become the value of the sub field in the JWT) to track it in the UserPool, and all operations are performed using the user's email/phone number as well as their sub. When performing a list-users or get-user operation, the Username field will contain the sub (uuid) as opposed to the email/phone number used when signing up.

This issue is concerning the second option (UsernameAttributes) - since that's the implementation that we are using in our app, and the implementation we would like to be able to test.

Code sample implementing this behaviour:

from moto import mock_cognitoidp
import boto3


@mock_cognitoidp()
def moto():
    cognito_client = boto3.client("cognito-idp", region_name="us-east-1")
    return setup_user_pool(cognito_client)


def aws():
    cognito_client = boto3.client("cognito-idp", region_name="us-east-1")
    return setup_user_pool(cognito_client)


def setup_user_pool(cognito_client):
    
    # create user pool
    user_pool_id = cognito_client.create_user_pool(
        PoolName="moto-implementation-test",
        # allow users to sign up using an email address as their username
        UsernameAttributes=["email"],
        Schema=[
            {
                "Name": "sub",
                "AttributeDataType": "String",
                "DeveloperOnlyAttribute": False,
                "Mutable": False,
                "Required": True,
                "StringAttributeConstraints": {"MinLength": "1", "MaxLength": "2048"},
            },
            {
                "Name": "preferred_username",
                "AttributeDataType": "String",
                "DeveloperOnlyAttribute": False,
                "Mutable": True,
                "Required": True,
                "StringAttributeConstraints": {"MinLength": "0", "MaxLength": "2048"},
            },
        ],
    )["UserPool"]["Id"]

    # create user pool client
    app_client_id = cognito_client.create_user_pool_client(
        UserPoolId=user_pool_id,
        ClientName="moto-implementation-test",
        CallbackURLs=["https://google.com"],
        ReadAttributes=["preferred_username"],
        ExplicitAuthFlows=["ALLOW_REFRESH_TOKEN_AUTH", "ALLOW_USER_PASSWORD_AUTH"],
        AllowedOAuthFlows=["implicit"],
        AllowedOAuthScopes=[
            "email",
            "openid",
            "aws.cognito.signin.user.admin",
            "profile",
        ],
    )["UserPoolClient"]["ClientId"]
    
    # sign up a new user using an email as the username value
    cognito_client.sign_up(
        ClientId=app_client_id,
        Username="test@example.com",
        Password="Password123!",
        UserAttributes=[
            {"Name": "preferred_username", "Value": "Test User"},
            {"Name": "email", "Value": "test@example.com"},
        ],
    )
    # confirm the user in order to use them to authenticated later
    try:
        cognito_client.admin_confirm_sign_up(
            UserPoolId=user_pool_id, Username="test@example.com"
        )
    # confirm sign up is not implemented in moto - skip
    except NotImplementedError:
        pass
    
    # retrieve user
    user = cognito_client.admin_get_user(
        UserPoolId=user_pool_id, Username="test@example.com"
    )

    return user, app_client_id

Result:

In [7]: moto() # current behaviour
Out[7]:
({'Username': 'test@example.com',
  'UserAttributes': [{'Name': 'preferred_username', 'Value': 'Test User'},
   {'Name': 'email', 'Value': 'test@example.com'}],
  'UserCreateDate': datetime.datetime(2021, 8, 31, 14, 53, 12, tzinfo=tzlocal()),
  'UserLastModifiedDate': datetime.datetime(2021, 8, 31, 14, 53, 12, tzinfo=tzlocal()),
  'Enabled': True,
  'UserStatus': 'UNCONFIRMED',
  'MFAOptions': [],
  'UserMFASettingList': [],
  'ResponseMetadata': {'HTTPStatusCode': 200,
   'HTTPHeaders': {'server': 'amazon.com'},
   'RetryAttempts': 0}},
 '7lrk15iku1xocmaq2u4ltjh8nl')

In [8]: aws() # desired behaviour
Out[8]:
({'Username': '3854d52d-1a9b-[***]-bea6-d4065e3559c8',
  'UserAttributes': [{'Name': 'sub',
    'Value': '3854d52d-1a9b-[***]-bea6-d4065e3559c8'},
   {'Name': 'email_verified', 'Value': 'false'},
   {'Name': 'preferred_username', 'Value': 'Test User'},
   {'Name': 'email', 'Value': 'test@example.com'}],
  'UserCreateDate': datetime.datetime(2021, 8, 31, 10, 55, 56, 718000, tzinfo=tzlocal()),
  'UserLastModifiedDate': datetime.datetime(2021, 8, 31, 10, 55, 56, 831000, tzinfo=tzlocal()),
  'Enabled': True,
  'UserStatus': 'CONFIRMED',
  'ResponseMetadata': {'RequestId': 'e1e83078-bdee-411b-88e9-d276ed177068',
   'HTTPStatusCode': 200,
   'HTTPHeaders': {'date': 'Tue, 31 Aug 2021 14:55:56 GMT',
    'content-type': 'application/x-amz-json-1.1',
    'content-length': '382',
    'connection': 'keep-alive',
    'x-amzn-requestid': 'e1e83078-bdee-411b-88e9-d276ed177068'},
   'RetryAttempts': 0}},
 '2uj2u[***]m1hs1')

The user returned by the mocked cognito instance has the user's email as the Username field, whereas the desired behaviour is that the user instance returned by the aws cognito instance has a sub as the Username field.

To confirm that the AWS cognito instance is correctly configured, I can sign in using the user's email address or their sub:

def authenticate(username, app_client_id):
    cognito_client = boto3.client("cognito-idp", region_name="us-east-1")
    try:
        result = cognito_client.initiate_auth(
            ClientId=app_client_id,
            AuthFlow="USER_PASSWORD_AUTH",
            AuthParameters={"USERNAME": username, "PASSWORD": "Password123!"},
        )
    except cognito_client.exceptions.UserNotFoundException:
        return "Failed"

    if "IdToken" in result.get("AuthenticationResult"):
        return "Success"

    return "Failed"

Result:

In [9]: authenticate("test@example.com", "2uj2u[***]m1hs1")
Out[9]: 'Success'

In [10]: authenticate("3854d52d-1a9b-414b-bea6-d4065e3559c8", "2uj2u[***]m1hs1")
Out[10]: 'Success'

It looks like the CognitoIdpUser class already creates a GUID: https://github.com/spulec/moto/blob/c707ee002c1f258def89dfb7e14a2c4c8cfa9ad4/moto/cognitoidp/models.py#L299-L303

I think the necessary modifications are:

  • When a user signs up, and the user pool's UsernameAttributes option is set:
    • Match the provided Username field to either an email or a phone number regex, depending on the value of UsernameAttribute and set the value of the email or phone_number attributes accordingly in the UserAttributes field.
  • When requesting a user (eg: get-user or list-users) for a user pool that has the UsernameAttributes option set:
  • Enable the rest of the cognito api operations that use the username parameters to retrieve users (eg: initiate-auth, admin-delete-user, confirm-forgot-password, etc) to work using either the backend generated GUID (self.id) or the fields provided in the UsernameAttributes.

Please let me know if I can help move this along - I'm happy to open a PR with an initial stab at the functionality if it helps.

Cheers,
Leo

Version info:

# installed using: `pip install 'moto[cognito-idp]'`
In [1]: import moto

In [2]: import boto3

In [3]: import botocore

In [4]: moto.__version__
Out[4]: '2.2.6'

In [5]: boto3.__version__
Out[5]: '1.18.6'

In [6]: botocore.__version__
Out[6]: '1.21.6'

In [7]: import sys

In [8]: sys.version
Out[8]: '3.8.7 (default, Mar  5 2021, 10:09:23) \n[Clang 12.0.0 (clang-1200.0.32.29)]'

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions