-
Notifications
You must be signed in to change notification settings - Fork 1.7k
feat(auth): Implement python mtls helpers #17495
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
782211c
28bc123
172dd39
27fae9f
93d6b82
805bb04
f167b3d
ae95056
57b2525
f8289d2
bca7b9d
ed2fbc0
db59235
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -14,12 +14,26 @@ | |||||
|
|
||||||
| """Utilites for mutual TLS.""" | ||||||
|
|
||||||
| import enum | ||||||
| import logging | ||||||
| from os import getenv | ||||||
| import ssl | ||||||
| from typing import Optional | ||||||
|
|
||||||
| from google.auth import environment_vars | ||||||
| from google.auth import exceptions | ||||||
| from google.auth.transport import _mtls_helper | ||||||
|
|
||||||
|
|
||||||
| _LOGGER = logging.getLogger(__name__) | ||||||
|
|
||||||
|
|
||||||
| class UseMtlsEndpointMode(enum.Enum): | ||||||
| ALWAYS = "always" | ||||||
| NEVER = "never" | ||||||
| AUTO = "auto" | ||||||
|
|
||||||
|
|
||||||
| def has_default_client_cert_source(include_context_aware=True): | ||||||
| """Check if default client SSL credentials exists on the device. | ||||||
|
|
||||||
|
|
@@ -60,7 +74,7 @@ def default_client_cert_source(): | |||||
| client certificate bytes and private key bytes, both in PEM format. | ||||||
|
|
||||||
| Raises: | ||||||
| google.auth.exceptions.DefaultClientCertSourceError: If the default | ||||||
| google.auth.exceptions.MutualTLSChannelError: If the default | ||||||
| client SSL credentials don't exist or are malformed. | ||||||
| """ | ||||||
| if not has_default_client_cert_source(include_context_aware=True): | ||||||
|
|
@@ -96,7 +110,7 @@ def default_client_encrypted_cert_source(cert_path, key_path): | |||||
| returns the cert_path, key_path and passphrase bytes. | ||||||
|
|
||||||
| Raises: | ||||||
| google.auth.exceptions.DefaultClientCertSourceError: If any problem | ||||||
| google.auth.exceptions.MutualTLSChannelError: If any problem | ||||||
|
nbayati marked this conversation as resolved.
|
||||||
| occurs when loading or saving the client certificate and key. | ||||||
| """ | ||||||
| if not has_default_client_cert_source(include_context_aware=True): | ||||||
|
|
@@ -140,3 +154,153 @@ def should_use_client_cert(): | |||||
| bool: indicating whether the client certificate should be used for mTLS. | ||||||
| """ | ||||||
| return _mtls_helper.check_use_client_cert() | ||||||
|
|
||||||
|
nbayati marked this conversation as resolved.
|
||||||
|
|
||||||
| def _load_client_cert_into_context( | ||||||
| ctx: ssl.SSLContext, | ||||||
|
nbayati marked this conversation as resolved.
|
||||||
| cert_bytes: bytes, | ||||||
| key_bytes: bytes, | ||||||
| passphrase: Optional[bytes] = None, | ||||||
| ) -> None: | ||||||
| """Load a client certificate and key into an SSL context. | ||||||
|
|
||||||
| Args: | ||||||
| ctx (ssl.SSLContext): The SSL context to load the certificate and key into. | ||||||
| cert_bytes (bytes): The client certificate bytes in PEM format. | ||||||
| key_bytes (bytes): The client private key bytes in PEM format. | ||||||
| passphrase (Optional[bytes]): The passphrase for the client private key. | ||||||
|
|
||||||
| Raises: | ||||||
| google.auth.exceptions.MutualTLSChannelError: If the SSL context is invalid, | ||||||
| or if loading the certificate and key fails. | ||||||
| """ | ||||||
| if not isinstance(ctx, ssl.SSLContext): | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Checking
Suggested change
References
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We intentionally enforce strict ssl.SSLContext typing here rather than using duck typing. Because this method passes sensitive material (the client private key and its passphrase) directly to the context, allowing arbitrary duck-typed wrapper objects could introduce security risks (like leaking the passphrase). Enforcing a genuine ssl.SSLContext ensures this data goes straight to the standard library. |
||||||
| raise exceptions.MutualTLSChannelError( | ||||||
| "Failed to load client certificate and key for mTLS. The provided context " | ||||||
| "object is invalid or does not support loading certificate chains." | ||||||
| ) | ||||||
|
|
||||||
| try: | ||||||
| with _mtls_helper.secure_cert_key_paths( | ||||||
| cert_bytes, key_bytes, passphrase=passphrase | ||||||
| ) as ( | ||||||
| cert_path, | ||||||
| key_path, | ||||||
| passphrase_val, | ||||||
| ): | ||||||
| if cert_path is None or key_path is None: | ||||||
| raise exceptions.MutualTLSChannelError( | ||||||
| "Failed to generate temporary file paths for the client certificate and key." | ||||||
| ) | ||||||
| ctx.load_cert_chain( | ||||||
| certfile=cert_path, keyfile=key_path, password=passphrase_val | ||||||
| ) | ||||||
| except ( | ||||||
|
nbayati marked this conversation as resolved.
|
||||||
| ssl.SSLError, | ||||||
| OSError, | ||||||
| ValueError, | ||||||
| RuntimeError, | ||||||
| TypeError, | ||||||
| ) as caught_exc: | ||||||
|
Comment on lines
+198
to
+204
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When configuring mTLS, if a custom transport or context is provided that cannot be automatically configured (for example, if loading the client certificate fails), we should gracefully fall back to standard TLS and log a descriptive warning instead of raising a except Exception as caught_exc:
_LOGGER.warning("Failed to configure mTLS with custom transport, falling back to standard TLS: %s", caught_exc)References
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think we should gracefully fall back to standard TLS if loading the client certificate fails. If the environment is configured to use mTLS, silently falling back to standard TLS on a failure could be a security vulnerability or lead to confusing downstream authorization errors. It's safer to fail fast by raising a MutualTLSChannelError so the user knows their mTLS configuration is broken. |
||||||
| new_exc = exceptions.MutualTLSChannelError(caught_exc) | ||||||
| raise new_exc from caught_exc | ||||||
|
|
||||||
|
|
||||||
| def load_default_client_cert(ctx: ssl.SSLContext) -> bool: | ||||||
| """Load the default client certificate and key into an SSL context if configured. | ||||||
|
|
||||||
| If client certificates are enabled and a default client certificate source is | ||||||
| found, the certificate and key are loaded into the SSL context. | ||||||
|
|
||||||
| Args: | ||||||
| ctx (ssl.SSLContext): The SSL context to load the default client certificate | ||||||
| and key into. | ||||||
|
|
||||||
| Returns: | ||||||
| bool: True if client certificates are enabled and the default client | ||||||
| certificate was successfully loaded. False if client certificates | ||||||
| are disabled or if no default certificate source is configured. | ||||||
|
|
||||||
| Raises: | ||||||
| google.auth.exceptions.MutualTLSChannelError: If the default client certificate | ||||||
| or key is malformed. | ||||||
| """ | ||||||
| if not should_use_client_cert() or not has_default_client_cert_source(): | ||||||
| return False | ||||||
| try: | ||||||
| ( | ||||||
| has_cert, | ||||||
| cert_bytes, | ||||||
| key_bytes, | ||||||
| passphrase, | ||||||
| ) = _mtls_helper.get_client_ssl_credentials() | ||||||
| except ( | ||||||
| exceptions.ClientCertError, | ||||||
| OSError, | ||||||
| RuntimeError, | ||||||
| ValueError, | ||||||
| ) as caught_exc: | ||||||
| new_exc = exceptions.MutualTLSChannelError(caught_exc) | ||||||
| raise new_exc from caught_exc | ||||||
| else: | ||||||
| if not has_cert: | ||||||
| return False | ||||||
| _load_client_cert_into_context(ctx, cert_bytes, key_bytes, passphrase) | ||||||
| return True | ||||||
|
|
||||||
|
|
||||||
| def get_default_ssl_context() -> Optional[ssl.SSLContext]: | ||||||
| """Get a default SSL context loaded with the default client certificate. | ||||||
|
|
||||||
| Returns: | ||||||
| ssl.SSLContext: An SSL context loaded with the default client | ||||||
| certificate, or None if client certificates are not configured | ||||||
| or available. | ||||||
|
|
||||||
| Raises: | ||||||
| google.auth.exceptions.MutualTLSChannelError: If the default client certificate | ||||||
| or key is malformed. | ||||||
| """ | ||||||
| if not should_use_client_cert() or not has_default_client_cert_source(): | ||||||
| return None | ||||||
|
|
||||||
| ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) | ||||||
|
nbayati marked this conversation as resolved.
|
||||||
| return ctx if load_default_client_cert(ctx) else None | ||||||
|
|
||||||
|
|
||||||
| def should_use_mtls_endpoint( | ||||||
| client_cert_available: Optional[bool] = None, | ||||||
| ) -> bool: | ||||||
| """Determine whether to use an mTLS endpoint. | ||||||
|
|
||||||
| This relies on the GOOGLE_API_USE_MTLS_ENDPOINT environment variable. If set to | ||||||
| "always", returns True. If set to "never", returns False. If set to "auto" | ||||||
| or unset, returns whether a client certificate is available. | ||||||
|
|
||||||
| Args: | ||||||
| client_cert_available (Optional[bool]): indicating if a client certificate | ||||||
| is available. If None, this is determined by checking if client | ||||||
| certificates are enabled using :func:`should_use_client_cert`. | ||||||
|
|
||||||
| Returns: | ||||||
| bool: indicating if an mTLS endpoint should be used. | ||||||
| """ | ||||||
| if client_cert_available is None: | ||||||
| client_cert_available = should_use_client_cert() | ||||||
|
nbayati marked this conversation as resolved.
|
||||||
|
|
||||||
| use_mtls_endpoint = getenv(environment_vars.GOOGLE_API_USE_MTLS_ENDPOINT) | ||||||
| use_mtls_endpoint = (use_mtls_endpoint or "auto").strip().lower() | ||||||
| try: | ||||||
| mode = UseMtlsEndpointMode(use_mtls_endpoint) | ||||||
| except ValueError: | ||||||
| raise exceptions.MutualTLSChannelError( | ||||||
| f"Unsupported {environment_vars.GOOGLE_API_USE_MTLS_ENDPOINT} value " | ||||||
| f"'{use_mtls_endpoint}'. Accepted values: never, auto, always." | ||||||
| ) | ||||||
|
|
||||||
| if mode == UseMtlsEndpointMode.ALWAYS: | ||||||
| return True | ||||||
| if mode == UseMtlsEndpointMode.NEVER: | ||||||
| return False | ||||||
| if mode == UseMtlsEndpointMode.AUTO: | ||||||
| return client_cert_available | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: it might be cleaner to define an enum instead of these magic strings
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Great suggestion, done! |
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is DefaultClientCertSourceError? I was concerned changing the exception could be a breaking change. But I don't actually see the old one in the codebase?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems like DefaultClientCertSourceError was never defined in this repository. Looking at the PR that introduced the method back in 2020, it has always raised MutualTLSChannelError, but the docstring incorrectly said
DefaultClientCertSourceError.This inconsistency was flagged when I was updating the file and I decided to update the docstring to match the real behavior of the method.