Skip to content

Commit f099834

Browse files
CopilotmnriemCopilot
authored
feat: Config-driven opt-in authentication registry with multi-platform support (#2393)
* Initial plan * feat: add authentication provider registry (GitHub + Azure DevOps) Agent-Logs-Url: https://github.com/github/spec-kit/sessions/da7ecfd0-e1c9-48dc-b692-27be0879e976 Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * feat: add try-each-provider HTTP helper and wire all catalog fetches through auth registry - Add authentication/http.py with open_url() that tries each configured provider in registry order, falling through on 401/403 to the next, and finally to unauthenticated - Add build_request() for one-shot request construction - Add configured_providers() to registry __init__ - Remove api_base_url() from AuthProvider ABC (unused) - Remove hosts attribute from providers (no host matching) - Replace _github_http.py usage in ExtensionCatalog and PresetCatalog - Wire IntegrationCatalog and WorkflowCatalog through open_url (were unauthenticated) - Wire _fetch_latest_release_tag() through open_url - Wire all inline --from-url downloads through open_url - Fix unused stub variable flagged by code-quality bot - 49 auth tests (positive + negative), 1805 total tests passing * fix: address review — fix stale docstrings, restore Accept header, add extra_headers to open_url - Fix _open_url() docstrings in extensions.py and presets.py that incorrectly claimed redirect stripping behavior - Add extra_headers parameter to open_url() so callers can pass additional headers (e.g. Accept) that persist across retries - Restore Accept: application/vnd.github+json header in _fetch_latest_release_tag() via extra_headers * feat: config-driven opt-in auth via ~/.specify/auth.json Security-first redesign: no credentials are sent unless the user explicitly creates ~/.specify/auth.json mapping hosts to providers. - Add authentication/config.py: loads and validates auth.json with host-to-provider mappings, supports token/token_env/azure-ad/azure-cli - Refactor AuthProvider ABC: auth_headers(token, scheme) + resolve_token(entry) - Refactor GitHubAuth: bearer scheme only, token from config entry - Refactor AzureDevOpsAuth: 4 schemes (basic-pat, bearer, azure-cli, azure-ad) with dynamic token acquisition for azure-cli and azure-ad - Rewrite authentication/http.py: host matching, redirect stripping, provider fallthrough on 401/403, unauthenticated fallback - Add docs/reference/authentication.md with full reference and template - 1823 tests passing (67 auth-specific) * 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 * fix: reject unknown providers, validate azure-ad fields, strip Authorization from extra_headers - Reject unknown provider keys during auth.json load with clear error message - Validate azure-ad tenant_id/client_id/client_secret_env as non-empty strings - Strip Authorization from extra_headers in both build_request and open_url to prevent accidental or intentional bypass of provider-configured auth - Add tests for unknown provider and incompatible scheme validation - 1831 tests passing * fix: extract shared auth test helpers, global config isolation, align docstring - Move _inject_github_config / make_github_auth_entry to tests/auth_helpers.py to eliminate duplication across test_extensions, test_presets, test_upgrade - Move auth config isolation fixture to global conftest.py (autouse) so ALL tests are isolated from ~/.specify/auth.json, not just test_upgrade - Align load_auth_config docstring with actual behavior: ValueError may be caught by higher-level HTTP helpers that warn and continue unauthenticated - 1831 tests passing * fix: preserve auth header across multi-hop redirect chains - Read Authorization from both headers and unredirected_hdrs in _StripAuthOnRedirect to survive multi-hop chains within allowed hosts - Add test_multi_hop_redirect_within_hosts_preserves_auth - 1832 tests passing * fix: use resolved config path in warning/error messages and patch build_opener in no-network test Agent-Logs-Url: https://github.com/github/spec-kit/sessions/86df9557-54f1-4fe4-a25f-9501cb2356cf Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * fix: assert full resolved config path in rate-limit output test Agent-Logs-Url: https://github.com/github/spec-kit/sessions/86df9557-54f1-4fe4-a25f-9501cb2356cf Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * fix: close HTTPError on 401/403, remove _VALID_AUTH_SCHEMES, catch TimeoutExpired, skip POSIX test on Windows, remove unused import Agent-Logs-Url: https://github.com/github/spec-kit/sessions/a1e29737-dd6e-4287-96c1-509e0c96fb21 Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * fix: use stable ~/.specify/auth.json in rate-limit message, skip POSIX permission check on Windows Agent-Logs-Url: https://github.com/github/spec-kit/sessions/4636bcdb-87ae-45d6-9545-a40e4effd617 Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * fix: validate host patterns, cache auth config per-process Agent-Logs-Url: https://github.com/github/spec-kit/sessions/889b58a7-7f8c-47e2-8056-931ebcc671cc Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * fix: clarify _is_valid_host_pattern docstring, clean up test sentinel type Agent-Logs-Url: https://github.com/github/spec-kit/sessions/889b58a7-7f8c-47e2-8056-931ebcc671cc Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * fix: improve _is_valid_host_pattern docstring and test observability Agent-Logs-Url: https://github.com/github/spec-kit/sessions/889b58a7-7f8c-47e2-8056-931ebcc671cc Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 5563269 commit f099834

19 files changed

Lines changed: 1851 additions & 174 deletions

docs/reference/authentication.md

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
# Authentication
2+
3+
Specify CLI uses **opt-in authentication** for HTTP requests to catalog
4+
sources, extension downloads, and release checks. No credentials are
5+
sent unless you explicitly configure them.
6+
7+
## Configuration
8+
9+
Create `~/.specify/auth.json` to enable authentication:
10+
11+
```json
12+
{
13+
"providers": [
14+
{
15+
"hosts": ["github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"],
16+
"provider": "github",
17+
"auth": "bearer",
18+
"token_env": "GH_TOKEN"
19+
}
20+
]
21+
}
22+
```
23+
24+
> **Security:** Restrict the file to owner-only access:
25+
> ```bash
26+
> chmod 600 ~/.specify/auth.json
27+
> ```
28+
29+
Without this file, all HTTP requests are unauthenticated.
30+
31+
## Fields
32+
33+
Each entry in the `providers` array has the following fields:
34+
35+
| Field | Required | Description |
36+
|---|---|---|
37+
| `hosts` | Yes | Array of hostnames this entry applies to. Supports exact hostnames, or a leading `*.` wildcard for subdomains only (for example, `*.visualstudio.com`). `*.visualstudio.com` matches `foo.visualstudio.com`, but not `visualstudio.com`. Other glob patterns such as `*github.com` or `gith?b.com` are not supported. |
38+
| `provider` | Yes | Built-in provider key: `github` or `azure-devops`. |
39+
| `auth` | Yes | Auth scheme (see below). |
40+
| `token` | No | Token value (inline). Use `token_env` instead when possible. |
41+
| `token_env` | No | Environment variable name to read the token from. |
42+
43+
For `azure-ad` auth, additional fields are required:
44+
45+
| Field | Required | Description |
46+
|---|---|---|
47+
| `tenant_id` | Yes | Azure AD tenant ID. |
48+
| `client_id` | Yes | Service principal client ID. |
49+
| `client_secret_env` | Yes | Environment variable containing the client secret. |
50+
51+
Either `token` or `token_env` must be set for `bearer` and `basic-pat` schemes.
52+
53+
## Providers and auth schemes
54+
55+
### GitHub (`github`)
56+
57+
| Scheme | Header | Use for |
58+
|---|---|---|
59+
| `bearer` | `Authorization: Bearer <token>` | PATs, fine-grained PATs, OAuth tokens, GitHub App tokens |
60+
61+
**Example — PAT via environment variable:**
62+
63+
```json
64+
{
65+
"hosts": ["github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"],
66+
"provider": "github",
67+
"auth": "bearer",
68+
"token_env": "GH_TOKEN"
69+
}
70+
```
71+
72+
### Azure DevOps (`azure-devops`)
73+
74+
| Scheme | Header | Use for |
75+
|---|---|---|
76+
| `basic-pat` | `Authorization: Basic base64(:<PAT>)` | Personal Access Tokens |
77+
| `bearer` | `Authorization: Bearer <token>` | Pre-acquired OAuth / Azure AD tokens |
78+
| `azure-cli` | `Authorization: Bearer <token>` | Token acquired via `az account get-access-token` |
79+
| `azure-ad` | `Authorization: Bearer <token>` | Token acquired via OAuth2 client credentials flow |
80+
81+
**Example — PAT via environment variable:**
82+
83+
```json
84+
{
85+
"hosts": ["dev.azure.com"],
86+
"provider": "azure-devops",
87+
"auth": "basic-pat",
88+
"token_env": "AZURE_DEVOPS_PAT"
89+
}
90+
```
91+
92+
**Example — Azure CLI (interactive login):**
93+
94+
```json
95+
{
96+
"hosts": ["dev.azure.com"],
97+
"provider": "azure-devops",
98+
"auth": "azure-cli"
99+
}
100+
```
101+
102+
Requires `az login` to have been run beforehand.
103+
104+
**Example — Azure AD service principal (CI/automation):**
105+
106+
```json
107+
{
108+
"hosts": ["dev.azure.com"],
109+
"provider": "azure-devops",
110+
"auth": "azure-ad",
111+
"tenant_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
112+
"client_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
113+
"client_secret_env": "AZURE_CLIENT_SECRET"
114+
}
115+
```
116+
117+
## Multiple entries
118+
119+
You can configure multiple entries for different hosts or organizations:
120+
121+
```json
122+
{
123+
"providers": [
124+
{
125+
"hosts": ["github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"],
126+
"provider": "github",
127+
"auth": "bearer",
128+
"token_env": "GH_TOKEN"
129+
},
130+
{
131+
"hosts": ["dev.azure.com"],
132+
"provider": "azure-devops",
133+
"auth": "basic-pat",
134+
"token_env": "AZURE_DEVOPS_PAT"
135+
}
136+
]
137+
}
138+
```
139+
140+
## How it works
141+
142+
1. For each outbound HTTP request, the URL hostname is matched against
143+
the `hosts` patterns in `auth.json`.
144+
2. If a match is found, the corresponding provider resolves the token
145+
and attaches the appropriate `Authorization` header.
146+
3. If the request receives a 401 or 403, the next matching entry is tried.
147+
4. After all matching entries are exhausted, an unauthenticated request
148+
is attempted as a final fallback.
149+
5. On redirects, the `Authorization` header is stripped if the redirect
150+
target leaves the entry's declared hosts — preventing credential
151+
leakage to CDNs or third-party services.
152+
153+
## Template
154+
155+
A reference `auth.json` with GitHub pre-configured:
156+
157+
```json
158+
{
159+
"providers": [
160+
{
161+
"hosts": [
162+
"github.com",
163+
"api.github.com",
164+
"raw.githubusercontent.com",
165+
"codeload.github.com"
166+
],
167+
"provider": "github",
168+
"auth": "bearer",
169+
"token_env": "GH_TOKEN"
170+
}
171+
]
172+
}
173+
```
174+
175+
To use it:
176+
177+
```bash
178+
mkdir -p ~/.specify
179+
# Copy the JSON above into ~/.specify/auth.json
180+
chmod 600 ~/.specify/auth.json
181+
```

src/specify_cli/__init__.py

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1762,22 +1762,14 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
17621762
On anything else — including a malformed response body — the exception
17631763
propagates; there is no catch-all (research D-006).
17641764
"""
1765-
req = urllib.request.Request(
1766-
GITHUB_API_LATEST,
1767-
headers={"Accept": "application/vnd.github+json"},
1768-
)
1769-
token = None
1770-
for env_var in ("GH_TOKEN", "GITHUB_TOKEN"):
1771-
candidate = os.environ.get(env_var)
1772-
if candidate is not None:
1773-
candidate = candidate.strip()
1774-
if candidate:
1775-
token = candidate
1776-
break
1777-
if token:
1778-
req.add_header("Authorization", f"Bearer {token}")
1765+
from .authentication.http import open_url
1766+
17791767
try:
1780-
with urllib.request.urlopen(req, timeout=5) as resp:
1768+
with open_url(
1769+
GITHUB_API_LATEST,
1770+
timeout=5,
1771+
extra_headers={"Accept": "application/vnd.github+json"},
1772+
) as resp:
17811773
payload = json.loads(resp.read().decode("utf-8"))
17821774
tag = payload.get("tag_name")
17831775
if not isinstance(tag, str) or not tag:
@@ -1786,7 +1778,9 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
17861778
except urllib.error.HTTPError as e:
17871779
# Order matters: HTTPError is a subclass of URLError.
17881780
if e.code == 403:
1789-
return None, "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)"
1781+
return None, (
1782+
"rate limited (configure ~/.specify/auth.json with a GitHub token)"
1783+
)
17901784
return None, f"HTTP {e.code}"
17911785
except (urllib.error.URLError, OSError):
17921786
return None, "offline or timeout"
@@ -3381,7 +3375,9 @@ def preset_add(
33813375
with tempfile.TemporaryDirectory() as tmpdir:
33823376
zip_path = Path(tmpdir) / "preset.zip"
33833377
try:
3384-
with urllib.request.urlopen(from_url, timeout=60) as response:
3378+
from specify_cli.authentication.http import open_url as _open_url
3379+
3380+
with _open_url(from_url, timeout=60) as response:
33853381
zip_path.write_bytes(response.read())
33863382
except urllib.error.URLError as e:
33873383
console.print(f"[red]Error:[/red] Failed to download: {e}")
@@ -4285,7 +4281,9 @@ def extension_add(
42854281
zip_path = download_dir / f"{extension}-url-download.zip"
42864282

42874283
try:
4288-
with urllib.request.urlopen(from_url, timeout=60) as response:
4284+
from specify_cli.authentication.http import open_url as _open_url
4285+
4286+
with _open_url(from_url, timeout=60) as response:
42894287
zip_data = response.read()
42904288
zip_path.write_bytes(zip_data)
42914289

@@ -5500,7 +5498,7 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None:
55005498
if source.startswith("http://") or source.startswith("https://"):
55015499
from ipaddress import ip_address
55025500
from urllib.parse import urlparse
5503-
from urllib.request import urlopen # noqa: S310
5501+
from specify_cli.authentication.http import open_url as _open_url
55045502

55055503
parsed_src = urlparse(source)
55065504
src_host = parsed_src.hostname or ""
@@ -5517,7 +5515,7 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None:
55175515

55185516
import tempfile
55195517
try:
5520-
with urlopen(source, timeout=30) as resp: # noqa: S310
5518+
with _open_url(source, timeout=30) as resp:
55215519
final_url = resp.geturl()
55225520
final_parsed = urlparse(final_url)
55235521
final_host = final_parsed.hostname or ""
@@ -5613,10 +5611,10 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None:
56135611
workflow_file = workflow_dir / "workflow.yml"
56145612

56155613
try:
5616-
from urllib.request import urlopen # noqa: S310 — URL comes from catalog
5614+
from specify_cli.authentication.http import open_url as _open_url
56175615

56185616
workflow_dir.mkdir(parents=True, exist_ok=True)
5619-
with urlopen(workflow_url, timeout=30) as response: # noqa: S310
5617+
with _open_url(workflow_url, timeout=30) as response:
56205618
# Validate final URL after redirects
56215619
final_url = response.geturl()
56225620
final_parsed = urlparse(final_url)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Authentication provider registry for multi-platform support.
2+
3+
Credentials are **opt-in only**. No authentication headers are sent unless
4+
the user creates ``~/.specify/auth.json`` mapping hosts to providers.
5+
Provider classes define *how* to authenticate (Bearer, Basic-PAT, etc.)
6+
while the config file defines *where* and *with what credentials*.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from typing import TYPE_CHECKING
12+
13+
if TYPE_CHECKING:
14+
from .base import AuthProvider
15+
16+
# Maps provider key → AuthProvider class instance.
17+
AUTH_REGISTRY: dict[str, AuthProvider] = {}
18+
19+
20+
def _register(provider: AuthProvider) -> None:
21+
"""Register a provider instance in the global registry.
22+
23+
Raises ``ValueError`` for falsy keys and ``KeyError`` for duplicates.
24+
"""
25+
key = provider.key
26+
if not key:
27+
raise ValueError("Cannot register provider with an empty key.")
28+
if key in AUTH_REGISTRY:
29+
raise KeyError(f"Provider with key {key!r} is already registered.")
30+
AUTH_REGISTRY[key] = provider
31+
32+
33+
def get_provider(key: str) -> AuthProvider | None:
34+
"""Return the provider for *key*, or ``None`` if not registered."""
35+
return AUTH_REGISTRY.get(key)
36+
37+
38+
# -- Register built-in providers -----------------------------------------
39+
40+
41+
def _register_builtins() -> None:
42+
"""Register all built-in authentication providers (alphabetical)."""
43+
from .azure_devops import AzureDevOpsAuth
44+
from .github import GitHubAuth
45+
46+
_register(AzureDevOpsAuth())
47+
_register(GitHubAuth())
48+
49+
50+
_register_builtins()

0 commit comments

Comments
 (0)