Skip to content

Commit 71e6201

Browse files
jawwad-aliclaude
andauthored
fix(auth): normalize whitespace in auth-config env-var/id references at store time (#3691)
* fix(auth): normalize whitespace in auth-config env-var/id references at store time token_env, client_secret_env, tenant_id, and client_id were VALIDATED on their .strip()ed form but STORED raw, so an accidentally padded value passed validation yet silently broke the downstream verbatim os.environ.get(name) / OAuth-URL lookups — load_auth_config succeeded but resolve_token returned None and the request quietly downgraded to unauthenticated (401/403) with no diagnostic. Normalize these whitespace-insignificant string references with a _norm helper at store time, mirroring how `hosts` is already normalized (h.strip().lower()). `token` is unchanged (already stripped at resolve time). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(auth): cover tenant_id/client_id/client_secret_env normalization Address review: the regression test only covered token_env, but the fix also normalizes tenant_id, client_id, and client_secret_env. Add a padded azure-ad entry asserting all three are stored stripped. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ea6843c commit 71e6201

2 files changed

Lines changed: 59 additions & 4 deletions

File tree

src/specify_cli/authentication/config.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from dataclasses import dataclass
1414
from fnmatch import fnmatch
1515
from pathlib import Path
16+
from typing import Any
1617
from urllib.parse import urlparse
1718

1819

@@ -53,6 +54,19 @@ def _is_valid_host_pattern(pattern: str) -> bool:
5354
return pattern.startswith("*.") and "*" not in pattern[2:]
5455

5556

57+
def _norm(value: Any) -> Any:
58+
"""Strip surrounding whitespace from a whitespace-insignificant string
59+
config reference (env-var names, tenant/client ids) before it is stored.
60+
61+
These fields are validated on their ``.strip()``ed form, so an accidentally
62+
padded value passes validation but then silently breaks the verbatim
63+
``os.environ.get(...)`` / URL lookups downstream. Normalizing at store time
64+
mirrors how ``hosts`` is already handled (``h.strip().lower()``). Non-string
65+
values (e.g. ``None``) pass through unchanged.
66+
"""
67+
return value.strip() if isinstance(value, str) else value
68+
69+
5670
def load_auth_config(
5771
path: Path | None = None,
5872
) -> list[AuthConfigEntry]:
@@ -182,10 +196,10 @@ def load_auth_config(
182196
provider=provider,
183197
auth=auth,
184198
token=token,
185-
token_env=token_env,
186-
tenant_id=entry_raw.get("tenant_id"),
187-
client_id=entry_raw.get("client_id"),
188-
client_secret_env=entry_raw.get("client_secret_env"),
199+
token_env=_norm(token_env),
200+
tenant_id=_norm(entry_raw.get("tenant_id")),
201+
client_id=_norm(entry_raw.get("client_id")),
202+
client_secret_env=_norm(entry_raw.get("client_secret_env")),
189203
)
190204
)
191205

tests/test_authentication.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,27 @@ def test_valid_github_config(self, tmp_path):
9292
assert entries[0].auth == "bearer"
9393
assert entries[0].token_env == "GH_TOKEN"
9494

95+
def test_padded_token_env_is_normalized_and_resolves(self, tmp_path, monkeypatch):
96+
# token_env is validated on its stripped form but was stored raw, so a
97+
# padded env-var name passed validation yet broke the verbatim
98+
# os.environ.get() lookup — resolve_token silently returned None.
99+
monkeypatch.setenv("GH_TOKEN", "secret-tok")
100+
cfg = tmp_path / "auth.json"
101+
cfg.write_text(json.dumps({
102+
"providers": [{
103+
"hosts": ["github.com"],
104+
"provider": "github",
105+
"auth": "bearer",
106+
"token_env": " GH_TOKEN ",
107+
}]
108+
}))
109+
entries = load_auth_config(cfg)
110+
assert len(entries) == 1
111+
# Stored normalized (matching how hosts are normalized), ...
112+
assert entries[0].token_env == "GH_TOKEN"
113+
# ... so the env lookup finds the token instead of returning None.
114+
assert GitHubAuth().resolve_token(entries[0]) == "secret-tok"
115+
95116
def test_valid_ado_config(self, tmp_path):
96117
cfg = tmp_path / "auth.json"
97118
cfg.write_text(json.dumps({
@@ -136,6 +157,26 @@ def test_azure_ad_config(self, tmp_path):
136157
assert entries[0].auth == "azure-ad"
137158
assert entries[0].tenant_id == "tid"
138159

160+
def test_padded_azure_ad_refs_are_normalized(self, tmp_path):
161+
# The normalization also covers tenant_id / client_id / client_secret_env
162+
# (used verbatim in the OAuth token URL/body and os.environ.get). Padded
163+
# values were validated on their stripped form but stored raw.
164+
cfg = tmp_path / "auth.json"
165+
cfg.write_text(json.dumps({
166+
"providers": [{
167+
"hosts": ["dev.azure.com"],
168+
"provider": "azure-devops",
169+
"auth": "azure-ad",
170+
"tenant_id": " tid ",
171+
"client_id": " cid ",
172+
"client_secret_env": " SECRET ",
173+
}]
174+
}))
175+
entries = load_auth_config(cfg)
176+
assert entries[0].tenant_id == "tid"
177+
assert entries[0].client_id == "cid"
178+
assert entries[0].client_secret_env == "SECRET"
179+
139180
def test_azure_cli_config(self, tmp_path):
140181
cfg = tmp_path / "auth.json"
141182
cfg.write_text(json.dumps({

0 commit comments

Comments
 (0)