Skip to content

Commit 6bfc7bb

Browse files
committed
Fix #400 token rotation feature support
1 parent 1b4eefc commit 6bfc7bb

File tree

12 files changed

+171
-2
lines changed

12 files changed

+171
-2
lines changed

examples/oauth_sqlite3_app.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@
55
from slack_bolt import App
66
from slack_bolt.oauth import OAuthFlow
77

8-
app = App(oauth_flow=OAuthFlow.sqlite3(database="./slackapp.db"))
8+
app = App(
9+
oauth_flow=OAuthFlow.sqlite3(
10+
database="./slackapp.db",
11+
token_rotation_expiration_minutes=60 * 24, # for testing
12+
)
13+
)
914

1015

1116
@app.event("app_mention")
@@ -14,6 +19,12 @@ def handle_app_mentions(body, say, logger):
1419
say("What's up?")
1520

1621

22+
@app.command("/token-rotation-modal")
23+
def handle_some_command(ack, body, logger):
24+
ack()
25+
logger.info(body)
26+
27+
1728
if __name__ == "__main__":
1829
app.start(3000)
1930

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import logging
2+
3+
logging.basicConfig(level=logging.DEBUG)
4+
5+
from slack_bolt import App
6+
from slack_bolt.oauth import OAuthFlow
7+
8+
app = App(
9+
oauth_flow=OAuthFlow.sqlite3(
10+
database="./slackapp.db",
11+
token_rotation_expiration_minutes=60 * 24, # for testing
12+
),
13+
installation_store_bot_only=True,
14+
)
15+
16+
17+
@app.event("app_mention")
18+
def handle_app_mentions(body, say, logger):
19+
logger.info(body)
20+
say("What's up?")
21+
22+
23+
@app.command("/token-rotation-modal")
24+
def handle_some_command(ack, body, logger):
25+
ack()
26+
logger.info(body)
27+
28+
29+
if __name__ == "__main__":
30+
app.start(3000)
31+
32+
# pip install slack_bolt
33+
# export SLACK_SIGNING_SECRET=***
34+
# export SLACK_BOT_TOKEN=xoxb-***
35+
# export SLACK_CLIENT_ID=111.111
36+
# export SLACK_CLIENT_SECRET=***
37+
# export SLACK_SCOPES=app_mentions:read,channels:history,im:history,chat:write
38+
# python oauth_app.py

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
),
4242
include_package_data=True, # MANIFEST.in
4343
install_requires=[
44-
"slack_sdk>=3.5.0,<4",
44+
"slack_sdk>=3.8.0rc2,<4",
4545
],
4646
setup_requires=["pytest-runner==5.2"],
4747
tests_require=test_dependencies,

slack_bolt/adapter/aws_lambda/lambda_s3_oauth_flow.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ def __init__(
6363
# In this case, the /slack/events endpoint doesn't work along with the OAuth flow.
6464
settings.authorize = InstallationStoreAuthorize(
6565
logger=logger,
66+
client_id=settings.client_id,
67+
client_secret=settings.client_secret,
6668
installation_store=settings.installation_store,
6769
bot_only=settings.installation_store_bot_only,
6870
)

slack_bolt/app/app.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,8 +218,11 @@ def message_hello(message, say):
218218

219219
self._installation_store: Optional[InstallationStore] = installation_store
220220
if self._installation_store is not None and self._authorize is None:
221+
settings = oauth_flow.settings if oauth_flow is not None else oauth_settings
221222
self._authorize = InstallationStoreAuthorize(
222223
installation_store=self._installation_store,
224+
client_id=settings.client_id if settings is not None else None,
225+
client_secret=settings.client_secret if settings is not None else None,
223226
logger=self._framework_logger,
224227
bot_only=installation_store_bot_only,
225228
)

slack_bolt/app/async_app.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,8 +229,11 @@ async def message_hello(message, say): # async function
229229
AsyncInstallationStore
230230
] = installation_store
231231
if self._async_installation_store is not None and self._async_authorize is None:
232+
settings = oauth_flow.settings if oauth_flow is not None else oauth_settings
232233
self._async_authorize = AsyncInstallationStoreAuthorize(
233234
installation_store=self._async_installation_store,
235+
client_id=settings.client_id if settings is not None else None,
236+
client_secret=settings.client_secret if settings is not None else None,
234237
logger=self._framework_logger,
235238
bot_only=installation_store_bot_only,
236239
)

slack_bolt/authorization/async_authorize.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
from slack_sdk.oauth.installation_store.async_installation_store import (
88
AsyncInstallationStore,
99
)
10+
from slack_sdk.oauth.token_rotation.async_rotator import AsyncTokenRotator
1011

