Diagnostic GitHub API
The org restricts OAuth Apps and this one was never approved
The integration works. Customers sign in, it reads their repositories, and the reviews are good. Then one customer files a ticket saying it shows them nothing, and their logs show 403 on every call that touches their organization while the calls against their personal repositories return happily. The token is fine. The scopes are the same ones every other customer granted. The account is a member of the organization and can see all of it in a browser. What is different is a setting neither of you can see from where you are standing: the organization has decided which OAuth Apps may touch its data, and yours is not on the list.
An organization can restrict OAuth App access to its data. When that policy is on, an application must be approved by an organization owner before any token it issues can read organization resources, and until then those tokens are refused for that organization while working normally everywhere else.
The finding is a shape, not a header. The same token succeeds on GET /user/repos and is refused on GET /orgs/{org}/repos, the refusal message mentions OAuth App access restrictions, and — the reading that does the real work — there is no x-github-sso header on it. A SAML refusal always carries that header. This one never does, which is how you tell a policy about the application apart from a policy about the token.
The repair belongs to an organization owner, who approves the application, or to you, by replacing the OAuth App with a GitHub App whose access is granted per installation instead of by a blanket policy.
The problem in plain words
This one is expensive because of who can see it. The organization's OAuth App policy is org-owner territory: a member sees the effect, the app's author sees nothing at all. So the two people trying to solve it are looking at different halves of a picture that only makes sense assembled. The author checks the app's registration, the scopes, the callback URL and the token issuance, and finds everything correct — because everything is correct. The customer checks their own account, sees they are a member with plenty of access, and reasonably concludes the integration is broken.
The support thread that follows is a classic. Reauthorize, says the author. Same result. Try a fresh account, says the author. Same result, because the policy is about the application and not the person. Try a different organization, and it suddenly works, which sends everyone off to look for something unusual about the first organization's repositories. Nothing about the repositories is unusual. A single checkbox in the organization's settings is.
The failing shape also mimics two other problems closely enough to waste days on either. It looks like a missing scope, because it is a 403 that appears on some resources and not others. It looks like SAML enforcement, because it is an organization refusing a valid token. Both of those have loud, readable signals attached — a header naming the scope that would have worked, or a header naming SSO — and this one's distinguishing feature is the absence of both, which is exactly the sort of evidence nobody goes looking for.
Why it happens
The gate is on the application, not on the credential. Every other refusal in this section is about the token: too narrow, expired, revoked, unauthorized. This is a policy that names your OAuth App and refuses everything it ever issues for that organization. That is why reissuing a token changes nothing, why a different user's token behaves identically, and why the failure is perfectly consistent per organization rather than per account.
The absence of the SAML header is the load-bearing reading. Both an unapproved OAuth App and an unauthorized token are organizations refusing a valid credential, and both are 403. SAML enforcement always announces itself with x-github-sso; the OAuth restriction never does. So the script records the header's absence explicitly as evidence rather than as a nothing, and refuses to call an OAuth restriction while that header is present.
The message string is corroboration, not proof. GitHub's wording about OAuth App access restrictions is helpful and it is prose, which means it can be rewritten at any time without warning. A diagnostic that matches only on the sentence breaks silently on the day the sentence changes, and a diagnostic that ignores it throws away the one direct statement of the cause. The script does both: the behavioural shape decides the verdict, the message raises the confidence, and the two are reported separately so a reader can see which they are relying on.
A restricted token can see less than no token at all. The most vivid evidence in the run is free: read the same organization listing with no credential. Anonymous callers see public repositories, so a run where the anonymous read returns 200 and the authenticated one returns 403 is a token being actively refused rather than merely under-privileged. That contrast is impossible to argue with, and it costs nothing against the core quota because unauthenticated requests draw on a separate bucket.
Only a member can run the diagnosis. This is the honest limit and it needs stating in the output, not in a footnote. The organization's policy is not readable from the app's side at all, so the app's author cannot confirm this even with perfect logs. The script has to be run by somebody holding a token issued to that application, by a member of that organization. If you are the author, this note is the thing to send to your customer.
GitHub Apps do not have this failure mode. An OAuth App asks for a blanket organization-wide policy decision; a GitHub App is installed on specific accounts and repositories with permissions the installer accepts. The approval is still a human step, but it is part of installing rather than a policy your app has to discover it has fallen foul of, and the App can see its own installations through the API. That is the structural repair when the same ticket arrives from a third customer.
The fix, as a flow
The verdict here is a shape and two absences. One token reads its own namespace and then the organization's, and personal succeeding beside organization refused is a gate around the organization rather than a complaint about the credential. The first absence is the SAML header, whose presence would make this somebody else's note, so the script records not finding it as a reading rather than as nothing. The second is that no endpoint publishes the policy at all, which is why the message string is scored separately as corroboration. The anonymous read costs nothing against core quota and produces the line no one can argue with: refused where no credential at all succeeds.
How to fix it
Name the credential, because this policy only touches OAuth Apps
The prefix is read locally. OAuth App access restrictions govern tokens issued by an OAuth App, so a token from one is in scope and a fine-grained personal access token or an App installation token is not — those get refused by different mechanisms with different repairs. Naming the credential first stops the whole diagnosis being aimed at the wrong gate.
Take the two-namespace reading that makes the shape
GET /user/repos?per_page=1 and GET /orgs/{org}/repos?per_page=1 with the same token, back to back. Personal succeeding while organization is refused is the signature. Both failing is a credential problem and a different note; both succeeding means nothing is restricted for this application today.
Read the absence of x-github-sso as evidence
The script records whether the SAML header was on the refusal. Present means this is SAML enforcement and the diagnosis stops here with a pointer to the note that owns it. Absent, alongside the two-namespace shape, is what makes it an application-level restriction. The header being missing is a finding, and the script prints it as one rather than saying nothing.
Compare against a request with no credential at all
One unauthenticated read of the same organization listing. If an anonymous caller gets 200 where the authenticated token gets 403, the token is being refused rather than being under-privileged, which is the difference between a policy and a permission. It costs nothing against your core quota because it draws on the unauthenticated bucket instead.
Print who has to act, and say plainly who can even see this
The repair is an organization owner approving the application in the organization's third-party access settings, and the script names that rather than pretending there is an API for it. It also prints the visibility asymmetry: the app's author cannot confirm this from their side, so the run has to happen with a member's token. Nothing here approves, requests approval, or asks anybody for anything.
How to check it worked
Once an owner approves the application, the organization listing starts answering the same token that was refused an hour ago.
GITHUB_TOKEN=$OAUTH_USER_TOKEN python3 github_oauth_app_restriction.py acme-corp
# read cost: 3 request(s) against the core hourly quota, plus 1 unauthenticated
# request against the separate 60-an-hour anonymous bucket
# credential: OAuth user token, account=dana
# GET /user/repos -> 200
# GET /orgs/acme-corp/repos -> 403
# x-github-sso: absent, and that absence is the finding: a SAML refusal always
# carries this header
# message: matched the documented OAuth App restriction wording
# anonymous read of the same listing -> 200
# restricted-below-anonymous: this token is refused where no token at all
# succeeds, so it is being blocked rather than under-privileged
# oauth-app-restricted: acme-corp restricts which OAuth Apps may access its
# data and this application has not been approved. No scope, no reissued
# token and no other user account will change that.
# visibility: the application's author cannot see this policy from their side.
# This run needs a token issued to the app, held by a member of acme-corp.
# repair: an owner of acme-corp approves the application in the organization's
# third-party access settings. There is no API that grants it and this script
# does not ask for it. Structurally, a GitHub App is installed per account
# rather than approved by blanket policy.
The full code
The verdict comes from a shape and two absences, which is unusual enough to be worth saying out loud. The shape is one token reading two namespaces. The first absence is x-github-sso, whose presence would make this a SAML problem instead; the script treats not finding it as a positive reading rather than as nothing happening. The second is the lack of any endpoint that publishes the policy, which is why the message string is scored separately as corroboration and why the run ends by naming the person who has to act. The anonymous read at the end is four lines of code and the most convincing line of output.
"""Show that an organization is refusing an application rather than a token.
Read only. GET requests and nothing else, and it approves nothing: approving an
OAuth App for an organization is an owner's decision made in the organization's
settings, there is no API that performs it, and this script neither asks for it
nor pretends to.
The verdict is a behavioural shape plus two absences. One token reads two
namespaces, personal and organization; a refusal on the second while the first
succeeds is the shape. The refusal carrying no x-github-sso header is the first
absence, and it is what separates an application-level policy from SAML
enforcement. The lack of any endpoint that publishes the policy is the second,
which is why the message string is scored as corroboration rather than proof.
What this can and cannot see: it can prove that this organization refuses this
credential where an anonymous caller succeeds, and that GitHub did not
attribute the refusal to SAML. It cannot read the organization's OAuth App
policy, because that needs owner access, and it cannot be run usefully by the
application's author at all -- the policy is invisible from the app's side. Run
it with a token issued to the application, held by a member of the
organization.
Environment:
GITHUB_TOKEN a token issued by the OAuth App, held by an org member
"""
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_oauth_app_restriction")
API = "https://api.github.com"
UA = "github-oauth-app-restriction/1.0"
SSO_HEADER = "x-github-sso"
ACCEPTED_SCOPES_HEADER = "x-accepted-oauth-scopes"
TOKEN_PREFIXES = (
("github_pat_", "fine-grained PAT"),
("ghp_", "classic PAT"),
("gho_", "OAuth user token"),
("ghu_", "App user-to-server token"),
("ghs_", "App installation token"),
("ghr_", "App refresh token"),
)
# Credentials this policy can actually govern. It restricts OAuth Apps, so a
# token issued by one is in scope and the rest are refused, when they are
# refused, by other mechanisms with other repairs.
GOVERNED_BY_OAUTH_POLICY = {
"OAuth user token": True,
"unknown": True,
"classic PAT": False,
"fine-grained PAT": False,
"App user-to-server token": False,
"App installation token": False,
"App refresh token": False,
}
# Corroboration only. This is prose written by GitHub and it can be reworded
# without warning, so matching it raises confidence and never decides the
# verdict on its own.
RESTRICTION_PHRASES = (
"oauth app access restrictions",
"oauth application access restrictions",
"third-party application",
"has not been granted access",
)
def read_cost():
"""Authenticated requests this run spends. Pure.
The anonymous read is deliberately not counted here: it draws on the
separate 60-an-hour unauthenticated bucket rather than on core quota.
"""
return 3
def token_kind(token):
"""Name the credential from its prefix. Pure; nothing leaves the machine."""
value = (token or "").strip()
for prefix, name in TOKEN_PREFIXES:
if value.startswith(prefix):
return name
return "unknown"
def governed(kind):
"""Can this policy apply to this credential at all. Pure. (bool, detail)."""
if GOVERNED_BY_OAUTH_POLICY.get(kind, True):
return (True, "this policy governs tokens issued by an OAuth App, and "
"a %s is one of those." % kind)
return (False, "a %s is not issued by an OAuth App, so this policy does not "
"govern it. A refusal here has another cause and another "
"note." % kind)
def message_signature(message):
"""Score the refusal's prose. Pure. (matched, phrase or None).
Deliberately a list of substrings rather than one exact sentence, because
the wording is not an API contract and a diagnostic that hangs on it breaks
silently the day it is edited.
"""
text = str(message or "").lower()
for phrase in RESTRICTION_PHRASES:
if phrase in text:
return (True, phrase)
return (False, None)
def namespace_shape(personal_status, org_status):
"""The two-namespace reading. Pure. (state, detail)."""
personal_ok = personal_status == 200
org_refused = org_status in (403, 404)
if personal_ok and org_refused:
return ("personal-ok-org-refused",
"the same token reads personal repositories and is refused on "
"this organization, which is a gate around the organization "
"rather than a problem with the credential.")
if not personal_ok and org_refused:
return ("refused-everywhere",
"the token is refused on personal repositories too, so this is "
"the credential rather than any organization policy.")
if personal_ok and not org_refused:
return ("nothing-refused",
"both namespaces answered, so nothing is being restricted for "
"this application today.")
return ("unclassified-shape",
"the pair of reads does not match a shape this script knows how to "
"name; report both statuses rather than guessing.")
def anonymous_contrast(anon_status, token_status):
"""Compare a credentialled read against no credential at all. Pure."""
if anon_status == 200 and token_status in (403, 404):
return ("restricted-below-anonymous",
"this token is refused where no token at all succeeds, so it is "
"being blocked rather than being under-privileged.")
if anon_status in (403, 404) and token_status in (403, 404):
return ("private-to-everyone",
"an anonymous caller cannot see this organization's listing "
"either, so the contrast proves nothing here. The organization "
"may simply have no public repositories.")
return ("no-contrast",
"the authenticated read succeeded, so there is nothing to contrast.")
def discriminate(shape, sso_form, accepted_scopes, matched, kind):
"""The verdict. Pure. (state, detail).
Order matters. SAML is checked first because its header is unambiguous, the
scope header second because it names its own repair, and the OAuth
restriction last because it is the diagnosis of exclusion -- established by
a shape and by what is missing.
"""
ok, _detail = governed(kind)
if sso_form:
return ("saml-not-oauth-restriction",
"the refusal carries x-github-sso, so this is SAML enforcement "
"and not an application policy. Two different notes, and the "
"header settles which.")
if accepted_scopes:
return ("scope-shaped-refusal",
"the refusal names the scopes it accepts in "
"x-accepted-oauth-scopes, which an application restriction does "
"not do. Diff that against what the token holds first.")
if shape == "refused-everywhere":
return ("credential-problem",
"the token is refused in its own namespace, so nothing about "
"an organization's policy explains it.")
if shape == "nothing-refused":
return ("not-restricted",
"this application is reaching the organization's resources "
"right now.")
if shape != "personal-ok-org-refused":
return ("undetermined",
"the reads do not form a shape this script will put a name to.")
if not ok:
return ("not-an-oauth-app-credential",
"the shape is right but the credential is not one this policy "
"governs, so look for an organization gate that applies to this "
"credential type instead.")
if matched:
return ("oauth-app-restricted",
"this organization restricts which OAuth Apps may access its "
"data and this application has not been approved. No scope, no "
"reissued token and no other user account will change that.")
return ("oauth-app-restricted-likely",
"the shape is exactly an application restriction and the refusal's "
"wording did not match anything known, which happens when GitHub "
"rewords a message. Treat the shape as the finding and the wording "
"as unavailable corroboration.")
def visibility_note():
"""Who can and cannot run this diagnosis. Pure."""
return ("the application's author cannot see this policy from their side. "
"This run needs a token issued to the app, held by a member of the "
"organization.")
def repair(state, org):
"""The sentence a reader has to act on. Pure."""
if state in ("oauth-app-restricted", "oauth-app-restricted-likely"):
return ("an owner of %s approves the application in the organization's "
"third-party access settings. There is no API that grants it "
"and this script does not ask for it. Structurally, a GitHub "
"App is installed per account rather than approved by blanket "
"policy, which removes this failure mode." % org)
if state == "saml-not-oauth-restriction":
return ("authorize the credential for the organization through the URL "
"in the x-github-sso header; that is a different repair.")
if state == "scope-shaped-refusal":
return ("compare the accepted scopes against the ones the token holds "
"and mint the narrowest one that closes the gap.")
if state == "credential-problem":
return ("fix the credential first; no organization policy is in play "
"while personal reads are failing too.")
if state == "not-an-oauth-app-credential":
return ("find the gate that applies to this credential type. An OAuth "
"App policy is not it.")
if state == "not-restricted":
return "nothing. This application is not being restricted by %s." % org
return ("report both statuses and the headers; this run did not reach a "
"verdict worth acting on.")
def get(session, url):
"""One GET. Returns the response object."""
r = session.get(url, timeout=30)
if r.status_code == 401:
raise SystemExit("401 from GitHub: GITHUB_TOKEN is missing, malformed "
"or revoked. That is a different note.")
return r
def body_message(response):
"""The API's message string, if the body has one. Pure enough."""
try:
payload = response.json()
except ValueError:
return ""
return (payload or {}).get("message", "") if isinstance(payload, dict) else ""
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("org", help="the organization refusing the application")
args = ap.parse_args()
token = os.environ.get("GITHUB_TOKEN")
if not token:
log.error("set GITHUB_TOKEN (a read-only token issued by the app)")
return 2
log.info("read cost: %d request(s) against the core hourly quota, plus 1 "
"unauthenticated request against the separate 60-an-hour "
"anonymous bucket", read_cost())
common = {
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
# GitHub rejects requests with no User-Agent outright.
"User-Agent": UA,
}
session = requests.Session()
session.headers.update(dict(common, Authorization="Bearer " + token))
kind = token_kind(token)
me = get(session, API + "/user")
account = (me.json() or {}).get("login") if me.status_code == 200 else None
log.info("credential: %s, account=%s", kind, account or "unreadable")
personal = get(session, API + "/user/repos?per_page=1")
log.info("GET /user/repos -> %s", personal.status_code)
org_listing = get(session, API + "/orgs/%s/repos?per_page=1" % args.org)
log.info("GET /orgs/%s/repos -> %s", args.org, org_listing.status_code)
sso_form = (org_listing.headers.get(SSO_HEADER) or "").split(";")[0].strip().lower()
accepted = org_listing.headers.get(ACCEPTED_SCOPES_HEADER)
log.info("%s: %s", SSO_HEADER,
sso_form or "absent, and that absence is the finding: a SAML "
"refusal always carries this header")
matched, phrase = message_signature(body_message(org_listing))
log.info("message: %s", "matched the documented OAuth App restriction "
"wording" if matched else "did not match any known "
"restriction wording")
anonymous = requests.Session()
anonymous.headers.update(common)
anon = anonymous.get(API + "/orgs/%s/repos?per_page=1" % args.org, timeout=30)
log.info("anonymous read of the same listing -> %s", anon.status_code)
contrast_state, contrast_detail = anonymous_contrast(
anon.status_code, org_listing.status_code)
log.info("%s: %s", contrast_state, contrast_detail)
shape, shape_detail = namespace_shape(personal.status_code,
org_listing.status_code)
log.info("%s: %s", shape, shape_detail)
state, detail = discriminate(shape, sso_form, accepted, matched, kind)
log.info("%s: %s", state, detail)
log.info("visibility: %s", visibility_note())
log.info("repair: %s", repair(state, args.org))
print(json.dumps({
"organization": args.org,
"account": account,
"credential_kind": kind,
"personal_status": personal.status_code,
"org_status": org_listing.status_code,
"anonymous_status": anon.status_code,
"sso_header": sso_form or None,
"accepted_scopes_header": accepted,
"message_matched": matched,
"message_phrase": phrase,
"shape": shape,
"contrast": contrast_state,
"state": state,
"detail": detail,
"visibility": visibility_note(),
"repair": repair(state, args.org),
}, indent=2, default=str))
return 1 if state.startswith("oauth-app-restricted") else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Show that an organization is refusing an application rather than a token.
*
* Read only, and it approves nothing: approving an OAuth App for an
* organization is an owner decision made in the organization settings, there is
* no API that performs it, and this script neither asks for it nor pretends to.
*
* The verdict is a behavioural shape plus two absences: one token reading two
* namespaces, a refusal with no x-github-sso header on it, and no endpoint
* anywhere that publishes the policy.
*
* Environment:
* GITHUB_TOKEN a token issued by the OAuth App, held by an org member
* GITHUB_ORG the organization refusing the application
*/
const API = 'https://api.github.com';
const UA = 'github-oauth-app-restriction/1.0';
export const SSO_HEADER = 'x-github-sso';
export const ACCEPTED_SCOPES_HEADER = 'x-accepted-oauth-scopes';
export const TOKEN_PREFIXES = [
['github_pat_', 'fine-grained PAT'],
['ghp_', 'classic PAT'],
['gho_', 'OAuth user token'],
['ghu_', 'App user-to-server token'],
['ghs_', 'App installation token'],
['ghr_', 'App refresh token'],
];
/** Credentials this policy can actually govern. */
export const GOVERNED_BY_OAUTH_POLICY = {
'OAuth user token': true,
unknown: true,
'classic PAT': false,
'fine-grained PAT': false,
'App user-to-server token': false,
'App installation token': false,
'App refresh token': false,
};
/** Corroboration only. GitHub prose can be reworded without warning. */
export const RESTRICTION_PHRASES = [
'oauth app access restrictions',
'oauth application access restrictions',
'third-party application',
'has not been granted access',
];
/** Authenticated requests this run spends. The anonymous read is separate. */
export function readCost() {
return 3;
}
/** Name the credential from its prefix. Pure. */
export function tokenKind(token) {
const value = String(token ?? '').trim();
for (const [prefix, name] of TOKEN_PREFIXES) {
if (value.startsWith(prefix)) return name;
}
return 'unknown';
}
/** Can this policy apply to this credential at all. Pure. */
export function governed(kind) {
if (GOVERNED_BY_OAUTH_POLICY[kind] ?? true) {
return [true, `this policy governs tokens issued by an OAuth App, and a ${kind} is one.`];
}
return [false, `a ${kind} is not issued by an OAuth App, so this policy does `
+ 'not govern it. A refusal here has another cause and another note.'];
}
/** Score the refusal prose. Pure. [matched, phrase]. */
export function messageSignature(message) {
const text = String(message ?? '').toLowerCase();
for (const phrase of RESTRICTION_PHRASES) {
if (text.includes(phrase)) return [true, phrase];
}
return [false, null];
}
/** The two-namespace reading. Pure. */
export function namespaceShape(personalStatus, orgStatus) {
const personalOk = personalStatus === 200;
const orgRefused = orgStatus === 403 || orgStatus === 404;
if (personalOk && orgRefused) {
return ['personal-ok-org-refused', 'the same token reads personal '
+ 'repositories and is refused on this organization, which is a gate '
+ 'around the organization rather than a problem with the credential.'];
}
if (!personalOk && orgRefused) {
return ['refused-everywhere', 'the token is refused on personal repositories '
+ 'too, so this is the credential rather than any organization policy.'];
}
if (personalOk && !orgRefused) {
return ['nothing-refused', 'both namespaces answered, so nothing is being '
+ 'restricted for this application today.'];
}
return ['unclassified-shape', 'the pair of reads does not match a shape this '
+ 'script knows how to name.'];
}
/** Compare a credentialled read against no credential at all. Pure. */
export function anonymousContrast(anonStatus, tokenStatus) {
const refused = tokenStatus === 403 || tokenStatus === 404;
if (anonStatus === 200 && refused) {
return ['restricted-below-anonymous', 'this token is refused where no token '
+ 'at all succeeds, so it is being blocked rather than under-privileged.'];
}
if ((anonStatus === 403 || anonStatus === 404) && refused) {
return ['private-to-everyone', 'an anonymous caller cannot see this listing '
+ 'either, so the contrast proves nothing here.'];
}
return ['no-contrast', 'the authenticated read succeeded, so there is nothing '
+ 'to contrast.'];
}
/** The verdict. Pure. [state, detail]. */
export function discriminate(shape, ssoForm, acceptedScopes, matched, kind) {
const [ok] = governed(kind);
if (ssoForm) {
return ['saml-not-oauth-restriction', 'the refusal carries x-github-sso, so '
+ 'this is SAML enforcement and not an application policy.'];
}
if (acceptedScopes) {
return ['scope-shaped-refusal', 'the refusal names the scopes it accepts in '
+ 'x-accepted-oauth-scopes, which an application restriction does not do.'];
}
if (shape === 'refused-everywhere') {
return ['credential-problem', 'the token is refused in its own namespace, so '
+ 'no organization policy explains it.'];
}
if (shape === 'nothing-refused') {
return ['not-restricted', 'this application is reaching the organization '
+ 'resources right now.'];
}
if (shape !== 'personal-ok-org-refused') {
return ['undetermined', 'the reads do not form a shape this script will put '
+ 'a name to.'];
}
if (!ok) {
return ['not-an-oauth-app-credential', 'the shape is right but the credential '
+ 'is not one this policy governs.'];
}
if (matched) {
return ['oauth-app-restricted', 'this organization restricts which OAuth Apps '
+ 'may access its data and this application has not been approved. No '
+ 'scope, no reissued token and no other user account will change that.'];
}
return ['oauth-app-restricted-likely', 'the shape is exactly an application '
+ 'restriction and the wording did not match anything known, which happens '
+ 'when GitHub rewords a message.'];
}
/** Who can and cannot run this diagnosis. Pure. */
export function visibilityNote() {
return 'the application author cannot see this policy from their side. This run '
+ 'needs a token issued to the app, held by a member of the organization.';
}
/** The sentence a reader has to act on. Pure. */
export function repair(state, org) {
if (state === 'oauth-app-restricted' || state === 'oauth-app-restricted-likely') {
return `an owner of ${org} approves the application in the organization `
+ 'third-party access settings. There is no API that grants it and this '
+ 'script does not ask for it. Structurally, a GitHub App is installed per '
+ 'account rather than approved by blanket policy.';
}
if (state === 'saml-not-oauth-restriction') {
return 'authorize the credential through the URL in the x-github-sso header.';
}
if (state === 'scope-shaped-refusal') {
return 'compare the accepted scopes against the ones the token holds.';
}
if (state === 'credential-problem') {
return 'fix the credential first; no organization policy is in play.';
}
if (state === 'not-an-oauth-app-credential') {
return 'find the gate that applies to this credential type.';
}
if (state === 'not-restricted') {
return `nothing. This application is not being restricted by ${org}.`;
}
return 'report both statuses and the headers; this run reached no verdict.';
}
function headers(token) {
const common = {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': UA,
};
return token ? { ...common, Authorization: `Bearer ${token}` } : common;
}
async function main() {
const token = process.env.GITHUB_TOKEN;
const org = process.env.GITHUB_ORG;
if (!token || !org) {
console.error('set GITHUB_TOKEN (issued by the app) and GITHUB_ORG');
process.exitCode = 2;
return;
}
console.log(`read cost: ${readCost()} request(s) against the core hourly quota, `
+ 'plus 1 unauthenticated request against the separate anonymous bucket');
const kind = tokenKind(token);
const me = await fetch(`${API}/user`, { headers: headers(token) });
const account = me.status === 200 ? (await me.json()).login : null;
console.log(`credential: ${kind}, account=${account ?? 'unreadable'}`);
const personal = await fetch(`${API}/user/repos?per_page=1`, { headers: headers(token) });
console.log(`GET /user/repos -> ${personal.status}`);
const orgListing = await fetch(`${API}/orgs/${org}/repos?per_page=1`,
{ headers: headers(token) });
console.log(`GET /orgs/${org}/repos -> ${orgListing.status}`);
const ssoForm = (orgListing.headers.get(SSO_HEADER) || '').split(';')[0].trim().toLowerCase();
const accepted = orgListing.headers.get(ACCEPTED_SCOPES_HEADER);
console.log(`${SSO_HEADER}: ${ssoForm || 'absent, and that absence is the finding'}`);
let message = '';
try {
const body = await orgListing.json();
message = body && typeof body === 'object' ? (body.message || '') : '';
} catch { message = ''; }
const [matched, phrase] = messageSignature(message);
console.log(`message: ${matched ? 'matched the documented restriction wording'
: 'did not match any known restriction wording'}`);
const anon = await fetch(`${API}/orgs/${org}/repos?per_page=1`, { headers: headers(null) });
console.log(`anonymous read of the same listing -> ${anon.status}`);
const [contrastState, contrastDetail] = anonymousContrast(anon.status, orgListing.status);
console.log(`${contrastState}: ${contrastDetail}`);
const [shape, shapeDetail] = namespaceShape(personal.status, orgListing.status);
console.log(`${shape}: ${shapeDetail}`);
const [state, detail] = discriminate(shape, ssoForm, accepted, matched, kind);
console.log(`${state}: ${detail}`);
console.log(`visibility: ${visibilityNote()}`);
console.log(`repair: ${repair(state, org)}`);
console.log(JSON.stringify({
organization: org,
account,
credential_kind: kind,
personal_status: personal.status,
org_status: orgListing.status,
anonymous_status: anon.status,
sso_header: ssoForm || null,
accepted_scopes_header: accepted,
message_matched: matched,
message_phrase: phrase,
shape,
contrast: contrastState,
state,
detail,
visibility: visibilityNote(),
repair: repair(state, org),
}, null, 2));
process.exitCode = state.startsWith('oauth-app-restricted') ? 1 : 0;
}
// Guarded so importing this file from the test runner does not start a run.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
Two of these tests exist to stop this note eating its neighbours. A refusal carrying x-github-sso has to come back as SAML no matter how perfectly the rest of the evidence fits an application restriction, and a refusal carrying x-accepted-oauth-scopes has to come back as a scope problem for the same reason: both of those name their own repair, and a diagnosis of exclusion must never outrank a direct statement. The rest asserts that the verdict survives GitHub rewording its message — the shape still decides, the wording only raises confidence — and that a token refused where an anonymous caller succeeds is reported as blocked rather than as under-privileged.
from github_oauth_app_restriction import (
anonymous_contrast, discriminate, governed, message_signature,
namespace_shape, read_cost, repair, token_kind, visibility_note,
)
RESTRICTED = ("Although you appear to have the correct authorization "
"credentials, the acme-corp organization has enabled OAuth App "
"access restrictions.")
def test_the_shape_is_one_token_reading_two_namespaces():
state, detail = namespace_shape(200, 403)
assert state == "personal-ok-org-refused"
assert "rather than a problem with the credential" in detail
assert namespace_shape(403, 403)[0] == "refused-everywhere"
assert namespace_shape(200, 200)[0] == "nothing-refused"
def test_a_saml_header_outranks_every_other_piece_of_evidence():
# Even with the perfect shape and the exact message, a refusal that names
# SSO is the other note. A diagnosis of exclusion never beats a statement.
matched, _ = message_signature(RESTRICTED)
state, detail = discriminate("personal-ok-org-refused", "required", None,
matched, "OAuth user token")
assert state == "saml-not-oauth-restriction"
assert "x-github-sso" in detail
def test_an_accepted_scopes_header_outranks_it_too():
state, _ = discriminate("personal-ok-org-refused", "", "repo, read:org",
True, "OAuth user token")
assert state == "scope-shaped-refusal"
def test_the_verdict_survives_github_rewording_the_message():
matched, phrase = message_signature(RESTRICTED)
assert matched is True and phrase == "oauth app access restrictions"
confident, _ = discriminate("personal-ok-org-refused", "", None, matched,
"OAuth user token")
assert confident == "oauth-app-restricted"
# Same shape, message reworded into something unrecognised. The shape still
# decides; only the confidence drops.
silent, detail = message_signature("Something entirely new was written here")
assert silent is False and detail is None
likely, why = discriminate("personal-ok-org-refused", "", None, silent,
"OAuth user token")
assert likely == "oauth-app-restricted-likely"
assert "rewords" in why
def test_a_token_refused_below_anonymous_is_blocked_not_underprivileged():
state, detail = anonymous_contrast(200, 403)
assert state == "restricted-below-anonymous"
assert "no token at all succeeds" in detail
assert anonymous_contrast(404, 403)[0] == "private-to-everyone"
assert anonymous_contrast(200, 200)[0] == "no-contrast"
def test_only_an_oauth_app_credential_is_governed_by_this_policy():
ok, _ = governed("OAuth user token")
assert ok is True
ok, detail = governed("App installation token")
assert ok is False and "not issued by an OAuth App" in detail
state, _ = discriminate("personal-ok-org-refused", "", None, True,
"App installation token")
assert state == "not-an-oauth-app-credential"
def test_a_credential_failing_everywhere_is_never_an_org_policy():
state, _ = discriminate("refused-everywhere", "", None, True, "OAuth user token")
assert state == "credential-problem"
assert "no organization policy is in play" in repair(state, "acme-corp")
def test_the_repair_names_a_person_and_denies_an_api():
fix = repair("oauth-app-restricted", "acme-corp")
assert "an owner of acme-corp approves the application" in fix
assert "no API that grants it" in fix
assert "does not ask for it" in fix
assert "GitHub App" in fix
def test_the_visibility_limit_is_part_of_the_output():
note = visibility_note()
assert "author cannot see this policy" in note
assert "member of the" in note
def test_the_credential_type_comes_from_its_prefix():
assert token_kind("gho_fake") == "OAuth user token"
assert token_kind("ghs_fake") == "App installation token"
assert token_kind("nope") == "unknown"
def test_the_anonymous_read_is_not_charged_to_core_quota():
assert read_cost() == 3
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
anonymousContrast, discriminate, governed, messageSignature, namespaceShape,
readCost, repair, tokenKind, visibilityNote,
} from './github-oauth-app-restriction.mjs';
const RESTRICTED = 'Although you appear to have the correct authorization '
+ 'credentials, the acme-corp organization has enabled OAuth App access restrictions.';
test('the shape is one token reading two namespaces', () => {
assert.equal(namespaceShape(200, 403)[0], 'personal-ok-org-refused');
assert.equal(namespaceShape(403, 403)[0], 'refused-everywhere');
assert.equal(namespaceShape(200, 200)[0], 'nothing-refused');
});
test('a saml header outranks every other piece of evidence', () => {
const [matched] = messageSignature(RESTRICTED);
assert.equal(
discriminate('personal-ok-org-refused', 'required', null, matched, 'OAuth user token')[0],
'saml-not-oauth-restriction',
);
});
test('an accepted scopes header outranks it too', () => {
assert.equal(
discriminate('personal-ok-org-refused', '', 'repo, read:org', true, 'OAuth user token')[0],
'scope-shaped-refusal',
);
});
test('the verdict survives github rewording the message', () => {
const [matched, phrase] = messageSignature(RESTRICTED);
assert.equal(matched, true);
assert.equal(phrase, 'oauth app access restrictions');
assert.equal(
discriminate('personal-ok-org-refused', '', null, matched, 'OAuth user token')[0],
'oauth-app-restricted',
);
const [silent] = messageSignature('Something entirely new was written here');
assert.equal(silent, false);
assert.equal(
discriminate('personal-ok-org-refused', '', null, silent, 'OAuth user token')[0],
'oauth-app-restricted-likely',
);
});
test('a token refused below anonymous is blocked not underprivileged', () => {
assert.equal(anonymousContrast(200, 403)[0], 'restricted-below-anonymous');
assert.equal(anonymousContrast(404, 403)[0], 'private-to-everyone');
assert.equal(anonymousContrast(200, 200)[0], 'no-contrast');
});
test('only an oauth app credential is governed by this policy', () => {
assert.equal(governed('OAuth user token')[0], true);
assert.equal(governed('App installation token')[0], false);
assert.equal(
discriminate('personal-ok-org-refused', '', null, true, 'App installation token')[0],
'not-an-oauth-app-credential',
);
});
test('the repair names a person and denies an api', () => {
const fix = repair('oauth-app-restricted', 'acme-corp');
assert.ok(fix.includes('an owner of acme-corp approves the application'));
assert.ok(fix.includes('no API that grants it'));
assert.ok(fix.includes('does not ask for it'));
});
test('the visibility limit is part of the output', () => {
assert.ok(visibilityNote().includes('cannot see this policy'));
});
test('the credential type comes from its prefix', () => {
assert.equal(tokenKind('gho_fake'), 'OAuth user token');
assert.equal(tokenKind('nope'), 'unknown');
});
test('the anonymous read is not charged to core quota', () => {
assert.equal(readCost(), 3);
});
FAQ
Why does the same app work for one customer and not another?
Because the policy belongs to the organization, not to your application or to their account. An organization that has turned on OAuth App access restrictions blocks every app it has not approved, and one that has not turned them on blocks nothing. So your integration behaves perfectly for nine customers and is invisible to the tenth, with identical code, identical scopes and identical tokens. It is also why reauthorizing, reissuing the token or trying another member of the same organization changes nothing at all.
How is this different from the SAML refusals?
SAML enforcement refuses a <em>token</em> that a person has not authorized for the organization, and it always says so in the <code>x-github-sso</code> header. This refuses an <em>application</em> that an owner has not approved, and it never sends that header. The practical consequence is different too: under SAML, each user authorizes their own credential and the app is fine; under an OAuth App restriction, no user can fix it for themselves no matter how many times they click, because the decision is about your app and is made once for the whole organization.
I am the app's author. Can I detect this from my side?
Not directly, and that is the most frustrating property of this problem. The organization's application policy is not exposed to the app, so from where you stand a restricted organization looks like a customer whose calls return 403 for no visible reason. What you can do is instrument the shape: log when a token succeeds against <code>/user/repos</code> and fails against an organization endpoint with no <code>x-github-sso</code> header, and surface that to the customer as a message naming the approval step. The diagnosis has to run on their side; the explanation can come from yours.
Is the message string reliable enough to match on?
It is worth reading and not worth depending on. GitHub's wording here is unusually explicit, which makes it excellent corroboration, but it is prose rather than an API contract and it has been edited before. A checker built only on the sentence goes quietly wrong the day it changes — quietly, because it will simply stop finding anything. The script scores the message separately from the verdict, so a rewording downgrades the finding from confident to likely instead of erasing it.
What does moving to a GitHub App actually change?
It replaces a blanket organization policy with an installation. A GitHub App is installed on an account, on selected repositories, with permissions the installer accepts, and the App can read its own installations through the API — so the equivalent of this problem is visible to you rather than only to your customer. The human approval does not disappear, and should not; what disappears is the situation where the approval is invisible from both sides at once, along with the token-per-user model that made the failure look like it was about credentials.
Related field notes
- The refusal that does carry an SSO header
- When the refusal names the scope it wanted
- One user revoking your app, not an org blocking it
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.
- About OAuth app access restrictions — GitHub Docs
- Approving OAuth apps for your organization — GitHub Docs
- Differences between GitHub Apps and OAuth apps — GitHub Docs
- Troubleshooting the REST API — GitHub Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.