Skip to content
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

Map a Django User to the slack installation (SlackInstallation) #1166

Closed
NathanSmeltzer opened this issue Sep 24, 2024 · 2 comments
Closed
Labels
area:adapter question Further information is requested

Comments

@NathanSmeltzer
Copy link

NathanSmeltzer commented Sep 24, 2024

My use case is a SaaS product built with Django where a user can integrate with Slack to receive product notifications. I wish to have a Django User instance as a ForeignKey field on the SlackInstallation model.

Reproducible in:

slack_bolt==1.20.1
slack_sdk==3.32.0
django==4.2.11
Python 3.10.14

OS info

Ubuntu 22.04.3

Steps to reproduce:

  1. Use the django example with the oauth_app django app
  2. Split the slack_oauth_handler into 2 separate handlers (slack_oauth_redirect_handler and slack_oauth_install_handler) per @WilliamBergamin's instructions in issue 1111

Here's the oauth_app/urls.py module.

from django.urls import path
import logging

from django.http import HttpRequest
from django.views.decorators.csrf import csrf_exempt

from slack_bolt.adapter.django import SlackRequestHandler

from . import views
from .slack_listeners import app

handler = SlackRequestHandler(app=app)


@csrf_exempt
def slack_events_handler(request: HttpRequest):
    return handler.handle(request)

def slack_oauth_install_handler(request: HttpRequest):
    """After clicking the initial slack install button and it goes to /slack/install
    We can't get the slack install instance here since they haven't connected yet"""
    user_rand_id = "test_rand_id"
    django_response = handler.handle(request)
    print(
        f"django_response: {django_response} of type: {type(django_response)} with dir {dir(django_response)}"
    )
    logging.info(
        f"django_response: {django_response} of type: {type(django_response)} with dir {dir(django_response)}"
    )
    django_response.set_cookie("user_rand_id", user_rand_id)
    print(
        f"inside slack_oauth_handler_install with user_rand_id: {user_rand_id}"
    )
    return django_response


def slack_oauth_redirect_handler(request: HttpRequest):
    """After the user authorizes the app, they are redirected to /slack/oauth_redirect"""
    # todo: https://github.com/slackapi/bolt-python/issues/1111 (should contain the slack user info)

    user_rand_id = request.COOKIES.get("user_rand_id")
    print(
        f"inside slack_oauth_handler_redirect with user_rand_id: {user_rand_id}"
    )
    logging.info(
        f"inside slack_oauth_handler_redirect with user_rand_id: {user_rand_id}"
    )
    print(f"request dir: {dir(request)}")
    print(f"request.COOKIES: {request.COOKIES}")
    print(f"request.GET.items(): {list(request.GET.items())}")
    print(f"request.META.items(): {request.META.items()}")

    return handler.handle(request)

urlpatterns = [
    path("", views.home, name="home"),
    path("slack/events", slack_events_handler, name="handle"),
    path("slack/install", slack_oauth_install_handler, name="install"),
    path("slack/oauth_redirect", slack_oauth_redirect_handler, name="oauth_redirect"),
]

Note: user_rand_id is just a dummy string in the above code. In my actual project, it's used to look up the Django User instance for updating the SlackInstallation model.

Expected result:

The slack installation's info to be available in the slack_oauth_redirect_handler.
Maybe I'm missing where to look for it, but I can't find anything regarding the team_id, slack channel, or any other install info. I've tried cookies, and Django's request.GET and request.META

Other thoughts

Maybe the Django example could be updated with guidance on how to link a Django User instance to the slack installation. There are a few other issues that ask about similar use cases. bolt-js has an open issue to add documentation for this.

If this approach isn't correct, I'm open to other suggestions. Maybe linking the Django User to the SlackInstallation isn't necessary.

@seratch seratch added question Further information is requested area:adapter labels Sep 24, 2024
@seratch
Copy link
Member

seratch commented Sep 24, 2024

Hi @NathanSmeltzer, thank you for asking the question! There are two approaches to customize the OAuth callback handler.

1. Callback Options

The first one is to use callback_options in oauth_settings:

def success(args: SuccessArgs) -> BoltResponse:
    # this args object provides the HTTP request and installation data
    # https://tools.slack.dev/bolt-python/api-docs/slack_bolt/oauth/callback_options.html#slack_bolt.oauth.callback_options.SuccessArgs
    # you can extract your custom cookie values from request.headers
    assert args.request is not None
    return BoltResponse(
        status=200,  # you can redirect users too
        body="Your own response to end-users here"
    )

app = App(
    # others ...
    oauth_settings=OAuthSettings(
        callback_options=CallbackOptions(success=success),
        # others ..
    ),
)

Refer to the following resources for more details:

2. OAuthFlow Inheritance

The second is to inherit OAuthFlow object and override store_installation method to save the installation along with the relavant property in your user object.

Lastly, regarding the documentation suggestion, indeed bolt documents can be more specific about advanced use cases. This can be improved in the future. As for the Django's user object example, I'd like to hold off adding such this time because how a developer handles the OAuth flow could depend on their requirements and there is no universal user table data structure. Once you've completed this task and sharing your knowledge here would be greatly appreciated.

