Liferay 7 SSO using OpenId Connect

Or how to integrate Liferay with Keycloak

I just completed a project that integrated Liferay 7.0 GA7 with Keycloak 4.8 for both authentication and authorization. For those who are not familar with Keycloak it is an open source access and identity manager,  https://www.keycloak.org/.

The authentication piece of this integration was assisted by the use of the use of the OpenId Connect Plugin that is available in the Liferay Marketplace.  Authorization was achieved through the use of the a Liferay post login hook and the Keycloak Restful API.

This integration also involved the use of Apache Httpd for the virtual hosting of both Liferay and Keycloak. This virtual hosting provided SSL support as well.

This blog assumes that Liferay, Keycloak and Apache Httpd are installed and that AJP is enabled on both the Liferay and Keycloak application servers. AJP will be used by Httpd virtual hosting to proxy request to both Liferay and Keycloak.  The following is the Httpd's settings that were used to perform this virtual hosting:

#virtual host for liferay
<VirtualHost *:443>
    ServerName aim1.xxxxxx.net    
    SSLEngine on
    SSLCertificateFile /etc/pki/tls/certs/aim1.xxxxxxx.net.cert.pem
    SSLCertificateKeyFile /etc/pki/tls/private/aim1.xxxxxx.net.key.pem
    
    # Set the header for the https protocol
    #RequestHeader set X-Forwarded-Proto "https"
    #RequestHeader set X-Forwarded-Port "443"     
 
    # Serve /excluded from the local httpd data
    ProxyPass /excluded !
         
    # Preserve the host when invoking tomcat
    ProxyPreserveHost on
         
    # Pass all traffic to a localhost tomcat.
    ProxyPass / ajp://localhost:8009/
    ProxyPassReverse / ajp://localhost:8009/
</VirtualHost>

# virtual host for keycloak
Listen 17443
<VirtualHost *:17443>
    ServerName aim1.aifoundry.net    
    SSLEngine on
    SSLCertificateFile /etc/pki/tls/certs/aim1.xxxxxx.net.cert.pem
    SSLCertificateKeyFile /etc/pki/tls/private/aim1.xxxxxx.net.key.pem
    
    # Set the header for the https protocol
    #RequestHeader set X-Forwarded-Proto "https"
    #RequestHeader set X-Forwarded-Port "443" 
    
    # Serve /excluded from the local httpd data
    ProxyPass /excluded !
         
    # Preserve the host when invoking tomcat
    ProxyPreserveHost on
         
    # Pass all traffic to a localhost tomcat.
    ProxyPass / ajp://localhost:8589/
    ProxyPassReverse / ajp://localhost:8589/
</VirtualHost>

The settings above assume that Httpd, Liferay and Keycloak are all on the same host. If this not the case then localhost should be substituted with a valid IP address or hostname for the Liferay and Keycloak servers. 

Keycloak Configuration

For the Keycloak configuration I created a separate realm for Liferay that had specific client, roles and users. The client configuration is probably the must important part of the realm configuration, especially its redirect URL:


The redirect URL, https://aim1.xxxxxx.net/*, shown above is base URL of the Liferay site I want to authenticate with Keycloak. Note the * wildcard character which allows Keycloak to access everything under this URL, especially /c/portal/login. 

Liferay's OpenID Connect PLugin Configuration

One other assumption that I make is that the OpenID Connect Plugin has been downloaded from the Liferay Marketplace and deployed to Liferay.  Once that is done an additional tab should appear in the Authentication section of the Liferay Control Panel's Instance Settings.  The following are the settings that were used for my instance:


One thing to bear in mind because I'm coming through my Httpd front door using SSL the JWT token that was generated by Keycloak is getting encrypted. In order for the plugin to be able to decrypt it the JVM that Liferay is running needs to have its SSL certificates updated  with the certificate the Httpd is using.

With the plugin configured and  enabled Liferay can now participate in the SSO being provided by Keycloak. If you get authenticated by Keycloak while accessing another application that it's also protecting you should have a valid JWT token in your browser.  This should allow you to access Liferay without having to login. If you're not yet authenticated the plugin will force you to authenticate with Keycloak.

More information on the OpenID Connect Plugin can be found at the following URL: https://github.com/finalist/liferay-oidc-plugin/blob/oidc-parent-0.5.0/README.md

Take note of the great sequence diagram located at the end of this readme page.

What About Authorization

If Keycloak SSO is working correctly we can now log into Liferay with user that are being managed by Keycloak:

The OpenID Connect Plugin will automatically add these authenticated users into Liferay's user repository. 

These users can also be assigned roles in Keycloak:

User roles information can be accessed using the Keycloak's Restful API. I found that the most direct way to this with Keycloaks's API is to use the following restful method which allows you to get a role's membership by its name:

GET /{realm}/clients/{id}/roles/{role-name}/users

