secured-remote-docker-build/scripts/mint_token.py
Lukas Schaefer 7aeef62a39
Implement auth for git repo
Signed-off-by: Lukas Schaefer <lukas@lschaefer.xyz>
2026-09-03 21:16:26 -04:00

116 lines
3.5 KiB
Python

#!/usr/bin/env python3
"""Mint a build JWT for the docker build service."""
from __future__ import annotations
import argparse
import hashlib
import os
import re
import sys
import urllib.error
import urllib.request
from datetime import UTC, datetime, timedelta
from pathlib import Path
import jwt
REPO_PATH = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
SAFE_SHA256 = re.compile(r"^[0-9a-fA-F]{64}$")
MAX_DOCKERFILE_BYTES = 1024 * 1024
def _load_dockerfile_bytes(source: str) -> bytes:
if source.startswith(("http://", "https://")):
try:
with urllib.request.urlopen(source, timeout=60) as response:
data = response.read(MAX_DOCKERFILE_BYTES + 1)
except urllib.error.URLError as exc:
raise RuntimeError(f"failed to fetch --hash-from URL: {exc}") from exc
else:
path = Path(source)
if not path.is_file():
raise RuntimeError(f"--hash-from not found: {source}")
data = path.read_bytes()
if len(data) > MAX_DOCKERFILE_BYTES:
raise RuntimeError("Dockerfile exceeds 1 MB limit")
return data
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--repo",
required=True,
help="Repository as owner/name on git.lschaefer.xyz",
)
parser.add_argument(
"--dockerfile",
required=True,
help="Dockerfile path inside the repo",
)
hash_group = parser.add_mutually_exclusive_group(required=True)
hash_group.add_argument(
"--hash-from",
help="Local Dockerfile path or http(s) URL to hash for the JWT",
)
hash_group.add_argument(
"--dockerfile-sha256",
metavar="HEX",
help="Dockerfile SHA-256 hex digest (64 chars) instead of hashing a file",
)
parser.add_argument(
"--files",
nargs="+",
metavar="PATH",
help="Relative file paths for the JWT files claim (upload or combined builds)",
)
parser.add_argument(
"--secret",
default=os.environ.get("JWT_SECRET"),
help="HS256 secret (or set JWT_SECRET)",
)
parser.add_argument("--ttl-minutes", type=int, default=15, help="Token lifetime")
args = parser.parse_args()
if not args.secret:
print("error: provide --secret or JWT_SECRET", file=sys.stderr)
return 1
if not REPO_PATH.match(args.repo.strip()):
print("error: --repo must be owner/name", file=sys.stderr)
return 1
if args.dockerfile_sha256 is not None:
digest = args.dockerfile_sha256.strip().lower()
if not SAFE_SHA256.match(digest):
print("error: --dockerfile-sha256 must be a 64-character hex digest", file=sys.stderr)
return 1
dockerfile_sha256 = digest
else:
try:
contents = _load_dockerfile_bytes(args.hash_from)
except RuntimeError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
dockerfile_sha256 = hashlib.sha256(contents).hexdigest()
now = datetime.now(UTC)
payload: dict[str, object] = {
"repo": args.repo.strip(),
"dockerfile": args.dockerfile,
"dockerfile_sha256": dockerfile_sha256,
"iat": now,
"exp": now + timedelta(minutes=args.ttl_minutes),
}
if args.files:
payload["files"] = args.files
token = jwt.encode(payload, args.secret, algorithm="HS256")
print(token)
return 0
if __name__ == "__main__":
raise SystemExit(main())