apache / superset

Apache Superset is a Data Visualization and Data Exploration Platform
https://superset.apache.org/
Apache License 2.0
62.45k stars 13.73k forks source link

Unable to complete url-redirection, redirection getting stuck after OAuth authentication completion from OKTA #25443

Closed ddhanabalan closed 1 year ago

ddhanabalan commented 1 year ago

How to reproduce the bug

In the OKTA server, i have set the following configurations:

  1. Created a default OKTA server and added this application
  2. Set client-authentication type and created the clientid and clientsecret for this application.
  3. Set grant-type as AuthorizationCode
  4. Set Login-flow to "redirect to app to initiate login".
  5. Added redirect-uri as : https://superset-app-domain/oauth-authorized/okta

I have configured the below code-snipped in superset_config.py.

from flask import redirect, g, flash, request
from flask_appbuilder.security.views import UserDBModelView,AuthDBView
from superset.security import SupersetSecurityManager
from flask_appbuilder.security.views import expose
from flask_appbuilder.security.manager import BaseSecurityManager
from flask_login import login_user, logout_user
from flask_appbuilder.security.manager import AUTH_OAUTH
from dotenv import load_dotenv
from typing import Optional
import os
import logging
import base64

logger = logging.getLogger()
load_dotenv()

# Superset specific config
ROW_LIMIT = 5000
SUPERSET_WORKERS = 1
SUPERSET_WEBSERVER_PORT = os.environ['PORT']
MAPBOX_API_KEY = os.getenv('MAPBOX_API_KEY')
SQLLAB_ASYNC_TIME_LIMIT_SEC = 300
SQLLAB_TIMEOUT = 300
SUPERSET_WEBSERVER_TIMEOUT = 300
PUBLIC_ROLE_LIKE = 'Gamma'
ENABLE_PROXY_FIX = True

# Flask App Builder configuration# Your App secret key

SECRET_KEY='**********************************************************'

# The SQLAlchemy connection string to your database backend
SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_URL')
SQLALCHEMY_TRACK_MODIFICATIONS = True

# Flask-WTF flag for CSRF.
WTF_CSRF_ENABLED = CSRF_ENABLED = True

# ====== Start Okta Login ===========
AUTH_TYPE = AUTH_OAUTH
AUTH_USER_REGISTRATION = True  # allow self-registration (login creates a user)
AUTH_USER_REGISTRATION_ROLE = 'Public'
AUTH_ROLE_ADMIN = 'Public'
AUTH_ROLE_PUBLIC = 'Public'
SESSION_COOKIE_SAMESITE=None
SESSION_COOKIE_HTTPONLY=True 

OKTA_BASE_URL = os.getenv('OKTA_BASE_URL')
OKTA_KEY = os.getenv('OKTA_KEY')
OKTA_SECRET = os.getenv('OKTA_SECRET')

sencode = (OKTA_KEY + ':' + OKTA_SECRET).encode("ascii")
bencode = base64.b64encode(sencode)
base64_string = bencode.decode("ascii")

OAUTH_PROVIDERS = [{
    'name':'okta',
    'token_key': 'code', # Name of the token in the response of access_token_url
    'icon':'fa-circle-o',   # Icon for the provider
    'remote_app': {
        'client_id': OKTA_KEY,  # Client Id (Identify Superset application)
        'client_secret': OKTA_SECRET, # Secret for this Client Id (Identify Superset application)
        'client_kwargs': {
            'scope': 'read profile email openid groups'
        },
        'access_token_method': 'POST',    # HTTP Method to call access_token_url
        'api_base_url': OKTA_BASE_URL+'/oauth2/default/v1/',
        'access_token_url': OKTA_BASE_URL + '/oauth2/default/v1/token',
        'authorize_url': OKTA_BASE_URL + '/oauth2/default/v1/authorize',
        'server_metadata_url': f'{OKTA_BASE_URL}/.well-known/oauth-authorization-server',
        'jwks_uri': OKTA_BASE_URL + '/oauth2/default/v1/keys',
        'access_token_headers':{    # Additional headers for calls to access_token_url
                'Authorization': 'Basic ' + base64_string
        }
    }
}]

class CustomAuthDBView(AuthDBView):
    login_template = 'appbuilder/general/security/login_db.html'

    @expose('/login/', methods=['GET', 'POST'])
    def login(self):
        redirect_url = "https://superset-app-domain/superset/welcome"
        if request.args.get('username') is not None:
            user = self.appbuilder.sm.find_user(username=request.args.get('username'))
            login_user(user, remember=False)
            return redirect(redirect_url)
        elif g.user is not None and g.user.is_authenticated():
            return redirect(redirect_url)
        else:
            flash('Unable to auto login', 'warning')
            return super(CustomAuthDBView,self).login()

