feat: Add new gcloud commands, API clients, and third-party libraries across various services.

This commit is contained in:
2026-01-01 20:26:35 +01:00
parent 5e23cbece0
commit a19e592eb7
25221 changed files with 8324611 additions and 0 deletions

View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,37 @@
#!/usr/bin/env python
# Copyright 2016 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Google OAuth 2.0 Library for Python."""
import sys
import warnings
class Python37DeprecationWarning(DeprecationWarning): # pragma: NO COVER
"""
Deprecation warning raised when Python 3.7 runtime is detected.
Python 3.7 support will be dropped after January 1, 2024.
"""
pass
# Checks if the current runtime is Python 3.7.
if sys.version_info.major == 3 and sys.version_info.minor == 7: # pragma: NO COVER
message = (
"After January 1, 2024, new releases of this library will drop support "
"for Python 3.7."
)
warnings.warn(message, Python37DeprecationWarning)

View File

@@ -0,0 +1,508 @@
#!/usr/bin/env python
# Copyright 2016 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""OAuth 2.0 client.
This is a client for interacting with an OAuth 2.0 authorization server's
token endpoint.
For more information about the token endpoint, see
`Section 3.1 of rfc6749`_
.. _Section 3.1 of rfc6749: https://tools.ietf.org/html/rfc6749#section-3.2
"""
import datetime
import http.client as http_client
import json
import urllib
from google.auth import _exponential_backoff
from google.auth import _helpers
from google.auth import exceptions
from google.auth import jwt
from google.auth import metrics
from google.auth import transport
_URLENCODED_CONTENT_TYPE = "application/x-www-form-urlencoded"
_JSON_CONTENT_TYPE = "application/json"
_JWT_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"
_REFRESH_GRANT_TYPE = "refresh_token"
_IAM_IDTOKEN_ENDPOINT = (
"https://iamcredentials.googleapis.com/v1/"
+ "projects/-/serviceAccounts/{}:generateIdToken"
)
def _handle_error_response(response_data, retryable_error):
"""Translates an error response into an exception.
Args:
response_data (Mapping | str): The decoded response data.
retryable_error Optional[bool]: A boolean indicating if an error is retryable.
Defaults to False.
Raises:
google.auth.exceptions.RefreshError: The errors contained in response_data.
"""
retryable_error = retryable_error if retryable_error else False
if isinstance(response_data, str):
raise exceptions.RefreshError(response_data, retryable=retryable_error)
try:
error_details = "{}: {}".format(
response_data["error"], response_data.get("error_description")
)
# If no details could be extracted, use the response data.
except (KeyError, ValueError):
error_details = json.dumps(response_data)
raise exceptions.RefreshError(
error_details, response_data, retryable=retryable_error
)
def _can_retry(status_code, response_data):
"""Checks if a request can be retried by inspecting the status code
and response body of the request.
Args:
status_code (int): The response status code.
response_data (Mapping | str): The decoded response data.
Returns:
bool: True if the response is retryable. False otherwise.
"""
if status_code in transport.DEFAULT_RETRYABLE_STATUS_CODES:
return True
try:
# For a failed response, response_body could be a string
error_desc = response_data.get("error_description") or ""
error_code = response_data.get("error") or ""
if not isinstance(error_code, str) or not isinstance(error_desc, str):
return False
# Per Oauth 2.0 RFC https://www.rfc-editor.org/rfc/rfc6749.html#section-4.1.2.1
# This is needed because a redirect will not return a 500 status code.
retryable_error_descriptions = {
"internal_failure",
"server_error",
"temporarily_unavailable",
}
if any(e in retryable_error_descriptions for e in (error_code, error_desc)):
return True
except AttributeError:
pass
return False
def _parse_expiry(response_data):
"""Parses the expiry field from a response into a datetime.
Args:
response_data (Mapping): The JSON-parsed response data.
Returns:
Optional[datetime]: The expiration or ``None`` if no expiration was
specified.
"""
expires_in = response_data.get("expires_in", None)
if expires_in is not None:
# Some services do not respect the OAUTH2.0 RFC and send expires_in as a
# JSON String.
if isinstance(expires_in, str):
expires_in = int(expires_in)
return _helpers.utcnow() + datetime.timedelta(seconds=expires_in)
else:
return None
def _token_endpoint_request_no_throw(
request,
token_uri,
body,
access_token=None,
use_json=False,
can_retry=True,
headers=None,
**kwargs
):
"""Makes a request to the OAuth 2.0 authorization server's token endpoint.
This function doesn't throw on response errors.
Args:
request (google.auth.transport.Request): A callable used to make
HTTP requests.
token_uri (str): The OAuth 2.0 authorizations server's token endpoint
URI.
body (Mapping[str, str]): The parameters to send in the request body.
access_token (Optional(str)): The access token needed to make the request.
use_json (Optional(bool)): Use urlencoded format or json format for the
content type. The default value is False.
can_retry (bool): Enable or disable request retry behavior.
headers (Optional[Mapping[str, str]]): The headers for the request.
kwargs: Additional arguments passed on to the request method. The
kwargs will be passed to `requests.request` method, see:
https://docs.python-requests.org/en/latest/api/#requests.request.
For example, you can use `cert=("cert_pem_path", "key_pem_path")`
to set up client side SSL certificate, and use
`verify="ca_bundle_path"` to set up the CA certificates for sever
side SSL certificate verification.
Returns:
Tuple(bool, Mapping[str, str], Optional[bool]): A boolean indicating
if the request is successful, a mapping for the JSON-decoded response
data and in the case of an error a boolean indicating if the error
is retryable.
"""
if use_json:
headers_to_use = {"Content-Type": _JSON_CONTENT_TYPE}
body = json.dumps(body).encode("utf-8")
else:
headers_to_use = {"Content-Type": _URLENCODED_CONTENT_TYPE}
body = urllib.parse.urlencode(body).encode("utf-8")
if access_token:
headers_to_use["Authorization"] = "Bearer {}".format(access_token)
if headers:
headers_to_use.update(headers)
def _perform_request():
response = request(
method="POST", url=token_uri, headers=headers_to_use, body=body, **kwargs
)
response_body = (
response.data.decode("utf-8")
if hasattr(response.data, "decode")
else response.data
)
response_data = ""
try:
# response_body should be a JSON
response_data = json.loads(response_body)
except ValueError:
response_data = response_body
if response.status == http_client.OK:
return True, response_data, None
retryable_error = _can_retry(
status_code=response.status, response_data=response_data
)
return False, response_data, retryable_error
request_succeeded, response_data, retryable_error = _perform_request()
if request_succeeded or not retryable_error or not can_retry:
return request_succeeded, response_data, retryable_error
retries = _exponential_backoff.ExponentialBackoff()
for _ in retries:
request_succeeded, response_data, retryable_error = _perform_request()
if request_succeeded or not retryable_error:
return request_succeeded, response_data, retryable_error
return False, response_data, retryable_error
def _token_endpoint_request(
request,
token_uri,
body,
access_token=None,
use_json=False,
can_retry=True,
headers=None,
**kwargs
):
"""Makes a request to the OAuth 2.0 authorization server's token endpoint.
Args:
request (google.auth.transport.Request): A callable used to make
HTTP requests.
token_uri (str): The OAuth 2.0 authorizations server's token endpoint
URI.
body (Mapping[str, str]): The parameters to send in the request body.
access_token (Optional(str)): The access token needed to make the request.
use_json (Optional(bool)): Use urlencoded format or json format for the
content type. The default value is False.
can_retry (bool): Enable or disable request retry behavior.
headers (Optional[Mapping[str, str]]): The headers for the request.
kwargs: Additional arguments passed on to the request method. The
kwargs will be passed to `requests.request` method, see:
https://docs.python-requests.org/en/latest/api/#requests.request.
For example, you can use `cert=("cert_pem_path", "key_pem_path")`
to set up client side SSL certificate, and use
`verify="ca_bundle_path"` to set up the CA certificates for sever
side SSL certificate verification.
Returns:
Mapping[str, str]: The JSON-decoded response data.
Raises:
google.auth.exceptions.RefreshError: If the token endpoint returned
an error.
"""
response_status_ok, response_data, retryable_error = _token_endpoint_request_no_throw(
request,
token_uri,
body,
access_token=access_token,
use_json=use_json,
can_retry=can_retry,
headers=headers,
**kwargs
)
if not response_status_ok:
_handle_error_response(response_data, retryable_error)
return response_data
def jwt_grant(request, token_uri, assertion, can_retry=True):
"""Implements the JWT Profile for OAuth 2.0 Authorization Grants.
For more details, see `rfc7523 section 4`_.
Args:
request (google.auth.transport.Request): A callable used to make
HTTP requests.
token_uri (str): The OAuth 2.0 authorizations server's token endpoint
URI.
assertion (str): The OAuth 2.0 assertion.
can_retry (bool): Enable or disable request retry behavior.
Returns:
Tuple[str, Optional[datetime], Mapping[str, str]]: The access token,
expiration, and additional data returned by the token endpoint.
Raises:
google.auth.exceptions.RefreshError: If the token endpoint returned
an error.
.. _rfc7523 section 4: https://tools.ietf.org/html/rfc7523#section-4
"""
body = {"assertion": assertion, "grant_type": _JWT_GRANT_TYPE}
response_data = _token_endpoint_request(
request,
token_uri,
body,
can_retry=can_retry,
headers={
metrics.API_CLIENT_HEADER: metrics.token_request_access_token_sa_assertion()
},
)
try:
access_token = response_data["access_token"]
except KeyError as caught_exc:
new_exc = exceptions.RefreshError(
"No access token in response.", response_data, retryable=False
)
raise new_exc from caught_exc
expiry = _parse_expiry(response_data)
return access_token, expiry, response_data
def call_iam_generate_id_token_endpoint(request, signer_email, audience, access_token):
"""Call iam.generateIdToken endpoint to get ID token.
Args:
request (google.auth.transport.Request): A callable used to make
HTTP requests.
signer_email (str): The signer email used to form the IAM
generateIdToken endpoint.
audience (str): The audience for the ID token.
access_token (str): The access token used to call the IAM endpoint.
Returns:
Tuple[str, datetime]: The ID token and expiration.
"""
body = {"audience": audience, "includeEmail": "true", "useEmailAzp": "true"}
response_data = _token_endpoint_request(
request,
_IAM_IDTOKEN_ENDPOINT.format(signer_email),
body,
access_token=access_token,
use_json=True,
)
try:
id_token = response_data["token"]
except KeyError as caught_exc:
new_exc = exceptions.RefreshError(
"No ID token in response.", response_data, retryable=False
)
raise new_exc from caught_exc
payload = jwt.decode(id_token, verify=False)
expiry = datetime.datetime.utcfromtimestamp(payload["exp"])
return id_token, expiry
def id_token_jwt_grant(request, token_uri, assertion, can_retry=True):
"""Implements the JWT Profile for OAuth 2.0 Authorization Grants, but
requests an OpenID Connect ID Token instead of an access token.
This is a variant on the standard JWT Profile that is currently unique
to Google. This was added for the benefit of authenticating to services
that require ID Tokens instead of access tokens or JWT bearer tokens.
Args:
request (google.auth.transport.Request): A callable used to make
HTTP requests.
token_uri (str): The OAuth 2.0 authorization server's token endpoint
URI.
assertion (str): JWT token signed by a service account. The token's
payload must include a ``target_audience`` claim.
can_retry (bool): Enable or disable request retry behavior.
Returns:
Tuple[str, Optional[datetime], Mapping[str, str]]:
The (encoded) Open ID Connect ID Token, expiration, and additional
data returned by the endpoint.
Raises:
google.auth.exceptions.RefreshError: If the token endpoint returned
an error.
"""
body = {"assertion": assertion, "grant_type": _JWT_GRANT_TYPE}
response_data = _token_endpoint_request(
request,
token_uri,
body,
can_retry=can_retry,
headers={
metrics.API_CLIENT_HEADER: metrics.token_request_id_token_sa_assertion()
},
)
try:
id_token = response_data["id_token"]
except KeyError as caught_exc:
new_exc = exceptions.RefreshError(
"No ID token in response.", response_data, retryable=False
)
raise new_exc from caught_exc
payload = jwt.decode(id_token, verify=False)
expiry = datetime.datetime.utcfromtimestamp(payload["exp"])
return id_token, expiry, response_data
def _handle_refresh_grant_response(response_data, refresh_token):
"""Extract tokens from refresh grant response.
Args:
response_data (Mapping[str, str]): Refresh grant response data.
refresh_token (str): Current refresh token.
Returns:
Tuple[str, str, Optional[datetime], Mapping[str, str]]: The access token,
refresh token, expiration, and additional data returned by the token
endpoint. If response_data doesn't have refresh token, then the current
refresh token will be returned.
Raises:
google.auth.exceptions.RefreshError: If the token endpoint returned
an error.
"""
try:
access_token = response_data["access_token"]
except KeyError as caught_exc:
new_exc = exceptions.RefreshError(
"No access token in response.", response_data, retryable=False
)
raise new_exc from caught_exc
refresh_token = response_data.get("refresh_token", refresh_token)
expiry = _parse_expiry(response_data)
return access_token, refresh_token, expiry, response_data
def refresh_grant(
request,
token_uri,
refresh_token,
client_id,
client_secret,
scopes=None,
rapt_token=None,
can_retry=True,
):
"""Implements the OAuth 2.0 refresh token grant.
For more details, see `rfc678 section 6`_.
Args:
request (google.auth.transport.Request): A callable used to make
HTTP requests.
token_uri (str): The OAuth 2.0 authorizations server's token endpoint
URI.
refresh_token (str): The refresh token to use to get a new access
token.
client_id (str): The OAuth 2.0 application's client ID.
client_secret (str): The Oauth 2.0 appliaction's client secret.
scopes (Optional(Sequence[str])): Scopes to request. If present, all
scopes must be authorized for the refresh token. Useful if refresh
token has a wild card scope (e.g.
'https://www.googleapis.com/auth/any-api').
rapt_token (Optional(str)): The reauth Proof Token.
can_retry (bool): Enable or disable request retry behavior.
Returns:
Tuple[str, str, Optional[datetime], Mapping[str, str]]: The access
token, new or current refresh token, expiration, and additional data
returned by the token endpoint.
Raises:
google.auth.exceptions.RefreshError: If the token endpoint returned
an error.
.. _rfc6748 section 6: https://tools.ietf.org/html/rfc6749#section-6
"""
body = {
"grant_type": _REFRESH_GRANT_TYPE,
"client_id": client_id,
"client_secret": client_secret,
"refresh_token": refresh_token,
}
if scopes:
body["scope"] = " ".join(scopes)
if rapt_token:
body["rapt"] = rapt_token
response_data = _token_endpoint_request(
request, token_uri, body, can_retry=can_retry
)
return _handle_refresh_grant_response(response_data, refresh_token)

View File

@@ -0,0 +1,204 @@
#!/usr/bin/env python
# Copyright 2021 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
""" Challenges for reauthentication.
"""
import abc
import base64
import getpass
import sys
from google.auth import _helpers
from google.auth import exceptions
REAUTH_ORIGIN = "https://accounts.google.com"
SAML_CHALLENGE_MESSAGE = (
"Please run `gcloud auth login` to complete reauthentication with SAML."
)
def get_user_password(text):
"""Get password from user.
Override this function with a different logic if you are using this library
outside a CLI.
Args:
text (str): message for the password prompt.
Returns:
str: password string.
"""
return getpass.getpass(text)
class ReauthChallenge(metaclass=abc.ABCMeta):
"""Base class for reauth challenges."""
@property
@abc.abstractmethod
def name(self): # pragma: NO COVER
"""Returns the name of the challenge."""
raise NotImplementedError("name property must be implemented")
@property
@abc.abstractmethod
def is_locally_eligible(self): # pragma: NO COVER
"""Returns true if a challenge is supported locally on this machine."""
raise NotImplementedError("is_locally_eligible property must be implemented")
@abc.abstractmethod
def obtain_challenge_input(self, metadata): # pragma: NO COVER
"""Performs logic required to obtain credentials and returns it.
Args:
metadata (Mapping): challenge metadata returned in the 'challenges' field in
the initial reauth request. Includes the 'challengeType' field
and other challenge-specific fields.
Returns:
response that will be send to the reauth service as the content of
the 'proposalResponse' field in the request body. Usually a dict
with the keys specific to the challenge. For example,
``{'credential': password}`` for password challenge.
"""
raise NotImplementedError("obtain_challenge_input method must be implemented")
class PasswordChallenge(ReauthChallenge):
"""Challenge that asks for user's password."""
@property
def name(self):
return "PASSWORD"
@property
def is_locally_eligible(self):
return True
@_helpers.copy_docstring(ReauthChallenge)
def obtain_challenge_input(self, unused_metadata):
passwd = get_user_password("Please enter your password:")
if not passwd:
passwd = " " # avoid the server crashing in case of no password :D
return {"credential": passwd}
class SecurityKeyChallenge(ReauthChallenge):
"""Challenge that asks for user's security key touch."""
@property
def name(self):
return "SECURITY_KEY"
@property
def is_locally_eligible(self):
return True
@_helpers.copy_docstring(ReauthChallenge)
def obtain_challenge_input(self, metadata):
try:
import pyu2f.convenience.authenticator # type: ignore
import pyu2f.errors # type: ignore
import pyu2f.model # type: ignore
except ImportError:
raise exceptions.ReauthFailError(
"pyu2f dependency is required to use Security key reauth feature. "
"It can be installed via `pip install pyu2f` or `pip install google-auth[reauth]`."
)
sk = metadata["securityKey"]
challenges = sk["challenges"]
# Read both 'applicationId' and 'relyingPartyId', if they are the same, use
# applicationId, if they are different, use relyingPartyId first and retry
# with applicationId
application_id = sk["applicationId"]
relying_party_id = sk["relyingPartyId"]
if application_id != relying_party_id:
application_parameters = [relying_party_id, application_id]
else:
application_parameters = [application_id]
challenge_data = []
for c in challenges:
kh = c["keyHandle"].encode("ascii")
key = pyu2f.model.RegisteredKey(bytearray(base64.urlsafe_b64decode(kh)))
challenge = c["challenge"].encode("ascii")
challenge = base64.urlsafe_b64decode(challenge)
challenge_data.append({"key": key, "challenge": challenge})
# Track number of tries to suppress error message until all application_parameters
# are tried.
tries = 0
for app_id in application_parameters:
try:
tries += 1
api = pyu2f.convenience.authenticator.CreateCompositeAuthenticator(
REAUTH_ORIGIN
)
response = api.Authenticate(
app_id, challenge_data, print_callback=sys.stderr.write
)
return {"securityKey": response}
except pyu2f.errors.U2FError as e:
if e.code == pyu2f.errors.U2FError.DEVICE_INELIGIBLE:
# Only show error if all app_ids have been tried
if tries == len(application_parameters):
sys.stderr.write("Ineligible security key.\n")
return None
continue
if e.code == pyu2f.errors.U2FError.TIMEOUT:
sys.stderr.write(
"Timed out while waiting for security key touch.\n"
)
else:
raise e
except pyu2f.errors.PluginError as e:
sys.stderr.write("Plugin error: {}.\n".format(e))
continue
except pyu2f.errors.NoDeviceFoundError:
sys.stderr.write("No security key found.\n")
return None
class SamlChallenge(ReauthChallenge):
"""Challenge that asks the users to browse to their ID Providers.
Currently SAML challenge is not supported. When obtaining the challenge
input, exception will be raised to instruct the users to run
`gcloud auth login` for reauthentication.
"""
@property
def name(self):
return "SAML"
@property
def is_locally_eligible(self):
return True
def obtain_challenge_input(self, metadata):
# Magic Arch has not fully supported returning a proper dedirect URL
# for programmatic SAML users today. So we error our here and request
# users to use gcloud to complete a login.
raise exceptions.ReauthSamlChallengeFailError(SAML_CHALLENGE_MESSAGE)
AVAILABLE_CHALLENGES = {
challenge.name: challenge
for challenge in [SecurityKeyChallenge(), PasswordChallenge(), SamlChallenge()]
}

