diff --git a/server/plugins/oidc/pom.xml b/server/plugins/oidc/pom.xml index d758dd2bf2..5f9b4fc3d2 100644 --- a/server/plugins/oidc/pom.xml +++ b/server/plugins/oidc/pom.xml @@ -15,15 +15,6 @@ ${project.groupId}:${project.artifactId} - - org.pac4j - pac4j-core - - - org.pac4j - pac4j-oidc - - com.walmartlabs.concord.server concord-server-sdk @@ -79,6 +70,11 @@ config provided + + com.fasterxml.jackson.core + jackson-databind + provided + org.junit.jupiter diff --git a/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcAuthFilter.java b/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcAuthFilter.java index fe84070e8b..b684e4db39 100644 --- a/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcAuthFilter.java +++ b/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcAuthFilter.java @@ -4,7 +4,7 @@ * ***** * Concord * ----- - * Copyright (C) 2020 Ivan Bodrov + * Copyright (C) 2017 - 2025 Walmart Inc. * ----- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,59 +20,49 @@ * ===== */ -import org.pac4j.core.config.Config; -import org.pac4j.core.context.JEEContext; -import org.pac4j.core.exception.http.RedirectionAction; -import org.pac4j.core.util.Pac4jConstants; -import org.pac4j.oidc.client.OidcClient; - import javax.inject.Inject; -import javax.inject.Named; import javax.servlet.*; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; +import java.util.UUID; +// TODO can be implemented as a JAX-RS resource public class OidcAuthFilter implements Filter { public static final String URL = "/api/service/oidc/auth"; + private static final String SESSION_STATE_KEY = "OIDC_STATE"; + private static final String SESSION_REDIRECT_KEY = "OIDC_REDIRECT_URL"; private final PluginConfiguration pluginConfig; - private final Config oidcConfig; - private final OidcClient client; + private final OidcService oidcService; @Inject - public OidcAuthFilter(PluginConfiguration pluginConfig, @Named("oidc") Config oidcConfig, OidcClient client) { + public OidcAuthFilter(PluginConfiguration pluginConfig, OidcService oidcService) { this.pluginConfig = pluginConfig; - this.oidcConfig = oidcConfig; - this.client = client; - - if (pluginConfig.isEnabled() && !client.isInitialized()) { - client.init(); - } + this.oidcService = oidcService; } @Override - @SuppressWarnings("unchecked") public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException { - HttpServletRequest req = (HttpServletRequest) request; - HttpServletResponse resp = (HttpServletResponse) response; + var req = (HttpServletRequest) request; + var resp = (HttpServletResponse) response; if (!pluginConfig.isEnabled()) { resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "OIDC disabled"); return; } - JEEContext context = new JEEContext(req, resp); - - String redirectUrl = req.getParameter("from"); - context.getSessionStore().set(context, Pac4jConstants.REQUESTED_URL, redirectUrl); + var redirectUrl = req.getParameter("from"); + var state = UUID.randomUUID().toString(); + var callbackUrl = pluginConfig.getUrlBase() + OidcCallbackFilter.URL + "?client_name=oidc"; - RedirectionAction action = client.getRedirectionActionBuilder() - .getRedirectionAction(context) - .orElseThrow(() -> new IllegalStateException("Can't get a redirection action for the request")); + var session = req.getSession(true); + session.setAttribute(SESSION_STATE_KEY, state); + session.setAttribute(SESSION_REDIRECT_KEY, redirectUrl); - oidcConfig.getHttpActionAdapter().adapt(action, context); + var authUrl = oidcService.buildAuthorizationUrl(callbackUrl, state); + resp.sendRedirect(authUrl); } @Override diff --git a/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcAuthenticationHandler.java b/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcAuthenticationHandler.java index 4233710e9d..70f816c55a 100644 --- a/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcAuthenticationHandler.java +++ b/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcAuthenticationHandler.java @@ -4,7 +4,7 @@ * ***** * Concord * ----- - * Copyright (C) 2017 - 2020 Walmart Inc. + * Copyright (C) 2017 - 2025 Walmart Inc. * ----- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,12 +22,8 @@ import com.walmartlabs.concord.server.boot.filters.AuthenticationHandler; import org.apache.shiro.authc.AuthenticationToken; -import org.pac4j.core.context.JEEContext; -import org.pac4j.core.credentials.TokenCredentials; -import org.pac4j.core.profile.ProfileManager; -import org.pac4j.oidc.config.OidcConfiguration; -import org.pac4j.oidc.credentials.authenticator.UserInfoOidcAuthenticator; -import org.pac4j.oidc.profile.OidcProfile; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import javax.inject.Inject; import javax.servlet.ServletRequest; @@ -35,22 +31,23 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; -import java.util.Optional; public class OidcAuthenticationHandler implements AuthenticationHandler { + private static final Logger log = LoggerFactory.getLogger(OidcAuthenticationHandler.class); private static final String FORM_URL_PATTERN = "/forms/.*"; private static final String AUTHORIZATION_HEADER = "Authorization"; private static final String HEADER_PREFIX = "Bearer"; + private static final String SESSION_PROFILE_KEY = "OIDC_USER_PROFILE"; private final PluginConfiguration cfg; - private final OidcConfiguration oidcCfg; + private final OidcService oidcService; @Inject - public OidcAuthenticationHandler(PluginConfiguration cfg, OidcConfiguration oidcCfg) { + public OidcAuthenticationHandler(PluginConfiguration cfg, OidcService oidcService) { this.cfg = cfg; - this.oidcCfg = oidcCfg; + this.oidcService = oidcService; } @Override @@ -59,33 +56,32 @@ public AuthenticationToken createToken(ServletRequest request, ServletResponse r return null; } - HttpServletRequest req = (HttpServletRequest) request; - HttpServletResponse resp = (HttpServletResponse) response; - JEEContext context = new JEEContext(req, resp); + var req = (HttpServletRequest) request; - Optional profile; - - // check the token first - String header = req.getHeader(AUTHORIZATION_HEADER); - if (header != null) { - String[] as = header.split(" "); - if (as.length != 2 || !as[0].equals(HEADER_PREFIX)) { + var header = req.getHeader(AUTHORIZATION_HEADER); + if (header == null) { + var session = req.getSession(false); + if (session == null) { return null; } - TokenCredentials credentials = new TokenCredentials(as[1].trim()); - - UserInfoOidcAuthenticator authenticator = new UserInfoOidcAuthenticator(oidcCfg); - authenticator.validate(credentials, context); + var profile = (UserProfile) session.getAttribute(SESSION_PROFILE_KEY); + return new OidcToken(profile); + } - // we know that UserInfoOidcAuthenticator produces OidcProfile, so we can cast to it here - profile = Optional.ofNullable((OidcProfile) credentials.getUserProfile()); - } else { - ProfileManager profileManager = new ProfileManager<>(context); - profile = profileManager.get(true); + var as = header.split(" "); + if (as.length != 2 || !as[0].equals(HEADER_PREFIX)) { + return null; } - return profile.map(OidcToken::new).orElse(null); + var accessToken = as[1].trim(); + try { + var profile = oidcService.validateToken(accessToken); + return new OidcToken(profile); + } catch (IOException e) { + log.warn("Token validation failed: {}", e.getMessage()); + return null; + } } @Override @@ -94,8 +90,8 @@ public boolean onAccessDenied(ServletRequest request, ServletResponse response) return false; } - HttpServletRequest req = (HttpServletRequest) request; - HttpServletResponse resp = (HttpServletResponse) response; + var req = (HttpServletRequest) request; + var resp = (HttpServletResponse) response; if (req.getRequestURI().matches(FORM_URL_PATTERN)) { resp.sendRedirect(resp.encodeRedirectURL(OidcAuthFilter.URL + "?from=" + req.getRequestURL())); diff --git a/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcCallbackFilter.java b/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcCallbackFilter.java index b72f273ff3..d7533fcb59 100644 --- a/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcCallbackFilter.java +++ b/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcCallbackFilter.java @@ -4,7 +4,7 @@ * ***** * Concord * ----- - * Copyright (C) 2020 Ivan Bodrov + * Copyright (C) 2017 - 2025 Walmart Inc. * ----- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,21 +20,16 @@ * ===== */ -import org.pac4j.core.config.Config; -import org.pac4j.core.context.JEEContext; -import org.pac4j.core.context.session.SessionStore; -import org.pac4j.core.engine.CallbackLogic; -import org.pac4j.core.exception.TechnicalException; -import org.pac4j.core.util.Pac4jConstants; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.inject.Inject; -import javax.inject.Named; -import javax.servlet.*; +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; -import javax.servlet.http.HttpSession; import java.io.IOException; public class OidcCallbackFilter implements Filter { @@ -42,39 +37,43 @@ public class OidcCallbackFilter implements Filter { private static final Logger log = LoggerFactory.getLogger(OidcCallbackFilter.class); public static final String URL = "/api/service/oidc/callback"; + private static final String SESSION_STATE_KEY = "OIDC_STATE"; + private static final String SESSION_REDIRECT_KEY = "OIDC_REDIRECT_URL"; + private static final String SESSION_PROFILE_KEY = "OIDC_USER_PROFILE"; private final PluginConfiguration cfg; - private final Config pac4jConfig; + private final OidcService oidcService; @Inject - public OidcCallbackFilter(PluginConfiguration cfg, - @Named("oidc") Config pac4jConfig) { - + public OidcCallbackFilter(PluginConfiguration cfg, OidcService oidcService) { this.cfg = cfg; - this.pac4jConfig = pac4jConfig; + this.oidcService = oidcService; } @Override - @SuppressWarnings("unchecked") public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException { - HttpServletRequest req = (HttpServletRequest) request; - HttpServletResponse resp = (HttpServletResponse) response; + var req = (HttpServletRequest) request; + var resp = (HttpServletResponse) response; if (!cfg.isEnabled()) { resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "OIDC disabled"); return; } - JEEContext context = new JEEContext(req, resp, pac4jConfig.getSessionStore()); + var session = req.getSession(false); + if (session == null) { + resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "No session"); + return; + } - String postLoginUrl = removeRequestedUrl(context); + var postLoginUrl = (String) session.getAttribute(SESSION_REDIRECT_KEY); if (postLoginUrl == null || postLoginUrl.trim().isEmpty()) { postLoginUrl = cfg.getAfterLoginUrl(); } - String error = req.getParameter("error"); + var error = req.getParameter("error"); if (error != null) { - String derivedError = "unknown"; + var derivedError = "unknown"; if ("access_denied".equals(error)) { derivedError = "oidc_access_denied"; } @@ -82,37 +81,31 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha return; } - try { - CallbackLogic callback = pac4jConfig.getCallbackLogic(); - callback.perform(context, pac4jConfig, pac4jConfig.getHttpActionAdapter(), postLoginUrl, true, false, true, OidcPluginModule.CLIENT_NAME); - } catch (TechnicalException e) { - log.warn("OIDC callback error: {}", e.getMessage()); - HttpSession session = req.getSession(false); - if (session != null) { - session.invalidate(); - } + var code = req.getParameter("code"); + var state = req.getParameter("state"); + var expectedState = (String) session.getAttribute(SESSION_STATE_KEY); + + if (code == null || state == null || !state.equals(expectedState)) { + log.warn("Invalid callback parameters: code={}, state={}, expectedState={}", code != null, state, expectedState); + session.invalidate(); resp.sendRedirect(resp.encodeRedirectURL(OidcAuthFilter.URL + "?from=" + postLoginUrl)); + return; } - } - @Override - public void init(FilterConfig filterConfig) { - // do nothing - } + try { + var redirectUri = cfg.getUrlBase() + URL + "?client_name=oidc"; + var profile = oidcService.exchangeCodeForProfile(code, redirectUri); - @Override - public void destroy() { - // do nothing - } + session.setAttribute(SESSION_PROFILE_KEY, profile); + session.removeAttribute(SESSION_STATE_KEY); + session.removeAttribute(SESSION_REDIRECT_KEY); + + resp.sendRedirect(resp.encodeRedirectURL(postLoginUrl)); - @SuppressWarnings("unchecked") - private static String removeRequestedUrl(JEEContext context) { - SessionStore sessionStore = context.getSessionStore(); - Object result = sessionStore.get(context, Pac4jConstants.REQUESTED_URL).orElse(null); - sessionStore.set(context, Pac4jConstants.REQUESTED_URL, ""); - if (result instanceof String) { - return (String) result; + } catch (Exception e) { + log.warn("OIDC callback error: {}", e.getMessage()); + session.invalidate(); + resp.sendRedirect(resp.encodeRedirectURL(OidcAuthFilter.URL + "?from=" + postLoginUrl)); } - return null; } } diff --git a/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcFilterChainConfigurator.java b/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcFilterChainConfigurator.java index bee62a8caf..dfc7309c3c 100644 --- a/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcFilterChainConfigurator.java +++ b/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcFilterChainConfigurator.java @@ -4,7 +4,7 @@ * ***** * Concord * ----- - * Copyright (C) 2020 Ivan Bodrov + * Copyright (C) 2017 - 2025 Walmart Inc. * ----- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,6 +24,9 @@ import org.apache.shiro.web.filter.mgt.FilterChainManager; import javax.inject.Inject; +import java.util.Objects; + +import static java.util.Objects.requireNonNull; public class OidcFilterChainConfigurator implements FilterChainConfigurator { @@ -36,9 +39,9 @@ public OidcFilterChainConfigurator(OidcAuthFilter authFilter, OidcCallbackFilter callbackFilter, OidcLogoutFilter logoutFilter) { - this.authFilter = authFilter; - this.callbackFilter = callbackFilter; - this.logoutFilter = logoutFilter; + this.authFilter = requireNonNull(authFilter); + this.callbackFilter = requireNonNull(callbackFilter); + this.logoutFilter = requireNonNull(logoutFilter); } @Override diff --git a/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcLogoutFilter.java b/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcLogoutFilter.java index 645bb088a7..e7fcfb195e 100644 --- a/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcLogoutFilter.java +++ b/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcLogoutFilter.java @@ -4,7 +4,7 @@ * ***** * Concord * ----- - * Copyright (C) 2017 - 2020 Walmart Inc. + * Copyright (C) 2017 - 2025 Walmart Inc. * ----- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,12 +20,7 @@ * ===== */ -import org.pac4j.core.config.Config; -import org.pac4j.core.context.JEEContext; -import org.pac4j.core.engine.LogoutLogic; - import javax.inject.Inject; -import javax.inject.Named; import javax.servlet.*; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -37,34 +32,23 @@ public class OidcLogoutFilter implements Filter { public static final String URL = "/api/service/oidc/logout"; private final PluginConfiguration cfg; - private final Config pac4jConfig; @Inject - public OidcLogoutFilter(PluginConfiguration cfg, - @Named("oidc") Config pac4jConfig) { - + public OidcLogoutFilter(PluginConfiguration cfg) { this.cfg = cfg; - this.pac4jConfig = pac4jConfig; } @Override - @SuppressWarnings("unchecked") public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { - HttpServletRequest req = (HttpServletRequest) request; - HttpServletResponse resp = (HttpServletResponse) response; - - JEEContext context = new JEEContext(req, resp, pac4jConfig.getSessionStore()); - - LogoutLogic logout = pac4jConfig.getLogoutLogic(); - String afterLogoutUrl = Optional.ofNullable(req.getParameter("from")).orElse(cfg.getAfterLogoutUrl()); - logout.perform(context, pac4jConfig, pac4jConfig.getHttpActionAdapter(), afterLogoutUrl, null, true, true, true); - } + var req = (HttpServletRequest) request; + var resp = (HttpServletResponse) response; - @Override - public void init(FilterConfig filterConfig) { - } + var session = req.getSession(false); + if (session != null) { + session.invalidate(); + } - @Override - public void destroy() { + var afterLogoutUrl = Optional.ofNullable(req.getParameter("from")).orElse(cfg.getAfterLogoutUrl()); + resp.sendRedirect(afterLogoutUrl); } } diff --git a/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcPluginModule.java b/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcPluginModule.java index bc4b48b418..7aff2a8435 100644 --- a/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcPluginModule.java +++ b/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcPluginModule.java @@ -4,7 +4,7 @@ * ***** * Concord * ----- - * Copyright (C) 2017 - 2020 Walmart Inc. + * Copyright (C) 2017 - 2025 Walmart Inc. * ----- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,70 +21,23 @@ */ import com.google.inject.AbstractModule; -import com.google.inject.Provides; import com.walmartlabs.concord.server.boot.FilterChainConfigurator; import com.walmartlabs.concord.server.boot.filters.AuthenticationHandler; import org.apache.shiro.realm.Realm; -import org.pac4j.core.client.Clients; -import org.pac4j.core.config.Config; -import org.pac4j.core.context.JEEContext; -import org.pac4j.core.context.session.JEESessionStore; -import org.pac4j.core.engine.DefaultCallbackLogic; -import org.pac4j.core.engine.DefaultLogoutLogic; -import org.pac4j.core.http.adapter.JEEHttpActionAdapter; -import org.pac4j.oidc.client.OidcClient; -import org.pac4j.oidc.config.OidcConfiguration; import javax.inject.Named; -import javax.inject.Singleton; +import static com.google.inject.Scopes.SINGLETON; import static com.google.inject.multibindings.Multibinder.newSetBinder; @Named public class OidcPluginModule extends AbstractModule { - public static final String CLIENT_NAME = "oidc"; - @Override protected void configure() { + bind(OidcService.class).in(SINGLETON); newSetBinder(binder(), AuthenticationHandler.class).addBinding().to(OidcAuthenticationHandler.class); newSetBinder(binder(), FilterChainConfigurator.class).addBinding().to(OidcFilterChainConfigurator.class); newSetBinder(binder(), Realm.class).addBinding().to(OidcRealm.class); } - - @Provides - public OidcConfiguration oidcConfiguration(PluginConfiguration cfg) { - OidcConfiguration oidcCfg = new OidcConfiguration(); - oidcCfg.setClientId(cfg.getClientId()); - oidcCfg.setSecret(cfg.getSecret()); - oidcCfg.setDiscoveryURI(cfg.getDiscoveryUri()); - if (cfg.getScopes() != null) { - oidcCfg.setScope(String.join(" ", cfg.getScopes())); - } - return oidcCfg; - } - - @Provides - @Singleton - public OidcClient oidcClient(PluginConfiguration cfg, OidcConfiguration oidcCfg) { - OidcClient client = new OidcClient<>(oidcCfg); - client.setName(CLIENT_NAME); - client.setCallbackUrl(cfg.getUrlBase() + OidcCallbackFilter.URL); - return client; - } - - @Provides - @Named("oidc") - public Config pac4jConfig(OidcClient client) { - Config config = new Config(); - config.setSessionStore(new JEESessionStore()); - config.setCallbackLogic(new DefaultCallbackLogic()); - config.setLogoutLogic(new DefaultLogoutLogic()); - config.setHttpActionAdapter(JEEHttpActionAdapter.INSTANCE); - - Clients clients = new Clients(client); - config.setClients(clients); - - return config; - } } diff --git a/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcRealm.java b/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcRealm.java index 96f0c72365..788e10af21 100644 --- a/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcRealm.java +++ b/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcRealm.java @@ -4,7 +4,7 @@ * ***** * Concord * ----- - * Copyright (C) 2017 - 2020 Walmart Inc. + * Copyright (C) 2017 - 2025 Walmart Inc. * ----- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -38,7 +38,6 @@ import org.apache.shiro.authz.AuthorizationInfo; import org.apache.shiro.realm.AuthorizingRealm; import org.apache.shiro.subject.PrincipalCollection; -import org.pac4j.oidc.profile.OidcProfile; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -84,16 +83,16 @@ public boolean supports(AuthenticationToken token) { protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { OidcToken t = (OidcToken) token; - OidcProfile profile = t.getProfile(); + UserProfile profile = t.getProfile(); // TODO replace getOrCreate+update with a single method? - String username = profile.getEmail().toLowerCase(); + String username = profile.email().toLowerCase(); UserEntry u = userManager.getOrCreate(username, null, UserType.LOCAL) - .orElseThrow(() -> new ConcordApplicationException("User not found: " + profile.getEmail())); + .orElseThrow(() -> new ConcordApplicationException("User not found: " + profile.email())); UUID userId = u.getId(); - userManager.update(userId, profile.getDisplayName(), profile.getEmail(), null, false, null); + userManager.update(userId, profile.displayName(), profile.email(), null, false, null); Set newTeams = new HashSet<>(); teamDao.tx(tx -> { @@ -142,7 +141,7 @@ protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principal return SecurityUtils.toAuthorizationInfo(principals, roles); } - private static boolean match(OidcProfile profile, List sources) { + private static boolean match(UserProfile profile, List sources) { for (PluginConfiguration.Source source : sources) { String attr = source.attribute(); String pattern = source.pattern(); @@ -210,10 +209,10 @@ public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo OidcToken stored = (OidcToken) account.getCredentials(); OidcToken received = (OidcToken) token; - Object a = stored.getProfile().getAccessToken(); - Object b = received.getProfile().getAccessToken(); + String a = stored.getProfile().accessToken(); + String b = received.getProfile().accessToken(); - return a.equals(b); + return a != null && a.equals(b); } } } diff --git a/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcService.java b/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcService.java new file mode 100644 index 0000000000..bb2bc4f457 --- /dev/null +++ b/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcService.java @@ -0,0 +1,173 @@ +package com.walmartlabs.concord.server.plugins.oidc; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart Inc. + * ----- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ===== + */ + +import com.fasterxml.jackson.databind.ObjectMapper; + +import javax.inject.Inject; +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpRequest.BodyPublishers; +import java.net.http.HttpResponse.BodyHandlers; +import java.time.Duration; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Objects.requireNonNull; + +public class OidcService { + + private final PluginConfiguration cfg; + private final HttpClient httpClient; + private final ObjectMapper objectMapper; + private volatile DiscoveryDocument discoveryDocument; + + @Inject + public OidcService(PluginConfiguration cfg) { + this.cfg = requireNonNull(cfg); + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(30)) + .build(); + this.objectMapper = new ObjectMapper(); + } + + public String buildAuthorizationUrl(String redirectUri, String state) { + var discovery = getDiscoveryDocument(); + var scopes = cfg.getScopes() != null ? String.join(" ", cfg.getScopes()) : "openid email profile"; + return discovery.authorizationEndpoint() + "?" + + "response_type=code" + + "&client_id=" + urlEncode(cfg.getClientId()) + + "&redirect_uri=" + urlEncode(redirectUri) + + "&scope=" + urlEncode(scopes) + + "&state=" + urlEncode(state); + } + + public UserProfile exchangeCodeForProfile(String code, String redirectUri) throws IOException { + var discovery = getDiscoveryDocument(); + + var tokenRequest = "grant_type=authorization_code" + + "&code=" + urlEncode(code) + + "&redirect_uri=" + urlEncode(redirectUri) + + "&client_id=" + urlEncode(cfg.getClientId()) + + "&client_secret=" + urlEncode(cfg.getSecret()); + + var request = HttpRequest.newBuilder() + .uri(URI.create(discovery.tokenEndpoint())) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(BodyPublishers.ofString(tokenRequest)) + .build(); + + try { + var response = httpClient.send(request, BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new IOException("Token exchange failed: " + response.statusCode() + " " + response.body()); + } + + var tokenResponse = objectMapper.readTree(response.body()); + var accessToken = tokenResponse.get("access_token").asText(); + if (accessToken == null) { + throw new IOException("No access token in response"); + } + + return getUserInfo(accessToken, discovery.userinfoEndpoint()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Request interrupted", e); + } + } + + public UserProfile validateToken(String accessToken) throws IOException { + var discovery = getDiscoveryDocument(); + return getUserInfo(accessToken, discovery.userinfoEndpoint()); + } + + private UserProfile getUserInfo(String accessToken, String userinfoEndpoint) throws IOException { + var request = HttpRequest.newBuilder() + .uri(URI.create(userinfoEndpoint)) + .header("Authorization", "Bearer " + accessToken) + .GET() + .build(); + + try { + var response = httpClient.send(request, BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new IOException("UserInfo request failed: " + response.statusCode() + " " + response.body()); + } + + var userInfo = objectMapper.readTree(response.body()); + var id = userInfo.get("sub").asText(); + var email = userInfo.get("email").asText(); + var displayName = userInfo.get("name").asText(email); + return new UserProfile(id, email, displayName, accessToken, userInfo); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Request interrupted", e); + } + } + + private DiscoveryDocument getDiscoveryDocument() { + if (discoveryDocument == null) { + synchronized (this) { + if (discoveryDocument == null) { + discoveryDocument = fetchDiscoveryDocument(); + } + } + } + return discoveryDocument; + } + + private DiscoveryDocument fetchDiscoveryDocument() { + try { + var request = HttpRequest.newBuilder() + .uri(URI.create(cfg.getDiscoveryUri())) + .GET() + .build(); + + var response = httpClient.send(request, BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new RuntimeException("Discovery document fetch failed: " + response.statusCode()); + } + + var discovery = objectMapper.readTree(response.body()); + return new DiscoveryDocument( + discovery.get("authorization_endpoint").asText(), + discovery.get("token_endpoint").asText(), + discovery.get("userinfo_endpoint").asText() + ); + } catch (IOException | InterruptedException e) { + throw new RuntimeException("Failed to fetch OIDC discovery document", e); + } + } + + private static String urlEncode(String value) { + return URLEncoder.encode(value, UTF_8); + } + + private record DiscoveryDocument( + String authorizationEndpoint, + String tokenEndpoint, + String userinfoEndpoint + ) { + } +} diff --git a/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcToken.java b/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcToken.java index 5e1364e971..f756aa4f7c 100644 --- a/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcToken.java +++ b/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/OidcToken.java @@ -4,7 +4,7 @@ * ***** * Concord * ----- - * Copyright (C) 2017 - 2020 Walmart Inc. + * Copyright (C) 2017 - 2025 Walmart Inc. * ----- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,27 +21,28 @@ */ import org.apache.shiro.authc.AuthenticationToken; -import org.pac4j.oidc.profile.OidcProfile; +import java.io.Serial; import java.io.Serializable; public class OidcToken implements AuthenticationToken, Serializable { + @Serial private static final long serialVersionUID = 1L; - private final OidcProfile profile; + private final UserProfile profile; - public OidcToken(OidcProfile profile) { + public OidcToken(UserProfile profile) { this.profile = profile; } - public OidcProfile getProfile() { + public UserProfile getProfile() { return profile; } @Override public Object getPrincipal() { - return profile.getId(); + return profile.id(); } @Override diff --git a/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/PluginConfiguration.java b/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/PluginConfiguration.java index 3a147bb14e..bfa36c4bef 100644 --- a/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/PluginConfiguration.java +++ b/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/PluginConfiguration.java @@ -4,7 +4,7 @@ * ***** * Concord * ----- - * Copyright (C) 2017 - 2020 Walmart Inc. + * Copyright (C) 2017 - 2025 Walmart Inc. * ----- * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/UserProfile.java b/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/UserProfile.java new file mode 100644 index 0000000000..aa87ee7c52 --- /dev/null +++ b/server/plugins/oidc/src/main/java/com/walmartlabs/concord/server/plugins/oidc/UserProfile.java @@ -0,0 +1,35 @@ +package com.walmartlabs.concord.server.plugins.oidc; + +/*- + * ***** + * Concord + * ----- + * Copyright (C) 2017 - 2025 Walmart Inc. + * ----- + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ===== + */ + +import com.fasterxml.jackson.databind.JsonNode; + +public record UserProfile( + String id, + String email, + String displayName, + String accessToken, + JsonNode attributes) { + + public Object getAttribute(String name) { + return attributes != null ? attributes.get(name) : null; + } +} diff --git a/targetplatform/pom.xml b/targetplatform/pom.xml index bd141fe2c9..6902f30b83 100644 --- a/targetplatform/pom.xml +++ b/targetplatform/pom.xml @@ -106,7 +106,6 @@ 4.9.0 0.9.10 8.23 - 4.5.8 0.1.2 4.7.0 42.7.3 @@ -1232,16 +1231,6 @@ jna ${jna.version} - - org.pac4j - pac4j-core - ${pac4j.version} - - - org.pac4j - pac4j-oidc - ${pac4j.version} - org.jboss.logging jboss-logging