Skip to content
Open
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
48 changes: 48 additions & 0 deletions docs/reference/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ Either `token` or `token_env` must be set for the `bearer`, `basic-pat`, and `ba
|---|---|---|
| `bearer` | `Authorization: Bearer <token>` | PATs, fine-grained PATs, OAuth tokens, GitHub App tokens |

The `github` provider supports `bearer` tokens and `github-cli` credentials
acquired on demand from GitHub CLI.

**Example — PAT via environment variable:**

```json
Expand All @@ -71,6 +74,30 @@ 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.

### GitHub Enterprise Server (GHES)

To use a private catalog or extension hosted on a GitHub Enterprise Server
Expand Down Expand Up @@ -98,6 +125,27 @@ 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"`, one provider entry represents one GitHub instance.
List exactly one bare enterprise host plus any service aliases that share its
credential:
Comment on lines +129 to +130

```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`. 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
157 changes: 148 additions & 9 deletions src/specify_cli/authentication/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,162 @@

from __future__ import annotations

import os
import shutil
import subprocess
from typing import TYPE_CHECKING

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
_GITHUB_PUBLIC_HOSTS = frozenset({
"github.com",
"api.github.com",
"raw.githubusercontent.com",
"codeload.github.com",
})
_GITHUB_SERVICE_PREFIXES = ("api.", "raw.", "codeload.")


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(RuntimeError):
"""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)
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:
"""Select the one GitHub CLI credential host represented by *entry*."""
exact_hosts = {
host.lower()
for host in entry.hosts
if "*" not in host
}
if exact_hosts & _GITHUB_PUBLIC_HOSTS:
if any(host not in _GITHUB_PUBLIC_HOSTS for host in exact_hosts):
raise GitHubCliAuthError(
"GitHub authentication failed for provider 'github': "
"a github-cli entry must describe one GitHub instance. "
"Use separate provider entries for github.com and GitHub Enterprise."
)
return "github.com"

bare_hosts = {
host
for host in exact_hosts
if not host.startswith(_GITHUB_SERVICE_PREFIXES)
}
Comment on lines +101 to +105
if len(bare_hosts) != 1:
raise GitHubCliAuthError(
"GitHub authentication failed for provider 'github': "
"a github-cli entry must list exactly one bare GitHub host."
)
return next(iter(bare_hosts))

@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):
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}'."
)
return token
Loading
Loading