Diagnostic GitHub API
some endpoints refuse an installation token whatever it holds
The App is installed. The JWT signs, the installation token mints, and nineteen calls in a row return exactly what they should. Then GET /user comes back 403 {"message": "Resource not accessible by integration"}, somebody adds a permission, every installer is emailed to accept the upgrade, and the same 403 arrives again the next morning.
That route does not accept installation access tokens, and no permission will change that. GET /user means the user this credential belongs to. An installation belongs to an App on an account, not to a person, so there is no user to return and the request is refused before permissions are consulted at all.
Prove it in two GETs. First GET /installation/repositories: a 200 there says the token is alive, valid and unexpired, which eliminates the entire credential from the investigation. Then repeat the failing path and look it up rather than debugging it, because the answer is a property of the route. The repair is a substitute endpoint — /installation/repositories for /user/repos, /app for /user — or, where the request genuinely needs a human, a user access token from the App's own authorization flow.
The problem in plain words
The message is the problem. Resource not accessible by integration reads like a permissions error, it is the same sentence GitHub uses when a permission really is missing, and it sends every reader to the App settings page. So the permission gets added, the installers are chased for a week to accept the upgrade, and nothing changes — because the response never named a permission in the first place.
Then the loop tightens. The next theory is that the added permission was the wrong one, so a broader one goes on. An App that only ever needed to read pull requests ends up requesting organization administration, and it is still 403ing on /user, because the endpoint is not asking what the App may do. It is asking who the App is, and getting an answer it cannot use.
It is also unusually easy to inherit. Client libraries and Terraform providers written against personal access tokens call /user early, often just to log who is running. Point one of them at App credentials and it fails on that convenience call, long before it reaches anything you actually wanted. The failing line is in somebody else's code and the path never appears in yours.
Why it happens
There are three credential classes, not one. A GitHub App can hold a JWT signed with its private key, which identifies the App; an installation access token, which acts as the App on one account; and a user access token, which acts as a person who authorized the App. Routes accept some of these and refuse others, and the refusal is a routing fact rather than a grant.
The /user family is the boundary. Anything that means the authenticated user — /user, /user/repos, /user/emails, /user/orgs, /user/installations, /notifications, /gists — needs a user behind the credential. Installation tokens have none, so all of them refuse, and they refuse identically no matter which permissions the App requested.
The refusal runs in the other direction too. GET /app and GET /app/installations want the App's JWT and will not take the installation token that the JWT produced. Sending the wrong one of your own two credentials is the same class of mistake, which is why the script sorts for both rather than only for the famous case.
An absent header is a hint, not the diagnosis. A 403 that carries no x-accepted-github-permissions is a sign that no permission was at issue. That header is the instrument for the note on missing App permissions; here it is only corroboration, because its absence tells you what the problem is not. The route table tells you what it is, and what to call instead.
A read-only script cannot enumerate the boundary. There is no endpoint that returns "the set of routes installation tokens may use". The published lists are documentation, so the table below is curated rather than fetched, and the script says so: an unrecognised path gets a heuristic and a clearly labelled confidence rather than a confident wrong answer.
The fix, as a flow
Two GETs, and neither of them is the one that hurts. The first is the liveness proof: an installation token that can list its own repositories is not a broken credential, whatever the second response says. The second is the path you were already calling. Everything after that is a lookup, because the answer is a property of the route rather than of the token, and the useful output is a different path to call rather than a permission to add.
How to fix it
Prove the token is alive before you debug anything else
GET /installation/repositories is answerable by an installation access token and by nothing else. A 200 there settles that the token minted correctly, has not passed its hour, and is installed somewhere. Every subsequent finding is then about the route, not the credential, and you have stopped guessing which of the two is broken.
Repeat the exact path that was refused
Not an approximation of it. The audience is per route, so /user and /users/octocat give opposite answers despite looking alike, and a path with a query string on it reduces to the same route as one without. Take the path out of the failing log line verbatim.
Look up the route rather than reading the message
Reduce the path to its template — /repos/acme/api/issues becomes /repos/{owner}/{repo}/issues — and ask which credential classes that template accepts. If installation tokens are on the list, this note does not apply and the 403 is about a permission after all. If they are not, no permission was ever going to work.
Take the substitute endpoint when there is one
/user/repos becomes /installation/repositories, which is better anyway: it returns exactly the repositories the installation covers rather than everything an account can see. /user becomes /app under the JWT if what you wanted was your own identity. Some routes have no equivalent, and the script says so plainly instead of inventing one.
Use a user access token only where a human is genuinely required
Email addresses, notifications and gists belong to people. Reaching them means sending the installer through the App's authorization flow and holding a user access token for that person, which reintroduces exactly the human dependency an App was meant to remove. Worth doing deliberately, and worth not doing by accident.
How to check it worked
Re-run against the substitute path. The state should move from needs-user-context to endpoint-accepted, with the same credential and no permission change anywhere.
python3 github_endpoint_audience.py --path /user/repos
# installation token alive: GET /installation/repositories returned 200
# /user/repos returned 403
# needs-user-context: this route accepts u2s, any. An installation access token
# is not one of them, so no permission opens it.
# repair: call /installation/repositories instead
python3 github_endpoint_audience.py --path /installation/repositories
# endpoint-accepted: /installation/repositories returned 200
The full code
Two GETs, one of which exists only to take the credential out of the argument. Everything that produces a finding is a lookup over two curated tables: which credential classes each route template accepts, and what to call instead when yours is not among them. The path reducer is the fiddly part — a full URL, a query string and a trailing slash all have to land on the same template — so it is pure, as is the heuristic that handles paths the table has never seen and the verdict that refuses to state a cause for them.
"""Say why a working GitHub App installation token is refused by one route.
Read only. Two GETs: one that proves the token is alive, and one that repeats
the call that was already failing. Nothing is minted, accepted, widened or
changed. Where the repair is a different endpoint, it is printed.
Some REST routes are unreachable with a server-to-server installation token
whatever permissions the App holds. GET /user is the famous one: it means "the
user this credential belongs to", and an installation belongs to an account
rather than to a person. No permission opens it, because permission is not the
question being asked.
Credential classes, abbreviated the same way throughout:
s2s an installation access token, acting as the App on one account
u2s a user access token, acting as a person who authorized the App
jwt the App's own JSON Web Token, signed with its private key
any any authenticated caller, a personal access token included
none no credential at all
"""
import argparse
import json
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("github_endpoint_audience")
API = "https://api.github.com"
UA = "github-endpoint-audience/1.0"
# Which credential classes each route template accepts. There is no endpoint
# that returns this, so the table is curated from the published lists rather
# than fetched, and anything absent from it is answered by heuristic with the
# uncertainty stated rather than hidden.
AUDIENCES = {
"/": {"none", "any", "s2s", "u2s", "jwt"},
"/meta": {"none", "any", "s2s", "u2s", "jwt"},
"/versions": {"none", "any", "s2s", "u2s", "jwt"},
"/rate_limit": {"any", "s2s", "u2s", "jwt"},
"/app": {"jwt"},
"/app/installations": {"jwt"},
"/app/installations/{installation_id}": {"jwt"},
"/installation/repositories": {"s2s"},
"/user": {"any", "u2s"},
"/user/repos": {"any", "u2s"},
"/user/emails": {"any", "u2s"},
"/user/orgs": {"any", "u2s"},
"/user/keys": {"any", "u2s"},
"/user/installations": {"any", "u2s"},
"/notifications": {"any", "u2s"},
"/gists": {"any", "u2s"},
"/users/{username}": {"none", "any", "s2s", "u2s"},
"/orgs/{org}": {"any", "s2s", "u2s"},
"/orgs/{org}/repos": {"any", "s2s", "u2s"},
"/orgs/{org}/members": {"any", "s2s", "u2s"},
"/repos/{owner}/{repo}": {"any", "s2s", "u2s"},
"/repos/{owner}/{repo}/issues": {"any", "s2s", "u2s"},
"/repos/{owner}/{repo}/pulls": {"any", "s2s", "u2s"},
"/repos/{owner}/{repo}/hooks": {"any", "s2s", "u2s"},
"/repos/{owner}/{repo}/commits": {"any", "s2s", "u2s"},
"/search/issues": {"any", "s2s", "u2s"},
}
# What to call instead. None for the second element means there is no
# server-to-server equivalent, which is a real answer and a better one than a
# nearby endpoint that returns different data.
SUBSTITUTES = {
"/user": ("/app",
"identifies the App itself, and is called with the App JWT "
"rather than with the installation token"),
"/user/repos": ("/installation/repositories",
"returns exactly the repositories this installation "
"covers, which is narrower and more accurate"),
"/user/installations": ("/app/installations",
"lists the installations of this App, under the "
"App JWT"),
"/user/orgs": ("/app/installations",
"each installation names the account it sits on, which is "
"the App equivalent of asking which orgs you are in"),
"/user/emails": (None,
"email addresses belong to a person; only a user access "
"token can read them"),
"/user/keys": (None,
"SSH keys belong to a person; only a user access token can "
"read them"),
"/notifications": (None,
"notifications belong to a person; subscribe the App to "
"webhook events instead of polling a human inbox"),
"/gists": (None, "GitHub Apps cannot reach gists at all"),
"/app": (None,
"this route is right, but it wants the App JWT; the installation "
"token is the thing the JWT produces, not a substitute for it"),
"/app/installations": (None,
"this route wants the App JWT, not the installation "
"token it produces"),
}
# Placeholder-free templates are matched first, so /user/repos wins over a
# same-shaped template with a variable in the first position.
ROUTES = sorted(AUDIENCES, key=lambda t: (t.count("{"), t))
def canonical(path):
"""Reduce a request path to the route template it matches. Pure.
A full URL, a query string, a fragment and a trailing slash all have to
land on the same template, because the path in a log line is rarely the
tidy form. Returns None when nothing matches, which the caller reports as
uncertainty rather than treating as a permitted route.
"""
raw = str(path or "").split("?")[0].split("#")[0].strip()
for prefix in ("https://api.github.com", "http://api.github.com"):
if raw.startswith(prefix):
raw = raw[len(prefix):]
if not raw.startswith("/"):
raw = "/" + raw
parts = [p for p in raw.split("/") if p]
if not parts:
return "/"
for template in ROUTES:
segments = [p for p in template.split("/") if p]
if len(segments) != len(parts):
continue
if all(s.startswith("{") or s == got for s, got in zip(segments, parts)):
return template
return None
def accepts(route):
"""The credential classes a known route template accepts. Pure."""
found = AUDIENCES.get(route)
return set(found) if found else None
def guess(path):
"""Heuristic audience for a path the table has never seen. Pure.
Returns (classes, reason). classes is None where even the heuristic
declines, because a guessed answer in this particular report would send
somebody to rewrite a working call.
"""
parts = [p for p in str(path or "").split("?")[0].split("/") if p]
if not parts:
return None, "an empty path matches nothing"
head = parts[0]
if head == "user":
return ({"any", "u2s"},
"every route under /user means the authenticated user, and an "
"installation is not a user")
if head == "app":
return ({"jwt"},
"routes under /app identify the App and are signed with the "
"App JWT rather than an installation token")
if head == "installation":
return ({"s2s"},
"routes under /installation are the installation's own view "
"of itself")
if head in ("notifications", "gists"):
return ({"any", "u2s"},
"this resource belongs to a person, so it needs a credential "
"that has one behind it")
return None, ("not in the table, and the first path segment carries no "
"rule this script is willing to apply")
def substitute(route):
"""The App-appropriate replacement for a route, if there is one. Pure."""
return SUBSTITUTES.get(route)
def verdict(alive, status, route, classes, guessed=False):
"""Turn a liveness proof and a route lookup into a finding. Pure.
alive is whether GET /installation/repositories returned 200, which is the
only thing that distinguishes "the credential is broken" from "the route
refuses this class of credential". Without it the two are the same 403.
"""
if not alive:
return ("not-an-installation-token",
"GET /installation/repositories did not return 200, so this "
"credential is not a live installation access token. Whatever "
"the other call did, it is not the mismatch this script looks "
"for.")
if status is not None and status < 400:
return ("endpoint-accepted",
"%s returned %d with this installation token, so the route "
"accepts it." % (route or "that path", status))
if classes is None:
return ("route-unknown",
"this path is not in the route table and the heuristic "
"declined it, so the audience is genuinely unknown. Check the "
"published list of endpoints available to installation access "
"tokens before rewriting anything.")
hedge = " (by heuristic rather than from the table)" if guessed else ""
if "s2s" in classes:
return ("installation-tokens-accepted",
"this route does accept installation access tokens%s, so the "
"refusal is about a permission rather than about the "
"credential class. Read x-accepted-github-permissions on the "
"same response." % hedge)
if "jwt" in classes and "u2s" not in classes:
return ("needs-app-jwt",
"this route wants the App's own JWT%s. The installation token "
"is what the JWT produces, not a substitute for it: sign a "
"fresh JWT and send that instead." % hedge)
return ("needs-user-context",
"this route accepts %s%s. An installation access token is not one "
"of them, so no permission opens it: the credential has no user "
"behind it and the route is asking about one."
% (", ".join(sorted(classes)), hedge))
def get(session, path):
"""One GET. Returns (status, json-or-None)."""
url = API + path if path.startswith("/") else path
r = session.get(url, timeout=30)
try:
body = r.json()
except ValueError:
body = None
return r.status_code, body
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--path", default="/user",
help="the API path that was refused, taken verbatim from "
"the failing log line")
args = ap.parse_args()
token = os.environ.get("GITHUB_INSTALLATION_TOKEN") or \
os.environ.get("GITHUB_TOKEN")
if not token:
log.error("set GITHUB_INSTALLATION_TOKEN to an installation access "
"token. Without one there is no credential class to test a "
"route against")
return 2
session = requests.Session()
session.headers.update({
"Authorization": "Bearer " + token,
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": UA,
})
# The liveness proof, and the only request here that is not the reader's
# own call. Only an installation access token can answer it at all.
probe_status, probe_body = get(session, "/installation/repositories?per_page=1")
alive = probe_status == 200
if alive:
total = probe_body.get("total_count") if isinstance(probe_body, dict) else None
log.info("installation token alive: GET /installation/repositories "
"returned 200 over %s repositories",
total if total is not None else "an unreported number of")
else:
log.info("GET /installation/repositories returned %d, so this is not "
"a live installation access token", probe_status)
status = None
if alive:
status, _ = get(session, args.path)
log.info("%s returned %d", args.path, status)
route = canonical(args.path)
classes = accepts(route) if route else None
guessed = False
if classes is None:
classes, reason = guess(args.path)
guessed = classes is not None
log.info("route: %s", route or "not in the table (%s)" % reason)
else:
log.info("route: %s accepts %s", route, ", ".join(sorted(classes)))
state, detail = verdict(alive, status, route, classes, guessed)
log.info("%s: %s", state, detail)
if state in ("needs-user-context", "needs-app-jwt"):
swap = substitute(route)
if swap and swap[0]:
log.info("repair: call %s instead, which %s", swap[0], swap[1])
elif swap:
log.info("repair: there is no server-to-server equivalent. %s",
swap[1])
else:
log.info("repair: find the App equivalent of this route in the "
"published endpoint list, or authorize a user and hold a "
"user access token for them")
if state == "installation-tokens-accepted":
log.info("repair: this is a permissions finding rather than a "
"credential-class one; diff the App's permissions against "
"the header the failing response carried")
print(json.dumps({"path": args.path, "route": route, "status": status,
"installation_token_alive": alive,
"accepts": sorted(classes) if classes else None,
"by_heuristic": guessed, "state": state}, indent=2))
return 1 if state in ("needs-user-context", "needs-app-jwt") else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Say why a working GitHub App installation token is refused by one route.
*
* Read only. Two GETs: one that proves the token is alive, and one that
* repeats the call that was already failing. The repair is a different
* endpoint, and it is printed rather than applied.
*
* Credential classes, abbreviated throughout:
* s2s an installation access token, acting as the App on one account
* u2s a user access token, acting as a person who authorized the App
* jwt the App's own JSON Web Token, signed with its private key
* any any authenticated caller, a personal access token included
* none no credential at all
*/
const API = 'https://api.github.com';
const UA = 'github-endpoint-audience/1.0';
/** Which credential classes each route template accepts. Curated, not fetched. */
export const AUDIENCES = {
'/': ['none', 'any', 's2s', 'u2s', 'jwt'],
'/meta': ['none', 'any', 's2s', 'u2s', 'jwt'],
'/versions': ['none', 'any', 's2s', 'u2s', 'jwt'],
'/rate_limit': ['any', 's2s', 'u2s', 'jwt'],
'/app': ['jwt'],
'/app/installations': ['jwt'],
'/app/installations/{installation_id}': ['jwt'],
'/installation/repositories': ['s2s'],
'/user': ['any', 'u2s'],
'/user/repos': ['any', 'u2s'],
'/user/emails': ['any', 'u2s'],
'/user/orgs': ['any', 'u2s'],
'/user/keys': ['any', 'u2s'],
'/user/installations': ['any', 'u2s'],
'/notifications': ['any', 'u2s'],
'/gists': ['any', 'u2s'],
'/users/{username}': ['none', 'any', 's2s', 'u2s'],
'/orgs/{org}': ['any', 's2s', 'u2s'],
'/orgs/{org}/repos': ['any', 's2s', 'u2s'],
'/orgs/{org}/members': ['any', 's2s', 'u2s'],
'/repos/{owner}/{repo}': ['any', 's2s', 'u2s'],
'/repos/{owner}/{repo}/issues': ['any', 's2s', 'u2s'],
'/repos/{owner}/{repo}/pulls': ['any', 's2s', 'u2s'],
'/repos/{owner}/{repo}/hooks': ['any', 's2s', 'u2s'],
'/repos/{owner}/{repo}/commits': ['any', 's2s', 'u2s'],
'/search/issues': ['any', 's2s', 'u2s'],
};
/** What to call instead. A null target means there is no s2s equivalent. */
export const SUBSTITUTES = {
'/user': ['/app',
'identifies the App itself, and is called with the App JWT rather than ' +
'with the installation token'],
'/user/repos': ['/installation/repositories',
'returns exactly the repositories this installation covers, which is ' +
'narrower and more accurate'],
'/user/installations': ['/app/installations',
'lists the installations of this App, under the App JWT'],
'/user/orgs': ['/app/installations',
'each installation names the account it sits on, which is the App ' +
'equivalent of asking which orgs you are in'],
'/user/emails': [null,
'email addresses belong to a person; only a user access token can read them'],
'/user/keys': [null,
'SSH keys belong to a person; only a user access token can read them'],
'/notifications': [null,
'notifications belong to a person; subscribe the App to webhook events ' +
'instead of polling a human inbox'],
'/gists': [null, 'GitHub Apps cannot reach gists at all'],
'/app': [null,
'this route is right, but it wants the App JWT; the installation token is ' +
'what the JWT produces, not a substitute for it'],
'/app/installations': [null,
'this route wants the App JWT, not the installation token it produces'],
};
/** Placeholder-free templates are matched first. */
const ROUTES = Object.keys(AUDIENCES).sort((a, b) => {
const ca = (a.match(/\{/g) ?? []).length;
const cb = (b.match(/\{/g) ?? []).length;
return (ca - cb) || a.localeCompare(b);
});
/**
* Reduce a request path to the route template it matches. Pure.
* Full URLs, query strings, fragments and trailing slashes all land on the
* same template. null when nothing matches.
*/
export function canonical(path) {
let raw = String(path ?? '').split('?')[0].split('#')[0].trim();
for (const prefix of ['https://api.github.com', 'http://api.github.com']) {
if (raw.startsWith(prefix)) raw = raw.slice(prefix.length);
}
if (!raw.startsWith('/')) raw = `/${raw}`;
const parts = raw.split('/').filter(Boolean);
if (!parts.length) return '/';
for (const template of ROUTES) {
const segments = template.split('/').filter(Boolean);
if (segments.length !== parts.length) continue;
if (segments.every((s, i) => s.startsWith('{') || s === parts[i])) return template;
}
return null;
}
/** The credential classes a known route template accepts. Pure. */
export function accepts(route) {
const found = AUDIENCES[route];
return found ? new Set(found) : null;
}
/**
* Heuristic audience for a path the table has never seen. Pure.
* Returns [classes, reason]; classes is null where the heuristic declines.
*/
export function guess(path) {
const parts = String(path ?? '').split('?')[0].split('/').filter(Boolean);
if (!parts.length) return [null, 'an empty path matches nothing'];
const head = parts[0];
if (head === 'user') {
return [new Set(['any', 'u2s']),
'every route under /user means the authenticated user, and an ' +
'installation is not a user'];
}
if (head === 'app') {
return [new Set(['jwt']),
'routes under /app identify the App and are signed with the App JWT ' +
'rather than an installation token'];
}
if (head === 'installation') {
return [new Set(['s2s']),
"routes under /installation are the installation's own view of itself"];
}
if (head === 'notifications' || head === 'gists') {
return [new Set(['any', 'u2s']),
'this resource belongs to a person, so it needs a credential that has ' +
'one behind it'];
}
return [null,
'not in the table, and the first path segment carries no rule this ' +
'script is willing to apply'];
}
/** The App-appropriate replacement for a route, if there is one. Pure. */
export function substitute(route) {
return SUBSTITUTES[route] ?? null;
}
/** Turn a liveness proof and a route lookup into a finding. Pure. */
export function verdict(alive, status, route, classes, guessed = false) {
if (!alive) {
return ['not-an-installation-token',
'GET /installation/repositories did not return 200, so this credential ' +
'is not a live installation access token. Whatever the other call did, ' +
'it is not the mismatch this script looks for.'];
}
if (status !== null && status !== undefined && status < 400) {
return ['endpoint-accepted',
`${route ?? 'that path'} returned ${status} with this installation ` +
'token, so the route accepts it.'];
}
if (!classes) {
return ['route-unknown',
'this path is not in the route table and the heuristic declined it, so ' +
'the audience is genuinely unknown. Check the published list of ' +
'endpoints available to installation access tokens before rewriting ' +
'anything.'];
}
const hedge = guessed ? ' (by heuristic rather than from the table)' : '';
if (classes.has('s2s')) {
return ['installation-tokens-accepted',
`this route does accept installation access tokens${hedge}, so the ` +
'refusal is about a permission rather than about the credential class. ' +
'Read x-accepted-github-permissions on the same response.'];
}
if (classes.has('jwt') && !classes.has('u2s')) {
return ['needs-app-jwt',
`this route wants the App's own JWT${hedge}. The installation token is ` +
'what the JWT produces, not a substitute for it: sign a fresh JWT and ' +
'send that instead.'];
}
return ['needs-user-context',
`this route accepts ${[...classes].sort().join(', ')}${hedge}. An ` +
'installation access token is not one of them, so no permission opens ' +
'it: the credential has no user behind it and the route is asking about one.'];
}
async function get(token, path) {
const url = path.startsWith('/') ? API + path : path;
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': UA,
},
});
let body = null;
try { body = await res.json(); } catch { body = null; }
return { status: res.status, body };
}
async function main() {
const token = process.env.GITHUB_INSTALLATION_TOKEN ?? process.env.GITHUB_TOKEN;
if (!token) {
console.error('set GITHUB_INSTALLATION_TOKEN to an installation access ' +
'token. Without one there is no credential class to test a route against');
process.exitCode = 2;
return;
}
const path = process.argv[2] ?? '/user';
const probe = await get(token, '/installation/repositories?per_page=1');
const alive = probe.status === 200;
if (alive) {
const total = probe.body?.total_count;
console.log('installation token alive: GET /installation/repositories ' +
`returned 200 over ${total ?? 'an unreported number of'} repositories`);
} else {
console.log(`GET /installation/repositories returned ${probe.status}, so ` +
'this is not a live installation access token');
}
let status = null;
if (alive) {
({ status } = await get(token, path));
console.log(`${path} returned ${status}`);
}
const route = canonical(path);
let classes = route ? accepts(route) : null;
let guessed = false;
if (!classes) {
const [guessed_classes, reason] = guess(path);
classes = guessed_classes;
guessed = Boolean(classes);
console.log(`route: ${route ?? `not in the table (${reason})`}`);
} else {
console.log(`route: ${route} accepts ${[...classes].sort().join(', ')}`);
}
const [state, detail] = verdict(alive, status, route, classes, guessed);
console.log(`${state}: ${detail}`);
if (state === 'needs-user-context' || state === 'needs-app-jwt') {
const swap = substitute(route);
if (swap && swap[0]) {
console.log(`repair: call ${swap[0]} instead, which ${swap[1]}`);
} else if (swap) {
console.log(`repair: there is no server-to-server equivalent. ${swap[1]}`);
} else {
console.log('repair: find the App equivalent of this route in the ' +
'published endpoint list, or authorize a user and hold a user access ' +
'token for them');
}
}
if (state === 'installation-tokens-accepted') {
console.log('repair: this is a permissions finding rather than a ' +
"credential-class one; diff the App's permissions against the header " +
'the failing response carried');
}
console.log(JSON.stringify({
path,
route,
status,
installation_token_alive: alive,
accepts: classes ? [...classes].sort() : null,
by_heuristic: guessed,
state,
}, null, 2));
process.exitCode = (state === 'needs-user-context' ||
state === 'needs-app-jwt') ? 1 : 0;
}
// Only run when invoked directly, so importing this module from the test file
// does not execute main(), fail on the missing token and set an exit code that
// fails the suite even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The cases worth pinning are the ones that look alike and are not: /user against /users/octocat, a path with a query string against the same path without one, and a route that refuses because it wants a person against a route that refuses because it wants the App's own JWT. The liveness flag gets its own tests too, because a script that says this route refuses your credential class about a credential that expired an hour ago is confidently wrong.
from github_endpoint_audience import (
accepts, canonical, guess, substitute, verdict,
)
def test_a_concrete_path_reduces_to_its_template():
assert canonical("/repos/acme/api/issues") == "/repos/{owner}/{repo}/issues"
assert canonical("/users/octocat") == "/users/{username}"
def test_query_strings_fragments_and_slashes_do_not_change_the_route():
assert canonical("/user/repos?per_page=100") == "/user/repos"
assert canonical("/user/repos/") == "/user/repos"
assert canonical("https://api.github.com/user/repos") == "/user/repos"
assert canonical("user/repos") == "/user/repos"
def test_user_and_users_are_different_routes():
assert canonical("/user") == "/user"
assert canonical("/users/octocat") != "/user"
def test_an_unknown_path_is_not_forced_onto_a_template():
assert canonical("/enterprises/acme/audit-log") is None
def test_the_route_table_answers_only_for_known_routes():
assert "s2s" not in accepts("/user")
assert "s2s" in accepts("/repos/{owner}/{repo}/issues")
assert accepts("/nowhere") is None
def test_the_heuristic_covers_the_user_family_and_declines_the_rest():
classes, _ = guess("/user/blocks")
assert classes == {"any", "u2s"}
classes, reason = guess("/enterprises/acme/audit-log")
assert classes is None
assert "not in the table" in reason
def test_the_heuristic_knows_app_routes_want_the_jwt():
classes, _ = guess("/app/hook/config")
assert classes == {"jwt"}
def test_a_dead_credential_is_never_reported_as_a_route_problem():
state, detail = verdict(False, 403, "/user", {"any", "u2s"})
assert state == "not-an-installation-token"
assert "not the mismatch" in detail
def test_a_route_that_wants_a_person_names_that_and_not_a_permission():
state, detail = verdict(True, 403, "/user", accepts("/user"))
assert state == "needs-user-context"
assert "no permission opens it" in detail
def test_a_route_that_wants_the_app_jwt_is_its_own_state():
state, detail = verdict(True, 401, "/app", accepts("/app"))
assert state == "needs-app-jwt"
assert "sign a fresh JWT" in detail
def test_a_route_that_does_accept_installation_tokens_is_sent_elsewhere():
state, detail = verdict(True, 403, "/repos/{owner}/{repo}/hooks",
accepts("/repos/{owner}/{repo}/hooks"))
assert state == "installation-tokens-accepted"
assert "x-accepted-github-permissions" in detail
def test_a_successful_call_is_not_a_finding():
assert verdict(True, 200, "/installation/repositories",
accepts("/installation/repositories"))[0] == "endpoint-accepted"
def test_an_unknown_route_says_so_rather_than_guessing():
state, detail = verdict(True, 403, None, None)
assert state == "route-unknown"
assert "genuinely unknown" in detail
def test_a_heuristic_answer_is_labelled_as_one():
_, detail = verdict(True, 403, None, {"any", "u2s"}, guessed=True)
assert "by heuristic" in detail
def test_substitutes_exist_where_there_is_an_equivalent_and_not_where_there_is_not():
assert substitute("/user/repos")[0] == "/installation/repositories"
assert substitute("/gists")[0] is None
assert substitute("/repos/{owner}/{repo}") is None
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
accepts, canonical, guess, substitute, verdict,
} from './github-endpoint-audience.mjs';
test('a concrete path reduces to its template', () => {
assert.equal(canonical('/repos/acme/api/issues'), '/repos/{owner}/{repo}/issues');
assert.equal(canonical('/users/octocat'), '/users/{username}');
});
test('query strings, fragments and slashes do not change the route', () => {
assert.equal(canonical('/user/repos?per_page=100'), '/user/repos');
assert.equal(canonical('/user/repos/'), '/user/repos');
assert.equal(canonical('https://api.github.com/user/repos'), '/user/repos');
assert.equal(canonical('user/repos'), '/user/repos');
});
test('user and users are different routes', () => {
assert.equal(canonical('/user'), '/user');
assert.notEqual(canonical('/users/octocat'), '/user');
});
test('an unknown path is not forced onto a template', () => {
assert.equal(canonical('/enterprises/acme/audit-log'), null);
});
test('the route table answers only for known routes', () => {
assert.ok(!accepts('/user').has('s2s'));
assert.ok(accepts('/repos/{owner}/{repo}/issues').has('s2s'));
assert.equal(accepts('/nowhere'), null);
});
test('the heuristic covers the user family and declines the rest', () => {
const [classes] = guess('/user/blocks');
assert.deepEqual([...classes].sort(), ['any', 'u2s']);
const [none, reason] = guess('/enterprises/acme/audit-log');
assert.equal(none, null);
assert.match(reason, /not in the table/);
});
test('the heuristic knows app routes want the jwt', () => {
const [classes] = guess('/app/hook/config');
assert.deepEqual([...classes], ['jwt']);
});
test('a dead credential is never reported as a route problem', () => {
const [state, detail] = verdict(false, 403, '/user', new Set(['any', 'u2s']));
assert.equal(state, 'not-an-installation-token');
assert.match(detail, /not the mismatch/);
});
test('a route that wants a person names that and not a permission', () => {
const [state, detail] = verdict(true, 403, '/user', accepts('/user'));
assert.equal(state, 'needs-user-context');
assert.match(detail, /no permission opens it/);
});
test('a route that wants the app jwt is its own state', () => {
const [state, detail] = verdict(true, 401, '/app', accepts('/app'));
assert.equal(state, 'needs-app-jwt');
assert.match(detail, /sign a fresh JWT/);
});
test('a route that does accept installation tokens is sent elsewhere', () => {
const route = '/repos/{owner}/{repo}/hooks';
const [state, detail] = verdict(true, 403, route, accepts(route));
assert.equal(state, 'installation-tokens-accepted');
assert.match(detail, /x-accepted-github-permissions/);
});
test('a successful call is not a finding', () => {
const route = '/installation/repositories';
assert.equal(verdict(true, 200, route, accepts(route))[0], 'endpoint-accepted');
});
test('an unknown route says so rather than guessing', () => {
const [state, detail] = verdict(true, 403, null, null);
assert.equal(state, 'route-unknown');
assert.match(detail, /genuinely unknown/);
});
test('a heuristic answer is labelled as one', () => {
const [, detail] = verdict(true, 403, null, new Set(['any', 'u2s']), true);
assert.match(detail, /by heuristic/);
});
test('substitutes exist where there is an equivalent and not where there is not', () => {
assert.equal(substitute('/user/repos')[0], '/installation/repositories');
assert.equal(substitute('/gists')[0], null);
assert.equal(substitute('/repos/{owner}/{repo}'), null);
});
FAQ
Is this the same thing as a missing App permission?
No, and the difference is whether a permission could ever help. A missing permission is a 403 on a route that does accept installation tokens, and the response names what it wanted in x-accepted-github-permissions. This is a 403 on a route that does not accept them at all, so that header is absent and there is nothing to add. The quickest discriminator is the header: present means go and diff the App's permissions, absent means the route is refusing the credential class and you need a different endpoint.
Why does GET /user work for my personal access token but not for the App?
Because a personal access token has a person behind it and an installation access token does not. GET /user is defined as the account the credential belongs to, and an installation belongs to an App on an organization or user account rather than to a human being. There is no answer to return, so the request is refused. If your library calls /user for logging or for a whoami check, point it at GET /app under the App JWT, which returns the App's own id, slug and owner.
Can I get a token that does work on the user endpoints?
Yes: a user access token, obtained by sending a person through the App's authorization flow so they consent, after which the App can act as them for the routes that need a user. It behaves quite differently from an installation token, since it is bounded by that person's own access as well as by the App's permissions, and it reintroduces a dependency on a specific human. That is worth doing on purpose for something like reading a signed-in user's email address, and worth avoiding for a background job.
The script says route-unknown. What now?
It means the path is not in the curated table and the first segment carried no rule the script would apply, which happens for the newer and more specialised parts of the API. Nothing about the API reports its own credential-class rules, so there is no way to resolve it programmatically: read the published list of endpoints available for installation access tokens and, if the route is on it, the refusal is a permission after all. Reporting the uncertainty is deliberate, because a confident wrong answer here sends someone to rewrite a working call.
Does adding every permission to the App eventually make it work?
It cannot, and this is the loop worth breaking. Permissions decide what an installation may do to resources; they do not give the installation a user, and the routes in question are asking for one. An App that keeps requesting broader permissions in pursuit of a route that will never accept it ends up with organization administration it never uses, which is a security finding created entirely by chasing the wrong cause. Stop when the failing response carries no accepted-permissions header.
Related field notes
- Resource not accessible by integration
- An installation that covers only some repositories
- An App JWT rejected on its expiry claim
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- Authenticating as a GitHub App installation — GitHub Docs
- Endpoints available for GitHub App installation access tokens — GitHub Docs
- Authenticating with a GitHub App on behalf of a user — GitHub Docs
- Permissions required for GitHub Apps — GitHub Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.