diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/client/service/ClientConverter.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/client/service/ClientConverter.java index 61ce4635bd..b9f693b886 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/api/client/service/ClientConverter.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/client/service/ClientConverter.java @@ -118,6 +118,7 @@ public RegisteredClientDTO registeredClientDtoFromEntity(ClientDetailsEntity ent clientDTO.setJwksUri(entity.getJwksUri()); clientDTO.setRedirectUris(cloneSet(entity.getRedirectUris())); + clientDTO.setPostLogoutRedirectUris(entity.getPostLogoutRedirectUris()); clientDTO.setTokenEndpointAuthMethod(TokenEndpointAuthenticationMethod .valueOf(Optional.ofNullable(entity.getTokenEndpointAuthMethod()) @@ -205,6 +206,8 @@ public ClientDetailsEntity entityFromRegistrationRequest(RegisteredClientDTO dto client.setRedirectUris(cloneSet(dto.getRedirectUris())); + client.setPostLogoutRedirectUris(dto.getPostLogoutRedirectUris()); + client.setScope(cloneSet(dto.getScope())); client.setGrantTypes(new HashSet<>()); diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/api/common/client/RegisteredClientDTO.java b/iam-login-service/src/main/java/it/infn/mw/iam/api/common/client/RegisteredClientDTO.java index a9abd602bc..83b1a74a62 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/api/common/client/RegisteredClientDTO.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/api/common/client/RegisteredClientDTO.java @@ -116,6 +116,13 @@ public class RegisteredClientDTO { ClientViews.NoSecretDynamicRegistration.class, ClientViews.DynamicRegistration.class}) private String clientUri; + @Valid + @JsonView({ClientViews.Limited.class, ClientViews.ClientManagement.class, + ClientViews.NoSecretDynamicRegistration.class, ClientViews.DynamicRegistration.class}) + private Set<@RedirectURI(message = "not a valid URL", + groups = {OnDynamicClientRegistration.class, OnDynamicClientUpdate.class, + OnClientCreation.class, OnClientUpdate.class}) String> postLogoutRedirectUris; + @Size(max = 2048, groups = {OnDynamicClientRegistration.class, OnDynamicClientUpdate.class, OnClientCreation.class, OnClientUpdate.class}) @@ -338,6 +345,14 @@ public void setClientUri(String clientUri) { this.clientUri = clientUri; } + public Set getPostLogoutRedirectUris() { + return postLogoutRedirectUris; + } + + public void setPostLogoutRedirectUris(Set postLogoutRedirectUris) { + this.postLogoutRedirectUris = postLogoutRedirectUris; + } + public String getTosUri() { return tosUri; } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/authn/OidcLogoutSuccessHandler.java b/iam-login-service/src/main/java/it/infn/mw/iam/authn/OidcLogoutSuccessHandler.java new file mode 100644 index 0000000000..3e0404cd72 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/authn/OidcLogoutSuccessHandler.java @@ -0,0 +1,114 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2021 + * + * 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. + */ +package it.infn.mw.iam.authn; + +import java.io.IOException; +import java.text.ParseException; +import java.util.List; +import java.util.Optional; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.mitre.jwt.assertion.impl.SelfAssertionValidator; +import org.mitre.oauth2.model.ClientDetailsEntity; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import com.nimbusds.jwt.JWT; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.JWTParser; + +import it.infn.mw.iam.persistence.repository.client.IamClientRepository; + +@Component +public class OidcLogoutSuccessHandler implements LogoutSuccessHandler { + + private static final Logger LOG = LoggerFactory.getLogger(OidcLogoutSuccessHandler.class); + + private IamClientRepository clientRepo; + private SelfAssertionValidator validator; + + public OidcLogoutSuccessHandler(IamClientRepository clientRepo, + SelfAssertionValidator validator) { + this.clientRepo = clientRepo; + this.validator = validator; + } + + @Override + public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException { + + String idTokenHint = request.getParameter("id_token_hint"); + String redirectUri = request.getParameter("post_logout_redirect_uri"); + String state = request.getParameter("state"); + + String fallback = "/login?logout"; + + if (idTokenHint == null || redirectUri == null) { + LOG.debug("OIDC logout: missing id_token_hint or post_logout_redirect_uri"); + response.sendRedirect(fallback); + return; + } + + try { + JWT idToken = JWTParser.parse(idTokenHint); + + if (!validator.isValid(idToken)) { + LOG.debug("OIDC logout: id_token_hint validation failed"); + response.sendRedirect(fallback); + return; + } + + JWTClaimsSet idTokenClaims = idToken.getJWTClaimsSet(); + List audience = idTokenClaims.getAudience(); + + Optional client = audience.stream() + .map(clientRepo::findByClientId) + .filter(Optional::isPresent) + .map(Optional::get) + .findFirst(); + + if (client.isEmpty()) { + LOG.debug("OIDC logout: no matching client found in audiences {}", audience); + response.sendRedirect(fallback); + return; + } + + if (!client.get().getPostLogoutRedirectUris().contains(redirectUri)) { + LOG.debug("OIDC logout redirect denied: invalid redirect URI {}", redirectUri); + response.sendRedirect(fallback); + return; + } + + UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromHttpUrl(redirectUri); + + if (state != null) { + uriBuilder.queryParam("state", state); + } + + response.sendRedirect(uriBuilder.toUriString()); + + } catch (ParseException e) { + LOG.debug("OIDC logout: invalid id_token_hint format", e); + response.sendRedirect(fallback); + } + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/config/security/IamWebSecurityConfig.java b/iam-login-service/src/main/java/it/infn/mw/iam/config/security/IamWebSecurityConfig.java index d3f317109f..c7631ba2c0 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/config/security/IamWebSecurityConfig.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/config/security/IamWebSecurityConfig.java @@ -65,6 +65,7 @@ import it.infn.mw.iam.authn.CheckMultiFactorIsEnabledSuccessHandler; import it.infn.mw.iam.authn.ExternalAuthenticationHintService; import it.infn.mw.iam.authn.HintAwareAuthenticationEntryPoint; +import it.infn.mw.iam.authn.OidcLogoutSuccessHandler; import it.infn.mw.iam.authn.multi_factor_authentication.ExtendedAuthenticationFilter; import it.infn.mw.iam.authn.multi_factor_authentication.ExtendedHttpServletRequestFilter; import it.infn.mw.iam.authn.multi_factor_authentication.MultiFactorVerificationFilter; @@ -144,6 +145,9 @@ public static class UserLoginConfig extends WebSecurityConfigurerAdapter { @Autowired private IamProperties iamProperties; + @Autowired + private OidcLogoutSuccessHandler oidcLogoutSuccessHandler; + @Autowired private IamTotpMfaProperties iamTotpMfaProperties; @@ -181,7 +185,6 @@ protected AuthenticationEntryPoint entryPoint() { return new HintAwareAuthenticationEntryPoint(delegate, hintService, aarcHintService); } - @Override protected void configure(final HttpSecurity http) throws Exception { @@ -217,6 +220,7 @@ protected void configure(final HttpSecurity http) throws Exception { .addFilterAfter(extendedHttpServletRequestFilter(), UsernamePasswordAuthenticationFilter.class) .logout() .logoutUrl("/logout") + .logoutSuccessHandler(oidcLogoutSuccessHandler) .and().anonymous() .and() .csrf() diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/wellknown/IamWellKnownInfoProvider.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/wellknown/IamWellKnownInfoProvider.java index 942d2763f6..17533437da 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/web/wellknown/IamWellKnownInfoProvider.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/web/wellknown/IamWellKnownInfoProvider.java @@ -51,6 +51,7 @@ public class IamWellKnownInfoProvider implements WellKnownInfoProvider { public static final String TOKEN_ENDPOINT = "token"; public static final String ABOUT_ENDPOINT = "about"; public static final String SCIM_ENDPOINT = "scim"; + public static final String LOGOUT_ENDPOINT = "logout"; private static final List TOKEN_ENDPOINT_AUTH_METHODS = newArrayList( "client_secret_basic", "client_secret_post", "client_secret_jwt", "private_key_jwt", "none"); @@ -106,6 +107,7 @@ public class IamWellKnownInfoProvider implements WellKnownInfoProvider { private final String deviceAuthorizationEndpoint; private final String aboutEndpoint; private final String scimEndpoint; + private final String logoutEndpoint; private Set supportedScopes; @@ -134,6 +136,7 @@ public IamWellKnownInfoProvider(IamProperties properties, deviceAuthorizationEndpoint = buildEndpointUrl(DeviceEndpoint.URL); aboutEndpoint = buildEndpointUrl(ABOUT_ENDPOINT); scimEndpoint = buildEndpointUrl(SCIM_ENDPOINT); + logoutEndpoint = buildEndpointUrl(LOGOUT_ENDPOINT); updateSupportedScopes(); } @@ -221,6 +224,8 @@ public Map getWellKnownInfo() { updateSupportedScopes(); result.put("scopes_supported", supportedScopes); + result.put("end_session_endpoint", logoutEndpoint); + return result; } diff --git a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/clients/client/clientsettings/clientsettings.component.html b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/clients/client/clientsettings/clientsettings.component.html index 63049573a8..7c1ba00d74 100644 --- a/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/clients/client/clientsettings/clientsettings.component.html +++ b/iam-login-service/src/main/webapp/resources/iam/apps/dashboard-app/components/clients/client/clientsettings/clientsettings.component.html @@ -72,6 +72,10 @@ this client" validator="$ctrl.validRedirectURI()">
+ + +
redirectUris) { + ClientDetailsEntity client = new ClientDetailsEntity(); + client.setClientId(clientId); + client.setPostLogoutRedirectUris(redirectUris); + + when(clientRepo.findByClientId(clientId)).thenReturn(Optional.of(client)); + } + + private String validJwtForClient(String clientId) { + return "eyJhbGciOiJub25lIn0." + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(("{\"aud\":[\"" + clientId + "\"]}").getBytes()) + "."; + } + + @Test + void missingParametersRedirectsToFallback() throws Exception { + handler.onLogoutSuccess(request, response, null); + + assertEquals("/login?logout", response.getRedirectedUrl()); + } + + @Test + void invalidIdTokenRedirectsToFallback() throws Exception { + request.setParameter("id_token_hint", "not-a-jwt"); + request.setParameter("post_logout_redirect_uri", "https://rp.example.org"); + + handler.onLogoutSuccess(request, response, null); + + assertEquals("/login?logout", response.getRedirectedUrl()); + } + + @Test + void tokenValidationFailsRedirectsToFallback() throws Exception { + String jwt = "eyJhbGciOiJub25lIn0.eyJhdWQiOlsiY2xpZW50Il19."; + + request.setParameter("id_token_hint", jwt); + request.setParameter("post_logout_redirect_uri", "https://rp.example.org"); + + when(validator.isValid(any(JWT.class))).thenReturn(false); + + handler.onLogoutSuccess(request, response, null); + + assertEquals("/login?logout", response.getRedirectedUrl()); + } + + @Test + void redirectUriNotRegisteredRedirectsToFallback() throws Exception { + String jwt = validJwtForClient("client"); + + request.setParameter("id_token_hint", jwt); + request.setParameter("post_logout_redirect_uri", "https://evil.example.org"); + + when(validator.isValid(any(JWT.class))).thenReturn(true); + mockClient("client", Set.of("https://rp.example.org/logout")); + + handler.onLogoutSuccess(request, response, null); + + assertEquals("/login?logout", response.getRedirectedUrl()); + } + + @Test + void validLogoutRedirectsToPostLogoutUri() throws Exception { + String jwt = validJwtForClient("client"); + + request.setParameter("id_token_hint", jwt); + request.setParameter("post_logout_redirect_uri", "https://rp.example.org/logout"); + request.setParameter("state", "xyz"); + + when(validator.isValid(any(JWT.class))).thenReturn(true); + mockClient("client", Set.of("https://rp.example.org/logout")); + + handler.onLogoutSuccess(request, response, null); + + assertEquals("https://rp.example.org/logout?state=xyz", response.getRedirectedUrl()); + } +}