223 lines
7.3 KiB
Python
Executable File
223 lines
7.3 KiB
Python
Executable File
#!/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 ensures we never hand OAuth tokens to unrelated
|
|
hosts even if git mis-scopes its lookup.
|
|
3. If ``gitea_access_token`` is live, emits
|
|
``username=<stored-user>`` and ``password=<gitea_access_token>``.
|
|
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 we exit **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 we actually authenticated against.
|
|
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: we don't know who we answer for
|
|
return 0
|
|
if not _request_matches(fields, configured):
|
|
emit(fields) # pass-through: request is for 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 <get|store|erase>", 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))
|