/********************************************************************** * This file is part of iDempiere ERP Open Source * * http://www.idempiere.org * * * * Copyright (C) Contributors * * * * This program is free software; you can redistribute it and/or * * modify it under the terms of the GNU General Public License * * as published by the Free Software Foundation; either version 2 * * of the License, or (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program; if not, write to the Free Software * * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, * * MA 02110-1301, USA. * * * * Contributors: * * - Trek Global Corporation * * - Heng Sin Low * **********************************************************************/ package com.trekglobal.idempiere.rest.api.oidc.keycloak; import java.util.ArrayList; import java.util.List; import java.util.Optional; import javax.ws.rs.container.ContainerRequestContext; import org.compiere.model.MOrg; import org.compiere.model.MRole; import org.compiere.model.MSession; import org.compiere.model.MSysConfig; import org.compiere.model.MUser; import org.compiere.model.Query; import org.compiere.util.Env; import org.compiere.util.Util; import org.osgi.service.component.annotations.Component; import com.auth0.jwt.exceptions.JWTVerificationException; import com.auth0.jwt.interfaces.Claim; import com.auth0.jwt.interfaces.DecodedJWT; import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.trekglobal.idempiere.rest.api.model.MOIDCService; import com.trekglobal.idempiere.rest.api.oidc.AbstractOIDCProvider; import com.trekglobal.idempiere.rest.api.oidc.AuthenticatedUser; import com.trekglobal.idempiere.rest.api.oidc.IOIDCProvider; /** * @author hengsin */ @Component(immediate = true, service = IOIDCProvider.class, property = "name=Keycloak") public class KeycloakProvider extends AbstractOIDCProvider { /** * Default constructor */ public KeycloakProvider() { } @Override public AuthenticatedUser getAuthenticatedUser(DecodedJWT decodedJwt, ContainerRequestContext requestContext, MOIDCService oidcService) { int AD_Client_ID = oidcService.getAD_Client_ID(); int AD_Org_ID = -1; int AD_User_ID = -1; int AD_Role_ID = -1; String orgHeader = requestContext.getHeaderString(MOIDCService.ORG_HEADER); String roleHeader = requestContext.getHeaderString(MOIDCService.ROLE_HEADER); String headerOrgValue = null; if (oidcService.isAuthorization_OIDC()) { //resource_access.${keycloak_client_id}.roles List roleNames = new ArrayList<>(); Claim azpClaim = decodedJwt.getClaim("azp"); String keycloakClientId = azpClaim.asString(); Claim resourceAccessClaim = decodedJwt.getClaim("resource_access"); if (resourceAccessClaim.isNull() || resourceAccessClaim.isMissing()) { throw new JWTVerificationException("Missing roles claim"); } String resourceAccessText = resourceAccessClaim.toString(); Gson gson = new Gson(); JsonObject resourceAccessJson = gson.fromJson(resourceAccessText, JsonObject.class); JsonObject resourceClientJson = resourceAccessJson.getAsJsonObject(keycloakClientId); JsonElement rolesElement = resourceClientJson.get("roles"); JsonArray rolesArray = rolesElement.getAsJsonArray(); rolesArray.forEach(e -> roleNames.add(e.getAsString())); if (roleNames.size() == 0) { throw new JWTVerificationException("Missing roles claim"); } //keycloak groups mapping to iDempiere org (AD_Org.Value) Claim groupsClaim = decodedJwt.getClaim("groups"); if (groupsClaim.isNull() || groupsClaim.isMissing()) { throw new JWTVerificationException("Missing organization claim"); } String[] groupNames = groupsClaim.asArray(String.class); List orgList = new ArrayList<>(); for(String groupName : groupNames) { if (groupName.startsWith("/")) groupName = groupName.substring(1); Query query = new Query(Env.getCtx(), MOrg.Table_Name, "AD_Client_ID=? AND Value=?", null); MOrg org = query.setOnlyActiveRecords(true).setParameters(AD_Client_ID, groupName).first(); if (org != null) { orgList.add(org); } } if (orgList.size() == 0) { throw new JWTVerificationException("Missing organization claim"); } if (orgList.size() == 1) { if (Util.isEmpty(orgHeader) || orgHeader.equals(orgList.get(0).getValue())) { AD_Org_ID = orgList.get(0).get_ID(); } else { throw new JWTVerificationException("Invalid %s header".formatted(MOIDCService.ORG_HEADER)); } } else { if (!Util.isEmpty(orgHeader)) { for(int i = 0; i < orgList.size(); i++) { if (orgHeader.equals(orgList.get(i).getValue())) { AD_Org_ID = orgList.get(i).get_ID(); break; } } if (AD_Org_ID == -1) { throw new JWTVerificationException("Invalid %s header".formatted(MOIDCService.ORG_HEADER)); } } } //keycloaks roles mapping to iDempiere role String roleName = null; if (roleNames.size() == 1) { roleName = roleNames.get(0); if (!Util.isEmpty(roleHeader) && !roleHeader.equals(roleName)) { throw new JWTVerificationException("Invalid %s header".formatted(MOIDCService.ROLE_HEADER)); } } else { if (!Util.isEmpty(roleHeader)) { Optional optional = roleNames.stream().filter(e -> roleHeader.equals(e)).findFirst(); if (optional.isPresent()) roleName = optional.get(); else throw new JWTVerificationException("Invalid %s header".formatted(MOIDCService.ROLE_HEADER)); } } //validate roleName claim Query query = new Query(Env.getCtx(), MRole.Table_Name, "AD_Client_ID=? AND Name=?", null); if (roleName != null) { MRole mRole = query.setOnlyActiveRecords(true).setParameters(AD_Client_ID, roleName).first(); if (mRole != null) { AD_Role_ID = mRole.get_ID(); } } else { MRole mRole = null; for (String r : roleNames) { mRole = query.setOnlyActiveRecords(true).setParameters(AD_Client_ID, r).first(); if (mRole != null) { AD_Role_ID = mRole.get_ID(); break; } } } if (AD_Role_ID == -1) { throw new JWTVerificationException("Invalid Role claim"); } } else { //not resolving authorization details from access token, try http header if (!Util.isEmpty(orgHeader)) { Query query = new Query(Env.getCtx(), MOrg.Table_Name, "AD_Client_ID=? AND Value=?", null); MOrg org = query.setOnlyActiveRecords(true).setParameters(AD_Client_ID, orgHeader).first(); if (org != null) { AD_Org_ID = org.getAD_Org_ID(); } if (AD_Org_ID == -1) throw new JWTVerificationException("Invalid %s header".formatted(MOIDCService.ORG_HEADER)); } if (!Util.isEmpty(roleHeader)) { Query query = new Query(Env.getCtx(), MRole.Table_Name, "AD_Client_ID=? AND Name=?", null); MRole mRole = query.setOnlyActiveRecords(true).setParameters(AD_Client_ID, roleHeader).first(); if (mRole != null) { AD_Role_ID = mRole.getAD_Role_ID(); } else { throw new JWTVerificationException("Invalid %s header".formatted(MOIDCService.ROLE_HEADER)); } } } //resolve user using ad_user.email or ad_user.name boolean useEmail = MSysConfig.getBooleanValue(MSysConfig.USE_EMAIL_FOR_LOGIN, false); String userId = decodedJwt.getClaim(useEmail ? "email" : "name").asString(); if (Util.isEmpty(userId)) { throw new JWTVerificationException("Missing user claim"); } Query query = new Query(Env.getCtx(), MUser.Table_Name, "AD_Client_ID IN (0,?) AND %s=?".formatted((useEmail ? "Email" : "Name")), null); MUser user = query.setOnlyActiveRecords(true).setParameters(AD_Client_ID, userId).setOrderBy("AD_Client_ID DESC").first(); if (user == null) { throw new JWTVerificationException("Invalid user claim"); } AD_User_ID = user.get_ID(); boolean orgAccessValidated = false; if (AD_Role_ID == -1) { //get role from http header or if no http header but user has single role only if (!Util.isEmpty(roleHeader)) { query = new Query(Env.getCtx(), MRole.Table_Name, "AD_Client_ID=? AND Name=?", null); MRole mRole = query.setOnlyActiveRecords(true).setParameters(AD_Client_ID, roleHeader).first(); if (mRole != null) { AD_Role_ID = mRole.getAD_Role_ID(); } else { throw new JWTVerificationException("Invalid %s header".formatted(MOIDCService.ROLE_HEADER)); } } else { AD_Role_ID = getSingleRoleIDOnly(AD_Client_ID, user); } } //get org from http header or if role has access to single org only if (AD_Role_ID >= 0) { if (AD_Org_ID == -1) { if (!Util.isEmpty(headerOrgValue)) { query = new Query(Env.getCtx(), MOrg.Table_Name, "AD_Client_ID=? AND Value=?", null); MOrg org = query.setOnlyActiveRecords(true).setParameters(AD_Client_ID, headerOrgValue).first(); if (org != null) { AD_Org_ID = org.getAD_Org_ID(); } else { throw new JWTVerificationException("Invalid %s header".formatted(MOIDCService.ORG_HEADER)); } } else { AD_Org_ID = getSingleOrgIdOnly(AD_Client_ID, AD_Role_ID, user); if (AD_Org_ID >= 0) orgAccessValidated = true; else throw new JWTVerificationException("Missing Organization Claim or Header"); } } } else { throw new JWTVerificationException("Missing Role Claim or Header"); } if (AD_Role_ID >= 0 && AD_Org_ID >= 0 && !orgAccessValidated){ //validate org access Env.setContext(Env.getCtx(), Env.AD_CLIENT_ID, AD_Client_ID); MRole mRole = new MRole(Env.getCtx(), AD_Role_ID, null); if (mRole.isUseUserOrgAccess()) { mRole.setAD_User_ID(AD_User_ID); } if (!mRole.isOrgAccess(AD_Org_ID, false)) { throw new JWTVerificationException("Invalid user and organization combination"); } } MSession session = MSession.get(Env.getCtx()); if (session == null){ session = MSession.create(Env.getCtx()); session.setWebSession("idempiere-rest-oidc"); session.saveEx(); } return new AuthenticatedUser(AD_Client_ID, AD_Org_ID, AD_Role_ID, AD_User_ID, session.getAD_Session_ID()); } }