View File

@@ -0,0 +1,637 @@
#!/usr/bin/env python
# Copyright 2016 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""OAuth 2.0 Credentials.
This module provides credentials based on OAuth 2.0 access and refresh tokens.
These credentials usually access resources on behalf of a user (resource
owner).
Specifically, this is intended to use access tokens acquired using the
`Authorization Code grant`_ and can refresh those tokens using a
optional `refresh token`_.
Obtaining the initial access and refresh token is outside of the scope of this
module. Consult `rfc6749 section 4.1`_ for complete details on the
Authorization Code grant flow.
.. _Authorization Code grant: https://tools.ietf.org/html/rfc6749#section-1.3.1
.. _refresh token: https://tools.ietf.org/html/rfc6749#section-6
.. _rfc6749 section 4.1: https://tools.ietf.org/html/rfc6749#section-4.1
"""
from datetime import datetime
import io
import json
import logging
import warnings
from google.auth import _cloud_sdk
from google.auth import _helpers
from google.auth import credentials
from google.auth import exceptions
from google.auth import metrics
from google.oauth2 import reauth
_LOGGER = logging.getLogger(__name__)
# The Google OAuth 2.0 token endpoint. Used for authorized user credentials.
_GOOGLE_OAUTH2_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"
class Credentials(credentials.ReadOnlyScoped, credentials.CredentialsWithQuotaProject):
"""Credentials using OAuth 2.0 access and refresh tokens.
The credentials are considered immutable except the tokens and the token
expiry, which are updated after refresh. If you want to modify the quota
project, use :meth:`with_quota_project` or ::
credentials = credentials.with_quota_project('myproject-123')
Reauth is disabled by default. To enable reauth, set the
`enable_reauth_refresh` parameter to True in the constructor. Note that
reauth feature is intended for gcloud to use only.
If reauth is enabled, `pyu2f` dependency has to be installed in order to use security
key reauth feature. Dependency can be installed via `pip install pyu2f` or `pip install
google-auth[reauth]`.
"""
def __init__(
self,
token,
refresh_token=None,
id_token=None,
token_uri=None,
client_id=None,
client_secret=None,
scopes=None,
default_scopes=None,
quota_project_id=None,
expiry=None,
rapt_token=None,
refresh_handler=None,
enable_reauth_refresh=False,
granted_scopes=None,
trust_boundary=None,
universe_domain=credentials.DEFAULT_UNIVERSE_DOMAIN,
account=None,
):
"""
Args:
token (Optional(str)): The OAuth 2.0 access token. Can be None
if refresh information is provided.
refresh_token (str): The OAuth 2.0 refresh token. If specified,
credentials can be refreshed.
id_token (str): The Open ID Connect ID Token.
token_uri (str): The OAuth 2.0 authorization server's token
endpoint URI. Must be specified for refresh, can be left as
None if the token can not be refreshed.
client_id (str): The OAuth 2.0 client ID. Must be specified for
refresh, can be left as None if the token can not be refreshed.
client_secret(str): The OAuth 2.0 client secret. Must be specified
for refresh, can be left as None if the token can not be
refreshed.
scopes (Sequence[str]): The scopes used to obtain authorization.
This parameter is used by :meth:`has_scopes`. OAuth 2.0
credentials can not request additional scopes after
authorization. The scopes must be derivable from the refresh
token if refresh information is provided (e.g. The refresh
token scopes are a superset of this or contain a wild card
scope like 'https://www.googleapis.com/auth/any-api').
default_scopes (Sequence[str]): Default scopes passed by a
Google client library. Use 'scopes' for user-defined scopes.
quota_project_id (Optional[str]): The project ID used for quota and billing.
This project may be different from the project used to
create the credentials.
rapt_token (Optional[str]): The reauth Proof Token.
refresh_handler (Optional[Callable[[google.auth.transport.Request, Sequence[str]], [str, datetime]]]):
A callable which takes in the HTTP request callable and the list of
OAuth scopes and when called returns an access token string for the
requested scopes and its expiry datetime. This is useful when no
refresh tokens are provided and tokens are obtained by calling
some external process on demand. It is particularly useful for
retrieving downscoped tokens from a token broker.
enable_reauth_refresh (Optional[bool]): Whether reauth refresh flow
should be used. This flag is for gcloud to use only.
granted_scopes (Optional[Sequence[str]]): The scopes that were consented/granted by the user.
This could be different from the requested scopes and it could be empty if granted
and requested scopes were same.
trust_boundary (str): String representation of trust boundary meta.
universe_domain (Optional[str]): The universe domain. The default
universe domain is googleapis.com.
account (Optional[str]): The account associated with the credential.
"""
super(Credentials, self).__init__()
self.token = token
self.expiry = expiry
self._refresh_token = refresh_token
self._id_token = id_token
self._scopes = scopes
self._default_scopes = default_scopes
self._granted_scopes = granted_scopes
self._token_uri = token_uri
self._client_id = client_id
self._client_secret = client_secret
self._quota_project_id = quota_project_id
self._rapt_token = rapt_token
self.refresh_handler = refresh_handler
self._enable_reauth_refresh = enable_reauth_refresh
self._trust_boundary = trust_boundary
self._universe_domain = universe_domain or credentials.DEFAULT_UNIVERSE_DOMAIN
self._account = account or ""
def __getstate__(self):
"""A __getstate__ method must exist for the __setstate__ to be called
This is identical to the default implementation.
See https://docs.python.org/3.7/library/pickle.html#object.__setstate__
"""
state_dict = self.__dict__.copy()
# Remove _refresh_handler function as there are limitations pickling and
# unpickling certain callables (lambda, functools.partial instances)
# because they need to be importable.
# Instead, the refresh_handler setter should be used to repopulate this.
if "_refresh_handler" in state_dict:
del state_dict["_refresh_handler"]
if "_refresh_worker" in state_dict:
del state_dict["_refresh_worker"]
return state_dict
def __setstate__(self, d):
"""Credentials pickled with older versions of the class do not have
all the attributes."""
self.token = d.get("token")
self.expiry = d.get("expiry")
self._refresh_token = d.get("_refresh_token")
self._id_token = d.get("_id_token")
self._scopes = d.get("_scopes")
self._default_scopes = d.get("_default_scopes")
self._granted_scopes = d.get("_granted_scopes")
self._token_uri = d.get("_token_uri")
self._client_id = d.get("_client_id")
self._client_secret = d.get("_client_secret")
self._quota_project_id = d.get("_quota_project_id")
self._rapt_token = d.get("_rapt_token")
self._enable_reauth_refresh = d.get("_enable_reauth_refresh")
self._trust_boundary = d.get("_trust_boundary")
self._universe_domain = (
d.get("_universe_domain") or credentials.DEFAULT_UNIVERSE_DOMAIN
)
# The refresh_handler setter should be used to repopulate this.
self._refresh_handler = None
self._refresh_worker = None
self._use_non_blocking_refresh = d.get("_use_non_blocking_refresh", False)
self._account = d.get("_account", "")
@property
def refresh_token(self):
"""Optional[str]: The OAuth 2.0 refresh token."""
return self._refresh_token
@property
def scopes(self):
"""Optional[str]: The OAuth 2.0 permission scopes."""
return self._scopes
@property
def granted_scopes(self):
"""Optional[Sequence[str]]: The OAuth 2.0 permission scopes that were granted by the user."""
return self._granted_scopes
@property
def token_uri(self):
"""Optional[str]: The OAuth 2.0 authorization server's token endpoint
URI."""
return self._token_uri
@property
def id_token(self):
"""Optional[str]: The Open ID Connect ID Token.
Depending on the authorization server and the scopes requested, this
may be populated when credentials are obtained and updated when
:meth:`refresh` is called. This token is a JWT. It can be verified
and decoded using :func:`google.oauth2.id_token.verify_oauth2_token`.
"""
return self._id_token
@property
def client_id(self):
"""Optional[str]: The OAuth 2.0 client ID."""
return self._client_id
@property
def client_secret(self):
"""Optional[str]: The OAuth 2.0 client secret."""
return self._client_secret
@property
def requires_scopes(self):
"""False: OAuth 2.0 credentials have their scopes set when
the initial token is requested and can not be changed."""
return False
@property
def rapt_token(self):
"""Optional[str]: The reauth Proof Token."""
return self._rapt_token
@property
def refresh_handler(self):
"""Returns the refresh handler if available.
Returns:
Optional[Callable[[google.auth.transport.Request, Sequence[str]], [str, datetime]]]:
The current refresh handler.
"""
return self._refresh_handler
@refresh_handler.setter
def refresh_handler(self, value):
"""Updates the current refresh handler.
Args:
value (Optional[Callable[[google.auth.transport.Request, Sequence[str]], [str, datetime]]]):
The updated value of the refresh handler.
Raises:
TypeError: If the value is not a callable or None.
"""
if not callable(value) and value is not None:
raise TypeError("The provided refresh_handler is not a callable or None.")
self._refresh_handler = value
@property
def account(self):
"""str: The user account associated with the credential. If the account is unknown an empty string is returned."""
return self._account
@_helpers.copy_docstring(credentials.CredentialsWithQuotaProject)
def with_quota_project(self, quota_project_id):
return self.__class__(
self.token,
refresh_token=self.refresh_token,
id_token=self.id_token,
token_uri=self.token_uri,
client_id=self.client_id,
client_secret=self.client_secret,
scopes=self.scopes,
default_scopes=self.default_scopes,
granted_scopes=self.granted_scopes,
quota_project_id=quota_project_id,
rapt_token=self.rapt_token,
enable_reauth_refresh=self._enable_reauth_refresh,
trust_boundary=self._trust_boundary,
universe_domain=self._universe_domain,
account=self._account,
)
@_helpers.copy_docstring(credentials.CredentialsWithTokenUri)
def with_token_uri(self, token_uri):
return self.__class__(
self.token,
refresh_token=self.refresh_token,
id_token=self.id_token,
token_uri=token_uri,
client_id=self.client_id,
client_secret=self.client_secret,
scopes=self.scopes,
default_scopes=self.default_scopes,
granted_scopes=self.granted_scopes,
quota_project_id=self.quota_project_id,
rapt_token=self.rapt_token,
enable_reauth_refresh=self._enable_reauth_refresh,
trust_boundary=self._trust_boundary,
universe_domain=self._universe_domain,
account=self._account,
)
def with_account(self, account):
"""Returns a copy of these credentials with a modified account.
Args:
account (str): The account to set
Returns:
google.oauth2.credentials.Credentials: A new credentials instance.
"""
return self.__class__(
self.token,
refresh_token=self.refresh_token,
id_token=self.id_token,
token_uri=self._token_uri,
client_id=self.client_id,
client_secret=self.client_secret,
scopes=self.scopes,
default_scopes=self.default_scopes,
granted_scopes=self.granted_scopes,
quota_project_id=self.quota_project_id,
rapt_token=self.rapt_token,
enable_reauth_refresh=self._enable_reauth_refresh,
trust_boundary=self._trust_boundary,
universe_domain=self._universe_domain,
account=account,
)
@_helpers.copy_docstring(credentials.CredentialsWithUniverseDomain)
def with_universe_domain(self, universe_domain):
return self.__class__(
self.token,
refresh_token=self.refresh_token,
id_token=self.id_token,
token_uri=self._token_uri,
client_id=self.client_id,
client_secret=self.client_secret,
scopes=self.scopes,
default_scopes=self.default_scopes,
granted_scopes=self.granted_scopes,
quota_project_id=self.quota_project_id,
rapt_token=self.rapt_token,
enable_reauth_refresh=self._enable_reauth_refresh,
trust_boundary=self._trust_boundary,
universe_domain=universe_domain,
account=self._account,
)
def _metric_header_for_usage(self):
return metrics.CRED_TYPE_USER
@_helpers.copy_docstring(credentials.Credentials)
def refresh(self, request):
if self._universe_domain != credentials.DEFAULT_UNIVERSE_DOMAIN:
raise exceptions.RefreshError(
"User credential refresh is only supported in the default "
"googleapis.com universe domain, but the current universe "
"domain is {}. If you created the credential with an access "
"token, it's likely that the provided token is expired now, "
"please update your code with a valid token.".format(
self._universe_domain
)
)
scopes = self._scopes if self._scopes is not None else self._default_scopes
# Use refresh handler if available and no refresh token is
# available. This is useful in general when tokens are obtained by calling
# some external process on demand. It is particularly useful for retrieving
# downscoped tokens from a token broker.
if self._refresh_token is None and self.refresh_handler:
token, expiry = self.refresh_handler(request, scopes=scopes)
# Validate returned data.
if not isinstance(token, str):
raise exceptions.RefreshError(
"The refresh_handler returned token is not a string."
)
if not isinstance(expiry, datetime):
raise exceptions.RefreshError(
"The refresh_handler returned expiry is not a datetime object."
)
if _helpers.utcnow() >= expiry - _helpers.REFRESH_THRESHOLD:
raise exceptions.RefreshError(
"The credentials returned by the refresh_handler are "
"already expired."
)
self.token = token
self.expiry = expiry
return
if (
self._refresh_token is None
or self._token_uri is None
or self._client_id is None
or self._client_secret is None
):
raise exceptions.RefreshError(
"The credentials do not contain the necessary fields need to "
"refresh the access token. You must specify refresh_token, "
"token_uri, client_id, and client_secret."
)
(
access_token,
refresh_token,
expiry,
grant_response,
rapt_token,
) = reauth.refresh_grant(
request,
self._token_uri,
self._refresh_token,
self._client_id,
self._client_secret,
scopes=scopes,
rapt_token=self._rapt_token,
enable_reauth_refresh=self._enable_reauth_refresh,
)
self.token = access_token
self.expiry = expiry
self._refresh_token = refresh_token
self._id_token = grant_response.get("id_token")
self._rapt_token = rapt_token
if scopes and "scope" in grant_response:
requested_scopes = frozenset(scopes)
self._granted_scopes = grant_response["scope"].split()
granted_scopes = frozenset(self._granted_scopes)
scopes_requested_but_not_granted = requested_scopes - granted_scopes
if scopes_requested_but_not_granted:
# User might be presented with unbundled scopes at the time of
# consent. So it is a valid scenario to not have all the requested
# scopes as part of granted scopes but log a warning in case the
# developer wants to debug the scenario.
_LOGGER.warning(
"Not all requested scopes were granted by the "
"authorization server, missing scopes {}.".format(
", ".join(scopes_requested_but_not_granted)
)
)
@classmethod
def from_authorized_user_info(cls, info, scopes=None):
"""Creates a Credentials instance from parsed authorized user info.
Args:
info (Mapping[str, str]): The authorized user info in Google
format.
scopes (Sequence[str]): Optional list of scopes to include in the
credentials.
Returns:
google.oauth2.credentials.Credentials: The constructed
credentials.
Raises:
ValueError: If the info is not in the expected format.
"""
keys_needed = set(("refresh_token", "client_id", "client_secret"))
missing = keys_needed.difference(info.keys())
if missing:
raise ValueError(
"Authorized user info was not in the expected format, missing "
"fields {}.".format(", ".join(missing))
)
# access token expiry (datetime obj); auto-expire if not saved
expiry = info.get("expiry")
if expiry:
expiry = datetime.strptime(
expiry.rstrip("Z").split(".")[0], "%Y-%m-%dT%H:%M:%S"
)
else:
expiry = _helpers.utcnow() - _helpers.REFRESH_THRESHOLD
# process scopes, which needs to be a seq
if scopes is None and "scopes" in info:
scopes = info.get("scopes")
if isinstance(scopes, str):
scopes = scopes.split(" ")
return cls(
token=info.get("token"),
refresh_token=info.get("refresh_token"),
token_uri=_GOOGLE_OAUTH2_TOKEN_ENDPOINT, # always overrides
scopes=scopes,
client_id=info.get("client_id"),
client_secret=info.get("client_secret"),
quota_project_id=info.get("quota_project_id"), # may not exist
expiry=expiry,
rapt_token=info.get("rapt_token"), # may not exist
trust_boundary=info.get("trust_boundary"), # may not exist
universe_domain=info.get("universe_domain"), # may not exist
account=info.get("account", ""), # may not exist
)
@classmethod
def from_authorized_user_file(cls, filename, scopes=None):
"""Creates a Credentials instance from an authorized user json file.
Args:
filename (str): The path to the authorized user json file.
scopes (Sequence[str]): Optional list of scopes to include in the
credentials.
Returns:
google.oauth2.credentials.Credentials: The constructed
credentials.
Raises:
ValueError: If the file is not in the expected format.
"""
with io.open(filename, "r", encoding="utf-8") as json_file:
data = json.load(json_file)
return cls.from_authorized_user_info(data, scopes)
def to_json(self, strip=None):
"""Utility function that creates a JSON representation of a Credentials
object.
Args:
strip (Sequence[str]): Optional list of members to exclude from the
generated JSON.
Returns:
str: A JSON representation of this instance. When converted into
a dictionary, it can be passed to from_authorized_user_info()
to create a new credential instance.
"""
prep = {
"token": self.token,
"refresh_token": self.refresh_token,
"token_uri": self.token_uri,
"client_id": self.client_id,
"client_secret": self.client_secret,
"scopes": self.scopes,
"rapt_token": self.rapt_token,
"universe_domain": self._universe_domain,
"account": self._account,
}
if self.expiry: # flatten expiry timestamp
prep["expiry"] = self.expiry.isoformat() + "Z"
# Remove empty entries (those which are None)
prep = {k: v for k, v in prep.items() if v is not None}
# Remove entries that explicitely need to be removed
if strip is not None:
prep = {k: v for k, v in prep.items() if k not in strip}
return json.dumps(prep)
class UserAccessTokenCredentials(credentials.CredentialsWithQuotaProject):
"""Access token credentials for user account.
Obtain the access token for a given user account or the current active
user account with the ``gcloud auth print-access-token`` command.
Args:
account (Optional[str]): Account to get the access token for. If not
specified, the current active account will be used.
quota_project_id (Optional[str]): The project ID used for quota
and billing.
"""
def __init__(self, account=None, quota_project_id=None):
warnings.warn(
"UserAccessTokenCredentials is deprecated, please use "
"google.oauth2.credentials.Credentials instead. To use "
"that credential type, simply run "
"`gcloud auth application-default login` and let the "
"client libraries pick up the application default credentials."
)
super(UserAccessTokenCredentials, self).__init__()
self._account = account
self._quota_project_id = quota_project_id
def with_account(self, account):
"""Create a new instance with the given account.
Args:
account (str): Account to get the access token for.
Returns:
google.oauth2.credentials.UserAccessTokenCredentials: The created
credentials with the given account.
"""
return self.__class__(account=account, quota_project_id=self._quota_project_id)
@_helpers.copy_docstring(credentials.CredentialsWithQuotaProject)
def with_quota_project(self, quota_project_id):
return self.__class__(account=self._account, quota_project_id=quota_project_id)
def refresh(self, request):
"""Refreshes the access token.
Args:
request (google.auth.transport.Request): This argument is required
by the base class interface but not used in this implementation,
so just set it to `None`.
Raises:
google.auth.exceptions.UserAccessTokenError: If the access token
refresh failed.
"""
self.token = _cloud_sdk.get_auth_access_token(self._account)
@_helpers.copy_docstring(credentials.Credentials)
def before_request(self, request, method, url, headers):
self.refresh(request)
self.apply(headers)

View File

@@ -0,0 +1,369 @@
#!/usr/bin/env python
# Copyright 2021 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""A module that provides functions for handling rapt authentication.
Reauth is a process of obtaining additional authentication (such as password,
security token, etc.) while refreshing OAuth 2.0 credentials for a user.
Credentials that use the Reauth flow must have the reauth scope,
``https://www.googleapis.com/auth/accounts.reauth``.
This module provides a high-level function for executing the Reauth process,
:func:`refresh_grant`, and lower-level helpers for doing the individual
steps of the reauth process.
Those steps are:
1. Obtaining a list of challenges from the reauth server.
2. Running through each challenge and sending the result back to the reauth
server.
3. Refreshing the access token using the returned rapt token.
"""
import sys
from google.auth import exceptions
from google.auth import metrics
from google.oauth2 import _client
from google.oauth2 import challenges
_REAUTH_SCOPE = "https://www.googleapis.com/auth/accounts.reauth"
_REAUTH_API = "https://reauth.googleapis.com/v2/sessions"
_REAUTH_NEEDED_ERROR = "invalid_grant"
_REAUTH_NEEDED_ERROR_INVALID_RAPT = "invalid_rapt"
_REAUTH_NEEDED_ERROR_RAPT_REQUIRED = "rapt_required"
_AUTHENTICATED = "AUTHENTICATED"
_CHALLENGE_REQUIRED = "CHALLENGE_REQUIRED"
_CHALLENGE_PENDING = "CHALLENGE_PENDING"
# Override this global variable to set custom max number of rounds of reauth
# challenges should be run.
RUN_CHALLENGE_RETRY_LIMIT = 5
def is_interactive():
"""Check if we are in an interractive environment.
Override this function with a different logic if you are using this library
outside a CLI.
If the rapt token needs refreshing, the user needs to answer the challenges.
If the user is not in an interractive environment, the challenges can not
be answered and we just wait for timeout for no reason.
Returns:
bool: True if is interactive environment, False otherwise.
"""
return sys.stdin.isatty()
def _get_challenges(
request, supported_challenge_types, access_token, requested_scopes=None
):
"""Does initial request to reauth API to get the challenges.
Args:
request (google.auth.transport.Request): A callable used to make
HTTP requests.
supported_challenge_types (Sequence[str]): list of challenge names
supported by the manager.
access_token (str): Access token with reauth scopes.
requested_scopes (Optional(Sequence[str])): Authorized scopes for the credentials.
Returns:
dict: The response from the reauth API.
"""
body = {"supportedChallengeTypes": supported_challenge_types}
if requested_scopes:
body["oauthScopesForDomainPolicyLookup"] = requested_scopes
metrics_header = {metrics.API_CLIENT_HEADER: metrics.reauth_start()}
return _client._token_endpoint_request(
request,
_REAUTH_API + ":start",
body,
access_token=access_token,
use_json=True,
headers=metrics_header,
)
def _send_challenge_result(
request, session_id, challenge_id, client_input, access_token
):
"""Attempt to refresh access token by sending next challenge result.
Args:
request (google.auth.transport.Request): A callable used to make
HTTP requests.
session_id (str): session id returned by the initial reauth call.
challenge_id (str): challenge id returned by the initial reauth call.
client_input: dict with a challenge-specific client input. For example:
``{'credential': password}`` for password challenge.
access_token (str): Access token with reauth scopes.
Returns:
dict: The response from the reauth API.
"""
body = {
"sessionId": session_id,
"challengeId": challenge_id,
"action": "RESPOND",
"proposalResponse": client_input,
}
metrics_header = {metrics.API_CLIENT_HEADER: metrics.reauth_continue()}
return _client._token_endpoint_request(
request,
_REAUTH_API + "/{}:continue".format(session_id),
body,
access_token=access_token,
use_json=True,
headers=metrics_header,
)
def _run_next_challenge(msg, request, access_token):
"""Get the next challenge from msg and run it.
Args:
msg (dict): Reauth API response body (either from the initial request to
https://reauth.googleapis.com/v2/sessions:start or from sending the
previous challenge response to
https://reauth.googleapis.com/v2/sessions/id:continue)
request (google.auth.transport.Request): A callable used to make
HTTP requests.
access_token (str): reauth access token
Returns:
dict: The response from the reauth API.
Raises:
google.auth.exceptions.ReauthError: if reauth failed.
"""
for challenge in msg["challenges"]:
if challenge["status"] != "READY":
# Skip non-activated challenges.
continue
c = challenges.AVAILABLE_CHALLENGES.get(challenge["challengeType"], None)
if not c:
raise exceptions.ReauthFailError(
"Unsupported challenge type {0}. Supported types: {1}".format(
challenge["challengeType"],
",".join(list(challenges.AVAILABLE_CHALLENGES.keys())),
)
)
if not c.is_locally_eligible:
raise exceptions.ReauthFailError(
"Challenge {0} is not locally eligible".format(
challenge["challengeType"]
)
)
client_input = c.obtain_challenge_input(challenge)
if not client_input:
return None
return _send_challenge_result(
request,
msg["sessionId"],
challenge["challengeId"],
client_input,
access_token,
)
return None
def _obtain_rapt(request, access_token, requested_scopes):
"""Given an http request method and reauth access token, get rapt token.
Args:
request (google.auth.transport.Request): A callable used to make
HTTP requests.
access_token (str): reauth access token
requested_scopes (Sequence[str]): scopes required by the client application
Returns:
str: The rapt token.
Raises:
google.auth.exceptions.ReauthError: if reauth failed
"""
msg = _get_challenges(
request,
list(challenges.AVAILABLE_CHALLENGES.keys()),
access_token,
requested_scopes,
)
if msg["status"] == _AUTHENTICATED:
return msg["encodedProofOfReauthToken"]
for _ in range(0, RUN_CHALLENGE_RETRY_LIMIT):
if not (
msg["status"] == _CHALLENGE_REQUIRED or msg["status"] == _CHALLENGE_PENDING
):
raise exceptions.ReauthFailError(
"Reauthentication challenge failed due to API error: {}".format(
msg["status"]
)
)
if not is_interactive():
raise exceptions.ReauthFailError(
"Reauthentication challenge could not be answered because you are not"
" in an interactive session."
)
msg = _run_next_challenge(msg, request, access_token)
if not msg:
raise exceptions.ReauthFailError("Failed to obtain rapt token.")
if msg["status"] == _AUTHENTICATED:
return msg["encodedProofOfReauthToken"]
# If we got here it means we didn't get authenticated.
raise exceptions.ReauthFailError("Failed to obtain rapt token.")
def get_rapt_token(
request, client_id, client_secret, refresh_token, token_uri, scopes=None
):
"""Given an http request method and refresh_token, get rapt token.
Args:
request (google.auth.transport.Request): A callable used to make
HTTP requests.
client_id (str): client id to get access token for reauth scope.
client_secret (str): client secret for the client_id
refresh_token (str): refresh token to refresh access token
token_uri (str): uri to refresh access token
scopes (Optional(Sequence[str])): scopes required by the client application
Returns:
str: The rapt token.
Raises:
google.auth.exceptions.RefreshError: If reauth failed.
"""
sys.stderr.write("Reauthentication required.\n")
# Get access token for reauth.
access_token, _, _, _ = _client.refresh_grant(
request=request,
client_id=client_id,
client_secret=client_secret,
refresh_token=refresh_token,
token_uri=token_uri,
scopes=[_REAUTH_SCOPE],
)
# Get rapt token from reauth API.
rapt_token = _obtain_rapt(request, access_token, requested_scopes=scopes)
return rapt_token
def refresh_grant(
request,
token_uri,
refresh_token,
client_id,
client_secret,
scopes=None,
rapt_token=None,
enable_reauth_refresh=False,
):
"""Implements the reauthentication flow.
Args:
request (google.auth.transport.Request): A callable used to make
HTTP requests.
token_uri (str): The OAuth 2.0 authorizations server's token endpoint
URI.
refresh_token (str): The refresh token to use to get a new access
token.
client_id (str): The OAuth 2.0 application's client ID.
client_secret (str): The Oauth 2.0 appliaction's client secret.
scopes (Optional(Sequence[str])): Scopes to request. If present, all
scopes must be authorized for the refresh token. Useful if refresh
token has a wild card scope (e.g.
'https://www.googleapis.com/auth/any-api').
rapt_token (Optional(str)): The rapt token for reauth.
enable_reauth_refresh (Optional[bool]): Whether reauth refresh flow
should be used. The default value is False. This option is for
gcloud only, other users should use the default value.
Returns:
Tuple[str, Optional[str], Optional[datetime], Mapping[str, str], str]: The
access token, new refresh token, expiration, the additional data
returned by the token endpoint, and the rapt token.
Raises:
google.auth.exceptions.RefreshError: If the token endpoint returned
an error.
"""
body = {
"grant_type": _client._REFRESH_GRANT_TYPE,
"client_id": client_id,
"client_secret": client_secret,
"refresh_token": refresh_token,
}
if scopes:
body["scope"] = " ".join(scopes)
if rapt_token:
body["rapt"] = rapt_token
metrics_header = {metrics.API_CLIENT_HEADER: metrics.token_request_user()}
response_status_ok, response_data, retryable_error = _client._token_endpoint_request_no_throw(
request, token_uri, body, headers=metrics_header
)
if not response_status_ok and isinstance(response_data, str):
raise exceptions.RefreshError(response_data, retryable=False)
if (
not response_status_ok
and response_data.get("error") == _REAUTH_NEEDED_ERROR
and (
response_data.get("error_subtype") == _REAUTH_NEEDED_ERROR_INVALID_RAPT
or response_data.get("error_subtype") == _REAUTH_NEEDED_ERROR_RAPT_REQUIRED
)
):
if not enable_reauth_refresh:
raise exceptions.RefreshError(
"Reauthentication is needed. Please run `gcloud auth application-default login` to reauthenticate."
)
rapt_token = get_rapt_token(
request, client_id, client_secret, refresh_token, token_uri, scopes=scopes
)
body["rapt"] = rapt_token
(
response_status_ok,
response_data,
retryable_error,
) = _client._token_endpoint_request_no_throw(
request, token_uri, body, headers=metrics_header
)
if not response_status_ok:
_client._handle_error_response(response_data, retryable_error)
return _client._handle_refresh_grant_response(response_data, refresh_token) + (
rapt_token,
)

