Search code examples
node.jsproxykeycloakapache-superset

Superset keycloak integration redirect after login not working


I'm using Superset 1.3.2 with Docker.

I've implemented access with Keycloak by adding the following files:

custom_sso_security_manager_keycloak.py

from flask import redirect, request
from flask_appbuilder.security.manager import AUTH_OID
from superset.security import SupersetSecurityManager
from flask_oidc import OpenIDConnect
from flask_appbuilder.security.views import AuthOIDView
from flask_login import login_user
from urllib.parse import quote
from flask_appbuilder.views import ModelView, SimpleFormView, expose
import logging
from sqlitedict import SqliteDict


class AuthOIDCView(AuthOIDView):
    @expose('/login/', methods=['GET', 'POST'])
    def login(self, flag=True):
        sm = self.appbuilder.sm
        oidc = sm.oid
        @self.appbuilder.sm.oid.require_login
        def handle_login():
            user = sm.auth_user_oid(oidc.user_getfield('email'))
            draftRole = oidc.user_getinfo(['resource_access'])
            logging.info("------------>{}".format(draftRole))
            # if 'decision_maker' in draftRole['resource_access']['superset']['roles']:
            if user is None:
                info = oidc.user_getinfo(['preferred_username',    
                    'given_name', 'family_name', 'email', 'resource_access'])
                user = sm.add_user(info.get('preferred_username'),   
                    info.get('given_name',''), info.get('family_name',''),    
                    info.get('email'), sm.find_role('Public'))

            login_user(user, remember=False)
            return redirect(self.appbuilder.get_url_for_index)
        return handle_login()

    @expose('/logout/', methods=['GET', 'POST'])
    def logout(self):
        oidc = self.appbuilder.sm.oid
        oidc.logout()
        super(AuthOIDCView, self).logout()        
        redirect_url = request.url_root.strip('/') + self.appbuilder.get_url_for_login
        return redirect(oidc.client_secrets.get('issuer') +   
        '/protocol/openid-connect/logout?redirect_uri=' +    
        quote(redirect_url))
class OIDCSecurityManager(SupersetSecurityManager):
    authoidview = AuthOIDCView
    def __init__(self,appbuilder):
        super(OIDCSecurityManager, self).__init__(appbuilder)
        if self.auth_type == AUTH_OID:
            # self.oid = OpenIDConnect(self.appbuilder.get_app)
            self.oid = OpenIDConnect(self.appbuilder.get_app, credentials_store=SqliteDict('users.db', autocommit=True))
    def database_access(self, database):
        return self.can_access_database(database)
    
    def all_datasource_access(self):
        return self.can_access_all_datasources()

    # Copied the same implementation of get_schemas_accessible_by_user because there is a bug in the Upload Excel class
    def schemas_accessible_by_user(self, database, schemas, hierarchical = True):
        """
        Return the list of SQL schemas accessible by the user.

        :param database: The SQL database
        :param schemas: The list of eligible SQL schemas
        :param hierarchical: Whether to check using the hierarchical permission logic
        :returns: The list of accessible SQL schemas
        """
        print(database)
        print(schemas)
        print(hierarchical)

        from superset.connectors.sqla.models import SqlaTable

        if hierarchical and self.can_access_database(database):
            return schemas

        # schema_access
        accessible_schemas = {
            self.unpack_schema_perm(s)[1]
            for s in self.user_view_menu_names("schema_access")
            if s.startswith(f"[{database}].")
        }

        # datasource_access
        perms = self.user_view_menu_names("datasource_access")
        if perms:
            tables = (
                self.get_session.query(SqlaTable.schema)
                .filter(SqlaTable.database_id == database.id)
                .filter(SqlaTable.schema.isnot(None))
                .filter(SqlaTable.schema != "")
                .filter(or_(SqlaTable.perm.in_(perms)))
                .distinct()
            )
            accessible_schemas.update([table.schema for table in tables])

        return [s for s in schemas if s in accessible_schemas]
    



    

custom_sso_security_manager.py

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


class CustomSsoSecurityManager(SupersetSecurityManager):
    def oauth_user_info(self, provider, response=None):
        if provider == 'fiware':
            logger = logging.getLogger()

            # As example, this line request a GET to base_url + '/' + userDetails with Bearer  Authentication,
            # and expects that authorization server checks the token, and response with user details
            me = self.appbuilder.sm.oauth_remotes[provider].get(os.environ.get('USER_URL')).json()
            logger.debug(me)
            return { 'name' : me['displayName'], 'email' : me['email'], 'id' : me['username'], 'username' : me['username'], 'first_name':'', 'last_name':'', 'role':'admin'}
    
    def auth_user_oauth(self, userinfo):

        logger = logging.getLogger()
        logger.debug("INAUTH USER OAUTH final userbefore update ={}".format(userinfo))
        """
            OAuth user Authentication

            :userinfo: dict with user information the keys have the same name
            as User model columns.
        """

        logger.debug("in auth_user_oauth")
        if "username" in userinfo:
            user = self.find_user(username=userinfo["username"])
        elif "email" in userinfo:
            user = self.find_user(email=userinfo["email"])

        else:
            user = False
            logger.error("User info does not have username or email {0}".format(userinfo))

            logger.debug("user after find_user={}. type={}".format(user, type(user)))

        if user:
            self.update_user_auth_stat(user)

        # return None
        # User is disabled
        # if user and not user.is_active:
        #     logger.info(LOGMSG_WAR_SEC_LOGIN_FAILED.format(userinfo))
        #     return None
        # If user does not exist on the DB and not self user registration, go away
        if not user and not self.auth_user_registration:
            logger.debug("user does not exist on the DB and not self user registration, go away")
            return None
        # User does not exist, create one if self registration.
        if not user:
            aur = self.auth_user_registration_role
            role = self.find_role(aur)
            logger.debug("Adding user with role={} representing aur={}".format(role, aur))
            user = self.add_user(
                username=userinfo["username"],
                first_name=userinfo.get("first_name", ""),
                last_name=userinfo.get("last_name", ""),
                email=userinfo.get("email", ""),
                role=role
            )
            if not user:
                logger.error("Error creating a new OAuth user %s" % userinfo["username"])
                return None
            else:
                logger.debug("Success!")
        logger.debug("final userbefore update ={}".format(user))
        user.role = "Admin"
        self.update_user(user)

        return user
    def database_access(self, database):
        return self.can_access_database(database)
        #return True
    
    def all_datasource_access(self):
        return self.can_access_all_datasources()
        #return True

