Skip to content

Commit 5ecf7e3

Browse files
authored
Improve the built-in authorize for better support of user-scope only installations (#576)
1 parent 0ee6bb7 commit 5ecf7e3

File tree

7 files changed

+529
-101
lines changed

7 files changed

+529
-101
lines changed

slack_bolt/app/app.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ def message_hello(message, say):
231231
client_secret=settings.client_secret if settings is not None else None,
232232
logger=self._framework_logger,
233233
bot_only=installation_store_bot_only,
234+
client=self._client, # for proxy use cases etc.
234235
)
235236

236237
self._oauth_flow: Optional[OAuthFlow] = None

slack_bolt/app/async_app.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ async def message_hello(message, say): # async function
239239
client_secret=settings.client_secret if settings is not None else None,
240240
logger=self._framework_logger,
241241
bot_only=installation_store_bot_only,
242+
client=self._async_client, # for proxy use cases etc.
242243
)
243244

244245
self._async_oauth_flow: Optional[AsyncOAuthFlow] = None

slack_bolt/authorization/async_authorize.py

Lines changed: 75 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
AsyncInstallationStore,
99
)
1010
from slack_sdk.oauth.token_rotation.async_rotator import AsyncTokenRotator
11+
from slack_sdk.web.async_client import AsyncWebClient
1112

