Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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:
> ```
> 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
```
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