Blogs
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!