commit 9bbe89fa8b776c201896de1d6df5072c57a0d3b8b1d0f198a315b83f55a74395 Author: Robert Schweikert Date: Mon Oct 7 11:16:19 2024 +0000 New packages needed for python-moto to build. OBS-URL: https://build.opensuse.org/package/show/devel:languages:python/python-pycognito?expand=0&rev=1 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9b03811 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,23 @@ +## Default LFS +*.7z filter=lfs diff=lfs merge=lfs -text +*.bsp filter=lfs diff=lfs merge=lfs -text +*.bz2 filter=lfs diff=lfs merge=lfs -text +*.gem filter=lfs diff=lfs merge=lfs -text +*.gz filter=lfs diff=lfs merge=lfs -text +*.jar filter=lfs diff=lfs merge=lfs -text +*.lz filter=lfs diff=lfs merge=lfs -text +*.lzma filter=lfs diff=lfs merge=lfs -text +*.obscpio filter=lfs diff=lfs merge=lfs -text +*.oxt filter=lfs diff=lfs merge=lfs -text +*.pdf filter=lfs diff=lfs merge=lfs -text +*.png filter=lfs diff=lfs merge=lfs -text +*.rpm filter=lfs diff=lfs merge=lfs -text +*.tbz filter=lfs diff=lfs merge=lfs -text +*.tbz2 filter=lfs diff=lfs merge=lfs -text +*.tgz filter=lfs diff=lfs merge=lfs -text +*.ttf filter=lfs diff=lfs merge=lfs -text +*.txz filter=lfs diff=lfs merge=lfs -text +*.whl filter=lfs diff=lfs merge=lfs -text +*.xz filter=lfs diff=lfs merge=lfs -text +*.zip filter=lfs diff=lfs merge=lfs -text +*.zst filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..57affb6 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.osc diff --git a/pycognito-2024.5.1.tar.gz b/pycognito-2024.5.1.tar.gz new file mode 100644 index 0000000..a2cc316 --- /dev/null +++ b/pycognito-2024.5.1.tar.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e211c66698c2c3dc8680e95107c2b4a922f504c3f7c179c27b8ee1ab0fc23ae4 +size 31182 diff --git a/python-pycognito.changes b/python-pycognito.changes new file mode 100644 index 0000000..f68e6ad --- /dev/null +++ b/python-pycognito.changes @@ -0,0 +1,4 @@ +------------------------------------------------------------------- +Fri Oct 4 07:40:37 UTC 2024 - ecsos + +- Initial version 2024.5.1 diff --git a/python-pycognito.spec b/python-pycognito.spec new file mode 100644 index 0000000..64bad08 --- /dev/null +++ b/python-pycognito.spec @@ -0,0 +1,81 @@ +# +# spec file for package python-pycognito +# +# Copyright (c) 2024 SUSE LLC +# +# All modifications and additions to the file contributed by third parties +# remain the property of their copyright owners, unless otherwise agreed +# upon. The license for this file, and modifications and additions to the +# file, is the same license as for the pristine package itself (unless the +# license for the pristine package is not an Open Source License, in which +# case the license is the MIT License). An "Open Source License" is a +# license that conforms to the Open Source Definition (Version 1.9) +# published by the Open Source Initiative. + +# Please submit bugfixes or comments via https://bugs.opensuse.org/ +# + + +%{?sle15_python_module_pythons} +Name: python-pycognito +Version: 2024.5.1 +Release: 0 +Summary: Python class to integrate Boto3's Cognito client +License: Apache-2.0 +Group: Development/Languages/Python +URL: https://github.com/pvizeli/pycognito +Source: https://files.pythonhosted.org/packages/source/p/pycognito/pycognito-%{version}.tar.gz +Source1: https://raw.githubusercontent.com/NabuCasa/pycognito/refs/heads/master/tests.py +BuildRequires: python-rpm-macros +BuildRequires: %{python_module pip} +BuildRequires: %{python_module setuptools} +BuildRequires: %{python_module wheel} +# SECTION test requirements +BuildRequires: %{python_module boto3 >= 1.10.49} +BuildRequires: %{python_module envs >= 1.3} +BuildRequires: %{python_module PyJWT >= 2.8.0} +BuildRequires: %{python_module requests >= 2.22.0} +BuildRequires: %{python_module pytest >= 8.2.1} +BuildRequires: %{python_module freezegun >= 1.5.1} +BuildRequires: %{python_module moto} +BuildRequires: %{python_module joserfc} +BuildRequires: %{python_module requests-mock >= 1.12.1} +# /SECTION +BuildRequires: fdupes +Requires: python-boto3 >= 1.10.49 +Requires: python-envs >= 1.3 +Requires: python-PyJWT >= 2.8.0 +Requires: python-requests >= 2.22.0 +BuildArch: noarch +%python_subpackages + +%description +Python class to integrate Boto3's Cognito client so it is easy to login users. With SRP support. + +%prep +%autosetup -p1 -n pycognito-%{version} +install %{SOURCE1} . + +%build +%pyproject_wheel + +%install +%pyproject_install +# Fix python-bytecode-inconsistent-mtime +pushd %{buildroot}%{python_sitelib} +find . -name '*.pyc' -exec rm -f '{}' ';' +python%python_bin_suffix -m compileall *.py ';' +popd +%python_expand %fdupes %{buildroot}%{$python_sitelib} + +%check +donttest="test_srp_requests_http_auth" +%pytest -k "not ($donttest)" tests.py + +%files %{python_files} +%doc README.md +%license LICENSE +%{python_sitelib}/pycognito +%{python_sitelib}/pycognito-%{version}.dist-info + +%changelog diff --git a/tests.py b/tests.py new file mode 100644 index 0000000..14eedf0 --- /dev/null +++ b/tests.py @@ -0,0 +1,500 @@ +import datetime +import unittest +import os.path +from unittest.mock import patch +import uuid + +import freezegun +import moto +import moto.cognitoidp +import boto3 +from botocore.exceptions import ParamValidationError +from botocore.stub import Stubber +from envs import env +import requests +import requests_mock + +from pycognito import Cognito, UserObj, GroupObj, TokenVerificationException +from pycognito.aws_srp import AWSSRP +from pycognito.utils import RequestsSrpAuth + + +def _mock_authenticate_user(_, client=None, client_metadata=None): + return { + "AuthenticationResult": { + "TokenType": "admin", + "IdToken": "dummy_token", + "AccessToken": "dummy_token", + "RefreshToken": "dummy_token", + } + } + + +def _mock_get_params(_): + return {"USERNAME": "bob", "SRP_A": "srp"} + + +def _mock_verify_tokens(self, token, id_name, token_use): + if "wrong" in token: + raise TokenVerificationException + setattr(self, id_name, token) + + +class UserObjTestCase(unittest.TestCase): + def setUp(self): + if env("USE_CLIENT_SECRET", "False") == "True": + self.app_id = env("COGNITO_APP_WITH_SECRET_ID") + else: + self.app_id = env("COGNITO_APP_ID") + self.cognito_user_pool_id = env("COGNITO_USER_POOL_ID", "us-east-1_123456789") + self.username = env("COGNITO_TEST_USERNAME") + + self.user = Cognito( + user_pool_id=self.cognito_user_pool_id, + client_id=self.app_id, + username=self.username, + ) + + self.user_metadata = { + "user_status": "CONFIRMED", + "username": "bjones", + } + self.user_info = [ + {"Name": "name", "Value": "Brian Jones"}, + {"Name": "given_name", "Value": "Brian"}, + {"Name": "birthdate", "Value": "12/7/1980"}, + ] + + def test_init(self): + user = UserObj("bjones", self.user_info, self.user, self.user_metadata) + self.assertEqual(user.username, self.user_metadata.get("username")) + self.assertEqual(user.name, self.user_info[0].get("Value")) + self.assertEqual(user.user_status, self.user_metadata.get("user_status")) + + +class GroupObjTestCase(unittest.TestCase): + def setUp(self): + if env("USE_CLIENT_SECRET", "False") == "True": + self.app_id = env("COGNITO_APP_WITH_SECRET_ID") + else: + self.app_id = env("COGNITO_APP_ID") + self.cognito_user_pool_id = env("COGNITO_USER_POOL_ID", "us-east-1_123456789") + self.group_data = {"GroupName": "test_group", "Precedence": 1} + self.cognito_obj = Cognito( + user_pool_id=self.cognito_user_pool_id, client_id=self.app_id + ) + + def test_init(self): + group = GroupObj(group_data=self.group_data, cognito_obj=self.cognito_obj) + self.assertEqual(group.group_name, "test_group") + self.assertEqual(group.precedence, 1) + + +class CognitoAuthTestCase(unittest.TestCase): + def setUp(self): + if env("USE_CLIENT_SECRET") == "True": + self.app_id = env("COGNITO_APP_WITH_SECRET_ID", "app") + self.client_secret = env("COGNITO_CLIENT_SECRET") + else: + self.app_id = env("COGNITO_APP_ID", "app") + self.client_secret = None + self.cognito_user_pool_id = env("COGNITO_USER_POOL_ID", "us-east-1_123456789") + self.username = env("COGNITO_TEST_USERNAME", "bob") + self.password = env("COGNITO_TEST_PASSWORD", "bobpassword") + self.user = Cognito( + self.cognito_user_pool_id, + self.app_id, + username=self.username, + client_secret=self.client_secret, + ) + + @patch("pycognito.aws_srp.AWSSRP.authenticate_user", _mock_authenticate_user) + @patch("pycognito.Cognito.verify_token", _mock_verify_tokens) + def test_authenticate(self): + + self.user.authenticate(self.password) + self.assertNotEqual(self.user.access_token, None) + self.assertNotEqual(self.user.id_token, None) + self.assertNotEqual(self.user.refresh_token, None) + + @patch("pycognito.aws_srp.AWSSRP.authenticate_user", _mock_authenticate_user) + @patch("pycognito.Cognito.verify_token", _mock_verify_tokens) + def test_verify_token(self): + self.user.authenticate(self.password) + bad_access_token = "{}wrong".format(self.user.access_token) + + with self.assertRaises(TokenVerificationException): + self.user.verify_token(bad_access_token, "access_token", "access") + + @patch("pycognito.Cognito", autospec=True) + def test_register(self, cognito_user): + user = cognito_user( + self.cognito_user_pool_id, self.app_id, username=self.username + ) + base_attr = dict( + given_name="Brian", + family_name="Jones", + name="Brian Jones", + email="bjones39@capless.io", + phone_number="+19194894555", + gender="Male", + preferred_username="billyocean", + ) + + user.set_base_attributes(**base_attr) + user.register("sampleuser", "sample4#Password") + + @patch("pycognito.aws_srp.AWSSRP.authenticate_user", _mock_authenticate_user) + @patch("pycognito.Cognito.verify_token", _mock_verify_tokens) + @patch("pycognito.Cognito._add_secret_hash", return_value=None) + def test_renew_tokens(self, _): + + stub = Stubber(self.user.client) + + # By the stubber nature, we need to add the sequence + # of calls for the AWS SRP auth to test the whole process + stub.add_response( + method="initiate_auth", + service_response={ + "AuthenticationResult": { + "TokenType": "admin", + "IdToken": "dummy_token", + "AccessToken": "dummy_token", + "RefreshToken": "dummy_token", + }, + "ResponseMetadata": {"HTTPStatusCode": 200}, + }, + expected_params={ + "ClientId": self.app_id, + "AuthFlow": "REFRESH_TOKEN_AUTH", + "AuthParameters": {"REFRESH_TOKEN": "dummy_token"}, + }, + ) + + with stub: + self.user.authenticate(self.password) + self.user.renew_access_token() + stub.assert_no_pending_responses() + + @patch("pycognito.Cognito", autospec=True) + def test_update_profile(self, cognito_user): + user = cognito_user( + self.cognito_user_pool_id, self.app_id, username=self.username + ) + user.authenticate(self.password) + user.update_profile({"given_name": "Jenkins"}) + + def test_admin_get_user(self): + stub = Stubber(self.user.client) + + stub.add_response( + method="admin_get_user", + service_response={ + "Enabled": True, + "UserStatus": "CONFIRMED", + "Username": self.username, + "UserAttributes": [], + }, + expected_params={ + "UserPoolId": self.cognito_user_pool_id, + "Username": self.username, + }, + ) + + with stub: + u = self.user.admin_get_user() + self.assertEqual(u.username, self.username) + stub.assert_no_pending_responses() + + def test_check_token(self): + # This is a sample JWT with an expiration time set to January, 1st, 3000 + self.user.access_token = ( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG" + "9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjMyNTAzNjgwMDAwfQ.C-1gPxrhUsiWeCvMvaZuuQYarkDNAc" + "pEGJPIqu_SrKQ" + ) + try: + valid = self.user.check_token() + except OverflowError: + self.skipTest("This test requires 64-bit time_t") + else: + self.assertFalse(valid) + + @patch("pycognito.Cognito", autospec=True) + def test_validate_verification(self, cognito_user): + u = cognito_user(self.cognito_user_pool_id, self.app_id, username=self.username) + u.validate_verification("4321") + + @patch("pycognito.Cognito", autospec=True) + def test_confirm_forgot_password(self, cognito_user): + u = cognito_user(self.cognito_user_pool_id, self.app_id, username=self.username) + u.confirm_forgot_password("4553", "samplepassword") + with self.assertRaises(TypeError): + u.confirm_forgot_password(self.password) + + @patch("pycognito.aws_srp.AWSSRP.authenticate_user", _mock_authenticate_user) + @patch("pycognito.Cognito.verify_token", _mock_verify_tokens) + @patch("pycognito.Cognito.check_token", return_value=True) + def test_change_password(self, _): + # u = cognito_user(self.cognito_user_pool_id, self.app_id, + # username=self.username) + self.user.authenticate(self.password) + + stub = Stubber(self.user.client) + + stub.add_response( + method="change_password", + service_response={"ResponseMetadata": {"HTTPStatusCode": 200}}, + expected_params={ + "PreviousPassword": self.password, + "ProposedPassword": "crazypassword$45DOG", + "AccessToken": self.user.access_token, + }, + ) + + with stub: + self.user.change_password(self.password, "crazypassword$45DOG") + stub.assert_no_pending_responses() + + with self.assertRaises(ParamValidationError): + self.user.change_password(self.password, None) + + def test_set_attributes(self): + user = Cognito(self.cognito_user_pool_id, self.app_id) + user._set_attributes( + {"ResponseMetadata": {"HTTPStatusCode": 200}}, {"somerandom": "attribute"} + ) + self.assertEqual(user.somerandom, "attribute") + + @patch("pycognito.Cognito.verify_token", _mock_verify_tokens) + def test_admin_authenticate(self): + + stub = Stubber(self.user.client) + + # By the stubber nature, we need to add the sequence + # of calls for the AWS SRP auth to test the whole process + stub.add_response( + method="admin_initiate_auth", + service_response={ + "AuthenticationResult": { + "TokenType": "admin", + "IdToken": "dummy_token", + "AccessToken": "dummy_token", + "RefreshToken": "dummy_token", + } + }, + expected_params={ + "UserPoolId": self.cognito_user_pool_id, + "ClientId": self.app_id, + "AuthFlow": "ADMIN_NO_SRP_AUTH", + "AuthParameters": { + "USERNAME": self.username, + "PASSWORD": self.password, + }, + }, + ) + + with stub: + self.user.admin_authenticate(self.password) + self.assertNotEqual(self.user.access_token, None) + self.assertNotEqual(self.user.id_token, None) + self.assertNotEqual(self.user.refresh_token, None) + stub.assert_no_pending_responses() + + +class AWSSRPTestCase(unittest.TestCase): + def setUp(self): + if env("USE_CLIENT_SECRET") == "True": + self.client_secret = env("COGNITO_CLIENT_SECRET") + self.app_id = env("COGNITO_APP_WITH_SECRET_ID", "app") + else: + self.app_id = env("COGNITO_APP_ID", "app") + self.client_secret = None + self.cognito_user_pool_id = env("COGNITO_USER_POOL_ID", "us-east-1_123456789") + self.username = env("COGNITO_TEST_USERNAME") + self.password = env("COGNITO_TEST_PASSWORD") + self.aws = AWSSRP( + username=self.username, + password=self.password, + pool_region="us-east-1", + pool_id=self.cognito_user_pool_id, + client_id=self.app_id, + client_secret=self.client_secret, + ) + + def tearDown(self): + del self.aws + + @patch("pycognito.aws_srp.AWSSRP.get_auth_params", _mock_get_params) + @patch("pycognito.aws_srp.AWSSRP.process_challenge", return_value={}) + def test_authenticate_user(self, _): + + stub = Stubber(self.aws.client) + + # By the stubber nature, we need to add the sequence + # of calls for the AWS SRP auth to test the whole process + stub.add_response( + method="initiate_auth", + service_response={ + "ChallengeName": "PASSWORD_VERIFIER", + "ChallengeParameters": {}, + }, + expected_params={ + "AuthFlow": "USER_SRP_AUTH", + "AuthParameters": _mock_get_params(None), + "ClientId": self.app_id, + }, + ) + + stub.add_response( + method="respond_to_auth_challenge", + service_response={ + "AuthenticationResult": { + "IdToken": "dummy_token", + "AccessToken": "dummy_token", + "RefreshToken": "dummy_token", + } + }, + expected_params={ + "ClientId": self.app_id, + "ChallengeName": "PASSWORD_VERIFIER", + "ChallengeResponses": {}, + }, + ) + + with stub: + tokens = self.aws.authenticate_user() + self.assertTrue("IdToken" in tokens["AuthenticationResult"]) + self.assertTrue("AccessToken" in tokens["AuthenticationResult"]) + self.assertTrue("RefreshToken" in tokens["AuthenticationResult"]) + stub.assert_no_pending_responses() + + def test_cognito_formatted_timestamp(self): + self.assertEqual( + self.aws.get_cognito_formatted_timestamp( + datetime.datetime(2022, 1, 1, 0, 0, 0) + ), + "Sat Jan 1 00:00:00 UTC 2022", + ) + + self.assertEqual( + self.aws.get_cognito_formatted_timestamp( + datetime.datetime(2022, 1, 2, 12, 0, 0) + ), + "Sun Jan 2 12:00:00 UTC 2022", + ) + + self.assertEqual( + self.aws.get_cognito_formatted_timestamp( + datetime.datetime(2022, 1, 3, 9, 0, 0) + ), + "Mon Jan 3 09:00:00 UTC 2022", + ) + + self.assertEqual( + self.aws.get_cognito_formatted_timestamp( + datetime.datetime(2022, 12, 31, 23, 59, 59) + ), + "Sat Dec 31 23:59:59 UTC 2022", + ) + + +@moto.mock_aws +class UtilsTestCase(unittest.TestCase): + username = "bob@test.com" + password = "Test1234!" + + def setUp(self) -> None: + + cognitoidp_client = boto3.client("cognito-idp", region_name="us-east-1") + + user_pool = cognitoidp_client.create_user_pool( + PoolName="pycognito-test-pool", + AliasAttributes=[ + "email", + ], + UsernameAttributes=[ + "email", + ], + ) + self.user_pool_id = user_pool["UserPool"]["Id"] + + user_pool_client = cognitoidp_client.create_user_pool_client( + UserPoolId=self.user_pool_id, + ClientName="test-client", + RefreshTokenValidity=1, + AccessTokenValidity=1, + IdTokenValidity=1, + TokenValidityUnits={ + "AccessToken": "hour", + "IdToken": "hour", + "RefreshToken": "days", + }, + ) + self.client_id = user_pool_client["UserPoolClient"]["ClientId"] + + cognitoidp_client.admin_create_user( + UserPoolId=self.user_pool_id, + Username=self.username, + TemporaryPassword=self.password, + MessageAction="SUPPRESS", + ) + cognitoidp_client.admin_set_user_password( + UserPoolId=self.user_pool_id, + Username=self.username, + Password=self.password, + Permanent=True, + ) + + @requests_mock.Mocker() + def test_srp_requests_http_auth(self, m): + # Get Moto's static public jwks + jwks_public_key_filename = os.path.join( + os.path.dirname(moto.cognitoidp.__file__), "resources/jwks-public.json" + ) + jwks_public_key_f = open(jwks_public_key_filename, "rb") + + # Create some test data + test_data = str(uuid.uuid4()) + + # Mock a test endpoint. We pretend this endpoint would require an Authorization header + m.get("http://test.com", text=test_data) + # Pycognito will automatically verify the token it receives. Mock the proper endpoint and return the static + # key from above + m.get( + f"https://cognito-idp.us-east-1.amazonaws.com/{self.user_pool_id}/.well-known/jwks.json", + body=jwks_public_key_f, + ) + + now = datetime.datetime.utcnow() + + # Standup the actual Requests plugin + srp_auth = RequestsSrpAuth( + username=self.username, + password=self.password, + user_pool_id=self.user_pool_id, + user_pool_region="us-east-1", + client_id=self.client_id, + ) + + # Make the actual request + req = requests.get("http://test.com", auth=srp_auth) + req.raise_for_status() + # Ensure the data returns matches the mocked endpoint + self.assertEqual(test_data, req.text) + + # Get the access token used + access_token_orig = srp_auth.cognito_client.access_token + + # Make a second request with a time 2 hours in the future + with freezegun.freeze_time(now + datetime.timedelta(hours=2)): + req = requests.get("http://test.com", auth=srp_auth) + req.raise_for_status() + + access_token_new = srp_auth.cognito_client.access_token + # Check that the access token was refreshed to a new one + self.assertNotEqual(access_token_orig, access_token_new) + + +if __name__ == "__main__": + unittest.main()