Liferay OpenID Connect User Synchronization

Although requiring a customization, it is possible to synchronize user details such as group membership from an OpenID Connect Session.

Introduction

In today's Office Hours session, the first question out of the gate was "If I'm using OpenID Connect for SSO, how can I synchronize UserGroup membership?".

And, well, I just didn't know, but I said that I'd find out and then write a blog post to share the details, figuring that others doing OIDC may face similar sorts of concerns.

I thought that I was going to have to bury myself deep into the bowels of Liferay source looking for how I could access these details, then spend some time coding up a solution, testing it out, ...

But I started first by reaching out to my coworkers, and my friend Michael Wall pointed me at a repository by my friend Fabian Bouché, and I quickly realized that all of my coding work has already been done for me.

It still made sense to write about OIDC and the Liferay authentication process, how Liferay was handling user additions, etc., so here we go...

What Is OpenID Connect?

OpenID Connect, also referred to as OIDC, is an authentication layer that sits on top of OAuth 2.0.

OAuth2, on its own, is strictly meant to define authorizations for what users may or may not do, but the specification did not have its own authentication mechanism. We use it in Liferay to grant authorizations to various Headless endpoints, and also for the new Client Extensions it is used to grant Liferay authorization to access the services and the services authorization to access Liferay.

Separating authentication from authorization allows for decoupling these security aspects and allows the administrator to mix and match providers as necessary.

For most of us SSO admins, this doesn't really make a lot of sense yeah? We typically pick one authentication/authorization solution because it is just easier to maintain this in a single solution rather than in separate systems. I mean, sure I might want to give "read email" authorization to user ABC, but I'm abdicating the authentication of user ABC to something else? And if I'm managing authentication for user ABC in another system, isn't it easier for me to also manage their authorizations there too?

So a consortium of folks got together and basically said "OAuth 2.0 has all of right authorization features, but it needs an authentication mechanism too." The OpenID Foundation was formed and the OIDC specification was created to add an SSO authentication layer over OAuth 2.0's authorization layer.

In OIDC, the main player is going to be the OpenID Connect Provider. It's the provider's job to handle the authentication/authorization aspects of OIDC.

Unlike SAML, Liferay cannot be an OIDC Provider, it can only be a consumer.

Liferay OIDC Authentication

Although there are actually a number of different supported flows (OIDC is, after all, based upon OAuth 2.0 flows), the primary one I'm going to cover here is the Authentication Flow:


 

This diagram is a generic one that doesn't touch on Liferay specifically, but it does a decent job at representing the flow. When looking at the image, Liferay is the Relying Party (Client).

That's all well and good, but what actually happens on the Liferay side?

Primarily Liferay is going to start managing/tracking a com.liferay.portal.security.sso.openid.connect.persistence.model.OpenIdConnectSession instance. This object is going to be tracking everything related to the OIDC authorization stuff including the OAuth2 clientId, clientSecret, the token, the expiration, etc. It is also going to contain the authentication aspects for the user including the claims and OIDC tokens.

As OIDC is an SSO implementation, there are auto-login aspects that conform to Liferay's normal authentication pipeline, and there are also associated servlet filters to intercept and process OIDC changes, etc.

