Replace GitHub App tokens with a pre-filled token form

The App approach does not survive contact with a hundred developers and
hundreds of repositories. Minting installation tokens requires the App private
key on every developer's machine, and a key that widely distributed is a key
that grants org-wide minting to everyone holding it.

Device flow looked like the way out, since it needs no private key, but
testing showed it does not scope. A token requested with repository_id for one
repository reached a second repository in the same installation: a
permission-gated endpoint returned 200 where an installation token scoped to
one repository returned 403 for the same public repository. GitHub accepts
repository_id and silently ignores it. Per-repo scoping therefore requires
either the private key or the client secret, and neither can live on a
developer's machine.

Fine-grained PATs do scope per repository and share no secret, and GitHub
supports pre-filling the creation form via URL parameters, which removes the
toil that made them unattractive. Setup now builds that URL from the origin
remote and opens it, leaving the operator to select the repository and paste
the result.

Three permissions - checks, vulnerability_alerts and secret_scanning_alerts -
are absent from GitHub's pre-fill parameters, so they are printed as a
checklist instead of sent as parameters that would be silently dropped and
look granted. There is no parameter for repository selection either.

Tokens are no longer re-minted per launch, since a PAT outlives a session; the
new token subcommand replaces one on expiry or revocation.
This commit is contained in:
2026-07-30 15:28:44 -05:00
parent d96f32d773
commit 890d10a315
4 changed files with 274 additions and 462 deletions
-83
View File
@@ -1,83 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# shellcheck source-path=SCRIPTDIR
# shellcheck source=tasks/ai/sbx
source "$(dirname "${BASH_SOURCE[0]}")/../tasks/ai/sbx"
failures=0
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
failures=$((failures + 1))
}
openssl genrsa -out "$work/key.pem" 2048 2>/dev/null
openssl rsa -in "$work/key.pem" -pubout -out "$work/pub.pem" 2>/dev/null
APP_ID=123456
APP_PRIVATE_KEY_FILE="$work/key.pem"
jwt="$(github_app_jwt)"
IFS='.' read -r header payload signature <<<"$jwt"
[[ -n "$header" && -n "$payload" && -n "$signature" ]] ||
fail "JWT is not three segments: $jwt"
[[ "$jwt" =~ ^[A-Za-z0-9_.-]+$ ]] ||
fail "JWT contains characters outside the base64url alphabet"
decode() {
local padded="$1"
while ((${#padded} % 4)); do
padded+="="
done
printf '%s' "$padded" | tr '_-' '/+' | openssl base64 -d -A
}
[[ "$(decode "$header" | jq -r '.alg')" == "RS256" ]] ||
fail "header alg is not RS256"
[[ "$(decode "$payload" | jq -r '.iss')" == "123456" ]] ||
fail "payload iss does not carry the App ID"
iat="$(decode "$payload" | jq -r '.iat')"
exp="$(decode "$payload" | jq -r '.exp')"
now="$(date +%s)"
((iat <= now)) || fail "iat is in the future ($iat > $now)"
((exp - iat <= 600)) || fail "lifetime exceeds GitHub's 10 minute cap"
((exp > now)) || fail "token is already expired on creation"
printf '%s' "$header.$payload" >"$work/signing_input"
decode "$signature" >"$work/sig.bin"
openssl dgst -sha256 -verify "$work/pub.pem" \
-signature "$work/sig.bin" "$work/signing_input" >/dev/null 2>&1 ||
fail "signature does not verify against the public key"
printf '%s' "$header.${payload}x" >"$work/tampered"
if openssl dgst -sha256 -verify "$work/pub.pem" \
-signature "$work/sig.bin" "$work/tampered" >/dev/null 2>&1; then
fail "a tampered signing input still verified"
fi
jq -e . >/dev/null <<<"$GITHUB_APP_PERMISSIONS" ||
fail "GITHUB_APP_PERMISSIONS is not valid JSON"
[[ "$(jq -r '.workflows' <<<"$GITHUB_APP_PERMISSIONS")" == "write" ]] ||
fail "workflows must be write; the schema defines no read level"
while read -r level; do
[[ "$level" == "read" || "$level" == "write" ]] ||
fail "invalid permission level: $level"
done < <(jq -r '.[]' <<<"$GITHUB_APP_PERMISSIONS")
if ((failures)); then
printf '%d assertion(s) failed\n' "$failures" >&2
exit 1
fi
printf 'All GitHub App JWT assertions passed.\n'
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -euo pipefail
# shellcheck source-path=SCRIPTDIR
# shellcheck source=tasks/ai/sbx
source "$(dirname "${BASH_SOURCE[0]}")/../tasks/ai/sbx"
failures=0
fail() {
printf 'FAIL: %s\n' "$1" >&2
failures=$((failures + 1))
}
assert_encodes() {
local input="$1" expected="$2" actual
actual="$(url_encode "$input")"
[[ "$actual" == "$expected" ]] ||
fail "url_encode '$input' produced '$actual', expected '$expected'"
}
assert_encodes "plain" "plain"
assert_encodes "a b" "a%20b"
assert_encodes "CareEvolution/api-portal" "CareEvolution%2Fapi-portal"
assert_encodes "a&b=c" "a%26b%3Dc"
assert_encodes "a?b#c" "a%3Fb%23c"
assert_encodes "keep.these~chars_-" "keep.these~chars_-"
REPOSITORY="CareEvolution/api-portal"
url="$(token_url)"
[[ "$url" == https://github.com/settings/personal-access-tokens/new\?* ]] ||
fail "URL does not target the token creation form: $url"
query="${url#*\?}"
[[ "$query" != *" "* ]] ||
fail "URL contains a raw space"
[[ "$query" == *"target_name=CareEvolution"* ]] ||
fail "target_name is not the repository owner"
[[ "$query" != *"target_name=CareEvolution%2Fapi-portal"* ]] ||
fail "target_name wrongly carries the full repository name"
[[ "$query" == *"expires_in=$DEFAULT_TOKEN_DAYS"* ]] ||
fail "expires_in is missing"
for permission in "${TOKEN_URL_PERMISSIONS[@]}"; do
[[ "$query" == *"&$permission"* ]] ||
fail "permission missing from URL: $permission"
done
while read -r level; do
[[ "$level" == "read" || "$level" == "write" || "$level" == "admin" ]] ||
fail "invalid permission level: $level"
done < <(printf '%s\n' "${TOKEN_URL_PERMISSIONS[@]}" | cut -d= -f2)
printf '%s\n' "${TOKEN_URL_PERMISSIONS[@]}" | grep -qx 'workflows=write' ||
fail "workflows must be requested at write"
for unsupported in checks= vulnerability_alerts= secret_scanning_alerts= repository=; do
[[ "$query" != *"$unsupported"* ]] ||
fail "URL sends a parameter the form ignores: $unsupported"
done
[[ ${#TOKEN_MANUAL_PERMISSIONS[@]} -eq 3 ]] ||
fail "expected 3 manually-ticked permissions, found ${#TOKEN_MANUAL_PERMISSIONS[@]}"
if ((failures)); then
printf '%d assertion(s) failed\n' "$failures" >&2
exit 1
fi
printf 'All token URL assertions passed.\n'