chore: 添加虚拟环境到仓库
- 添加 backend_service/venv 虚拟环境 - 包含所有Python依赖包 - 注意:虚拟环境约393MB,包含12655个文件
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,625 @@
|
||||
# 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 credentials
|
||||
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"
|
||||
|
||||
|
||||
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)
|
||||
|
||||
response_data = {}
|
||||
retryable_error = False
|
||||
|
||||
retries = _exponential_backoff.ExponentialBackoff()
|
||||
for _ in retries:
|
||||
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
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
if not can_retry or not retryable_error:
|
||||
return False, 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,
|
||||
iam_id_token_endpoint,
|
||||
signer_email,
|
||||
audience,
|
||||
access_token,
|
||||
universe_domain=credentials.DEFAULT_UNIVERSE_DOMAIN,
|
||||
):
|
||||
"""Call iam.generateIdToken endpoint to get ID token.
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): A callable used to make
|
||||
HTTP requests.
|
||||
iam_id_token_endpoint (str): The IAM ID token endpoint to use.
|
||||
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.
|
||||
universe_domain (str): The universe domain for the request. The
|
||||
default is ``googleapis.com``.
|
||||
|
||||
Returns:
|
||||
Tuple[str, datetime]: The ID token and expiration.
|
||||
"""
|
||||
body = {"audience": audience, "includeEmail": "true", "useEmailAzp": "true"}
|
||||
|
||||
response_data = _token_endpoint_request(
|
||||
request,
|
||||
iam_id_token_endpoint.replace(
|
||||
credentials.DEFAULT_UNIVERSE_DOMAIN, universe_domain
|
||||
).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)
|
||||
|
||||
|
||||
def _lookup_trust_boundary(request, url, headers=None):
|
||||
"""Implements the global lookup of a credential trust boundary.
|
||||
For the lookup, we send a request to the global lookup endpoint and then
|
||||
parse the response. Service account credentials, workload identity
|
||||
pools and workforce pools implementation may have trust boundaries configured.
|
||||
Args:
|
||||
request (google.auth.transport.Request): A callable used to make
|
||||
HTTP requests.
|
||||
url (str): The trust boundary lookup url.
|
||||
headers (Optional[Mapping[str, str]]): The headers for the request.
|
||||
Returns:
|
||||
Mapping[str,list|str]: A dictionary containing
|
||||
"locations" as a list of allowed locations as strings and
|
||||
"encodedLocations" as a hex string.
|
||||
e.g:
|
||||
{
|
||||
"locations": [
|
||||
"us-central1", "us-east1", "europe-west1", "asia-east1"
|
||||
],
|
||||
"encodedLocations": "0xA30"
|
||||
}
|
||||
If the credential is not set up with explicit trust boundaries, a trust boundary
|
||||
of "all" will be returned as a default response.
|
||||
{
|
||||
"locations": [],
|
||||
"encodedLocations": "0x0"
|
||||
}
|
||||
Raises:
|
||||
exceptions.RefreshError: If the response status code is not 200.
|
||||
exceptions.MalformedError: If the response is not in a valid format.
|
||||
"""
|
||||
|
||||
response_data = _lookup_trust_boundary_request(request, url, headers=headers)
|
||||
# In case of no-op response, the "locations" list may or may not be present as an empty list.
|
||||
if "encodedLocations" not in response_data:
|
||||
raise exceptions.MalformedError(
|
||||
"Invalid trust boundary info: {}".format(response_data)
|
||||
)
|
||||
return response_data
|
||||
|
||||
|
||||
def _lookup_trust_boundary_request(request, url, can_retry=True, headers=None):
|
||||
"""Makes a request to the trust boundary lookup endpoint.
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): A callable used to make
|
||||
HTTP requests.
|
||||
url (str): The trust boundary lookup url.
|
||||
can_retry (bool): Enable or disable request retry behavior. Defaults to true.
|
||||
headers (Optional[Mapping[str, str]]): The headers for the request.
|
||||
|
||||
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 = _lookup_trust_boundary_request_no_throw(
|
||||
request, url, can_retry, headers
|
||||
)
|
||||
if not response_status_ok:
|
||||
_handle_error_response(response_data, retryable_error)
|
||||
return response_data
|
||||
|
||||
|
||||
def _lookup_trust_boundary_request_no_throw(request, url, can_retry=True, headers=None):
|
||||
"""Makes a request to the trust boundary lookup endpoint. This
|
||||
function doesn't throw on response errors.
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): A callable used to make
|
||||
HTTP requests.
|
||||
url (str): The trust boundary lookup url.
|
||||
can_retry (bool): Enable or disable request retry behavior. Defaults to true.
|
||||
headers (Optional[Mapping[str, str]]): The headers for the request.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
response_data = {}
|
||||
retryable_error = False
|
||||
|
||||
retries = _exponential_backoff.ExponentialBackoff()
|
||||
for _ in retries:
|
||||
response = request(method="GET", url=url, headers=headers)
|
||||
response_body = (
|
||||
response.data.decode("utf-8")
|
||||
if hasattr(response.data, "decode")
|
||||
else 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
|
||||
)
|
||||
|
||||
if not can_retry or not retryable_error:
|
||||
return False, response_data, retryable_error
|
||||
|
||||
return False, response_data, retryable_error
|
||||
@@ -0,0 +1,286 @@
|
||||
# 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 async 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 exceptions
|
||||
from google.auth import jwt
|
||||
from google.oauth2 import _client as client
|
||||
|
||||
|
||||
async def _token_endpoint_request_no_throw(
|
||||
request, token_uri, body, access_token=None, use_json=False, can_retry=True
|
||||
):
|
||||
"""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.
|
||||
|
||||
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 = {"Content-Type": client._JSON_CONTENT_TYPE}
|
||||
body = json.dumps(body).encode("utf-8")
|
||||
else:
|
||||
headers = {"Content-Type": client._URLENCODED_CONTENT_TYPE}
|
||||
body = urllib.parse.urlencode(body).encode("utf-8")
|
||||
|
||||
if access_token:
|
||||
headers["Authorization"] = "Bearer {}".format(access_token)
|
||||
|
||||
response_data = {}
|
||||
retryable_error = False
|
||||
|
||||
retries = _exponential_backoff.ExponentialBackoff()
|
||||
for _ in retries:
|
||||
response = await request(
|
||||
method="POST", url=token_uri, headers=headers, body=body
|
||||
)
|
||||
|
||||
# Using data.read() resulted in zlib decompression errors. This may require future investigation.
|
||||
response_body1 = await response.content()
|
||||
|
||||
response_body = (
|
||||
response_body1.decode("utf-8")
|
||||
if hasattr(response_body1, "decode")
|
||||
else response_body1
|
||||
)
|
||||
|
||||
try:
|
||||
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 = client._can_retry(
|
||||
status_code=response.status, response_data=response_data
|
||||
)
|
||||
|
||||
if not can_retry or not retryable_error:
|
||||
return False, response_data, retryable_error
|
||||
|
||||
return False, response_data, retryable_error
|
||||
|
||||
|
||||
async def _token_endpoint_request(
|
||||
request, token_uri, body, access_token=None, use_json=False, can_retry=True
|
||||
):
|
||||
"""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.
|
||||
|
||||
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 = await _token_endpoint_request_no_throw(
|
||||
request,
|
||||
token_uri,
|
||||
body,
|
||||
access_token=access_token,
|
||||
use_json=use_json,
|
||||
can_retry=can_retry,
|
||||
)
|
||||
if not response_status_ok:
|
||||
client._handle_error_response(response_data, retryable_error)
|
||||
return response_data
|
||||
|
||||
|
||||
async 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": client._JWT_GRANT_TYPE}
|
||||
|
||||
response_data = await _token_endpoint_request(
|
||||
request, token_uri, body, can_retry=can_retry
|
||||
)
|
||||
|
||||
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 = client._parse_expiry(response_data)
|
||||
|
||||
return access_token, expiry, response_data
|
||||
|
||||
|
||||
async 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": client._JWT_GRANT_TYPE}
|
||||
|
||||
response_data = await _token_endpoint_request(
|
||||
request, token_uri, body, can_retry=can_retry
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
|
||||
async 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, Optional[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": 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
|
||||
|
||||
response_data = await _token_endpoint_request(
|
||||
request, token_uri, body, can_retry=can_retry
|
||||
)
|
||||
return client._handle_refresh_grant_response(response_data, refresh_token)
|
||||
@@ -0,0 +1,118 @@
|
||||
# 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 Async 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 google.auth import _credentials_async as credentials
|
||||
from google.auth import _helpers
|
||||
from google.auth import exceptions
|
||||
from google.oauth2 import _reauth_async as reauth
|
||||
from google.oauth2 import credentials as oauth2_credentials
|
||||
|
||||
|
||||
class Credentials(oauth2_credentials.Credentials):
|
||||
"""Credentials using OAuth 2.0 access and refresh tokens.
|
||||
|
||||
The credentials are considered immutable. If you want to modify the
|
||||
quota project, use :meth:`with_quota_project` or ::
|
||||
|
||||
credentials = credentials.with_quota_project('myproject-123)
|
||||
"""
|
||||
|
||||
@_helpers.copy_docstring(credentials.Credentials)
|
||||
async def refresh(self, request):
|
||||
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,
|
||||
) = await reauth.refresh_grant(
|
||||
request,
|
||||
self._token_uri,
|
||||
self._refresh_token,
|
||||
self._client_id,
|
||||
self._client_secret,
|
||||
scopes=self._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 self._scopes and "scope" in grant_response:
|
||||
requested_scopes = frozenset(self._scopes)
|
||||
granted_scopes = frozenset(grant_response["scope"].split())
|
||||
scopes_requested_but_not_granted = requested_scopes - granted_scopes
|
||||
if scopes_requested_but_not_granted:
|
||||
raise exceptions.RefreshError(
|
||||
"Not all requested scopes were granted by the "
|
||||
"authorization server, missing scopes {}.".format(
|
||||
", ".join(scopes_requested_but_not_granted)
|
||||
)
|
||||
)
|
||||
|
||||
@_helpers.copy_docstring(credentials.Credentials)
|
||||
async def before_request(self, request, method, url, headers):
|
||||
if not self.valid:
|
||||
await self.refresh(request)
|
||||
self.apply(headers)
|
||||
|
||||
|
||||
class UserAccessTokenCredentials(oauth2_credentials.UserAccessTokenCredentials):
|
||||
"""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.
|
||||
|
||||
"""
|
||||
@@ -0,0 +1,285 @@
|
||||
# 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.
|
||||
|
||||
"""Google ID Token helpers.
|
||||
|
||||
Provides support for verifying `OpenID Connect ID Tokens`_, especially ones
|
||||
generated by Google infrastructure.
|
||||
|
||||
To parse and verify an ID Token issued by Google's OAuth 2.0 authorization
|
||||
server use :func:`verify_oauth2_token`. To verify an ID Token issued by
|
||||
Firebase, use :func:`verify_firebase_token`.
|
||||
|
||||
A general purpose ID Token verifier is available as :func:`verify_token`.
|
||||
|
||||
Example::
|
||||
|
||||
from google.oauth2 import _id_token_async
|
||||
from google.auth.transport import aiohttp_requests
|
||||
|
||||
request = aiohttp_requests.Request()
|
||||
|
||||
id_info = await _id_token_async.verify_oauth2_token(
|
||||
token, request, 'my-client-id.example.com')
|
||||
|
||||
if id_info['iss'] != 'https://accounts.google.com':
|
||||
raise ValueError('Wrong issuer.')
|
||||
|
||||
userid = id_info['sub']
|
||||
|
||||
By default, this will re-fetch certificates for each verification. Because
|
||||
Google's public keys are only changed infrequently (on the order of once per
|
||||
day), you may wish to take advantage of caching to reduce latency and the
|
||||
potential for network errors. This can be accomplished using an external
|
||||
library like `CacheControl`_ to create a cache-aware
|
||||
:class:`google.auth.transport.Request`::
|
||||
|
||||
import cachecontrol
|
||||
import google.auth.transport.requests
|
||||
import requests
|
||||
|
||||
session = requests.session()
|
||||
cached_session = cachecontrol.CacheControl(session)
|
||||
request = google.auth.transport.requests.Request(session=cached_session)
|
||||
|
||||
.. _OpenID Connect ID Token:
|
||||
http://openid.net/specs/openid-connect-core-1_0.html#IDToken
|
||||
.. _CacheControl: https://cachecontrol.readthedocs.io
|
||||
"""
|
||||
|
||||
import http.client as http_client
|
||||
import json
|
||||
import os
|
||||
|
||||
from google.auth import environment_vars
|
||||
from google.auth import exceptions
|
||||
from google.auth import jwt
|
||||
from google.auth.transport import requests
|
||||
from google.oauth2 import id_token as sync_id_token
|
||||
|
||||
|
||||
async def _fetch_certs(request, certs_url):
|
||||
"""Fetches certificates.
|
||||
|
||||
Google-style cerificate endpoints return JSON in the format of
|
||||
``{'key id': 'x509 certificate'}``.
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): The object used to make
|
||||
HTTP requests. This must be an aiohttp request.
|
||||
certs_url (str): The certificate endpoint URL.
|
||||
|
||||
Returns:
|
||||
Mapping[str, str]: A mapping of public key ID to x.509 certificate
|
||||
data.
|
||||
"""
|
||||
response = await request(certs_url, method="GET")
|
||||
|
||||
if response.status != http_client.OK:
|
||||
raise exceptions.TransportError(
|
||||
"Could not fetch certificates at {}".format(certs_url)
|
||||
)
|
||||
|
||||
data = await response.content()
|
||||
|
||||
return json.loads(data)
|
||||
|
||||
|
||||
async def verify_token(
|
||||
id_token,
|
||||
request,
|
||||
audience=None,
|
||||
certs_url=sync_id_token._GOOGLE_OAUTH2_CERTS_URL,
|
||||
clock_skew_in_seconds=0,
|
||||
):
|
||||
"""Verifies an ID token and returns the decoded token.
|
||||
|
||||
Args:
|
||||
id_token (Union[str, bytes]): The encoded token.
|
||||
request (google.auth.transport.Request): The object used to make
|
||||
HTTP requests. This must be an aiohttp request.
|
||||
audience (str): The audience that this token is intended for. If None
|
||||
then the audience is not verified.
|
||||
certs_url (str): The URL that specifies the certificates to use to
|
||||
verify the token. This URL should return JSON in the format of
|
||||
``{'key id': 'x509 certificate'}``.
|
||||
clock_skew_in_seconds (int): The clock skew used for `iat` and `exp`
|
||||
validation.
|
||||
|
||||
Returns:
|
||||
Mapping[str, Any]: The decoded token.
|
||||
"""
|
||||
certs = await _fetch_certs(request, certs_url)
|
||||
|
||||
return jwt.decode(
|
||||
id_token,
|
||||
certs=certs,
|
||||
audience=audience,
|
||||
clock_skew_in_seconds=clock_skew_in_seconds,
|
||||
)
|
||||
|
||||
|
||||
async def verify_oauth2_token(
|
||||
id_token, request, audience=None, clock_skew_in_seconds=0
|
||||
):
|
||||
"""Verifies an ID Token issued by Google's OAuth 2.0 authorization server.
|
||||
|
||||
Args:
|
||||
id_token (Union[str, bytes]): The encoded token.
|
||||
request (google.auth.transport.Request): The object used to make
|
||||
HTTP requests. This must be an aiohttp request.
|
||||
audience (str): The audience that this token is intended for. This is
|
||||
typically your application's OAuth 2.0 client ID. If None then the
|
||||
audience is not verified.
|
||||
clock_skew_in_seconds (int): The clock skew used for `iat` and `exp`
|
||||
validation.
|
||||
|
||||
Returns:
|
||||
Mapping[str, Any]: The decoded token.
|
||||
|
||||
Raises:
|
||||
exceptions.GoogleAuthError: If the issuer is invalid.
|
||||
"""
|
||||
idinfo = await verify_token(
|
||||
id_token,
|
||||
request,
|
||||
audience=audience,
|
||||
certs_url=sync_id_token._GOOGLE_OAUTH2_CERTS_URL,
|
||||
clock_skew_in_seconds=clock_skew_in_seconds,
|
||||
)
|
||||
|
||||
if idinfo["iss"] not in sync_id_token._GOOGLE_ISSUERS:
|
||||
raise exceptions.GoogleAuthError(
|
||||
"Wrong issuer. 'iss' should be one of the following: {}".format(
|
||||
sync_id_token._GOOGLE_ISSUERS
|
||||
)
|
||||
)
|
||||
|
||||
return idinfo
|
||||
|
||||
|
||||
async def verify_firebase_token(
|
||||
id_token, request, audience=None, clock_skew_in_seconds=0
|
||||
):
|
||||
"""Verifies an ID Token issued by Firebase Authentication.
|
||||
|
||||
Args:
|
||||
id_token (Union[str, bytes]): The encoded token.
|
||||
request (google.auth.transport.Request): The object used to make
|
||||
HTTP requests. This must be an aiohttp request.
|
||||
audience (str): The audience that this token is intended for. This is
|
||||
typically your Firebase application ID. If None then the audience
|
||||
is not verified.
|
||||
clock_skew_in_seconds (int): The clock skew used for `iat` and `exp`
|
||||
validation.
|
||||
|
||||
Returns:
|
||||
Mapping[str, Any]: The decoded token.
|
||||
"""
|
||||
return await verify_token(
|
||||
id_token,
|
||||
request,
|
||||
audience=audience,
|
||||
certs_url=sync_id_token._GOOGLE_APIS_CERTS_URL,
|
||||
clock_skew_in_seconds=clock_skew_in_seconds,
|
||||
)
|
||||
|
||||
|
||||
async def fetch_id_token(request, audience):
|
||||
"""Fetch the ID Token from the current environment.
|
||||
|
||||
This function acquires ID token from the environment in the following order.
|
||||
See https://google.aip.dev/auth/4110.
|
||||
|
||||
1. If the environment variable ``GOOGLE_APPLICATION_CREDENTIALS`` is set
|
||||
to the path of a valid service account JSON file, then ID token is
|
||||
acquired using this service account credentials.
|
||||
2. If the application is running in Compute Engine, App Engine or Cloud Run,
|
||||
then the ID token are obtained from the metadata server.
|
||||
3. If metadata server doesn't exist and no valid service account credentials
|
||||
are found, :class:`~google.auth.exceptions.DefaultCredentialsError` will
|
||||
be raised.
|
||||
|
||||
Example::
|
||||
|
||||
import google.oauth2._id_token_async
|
||||
import google.auth.transport.aiohttp_requests
|
||||
|
||||
request = google.auth.transport.aiohttp_requests.Request()
|
||||
target_audience = "https://pubsub.googleapis.com"
|
||||
|
||||
id_token = await google.oauth2._id_token_async.fetch_id_token(request, target_audience)
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.aiohttp_requests.Request): A callable used to make
|
||||
HTTP requests.
|
||||
audience (str): The audience that this ID token is intended for.
|
||||
|
||||
Returns:
|
||||
str: The ID token.
|
||||
|
||||
Raises:
|
||||
~google.auth.exceptions.DefaultCredentialsError:
|
||||
If metadata server doesn't exist and no valid service account
|
||||
credentials are found.
|
||||
"""
|
||||
# 1. Try to get credentials from the GOOGLE_APPLICATION_CREDENTIALS environment
|
||||
# variable.
|
||||
credentials_filename = os.environ.get(environment_vars.CREDENTIALS)
|
||||
if credentials_filename:
|
||||
if not (
|
||||
os.path.exists(credentials_filename)
|
||||
and os.path.isfile(credentials_filename)
|
||||
):
|
||||
raise exceptions.DefaultCredentialsError(
|
||||
"GOOGLE_APPLICATION_CREDENTIALS path is either not found or invalid."
|
||||
)
|
||||
|
||||
try:
|
||||
with open(credentials_filename, "r") as f:
|
||||
from google.oauth2 import _service_account_async as service_account
|
||||
|
||||
info = json.load(f)
|
||||
if info.get("type") == "service_account":
|
||||
credentials = service_account.IDTokenCredentials.from_service_account_info(
|
||||
info, target_audience=audience
|
||||
)
|
||||
await credentials.refresh(request)
|
||||
return credentials.token
|
||||
except ValueError as caught_exc:
|
||||
new_exc = exceptions.DefaultCredentialsError(
|
||||
"GOOGLE_APPLICATION_CREDENTIALS is not valid service account credentials.",
|
||||
caught_exc,
|
||||
)
|
||||
raise new_exc from caught_exc
|
||||
|
||||
# 2. Try to fetch ID token from metada server if it exists. The code works
|
||||
# for GAE and Cloud Run metadata server as well.
|
||||
try:
|
||||
from google.auth import compute_engine
|
||||
from google.auth.compute_engine import _metadata
|
||||
|
||||
request_new = requests.Request()
|
||||
if _metadata.ping(request_new):
|
||||
credentials = compute_engine.IDTokenCredentials(
|
||||
request_new, audience, use_metadata_identity_endpoint=True
|
||||
)
|
||||
credentials.refresh(request_new)
|
||||
return credentials.token
|
||||
except (ImportError, exceptions.TransportError):
|
||||
pass
|
||||
|
||||
raise exceptions.DefaultCredentialsError(
|
||||
"Neither metadata server or valid service account credentials are found."
|
||||
)
|
||||
@@ -0,0 +1,328 @@
|
||||
# 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.oauth2 import _client
|
||||
from google.oauth2 import _client_async
|
||||
from google.oauth2 import challenges
|
||||
from google.oauth2 import reauth
|
||||
|
||||
|
||||
async 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. This must be an aiohttp request.
|
||||
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
|
||||
|
||||
return await _client_async._token_endpoint_request(
|
||||
request,
|
||||
reauth._REAUTH_API + ":start",
|
||||
body,
|
||||
access_token=access_token,
|
||||
use_json=True,
|
||||
)
|
||||
|
||||
|
||||
async 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. This must be an aiohttp request.
|
||||
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,
|
||||
}
|
||||
|
||||
return await _client_async._token_endpoint_request(
|
||||
request,
|
||||
reauth._REAUTH_API + "/{}:continue".format(session_id),
|
||||
body,
|
||||
access_token=access_token,
|
||||
use_json=True,
|
||||
)
|
||||
|
||||
|
||||
async 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. This must be an aiohttp request.
|
||||
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 await _send_challenge_result(
|
||||
request,
|
||||
msg["sessionId"],
|
||||
challenge["challengeId"],
|
||||
client_input,
|
||||
access_token,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async 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. This must be an aiohttp request.
|
||||
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 = await _get_challenges(
|
||||
request,
|
||||
list(challenges.AVAILABLE_CHALLENGES.keys()),
|
||||
access_token,
|
||||
requested_scopes,
|
||||
)
|
||||
|
||||
if msg["status"] == reauth._AUTHENTICATED:
|
||||
return msg["encodedProofOfReauthToken"]
|
||||
|
||||
for _ in range(0, reauth.RUN_CHALLENGE_RETRY_LIMIT):
|
||||
if not (
|
||||
msg["status"] == reauth._CHALLENGE_REQUIRED
|
||||
or msg["status"] == reauth._CHALLENGE_PENDING
|
||||
):
|
||||
raise exceptions.ReauthFailError(
|
||||
"Reauthentication challenge failed due to API error: {}".format(
|
||||
msg["status"]
|
||||
)
|
||||
)
|
||||
|
||||
if not reauth.is_interactive():
|
||||
raise exceptions.ReauthFailError(
|
||||
"Reauthentication challenge could not be answered because you are not"
|
||||
" in an interactive session."
|
||||
)
|
||||
|
||||
msg = await _run_next_challenge(msg, request, access_token)
|
||||
|
||||
if msg["status"] == reauth._AUTHENTICATED:
|
||||
return msg["encodedProofOfReauthToken"]
|
||||
|
||||
# If we got here it means we didn't get authenticated.
|
||||
raise exceptions.ReauthFailError("Failed to obtain rapt token.")
|
||||
|
||||
|
||||
async 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. This must be an aiohttp request.
|
||||
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, _, _, _ = await _client_async.refresh_grant(
|
||||
request=request,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
refresh_token=refresh_token,
|
||||
token_uri=token_uri,
|
||||
scopes=[reauth._REAUTH_SCOPE],
|
||||
)
|
||||
|
||||
# Get rapt token from reauth API.
|
||||
rapt_token = await _obtain_rapt(request, access_token, requested_scopes=scopes)
|
||||
|
||||
return rapt_token
|
||||
|
||||
|
||||
async 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. This must be an aiohttp request.
|
||||
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
|
||||
|
||||
response_status_ok, response_data, retryable_error = await _client_async._token_endpoint_request_no_throw(
|
||||
request, token_uri, body
|
||||
)
|
||||
if (
|
||||
not response_status_ok
|
||||
and response_data.get("error") == reauth._REAUTH_NEEDED_ERROR
|
||||
and (
|
||||
response_data.get("error_subtype")
|
||||
== reauth._REAUTH_NEEDED_ERROR_INVALID_RAPT
|
||||
or response_data.get("error_subtype")
|
||||
== reauth._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 = await 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,
|
||||
) = await _client_async._token_endpoint_request_no_throw(
|
||||
request, token_uri, body
|
||||
)
|
||||
|
||||
if not response_status_ok:
|
||||
_client._handle_error_response(response_data, retryable_error)
|
||||
refresh_response = _client._handle_refresh_grant_response(
|
||||
response_data, refresh_token
|
||||
)
|
||||
return refresh_response + (rapt_token,)
|
||||
@@ -0,0 +1,132 @@
|
||||
# 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.
|
||||
|
||||
"""Service Accounts: JSON Web Token (JWT) Profile for OAuth 2.0
|
||||
|
||||
NOTE: This file adds asynchronous refresh methods to both credentials
|
||||
classes, and therefore async/await syntax is required when calling this
|
||||
method when using service account credentials with asynchronous functionality.
|
||||
Otherwise, all other methods are inherited from the regular service account
|
||||
credentials file google.oauth2.service_account
|
||||
|
||||
"""
|
||||
|
||||
from google.auth import _credentials_async as credentials_async
|
||||
from google.auth import _helpers
|
||||
from google.oauth2 import _client_async
|
||||
from google.oauth2 import service_account
|
||||
|
||||
|
||||
class Credentials(
|
||||
service_account.Credentials, credentials_async.Scoped, credentials_async.Credentials
|
||||
):
|
||||
"""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_async.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_async.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_async.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')
|
||||
"""
|
||||
|
||||
@_helpers.copy_docstring(credentials_async.Credentials)
|
||||
async def refresh(self, request):
|
||||
assertion = self._make_authorization_grant_assertion()
|
||||
access_token, expiry, _ = await _client_async.jwt_grant(
|
||||
request, self._token_uri, assertion
|
||||
)
|
||||
self.token = access_token
|
||||
self.expiry = expiry
|
||||
|
||||
|
||||
class IDTokenCredentials(
|
||||
service_account.IDTokenCredentials,
|
||||
credentials_async.Signing,
|
||||
credentials_async.Credentials,
|
||||
):
|
||||
"""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_async.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_async.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_async.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)
|
||||
|
||||
"""
|
||||
|
||||
@_helpers.copy_docstring(credentials_async.Credentials)
|
||||
async def refresh(self, request):
|
||||
assertion = self._make_authorization_grant_assertion()
|
||||
access_token, expiry, _ = await _client_async.id_token_jwt_grant(
|
||||
request, self._token_uri, assertion
|
||||
)
|
||||
self.token = access_token
|
||||
self.expiry = expiry
|
||||
@@ -0,0 +1,281 @@
|
||||
# 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
|
||||
from google.oauth2 import webauthn_handler_factory
|
||||
from google.oauth2.webauthn_types import (
|
||||
AuthenticationExtensionsClientInputs,
|
||||
GetRequest,
|
||||
PublicKeyCredentialDescriptor,
|
||||
)
|
||||
|
||||
|
||||
REAUTH_ORIGIN = "https://accounts.google.com"
|
||||
SAML_CHALLENGE_MESSAGE = (
|
||||
"Please run `gcloud auth login` to complete reauthentication with SAML."
|
||||
)
|
||||
WEBAUTHN_TIMEOUT_MS = 120000 # Two minute timeout
|
||||
|
||||
|
||||
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):
|
||||
# Check if there is an available Webauthn Handler, if not use pyu2f
|
||||
try:
|
||||
factory = webauthn_handler_factory.WebauthnHandlerFactory()
|
||||
webauthn_handler = factory.get_handler()
|
||||
if webauthn_handler is not None:
|
||||
sys.stderr.write("Please insert and touch your security key\n")
|
||||
return self._obtain_challenge_input_webauthn(metadata, webauthn_handler)
|
||||
except Exception:
|
||||
# Attempt pyu2f if exception in webauthn flow
|
||||
pass
|
||||
|
||||
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
|
||||
|
||||
def _obtain_challenge_input_webauthn(self, metadata, webauthn_handler):
|
||||
sk = metadata.get("securityKey")
|
||||
if sk is None:
|
||||
raise exceptions.InvalidValue("securityKey is None")
|
||||
challenges = sk.get("challenges")
|
||||
application_id = sk.get("applicationId")
|
||||
relying_party_id = sk.get("relyingPartyId")
|
||||
if challenges is None or len(challenges) < 1:
|
||||
raise exceptions.InvalidValue("challenges is None or empty")
|
||||
if application_id is None:
|
||||
raise exceptions.InvalidValue("application_id is None")
|
||||
if relying_party_id is None:
|
||||
raise exceptions.InvalidValue("relying_party_id is None")
|
||||
|
||||
allow_credentials = []
|
||||
for challenge in challenges:
|
||||
kh = challenge.get("keyHandle")
|
||||
if kh is None:
|
||||
raise exceptions.InvalidValue("keyHandle is None")
|
||||
key_handle = self._unpadded_urlsafe_b64recode(kh)
|
||||
allow_credentials.append(PublicKeyCredentialDescriptor(id=key_handle))
|
||||
|
||||
extension = AuthenticationExtensionsClientInputs(appid=application_id)
|
||||
|
||||
challenge = challenges[0].get("challenge")
|
||||
if challenge is None:
|
||||
raise exceptions.InvalidValue("challenge is None")
|
||||
|
||||
get_request = GetRequest(
|
||||
origin=REAUTH_ORIGIN,
|
||||
rpid=relying_party_id,
|
||||
challenge=self._unpadded_urlsafe_b64recode(challenge),
|
||||
timeout_ms=WEBAUTHN_TIMEOUT_MS,
|
||||
allow_credentials=allow_credentials,
|
||||
user_verification="required",
|
||||
extensions=extension,
|
||||
)
|
||||
|
||||
try:
|
||||
get_response = webauthn_handler.get(get_request)
|
||||
except Exception as e:
|
||||
sys.stderr.write("Webauthn Error: {}.\n".format(e))
|
||||
raise e
|
||||
|
||||
response = {
|
||||
"clientData": get_response.response.client_data_json,
|
||||
"authenticatorData": get_response.response.authenticator_data,
|
||||
"signatureData": get_response.response.signature,
|
||||
"applicationId": application_id,
|
||||
"keyHandle": get_response.id,
|
||||
"securityKeyReplyType": 2,
|
||||
}
|
||||
return {"securityKey": response}
|
||||
|
||||
def _unpadded_urlsafe_b64recode(self, s):
|
||||
"""Converts standard b64 encoded string to url safe b64 encoded string
|
||||
with no padding."""
|
||||
b = base64.urlsafe_b64decode(s)
|
||||
return base64.urlsafe_b64encode(b).decode().rstrip("=")
|
||||
|
||||
|
||||
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()]
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
# 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"
|
||||
|
||||
# The Google OAuth 2.0 token info endpoint. Used for getting token info JSON from access tokens.
|
||||
_GOOGLE_OAUTH2_TOKEN_INFO_ENDPOINT = "https://oauth2.googleapis.com/tokeninfo"
|
||||
|
||||
|
||||
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 ""
|
||||
self._cred_file_path = None
|
||||
|
||||
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
|
||||
)
|
||||
self._cred_file_path = d.get("_cred_file_path")
|
||||
# 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
|
||||
|
||||
def _make_copy(self):
|
||||
cred = 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=self._account,
|
||||
)
|
||||
cred._cred_file_path = self._cred_file_path
|
||||
return cred
|
||||
|
||||
@_helpers.copy_docstring(credentials.Credentials)
|
||||
def get_cred_info(self):
|
||||
if self._cred_file_path:
|
||||
cred_info = {
|
||||
"credential_source": self._cred_file_path,
|
||||
"credential_type": "user credentials",
|
||||
}
|
||||
if self.account:
|
||||
cred_info["principal"] = self.account
|
||||
return cred_info
|
||||
return None
|
||||
|
||||
@_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 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.
|
||||
"""
|
||||
cred = self._make_copy()
|
||||
cred._account = account
|
||||
return cred
|
||||
|
||||
@_helpers.copy_docstring(credentials.CredentialsWithUniverseDomain)
|
||||
def with_universe_domain(self, universe_domain):
|
||||
cred = self._make_copy()
|
||||
cred._universe_domain = universe_domain
|
||||
return cred
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,251 @@
|
||||
# Copyright 2022 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.
|
||||
|
||||
"""Experimental GDCH credentials support.
|
||||
"""
|
||||
|
||||
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.oauth2 import _client
|
||||
|
||||
|
||||
TOKEN_EXCHANGE_TYPE = "urn:ietf:params:oauth:token-type:token-exchange"
|
||||
ACCESS_TOKEN_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"
|
||||
SERVICE_ACCOUNT_TOKEN_TYPE = "urn:k8s:params:oauth:token-type:serviceaccount"
|
||||
JWT_LIFETIME = datetime.timedelta(seconds=3600) # 1 hour
|
||||
|
||||
|
||||
class ServiceAccountCredentials(credentials.Credentials):
|
||||
"""Credentials for GDCH (`Google Distributed Cloud Hosted`_) for service
|
||||
account users.
|
||||
|
||||
.. _Google Distributed Cloud Hosted:
|
||||
https://cloud.google.com/blog/topics/hybrid-cloud/\
|
||||
announcing-google-distributed-cloud-edge-and-hosted
|
||||
|
||||
To create a GDCH service account credential, first create a JSON file of
|
||||
the following format::
|
||||
|
||||
{
|
||||
"type": "gdch_service_account",
|
||||
"format_version": "1",
|
||||
"project": "<project name>",
|
||||
"private_key_id": "<key id>",
|
||||
"private_key": "-----BEGIN EC PRIVATE KEY-----\n<key bytes>\n-----END EC PRIVATE KEY-----\n",
|
||||
"name": "<service identity name>",
|
||||
"ca_cert_path": "<CA cert path>",
|
||||
"token_uri": "https://service-identity.<Domain>/authenticate"
|
||||
}
|
||||
|
||||
The "format_version" field stands for the format of the JSON file. For now
|
||||
it is always "1". The `private_key_id` and `private_key` is used for signing.
|
||||
The `ca_cert_path` is used for token server TLS certificate verification.
|
||||
|
||||
After the JSON file is created, set `GOOGLE_APPLICATION_CREDENTIALS` environment
|
||||
variable to the JSON file path, then use the following code to create the
|
||||
credential::
|
||||
|
||||
import google.auth
|
||||
|
||||
credential, _ = google.auth.default()
|
||||
credential = credential.with_gdch_audience("<the audience>")
|
||||
|
||||
We can also create the credential directly::
|
||||
|
||||
from google.oauth import gdch_credentials
|
||||
|
||||
credential = gdch_credentials.ServiceAccountCredentials.from_service_account_file("<the json file path>")
|
||||
credential = credential.with_gdch_audience("<the audience>")
|
||||
|
||||
The token is obtained in the following way. This class first creates a
|
||||
self signed JWT. It uses the `name` value as the `iss` and `sub` claim, and
|
||||
the `token_uri` as the `aud` claim, and signs the JWT with the `private_key`.
|
||||
It then sends the JWT to the `token_uri` to exchange a final token for
|
||||
`audience`.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, signer, service_identity_name, project, audience, token_uri, ca_cert_path
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
signer (google.auth.crypt.Signer): The signer used to sign JWTs.
|
||||
service_identity_name (str): The service identity name. It will be
|
||||
used as the `iss` and `sub` claim in the self signed JWT.
|
||||
project (str): The project.
|
||||
audience (str): The audience for the final token.
|
||||
token_uri (str): The token server uri.
|
||||
ca_cert_path (str): The CA cert path for token server side TLS
|
||||
certificate verification. If the token server uses well known
|
||||
CA, then this parameter can be `None`.
|
||||
"""
|
||||
super(ServiceAccountCredentials, self).__init__()
|
||||
self._signer = signer
|
||||
self._service_identity_name = service_identity_name
|
||||
self._project = project
|
||||
self._audience = audience
|
||||
self._token_uri = token_uri
|
||||
self._ca_cert_path = ca_cert_path
|
||||
|
||||
def _create_jwt(self):
|
||||
now = _helpers.utcnow()
|
||||
expiry = now + JWT_LIFETIME
|
||||
iss_sub_value = "system:serviceaccount:{}:{}".format(
|
||||
self._project, self._service_identity_name
|
||||
)
|
||||
|
||||
payload = {
|
||||
"iss": iss_sub_value,
|
||||
"sub": iss_sub_value,
|
||||
"aud": self._token_uri,
|
||||
"iat": _helpers.datetime_to_secs(now),
|
||||
"exp": _helpers.datetime_to_secs(expiry),
|
||||
}
|
||||
|
||||
return _helpers.from_bytes(jwt.encode(self._signer, payload))
|
||||
|
||||
@_helpers.copy_docstring(credentials.Credentials)
|
||||
def refresh(self, request):
|
||||
import google.auth.transport.requests
|
||||
|
||||
if not isinstance(request, google.auth.transport.requests.Request):
|
||||
raise exceptions.RefreshError(
|
||||
"For GDCH service account credentials, request must be a google.auth.transport.requests.Request object"
|
||||
)
|
||||
|
||||
# Create a self signed JWT, and do token exchange.
|
||||
jwt_token = self._create_jwt()
|
||||
request_body = {
|
||||
"grant_type": TOKEN_EXCHANGE_TYPE,
|
||||
"audience": self._audience,
|
||||
"requested_token_type": ACCESS_TOKEN_TOKEN_TYPE,
|
||||
"subject_token": jwt_token,
|
||||
"subject_token_type": SERVICE_ACCOUNT_TOKEN_TYPE,
|
||||
}
|
||||
response_data = _client._token_endpoint_request(
|
||||
request,
|
||||
self._token_uri,
|
||||
request_body,
|
||||
access_token=None,
|
||||
use_json=True,
|
||||
verify=self._ca_cert_path,
|
||||
)
|
||||
|
||||
self.token, _, self.expiry, _ = _client._handle_refresh_grant_response(
|
||||
response_data, None
|
||||
)
|
||||
|
||||
def with_gdch_audience(self, audience):
|
||||
"""Create a copy of GDCH credentials with the specified audience.
|
||||
|
||||
Args:
|
||||
audience (str): The intended audience for GDCH credentials.
|
||||
"""
|
||||
return self.__class__(
|
||||
self._signer,
|
||||
self._service_identity_name,
|
||||
self._project,
|
||||
audience,
|
||||
self._token_uri,
|
||||
self._ca_cert_path,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _from_signer_and_info(cls, signer, info):
|
||||
"""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.
|
||||
|
||||
Returns:
|
||||
google.oauth2.gdch_credentials.ServiceAccountCredentials: The constructed
|
||||
credentials.
|
||||
|
||||
Raises:
|
||||
ValueError: If the info is not in the expected format.
|
||||
"""
|
||||
if info["format_version"] != "1":
|
||||
raise ValueError("Only format version 1 is supported")
|
||||
|
||||
return cls(
|
||||
signer,
|
||||
info["name"], # service_identity_name
|
||||
info["project"],
|
||||
None, # audience
|
||||
info["token_uri"],
|
||||
info.get("ca_cert_path", None),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_service_account_info(cls, info):
|
||||
"""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.oauth2.gdch_credentials.ServiceAccountCredentials: The constructed
|
||||
credentials.
|
||||
|
||||
Raises:
|
||||
ValueError: If the info is not in the expected format.
|
||||
"""
|
||||
signer = _service_account_info.from_dict(
|
||||
info,
|
||||
require=[
|
||||
"format_version",
|
||||
"private_key_id",
|
||||
"private_key",
|
||||
"name",
|
||||
"project",
|
||||
"token_uri",
|
||||
],
|
||||
use_rsa_signer=False,
|
||||
)
|
||||
return cls._from_signer_and_info(signer, info)
|
||||
|
||||
@classmethod
|
||||
def from_service_account_file(cls, filename):
|
||||
"""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.oauth2.gdch_credentials.ServiceAccountCredentials: The constructed
|
||||
credentials.
|
||||
"""
|
||||
info, signer = _service_account_info.from_filename(
|
||||
filename,
|
||||
require=[
|
||||
"format_version",
|
||||
"private_key_id",
|
||||
"private_key",
|
||||
"name",
|
||||
"project",
|
||||
"token_uri",
|
||||
],
|
||||
use_rsa_signer=False,
|
||||
)
|
||||
return cls._from_signer_and_info(signer, info)
|
||||
@@ -0,0 +1,370 @@
|
||||
# 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 ID Token helpers.
|
||||
|
||||
Provides support for verifying `OpenID Connect ID Tokens`_, especially ones
|
||||
generated by Google infrastructure.
|
||||
|
||||
To parse and verify an ID Token issued by Google's OAuth 2.0 authorization
|
||||
server use :func:`verify_oauth2_token`. To verify an ID Token issued by
|
||||
Firebase, use :func:`verify_firebase_token`.
|
||||
|
||||
A general purpose ID Token verifier is available as :func:`verify_token`.
|
||||
|
||||
Example::
|
||||
|
||||
from google.oauth2 import id_token
|
||||
from google.auth.transport import requests
|
||||
|
||||
request = requests.Request()
|
||||
|
||||
id_info = id_token.verify_oauth2_token(
|
||||
token, request, 'my-client-id.example.com')
|
||||
|
||||
userid = id_info['sub']
|
||||
|
||||
By default, this will re-fetch certificates for each verification. Because
|
||||
Google's public keys are only changed infrequently (on the order of once per
|
||||
day), you may wish to take advantage of caching to reduce latency and the
|
||||
potential for network errors. This can be accomplished using an external
|
||||
library like `CacheControl`_ to create a cache-aware
|
||||
:class:`google.auth.transport.Request`::
|
||||
|
||||
import cachecontrol
|
||||
import google.auth.transport.requests
|
||||
import requests
|
||||
|
||||
session = requests.session()
|
||||
cached_session = cachecontrol.CacheControl(session)
|
||||
request = google.auth.transport.requests.Request(session=cached_session)
|
||||
|
||||
.. _OpenID Connect ID Tokens:
|
||||
http://openid.net/specs/openid-connect-core-1_0.html#IDToken
|
||||
.. _CacheControl: https://cachecontrol.readthedocs.io
|
||||
"""
|
||||
|
||||
import http.client as http_client
|
||||
import json
|
||||
import os
|
||||
|
||||
from google.auth import environment_vars
|
||||
from google.auth import exceptions
|
||||
from google.auth import jwt
|
||||
|
||||
|
||||
# The URL that provides public certificates for verifying ID tokens issued
|
||||
# by Google's OAuth 2.0 authorization server.
|
||||
_GOOGLE_OAUTH2_CERTS_URL = "https://www.googleapis.com/oauth2/v1/certs"
|
||||
|
||||
# The URL that provides public certificates for verifying ID tokens issued
|
||||
# by Firebase and the Google APIs infrastructure
|
||||
_GOOGLE_APIS_CERTS_URL = (
|
||||
"https://www.googleapis.com/robot/v1/metadata/x509"
|
||||
"/securetoken@system.gserviceaccount.com"
|
||||
)
|
||||
|
||||
_GOOGLE_ISSUERS = ["accounts.google.com", "https://accounts.google.com"]
|
||||
|
||||
|
||||
def _fetch_certs(request, certs_url):
|
||||
"""Fetches certificates.
|
||||
|
||||
Google-style cerificate endpoints return JSON in the format of
|
||||
``{'key id': 'x509 certificate'}`` or a certificate array according
|
||||
to the JWK spec (see https://tools.ietf.org/html/rfc7517).
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): The object used to make
|
||||
HTTP requests.
|
||||
certs_url (str): The certificate endpoint URL.
|
||||
|
||||
Returns:
|
||||
Mapping[str, str] | Mapping[str, list]: A mapping of public keys
|
||||
in x.509 or JWK spec.
|
||||
"""
|
||||
response = request(certs_url, method="GET")
|
||||
|
||||
if response.status != http_client.OK:
|
||||
raise exceptions.TransportError(
|
||||
"Could not fetch certificates at {}".format(certs_url)
|
||||
)
|
||||
|
||||
return json.loads(response.data.decode("utf-8"))
|
||||
|
||||
|
||||
def verify_token(
|
||||
id_token,
|
||||
request,
|
||||
audience=None,
|
||||
certs_url=_GOOGLE_OAUTH2_CERTS_URL,
|
||||
clock_skew_in_seconds=0,
|
||||
):
|
||||
"""Verifies an ID token and returns the decoded token.
|
||||
|
||||
Args:
|
||||
id_token (Union[str, bytes]): The encoded token.
|
||||
request (google.auth.transport.Request): The object used to make
|
||||
HTTP requests.
|
||||
audience (str or list): The audience or audiences that this token is
|
||||
intended for. If None then the audience is not verified.
|
||||
certs_url (str): The URL that specifies the certificates to use to
|
||||
verify the token. This URL should return JSON in the format of
|
||||
``{'key id': 'x509 certificate'}`` or a certificate array according to
|
||||
the JWK spec (see https://tools.ietf.org/html/rfc7517).
|
||||
clock_skew_in_seconds (int): The clock skew used for `iat` and `exp`
|
||||
validation.
|
||||
|
||||
Returns:
|
||||
Mapping[str, Any]: The decoded token.
|
||||
"""
|
||||
certs = _fetch_certs(request, certs_url)
|
||||
|
||||
if "keys" in certs:
|
||||
try:
|
||||
import jwt as jwt_lib # type: ignore
|
||||
except ImportError as caught_exc: # pragma: NO COVER
|
||||
raise ImportError(
|
||||
"The pyjwt library is not installed, please install the pyjwt package to use the jwk certs format."
|
||||
) from caught_exc
|
||||
jwks_client = jwt_lib.PyJWKClient(certs_url)
|
||||
signing_key = jwks_client.get_signing_key_from_jwt(id_token)
|
||||
return jwt_lib.decode(
|
||||
id_token,
|
||||
signing_key.key,
|
||||
algorithms=[signing_key.algorithm_name],
|
||||
audience=audience,
|
||||
)
|
||||
else:
|
||||
return jwt.decode(
|
||||
id_token,
|
||||
certs=certs,
|
||||
audience=audience,
|
||||
clock_skew_in_seconds=clock_skew_in_seconds,
|
||||
)
|
||||
|
||||
|
||||
def verify_oauth2_token(id_token, request, audience=None, clock_skew_in_seconds=0):
|
||||
"""Verifies an ID Token issued by Google's OAuth 2.0 authorization server.
|
||||
|
||||
Args:
|
||||
id_token (Union[str, bytes]): The encoded token.
|
||||
request (google.auth.transport.Request): The object used to make
|
||||
HTTP requests.
|
||||
audience (str): The audience that this token is intended for. This is
|
||||
typically your application's OAuth 2.0 client ID. If None then the
|
||||
audience is not verified.
|
||||
clock_skew_in_seconds (int): The clock skew used for `iat` and `exp`
|
||||
validation.
|
||||
|
||||
Returns:
|
||||
Mapping[str, Any]: The decoded token.
|
||||
|
||||
Raises:
|
||||
exceptions.GoogleAuthError: If the issuer is invalid.
|
||||
ValueError: If token verification fails
|
||||
"""
|
||||
idinfo = verify_token(
|
||||
id_token,
|
||||
request,
|
||||
audience=audience,
|
||||
certs_url=_GOOGLE_OAUTH2_CERTS_URL,
|
||||
clock_skew_in_seconds=clock_skew_in_seconds,
|
||||
)
|
||||
|
||||
if idinfo["iss"] not in _GOOGLE_ISSUERS:
|
||||
raise exceptions.GoogleAuthError(
|
||||
"Wrong issuer. 'iss' should be one of the following: {}".format(
|
||||
_GOOGLE_ISSUERS
|
||||
)
|
||||
)
|
||||
|
||||
return idinfo
|
||||
|
||||
|
||||
def verify_firebase_token(id_token, request, audience=None, clock_skew_in_seconds=0):
|
||||
"""Verifies an ID Token issued by Firebase Authentication.
|
||||
|
||||
Args:
|
||||
id_token (Union[str, bytes]): The encoded token.
|
||||
request (google.auth.transport.Request): The object used to make
|
||||
HTTP requests.
|
||||
audience (str): The audience that this token is intended for. This is
|
||||
typically your Firebase application ID. If None then the audience
|
||||
is not verified.
|
||||
clock_skew_in_seconds (int): The clock skew used for `iat` and `exp`
|
||||
validation.
|
||||
|
||||
Returns:
|
||||
Mapping[str, Any]: The decoded token.
|
||||
"""
|
||||
return verify_token(
|
||||
id_token,
|
||||
request,
|
||||
audience=audience,
|
||||
certs_url=_GOOGLE_APIS_CERTS_URL,
|
||||
clock_skew_in_seconds=clock_skew_in_seconds,
|
||||
)
|
||||
|
||||
|
||||
def fetch_id_token_credentials(audience, request=None):
|
||||
"""Create the ID Token credentials from the current environment.
|
||||
|
||||
This function acquires ID token from the environment in the following order.
|
||||
See https://google.aip.dev/auth/4110.
|
||||
|
||||
1. If the environment variable ``GOOGLE_APPLICATION_CREDENTIALS`` is set
|
||||
to the path of a valid service account JSON file, then ID token is
|
||||
acquired using this service account credentials.
|
||||
2. If the application is running in Compute Engine, App Engine or Cloud Run,
|
||||
then the ID token are obtained from the metadata server.
|
||||
3. If metadata server doesn't exist and no valid service account credentials
|
||||
are found, :class:`~google.auth.exceptions.DefaultCredentialsError` will
|
||||
be raised.
|
||||
|
||||
Example::
|
||||
|
||||
import google.oauth2.id_token
|
||||
import google.auth.transport.requests
|
||||
|
||||
request = google.auth.transport.requests.Request()
|
||||
target_audience = "https://pubsub.googleapis.com"
|
||||
|
||||
# Create ID token credentials.
|
||||
credentials = google.oauth2.id_token.fetch_id_token_credentials(target_audience, request=request)
|
||||
|
||||
# Refresh the credential to obtain an ID token.
|
||||
credentials.refresh(request)
|
||||
|
||||
id_token = credentials.token
|
||||
id_token_expiry = credentials.expiry
|
||||
|
||||
Args:
|
||||
audience (str): The audience that this ID token is intended for.
|
||||
request (Optional[google.auth.transport.Request]): A callable used to make
|
||||
HTTP requests. A request object will be created if not provided.
|
||||
|
||||
Returns:
|
||||
google.auth.credentials.Credentials: The ID token credentials.
|
||||
|
||||
Raises:
|
||||
~google.auth.exceptions.DefaultCredentialsError:
|
||||
If metadata server doesn't exist and no valid service account
|
||||
credentials are found.
|
||||
"""
|
||||
# 1. Try to get credentials from the GOOGLE_APPLICATION_CREDENTIALS environment
|
||||
# variable.
|
||||
credentials_filename = os.environ.get(environment_vars.CREDENTIALS)
|
||||
if credentials_filename:
|
||||
if not (
|
||||
os.path.exists(credentials_filename)
|
||||
and os.path.isfile(credentials_filename)
|
||||
):
|
||||
raise exceptions.DefaultCredentialsError(
|
||||
"GOOGLE_APPLICATION_CREDENTIALS path is either not found or invalid."
|
||||
)
|
||||
|
||||
try:
|
||||
with open(credentials_filename, "r") as f:
|
||||
from google.oauth2 import service_account
|
||||
|
||||
info = json.load(f)
|
||||
if info.get("type") == "service_account":
|
||||
return service_account.IDTokenCredentials.from_service_account_info(
|
||||
info, target_audience=audience
|
||||
)
|
||||
elif info.get("type") == "impersonated_service_account":
|
||||
from google.auth import impersonated_credentials
|
||||
|
||||
target_credentials = impersonated_credentials.Credentials.from_impersonated_service_account_info(
|
||||
info
|
||||
)
|
||||
|
||||
return impersonated_credentials.IDTokenCredentials(
|
||||
target_credentials=target_credentials,
|
||||
target_audience=audience,
|
||||
include_email=True,
|
||||
)
|
||||
except ValueError as caught_exc:
|
||||
new_exc = exceptions.DefaultCredentialsError(
|
||||
"GOOGLE_APPLICATION_CREDENTIALS is not valid service account credentials.",
|
||||
caught_exc,
|
||||
)
|
||||
raise new_exc from caught_exc
|
||||
|
||||
# 2. Try to fetch ID token from metada server if it exists. The code
|
||||
# works for GAE and Cloud Run metadata server as well.
|
||||
try:
|
||||
from google.auth import compute_engine
|
||||
from google.auth.compute_engine import _metadata
|
||||
|
||||
# Create a request object if not provided.
|
||||
if not request:
|
||||
import google.auth.transport.requests
|
||||
|
||||
request = google.auth.transport.requests.Request()
|
||||
|
||||
if _metadata.ping(request):
|
||||
return compute_engine.IDTokenCredentials(
|
||||
request, audience, use_metadata_identity_endpoint=True
|
||||
)
|
||||
except (ImportError, exceptions.TransportError):
|
||||
pass
|
||||
|
||||
raise exceptions.DefaultCredentialsError(
|
||||
"Neither metadata server or valid service account credentials are found."
|
||||
)
|
||||
|
||||
|
||||
def fetch_id_token(request, audience):
|
||||
"""Fetch the ID Token from the current environment.
|
||||
|
||||
This function acquires ID token from the environment in the following order.
|
||||
See https://google.aip.dev/auth/4110.
|
||||
|
||||
1. If the environment variable ``GOOGLE_APPLICATION_CREDENTIALS`` is set
|
||||
to the path of a valid service account JSON file, then ID token is
|
||||
acquired using this service account credentials.
|
||||
2. If the application is running in Compute Engine, App Engine or Cloud Run,
|
||||
then the ID token are obtained from the metadata server.
|
||||
3. If metadata server doesn't exist and no valid service account credentials
|
||||
are found, :class:`~google.auth.exceptions.DefaultCredentialsError` will
|
||||
be raised.
|
||||
|
||||
Example::
|
||||
|
||||
import google.oauth2.id_token
|
||||
import google.auth.transport.requests
|
||||
|
||||
request = google.auth.transport.requests.Request()
|
||||
target_audience = "https://pubsub.googleapis.com"
|
||||
|
||||
id_token = google.oauth2.id_token.fetch_id_token(request, target_audience)
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): A callable used to make
|
||||
HTTP requests.
|
||||
audience (str): The audience that this ID token is intended for.
|
||||
|
||||
Returns:
|
||||
str: The ID token.
|
||||
|
||||
Raises:
|
||||
~google.auth.exceptions.DefaultCredentialsError:
|
||||
If metadata server doesn't exist and no valid service account
|
||||
credentials are found.
|
||||
"""
|
||||
id_token_credentials = fetch_id_token_credentials(audience, request=request)
|
||||
id_token_credentials.refresh(request)
|
||||
return id_token_credentials.token
|
||||
@@ -0,0 +1,2 @@
|
||||
# Marker file for PEP 561.
|
||||
# The google-oauth2 package uses inline types.
|
||||
@@ -0,0 +1,369 @@
|
||||
# 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)
|
||||
sys.stderr.write("Reauthentication successful.\n")
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,881 @@
|
||||
# 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 _constants
|
||||
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 iam
|
||||
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,
|
||||
credentials.CredentialsWithTrustBoundary,
|
||||
):
|
||||
"""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 (Mapping[str,str]): A credential trust boundary.
|
||||
|
||||
.. 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._cred_file_path = None
|
||||
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 = trust_boundary
|
||||
|
||||
@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,
|
||||
trust_boundary=self._trust_boundary,
|
||||
)
|
||||
cred._cred_file_path = self._cred_file_path
|
||||
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
|
||||
|
||||
@_helpers.copy_docstring(credentials.CredentialsWithTrustBoundary)
|
||||
def with_trust_boundary(self, trust_boundary):
|
||||
cred = self._make_copy()
|
||||
cred._trust_boundary = trust_boundary
|
||||
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.CredentialsWithTrustBoundary)
|
||||
def _refresh_token(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
|
||||
)
|
||||
|
||||
def _build_trust_boundary_lookup_url(self):
|
||||
"""Builds and returns the URL for the trust boundary lookup API.
|
||||
|
||||
This method constructs the specific URL for the IAM Credentials API's
|
||||
`allowedLocations` endpoint, using the credential's universe domain
|
||||
and service account email.
|
||||
|
||||
Raises:
|
||||
ValueError: If `self.service_account_email` is None or an empty
|
||||
string, as it's required to form the URL.
|
||||
|
||||
Returns:
|
||||
str: The URL for the trust boundary lookup endpoint.
|
||||
"""
|
||||
if not self.service_account_email:
|
||||
raise ValueError(
|
||||
"Service account email is required to build the trust boundary lookup URL."
|
||||
)
|
||||
return _constants._SERVICE_ACCOUNT_TRUST_BOUNDARY_LOOKUP_ENDPOINT.format(
|
||||
universe_domain=self._universe_domain,
|
||||
service_account_email=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
|
||||
|
||||
@_helpers.copy_docstring(credentials.Credentials)
|
||||
def get_cred_info(self):
|
||||
if self._cred_file_path:
|
||||
return {
|
||||
"credential_source": self._cred_file_path,
|
||||
"credential_type": "service account credentials",
|
||||
"principal": self.service_account_email,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
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
|
||||
self._iam_id_token_endpoint = iam._IAM_IDTOKEN_ENDPOINT.replace(
|
||||
"googleapis.com", self._universe_domain
|
||||
)
|
||||
|
||||
if self._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._iam_id_token_endpoint,
|
||||
self.signer_email,
|
||||
self._target_audience,
|
||||
jwt_credentials.token.decode(),
|
||||
self._universe_domain,
|
||||
)
|
||||
|
||||
@_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
|
||||
@@ -0,0 +1,176 @@
|
||||
# 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},
|
||||
)
|
||||
@@ -0,0 +1,168 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,82 @@
|
||||
import abc
|
||||
import os
|
||||
import struct
|
||||
import subprocess
|
||||
|
||||
from google.auth import exceptions
|
||||
from google.oauth2.webauthn_types import GetRequest, GetResponse
|
||||
|
||||
|
||||
class WebAuthnHandler(abc.ABC):
|
||||
@abc.abstractmethod
|
||||
def is_available(self) -> bool:
|
||||
"""Check whether this WebAuthn handler is available"""
|
||||
raise NotImplementedError("is_available method must be implemented")
|
||||
|
||||
@abc.abstractmethod
|
||||
def get(self, get_request: GetRequest) -> GetResponse:
|
||||
"""WebAuthn get (assertion)"""
|
||||
raise NotImplementedError("get method must be implemented")
|
||||
|
||||
|
||||
class PluginHandler(WebAuthnHandler):
|
||||
"""Offloads WebAuthn get reqeust to a pluggable command-line tool.
|
||||
|
||||
Offloads WebAuthn get to a plugin which takes the form of a
|
||||
command-line tool. The command-line tool is configurable via the
|
||||
PluginHandler._ENV_VAR environment variable.
|
||||
|
||||
The WebAuthn plugin should implement the following interface:
|
||||
|
||||
Communication occurs over stdin/stdout, and messages are both sent and
|
||||
received in the form:
|
||||
|
||||
[4 bytes - payload size (little-endian)][variable bytes - json payload]
|
||||
"""
|
||||
|
||||
_ENV_VAR = "GOOGLE_AUTH_WEBAUTHN_PLUGIN"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
try:
|
||||
self._find_plugin()
|
||||
except Exception:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def get(self, get_request: GetRequest) -> GetResponse:
|
||||
request_json = get_request.to_json()
|
||||
cmd = self._find_plugin()
|
||||
response_json = self._call_plugin(cmd, request_json)
|
||||
return GetResponse.from_json(response_json)
|
||||
|
||||
def _call_plugin(self, cmd: str, input_json: str) -> str:
|
||||
# Calculate length of input
|
||||
input_length = len(input_json)
|
||||
length_bytes_le = struct.pack("<I", input_length)
|
||||
request = length_bytes_le + input_json.encode()
|
||||
|
||||
# Call plugin
|
||||
process_result = subprocess.run(
|
||||
[cmd], input=request, capture_output=True, check=True
|
||||
)
|
||||
|
||||
# Check length of response
|
||||
response_len_le = process_result.stdout[:4]
|
||||
response_len = struct.unpack("<I", response_len_le)[0]
|
||||
response = process_result.stdout[4:]
|
||||
if response_len != len(response):
|
||||
raise exceptions.MalformedError(
|
||||
"Plugin response length {} does not match data {}".format(
|
||||
response_len, len(response)
|
||||
)
|
||||
)
|
||||
return response.decode()
|
||||
|
||||
def _find_plugin(self) -> str:
|
||||
plugin_cmd = os.environ.get(PluginHandler._ENV_VAR)
|
||||
if plugin_cmd is None:
|
||||
raise exceptions.InvalidResource(
|
||||
"{} env var is not set".format(PluginHandler._ENV_VAR)
|
||||
)
|
||||
return plugin_cmd
|
||||
@@ -0,0 +1,16 @@
|
||||
from typing import List, Optional
|
||||
|
||||
from google.oauth2.webauthn_handler import PluginHandler, WebAuthnHandler
|
||||
|
||||
|
||||
class WebauthnHandlerFactory:
|
||||
handlers: List[WebAuthnHandler]
|
||||
|
||||
def __init__(self):
|
||||
self.handlers = [PluginHandler()]
|
||||
|
||||
def get_handler(self) -> Optional[WebAuthnHandler]:
|
||||
for handler in self.handlers:
|
||||
if handler.is_available():
|
||||
return handler
|
||||
return None
|
||||
@@ -0,0 +1,156 @@
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from google.auth import exceptions
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PublicKeyCredentialDescriptor:
|
||||
"""Descriptor for a security key based credential.
|
||||
|
||||
https://www.w3.org/TR/webauthn-3/#dictionary-credential-descriptor
|
||||
|
||||
Args:
|
||||
id: <url-safe base64-encoded> credential id (key handle).
|
||||
transports: <'usb'|'nfc'|'ble'|'internal'> List of supported transports.
|
||||
"""
|
||||
|
||||
id: str
|
||||
transports: Optional[List[str]] = None
|
||||
|
||||
def to_dict(self):
|
||||
cred = {"type": "public-key", "id": self.id}
|
||||
if self.transports:
|
||||
cred["transports"] = self.transports
|
||||
return cred
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuthenticationExtensionsClientInputs:
|
||||
"""Client extensions inputs for WebAuthn extensions.
|
||||
|
||||
Args:
|
||||
appid: app id that can be asserted with in addition to rpid.
|
||||
https://www.w3.org/TR/webauthn-3/#sctn-appid-extension
|
||||
"""
|
||||
|
||||
appid: Optional[str] = None
|
||||
|
||||
def to_dict(self):
|
||||
extensions = {}
|
||||
if self.appid:
|
||||
extensions["appid"] = self.appid
|
||||
return extensions
|
||||
|
||||
|
||||
@dataclass
|
||||
class GetRequest:
|
||||
"""WebAuthn get request
|
||||
|
||||
Args:
|
||||
origin: Origin where the WebAuthn get assertion takes place.
|
||||
rpid: Relying Party ID.
|
||||
challenge: <url-safe base64-encoded> raw challenge.
|
||||
timeout_ms: Timeout number in millisecond.
|
||||
allow_credentials: List of allowed credentials.
|
||||
user_verification: <'required'|'preferred'|'discouraged'> User verification requirement.
|
||||
extensions: WebAuthn authentication extensions inputs.
|
||||
"""
|
||||
|
||||
origin: str
|
||||
rpid: str
|
||||
challenge: str
|
||||
timeout_ms: Optional[int] = None
|
||||
allow_credentials: Optional[List[PublicKeyCredentialDescriptor]] = None
|
||||
user_verification: Optional[str] = None
|
||||
extensions: Optional[AuthenticationExtensionsClientInputs] = None
|
||||
|
||||
def to_json(self) -> str:
|
||||
req_options: Dict[str, Any] = {"rpId": self.rpid, "challenge": self.challenge}
|
||||
if self.timeout_ms:
|
||||
req_options["timeout"] = self.timeout_ms
|
||||
if self.allow_credentials:
|
||||
req_options["allowCredentials"] = [
|
||||
c.to_dict() for c in self.allow_credentials
|
||||
]
|
||||
if self.user_verification:
|
||||
req_options["userVerification"] = self.user_verification
|
||||
if self.extensions:
|
||||
req_options["extensions"] = self.extensions.to_dict()
|
||||
return json.dumps(
|
||||
{"type": "get", "origin": self.origin, "requestData": req_options}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthenticatorAssertionResponse:
|
||||
"""Authenticator response to a WebAuthn get (assertion) request.
|
||||
|
||||
https://www.w3.org/TR/webauthn-3/#authenticatorassertionresponse
|
||||
|
||||
Args:
|
||||
client_data_json: <url-safe base64-encoded> client data JSON.
|
||||
authenticator_data: <url-safe base64-encoded> authenticator data.
|
||||
signature: <url-safe base64-encoded> signature.
|
||||
user_handle: <url-safe base64-encoded> user handle.
|
||||
"""
|
||||
|
||||
client_data_json: str
|
||||
authenticator_data: str
|
||||
signature: str
|
||||
user_handle: Optional[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GetResponse:
|
||||
"""WebAuthn get (assertion) response.
|
||||
|
||||
Args:
|
||||
id: <url-safe base64-encoded> credential id (key handle).
|
||||
response: The authenticator assertion response.
|
||||
authenticator_attachment: <'cross-platform'|'platform'> The attachment status of the authenticator.
|
||||
client_extension_results: WebAuthn authentication extensions output results in a dictionary.
|
||||
"""
|
||||
|
||||
id: str
|
||||
response: AuthenticatorAssertionResponse
|
||||
authenticator_attachment: Optional[str]
|
||||
client_extension_results: Optional[Dict]
|
||||
|
||||
@staticmethod
|
||||
def from_json(json_str: str):
|
||||
"""Verify and construct GetResponse from a JSON string."""
|
||||
try:
|
||||
resp_json = json.loads(json_str)
|
||||
except ValueError:
|
||||
raise exceptions.MalformedError("Invalid Get JSON response")
|
||||
if resp_json.get("type") != "getResponse":
|
||||
raise exceptions.MalformedError(
|
||||
"Invalid Get response type: {}".format(resp_json.get("type"))
|
||||
)
|
||||
pk_cred = resp_json.get("responseData")
|
||||
if pk_cred is None:
|
||||
if resp_json.get("error"):
|
||||
raise exceptions.ReauthFailError(
|
||||
"WebAuthn.get failure: {}".format(resp_json["error"])
|
||||
)
|
||||
else:
|
||||
raise exceptions.MalformedError("Get response is empty")
|
||||
if pk_cred.get("type") != "public-key":
|
||||
raise exceptions.MalformedError(
|
||||
"Invalid credential type: {}".format(pk_cred.get("type"))
|
||||
)
|
||||
assertion_json = pk_cred["response"]
|
||||
assertion_resp = AuthenticatorAssertionResponse(
|
||||
client_data_json=assertion_json["clientDataJSON"],
|
||||
authenticator_data=assertion_json["authenticatorData"],
|
||||
signature=assertion_json["signature"],
|
||||
user_handle=assertion_json.get("userHandle"),
|
||||
)
|
||||
return GetResponse(
|
||||
id=pk_cred["id"],
|
||||
response=assertion_resp,
|
||||
authenticator_attachment=pk_cred.get("authenticatorAttachment"),
|
||||
client_extension_results=pk_cred.get("clientExtensionResults"),
|
||||
)
|
||||
Reference in New Issue
Block a user