Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
fa7479a
Initial plan
Copilot Apr 28, 2026
fbf19dd
feat: add authentication provider registry (GitHub + Azure DevOps)
Copilot Apr 28, 2026
9c292d9
feat: add try-each-provider HTTP helper and wire all catalog fetches …
mnriem Apr 28, 2026
eee6119
fix: address review — fix stale docstrings, restore Accept header, ad…
mnriem Apr 28, 2026
44f3ec4
feat: config-driven opt-in auth via ~/.specify/auth.json
mnriem Apr 28, 2026
543ed48
fix: address review — unused imports, host normalization, provider+sc…
mnriem Apr 28, 2026
36919ec
fix: reject unknown providers, validate azure-ad fields, strip Author…
mnriem Apr 28, 2026
3cd5541
fix: extract shared auth test helpers, global config isolation, align…
mnriem Apr 28, 2026
105f03b
fix: preserve auth header across multi-hop redirect chains
mnriem Apr 28, 2026
3b0fc62
fix: use resolved config path in warning/error messages and patch bui…
Copilot May 6, 2026
68b99cb
fix: assert full resolved config path in rate-limit output test
Copilot May 6, 2026
9024848
fix: close HTTPError on 401/403, remove _VALID_AUTH_SCHEMES, catch Ti…
Copilot May 6, 2026
ef5deb7
fix: use stable ~/.specify/auth.json in rate-limit message, skip POSI…
Copilot May 6, 2026
dda6883
fix: validate host patterns, cache auth config per-process
Copilot May 6, 2026
5841d9f
fix: clarify _is_valid_host_pattern docstring, clean up test sentinel…
Copilot May 6, 2026
2cf2bd3
fix: improve _is_valid_host_pattern docstring and test observability
Copilot May 6, 2026
faca5d7
Potential fix for pull request finding
mnriem May 7, 2026
00f383e
Potential fix for pull request finding
mnriem May 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions docs/reference/authentication.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
# Authentication

Specify CLI uses **opt-in authentication** for HTTP requests to catalog
sources, extension downloads, and release checks. No credentials are
sent unless you explicitly configure them.

## Configuration

Create `~/.specify/auth.json` to enable authentication:

```json
{
"providers": [
{
"hosts": ["github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"],
"provider": "github",
"auth": "bearer",
"token_env": "GH_TOKEN"
}
]
}
```

> **Security:** Restrict the file to owner-only access:
> ```bash
> chmod 600 ~/.specify/auth.json
> ```

Without this file, all HTTP requests are unauthenticated.

## Fields

Each entry in the `providers` array has the following fields:

