diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/wlcg/WLCGUserInfoAdapter.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/wlcg/WLCGUserInfoAdapter.java index c82802dfa..7bf1ef66e 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/wlcg/WLCGUserInfoAdapter.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/wlcg/WLCGUserInfoAdapter.java @@ -15,9 +15,15 @@ */ package it.infn.mw.iam.core.oauth.profile.wlcg; + + +import static java.util.Objects.isNull; + import org.mitre.openid.connect.model.UserInfo; +import com.google.gson.JsonArray; import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; import it.infn.mw.iam.core.userinfo.DelegateUserInfoAdapter; @@ -25,8 +31,11 @@ public class WLCGUserInfoAdapter extends DelegateUserInfoAdapter { private static final long serialVersionUID = 1L; - private WLCGUserInfoAdapter(UserInfo delegate) { + private final String[] resolvedGroups; + + private WLCGUserInfoAdapter(UserInfo delegate, String[] resolvedGroups) { super(delegate); + this.resolvedGroups = resolvedGroups; } @Override @@ -35,10 +44,22 @@ public JsonObject toJson() { json.remove("groups"); + if (!isNull(resolvedGroups)) { + JsonArray groups = new JsonArray(); + for (String g : resolvedGroups) { + groups.add(new JsonPrimitive(g)); + } + json.add("wlcg.groups", groups); + } + return json; } + public static WLCGUserInfoAdapter forUserInfo(UserInfo delegate, String[] resolvedGroups) { + return new WLCGUserInfoAdapter(delegate, resolvedGroups); + } + public static WLCGUserInfoAdapter forUserInfo(UserInfo delegate) { - return new WLCGUserInfoAdapter(delegate); + return new WLCGUserInfoAdapter(delegate, null); } } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/wlcg/WLCGUserinfoHelper.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/wlcg/WLCGUserinfoHelper.java index ba3958517..b8a5eca03 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/wlcg/WLCGUserinfoHelper.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/wlcg/WLCGUserinfoHelper.java @@ -18,29 +18,67 @@ import static it.infn.mw.iam.core.oauth.profile.wlcg.WLCGUserInfoAdapter.forUserInfo; import static java.util.Objects.isNull; +import java.text.ParseException; +import java.util.Optional; + import org.mitre.openid.connect.model.UserInfo; import org.mitre.openid.connect.service.UserInfoService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.security.oauth2.provider.OAuth2Authentication; +import org.springframework.security.oauth2.provider.authentication.OAuth2AuthenticationDetails; + +import com.nimbusds.jwt.JWT; +import com.nimbusds.jwt.JWTParser; import it.infn.mw.iam.config.IamProperties; import it.infn.mw.iam.core.oauth.profile.common.BaseUserinfoHelper; public class WLCGUserinfoHelper extends BaseUserinfoHelper { + public static final Logger LOG = LoggerFactory.getLogger(WLCGUserinfoHelper.class); + public WLCGUserinfoHelper(IamProperties props, UserInfoService userInfoService) { super(props, userInfoService); } + + private Optional resolveGroupsFromToken(OAuth2Authentication authentication) { + OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails) authentication.getDetails(); + + if (isNull(details) || isNull(details.getTokenValue())) { + return Optional.empty(); + } + + try { + JWT accessToken = JWTParser.parse(details.getTokenValue()); + String[] resolvedGroups = accessToken.getJWTClaimsSet().getStringArrayClaim("wlcg.groups"); + + return Optional.ofNullable(resolvedGroups); + + } catch (ParseException e) { + LOG.error("Error parsing access token: {}", e.getMessage(), e); + return Optional.empty(); + } + } + @Override public UserInfo resolveUserInfo(OAuth2Authentication authentication) { - + UserInfo ui = lookupUserinfo(authentication); if (isNull(ui)) { return null; } - - return forUserInfo(ui); + + Optional resolvedGroups = resolveGroupsFromToken(authentication); + + if (resolvedGroups.isPresent()) { + return forUserInfo(ui, resolvedGroups.get()); + } else { + return forUserInfo(ui); + } + } } diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/userinfo/IamScopeClaimTranslationService.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/userinfo/IamScopeClaimTranslationService.java index 808396fc0..56a1545a7 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/userinfo/IamScopeClaimTranslationService.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/userinfo/IamScopeClaimTranslationService.java @@ -41,6 +41,7 @@ import static it.infn.mw.iam.core.userinfo.UserInfoClaim.SUB; import static it.infn.mw.iam.core.userinfo.UserInfoClaim.UPDATED_AT; import static it.infn.mw.iam.core.userinfo.UserInfoClaim.WEBSITE; +import static it.infn.mw.iam.core.userinfo.UserInfoClaim.WLCG_GROUPS; import static it.infn.mw.iam.core.userinfo.UserInfoClaim.ZONEINFO; import java.util.EnumSet; @@ -70,6 +71,7 @@ public class IamScopeClaimTranslationService implements ScopeClaimTranslationSer public static final String EDUPERSON_ENTITLEMENT_SCOPE = "eduperson_entitlement"; public static final String ATTR_SCOPE = "attr"; public static final String SSH_KEYS_SCOPE = "ssh-keys"; + public static final String WLCG_GROUPS_SCOPE = "wlcg.groups"; protected static final Set PROFILE_CLAIMS = EnumSet.of(NAME, PREFERRED_USERNAME, GIVEN_NAME, FAMILY_NAME, MIDDLE_NAME, NICKNAME, PROFILE, PICTURE, WEBSITE, GENDER, ZONEINFO, @@ -90,10 +92,11 @@ public IamScopeClaimTranslationService() { mapScopeToClaim(EDUPERSON_ENTITLEMENT_SCOPE, EDUPERSON_ENTITLEMENT); mapScopeToClaim(ATTR_SCOPE, ATTR); mapScopeToClaim(SSH_KEYS_SCOPE, SSH_KEYS); + mapScopeToClaim(WLCG_GROUPS_SCOPE, WLCG_GROUPS); } private void mapScopeToClaim(String scope, UserInfoClaim claim) { - scopesToClaims.put(scope, claim.name().toLowerCase()); + scopesToClaims.put(scope, claim.getClaimName()); } private void mapScopeToClaim(String scope, Set claimSet) { diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/userinfo/UserInfoClaim.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/userinfo/UserInfoClaim.java index 616ce8d1f..59849c5b1 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/userinfo/UserInfoClaim.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/userinfo/UserInfoClaim.java @@ -16,31 +16,41 @@ package it.infn.mw.iam.core.userinfo; public enum UserInfoClaim { - ATTR, - SUB, - NAME, - PREFERRED_USERNAME, - GIVEN_NAME, - FAMILY_NAME, - MIDDLE_NAME, - NICKNAME, - PROFILE, - PICTURE, - WEBSITE, - GENDER, - ZONEINFO, - LOCALE, - UPDATED_AT, - BIRTHDATE, - EMAIL, - EMAIL_VERIFIED, - PHONE_NUMBER, - PHONE_NUMBER_VERIFIED, - ADDRESS, - ORGANISATION_NAME, - GROUPS, - EXTERNAL_AUTHN, - EDUPERSON_SCOPED_AFFILIATION, - EDUPERSON_ENTITLEMENT, - SSH_KEYS; + ATTR("attr"), + SUB("sub"), + NAME("name"), + PREFERRED_USERNAME("preferred_username"), + GIVEN_NAME("given_name"), + FAMILY_NAME("family_name"), + MIDDLE_NAME("middle_name"), + NICKNAME("nickname"), + PROFILE("profile"), + PICTURE("picture"), + WEBSITE("website"), + GENDER("gender"), + ZONEINFO("zoneinfo"), + LOCALE("locale"), + UPDATED_AT("updated_at"), + BIRTHDATE("birthdate"), + EMAIL("email"), + EMAIL_VERIFIED("email_verified"), + PHONE_NUMBER("phone_number"), + PHONE_NUMBER_VERIFIED("phone_number_verified"), + ADDRESS("address"), + ORGANISATION_NAME("organisation_name"), + GROUPS("groups"), + WLCG_GROUPS("wlcg.groups"), + EXTERNAL_AUTHN("external_authn"), + EDUPERSON_SCOPED_AFFILIATION("eduperson_scoped_affiliation"), + EDUPERSON_ENTITLEMENT("eduperson_entitlement"), + SSH_KEYS("ssh_keys"); + + private UserInfoClaim(String claimName) { + this.claimName = claimName; + } + private String claimName; + + public String getClaimName() { + return claimName; + } } diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/TestUtils.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/TestUtils.java index 77110501b..a27e6566a 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/TestUtils.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/TestUtils.java @@ -180,8 +180,6 @@ public String getAccessToken() { return req.when() .post("/token") .then() - .log() - .all(true) .statusCode(HttpStatus.OK.value()) .extract() .path("access_token"); diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/WLCGProfileUserinfoEndpointTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/WLCGProfileUserinfoEndpointTests.java new file mode 100644 index 000000000..382c2ad83 --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/WLCGProfileUserinfoEndpointTests.java @@ -0,0 +1,122 @@ +/** + * Copyright (c) Istituto Nazionale di Fisica Nucleare (INFN). 2016-2019 + * + * 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.test.oauth.profile; + +import static org.hamcrest.CoreMatchers.hasItems; +import static org.hamcrest.Matchers.nullValue; + +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.SpringApplicationConfiguration; +import org.springframework.boot.test.WebIntegrationTest; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jayway.restassured.RestAssured; + +import it.infn.mw.iam.IamLoginService; +import it.infn.mw.iam.test.TestUtils; + +@RunWith(SpringJUnit4ClassRunner.class) +@SpringApplicationConfiguration(classes = {IamLoginService.class}) +@WebIntegrationTest(randomPort = true) +@Transactional +@TestPropertySource(properties = { +// @formatter:off + "iam.jwt-profile.default-profile=wlcg", + "scope.matchers[0].name=storage.read", + "scope.matchers[0].type=path", + "scope.matchers[0].prefix=storage.read", + "scope.matchers[0].path=/", + "scope.matchers[1].name=storage.write", + "scope.matchers[1].type=path", + "scope.matchers[1].prefix=storage.write", + "scope.matchers[1].path=/", + "scope.matchers[2].name=wlcg.groups", + "scope.matchers[2].type=regexp", + "scope.matchers[2].regexp=^wlcg\\.groups(?::((?:\\/[a-zA-Z0-9][a-zA-Z0-9_.-]*)+))?$", + // @formatter:on +}) +public class WLCGProfileUserinfoEndpointTests { + + private static final String USERNAME = "test"; + private static final String PASSWORD = "password"; + private static final String USERINFO_URL_TEMPLATE = "http://localhost:%d/userinfo"; + + @Value("${local.server.port}") + private Integer iamPort; + + private String userinfoUrl; + + @Autowired + ObjectMapper mapper; + + @BeforeClass + public static void init() { + TestUtils.initRestAssured(); + } + + @Before + public void setup() { + RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); + RestAssured.port = iamPort; + userinfoUrl = String.format(USERINFO_URL_TEMPLATE, iamPort); + } + + @Test + public void testUserinfoResponseWithGroups() { + String accessToken = TestUtils.passwordTokenGetter() + .port(iamPort) + .username(USERNAME) + .password(PASSWORD) + .scope("openid profile wlcg.groups") + .getAccessToken(); + + RestAssured.given() + .header("Authorization", String.format("Bearer %s", accessToken)) + .when() + .get(userinfoUrl) + .then() + .statusCode(HttpStatus.OK.value()) + .body("\"wlcg.groups\"", hasItems("/Analysis", "/Production")); + } + + @Test + public void testUserinfoResponseWithoutGroups() { + String accessToken = TestUtils.passwordTokenGetter() + .port(iamPort) + .username(USERNAME) + .password(PASSWORD) + .scope("openid profile") + .getAccessToken(); + + RestAssured.given() + .header("Authorization", String.format("Bearer %s", accessToken)) + .when() + .get(userinfoUrl) + .then() + .statusCode(HttpStatus.OK.value()) + .body("\"wlcg.groups\"", nullValue()); + } + +}