#!/usr/bin/env python3 """Git credential helper backed by the codevalet OAuth auth file. Git invokes credential helpers with one of three verbs on argv[1]: get : read key=value fields from stdin; emit credentials on stdout store : persist credentials (no-op here; tokens are OAuth-owned) erase : forget credentials (no-op here; ``just logout`` owns that) On every ``get`` the helper: 1. Reads the auth file (same path the gateway uses: ``~/.forge-stack-devpi-gateway-gitea/client-auth.json`` by default, or whatever ``FSDGG_AUTH_STORE_PATH`` / ``FSDGG_RUNTIME_DIR`` point at). If the file is missing, the helper passes git's input through unchanged so the normal prompt chain continues. 2. Checks that the git request host matches the host recorded in ``_forge_gitea_base_url`` (or ``FORGE_GITEA_URL``). If not, passes through. This prevents OAuth token disclosure to unrelated hosts even if git mis-scopes its lookup. 3. If ``gitea_access_token`` is live, emits ``username=`` and ``password=``. 4. If the token is expired and a refresh token is present, runs the OAuth refresh flow (``forge_auth.run_refresh``) and retries once. Refresh failures are surfaced by exiting non-zero; git then falls through to the user's configured helper chain (normally a prompt). Security notes -------------- * The helper never writes to stdout except the credential key=value block. Logs go to stderr. * On OAuth refresh failure the helper exits **non-zero** rather than silently returning stale credentials. """ from __future__ import annotations import os import sys from pathlib import Path from urllib.parse import urlsplit def _load_forge_auth_module(): """Import ``forge_auth`` from the same directory as this script. This works both when the helper is invoked directly from ``scripts/`` (development) and after installation under ``~/.local/bin`` (because the installer copies ``forge_auth.py`` alongside ``git-credential-forge``). """ here = Path(__file__).resolve().parent if str(here) not in sys.path: sys.path.insert(0, str(here)) import forge_auth # noqa: E402 return forge_auth forge_auth = _load_forge_auth_module() # -------------------------------------------------------------------- # git credential protocol I/O # -------------------------------------------------------------------- def read_git_fields(stream) -> dict[str, str]: fields: dict[str, str] = {} for line in stream: line = line.rstrip("\n") if not line: break if "=" not in line: raise ValueError(f"unexpected git credential input line: {line!r}") key, _, value = line.partition("=") fields[key] = value return fields def emit(fields: dict[str, str]) -> None: for key, value in fields.items(): sys.stdout.write(f"{key}={value}\n") # -------------------------------------------------------------------- # Host matching # -------------------------------------------------------------------- def _configured_host() -> tuple[str, str, int | None] | None: """Return (scheme, host, port) of the host this helper is allowed to produce credentials for. Priority: 1. The ``_forge_gitea_base_url`` field inside the stored auth file: that is the host recorded during authentication. 2. The ``FORGE_GITEA_URL`` env var (pre-login override). Returns None if neither is set; the helper then passes through. """ store = forge_auth.auth_store_path() state = forge_auth.AuthFile.read(store) stored_url = str(state.raw.get("_forge_gitea_base_url") or "").strip() if stored_url: parsed = urlsplit(stored_url) return parsed.scheme.lower(), (parsed.hostname or "").lower(), parsed.port env_url = os.environ.get("FORGE_GITEA_URL", "").strip() if env_url: parsed = urlsplit(env_url) return parsed.scheme.lower(), (parsed.hostname or "").lower(), parsed.port return None def _request_matches( fields: dict[str, str], configured: tuple[str, str, int | None] ) -> bool: scheme, host, port = configured git_scheme = fields.get("protocol", "").lower() git_host = fields.get("host", "").lower() # git passes host:port as "host" when the URL carried a port. if ":" in git_host: host_part, _, port_str = git_host.partition(":") try: git_port = int(port_str) except ValueError: return False git_host = host_part else: git_port = None if git_scheme != scheme: return False if git_host != host: return False if port is not None and git_port != port: return False if port is None and git_port is not None: # Stored URL had no explicit port (default 443) but request does; # only match the default-HTTPS case. if scheme == "https" and git_port != 443: return False if scheme == "http" and git_port != 80: return False return True # -------------------------------------------------------------------- # Commands # -------------------------------------------------------------------- def cmd_get(fields: dict[str, str]) -> int: configured = _configured_host() if configured is None: emit(fields) # pass-through: helper scope is unknown return 0 if not _request_matches(fields, configured): emit(fields) # pass-through: request targets a different host return 0 state = forge_auth.AuthFile.read(forge_auth.auth_store_path()) # Fast path: stored access token is still live. if state.has_live_gitea_token(): _emit_credentials(fields, state) return 0 # Slow path: try to refresh. try: config = forge_auth.ForgeAuthConfig.from_env() except forge_auth.AuthError: forge_auth.cli_warn( "stored token is expired and FORGE_GITEA_URL / " "FSDGG_CLI_CLIENT_ID are not set in the environment; " "cannot refresh. Run 'just login' to re-authenticate." ) emit(fields) return 0 try: refreshed = forge_auth.run_refresh(config, must_refresh=True) except forge_auth.AuthError as exc: forge_auth.cli_warn( f"token refresh failed: {exc}. " f"Run 'just login' to re-authenticate." ) emit(fields) return 0 _emit_credentials(fields, refreshed) return 0 def _emit_credentials(fields: dict[str, str], state: forge_auth.AuthFile) -> None: token = state.gitea_access_token if not token: emit(fields) return out = dict(fields) out["username"] = state.username or fields.get("username") or "oauth" out["password"] = token emit(out) def cmd_consume_noop(stream) -> int: for _ in stream: pass return 0 def main(argv: list[str]) -> int: if len(argv) < 2: print("usage: git-credential-forge ", file=sys.stderr) return 2 action = argv[1] if action == "get": fields = read_git_fields(sys.stdin) return cmd_get(fields) if action in ("store", "erase"): return cmd_consume_noop(sys.stdin) print(f"git-credential-forge: unsupported action: {action}", file=sys.stderr) return 2 if __name__ == "__main__": sys.exit(main(sys.argv))