| Field | Required | Description |
|---|---|---|
| `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. |
| `provider` | Yes | Built-in provider key: `github` or `azure-devops`. |
| `auth` | Yes | Auth scheme (see below). |
| `token` | No | Token value (inline). Use `token_env` instead when possible. |
| `token_env` | No | Environment variable name to read the token from. |

Comment on lines +35 to +42
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tables use leading double pipes (||) which creates an empty first column and is likely unintended / may break markdown rendering and linting. Consider converting these to standard markdown table syntax with single leading/trailing |.

Copilot uses AI. Check for mistakes.
For `azure-ad` auth, additional fields are required:

| Field | Required | Description |
|---|---|---|
| `tenant_id` | Yes | Azure AD tenant ID. |
| `client_id` | Yes | Service principal client ID. |
| `client_secret_env` | Yes | Environment variable containing the client secret. |

Either `token` or `token_env` must be set for `bearer` and `basic-pat` schemes.

## Providers and auth schemes

### GitHub (`github`)

| Scheme | Header | Use for |
|---|---|---|
| `bearer` | `Authorization: Bearer <token>` | PATs, fine-grained PATs, OAuth tokens, GitHub App tokens |

**Example — PAT via environment variable:**

```json
{
"hosts": ["github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"],
"provider": "github",
"auth": "bearer",
"token_env": "GH_TOKEN"
}
```

### Azure DevOps (`azure-devops`)

| Scheme | Header | Use for |
|---|---|---|
| `basic-pat` | `Authorization: Basic base64(:<PAT>)` | Personal Access Tokens |
| `bearer` | `Authorization: Bearer <token>` | Pre-acquired OAuth / Azure AD tokens |
| `azure-cli` | `Authorization: Bearer <token>` | Token acquired via `az account get-access-token` |
| `azure-ad` | `Authorization: Bearer <token>` | Token acquired via OAuth2 client credentials flow |

**Example — PAT via environment variable:**

```json
{
"hosts": ["dev.azure.com"],
"provider": "azure-devops",
"auth": "basic-pat",
"token_env": "AZURE_DEVOPS_PAT"
}
```

**Example — Azure CLI (interactive login):**

```json
{
"hosts": ["dev.azure.com"],
"provider": "azure-devops",
"auth": "azure-cli"
}
```

Requires `az login` to have been run beforehand.

**Example — Azure AD service principal (CI/automation):**

```json
{
"hosts": ["dev.azure.com"],
"provider": "azure-devops",
"auth": "azure-ad",
"tenant_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"client_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"client_secret_env": "AZURE_CLIENT_SECRET"
}
```

## Multiple entries

You can configure multiple entries for different hosts or organizations:

```json
{
"providers": [
{
"hosts": ["github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"],
"provider": "github",
"auth": "bearer",
"token_env": "GH_TOKEN"
},
{
"hosts": ["dev.azure.com"],
"provider": "azure-devops",
"auth": "basic-pat",
"token_env": "AZURE_DEVOPS_PAT"
}
]
}
```

## How it works

1. For each outbound HTTP request, the URL hostname is matched against
the `hosts` patterns in `auth.json`.
2. If a match is found, the corresponding provider resolves the token
and attaches the appropriate `Authorization` header.
3. If the request receives a 401 or 403, the next matching entry is tried.
4. After all matching entries are exhausted, an unauthenticated request
is attempted as a final fallback.
5. On redirects, the `Authorization` header is stripped if the redirect
target leaves the entry's declared hosts — preventing credential
leakage to CDNs or third-party services.

## Template

A reference `auth.json` with GitHub pre-configured:

```json
{
"providers": [
{
"hosts": [
"github.com",
"api.github.com",
"raw.githubusercontent.com",
"codeload.github.com"
],
"provider": "github",
"auth": "bearer",
"token_env": "GH_TOKEN"
}
]
}
```

To use it:

```bash
mkdir -p ~/.specify
# Copy the JSON above into ~/.specify/auth.json
chmod 600 ~/.specify/auth.json
```
42 changes: 20 additions & 22 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1721,22 +1721,14 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
On anything else — including a malformed response body — the exception
propagates; there is no catch-all (research D-006).
"""
req = urllib.request.Request(
GITHUB_API_LATEST,
headers={"Accept": "application/vnd.github+json"},
)
token = None
for env_var in ("GH_TOKEN", "GITHUB_TOKEN"):
candidate = os.environ.get(env_var)
if candidate is not None:
candidate = candidate.strip()
if candidate:
token = candidate
break
if token:
req.add_header("Authorization", f"Bearer {token}")
from .authentication.http import open_url

try:
with urllib.request.urlopen(req, timeout=5) as resp:
with open_url(
GITHUB_API_LATEST,
timeout=5,
extra_headers={"Accept": "application/vnd.github+json"},
) as resp:
Comment thread
mnriem marked this conversation as resolved.
payload = json.loads(resp.read().decode("utf-8"))
Comment thread
mnriem marked this conversation as resolved.
tag = payload.get("tag_name")
if not isinstance(tag, str) or not tag:
Expand All @@ -1745,7 +1737,9 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
except urllib.error.HTTPError as e:
# Order matters: HTTPError is a subclass of URLError.
if e.code == 403:
return None, "rate limited (try setting GH_TOKEN or GITHUB_TOKEN)"
return None, (
"rate limited (configure ~/.specify/auth.json with a GitHub token)"
)
return None, f"HTTP {e.code}"
except (urllib.error.URLError, OSError):
return None, "offline or timeout"
Expand Down Expand Up @@ -2633,7 +2627,9 @@ def preset_add(
with tempfile.TemporaryDirectory() as tmpdir:
zip_path = Path(tmpdir) / "preset.zip"
try:
with urllib.request.urlopen(from_url, timeout=60) as response:
from specify_cli.authentication.http import open_url as _open_url

with _open_url(from_url, timeout=60) as response:
zip_path.write_bytes(response.read())
except urllib.error.URLError as e:
console.print(f"[red]Error:[/red] Failed to download: {e}")
Expand Down Expand Up @@ -3637,7 +3633,9 @@ def extension_add(
zip_path = download_dir / f"{extension}-url-download.zip"

try:
with urllib.request.urlopen(from_url, timeout=60) as response:
from specify_cli.authentication.http import open_url as _open_url

with _open_url(from_url, timeout=60) as response:
zip_data = response.read()
zip_path.write_bytes(zip_data)

Expand Down Expand Up @@ -4927,7 +4925,7 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None:
if source.startswith("http://") or source.startswith("https://"):
from ipaddress import ip_address
from urllib.parse import urlparse
from urllib.request import urlopen # noqa: S310
from specify_cli.authentication.http import open_url as _open_url

parsed_src = urlparse(source)
src_host = parsed_src.hostname or ""
Expand All @@ -4944,7 +4942,7 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None:

import tempfile
try:
with urlopen(source, timeout=30) as resp: # noqa: S310
with _open_url(source, timeout=30) as resp:
final_url = resp.geturl()
final_parsed = urlparse(final_url)
final_host = final_parsed.hostname or ""
Expand Down Expand Up @@ -5040,10 +5038,10 @@ def _validate_and_install_local(yaml_path: Path, source_label: str) -> None:
workflow_file = workflow_dir / "workflow.yml"

try:
from urllib.request import urlopen # noqa: S310 — URL comes from catalog
from specify_cli.authentication.http import open_url as _open_url

workflow_dir.mkdir(parents=True, exist_ok=True)
with urlopen(workflow_url, timeout=30) as response: # noqa: S310
with _open_url(workflow_url, timeout=30) as response:
# Validate final URL after redirects
final_url = response.geturl()
final_parsed = urlparse(final_url)
Expand Down
50 changes: 50 additions & 0 deletions src/specify_cli/authentication/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Authentication provider registry for multi-platform support.

Credentials are **opt-in only**. No authentication headers are sent unless
the user creates ``~/.specify/auth.json`` mapping hosts to providers.
Provider classes define *how* to authenticate (Bearer, Basic-PAT, etc.)
while the config file defines *where* and *with what credentials*.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
from .base import AuthProvider

# Maps provider key → AuthProvider class instance.
AUTH_REGISTRY: dict[str, AuthProvider] = {}


def _register(provider: AuthProvider) -> None:
"""Register a provider instance in the global registry.

Raises ``ValueError`` for falsy keys and ``KeyError`` for duplicates.
"""
key = provider.key
if not key:
raise ValueError("Cannot register provider with an empty key.")
if key in AUTH_REGISTRY:
raise KeyError(f"Provider with key {key!r} is already registered.")
AUTH_REGISTRY[key] = provider


def get_provider(key: str) -> AuthProvider | None:
"""Return the provider for *key*, or ``None`` if not registered."""
return AUTH_REGISTRY.get(key)


# -- Register built-in providers -----------------------------------------


def _register_builtins() -> None:
"""Register all built-in authentication providers (alphabetical)."""
from .azure_devops import AzureDevOpsAuth
from .github import GitHubAuth

_register(AzureDevOpsAuth())
_register(GitHubAuth())


_register_builtins()
Loading
Loading