During the login portion, Liferay uses the com.liferay.portal.security.sso.openid.connect.internal.OIDCUserInfoProcessor to look up the user (if they already exist) or create the user (if they don't already exist). This class will use the OIDC User Information Mapper JSON which you can define in the OAuth Client Administration control panel:


 

Now, unfortunately, this info is only going to be used when the user is being added the first time. If the user already exists, none of the OIDC token details are going to be used to update the existing user.

Supporting OIDC Synchronization

So OOTB, Liferay does not support synchronizing the user account from the OIDC claims.

This seems kind of unfortunate, because (IMHO) if we're using the external OIDC system for SSO and our "source of truth" for the user, we would really want to synchronize this data, especially if we want to manage roles and/or group memberships.

So, how could we handle this synchronization if this was something we needed or wanted?

The solution proposed by my friend Fabian Bouché is to use a Post Login Action to access the OIDC session instance, access the OIDC token details/claims and update the user as necessary.

So first thing we need is to set up the Lifecycle Listener such as:

@Component(
  immediate = true, property = "key=login.events.post",
  service = LifecycleAction.class
)
public class OIDCUserSynchronizationPostLoginAction implements LifecycleAction {

  @Override
  public void processLifecycleEvent(LifecycleEvent lifecycleEvent) 
    throws ActionException {
	
    ...
  }
}

So this component foundation is our starting point, it will be invoked immediately after the user logs in.

Now just because user has logged in, it doesn't mean this is an OIDC session. We'll need to verify if this is an OIDC session and bail out if it is not. We can do that using the following code:

HttpServletRequest httpServletRequest = lifecycleEvent.getRequest();
HttpSession httpSession = httpServletRequest.getSession();
Long openIdConnectSessionId = (Long)httpSession.getAttribute(
  OpenIdConnectWebKeys.OPEN_ID_CONNECT_SESSION_ID);

if (openIdConnectSessionId == null) {
  // this is not an OIDC user
  return;
}

If we make it past the if() block, we know that this is an OIDC session, so now we need to gather the session details:

try {
  OpenIdConnectSession openIdConnectSession = 
    _openIdConnectSessionLocalService
      .getOpenIdConnectSession(openIdConnectSessionId);
  String idToken = openIdConnectSession.getIdToken();
	
  JWT jwt = JWTParser.parse(idToken);
  JWTClaimsSet claimsSet = jwt.getJWTClaimsSet();
	
  // process the claims here...
	
} catch (ParseException e) {
  _log.error("Failed to parse id token", e);
}				

Once you have the JWTClaimsSet, you can basically do what you want with them in order to synchronize changes to the Liferay user.

In Fabian's case, he introduced a way to use the "member" claim to update user group memberships. You can find his implementation here: https://github.com/fabian-bouche-liferay/saml-user-group-mapping/blob/master/modules/user-group-oidc/src/main/java/com/liferay/samples/fbo/oidc/mapping/OIDCUserGroupMappingPostLoginAction.java

Conclusion

Obviously there is much more you can do here. Fabian's code is only synchronizing user group memberships, but it could be extended to synchronize roles and other information sourcing from the claims captured from the OIDC provider.

That, of course, I will leave to you to handle.

Some points I would make before going:

First, take a look at Liferay's implementation of the OIDCUserInfoProcessor here: https://github.com/liferay/liferay-portal/blob/master/modules/apps/portal-security-sso/portal-security-sso-openid-connect-impl/src/main/java/com/liferay/portal/security/sso/openid/connect/internal/OIDCUserInfoProcessor.java#L230 as it will show you how Liferay uses the OIDC user mapping JSON to find the right claims.

Second, take a look at Fabian's implementation linked above.

Third, consider taking a mixed approach... For example, Fabian's implementation would be slightly better if he had leveraged the user mapping JSON to decouple the actual claim key name from the code, so using an updated claim would just be a matter of updating the user mapping JSON instead of requiring a code change, test, and deploy if "member" were to change somehow.

Hope this helps in your OpenID Connect journey!

Blogs

Great coincidence Dave... exactly the subject I'm working on in my new company !

I have an ask on openId subject for you or Fabian (but perhaps should I post it on slack). What will be the better approach on Single Log Out, dealing with different situations as :

- logout from frontend => send message to the provider to propagate other app client sessions 

- timed out session (or killed session) on portal side => send message to the provider to close session token on its side

- logout received from provider, on client session ended (triggerred by an other app)

My provider is KeyCloak.

IMHO, SLO is hard (conceptually).

When I use SSO to log into liferay.dev, as an employee I'm also signing in for our ticketing system, our customer download area, the Liferay Marketplace, plus a number of other resources. I'm typically not _purposefully_ logging into those other systems, i.e. I'm lucky if I go to the marketplace site once a quarter or something, but when I land there and I don't have to log in, it's quite convenient.

If I wanted to log out of liferay.dev, as a user conceptually I'm only thinking about logging out of liferay.dev, not all of the other systems, but with SLO even though I'm only thinking about logging out of one, I've now logged out of the others.

Once I've logged out of one and, because of SLO, I'm logged out of the others, if I then flipped over to our ticketing system, I have to log in. Now SLO is actually _inconvenient_ for me because I didn't actually log out of the ticketing system, SLO did.

So as a user, now, I don't log out at all, because a SLO logout is not anything that I want to deal with. Obviously my sessions will expire across a number of the systems, but because I'm staying in other Liferay systems all day, my SSO tokens are good and if I go back to liferay.dev after some delay I get auto-logged back in. So as a _user_, I don't want SLO at all.

Now, from a business perspective, though, there's a different argument to make, right? Because of security, I want SLO because if a user chooses to log out, I want their sessions killed on all of the apps they might be using and hey, if they need to re-log back in to access one of the other systems, that is still safer than just letting the SSO token live all day.

But still I can be foiled when the users stop logging out because they don't like the inconvenience of re-authenticating. And as long as they stay active in at least one of the systems I'm protecting with SSO, their token has to remain valid.

No matter what I want to do as an admin from a SLO perspective, it only will matter if the users actually log out...

Now I'm sure I haven't answered your question, but if I were to, it would probably be something along the lines of "it depends upon what your requirements dictate". But hopefully I've given you something to chew upon?

Hi Dave and thank you for giving your point of view with arguments. I'm agree with you : personally, I prefer to choose when I log out (and even "on") from any system. But my ask was from a business need, when end users came to portal and then access by a link to an other system, under the same IAM. In this case, they don't need/want to log in again. And then a logout from the portal or even from the linked system should be propagated (by security design). But your answer make me think that I should first address User Experience and journey before deeping in technical answer. So, i'm going on chewing a little more until I came to a perfect bubble gum...

We use a reverse proxy mod_auth_openidc + a custom header based login module for that. mod_auth_openidc supports backchannel logout quite well and configuration is rather easy. It also works quite well with Keycloak.

The custom module we have implemented (alas, for a customer, so I can't really open source it) basically works the same as the RequestHeaderAutoLogin module. It reads the oidc headers that mod_auth_openidc sets and creates/updates the user. https://github.com/liferay/liferay-portal/blob/master/modules/apps/portal-security/portal-security-auto-login/src/main/java/com/liferay/portal/security/auto/login/request/header/RequestHeaderAutoLogin.java

We also have a filter that:

- Stores the oidcUsername in the session - Compares the stored username in the session with the headers ​​​​​​​- If it doesn't match, invalidates the session

That way we enforce that the user in Liferay always matches the user from the reverse proxy. I couldn't find an example for that in the Liferay code, but at least some (older?) Liferay versions also had such a filter.

Great Info @David and @Fabian. In fact I run into an issue where userinfo needs to be updated. New employees are registered initially in the IDP with their personal email address to start the onboarding. They login to Liferay and all works fine. Now their email address is updated to the company email and all of a sudden they can't login in any more. Currently removing their user account from Liferay fixes the problem, but probably updating their email address would solve it on the fly.