#!/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())