159 lines
8.0 KiB
Python
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())
|