From 56a682ea2039cba532696b2ca182b73baa46d1b3 Mon Sep 17 00:00:00 2001 From: Enrico Vianello Date: Tue, 25 Jul 2023 16:21:33 +0100 Subject: [PATCH 1/3] Add 'keycloak' JWT profile - Add access-token builder tests - Add scope-profile resolver tests - TO-DO: test userinfo/id-token/introspection responses --- .../java/it/infn/mw/iam/config/IamConfig.java | 38 +++++- .../it/infn/mw/iam/config/IamProperties.java | 3 +- .../profile/ScopeAwareProfileResolver.java | 3 +- .../profile/keycloak/KeycloakGroupHelper.java | 40 ++++++ .../keycloak/KeycloakIdTokenCustomizer.java | 66 ++++++++++ .../keycloak/KeycloakIntrospectionHelper.java | 61 +++++++++ .../profile/keycloak/KeycloakJWTProfile.java | 81 ++++++++++++ .../KeycloakProfileAccessTokenBuilder.java | 73 +++++++++++ .../keycloak/KeycloakUserInfoAdapter.java | 65 +++++++++ .../keycloak/KeycloakUserinfoHelper.java | 86 ++++++++++++ .../KeycloakAccessTokenBuilderTests.java | 123 ++++++++++++++++++ .../KeycloakProfileIntegrationTests.java | 60 +++++++++ .../oauth/profile/ProfileSelectorTests.java | 23 ++++ 13 files changed, 719 insertions(+), 3 deletions(-) create mode 100644 iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakGroupHelper.java create mode 100644 iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakIdTokenCustomizer.java create mode 100644 iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakIntrospectionHelper.java create mode 100644 iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakJWTProfile.java create mode 100644 iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakProfileAccessTokenBuilder.java create mode 100644 iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakUserInfoAdapter.java create mode 100644 iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakUserinfoHelper.java create mode 100644 iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/KeycloakAccessTokenBuilderTests.java create mode 100644 iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/KeycloakProfileIntegrationTests.java diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/config/IamConfig.java b/iam-login-service/src/main/java/it/infn/mw/iam/config/IamConfig.java index e45d7100d..62e35ed80 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/config/IamConfig.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/config/IamConfig.java @@ -17,6 +17,7 @@ import static it.infn.mw.iam.core.oauth.profile.ScopeAwareProfileResolver.AARC_PROFILE_ID; import static it.infn.mw.iam.core.oauth.profile.ScopeAwareProfileResolver.IAM_PROFILE_ID; +import static it.infn.mw.iam.core.oauth.profile.ScopeAwareProfileResolver.KEYCLOAK_PROFILE_ID; import static it.infn.mw.iam.core.oauth.profile.ScopeAwareProfileResolver.WLCG_PROFILE_ID; import java.time.Clock; @@ -69,6 +70,12 @@ import it.infn.mw.iam.core.oauth.profile.iam.IamJWTProfileIdTokenCustomizer; import it.infn.mw.iam.core.oauth.profile.iam.IamJWTProfileTokenIntrospectionHelper; import it.infn.mw.iam.core.oauth.profile.iam.IamJWTProfileUserinfoHelper; +import it.infn.mw.iam.core.oauth.profile.keycloak.KeycloakGroupHelper; +import it.infn.mw.iam.core.oauth.profile.keycloak.KeycloakIdTokenCustomizer; +import it.infn.mw.iam.core.oauth.profile.keycloak.KeycloakIntrospectionHelper; +import it.infn.mw.iam.core.oauth.profile.keycloak.KeycloakJWTProfile; +import it.infn.mw.iam.core.oauth.profile.keycloak.KeycloakProfileAccessTokenBuilder; +import it.infn.mw.iam.core.oauth.profile.keycloak.KeycloakUserinfoHelper; import it.infn.mw.iam.core.oauth.profile.wlcg.WLCGGroupHelper; import it.infn.mw.iam.core.oauth.profile.wlcg.WLCGJWTProfile; import it.infn.mw.iam.core.oauth.scope.matchers.DefaultScopeMatcherRegistry; @@ -152,6 +159,27 @@ JWTProfile aarcJwtProfile(IamProperties props, IamAccountRepository accountRepo, return new AarcJWTProfile(atBuilder, idHelper, uiHelper, intrHelper); } + @Bean(name = "keycloakJwtProfile") + JWTProfile keycloakJwtProfile(IamProperties props, IamAccountRepository accountRepo, + ScopeClaimTranslationService converter, UserInfoService userInfoService, ScopeMatcherRegistry registry, ClaimValueHelper claimHelper) { + + KeycloakGroupHelper groupHelper = new KeycloakGroupHelper(); + + KeycloakProfileAccessTokenBuilder atBuilder = + new KeycloakProfileAccessTokenBuilder(props, groupHelper); + + KeycloakUserinfoHelper uiHelper = + new KeycloakUserinfoHelper(props, userInfoService); + + KeycloakIdTokenCustomizer idHelper = + new KeycloakIdTokenCustomizer(accountRepo, converter, claimHelper, groupHelper, props); + + BaseIntrospectionHelper intrHelper = new KeycloakIntrospectionHelper(props, + new DefaultIntrospectionResultAssembler(), registry, groupHelper); + + return new KeycloakJWTProfile(atBuilder, idHelper, uiHelper, intrHelper); + } + @Bean(name = "iamJwtProfile") JWTProfile iamJwtProfile(IamProperties props, IamAccountRepository accountRepo, ScopeClaimTranslationService converter, ClaimValueHelper claimHelper, @@ -188,7 +216,9 @@ attributeMapHelper, new DefaultIntrospectionResultAssembler(), registry, @Bean JWTProfileResolver jwtProfileResolver(@Qualifier("iamJwtProfile") JWTProfile iamProfile, @Qualifier("wlcgJwtProfile") JWTProfile wlcgProfile, - @Qualifier("aarcJwtProfile") JWTProfile aarcProfile, IamProperties properties, + @Qualifier("aarcJwtProfile") JWTProfile aarcProfile, + @Qualifier("keycloakJwtProfile") JWTProfile kcProfile, + IamProperties properties, ClientDetailsService clientDetailsService) { JWTProfile defaultProfile = iamProfile; @@ -203,10 +233,16 @@ JWTProfileResolver jwtProfileResolver(@Qualifier("iamJwtProfile") JWTProfile iam defaultProfile = aarcProfile; } + if (it.infn.mw.iam.config.IamProperties.JWTProfile.Profile.KEYCLOAK + .equals(properties.getJwtProfile().getDefaultProfile())) { + defaultProfile = kcProfile; + } + Map profileMap = Maps.newHashMap(); profileMap.put(IAM_PROFILE_ID, iamProfile); profileMap.put(WLCG_PROFILE_ID, wlcgProfile); profileMap.put(AARC_PROFILE_ID, aarcProfile); + profileMap.put(KEYCLOAK_PROFILE_ID, kcProfile); LOG.info("Default JWT profile: {}", defaultProfile.name()); return new ScopeAwareProfileResolver(defaultProfile, profileMap, clientDetailsService); diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/config/IamProperties.java b/iam-login-service/src/main/java/it/infn/mw/iam/config/IamProperties.java index 02c4f8951..bd76c10df 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/config/IamProperties.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/config/IamProperties.java @@ -354,7 +354,8 @@ public static class JWTProfile { public enum Profile { IAM, WLCG, - AARC + AARC, + KEYCLOAK } Profile defaultProfile = Profile.IAM; diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/ScopeAwareProfileResolver.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/ScopeAwareProfileResolver.java index ac03dc430..5b58fa08d 100644 --- a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/ScopeAwareProfileResolver.java +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/ScopeAwareProfileResolver.java @@ -34,9 +34,10 @@ public class ScopeAwareProfileResolver implements JWTProfileResolver { public static final String AARC_PROFILE_ID = "aarc"; public static final String IAM_PROFILE_ID = "iam"; public static final String WLCG_PROFILE_ID = "wlcg"; + public static final String KEYCLOAK_PROFILE_ID = "keycloak"; private static final Set SUPPORTED_PROFILES = - newHashSet(AARC_PROFILE_ID, IAM_PROFILE_ID, WLCG_PROFILE_ID); + newHashSet(AARC_PROFILE_ID, IAM_PROFILE_ID, WLCG_PROFILE_ID, KEYCLOAK_PROFILE_ID); private final Map profileMap; private final JWTProfile defaultProfile; diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakGroupHelper.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakGroupHelper.java new file mode 100644 index 000000000..d6231fcb3 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakGroupHelper.java @@ -0,0 +1,40 @@ +/** + * 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.core.oauth.profile.keycloak; + +import java.util.Set; +import java.util.stream.Collectors; + +import com.google.common.collect.Sets; + +import it.infn.mw.iam.persistence.model.IamGroup; +import it.infn.mw.iam.persistence.model.IamUserInfo; + +public class KeycloakGroupHelper { + + public final static String KEYCLOAK_ROLES_CLAIM = "roles"; + + public Set resolveGroups(IamUserInfo userInfo) { + + return Sets.newLinkedHashSet(userInfo.getGroups()); + } + + public Set resolveGroupNames(IamUserInfo userInfo) { + + return userInfo.getGroups().stream().map(g -> g.getName()).collect(Collectors.toSet()); + } + +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakIdTokenCustomizer.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakIdTokenCustomizer.java new file mode 100644 index 000000000..6c1be7320 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakIdTokenCustomizer.java @@ -0,0 +1,66 @@ +/** + * 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.core.oauth.profile.keycloak; + +import java.util.Set; + +import org.mitre.oauth2.model.ClientDetailsEntity; +import org.mitre.oauth2.model.OAuth2AccessTokenEntity; +import org.mitre.openid.connect.service.ScopeClaimTranslationService; +import org.springframework.security.oauth2.provider.OAuth2Request; + +import com.nimbusds.jwt.JWTClaimsSet.Builder; + +import it.infn.mw.iam.config.IamProperties; +import it.infn.mw.iam.core.oauth.profile.iam.ClaimValueHelper; +import it.infn.mw.iam.core.oauth.profile.iam.IamJWTProfileIdTokenCustomizer; +import it.infn.mw.iam.persistence.model.IamAccount; +import it.infn.mw.iam.persistence.model.IamUserInfo; +import it.infn.mw.iam.persistence.repository.IamAccountRepository; + +@SuppressWarnings("deprecation") +public class KeycloakIdTokenCustomizer extends IamJWTProfileIdTokenCustomizer { + + private final KeycloakGroupHelper groupHelper; + + public KeycloakIdTokenCustomizer(IamAccountRepository accountRepo, + ScopeClaimTranslationService scopeClaimConverter, ClaimValueHelper claimValueHelper, + KeycloakGroupHelper groupHelper, IamProperties properties) { + super(accountRepo, scopeClaimConverter, claimValueHelper, properties); + this.groupHelper = groupHelper; + } + + @Override + public void customizeIdTokenClaims(Builder idClaims, ClientDetailsEntity client, + OAuth2Request request, String sub, OAuth2AccessTokenEntity accessToken, IamAccount account) { + + super.customizeIdTokenClaims(idClaims, client, request, sub, accessToken, account); + + IamUserInfo info = account.getUserInfo(); + Set groupNames = groupHelper.resolveGroupNames(info); + + if (!groupNames.isEmpty()) { + idClaims.claim(KeycloakGroupHelper.KEYCLOAK_ROLES_CLAIM, groupNames); + } + + // Drop group claims as set by IAM JWT profile + idClaims.claim("groups", null); + + includeLabelsInIdToken(idClaims, account); + + } + +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakIntrospectionHelper.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakIntrospectionHelper.java new file mode 100644 index 000000000..56448a033 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakIntrospectionHelper.java @@ -0,0 +1,61 @@ +/** + * 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.core.oauth.profile.keycloak; + +import java.util.Map; +import java.util.Set; + +import org.mitre.oauth2.model.OAuth2AccessTokenEntity; +import org.mitre.oauth2.service.IntrospectionResultAssembler; +import org.mitre.openid.connect.model.UserInfo; + +import it.infn.mw.iam.config.IamProperties; +import it.infn.mw.iam.core.oauth.profile.common.BaseIntrospectionHelper; +import it.infn.mw.iam.core.oauth.scope.matchers.ScopeMatcherRegistry; +import it.infn.mw.iam.persistence.repository.UserInfoAdapter; + + +public class KeycloakIntrospectionHelper extends BaseIntrospectionHelper { + + private final KeycloakGroupHelper groupHelper; + + public KeycloakIntrospectionHelper(IamProperties props, IntrospectionResultAssembler assembler, + ScopeMatcherRegistry registry, KeycloakGroupHelper helper) { + super(props, assembler, registry); + this.groupHelper = helper; + } + + @Override + public Map assembleIntrospectionResult(OAuth2AccessTokenEntity accessToken, + UserInfo userInfo, Set authScopes) { + + Map result = getAssembler().assembleFrom(accessToken, userInfo, authScopes); + + addIssuerClaim(result); + addAudience(result, accessToken); + addScopeClaim(result, filterScopes(accessToken, authScopes)); + + Set groups = + groupHelper.resolveGroupNames(((UserInfoAdapter) userInfo).getUserinfo()); + + if (!groups.isEmpty()) { + result.put(KeycloakGroupHelper.KEYCLOAK_ROLES_CLAIM, groups); + } + + return result; + } + +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakJWTProfile.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakJWTProfile.java new file mode 100644 index 000000000..726955680 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakJWTProfile.java @@ -0,0 +1,81 @@ +/** + * 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.core.oauth.profile.keycloak; + +import org.springframework.security.oauth2.provider.OAuth2Request; + +import it.infn.mw.iam.core.oauth.profile.IDTokenCustomizer; +import it.infn.mw.iam.core.oauth.profile.IntrospectionResultHelper; +import it.infn.mw.iam.core.oauth.profile.JWTAccessTokenBuilder; +import it.infn.mw.iam.core.oauth.profile.JWTProfile; +import it.infn.mw.iam.core.oauth.profile.RequestValidator; +import it.infn.mw.iam.core.oauth.profile.UserInfoHelper; + +@SuppressWarnings("deprecation") +public class KeycloakJWTProfile implements JWTProfile, RequestValidator { + + public static final String PROFILE_VERSION = "1.0"; + public static final String PROFILE_NAME = "Keycloak JWT profile " + PROFILE_VERSION; + + private final JWTAccessTokenBuilder accessTokenBuilder; + private final IDTokenCustomizer idTokenCustomizer; + private final UserInfoHelper userInfoHelper; + private final IntrospectionResultHelper introspectionHelper; + + public KeycloakJWTProfile(JWTAccessTokenBuilder accessTokenBuilder, IDTokenCustomizer idTokenBuilder, + UserInfoHelper userInfoHelper, IntrospectionResultHelper introspectionHelper) { + + this.accessTokenBuilder = accessTokenBuilder; + this.idTokenCustomizer = idTokenBuilder; + this.userInfoHelper = userInfoHelper; + this.introspectionHelper = introspectionHelper; + } + + @Override + public JWTAccessTokenBuilder getAccessTokenBuilder() { + return accessTokenBuilder; + } + + @Override + public IDTokenCustomizer getIDTokenCustomizer() { + return idTokenCustomizer; + } + + @Override + public IntrospectionResultHelper getIntrospectionResultHelper() { + return introspectionHelper; + } + + @Override + public UserInfoHelper getUserinfoHelper() { + return userInfoHelper; + } + + @Override + public String name() { + return PROFILE_NAME; + } + + @Override + public RequestValidator getRequestValidator() { + return this; + } + + @Override + public void validateRequest(OAuth2Request request) { + } + +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakProfileAccessTokenBuilder.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakProfileAccessTokenBuilder.java new file mode 100644 index 000000000..672538d61 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakProfileAccessTokenBuilder.java @@ -0,0 +1,73 @@ +/** + * 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.core.oauth.profile.keycloak; + +import static java.util.Objects.isNull; +import static java.util.stream.Collectors.joining; + +import java.time.Instant; +import java.util.Date; +import java.util.Set; + +import org.mitre.oauth2.model.OAuth2AccessTokenEntity; +import org.mitre.openid.connect.model.UserInfo; +import org.springframework.security.oauth2.provider.OAuth2Authentication; + +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.JWTClaimsSet.Builder; + +import it.infn.mw.iam.config.IamProperties; +import it.infn.mw.iam.core.oauth.profile.common.BaseAccessTokenBuilder; +import it.infn.mw.iam.persistence.repository.UserInfoAdapter; + +@SuppressWarnings("deprecation") +public class KeycloakProfileAccessTokenBuilder extends BaseAccessTokenBuilder { + + public static final String PROFILE_VERSION = "1.0"; + + final KeycloakGroupHelper groupHelper; + + public KeycloakProfileAccessTokenBuilder(IamProperties properties, KeycloakGroupHelper groupHelper) { + super(properties); + this.groupHelper = groupHelper; + } + + + @Override + public JWTClaimsSet buildAccessToken(OAuth2AccessTokenEntity token, + OAuth2Authentication authentication, UserInfo userInfo, Instant issueTime) { + + Builder builder = baseJWTSetup(token, authentication, userInfo, issueTime); + + builder.notBeforeTime(Date.from(issueTime)); + + if (!token.getScope().isEmpty()) { + builder.claim(SCOPE_CLAIM_NAME, token.getScope().stream().collect(joining(SPACE))); + } + + if (!isNull(userInfo)) { + Set groupNames = + groupHelper.resolveGroupNames(((UserInfoAdapter) userInfo).getUserinfo()); + + if (!groupNames.isEmpty()) { + builder.claim(KeycloakGroupHelper.KEYCLOAK_ROLES_CLAIM, groupNames); + } + } + + return builder.build(); + } + +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakUserInfoAdapter.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakUserInfoAdapter.java new file mode 100644 index 000000000..c6e7c1119 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakUserInfoAdapter.java @@ -0,0 +1,65 @@ +/** + * 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.core.oauth.profile.keycloak; + + + +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 KeycloakUserInfoAdapter extends DelegateUserInfoAdapter { + + private static final long serialVersionUID = 1L; + + private final String[] resolvedGroups; + + private KeycloakUserInfoAdapter(UserInfo delegate, String[] resolvedGroups) { + super(delegate); + this.resolvedGroups = resolvedGroups; + } + + @Override + public JsonObject toJson() { + JsonObject json = super.toJson(); + + json.remove("groups"); + + if (!isNull(resolvedGroups)) { + JsonArray groups = new JsonArray(); + for (String g : resolvedGroups) { + groups.add(new JsonPrimitive(g)); + } + json.add(KeycloakGroupHelper.KEYCLOAK_ROLES_CLAIM, groups); + } + + return json; + } + + public static KeycloakUserInfoAdapter forUserInfo(UserInfo delegate, String[] resolvedGroups) { + return new KeycloakUserInfoAdapter(delegate, resolvedGroups); + } + + public static KeycloakUserInfoAdapter forUserInfo(UserInfo delegate) { + return new KeycloakUserInfoAdapter(delegate, null); + } +} diff --git a/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakUserinfoHelper.java b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakUserinfoHelper.java new file mode 100644 index 000000000..47a751290 --- /dev/null +++ b/iam-login-service/src/main/java/it/infn/mw/iam/core/oauth/profile/keycloak/KeycloakUserinfoHelper.java @@ -0,0 +1,86 @@ +/** + * 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.core.oauth.profile.keycloak; + +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; + +@SuppressWarnings("deprecation") +public class KeycloakUserinfoHelper extends BaseUserinfoHelper { + + public static final Logger LOG = LoggerFactory.getLogger(KeycloakUserinfoHelper.class); + + public KeycloakUserinfoHelper(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(KeycloakGroupHelper.KEYCLOAK_ROLES_CLAIM); + + 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; + } + + Optional resolvedGroups = resolveGroupsFromToken(authentication); + + if (resolvedGroups.isPresent()) { + return forUserInfo(ui, resolvedGroups.get()); + } else { + return forUserInfo(ui); + } + + } + +} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/KeycloakAccessTokenBuilderTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/KeycloakAccessTokenBuilderTests.java new file mode 100644 index 000000000..9eb698bae --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/KeycloakAccessTokenBuilderTests.java @@ -0,0 +1,123 @@ +/** + * 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.test.oauth.profile; + +import static it.infn.mw.iam.core.oauth.granters.TokenExchangeTokenGranter.TOKEN_EXCHANGE_GRANT_TYPE; +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.time.Instant; +import java.util.Map; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mitre.oauth2.model.ClientDetailsEntity; +import org.mitre.oauth2.model.OAuth2AccessTokenEntity; +import org.mitre.openid.connect.model.UserInfo; +import org.mitre.openid.connect.service.ScopeClaimTranslationService; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.security.oauth2.common.exceptions.InvalidRequestException; +import org.springframework.security.oauth2.provider.OAuth2Authentication; + +import com.google.common.collect.Maps; + +import it.infn.mw.iam.config.IamProperties; +import it.infn.mw.iam.core.oauth.profile.iam.ClaimValueHelper; +import it.infn.mw.iam.core.oauth.profile.iam.IamJWTProfileAccessTokenBuilder; +import it.infn.mw.iam.core.oauth.profile.keycloak.KeycloakGroupHelper; +import it.infn.mw.iam.core.oauth.profile.keycloak.KeycloakProfileAccessTokenBuilder; +import it.infn.mw.iam.test.util.oauth.MockOAuth2Request; + +@SuppressWarnings("deprecation") +@RunWith(MockitoJUnitRunner.class) +public class KeycloakAccessTokenBuilderTests { + + IamProperties properties = new IamProperties(); + + @Mock + ScopeClaimTranslationService scService; + + @Mock + ClaimValueHelper claimValueHelper; + + @Mock + OAuth2AccessTokenEntity tokenEntity; + + @Mock + ClientDetailsEntity client; + + @Mock + OAuth2Authentication authentication; + + @Spy + MockOAuth2Request oauth2Request = + new MockOAuth2Request("clientId", new String[] {"openid", "profile"}); + + @Mock + UserInfo userInfo; + + final Instant now = Clock.systemDefaultZone().instant(); + + final KeycloakGroupHelper groupHelper = new KeycloakGroupHelper(); + + KeycloakProfileAccessTokenBuilder tokenBuilder; + + @Before + public void setup() { + + tokenBuilder = + new KeycloakProfileAccessTokenBuilder(properties, groupHelper); + when(tokenEntity.getExpiration()).thenReturn(null); + when(tokenEntity.getClient()).thenReturn(client); + when(client.getClientId()).thenReturn("client"); + // when(authentication.getName()).thenReturn("auth-name"); + when(authentication.getOAuth2Request()).thenReturn(oauth2Request); + // when(authentication.isClientOnly()).thenReturn(false); + when(userInfo.getSub()).thenReturn("userinfo-sub"); + when(oauth2Request.getGrantType()).thenReturn(TOKEN_EXCHANGE_GRANT_TYPE); + } + + + @Test(expected = InvalidRequestException.class) + public void testMissingSubjectTokenTokenExchangeErrors() { + try { + tokenBuilder.buildAccessToken(tokenEntity, authentication, userInfo, now); + } catch (InvalidRequestException e) { + assertThat(e.getMessage(), containsString("subject_token not found")); + throw e; + } + } + + @Test(expected = InvalidRequestException.class) + public void testSubjectTokenNotParsable() { + Map paramsMap = Maps.newHashMap(); + paramsMap.put("subject_token", "3427thjdfhgejt73ja"); + + oauth2Request.setRequestParameters(paramsMap); + try { + tokenBuilder.buildAccessToken(tokenEntity, authentication, userInfo, now); + } catch (InvalidRequestException e) { + assertThat(e.getMessage(), containsString("Error parsing subject token")); + throw e; + } + } + +} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/KeycloakProfileIntegrationTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/KeycloakProfileIntegrationTests.java new file mode 100644 index 000000000..5bbcc4f8f --- /dev/null +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/KeycloakProfileIntegrationTests.java @@ -0,0 +1,60 @@ +package it.infn.mw.iam.test.oauth.profile; + +import static org.hamcrest.CoreMatchers.hasItem; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.nullValue; + +import java.util.List; + +import org.assertj.core.util.Lists; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringRunner; + +import com.nimbusds.jwt.JWT; +import com.nimbusds.jwt.JWTParser; + +import it.infn.mw.iam.test.oauth.EndpointsTestUtils; +import it.infn.mw.iam.test.util.annotation.IamMockMvcIntegrationTest; + +@RunWith(SpringRunner.class) +@IamMockMvcIntegrationTest +@TestPropertySource(properties = { + "iam.jwt-profile.default-profile=keycloak", +}) +public class KeycloakProfileIntegrationTests extends EndpointsTestUtils { + + private static final String CLIENT_ID = "password-grant"; + private static final String CLIENT_SECRET = "secret"; + private static final String USERNAME = "test"; + private static final String PASSWORD = "password"; + + private String getAccessTokenForUser(String scopes) throws Exception { + + return new AccessTokenGetter().grantType("password") + .clientId(CLIENT_ID) + .clientSecret(CLIENT_SECRET) + .username(USERNAME) + .password(PASSWORD) + .scope(scopes) + .getAccessTokenValue(); + } + + @Test + public void testKeycloakProfile() throws Exception { + JWT token = JWTParser.parse(getAccessTokenForUser("openid profile")); + + assertThat(token.getJWTClaimsSet().getClaim("scope"), is("openid profile")); + assertThat(token.getJWTClaimsSet().getClaim("nbf"), notNullValue()); + assertThat(token.getJWTClaimsSet().getClaim("groups"), nullValue()); + assertThat(token.getJWTClaimsSet().getClaim("roles"), notNullValue()); + List roles = Lists.newArrayList(token.getJWTClaimsSet().getStringArrayClaim("roles")); + assertThat(roles, hasSize(2)); + assertThat(roles, hasItem("Analysis")); + assertThat(roles, hasItem("Production")); + } +} diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/ProfileSelectorTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/ProfileSelectorTests.java index d3470cc5c..28cbf4ca6 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/ProfileSelectorTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/ProfileSelectorTests.java @@ -58,6 +58,9 @@ public class ProfileSelectorTests { @Mock JWTProfile wlcgProfile; + @Mock + JWTProfile keycloakProfile; + ScopeAwareProfileResolver profileResolver; @Before @@ -67,6 +70,7 @@ public void setup() { profileMap.put(ScopeAwareProfileResolver.AARC_PROFILE_ID, aarcProfile); profileMap.put(ScopeAwareProfileResolver.IAM_PROFILE_ID, iamProfile); profileMap.put(ScopeAwareProfileResolver.WLCG_PROFILE_ID, wlcgProfile); + profileMap.put(ScopeAwareProfileResolver.KEYCLOAK_PROFILE_ID, keycloakProfile); profileResolver = new ScopeAwareProfileResolver(iamProfile, profileMap, clientsService); } @@ -133,5 +137,24 @@ public void multipleProfilesLeadToDefaultProfile() throws Exception { profile = profileResolver.resolveProfile(CLIENT_ID); assertThat(profile, is(iamProfile)); + + when(client.getScope()).thenReturn( + newLinkedHashSet(() -> Arrays.stream(new String[] {"openid", "keycloak"}).iterator())); + + profile = profileResolver.resolveProfile(CLIENT_ID); + assertThat(profile, is(keycloakProfile)); + + when(client.getScope()).thenReturn( + newLinkedHashSet(() -> Arrays.stream(new String[] {"openid", "keycloak", "iam"}).iterator())); + + profile = profileResolver.resolveProfile(CLIENT_ID); + assertThat(profile, is(iamProfile)); + + when(client.getScope()).thenReturn( + newLinkedHashSet(() -> Arrays.stream(new String[] {"openid", "keycloak", "wlcg"}).iterator())); + + profile = profileResolver.resolveProfile(CLIENT_ID); + assertThat(profile, is(iamProfile)); + } } From b8b4b1fac62a0e8a0ee3b01a45c5a6b6b8e4ea05 Mon Sep 17 00:00:00 2001 From: Enrico Vianello Date: Tue, 25 Jul 2023 16:26:47 +0100 Subject: [PATCH 2/3] Add missing license --- .../profile/KeycloakProfileIntegrationTests.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/KeycloakProfileIntegrationTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/KeycloakProfileIntegrationTests.java index 5bbcc4f8f..8443f3244 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/KeycloakProfileIntegrationTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/profile/KeycloakProfileIntegrationTests.java @@ -1,3 +1,18 @@ +/** + * 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.test.oauth.profile; import static org.hamcrest.CoreMatchers.hasItem; From e2bac66139cf2e2b5fc86f99d07b499112e9da8a Mon Sep 17 00:00:00 2001 From: James Acris Date: Wed, 26 Jul 2023 11:41:01 +0100 Subject: [PATCH 3/3] Typo correction --- .../test/oauth/introspection/IntrospectionEndpointTests.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/introspection/IntrospectionEndpointTests.java b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/introspection/IntrospectionEndpointTests.java index 7c1c477d4..bca7e412c 100644 --- a/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/introspection/IntrospectionEndpointTests.java +++ b/iam-login-service/src/test/java/it/infn/mw/iam/test/oauth/introspection/IntrospectionEndpointTests.java @@ -44,7 +44,7 @@ public class IntrospectionEndpointTests extends EndpointsTestUtils { @Value("${iam.organisation.name}") String organisationName; - + @Value("${iam.issuer}") String issuer; @@ -53,7 +53,7 @@ public class IntrospectionEndpointTests extends EndpointsTestUtils { private static final String CLIENT_SECRET = "secret"; @Test - public void testIntrospectionEndpointRetursBasicUserInformation() throws Exception { + public void testIntrospectionEndpointReturnsBasicUserInformation() throws Exception { String accessToken = getPasswordAccessToken(); // @formatter:off