feat: add reauth support to async user credentials (#738)
diff --git a/google/oauth2/_client_async.py b/google/oauth2/_client_async.py index 4817ea4..cf51211 100644 --- a/google/oauth2/_client_async.py +++ b/google/oauth2/_client_async.py
@@ -30,53 +30,16 @@ from six.moves import http_client from six.moves import urllib -from google.auth import _helpers from google.auth import exceptions from google.auth import jwt from google.oauth2 import _client as client -def _handle_error_response(response_body): - """"Translates an error response into an exception. - - Args: - response_body (str): The decoded response data. - - Raises: - google.auth.exceptions.RefreshError - """ - try: - error_data = json.loads(response_body) - error_details = "{}: {}".format( - error_data["error"], error_data.get("error_description") - ) - # If no details could be extracted, use the response data. - except (KeyError, ValueError): - error_details = response_body - - raise exceptions.RefreshError(error_details, response_body) - - -def _parse_expiry(response_data): - """Parses the expiry field from a response into a datetime. - - Args: - response_data (Mapping): The JSON-parsed response data. - - Returns: - Optional[datetime]: The expiration or ``None`` if no expiration was - specified. - """ - expires_in = response_data.get("expires_in", None) - - if expires_in is not None: - return _helpers.utcnow() + datetime.timedelta(seconds=expires_in) - else: - return None - - -async def _token_endpoint_request(request, token_uri, body): +async def _token_endpoint_request_no_throw( + request, token_uri, body, access_token=None, use_json=False +): """Makes a request to the OAuth 2.0 authorization server's token endpoint. + This function doesn't throw on response errors. Args: request (google.auth.transport.Request): A callable used to make @@ -84,16 +47,23 @@ token_uri (str): The OAuth 2.0 authorizations server's token endpoint URI. body (Mapping[str, str]): The parameters to send in the request body. + access_token (Optional(str)): The access token needed to make the request. + use_json (Optional(bool)): Use urlencoded format or json format for the + content type. The default value is False. Returns: - Mapping[str, str]: The JSON-decoded response data. - - Raises: - google.auth.exceptions.RefreshError: If the token endpoint returned - an error. + Tuple(bool, Mapping[str, str]): A boolean indicating if the request is + successful, and a mapping for the JSON-decoded response data. """ - body = urllib.parse.urlencode(body).encode("utf-8") - headers = {"content-type": client._URLENCODED_CONTENT_TYPE} + if use_json: + headers = {"Content-Type": client._JSON_CONTENT_TYPE} + body = json.dumps(body).encode("utf-8") + else: + headers = {"Content-Type": client._URLENCODED_CONTENT_TYPE} + body = urllib.parse.urlencode(body).encode("utf-8") + + if access_token: + headers["Authorization"] = "Bearer {}".format(access_token) retry = 0 # retry to fetch token for maximum of two times if any internal failure @@ -126,8 +96,38 @@ ): retry += 1 continue - _handle_error_response(response_body) + return response.status == http_client.OK, response_data + return response.status == http_client.OK, response_data + + +async def _token_endpoint_request( + request, token_uri, body, access_token=None, use_json=False +): + """Makes a request to the OAuth 2.0 authorization server's token endpoint. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + token_uri (str): The OAuth 2.0 authorizations server's token endpoint + URI. + body (Mapping[str, str]): The parameters to send in the request body. + access_token (Optional(str)): The access token needed to make the request. + use_json (Optional(bool)): Use urlencoded format or json format for the + content type. The default value is False. + + Returns: + Mapping[str, str]: The JSON-decoded response data. + + Raises: + google.auth.exceptions.RefreshError: If the token endpoint returned + an error. + """ + response_status_ok, response_data = await _token_endpoint_request_no_throw( + request, token_uri, body, access_token=access_token, use_json=use_json + ) + if not response_status_ok: + client._handle_error_response(response_data) return response_data @@ -163,7 +163,7 @@ new_exc = exceptions.RefreshError("No access token in response.", response_data) six.raise_from(new_exc, caught_exc) - expiry = _parse_expiry(response_data) + expiry = client._parse_expiry(response_data) return access_token, expiry, response_data @@ -210,7 +210,13 @@ async def refresh_grant( - request, token_uri, refresh_token, client_id, client_secret, scopes=None + request, + token_uri, + refresh_token, + client_id, + client_secret, + scopes=None, + rapt_token=None, ): """Implements the OAuth 2.0 refresh token grant. @@ -229,10 +235,11 @@ scopes must be authorized for the refresh token. Useful if refresh token has a wild card scope (e.g. 'https://www.googleapis.com/auth/any-api'). + rapt_token (Optional(str)): The reauth Proof Token. Returns: Tuple[str, Optional[str], Optional[datetime], Mapping[str, str]]: The - access token, new refresh token, expiration, and additional data + access token, new or current refresh token, expiration, and additional data returned by the token endpoint. Raises: @@ -249,16 +256,8 @@ } if scopes: body["scope"] = " ".join(scopes) + if rapt_token: + body["rapt"] = rapt_token response_data = await _token_endpoint_request(request, token_uri, body) - - try: - access_token = response_data["access_token"] - except KeyError as caught_exc: - new_exc = exceptions.RefreshError("No access token in response.", response_data) - six.raise_from(new_exc, caught_exc) - - refresh_token = response_data.get("refresh_token", refresh_token) - expiry = _parse_expiry(response_data) - - return access_token, refresh_token, expiry, response_data + return client._handle_refresh_grant_response(response_data, refresh_token)
diff --git a/google/oauth2/_credentials_async.py b/google/oauth2/_credentials_async.py index eb3e97c..b4878c5 100644 --- a/google/oauth2/_credentials_async.py +++ b/google/oauth2/_credentials_async.py
@@ -34,7 +34,7 @@ from google.auth import _credentials_async as credentials from google.auth import _helpers from google.auth import exceptions -from google.oauth2 import _client_async as _client +from google.oauth2 import _reauth_async as reauth from google.oauth2 import credentials as oauth2_credentials @@ -66,23 +66,26 @@ refresh_token, expiry, grant_response, - ) = await _client.refresh_grant( + rapt_token, + ) = await reauth.refresh_grant( request, self._token_uri, self._refresh_token, self._client_id, self._client_secret, - self._scopes, + scopes=self._scopes, + rapt_token=self._rapt_token, ) self.token = access_token self.expiry = expiry self._refresh_token = refresh_token self._id_token = grant_response.get("id_token") + self._rapt_token = rapt_token - if self._scopes and "scopes" in grant_response: + if self._scopes and "scope" in grant_response: requested_scopes = frozenset(self._scopes) - granted_scopes = frozenset(grant_response["scopes"].split()) + granted_scopes = frozenset(grant_response["scope"].split()) scopes_requested_but_not_granted = requested_scopes - granted_scopes if scopes_requested_but_not_granted: raise exceptions.RefreshError(
diff --git a/google/oauth2/_reauth_async.py b/google/oauth2/_reauth_async.py new file mode 100644 index 0000000..09e0760 --- /dev/null +++ b/google/oauth2/_reauth_async.py
@@ -0,0 +1,320 @@ +# 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. + +"""A module that provides functions for handling rapt authentication. + +Reauth is a process of obtaining additional authentication (such as password, +security token, etc.) while refreshing OAuth 2.0 credentials for a user. + +Credentials that use the Reauth flow must have the reauth scope, +``https://www.googleapis.com/auth/accounts.reauth``. + +This module provides a high-level function for executing the Reauth process, +:func:`refresh_grant`, and lower-level helpers for doing the individual +steps of the reauth process. + +Those steps are: + +1. Obtaining a list of challenges from the reauth server. +2. Running through each challenge and sending the result back to the reauth + server. +3. Refreshing the access token using the returned rapt token. +""" + +import sys + +from six.moves import range + +from google.auth import exceptions +from google.oauth2 import _client +from google.oauth2 import _client_async +from google.oauth2 import challenges +from google.oauth2 import reauth + + +async def _get_challenges( + request, supported_challenge_types, access_token, requested_scopes=None +): + """Does initial request to reauth API to get the challenges. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. This must be an aiohttp request. + supported_challenge_types (Sequence[str]): list of challenge names + supported by the manager. + access_token (str): Access token with reauth scopes. + requested_scopes (Optional(Sequence[str])): Authorized scopes for the credentials. + + Returns: + dict: The response from the reauth API. + """ + body = {"supportedChallengeTypes": supported_challenge_types} + if requested_scopes: + body["oauthScopesForDomainPolicyLookup"] = requested_scopes + + return await _client_async._token_endpoint_request( + request, + reauth._REAUTH_API + ":start", + body, + access_token=access_token, + use_json=True, + ) + + +async def _send_challenge_result( + request, session_id, challenge_id, client_input, access_token +): + """Attempt to refresh access token by sending next challenge result. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. This must be an aiohttp request. + session_id (str): session id returned by the initial reauth call. + challenge_id (str): challenge id returned by the initial reauth call. + client_input: dict with a challenge-specific client input. For example: + ``{'credential': password}`` for password challenge. + access_token (str): Access token with reauth scopes. + + Returns: + dict: The response from the reauth API. + """ + body = { + "sessionId": session_id, + "challengeId": challenge_id, + "action": "RESPOND", + "proposalResponse": client_input, + } + + return await _client_async._token_endpoint_request( + request, + reauth._REAUTH_API + "/{}:continue".format(session_id), + body, + access_token=access_token, + use_json=True, + ) + + +async def _run_next_challenge(msg, request, access_token): + """Get the next challenge from msg and run it. + + Args: + msg (dict): Reauth API response body (either from the initial request to + https://reauth.googleapis.com/v2/sessions:start or from sending the + previous challenge response to + https://reauth.googleapis.com/v2/sessions/id:continue) + request (google.auth.transport.Request): A callable used to make + HTTP requests. This must be an aiohttp request. + access_token (str): reauth access token + + Returns: + dict: The response from the reauth API. + + Raises: + google.auth.exceptions.ReauthError: if reauth failed. + """ + for challenge in msg["challenges"]: + if challenge["status"] != "READY": + # Skip non-activated challenges. + continue + c = challenges.AVAILABLE_CHALLENGES.get(challenge["challengeType"], None) + if not c: + raise exceptions.ReauthFailError( + "Unsupported challenge type {0}. Supported types: {1}".format( + challenge["challengeType"], + ",".join(list(challenges.AVAILABLE_CHALLENGES.keys())), + ) + ) + if not c.is_locally_eligible: + raise exceptions.ReauthFailError( + "Challenge {0} is not locally eligible".format( + challenge["challengeType"] + ) + ) + client_input = c.obtain_challenge_input(challenge) + if not client_input: + return None + return await _send_challenge_result( + request, + msg["sessionId"], + challenge["challengeId"], + client_input, + access_token, + ) + return None + + +async def _obtain_rapt(request, access_token, requested_scopes): + """Given an http request method and reauth access token, get rapt token. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. This must be an aiohttp request. + access_token (str): reauth access token + requested_scopes (Sequence[str]): scopes required by the client application + + Returns: + str: The rapt token. + + Raises: + google.auth.exceptions.ReauthError: if reauth failed + """ + msg = await _get_challenges( + request, + list(challenges.AVAILABLE_CHALLENGES.keys()), + access_token, + requested_scopes, + ) + + if msg["status"] == reauth._AUTHENTICATED: + return msg["encodedProofOfReauthToken"] + + for _ in range(0, reauth.RUN_CHALLENGE_RETRY_LIMIT): + if not ( + msg["status"] == reauth._CHALLENGE_REQUIRED + or msg["status"] == reauth._CHALLENGE_PENDING + ): + raise exceptions.ReauthFailError( + "Reauthentication challenge failed due to API error: {}".format( + msg["status"] + ) + ) + + if not reauth.is_interactive(): + raise exceptions.ReauthFailError( + "Reauthentication challenge could not be answered because you are not" + " in an interactive session." + ) + + msg = await _run_next_challenge(msg, request, access_token) + + if msg["status"] == reauth._AUTHENTICATED: + return msg["encodedProofOfReauthToken"] + + # If we got here it means we didn't get authenticated. + raise exceptions.ReauthFailError("Failed to obtain rapt token.") + + +async def get_rapt_token( + request, client_id, client_secret, refresh_token, token_uri, scopes=None +): + """Given an http request method and refresh_token, get rapt token. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. This must be an aiohttp request. + client_id (str): client id to get access token for reauth scope. + client_secret (str): client secret for the client_id + refresh_token (str): refresh token to refresh access token + token_uri (str): uri to refresh access token + scopes (Optional(Sequence[str])): scopes required by the client application + + Returns: + str: The rapt token. + Raises: + google.auth.exceptions.RefreshError: If reauth failed. + """ + sys.stderr.write("Reauthentication required.\n") + + # Get access token for reauth. + access_token, _, _, _ = await _client_async.refresh_grant( + request=request, + client_id=client_id, + client_secret=client_secret, + refresh_token=refresh_token, + token_uri=token_uri, + scopes=[reauth._REAUTH_SCOPE], + ) + + # Get rapt token from reauth API. + rapt_token = await _obtain_rapt(request, access_token, requested_scopes=scopes) + + return rapt_token + + +async def refresh_grant( + request, + token_uri, + refresh_token, + client_id, + client_secret, + scopes=None, + rapt_token=None, +): + """Implements the reauthentication flow. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. This must be an aiohttp request. + token_uri (str): The OAuth 2.0 authorizations server's token endpoint + URI. + refresh_token (str): The refresh token to use to get a new access + token. + client_id (str): The OAuth 2.0 application's client ID. + client_secret (str): The Oauth 2.0 appliaction's client secret. + scopes (Optional(Sequence[str])): Scopes to request. If present, all + scopes must be authorized for the refresh token. Useful if refresh + token has a wild card scope (e.g. + 'https://www.googleapis.com/auth/any-api'). + rapt_token (Optional(str)): The rapt token for reauth. + + Returns: + Tuple[str, Optional[str], Optional[datetime], Mapping[str, str], str]: The + access token, new refresh token, expiration, the additional data + returned by the token endpoint, and the rapt token. + + Raises: + google.auth.exceptions.RefreshError: If the token endpoint returned + an error. + """ + body = { + "grant_type": _client._REFRESH_GRANT_TYPE, + "client_id": client_id, + "client_secret": client_secret, + "refresh_token": refresh_token, + } + if scopes: + body["scope"] = " ".join(scopes) + if rapt_token: + body["rapt"] = rapt_token + + response_status_ok, response_data = await _client_async._token_endpoint_request_no_throw( + request, token_uri, body + ) + if ( + not response_status_ok + and response_data.get("error") == reauth._REAUTH_NEEDED_ERROR + and ( + response_data.get("error_subtype") + == reauth._REAUTH_NEEDED_ERROR_INVALID_RAPT + or response_data.get("error_subtype") + == reauth._REAUTH_NEEDED_ERROR_RAPT_REQUIRED + ) + ): + rapt_token = await get_rapt_token( + request, client_id, client_secret, refresh_token, token_uri, scopes=scopes + ) + body["rapt"] = rapt_token + ( + response_status_ok, + response_data, + ) = await _client_async._token_endpoint_request_no_throw( + request, token_uri, body + ) + + if not response_status_ok: + _client._handle_error_response(response_data) + refresh_response = _client._handle_refresh_grant_response( + response_data, refresh_token + ) + return refresh_response + (rapt_token,)
diff --git a/google/oauth2/reauth.py b/google/oauth2/reauth.py index d539d7c..d914fe9 100644 --- a/google/oauth2/reauth.py +++ b/google/oauth2/reauth.py
@@ -296,9 +296,9 @@ rapt_token (Optional(str)): The rapt token for reauth. Returns: - Tuple[str, Optional[str], Optional[datetime], Mapping[str, str]]: The - access token, new refresh token, expiration, and additional data - returned by the token endpoint. + Tuple[str, Optional[str], Optional[datetime], Mapping[str, str], str]: The + access token, new refresh token, expiration, the additional data + returned by the token endpoint, and the rapt token. Raises: google.auth.exceptions.RefreshError: If the token endpoint returned