From 9e1082366d113286bc063051fd76b4799791d943 Mon Sep 17 00:00:00 2001 From: arithmetic1728 <58957152+arithmetic1728@users.noreply.github.com> Date: Fri, 23 Apr 2021 15:27:02 -0700 Subject: feat: add reauth support to async user credentials (#738) --- tests_async/oauth2/test__client_async.py | 67 +++--- tests_async/oauth2/test_credentials_async.py | 36 ++- tests_async/oauth2/test_reauth_async.py | 328 +++++++++++++++++++++++++++ 3 files changed, 392 insertions(+), 39 deletions(-) create mode 100644 tests_async/oauth2/test_reauth_async.py (limited to 'tests_async') diff --git a/tests_async/oauth2/test__client_async.py b/tests_async/oauth2/test__client_async.py index 458937a..6e48c45 100644 --- a/tests_async/oauth2/test__client_async.py +++ b/tests_async/oauth2/test__client_async.py @@ -29,34 +29,6 @@ from google.oauth2 import _client_async as _client from tests.oauth2 import test__client as test_client -def test__handle_error_response(): - response_data = json.dumps({"error": "help", "error_description": "I'm alive"}) - - with pytest.raises(exceptions.RefreshError) as excinfo: - _client._handle_error_response(response_data) - - assert excinfo.match(r"help: I\'m alive") - - -def test__handle_error_response_non_json(): - response_data = "Help, I'm alive" - - with pytest.raises(exceptions.RefreshError) as excinfo: - _client._handle_error_response(response_data) - - assert excinfo.match(r"Help, I\'m alive") - - -@mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) -def test__parse_expiry(unused_utcnow): - result = _client._parse_expiry({"expires_in": 500}) - assert result == datetime.datetime.min + datetime.timedelta(seconds=500) - - -def test__parse_expiry_none(): - assert _client._parse_expiry({}) is None - - def make_request(response_data, status=http_client.OK): response = mock.AsyncMock(spec=["transport.Response"]) response.status = status @@ -82,7 +54,7 @@ async def test__token_endpoint_request(): request.assert_called_with( method="POST", url="http://example.com", - headers={"content-type": "application/x-www-form-urlencoded"}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, body="test=params".encode("utf-8"), ) @@ -90,6 +62,35 @@ async def test__token_endpoint_request(): assert result == {"test": "response"} +@pytest.mark.asyncio +async def test__token_endpoint_request_json(): + + request = make_request({"test": "response"}) + access_token = "access_token" + + result = await _client._token_endpoint_request( + request, + "http://example.com", + {"test": "params"}, + access_token=access_token, + use_json=True, + ) + + # Check request call + request.assert_called_with( + method="POST", + url="http://example.com", + headers={ + "Content-Type": "application/json", + "Authorization": "Bearer access_token", + }, + body=b'{"test": "params"}', + ) + + # Check result + assert result == {"test": "response"} + + @pytest.mark.asyncio async def test__token_endpoint_request_error(): request = make_request({}, status=http_client.BAD_REQUEST) @@ -218,7 +219,12 @@ async def test_refresh_grant(unused_utcnow): ) token, refresh_token, expiry, extra_data = await _client.refresh_grant( - request, "http://example.com", "refresh_token", "client_id", "client_secret" + request, + "http://example.com", + "refresh_token", + "client_id", + "client_secret", + rapt_token="rapt_token", ) # Check request call @@ -229,6 +235,7 @@ async def test_refresh_grant(unused_utcnow): "refresh_token": "refresh_token", "client_id": "client_id", "client_secret": "client_secret", + "rapt": "rapt_token", }, ) diff --git a/tests_async/oauth2/test_credentials_async.py b/tests_async/oauth2/test_credentials_async.py index 5c883d6..99cf16f 100644 --- a/tests_async/oauth2/test_credentials_async.py +++ b/tests_async/oauth2/test_credentials_async.py @@ -58,7 +58,7 @@ class TestCredentials: assert credentials.client_id == self.CLIENT_ID assert credentials.client_secret == self.CLIENT_SECRET - @mock.patch("google.oauth2._client_async.refresh_grant", autospec=True) + @mock.patch("google.oauth2._reauth_async.refresh_grant", autospec=True) @mock.patch( "google.auth._helpers.utcnow", return_value=datetime.datetime.min + _helpers.CLOCK_SKEW, @@ -68,6 +68,7 @@ class TestCredentials: token = "token" expiry = _helpers.utcnow() + datetime.timedelta(seconds=500) grant_response = {"id_token": mock.sentinel.id_token} + rapt_token = "rapt_token" refresh_grant.return_value = ( # Access token token, @@ -77,6 +78,8 @@ class TestCredentials: expiry, # Extra data grant_response, + # Rapt token + rapt_token, ) request = mock.AsyncMock(spec=["transport.Request"]) @@ -93,12 +96,14 @@ class TestCredentials: self.CLIENT_ID, self.CLIENT_SECRET, None, + None, ) # Check that the credentials have the token and expiry assert creds.token == token assert creds.expiry == expiry assert creds.id_token == mock.sentinel.id_token + assert creds.rapt_token == rapt_token # Check that the credentials are valid (have a token and are not # expired) @@ -114,7 +119,7 @@ class TestCredentials: request.assert_not_called() - @mock.patch("google.oauth2._client_async.refresh_grant", autospec=True) + @mock.patch("google.oauth2._reauth_async.refresh_grant", autospec=True) @mock.patch( "google.auth._helpers.utcnow", return_value=datetime.datetime.min + _helpers.CLOCK_SKEW, @@ -127,6 +132,7 @@ class TestCredentials: token = "token" expiry = _helpers.utcnow() + datetime.timedelta(seconds=500) grant_response = {"id_token": mock.sentinel.id_token} + rapt_token = "rapt_token" refresh_grant.return_value = ( # Access token token, @@ -136,6 +142,8 @@ class TestCredentials: expiry, # Extra data grant_response, + # Rapt token + rapt_token, ) request = mock.AsyncMock(spec=["transport.Request"]) @@ -146,6 +154,7 @@ class TestCredentials: client_id=self.CLIENT_ID, client_secret=self.CLIENT_SECRET, scopes=scopes, + rapt_token="old_rapt_token", ) # Refresh credentials @@ -159,6 +168,7 @@ class TestCredentials: self.CLIENT_ID, self.CLIENT_SECRET, scopes, + "old_rapt_token", ) # Check that the credentials have the token and expiry @@ -166,12 +176,13 @@ class TestCredentials: assert creds.expiry == expiry assert creds.id_token == mock.sentinel.id_token assert creds.has_scopes(scopes) + assert creds.rapt_token == rapt_token # Check that the credentials are valid (have a token and are not # expired.) assert creds.valid - @mock.patch("google.oauth2._client_async.refresh_grant", autospec=True) + @mock.patch("google.oauth2._reauth_async.refresh_grant", autospec=True) @mock.patch( "google.auth._helpers.utcnow", return_value=datetime.datetime.min + _helpers.CLOCK_SKEW, @@ -183,10 +194,8 @@ class TestCredentials: scopes = ["email", "profile"] token = "token" expiry = _helpers.utcnow() + datetime.timedelta(seconds=500) - grant_response = { - "id_token": mock.sentinel.id_token, - "scopes": " ".join(scopes), - } + grant_response = {"id_token": mock.sentinel.id_token, "scope": " ".join(scopes)} + rapt_token = "rapt_token" refresh_grant.return_value = ( # Access token token, @@ -196,6 +205,8 @@ class TestCredentials: expiry, # Extra data grant_response, + # Rapt token + rapt_token, ) request = mock.AsyncMock(spec=["transport.Request"]) @@ -219,6 +230,7 @@ class TestCredentials: self.CLIENT_ID, self.CLIENT_SECRET, scopes, + None, ) # Check that the credentials have the token and expiry @@ -226,12 +238,13 @@ class TestCredentials: assert creds.expiry == expiry assert creds.id_token == mock.sentinel.id_token assert creds.has_scopes(scopes) + assert creds.rapt_token == rapt_token # Check that the credentials are valid (have a token and are not # expired.) assert creds.valid - @mock.patch("google.oauth2._client_async.refresh_grant", autospec=True) + @mock.patch("google.oauth2._reauth_async.refresh_grant", autospec=True) @mock.patch( "google.auth._helpers.utcnow", return_value=datetime.datetime.min + _helpers.CLOCK_SKEW, @@ -246,8 +259,9 @@ class TestCredentials: expiry = _helpers.utcnow() + datetime.timedelta(seconds=500) grant_response = { "id_token": mock.sentinel.id_token, - "scopes": " ".join(scopes_returned), + "scope": " ".join(scopes_returned), } + rapt_token = "rapt_token" refresh_grant.return_value = ( # Access token token, @@ -257,6 +271,8 @@ class TestCredentials: expiry, # Extra data grant_response, + # Rapt token + rapt_token, ) request = mock.AsyncMock(spec=["transport.Request"]) @@ -267,6 +283,7 @@ class TestCredentials: client_id=self.CLIENT_ID, client_secret=self.CLIENT_SECRET, scopes=scopes, + rapt_token=None, ) # Refresh credentials @@ -283,6 +300,7 @@ class TestCredentials: self.CLIENT_ID, self.CLIENT_SECRET, scopes, + None, ) # Check that the credentials have the token and expiry diff --git a/tests_async/oauth2/test_reauth_async.py b/tests_async/oauth2/test_reauth_async.py new file mode 100644 index 0000000..f144d89 --- /dev/null +++ b/tests_async/oauth2/test_reauth_async.py @@ -0,0 +1,328 @@ +# Copyright 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import copy + +import mock +import pytest + +from google.auth import exceptions +from google.oauth2 import _reauth_async +from google.oauth2 import reauth + + +MOCK_REQUEST = mock.AsyncMock(spec=["transport.Request"]) +CHALLENGES_RESPONSE_TEMPLATE = { + "status": "CHALLENGE_REQUIRED", + "sessionId": "123", + "challenges": [ + { + "status": "READY", + "challengeId": 1, + "challengeType": "PASSWORD", + "securityKey": {}, + } + ], +} +CHALLENGES_RESPONSE_AUTHENTICATED = { + "status": "AUTHENTICATED", + "sessionId": "123", + "encodedProofOfReauthToken": "new_rapt_token", +} + + +class MockChallenge(object): + def __init__(self, name, locally_eligible, challenge_input): + self.name = name + self.is_locally_eligible = locally_eligible + self.challenge_input = challenge_input + + def obtain_challenge_input(self, metadata): + return self.challenge_input + + +@pytest.mark.asyncio +async def test__get_challenges(): + with mock.patch( + "google.oauth2._client_async._token_endpoint_request" + ) as mock_token_endpoint_request: + await _reauth_async._get_challenges(MOCK_REQUEST, ["SAML"], "token") + mock_token_endpoint_request.assert_called_with( + MOCK_REQUEST, + reauth._REAUTH_API + ":start", + {"supportedChallengeTypes": ["SAML"]}, + access_token="token", + use_json=True, + ) + + +@pytest.mark.asyncio +async def test__get_challenges_with_scopes(): + with mock.patch( + "google.oauth2._client_async._token_endpoint_request" + ) as mock_token_endpoint_request: + await _reauth_async._get_challenges( + MOCK_REQUEST, ["SAML"], "token", requested_scopes=["scope"] + ) + mock_token_endpoint_request.assert_called_with( + MOCK_REQUEST, + reauth._REAUTH_API + ":start", + { + "supportedChallengeTypes": ["SAML"], + "oauthScopesForDomainPolicyLookup": ["scope"], + }, + access_token="token", + use_json=True, + ) + + +@pytest.mark.asyncio +async def test__send_challenge_result(): + with mock.patch( + "google.oauth2._client_async._token_endpoint_request" + ) as mock_token_endpoint_request: + await _reauth_async._send_challenge_result( + MOCK_REQUEST, "123", "1", {"credential": "password"}, "token" + ) + mock_token_endpoint_request.assert_called_with( + MOCK_REQUEST, + reauth._REAUTH_API + "/123:continue", + { + "sessionId": "123", + "challengeId": "1", + "action": "RESPOND", + "proposalResponse": {"credential": "password"}, + }, + access_token="token", + use_json=True, + ) + + +@pytest.mark.asyncio +async def test__run_next_challenge_not_ready(): + challenges_response = copy.deepcopy(CHALLENGES_RESPONSE_TEMPLATE) + challenges_response["challenges"][0]["status"] = "STATUS_UNSPECIFIED" + assert ( + await _reauth_async._run_next_challenge( + challenges_response, MOCK_REQUEST, "token" + ) + is None + ) + + +@pytest.mark.asyncio +async def test__run_next_challenge_not_supported(): + challenges_response = copy.deepcopy(CHALLENGES_RESPONSE_TEMPLATE) + challenges_response["challenges"][0]["challengeType"] = "CHALLENGE_TYPE_UNSPECIFIED" + with pytest.raises(exceptions.ReauthFailError) as excinfo: + await _reauth_async._run_next_challenge( + challenges_response, MOCK_REQUEST, "token" + ) + assert excinfo.match(r"Unsupported challenge type CHALLENGE_TYPE_UNSPECIFIED") + + +@pytest.mark.asyncio +async def test__run_next_challenge_not_locally_eligible(): + mock_challenge = MockChallenge("PASSWORD", False, "challenge_input") + with mock.patch( + "google.oauth2.challenges.AVAILABLE_CHALLENGES", {"PASSWORD": mock_challenge} + ): + with pytest.raises(exceptions.ReauthFailError) as excinfo: + await _reauth_async._run_next_challenge( + CHALLENGES_RESPONSE_TEMPLATE, MOCK_REQUEST, "token" + ) + assert excinfo.match(r"Challenge PASSWORD is not locally eligible") + + +@pytest.mark.asyncio +async def test__run_next_challenge_no_challenge_input(): + mock_challenge = MockChallenge("PASSWORD", True, None) + with mock.patch( + "google.oauth2.challenges.AVAILABLE_CHALLENGES", {"PASSWORD": mock_challenge} + ): + assert ( + await _reauth_async._run_next_challenge( + CHALLENGES_RESPONSE_TEMPLATE, MOCK_REQUEST, "token" + ) + is None + ) + + +@pytest.mark.asyncio +async def test__run_next_challenge_success(): + mock_challenge = MockChallenge("PASSWORD", True, {"credential": "password"}) + with mock.patch( + "google.oauth2.challenges.AVAILABLE_CHALLENGES", {"PASSWORD": mock_challenge} + ): + with mock.patch( + "google.oauth2._reauth_async._send_challenge_result" + ) as mock_send_challenge_result: + await _reauth_async._run_next_challenge( + CHALLENGES_RESPONSE_TEMPLATE, MOCK_REQUEST, "token" + ) + mock_send_challenge_result.assert_called_with( + MOCK_REQUEST, "123", 1, {"credential": "password"}, "token" + ) + + +@pytest.mark.asyncio +async def test__obtain_rapt_authenticated(): + with mock.patch( + "google.oauth2._reauth_async._get_challenges", + return_value=CHALLENGES_RESPONSE_AUTHENTICATED, + ): + new_rapt_token = await _reauth_async._obtain_rapt(MOCK_REQUEST, "token", None) + assert new_rapt_token == "new_rapt_token" + + +@pytest.mark.asyncio +async def test__obtain_rapt_authenticated_after_run_next_challenge(): + with mock.patch( + "google.oauth2._reauth_async._get_challenges", + return_value=CHALLENGES_RESPONSE_TEMPLATE, + ): + with mock.patch( + "google.oauth2._reauth_async._run_next_challenge", + side_effect=[ + CHALLENGES_RESPONSE_TEMPLATE, + CHALLENGES_RESPONSE_AUTHENTICATED, + ], + ): + with mock.patch("google.oauth2.reauth.is_interactive", return_value=True): + assert ( + await _reauth_async._obtain_rapt(MOCK_REQUEST, "token", None) + == "new_rapt_token" + ) + + +@pytest.mark.asyncio +async def test__obtain_rapt_unsupported_status(): + challenges_response = copy.deepcopy(CHALLENGES_RESPONSE_TEMPLATE) + challenges_response["status"] = "STATUS_UNSPECIFIED" + with mock.patch( + "google.oauth2._reauth_async._get_challenges", return_value=challenges_response + ): + with pytest.raises(exceptions.ReauthFailError) as excinfo: + await _reauth_async._obtain_rapt(MOCK_REQUEST, "token", None) + assert excinfo.match(r"API error: STATUS_UNSPECIFIED") + + +@pytest.mark.asyncio +async def test__obtain_rapt_not_interactive(): + with mock.patch( + "google.oauth2._reauth_async._get_challenges", + return_value=CHALLENGES_RESPONSE_TEMPLATE, + ): + with mock.patch("google.oauth2.reauth.is_interactive", return_value=False): + with pytest.raises(exceptions.ReauthFailError) as excinfo: + await _reauth_async._obtain_rapt(MOCK_REQUEST, "token", None) + assert excinfo.match(r"not in an interactive session") + + +@pytest.mark.asyncio +async def test__obtain_rapt_not_authenticated(): + with mock.patch( + "google.oauth2._reauth_async._get_challenges", + return_value=CHALLENGES_RESPONSE_TEMPLATE, + ): + with mock.patch("google.oauth2.reauth.RUN_CHALLENGE_RETRY_LIMIT", 0): + with pytest.raises(exceptions.ReauthFailError) as excinfo: + await _reauth_async._obtain_rapt(MOCK_REQUEST, "token", None) + assert excinfo.match(r"Reauthentication failed") + + +@pytest.mark.asyncio +async def test_get_rapt_token(): + with mock.patch( + "google.oauth2._client_async.refresh_grant", + return_value=("token", None, None, None), + ) as mock_refresh_grant: + with mock.patch( + "google.oauth2._reauth_async._obtain_rapt", return_value="new_rapt_token" + ) as mock_obtain_rapt: + assert ( + await _reauth_async.get_rapt_token( + MOCK_REQUEST, + "client_id", + "client_secret", + "refresh_token", + "token_uri", + ) + == "new_rapt_token" + ) + mock_refresh_grant.assert_called_with( + request=MOCK_REQUEST, + client_id="client_id", + client_secret="client_secret", + refresh_token="refresh_token", + token_uri="token_uri", + scopes=[reauth._REAUTH_SCOPE], + ) + mock_obtain_rapt.assert_called_with( + MOCK_REQUEST, "token", requested_scopes=None + ) + + +@pytest.mark.asyncio +async def test_refresh_grant_failed(): + with mock.patch( + "google.oauth2._client_async._token_endpoint_request_no_throw" + ) as mock_token_request: + mock_token_request.return_value = (False, {"error": "Bad request"}) + with pytest.raises(exceptions.RefreshError) as excinfo: + await _reauth_async.refresh_grant( + MOCK_REQUEST, + "token_uri", + "refresh_token", + "client_id", + "client_secret", + scopes=["foo", "bar"], + rapt_token="rapt_token", + ) + assert excinfo.match(r"Bad request") + mock_token_request.assert_called_with( + MOCK_REQUEST, + "token_uri", + { + "grant_type": "refresh_token", + "client_id": "client_id", + "client_secret": "client_secret", + "refresh_token": "refresh_token", + "scope": "foo bar", + "rapt": "rapt_token", + }, + ) + + +@pytest.mark.asyncio +async def test_refresh_grant_success(): + with mock.patch( + "google.oauth2._client_async._token_endpoint_request_no_throw" + ) as mock_token_request: + mock_token_request.side_effect = [ + (False, {"error": "invalid_grant", "error_subtype": "rapt_required"}), + (True, {"access_token": "access_token"}), + ] + with mock.patch( + "google.oauth2._reauth_async.get_rapt_token", return_value="new_rapt_token" + ): + assert await _reauth_async.refresh_grant( + MOCK_REQUEST, "token_uri", "refresh_token", "client_id", "client_secret" + ) == ( + "access_token", + "refresh_token", + None, + {"access_token": "access_token"}, + "new_rapt_token", + ) -- cgit v1.2.3