Skip to content

Commit 543ed48

Browse files
committed
fix: address review — unused imports, host normalization, provider+scheme validation, security hardening
- Remove unused imports (os, field, Any) in config.py - Normalize hosts during load (strip + lowercase) - Validate token/token_env are non-empty strings during load - Validate provider+scheme compatibility during load - Fix extra_headers order: auth headers applied last, cannot be overridden - Remove unused 'tried' variable in http.py - Warn (once) on malformed auth.json instead of silent fallback - URL-encode OAuth2 client credentials body in azure_devops.py - Update 403 message to mention auth.json configuration - Fix registry leak in test_register_duplicate (try/finally) - Fix import style consistency in test_authentication.py - Add azure-cli and azure-ad token acquisition tests (mock subprocess/urlopen) - Add autouse fixture to isolate upgrade tests from real auth.json - 1829 tests passing
1 parent 44f3ec4 commit 543ed48

6 files changed

Lines changed: 130 additions & 27 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1737,7 +1737,7 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
17371737
except urllib.error.HTTPError as e:
17381738
# Order matters: HTTPError is a subclass of URLError.
17391739
if e.code == 403:
1740-
return None, "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)"
1740+
return None, "rate limited (try setting GH_TOKEN and configuring ~/.specify/auth.json)"
17411741
return None, f"HTTP {e.code}"
17421742
except (urllib.error.URLError, OSError):
17431743
return None, "offline or timeout"

src/specify_cli/authentication/azure_devops.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -95,12 +95,13 @@ def _acquire_via_client_credentials(entry: AuthConfigEntry) -> str | None:
9595
f"https://login.microsoftonline.com/{entry.tenant_id}"
9696
"/oauth2/v2.0/token"
9797
)
98-
body = (
99-
f"grant_type=client_credentials"
100-
f"&client_id={entry.client_id}"
101-
f"&client_secret={client_secret}"
102-
f"&scope={_ADO_RESOURCE_ID}/.default"
103-
).encode("utf-8")
98+
from urllib.parse import urlencode
99+
body = urlencode({
100+
"grant_type": "client_credentials",
101+
"client_id": entry.client_id,
102+
"client_secret": client_secret,
103+
"scope": f"{_ADO_RESOURCE_ID}/.default",
104+
}).encode("utf-8")
104105

