Diagnostic Slack
admin.* needs a user token your install never stored
The scope is granted. You can see it in the app configuration, you added it deliberately, and the install went through without complaint: admin.apps:read, right there on the list. And admin.apps.approved.list comes back {"ok": false, "error": "not_allowed_token_type"} every single time.
So you reinstall. Then you check the scope again, and it is still there, and the error is still the same. What has happened is that the scope you are looking at and the token you are sending were never attached to each other. Slack issued two credentials at the end of that OAuth flow, your install handler kept one of them, and the one it kept is the one that can never do this.
Every admin.* method requires a user token. Not a bot token with more scopes — a different credential entirely. The scopes themselves make this unambiguous: admin.apps:read is documented as supporting User tokens only, and the same is true across the family. There is no configuration in which a xoxb- token holds an admin.* scope, so no amount of reinstalling the bot fixes this.
The user token exists. oauth.v2.access returns it as authed_user.access_token, alongside the bot token at the top level as access_token, with its own separate grant string in authed_user.scope. Install handlers routinely persist the top-level one and drop the rest, because for every other purpose the bot token is the one you want. The admin surface is the one place where it is useless.
So this is a pre-flight inventory rather than a runtime diagnosis, and the script below never calls an admin method to reach its conclusion. It asks auth.test what class each credential in your environment is, reads the grant from the X-OAuth-Scopes response header, and compares that against what the methods you intend to call are documented to require. If no user token is stored, every admin call is going to fail and you already know it.
One more requirement that catches people after they fix the token: admin.apps:read is documented as requiring installation by an Admin or Owner of an Enterprise organization on the whole org, not on an individual workspace.
The problem in plain words
The confusion starts with a screen that is telling the truth in a way nobody reads carefully. App configuration lists scopes under two headings, Bot Token Scopes and User Token Scopes, and the admin.* scopes only ever appear under the second. If you added admin.apps:read you added it there, because that is the only place it can go. Nothing on that page then reminds you that the token you copied into your environment came from the other list.
The error compounds it. not_allowed_token_type says what is wrong with the credential you brought and nothing about the one it wanted, and missing_scope — which the same method returns in a different configuration — sends you straight back to the scopes screen where everything looks correct. Both errors are read as "add a scope and reinstall", and both survive that.
Underneath is an install handler that is perfectly reasonable everywhere else. The OAuth exchange returns an object with a bot token at the top level, a user token nested under authed_user, and a separate scope string for each. A store built for a bot app persists access_token, bot_user_id, team.id and moves on. Nothing in the app's daily work notices the omission, because posting messages, reading channels and publishing views all use the bot token. The gap surfaces months later when someone asks for a feature that needs the admin surface, and by then the installs that would have carried the user token have all already happened.
And there is a second gap behind the first, which is why fixing the storage sometimes is not enough. A user token carries admin authority only if the human who authorised the install had that authority, and only if the install was performed at the level the scope requires. admin.apps:read is documented as requiring an org Admin or Owner installing on the entire organization. A developer who clicks through the install themselves gets a user token with the scope string on it and no authority behind it, which fails differently and is a separate note.
Why it happens
This note asks a different question from the one next door, and the difference is the repair. not_allowed_token_type is about reading one method's refusal at runtime and working out which class of credential that method wanted; it assumes you hold several and are sending the wrong one. This note assumes you probably hold exactly one, and the finding is that the class the admin family requires is absent from your environment altogether. One is fixed by changing which variable the call reads. This one is fixed by changing the install handler and reinstalling, which is a deploy and a conversation with the customer's administrator.
Nothing here calls an admin method, and that is a design decision rather than caution. The question "will admin.users.list accept this token" is answered completely by two facts that are both already available: what class the token is, and what class the method requires. Slack publishes the second. auth.test gives you the first for the cost of one request. Calling the method to find out adds no information, and the admin.* family is one where the neighbouring methods write — admin.users.remove, admin.conversations.archive, admin.apps.restrict — so a script that gets into the habit of probing that surface to see what happens is a script one typo away from doing something to a customer's organization.
The class is read from auth.test, not from the prefix. A bot token's auth.test response carries a bot_id; a user token's does not. The prefix is a good first signal and has its own note, but it is a string in your environment and the response is Slack's own answer about the credential you actually sent. Where they disagree, the response wins.
The scope requirement is derived per method and printed, not summarised. "Admin methods need admin scopes" is not actionable. admin.conversations.search → admin.conversations:read, user token is. The map in the script derives the scope from the method family and the read or write half from the method name, and any method it does not recognise is reported as unknown with a pointer to the reference rather than guessed at, because a confidently wrong scope name sends someone to request a scope that does not exist.
Write methods are named and refused, not planned for. If you pass admin.users.remove the script tells you it is a write and declines to include it in the plan. This is a read-only audit; the credential it recommends storing is a read credential, and quietly helping someone assemble the token for a destructive call is not the job.
Nothing prints a credential. The inventory reports the class, the prefix family and the number of scopes granted. It never prints the token, never prints a prefix of the token beyond the documented class marker, and the install record it reads is expected to have the token values removed first — the finding is about which keys are present, and their values are irrelevant to it.
The fix, as a flow
The red arrow is the first one, and what it points at is not a failure in Slack. The OAuth exchange did everything right: it returned a bot token at the top level and a user token underneath it, each with its own grant, exactly as documented. The box the arrow lands on is your own install handler keeping one of them, which is a reasonable thing to do for an app that never touches the admin surface, and every box after it is that decision being correct for months. The refusal at the end arrives on the first call that needs the credential nobody stored. The fix branch sorts credentials rather than errors, and its second row is the one that costs people an afternoon: a bot token carrying admin scopes is not a credential with a problem, it is two credentials being confused for one.
How to fix it
Name every Slack credential your app holds, not just the one that broke
Pass --tokens with the environment variable names: SLACK_BOT_TOKEN,SLACK_USER_TOKEN,SLACK_APP_TOKEN. The finding is usually the variable that is not on the list, so listing what you think you have is the first half of the check.
Let auth.test say what class each one is
token_class reads the response: a bot_id means a bot token, its absence with a user_id means a user token. A refusal is reported as unknown rather than guessed from the prefix, because a revoked user token and a bot token both fail to be admin-capable for different reasons.
Read the grant from the response header
X-OAuth-Scopes carries the complete granted list on every Web API response. admin_capable combines it with the class, and its most important behaviour is that a bot token is never capable no matter what the header says, because that scope cannot be attached to it.
Derive what each method you call actually requires
requirement_for turns admin.apps.approved.list into admin.apps:read on a user token. Unrecognised families come back unknown with a pointer to the method reference; write methods come back refused, because this audit does not help you assemble a credential for one.
Read your install record for the half that was dropped
install_gap takes your stored OAuth record with the token values removed and reports on key presence: no authed_user, no authed_user.access_token, an authed_user.scope with no admin scope in it, or is_enterprise_install: false where the scope documentation requires an org-level install.
Fix the install, not the call
Request the admin.* scopes under User Token Scopes, persist authed_user.access_token and authed_user.scope next to the bot token rather than instead of it, and have an org Admin or Owner perform the install on the organization. Then route only admin.* calls at that credential.
How to check it worked
Store the user token, re-run with both variables named, and the plan should resolve every admin method to a credential.
python3 slack_admin_credential.py \
--tokens SLACK_BOT_TOKEN,SLACK_USER_TOKEN \
--methods admin.apps.approved.list,admin.conversations.search,conversations.list
# cred SLACK_BOT_TOKEN bot xoxb 14 scope(s) not admin capable
# reason admin.*:read is a user token scope and cannot be
# granted to a bot token in any configuration
# cred SLACK_USER_TOKEN user xoxp 3 scope(s) admin capable
# method admin.apps.approved.list needs admin.apps:read on a user token
# served by SLACK_USER_TOKEN
# method admin.conversations.search needs admin.conversations:read on a user token
# served by SLACK_USER_TOKEN
# method conversations.list needs channels:read on a bot or user token
# served by SLACK_BOT_TOKEN
# verdict clean 3 method(s) resolved, 0 unserved
# note: no admin method was called to establish any of this
The full code
Everything that decides anything is pure, and the network surface is one method wide: READS is ("auth.test",) and the tests assert it, because the promise that this script never touches the admin surface is worth checking mechanically. token_class reads Slack's own answer rather than a prefix. admin_capable is the function with the strong opinion: a bot token is not capable even when the scope header says otherwise, because that combination does not exist. requirement_for derives the scope and the token class per method and refuses to guess at families it does not know, and install_gap reads key presence in a redacted install record so the missing half of the grant shows up before anybody calls anything.
"""Decide whether you hold a credential the admin.* family will accept.
Read only, and deliberately narrower than it could be: it never calls an
admin method. The question here is not "did that method refuse me" but "does
the class of credential that method requires exist in my environment at all",
and that is settled by the token class plus the method's own documented
requirement. Calling admin.apps.approved.list to find out would add nothing,
and the family it lives in contains methods that write.
READS is the entire network surface of this script: auth.test, once per
credential named on the command line. The tests assert that.
Nothing here prints a credential. The inventory reports a class, a prefix
family and a count of granted scopes, and the install record it reads is
expected to arrive with the token values already removed.
"""
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("slack_admin_credential")
API = "https://slack.com/api/"
# The whole network surface. One method, one class of question.
READS = ("auth.test",)
# Families whose scope name follows the documented admin.<family>:<read|write>
# shape and which this script is willing to state. Anything outside this tuple
# is reported as unknown with a pointer, because inventing a scope name sends
# somebody to request a permission that does not exist.
KNOWN_ADMIN_FAMILIES = ("apps", "barriers", "conversations", "inviteRequests",
"teams", "usergroups", "users", "workflows")
# Method name endings that identify a read. Everything else in the admin family
# is treated as a write and refused: this is a read-only audit and it does not
# help assemble the credential for admin.users.remove.
READ_SUFFIXES = ("list", "search", "lookup", "info", "get", "getentities",
"listassignments", "listchannels")
# Non-admin methods worth naming, so a mixed --methods list produces a whole
# plan rather than a plan with holes in it.
ORDINARY_SCOPES = {
"conversations.list": "channels:read",
"conversations.info": "channels:read",
"conversations.history": "channels:history",
"users.list": "users:read",
"users.info": "users:read",
"team.info": "team:read",
"auth.test": "none",
}
def credential_shape(token):
"""The documented class marker of a token, and nothing else. Pure.
Returns the prefix family only: xoxb, xoxp, xapp, xoxe or unknown. No
character of the secret itself is returned by this or by anything that
calls it.
"""
text = str(token or "")
for prefix in ("xoxb-", "xoxp-", "xapp-", "xoxe-", "xoxe."):
if text.startswith(prefix):
return prefix.rstrip("-.")
return "unknown" if text else "absent"
def token_class(body):
"""What class of credential did Slack just answer for? Pure.
Reads auth.test rather than the prefix, because the prefix is a string in
your environment and the response is Slack's answer about the credential
that actually arrived. Returns (klass, detail) with klass one of "bot",
"user" or "unknown".
"""
doc = body or {}
if doc.get("ok") is not True:
error = str(doc.get("error") or "unknown_error")
return ("unknown", "auth.test refused this credential: " + error)
if doc.get("bot_id"):
return ("bot", "auth.test returned a bot_id")
if doc.get("user_id"):
return ("user", "auth.test returned a user_id and no bot_id")
return ("unknown", "auth.test answered ok with neither bot_id nor user_id")
def granted_scopes(header_value):
"""The X-OAuth-Scopes header as a sorted list. Pure."""
return sorted({s.strip() for s in str(header_value or "").split(",") if s.strip()})
def admin_capable(klass, scopes):
"""Can this credential ever serve an admin.* method? Pure.
The opinionated one. A bot token is not capable even when a scope list
handed to this function says otherwise, because admin.*:read is documented
as a user token scope and that combination does not exist: if you are
looking at it, you are looking at two different credentials.
Returns (state, why) with state one of "capable", "wrong-class",
"no-admin-scope" or "unknown".
"""
admin_scopes = [s for s in (scopes or []) if str(s).startswith("admin.")]
if klass == "bot":
return ("wrong-class", "admin.*:read is a user token scope and cannot be "
"granted to a bot token in any configuration")
if klass == "user":
if admin_scopes:
return ("capable", "%d admin scope(s) on a user token" % len(admin_scopes))
return ("no-admin-scope", "a user token, but no admin.* scope was granted to "
"it; request them under User Token Scopes")
return ("unknown", "the class of this credential was not established, so its "
"capability is not known either")
def requirement_for(method):
"""What one method requires, derived rather than remembered. Pure.
Returns (scope, klass, note). klass is "user", "bot-or-user" or "unknown".
An admin write comes back refused, and an admin family this script has not
been taught comes back unknown with a pointer, because a confidently wrong
scope name is worse than an admission.
"""
name = str(method or "").strip()
if not name:
return (None, "unknown", "no method given")
if not name.startswith("admin."):
scope = ORDINARY_SCOPES.get(name)
if scope:
return (scope, "bot-or-user", "an ordinary Web API method")
return (None, "bot-or-user", "not an admin method; check the method "
"reference for its scope")
parts = name.split(".")
if len(parts) < 3:
return (None, "user", "an admin method, but the family could not be read "
"from the name")
family = parts[1]
tail = parts[-1].lower()
if tail not in READ_SUFFIXES:
return (None, "user", "this is an admin write; a read-only audit does not "
"plan a credential for it")
if family not in KNOWN_ADMIN_FAMILIES:
return (None, "user", "an admin read in a family this script does not name; "
"check the method reference for the exact scope")
return ("admin.%s:read" % family, "user", "an admin read")
def serves(requirement, inventory):
"""Which stored credential can serve this method? Pure.
inventory: [(env_name, klass, scopes, capability), ...]. Returns
(env_name or None, why). The rule is class first and scope second, because
a scope on the wrong class is not a partial answer, it is a different
credential.
"""
scope, klass, _note = requirement
for name, cred_class, scopes, _capability in inventory or []:
if klass == "user" and cred_class != "user":
continue
if klass == "bot-or-user" and cred_class not in ("bot", "user"):
continue
if scope and scope != "none" and scope not in (scopes or []):
continue
return (name, "class %s, scope %s" % (cred_class, scope or "unknown"))
if klass == "user":
return (None, "no user token is stored, so nothing here can serve an "
"admin method")
return (None, "no stored credential holds the required scope")
def install_gap(record):
"""Read a redacted OAuth install record for the half that was dropped. Pure.
Takes the stored oauth.v2.access response with the token values removed and
reports on which keys are present. Values are never read, so a record that
still contains a secret is not a hazard this function creates.
"""
doc = record or {}
out = []
if "access_token" not in doc:
out.append(("no-bot-token", "the record has no top level access_token, so "
"this may not be an oauth.v2.access response"))
authed = doc.get("authed_user")
if not isinstance(authed, dict):
out.append(("user-half-dropped", "no authed_user object; the user half of "
"the grant was not persisted"))
else:
if "access_token" not in authed:
out.append(("user-token-dropped", "authed_user exists but its "
"access_token was not persisted"))
scope = str(authed.get("scope") or "")
if scope and not any(s.strip().startswith("admin.") for s in scope.split(",")):
out.append(("no-admin-scope-asked", "the user grant carries no admin.* "
"scope; it was never requested"))
if doc.get("is_enterprise_install") is False:
out.append(("workspace-level-install", "admin.apps:read is documented as "
"requiring an org level install by an "
"Admin or Owner of the organization"))
return out
def plan_verdict(rows):
"""One line for the whole plan. Pure.
rows: [(method, env_name or None), ...]. Refuses to say clean while any
method has no credential behind it.
"""
total = len(rows or [])
unserved = [m for m, name in (rows or []) if not name]
if not total:
return ("empty", "no methods were named, so nothing was planned")
if unserved:
return ("%d unserved" % len(unserved),
"%d of %d method(s) have no stored credential: %s"
% (len(unserved), total, ", ".join(unserved[:4])))
return ("clean", "%d method(s) resolved, 0 unserved" % total)
def auth_test(session, token):
"""One GET. Returns (body, granted scopes) and never returns the token."""
r = session.get(API + "auth.test",
headers={"Authorization": "Bearer " + token}, timeout=30)
scopes = granted_scopes(r.headers.get("X-OAuth-Scopes"))
try:
return (r.json(), scopes)
except ValueError:
return ({"ok": False, "error": "unparseable_body"}, scopes)
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--tokens", default="SLACK_BOT_TOKEN,SLACK_USER_TOKEN",
help="comma separated environment variable names holding your "
"Slack credentials")
ap.add_argument("--methods", default="admin.apps.approved.list",
help="comma separated method names your app calls")
ap.add_argument("--install-record", default="",
help="your stored oauth.v2.access record as JSON, with the token "
"values removed; only key presence is read")
args = ap.parse_args()
session = requests.Session()
inventory = []
for env_name in [n.strip() for n in args.tokens.split(",") if n.strip()]:
token = os.environ.get(env_name)
if not token:
log.warning("cred %-18s absent set %s if your app holds one",
env_name, env_name)
continue
body, scopes = auth_test(session, token)
klass, detail = token_class(body)
capability, why = admin_capable(klass, scopes)
level = log.info if capability in ("capable", "no-admin-scope") else log.warning
level("cred %-18s %-10s %-5s %2d scope(s) %s", env_name, klass,
credential_shape(token), len(scopes),
"admin capable" if capability == "capable" else "not admin capable")
log.info(" class read from %s", detail)
if capability != "capable":
log.warning(" reason %s", why)
inventory.append((env_name, klass, scopes, capability))
rows = []
for method in [m.strip() for m in args.methods.split(",") if m.strip()]:
requirement = requirement_for(method)
scope, klass, note = requirement
if scope is None and klass == "user":
log.warning("method %-30s %s", method, note)
rows.append((method, None))
continue
log.info("method %-30s needs %s on a %s token", method,
scope or "an unnamed scope", "user" if klass == "user" else "bot or user")
served, why = serves(requirement, inventory)
if served:
log.info(" served by %s (%s)", served, why)
else:
log.warning(" unserved %s", why)
rows.append((method, served))
if args.install_record:
with open(args.install_record, encoding="utf-8") as handle:
record = json.load(handle) or {}
for code, why in install_gap(record):
log.warning("install %-18s %s", code, why)
state, detail = plan_verdict(rows)
if state == "clean":
log.info("verdict clean %s", detail)
log.info(" note: no admin method was called to establish any of this")
return 0
log.warning("verdict %-16s %s", state, detail)
log.warning(" repair: request the admin.* scopes under User Token Scopes, not Bot "
"Token Scopes; they cannot be attached to a bot token")
log.warning(" repair: persist authed_user.access_token and authed_user.scope "
"alongside the bot token, and route only admin.* calls at it")
log.warning(" repair: have an org Admin or Owner install on the organization; "
"admin.apps:read is documented as requiring that")
log.warning(" note: no admin method was called to establish any of this")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Decide whether you hold a credential the admin.* family will accept.
*
* Read only, and it never calls an admin method. The question is not "did
* that method refuse me" but "does the class of credential it requires exist
* in my environment at all", which is settled by the token class plus the
* method's documented requirement. READS is the entire network surface of
* this module, and the tests assert it.
*
* Nothing here prints a credential: the inventory reports a class, a prefix
* family and a count of scopes, and the install record is expected to arrive
* with its token values already removed.
*/
import { readFile } from 'node:fs/promises';
const API = 'https://slack.com/api/';
// The whole network surface. One method, one class of question.
export const READS = ['auth.test'];
export const KNOWN_ADMIN_FAMILIES = ['apps', 'barriers', 'conversations',
'inviteRequests', 'teams', 'usergroups', 'users', 'workflows'];
export const READ_SUFFIXES = ['list', 'search', 'lookup', 'info', 'get',
'getentities', 'listassignments', 'listchannels'];
export const ORDINARY_SCOPES = {
'conversations.list': 'channels:read',
'conversations.info': 'channels:read',
'conversations.history': 'channels:history',
'users.list': 'users:read',
'users.info': 'users:read',
'team.info': 'team:read',
'auth.test': 'none',
};
// The documented class marker and nothing else. No character of the secret.
export function credentialShape(token) {
const text = String(token ?? '');
for (const prefix of ['xoxb-', 'xoxp-', 'xapp-', 'xoxe-', 'xoxe.']) {
if (text.startsWith(prefix)) return prefix.replace(/[-.]$/, '');
}
return text ? 'unknown' : 'absent';
}
// Slack's own answer about the credential that arrived, not the prefix.
export function tokenClass(body) {
const doc = body ?? {};
if (doc.ok !== true) {
return ['unknown', `auth.test refused this credential: ${doc.error ?? 'unknown_error'}`];
}
if (doc.bot_id) return ['bot', 'auth.test returned a bot_id'];
if (doc.user_id) return ['user', 'auth.test returned a user_id and no bot_id'];
return ['unknown', 'auth.test answered ok with neither bot_id nor user_id'];
}
export function grantedScopes(headerValue) {
const parts = String(headerValue ?? '').split(',').map((s) => s.trim()).filter(Boolean);
return [...new Set(parts)].sort();
}
// A bot token is never capable, whatever a scope list says: that combination
// does not exist, so seeing it means you are looking at two credentials.
export function adminCapable(klass, scopes) {
const admin = (scopes ?? []).filter((s) => String(s).startsWith('admin.'));
if (klass === 'bot') {
return ['wrong-class', 'admin.*:read is a user token scope and cannot be granted '
+ 'to a bot token in any configuration'];
}
if (klass === 'user') {
if (admin.length) return ['capable', `${admin.length} admin scope(s) on a user token`];
return ['no-admin-scope', 'a user token, but no admin.* scope was granted to it; '
+ 'request them under User Token Scopes'];
}
return ['unknown', 'the class of this credential was not established, so its '
+ 'capability is not known either'];
}
export function requirementFor(method) {
const name = String(method ?? '').trim();
if (!name) return [null, 'unknown', 'no method given'];
if (!name.startsWith('admin.')) {
const scope = ORDINARY_SCOPES[name];
if (scope) return [scope, 'bot-or-user', 'an ordinary Web API method'];
return [null, 'bot-or-user', 'not an admin method; check the method reference '
+ 'for its scope'];
}
const parts = name.split('.');
if (parts.length < 3) {
return [null, 'user', 'an admin method, but the family could not be read from the name'];
}
const family = parts[1];
const tail = parts[parts.length - 1].toLowerCase();
if (!READ_SUFFIXES.includes(tail)) {
return [null, 'user', 'this is an admin write; a read-only audit does not plan a '
+ 'credential for it'];
}
if (!KNOWN_ADMIN_FAMILIES.includes(family)) {
return [null, 'user', 'an admin read in a family this script does not name; check '
+ 'the method reference for the exact scope'];
}
return [`admin.${family}:read`, 'user', 'an admin read'];
}
// Class first, scope second: a scope on the wrong class is not a partial
// answer, it is a different credential.
export function serves(requirement, inventory) {
const [scope, klass] = requirement;
for (const [name, credClass, scopes] of inventory ?? []) {
if (klass === 'user' && credClass !== 'user') continue;
if (klass === 'bot-or-user' && credClass !== 'bot' && credClass !== 'user') continue;
if (scope && scope !== 'none' && !(scopes ?? []).includes(scope)) continue;
return [name, `class ${credClass}, scope ${scope ?? 'unknown'}`];
}
if (klass === 'user') {
return [null, 'no user token is stored, so nothing here can serve an admin method'];
}
return [null, 'no stored credential holds the required scope'];
}
// Key presence only. Values are never read, so a record that still holds a
// secret is not a hazard this function creates.
export function installGap(record) {
const doc = record ?? {};
const out = [];
if (!('access_token' in doc)) {
out.push(['no-bot-token', 'the record has no top level access_token, so this may '
+ 'not be an oauth.v2.access response']);
}
const authed = doc.authed_user;
if (!authed || typeof authed !== 'object') {
out.push(['user-half-dropped', 'no authed_user object; the user half of the grant '
+ 'was not persisted']);
} else {
if (!('access_token' in authed)) {
out.push(['user-token-dropped', 'authed_user exists but its access_token was '
+ 'not persisted']);
}
const scope = String(authed.scope ?? '');
if (scope && !scope.split(',').some((s) => s.trim().startsWith('admin.'))) {
out.push(['no-admin-scope-asked', 'the user grant carries no admin.* scope; it '
+ 'was never requested']);
}
}
if (doc.is_enterprise_install === false) {
out.push(['workspace-level-install', 'admin.apps:read is documented as requiring '
+ 'an org level install by an Admin or Owner of the organization']);
}
return out;
}
export function planVerdict(rows) {
const total = (rows ?? []).length;
const unserved = (rows ?? []).filter(([, name]) => !name).map(([m]) => m);
if (!total) return ['empty', 'no methods were named, so nothing was planned'];
if (unserved.length) {
return [`${unserved.length} unserved`,
`${unserved.length} of ${total} method(s) have no stored credential: `
+ `${unserved.slice(0, 4).join(', ')}`];
}
return ['clean', `${total} method(s) resolved, 0 unserved`];
}
function arg(args, name, fallback = '') {
const i = args.indexOf(name);
return i === -1 ? fallback : args[i + 1];
}
async function authTest(token) {
const r = await fetch(`${API}auth.test`, { headers: { Authorization: `Bearer ${token}` } });
const scopes = grantedScopes(r.headers.get('x-oauth-scopes'));
try {
return [await r.json(), scopes];
} catch {
return [{ ok: false, error: 'unparseable_body' }, scopes];
}
}
async function main() {
const args = process.argv.slice(2);
const names = arg(args, '--tokens', 'SLACK_BOT_TOKEN,SLACK_USER_TOKEN')
.split(',').map((s) => s.trim()).filter(Boolean);
const methods = arg(args, '--methods', 'admin.apps.approved.list')
.split(',').map((s) => s.trim()).filter(Boolean);
const inventory = [];
for (const envName of names) {
const token = process.env[envName];
if (!token) {
console.warn(`cred ${envName.padEnd(18)} absent set ${envName} if your `
+ 'app holds one');
continue;
}
// eslint-disable-next-line no-await-in-loop
const [body, scopes] = await authTest(token);
const [klass, detail] = tokenClass(body);
const [capability, why] = adminCapable(klass, scopes);
const line = `cred ${envName.padEnd(18)} ${klass.padEnd(10)} `
+ `${credentialShape(token).padEnd(5)} ${String(scopes.length).padStart(2)} scope(s) `
+ `${capability === 'capable' ? 'admin capable' : 'not admin capable'}`;
if (capability === 'capable' || capability === 'no-admin-scope') console.log(line);
else console.warn(line);
console.log(` class read from ${detail}`);
if (capability !== 'capable') console.warn(` reason ${why}`);
inventory.push([envName, klass, scopes, capability]);
}
const rows = [];
for (const method of methods) {
const requirement = requirementFor(method);
const [scope, klass, note] = requirement;
if (scope === null && klass === 'user') {
console.warn(`method ${method.padEnd(30)} ${note}`);
rows.push([method, null]);
continue;
}
console.log(`method ${method.padEnd(30)} needs ${scope ?? 'an unnamed scope'} `
+ `on a ${klass === 'user' ? 'user' : 'bot or user'} token`);
const [served, why] = serves(requirement, inventory);
if (served) console.log(` served by ${served} (${why})`);
else console.warn(` unserved ${why}`);
rows.push([method, served]);
}
const recordPath = arg(args, '--install-record');
if (recordPath) {
const record = JSON.parse(await readFile(recordPath, 'utf8')) ?? {};
for (const [code, why] of installGap(record)) {
console.warn(`install ${code.padEnd(18)} ${why}`);
}
}
const [state, detail] = planVerdict(rows);
if (state === 'clean') {
console.log(`verdict clean ${detail}`);
console.log(' note: no admin method was called to establish any of this');
return;
}
console.warn(`verdict ${state.padEnd(16)} ${detail}`);
console.warn(' repair: request the admin.* scopes under User Token Scopes, not Bot '
+ 'Token Scopes; they cannot be attached to a bot token');
console.warn(' repair: persist authed_user.access_token and authed_user.scope '
+ 'alongside the bot token, and route only admin.* calls at it');
console.warn(' repair: have an org Admin or Owner install on the organization; '
+ 'admin.apps:read is documented as requiring that');
console.warn(' note: no admin method was called to establish any of this');
process.exitCode = 1;
}
// Only run when invoked directly, so importing this module in the tests does
// not execute main().
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
Two assertions carry the argument and the rest support them. The first is that a bot token holding a list of admin.* scopes is still reported as incapable, because that pairing does not exist in Slack and a script that believes the header over the class will send someone round the reinstall loop a fourth time. The second is mechanical: READS must contain auth.test and nothing else, so the claim that this script never touches the admin surface is checked rather than asserted in a comment. After that it is the refusals — an admin write is refused rather than planned, an unknown family declines to invent a scope name — and the install record, where the fixture is key-shaped and carries no token values at all.
from slack_admin_credential import (
READS, admin_capable, credential_shape, granted_scopes, install_gap,
plan_verdict, requirement_for, serves, token_class,
)
BOT = "xoxb-fake-1"
USER = "xoxp-fake-2"
def test_the_network_surface_is_one_read_method():
assert READS == ("auth.test",)
def test_a_bot_token_is_never_admin_capable_whatever_the_header_says():
state, why = admin_capable("bot", ["admin.apps:read", "admin.users:read"])
assert state == "wrong-class"
assert "cannot be granted to a bot token" in why
def test_a_user_token_with_an_admin_scope_is_capable():
assert admin_capable("user", ["admin.apps:read"])[0] == "capable"
def test_a_user_token_without_one_is_a_different_finding():
state, why = admin_capable("user", ["channels:read"])
assert state == "no-admin-scope"
assert "User Token Scopes" in why
def test_the_class_comes_from_the_response_not_the_prefix():
assert token_class({"ok": True, "bot_id": "B1", "user_id": "U1"})[0] == "bot"
assert token_class({"ok": True, "user_id": "U1"})[0] == "user"
assert token_class({"ok": False, "error": "invalid_auth"})[0] == "unknown"
def test_a_refused_credential_is_unknown_rather_than_assumed():
klass, detail = token_class({"ok": False, "error": "token_revoked"})
assert klass == "unknown"
assert "token_revoked" in detail
def test_the_shape_reports_a_class_marker_and_no_secret():
assert credential_shape(BOT) == "xoxb"
assert credential_shape(USER) == "xoxp"
assert credential_shape("") == "absent"
assert "fake" not in credential_shape(BOT)
def test_scopes_are_split_deduplicated_and_sorted():
assert granted_scopes("users:read, channels:read ,users:read") == [
"channels:read", "users:read"]
def test_an_admin_read_derives_its_scope_from_the_family():
assert requirement_for("admin.apps.approved.list") == (
"admin.apps:read", "user", "an admin read")
assert requirement_for("admin.conversations.search")[0] == "admin.conversations:read"
assert requirement_for("admin.teams.list")[0] == "admin.teams:read"
def test_an_admin_write_is_refused_rather_than_planned_for():
scope, klass, note = requirement_for("admin.users.remove")
assert scope is None and klass == "user"
assert "read-only audit" in note
def test_an_unknown_admin_family_does_not_invent_a_scope_name():
scope, _klass, note = requirement_for("admin.somethingNew.list")
assert scope is None
assert "does not name" in note
def test_an_ordinary_method_is_served_by_the_bot_token():
inventory = [("SLACK_BOT_TOKEN", "bot", ["channels:read"], "wrong-class")]
assert serves(requirement_for("conversations.list"), inventory)[0] == "SLACK_BOT_TOKEN"
def test_an_admin_method_is_not_served_by_a_bot_token_holding_the_scope():
inventory = [("SLACK_BOT_TOKEN", "bot", ["admin.apps:read"], "wrong-class")]
served, why = serves(requirement_for("admin.apps.approved.list"), inventory)
assert served is None
assert "no user token is stored" in why
def test_the_user_token_serves_it_once_stored():
inventory = [("SLACK_BOT_TOKEN", "bot", ["channels:read"], "wrong-class"),
("SLACK_USER_TOKEN", "user", ["admin.apps:read"], "capable")]
assert serves(requirement_for("admin.apps.approved.list"),
inventory)[0] == "SLACK_USER_TOKEN"
def test_the_install_record_shows_the_half_that_was_dropped():
codes = [c for c, _w in install_gap({"access_token": "", "team": {"id": "T1"}})]
assert "user-half-dropped" in codes
def test_a_user_grant_without_admin_scopes_is_named_separately():
record = {"access_token": "", "authed_user": {"id": "U1", "access_token": "",
"scope": "identify,channels:read"}}
codes = [c for c, _w in install_gap(record)]
assert codes == ["no-admin-scope-asked"]
def test_a_workspace_level_install_is_flagged_against_the_scope_requirement():
record = {"access_token": "", "is_enterprise_install": False,
"authed_user": {"access_token": "", "scope": "admin.apps:read"}}
codes = [c for c, _w in install_gap(record)]
assert codes == ["workspace-level-install"]
def test_a_complete_record_produces_nothing():
record = {"access_token": "", "is_enterprise_install": True,
"authed_user": {"id": "U1", "access_token": "", "scope": "admin.apps:read"}}
assert install_gap(record) == []
def test_the_verdict_refuses_to_be_clean_with_an_unserved_method():
state, detail = plan_verdict([("admin.apps.approved.list", None),
("conversations.list", "SLACK_BOT_TOKEN")])
assert state == "1 unserved"
assert "admin.apps.approved.list" in detail
def test_a_fully_served_plan_is_clean():
assert plan_verdict([("conversations.list", "SLACK_BOT_TOKEN")])[0] == "clean"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
READS, adminCapable, credentialShape, grantedScopes, installGap, planVerdict,
requirementFor, serves, tokenClass,
} from './slack-admin-credential.mjs';
const BOT = 'xoxb-fake-1';
const USER = 'xoxp-fake-2';
test('the network surface is one read method', () => {
assert.deepEqual(READS, ['auth.test']);
});
test('a bot token is never admin capable whatever the header says', () => {
const [state, why] = adminCapable('bot', ['admin.apps:read', 'admin.users:read']);
assert.equal(state, 'wrong-class');
assert.match(why, /cannot be granted to a bot token/);
});
test('a user token with an admin scope is capable', () => {
assert.equal(adminCapable('user', ['admin.apps:read'])[0], 'capable');
});
test('a user token without one is a different finding', () => {
const [state, why] = adminCapable('user', ['channels:read']);
assert.equal(state, 'no-admin-scope');
assert.match(why, /User Token Scopes/);
});
test('the class comes from the response not the prefix', () => {
assert.equal(tokenClass({ ok: true, bot_id: 'B1', user_id: 'U1' })[0], 'bot');
assert.equal(tokenClass({ ok: true, user_id: 'U1' })[0], 'user');
assert.equal(tokenClass({ ok: false, error: 'invalid_auth' })[0], 'unknown');
});
test('a refused credential is unknown rather than assumed', () => {
const [klass, detail] = tokenClass({ ok: false, error: 'token_revoked' });
assert.equal(klass, 'unknown');
assert.match(detail, /token_revoked/);
});
test('the shape reports a class marker and no secret', () => {
assert.equal(credentialShape(BOT), 'xoxb');
assert.equal(credentialShape(USER), 'xoxp');
assert.equal(credentialShape(''), 'absent');
assert.equal(credentialShape(BOT).includes('fake'), false);
});
test('scopes are split, deduplicated and sorted', () => {
assert.deepEqual(grantedScopes('users:read, channels:read ,users:read'),
['channels:read', 'users:read']);
});
test('an admin read derives its scope from the family', () => {
assert.deepEqual(requirementFor('admin.apps.approved.list'),
['admin.apps:read', 'user', 'an admin read']);
assert.equal(requirementFor('admin.conversations.search')[0], 'admin.conversations:read');
assert.equal(requirementFor('admin.teams.list')[0], 'admin.teams:read');
});
test('an admin write is refused rather than planned for', () => {
const [scope, klass, note] = requirementFor('admin.users.remove');
assert.equal(scope, null);
assert.equal(klass, 'user');
assert.match(note, /read-only audit/);
});
test('an unknown admin family does not invent a scope name', () => {
const [scope, , note] = requirementFor('admin.somethingNew.list');
assert.equal(scope, null);
assert.match(note, /does not name/);
});
test('an admin method is not served by a bot token holding the scope', () => {
const inventory = [['SLACK_BOT_TOKEN', 'bot', ['admin.apps:read'], 'wrong-class']];
const [served, why] = serves(requirementFor('admin.apps.approved.list'), inventory);
assert.equal(served, null);
assert.match(why, /no user token is stored/);
});
test('the user token serves it once stored', () => {
const inventory = [['SLACK_BOT_TOKEN', 'bot', ['channels:read'], 'wrong-class'],
['SLACK_USER_TOKEN', 'user', ['admin.apps:read'], 'capable']];
assert.equal(serves(requirementFor('admin.apps.approved.list'), inventory)[0],
'SLACK_USER_TOKEN');
});
test('the install record shows the half that was dropped', () => {
const codes = installGap({ access_token: '', team: { id: 'T1' } }).map(([c]) => c);
assert.equal(codes.includes('user-half-dropped'), true);
});
test('a user grant without admin scopes is named separately', () => {
const record = { access_token: '',
authed_user: { id: 'U1', access_token: '', scope: 'identify,channels:read' } };
assert.deepEqual(installGap(record).map(([c]) => c), ['no-admin-scope-asked']);
});
test('a workspace level install is flagged against the scope requirement', () => {
const record = { access_token: '', is_enterprise_install: false,
authed_user: { access_token: '', scope: 'admin.apps:read' } };
assert.deepEqual(installGap(record).map(([c]) => c), ['workspace-level-install']);
});
test('the verdict refuses to be clean with an unserved method', () => {
const [state, detail] = planVerdict([['admin.apps.approved.list', null],
['conversations.list', 'SLACK_BOT_TOKEN']]);
assert.equal(state, '1 unserved');
assert.match(detail, /admin\.apps\.approved\.list/);
});
test('a fully served plan is clean', () => {
assert.equal(planVerdict([['conversations.list', 'SLACK_BOT_TOKEN']])[0], 'clean');
});
FAQ
I added admin.apps:read and reinstalled twice. Why does the bot token still not work?
Because the scope and the token were never attached to each other. Slack lists scopes under two headings, and admin.* scopes only appear under User Token Scopes, so adding one adds it to the user grant. The install then returns two credentials: the bot token at the top level of the oauth.v2.access response, and the user token at authed_user.access_token with its own scope string. Reinstalling faithfully repeats that, and if your handler stores only the first one, the second one arrives and is discarded every time.
How is this different from the not_allowed_token_type note?
That note is about runtime and about one method: you hold several credentials, a method refuses one of them, and the question is which class it wanted. This note is about your environment before any call happens, and the usual finding is that the class the admin family requires is not stored anywhere. The repairs are different sizes. One is changing which variable a call reads. This one is a change to the install handler, a redeploy, and asking the customer's org administrator to reinstall.
Why will the script not just call admin.apps.approved.list and tell me what happens?
Because the answer is already available for less. The token class comes from auth.test and the method's requirement is documented, so calling it adds a request and no information. There is also a reason to keep a habit here: the admin family contains methods that remove users, archive conversations and restrict apps, and a diagnostic that pokes that surface to see what comes back is one typo away from doing something to a customer's organization. This one calls auth.test and stops.
The user token is stored now and admin calls still fail. What next?
Read the error. not_allowed_token_type means the class is still wrong, so check which variable the call reads. missing_scope means the class is right and the grant is short, which is a scope request and a reinstall. not_an_admin means both are right and the human who authorised the install does not hold the role, which is a different note. feature_not_enabled means the customer is not on a plan that has the admin API at all, which is a third. The ladder is short and each rung has a different owner.
Does a service account help?
Considerably, and for a reason that has nothing to do with this note's error. A user token carries one person's authority, so when that person changes role or leaves the organization the integration stops with no configuration change anywhere. Installing with a dedicated account that holds the admin role, rather than with a named individual's account, survives both. It does not change anything about the token class: the credential is still a user token and still comes back as authed_user.access_token.
Related field notes
- the runtime refusal, read per method
- the class is right and the person is not
- the scope landed on the other token
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.
- admin.apps:read scope reference - Slack Docs
- oauth.v2.access method reference - Slack Docs
- admin.apps.approved.list method reference - Slack Docs
- auth.test method reference - Slack Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.