Skip to content

Commit 44f3ec4

Browse files
committed
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)
1 parent eee6119 commit 44f3ec4

11 files changed

Lines changed: 1151 additions & 566 deletions

File tree

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+
> ```
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 wildcards (e.g. `*.visualstudio.com`). |
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/authentication/__init__.py

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
"""Authentication provider registry for multi-platform support.
22
3-
Follows the same pattern as ``src/specify_cli/integrations/``.
4-
5-
Each provider is a self-contained module that handles credential resolution
6-
and header construction for a specific platform (GitHub, Azure DevOps, etc.).
7-
Built-in providers are instantiated and added to the global ``AUTH_REGISTRY``
8-
by ``_register_builtins()``.
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*.
97
"""
108

119
from __future__ import annotations
@@ -15,7 +13,7 @@
1513
if TYPE_CHECKING:
1614
from .base import AuthProvider
1715

18-
# Maps provider key → AuthProvider instance.
16+
# Maps provider key → AuthProvider class instance.
1917
AUTH_REGISTRY: dict[str, AuthProvider] = {}
2018

2119

@@ -37,21 +35,14 @@ def get_provider(key: str) -> AuthProvider | None:
3735
return AUTH_REGISTRY.get(key)
3836

3937

40-
def configured_providers() -> list[AuthProvider]:
41-
"""Return all providers that currently have credentials configured."""
42-
return [p for p in AUTH_REGISTRY.values() if p.is_configured()]
43-
44-
4538
# -- Register built-in providers -----------------------------------------
4639

4740

4841
def _register_builtins() -> None:
4942
"""Register all built-in authentication providers (alphabetical)."""
50-
# -- Imports (alphabetical) -------------------------------------------
5143
from .azure_devops import AzureDevOpsAuth
5244
from .github import GitHubAuth
5345

54-
# -- Registration (alphabetical) --------------------------------------
5546
_register(AzureDevOpsAuth())
5647
_register(GitHubAuth())
5748

src/specify_cli/authentication/azure_devops.py

Lines changed: 96 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,44 +3,114 @@
33
from __future__ import annotations
44

55
import base64
6+
import json as _json
67
import os
8+
import subprocess
9+
from typing import TYPE_CHECKING
710

811
from .base import AuthProvider
912

13+
if TYPE_CHECKING:
14+
from .config import AuthConfigEntry
15+
16+
# Azure DevOps resource ID for OAuth / Azure AD token acquisition.
17+
_ADO_RESOURCE_ID = "499b84ac-1321-427f-aa17-267ca6975798"
18+
1019

1120
class AzureDevOpsAuth(AuthProvider):
1221
"""Azure DevOps authentication provider.
1322
14-
Resolves credentials from ``AZURE_DEVOPS_PAT`` or ``ADO_TOKEN``
15-
environment variables, checking ``AZURE_DEVOPS_PAT`` first.
23+
Supports four auth schemes:
1624
17-
Azure DevOps uses HTTP Basic Authentication with an empty username and the
18-
Personal Access Token (PAT) as the password. The credentials are
19-
Base64-encoded in the form ``:<PAT>`` as required by the Azure DevOps REST
20-
API.
25+
* ``basic-pat`` — PAT with empty username, Base64-encoded as ``:<PAT>``
26+
* ``bearer`` — pre-acquired OAuth / Azure AD token
27+
* ``azure-cli`` — acquires a token via ``az account get-access-token``
28+
* ``azure-ad`` — acquires a token via OAuth2 client credentials flow
2129
"""
2230

2331
key = "azure-devops"
32+
supported_auth_schemes = ("basic-pat", "bearer", "azure-cli", "azure-ad")
2433

25-
def get_token(self) -> str | None:
26-
"""Return the first non-empty PAT from AZURE_DEVOPS_PAT or ADO_TOKEN."""
27-
for env_var in ("AZURE_DEVOPS_PAT", "ADO_TOKEN"):
28-
candidate = os.environ.get(env_var)
29-
if candidate is not None:
30-
candidate = candidate.strip()
31-
if candidate:
32-
return candidate
33-
return None
34-
35-
def auth_headers(self) -> dict[str, str]:
36-
"""Return Azure DevOps Basic auth headers, or an empty dict if not configured.
37-
38-
Azure DevOps REST API requires Basic authentication where the password
39-
is the PAT and the username is left empty, encoded as ``:<PAT>`` in
40-
Base64.
41-
"""
42-
token = self.get_token()
43-
if token:
34+
def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]:
35+
"""Build the ``Authorization`` header for the given scheme."""
36+
if auth_scheme == "basic-pat":
4437
encoded = base64.b64encode(f":{token}".encode("ascii")).decode("ascii")
4538
return {"Authorization": f"Basic {encoded}"}
46-
return {}
39+
if auth_scheme in ("bearer", "azure-cli", "azure-ad"):
40+
return {"Authorization": f"Bearer {token}"}
41+
raise ValueError(
42+
f"AzureDevOpsAuth does not support auth scheme {auth_scheme!r}"
43+
)
44+
45+
def resolve_token(self, entry: AuthConfigEntry) -> str | None:
46+
"""Resolve token, with special handling for azure-cli and azure-ad."""
47+
if entry.auth == "azure-cli":
48+
return self._acquire_via_az_cli()
49+
if entry.auth == "azure-ad":
50+
return self._acquire_via_client_credentials(entry)
51+
return super().resolve_token(entry)
52+
53+
# -- Token acquisition ------------------------------------------------
54+
55+
@staticmethod
56+
def _acquire_via_az_cli() -> str | None:
57+
"""Run ``az account get-access-token`` and return the access token."""
58+
try:
59+
result = subprocess.run( # noqa: S603, S607
60+
[
61+
"az",
62+
"account",
63+
"get-access-token",
64+
"--resource",
65+
_ADO_RESOURCE_ID,
66+
"--output",
67+
"json",
68+
],
69+
capture_output=True,
70+
text=True,
71+
timeout=30,
72+
check=False,
73+
)
74+
if result.returncode != 0:
75+
return None
76+
payload = _json.loads(result.stdout)
77+
token = payload.get("accessToken", "").strip()
78+
return token or None
79+
except (OSError, _json.JSONDecodeError, KeyError):
80+
return None
81+
82+
@staticmethod
83+
def _acquire_via_client_credentials(entry: AuthConfigEntry) -> str | None:
84+
"""Acquire a token via OAuth2 client credentials flow."""
85+
import urllib.error
86+
import urllib.request
87+
88+
if not entry.tenant_id or not entry.client_id or not entry.client_secret_env:
89+
return None
90+
client_secret = os.environ.get(entry.client_secret_env, "").strip()
91+
if not client_secret:
92+
return None
93+
94+
url = (
95+
f"https://login.microsoftonline.com/{entry.tenant_id}"
96+
"/oauth2/v2.0/token"
97+
)
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")
104+
105+
req = urllib.request.Request(
106+
url,
107+
data=body,
108+
headers={"Content-Type": "application/x-www-form-urlencoded"},
109+
)
110+
try:
111+
with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310
112+
payload = _json.loads(resp.read().decode("utf-8"))
113+
token = payload.get("access_token", "").strip()
114+
return token or None
115+
except (urllib.error.URLError, OSError, _json.JSONDecodeError, KeyError):
116+
return None

0 commit comments

Comments
 (0)