Files
skill-git-push/skills/updating-gitea-repositories/scripts/credential_oauth_bootstrap.py
T

159 lines
8.0 KiB
Python

#!/usr/bin/env python3
"""Authorize one Gitea OAuth identity and save it in an isolated Windows vault.
This program does not change Git configuration. The agent must inspect helper
precedence and install the resulting site-specific helper chain separately.
"""
import argparse
import hashlib
import hmac
import json
import os
from pathlib import Path
import secrets
import shlex
import shutil
import subprocess
import sys
import tempfile
from urllib.request import build_opener
import credential_migrate as migration
import credential_probe as probe
import credential_win_oauth as vault
GITEA_PUBLIC_CLIENT = "a4792ccc-144e-407e-86c9-5e7d8d9c3269"
class BootstrapError(Exception):
"""Contains no secret values."""
def helper_command(python, installed, namespace):
"""Build a Git shell helper without interpreting path metacharacters."""
parts = (python.replace("\\", "/"), installed.replace("\\", "/"), "--namespace", namespace)
return "!" + " ".join(shlex.quote(part) for part in parts)
def persist_verified(site, account, remote, fields, namespace, opener):
if not vault.complete(fields):
raise BootstrapError("The server did not return a complete refreshable OAuth credential.")
try:
access = fields["password"].encode("ascii")
fields["oauth_refresh_token"].encode("ascii")
api, _ = probe.check_requests(site, account, access, remote, opener)
_, git = probe.check_requests(site, "oauth2", access, remote, opener)
if api != "matched_account" or git != "read_advertisement":
raise BootstrapError("The returned account or Git read access could not be verified.")
host = migration.checked_request(site, account)["host"].decode("ascii")
target = vault.target_name(namespace, "https", host, "oauth2")
if vault.read_record(target) is not None:
raise BootstrapError("The new vault namespace is already occupied.")
record = {key: fields[key] for key in vault.FIELDS}
vault.write_record(target, record)
saved = vault.read_record(target)
if not saved or any(not hmac.compare_digest(saved.get(key, ""), record[key]) for key in vault.FIELDS):
raise BootstrapError("The secure-store readback did not match; Git configuration was not changed.")
except (UnicodeError, ValueError, migration.MigrationError, vault.VaultError, probe.ProbeError):
raise BootstrapError("The OAuth credential could not be checked or saved safely.") from None
def find_oauth_helper(explicit):
candidate = explicit or shutil.which("git-credential-oauth")
if not candidate and os.environ.get("LOCALAPPDATA"):
candidate = str(Path(os.environ["LOCALAPPDATA"]) / "Microsoft" / "WinGet" / "Links" / "git-credential-oauth.exe")
if not candidate or not Path(candidate).is_file():
raise BootstrapError("Install the official git-credential-oauth helper first.")
# Keep package-manager shims stable across upgrades instead of resolving the
# current versioned target of a symlink.
return os.path.abspath(candidate)
def install_vault_helper():
source = (Path(__file__).resolve().parent / "credential_win_oauth.py").read_bytes()
digest = hashlib.sha256(source).hexdigest()[:20]
directory = Path(os.environ["LOCALAPPDATA"]) / "updating-gitea-repositories"
directory.mkdir(parents=True, exist_ok=True)
target = directory / ("credential_win_oauth-" + digest + ".py")
try:
with target.open("xb") as handle:
handle.write(source)
except FileExistsError:
if target.read_bytes() != source:
raise BootstrapError("The helper installation path is occupied by different content.") from None
return str(target)
def bootstrap(args):
if sys.platform != "win32" or not os.environ.get("LOCALAPPDATA"):
raise BootstrapError("This OAuth vault bootstrap currently requires native Windows.")
request = migration.checked_request(args.site, args.account)
host = request["host"].decode("ascii")
site = "https://" + host
oauth_helper = find_oauth_helper(args.oauth_helper)
env = migration.safe_environment()
remote_process = subprocess.run(["git", "config", "--get", "remote.origin.url"], cwd=args.repo, env=env,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=10, check=True)
remote = remote_process.stdout.decode("utf-8").strip()
probe.git_read_url(site, remote)
for key in list(env):
if key.startswith("GIT_CONFIG_"):
env.pop(key, None)
with tempfile.TemporaryDirectory(prefix="gitea-oauth-") as directory:
config = Path(directory) / "config"
env.update({"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": str(config), "XDG_CONFIG_HOME": directory})
settings = {"oauthClientId": args.client_id, "oauthAuthURL": "/login/oauth/authorize",
"oauthTokenURL": "/login/oauth/access_token", "oauthScopes": "read:user write:repository"}
for key, value in settings.items():
subprocess.run(["git", "config", "--file", str(config), "credential." + site + "." + key, value],
cwd=directory, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=10, check=True)
data = b"protocol=https\nhost=" + request["host"] + b"\nusername=oauth2\n\n"
print("Complete the Gitea browser authorization; do not share tokens or passwords.", flush=True)
result = subprocess.run([oauth_helper, "get"], input=data, cwd=directory, env=env,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=300, check=True)
fields = {"username": "oauth2"}
for line in result.stdout.splitlines():
if b"=" in line:
key, value = line.split(b"=", 1)
if key in {b"password", b"password_expiry_utc", b"oauth_refresh_token"}:
key = key.decode("ascii")
if key in fields:
raise BootstrapError("The OAuth helper returned duplicate fields.")
fields[key] = value.decode("ascii")
installed = install_vault_helper()
namespace = "gitea-oauth-" + secrets.token_hex(16)
persist_verified(site, args.account, remote, fields, namespace, build_opener(probe.NoRedirect()))
return {"site": site, "account": args.account, "namespace": namespace, "python": sys.executable,
"vault_helper": installed, "oauth_helper": oauth_helper, "client_id": args.client_id,
"helper_command": helper_command(sys.executable, installed, namespace)}
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--site", required=True, help="clean HTTPS site origin")
parser.add_argument("--account", required=True, help="confirmed Gitea account")
parser.add_argument("--repo", required=True, help="local repository on the site")
parser.add_argument("--oauth-helper", help="path to the official git-credential-oauth executable")
parser.add_argument("--client-id", default=GITEA_PUBLIC_CLIENT, help="registered public OAuth client ID")
args = parser.parse_args(argv)
try:
info = bootstrap(args)
print("Account verified and Git read advertisement received; credential saved in Windows Credential Manager.")
print("A public repository can allow anonymous reads; this does not prove push access.")
print("Non-secret configuration metadata: " + json.dumps(info, ensure_ascii=True))
print("Git configuration is unchanged; follow the OAuth helper-chain instructions in the skill.")
return 0
except BootstrapError as exc:
print("OAuth setup stopped: " + str(exc), file=sys.stderr)
return 1
except (migration.MigrationError, probe.ProbeError, vault.VaultError,
OSError, subprocess.SubprocessError, UnicodeError, ValueError, KeyError):
print("OAuth setup could not complete; no secrets are displayed and Git configuration is unchanged.", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())