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