The server
slack_server.py
dead_grant_errors is how Slack says a grant is gone. Where RFC 6749 says
invalid_grant, oauth.v2.access answers a bad refresh token with
invalid_refresh_token, and an app the workspace removed with token_revoked.
Declared, those get what invalid_grant gets: no second request for a
minute, and a CredentialError whose
reauthorize is true. Left out, they read as any other failure, and every tool
call for that user asks Slack’s token endpoint again.
revocation is apps.uninstall,
which removes the app from the workspace and revokes every token the
installation holds: Disconnecting.
OAuth2Server.discover("https://slack.com") is the wrong move here. Sign in with
Slack is a separate OpenID Connect surface with its own token endpoint
(openid.connect.token), and it issues an identity token — not the workspace
token the packs’ tools authenticate with. Declare the constant.
Two shapes of Slack app
Token rotation is opt-in per app, and it changes which credential provider you want:
With rotation off there is nothing to refresh, and
OAuth2Client will refuse to
be built without a refresh token — correctly, since a client that can never
refresh is a footgun:
slack_bot_token.py
slack_client.py
$SLACK_BOT_TOKEN is also the pack’s fallback: with no configure() call at
all, the tools read it directly. That is the shape for a script, not for a
product.
Scopes
Each tool declares the scopes its method page lists, for a bot token and for a user token: one per kind of conversation where the method reaches several, so a tool that reads public and private channels alike asks for one of each.scopes_for collects them for the tools you
pass it, so the request is only as wide as the tools you hand a model:
slack_scopes.py
search_messages needs a user token. Slack’s search.messages cannot be
called with a bot token at all, so scopes_for(slack.TOOLS) refuses and names
it, and scopes_for([slack.search_messages], kind=slack.USER) is
['search:read']. A user token also differs on two other methods: joining a
channel takes channels:write rather than channels:join, and creating one
channels:write rather than channels:manage. Called with a bot token anyway,
search raises InsufficientScope with
kind slack.USER; a missing scope raises it with Slack’s own needed.
Slack splits scopes across two parameters. scope asks for bot scopes;
user-token scopes go in a separate user_scope. authorize() puts your scopes
in scope, so the user half travels as a vendor parameter:
slack_connect.py
user_scope can live in the server’s authorization_params instead,
beside the rest of the vendor lore.
Getting the first grant
The route, the session andstate are yours. What is
Slack-specific is reading the response, because the token you want may not be
the one at the top level:
slack_callback.py
grant.access_token is the bot token. The user token — the one
search_messages needs — is nested under authed_user, along with its own
scopes and, under rotation, its own refresh token. The two refresh
independently; whichever refresh token you store is the one you get back.
On an app without rotation, exchange() raises: no refresh token came back and
the default expect_refresh_token=True says that is the silent-death case. Here
it is not, so say so — flow.exchange(code, expect_refresh_token=False) — and
hold the result in a StaticTokenProvider.
Refresh behaviour
Rotation invalidates the token you just used. Slack returns a new refresh token on every refresh and kills the old one, so a refresh token you failed to persist is a dead integration at the next refresh.on_refresh is where that
write goes, and it is not optional on a rotating app.
Concurrency is the trap rotation brings. Twelve parallel tool calls each
firing their own refresh would poison eleven of them. One OAuth2Client
serialises refreshes across those calls; what it cannot see is your other
process holding the same refresh token. Across processes, your store is the
shared cache — one writer through on_refresh, readers rebuilding the client
from the stored value.
Slack reports token failures inside a 200. oauth.v2.access answers a bad
code with HTTP 200 and {"ok": false, "error": "invalid_code"}. Charter checks
the error field regardless of status, so it raises CredentialError like any
other server — the same rule the pack’s envelope applies to
the tools themselves.
Reinstalling replaces the grant. A workspace reinstall issues fresh tokens;
the previous refresh token stops working, and the symptom is
invalid_refresh_token against a user who just clicked “Allow”. Re-run the
callback path that stores the grant on every install, not only the first.
The spent refresh token’s grace period, measured. Presented again a few
minutes after a refresh, the spent token is accepted, and Slack answers with the
current refresh token rather than minting another, so a worker holding a stale
copy gets the live grant. Against a rotation-enabled app it was still accepted
at 4 minutes and refused at 8, as HTTP 200 with invalid_refresh_token. The
grant itself kept working.
PKCE is a per-app setting (OAuth & Permissions, Proof Key for Code
Exchange), off by default. Charter sends code_challenge by default, and
against an app that had not opted in, Slack accepted the consent link and the
exchange that carried the verifier. So the default works whether or not your
app opts in. state is your CSRF defense either way.
Scopes joined by spaces work. Slack documents a comma between scopes, and
Charter’s link joins them with a space, as RFC 6749 says. Slack granted all
eleven of the pack’s bot scopes from that link.
Disconnecting
Slack has two ways to end access, and only one of them disconnects a workspace.auth.revoke revokes one token. Revoking a bot token that way deactivates the
bot but leaves the app installed, and with rotation on, the refresh token
outlives it. apps.uninstall removes the installation and every token it
holds, which is what a “Disconnect Slack” button means, so it is what the
declaration sends. It wants the app’s client ID and secret as well as a token.
With rotation off there is no client, only the bot token you stored, so revoke
it as a string:
slack_disconnect.py
OAuth2Client.revoke() does the same with its current
access token, refreshing first if it has lapsed. An installation already
removed answers invalid_auth, account_inactive or token_revoked inside an
HTTP 200, and both calls return False for it rather than raising.
Where the rest is
- Getting the grant — the route, the session,
state, PKCE - Authorization servers — discovery, and what varies between servers
- Packs — the ten Slack tools and what they trim