Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 3 additions & 3 deletions .github/security-audit-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -374,9 +374,9 @@ mdurl==0.1.2 \
--hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \
--hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba
# via markdown-it-py
opentelemetry-api==1.45.0 \
--hash=sha256:711ede81773c8025c2c03dac0450bc89f3d30aea6eabcc815c570d4e35a963f7 \
--hash=sha256:80e068aba7cd56c8b58512d6a36f8d25cb1dfaa0c0a4cc1c938ccf9f362d9cb3
opentelemetry-api==1.45.1 \
--hash=sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75 \
--hash=sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb
# via mcp
packaging==26.3 \
--hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \
Expand Down
79 changes: 75 additions & 4 deletions docs/reference/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ Either `token` or `token_env` must be set for the `bearer`, `basic-pat`, and `ba
| Scheme | Header | Use for |
|---|---|---|
| `bearer` | `Authorization: Bearer <token>` | PATs, fine-grained PATs, OAuth tokens, GitHub App tokens |
| `github-cli` | `Authorization: Bearer <token>` | Credentials acquired on demand via `gh auth token` |
**Example — PAT via environment variable:**
Expand All @@ -71,6 +72,51 @@ Either `token` or `token_env` must be set for the `bearer`, `basic-pat`, and `ba
}
```
**Example — GitHub CLI:**

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

Install [GitHub CLI](https://cli.github.com/) and run `gh auth login` before
using this mode. Specify runs the fixed command
`gh auth token --hostname github.com` without a shell, caches the result in
memory for the current process, and never writes the token to disk.
Surrounding whitespace is trimmed; tokens containing non-ASCII characters,
embedded carriage returns, or newlines are rejected without including the
token in the error.
Credential acquisition failures fail the affected request without trying another
credential or an unauthenticated request. They use the existing URL-error
Comment thread
Copilot marked this conversation as resolved.
category, so callers retain their normal error handling (for example, warning
and skipping an unavailable catalog).
Release checks and upgrades preserve the authentication diagnostic rather than
reporting a network outage. Online bundle catalog fetching and reference
validation treat authentication failures as errors, not as availability
fallbacks to packaged snapshots or unchecked references.

For `github-cli` only, the **first hostname in `hosts`** is passed to
`gh auth token --hostname`. Put `github.com` or your enterprise instance's
GitHub CLI login hostname first. It must be an exact hostname, not a wildcard.
All listed hosts remain request-matching destinations authorized to receive
that credential; later entries may use supported wildcards. Specify does not
infer the credential hostname from service prefixes or the requested URL.
The in-memory cache is keyed by the first hostname. Use separate provider
entries for hosts requiring different credentials. Host order has no effect
on existing authentication modes.

### GitHub Enterprise Server (GHES)

To use a private catalog or extension hosted on a GitHub Enterprise Server
Expand Down Expand Up @@ -98,6 +144,28 @@ and `codeload.` subdomains your catalog/extension URLs use. A
`*.ghes.example.com` wildcard matches subdomains but **not** the bare host,
so always include the bare host explicitly.

With `auth: "github-cli"`, list your enterprise instance's GitHub CLI login
hostname first, followed by any request hosts or wildcard patterns that should
receive the same credential:

```json
{
"hosts": [
"ghes.example.com",
"raw.ghes.example.com",
"codeload.ghes.example.com"
],
"provider": "github",
"auth": "github-cli"
}
```

Specify runs `gh auth token --hostname ghes.example.com`, using the first
hostname exactly as configured. Use separate provider entries for separate
GitHub instances. The selected host must already be
authenticated in GitHub CLI; failures suggest `gh auth login --hostname <host>`
without including GitHub CLI command output.

### GitHub Enterprise Cloud with data residency (GHE.com)

GHE.com tenants use separate web and REST API hostnames. List both hosts so
Expand Down Expand Up @@ -264,10 +332,13 @@ You can configure multiple entries for different hosts or organizations:
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.
and attaches the appropriate `Authorization` header. A `github-cli`
acquisition failure ends the request immediately, without trying later
matching entries or making an unauthenticated request.
3. After successful token acquisition, if the request receives a 401 or 403,
the next matching entry is tried.
4. After all matching entries are exhausted without an acquisition failure,
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.
Expand Down
11 changes: 9 additions & 2 deletions src/specify_cli/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"rate limited (configure ~/.specify/auth.json with a GitHub token)"
)
_RESOLUTION_FAILURE_HTTP_PREFIX = "HTTP "
_RESOLUTION_FAILURE_AUTH_PREFIX = "authentication error: "
_FAILURE_INSTALLER_MISSING = "installer-missing"
_FAILURE_INSTALLER_INVALID = "installer-invalid"
_FAILURE_TARGET_TAG_UNPARSEABLE = "target-tag-unparseable"
Expand Down Expand Up @@ -108,11 +109,12 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
"""Return (tag, failure_category). Exactly one outbound call, 5 s timeout.
On success: (tag_name, None).
On a documented network/HTTP failure (added in T029/T030): (None, category).
On a documented authentication/network/HTTP failure: (None, category).
On anything else — including a malformed response body — the exception
propagates; there is no catch-all (research D-006).
"""
from .authentication.http import open_url
from .authentication.github import GitHubCliAuthError

try:
with open_url(
Expand All @@ -131,6 +133,8 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
if not isinstance(tag, str) or not tag:
raise ValueError("GitHub API response missing valid tag_name")
return tag, None
except GitHubCliAuthError as exc:
return None, f"{_RESOLUTION_FAILURE_AUTH_PREFIX}{exc.reason}"
except urllib.error.HTTPError as e:
# Order matters: HTTPError is a subclass of URLError.
# 403 (primary rate limit / abuse detection) and 429 (Too Many Requests /
Expand Down Expand Up @@ -1021,7 +1025,10 @@ def _emit_failure(
"""Render user-facing output for resolver, installer, or verification failures."""
if (
category in _RESOLUTION_FAILURE_CATEGORIES
or category.startswith(_RESOLUTION_FAILURE_HTTP_PREFIX)
or category.startswith((
_RESOLUTION_FAILURE_HTTP_PREFIX,
_RESOLUTION_FAILURE_AUTH_PREFIX,
))
):
console.print(f"Upgrade aborted: {category}", soft_wrap=True)
return
Expand Down
139 changes: 130 additions & 9 deletions src/specify_cli/authentication/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,144 @@

from __future__ import annotations

import os
import shutil
import subprocess
from typing import TYPE_CHECKING
from urllib.error import URLError

from .base import AuthProvider

if TYPE_CHECKING:
from .config import AuthConfigEntry

class GitHubAuth(AuthProvider):
"""GitHub authentication provider.

Supports the ``bearer`` auth scheme, used for PATs, fine-grained PATs,
OAuth tokens, and GitHub App installation tokens.
"""
_GITHUB_CLI_TIMEOUT_SECONDS = 10


def _is_safe_cli_hostname(hostname: str) -> bool:
"""Return whether *hostname* is a shell-safe DNS hostname."""
if not hostname or len(hostname) > 253 or not hostname.isascii():
return False
labels = hostname.split(".")
return all(
0 < len(label) <= 63
and label[0].isalnum()
and label[-1].isalnum()
and all(char.isalnum() or char == "-" for char in label)
for label in labels
)


class GitHubCliAuthError(URLError):
"""Raised when GitHub CLI cannot provide a configured credential."""


class GitHubAuth(AuthProvider):
"""GitHub authentication provider."""

key = "github"
supported_auth_schemes = ("bearer",)
supported_auth_schemes = ("bearer", "github-cli")

def __init__(self) -> None:
self._github_cli_cache: dict[str, tuple[bool, str]] = {}

def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]:
"""Return ``Authorization: Bearer <token>``."""
if auth_scheme != "bearer":
"""Return a bearer authorization header."""
if auth_scheme not in self.supported_auth_schemes:
raise ValueError(
f"GitHubAuth does not support auth scheme {auth_scheme!r}"
)
return {"Authorization": f"Bearer {token}"}
return {"Authorization": "Bearer " + token}

def resolve_token(self, entry: AuthConfigEntry) -> str | None:
"""Resolve a configured token or acquire one from GitHub CLI."""
if entry.auth != "github-cli":
return super().resolve_token(entry)

credential_host = self._credential_hostname(entry)
cached = self._github_cli_cache.get(credential_host)
if cached is not None:
succeeded, value = cached
if succeeded:
return value
raise GitHubCliAuthError(value)

try:
token = self._acquire_via_gh_cli(credential_host)
except GitHubCliAuthError as exc:
message = str(exc.reason)
self._github_cli_cache[credential_host] = (False, message)
raise GitHubCliAuthError(message) from None

self._github_cli_cache[credential_host] = (True, token)
return token

@staticmethod
def _credential_hostname(entry: AuthConfigEntry) -> str:
"""Use the first configured host for GitHub CLI credential acquisition."""
hostname = entry.hosts[0]
Comment thread
mnriem marked this conversation as resolved.
if "*" in hostname:
raise GitHubCliAuthError(
"GitHub authentication failed for provider 'github': "
"the first hostname in a github-cli entry must be an exact "
"hostname, not a wildcard."
)
return hostname

@staticmethod
def _acquire_via_gh_cli(hostname: str) -> str:
"""Run the fixed GitHub CLI token command for *hostname*."""
if not _is_safe_cli_hostname(hostname):
raise GitHubCliAuthError(
"GitHub authentication failed for provider 'github': "
"the configured hostname is not valid for GitHub CLI authentication."
)

resolved = shutil.which("gh")
if not resolved or not os.path.isabs(resolved):
Comment thread
mnriem marked this conversation as resolved.
raise GitHubCliAuthError(
"GitHub authentication failed for provider 'github': "
"GitHub CLI ('gh') was not found."
)

try:
result = subprocess.run( # noqa: S603
[resolved, "auth", "token", "--hostname", hostname],
capture_output=True,
text=True,
timeout=_GITHUB_CLI_TIMEOUT_SECONDS,
check=False,
shell=False,
)
except subprocess.TimeoutExpired:
raise GitHubCliAuthError(
"GitHub authentication failed for provider 'github': "
f"GitHub CLI timed out while requesting a token for {hostname!r}."
) from None
except (OSError, UnicodeDecodeError):
raise GitHubCliAuthError(
"GitHub authentication failed for provider 'github': "
f"GitHub CLI could not provide a token for {hostname!r}."
) from None

if result.returncode != 0:
raise GitHubCliAuthError(
"GitHub authentication failed for provider 'github': "
f"GitHub CLI could not provide a token for {hostname!r}. "
f"Run 'gh auth login --hostname {hostname}'."
)

token = result.stdout.strip()
if not token:
raise GitHubCliAuthError(
"GitHub authentication failed for provider 'github': "
f"GitHub CLI returned an empty token for {hostname!r}. "
f"Run 'gh auth login --hostname {hostname}'."
)
if not token.isascii() or "\r" in token or "\n" in token:
raise GitHubCliAuthError(
"GitHub authentication failed for provider 'github': "
f"GitHub CLI returned a malformed token for {hostname!r}."
)
return token
11 changes: 8 additions & 3 deletions src/specify_cli/authentication/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ def build_request(url: str, extra_headers: dict[str, str] | None = None) -> urll
Uses the first matching entry from ``auth.json`` whose token resolves.
Returns a plain request when no entry matches or the file doesn't exist.
A ``github-cli`` acquisition failure raises immediately, without trying
later matching entries or returning an unauthenticated request.
"""
headers: dict[str, str] = {}
if extra_headers:
Expand Down Expand Up @@ -174,9 +176,12 @@ def open_url(
"""Open *url* with config-driven auth, redirect stripping, and fallthrough.
1. Find ``auth.json`` entries whose hosts match the URL.
2. For each entry, resolve the token and try the request.
3. On 401/403 move to the next matching entry.
4. After all entries exhausted (or none matched), try unauthenticated.
2. For each entry, resolve the token and try the request. A ``github-cli``
acquisition failure raises immediately, with no credential or
unauthenticated fallback.
3. On HTTP 401/403 after successful acquisition, try the next matching entry.
4. After all entries exhausted without an acquisition failure (or none
matched), try unauthenticated.
5. Non-auth errors (404, 500, network) raise immediately.
*extra_headers* (e.g. ``Accept``) are merged into every attempt.
Expand Down
7 changes: 4 additions & 3 deletions src/specify_cli/bundles/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ def _http_get_json(source_id: str, url: str) -> dict:
HTTPS/host guarantee from ``_validate_remote_url`` is preserved end to end
rather than only on the initial URL.
"""
from ..authentication.github import GitHubCliAuthError
from ..authentication.http import RedirectPolicyError, open_url

def _validate_redirect(_old_url: str, new_url: str) -> None:
Expand Down Expand Up @@ -258,9 +259,9 @@ def _validate_redirect(_old_url: str, new_url: str) -> None:
raise BundlerError(
f"Failed to fetch catalog from {url}: {exc}"
) from exc
except RedirectPolicyError as exc:
# Unsafe/malformed redirects are security failures and must surface as
# a hard error instead of being downgraded to catalog unavailability.
except (GitHubCliAuthError, RedirectPolicyError) as exc:
# Authentication and redirect-policy failures are definitive, not
# transient catalog unavailability.
raise BundlerError(
f"Failed to fetch catalog from {url}: {exc}"
) from exc
Expand Down
3 changes: 2 additions & 1 deletion src/specify_cli/bundles/component_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from http.client import HTTPException
from urllib.error import HTTPError, URLError

from ..authentication.github import GitHubCliAuthError
from ..authentication.http import RedirectPolicyError
from . import BundlerError
from .manifest import ComponentRef
Expand All @@ -19,7 +20,7 @@ def _is_unavailable(exc: BaseException) -> bool:
seen: set[int] = set()
while exc is not None and id(exc) not in seen:
seen.add(id(exc))
if isinstance(exc, RedirectPolicyError):
if isinstance(exc, (GitHubCliAuthError, RedirectPolicyError)):
return False
if isinstance(exc, HTTPError):
return exc.code in (408, 429) or 500 <= exc.code <= 599
Expand Down
Loading
Loading