105106
req = urllib.request.Request(
106107
url,

src/specify_cli/authentication/config.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,10 @@
88
from __future__ import annotations
99

1010
import json
11-
import os
1211
import stat
13-
from dataclasses import dataclass, field
12+
from dataclasses import dataclass
1413
from fnmatch import fnmatch
1514
from pathlib import Path
16-
from typing import Any
1715
from urllib.parse import urlparse
1816

1917

@@ -94,8 +92,10 @@ def load_auth_config(
9492
hosts = entry_raw.get("hosts")
9593
if not isinstance(hosts, list) or not hosts:
9694
raise ValueError(f"providers[{i}]: 'hosts' must be a non-empty array")
97-
if not all(isinstance(h, str) and h for h in hosts):
95+
if not all(isinstance(h, str) and h.strip() for h in hosts):
9896
raise ValueError(f"providers[{i}]: each host must be a non-empty string")
97+
# Normalize hosts: strip whitespace and lowercase
98+
hosts = [h.strip().lower() for h in hosts]
9999

100100
provider = entry_raw.get("provider", "")
101101
if not isinstance(provider, str) or not provider:
@@ -113,6 +113,21 @@ def load_auth_config(
113113
token = entry_raw.get("token")
114114
token_env = entry_raw.get("token_env")
115115

116+
# Validate token/token_env types
117+
if token is not None and (not isinstance(token, str) or not token.strip()):
118+
raise ValueError(f"providers[{i}]: 'token' must be a non-empty string")
119+
if token_env is not None and (not isinstance(token_env, str) or not token_env.strip()):
120+
raise ValueError(f"providers[{i}]: 'token_env' must be a non-empty string")
121+
122+
# Validate provider+scheme compatibility
123+
from . import get_provider as _get_provider
124+
_prov = _get_provider(provider)
125+
if _prov is not None and auth not in _prov.supported_auth_schemes:
126+
raise ValueError(
127+
f"providers[{i}]: provider {provider!r} does not support "
128+
f"auth scheme {auth!r}; supported: {list(_prov.supported_auth_schemes)}"
129+
)
130+
116131
# Validate token source based on auth scheme
117132
if auth in ("bearer", "basic-pat"):
118133
if not token and not token_env:

src/specify_cli/authentication/http.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,14 @@ def _load_config() -> list[AuthConfigEntry]:
2929
return _config_override
3030
try:
3131
return load_auth_config()
32-
except (ValueError, OSError):
32+
except (ValueError, OSError) as exc:
33+
import warnings
34+
warnings.warn(
35+
f"Failed to load ~/.specify/auth.json: {exc}. "
36+
"All requests will be unauthenticated.",
37+
UserWarning,
38+
stacklevel=2,
39+
)
3340
return []
3441

3542

@@ -67,6 +74,9 @@ def build_request(url: str, extra_headers: dict[str, str] | None = None) -> urll
6774
Returns a plain request when no entry matches or the file doesn't exist.
6875
"""
6976
headers: dict[str, str] = {}
77+
if extra_headers:
78+
headers.update(extra_headers)
79+
# Auth headers applied last — cannot be overridden by extra_headers
7080
entries = find_entries_for_url(url, _load_config())
7181
for entry in entries:
7282
provider = get_provider(entry.provider)
@@ -76,8 +86,6 @@ def build_request(url: str, extra_headers: dict[str, str] | None = None) -> urll
7686
if token:
7787
headers.update(provider.auth_headers(token, entry.auth))
7888
break
79-
if extra_headers:
80-
headers.update(extra_headers)
8189
return urllib.request.Request(url, headers=headers)
8290

8391

@@ -95,21 +103,21 @@ def open_url(url: str, timeout: int = 10, extra_headers: dict[str, str] | None =
95103
entries = find_entries_for_url(url, _load_config())
96104

97105
def _make_req(auth_headers: dict[str, str]) -> urllib.request.Request:
98-
merged = {**auth_headers}
106+
merged = {}
99107
if extra_headers:
100108
merged.update(extra_headers)
109+
# Auth headers applied last — cannot be overridden by extra_headers
110+
merged.update(auth_headers)
101111
return urllib.request.Request(url, headers=merged)
102112

103113
# Try each matching entry
104-
tried = 0
105114
for entry in entries:
106115
provider = get_provider(entry.provider)
107116
if provider is None:
108117
continue
109118
token = provider.resolve_token(entry)
110119
if not token:
111120
continue
112-
tried += 1
113121

114122
req = _make_req(provider.auth_headers(token, entry.auth))
115123
opener = urllib.request.build_opener(_StripAuthOnRedirect(entry.hosts))

tests/test_authentication.py

Lines changed: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -303,10 +303,12 @@ def test_register_duplicate_raises_key_error(self):
303303
class _UniqueStub(_StubProvider):
304304
key = "__test_duplicate__"
305305

306-
_register(_UniqueStub())
307-
with pytest.raises(KeyError, match="already registered"):
306+
try:
308307
_register(_UniqueStub())
309-
AUTH_REGISTRY.pop("__test_duplicate__", None)
308+
with pytest.raises(KeyError, match="already registered"):
309+
_register(_UniqueStub())
310+
finally:
311+
AUTH_REGISTRY.pop("__test_duplicate__", None)
310312

311313
def test_register_empty_key_raises_value_error(self):
312314
class _EmptyKey(_StubProvider):
@@ -406,6 +408,76 @@ def test_supported_schemes(self):
406408
assert "azure-cli" in schemes
407409
assert "azure-ad" in schemes
408410

411+
def test_resolve_token_azure_cli_success(self):
412+
"""azure-cli acquires token via az CLI."""
413+
from unittest.mock import patch, MagicMock
414+
entry = AuthConfigEntry(
415+
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli",
416+
)
417+
result = MagicMock()
418+
result.returncode = 0
419+
result.stdout = '{"accessToken": "cli-acquired-token"}'
420+
with patch("specify_cli.authentication.azure_devops.subprocess.run", return_value=result):
421+
assert AzureDevOpsAuth().resolve_token(entry) == "cli-acquired-token"
422+
423+
def test_resolve_token_azure_cli_failure_returns_none(self):
424+
"""azure-cli returns None when az CLI fails."""
425+
from unittest.mock import patch, MagicMock
426+
entry = AuthConfigEntry(
427+
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli",
428+
)
429+
result = MagicMock()
430+
result.returncode = 1
431+
result.stdout = ""
432+
with patch("specify_cli.authentication.azure_devops.subprocess.run", return_value=result):
433+
assert AzureDevOpsAuth().resolve_token(entry) is None
434+
435+
def test_resolve_token_azure_cli_not_installed_returns_none(self):
436+
"""azure-cli returns None when az is not installed."""
437+
from unittest.mock import patch
438+
entry = AuthConfigEntry(
439+
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli",
440+
)
441+
with patch("specify_cli.authentication.azure_devops.subprocess.run", side_effect=OSError("not found")):
442+
assert AzureDevOpsAuth().resolve_token(entry) is None
443+
444+
def test_resolve_token_azure_ad_success(self, monkeypatch):
445+
"""azure-ad acquires token via OAuth2 client credentials."""
446+
from unittest.mock import patch, MagicMock
447+
monkeypatch.setenv("MY_SECRET", "secret-value")
448+
entry = AuthConfigEntry(
449+
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
450+
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
451+
)
452+
mock_resp = MagicMock()
453+
mock_resp.read.return_value = b'{"access_token": "ad-acquired-token"}'
454+
mock_resp.__enter__ = lambda s: s
455+
mock_resp.__exit__ = MagicMock(return_value=False)
456+
with patch("urllib.request.urlopen", return_value=mock_resp):
457+
assert AzureDevOpsAuth().resolve_token(entry) == "ad-acquired-token"
458+
459+
def test_resolve_token_azure_ad_missing_secret_returns_none(self, monkeypatch):
460+
"""azure-ad returns None when client secret env var is missing."""
461+
monkeypatch.delenv("MY_SECRET", raising=False)
462+
entry = AuthConfigEntry(
463+
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
464+
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
465+
)
466+
assert AzureDevOpsAuth().resolve_token(entry) is None
467+
468+
def test_resolve_token_azure_ad_network_error_returns_none(self, monkeypatch):
469+
"""azure-ad returns None on network errors."""
470+
import urllib.error
471+
from unittest.mock import patch
472+
monkeypatch.setenv("MY_SECRET", "secret-value")
473+
entry = AuthConfigEntry(
474+
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
475+
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
476+
)
477+
with patch("urllib.request.urlopen",
478+
side_effect=urllib.error.URLError("connection refused")):
479+
assert AzureDevOpsAuth().resolve_token(entry) is None
480+
409481

410482
# ---------------------------------------------------------------------------
411483
# open_url / build_request — positive tests
@@ -414,7 +486,7 @@ def test_supported_schemes(self):
414486

415487
class TestAuthenticatedHttp:
416488
def _set_config(self, monkeypatch, entries):
417-
import specify_cli.authentication.http as _mod
489+
from specify_cli.authentication import http as _mod
418490
monkeypatch.setattr(_mod, "_config_override", entries)
419491

420492
def test_build_request_attaches_auth_for_matching_host(self, monkeypatch):
@@ -515,7 +587,7 @@ def fake_side_effect(req, timeout=None):
515587

516588
class TestAuthenticatedHttpNegative:
517589
def _set_config(self, monkeypatch, entries):
518-
import specify_cli.authentication.http as _mod
590+
from specify_cli.authentication import http as _mod
519591
monkeypatch.setattr(_mod, "_config_override", entries)
520592

521593
def test_500_raises_immediately(self, monkeypatch):
@@ -601,7 +673,7 @@ def test_redirect_outside_hosts_strips_auth(self):
601673

602674
class TestFetchLatestReleaseTagDelegation:
603675
def _set_config(self, monkeypatch, entries):
604-
import specify_cli.authentication.http as _mod
676+
from specify_cli.authentication import http as _mod
605677
monkeypatch.setattr(_mod, "_config_override", entries)
606678

607679
def _capture_request(self):

tests/test_upgrade.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@
2424
app,
2525
)
2626

27+
28+
@pytest.fixture(autouse=True)
29+
def _isolate_auth_config(monkeypatch):
30+
"""Ensure tests never read the real ~/.specify/auth.json."""
31+
from specify_cli.authentication import http as _mod
32+
monkeypatch.setattr(_mod, "_config_override", [])
33+
2734
from tests.conftest import strip_ansi
2835

2936
runner = CliRunner()
@@ -223,7 +230,7 @@ def test_403_maps_to_rate_limited(self):
223230
):
224231
tag, reason = _fetch_latest_release_tag()
225232
assert tag is None
226-
assert reason == "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)"
233+
assert reason == "rate limited (try setting GH_TOKEN and configuring ~/.specify/auth.json)"
227234

228235
@pytest.mark.parametrize("code", [404, 500, 502])
229236
def test_other_http_uses_code_string(self, code):
@@ -247,7 +254,7 @@ def test_generic_exception_propagates(self):
247254

248255
_FAILURE_CASES = [
249256
("offline or timeout", urllib.error.URLError("down")),
250-
("rate limited (try setting GH_TOKEN or GITHUB_TOKEN)", _http_error(403)),
257+
("rate limited (try setting GH_TOKEN and configuring ~/.specify/auth.json)", _http_error(403)),
251258
("HTTP 500", _http_error(500)),
252259
]
253260

@@ -263,10 +270,10 @@ def test_failure_prints_installed_plus_one_line_reason(
263270
result = runner.invoke(app, ["self", "check"])
264271
output = strip_ansi(result.output)
265272
assert "Installed: 0.7.4" in output
266-
if expected_reason == "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)":
273+
if expected_reason == "rate limited (try setting GH_TOKEN and configuring ~/.specify/auth.json)":
267274
assert "Could not check latest release: rate limited" in output
268275
assert "GH_TOKEN" in output
269-
assert "GITHUB_TOKEN" in output
276+
assert "auth.json" in output
270277
else:
271278
assert f"Could not check latest release: {expected_reason}" in output
272279

0 commit comments

Comments
 (0)