View File

@@ -0,0 +1,830 @@
#!/usr/bin/env python
# Copyright 2016 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Service Accounts: JSON Web Token (JWT) Profile for OAuth 2.0
This module implements the JWT Profile for OAuth 2.0 Authorization Grants
as defined by `RFC 7523`_ with particular support for how this RFC is
implemented in Google's infrastructure. Google refers to these credentials
as *Service Accounts*.
Service accounts are used for server-to-server communication, such as
interactions between a web application server and a Google service. The
service account belongs to your application instead of to an individual end
user. In contrast to other OAuth 2.0 profiles, no users are involved and your
application "acts" as the service account.
Typically an application uses a service account when the application uses
Google APIs to work with its own data rather than a user's data. For example,
an application that uses Google Cloud Datastore for data persistence would use
a service account to authenticate its calls to the Google Cloud Datastore API.
However, an application that needs to access a user's Drive documents would
use the normal OAuth 2.0 profile.
Additionally, Google Apps domain administrators can grant service accounts
`domain-wide delegation`_ authority to access user data on behalf of users in
the domain.
This profile uses a JWT to acquire an OAuth 2.0 access token. The JWT is used
in place of the usual authorization token returned during the standard
OAuth 2.0 Authorization Code grant. The JWT is only used for this purpose, as
the acquired access token is used as the bearer token when making requests
using these credentials.
This profile differs from normal OAuth 2.0 profile because no user consent
step is required. The use of the private key allows this profile to assert
identity directly.
This profile also differs from the :mod:`google.auth.jwt` authentication
because the JWT credentials use the JWT directly as the bearer token. This
profile instead only uses the JWT to obtain an OAuth 2.0 access token. The
obtained OAuth 2.0 access token is used as the bearer token.
Domain-wide delegation
----------------------
Domain-wide delegation allows a service account to access user data on
behalf of any user in a Google Apps domain without consent from the user.
For example, an application that uses the Google Calendar API to add events to
the calendars of all users in a Google Apps domain would use a service account
to access the Google Calendar API on behalf of users.
The Google Apps administrator must explicitly authorize the service account to
do this. This authorization step is referred to as "delegating domain-wide
authority" to a service account.
You can use domain-wise delegation by creating a set of credentials with a
specific subject using :meth:`~Credentials.with_subject`.
.. _RFC 7523: https://tools.ietf.org/html/rfc7523
"""
import copy
import datetime
from google.auth import _helpers
from google.auth import _service_account_info
from google.auth import credentials
from google.auth import exceptions
from google.auth import jwt
from google.auth import metrics
from google.oauth2 import _client
_DEFAULT_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds
_GOOGLE_OAUTH2_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"
class Credentials(
credentials.Signing,
credentials.Scoped,
credentials.CredentialsWithQuotaProject,
credentials.CredentialsWithTokenUri,
):
"""Service account credentials
Usually, you'll create these credentials with one of the helper
constructors. To create credentials using a Google service account
private key JSON file::
credentials = service_account.Credentials.from_service_account_file(
'service-account.json')
Or if you already have the service account file loaded::
service_account_info = json.load(open('service_account.json'))
credentials = service_account.Credentials.from_service_account_info(
service_account_info)
Both helper methods pass on arguments to the constructor, so you can
specify additional scopes and a subject if necessary::
credentials = service_account.Credentials.from_service_account_file(
'service-account.json',
scopes=['email'],
subject='user@example.com')
The credentials are considered immutable. If you want to modify the scopes
or the subject used for delegation, use :meth:`with_scopes` or
:meth:`with_subject`::
scoped_credentials = credentials.with_scopes(['email'])
delegated_credentials = credentials.with_subject(subject)
To add a quota project, use :meth:`with_quota_project`::
credentials = credentials.with_quota_project('myproject-123')
"""
def __init__(
self,
signer,
service_account_email,
token_uri,
scopes=None,
default_scopes=None,
subject=None,
project_id=None,
quota_project_id=None,
additional_claims=None,
always_use_jwt_access=False,
universe_domain=credentials.DEFAULT_UNIVERSE_DOMAIN,
trust_boundary=None,
):
"""
Args:
signer (google.auth.crypt.Signer): The signer used to sign JWTs.
service_account_email (str): The service account's email.
scopes (Sequence[str]): User-defined scopes to request during the
authorization grant.
default_scopes (Sequence[str]): Default scopes passed by a
Google client library. Use 'scopes' for user-defined scopes.
token_uri (str): The OAuth 2.0 Token URI.
subject (str): For domain-wide delegation, the email address of the
user to for which to request delegated access.
project_id (str): Project ID associated with the service account
credential.
quota_project_id (Optional[str]): The project ID used for quota and
billing.
additional_claims (Mapping[str, str]): Any additional claims for
the JWT assertion used in the authorization grant.
always_use_jwt_access (Optional[bool]): Whether self signed JWT should
be always used.
universe_domain (str): The universe domain. The default
universe domain is googleapis.com. For default value self
signed jwt is used for token refresh.
trust_boundary (str): String representation of trust boundary meta.
.. note:: Typically one of the helper constructors
:meth:`from_service_account_file` or
:meth:`from_service_account_info` are used instead of calling the
constructor directly.
"""
super(Credentials, self).__init__()
self._scopes = scopes
self._default_scopes = default_scopes
self._signer = signer
self._service_account_email = service_account_email
self._subject = subject
self._project_id = project_id
self._quota_project_id = quota_project_id
self._token_uri = token_uri
self._always_use_jwt_access = always_use_jwt_access
self._universe_domain = universe_domain or credentials.DEFAULT_UNIVERSE_DOMAIN
if universe_domain != credentials.DEFAULT_UNIVERSE_DOMAIN:
self._always_use_jwt_access = True
self._jwt_credentials = None
if additional_claims is not None:
self._additional_claims = additional_claims
else:
self._additional_claims = {}
self._trust_boundary = {"locations": [], "encoded_locations": "0x0"}
@classmethod
def _from_signer_and_info(cls, signer, info, **kwargs):
"""Creates a Credentials instance from a signer and service account
info.
Args:
signer (google.auth.crypt.Signer): The signer used to sign JWTs.
info (Mapping[str, str]): The service account info.
kwargs: Additional arguments to pass to the constructor.
Returns:
google.auth.jwt.Credentials: The constructed credentials.
Raises:
ValueError: If the info is not in the expected format.
"""
return cls(
signer,
service_account_email=info["client_email"],
token_uri=info["token_uri"],
project_id=info.get("project_id"),
universe_domain=info.get(
"universe_domain", credentials.DEFAULT_UNIVERSE_DOMAIN
),
trust_boundary=info.get("trust_boundary"),
**kwargs
)
@classmethod
def from_service_account_info(cls, info, **kwargs):
"""Creates a Credentials instance from parsed service account info.
Args:
info (Mapping[str, str]): The service account info in Google
format.
kwargs: Additional arguments to pass to the constructor.
Returns:
google.auth.service_account.Credentials: The constructed
credentials.
Raises:
ValueError: If the info is not in the expected format.
"""
signer = _service_account_info.from_dict(
info, require=["client_email", "token_uri"]
)
return cls._from_signer_and_info(signer, info, **kwargs)
@classmethod
def from_service_account_file(cls, filename, **kwargs):
"""Creates a Credentials instance from a service account json file.
Args:
filename (str): The path to the service account json file.
kwargs: Additional arguments to pass to the constructor.
Returns:
google.auth.service_account.Credentials: The constructed
credentials.
"""
info, signer = _service_account_info.from_filename(
filename, require=["client_email", "token_uri"]
)
return cls._from_signer_and_info(signer, info, **kwargs)
@property
def service_account_email(self):
"""The service account email."""
return self._service_account_email
@property
def project_id(self):
"""Project ID associated with this credential."""
return self._project_id
@property
def requires_scopes(self):
"""Checks if the credentials requires scopes.
Returns:
bool: True if there are no scopes set otherwise False.
"""
return True if not self._scopes else False
def _make_copy(self):
cred = self.__class__(
self._signer,
service_account_email=self._service_account_email,
scopes=copy.copy(self._scopes),
default_scopes=copy.copy(self._default_scopes),
token_uri=self._token_uri,
subject=self._subject,
project_id=self._project_id,
quota_project_id=self._quota_project_id,
additional_claims=self._additional_claims.copy(),
always_use_jwt_access=self._always_use_jwt_access,
universe_domain=self._universe_domain,
)
return cred
@_helpers.copy_docstring(credentials.Scoped)
def with_scopes(self, scopes, default_scopes=None):
cred = self._make_copy()
cred._scopes = scopes
cred._default_scopes = default_scopes
return cred
def with_always_use_jwt_access(self, always_use_jwt_access):
"""Create a copy of these credentials with the specified always_use_jwt_access value.
Args:
always_use_jwt_access (bool): Whether always use self signed JWT or not.
Returns:
google.auth.service_account.Credentials: A new credentials
instance.
Raises:
google.auth.exceptions.InvalidValue: If the universe domain is not
default and always_use_jwt_access is False.
"""
cred = self._make_copy()
if (
cred._universe_domain != credentials.DEFAULT_UNIVERSE_DOMAIN
and not always_use_jwt_access
):
raise exceptions.InvalidValue(
"always_use_jwt_access should be True for non-default universe domain"
)
cred._always_use_jwt_access = always_use_jwt_access
return cred
@_helpers.copy_docstring(credentials.CredentialsWithUniverseDomain)
def with_universe_domain(self, universe_domain):
cred = self._make_copy()
cred._universe_domain = universe_domain
if universe_domain != credentials.DEFAULT_UNIVERSE_DOMAIN:
cred._always_use_jwt_access = True
return cred
def with_subject(self, subject):
"""Create a copy of these credentials with the specified subject.
Args:
subject (str): The subject claim.
Returns:
google.auth.service_account.Credentials: A new credentials
instance.
"""
cred = self._make_copy()
cred._subject = subject
return cred
def with_claims(self, additional_claims):
"""Returns a copy of these credentials with modified claims.
Args:
additional_claims (Mapping[str, str]): Any additional claims for
the JWT payload. This will be merged with the current
additional claims.
Returns:
google.auth.service_account.Credentials: A new credentials
instance.
"""
new_additional_claims = copy.deepcopy(self._additional_claims)
new_additional_claims.update(additional_claims or {})
cred = self._make_copy()
cred._additional_claims = new_additional_claims
return cred
@_helpers.copy_docstring(credentials.CredentialsWithQuotaProject)
def with_quota_project(self, quota_project_id):
cred = self._make_copy()
cred._quota_project_id = quota_project_id
return cred
@_helpers.copy_docstring(credentials.CredentialsWithTokenUri)
def with_token_uri(self, token_uri):
cred = self._make_copy()
cred._token_uri = token_uri
return cred
def _make_authorization_grant_assertion(self):
"""Create the OAuth 2.0 assertion.
This assertion is used during the OAuth 2.0 grant to acquire an
access token.
Returns:
bytes: The authorization grant assertion.
"""
now = _helpers.utcnow()
lifetime = datetime.timedelta(seconds=_DEFAULT_TOKEN_LIFETIME_SECS)
expiry = now + lifetime
payload = {
"iat": _helpers.datetime_to_secs(now),
"exp": _helpers.datetime_to_secs(expiry),
# The issuer must be the service account email.
"iss": self._service_account_email,
# The audience must be the auth token endpoint's URI
"aud": _GOOGLE_OAUTH2_TOKEN_ENDPOINT,
"scope": _helpers.scopes_to_string(self._scopes or ()),
}
payload.update(self._additional_claims)
# The subject can be a user email for domain-wide delegation.
if self._subject:
payload.setdefault("sub", self._subject)
token = jwt.encode(self._signer, payload)
return token
def _use_self_signed_jwt(self):
# Since domain wide delegation doesn't work with self signed JWT. If
# subject exists, then we should not use self signed JWT.
return self._subject is None and self._jwt_credentials is not None
def _metric_header_for_usage(self):
if self._use_self_signed_jwt():
return metrics.CRED_TYPE_SA_JWT
return metrics.CRED_TYPE_SA_ASSERTION
@_helpers.copy_docstring(credentials.Credentials)
def refresh(self, request):
if self._always_use_jwt_access and not self._jwt_credentials:
# If self signed jwt should be used but jwt credential is not
# created, try to create one with scopes
self._create_self_signed_jwt(None)
if (
self._universe_domain != credentials.DEFAULT_UNIVERSE_DOMAIN
and self._subject
):
raise exceptions.RefreshError(
"domain wide delegation is not supported for non-default universe domain"
)
if self._use_self_signed_jwt():
self._jwt_credentials.refresh(request)
self.token = self._jwt_credentials.token.decode()
self.expiry = self._jwt_credentials.expiry
else:
assertion = self._make_authorization_grant_assertion()
access_token, expiry, _ = _client.jwt_grant(
request, self._token_uri, assertion
)
self.token = access_token
self.expiry = expiry
def _create_self_signed_jwt(self, audience):
"""Create a self-signed JWT from the credentials if requirements are met.
Args:
audience (str): The service URL. ``https://[API_ENDPOINT]/``
"""
# https://google.aip.dev/auth/4111
if self._always_use_jwt_access:
if self._scopes:
additional_claims = {"scope": " ".join(self._scopes)}
if (
self._jwt_credentials is None
or self._jwt_credentials.additional_claims != additional_claims
):
self._jwt_credentials = jwt.Credentials.from_signing_credentials(
self, None, additional_claims=additional_claims
)
elif audience:
if (
self._jwt_credentials is None
or self._jwt_credentials._audience != audience
):
self._jwt_credentials = jwt.Credentials.from_signing_credentials(
self, audience
)
elif self._default_scopes:
additional_claims = {"scope": " ".join(self._default_scopes)}
if (
self._jwt_credentials is None
or additional_claims != self._jwt_credentials.additional_claims
):
self._jwt_credentials = jwt.Credentials.from_signing_credentials(
self, None, additional_claims=additional_claims
)
elif not self._scopes and audience:
self._jwt_credentials = jwt.Credentials.from_signing_credentials(
self, audience
)
@_helpers.copy_docstring(credentials.Signing)
def sign_bytes(self, message):
return self._signer.sign(message)
@property # type: ignore
@_helpers.copy_docstring(credentials.Signing)
def signer(self):
return self._signer
@property # type: ignore
@_helpers.copy_docstring(credentials.Signing)
def signer_email(self):
return self._service_account_email
class IDTokenCredentials(
credentials.Signing,
credentials.CredentialsWithQuotaProject,
credentials.CredentialsWithTokenUri,
):
"""Open ID Connect ID Token-based service account credentials.
These credentials are largely similar to :class:`.Credentials`, but instead
of using an OAuth 2.0 Access Token as the bearer token, they use an Open
ID Connect ID Token as the bearer token. These credentials are useful when
communicating to services that require ID Tokens and can not accept access
tokens.
Usually, you'll create these credentials with one of the helper
constructors. To create credentials using a Google service account
private key JSON file::
credentials = (
service_account.IDTokenCredentials.from_service_account_file(
'service-account.json'))
Or if you already have the service account file loaded::
service_account_info = json.load(open('service_account.json'))
credentials = (
service_account.IDTokenCredentials.from_service_account_info(
service_account_info))
Both helper methods pass on arguments to the constructor, so you can
specify additional scopes and a subject if necessary::
credentials = (
service_account.IDTokenCredentials.from_service_account_file(
'service-account.json',
scopes=['email'],
subject='user@example.com'))
The credentials are considered immutable. If you want to modify the scopes
or the subject used for delegation, use :meth:`with_scopes` or
:meth:`with_subject`::
scoped_credentials = credentials.with_scopes(['email'])
delegated_credentials = credentials.with_subject(subject)
"""
def __init__(
self,
signer,
service_account_email,
token_uri,
target_audience,
additional_claims=None,
quota_project_id=None,
universe_domain=credentials.DEFAULT_UNIVERSE_DOMAIN,
):
"""
Args:
signer (google.auth.crypt.Signer): The signer used to sign JWTs.
service_account_email (str): The service account's email.
token_uri (str): The OAuth 2.0 Token URI.
target_audience (str): The intended audience for these credentials,
used when requesting the ID Token. The ID Token's ``aud`` claim
will be set to this string.
additional_claims (Mapping[str, str]): Any additional claims for
the JWT assertion used in the authorization grant.
quota_project_id (Optional[str]): The project ID used for quota and billing.
universe_domain (str): The universe domain. The default
universe domain is googleapis.com. For default value IAM ID
token endponint is used for token refresh. Note that
iam.serviceAccountTokenCreator role is required to use the IAM
endpoint.
.. note:: Typically one of the helper constructors
:meth:`from_service_account_file` or
:meth:`from_service_account_info` are used instead of calling the
constructor directly.
"""
super(IDTokenCredentials, self).__init__()
self._signer = signer
self._service_account_email = service_account_email
self._token_uri = token_uri
self._target_audience = target_audience
self._quota_project_id = quota_project_id
self._use_iam_endpoint = False
if not universe_domain:
self._universe_domain = credentials.DEFAULT_UNIVERSE_DOMAIN
else:
self._universe_domain = universe_domain
if universe_domain != credentials.DEFAULT_UNIVERSE_DOMAIN:
self._use_iam_endpoint = True
if additional_claims is not None:
self._additional_claims = additional_claims
else:
self._additional_claims = {}
@classmethod
def _from_signer_and_info(cls, signer, info, **kwargs):
"""Creates a credentials instance from a signer and service account
info.
Args:
signer (google.auth.crypt.Signer): The signer used to sign JWTs.
info (Mapping[str, str]): The service account info.
kwargs: Additional arguments to pass to the constructor.
Returns:
google.auth.jwt.IDTokenCredentials: The constructed credentials.
Raises:
ValueError: If the info is not in the expected format.
"""
kwargs.setdefault("service_account_email", info["client_email"])
kwargs.setdefault("token_uri", info["token_uri"])
if "universe_domain" in info:
kwargs["universe_domain"] = info["universe_domain"]
return cls(signer, **kwargs)
@classmethod
def from_service_account_info(cls, info, **kwargs):
"""Creates a credentials instance from parsed service account info.
Args:
info (Mapping[str, str]): The service account info in Google
format.
kwargs: Additional arguments to pass to the constructor.
Returns:
google.auth.service_account.IDTokenCredentials: The constructed
credentials.
Raises:
ValueError: If the info is not in the expected format.
"""
signer = _service_account_info.from_dict(
info, require=["client_email", "token_uri"]
)
return cls._from_signer_and_info(signer, info, **kwargs)
@classmethod
def from_service_account_file(cls, filename, **kwargs):
"""Creates a credentials instance from a service account json file.
Args:
filename (str): The path to the service account json file.
kwargs: Additional arguments to pass to the constructor.
Returns:
google.auth.service_account.IDTokenCredentials: The constructed
credentials.
"""
info, signer = _service_account_info.from_filename(
filename, require=["client_email", "token_uri"]
)
return cls._from_signer_and_info(signer, info, **kwargs)
def _make_copy(self):
cred = self.__class__(
self._signer,
service_account_email=self._service_account_email,
token_uri=self._token_uri,
target_audience=self._target_audience,
additional_claims=self._additional_claims.copy(),
quota_project_id=self.quota_project_id,
universe_domain=self._universe_domain,
)
# _use_iam_endpoint is not exposed in the constructor
cred._use_iam_endpoint = self._use_iam_endpoint
return cred
def with_target_audience(self, target_audience):
"""Create a copy of these credentials with the specified target
audience.
Args:
target_audience (str): The intended audience for these credentials,
used when requesting the ID Token.
Returns:
google.auth.service_account.IDTokenCredentials: A new credentials
instance.
"""
cred = self._make_copy()
cred._target_audience = target_audience
return cred
def _with_use_iam_endpoint(self, use_iam_endpoint):
"""Create a copy of these credentials with the use_iam_endpoint value.
Args:
use_iam_endpoint (bool): If True, IAM generateIdToken endpoint will
be used instead of the token_uri. Note that
iam.serviceAccountTokenCreator role is required to use the IAM
endpoint. The default value is False. This feature is currently
experimental and subject to change without notice.
Returns:
google.auth.service_account.IDTokenCredentials: A new credentials
instance.
Raises:
google.auth.exceptions.InvalidValue: If the universe domain is not
default and use_iam_endpoint is False.
"""
cred = self._make_copy()
if (
cred._universe_domain != credentials.DEFAULT_UNIVERSE_DOMAIN
and not use_iam_endpoint
):
raise exceptions.InvalidValue(
"use_iam_endpoint should be True for non-default universe domain"
)
cred._use_iam_endpoint = use_iam_endpoint
return cred
@_helpers.copy_docstring(credentials.CredentialsWithQuotaProject)
def with_quota_project(self, quota_project_id):
cred = self._make_copy()
cred._quota_project_id = quota_project_id
return cred
@_helpers.copy_docstring(credentials.CredentialsWithTokenUri)
def with_token_uri(self, token_uri):
cred = self._make_copy()
cred._token_uri = token_uri
return cred
def _make_authorization_grant_assertion(self):
"""Create the OAuth 2.0 assertion.
This assertion is used during the OAuth 2.0 grant to acquire an
ID token.
Returns:
bytes: The authorization grant assertion.
"""
now = _helpers.utcnow()
lifetime = datetime.timedelta(seconds=_DEFAULT_TOKEN_LIFETIME_SECS)
expiry = now + lifetime
payload = {
"iat": _helpers.datetime_to_secs(now),
"exp": _helpers.datetime_to_secs(expiry),
# The issuer must be the service account email.
"iss": self.service_account_email,
# The audience must be the auth token endpoint's URI
"aud": _GOOGLE_OAUTH2_TOKEN_ENDPOINT,
# The target audience specifies which service the ID token is
# intended for.
"target_audience": self._target_audience,
}
payload.update(self._additional_claims)
token = jwt.encode(self._signer, payload)
return token
def _refresh_with_iam_endpoint(self, request):
"""Use IAM generateIdToken endpoint to obtain an ID token.
It works as follows:
1. First we create a self signed jwt with
https://www.googleapis.com/auth/iam being the scope.
2. Next we use the self signed jwt as the access token, and make a POST
request to IAM generateIdToken endpoint. The request body is:
{
"audience": self._target_audience,
"includeEmail": "true",
"useEmailAzp": "true",
}
If the request is succesfully, it will return {"token":"the ID token"},
and we can extract the ID token and compute its expiry.
"""
jwt_credentials = jwt.Credentials.from_signing_credentials(
self,
None,
additional_claims={"scope": "https://www.googleapis.com/auth/iam"},
)
jwt_credentials.refresh(request)
self.token, self.expiry = _client.call_iam_generate_id_token_endpoint(
request,
self.signer_email,
self._target_audience,
jwt_credentials.token.decode(),
)
@_helpers.copy_docstring(credentials.Credentials)
def refresh(self, request):
if self._use_iam_endpoint:
self._refresh_with_iam_endpoint(request)
else:
assertion = self._make_authorization_grant_assertion()
access_token, expiry, _ = _client.id_token_jwt_grant(
request, self._token_uri, assertion
)
self.token = access_token
self.expiry = expiry
@property
def service_account_email(self):
"""The service account email."""
return self._service_account_email
@_helpers.copy_docstring(credentials.Signing)
def sign_bytes(self, message):
return self._signer.sign(message)
@property # type: ignore
@_helpers.copy_docstring(credentials.Signing)
def signer(self):
return self._signer
@property # type: ignore
@_helpers.copy_docstring(credentials.Signing)
def signer_email(self):
return self._service_account_email

View File

@@ -0,0 +1,177 @@
#!/usr/bin/env python
# Copyright 2020 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""OAuth 2.0 Token Exchange Spec.
This module defines a token exchange utility based on the `OAuth 2.0 Token
Exchange`_ spec. This will be mainly used to exchange external credentials
for GCP access tokens in workload identity pools to access Google APIs.
The implementation will support various types of client authentication as
allowed in the spec.
A deviation on the spec will be for additional Google specific options that
cannot be easily mapped to parameters defined in the RFC.
The returned dictionary response will be based on the `rfc8693 section 2.2.1`_
spec JSON response.
.. _OAuth 2.0 Token Exchange: https://tools.ietf.org/html/rfc8693
.. _rfc8693 section 2.2.1: https://tools.ietf.org/html/rfc8693#section-2.2.1
"""
import http.client as http_client
import json
import urllib
from google.oauth2 import utils
_URLENCODED_HEADERS = {"Content-Type": "application/x-www-form-urlencoded"}
class Client(utils.OAuthClientAuthHandler):
"""Implements the OAuth 2.0 token exchange spec based on
https://tools.ietf.org/html/rfc8693.
"""
def __init__(self, token_exchange_endpoint, client_authentication=None):
"""Initializes an STS client instance.
Args:
token_exchange_endpoint (str): The token exchange endpoint.
client_authentication (Optional(google.oauth2.oauth2_utils.ClientAuthentication)):
The optional OAuth client authentication credentials if available.
"""
super(Client, self).__init__(client_authentication)
self._token_exchange_endpoint = token_exchange_endpoint
def _make_request(self, request, headers, request_body):
# Initialize request headers.
request_headers = _URLENCODED_HEADERS.copy()
# Inject additional headers.
if headers:
for k, v in dict(headers).items():
request_headers[k] = v
# Apply OAuth client authentication.
self.apply_client_authentication_options(request_headers, request_body)
# Execute request.
response = request(
url=self._token_exchange_endpoint,
method="POST",
headers=request_headers,
body=urllib.parse.urlencode(request_body).encode("utf-8"),
)
response_body = (
response.data.decode("utf-8")
if hasattr(response.data, "decode")
else response.data
)
# If non-200 response received, translate to OAuthError exception.
if response.status != http_client.OK:
utils.handle_error_response(response_body)
response_data = json.loads(response_body)
# Return successful response.
return response_data
def exchange_token(
self,
request,
grant_type,
subject_token,
subject_token_type,
resource=None,
audience=None,
scopes=None,
requested_token_type=None,
actor_token=None,
actor_token_type=None,
additional_options=None,
additional_headers=None,
):
"""Exchanges the provided token for another type of token based on the
rfc8693 spec.
Args:
request (google.auth.transport.Request): A callable used to make
HTTP requests.
grant_type (str): The OAuth 2.0 token exchange grant type.
subject_token (str): The OAuth 2.0 token exchange subject token.
subject_token_type (str): The OAuth 2.0 token exchange subject token type.
resource (Optional[str]): The optional OAuth 2.0 token exchange resource field.
audience (Optional[str]): The optional OAuth 2.0 token exchange audience field.
scopes (Optional[Sequence[str]]): The optional list of scopes to use.
requested_token_type (Optional[str]): The optional OAuth 2.0 token exchange requested
token type.
actor_token (Optional[str]): The optional OAuth 2.0 token exchange actor token.
actor_token_type (Optional[str]): The optional OAuth 2.0 token exchange actor token type.
additional_options (Optional[Mapping[str, str]]): The optional additional
non-standard Google specific options.
additional_headers (Optional[Mapping[str, str]]): The optional additional
headers to pass to the token exchange endpoint.
Returns:
Mapping[str, str]: The token exchange JSON-decoded response data containing
the requested token and its expiration time.
Raises:
google.auth.exceptions.OAuthError: If the token endpoint returned
an error.
"""
# Initialize request body.
request_body = {
"grant_type": grant_type,
"resource": resource,
"audience": audience,
"scope": " ".join(scopes or []),
"requested_token_type": requested_token_type,
"subject_token": subject_token,
"subject_token_type": subject_token_type,
"actor_token": actor_token,
"actor_token_type": actor_token_type,
"options": None,
}
# Add additional non-standard options.
if additional_options:
request_body["options"] = urllib.parse.quote(json.dumps(additional_options))
# Remove empty fields in request body.
for k, v in dict(request_body).items():
if v is None or v == "":
del request_body[k]
return self._make_request(request, additional_headers, request_body)
def refresh_token(self, request, refresh_token):
"""Exchanges a refresh token for an access token based on the
RFC6749 spec.
Args:
request (google.auth.transport.Request): A callable used to make
HTTP requests.
subject_token (str): The OAuth 2.0 refresh token.
"""
return self._make_request(
request,
None,
{"grant_type": "refresh_token", "refresh_token": refresh_token},
)