I employed this call in as part of a restful client that gets both the access token and the user's roles:

    public String getAdminAccessToken() {
        Form form = new Form();
        form.param(USERNAME, AUTH_SERVICE_USERNAME);
        form.param(PASSWORD, AUTH_SERVICE_PASSWORD);
        form.param(GRANT_TYPE, AUTH_SERVICE_GRANT_TYPE);
        form.param(CLIENT_ID, AUTH_SERVICE_CLIENT_ID);

        Boolean encode = true;
        String accessToken = null;

        Response response = client.postForm(AUTH_SERVICE_URL, AUTH_SERVICE_TOKEN_PATH, MediaType.APPLICATION_JSON, form,
                encode);
        if (response != null) {
            AuthToken authToken = response.readEntity(AuthToken.class);
            if (authToken != null) {
                accessToken = authToken.getAccessToken();
            } else {
                LOGGER.error(COULD_NOT_GET_ADMIN_AUTH_TOKEN);
            }
        } else {
            LOGGER.error(COULD_NOT_GET_ADMIN_AUTH_RESPONSE);
        }

        return accessToken;
    }

    public List<String> getUserRoles(String accessToken, String userName) {
        if (accessToken == null || accessToken.isEmpty()) {
            LOGGER.error(INVALID_ARGUMENT_NO_ACCESS_TOKEN_PROVIDED);
            return null;
        }
        
        if (userName == null || userName.isEmpty()) {
            LOGGER.error(INVALID_ARGUMENT_NO_USER_NAME_PROVIDED);
            return null;
        }        

        
        List<String> userRoles = new ArrayList<String>();
        for (String authServiceRole : AUTH_SERVICE_ROLES) {
            String roleUserPath = AUTH_SERVICE_ROLES_PATH + SLASH + authServiceRole + USERS_PATH;
            Response response = client.get(AUTH_SERVICE_URL, roleUserPath, MediaType.APPLICATION_JSON, null,
                    accessToken);
            if (response != null) {
                List<AuthUser> authUsers = response.readEntity(new GenericType<List<AuthUser>>() {
                });
                
                if (authUsers !=null && authUsers.size() > 0) {
                    for (AuthUser authUser : authUsers) {
                        String authUserName = authUser.getUsername();
                        if (authUserName != null && authUserName.equals(userName)) {
                            userRoles.add(authServiceRole);
                            break;
                        }
                    }
                } 
            }
        }

        return userRoles;
    }

 There are other ways to do this but they would require multiple restful calls to achieve the same result. Unfortunately there seems to be no way with the current Keycloak API to access a user's roles directly by using a username at this time.  More information of the Keycloak API can be found at the following URL:  https://www.keycloak.org/docs-api/4.8/rest-api/index.html#_roles_resource

The user's Keycloak roles can be synchronized with corresponding Liferay roles using the Liferay API. This can be triggered by a post login event. Here's a method that I have which gets invoked once the user has been successfully authenticated

  public String getScreenName() {
        ExternalContext externalContext = FacesContext.getCurrentInstance().getExternalContext();
        HttpServletRequest request = PortalUtil.getHttpServletRequest((PortletRequest) externalContext.getRequest());
        HttpServletRequest originalRequest = PortalUtil.getOriginalServletRequest(request);
        
        HttpServletResponse response = PortalUtil
                .getHttpServletResponse((PortletResponse) externalContext.getResponse());

        try {
            User user = PortalUtil.getUser(originalRequest);
            screenName = user.getScreenName();
            LOGGER.info("screenName=" + screenName);
            
            String reminderQueryQuestion = user.getReminderQueryQuestion();
            if (reminderQueryQuestion == null || reminderQueryQuestion.isEmpty()
                    || reminderQueryQuestion.equals(OPENID_CONNECT_REMINDER_QUESTION) == false) {
                LOGGER.info("Not an openId Connect user");
                
                response.sendRedirect(landingPage);
                return screenName;
            }

            String adminAccessToken = AUTH_CLIENT.getAdminAccessToken();
            LOGGER.info("adminAccessToken=" + adminAccessToken);
            if (adminAccessToken != null && adminAccessToken.length() > 0) {
                long userId = user.getUserId();

                
                if (screenName != null && screenName.length() > 0) {
                    List<String> userRoles = AUTH_CLIENT.getUserRoles(adminAccessToken, screenName);
                    LOGGER.info("userRoles=" + userRoles);
                    if (userRoles != null && userRoles.size() > 0) {
                        for (String userRole : userRoles) {
                            Role role = RoleLocalServiceUtil.getRole(user.getCompanyId(), userRole);
                            long roleId = role.getRoleId();
                            boolean hasUserRole = RoleLocalServiceUtil.hasUserRole(userId, roleId);
                            if (hasUserRole) {
                                LOGGER.info(screenName + HAS_FOLLOWING_ROLE + role.getName());                                
                                continue;
                            }
                            
                            RoleLocalServiceUtil.addUserRole(userId, roleId);
                            LOGGER.info(ADDED + screenName + TO_FOLLOWING_ROLE + role.getName());
                        }
                        
                        List<String> AUTH_SERVICE_ROLES = AuthClient.getAUTH_SERVICE_ROLES();
                        List<Role> portalUserRoles = user.getRoles();
                        for (Role portalUserRole : portalUserRoles) {
                            String portalRoleName = portalUserRole.getName();
                            if(AUTH_SERVICE_ROLES.contains(portalRoleName)) {
                                if(!userRoles.contains(portalRoleName)) {
                                    RoleLocalServiceUtil.deleteUserRole(userId, portalUserRole.getRoleId());
                                     LOGGER.info(DELETED + screenName + FROM_THE_FOLLOWING + portalRoleName);
                                }
                            }
                        }
                        
                        if (user.getRoleIds().length <= 1) {
                            throw new Exception(screenName + USER_HAS_NO_ROLES);    
                        }
                    } else {
                        throw new Exception(screenName + USER_HAS_NO_ROLES);                    
                    }
                }

            }
            
            response.sendRedirect(landingPage);

        } catch (Exception e) {
            LOGGER.error(e.getLocalizedMessage());
            try {
                response.sendRedirect(errorPage);
            } catch (IOException e1) {
                LOGGER.error(e.getLocalizedMessage());
            }
        }
        
        return screenName;
    }

 

