Skip to main content
Slack’s token endpoint is an ordinary form POST wearing a Web API method name. The part that catches people is not the endpoint — it is that a Slack app hands back two tokens, and that whether it hands back a refresh token at all is a per-app setting.

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
With rotation on:
slack_client.py
Rotation can’t be turned off once it is on. Each refresh returns a new refresh token, and the one it used is revoked “after a short grace period”. Slack also keeps at most two access tokens active: a refresh before the 12 hours are up that would make a third revokes the oldest. So several processes serving the same workspace share the whole grant through your store rather than each refreshing for itself: More than one process. All of this is from Slack’s token rotation guide. $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
A constant 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 and state 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
With rotation on, 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