Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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.
5 changes: 5 additions & 0 deletions standardized-auth/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
SLACK_CLIENT_ID=
SLACK_CLIENT_SECRET=
SLACK_SIGNING_SECRET=
BASE_URL=https://1234-56-78-90-0.ngrok-free.app
PORT=3000
5 changes: 5 additions & 0 deletions standardized-auth/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
__pycache__
.venv
.env*
!.env.example
data/
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"
}
}
31 changes: 31 additions & 0 deletions standardized-auth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# 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 keeps every connection in Bolt's installation store: a user is connected when the store holds their user token. Shared helpers live in [`src/acme.py`](./src/acme.py), and each listener has its own file under [`src/listeners`](./src/listeners) that registers itself with `@app.action` or `@app.event`. [`src/bolt_app.py`](./src/bolt_app.py) imports every file in that directory when it builds the app, so a new listener only needs a new file.

- **Connect** opens `/slack/install`. When the OAuth flow saves the user's token, [`bolt_app.py`](./src/bolt_app.py) reports the connection with `apps.user.connection.requestAuth` and refreshes App Home.
- **Disconnect** ([`actions/disconnect.py`](./src/listeners/actions/disconnect.py)) forgets the user's token, reports the user as disconnected, then revokes the token.
- **`user_connection_status_requested`** ([`events/user_connection_status_requested.py`](./src/listeners/events/user_connection_status_requested.py)) answers with `apps.user.connection.update` whenever Slack asks about a user it doesn't know yet.
- **`tokens_revoked`** ([`events/tokens_revoked.py`](./src/listeners/events/tokens_revoked.py)) forgets users who remove Acme from their Slack settings and reports them as disconnected.
- **App Home** ([`events/app_home_opened.py`](./src/listeners/events/app_home_opened.py)) shows an `auth_provider` block with the user's current connection.

## Setup

```sh
$ ngrok http 3000 # Replace 1234-56-78-90-0.ngrok-free.app in manifest.json
$ slack install --environment local # Create a new app
$ slack app settings # Gather the client ID, client secret, and signing secret
$ slack env init # Update defaults with them and your ngrok URL
$ slack manifest # Validate fields
$ slack run
$ open https://1234-56-78-90-0.ngrok-free.app/slack/install # Install the app
```

Then open the app's **Home** tab and click **Connect**.

Swap the `users:read` and `users.profile:read` scopes for the ones your connection needs.
6 changes: 6 additions & 0 deletions standardized-auth/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import os

from src.bolt_app import app

