Skip to content

Commit

Permalink
Include wlcg.groups information in userinfo response
Browse files Browse the repository at this point in the history
Even though the IAM access token is a JWT and even though groups are
included in the access token when requested, as mandated by the WLCG JWT
profile, there are still apps treating the access token as an opaque
string.

To support those apps, and be more consistent with the traditional IAM
profile behaviour, IAM should include group information in the userinfo
endpoint response also for the WLCG profile.

Issue: #432
  • Loading branch information
andreaceccanti committed Oct 24, 2021
1 parent 195c2d7 commit 4bfc271
Show file tree
Hide file tree
Showing 6 changed files with 227 additions and 35 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,27 @@
*/
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;

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
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String[]> 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<String[]> resolvedGroups = resolveGroupsFromToken(authentication);

if (resolvedGroups.isPresent()) {
return forUserInfo(ui, resolvedGroups.get());
} else {
return forUserInfo(ui);
}

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<UserInfoClaim> PROFILE_CLAIMS = EnumSet.of(NAME, PREFERRED_USERNAME,
GIVEN_NAME, FAMILY_NAME, MIDDLE_NAME, NICKNAME, PROFILE, PICTURE, WEBSITE, GENDER, ZONEINFO,
Expand All @@ -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<UserInfoClaim> claimSet) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,6 @@ public String getAccessToken() {
return req.when()
.post("/token")
.then()
.log()
.all(true)
.statusCode(HttpStatus.OK.value())
.extract()
.path("access_token");
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}

}

0 comments on commit 4bfc271

Please sign in to comment.