Initial Commit
This commit is contained in:
222
scripts/git-credential-forge.py
Executable file
222
scripts/git-credential-forge.py
Executable file
@@ -0,0 +1,222 @@
|
||||
#!/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=<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 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 <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))
|
||||
Reference in New Issue
Block a user