Repository navigation
feat(standardized-auth): add an acme sample for standardized app auth #305
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
adce849
ad015ea
e6f9a83
9d42ba6
2f734bb
ce7b9c9
6504b46
1d3d917
250924c
0506019
03a47e1
0bfe441
c883e80
c659723
0bcc0d5
2793bff
c436555
e9a652f
9214445
44dfd63
8e90abe
ebfc9a0
ebf8d22
866e35a
bd98883
cdbca97
9de3c36
f421ea3
c7eee0b
af37074
36766d5
88ad4bb
029cedd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| # Authentication | ||
|
|
||
| Connect user accounts and act on their behalf from a Slack app. | ||
|
|
||
| Read the [docs](https://docs.slack.dev/authentication/) to learn concepts behind these constructions, or explore implementations of specific features. | ||
|
|
||
| ## What's on display | ||
|
|
||
| - **[Standardized Auth](standardized-auth/)**: Connect user accounts with the Slack-owned Connect and Manage connection controls. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| __pycache__ | ||
| .mypy_cache | ||
| .pytest_cache | ||
| .ruff_cache | ||
| .venv | ||
| .env* | ||
| acme.db* | ||
| *.egg-info | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| apps.dev.json | ||
| cache/ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| { | ||
| "manifest": { | ||
| "source": "local" | ||
| }, | ||
| "project_id": "00000000-0000-0000-0000-000000000000" | ||
| } |
| 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" | ||
| } | ||
| } |
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📚 issue (blocking): Let's move this to |
| 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** in App Home, or on the app's profile (which sends a `user_connection` event with the `disconnect` subtype), forgets the connection, reports the user as disconnected, and refreshes App Home. | ||
| - **`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**. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| 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"]) | ||
|
|
||
|
|
||
| def show_connection(client: WebClient, user_id: str): | ||
| 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("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) | ||
| show_connection(client, user_id) | ||
|
|
||
|
|
||
| @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) | ||
| show_connection(client, user_id) | ||
|
|
||
|
|
||
| @app.event("user_connection") | ||
| def user_connection_callback(client: WebClient, event: dict): | ||
| if event["subtype"] == "connect": | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔌 question: Should the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good catch! The disconnect subtype now forgets the connection and refreshes App Home, sharing one path with the Disconnect button |
||
| client.apps_user_connection_requestAuth( | ||
| trigger_id=event["trigger_id"], primary_provider=provider(event["user"]) | ||
| ) | ||
| elif event["subtype"] == "disconnect": | ||
| connections.disconnect(event["user"]) | ||
| show_connection(client, 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() | ||
| 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"] | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📚 question: Is
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yess! the scope for apps.user.connection.update is getting removed since no scope is needed for this endpoint |
||
| } | ||
| }, | ||
| "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 | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| [project] | ||
| name = "bolt-python-examples-standardized-auth" | ||
| version = "0.0.0" | ||
| requires-python = ">=3.10" | ||
| dependencies = [ | ||
| "slack_bolt==1.30.0", | ||
| "slack_cli_hooks==0.3.0", | ||
| "slack_sdk==3.46.0", | ||
| ] | ||
|
|
||
| [project.optional-dependencies] | ||
| dev = [ | ||
| "mypy==2.4.0", | ||
| "pytest==9.1.1", | ||
| "ruff==0.17.3", | ||
| ] | ||
|
|
||
| [tool.setuptools.packages.find] | ||
| include = ["src*"] |
| 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), | ||
| ], | ||
| ) |
| 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 |
| 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() |
| 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() |
| 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} |
Uh oh!
There was an error while loading. Please reload this page.