Skip to content
Draft
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:
> ```
> 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 wildcards (e.g. `*.visualstudio.com`). |
| `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
```
40 changes: 18 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,7 @@ 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 (try setting GH_TOKEN and configuring ~/.specify/auth.json)"
Comment thread
mnriem marked this conversation as resolved.
return None, f"HTTP {e.code}"
except (urllib.error.URLError, OSError):
return None, "offline or timeout"
Expand Down Expand Up @@ -2633,7 +2625,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 +3631,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 +4923,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 +4940,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 +5036,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