View File

@@ -0,0 +1,169 @@
#!/usr/bin/env python
# Copyright 2020 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""OAuth 2.0 Utilities.
This module provides implementations for various OAuth 2.0 utilities.
This includes `OAuth error handling`_ and
`Client authentication for OAuth flows`_.
OAuth error handling
--------------------
This will define interfaces for handling OAuth related error responses as
stated in `RFC 6749 section 5.2`_.
This will include a common function to convert these HTTP error responses to a
:class:`google.auth.exceptions.OAuthError` exception.
Client authentication for OAuth flows
-------------------------------------
We introduce an interface for defining client authentication credentials based
on `RFC 6749 section 2.3.1`_. This will expose the following
capabilities:
* Ability to support basic authentication via request header.
* Ability to support bearer token authentication via request header.
* Ability to support client ID / secret authentication via request body.
.. _RFC 6749 section 2.3.1: https://tools.ietf.org/html/rfc6749#section-2.3.1
.. _RFC 6749 section 5.2: https://tools.ietf.org/html/rfc6749#section-5.2
"""
import abc
import base64
import enum
import json
from google.auth import exceptions
# OAuth client authentication based on
# https://tools.ietf.org/html/rfc6749#section-2.3.
class ClientAuthType(enum.Enum):
basic = 1
request_body = 2
class ClientAuthentication(object):
"""Defines the client authentication credentials for basic and request-body
types based on https://tools.ietf.org/html/rfc6749#section-2.3.1.
"""
def __init__(self, client_auth_type, client_id, client_secret=None):
"""Instantiates a client authentication object containing the client ID
and secret credentials for basic and response-body auth.
Args:
client_auth_type (google.oauth2.oauth_utils.ClientAuthType): The
client authentication type.
client_id (str): The client ID.
client_secret (Optional[str]): The client secret.
"""
self.client_auth_type = client_auth_type
self.client_id = client_id
self.client_secret = client_secret
class OAuthClientAuthHandler(metaclass=abc.ABCMeta):
"""Abstract class for handling client authentication in OAuth-based
operations.
"""
def __init__(self, client_authentication=None):
"""Instantiates an OAuth client authentication handler.
Args:
client_authentication (Optional[google.oauth2.utils.ClientAuthentication]):
The OAuth client authentication credentials if available.
"""
super(OAuthClientAuthHandler, self).__init__()
self._client_authentication = client_authentication
def apply_client_authentication_options(
self, headers, request_body=None, bearer_token=None
):
"""Applies client authentication on the OAuth request's headers or POST
body.
Args:
headers (Mapping[str, str]): The HTTP request header.
request_body (Optional[Mapping[str, str]]): The HTTP request body
dictionary. For requests that do not support request body, this
is None and will be ignored.
bearer_token (Optional[str]): The optional bearer token.
"""
# Inject authenticated header.
self._inject_authenticated_headers(headers, bearer_token)
# Inject authenticated request body.
if bearer_token is None:
self._inject_authenticated_request_body(request_body)
def _inject_authenticated_headers(self, headers, bearer_token=None):
if bearer_token is not None:
headers["Authorization"] = "Bearer %s" % bearer_token
elif (
self._client_authentication is not None
and self._client_authentication.client_auth_type is ClientAuthType.basic
):
username = self._client_authentication.client_id
password = self._client_authentication.client_secret or ""
credentials = base64.b64encode(
("%s:%s" % (username, password)).encode()
).decode()
headers["Authorization"] = "Basic %s" % credentials
def _inject_authenticated_request_body(self, request_body):
if (
self._client_authentication is not None
and self._client_authentication.client_auth_type
is ClientAuthType.request_body
):
if request_body is None:
raise exceptions.OAuthError(
"HTTP request does not support request-body"
)
else:
request_body["client_id"] = self._client_authentication.client_id
request_body["client_secret"] = (
self._client_authentication.client_secret or ""
)
def handle_error_response(response_body):
"""Translates an error response from an OAuth operation into an
OAuthError exception.
Args:
response_body (str): The decoded response data.
Raises:
google.auth.exceptions.OAuthError
"""
try:
error_components = []
error_data = json.loads(response_body)
error_components.append("Error code {}".format(error_data["error"]))
if "error_description" in error_data:
error_components.append(": {}".format(error_data["error_description"]))
if "error_uri" in error_data:
error_components.append(" - {}".format(error_data["error_uri"]))
error_details = "".join(error_components)
# If no details could be extracted, use the response data.
except (KeyError, ValueError):
error_details = response_body
raise exceptions.OAuthError(error_details, response_body)