IDEMPIERE-5346 SSO Support (#2038)

* IDEMPIERE-5346 SSO Support

- add OIDC support to core
This commit is contained in:
hengsin 2023-10-04 02:26:03 +08:00 committed by GitHub
parent e61eab3901
commit 3ffdd2be0c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 582 additions and 1 deletions

View File

@ -0,0 +1,10 @@
-- IDEMPIERE-5346 SSO Support
SELECT register_migration_script('202310021327_IDEMPIERE-5346.sql') FROM dual;
SET SQLBLANKLINES ON
SET DEFINE OFF
-- Oct 2, 2023, 1:27:01 PM MYT
INSERT INTO AD_Ref_List (AD_Ref_List_ID,Name,AD_Reference_ID,Value,AD_Client_ID,AD_Org_ID,IsActive,Created,CreatedBy,Updated,UpdatedBy,EntityType,AD_Ref_List_UU) VALUES (200656,'OpenID Connect',200213,'OIDC',0,0,'Y',TO_TIMESTAMP('2023-10-02 13:27:00','YYYY-MM-DD HH24:MI:SS'),100,TO_TIMESTAMP('2023-10-02 13:27:00','YYYY-MM-DD HH24:MI:SS'),100,'D','e1913c90-dcac-4a0d-bd55-dfa3408654e8')
;

View File

@ -0,0 +1,7 @@
-- IDEMPIERE-5346 SSO Support
SELECT register_migration_script('202310021327_IDEMPIERE-5346.sql') FROM dual;
-- Oct 2, 2023, 1:27:01 PM MYT
INSERT INTO AD_Ref_List (AD_Ref_List_ID,Name,AD_Reference_ID,Value,AD_Client_ID,AD_Org_ID,IsActive,Created,CreatedBy,Updated,UpdatedBy,EntityType,AD_Ref_List_UU) VALUES (200656,'OpenID Connect',200213,'OIDC',0,0,'Y',TO_TIMESTAMP('2023-10-02 13:27:00','YYYY-MM-DD HH24:MI:SS'),100,TO_TIMESTAMP('2023-10-02 13:27:00','YYYY-MM-DD HH24:MI:SS'),100,'D','e1913c90-dcac-4a0d-bd55-dfa3408654e8')
;

View File

@ -687,4 +687,10 @@
fragment="true"
unpack="false"/>
<plugin
id="org.idempiere.ui.sso.oidc"
download-size="0"
install-size="0"
version="0.0.0"
unpack="false"/>
</feature>

View File

@ -440,6 +440,7 @@
<setEntry value="org.idempiere.jetty.osgi.boot.fragment@default:false"/>
<setEntry value="org.idempiere.keikai@default:false"/>
<setEntry value="org.idempiere.printformat.editor@default:default"/>
<setEntry value="org.idempiere.ui.sso.oidc@default:default"/>
<setEntry value="org.idempiere.webservices@default:default"/>
<setEntry value="org.idempiere.zk.billboard.chart@default:default"/>
<setEntry value="org.idempiere.zk.billboard@default:false"/>

View File

@ -42,7 +42,6 @@ import org.compiere.model.MPasswordHistory;
import org.compiere.model.MPasswordRule;
import org.compiere.model.MSysConfig;
import org.compiere.model.MUser;
import org.compiere.model.PO;
import org.compiere.model.SystemIDs;
import org.compiere.util.CLogger;
import org.compiere.util.DisplayType;

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry exported="true" kind="lib" path="lib/content-type.jar"/>
<classpathentry exported="true" kind="lib" path="lib/accessors-smart.jar"/>
<classpathentry exported="true" kind="lib" path="lib/lang-tag.jar"/>
<classpathentry exported="true" kind="lib" path="lib/json-smart.jar"/>
<classpathentry exported="true" kind="lib" path="lib/oauth2-oidc-sdk.jar"/>
<classpathentry exported="true" kind="lib" path="lib/nimbus-jose-jwt.jar"/>
<classpathentry kind="con" path="org.eclipse.pde.core.requiredPlugins"/>
<classpathentry kind="src" path="src"/>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-17">
<attributes>
<attribute name="module" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="output" path="target/classes"/>
</classpath>

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>org.idempiere.ui.sso.oidc</name>
<comment></comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.jdt.core.javabuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.pde.ManifestBuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.pde.SchemaBuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.pde.ds.core.builder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.pde.PluginNature</nature>
<nature>org.eclipse.jdt.core.javanature</nature>
</natures>
</projectDescription>

View File

@ -0,0 +1,2 @@
eclipse.preferences.version=1
encoding/<project>=UTF-8

View File

@ -0,0 +1,3 @@
eclipse.preferences.version=1
pluginProject.extensions=false
resolve.requirebundle=false

View File

@ -0,0 +1,27 @@
Manifest-Version: 1.0
Bundle-ManifestVersion: 2
Bundle-Name: OIDC Provider
Bundle-SymbolicName: org.idempiere.ui.sso.oidc
Bundle-Version: 11.0.0.qualifier
Bundle-Vendor: iDempiere.org
Bundle-RequiredExecutionEnvironment: JavaSE-17
Automatic-Module-Name: org.idempiere.ui.sso.oidc
Import-Package: javax.servlet;version="4.0.0",
javax.servlet.http;version="4.0.0",
org.osgi.framework;version="1.3.0",
org.osgi.service.component.annotations;version="1.3.0",
org.slf4j;version="1.7.30",
org.slf4j.event;version="1.7.30",
org.slf4j.helpers;version="1.7.30",
org.slf4j.spi;version="1.7.30"
Bundle-ActivationPolicy: lazy
Require-Bundle: org.adempiere.base;bundle-version="11.0.0",
org.eclipse.core.runtime;bundle-version="3.24.100"
Service-Component: OSGI-INF/org.idempiere.ui.sso.oidc.factory.OIDCServiceFactory.xml
Bundle-ClassPath: lib/nimbus-jose-jwt.jar,
lib/oauth2-oidc-sdk.jar,
lib/json-smart.jar,
lib/lang-tag.jar,
lib/accessors-smart.jar,
lib/content-type.jar,
.

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<scr:component xmlns:scr="http://www.osgi.org/xmlns/scr/v1.1.0" immediate="true" name="org.idempiere.ui.sso.oidc.factory.OIDCServiceFactory">
<property name="service.ranking" type="Integer" value="0"/>
<service>
<provide interface="org.adempiere.base.sso.ISSOPrincipalFactory"/>
</service>
<implementation class="org.idempiere.ui.sso.oidc.factory.OIDCServiceFactory"/>
</scr:component>

View File

@ -0,0 +1,3 @@
# org.idempiere.ui.sso.oidc
OpenID Connect SSO provider for iDempiere Web Client

View File

@ -0,0 +1,11 @@
source.. = src/
output.. = target/classes/
bin.includes = META-INF/,\
.,\
OSGI-INF/,\
lib/nimbus-jose-jwt.jar,\
lib/oauth2-oidc-sdk.jar,\
lib/json-smart.jar,\
lib/lang-tag.jar,\
lib/accessors-smart.jar,\
lib/content-type.jar

View File

@ -0,0 +1,90 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.idempiere</groupId>
<artifactId>org.idempiere.parent</artifactId>
<version>${revision}</version>
<relativePath>../org.idempiere.parent/pom.xml</relativePath>
</parent>
<artifactId>org.idempiere.ui.sso.oidc</artifactId>
<packaging>eclipse-plugin</packaging>
<build>
<plugins>
<plugin>
<artifactId>maven-clean-plugin</artifactId>
<executions>
<execution>
<id>auto-clean</id>
<phase>validate</phase>
<goals>
<goal>clean</goal>
</goals>
</execution>
</executions>
<configuration>
<filesets>
<fileset>
<directory>${project.basedir}/lib</directory>
<includes>
<include>*.jar</include>
</includes>
<followSymlinks>false</followSymlinks>
</fileset>
</filesets>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<executions>
<execution>
<phase>validate</phase>
<goals>
<goal>copy</goal>
</goals>
<configuration>
<artifactItems>
<artifactItem>
<groupId>com.nimbusds</groupId>
<artifactId>oauth2-oidc-sdk</artifactId>
<version>10.7.1</version>
</artifactItem>
<artifactItem>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>9.31</version>
</artifactItem>
<artifactItem>
<groupId>net.minidev</groupId>
<artifactId>json-smart</artifactId>
<version>2.4.10</version>
</artifactItem>
<artifactItem>
<groupId>com.nimbusds</groupId>
<artifactId>lang-tag</artifactId>
<version>1.7</version>
</artifactItem>
<artifactItem>
<groupId>net.minidev</groupId>
<artifactId>accessors-smart</artifactId>
<version>2.4.9</version>
</artifactItem>
<artifactItem>
<groupId>com.nimbusds</groupId>
<artifactId>content-type</artifactId>
<version>2.2</version>
</artifactItem>
</artifactItems>
<outputDirectory>lib</outputDirectory>
<stripVersion>true</stripVersion>
<overWriteReleases>true</overWriteReleases>
<overWriteSnapshots>true</overWriteSnapshots>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,58 @@
/***********************************************************************
* 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: *
* - hengsin *
**********************************************************************/
package org.idempiere.ui.sso.oidc.factory;
import org.adempiere.base.sso.ISSOPrincipalFactory;
import org.adempiere.base.sso.ISSOPrincipalService;
import org.compiere.model.I_SSO_PrincipalConfig;
import org.osgi.service.component.annotations.Component;
import org.idempiere.ui.sso.oidc.service.OIDCPrincipalService;
/**
* Factory for OIDC principal service
* @author hengsin
*/
@Component(immediate = true, service = org.adempiere.base.sso.ISSOPrincipalFactory.class, property = {"service.ranking:Integer=0"})
public class OIDCServiceFactory implements ISSOPrincipalFactory {
/** SSO provider id for OIDC */
public static final String OIDC_PROVIDER_ID = "OIDC";
/**
* Default constructor
*/
public OIDCServiceFactory() {
}
@Override
public ISSOPrincipalService getSSOPrincipalService(I_SSO_PrincipalConfig config) {
if (config.getSSO_Provider().equals(OIDC_PROVIDER_ID))
return new OIDCPrincipalService(config);
return null;
}
}

View File

@ -0,0 +1,305 @@
/***********************************************************************
* 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: *
* - hengsin *
**********************************************************************/
package org.idempiere.ui.sso.oidc.service;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.text.ParseException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import org.adempiere.base.sso.ISSOPrincipalService;
import org.adempiere.base.sso.SSOUtils;
import org.compiere.model.I_SSO_PrincipalConfig;
import org.compiere.model.MSysConfig;
import org.compiere.util.Language;
import org.compiere.util.Util;
import com.nimbusds.oauth2.sdk.AuthorizationCode;
import com.nimbusds.oauth2.sdk.AuthorizationCodeGrant;
import com.nimbusds.oauth2.sdk.AuthorizationGrant;
import com.nimbusds.oauth2.sdk.GeneralException;
import com.nimbusds.oauth2.sdk.ResponseType;
import com.nimbusds.oauth2.sdk.Scope;
import com.nimbusds.oauth2.sdk.TokenErrorResponse;
import com.nimbusds.oauth2.sdk.TokenRequest;
import com.nimbusds.oauth2.sdk.TokenResponse;
import com.nimbusds.oauth2.sdk.auth.ClientAuthentication;
import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic;
import com.nimbusds.oauth2.sdk.auth.Secret;
import com.nimbusds.oauth2.sdk.http.HTTPResponse;
import com.nimbusds.oauth2.sdk.id.ClientID;
import com.nimbusds.oauth2.sdk.id.Issuer;
import com.nimbusds.oauth2.sdk.id.State;
import com.nimbusds.oauth2.sdk.token.AccessToken;
import com.nimbusds.oauth2.sdk.token.BearerAccessToken;
import com.nimbusds.openid.connect.sdk.AuthenticationErrorResponse;
import com.nimbusds.openid.connect.sdk.AuthenticationRequest;
import com.nimbusds.openid.connect.sdk.AuthenticationResponse;
import com.nimbusds.openid.connect.sdk.AuthenticationResponseParser;
import com.nimbusds.openid.connect.sdk.AuthenticationSuccessResponse;
import com.nimbusds.openid.connect.sdk.Nonce;
import com.nimbusds.openid.connect.sdk.OIDCTokenResponse;
import com.nimbusds.openid.connect.sdk.OIDCTokenResponseParser;
import com.nimbusds.openid.connect.sdk.Prompt;
import com.nimbusds.openid.connect.sdk.UserInfoRequest;
import com.nimbusds.openid.connect.sdk.UserInfoResponse;
import com.nimbusds.openid.connect.sdk.claims.UserInfo;
import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
/**
* Implement SSO principal provider service for OIDC
* @author hengsin
*/
public class OIDCPrincipalService implements ISSOPrincipalService {
/** code parameter from OIDC */
private static final String AUTHENTICATION_CODE_PARAMETER = "code";
private static final String OIDC_STATE = "oidc.state";
private I_SSO_PrincipalConfig principalConfig;
private OIDCProviderMetadata metaData = null;
/**
* Default constructor
* @param principleConfig
*/
public OIDCPrincipalService(I_SSO_PrincipalConfig principleConfig) {
this.principalConfig = principleConfig;
}
@Override
public String getUserName(Object principalObject) throws ParseException {
if (principalObject != null && principalObject instanceof UserInfo)
{
boolean isEmailLogin = MSysConfig.getBooleanValue(MSysConfig.USE_EMAIL_FOR_LOGIN, false);
UserInfo userInfo = (UserInfo) principalObject;
if (isEmailLogin)
return (String) userInfo.getEmailAddress();
else
return (String) userInfo.getName();
}
return null;
}
@Override
public Language getLanguage(Object principalObject) throws ParseException {
return Language.getBaseLanguage();
}
@Override
public boolean hasAuthenticationCode(HttpServletRequest request, HttpServletResponse response) {
String code = request.getParameter(AUTHENTICATION_CODE_PARAMETER);
return !Util.isEmpty(code, true);
}
@Override
public void getAuthenticationToken(HttpServletRequest request, HttpServletResponse response, String redirectMode)
throws Throwable {
String url = request.getRequestURL().toString();
String query = request.getQueryString();
if (query != null) {
url += "?" + query;
}
AuthenticationResponse authResponse = AuthenticationResponseParser.parse(new URI(url));
// Check the returned state parameter, must match the original
State state = (State) request.getSession().getAttribute(OIDC_STATE);
if (!state.equals(authResponse.getState())) {
// Unexpected or tampered response, stop!!!
request.getSession().removeAttribute(OIDC_STATE);
response.sendRedirect(getRedirectURL(principalConfig, redirectMode));
return;
}
if (!authResponse.indicatesSuccess()) {
// The request was denied or some error occurred
AuthenticationErrorResponse errorResponse = authResponse.toErrorResponse();
throw new RuntimeException(errorResponse.toString());
}
AuthenticationSuccessResponse successResponse = authResponse.toSuccessResponse();
// Retrieve the authorization code, to be used later to exchange the code for
// an access token at the token endpoint of the server
AuthorizationCode authorizationCode = successResponse.getAuthorizationCode();
URI redirectURI = new URI(getRedirectURL(principalConfig, redirectMode));
AuthorizationGrant authorizationGrant = new AuthorizationCodeGrant(authorizationCode, redirectURI);
TokenResponse tokenResponse = issueTokenRequest(authorizationGrant);
processTokenResponse(request, tokenResponse, true);
}
/**
* @param principalConfig
* @param clientType SSO_MODE_WEBUI, SSO_MODE_MONITOR or SSO_MODE_OSGI (webui, server monitor, osgi console)
* @return redirect url for clientType
*/
private String getRedirectURL(I_SSO_PrincipalConfig principalConfig, String clientType) {
if (SSOUtils.SSO_MODE_WEBUI.equals(clientType))
return principalConfig.getSSO_ApplicationRedirectURIs();
else if (SSOUtils.SSO_MODE_MONITOR.equals(clientType))
return principalConfig.getSSO_IDempMonitorRedirectURIs();
else if (SSOUtils.SSO_MODE_OSGI.equals(clientType))
return principalConfig.getSSO_OSGIRedirectURIs();
else
return null;
}
/**
* Process token response from keycloak server
* @param request
* @param tokenResponse
* @param getUserInfo true to get UserInfo from userinfo endpoint
* @throws URISyntaxException
* @throws IOException
* @throws GeneralException
*/
private void processTokenResponse(HttpServletRequest request, TokenResponse tokenResponse, boolean getUserInfo)
throws URISyntaxException, IOException, GeneralException {
if (!tokenResponse.indicatesSuccess()) {
// We got an error response...
TokenErrorResponse errorResponse = tokenResponse.toErrorResponse();
throw new RuntimeException(errorResponse.toJSONObject().toJSONString());
}
OIDCTokenResponse oidcTokenResponse = (OIDCTokenResponse) tokenResponse.toSuccessResponse();
// Get the access token
AccessToken accessToken = oidcTokenResponse.getOIDCTokens().getAccessToken();
if (getUserInfo) {
String userInfoURL = getMetaData().getUserInfoEndpointURI().toString();
URI userInfoEndpoint = new URI(userInfoURL);
BearerAccessToken bearerAccessToken = new BearerAccessToken(accessToken.getValue()); // The access token
// Make the request
HTTPResponse httpResponse = new UserInfoRequest(userInfoEndpoint, bearerAccessToken)
.toHTTPRequest()
.send();
// Parse the response
UserInfoResponse userInfoResponse = UserInfoResponse.parse(httpResponse);
if (!userInfoResponse.indicatesSuccess()) {
// The request failed, e.g. due to invalid or expired token
throw new RuntimeException(userInfoResponse.toErrorResponse().toString());
}
// Extract the claims
UserInfo userInfo = userInfoResponse.toSuccessResponse().getUserInfo();
setAuthenticationResult(request.getSession(), userInfo);
}
}
private OIDCProviderMetadata getMetaData() throws GeneralException, IOException {
if (metaData == null) {
String discoveryURI = principalConfig.getSSO_ApplicationDiscoveryURI();
Issuer issuer = new Issuer(discoveryURI.substring(0, discoveryURI.indexOf("/.well-known/openid-configuration")));
metaData = OIDCProviderMetadata.resolve(issuer);
}
return metaData;
}
/**
* Send Token request to oidc token endpoint
* @param authorizationGrant
* @return TokenResponse
* @throws URISyntaxException
* @throws IOException
* @throws GeneralException
*/
private TokenResponse issueTokenRequest(AuthorizationGrant authorizationGrant) throws URISyntaxException,
IOException, GeneralException {
// The credentials to authenticate the client at the token endpoint
ClientID clientID = new ClientID(principalConfig.getSSO_ApplicationClientID());
Secret clientSecret = new Secret(principalConfig.getSSO_ApplicationSecretKey());
ClientAuthentication clientAuth = new ClientSecretBasic(clientID, clientSecret);
// The token endpoint
String tokenEndpointURL = getMetaData().getTokenEndpointURI().toString();
URI tokenEndpoint = new URI(tokenEndpointURL);
// Make the token request
TokenRequest tokenRequest = new TokenRequest(tokenEndpoint, clientAuth, authorizationGrant);
return OIDCTokenResponseParser.parse(tokenRequest.toHTTPRequest().send());
}
/**
* Store UserInfo as session token
* @param session
* @param userInfo
*/
private void setAuthenticationResult(HttpSession session, UserInfo userInfo) {
session.setAttribute(ISSOPrincipalService.SSO_PRINCIPAL_SESSION_TOKEN, userInfo);
}
@Override
public boolean isAuthenticated(HttpServletRequest request, HttpServletResponse response) {
return request.getSession().getAttribute(ISSOPrincipalService.SSO_PRINCIPAL_SESSION_TOKEN) != null;
}
@Override
public void redirectForAuthentication(HttpServletRequest request, HttpServletResponse response, String redirectMode)
throws IOException {
AuthenticationRequest authRequest = null;
try {
String url = getMetaData().getAuthorizationEndpointURI().toString();
authRequest = new AuthenticationRequest.Builder(
new ResponseType(AUTHENTICATION_CODE_PARAMETER),
new Scope("openid", "profile", "email"),
new ClientID(principalConfig.getSSO_ApplicationClientID()),
new URI(getRedirectURL(principalConfig, redirectMode)))
.state(new State())
.nonce(new Nonce())
.prompt(new Prompt(Prompt.Type.LOGIN))
.endpointURI(new URI(url))
.build();
} catch (URISyntaxException e) {
throw new RuntimeException(e);
} catch (GeneralException e) {
throw new RuntimeException(e);
}
//store state for response verification
request.getSession().setAttribute(OIDC_STATE, authRequest.getState());
URI redirectURI = authRequest.toURI();
response.sendRedirect(redirectURI.toURL().toString());
}
@Override
public void removePrincipalFromSession(HttpServletRequest httpRequest) {
httpRequest.getSession().removeAttribute(ISSOPrincipalService.SSO_PRINCIPAL_SESSION_TOKEN);
httpRequest.getSession().removeAttribute(OIDC_STATE);
}
}

View File

@ -56,6 +56,7 @@
<module>org.idempiere.zk-feature</module>
<module>org.idempiere.webservices.client-feature</module>
<module>org.idempiere.jetty.osgi.boot.fragment</module>
<module>org.idempiere.ui.sso.oidc</module>
<module>org.idempiere.p2</module>
<module>org.idempiere.javadoc</module>
<module>org.idempiere.test-feature</module>