blob: cbf1d7f09394ed63671f429b84b3d8f6bd91a607 [file] [log] [blame]
arithmetic172882293fe2021-04-14 11:22:13 -07001# 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
arithmetic17285bd5ccf2021-10-21 15:25:46 -070037from six.moves import range
38
arithmetic172882293fe2021-04-14 11:22:13 -070039from 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,
arithmetic172813aed5f2021-09-07 16:24:45 -0700280 enable_reauth_refresh=False,
arithmetic172882293fe2021-04-14 11:22:13 -0700281):
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.
arithmetic172813aed5f2021-09-07 16:24:45 -0700298 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.
arithmetic172882293fe2021-04-14 11:22:13 -0700301
302 Returns:
arithmetic17289e108232021-04-23 15:27:02 -0700303 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.
arithmetic172882293fe2021-04-14 11:22:13 -0700306
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 ):
arithmetic172813aed5f2021-09-07 16:24:45 -0700333 if not enable_reauth_refresh:
334 raise exceptions.RefreshError(
335 "Reauthentication is needed. Please run `gcloud auth login --update-adc` to reauthenticate."
336 )
337
arithmetic172882293fe2021-04-14 11:22:13 -0700338 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 )