Files
LangBot/src/langbot/pkg/api/http/service/slack_setup.py
T

354 lines
13 KiB
Python

"""Short-lived Slack app provisioning and OAuth handoff sessions."""
import asyncio
import hashlib
import hmac
import ipaddress
import json
import re
import secrets
import time
from urllib.parse import urlencode, urlsplit
import aiohttp
from langbot.pkg.utils import httpclient
class SlackSetupError(ValueError):
pass
def callback_url(webhook_url: str, bot_uuid: str) -> str:
parsed = urlsplit(webhook_url)
suffix = f'/bots/{bot_uuid}'
if (
parsed.scheme != 'https'
or not parsed.hostname
or parsed.username
or parsed.password
or parsed.query
or parsed.fragment
or not parsed.path.endswith(suffix)
or parsed.hostname.lower() == 'localhost'
):
raise SlackSetupError('invalid_webhook_url')
try:
if not ipaddress.ip_address(parsed.hostname).is_global:
raise SlackSetupError('invalid_webhook_url')
except ValueError as exc:
if isinstance(exc, SlackSetupError):
raise
return webhook_url[: -len(suffix)] + '/api/v1/platform/adapters/slack/setup/callback'
def manifest(name: str, webhook_url: str, redirect_url: str, *, events: bool, socket_mode=False) -> dict:
settings = {'socket_mode_enabled': socket_mode}
if events:
settings['event_subscriptions'] = {
**({} if socket_mode else {'request_url': webhook_url}),
'bot_events': [
'app_mention',
'message.im',
'message.channels',
'message.groups',
'message.mpim',
'reaction_added',
'reaction_removed',
'member_joined_channel',
'member_left_channel',
'channel_rename',
'group_rename',
'channel_archive',
'channel_unarchive',
'group_archive',
'group_unarchive',
'app_home_opened',
'app_uninstalled',
'tokens_revoked',
],
}
settings['interactivity'] = {
'is_enabled': True,
**({} if socket_mode else {'request_url': webhook_url}),
}
return {
'display_information': {'name': name},
'features': {
'bot_user': {'display_name': name, 'always_online': False},
'app_home': {'messages_tab_enabled': True, 'messages_tab_read_only_enabled': False},
},
'oauth_config': {
'redirect_urls': [redirect_url],
'scopes': {
'bot': [
'app_mentions:read',
'reactions:read',
'chat:write',
'channels:history',
'channels:read',
'groups:history',
'groups:read',
'im:history',
'im:read',
'im:write',
'mpim:history',
'mpim:read',
'users:read',
'files:read',
'files:write',
]
},
},
'settings': settings,
}
class SlackSetupService:
TTL = 900
def __init__(self, ap):
self.ap = ap
self.sessions = {}
@staticmethod
def scope(context):
return (context.instance_uuid, context.workspace_uuid, context.placement_generation, context.principal)
def owned(self, context, session_id):
session = self.sessions.get(session_id)
if not session or session['scope'] != self.scope(context):
raise SlackSetupError('session_not_found')
return session
def discard(self, session_id):
session = self.sessions.pop(session_id, None)
if session:
session['timer'].cancel()
task = session.get('task')
if task and not task.done():
task.cancel()
session.clear()
async def api(self, method, token=None, **params):
headers = {'Authorization': f'Bearer {token}'} if token else {}
try:
async with httpclient.get_session().post(
f'https://slack.com/api/{method}',
data=params,
headers=headers,
timeout=aiohttp.ClientTimeout(total=25),
allow_redirects=False,
) as response:
if response.status != 200:
raise SlackSetupError(f'slack_http_{response.status}')
data = await response.json()
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
raise SlackSetupError('slack_connection_failed') from exc
if not data.get('ok'):
# Never surface arbitrary upstream text or credentials in errors.
code = str(data.get('error', 'unknown_error'))
if not re.fullmatch(r'[a-z_]{1,80}', code):
code = 'unknown_error'
raise SlackSetupError(code)
return data
async def start(
self,
context,
*,
bot_uuid,
access_token,
refresh_token,
webhook_url='',
name='LangBot',
socket_mode=False,
redirect_url='',
):
if not all(isinstance(value, str) for value in (bot_uuid, access_token, refresh_token, webhook_url, name)):
raise SlackSetupError('invalid_input')
access_token, refresh_token, webhook_url, name = (
access_token.strip(),
refresh_token.strip(),
webhook_url.strip(),
name.strip(),
)
if not access_token or not refresh_token or not 1 <= len(name) <= 35:
raise SlackSetupError('invalid_input')
if not isinstance(socket_mode, bool):
raise SlackSetupError('invalid_input')
if socket_mode:
parsed = urlsplit(redirect_url)
if (
not parsed.hostname
or parsed.username
or parsed.password
or parsed.query
or parsed.fragment
or not parsed.path.endswith('/api/v1/platform/adapters/slack/setup/callback')
or not (parsed.scheme == 'https' or (parsed.scheme == 'http' and parsed.hostname == 'localhost'))
):
raise SlackSetupError('invalid_redirect_url')
redirect = redirect_url
else:
redirect = callback_url(webhook_url, bot_uuid)
bot = await self.ap.bot_service.get_bot(context, bot_uuid)
if not bot or bot['adapter'] not in ('slack', 'slack-omni'):
raise SlackSetupError('slack_bot_not_found')
if (
len(self.sessions) >= 100
or sum(s['context'].workspace_uuid == context.workspace_uuid for s in self.sessions.values()) >= 10
):
raise SlackSetupError('too_many_sessions')
if any(s['bot_uuid'] == bot_uuid for s in self.sessions.values()):
raise SlackSetupError('setup_already_active')
sid = secrets.token_urlsafe(32)
session = {
'scope': self.scope(context),
'context': context,
'status': 'creating',
'bot_uuid': bot_uuid,
'redirect_url': redirect,
'webhook_url': webhook_url,
'socket_mode': socket_mode,
'timer': asyncio.get_running_loop().call_later(self.TTL, self.discard, sid),
}
self.sessions[sid] = session
coro = self._create(session, access_token, refresh_token, name, sid)
try:
wrapper = self.ap.task_mgr.create_user_task(
coro,
kind='platform-adapter-credential-exchange',
name=f'slack-setup-{sid}',
label='Slack app setup',
instance_uuid=context.instance_uuid,
workspace_uuid=context.workspace_uuid,
placement_generation=context.placement_generation,
)
session['task'] = wrapper.task
except Exception:
coro.close()
self.discard(sid)
raise SlackSetupError('too_many_sessions') from None
return {'session_id': sid}
async def _create(self, session, access, refresh, name, sid):
async def config_api(method, **params):
nonlocal access, refresh
try:
return await self.api(method, access, **params)
except SlackSetupError as exc:
if str(exc) not in ('token_expired', 'invalid_auth', 'token_revoked'):
raise
rotated = await self.api('tooling.tokens.rotate', refresh_token=refresh)
access, refresh = rotated['token'], rotated['refresh_token']
return await self.api(method, access, **params)
try:
base = manifest(
name, session['webhook_url'], session['redirect_url'], events=False, socket_mode=session['socket_mode']
)
result = await config_api('apps.manifest.create', manifest=json.dumps(base))
session['app_id'] = result['app_id']
session['credentials'] = result['credentials']
# Register the signing secret before Slack validates the final event URL.
complete = manifest(
name, session['webhook_url'], session['redirect_url'], events=True, socket_mode=session['socket_mode']
)
await config_api('apps.manifest.update', app_id=result['app_id'], manifest=json.dumps(complete))
session['authorize_url'] = 'https://slack.com/oauth/v2/authorize?' + urlencode(
{
'client_id': result['credentials']['client_id'],
'scope': ','.join(complete['oauth_config']['scopes']['bot']),
'redirect_uri': session['redirect_url'],
'state': sid,
}
)
session['status'] = 'waiting'
except Exception as exc:
if session:
session['status'] = 'error'
session['error'] = str(exc) if isinstance(exc, SlackSetupError) else 'setup_failed'
session.pop('credentials', None)
finally:
access = refresh = ''
def status(self, context, sid):
session = self.owned(context, sid)
result = {key: session[key] for key in ('status', 'app_id', 'authorize_url', 'error') if key in session}
if session['status'] == 'success':
result['config'] = session['config']
return result
async def finish(self, sid, code, error=None):
session = self.sessions.get(sid)
if not session or session['status'] != 'waiting':
raise SlackSetupError('session_not_found')
session['status'] = 'authorizing'
try:
if error or not code:
raise SlackSetupError('authorization_denied')
context = session['context']
await self.ap.workspace_service.get_execution_binding(
context.workspace_uuid,
expected_generation=context.placement_generation,
)
creds = session['credentials']
result = await self.api(
'oauth.v2.access',
client_id=creds['client_id'],
client_secret=creds['client_secret'],
code=code,
redirect_uri=session['redirect_url'],
)
if result.get('app_id') != session['app_id'] or result.get('token_type') != 'bot':
raise SlackSetupError('unexpected_oauth_response')
session['config'] = {
'bot_token': result['access_token'],
'signing_secret': creds['signing_secret'],
'socket_mode': session['socket_mode'],
}
session['status'] = 'success'
except Exception as exc:
if session:
session['status'] = 'error'
session['error'] = str(exc) if isinstance(exc, SlackSetupError) else 'authorization_failed'
raise SlackSetupError('authorization_failed') from None
finally:
session.pop('credentials', None)
session.pop('authorize_url', None)
async def challenge(self, bot_uuid, request):
session = next((s for s in self.sessions.values() if s['bot_uuid'] == bot_uuid and s.get('credentials')), None)
if not session:
return None
body = await request.get_data()
timestamp = request.headers.get('X-Slack-Request-Timestamp', '')
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
return None
signature = (
'v0='
+ hmac.new(
session['credentials']['signing_secret'].encode(),
b'v0:' + timestamp.encode() + b':' + body,
hashlib.sha256,
).hexdigest()
)
if not hmac.compare_digest(signature, request.headers.get('X-Slack-Signature', '')):
return None
data = await request.get_json(silent=True)
if not isinstance(data, dict) or data.get('type') != 'url_verification':
return None
context = session['context']
await self.ap.workspace_service.get_execution_binding(
context.workspace_uuid,
expected_generation=context.placement_generation,
)
return {'challenge': data.get('challenge', '')}
def get_slack_setup(ap):
if not hasattr(ap, 'slack_setup_service'):
ap.slack_setup_service = SlackSetupService(ap)
return ap.slack_setup_service