Skip to content

Commit 8f606a2

Browse files
committed
Fix issue #1059 token rotation support
1 parent df9d71b commit 8f606a2

33 files changed

Lines changed: 2553 additions & 52 deletions

integration_tests/samples/oauth/oauth_v2_async.py

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ async def oauth_callback(req: Request):
116116
body=html,
117117
)
118118

119-
error = req.args["error"] if "error" in req.args else ""
119+
error = req.args.get("error") if "error" in req.args else ""
120120
return HTTPResponse(
121121
status=400, body=f"Something is wrong with the installation (error: {error})"
122122
)
@@ -143,10 +143,10 @@ async def slack_app(req: Request):
143143
):
144144
return HTTPResponse(status=403, body="invalid request")
145145

146-
if "command" in req.form and req.form["command"] == "/open-modal":
146+
if "command" in req.form and req.form.get("command") == "/open-modal":
147147
try:
148148
enterprise_id = req.form.get("enterprise_id")
149-
team_id = req.form["team_id"]
149+
team_id = req.form.get("team_id")
150150
bot = installation_store.find_bot(
151151
enterprise_id=enterprise_id,
152152
team_id=team_id,
@@ -157,7 +157,7 @@ async def slack_app(req: Request):
157157

158158
client = AsyncWebClient(token=bot_token)
159159
await client.views_open(
160-
trigger_id=req.form["trigger_id"],
160+
trigger_id=req.form.get("trigger_id"),
161161
view={
162162
"type": "modal",
163163
"callback_id": "modal-id",
@@ -188,12 +188,12 @@ async def slack_app(req: Request):
188188
)
189189

190190
elif "payload" in req.form:
191-
payload = json.loads(req.form["payload"])
191+
payload = json.loads(req.form.get("payload"))
192192
if (
193-
payload["type"] == "view_submission"
194-
and payload["view"]["callback_id"] == "modal-id"
193+
payload.get("type") == "view_submission"
194+
and payload.get("view").get("callback_id") == "modal-id"
195195
):
196-
submitted_data = payload["view"]["state"]["values"]
196+
submitted_data = payload.get("view").get("state").get("values")
197197
print(
198198
submitted_data
199199
) # {'b-id': {'a-id': {'type': 'plain_text_input', 'value': 'your input'}}}
@@ -203,9 +203,8 @@ async def slack_app(req: Request):
203203

204204

205205
if __name__ == "__main__":
206-
# export SLACK_TEST_CLIENT_ID=123.123
207-
# export SLACK_TEST_CLIENT_SECRET=xxx
208-
# export SLACK_TEST_REDIRECT_URI=https://{yours}.ngrok.io/slack/oauth/callback
206+
# export SLACK_CLIENT_ID=123.123
207+
# export SLACK_CLIENT_SECRET=xxx
209208
# export SLACK_SIGNING_SECRET=***
210209

211210
app.run(host="0.0.0.0", port=3000)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.env*
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
# ---------------------
2+
# Flask App for Slack OAuth flow
3+
# ---------------------
4+
5+
# pip3 install flask
6+
from typing import Optional
7+
8+
from integration_tests.samples.token_rotation.util import (
9+
parse_body,
10+
extract_enterprise_id,
11+
extract_user_id,
12+
extract_team_id,
13+
extract_is_enterprise_install,
14+
extract_content_type,
15+
)
16+
17+
import logging
18+
import os
19+
from slack_sdk.web import WebClient
20+
from slack_sdk.oauth.token_rotation import TokenRotator
21+
from slack_sdk.oauth import AuthorizeUrlGenerator, RedirectUriPageRenderer
22+
from slack_sdk.oauth.installation_store import FileInstallationStore, Installation
23+
from slack_sdk.oauth.state_store import FileOAuthStateStore
24+
25+
client_id = os.environ["SLACK_CLIENT_ID"]
26+
client_secret = os.environ["SLACK_CLIENT_SECRET"]
27+
scopes = ["app_mentions:read", "chat:write", "commands"]
28+
user_scopes = ["search:read"]
29+
30+
logger = logging.getLogger(__name__)
31+
logging.basicConfig(level=logging.DEBUG)
32+
33+
state_store = FileOAuthStateStore(expiration_seconds=300)
34+
installation_store = FileInstallationStore()
35+
token_rotator = TokenRotator(
36+
client_id=client_id,
37+
client_secret=client_secret,
38+
)
39+
40+
# ---------------------
41+
# Flask App for Slack events
42+
# ---------------------
43+
44+
import json
45+
from slack_sdk.errors import SlackApiError
46+
from slack_sdk.signature import SignatureVerifier
47+
48+
signing_secret = os.environ["SLACK_SIGNING_SECRET"]
49+
signature_verifier = SignatureVerifier(signing_secret=signing_secret)
50+
51+
52+
def rotate_tokens(
53+
enterprise_id: Optional[str] = None,
54+
team_id: Optional[str] = None,
55+
user_id: Optional[str] = None,
56+
is_enterprise_install: Optional[bool] = None,
57+
):
58+
installation = installation_store.find_installation(
59+
enterprise_id=enterprise_id,
60+
team_id=team_id,
61+
user_id=user_id,
62+
is_enterprise_install=is_enterprise_install,
63+
)
64+
if installation is not None:
65+
updated_installation = token_rotator.perform_token_rotation(
66+
installation=installation,
67+
minutes_before_expiration=60 * 24 * 365, # one year for testing
68+
)
69+
if updated_installation is not None:
70+
installation_store.save(updated_installation)
71+
72+
73+
from flask import Flask, request, make_response
74+
75+
app = Flask(__name__)
76+
app.debug = True
77+
78+
79+
@app.route("/slack/events", methods=["POST"])
80+
def slack_app():
81+
if not signature_verifier.is_valid(
82+
body=request.get_data(),
83+
timestamp=request.headers.get("X-Slack-Request-Timestamp"),
84+
signature=request.headers.get("X-Slack-Signature"),
85+
):
86+
return make_response("invalid request", 403)
87+
88+
raw_body = request.data.decode("utf-8")
89+
body = parse_body(body=raw_body, content_type=extract_content_type(request.headers))
90+
rotate_tokens(
91+
enterprise_id=extract_enterprise_id(body),
92+
team_id=extract_team_id(body),
93+
user_id=extract_user_id(body),
94+
is_enterprise_install=extract_is_enterprise_install(body),
95+
)
96+
97+
if "command" in request.form and request.form["command"] == "/token-rotation-modal":
98+
try:
99+
enterprise_id = request.form.get("enterprise_id")
100+
team_id = request.form["team_id"]
101+
bot = installation_store.find_bot(
102+
enterprise_id=enterprise_id,
103+
team_id=team_id,
104+
)
105+
bot_token = bot.bot_token if bot else None
106+
if not bot_token:
107+
return make_response("Please install this app first!", 200)
108+
109+
client = WebClient(token=bot_token)
110+
trigger_id = request.form["trigger_id"]
111+
response = client.views_open(
112+
trigger_id=trigger_id,
113+
view={
114+
"type": "modal",
115+
"callback_id": "modal-id",
116+
"title": {"type": "plain_text", "text": "Awesome Modal"},
117+
"submit": {"type": "plain_text", "text": "Submit"},
118+
"close": {"type": "plain_text", "text": "Cancel"},
119+
"blocks": [
120+
{
121+
"type": "input",
122+
"block_id": "b-id",
123+
"label": {
124+
"type": "plain_text",
125+
"text": "Input label",
126+
},
127+
"element": {
128+
"action_id": "a-id",
129+
"type": "plain_text_input",
130+
},
131+
}
132+
],
133+
},
134+
)
135+
return make_response("", 200)
136+
except SlackApiError as e:
137+
code = e.response["error"]
138+
return make_response(f"Failed to open a modal due to {code}", 200)
139+
140+
elif "payload" in request.form:
141+
payload = json.loads(request.form["payload"])
142+
if (
143+
payload["type"] == "view_submission"
144+
and payload["view"]["callback_id"] == "modal-id"
145+
):
146+
submitted_data = payload["view"]["state"]["values"]
147+
print(
148+
submitted_data
149+
) # {'b-id': {'a-id': {'type': 'plain_text_input', 'value': 'your input'}}}
150+
return make_response("", 200)
151+
152+
else:
153+
if raw_body.startswith("{"):
154+
event_payload = json.loads(raw_body)
155+
logger.info(f"Events API payload: {event_payload}")
156+
if event_payload.get("type") == "url_verification":
157+
return make_response(event_payload.get("challenge"), 200)
158+
return make_response("", 200)
159+
160+
return make_response("", 404)
161+
162+
163+
# ---------------------
164+
# Flask App for Slack OAuth flow
165+
# ---------------------
166+
167+
authorization_url_generator = AuthorizeUrlGenerator(
168+
client_id=client_id,
169+
scopes=scopes,
170+
user_scopes=user_scopes,
171+
)
172+
redirect_page_renderer = RedirectUriPageRenderer(
173+
install_path="/slack/install",
174+
redirect_uri_path="/slack/oauth_redirect",
175+
)
176+
177+
178+
@app.route("/slack/install", methods=["GET"])
179+
def oauth_start():
180+
state = state_store.issue()
181+
url = authorization_url_generator.generate(state)
182+
return (
183+
'<html><head><link rel="icon" href="data:,"></head><body>'
184+
f'<a href="{url}">'
185+
f'<img alt=""Add to Slack"" height="40" width="139" src="https://platform.slack-edge.com/img/add_to_slack.png" srcset="https://platform.slack-edge.com/img/add_to_slack.png 1x, https://platform.slack-edge.com/img/add_to_slack@2x.png 2x" /></a>'
186+
"</body></html>"
187+
)
188+
189+
190+
@app.route("/slack/oauth_redirect", methods=["GET"])
191+
def oauth_callback():
192+
# Retrieve the auth code and state from the request params
193+
if "code" in request.args:
194+
state = request.args["state"]
195+
if state_store.consume(state):
196+
code = request.args["code"]
197+
client = WebClient() # no prepared token needed for this app
198+
oauth_response = client.oauth_v2_access(
199+
client_id=client_id, client_secret=client_secret, code=code
200+
)
201+
logger.info(f"oauth.v2.access response: {oauth_response}")
202+
203+
installed_enterprise = oauth_response.get("enterprise", {})
204+
is_enterprise_install = oauth_response.get("is_enterprise_install")
205+
installed_team = oauth_response.get("team", {})
206+
installer = oauth_response.get("authed_user", {})
207+
incoming_webhook = oauth_response.get("incoming_webhook", {})
208+
209+
bot_token = oauth_response.get("access_token")
210+
# NOTE: oauth.v2.access doesn't include bot_id in response
211+
bot_id = None
212+
enterprise_url = None
213+
if bot_token is not None:
214+
auth_test = client.auth_test(token=bot_token)
215+
bot_id = auth_test["bot_id"]
216+
if is_enterprise_install is True:
217+
enterprise_url = auth_test.get("url")
218+
219+
installation = Installation(
220+
app_id=oauth_response.get("app_id"),
221+
enterprise_id=installed_enterprise.get("id"),
222+
enterprise_name=installed_enterprise.get("name"),
223+
enterprise_url=enterprise_url,
224+
team_id=installed_team.get("id"),
225+
team_name=installed_team.get("name"),
226+
bot_token=bot_token,
227+
bot_id=bot_id,
228+
bot_user_id=oauth_response.get("bot_user_id"),
229+
bot_scopes=oauth_response.get("scope"), # comma-separated string
230+
bot_refresh_token=oauth_response.get("refresh_token"),
231+
bot_token_expires_in=oauth_response.get("expires_in"),
232+
user_id=installer.get("id"),
233+
user_token=installer.get("access_token"),
234+
user_scopes=installer.get("scope"), # comma-separated string
235+
user_refresh_token=installer.get("refresh_token"),
236+
user_token_expires_in=installer.get("expires_in"),
237+
incoming_webhook_url=incoming_webhook.get("url"),
238+
incoming_webhook_channel=incoming_webhook.get("channel"),
239+
incoming_webhook_channel_id=incoming_webhook.get("channel_id"),
240+
incoming_webhook_configuration_url=incoming_webhook.get(
241+
"configuration_url"
242+
),
243+
is_enterprise_install=is_enterprise_install,
244+
token_type=oauth_response.get("token_type"),
245+
)
246+
installation_store.save(installation)
247+
return redirect_page_renderer.render_success_page(
248+
app_id=installation.app_id,
249+
team_id=installation.team_id,
250+
is_enterprise_install=installation.is_enterprise_install,
251+
enterprise_url=installation.enterprise_url,
252+
)
253+
else:
254+
return redirect_page_renderer.render_failure_page(
255+
"the state value is already expired"
256+
)
257+
258+
error = request.args["error"] if "error" in request.args else ""
259+
return make_response(
260+
f"Something is wrong with the installation (error: {error})", 400
261+
)
262+
263+
264+
if __name__ == "__main__":
265+
# export SLACK_CLIENT_ID=123.123
266+
# export SLACK_CLIENT_SECRET=xxx
267+
# export SLACK_SIGNING_SECRET=***
268+
# export FLASK_ENV=development
269+
270+
app.run("localhost", 3000)
271+
272+
# python3 integration_tests/samples/token_rotation/oauth.py
273+
# ngrok http 3000
274+
# https://{yours}.ngrok.io/slack/oauth/start

0 commit comments

Comments
 (0)