Skip to content

Commit 77ad53e

Browse files
feat: add request response logging to auth (#1678)
* feat: add functionality to hash data (#1677) * feat: add functionality to hash data * change sensitive fields to private * update to sha512 * update docstring * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * chore: add request-response log helpers (#1685) * chore: add request-response log helpers * fix presubmit * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * feat: opt-in logging support for request / response (#1686) * feat: opt-in logging support for request/response * add pragma no cover * add test coverage for request/response * add code coverage * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * chore: remove logging for async requests (#1698) * chore: remove logging for async requests * change Dict to Mapping * fix mypy and lint issues * address PR feedback * link issue * feat: parse request/response for logging (#1696) * feat: parse request/response for logging * add test case for list * address PR comments * address PR feedback * fix typo * add test coverage * add code coverage * feat: hash sensitive info in logs (#1700) * feat: hash sensitive info in logs * make helper private * add code coverage * address PR feedback * fix mypy type issue * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * feat: add support for async response log (#1733) * feat: add support for async response log * fix whitespace * add await * add code coverage * fix lint * fix lint issues * address PR feedback * address PR feedback * link issue * feat: add request response logs for sync api calls (#1747) * fix: remove dependency on api-core for logging (#1748) * fix: remove dep on api-core for logging * disable propagation to the root logger * update async helpers tests * fix lint issue --------- Co-authored-by: Owl Bot <gcf-owl-bot[bot]@users.noreply.github.com>
1 parent 9e9f813 commit 77ad53e

File tree

9 files changed

+863
-7
lines changed

9 files changed

+863
-7
lines changed

google/auth/_helpers.py

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,38 @@
1818
import calendar
1919
import datetime
2020
from email.message import Message
21+
import hashlib
22+
import json
23+
import logging
2124
import sys
25+
from typing import Any, Dict, Mapping, Optional, Union
2226
import urllib
2327

2428
from google.auth import exceptions
2529

30+
31+
# _BASE_LOGGER_NAME is the base logger for all google-based loggers.
32+
_BASE_LOGGER_NAME = "google"
33+
34+
# _LOGGING_INITIALIZED ensures that base logger is only configured once
35+
# (unless already configured by the end-user).
36+
_LOGGING_INITIALIZED = False
37+
38+
2639
# The smallest MDS cache used by this library stores tokens until 4 minutes from
2740
# expiry.
2841
REFRESH_THRESHOLD = datetime.timedelta(minutes=3, seconds=45)
2942

43+
# TODO(https://github.com/googleapis/google-auth-library-python/issues/1684): Audit and update the list below.
44+
_SENSITIVE_FIELDS = {
45+
"accessToken",
46+
"access_token",
47+
"id_token",
48+
"client_id",
49+
"refresh_token",
50+
"client_secret",
51+
}
52+
3053

3154
def copy_docstring(source_class):
3255
"""Decorator that copies a method's docstring from another class.
@@ -271,3 +294,220 @@ def is_python_3():
271294
bool: True if the Python interpreter is Python 3 and False otherwise.
272295
"""
273296
return sys.version_info > (3, 0)
297+
298+
299+
def _hash_sensitive_info(data: Union[dict, list]) -> Union[dict, list, str]:
300+
"""
301+
Hashes sensitive information within a dictionary.
302+
303+
Args:
304+
data: The dictionary containing data to be processed.
305+
306+
Returns:
307+
A new dictionary with sensitive values replaced by their SHA512 hashes.
308+
If the input is a list, returns a list with each element recursively processed.
309+
If the input is neither a dict nor a list, returns the type of the input as a string.
310+
311+
"""
312+
if isinstance(data, dict):
313+
hashed_data: Dict[Any, Union[Optional[str], dict, list]] = {}
314+
for key, value in data.items():
315+
if key in _SENSITIVE_FIELDS and not isinstance(value, (dict, list)):
316+
hashed_data[key] = _hash_value(value, key)
317+
elif isinstance(value, (dict, list)):
318+
hashed_data[key] = _hash_sensitive_info(value)
319+
else:
320+
hashed_data[key] = value
321+
return hashed_data
322+
elif isinstance(data, list):
323+
hashed_list = []
324+
for val in data:
325+
hashed_list.append(_hash_sensitive_info(val))
326+
return hashed_list
327+
else:
328+
# TODO(https://github.com/googleapis/google-auth-library-python/issues/1701):
329+
# Investigate and hash sensitive info before logging when the data type is
330+
# not a dict or a list.
331+
return str(type(data))
332+
333+
334+
def _hash_value(value, field_name: str) -> Optional[str]:
335+
"""Hashes a value and returns a formatted hash string."""
336+
if value is None:
337+
return None
338+
encoded_value = str(value).encode("utf-8")
339+
hash_object = hashlib.sha512()
340+
hash_object.update(encoded_value)
341+
hex_digest = hash_object.hexdigest()
342+
return f"hashed_{field_name}-{hex_digest}"
343+
344+
345+
def _logger_configured(logger: logging.Logger) -> bool:
346+
"""Determines whether `logger` has non-default configuration
347+
348+
Args:
349+
logger: The logger to check.
350+
351+
Returns:
352+
bool: Whether the logger has any non-default configuration.
353+
"""
354+
return (
355+
logger.handlers != [] or logger.level != logging.NOTSET or not logger.propagate
356+
)
357+
358+
359+
def is_logging_enabled(logger: logging.Logger) -> bool:
360+
"""
361+
Checks if debug logging is enabled for the given logger.
362+
363+
Args:
364+
logger: The logging.Logger instance to check.
365+
366+
Returns:
367+
True if debug logging is enabled, False otherwise.
368+
"""
369+
# NOTE: Log propagation to the root logger is disabled unless
370+
# the base logger i.e. logging.getLogger("google") is
371+
# explicitly configured by the end user. Ideally this
372+
# needs to happen in the client layer (already does for GAPICs).
373+
# However, this is implemented here to avoid logging
374+
# (if a root logger is configured) when a version of google-auth
375+
# which supports logging is used with:
376+
# - an older version of a GAPIC which does not support logging.
377+
# - Apiary client which does not support logging.
378+
global _LOGGING_INITIALIZED
379+
if not _LOGGING_INITIALIZED:
380+
base_logger = logging.getLogger(_BASE_LOGGER_NAME)
381+
if not _logger_configured(base_logger):
382+
base_logger.propagate = False
383+
_LOGGING_INITIALIZED = True
384+
385+
return logger.isEnabledFor(logging.DEBUG)
386+
387+
388+
def request_log(
389+
logger: logging.Logger,
390+
method: str,
391+
url: str,
392+
body: Optional[bytes],
393+
headers: Optional[Mapping[str, str]],
394+
) -> None:
395+
"""
396+
Logs an HTTP request at the DEBUG level if logging is enabled.
397+
398+
Args:
399+
logger: The logging.Logger instance to use.
400+
method: The HTTP method (e.g., "GET", "POST").
401+
url: The URL of the request.
402+
body: The request body (can be None).
403+
headers: The request headers (can be None).
404+
"""
405+
if is_logging_enabled(logger):
406+
content_type = (
407+
headers["Content-Type"] if headers and "Content-Type" in headers else ""
408+
)
409+
json_body = _parse_request_body(body, content_type=content_type)
410+
logged_body = _hash_sensitive_info(json_body)
411+
logger.debug(
412+
"Making request...",
413+
extra={
414+
"httpRequest": {
415+
"method": method,
416+
"url": url,
417+
"body": logged_body,
418+
"headers": headers,
419+
}
420+
},
421+
)
422+
423+
424+
def _parse_request_body(body: Optional[bytes], content_type: str = "") -> Any:
425+
"""
426+
Parses a request body, handling bytes and string types, and different content types.
427+
428+
Args:
429+
body (Optional[bytes]): The request body.
430+
content_type (str): The content type of the request body, e.g., "application/json",
431+
"application/x-www-form-urlencoded", or "text/plain". If empty, attempts
432+
to parse as JSON.
433+
434+
Returns:
435+
Parsed body (dict, str, or None).
436+
- JSON: Decodes if content_type is "application/json" or None (fallback).
437+
- URL-encoded: Parses if content_type is "application/x-www-form-urlencoded".
438+
- Plain text: Returns string if content_type is "text/plain".
439+
- None: Returns if body is None, UTF-8 decode fails, or content_type is unknown.
440+
"""
441+
if body is None:
442+
return None
443+
try:
444+
body_str = body.decode("utf-8")
445+
except (UnicodeDecodeError, AttributeError):
446+
return None
447+
content_type = content_type.lower()
448+
if not content_type or "application/json" in content_type:
449+
try:
450+
return json.loads(body_str)
451+
except (json.JSONDecodeError, TypeError):
452+
return body_str
453+
if "application/x-www-form-urlencoded" in content_type:
454+
parsed_query = urllib.parse.parse_qs(body_str)
455+
result = {k: v[0] for k, v in parsed_query.items()}
456+
return result
457+
if "text/plain" in content_type:
458+
return body_str
459+
return None
460+
461+
462+
def _parse_response(response: Any) -> Any:
463+
"""
464+
Parses a response, attempting to decode JSON.
465+
466+
Args:
467+
response: The response object to parse. This can be any type, but
468+
it is expected to have a `json()` method if it contains JSON.
469+
470+
Returns:
471+
The parsed response. If the response contains valid JSON, the
472+
decoded JSON object (e.g., a dictionary or list) is returned.
473+
If the response does not have a `json()` method or if the JSON
474+
decoding fails, None is returned.
475+
"""
476+
try:
477+
json_response = response.json()
478+
return json_response
479+
except Exception:
480+
# TODO(https://github.com/googleapis/google-auth-library-python/issues/1744):
481+
# Parse and return response payload as json based on different content types.
482+
return None
483+
484+
485+
def _response_log_base(logger: logging.Logger, parsed_response: Any) -> None:
486+
"""
487+
Logs a parsed HTTP response at the DEBUG level.
488+
489+
This internal helper function takes a parsed response and logs it
490+
using the provided logger. It also applies a hashing function to
491+
potentially sensitive information before logging.
492+
493+
Args:
494+
logger: The logging.Logger instance to use for logging.
495+
parsed_response: The parsed HTTP response object (e.g., a dictionary,
496+
list, or the original response if parsing failed).
497+
"""
498+
499+
logged_response = _hash_sensitive_info(parsed_response)
500+
logger.debug("Response received...", extra={"httpResponse": logged_response})
501+
502+
503+
def response_log(logger: logging.Logger, response: Any) -> None:
504+
"""
505+
Logs an HTTP response at the DEBUG level if logging is enabled.
506+
507+
Args:
508+
logger: The logging.Logger instance to use.
509+
response: The HTTP response object to log.
510+
"""
511+
if is_logging_enabled(logger):
512+
json_response = _parse_response(response)
513+
_response_log_base(logger, json_response)

google/auth/aio/_helpers.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Copyright 2025 Google Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Helper functions for commonly used utilities."""
16+
17+
18+
import logging
19+
from typing import Any
20+
21+
from google.auth import _helpers
22+
23+
24+
async def _parse_response_async(response: Any) -> Any:
25+
"""
26+
Parses an async response, attempting to decode JSON.
27+
28+
Args:
29+
response: The response object to parse. This can be any type, but
30+
it is expected to have a `json()` method if it contains JSON.
31+
32+
Returns:
33+
The parsed response. If the response contains valid JSON, the
34+
decoded JSON object (e.g., a dictionary) is returned.
35+
If the response does not have a `json()` method or if the JSON
36+
decoding fails, None is returned.
37+
"""
38+
try:
39+
json_response = await response.json()
40+
return json_response
41+
except Exception:
42+
# TODO(https://github.com/googleapis/google-auth-library-python/issues/1745):
43+
# Parse and return response payload as json based on different content types.
44+
return None
45+
46+
47+
async def response_log_async(logger: logging.Logger, response: Any) -> None:
48+
"""
49+
Logs an Async HTTP response at the DEBUG level if logging is enabled.
50+
51+
Args:
52+
logger: The logging.Logger instance to use.
53+
response: The HTTP response object to log.
54+
"""
55+
if _helpers.is_logging_enabled(logger):
56+
json_response = await _parse_response_async(response)
57+
_helpers._response_log_base(logger, json_response)

google/auth/aio/transport/aiohttp.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"""
1717

1818
import asyncio
19+
import logging
1920
from typing import AsyncGenerator, Mapping, Optional
2021

2122
try:
@@ -27,8 +28,11 @@
2728

2829
from google.auth import _helpers
2930
from google.auth import exceptions
31+
from google.auth.aio import _helpers as _helpers_async
3032
from google.auth.aio import transport
3133

34+
_LOGGER = logging.getLogger(__name__)
35+
3236

3337
class Response(transport.Response):
3438
"""
@@ -155,6 +159,7 @@ async def __call__(
155159
self._session = aiohttp.ClientSession()
156160

157161
client_timeout = aiohttp.ClientTimeout(total=timeout)
162+
_helpers.request_log(_LOGGER, method, url, body, headers)
158163
response = await self._session.request(
159164
method,
160165
url,
@@ -163,6 +168,7 @@ async def __call__(
163168
timeout=client_timeout,
164169
**kwargs,
165170
)
171+
await _helpers_async.response_log_async(_LOGGER, response)
166172
return Response(response)
167173

168174
except aiohttp.ClientError as caught_exc:

google/auth/transport/_aiohttp_requests.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,20 @@
2222

2323
import asyncio
2424
import functools
25+
import logging
2526

2627
import aiohttp # type: ignore
2728
import urllib3 # type: ignore
2829

30+
from google.auth import _helpers
2931
from google.auth import exceptions
3032
from google.auth import transport
33+
from google.auth.aio import _helpers as _helpers_async
3134
from google.auth.transport import requests
3235

36+
37+
_LOGGER = logging.getLogger(__name__)
38+
3339
# Timeout can be re-defined depending on async requirement. Currently made 60s more than
3440
# sync timeout.
3541
_DEFAULT_TIMEOUT = 180 # in seconds
@@ -182,10 +188,11 @@ async def __call__(
182188
self.session = aiohttp.ClientSession(
183189
auto_decompress=False
184190
) # pragma: NO COVER
185-
requests._LOGGER.debug("Making request: %s %s", method, url)
191+
_helpers.request_log(_LOGGER, method, url, body, headers)
186192
response = await self.session.request(
187193
method, url, data=body, headers=headers, timeout=timeout, **kwargs
188194
)
195+
await _helpers_async.response_log_async(_LOGGER, response)
189196
return _CombinedResponse(response)
190197

191198
except aiohttp.ClientError as caught_exc:

0 commit comments

Comments
 (0)