1213
from slack_bolt.authorization.async_authorize_args import AsyncAuthorizeArgs
1314
from slack_bolt.authorization import AuthorizeResult
@@ -124,6 +125,7 @@ def __init__(
124125
# use only InstallationStore#find_bot(enterprise_id, team_id)
125126
bot_only: bool = False,
126127
cache_enabled: bool = False,
128+
client: Optional[AsyncWebClient] = None,
127129
):
128130
self.logger = logger
129131
self.installation_store = installation_store
@@ -136,6 +138,7 @@ def __init__(
136138
self.token_rotator = AsyncTokenRotator(
137139
client_id=client_id,
138140
client_secret=client_secret,
141+
client=client,
139142
)
140143
else:
141144
self.token_rotator = None
@@ -168,65 +171,78 @@ async def __call__(
168171
try:
169172
# Note that this is the latest information for the org/workspace.
170173
# The installer may not be the user associated with this incoming request.
171-
installation: Optional[
174+
latest_installation: Optional[
172175
Installation
173176
] = await self.installation_store.async_find_installation(
174177
enterprise_id=enterprise_id,
175178
team_id=team_id,
176179
is_enterprise_install=context.is_enterprise_install,
177180
)
178-
179-
if installation is not None:
180-
if installation.user_id != user_id:
181+
# If the user_token in the latest_installation is not for the user associated with this request,
182+
# we'll fetch a different installation for the user below
183+
# The example use cases are:
184+
# - The app's installation requires both bot and user tokens
185+
# - The app has two installation paths 1) bot installation 2) individual user authorization
186+
this_user_installation: Optional[Installation] = None
187+
188+
if latest_installation is not None:
189+
# Save the latest bot token
190+
bot_token = latest_installation.bot_token # this still can be None
191+
user_token = (
192+
latest_installation.user_token
193+
) # this still can be None
194+
195+
if latest_installation.user_id != user_id:
181196
# First off, remove the user token as the installer is a different user
182-
installation.user_token = None
183-
installation.user_scopes = []
197+
latest_installation.user_token = None
198+
latest_installation.user_scopes = []
184199

185200
# try to fetch the request user's installation
186201
# to reflect the user's access token if exists
187-
user_installation = (
202+
this_user_installation = (
188203
await self.installation_store.async_find_installation(
189204
enterprise_id=enterprise_id,
190205
team_id=team_id,
191206
user_id=user_id,
192207
is_enterprise_install=context.is_enterprise_install,
193208
)
194209
)
195-
if user_installation is not None:
196-
# Overwrite the installation with the one for this user
197-
installation = user_installation
198-
199-
bot_token, user_token = (
200-
installation.bot_token,
201-
installation.user_token,
202-
)
203-
204-
if (
205-
installation.user_refresh_token is not None
206-
or installation.bot_refresh_token is not None
207-
):
208-
if self.token_rotator is None:
209-
raise BoltError(self._config_error_message)
210-
refreshed = await self.token_rotator.perform_token_rotation(
211-
installation=installation,
212-
minutes_before_expiration=self.token_rotation_expiration_minutes,
213-
)
214-
if refreshed is not None:
215-
await self.installation_store.async_save(refreshed)
216-
bot_token, user_token = (
217-
refreshed.bot_token,
218-
refreshed.user_token,
210+
if this_user_installation is not None:
211+
user_token = this_user_installation.user_token
212+
if latest_installation.bot_token is None:
213+
# If latest_installation has a bot token, we never overwrite the value
214+
bot_token = this_user_installation.bot_token
215+
216+
# If token rotation is enabled, running rotation may be needed here
217+
refreshed = await self._rotate_and_save_tokens_if_necessary(
218+
this_user_installation
219219
)
220+
if refreshed is not None:
221+
user_token = refreshed.user_token
222+
if latest_installation.bot_token is None:
223+
# If latest_installation has a bot token, we never overwrite the value
224+
bot_token = refreshed.bot_token
225+
226+
# If token rotation is enabled, running rotation may be needed here
227+
refreshed = await self._rotate_and_save_tokens_if_necessary(
228+
latest_installation
229+
)
230+
if refreshed is not None:
231+
bot_token = refreshed.bot_token
232+
if this_user_installation is None:
233+
# Only when we don't have `this_user_installation` here,
234+
# the `user_token` is for the user associated with this request
235+
user_token = refreshed.user_token
220236

221237
except NotImplementedError as _:
222238
self.find_installation_available = False
223239

224240
if (
225-
# If you intentionally use only find_bot / delete_bot,
241+
# If you intentionally use only `find_bot` / `delete_bot`,
226242
self.bot_only
227-
# If find_installation method is not available,
243+
# If the `find_installation` method is not available,
228244
or not self.find_installation_available
229-
# If find_installation did not return data and find_bot method is available,
245+
# If the `find_installation` method did not return data and find_bot method is available,
230246
or (
231247
self.find_bot_available is True
232248
and bot_token is None
@@ -294,3 +310,28 @@ def _debug_log_for_not_found(
294310
"No installation data found "
295311
f"for enterprise_id: {enterprise_id} team_id: {team_id}"
296312
)
313+
314+
async def _rotate_and_save_tokens_if_necessary(
315+
self, installation: Optional[Installation]
316+
) -> Optional[Installation]:
317+
if installation is None or (
318+
installation.user_refresh_token is None
319+
and installation.bot_refresh_token is None
320+
):
321+
# No need to rotate tokens
322+
return None
323+
324+
if self.token_rotator is None:
325+
# Token rotation is required but this Bolt app is not properly configured
326+
raise BoltError(self._config_error_message)
327+
328+
refreshed: Optional[
329+
Installation
330+
] = await self.token_rotator.perform_token_rotation(
331+
installation=installation,
332+
minutes_before_expiration=self.token_rotation_expiration_minutes,
333+
)
334+
if refreshed is not None:
335+
# Save the refreshed data in database for following requests
336+
await self.installation_store.async_save(refreshed)
337+
return refreshed

slack_bolt/authorization/authorize.py

Lines changed: 83 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44

55
from slack_sdk.errors import SlackApiError
66
from slack_sdk.oauth import InstallationStore
7-
from slack_sdk.oauth.installation_store import Bot
7+
from slack_sdk.oauth.installation_store.models.bot import Bot
88
from slack_sdk.oauth.installation_store.models.installation import Installation
99
from slack_sdk.oauth.token_rotation.rotator import TokenRotator
10+
from slack_sdk.web import WebClient
1011

1112
from slack_bolt.authorization.authorize_args import AuthorizeArgs
1213
from slack_bolt.authorization.authorize_result import AuthorizeResult
@@ -33,8 +34,8 @@ def __call__(
3334

3435

3536
class CallableAuthorize(Authorize):
36-
"""When you pass the authorize argument in AsyncApp constructor,
37-
This authorize implementation will be used.
37+
"""When you pass the `authorize` argument in AsyncApp constructor,
38+
This `authorize` implementation will be used.
3839
"""
3940

4041
def __init__(
@@ -102,9 +103,9 @@ def __call__(
102103

103104

104105
class InstallationStoreAuthorize(Authorize):
105-
"""If you use the OAuth flow settings, this authorize implementation will be used.
106+
"""If you use the OAuth flow settings, this `authorize` implementation will be used.
106107
As long as your own InstallationStore (or the built-in ones) works as you expect,
107-
you can expect that the authorize layer should work for you without any customization.
108+
you can expect that the `authorize` layer should work for you without any customization.
108109
"""
109110

110111
authorize_result_cache: Dict[str, AuthorizeResult]
@@ -129,6 +130,7 @@ def __init__(
129130
# use only InstallationStore#find_bot(enterprise_id, team_id)
130131
bot_only: bool = False,
131132
cache_enabled: bool = False,
133+
client: Optional[WebClient] = None,
132134
):
133135
self.logger = logger
134136
self.installation_store = installation_store
@@ -143,6 +145,7 @@ def __init__(
143145
self.token_rotator = TokenRotator(
144146
client_id=client_id,
145147
client_secret=client_secret,
148+
client=client,
146149
)
147150
else:
148151
self.token_rotator = None
@@ -168,61 +171,78 @@ def __call__(
168171
try:
169172
# Note that this is the latest information for the org/workspace.
170173
# The installer may not be the user associated with this incoming request.
171-
installation: Optional[
174+
latest_installation: Optional[
172175
Installation
173176
] = self.installation_store.find_installation(
174177
enterprise_id=enterprise_id,
175178
team_id=team_id,
176179
is_enterprise_install=context.is_enterprise_install,
177180
)
178-
if installation is not None:
179-
if installation.user_id != user_id:
181+
# If the user_token in the latest_installation is not for the user associated with this request,
182+
# we'll fetch a different installation for the user below.
183+
# The example use cases are:
184+
# - The app's installation requires both bot and user tokens
185+
# - The app has two installation paths 1) bot installation 2) individual user authorization
186+
this_user_installation: Optional[Installation] = None
187+
188+
if latest_installation is not None:
189+
# Save the latest bot token
190+
bot_token = latest_installation.bot_token # this still can be None
191+
user_token = (
192+
latest_installation.user_token
193+
) # this still can be None
194+
195+
if latest_installation.user_id != user_id:
180196
# First off, remove the user token as the installer is a different user
181-
installation.user_token = None
182-
installation.user_scopes = []
197+
latest_installation.user_token = None
198+
latest_installation.user_scopes = []
183199

184200
# try to fetch the request user's installation
185201
# to reflect the user's access token if exists
186-
user_installation = self.installation_store.find_installation(
187-
enterprise_id=enterprise_id,
188-
team_id=team_id,
189-
user_id=user_id,
190-
is_enterprise_install=context.is_enterprise_install,
202+
this_user_installation = (
203+
self.installation_store.find_installation(
204+
enterprise_id=enterprise_id,
205+
team_id=team_id,
206+
user_id=user_id,
207+
is_enterprise_install=context.is_enterprise_install,
208+
)
191209
)
192-
if user_installation is not None:
193-
# Overwrite the installation with the one for this user
194-
installation = user_installation
210+
if this_user_installation is not None:
211+
user_token = this_user_installation.user_token
212+
if latest_installation.bot_token is None:
213+
# If latest_installation has a bot token, we never overwrite the value
214+
bot_token = this_user_installation.bot_token
195215

196-
bot_token, user_token = (
197-
installation.bot_token,
198-
installation.user_token,
199-
)
200-
if (
201-
installation.user_refresh_token is not None
202-
or installation.bot_refresh_token is not None
203-
):
204-
if self.token_rotator is None:
205-
raise BoltError(self._config_error_message)
206-
refreshed = self.token_rotator.perform_token_rotation(
207-
installation=installation,
208-
minutes_before_expiration=self.token_rotation_expiration_minutes,
209-
)
210-
if refreshed is not None:
211-
self.installation_store.save(refreshed)
212-
bot_token, user_token = (
213-
refreshed.bot_token,
214-
refreshed.user_token,
216+
# If token rotation is enabled, running rotation may be needed here
217+
refreshed = self._rotate_and_save_tokens_if_necessary(
218+
this_user_installation
215219
)
220+
if refreshed is not None:
221+
user_token = refreshed.user_token
222+
if latest_installation.bot_token is None:
223+
# If latest_installation has a bot token, we never overwrite the value
224+
bot_token = refreshed.bot_token
225+
226+
# If token rotation is enabled, running rotation may be needed here
227+
refreshed = self._rotate_and_save_tokens_if_necessary(
228+
latest_installation
229+
)
230+
if refreshed is not None:
231+
bot_token = refreshed.bot_token
232+
if this_user_installation is None:
233+
# Only when we don't have `this_user_installation` here,
234+
# the `user_token` is for the user associated with this request
235+
user_token = refreshed.user_token
216236

217237
except NotImplementedError as _:
218238
self.find_installation_available = False
219239

220240
if (
221-
# If you intentionally use only find_bot / delete_bot,
241+
# If you intentionally use only `find_bot` / `delete_bot`,
222242
self.bot_only
223-
# If find_installation method is not available,
243+
# If the `find_installation` method is not available,
224244
or not self.find_installation_available
225-
# If find_installation did not return data and find_bot method is available,
245+
# If the `find_installation` method did not return data and find_bot method is available,
226246
or (
227247
self.find_bot_available is True
228248
and bot_token is None
@@ -290,3 +310,26 @@ def _debug_log_for_not_found(
290310
"No installation data found "
291311
f"for enterprise_id: {enterprise_id} team_id: {team_id}"
292312
)
313+
314+
def _rotate_and_save_tokens_if_necessary(
315+
self, installation: Optional[Installation]
316+
) -> Optional[Installation]:
317+
if installation is None or (
318+
installation.user_refresh_token is None
319+
and installation.bot_refresh_token is None
320+
):
321+
# No need to rotate tokens
322+
return None
323+
324+
if self.token_rotator is None:
325+
# Token rotation is required but this Bolt app is not properly configured
326+
raise BoltError(self._config_error_message)
327+
328+
refreshed: Optional[Installation] = self.token_rotator.perform_token_rotation(
329+
installation=installation,
330+
minutes_before_expiration=self.token_rotation_expiration_minutes,
331+
)
332+
if refreshed is not None:
333+
# Save the refreshed data in database for following requests
334+
self.installation_store.save(refreshed)
335+
return refreshed

0 commit comments

Comments
 (0)