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:
@@ -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'
|
||||
Executable
+75
@@ -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'
|
||||
Reference in New Issue
Block a user