client_secret.json

 {
   "web": {
      "realm_public_key": "superset",
      "issuer": "http://<your_domani>/realms/energidrica",
      "auth_uri":"http://<your_domani>/realms/energidrica/protocol/openid-connect/auth",
      "client_id": "superset",
      "client_secret": "secret",
      "verify_ssl_server": false,
      "redirect_urls": [
         "<redirect_domain>"
      ],
      "userinfo_uri":"http://<your_domani>/realms/energidrica/protocol/openid-connect/userinfo",
      "token_uri":"http://<your_domani>/realms/energidrica/protocol/openid-connect/token",
      "token_introspection_uri":"http://<your_domani>/realms/energidrica/protocol/openid-connect/token/introspect"
     }
 }

Changing the fields based on my needs, I then added the following to superset_configs.py.

AUTH_TYPE = AUTH_OID
OIDC_CLIENT_SECRETS='/app/docker/pythonpath_dev/client_secret.json'
OIDC_ID_TOKEN_COOKIE_SECURE = False
OIDC_REQUIRE_VERIFIED_EMAIL = False
OIDC_CLOCK_SKEW = 560
OIDC_VALID_ISSUERS = '<valid-issuer-link>'
AUTH_USER_REGISTRATION = True
AUTH_USER_REGISTRATION_ROLE = 'Gamma'
CUSTOM_SECURITY_MANAGER = OIDCSecurityManager
OIDC_INTROSPECTION_AUTH_METHOD = 'client_secret_post'
OIDC_TOKEN_TYPE_HINT = 'access_token'

I'm running the application locally, and when I launch it, attempting to go to http://localhost:8088 correctly redirects me to the Keycloak server. However, after logging in, the Keycloak server fails to redirect me to the proper link and instead redirects me to http://superset:8088. How can I fix this?

I've tried changing Keycloak settings, but without success. When checking the logs, I also noticed the following:

superset_node | [HPM] Proxy created: / -> http://superset:8088

The log entry "superset_node | [HPM] Proxy created: / -> http://superset:8088" indicates that a proxy has been created using the http-proxy-middleware (HPM). Essentially, this means that the Node.js server running Superset is forwarding requests it receives at the root ("/") to the Superset server operating at http://superset:8088.

It's possible that this is the cause of the incorrect redirection. I think it could be the reason, but I'm not sure. If it is the cause, I have no idea how to fix it.

i've tried all possible customization in keycloak settings and dofferent configurations in superset_configs.py


Solution

  • My intuition was correct. in Dev mode, superset, is loaded with a node proxy module to redirect the request from the frontend to backend, the file that setup the module is webpack.proxy-config.js located in path superset/superset-frontend. in this file we can find

    
    const parsedArgs = require('yargs').argv;
    
    const { supersetPort = 8088, superset: supersetUrl = null } = parsedArgs;
    const backend = (supersetUrl || `http://superset:${supersetPort}`).replace(
      '//+$/',
      '',
    ); // strip ending backslash
    

    Because I'm not passing a command line argument, the proxy was set on Superset:8088. In some way (I wasn't able to understand why, if you do, please comment), the proxy influences the parameter redirect_uri' sent to Keycloak, which Keycloak uses to redirect back to the client!

    To solve the problem, I just changed the line..

    const backend = (supersetUrl || `http://superset:${supersetPort}`).replace(
      '//+$/',
      '',
    ); // strip ending backslash
     
    

    to

    const backend = (supersetUrl || `http://localhost:${supersetPort}`).replace(
      '//+$/',
      '',
    ); // strip ending backslash
    
    

    In this way, the 'redirect_uri' sent to Keycloak is correct, and after login, the redirects work!

    In production, you probably want to disable the integrated reverse-proxy (to do this, simply add ENABLE_PROXY_FIX = False in superset_config.py) and use a more secure reverse proxy like NginX or Apache. In this case, you should properly configure the chosen reverse proxy to achieve the same as above. In my case, using NginX, this configuration works!

        location / {
          proxy_buffers         8 16k;  # Buffer pool = 8 buffers of 16k
          proxy_buffer_size     16k;    # 16k of buffers from pool used for headers
          proxy_pass      http://identity-access-management:8080;
          proxy_set_header Host            $host;
          proxy_redirect off;
          proxy_set_header X-Forwarded-For $remote_addr;
        }
    
    .... OTHER STUFF