1112
from slack_bolt.authorization.async_authorize_args import AsyncAuthorizeArgs
1213
from slack_bolt.authorization import AuthorizeResult
1314
from slack_bolt.context.async_context import AsyncBoltContext
15+
from slack_bolt.error import BoltError
1416

1517

1618
class AsyncAuthorize:
@@ -94,12 +96,18 @@ class AsyncInstallationStoreAuthorize(AsyncAuthorize):
9496
authorize_result_cache: Dict[str, AuthorizeResult]
9597
find_installation_available: Optional[bool]
9698
find_bot_available: Optional[bool]
99+
token_rotator: Optional[AsyncTokenRotator]
100+
101+
_config_error_message: str = "AsyncInstallationStore with client_id/client_secret are required for token rotation"
97102

98103
def __init__(
99104
self,
100105
*,
101106
logger: Logger,
102107
installation_store: AsyncInstallationStore,
108+
client_id: Optional[str] = None,
109+
client_secret: Optional[str] = None,
110+
token_rotation_expiration_minutes: Optional[int] = None,
103111
# For v1.0.x compatibility and people who still want its simplicity
104112
# use only InstallationStore#find_bot(enterprise_id, team_id)
105113
bot_only: bool = False,
@@ -112,6 +120,16 @@ def __init__(
112120
self.authorize_result_cache = {}
113121
self.find_installation_available = None
114122
self.find_bot_available = None
123+
if client_id is not None and client_secret is not None:
124+
self.token_rotator = AsyncTokenRotator(
125+
client_id=client_id,
126+
client_secret=client_secret,
127+
)
128+
else:
129+
self.token_rotator = None
130+
self.token_rotation_expiration_minutes = (
131+
token_rotation_expiration_minutes or 120
132+
)
115133

116134
async def __call__(
117135
self,
@@ -171,6 +189,20 @@ async def __call__(
171189
installation.user_token,
172190
)
173191

192+
if installation.user_refresh_token is not None:
193+
if self.token_rotator is None:
194+
raise BoltError(self._config_error_message)
195+
refreshed = await self.token_rotator.perform_token_rotation(
196+
installation=installation,
197+
token_rotation_expiration_minutes=self.token_rotation_expiration_minutes,
198+
)
199+
if refreshed is not None:
200+
await self.installation_store.async_save(refreshed)
201+
bot_token, user_token = (
202+
refreshed.bot_token,
203+
refreshed.user_token,
204+
)
205+
174206
except NotImplementedError as _:
175207
self.find_installation_available = False
176208

@@ -199,6 +231,18 @@ async def __call__(
199231
except Exception as e:
200232
self.logger.info(f"Failed to call find_bot method: {e}")
201233

234+
if bot.bot_refresh_token is not None:
235+
# Token rotation
236+
if self.token_rotator is None:
237+
raise BoltError(self._config_error_message)
238+
refreshed = await self.token_rotator.perform_bot_token_rotation(
239+
bot=bot,
240+
token_rotation_expiration_minutes=self.token_rotation_expiration_minutes,
241+
)
242+
if refreshed is not None:
243+
await self.installation_store.async_save_bot(refreshed)
244+
bot_token = refreshed.bot_token
245+
202246
token: Optional[str] = bot_token or user_token
203247
if token is None:
204248
# No valid token was found

slack_bolt/authorization/authorize.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import inspect
2+
import os
23
from logging import Logger
34
from typing import Optional, Callable, Dict, Any
45

56
from slack_sdk.errors import SlackApiError
67
from slack_sdk.oauth import InstallationStore
78
from slack_sdk.oauth.installation_store import Bot
89
from slack_sdk.oauth.installation_store.models.installation import Installation
10+
from slack_sdk.oauth.token_rotation.rotator import TokenRotator
911

1012
from slack_bolt.authorization.authorize_args import AuthorizeArgs
1113
from slack_bolt.authorization.authorize_result import AuthorizeResult
1214
from slack_bolt.context.context import BoltContext
15+
from slack_bolt.error import BoltError
1316

1417

1518
class Authorize:
@@ -97,12 +100,20 @@ class InstallationStoreAuthorize(Authorize):
97100
bot_only: bool
98101
find_installation_available: bool
99102
find_bot_available: bool
103+
token_rotator: Optional[TokenRotator]
104+
105+
_config_error_message: str = (
106+
"InstallationStore with client_id/client_secret are required for token rotation"
107+
)
100108

101109
def __init__(
102110
self,
103111
*,
104112
logger: Logger,
105113
installation_store: InstallationStore,
114+
client_id: Optional[str] = None,
115+
client_secret: Optional[str] = None,
116+
token_rotation_expiration_minutes: Optional[int] = None,
106117
# For v1.0.x compatibility and people who still want its simplicity
107118
# use only InstallationStore#find_bot(enterprise_id, team_id)
108119
bot_only: bool = False,
@@ -117,6 +128,16 @@ def __init__(
117128
installation_store, "find_installation"
118129
)
119130
self.find_bot_available = hasattr(installation_store, "find_bot")
131+
if client_id is not None and client_secret is not None:
132+
self.token_rotator = TokenRotator(
133+
client_id=client_id,
134+
client_secret=client_secret,
135+
)
136+
else:
137+
self.token_rotator = None
138+
self.token_rotation_expiration_minutes = (
139+
token_rotation_expiration_minutes or 120
140+
)
120141

121142
def __call__(
122143
self,
@@ -165,6 +186,19 @@ def __call__(
165186
installation.bot_token,
166187
installation.user_token,
167188
)
189+
if installation.user_refresh_token is not None:
190+
if self.token_rotator is None:
191+
raise BoltError(self._config_error_message)
192+
refreshed = self.token_rotator.perform_token_rotation(
193+
installation=installation,
194+
minutes_before_expiration=self.token_rotation_expiration_minutes,
195+
)
196+
if refreshed is not None:
197+
self.installation_store.save(refreshed)
198+
bot_token, user_token = (
199+
refreshed.bot_token,
200+
refreshed.user_token,
201+
)
168202

169203
except NotImplementedError as _:
170204
self.find_installation_available = False
@@ -194,6 +228,18 @@ def __call__(
194228
except Exception as e:
195229
self.logger.info(f"Failed to call find_bot method: {e}")
196230

231+
if bot.bot_refresh_token is not None:
232+
# Token rotation
233+
if self.token_rotator is None:
234+
raise BoltError(self._config_error_message)
235+
refreshed = self.token_rotator.perform_bot_token_rotation(
236+
bot=bot,
237+
minutes_before_expiration=self.token_rotation_expiration_minutes,
238+
)
239+
if refreshed is not None:
240+
self.installation_store.save_bot(refreshed)
241+
bot_token = refreshed.bot_token
242+
197243
token: Optional[str] = bot_token or user_token
198244
if token is None:
199245
# No valid token was found

slack_bolt/oauth/async_oauth_flow.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,9 +338,13 @@ async def run_installation(self, code: str) -> Optional[Installation]:
338338
bot_id=bot_id,
339339
bot_user_id=oauth_response.get("bot_user_id"),
340340
bot_scopes=oauth_response.get("scope"), # comma-separated string
341+
bot_refresh_token=oauth_response.get("refresh_token"), # since v1.7
342+
bot_token_expires_in=oauth_response.get("expires_in"), # since v1.7
341343
user_id=installer.get("id"),
342344
user_token=installer.get("access_token"),
343345
user_scopes=installer.get("scope"), # comma-separated string
346+
user_refresh_token=installer.get("refresh_token"), # since v1.7
347+
user_token_expires_in=installer.get("expires_in"), # since v1.7
344348
incoming_webhook_url=incoming_webhook.get("url"),
345349
incoming_webhook_channel=incoming_webhook.get("channel"),
346350
incoming_webhook_channel_id=incoming_webhook.get("channel_id"),

slack_bolt/oauth/async_oauth_settings.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class AsyncOAuthSettings:
4141
# Installation Management
4242
installation_store: AsyncInstallationStore
4343
installation_store_bot_only: bool
44+
token_rotation_expiration_minutes: int
4445
authorize: AsyncAuthorize
4546
# state parameter related configurations
4647
state_store: AsyncOAuthStateStore
@@ -73,6 +74,7 @@ def __init__(
7374
# Installation Management
7475
installation_store: Optional[AsyncInstallationStore] = None,
7576
installation_store_bot_only: bool = False,
77+
token_rotation_expiration_minutes: int = 120,
7678
# state parameter related configurations
7779
state_store: Optional[AsyncOAuthStateStore] = None,
7880
state_cookie_name: str = OAuthStateUtils.default_cookie_name,
@@ -140,8 +142,12 @@ def __init__(
140142
installation_store or get_or_create_default_installation_store(client_id)
141143
)
142144
self.installation_store_bot_only = installation_store_bot_only
145+
self.token_rotation_expiration_minutes = token_rotation_expiration_minutes
143146
self.authorize = AsyncInstallationStoreAuthorize(
144147
logger=logger,
148+
client_id=self.client_id,
149+
client_secret=self.client_secret,
150+
token_rotation_expiration_minutes=self.token_rotation_expiration_minutes,
145151
installation_store=self.installation_store,
146152
bot_only=self.installation_store_bot_only,
147153
)

0 commit comments

Comments
 (0)