The code above will both add or remove  a user to or from a roles in Liferay , respectively, based on the the corresponding roles that they have in Ke ycloak.

I hope some will find this blog useful and that it saves them some time.

 

Blogs

I should mention to access the user's roles in cloak there is one thing that must be done in order for the code I have above to work correctly.  When entering the username and email address for the user in Keycloak the username must match the first portion of the address before the @ sign.  This is because Liferay will use that first portion to create the  user's screen name in Liferay.  My code uses the screen name to call back to Keycloak in order to retrieve the roles using Keycloak's Rest API. I know its not a perfect solution and I may need to rethink this at a later date.

One thing I recently became aware of is that many of the Keycloak Restful API calls have a max query parameter to limit the number a of records that the call actually returns.  What the Keycloak documentation neglected to mention is that there is a default for this max parameter which seems to be normally set to 100 records.  I ran into this default setting on the one call I was making in my code above:

GET /{realm}/clients/{id}/roles/{role-name}/users

I had to add the setting of the max query parameter to overcome this limit of 100:

    Map<String, String> params = new HashMap<String, String>();             params.put(MAX, AUTH_SERVICE_ROLES_MAX);

            String roleUserPath = AUTH_SERVICE_ROLES_PATH + SLASH + authServiceRole + USERS_PATH;             Response response = client.get(AUTH_SERVICE_URL, roleUserPath, MediaType.APPLICATION_JSON, params,                     accessToken);

 

It would be better if the API didn't limit the call with a default unless specified by the developer.

Hello, 

 

thank you for this post.  Did you try to use this plugin ( OpenID Connect  Plugin ) with Liferay 7.1 and  Keycloak 7.*?

 

The last supported version in the list of current requirementsof this plugin in the Liferay Marketplace is Liferay DXP 7.0 GA1+.

 

Now, I am wondering, if this plugin is compatible with liferay 7.1 and 7.2.

Do you know something about it?

 

Thanks  Best regards Viktoria

"Did you try to use this plugin (   OpenID Connect    Plugin ) with Liferay 7.1 and  Keycloak 7.*?"

 

I did try this but had no luck with it. I'll admit I haven't tried any recent renditions of this plugin. We're sticking on LR 7.0 GA7 for now. 

Hi William, thank you for this blog post.Do you know if the out-of-the-box SSO openid connect integration available in Liferay 7.3 also automatically imports users' roles at login?I have configured Liferay to use KeyCloak as SSO service and it works fine. The users stored in KeyCloak are automatically imported (created) in Liferay at login time. Now I am trying to make it also import roles.I have configured KeyCloak to make it send roles to Liferay both through the id_token (returned by /token call) and /userinfo endpoints. The roles are added in the responses of these endpoints as a JSON array assigned to an attribute named "roles" (customizable KeyCloak side). The elements of this array are simple strings corresponding to the "roles keys" as defined in Liferay (User, Owner, ...); but that doesn't seem to be enough for Liferay

Do you know if Liferay requires roles to be sent in another format? Maybe with a different attribute name than "roles"'?

Thank you

Mario 

In my experimentation, yes it does (I'm using Cognito, not Keycloak, but the integration isn't specific to any OIDC provider.  The default behavior is that upon first login via OIDC, the account is created and then the user is put through the local account setup workflow -- TOS, Password establishment and password hint.  So this integration will probably need some kind of post-login procedure like the one in this post to avoid all that.

I recently realized the classic integration scenario between Liferay and Keycloak using the OpenID Connect protocol and the Liferay OOTB connector. I reported in this article How to connect Keycloak and Liferay via OpenID Connect (https://bit.ly/keycloak-liferay-openid-connect) the complete experience.