Skip to content
This repository was archived by the owner on Mar 6, 2026. It is now read-only.

Commit e8f801c

Browse files
Refactor custom headers feature and tests
- Implement `with_headers` in `Credentials` using shallow copy for safety. - Move `PROTECTED_HEADERS` to class attribute `_PROTECTED_HEADERS`. - Refactor `tests/test_credentials.py` to use `TestWithHeaders` class and `pytest.mark.parametrize` for better coverage and readability. - Fix logic to ensure `_custom_headers` are properly isolated between copies.
1 parent 59a5f58 commit e8f801c

2 files changed

Lines changed: 96 additions & 0 deletions

File tree

google/auth/credentials.py

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

1818
import abc
19+
import copy
1920
from enum import Enum
2021
import os
2122
from typing import List
@@ -69,6 +70,14 @@ def __init__(self):
6970

7071
self._use_non_blocking_refresh = False
7172
self._refresh_worker = RefreshThreadManager()
73+
self._custom_headers = {}
74+
75+
_PROTECTED_HEADERS = {
76+
"authorization",
77+
"x-goog-user-project",
78+
"x-goog-api-client",
79+
"x-allowed-locations",
80+
}
7281

7382
@property
7483
def expired(self):
@@ -185,6 +194,7 @@ def apply(self, headers, token=None):
185194
self._apply(headers, token)
186195
if self.quota_project_id:
187196
headers["x-goog-user-project"] = self.quota_project_id
197+
headers.update(self._custom_headers)
188198

189199
def _blocking_refresh(self, request):
190200
if not self.valid:
@@ -233,6 +243,30 @@ def before_request(self, request, method, url, headers):
233243
def with_non_blocking_refresh(self):
234244
self._use_non_blocking_refresh = True
235245

246+
def with_headers(self, headers):
247+
"""Returns a copy of these credentials with additional custom headers.
248+
249+
Args:
250+
headers (Mapping[str, str]): The custom headers to add.
251+
252+
Returns:
253+
google.auth.credentials.Credentials: A new credentials instance.
254+
255+
Raises:
256+
ValueError: If a protected header is included in the input headers.
257+
"""
258+
for key in headers:
259+
if key.lower() in self._PROTECTED_HEADERS:
260+
raise ValueError(
261+
f"Header '{key}' is protected and cannot be set with with_headers. "
262+
"These headers are managed by the library."
263+
)
264+
265+
new_creds = copy.copy(self)
266+
new_creds._custom_headers = self._custom_headers.copy()
267+
new_creds._custom_headers.update(headers)
268+
return new_creds
269+
236270

237271
class CredentialsWithQuotaProject(Credentials):
238272
"""Abstract base for credentials supporting ``with_quota_project`` factory"""

tests/test_credentials.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,68 @@ def test_with_non_blocking_refresh():
7272
assert c._use_non_blocking_refresh
7373

7474

75+
class TestWithHeaders:
76+
def test_add_new_header(self):
77+
credentials = CredentialsImpl()
78+
request = mock.Mock()
79+
80+
creds_with_header = credentials.with_headers({"X-Custom-Header": "value1"})
81+
headers = {}
82+
creds_with_header.before_request(request, "http://example.com", "GET", headers)
83+
84+
assert headers["X-Custom-Header"] == "value1"
85+
assert "authorization" in headers
86+
# Ensure it is a new instance
87+
assert creds_with_header is not credentials
88+
# Ensure original credentials are not modified
89+
assert (
90+
not hasattr(credentials, "_custom_headers") or not credentials._custom_headers
91+
)
92+
93+
def test_update_existing_header(self):
94+
credentials = CredentialsImpl()
95+
request = mock.Mock()
96+
97+
creds_with_header = credentials.with_headers({"X-Custom-Header": "value1"})
98+
creds_updated = creds_with_header.with_headers({"X-Custom-Header": "value2"})
99+
headers = {}
100+
creds_updated.before_request(request, "http://example.com", "GET", headers)
101+
102+
assert headers["X-Custom-Header"] == "value2"
103+
104+
def test_chaining_headers(self):
105+
credentials = CredentialsImpl()
106+
request = mock.Mock()
107+
108+
creds_chained = credentials.with_headers({"X-Header-1": "v1"}).with_headers(
109+
{"X-Header-2": "v2"}
110+
)
111+
headers = {}
112+
creds_chained.before_request(request, "http://example.com", "GET", headers)
113+
114+
assert headers["X-Header-1"] == "v1"
115+
assert headers["X-Header-2"] == "v2"
116+
117+
@pytest.mark.parametrize(
118+
"header_key",
119+
["Authorization", "X-Goog-User-Project", "authorization", "x-allowed-locations"],
120+
)
121+
def test_protected_headers(self, header_key):
122+
credentials = CredentialsImpl()
123+
with pytest.raises(ValueError, match="is protected and cannot be set"):
124+
credentials.with_headers({header_key: "value"})
125+
126+
def test_original_credentials_not_modified(self):
127+
credentials = CredentialsImpl()
128+
request = mock.Mock()
129+
130+
credentials.with_headers({"X-Custom-Header": "value1"})
131+
headers = {}
132+
credentials.before_request(request, "http://example.com", "GET", headers)
133+
134+
assert "X-Custom-Header" not in headers
135+
136+
75137
def test_expired_and_valid():
76138
credentials = CredentialsImpl()
77139
credentials.token = "token"

0 commit comments

Comments
 (0)