I hope this was helpful to you.

@NathanSmeltzer
Copy link
Author

Thank you @seratch!
Here's the 1. Callback Options code that's working for my Django project:

Note: args.request.installation has the team_id and other info inside the success callback

slack_app/urls.py:

@csrf_exempt
def slack_events_handler(request: HttpRequest):
    return handler.handle(request)


def slack_oauth_install_handler(request: HttpRequest):
    """After clicking the initial slack install button and it goes to /slack/install
    We can't get the slack install instance here since they haven't connected yet"""
    user_rand_id = request.user.rand_id
    django_response = handler.handle(request)
    django_response.set_cookie("user_rand_id", user_rand_id)
    logger.debug(
        f"inside slack_oauth_handler_install with user_rand_id: {user_rand_id}"
    )
    return django_response


def slack_oauth_redirect_handler(request: HttpRequest):
    """After the user authorizes the app, they are redirected to /slack/oauth_redirect"""
    logger.debug("inside slack_oauth_redirect_handler")
    return handler.handle(request)


urlpatterns = [
    path("events/", slack_events_handler, name="handle"),
    # can't have a forward slash for oauth to work for installing
    # todo: how to not have to override the url comparison in the adapter.django.handler
    path("install", slack_oauth_install_handler, name="install"),
    path("oauth_redirect/", slack_oauth_redirect_handler, name="oauth_redirect"),
]

slack_app/slack_listeners.py:

client_id, client_secret, signing_secret, scopes, user_scopes = (
    config("SLACK_CLIENT_ID"),
    config("SLACK_CLIENT_SECRET"),
    config("SLACK_SIGNING_SECRET"),
    config("SLACK_SCOPES", "commands").split(","),
    config("SLACK_USER_SCOPES", "search:read").split(","),
)


def failure(args: FailureArgs) -> BoltResponse:
    # If you reuse the default handler, you can use args.default.success/failure
    # https://github.com/slackapi/bolt-python/issues/328#issuecomment-835225718
    logger.debug("inside failure")
    return args.default.failure(args)


def success(args: SuccessArgs) -> BoltResponse:
    """
    Should have the HTTP request and user information data:
    https://github.com/slackapi/bolt-python/issues/1166#issuecomment-2372565630
    # https://tools.slack.dev/bolt-python/api-docs/slack_bolt/oauth/callback_options.html#slack_bolt.oauth.callback_options.SuccessArgs
    # you can extract your custom cookie values from request.headers
    Link the user to the installation
    https://github.com/slackapi/bolt-python/issues/328#issuecomment-835225718
    """
    url = config("APP_URL")
    redirect_headers = {
        "Location": url,
        "Content-Type": "text/html; charset=utf-8",
    }
    body = "<html><body>Redirecting...</body></html>"
    request = args.request  # BoltRequest
    headers = request.headers
    installation = args.installation
    if not installation:
        # todo: how to handle
        logger.error("No installation found")
        return BoltResponse(
            status=302,
            headers=redirect_headers,
            body=body,
        )

    cookie_string = headers["cookie"][0]
    match = re.search(r"user_rand_id=([^;]+)", cookie_string)
    if match:
        user_rand_id = match.group(1)
    else:
        # todo: how to handle
        logger.error("No user_rand_id found in cookie")
        return BoltResponse(
            status=302,
            headers=redirect_headers,
            body=body,
        )

    user = User.objects.filter(rand_id=user_rand_id).first()
    if not user or not installation:
        # todo: how to handle
        logger.error(f"user: {user} or installation: {installation} not found")
        return BoltResponse(
            status=302,
            headers=redirect_headers,
            body=body,
        )

    team_id = installation.team_id
    team_name = installation.team_name

    slack_installation = SlackInstallation.objects.all().filter(team_id=team_id).first()
    if not slack_installation:
        # todo: how to handle
        logger.error(f"No slack_installation found for team_id: {team_id}")
        return BoltResponse(
            status=302,
            headers=redirect_headers,
            body=body,
        )
    # from models.py SlackInstallation.django_user = models.ForeignKey(your_user_model)
    slack_installation.django_user = user
    slack_installation.save()

    return BoltResponse(
        status=302,
        headers=redirect_headers,
        body=body,
    )


oauth_settings = OAuthSettings(
    client_id=client_id,
    client_secret=client_secret,
    scopes=scopes,
    user_scopes=user_scopes,
    # If you want to test token rotation, enabling the following line will make it easy
    # token_rotation_expiration_minutes=1000000,
    installation_store=DjangoInstallationStore(
        client_id=client_id,
        logger=logger,
    ),
    state_store=DjangoOAuthStateStore(
        expiration_seconds=120,
        logger=logger,
    ),
    callback_options=CallbackOptions(success=success, failure=failure),
)
app = App(
    signing_secret=signing_secret,
    oauth_settings=oauth_settings,
)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area:adapter question Further information is requested
Projects
None yet
Development

No branches or pull requests

2 participants