arithmetic1728 | 82293fe | 2021-04-14 11:22:13 -0700 | [diff] [blame] | 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 | |
| 17 | Reauth is a process of obtaining additional authentication (such as password, |
| 18 | security token, etc.) while refreshing OAuth 2.0 credentials for a user. |
| 19 | |
| 20 | Credentials that use the Reauth flow must have the reauth scope, |
| 21 | ``https://www.googleapis.com/auth/accounts.reauth``. |
| 22 | |
| 23 | This module provides a high-level function for executing the Reauth process, |
| 24 | :func:`refresh_grant`, and lower-level helpers for doing the individual |
| 25 | steps of the reauth process. |
| 26 | |
| 27 | Those steps are: |
| 28 | |
| 29 | 1. Obtaining a list of challenges from the reauth server. |
| 30 | 2. Running through each challenge and sending the result back to the reauth |
| 31 | server. |
| 32 | 3. Refreshing the access token using the returned rapt token. |
| 33 | """ |
| 34 | |
| 35 | import sys |
| 36 | |
arithmetic1728 | 5bd5ccf | 2021-10-21 15:25:46 -0700 | [diff] [blame] | 37 | from six.moves import range |
| 38 | |
arithmetic1728 | 82293fe | 2021-04-14 11:22:13 -0700 | [diff] [blame] | 39 | from google.auth import exceptions |
| 40 | from google.oauth2 import _client |
| 41 | from 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. |
| 58 | RUN_CHALLENGE_RETRY_LIMIT = 5 |
| 59 | |
| 60 | |
| 61 | def 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 | |
| 78 | def _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 | |
| 103 | def _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 | |
| 136 | def _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 | |
| 185 | def _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 | |
| 235 | def 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 | |
| 272 | def refresh_grant( |
| 273 | request, |
| 274 | token_uri, |
| 275 | refresh_token, |
| 276 | client_id, |
| 277 | client_secret, |
| 278 | scopes=None, |
| 279 | rapt_token=None, |
arithmetic1728 | 13aed5f | 2021-09-07 16:24:45 -0700 | [diff] [blame] | 280 | enable_reauth_refresh=False, |
arithmetic1728 | 82293fe | 2021-04-14 11:22:13 -0700 | [diff] [blame] | 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. |
arithmetic1728 | 13aed5f | 2021-09-07 16:24:45 -0700 | [diff] [blame] | 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. |
arithmetic1728 | 82293fe | 2021-04-14 11:22:13 -0700 | [diff] [blame] | 301 | |
| 302 | Returns: |
arithmetic1728 | 9e10823 | 2021-04-23 15:27:02 -0700 | [diff] [blame] | 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. |
arithmetic1728 | 82293fe | 2021-04-14 11:22:13 -0700 | [diff] [blame] | 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 | ): |
arithmetic1728 | 13aed5f | 2021-09-07 16:24:45 -0700 | [diff] [blame] | 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 | |
arithmetic1728 | 82293fe | 2021-04-14 11:22:13 -0700 | [diff] [blame] | 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 | ) |