class CustomSsoSecurityManager(SupersetSecurityManager):
    authdbview = CustomAuthDBView
    def oauth_user_info(self, provider, response=None):
        logger.log('oauth2 provider: {0}'.format(provider))
        logger.log('response: {0}'.format(response))
        if provider == "okta":
            me = self.appbuilder.sm.oauth_remotes[provider].get("userinfo")
            data = me.json()
            return {
                "username": data.get("sub", ""),
                "first_name": "",
                "last_name": "",
                "email": data.get("email", ""),
                "role_keys": data.get("groups", []),
            }

    def auth_user_oauth(self, userinfo):
        user = super(CustomSsoSecurityManager, self).auth_user_oauth(userinfo)
        logger.debug(' Update <User: %s> role to %s', user.username)
        self.update_user(user)  # update user roles
        return user

CUSTOM_SECURITY_MANAGER = CustomSsoSecurityManager
# ====== End Okta Login ============

After all these configurations, executing the below commands to configure and run superset in Heroku.

  1. superset db upgrade
  2. superset fab create-admin
  3. superset init
  4. superset run

Expected results

When clicked login, user is directed to OKTA sign-in page and user is able to complete the authentication/authorization process and user is redirected to superset welcome screen.

Actual results

The redirect is getting stuck in the middle. After authorization is complete, the app-redirect-url is getting stuck at https://superset-app-domain/login/okta?next=

as there is no url parameter passed.

Screenshots

Screenshot from 2023-09-28 15-36-59

Environment

mdeshmu commented 1 year ago

Any hints in the logs?

ddhanabalan commented 1 year ago

The CustomSsoSecurityManager was not overridden. Replaced the OKTA code with this:

# ====== Start Okta Login ===========
ENABLE_PROXY_FIX = True
AUTH_TYPE = AUTH_OAUTH
AUTH_USER_REGISTRATION = True  # allow self-registration (login creates a user)
AUTH_USER_REGISTRATION_ROLE = 'Public'
AUTH_ROLE_ADMIN = 'Public'
AUTH_ROLE_PUBLIC = 'Public'

SESSION_COOKIE_SAMESITE='None'
SESSION_COOKIE_HTTPONLY=False
SESSION_COOKIE_SECURE = True

OKTA_BASE_URL = get_env_variable('OKTA_BASE_URL')
OKTA_KEY = get_env_variable('OKTA_KEY')
OKTA_SECRET = get_env_variable('OKTA_SECRET')

sencode = (OKTA_KEY + ':' + OKTA_SECRET).encode("ascii")
bencode = base64.b64encode(sencode)
base64_string = bencode.decode("ascii")

OAUTH_PROVIDERS = [{
    'name':'okta',
    'token_key': 'access_token', # Name of the token in the response of access_token_url
    'icon':'fa-circle-o',   # Icon for the provider
    'remote_app': {
        'client_id': OKTA_KEY,  # Client Id (Identify Superset application)
        'client_secret': OKTA_SECRET, # Secret for this Client Id (Identify Superset application)
        'client_kwargs': {
            'scope': 'read profile email openid groups'
        },
        'access_token_method': 'POST',    # HTTP Method to call access_token_url
        'api_base_url': OKTA_BASE_URL+'/oauth2/default/v1/',
        'access_token_url': OKTA_BASE_URL + '/oauth2/default/v1/token',
        'authorize_url': OKTA_BASE_URL + '/oauth2/default/v1/authorize',
        'server_metadata_url': f'{OKTA_BASE_URL}/.well-known/openid-configuration',
        'jwks_uri': OKTA_BASE_URL + '/oauth2/default/v1/keys',
        'access_token_headers':{    # Additional headers for calls to access_token_url
                'Authorization': 'Basic ' + base64_string
        }
    }
}]

