Diagnostic GitHub API
The token is valid and was never SSO-authorized for the org
The token was minted ten minutes ago with every scope the endpoint documents. GET /user works. GET /orgs/acme works. GET /orgs/acme/repos comes back 403 {"message": "Resource protected by organization SAML enforcement. You must grant your OAuth token access to this organization."}, and on some endpoints it does not even say that much — it returns a bare 404 on a repository that is open in the next browser tab. Nothing is wrong with the credential. The organization enforces SAML single sign-on, which means every token has to be individually authorized against it by a person, in a browser, and this one never has been.
SAML enforcement adds a gate that has nothing to do with scopes. A classic personal access token or an OAuth token is valid the moment it is minted, and it holds no access to an SSO-enforced organization until a human authorizes that specific token for that specific organization. A brand-new, perfectly-scoped token is refused on its first call.
The refusal names itself. GitHub puts x-github-sso: required; url=https://github.com/orgs/ORG/sso?authorization_request=... on the response, and that URL is the repair. Visit it, authorize the token, and the same call succeeds. The URL is short-lived — treat it as good for about an hour — and https://github.com/orgs/ORG/sso reaches the same place without one.
Read the header's form, not just its presence. required on a refusal is this note. partial-results on a 200 is a different problem where nothing was refused and organizations were quietly left out of a list instead.
The problem in plain words
Everything about this failure argues for the credential. It is a 403, which is what a missing scope produces. It happens on a token somebody just created, which is the thing they most recently changed. It happens on some calls and not others, which is what a scope gap looks like. And the fix people reach for — mint a wider token — is fast enough to try four times before anyone questions it. All four are refused identically, because all four are unauthorized in exactly the same way.
Then the 404s start arriving and the search widens. SAML enforcement does not always announce itself: on repository endpoints it can present as plain Not Found, indistinguishable from a typo, a deleted repository or a token with no access at all. Somebody checks the spelling, then the owner name, then the case of both, and the ticket that eventually gets written says the API is inconsistent. It is consistent. It is refusing to confirm that a private resource exists to a credential the organization has not admitted.
The part that turns an afternoon into a fortnight is automation. A person hits this once, sees the browser prompt, clicks through it and never thinks about it again — so the institutional memory of the problem is a shrug. A CI job cannot click anything. It gets the 403, retries with backoff, retries again on the next run, and the graph of failures looks like a flaky API rather than a locked door. Nothing about a retry loop can ever open this, and the fact that a human fixed it in four seconds last time is why nobody suspects that.
Why it happens
Validity and authorization are two different questions. A token is valid if GitHub recognises it and it has not expired or been revoked; GET /user answers that. A token is authorized for an organization if a human has granted that individual credential access to that organization through the identity provider, and nothing about minting a token does that. So the healthy-looking half of the diagnosis — the token authenticates, it names the right account, it carries the right scopes — is all true and all beside the point.
The header is the only place GitHub explains itself, and it arrives on the failure. x-github-sso is a response header, so it is invisible to anyone reading a body, a log line of the message string, or an exception that captured only the status. Most HTTP clients throw away headers on a non-2xx by default. That is the single practical reason this problem stays unsolved for weeks: the explanation was delivered and discarded.
The two forms of that header mean opposite things. required; url=... appears on a refusal and means nothing was returned. partial-results; organizations=21955855,20582480 appears on a 200 and means something was returned with pieces missing. Code that tests only for the header's presence will treat a silently truncated list as a hard failure, or worse, treat a hard failure as a truncation and carry on. Parse the form.
The URL in the header is a one-time, time-limited handoff to a person. It carries an authorization_request identifier and it goes stale, which is why a URL pasted into a ticket last Tuesday takes today's reader to a page that no longer means anything. The stable address is https://github.com/orgs/ORG/sso. Either way the click is the point: authorization is a human control, deliberately, and a script that could perform it would be a hole in the control rather than a feature.
Which credentials are subject to this is a short and useful list. Classic personal access tokens, OAuth tokens and an App's user-to-server tokens are all authorized per token, per organization. An App installation token is not: it belongs to an installation the organization already approved, and it does not depend on any human's SAML session. That single line is the whole answer for unattended automation, and it is why the repair for a CI job is not "authorize this token" but "stop using a user token here".
A fine-grained token fails this differently. It has no per-token SSO authorization page. Its access to an organization is settled when it is created and by the organization's token policy, so a fine-grained token that cannot see an organization is usually waiting for an owner to approve it rather than waiting for an SSO click. The script names the credential type before it offers an SSO repair, because offering the wrong one costs a day.
The fix, as a flow
Three reads and a header parser. The pair of organization reads is there to rule out the boring explanations before the header is trusted: a name that does not resolve fails both, a dead credential fails everything, and SAML enforcement is the case where the public record answers and the listing does not. Then the header does the work. Its required form carries the URL that is the entire repair, and its partial-results form belongs to another note, so the script sorts on the form rather than on the header being present. The last read is local: the credential's prefix decides whether a click can help it at all, because sending an installation token to an SSO page costs a day and explains nothing.
How to fix it
Establish that the credential is fine, so it stops being the suspect
One read of GET /user. If that returns 200 the token is valid, unexpired and unrevoked, and the account it belongs to is named in the output. That reading exists to close a door: everything after it is about an organization's opinion of a credential that GitHub itself is perfectly happy with.
Take the pair of org reads that makes the signature
The script reads GET /orgs/{org} and then GET /orgs/{org}/repos?per_page=1. Public organization metadata answering 200 while the listing answers 403 or 404 is the shape of SAML enforcement, and it also rules out the mundane explanations — a misspelled organization would fail both, a dead token would fail all three.
Read x-github-sso off the refusal and parse its form
The header is taken from the response object, not the body, and split into a form and its parameters. required is this note and carries a URL. partial-results is not this note at all and carries organization IDs; the script says so and points at the note that owns it instead of forcing the case into a verdict about refusals.
Price the credential type before printing an SSO repair
The token's prefix says what it is, locally, without a request. A classic token, an OAuth token or a user-to-server token can be authorized by a click. An installation token is not subject to this at all, so an SSO verdict against one means something else is wrong. A fine-grained token has no authorization URL to visit and is routed to the pending-approval note. The wrong repair here is expensive, so the script refuses to guess.
Print the URL for a person, and never open it
The script prints the authorization URL from the header, or the stable /orgs/ORG/sso address when the header did not carry one, together with the note that it expires. It does not follow it, cannot follow it, and should not: this is a control that exists to require a human. For anything unattended, the printed repair names the durable answer instead, which is an App installation token that no SAML session can lapse under.
How to check it worked
After somebody visits the URL and authorizes the token, the same three reads come back clean and the header is gone.
GITHUB_TOKEN=$READ_ONLY_TOKEN python3 github_sso_required.py acme-corp
# read cost: 3 request(s) against the core hourly quota
# token: classic PAT, account=octobot
# GET /orgs/acme-corp -> 200
# GET /orgs/acme-corp/repos -> 403
# x-github-sso: form=required
# sso-authorization-required: this organization enforces SAML single sign-on and
# this credential has never been authorized against it. The token is valid;
# the organization has not admitted it.
# credential: a classic PAT is authorized per token, per organization, by a
# person. Reminting it wider cannot change this answer.
# repair: open https://github.com/orgs/acme-corp/sso?authorization_request=AB12CD
# in a browser and authorize this token for acme-corp. That URL is
# short-lived; https://github.com/orgs/acme-corp/sso reaches the same page.
# This script does not open it and must not: the click is the control.
# unattended: a user token here will need this click again whenever the
# organization's SAML session lapses. An App installation token does not.
The full code
The live part is three GETs and the rest is a header parser with opinions. The parser earns its place twice: once because the two forms of x-github-sso mean opposite things and code that only tests for presence gets one of them backwards, and once because the URL it extracts is the entire repair. Everything else is a small table from credential prefix to whether a click can even help, which is the check that stops an SSO answer being handed to an installation token that was refused for a completely different reason.
"""Tell a SAML refusal apart from every other 403, from one response header.
Read only. GET requests and nothing else, and one promise beyond that: this
script never authorizes anything. Authorizing a token against an organization
that enforces SAML single sign-on is deliberately a human step taken in a
browser, and a tool that performed it on your behalf would be a hole in the
control it is diagnosing. So this reads the refusal, prints the URL a person
has to visit, and stops there.
The finding is one response header. On a refusal, `x-github-sso` carries the
`required` form and a URL. On a successful cross-organization listing the same
header can carry the `partial-results` form instead, which is a different
problem with a different repair, and this script names it and hands it over
rather than flattening the two into one verdict.
What this can and cannot see: it can prove that this organization refuses this
credential and that GitHub attributed the refusal to SAML. It cannot tell you
whether the token was authorized once and lapsed, because a read-only token
cannot read its own authorization history; that needs an organization owner and
is the sibling note. Pass --worked-before if you know it used to succeed and
this script will send you there instead of offering a first-time repair.
Environment:
GITHUB_TOKEN the token that is being refused
"""
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_sso_required")
API = "https://api.github.com"
UA = "github-sso-required/1.0"
SSO_HEADER = "x-github-sso"
# The two forms, and they are opposites. `required` arrives on a response that
# returned nothing; `partial-results` arrives on a 200 that returned most of
# something. Testing for the header without reading the form gets one of them
# exactly backwards.
FORM_REQUIRED = "required"
FORM_PARTIAL = "partial-results"
# Longest prefixes first so a future prefix extending an existing one is not
# swallowed by its shorter neighbour.
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"),
)
# Whether a click on the authorization URL can help this kind of credential.
# Getting this wrong costs a day, because the repair printed for one kind is
# useless and misleading for another.
CLICK_HELPS = {
"classic PAT": (True, "a classic PAT is authorized per token, per "
"organization, by a person. Reminting it wider "
"cannot change this answer."),
"OAuth user token": (True, "an OAuth token is authorized per token, per "
"organization, by the person who granted it."),
"App user-to-server token": (True, "a user-to-server token inherits the "
"user's SAML standing, so the same "
"click applies to it."),
"fine-grained PAT": (False, "a fine-grained PAT has no per-token SSO "
"authorization page. Its access to an "
"organization is settled at creation and by "
"the organization's token policy, so a refusal "
"here is usually a token waiting for an owner "
"to approve it."),
"App installation token": (False, "an installation token is not subject to "
"per-token SSO authorization at all. If "
"one is being refused, SAML is not the "
"reason and this note is the wrong one."),
"App refresh token": (False, "a refresh token is not used against these "
"endpoints; exchange it first."),
"unknown": (False, "the credential type could not be named from its "
"prefix, so nothing here prices whether a click helps."),
}
STABLE_SSO_URL = "https://github.com/orgs/%s/sso"
def read_cost():
"""Requests this run will spend against the core quota. Pure."""
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 parse_sso_header(value):
"""Split x-github-sso into a form and its parameters. Pure.
Returns {"form": str|None, "url": str|None, "organizations": [str]}. The
header is `form; key=value; key=value`, and the value of `url` contains its
own `=` characters, so the split is on the first one only.
"""
out = {"form": None, "url": None, "organizations": []}
if not value:
return out
parts = [p.strip() for p in str(value).split(";") if p.strip()]
if not parts:
return out
out["form"] = parts[0].lower()
for part in parts[1:]:
if "=" not in part:
continue
key, _, val = part.partition("=")
key, val = key.strip().lower(), val.strip()
if key == "url":
out["url"] = val
elif key == "organizations":
out["organizations"] = [i.strip() for i in val.split(",") if i.strip()]
return out
def enforcement_signature(meta_status, listing_status, sso):
"""Classify one pair of organization reads plus the header. Pure.
(state, detail). The pair matters: a misspelled organization fails both
reads, a dead credential fails everything, and SAML enforcement is the case
where the public metadata is readable and the listing is not.
"""
form = (sso or {}).get("form")
refused = listing_status in (403, 404)
if form == FORM_PARTIAL:
return ("partial-results-not-a-refusal",
"the header carries the partial-results form, which arrives on "
"a response that succeeded with organizations left out of it. "
"That is a different problem: nothing was refused here.")
if refused and form == FORM_REQUIRED:
return ("sso-authorization-required",
"this organization enforces SAML single sign-on and this "
"credential has not been authorized against it. The token is "
"valid; the organization has not admitted it.")
if refused and meta_status == 200:
return ("refused-without-sso-header",
"the organization is readable and the listing is not, but "
"GitHub did not attribute the refusal to SAML. Look at the "
"scopes the endpoint accepts, or at an organization policy "
"that blocks the application rather than the token.")
if refused:
return ("organization-unreadable",
"even the organization's own record could not be read, so this "
"may be a name that does not resolve rather than a gate. Check "
"the spelling before reading anything else into it.")
if form == FORM_REQUIRED:
return ("sso-required-on-a-success",
"the listing succeeded and still carried the required form. "
"Treat the header as advance warning: another endpoint on this "
"organization will refuse the same credential.")
return ("no-refusal-to-explain",
"the listing succeeded and carried no SAML header, so this "
"credential is authorized for this organization right now.")
def authorize_url(sso, org):
"""The address a person has to open. Pure. (url, source).
The header's URL carries a short-lived authorization_request identifier, so
a URL copied into a ticket last week is already stale. The stable address
reaches the same page and never expires, which is the one worth printing
when the header did not supply one.
"""
from_header = (sso or {}).get("url")
if from_header:
return (from_header, "from the x-github-sso header, and short-lived: "
"treat it as good for about an hour")
return (STABLE_SSO_URL % org,
"the stable organization address, because the refusal carried no "
"URL of its own")
def click_verdict(kind):
"""Can a human authorization click change this answer. Pure. (bool, detail)."""
return CLICK_HELPS.get(kind, CLICK_HELPS["unknown"])
def which_sso_note(worked_before):
"""First authorization, or a lapsed one. Pure. (state, detail).
A read-only token cannot read its own authorization history, so the one
fact that separates these is the caller's own: did this credential ever
succeed against this organization. Asked rather than guessed.
"""
if worked_before:
return ("session-lapse",
"this credential succeeded here before, so it was authorized "
"once and the authorization has lapsed rather than never "
"existing. The click is the same; what changes is that it will "
"be needed again on the organization's schedule.")
return ("first-authorization",
"no prior success was reported, so treat this as a credential that "
"has never been authorized for this organization. One click "
"settles it until the organization's session interval says "
"otherwise.")
def repair(state, org, url, kind, worked_before):
"""The sentence a reader has to act on. Pure."""
helps, _detail = click_verdict(kind)
if state != "sso-authorization-required":
if state == "partial-results-not-a-refusal":
return ("read the withheld organization IDs out of the header and "
"treat the response as incomplete. Nothing here needs "
"authorizing to make a call succeed, because the call "
"succeeded.")
if state == "refused-without-sso-header":
return ("diff the scopes the refusal names against the ones the "
"credential holds, and check whether the organization "
"restricts the application itself.")
if state == "organization-unreadable":
return "check the organization name, then read this again."
return "nothing on SAML. This credential is admitted to %s today." % org
if not helps:
return ("do not send anyone to the SSO page for this credential type. "
"The refusal is real and SAML is not the explanation for it.")
lead = ("open %s in a browser and authorize this credential for %s. This "
"script does not open it and must not: the click is the control."
% (url, org))
if worked_before:
return (lead + " Expect to do it again whenever the organization's "
"SAML session lapses, and move anything unattended onto "
"an App installation token, which does not lapse with a "
"person's session.")
return (lead + " For anything unattended, prefer an App installation "
"token: it belongs to an installation the organization "
"already approved and is never subject to this click.")
def get(session, path):
"""One GET. Returns the response object."""
r = session.get(API + path, timeout=30)
if r.status_code == 401:
raise SystemExit("401 from GitHub: GITHUB_TOKEN is missing, malformed "
"or revoked. That is a different note; this one starts "
"from a credential GitHub still recognises.")
return r
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("org", help="the organization login being refused")
ap.add_argument("--worked-before", action="store_true",
help="this credential succeeded against this organization "
"in the past, which makes it a lapse rather than a "
"first authorization")
args = ap.parse_args()
token = os.environ.get("GITHUB_TOKEN")
if not token:
log.error("set GITHUB_TOKEN (a read-only token is enough)")
return 2
log.info("read cost: %d request(s) against the core hourly quota", read_cost())
session = requests.Session()
session.headers.update({
"Authorization": "Bearer " + token,
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
# GitHub rejects requests with no User-Agent outright.
"User-Agent": UA,
})
kind = token_kind(token)
me = get(session, "/user")
account = (me.json() or {}).get("login") if me.status_code == 200 else None
log.info("token: %s, account=%s", kind, account or "unreadable")
meta = get(session, "/orgs/" + args.org)
log.info("GET /orgs/%s -> %s", args.org, meta.status_code)
listing = get(session, "/orgs/%s/repos?per_page=1" % args.org)
log.info("GET /orgs/%s/repos -> %s", args.org, listing.status_code)
raw = listing.headers.get(SSO_HEADER) or meta.headers.get(SSO_HEADER)
sso = parse_sso_header(raw)
log.info("%s: form=%s", SSO_HEADER, sso["form"] or "absent")
state, detail = enforcement_signature(meta.status_code, listing.status_code, sso)
log.info("%s: %s", state, detail)
helps, click_detail = click_verdict(kind)
log.info("credential: %s", click_detail)
url, url_source = authorize_url(sso, args.org)
history_state, history_detail = which_sso_note(args.worked_before)
if state == "sso-authorization-required":
log.info("authorization url: %s (%s)", url, url_source)
log.info("%s: %s", history_state, history_detail)
log.info("repair: %s", repair(state, args.org, url, kind, args.worked_before))
print(json.dumps({
"organization": args.org,
"account": account,
"token_kind": kind,
"org_read_status": meta.status_code,
"listing_status": listing.status_code,
"sso_header": sso,
"state": state,
"detail": detail,
"click_can_help": helps,
"authorization_url": url if state == "sso-authorization-required" else None,
"history_state": history_state,
"repair": repair(state, args.org, url, kind, args.worked_before),
}, indent=2, default=str))
return 1 if state == "sso-authorization-required" else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Tell a SAML refusal apart from every other 403, from one response header.
*
* Read only, and one promise beyond that: this script never authorizes
* anything. Authorizing a credential against an organization that enforces
* SAML is deliberately a human step taken in a browser, so this reads the
* refusal, prints the URL a person has to visit, and stops.
*
* The two forms of x-github-sso mean opposite things. `required` arrives on a
* response that returned nothing. `partial-results` arrives on a 200 that
* returned most of something, which is a different note entirely.
*
* Environment:
* GITHUB_TOKEN the credential being refused
* GITHUB_ORG the organization login being refused
* GITHUB_WORKED_BEFORE set to 1 if this credential used to succeed here
*/
const API = 'https://api.github.com';
const UA = 'github-sso-required/1.0';
export const SSO_HEADER = 'x-github-sso';
export const FORM_REQUIRED = 'required';
export const FORM_PARTIAL = 'partial-results';
/** Longest prefixes first. */
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'],
];
/** Whether a click on the authorization URL can help this credential type. */
export const CLICK_HELPS = {
'classic PAT': [true, 'a classic PAT is authorized per token, per organization, '
+ 'by a person. Reminting it wider cannot change this answer.'],
'OAuth user token': [true, 'an OAuth token is authorized per token, per '
+ 'organization, by the person who granted it.'],
'App user-to-server token': [true, 'a user-to-server token inherits the user of '
+ 'record SAML standing, so the same click applies to it.'],
'fine-grained PAT': [false, 'a fine-grained PAT has no per-token SSO '
+ 'authorization page. Its access is settled at creation and by the '
+ 'organization token policy, so a refusal here is usually a token waiting '
+ 'for an owner to approve it.'],
'App installation token': [false, 'an installation token is not subject to '
+ 'per-token SSO authorization at all. If one is refused, SAML is not why.'],
'App refresh token': [false, 'a refresh token is not used against these '
+ 'endpoints; exchange it first.'],
unknown: [false, 'the credential type could not be named from its prefix, so '
+ 'nothing here prices whether a click helps.'],
};
export const STABLE_SSO_URL = (org) => `https://github.com/orgs/${org}/sso`;
/** Requests this run will spend against the core quota. Pure. */
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';
}
/** Split x-github-sso into a form and its parameters. Pure. */
export function parseSsoHeader(value) {
const out = { form: null, url: null, organizations: [] };
if (!value) return out;
const parts = String(value).split(';').map((p) => p.trim()).filter(Boolean);
if (parts.length === 0) return out;
out.form = parts[0].toLowerCase();
for (const part of parts.slice(1)) {
const at = part.indexOf('=');
if (at < 0) continue;
const key = part.slice(0, at).trim().toLowerCase();
const val = part.slice(at + 1).trim();
if (key === 'url') out.url = val;
else if (key === 'organizations') {
out.organizations = val.split(',').map((i) => i.trim()).filter(Boolean);
}
}
return out;
}
/** Classify one pair of organization reads plus the header. Pure. */
export function enforcementSignature(metaStatus, listingStatus, sso) {
const form = (sso || {}).form;
const refused = listingStatus === 403 || listingStatus === 404;
if (form === FORM_PARTIAL) {
return ['partial-results-not-a-refusal', 'the header carries the '
+ 'partial-results form, which arrives on a response that succeeded with '
+ 'organizations left out of it. Nothing was refused here.'];
}
if (refused && form === FORM_REQUIRED) {
return ['sso-authorization-required', 'this organization enforces SAML single '
+ 'sign-on and this credential has not been authorized against it. The '
+ 'token is valid; the organization has not admitted it.'];
}
if (refused && metaStatus === 200) {
return ['refused-without-sso-header', 'the organization is readable and the '
+ 'listing is not, but GitHub did not attribute the refusal to SAML.'];
}
if (refused) {
return ['organization-unreadable', 'even the organization own record could '
+ 'not be read, so this may be a name that does not resolve.'];
}
if (form === FORM_REQUIRED) {
return ['sso-required-on-a-success', 'the listing succeeded and still carried '
+ 'the required form. Another endpoint will refuse the same credential.'];
}
return ['no-refusal-to-explain', 'the listing succeeded and carried no SAML '
+ 'header, so this credential is authorized for this organization right now.'];
}
/** The address a person has to open. Pure. [url, source]. */
export function authorizeUrl(sso, org) {
const fromHeader = (sso || {}).url;
if (fromHeader) {
return [fromHeader, 'from the x-github-sso header, and short-lived: treat it '
+ 'as good for about an hour'];
}
return [STABLE_SSO_URL(org), 'the stable organization address, because the '
+ 'refusal carried no URL of its own'];
}
/** Can a human authorization click change this answer. Pure. */
export function clickVerdict(kind) {
return CLICK_HELPS[kind] || CLICK_HELPS.unknown;
}
/** First authorization, or a lapsed one. Pure. */
export function whichSsoNote(workedBefore) {
if (workedBefore) {
return ['session-lapse', 'this credential succeeded here before, so it was '
+ 'authorized once and the authorization has lapsed. The click is the same; '
+ 'what changes is that it will be needed again on a schedule.'];
}
return ['first-authorization', 'no prior success was reported, so treat this as '
+ 'a credential that has never been authorized for this organization.'];
}
/** The sentence a reader has to act on. Pure. */
export function repair(state, org, url, kind, workedBefore) {
const [helps] = clickVerdict(kind);
if (state !== 'sso-authorization-required') {
if (state === 'partial-results-not-a-refusal') {
return 'read the withheld organization IDs out of the header and treat the '
+ 'response as incomplete. Nothing here needs authorizing.';
}
if (state === 'refused-without-sso-header') {
return 'diff the scopes the refusal names against the ones the credential '
+ 'holds, and check whether the organization restricts the application.';
}
if (state === 'organization-unreadable') {
return 'check the organization name, then read this again.';
}
return `nothing on SAML. This credential is admitted to ${org} today.`;
}
if (!helps) {
return 'do not send anyone to the SSO page for this credential type. The '
+ 'refusal is real and SAML is not the explanation for it.';
}
const lead = `open ${url} in a browser and authorize this credential for ${org}. `
+ 'This script does not open it and must not: the click is the control.';
if (workedBefore) {
return `${lead} Expect to do it again whenever the SAML session lapses, and `
+ 'move anything unattended onto an App installation token.';
}
return `${lead} For anything unattended, prefer an App installation token: it `
+ 'belongs to an installation the organization already approved.';
}
function headers(token) {
return {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': UA,
};
}
async function main() {
const token = process.env.GITHUB_TOKEN;
const org = process.env.GITHUB_ORG;
if (!token || !org) {
console.error('set GITHUB_TOKEN (read-only is enough) and GITHUB_ORG');
process.exitCode = 2;
return;
}
const workedBefore = process.env.GITHUB_WORKED_BEFORE === '1';
console.log(`read cost: ${readCost()} request(s) against the core hourly quota`);
const h = headers(token);
const me = await fetch(`${API}/user`, { headers: h });
const kind = tokenKind(token);
const account = me.status === 200 ? (await me.json()).login : null;
console.log(`token: ${kind}, account=${account ?? 'unreadable'}`);
const meta = await fetch(`${API}/orgs/${org}`, { headers: h });
console.log(`GET /orgs/${org} -> ${meta.status}`);
const listing = await fetch(`${API}/orgs/${org}/repos?per_page=1`, { headers: h });
console.log(`GET /orgs/${org}/repos -> ${listing.status}`);
const raw = listing.headers.get(SSO_HEADER) || meta.headers.get(SSO_HEADER);
const sso = parseSsoHeader(raw);
console.log(`${SSO_HEADER}: form=${sso.form ?? 'absent'}`);
const [state, detail] = enforcementSignature(meta.status, listing.status, sso);
console.log(`${state}: ${detail}`);
const [helps, clickDetail] = clickVerdict(kind);
console.log(`credential: ${clickDetail}`);
const [url, urlSource] = authorizeUrl(sso, org);
const [historyState] = whichSsoNote(workedBefore);
if (state === 'sso-authorization-required') {
console.log(`authorization url: ${url} (${urlSource})`);
}
console.log(`repair: ${repair(state, org, url, kind, workedBefore)}`);
console.log(JSON.stringify({
organization: org,
account,
token_kind: kind,
org_read_status: meta.status,
listing_status: listing.status,
sso_header: sso,
state,
detail,
click_can_help: helps,
authorization_url: state === 'sso-authorization-required' ? url : null,
history_state: historyState,
repair: repair(state, org, url, kind, workedBefore),
}, null, 2));
process.exitCode = state === 'sso-authorization-required' ? 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
The parser is asserted first and hardest, because the whole note rests on it: the URL contains its own = characters and a naive split loses half of it, and the two forms have to come out as different verdicts rather than as one header that was present. Then the pair of organization reads, where the point of the second read is that a misspelled organization and an enforced one are not allowed to produce the same answer. The last group is the guardrail: an installation token that gets refused must never be handed an SSO repair, and the sentence the script prints has to keep saying that the click belongs to a person.
from github_sso_required import (
FORM_PARTIAL, FORM_REQUIRED, authorize_url, click_verdict,
enforcement_signature, parse_sso_header, read_cost, repair, token_kind,
which_sso_note,
)
REQUIRED_HEADER = ("required; url=https://github.com/orgs/acme-corp/sso"
"?authorization_request=AB12CD")
PARTIAL_HEADER = "partial-results; organizations=21955855,20582480"
def test_the_required_form_keeps_its_whole_url():
# The URL contains = characters of its own. Splitting on every one of them
# is the bug that turns the repair into a fragment.
sso = parse_sso_header(REQUIRED_HEADER)
assert sso["form"] == FORM_REQUIRED
assert sso["url"].endswith("?authorization_request=AB12CD")
assert sso["url"].startswith("https://github.com/orgs/acme-corp/sso")
def test_the_partial_form_is_a_different_finding_not_a_refusal():
sso = parse_sso_header(PARTIAL_HEADER)
assert sso["form"] == FORM_PARTIAL
assert sso["organizations"] == ["21955855", "20582480"]
state, detail = enforcement_signature(200, 200, sso)
assert state == "partial-results-not-a-refusal"
assert "Nothing was refused" in detail or "nothing was refused" in detail
def test_an_absent_header_parses_without_inventing_a_form():
assert parse_sso_header(None) == {"form": None, "url": None,
"organizations": []}
assert parse_sso_header("")["form"] is None
assert parse_sso_header("required")["url"] is None
def test_the_signature_is_a_pair_of_reads_not_one_status():
sso = parse_sso_header(REQUIRED_HEADER)
assert enforcement_signature(200, 403, sso)[0] == "sso-authorization-required"
# SAML can mask as a bare 404, so the same pair with a 404 is the same
# finding rather than a missing organization.
assert enforcement_signature(200, 404, sso)[0] == "sso-authorization-required"
def test_a_misspelled_org_is_never_reported_as_saml():
empty = parse_sso_header(None)
state, detail = enforcement_signature(404, 404, empty)
assert state == "organization-unreadable"
assert "spelling" in detail
def test_a_refusal_without_the_header_is_handed_elsewhere():
empty = parse_sso_header(None)
state, detail = enforcement_signature(200, 403, empty)
assert state == "refused-without-sso-header"
assert "did not attribute" in detail
def test_a_clean_read_is_reported_as_authorized_today():
empty = parse_sso_header(None)
assert enforcement_signature(200, 200, empty)[0] == "no-refusal-to-explain"
# The header on a success is advance warning rather than nothing.
warned = parse_sso_header(REQUIRED_HEADER)
assert enforcement_signature(200, 200, warned)[0] == "sso-required-on-a-success"
def test_the_url_falls_back_to_the_address_that_never_expires():
url, source = authorize_url(parse_sso_header(REQUIRED_HEADER), "acme-corp")
assert "authorization_request=AB12CD" in url and "short-lived" in source
url, source = authorize_url(parse_sso_header(None), "acme-corp")
assert url == "https://github.com/orgs/acme-corp/sso"
assert "stable" in source
def test_an_installation_token_is_never_sent_to_the_sso_page():
helps, detail = click_verdict("App installation token")
assert helps is False
assert "not subject to" in detail
fix = repair("sso-authorization-required", "acme-corp",
"https://github.com/orgs/acme-corp/sso",
"App installation token", False)
assert "do not send anyone to the SSO page" in fix
def test_a_fine_grained_token_is_routed_to_the_approval_note():
helps, detail = click_verdict("fine-grained PAT")
assert helps is False
assert "waiting for an owner" in detail
def test_a_classic_token_gets_the_click_and_the_warning_about_widening():
helps, detail = click_verdict("classic PAT")
assert helps is True
assert "Reminting it wider cannot change this answer" in detail
def test_the_repair_says_the_click_belongs_to_a_person():
fix = repair("sso-authorization-required", "acme-corp",
"https://github.com/orgs/acme-corp/sso", "classic PAT", False)
assert "does not open it and must not" in fix
assert "installation token" in fix
def test_a_prior_success_points_at_the_lapse_note_instead():
state, detail = which_sso_note(True)
assert state == "session-lapse"
assert "lapsed" in detail
assert which_sso_note(False)[0] == "first-authorization"
fix = repair("sso-authorization-required", "acme-corp",
"https://github.com/orgs/acme-corp/sso", "classic PAT", True)
assert "again" in fix
def test_the_credential_type_comes_from_its_prefix_locally():
# Obviously fake, and short. Nothing in this suite is a real credential.
assert token_kind("ghp_fake") == "classic PAT"
assert token_kind("gho_fake") == "OAuth user token"
assert token_kind("ghs_fake") == "App installation token"
assert token_kind("github_pat_x") == "fine-grained PAT"
assert token_kind("nope") == "unknown"
def test_the_run_costs_three_reads():
assert read_cost() == 3
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
FORM_PARTIAL, FORM_REQUIRED, authorizeUrl, clickVerdict, enforcementSignature,
parseSsoHeader, readCost, repair, tokenKind, whichSsoNote,
} from './github-sso-required.mjs';
const REQUIRED_HEADER = 'required; url=https://github.com/orgs/acme-corp/sso'
+ '?authorization_request=AB12CD';
const PARTIAL_HEADER = 'partial-results; organizations=21955855,20582480';
test('the required form keeps its whole url', () => {
const sso = parseSsoHeader(REQUIRED_HEADER);
assert.equal(sso.form, FORM_REQUIRED);
assert.ok(sso.url.endsWith('?authorization_request=AB12CD'));
});
test('the partial form is a different finding not a refusal', () => {
const sso = parseSsoHeader(PARTIAL_HEADER);
assert.equal(sso.form, FORM_PARTIAL);
assert.deepEqual(sso.organizations, ['21955855', '20582480']);
assert.equal(enforcementSignature(200, 200, sso)[0], 'partial-results-not-a-refusal');
});
test('an absent header parses without inventing a form', () => {
assert.deepEqual(parseSsoHeader(null), { form: null, url: null, organizations: [] });
assert.equal(parseSsoHeader('required').url, null);
});
test('the signature is a pair of reads not one status', () => {
const sso = parseSsoHeader(REQUIRED_HEADER);
assert.equal(enforcementSignature(200, 403, sso)[0], 'sso-authorization-required');
assert.equal(enforcementSignature(200, 404, sso)[0], 'sso-authorization-required');
});
test('a misspelled org is never reported as saml', () => {
assert.equal(enforcementSignature(404, 404, parseSsoHeader(null))[0],
'organization-unreadable');
});
test('a refusal without the header is handed elsewhere', () => {
assert.equal(enforcementSignature(200, 403, parseSsoHeader(null))[0],
'refused-without-sso-header');
});
test('the url falls back to the address that never expires', () => {
const [url, source] = authorizeUrl(parseSsoHeader(null), 'acme-corp');
assert.equal(url, 'https://github.com/orgs/acme-corp/sso');
assert.ok(source.includes('stable'));
});
test('an installation token is never sent to the sso page', () => {
const [helps] = clickVerdict('App installation token');
assert.equal(helps, false);
const fix = repair('sso-authorization-required', 'acme-corp',
'https://github.com/orgs/acme-corp/sso', 'App installation token', false);
assert.ok(fix.includes('do not send anyone to the SSO page'));
});
test('the repair says the click belongs to a person', () => {
const fix = repair('sso-authorization-required', 'acme-corp',
'https://github.com/orgs/acme-corp/sso', 'classic PAT', false);
assert.ok(fix.includes('does not open it and must not'));
});
test('a prior success points at the lapse note instead', () => {
assert.equal(whichSsoNote(true)[0], 'session-lapse');
assert.equal(whichSsoNote(false)[0], 'first-authorization');
});
test('the credential type comes from its prefix locally', () => {
assert.equal(tokenKind('ghp_fake'), 'classic PAT');
assert.equal(tokenKind('ghs_fake'), 'App installation token');
assert.equal(tokenKind('nope'), 'unknown');
});
test('the run costs three reads', () => {
assert.equal(readCost(), 3);
});
FAQ
Is this the same as the note about organizations missing from a list?
No, and the header is what separates them. That note is about a <code>200</code>: the call succeeded, some organizations were quietly left out of the body, and the header carries the <code>partial-results</code> form naming their IDs. This note is about a refusal: nothing came back, and the header carries the <code>required</code> form plus a URL. One is silent under-reporting you have to detect, the other is a locked door that tells you where the key is. The scripts in both notes parse the form for exactly this reason.
How is this different from the token whose SAML session expired?
By history and by what happens next. This note is a credential that has never been authorized for the organization at all, so its very first call is refused and one click settles it. The <a href="/github/saml-session-expired/">lapse</a> is a credential that was authorized, worked for weeks and started being refused with no change to anything, because the organization requires re-authentication on an interval. The refusals look identical; the repairs differ in that one of them recurs on a timetable. This script asks whether the credential ever worked here rather than guessing, and hands you over when the answer is yes.
Can the script authorize the token for me if I give it more permission?
No, and it is important that the answer is no. SSO authorization is a control that exists specifically to require a person and an identity provider in the loop. A tool that could satisfy it on your behalf would not be a convenience, it would be the removal of the control. So the script prints the URL, says that it will not open it, and exits. The only automation-shaped answer to this problem is to stop using a credential that needs the click, which means a GitHub App installation token.
Why does the failure sometimes look like 404 instead of 403?
For the same reason a great many GitHub refusals do: confirming that a private resource exists would leak it to a caller with no right to know. On organization endpoints the enforcement message is usually explicit, and on repository endpoints under an enforcing organization you can get a bare <code>Not Found</code> instead. The header is the constant. It arrives on both, which is why the script reads the response object rather than matching on the message string, and why matching on the message alone is how people end up chasing a typo for a day.
Our CI job hits this every few weeks. What is the actual fix?
Move it off a user credential. Classic tokens, OAuth tokens and user-to-server tokens all depend on a person's standing with the identity provider, which is a dependency no unattended job should have; it is also why the job fails at unrelated times, since it fails when a human's session lapses. An App installation token is issued to an installation the organization already approved, expires on its own predictable one-hour clock, and is never subject to per-token SSO authorization. The click disappears rather than being scheduled.
Related field notes
- The same refusal, on a token that was authorized once
- The 200 that quietly leaves organizations out
- Why the refusal sometimes arrives as Not Found
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.
- Authorizing a personal access token for use with SAML single sign-on — GitHub Docs
- Authenticating to the REST API with SAML single sign-on — GitHub Docs
- About authentication with SAML single sign-on — GitHub Docs
- Authenticating as a GitHub App installation — GitHub Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.