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 challenges
42
43
44_REAUTH_SCOPE = "https://www.googleapis.com/auth/accounts.reauth"
45_REAUTH_API = "https://reauth.googleapis.com/v2/sessions"
46
47_REAUTH_NEEDED_ERROR = "invalid_grant"
48_REAUTH_NEEDED_ERROR_INVALID_RAPT = "invalid_rapt"
49_REAUTH_NEEDED_ERROR_RAPT_REQUIRED = "rapt_required"
50
51_AUTHENTICATED = "AUTHENTICATED"
52_CHALLENGE_REQUIRED = "CHALLENGE_REQUIRED"
53_CHALLENGE_PENDING = "CHALLENGE_PENDING"
54
55
56# Override this global variable to set custom max number of rounds of reauth
57# challenges should be run.
58RUN_CHALLENGE_RETRY_LIMIT = 5
59
60
61def is_interactive():
62    """Check if we are in an interractive environment.
63
64    Override this function with a different logic if you are using this library
65    outside a CLI.
66
67    If the rapt token needs refreshing, the user needs to answer the challenges.
68    If the user is not in an interractive environment, the challenges can not
69    be answered and we just wait for timeout for no reason.
70
71    Returns:
72        bool: True if is interactive environment, False otherwise.
73    """
74
75    return sys.stdin.isatty()
76
77
78def _get_challenges(
79    request, supported_challenge_types, access_token, requested_scopes=None
80):
81    """Does initial request to reauth API to get the challenges.
82
83    Args:
84        request (google.auth.transport.Request): A callable used to make
85            HTTP requests.
86        supported_challenge_types (Sequence[str]): list of challenge names
87            supported by the manager.
88        access_token (str): Access token with reauth scopes.
89        requested_scopes (Optional(Sequence[str])): Authorized scopes for the credentials.
90
91    Returns:
92        dict: The response from the reauth API.
93    """
94    body = {"supportedChallengeTypes": supported_challenge_types}
95    if requested_scopes:
96        body["oauthScopesForDomainPolicyLookup"] = requested_scopes
97
98    return _client._token_endpoint_request(
99        request, _REAUTH_API + ":start", body, access_token=access_token, use_json=True
100    )
101
102
103def _send_challenge_result(
104    request, session_id, challenge_id, client_input, access_token
105):
106    """Attempt to refresh access token by sending next challenge result.
107
108    Args:
109        request (google.auth.transport.Request): A callable used to make
110            HTTP requests.
111        session_id (str): session id returned by the initial reauth call.
112        challenge_id (str): challenge id returned by the initial reauth call.
113        client_input: dict with a challenge-specific client input. For example:
114            ``{'credential': password}`` for password challenge.
115        access_token (str): Access token with reauth scopes.
116
117    Returns:
118        dict: The response from the reauth API.
119    """
120    body = {
121        "sessionId": session_id,
122        "challengeId": challenge_id,
123        "action": "RESPOND",
124        "proposalResponse": client_input,
125    }
126
127    return _client._token_endpoint_request(
128        request,
129        _REAUTH_API + "/{}:continue".format(session_id),
130        body,
131        access_token=access_token,
132        use_json=True,
133    )
134
135
136def _run_next_challenge(msg, request, access_token):
137    """Get the next challenge from msg and run it.
138
139    Args:
140        msg (dict): Reauth API response body (either from the initial request to
141            https://reauth.googleapis.com/v2/sessions:start or from sending the
142            previous challenge response to
143            https://reauth.googleapis.com/v2/sessions/id:continue)
144        request (google.auth.transport.Request): A callable used to make
145            HTTP requests.
146        access_token (str): reauth access token
147
148    Returns:
149        dict: The response from the reauth API.
150
151    Raises:
152        google.auth.exceptions.ReauthError: if reauth failed.
153    """
154    for challenge in msg["challenges"]:
155        if challenge["status"] != "READY":
156            # Skip non-activated challenges.
157            continue
158        c = challenges.AVAILABLE_CHALLENGES.get(challenge["challengeType"], None)
159        if not c:
160            raise exceptions.ReauthFailError(
161                "Unsupported challenge type {0}. Supported types: {1}".format(
162                    challenge["challengeType"],
163                    ",".join(list(challenges.AVAILABLE_CHALLENGES.keys())),
164                )
165            )
166        if not c.is_locally_eligible:
167            raise exceptions.ReauthFailError(
168                "Challenge {0} is not locally eligible".format(
169                    challenge["challengeType"]
170                )
171            )
172        client_input = c.obtain_challenge_input(challenge)
173        if not client_input:
174            return None
175        return _send_challenge_result(
176            request,
177            msg["sessionId"],
178            challenge["challengeId"],
179            client_input,
180            access_token,
181        )
182    return None
183
184
185def _obtain_rapt(request, access_token, requested_scopes):
186    """Given an http request method and reauth access token, get rapt token.
187
188    Args:
189        request (google.auth.transport.Request): A callable used to make
190            HTTP requests.
191        access_token (str): reauth access token
192        requested_scopes (Sequence[str]): scopes required by the client application
193
194    Returns:
195        str: The rapt token.
196
197    Raises:
198        google.auth.exceptions.ReauthError: if reauth failed
199    """
200    msg = _get_challenges(
201        request,
202        list(challenges.AVAILABLE_CHALLENGES.keys()),
203        access_token,
204        requested_scopes,
205    )
206
207    if msg["status"] == _AUTHENTICATED:
208        return msg["encodedProofOfReauthToken"]
209
210    for _ in range(0, RUN_CHALLENGE_RETRY_LIMIT):
211        if not (
212            msg["status"] == _CHALLENGE_REQUIRED or msg["status"] == _CHALLENGE_PENDING
213        ):
214            raise exceptions.ReauthFailError(
215                "Reauthentication challenge failed due to API error: {}".format(
216                    msg["status"]
217                )
218            )
219
220        if not is_interactive():
221            raise exceptions.ReauthFailError(
222                "Reauthentication challenge could not be answered because you are not"
223                " in an interactive session."
224            )
225
226        msg = _run_next_challenge(msg, request, access_token)
227
228        if msg["status"] == _AUTHENTICATED:
229            return msg["encodedProofOfReauthToken"]
230
231    # If we got here it means we didn't get authenticated.
232    raise exceptions.ReauthFailError("Failed to obtain rapt token.")
233
234
235def get_rapt_token(
236    request, client_id, client_secret, refresh_token, token_uri, scopes=None
237):
238    """Given an http request method and refresh_token, get rapt token.
239
240    Args:
241        request (google.auth.transport.Request): A callable used to make
242            HTTP requests.
243        client_id (str): client id to get access token for reauth scope.
244        client_secret (str): client secret for the client_id
245        refresh_token (str): refresh token to refresh access token
246        token_uri (str): uri to refresh access token
247        scopes (Optional(Sequence[str])): scopes required by the client application
248
249    Returns:
250        str: The rapt token.
251    Raises:
252        google.auth.exceptions.RefreshError: If reauth failed.
253    """
254    sys.stderr.write("Reauthentication required.\n")
255
256    # Get access token for reauth.
257    access_token, _, _, _ = _client.refresh_grant(
258        request=request,
259        client_id=client_id,
260        client_secret=client_secret,
261        refresh_token=refresh_token,
262        token_uri=token_uri,
263        scopes=[_REAUTH_SCOPE],
264    )
265
266    # Get rapt token from reauth API.
267    rapt_token = _obtain_rapt(request, access_token, requested_scopes=scopes)
268
269    return rapt_token
270
271
272def refresh_grant(
273    request,
274    token_uri,
275    refresh_token,
276    client_id,
277    client_secret,
278    scopes=None,
279    rapt_token=None,
280    enable_reauth_refresh=False,
281):
282    """Implements the reauthentication flow.
283
284    Args:
285        request (google.auth.transport.Request): A callable used to make
286            HTTP requests.
287        token_uri (str): The OAuth 2.0 authorizations server's token endpoint
288            URI.
289        refresh_token (str): The refresh token to use to get a new access
290            token.
291        client_id (str): The OAuth 2.0 application's client ID.
292        client_secret (str): The Oauth 2.0 appliaction's client secret.
293        scopes (Optional(Sequence[str])): Scopes to request. If present, all
294            scopes must be authorized for the refresh token. Useful if refresh
295            token has a wild card scope (e.g.
296            'https://www.googleapis.com/auth/any-api').
297        rapt_token (Optional(str)): The rapt token for reauth.
298        enable_reauth_refresh (Optional[bool]): Whether reauth refresh flow
299            should be used. The default value is False. This option is for
300            gcloud only, other users should use the default value.
301
302    Returns:
303        Tuple[str, Optional[str], Optional[datetime], Mapping[str, str], str]: The
304            access token, new refresh token, expiration, the additional data
305            returned by the token endpoint, and the rapt token.
306
307    Raises:
308        google.auth.exceptions.RefreshError: If the token endpoint returned
309            an error.
310    """
311    body = {
312        "grant_type": _client._REFRESH_GRANT_TYPE,
313        "client_id": client_id,
314        "client_secret": client_secret,
315        "refresh_token": refresh_token,
316    }
317    if scopes:
318        body["scope"] = " ".join(scopes)
319    if rapt_token:
320        body["rapt"] = rapt_token
321
322    response_status_ok, response_data = _client._token_endpoint_request_no_throw(
323        request, token_uri, body
324    )
325    if (
326        not response_status_ok
327        and response_data.get("error") == _REAUTH_NEEDED_ERROR
328        and (
329            response_data.get("error_subtype") == _REAUTH_NEEDED_ERROR_INVALID_RAPT
330            or response_data.get("error_subtype") == _REAUTH_NEEDED_ERROR_RAPT_REQUIRED
331        )
332    ):
333        if not enable_reauth_refresh:
334            raise exceptions.RefreshError(
335                "Reauthentication is needed. Please run `gcloud auth login --update-adc` to reauthenticate."
336            )
337
338        rapt_token = get_rapt_token(
339            request, client_id, client_secret, refresh_token, token_uri, scopes=scopes
340        )
341        body["rapt"] = rapt_token
342        (response_status_ok, response_data) = _client._token_endpoint_request_no_throw(
343            request, token_uri, body
344        )
345
346    if not response_status_ok:
347        _client._handle_error_response(response_data)
348    return _client._handle_refresh_grant_response(response_data, refresh_token) + (
349        rapt_token,
350    )
351