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):
- 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)
- 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)]'
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):
AliasAttributesfield. 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 theAliasAttributesfield (provided the email/phone number has been verified)UsernameAttributesfield. 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 thesubfield in the JWT) to track it in the UserPool, and all operations are performed using the user's email/phone number as well as theirsub. When performing alist-usersorget-useroperation, theUsernamefield will contain thesub(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:
Result:
The user returned by the mocked cognito instance has the user's email as the
Usernamefield, whereas the desired behaviour is that the user instance returned by the aws cognito instance has asubas theUsernamefield.To confirm that the AWS cognito instance is correctly configured, I can sign in using the user's email address or their sub:
Result:
It looks like the
CognitoIdpUserclass already creates a GUID: https://github.com/spulec/moto/blob/c707ee002c1f258def89dfb7e14a2c4c8cfa9ad4/moto/cognitoidp/models.py#L299-L303I think the necessary modifications are:
UsernameAttributesoption is set:Usernamefield to either an email or a phone number regex, depending on the value ofUsernameAttributeand set the value of theemailorphone_numberattributes accordingly in theUserAttributesfield.get-userorlist-users) for a user pool that has theUsernameAttributesoption set:_base_json()method: https://github.com/spulec/moto/blob/c707ee002c1f258def89dfb7e14a2c4c8cfa9ad4/moto/cognitoidp/models.py#L318-L325 should replace the value ofUsernamewithself.id.usernameparameters to retrieve users (eg:initiate-auth,admin-delete-user,confirm-forgot-password, etc) to work using either the backend generatedGUID(self.id) or the fields provided in theUsernameAttributes.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: