Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
adce849
feat(standardized-auth): add an acme sample for standardized app auth
WilliamBergamin Oct 16, 2026
ad015ea
chore: merge main into standardized-auth
WilliamBergamin Oct 16, 2026
e6f9a83
refactor(standardized-auth): use the stateless oauth state store
WilliamBergamin Oct 16, 2026
9d42ba6
refactor(standardized-auth): one file per listener and plain-function…
WilliamBergamin Oct 19, 2026
2f734bb
refactor(standardized-auth): register listeners with decorators on load
WilliamBergamin Oct 19, 2026
ce7b9c9
refactor(standardized-auth): build the app and its oauth callbacks in…
WilliamBergamin Oct 19, 2026
6504b46
refactor(standardized-auth): import listeners from bolt_app.py
WilliamBergamin Oct 19, 2026
1d3d917
refactor(standardized-auth): name listener modules relative to their …
WilliamBergamin Oct 19, 2026
250924c
refactor(standardized-auth): find listener files with glob
WilliamBergamin Oct 19, 2026
0506019
refactor(standardized-auth): glob every python file in listeners
WilliamBergamin Oct 19, 2026
03a47e1
refactor(standardized-auth): load listeners with pkgutil.walk_packages
WilliamBergamin Oct 19, 2026
0bfe441
style(standardized-auth): build the listener prefix with an f-string
WilliamBergamin Oct 19, 2026
c883e80
fix(standardized-auth): clear the user token from the latest installa…
WilliamBergamin Oct 19, 2026
c659723
fix(standardized-auth): return users to app home after connecting
WilliamBergamin Oct 19, 2026
0bcc0d5
refactor(standardized-auth): run over socket mode with a sample sign-…
WilliamBergamin Oct 20, 2026
2793bff
refactor(standardized-auth): reuse the sdk state store and trim the s…
WilliamBergamin Oct 20, 2026
c436555
refactor(standardized-auth): connect in slack without a sign-in page
WilliamBergamin Oct 20, 2026
e9a652f
refactor(standardized-auth): move every listener into one file
WilliamBergamin Oct 20, 2026
9214445
refactor(standardized-auth): define the app and its listeners in app.py
WilliamBergamin Oct 20, 2026
44dfd63
fix(standardized-auth): handle the user_connection event and fixed au…
WilliamBergamin Oct 21, 2026
8e90abe
refactor(standardized-auth): create the connections folder once
WilliamBergamin Oct 21, 2026
ebfc9a0
refactor(standardized-auth): show every slack call in its listener
WilliamBergamin Oct 21, 2026
ebf8d22
feat(standardized-auth): only handle clicks for the acme provider
WilliamBergamin Oct 21, 2026
866e35a
refactor(standardized-auth): trim the manifest to what the sample uses
WilliamBergamin Oct 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,11 @@ updates:
default-days: 7
exclude:
- "slack*"
- package-ecosystem: "pip"
directory: "standardized-auth"
schedule:
interval: "daily"
cooldown:
default-days: 7
exclude:
- "slack*"
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ jobs:
- "ai/slackbot-mcp-client/slack-identity"
- "block-kit"
- "methods"
- "standardized-auth"
defaults:
run:
working-directory: ${{ matrix.showcase }}
Expand Down Expand Up @@ -53,6 +54,7 @@ jobs:
- "ai/slackbot-mcp-client/slack-identity"
- "block-kit"
- "methods"
- "standardized-auth"
defaults:
run:
working-directory: ${{ matrix.showcase }}
Expand Down Expand Up @@ -82,6 +84,7 @@ jobs:
- "ai/slackbot-mcp-client/rich-responses/mcp-apps"
- "ai/slackbot-mcp-client/slack-identity"
- "block-kit"
- "standardized-auth"
defaults:
run:
working-directory: ${{ matrix.showcase }}
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ This collections of examples highlights features of a Slack app in the language
- **[AI in Slack](./ai)**: Agent experiences and MCP features in an interactive conversation interface.
- **[Block Kit](./block-kit)**: The framework of visual components arranged to create app layouts.
- **[Methods](./methods)**: An interface for querying information from and enacting change in a Slack workspace.
- **[Standardized Auth](./standardized-auth)**: Connect user accounts with the Slack-owned Connect and Manage connection controls.
7 changes: 7 additions & 0 deletions standardized-auth/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
__pycache__
.mypy_cache
.pytest_cache
.ruff_cache
.venv
.env*
acme.db*
2 changes: 2 additions & 0 deletions standardized-auth/.slack/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
apps.dev.json
cache/
6 changes: 6 additions & 0 deletions standardized-auth/.slack/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"manifest": {
"source": "local"
},
"project_id": "00000000-0000-0000-0000-000000000000"
}
5 changes: 5 additions & 0 deletions standardized-auth/.slack/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"hooks": {
"get-hooks": "python3 -m slack_cli_hooks.hooks.get_hooks"
}
}
27 changes: 27 additions & 0 deletions standardized-auth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Standardized Auth

Connect your Acme account to Slack with the Slack-owned **Connect** and **Manage connection** controls. Rocket skates, anvils, and giant magnets not included.

Read the docs: https://docs.slack.dev/authentication/standardized-auth

## How it works

Acme runs over Socket Mode and keeps every connection in a local `shelve` file ([`src/connections.py`](./src/connections.py)): a user is connected when their Slack user ID is in it. The provider object and the App Home view live in [`src/acme.py`](./src/acme.py), and every listener and Slack API call lives in [`app.py`](./app.py).

- **Connect** connects the Slack user who clicked it, with their user ID as the connected identity. It reports the connection with `apps.user.connection.requestAuth` and refreshes App Home.
- **Connect** or **Manage** on the app's profile sends the `user_connection` event, and Acme answers within 3 seconds by opening the connection modal with `apps.user.connection.requestAuth` and the event's `trigger_id`.
- **Disconnect** forgets the connection and reports the user as disconnected.
- **`user_connection_status_requested`** answers with `apps.user.connection.update` whenever Slack asks about a user it doesn't know yet.
- **App Home** shows an `auth_provider` block with the user's current connection.

Connecting in one click stands in for your service's real login. Slack requires at least one host in the manifest's `features.user_connections.auth_hosts`, so `example.com` holds the place until you have one. To sign users in to your own service, set `auth_url` on the provider to your OAuth or SSO page, replace `example.com` with its host, and carry the Slack user through the browser in a signed `state` (the SDK's `StatelessOAuthStateStore` works for this) so a connection always lands on the user who clicked **Connect**.

## Setup

Everything arrives over Socket Mode, so the app needs no public URL. The manifest subscribes to the `user_connection` and `user_connection_status_requested` events, which every app with `features.user_connections` needs. Use a Slack CLI newer than v4.10.0: older versions don't send `features.user_connections` with the manifest.

```sh
$ slack run
```

Then open the app's **Home** tab and click **Connect**.
57 changes: 57 additions & 0 deletions standardized-auth/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import os

from slack_bolt import Ack, App
from slack_bolt.adapter.socket_mode import SocketModeHandler
from slack_sdk import WebClient

from src import connections
from src.acme import PROVIDER_VALUE, home_view, provider

app = App(token=os.environ["SLACK_BOT_TOKEN"])


@app.event("app_home_opened")
def app_home_opened_callback(client: WebClient, event: dict):
client.views_publish(user_id=event["user"], view=home_view(provider(event["user"])))


@app.action("auth_provider_connect")
def connect_callback(ack: Ack, body: dict, client: WebClient):
ack()
if body["actions"][0]["value"] != PROVIDER_VALUE:
return
user_id = body["user"]["id"]
connections.connect(user_id)
acme = provider(user_id)
client.apps_user_connection_requestAuth(user_id=user_id, primary_provider=acme)
client.views_publish(user_id=user_id, view=home_view(acme))


@app.action("auth_provider_disconnect")
def disconnect_callback(ack: Ack, body: dict, client: WebClient):
ack()
if body["actions"][0]["value"] != PROVIDER_VALUE:
return
user_id = body["user"]["id"]
connections.disconnect(user_id)
acme = provider(user_id)
client.apps_user_connection_requestAuth(user_id=user_id, primary_provider=acme)
client.views_publish(user_id=user_id, view=home_view(acme))


@app.event("user_connection")
def user_connection_callback(client: WebClient, event: dict):
if event["subtype"] == "connect":
client.apps_user_connection_requestAuth(
trigger_id=event["trigger_id"], primary_provider=provider(event["user"])
)


@app.event("user_connection_status_requested")
def user_connection_status_requested_callback(client: WebClient, event: dict):
status = "connected" if connections.is_connected(event["user"]) else "disconnected"
client.apps_user_connection_update(user_id=event["user"], status=status)


if __name__ == "__main__":
SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start()
39 changes: 39 additions & 0 deletions standardized-auth/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"display_information": {
"name": "Acme",
"description": "Rocket skates, anvils, and giant magnets for every plan"
},
"features": {
"app_home": {
"home_tab_enabled": true
},
"bot_user": {
"display_name": "Acme",
"always_online": true
},
"user_connections": {
"is_enabled": true,
"auth_hosts": ["example.com"]
}
},
"oauth_config": {
"scopes": {
"bot": ["users:read"]
}
},
"settings": {
"event_subscriptions": {
"bot_events": [
"app_home_opened",
"user_connection",
"user_connection_status_requested"
]
},
"interactivity": {
"is_enabled": true
},
"org_deploy_enabled": true,
"socket_mode_enabled": true,
"token_rotation_enabled": false
}
}
6 changes: 6 additions & 0 deletions standardized-auth/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
mypy==2.4.0
pytest==9.1.1
ruff==0.16.10
slack_bolt==1.30.0
slack_cli_hooks==0.3.0
slack_sdk @ git+https://github.com/slackapi/python-slack-sdk.git@699e1d1fe91effa4cb03bd0e3e8e5d88f43258b4
Empty file.
37 changes: 37 additions & 0 deletions standardized-auth/src/acme.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from typing import Any

from slack_sdk.models.blocks import AuthProviderBlock, SectionBlock
from slack_sdk.models.views import View

from src import connections

PROVIDER_VALUE = "acme"


def provider(user_id: str) -> dict[str, Any]:
acme: dict[str, Any] = {
"value": PROVIDER_VALUE,
"name": {"type": "plain_text", "text": "Acme account"},
"description": {
"type": "plain_text",
"text": "Order rocket skates, anvils, and giant magnets without leaving Slack.",
},
"is_connected": connections.is_connected(user_id),
}
if acme["is_connected"]:
acme["connected_identity"] = {"type": "plain_text", "text": user_id}
return acme


def home_view(acme: dict[str, Any]) -> View:
return View(
type="home",
blocks=[
SectionBlock(
text="*Welcome to Acme!* :rocket:\n"
"Anvils ship same day, gravity permitting. "
"Please keep giant magnets away from your laptop."
),
AuthProviderBlock(**acme),
],
)
18 changes: 18 additions & 0 deletions standardized-auth/src/connections.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import shelve

FILENAME = "acme.db"


def connect(user_id: str) -> None:
with shelve.open(FILENAME) as db:
db[user_id] = True


def disconnect(user_id: str) -> None:
with shelve.open(FILENAME) as db:
db.pop(user_id, None)


def is_connected(user_id: str) -> bool:
with shelve.open(FILENAME) as db:
return user_id in db
Empty file.
6 changes: 6 additions & 0 deletions standardized-auth/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import os
from unittest.mock import patch

os.environ["SLACK_BOT_TOKEN"] = "xoxb-test-bot"

patch("slack_sdk.WebClient.auth_test").start()
13 changes: 13 additions & 0 deletions standardized-auth/tests/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from tempfile import TemporaryDirectory
from unittest.mock import patch


class TempConnections:
def __init__(self):
self.dir = TemporaryDirectory()
self.filename = patch("src.connections.FILENAME", f"{self.dir.name}/acme.db")
self.filename.start()

def close(self):
self.filename.stop()
self.dir.cleanup()
38 changes: 38 additions & 0 deletions standardized-auth/tests/test_acme.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from src import connections
from src.acme import home_view, provider
from tests.helpers import TempConnections


class TestAcme:
def setup_method(self):
self.connections = TempConnections()

def teardown_method(self):
self.connections.close()

def test_disconnected_provider_offers_to_connect(self):
assert provider("U0ADA") == {
"value": "acme",
"name": {"type": "plain_text", "text": "Acme account"},
"description": {
"type": "plain_text",
"text": "Order rocket skates, anvils, and giant magnets without leaving Slack.",
},
"is_connected": False,
}

def test_connected_provider_names_the_slack_user(self):
connections.connect("U0ADA")

acme = provider("U0ADA")

assert acme["is_connected"] is True
assert acme["connected_identity"] == {"type": "plain_text", "text": "U0ADA"}

def test_home_view_renders_the_provider_as_an_auth_provider_block(self):
acme = provider("U0ADA")

view = home_view(acme).to_dict()

assert view["type"] == "home"
assert view["blocks"][-1] == {"type": "auth_provider", **acme}
Loading