diff options
author | robot-piglet <robot-piglet@yandex-team.com> | 2024-06-21 09:28:26 +0300 |
---|---|---|
committer | robot-piglet <robot-piglet@yandex-team.com> | 2024-06-21 09:36:40 +0300 |
commit | 0cb3f820fac6a243bcb7e4c4388700898660bfd0 (patch) | |
tree | 056f1b8bc5f72039fa422aac0af13bab0e966aa7 /contrib/python/google-auth | |
parent | 08049311fe5c42a97e8bb47a73fb6cd143c0bdb1 (diff) | |
download | ydb-0cb3f820fac6a243bcb7e4c4388700898660bfd0.tar.gz |
Intermediate changes
Diffstat (limited to 'contrib/python/google-auth')
30 files changed, 1121 insertions, 56 deletions
diff --git a/contrib/python/google-auth/py3/.dist-info/METADATA b/contrib/python/google-auth/py3/.dist-info/METADATA index e0785656a4..d8ad54493a 100644 --- a/contrib/python/google-auth/py3/.dist-info/METADATA +++ b/contrib/python/google-auth/py3/.dist-info/METADATA @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: google-auth -Version: 2.29.0 +Version: 2.30.0 Summary: Google Authentication Library Home-page: https://github.com/googleapis/google-auth-library-python Author: Google Cloud Platform diff --git a/contrib/python/google-auth/py3/google/auth/external_account.py b/contrib/python/google-auth/py3/google/auth/external_account.py index c14001bc2b..3943de2a34 100644 --- a/contrib/python/google-auth/py3/google/auth/external_account.py +++ b/contrib/python/google-auth/py3/google/auth/external_account.py @@ -52,7 +52,7 @@ _STS_REQUESTED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token" # Cloud resource manager URL used to retrieve project information. _CLOUD_RESOURCE_MANAGER = "https://cloudresourcemanager.googleapis.com/v1/projects/" # Default Google sts token url. -_DEFAULT_TOKEN_URL = "https://sts.googleapis.com/v1/token" +_DEFAULT_TOKEN_URL = "https://sts.{universe_domain}/v1/token" @dataclass @@ -147,7 +147,12 @@ class Credentials( super(Credentials, self).__init__() self._audience = audience self._subject_token_type = subject_token_type + self._universe_domain = universe_domain self._token_url = token_url + if self._token_url == _DEFAULT_TOKEN_URL: + self._token_url = self._token_url.replace( + "{universe_domain}", self._universe_domain + ) self._token_info_url = token_info_url self._credential_source = credential_source self._service_account_impersonation_url = service_account_impersonation_url @@ -160,7 +165,6 @@ class Credentials( self._scopes = scopes self._default_scopes = default_scopes self._workforce_pool_user_project = workforce_pool_user_project - self._universe_domain = universe_domain or credentials.DEFAULT_UNIVERSE_DOMAIN self._trust_boundary = { "locations": [], "encoded_locations": "0x0", diff --git a/contrib/python/google-auth/py3/google/auth/iam.py b/contrib/python/google-auth/py3/google/auth/iam.py index e9df844178..bba1624c16 100644 --- a/contrib/python/google-auth/py3/google/auth/iam.py +++ b/contrib/python/google-auth/py3/google/auth/iam.py @@ -27,8 +27,23 @@ from google.auth import _helpers from google.auth import crypt from google.auth import exceptions -_IAM_API_ROOT_URI = "https://iamcredentials.googleapis.com/v1" -_SIGN_BLOB_URI = _IAM_API_ROOT_URI + "/projects/-/serviceAccounts/{}:signBlob?alt=json" + +_IAM_SCOPE = ["https://www.googleapis.com/auth/iam"] + +_IAM_ENDPOINT = ( + "https://iamcredentials.googleapis.com/v1/projects/-" + + "/serviceAccounts/{}:generateAccessToken" +) + +_IAM_SIGN_ENDPOINT = ( + "https://iamcredentials.googleapis.com/v1/projects/-" + + "/serviceAccounts/{}:signBlob" +) + +_IAM_IDTOKEN_ENDPOINT = ( + "https://iamcredentials.googleapis.com/v1/" + + "projects/-/serviceAccounts/{}:generateIdToken" +) class Signer(crypt.Signer): @@ -67,7 +82,7 @@ class Signer(crypt.Signer): message = _helpers.to_bytes(message) method = "POST" - url = _SIGN_BLOB_URI.format(self._service_account_email) + url = _IAM_SIGN_ENDPOINT.format(self._service_account_email) headers = {"Content-Type": "application/json"} body = json.dumps( {"payload": base64.b64encode(message).decode("utf-8")} diff --git a/contrib/python/google-auth/py3/google/auth/identity_pool.py b/contrib/python/google-auth/py3/google/auth/identity_pool.py index a9ec577334..1c97885a4a 100644 --- a/contrib/python/google-auth/py3/google/auth/identity_pool.py +++ b/contrib/python/google-auth/py3/google/auth/identity_pool.py @@ -39,7 +39,7 @@ try: from collections.abc import Mapping # Python 2.7 compatibility except ImportError: # pragma: NO COVER - from collections import Mapping + from collections import Mapping # type: ignore import abc import json import os diff --git a/contrib/python/google-auth/py3/google/auth/impersonated_credentials.py b/contrib/python/google-auth/py3/google/auth/impersonated_credentials.py index d32e6eb69a..3c6f8712a9 100644 --- a/contrib/python/google-auth/py3/google/auth/impersonated_credentials.py +++ b/contrib/python/google-auth/py3/google/auth/impersonated_credentials.py @@ -34,32 +34,15 @@ import json from google.auth import _helpers 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 -_IAM_SCOPE = ["https://www.googleapis.com/auth/iam"] - -_IAM_ENDPOINT = ( - "https://iamcredentials.googleapis.com/v1/projects/-" - + "/serviceAccounts/{}:generateAccessToken" -) - -_IAM_SIGN_ENDPOINT = ( - "https://iamcredentials.googleapis.com/v1/projects/-" - + "/serviceAccounts/{}:signBlob" -) - -_IAM_IDTOKEN_ENDPOINT = ( - "https://iamcredentials.googleapis.com/v1/" - + "projects/-/serviceAccounts/{}:generateIdToken" -) _REFRESH_ERROR = "Unable to acquire impersonated credentials" _DEFAULT_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds -_DEFAULT_TOKEN_URI = "https://oauth2.googleapis.com/token" - def _make_iam_token_request( request, principal, headers, body, iam_endpoint_override=None @@ -83,7 +66,7 @@ def _make_iam_token_request( `iamcredentials.googleapis.com` is not enabled or the `Service Account Token Creator` is not assigned """ - iam_endpoint = iam_endpoint_override or _IAM_ENDPOINT.format(principal) + iam_endpoint = iam_endpoint_override or iam._IAM_ENDPOINT.format(principal) body = json.dumps(body).encode("utf-8") @@ -225,7 +208,9 @@ class Credentials( # added to refresh correctly. User credentials cannot have # their original scopes modified. if isinstance(self._source_credentials, credentials.Scoped): - self._source_credentials = self._source_credentials.with_scopes(_IAM_SCOPE) + self._source_credentials = self._source_credentials.with_scopes( + iam._IAM_SCOPE + ) # If the source credential is service account and self signed jwt # is needed, we need to create a jwt credential inside it if ( @@ -290,7 +275,7 @@ class Credentials( def sign_bytes(self, message): from google.auth.transport.requests import AuthorizedSession - iam_sign_endpoint = _IAM_SIGN_ENDPOINT.format(self._target_principal) + iam_sign_endpoint = iam._IAM_SIGN_ENDPOINT.format(self._target_principal) body = { "payload": base64.b64encode(message).decode("utf-8"), @@ -425,7 +410,7 @@ class IDTokenCredentials(credentials.CredentialsWithQuotaProject): def refresh(self, request): from google.auth.transport.requests import AuthorizedSession - iam_sign_endpoint = _IAM_IDTOKEN_ENDPOINT.format( + iam_sign_endpoint = iam._IAM_IDTOKEN_ENDPOINT.format( self._target_credentials.signer_email ) diff --git a/contrib/python/google-auth/py3/google/auth/pluggable.py b/contrib/python/google-auth/py3/google/auth/pluggable.py index 53b4eac5b4..d725188f87 100644 --- a/contrib/python/google-auth/py3/google/auth/pluggable.py +++ b/contrib/python/google-auth/py3/google/auth/pluggable.py @@ -34,7 +34,7 @@ try: from collections.abc import Mapping # Python 2.7 compatibility except ImportError: # pragma: NO COVER - from collections import Mapping + from collections import Mapping # type: ignore import json import os import subprocess diff --git a/contrib/python/google-auth/py3/google/auth/py.typed b/contrib/python/google-auth/py3/google/auth/py.typed new file mode 100644 index 0000000000..e1ab889b3a --- /dev/null +++ b/contrib/python/google-auth/py3/google/auth/py.typed @@ -0,0 +1,2 @@ +# Marker file for PEP 561. +# The google-auth package uses inline types. diff --git a/contrib/python/google-auth/py3/google/auth/transport/_custom_tls_signer.py b/contrib/python/google-auth/py3/google/auth/transport/_custom_tls_signer.py index 57a563d03b..9279158d45 100644 --- a/contrib/python/google-auth/py3/google/auth/transport/_custom_tls_signer.py +++ b/contrib/python/google-auth/py3/google/auth/transport/_custom_tls_signer.py @@ -46,10 +46,17 @@ SIGN_CALLBACK_CTYPE = ctypes.CFUNCTYPE( # Cast SSL_CTX* to void* -def _cast_ssl_ctx_to_void_p(ssl_ctx): +def _cast_ssl_ctx_to_void_p_pyopenssl(ssl_ctx): return ctypes.cast(int(cffi.FFI().cast("intptr_t", ssl_ctx)), ctypes.c_void_p) +# Cast SSL_CTX* to void* +def _cast_ssl_ctx_to_void_p_stdlib(context): + return ctypes.c_void_p.from_address( + id(context) + ctypes.sizeof(ctypes.c_void_p) * 2 + ) + + # Load offload library and set up the function types. def load_offload_lib(offload_lib_path): _LOGGER.debug("loading offload library from %s", offload_lib_path) @@ -249,10 +256,15 @@ class CustomTlsSigner(object): self._signer_lib, self._enterprise_cert_file_path ) - def attach_to_ssl_context(self, ctx): + def should_use_provider(self): if self._provider_lib: + return True + return False + + def attach_to_ssl_context(self, ctx): + if self.should_use_provider(): if not self._provider_lib.ECP_attach_to_ctx( - _cast_ssl_ctx_to_void_p(ctx._ctx._context), + _cast_ssl_ctx_to_void_p_stdlib(ctx), self._enterprise_cert_file_path.encode("ascii"), ): raise exceptions.MutualTLSChannelError( @@ -262,7 +274,7 @@ class CustomTlsSigner(object): if not self._offload_lib.ConfigureSslContext( self._sign_callback, ctypes.c_char_p(self._cert), - _cast_ssl_ctx_to_void_p(ctx._ctx._context), + _cast_ssl_ctx_to_void_p_pyopenssl(ctx._ctx._context), ): raise exceptions.MutualTLSChannelError( "failed to configure ECP Offload SSL context" diff --git a/contrib/python/google-auth/py3/google/auth/transport/requests.py b/contrib/python/google-auth/py3/google/auth/transport/requests.py index aa16113226..63a2b4596c 100644 --- a/contrib/python/google-auth/py3/google/auth/transport/requests.py +++ b/contrib/python/google-auth/py3/google/auth/transport/requests.py @@ -262,19 +262,16 @@ class _MutualTlsOffloadAdapter(requests.adapters.HTTPAdapter): def __init__(self, enterprise_cert_file_path): import certifi - import urllib3.contrib.pyopenssl - from google.auth.transport import _custom_tls_signer - # Call inject_into_urllib3 to activate certificate checking. See the - # following links for more info: - # (1) doc: https://github.com/urllib3/urllib3/blob/cb9ebf8aac5d75f64c8551820d760b72b619beff/src/urllib3/contrib/pyopenssl.py#L31-L32 - # (2) mTLS example: https://github.com/urllib3/urllib3/issues/474#issuecomment-253168415 - urllib3.contrib.pyopenssl.inject_into_urllib3() - self.signer = _custom_tls_signer.CustomTlsSigner(enterprise_cert_file_path) self.signer.load_libraries() + if not self.signer.should_use_provider(): + import urllib3.contrib.pyopenssl + + urllib3.contrib.pyopenssl.inject_into_urllib3() + poolmanager = create_urllib3_context() poolmanager.load_verify_locations(cafile=certifi.where()) self.signer.attach_to_ssl_context(poolmanager) diff --git a/contrib/python/google-auth/py3/google/auth/version.py b/contrib/python/google-auth/py3/google/auth/version.py index f0dd919dca..0800489978 100644 --- a/contrib/python/google-auth/py3/google/auth/version.py +++ b/contrib/python/google-auth/py3/google/auth/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.29.0" +__version__ = "2.30.0" diff --git a/contrib/python/google-auth/py3/google/oauth2/_client.py b/contrib/python/google-auth/py3/google/oauth2/_client.py index d2af6c8aa8..bce797b88b 100644 --- a/contrib/python/google-auth/py3/google/oauth2/_client.py +++ b/contrib/python/google-auth/py3/google/oauth2/_client.py @@ -39,10 +39,6 @@ _URLENCODED_CONTENT_TYPE = "application/x-www-form-urlencoded" _JSON_CONTENT_TYPE = "application/json" _JWT_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer" _REFRESH_GRANT_TYPE = "refresh_token" -_IAM_IDTOKEN_ENDPOINT = ( - "https://iamcredentials.googleapis.com/v1/" - + "projects/-/serviceAccounts/{}:generateIdToken" -) def _handle_error_response(response_data, retryable_error): @@ -328,12 +324,15 @@ def jwt_grant(request, token_uri, assertion, can_retry=True): return access_token, expiry, response_data -def call_iam_generate_id_token_endpoint(request, signer_email, audience, access_token): +def call_iam_generate_id_token_endpoint( + request, iam_id_token_endpoint, signer_email, audience, access_token +): """Call iam.generateIdToken endpoint to get ID token. Args: request (google.auth.transport.Request): A callable used to make HTTP requests. + 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. @@ -346,7 +345,7 @@ def call_iam_generate_id_token_endpoint(request, signer_email, audience, access_ response_data = _token_endpoint_request( request, - _IAM_IDTOKEN_ENDPOINT.format(signer_email), + iam_id_token_endpoint.format(signer_email), body, access_token=access_token, use_json=True, diff --git a/contrib/python/google-auth/py3/google/oauth2/challenges.py b/contrib/python/google-auth/py3/google/oauth2/challenges.py index c55796323b..6468498bcb 100644 --- a/contrib/python/google-auth/py3/google/oauth2/challenges.py +++ b/contrib/python/google-auth/py3/google/oauth2/challenges.py @@ -22,12 +22,19 @@ 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): @@ -110,6 +117,17 @@ class SecurityKeyChallenge(ReauthChallenge): @_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 @@ -173,6 +191,66 @@ class SecurityKeyChallenge(ReauthChallenge): 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. diff --git a/contrib/python/google-auth/py3/google/oauth2/py.typed b/contrib/python/google-auth/py3/google/oauth2/py.typed new file mode 100644 index 0000000000..aedf18e4b6 --- /dev/null +++ b/contrib/python/google-auth/py3/google/oauth2/py.typed @@ -0,0 +1,2 @@ +# Marker file for PEP 561. +# The google-oauth2 package uses inline types. diff --git a/contrib/python/google-auth/py3/google/oauth2/reauth.py b/contrib/python/google-auth/py3/google/oauth2/reauth.py index 5870347739..1e39e0bc7f 100644 --- a/contrib/python/google-auth/py3/google/oauth2/reauth.py +++ b/contrib/python/google-auth/py3/google/oauth2/reauth.py @@ -274,6 +274,7 @@ def get_rapt_token( # 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 diff --git a/contrib/python/google-auth/py3/google/oauth2/service_account.py b/contrib/python/google-auth/py3/google/oauth2/service_account.py index 04fd7797ad..0e12868f14 100644 --- a/contrib/python/google-auth/py3/google/oauth2/service_account.py +++ b/contrib/python/google-auth/py3/google/oauth2/service_account.py @@ -77,6 +77,7 @@ 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 @@ -595,8 +596,11 @@ class IDTokenCredentials( 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 universe_domain != credentials.DEFAULT_UNIVERSE_DOMAIN: + if self._universe_domain != credentials.DEFAULT_UNIVERSE_DOMAIN: self._use_iam_endpoint = True if additional_claims is not None: @@ -792,6 +796,7 @@ class IDTokenCredentials( 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(), diff --git a/contrib/python/google-auth/py3/google/oauth2/webauthn_handler.py b/contrib/python/google-auth/py3/google/oauth2/webauthn_handler.py new file mode 100644 index 0000000000..e27c7e0990 --- /dev/null +++ b/contrib/python/google-auth/py3/google/oauth2/webauthn_handler.py @@ -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 diff --git a/contrib/python/google-auth/py3/google/oauth2/webauthn_handler_factory.py b/contrib/python/google-auth/py3/google/oauth2/webauthn_handler_factory.py new file mode 100644 index 0000000000..184329fed7 --- /dev/null +++ b/contrib/python/google-auth/py3/google/oauth2/webauthn_handler_factory.py @@ -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 diff --git a/contrib/python/google-auth/py3/google/oauth2/webauthn_types.py b/contrib/python/google-auth/py3/google/oauth2/webauthn_types.py new file mode 100644 index 0000000000..7784e83d0b --- /dev/null +++ b/contrib/python/google-auth/py3/google/oauth2/webauthn_types.py @@ -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"), + ) diff --git a/contrib/python/google-auth/py3/tests/compute_engine/test_credentials.py b/contrib/python/google-auth/py3/tests/compute_engine/test_credentials.py index 9cca317924..bb29f8c6e2 100644 --- a/contrib/python/google-auth/py3/tests/compute_engine/test_credentials.py +++ b/contrib/python/google-auth/py3/tests/compute_engine/test_credentials.py @@ -499,7 +499,7 @@ class TestIDTokenCredentials(object): responses.add( responses.POST, "https://iamcredentials.googleapis.com/v1/projects/-/" - "serviceAccounts/service-account@example.com:signBlob?alt=json", + "serviceAccounts/service-account@example.com:signBlob", status=200, content_type="application/json", json={"keyId": "some-key-id", "signedBlob": signature}, @@ -657,7 +657,7 @@ class TestIDTokenCredentials(object): responses.add( responses.POST, "https://iamcredentials.googleapis.com/v1/projects/-/" - "serviceAccounts/service-account@example.com:signBlob?alt=json", + "serviceAccounts/service-account@example.com:signBlob", status=200, content_type="application/json", json={"keyId": "some-key-id", "signedBlob": signature}, diff --git a/contrib/python/google-auth/py3/tests/oauth2/test__client.py b/contrib/python/google-auth/py3/tests/oauth2/test__client.py index 444232f396..f9a2d3aff4 100644 --- a/contrib/python/google-auth/py3/tests/oauth2/test__client.py +++ b/contrib/python/google-auth/py3/tests/oauth2/test__client.py @@ -24,6 +24,7 @@ import pytest # type: ignore from google.auth import _helpers from google.auth import crypt from google.auth import exceptions +from google.auth import iam from google.auth import jwt from google.auth import transport from google.oauth2 import _client @@ -319,7 +320,11 @@ def test_call_iam_generate_id_token_endpoint(): request = make_request({"token": id_token}) token, expiry = _client.call_iam_generate_id_token_endpoint( - request, "fake_email", "fake_audience", "fake_access_token" + request, + iam._IAM_IDTOKEN_ENDPOINT, + "fake_email", + "fake_audience", + "fake_access_token", ) assert ( @@ -352,7 +357,11 @@ def test_call_iam_generate_id_token_endpoint_no_id_token(): with pytest.raises(exceptions.RefreshError) as excinfo: _client.call_iam_generate_id_token_endpoint( - request, "fake_email", "fake_audience", "fake_access_token" + request, + iam._IAM_IDTOKEN_ENDPOINT, + "fake_email", + "fake_audience", + "fake_access_token", ) assert excinfo.match("No ID token in response") diff --git a/contrib/python/google-auth/py3/tests/oauth2/test_challenges.py b/contrib/python/google-auth/py3/tests/oauth2/test_challenges.py index a06f552837..4116b913ab 100644 --- a/contrib/python/google-auth/py3/tests/oauth2/test_challenges.py +++ b/contrib/python/google-auth/py3/tests/oauth2/test_challenges.py @@ -15,6 +15,7 @@ """Tests for the reauth module.""" import base64 +import os import sys import mock @@ -23,6 +24,13 @@ import pyu2f # type: ignore from google.auth import exceptions from google.oauth2 import challenges +from google.oauth2.webauthn_types import ( + AuthenticationExtensionsClientInputs, + AuthenticatorAssertionResponse, + GetRequest, + GetResponse, + PublicKeyCredentialDescriptor, +) def test_get_user_password(): @@ -54,6 +62,8 @@ def test_security_key(): # Test the case that security key challenge is passed with applicationId and # relyingPartyId the same. + os.environ.pop('"GOOGLE_AUTH_WEBAUTHN_PLUGIN"', None) + with mock.patch("pyu2f.model.RegisteredKey", return_value=mock_key): with mock.patch( "pyu2f.convenience.authenticator.CompositeAuthenticator.Authenticate" @@ -70,6 +80,19 @@ def test_security_key(): print_callback=sys.stderr.write, ) + # Test the case that webauthn plugin is available + os.environ["GOOGLE_AUTH_WEBAUTHN_PLUGIN"] = "plugin" + + with mock.patch( + "google.oauth2.challenges.SecurityKeyChallenge._obtain_challenge_input_webauthn", + return_value={"securityKey": "security key response"}, + ): + + assert challenge.obtain_challenge_input(metadata) == { + "securityKey": "security key response" + } + os.environ.pop('"GOOGLE_AUTH_WEBAUTHN_PLUGIN"', None) + # Test the case that security key challenge is passed with applicationId and # relyingPartyId different, first call works. metadata["securityKey"]["relyingPartyId"] = "security_key_relying_party_id" @@ -173,6 +196,136 @@ def test_security_key(): assert excinfo.match(r"pyu2f dependency is required") +def test_security_key_webauthn(): + metadata = { + "status": "READY", + "challengeId": 2, + "challengeType": "SECURITY_KEY", + "securityKey": { + "applicationId": "security_key_application_id", + "challenges": [ + { + "keyHandle": "some_key", + "challenge": base64.urlsafe_b64encode( + "some_challenge".encode("ascii") + ).decode("ascii"), + } + ], + "relyingPartyId": "security_key_application_id", + }, + } + + challenge = challenges.SecurityKeyChallenge() + + sk = metadata["securityKey"] + sk_challenges = sk["challenges"] + + application_id = sk["applicationId"] + + allow_credentials = [] + for sk_challenge in sk_challenges: + allow_credentials.append( + PublicKeyCredentialDescriptor(id=sk_challenge["keyHandle"]) + ) + + extension = AuthenticationExtensionsClientInputs(appid=application_id) + + get_request = GetRequest( + origin=challenges.REAUTH_ORIGIN, + rpid=application_id, + challenge=challenge._unpadded_urlsafe_b64recode(sk_challenge["challenge"]), + timeout_ms=challenges.WEBAUTHN_TIMEOUT_MS, + allow_credentials=allow_credentials, + user_verification="required", + extensions=extension, + ) + + assertion_resp = AuthenticatorAssertionResponse( + client_data_json="clientDataJSON", + authenticator_data="authenticatorData", + signature="signature", + user_handle="userHandle", + ) + get_response = GetResponse( + id="id", + response=assertion_resp, + authenticator_attachment="authenticatorAttachment", + client_extension_results="clientExtensionResults", + ) + response = { + "clientData": get_response.response.client_data_json, + "authenticatorData": get_response.response.authenticator_data, + "signatureData": get_response.response.signature, + "applicationId": "security_key_application_id", + "keyHandle": get_response.id, + "securityKeyReplyType": 2, + } + + mock_handler = mock.Mock() + mock_handler.get.return_value = get_response + + # Test success case + assert challenge._obtain_challenge_input_webauthn(metadata, mock_handler) == { + "securityKey": response + } + mock_handler.get.assert_called_with(get_request) + + # Test exceptions + + # Missing Values + sk = metadata["securityKey"] + metadata["securityKey"] = None + with pytest.raises(exceptions.InvalidValue): + challenge._obtain_challenge_input_webauthn(metadata, mock_handler) + metadata["securityKey"] = sk + + c = metadata["securityKey"]["challenges"] + metadata["securityKey"]["challenges"] = None + with pytest.raises(exceptions.InvalidValue): + challenge._obtain_challenge_input_webauthn(metadata, mock_handler) + metadata["securityKey"]["challenges"] = [] + with pytest.raises(exceptions.InvalidValue): + challenge._obtain_challenge_input_webauthn(metadata, mock_handler) + metadata["securityKey"]["challenges"] = c + + aid = metadata["securityKey"]["applicationId"] + metadata["securityKey"]["applicationId"] = None + with pytest.raises(exceptions.InvalidValue): + challenge._obtain_challenge_input_webauthn(metadata, mock_handler) + metadata["securityKey"]["applicationId"] = aid + + rpi = metadata["securityKey"]["relyingPartyId"] + metadata["securityKey"]["relyingPartyId"] = None + with pytest.raises(exceptions.InvalidValue): + challenge._obtain_challenge_input_webauthn(metadata, mock_handler) + metadata["securityKey"]["relyingPartyId"] = rpi + + kh = metadata["securityKey"]["challenges"][0]["keyHandle"] + metadata["securityKey"]["challenges"][0]["keyHandle"] = None + with pytest.raises(exceptions.InvalidValue): + challenge._obtain_challenge_input_webauthn(metadata, mock_handler) + metadata["securityKey"]["challenges"][0]["keyHandle"] = kh + + ch = metadata["securityKey"]["challenges"][0]["challenge"] + metadata["securityKey"]["challenges"][0]["challenge"] = None + with pytest.raises(exceptions.InvalidValue): + challenge._obtain_challenge_input_webauthn(metadata, mock_handler) + metadata["securityKey"]["challenges"][0]["challenge"] = ch + + # Handler Exceptions + mock_handler.get.side_effect = exceptions.MalformedError + with pytest.raises(exceptions.MalformedError): + challenge._obtain_challenge_input_webauthn(metadata, mock_handler) + + mock_handler.get.side_effect = exceptions.InvalidResource + with pytest.raises(exceptions.InvalidResource): + challenge._obtain_challenge_input_webauthn(metadata, mock_handler) + + mock_handler.get.side_effect = exceptions.ReauthFailError + with pytest.raises(exceptions.ReauthFailError): + challenge._obtain_challenge_input_webauthn(metadata, mock_handler) + + @mock.patch("getpass.getpass", return_value="foo") def test_password_challenge(getpass_mock): challenge = challenges.PasswordChallenge() diff --git a/contrib/python/google-auth/py3/tests/oauth2/test_service_account.py b/contrib/python/google-auth/py3/tests/oauth2/test_service_account.py index ce0c72fa0a..0dbe316a0f 100644 --- a/contrib/python/google-auth/py3/tests/oauth2/test_service_account.py +++ b/contrib/python/google-auth/py3/tests/oauth2/test_service_account.py @@ -22,6 +22,7 @@ import pytest # type: ignore from google.auth import _helpers from google.auth import crypt from google.auth import exceptions +from google.auth import iam from google.auth import jwt from google.auth import transport from google.auth.credentials import DEFAULT_UNIVERSE_DOMAIN @@ -772,10 +773,36 @@ class TestIDTokenCredentials(object): ) request = mock.Mock() credentials.refresh(request) - req, signer_email, target_audience, access_token = call_iam_generate_id_token_endpoint.call_args[ + req, iam_endpoint, signer_email, target_audience, access_token = call_iam_generate_id_token_endpoint.call_args[ 0 ] assert req == request + assert iam_endpoint == iam._IAM_IDTOKEN_ENDPOINT + assert signer_email == "service-account@example.com" + assert target_audience == "https://example.com" + decoded_access_token = jwt.decode(access_token, verify=False) + assert decoded_access_token["scope"] == "https://www.googleapis.com/auth/iam" + + @mock.patch( + "google.oauth2._client.call_iam_generate_id_token_endpoint", autospec=True + ) + def test_refresh_iam_flow_non_gdu(self, call_iam_generate_id_token_endpoint): + credentials = self.make_credentials(universe_domain="fake-universe") + token = "id_token" + call_iam_generate_id_token_endpoint.return_value = ( + token, + _helpers.utcnow() + datetime.timedelta(seconds=500), + ) + request = mock.Mock() + credentials.refresh(request) + req, iam_endpoint, signer_email, target_audience, access_token = call_iam_generate_id_token_endpoint.call_args[ + 0 + ] + assert req == request + assert ( + iam_endpoint + == "https://iamcredentials.fake-universe/v1/projects/-/serviceAccounts/{}:generateIdToken" + ) assert signer_email == "service-account@example.com" assert target_audience == "https://example.com" decoded_access_token = jwt.decode(access_token, verify=False) diff --git a/contrib/python/google-auth/py3/tests/oauth2/test_webauthn_handler.py b/contrib/python/google-auth/py3/tests/oauth2/test_webauthn_handler.py new file mode 100644 index 0000000000..454e97cb61 --- /dev/null +++ b/contrib/python/google-auth/py3/tests/oauth2/test_webauthn_handler.py @@ -0,0 +1,148 @@ +import json +import struct + +import mock +import pytest # type: ignore + +from google.auth import exceptions +from google.oauth2 import webauthn_handler +from google.oauth2 import webauthn_types + + +@pytest.fixture +def os_get_stub(): + with mock.patch.object( + webauthn_handler.os.environ, + "get", + return_value="gcloud_webauthn_plugin", + name="fake os.environ.get", + ) as mock_os_environ_get: + yield mock_os_environ_get + + +@pytest.fixture +def subprocess_run_stub(): + with mock.patch.object( + webauthn_handler.subprocess, "run", name="fake subprocess.run" + ) as mock_subprocess_run: + yield mock_subprocess_run + + +def test_PluginHandler_is_available(os_get_stub): + test_handler = webauthn_handler.PluginHandler() + + assert test_handler.is_available() is True + + os_get_stub.return_value = None + assert test_handler.is_available() is False + + +GET_ASSERTION_REQUEST = webauthn_types.GetRequest( + origin="fake_origin", + rpid="fake_rpid", + challenge="fake_challenge", + allow_credentials=[webauthn_types.PublicKeyCredentialDescriptor(id="fake_id_1")], +) + + +def test_malformated_get_assertion_response(os_get_stub, subprocess_run_stub): + response_len = struct.pack("<I", 5) + response = "1234567890" + mock_response = mock.Mock() + mock_response.stdout = response_len + response.encode() + subprocess_run_stub.return_value = mock_response + + test_handler = webauthn_handler.PluginHandler() + with pytest.raises(exceptions.MalformedError) as excinfo: + test_handler.get(GET_ASSERTION_REQUEST) + assert "Plugin response length" in str(excinfo.value) + + +def test_failure_get_assertion(os_get_stub, subprocess_run_stub): + failure_response = { + "type": "getResponse", + "error": "fake_plugin_get_assertion_failure", + } + response_json = json.dumps(failure_response).encode() + response_len = struct.pack("<I", len(response_json)) + + # process returns get response in json + mock_response = mock.Mock() + mock_response.stdout = response_len + response_json + subprocess_run_stub.return_value = mock_response + + test_handler = webauthn_handler.PluginHandler() + with pytest.raises(exceptions.ReauthFailError) as excinfo: + test_handler.get(GET_ASSERTION_REQUEST) + assert failure_response["error"] in str(excinfo.value) + + +def test_success_get_assertion(os_get_stub, subprocess_run_stub): + success_response = { + "type": "public-key", + "id": "fake-id", + "authenticatorAttachment": "cross-platform", + "clientExtensionResults": {"appid": True}, + "response": { + "clientDataJSON": "fake_client_data_json_base64", + "authenticatorData": "fake_authenticator_data_base64", + "signature": "fake_signature_base64", + "userHandle": "fake_user_handle_base64", + }, + } + valid_plugin_response = {"type": "getResponse", "responseData": success_response} + valid_plugin_response_json = json.dumps(valid_plugin_response).encode() + valid_plugin_response_len = struct.pack("<I", len(valid_plugin_response_json)) + + # process returns get response in json + mock_response = mock.Mock() + mock_response.stdout = valid_plugin_response_len + valid_plugin_response_json + subprocess_run_stub.return_value = mock_response + + # Call get() + test_handler = webauthn_handler.PluginHandler() + got_response = test_handler.get(GET_ASSERTION_REQUEST) + + # Validate expected plugin request + os_get_stub.assert_called_once() + subprocess_run_stub.assert_called_once() + + stdin_input = subprocess_run_stub.call_args.kwargs["input"] + input_json_len_le = stdin_input[:4] + input_json_len = struct.unpack("<I", input_json_len_le)[0] + input_json = stdin_input[4:] + assert len(input_json) == input_json_len + + input_dict = json.loads(input_json.decode("utf8")) + assert input_dict == { + "type": "get", + "origin": "fake_origin", + "requestData": { + "rpid": "fake_rpid", + "challenge": "fake_challenge", + "allowCredentials": [{"type": "public-key", "id": "fake_id_1"}], + }, + } + + # Validate get assertion response + assert got_response.id == success_response["id"] + assert ( + got_response.authenticator_attachment + == success_response["authenticatorAttachment"] + ) + assert ( + got_response.client_extension_results + == success_response["clientExtensionResults"] + ) + assert ( + got_response.response.client_data_json + == success_response["response"]["clientDataJSON"] + ) + assert ( + got_response.response.authenticator_data + == success_response["response"]["authenticatorData"] + ) + assert got_response.response.signature == success_response["response"]["signature"] + assert ( + got_response.response.user_handle == success_response["response"]["userHandle"] + ) diff --git a/contrib/python/google-auth/py3/tests/oauth2/test_webauthn_handler_factory.py b/contrib/python/google-auth/py3/tests/oauth2/test_webauthn_handler_factory.py new file mode 100644 index 0000000000..47890ce4b4 --- /dev/null +++ b/contrib/python/google-auth/py3/tests/oauth2/test_webauthn_handler_factory.py @@ -0,0 +1,29 @@ +import mock +import pytest # type: ignore + +from google.oauth2 import webauthn_handler +from google.oauth2 import webauthn_handler_factory + + +@pytest.fixture +def os_get_stub(): + with mock.patch.object( + webauthn_handler.os.environ, + "get", + return_value="gcloud_webauthn_plugin", + name="fake os.environ.get", + ) as mock_os_environ_get: + yield mock_os_environ_get + + +# Check that get_handler returns a value when env is set, +# that type is PluginHandler, and that no value is returned +# if env not set. +def test_WebauthHandlerFactory_get(os_get_stub): + factory = webauthn_handler_factory.WebauthnHandlerFactory() + assert factory.get_handler() is not None + + assert isinstance(factory.get_handler(), webauthn_handler.PluginHandler) + + os_get_stub.return_value = None + assert factory.get_handler() is None diff --git a/contrib/python/google-auth/py3/tests/oauth2/test_webauthn_types.py b/contrib/python/google-auth/py3/tests/oauth2/test_webauthn_types.py new file mode 100644 index 0000000000..5231d21896 --- /dev/null +++ b/contrib/python/google-auth/py3/tests/oauth2/test_webauthn_types.py @@ -0,0 +1,237 @@ +import json + +import pytest # type: ignore + +from google.oauth2 import webauthn_types + + +@pytest.mark.parametrize( + "test_pub_key_cred,expected_dict", + [ + ( + webauthn_types.PublicKeyCredentialDescriptor( + id="fake_cred_id_base64", transports=None + ), + {"type": "public-key", "id": "fake_cred_id_base64"}, + ), + ( + webauthn_types.PublicKeyCredentialDescriptor( + id="fake_cred_id_base64", transports=[] + ), + {"type": "public-key", "id": "fake_cred_id_base64"}, + ), + ( + webauthn_types.PublicKeyCredentialDescriptor( + id="fake_cred_id_base64", transports=["usb"] + ), + {"type": "public-key", "id": "fake_cred_id_base64", "transports": ["usb"]}, + ), + ( + webauthn_types.PublicKeyCredentialDescriptor( + id="fake_cred_id_base64", transports=["usb", "internal"] + ), + { + "type": "public-key", + "id": "fake_cred_id_base64", + "transports": ["usb", "internal"], + }, + ), + ], +) +def test_PublicKeyCredentialDescriptor(test_pub_key_cred, expected_dict): + assert test_pub_key_cred.to_dict() == expected_dict + + +@pytest.mark.parametrize( + "test_extension_input,expected_dict", + [ + (webauthn_types.AuthenticationExtensionsClientInputs(), {}), + (webauthn_types.AuthenticationExtensionsClientInputs(appid=""), {}), + ( + webauthn_types.AuthenticationExtensionsClientInputs(appid="fake_appid"), + {"appid": "fake_appid"}, + ), + ], +) +def test_AuthenticationExtensionsClientInputs(test_extension_input, expected_dict): + assert test_extension_input.to_dict() == expected_dict + + +@pytest.mark.parametrize("has_allow_credentials", [(False), (True)]) +def test_GetRequest(has_allow_credentials): + allow_credentials = [ + webauthn_types.PublicKeyCredentialDescriptor(id="fake_id_1"), + webauthn_types.PublicKeyCredentialDescriptor(id="fake_id_2"), + ] + test_get_request = webauthn_types.GetRequest( + origin="fake_origin", + rpid="fake_rpid", + challenge="fake_challenge", + timeout_ms=123, + allow_credentials=allow_credentials if has_allow_credentials else None, + user_verification="preferred", + extensions=webauthn_types.AuthenticationExtensionsClientInputs( + appid="fake_appid" + ), + ) + expected_allow_credentials = [ + {"type": "public-key", "id": "fake_id_1"}, + {"type": "public-key", "id": "fake_id_2"}, + ] + exepcted_dict = { + "type": "get", + "origin": "fake_origin", + "requestData": { + "rpid": "fake_rpid", + "timeout": 123, + "challenge": "fake_challenge", + "userVerification": "preferred", + "extensions": {"appid": "fake_appid"}, + }, + } + if has_allow_credentials: + exepcted_dict["requestData"]["allowCredentials"] = expected_allow_credentials + assert json.loads(test_get_request.to_json()) == exepcted_dict + + +@pytest.mark.parametrize( + "has_user_handle,has_authenticator_attachment,has_client_extension_results", + [ + (False, False, False), + (False, False, True), + (False, True, False), + (False, True, True), + (True, False, False), + (True, False, True), + (True, True, False), + (True, True, True), + ], +) +def test_GetResponse( + has_user_handle, has_authenticator_attachment, has_client_extension_results +): + input_response_data = { + "type": "public-key", + "id": "fake-id", + "authenticatorAttachment": "cross-platform", + "clientExtensionResults": {"appid": True}, + "response": { + "clientDataJSON": "fake_client_data_json_base64", + "authenticatorData": "fake_authenticator_data_base64", + "signature": "fake_signature_base64", + "userHandle": "fake_user_handle_base64", + }, + } + if not has_authenticator_attachment: + input_response_data.pop("authenticatorAttachment") + if not has_client_extension_results: + input_response_data.pop("clientExtensionResults") + if not has_user_handle: + input_response_data["response"].pop("userHandle") + + response = webauthn_types.GetResponse.from_json( + json.dumps({"type": "getResponse", "responseData": input_response_data}) + ) + + assert response.id == input_response_data["id"] + assert response.authenticator_attachment == ( + input_response_data["authenticatorAttachment"] + if has_authenticator_attachment + else None + ) + assert response.client_extension_results == ( + input_response_data["clientExtensionResults"] + if has_client_extension_results + else None + ) + assert ( + response.response.client_data_json + == input_response_data["response"]["clientDataJSON"] + ) + assert ( + response.response.authenticator_data + == input_response_data["response"]["authenticatorData"] + ) + assert response.response.signature == input_response_data["response"]["signature"] + assert response.response.user_handle == ( + input_response_data["response"]["userHandle"] if has_user_handle else None + ) + + +@pytest.mark.parametrize( + "input_dict,expected_error", + [ + ({"xyz_type": "wrong_type"}, "Invalid Get response type"), + ({"type": "wrong_type"}, "Invalid Get response type"), + ({"type": "getResponse"}, "Get response is empty"), + ( + {"type": "getResponse", "error": "fake_get_response_error"}, + "WebAuthn.get failure: fake_get_response_error", + ), + ( + {"type": "getResponse", "responseData": {"xyz_type": "wrong_type"}}, + "Invalid credential type", + ), + ( + {"type": "getResponse", "responseData": {"type": "wrong_type"}}, + "Invalid credential type", + ), + ( + { + "type": "getResponse", + "responseData": {"type": "public-key", "response": {}}, + }, + "KeyError", + ), + ( + { + "type": "getResponse", + "responseData": { + "type": "public-key", + "response": {"clientDataJSON": "fake_client_data_json_base64"}, + }, + }, + "KeyError", + ), + ( + { + "type": "getResponse", + "responseData": { + "type": "public-key", + "response": { + "clientDataJSON": "fake_client_data_json_base64", + "authenticatorData": "fake_authenticator_data_base64", + }, + }, + }, + "KeyError", + ), + ( + { + "type": "getResponse", + "responseData": { + "type": "public-key", + "response": { + "clientDataJSON": "fake_client_data_json_base64", + "authenticatorData": "fake_authenticator_data_base64", + "signature": "fake_signature_base64", + }, + }, + }, + "KeyError", + ), + ], +) +def test_GetResponse_error(input_dict, expected_error): + with pytest.raises(Exception) as excinfo: + webauthn_types.GetResponse.from_json(json.dumps(input_dict)) + if expected_error == "KeyError": + assert excinfo.type is KeyError + else: + assert expected_error in str(excinfo.value) + + +def test_MalformatedJsonInput(): + with pytest.raises(ValueError) as excinfo: + webauthn_types.GetResponse.from_json(")]}") + assert "Invalid Get JSON response" in str(excinfo.value) diff --git a/contrib/python/google-auth/py3/tests/test_aws.py b/contrib/python/google-auth/py3/tests/test_aws.py index 5614820312..df1f02e7d7 100644 --- a/contrib/python/google-auth/py3/tests/test_aws.py +++ b/contrib/python/google-auth/py3/tests/test_aws.py @@ -1220,6 +1220,39 @@ class TestCredentials(object): url + SERVICE_ACCOUNT_IMPERSONATION_URL_ROUTE ) + def test_info_with_default_token_url(self): + credentials = aws.Credentials( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + credential_source=self.CREDENTIAL_SOURCE.copy(), + ) + + assert credentials.info == { + "type": "external_account", + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "credential_source": self.CREDENTIAL_SOURCE.copy(), + "universe_domain": DEFAULT_UNIVERSE_DOMAIN, + } + + def test_info_with_default_token_url_with_universe_domain(self): + credentials = aws.Credentials( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + credential_source=self.CREDENTIAL_SOURCE.copy(), + universe_domain="testdomain.org", + ) + + assert credentials.info == { + "type": "external_account", + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": "https://sts.testdomain.org/v1/token", + "credential_source": self.CREDENTIAL_SOURCE.copy(), + "universe_domain": "testdomain.org", + } + def test_retrieve_subject_token_missing_region_url(self): # When AWS_REGION envvar is not available, region_url is required for # determining the current AWS region. diff --git a/contrib/python/google-auth/py3/tests/test_identity_pool.py b/contrib/python/google-auth/py3/tests/test_identity_pool.py index 0de711832f..e4efe46c6b 100644 --- a/contrib/python/google-auth/py3/tests/test_identity_pool.py +++ b/contrib/python/google-auth/py3/tests/test_identity_pool.py @@ -783,6 +783,39 @@ class TestCredentials(object): "universe_domain": DEFAULT_UNIVERSE_DOMAIN, } + def test_info_with_default_token_url(self): + credentials = identity_pool.Credentials( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + credential_source=self.CREDENTIAL_SOURCE_TEXT_URL.copy(), + ) + + assert credentials.info == { + "type": "external_account", + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "credential_source": self.CREDENTIAL_SOURCE_TEXT_URL, + "universe_domain": DEFAULT_UNIVERSE_DOMAIN, + } + + def test_info_with_default_token_url_with_universe_domain(self): + credentials = identity_pool.Credentials( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + credential_source=self.CREDENTIAL_SOURCE_TEXT_URL.copy(), + universe_domain="testdomain.org", + ) + + assert credentials.info == { + "type": "external_account", + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": "https://sts.testdomain.org/v1/token", + "credential_source": self.CREDENTIAL_SOURCE_TEXT_URL, + "universe_domain": "testdomain.org", + } + def test_retrieve_subject_token_missing_subject_token(self, tmpdir): # Provide empty text file. empty_file = tmpdir.join("empty.txt") diff --git a/contrib/python/google-auth/py3/tests/transport/test__custom_tls_signer.py b/contrib/python/google-auth/py3/tests/transport/test__custom_tls_signer.py index d2907bad29..3a33c2c021 100644 --- a/contrib/python/google-auth/py3/tests/transport/test__custom_tls_signer.py +++ b/contrib/python/google-auth/py3/tests/transport/test__custom_tls_signer.py @@ -195,6 +195,7 @@ def test_custom_tls_signer(): get_cert.assert_called_once() get_sign_callback.assert_called_once() offload_lib.ConfigureSslContext.assert_called_once() + assert not signer_object.should_use_provider() assert signer_object._enterprise_cert_file_path == ENTERPRISE_CERT_FILE assert signer_object._offload_lib == offload_lib assert signer_object._signer_lib == signer_lib @@ -216,6 +217,7 @@ def test_custom_tls_signer_provider(): signer_object.load_libraries() signer_object.attach_to_ssl_context(mock.MagicMock()) + assert signer_object.should_use_provider() assert signer_object._enterprise_cert_file_path == ENTERPRISE_CERT_FILE_PROVIDER assert signer_object._provider_lib == provider_lib load_provider_lib.assert_called_with("/path/to/provider/lib") diff --git a/contrib/python/google-auth/py3/tests/transport/test_requests.py b/contrib/python/google-auth/py3/tests/transport/test_requests.py index aadc1ddbfd..0da3e36d9a 100644 --- a/contrib/python/google-auth/py3/tests/transport/test_requests.py +++ b/contrib/python/google-auth/py3/tests/transport/test_requests.py @@ -568,3 +568,38 @@ class TestMutualTlsOffloadAdapter(object): adapter.proxy_manager_for() mock_proxy_manager_for.assert_called_with(ssl_context=adapter._ctx_proxymanager) + + @mock.patch.object(requests.adapters.HTTPAdapter, "init_poolmanager") + @mock.patch.object(requests.adapters.HTTPAdapter, "proxy_manager_for") + @mock.patch.object( + google.auth.transport._custom_tls_signer.CustomTlsSigner, "should_use_provider" + ) + @mock.patch.object( + google.auth.transport._custom_tls_signer.CustomTlsSigner, "load_libraries" + ) + @mock.patch.object( + google.auth.transport._custom_tls_signer.CustomTlsSigner, + "attach_to_ssl_context", + ) + def test_success_should_use_provider( + self, + mock_attach_to_ssl_context, + mock_load_libraries, + mock_should_use_provider, + mock_proxy_manager_for, + mock_init_poolmanager, + ): + enterprise_cert_file_path = "/path/to/enterprise/cert/json" + adapter = google.auth.transport.requests._MutualTlsOffloadAdapter( + enterprise_cert_file_path + ) + + mock_should_use_provider.side_effect = True + mock_load_libraries.assert_called_once() + assert mock_attach_to_ssl_context.call_count == 2 + + adapter.init_poolmanager() + mock_init_poolmanager.assert_called_with(ssl_context=adapter._ctx_poolmanager) + + adapter.proxy_manager_for() + mock_proxy_manager_for.assert_called_with(ssl_context=adapter._ctx_proxymanager) diff --git a/contrib/python/google-auth/py3/ya.make b/contrib/python/google-auth/py3/ya.make index 952d1ebdd3..3216499980 100644 --- a/contrib/python/google-auth/py3/ya.make +++ b/contrib/python/google-auth/py3/ya.make @@ -2,7 +2,7 @@ PY3_LIBRARY() -VERSION(2.29.0) +VERSION(2.30.0) LICENSE(Apache-2.0) @@ -86,12 +86,17 @@ PY_SRCS( google/oauth2/service_account.py google/oauth2/sts.py google/oauth2/utils.py + google/oauth2/webauthn_handler.py + google/oauth2/webauthn_handler_factory.py + google/oauth2/webauthn_types.py ) RESOURCE_FILES( PREFIX contrib/python/google-auth/py3/ .dist-info/METADATA .dist-info/top_level.txt + google/auth/py.typed + google/oauth2/py.typed ) END() |