class CustomAuthOAuthView(AuthOAuthView):
    @expose("/login/")
    @expose("/login/<provider>")
    @expose("/login/<provider>/<register>")
    def login(self, provider: Optional[str] = None) -> WerkzeugResponse:
        logger.debug("Provider: {0}".format(provider))
        if g.user is not None and g.user.is_authenticated:
            logger.debug("Already authenticated {0}".format(g.user))
            return redirect(self.appbuilder.get_url_for_index)

        if provider is None:
            if len(self.appbuilder.sm.oauth_providers) > 1:
                return self.render_template(
                    self.login_template,
                    providers=self.appbuilder.sm.oauth_providers,
                    title=self.title,
                    appbuilder=self.appbuilder,
                )
            else:
                provider = self.appbuilder.sm.oauth_providers[0]["name"]

        logger.debug("Going to call authorize for: {0}".format(provider))
        state = jwt.encode(
            request.args.to_dict(flat=False),
            self.appbuilder.app.config["SECRET_KEY"],
            algorithm="HS256",
        )
        try:
            if provider == "okta":
                return self.appbuilder.sm.oauth_remotes[provider].authorize_redirect(
                    redirect_uri=url_for(
                        ".oauth_authorized",
                        provider=provider,
                        _external=True,
                        state=state,
                    )
                )
            else:
                return self.appbuilder.sm.oauth_remotes[provider].authorize_redirect(
                    redirect_uri=url_for(
                        ".oauth_authorized", provider=provider, _external=True
                    ),
                    state=state.decode("ascii") if isinstance(state, bytes) else state,
                )
        except Exception as e:
            logger.error("Error on OAuth authorize: {0}".format(e))
            flash(as_unicode(self.invalid_login_message), "warning")
            return redirect(self.appbuilder.get_url_for_index)

    @expose("/oauth-authorized/okta")
    def oauth_authorized(self, provider):
        logger.debug("Authorized init")
        resp = self.appbuilder.sm.oauth_remotes[provider].authorized_response()
        if resp is None:
            flash(u"You denied the request to sign in.", "warning")
            return redirect("login")
        logger.debug("OAUTH Authorized resp: {0}".format(resp))
        # Retrieves specific user info from the provider
        try:
            self.appbuilder.sm.set_oauth_session(provider, resp)
            userinfo = self.appbuilder.sm.oauth_user_info(provider, resp)
        except Exception as e:
            logger.error("Error returning OAuth user info: {0}".format(e))
            user = None
        else:
            logger.debug("User info retrieved from {0}: {1}".format(provider, userinfo))
            # User email is not whitelisted
            if provider in self.appbuilder.sm.oauth_whitelists:
                whitelist = self.appbuilder.sm.oauth_whitelists[provider]
                allow = False
                for e in whitelist:
                    if re.search(e, userinfo["email"]):
                        allow = True
                        break
                if not allow:
                    flash(u"You are not authorized.", "warning")
                    return redirect("login")
            else:
                logger.debug("No whitelist for OAuth provider")
            user = self.appbuilder.sm.auth_user_oauth(userinfo)

        if user is None:
            flash(as_unicode(self.invalid_login_message), "warning")
            return redirect("login")
        else:
            login_user(user)
            try:
                state = jwt.decode(
                    request.args["code"],
                    self.appbuilder.app.config["SECRET_KEY"],
                    algorithms=["HS256"],
                )
            except jwt.InvalidTokenError:
                raise Exception("State signature is not valid!")

            try:
                next_url = state["next"][0] or self.appbuilder.get_url_for_index
                print("next_url: " + next_url)
                if (len(next_url) <= 1):
                    next_url = "/superset/welcome/"
            except (KeyError, IndexError):
                next_url = self.appbuilder.get_url_for_index
                print("next_url: " + next_url)

            return redirect(next_url)

class CustomSsoSecurityManager(SupersetSecurityManager):
    authoidview = CustomAuthOAuthView
    def __init__(self, appbuilder):
        super(CustomSsoSecurityManager, self).__init__(appbuilder)

    def oauth_user_info(self, provider, response=None):
        logger.debug('oauth2 provider: {0}'.format(provider))
        logger.debug('response: {0}'.format(response))
        if provider == "okta":
            me = self.appbuilder.sm.oauth_remotes[provider].get("userinfo")
            return {
                "username": me.data.get("email", ""),
                "first_name": "",
                "last_name": "",
                "email": me.data.get("email", ""),
                "role_keys": me.data.get("groups", []),
            }

    def auth_user_oauth(self, userinfo):
        user = super(CustomSsoSecurityManager, self).auth_user_oauth(userinfo)
        if user == None:
            logger.debug(' Update <User: %s> role to %s', userinfo.username)
            self.update_user(userinfo)  # update user roles
        return user

SECURITY_MANAGER_CLASS = CustomSsoSecurityManager
# ====== End Okta Login ============

Guess its an older version of Flask-AppBuilder.

vrychkov-repay commented 2 months ago

@ddhanabalan, this may be related to a different way security manager is customized in Superset - https://github.com/apache/superset/blob/4.0.1/superset/initialization/__init__.py#L524-L539 - not like in classic FAB. Try CUSTOM_SECURITY_MANAGER