if __name__ == "__main__":
app.start(port=int(os.environ.get("PORT", "3000")))
45 changes: 45 additions & 0 deletions standardized-auth/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"display_information": {
"name": "Acme",
"description": "Rocket skates, anvils, and giant magnets for every plan",
"background_color": "#4A154B"
},
"features": {
"app_home": {
"home_tab_enabled": true,
"messages_tab_enabled": false
},
"bot_user": {
"display_name": "Acme",
"always_online": true
},
"user_connections": {
"is_enabled": true,
"auth_hosts": ["1234-56-78-90-0.ngrok-free.app"]
}
},
"oauth_config": {
"redirect_urls": ["https://1234-56-78-90-0.ngrok-free.app/slack/oauth_redirect"],
"scopes": {
"bot": ["users:read"],
"user": ["users.profile:read"]
}
},
"settings": {
"event_subscriptions": {
"request_url": "https://1234-56-78-90-0.ngrok-free.app/slack/events",
"bot_events": [
"app_home_opened",
"tokens_revoked",
"user_connection_status_requested"
]
},
"interactivity": {
"is_enabled": true,
"request_url": "https://1234-56-78-90-0.ngrok-free.app/slack/events"
},
"org_deploy_enabled": true,
"socket_mode_enabled": false,
"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.
87 changes: 87 additions & 0 deletions standardized-auth/src/acme.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import os
from typing import Any

from slack_bolt import BoltContext
from slack_sdk import WebClient
from slack_sdk.models.blocks import AuthProviderBlock, MarkdownTextObject, SectionBlock
from slack_sdk.models.views import View
from slack_sdk.oauth.installation_store import FileInstallationStore, Installation

installation_store = FileInstallationStore(base_dir="./data/installations")

GREETING = (
"*Welcome to Acme!* :rocket:\n"
"Anvils ship same day, gravity permitting. "
"Please keep giant magnets away from your laptop."
)


def find_account(context: BoltContext, user_id: str) -> Installation | None:
installation = installation_store.find_installation(
enterprise_id=context.enterprise_id,
team_id=context.team_id,
user_id=user_id,
is_enterprise_install=context.is_enterprise_install,
)
if installation is None or installation.user_token is None:
return None
return installation


def find_user_token(context: BoltContext, user_id: str) -> str | None:
account = find_account(context, user_id)
return None if account is None else account.user_token


def forget(context: BoltContext, user_id: str) -> str | None:
account = find_account(context, user_id)
if account is None:
return None
user_token = account.user_token
account.user_token = None
installation_store.save(account)
return user_token


def plain_text(text: str) -> dict[str, str]:
return {"type": "plain_text", "text": text}


def provider(client: WebClient, user_token: str | None) -> dict[str, Any]:
acme: dict[str, Any] = {
"value": "acme",
"name": plain_text("Acme account"),
"description": plain_text(
"Order rocket skates, anvils, and giant magnets without leaving Slack."
),
"auth_url": f"{os.environ['BASE_URL']}/slack/install",
"is_connected": user_token is not None,
}
if user_token is not None:
identity = client.auth_test(token=user_token)
acme["connected_identity"] = plain_text(f"@{identity['user']}")
acme["connected_context"] = plain_text(identity["team"])
return acme


def home_view(acme: dict[str, Any]) -> View:
return View(
type="home",
blocks=[
SectionBlock(text=MarkdownTextObject(text=GREETING)),
AuthProviderBlock(**acme),
],
)


def report(client: WebClient, user_id: str, user_token: str | None = None):
acme = provider(client, user_token)
client.apps_user_connection_requestAuth(user_id=user_id, primary_provider=acme)
client.views_publish(user_id=user_id, view=home_view(acme))


def disconnect(client: WebClient, context: BoltContext, user_id: str):
forgotten_token = forget(context, user_id)
report(client, user_id)
if forgotten_token is not None:
client.auth_revoke(token=forgotten_token)
53 changes: 53 additions & 0 deletions standardized-auth/src/bolt_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import os
from importlib import import_module
from pkgutil import walk_packages

from slack_bolt import App, BoltResponse
from slack_bolt.oauth.callback_options import CallbackOptions, FailureArgs, SuccessArgs
from slack_bolt.oauth.oauth_settings import OAuthSettings
from slack_sdk import WebClient
from slack_sdk.oauth.installation_store import Installation
from slack_sdk.oauth.state_store.stateless import StatelessOAuthStateStore

from src import acme, listeners


def app_home_url(installation: Installation) -> str:
team_id = installation.team_id or installation.enterprise_id
return f"slack://app?team={team_id}&id={installation.app_id}&tab=home"


def success_callback(args: SuccessArgs) -> BoltResponse:
installation = args.installation
client = WebClient(token=installation.bot_token)
acme.report(client, installation.user_id, installation.user_token)
return BoltResponse(status=302, headers={"Location": app_home_url(installation)})


def failure_callback(args: FailureArgs) -> BoltResponse:
return args.default.failure(args)


app = App(
oauth_settings=OAuthSettings(
scopes=["users:read"],
user_scopes=["users.profile:read"],
install_page_rendering_enabled=False,
installation_store=acme.installation_store,
state_store=StatelessOAuthStateStore(
expiration_seconds=300,
signing_secret=os.environ["SLACK_SIGNING_SECRET"],
),
callback_options=CallbackOptions(
success=success_callback, failure=failure_callback
),
),
)


def load_listeners() -> None:
for module_info in walk_packages(listeners.__path__, f"{listeners.__name__}."):
import_module(module_info.name)


load_listeners()
Empty file.
Empty file.
8 changes: 8 additions & 0 deletions standardized-auth/src/listeners/actions/connect.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from slack_bolt import Ack

from src.bolt_app import app


@app.action("auth_provider_connect_acme")
def connect_callback(ack: Ack):
ack()
11 changes: 11 additions & 0 deletions standardized-auth/src/listeners/actions/disconnect.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from slack_bolt import Ack, BoltContext
from slack_sdk import WebClient

from src.acme import disconnect
from src.bolt_app import app


@app.action("auth_provider_disconnect_acme")
def disconnect_callback(ack: Ack, body: dict, client: WebClient, context: BoltContext):
ack()
disconnect(client, context, body["user"]["id"])
21 changes: 21 additions & 0 deletions standardized-auth/src/listeners/actions/user_connection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from slack_bolt import Ack, BoltContext
from slack_sdk import WebClient

from src.acme import disconnect, find_user_token, provider
from src.bolt_app import app


@app.action("user_connection")
def user_connection_callback(
ack: Ack, body: dict, client: WebClient, context: BoltContext
):
ack()
user_id = body["user"]["id"]
if body["subtype"] == "connect":
user_token = find_user_token(context, user_id)
client.apps_user_connection_requestAuth(
trigger_id=body["trigger_id"],
primary_provider=provider(client, user_token),
)
elif body["subtype"] == "disconnect":
disconnect(client, context, user_id)
Empty file.
13 changes: 13 additions & 0 deletions standardized-auth/src/listeners/events/app_home_opened.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from slack_bolt import BoltContext
from slack_sdk import WebClient

from src.acme import find_user_token, home_view, provider
from src.bolt_app import app


@app.event("app_home_opened")
def app_home_opened_callback(client: WebClient, context: BoltContext, event: dict):
user_token = find_user_token(context, event["user"])
client.views_publish(
user_id=event["user"], view=home_view(provider(client, user_token))
)
12 changes: 12 additions & 0 deletions standardized-auth/src/listeners/events/tokens_revoked.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from slack_bolt import BoltContext
from slack_sdk import WebClient

from src.acme import forget, report
from src.bolt_app import app


@app.event("tokens_revoked")
def tokens_revoked_callback(client: WebClient, context: BoltContext, event: dict):
for user_id in event["tokens"].get("oauth", []):
forget(context, user_id)
report(client, user_id)
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from slack_bolt import BoltContext
from slack_sdk import WebClient

from src.acme import find_user_token
from src.bolt_app import app


@app.event("user_connection_status_requested")
def user_connection_status_requested_callback(
client: WebClient, context: BoltContext, event: dict
):
user_token = find_user_token(context, event["user"])
status = "disconnected" if user_token is None else "connected"
client.apps_user_connection_update(user_id=event["user"], status=status)
Empty file.
Loading
Loading