1# Copyright 2021 Google LLC
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#      http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"""A module that provides functions for handling rapt authentication.
16
17Reauth is a process of obtaining additional authentication (such as password,
18security token, etc.) while refreshing OAuth 2.0 credentials for a user.
19
20Credentials that use the Reauth flow must have the reauth scope,
21``https://www.googleapis.com/auth/accounts.reauth``.
22
23This module provides a high-level function for executing the Reauth process,
24:func:`refresh_grant`, and lower-level helpers for doing the individual
25steps of the reauth process.
26
27Those steps are:
28
291. Obtaining a list of challenges from the reauth server.
302. Running through each challenge and sending the result back to the reauth
31   server.
323. Refreshing the access token using the returned rapt token.
33"""
34
35import sys
36
37from six.moves import range
38
39from google.auth import exceptions
40from google.oauth2 import _client
41from google.oauth2 import _client_async
42from google.oauth2 import challenges
43from google.oauth2 import reauth
44
45
46async def _get_challenges(
47    request, supported_challenge_types, access_token, requested_scopes=None
48):
49    """Does initial request to reauth API to get the challenges.
50
51    Args:
52        request (google.auth.transport.Request): A callable used to make
53            HTTP requests. This must be an aiohttp request.
54        supported_challenge_types (Sequence[str]): list of challenge names
55            supported by the manager.
56        access_token (str): Access token with reauth scopes.
57        requested_scopes (Optional(Sequence[str])): Authorized scopes for the credentials.
58
59    Returns:
60        dict: The response from the reauth API.
61    """
62    body = {"supportedChallengeTypes": supported_challenge_types}
63    if requested_scopes:
64        body["oauthScopesForDomainPolicyLookup"] = requested_scopes
65
66    return await _client_async._token_endpoint_request(
67        request,
68        reauth._REAUTH_API + ":start",
69        body,
70        access_token=access_token,
71        use_json=True,
72    )
73
74
75async def _send_challenge_result(
76    request, session_id, challenge_id, client_input, access_token
77):
78    """Attempt to refresh access token by sending next challenge result.
79
80    Args:
81        request (google.auth.transport.Request): A callable used to make
82            HTTP requests. This must be an aiohttp request.
83        session_id (str): session id returned by the initial reauth call.
84        challenge_id (str): challenge id returned by the initial reauth call.
85        client_input: dict with a challenge-specific client input. For example:
86            ``{'credential': password}`` for password challenge.
87        access_token (str): Access token with reauth scopes.
88
89    Returns:
90        dict: The response from the reauth API.
91    """
92    body = {
93        "sessionId": session_id,
94        "challengeId": challenge_id,
95        "action": "RESPOND",
96        "proposalResponse": client_input,
97    }
98
99    return await _client_async._token_endpoint_request(
100        request,
101        reauth._REAUTH_API + "/{}:continue".format(session_id),
102        body,
103        access_token=access_token,
104        use_json=True,
105    )
106
107
108async def _run_next_challenge(msg, request, access_token):
109    """Get the next challenge from msg and run it.
110
111    Args:
112        msg (dict): Reauth API response body (either from the initial request to
113            https://reauth.googleapis.com/v2/sessions:start or from sending the
114            previous challenge response to
115            https://reauth.googleapis.com/v2/sessions/id:continue)
116        request (google.auth.transport.Request): A callable used to make
117            HTTP requests. This must be an aiohttp request.
118        access_token (str): reauth access token
119
120    Returns:
121        dict: The response from the reauth API.
122
123    Raises:
124        google.auth.exceptions.ReauthError: if reauth failed.
125    """
126    for challenge in msg["challenges"]:
127        if challenge["status"] != "READY":
128            # Skip non-activated challenges.
129            continue
130        c = challenges.AVAILABLE_CHALLENGES.get(challenge["challengeType"], None)
131        if not c:
132            raise exceptions.ReauthFailError(
133                "Unsupported challenge type {0}. Supported types: {1}".format(
134                    challenge["challengeType"],
135                    ",".join(list(challenges.AVAILABLE_CHALLENGES.keys())),
136                )
137            )
138        if not c.is_locally_eligible:
139            raise exceptions.ReauthFailError(
140                "Challenge {0} is not locally eligible".format(
141                    challenge["challengeType"]
142                )
143            )
144        client_input = c.obtain_challenge_input(challenge)
145        if not client_input:
146            return None
147        return await _send_challenge_result(
148            request,
149            msg["sessionId"],
150            challenge["challengeId"],
151            client_input,
152            access_token,
153        )
154    return None
155
156
157async def _obtain_rapt(request, access_token, requested_scopes):
158    """Given an http request method and reauth access token, get rapt token.
159
160    Args:
161        request (google.auth.transport.Request): A callable used to make
162            HTTP requests. This must be an aiohttp request.
163        access_token (str): reauth access token
164        requested_scopes (Sequence[str]): scopes required by the client application
165
166    Returns:
167        str: The rapt token.
168
169    Raises:
170        google.auth.exceptions.ReauthError: if reauth failed
171    """
172    msg = await _get_challenges(
173        request,
174        list(challenges.AVAILABLE_CHALLENGES.keys()),
175        access_token,
176        requested_scopes,
177    )
178
179    if msg["status"] == reauth._AUTHENTICATED:
180        return msg["encodedProofOfReauthToken"]
181
182    for _ in range(0, reauth.RUN_CHALLENGE_RETRY_LIMIT):
183        if not (
184            msg["status"] == reauth._CHALLENGE_REQUIRED
185            or msg["status"] == reauth._CHALLENGE_PENDING
186        ):
187            raise exceptions.ReauthFailError(
188                "Reauthentication challenge failed due to API error: {}".format(
189                    msg["status"]
190                )
191            )
192
193        if not reauth.is_interactive():
194            raise exceptions.ReauthFailError(
195                "Reauthentication challenge could not be answered because you are not"
196                " in an interactive session."
197            )
198
199        msg = await _run_next_challenge(msg, request, access_token)
200
201        if msg["status"] == reauth._AUTHENTICATED:
202            return msg["encodedProofOfReauthToken"]
203
204    # If we got here it means we didn't get authenticated.
205    raise exceptions.ReauthFailError("Failed to obtain rapt token.")
206
207
208async def get_rapt_token(
209    request, client_id, client_secret, refresh_token, token_uri, scopes=None
210):
211    """Given an http request method and refresh_token, get rapt token.
212
213    Args:
214        request (google.auth.transport.Request): A callable used to make
215            HTTP requests. This must be an aiohttp request.
216        client_id (str): client id to get access token for reauth scope.
217        client_secret (str): client secret for the client_id
218        refresh_token (str): refresh token to refresh access token
219        token_uri (str): uri to refresh access token
220        scopes (Optional(Sequence[str])): scopes required by the client application
221
222    Returns:
223        str: The rapt token.
224    Raises:
225        google.auth.exceptions.RefreshError: If reauth failed.
226    """
227    sys.stderr.write("Reauthentication required.\n")
228
229    # Get access token for reauth.
230    access_token, _, _, _ = await _client_async.refresh_grant(
231        request=request,
232        client_id=client_id,
233        client_secret=client_secret,
234        refresh_token=refresh_token,
235        token_uri=token_uri,
236        scopes=[reauth._REAUTH_SCOPE],
237    )
238
239    # Get rapt token from reauth API.
240    rapt_token = await _obtain_rapt(request, access_token, requested_scopes=scopes)
241
242    return rapt_token
243
244
245async def refresh_grant(
246    request,
247    token_uri,
248    refresh_token,
249    client_id,
250    client_secret,
251    scopes=None,
252    rapt_token=None,
253    enable_reauth_refresh=False,
254):
255    """Implements the reauthentication flow.
256
257    Args:
258        request (google.auth.transport.Request): A callable used to make
259            HTTP requests. This must be an aiohttp request.
260        token_uri (str): The OAuth 2.0 authorizations server's token endpoint
261            URI.
262        refresh_token (str): The refresh token to use to get a new access
263            token.
264        client_id (str): The OAuth 2.0 application's client ID.
265        client_secret (str): The Oauth 2.0 appliaction's client secret.
266        scopes (Optional(Sequence[str])): Scopes to request. If present, all
267            scopes must be authorized for the refresh token. Useful if refresh
268            token has a wild card scope (e.g.
269            'https://www.googleapis.com/auth/any-api').
270        rapt_token (Optional(str)): The rapt token for reauth.
271        enable_reauth_refresh (Optional[bool]): Whether reauth refresh flow
272            should be used. The default value is False. This option is for
273            gcloud only, other users should use the default value.
274
275    Returns:
276        Tuple[str, Optional[str], Optional[datetime], Mapping[str, str], str]: The
277            access token, new refresh token, expiration, the additional data
278            returned by the token endpoint, and the rapt token.
279
280    Raises:
281        google.auth.exceptions.RefreshError: If the token endpoint returned
282            an error.
283    """
284    body = {
285        "grant_type": _client._REFRESH_GRANT_TYPE,
286        "client_id": client_id,
287        "client_secret": client_secret,
288        "refresh_token": refresh_token,
289    }
290    if scopes:
291        body["scope"] = " ".join(scopes)
292    if rapt_token:
293        body["rapt"] = rapt_token
294
295    response_status_ok, response_data = await _client_async._token_endpoint_request_no_throw(
296        request, token_uri, body
297    )
298    if (
299        not response_status_ok
300        and response_data.get("error") == reauth._REAUTH_NEEDED_ERROR
301        and (
302            response_data.get("error_subtype")
303            == reauth._REAUTH_NEEDED_ERROR_INVALID_RAPT
304            or response_data.get("error_subtype")
305            == reauth._REAUTH_NEEDED_ERROR_RAPT_REQUIRED
306        )
307    ):
308        if not enable_reauth_refresh:
309            raise exceptions.RefreshError(
310                "Reauthentication is needed. Please run `gcloud auth login --update-adc` to reauthenticate."
311            )
312
313        rapt_token = await get_rapt_token(
314            request, client_id, client_secret, refresh_token, token_uri, scopes=scopes
315        )
316        body["rapt"] = rapt_token
317        (
318            response_status_ok,
319            response_data,
320        ) = await _client_async._token_endpoint_request_no_throw(
321            request, token_uri, body
322        )
323
324    if not response_status_ok:
325        _client._handle_error_response(response_data)
326    refresh_response = _client._handle_refresh_grant_response(
327        response_data, refresh_token
328    )
329    return refresh_response + (rapt_token,)
330