This is an automated email from the ASF dual-hosted git repository.
rombert pushed a commit to branch master
in repository
https://gitbox.apache.org/repos/asf/sling-org-apache-sling-auth-oauth-client.git
The following commit(s) were added to refs/heads/master by this push:
new b531aa1 SLING-12850 Added support for groups in ID Token. (#25)
b531aa1 is described below
commit b531aa11908c1fa8102ec62d7f0b4280d76051a0
Author: Nicola Scendoni <[email protected]>
AuthorDate: Wed Jul 30 15:52:12 2025 +0200
SLING-12850 Added support for groups in ID Token. (#25)
---
.../impl/SlingUserInfoProcessorImpl.java | 55 +++-
.../auth/oauth_client/spi/UserInfoProcessor.java | 7 +-
.../impl/SlingUserInfoProcessorImplTest.java | 283 +++++++++++++++++++++
3 files changed, 334 insertions(+), 11 deletions(-)
diff --git
a/src/main/java/org/apache/sling/auth/oauth_client/impl/SlingUserInfoProcessorImpl.java
b/src/main/java/org/apache/sling/auth/oauth_client/impl/SlingUserInfoProcessorImpl.java
index 28bbbca..f680cbd 100644
---
a/src/main/java/org/apache/sling/auth/oauth_client/impl/SlingUserInfoProcessorImpl.java
+++
b/src/main/java/org/apache/sling/auth/oauth_client/impl/SlingUserInfoProcessorImpl.java
@@ -18,8 +18,11 @@
*/
package org.apache.sling.auth.oauth_client.impl;
+import java.util.List;
+
import com.nimbusds.oauth2.sdk.ParseException;
import com.nimbusds.oauth2.sdk.TokenResponse;
+import com.nimbusds.openid.connect.sdk.OIDCTokenResponse;
import com.nimbusds.openid.connect.sdk.claims.UserInfo;
import net.minidev.json.JSONArray;
import net.minidev.json.JSONObject;
@@ -49,11 +52,21 @@ public class SlingUserInfoProcessorImpl implements
UserInfoProcessor {
name = "Apache Sling Oidc UserInfo Processor",
description = "Apache Sling Oidc UserInfo Processor Service")
@interface Config {
+
+ @AttributeDefinition(name = "groupsInIdToken", description = "Read
groups from ID Token")
+ boolean groupsInIdToken() default false;
+
@AttributeDefinition(name = "storeAccessToken", description = "Store
access Token under User Node")
boolean storeAccessToken() default false;
@AttributeDefinition(name = "storeRefreshToken", description = "Store
access Refresh under User Node")
boolean storeRefreshToken() default false;
+
+ @AttributeDefinition(
+ name = "groupsClaimName",
+ description = "Name of the claim in the ID Token or UserInfo
that contains the groups. "
+ + "If not set, the default 'groups' is used")
+ String groupsClaimName() default "groups";
}
private static final Logger logger =
LoggerFactory.getLogger(SlingUserInfoProcessorImpl.class);
@@ -61,6 +74,8 @@ public class SlingUserInfoProcessorImpl implements
UserInfoProcessor {
private final CryptoService cryptoService;
private final boolean storeAccessToken;
private final boolean storeRefreshToken;
+ private final boolean groupsInIdToken;
+ private final String groupsClaimName;
@Activate
public SlingUserInfoProcessorImpl(
@@ -68,6 +83,8 @@ public class SlingUserInfoProcessorImpl implements
UserInfoProcessor {
this.cryptoService = service;
this.storeAccessToken = config.storeAccessToken();
this.storeRefreshToken = config.storeRefreshToken();
+ this.groupsInIdToken = config.groupsInIdToken();
+ this.groupsClaimName = config.groupsClaimName();
}
@Override
@@ -93,20 +110,40 @@ public class SlingUserInfoProcessorImpl implements
UserInfoProcessor {
logger.debug("Name: {}", userInfo.getGivenName());
logger.debug("FamilyName: {}", userInfo.getFamilyName());
- Object groups = userInfo.toJSONObject().remove("groups");
+ // If groups are not in ID Token, add them from UserInfo
+ userInfo.toJSONObject().forEach((key, value) -> {
+ if (value != null) {
+ credentials.setAttribute("profile/" + key,
value.toString());
+ }
+ });
+ }
+
+ if (groupsInIdToken) {
+ // If groups are in ID Token, add them to the credentials
+ try {
+ Object groups = tokenResponse
+ .toSuccessResponse()
+ .getTokens()
+ .toOIDCTokens()
+ .getIDToken()
+ .getJWTClaimsSet()
+ .getClaim(groupsClaimName);
+ if (groups instanceof List) {
+ logger.debug("Groups from ID Token: {}", groups);
+ ((List) groups).forEach(group ->
credentials.addGroup(group.toString()));
+ }
+ } catch (java.text.ParseException e) {
+ throw new RuntimeException(e);
+ }
+ } else if (userInfo != null) {
+ // If groups are not in ID Token, check UserInfo for groups
+ Object groups = userInfo.toJSONObject().remove(groupsClaimName);
if (groups instanceof JSONArray) {
JSONArray groupJsonArray = (JSONArray) groups;
logger.debug("Groups: {}", groups);
// Convert the groups in a Set of Strings
groupJsonArray.forEach(group ->
credentials.addGroup(group.toString()));
}
-
- // Set all the attributes in userInfo to the credentials
- userInfo.toJSONObject().forEach((key, value) -> {
- if (value != null) {
- credentials.setAttribute("profile/" + key,
value.toString());
- }
- });
}
// Store the Access Token on user node
String accessToken = tokens.accessToken();
@@ -145,7 +182,7 @@ public class SlingUserInfoProcessorImpl implements
UserInfoProcessor {
private static @NotNull TokenResponse parseTokenResponse(@NotNull String
stringTokenResponse) {
try {
JSONObject jsonTokenResponse = (JSONObject)
JSONValue.parse(stringTokenResponse);
- return TokenResponse.parse(jsonTokenResponse);
+ return OIDCTokenResponse.parse(jsonTokenResponse);
} catch (ParseException e) {
throw new RuntimeException("Failed to parse TokenResponse in
UserInfoProcessor", e);
}
diff --git
a/src/main/java/org/apache/sling/auth/oauth_client/spi/UserInfoProcessor.java
b/src/main/java/org/apache/sling/auth/oauth_client/spi/UserInfoProcessor.java
index d794481..d117a9f 100644
---
a/src/main/java/org/apache/sling/auth/oauth_client/spi/UserInfoProcessor.java
+++
b/src/main/java/org/apache/sling/auth/oauth_client/spi/UserInfoProcessor.java
@@ -30,7 +30,9 @@ public interface UserInfoProcessor {
/**
*
- * <p>This method is called by the OIDC authentication handler after the
user info and token response have been received from the identity provider.</p>
+ * <p>This method is called by the OIDC authentication handler after the
user info and token response have been received from the identity provider.
+ * If a failure occurs during processing, a {@link RuntimeException}
should be thrown to indicate the failure.
+ * </p>
*
* @param userInfo the user info received from the identity provider, may
be null if not available. See:
https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
* @param tokenResponse the token response received from the identity
provider, must not be null. See:
https://openid.net/specs/openid-connect-core-1_0.html#HybridTokenResponse
@@ -46,5 +48,6 @@ public interface UserInfoProcessor {
*/
@NotNull
OidcAuthCredentials process(
- @Nullable String userInfo, @NotNull String tokenResponse, @NotNull
String oidcSubject, @NotNull String idp);
+ @Nullable String userInfo, @NotNull String tokenResponse, @NotNull
String oidcSubject, @NotNull String idp)
+ throws RuntimeException;
}
diff --git
a/src/test/java/org/apache/sling/auth/oauth_client/impl/SlingUserInfoProcessorImplTest.java
b/src/test/java/org/apache/sling/auth/oauth_client/impl/SlingUserInfoProcessorImplTest.java
new file mode 100644
index 0000000..e748053
--- /dev/null
+++
b/src/test/java/org/apache/sling/auth/oauth_client/impl/SlingUserInfoProcessorImplTest.java
@@ -0,0 +1,283 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.apache.sling.auth.oauth_client.impl;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.crypto.MACSigner;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+import net.minidev.json.JSONArray;
+import net.minidev.json.JSONObject;
+import org.apache.sling.auth.oauth_client.spi.OidcAuthCredentials;
+import org.apache.sling.commons.crypto.CryptoService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.osgi.util.converter.Converters;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.*;
+
+class SlingUserInfoProcessorImplTest {
+
+ @Mock
+ private CryptoService cryptoService = mock(CryptoService.class);
+
+ private SlingUserInfoProcessorImpl processor;
+
+ private static final String TEST_SUBJECT = "test-subject-123";
+ private static final String TEST_IDP = "test-idp";
+ private static final String TEST_ACCESS_TOKEN = "test-access-token";
+ private static final String TEST_REFRESH_TOKEN = "test-refresh-token";
+ private static final String ENCRYPTED_TOKEN = "encrypted-token";
+
+ @BeforeEach
+ void setUp() {
+ SlingUserInfoProcessorImpl.Config cfg = Converters.standardConverter()
+ .convert(Map.of(
+ "groupsInIdToken", false,
+ "storeAccessToken", false,
+ "storeRefreshToken", false,
+ "groupsClaimName", "groups"))
+ .to(SlingUserInfoProcessorImpl.Config.class);
+ processor = new SlingUserInfoProcessorImpl(cryptoService, cfg);
+
+ when(cryptoService.encrypt(anyString())).thenReturn(ENCRYPTED_TOKEN);
+ }
+
+ @Test
+ void testProcessWithMinimalTokenResponse() throws Exception {
+ // Create minimal token response
+ String tokenResponse = createTokenResponse(TEST_ACCESS_TOKEN,
TEST_REFRESH_TOKEN);
+
+ OidcAuthCredentials result = processor.process(null, tokenResponse,
TEST_SUBJECT, TEST_IDP);
+
+ assertNotNull(result);
+ assertEquals(TEST_SUBJECT, result.getUserId());
+ assertEquals(TEST_IDP, result.getIdp());
+ assertEquals("", result.getAttribute(".token"));
+ assertTrue(result.getAttributes().containsKey(".token"));
+ // Should not have any profile attributes when userInfo is null
+ assertFalse(result.getAttributes().keySet().stream().anyMatch(name ->
name.startsWith("profile/")));
+ }
+
+ @Test
+ void testProcessWithUserInfo() throws Exception {
+ // Create user info JSON
+ JSONObject userInfoJson = new JSONObject();
+ userInfoJson.put("sub", TEST_SUBJECT);
+ userInfoJson.put("email", "[email protected]");
+ userInfoJson.put("given_name", "John");
+ userInfoJson.put("family_name", "Doe");
+ userInfoJson.put("name", "John Doe");
+
+ String tokenResponse = createTokenResponse(TEST_ACCESS_TOKEN,
TEST_REFRESH_TOKEN);
+
+ OidcAuthCredentials result =
+ processor.process(userInfoJson.toJSONString(), tokenResponse,
TEST_SUBJECT, TEST_IDP);
+
+ assertNotNull(result);
+ assertEquals("[email protected]", result.getAttribute("profile/email"));
+ assertEquals("John", result.getAttribute("profile/given_name"));
+ assertEquals("Doe", result.getAttribute("profile/family_name"));
+ assertEquals("John Doe", result.getAttribute("profile/name"));
+ assertEquals(TEST_SUBJECT, result.getAttribute("profile/sub"));
+ }
+
+ @Test
+ void testProcessWithGroupsInUserInfo() throws Exception {
+ // Create user info with groups
+ JSONObject userInfoJson = new JSONObject();
+ userInfoJson.put("sub", TEST_SUBJECT);
+ userInfoJson.put("email", "[email protected]");
+ JSONArray groups = new JSONArray();
+ groups.add("group1");
+ groups.add("group2");
+ userInfoJson.put("groups", groups);
+
+ String tokenResponse = createTokenResponse(TEST_ACCESS_TOKEN,
TEST_REFRESH_TOKEN);
+
+ OidcAuthCredentials result =
+ processor.process(userInfoJson.toJSONString(), tokenResponse,
TEST_SUBJECT, TEST_IDP);
+
+ assertNotNull(result);
+ assertGroupsContain(result.getGroups(), "group1", "group2");
+ // Groups are also present in profile attributes because they're
processed from different JSON instances
+ assertEquals("[\"group1\",\"group2\"]",
result.getAttribute("profile/groups"));
+ }
+
+ @Test
+ void testProcessWithGroupsInIdToken() throws Exception {
+ // Configure to read groups from ID token
+ SlingUserInfoProcessorImpl.Config cfg = Converters.standardConverter()
+ .convert(Map.of(
+ "groupsInIdToken", true,
+ "storeAccessToken", false,
+ "storeRefreshToken", false,
+ "groupsClaimName", "groups"))
+ .to(SlingUserInfoProcessorImpl.Config.class);
+ processor = new SlingUserInfoProcessorImpl(cryptoService, cfg);
+
+ // Create ID token with groups
+ List<String> groups = Arrays.asList("admin", "user");
+ String tokenResponse =
createTokenResponseWithIdToken(TEST_ACCESS_TOKEN, TEST_REFRESH_TOKEN, groups);
+
+ OidcAuthCredentials result = processor.process(null, tokenResponse,
TEST_SUBJECT, TEST_IDP);
+
+ assertNotNull(result);
+ assertGroupsContain(result.getGroups(), "admin", "user");
+ }
+
+ @Test
+ void testStoreAccessToken() throws Exception {
+ SlingUserInfoProcessorImpl.Config cfg = Converters.standardConverter()
+ .convert(Map.of(
+ "groupsInIdToken", false,
+ "storeAccessToken", true,
+ "storeRefreshToken", false,
+ "groupsClaimName", "groups"))
+ .to(SlingUserInfoProcessorImpl.Config.class);
+ processor = new SlingUserInfoProcessorImpl(cryptoService, cfg);
+
+ String tokenResponse = createTokenResponse(TEST_ACCESS_TOKEN,
TEST_REFRESH_TOKEN);
+
+ OidcAuthCredentials result = processor.process(null, tokenResponse,
TEST_SUBJECT, TEST_IDP);
+
+ assertNotNull(result);
+ assertEquals(ENCRYPTED_TOKEN,
result.getAttribute(OAuthTokenStore.PROPERTY_NAME_ACCESS_TOKEN));
+ verify(cryptoService).encrypt(TEST_ACCESS_TOKEN);
+ }
+
+ @Test
+ void testProcessWithEmptyGroups() throws Exception {
+ // Create user info with empty groups array
+ JSONObject userInfoJson = new JSONObject();
+ userInfoJson.put("sub", TEST_SUBJECT);
+ userInfoJson.put("groups", new JSONArray());
+
+ String tokenResponse = createTokenResponse(TEST_ACCESS_TOKEN,
TEST_REFRESH_TOKEN);
+
+ OidcAuthCredentials result =
+ processor.process(userInfoJson.toJSONString(), tokenResponse,
TEST_SUBJECT, TEST_IDP);
+
+ assertNotNull(result);
+ assertGroupsEmpty(result.getGroups());
+ }
+
+ @Test
+ void testProcessWithInvalidTokenResponse() {
+ String invalidTokenResponse = "invalid-json";
+
+ assertThrows(RuntimeException.class, () -> {
+ processor.process(null, invalidTokenResponse, TEST_SUBJECT,
TEST_IDP);
+ });
+ }
+
+ @Test
+ void testProcessWithInvalidUserInfo() throws Exception {
+ String tokenResponse = createTokenResponse(TEST_ACCESS_TOKEN,
TEST_REFRESH_TOKEN);
+ String invalidUserInfo = "invalid-json";
+
+ assertThrows(RuntimeException.class, () -> {
+ processor.process(invalidUserInfo, tokenResponse, TEST_SUBJECT,
TEST_IDP);
+ });
+ }
+
+ private String createTokenResponse(String accessToken, String
refreshToken) throws Exception {
+ // Create a properly formatted OAuth 2.0 token response
+ JSONObject tokenResponse = new JSONObject();
+ if (accessToken != null) {
+ tokenResponse.put("access_token", accessToken);
+ tokenResponse.put("token_type", "Bearer");
+ tokenResponse.put("expires_in", 3600); // 1 hour
+ }
+ if (refreshToken != null) {
+ tokenResponse.put("refresh_token", refreshToken);
+ }
+ tokenResponse.put("scope", "openid profile");
+ return tokenResponse.toJSONString();
+ }
+
+ private String createTokenResponseWithIdToken(String accessToken, String
refreshToken, List<String> groups)
+ throws Exception {
+ // Create a properly formatted OAuth 2.0 token response with ID token
+ JSONObject tokenResponse = new JSONObject();
+
+ if (accessToken != null) {
+ tokenResponse.put("access_token", accessToken);
+ tokenResponse.put("token_type", "Bearer");
+ tokenResponse.put("expires_in", 3600); // 1 hour
+ }
+ if (refreshToken != null) {
+ tokenResponse.put("refresh_token", refreshToken);
+ }
+
+ SignedJWT idToken = createIdToken(groups);
+ tokenResponse.put("id_token", idToken.serialize());
+ tokenResponse.put("scope", "openid profile");
+
+ return tokenResponse.toJSONString();
+ }
+
+ private SignedJWT createIdToken(List<String> groups) throws Exception {
+ JWTClaimsSet.Builder claimsBuilder = new JWTClaimsSet.Builder()
+ .subject(TEST_SUBJECT)
+ .issuer("test-issuer")
+ .audience("test-audience")
+ .issueTime(new java.util.Date())
+ .expirationTime(new java.util.Date(System.currentTimeMillis()
+ 3600000)); // 1 hour from now
+
+ if (groups != null && !groups.isEmpty()) {
+ claimsBuilder.claim("groups", groups);
+ }
+
+ JWTClaimsSet claims = claimsBuilder.build();
+
+ SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256),
claims);
+
+ // Sign with a test secret (must be at least 32 bytes for HS256)
+ String secret =
"test-secret-key-that-is-long-enough-for-hmac-signing-with-hs256";
+ MACSigner signer = new MACSigner(secret.getBytes());
+ jwt.sign(signer);
+
+ return jwt;
+ }
+
+ private void assertGroupsContain(Iterable<String> groups, String...
expectedGroups) {
+ List<String> groupList = new ArrayList<>();
+ groups.forEach(groupList::add);
+ assertEquals(expectedGroups.length, groupList.size());
+ for (String expectedGroup : expectedGroups) {
+ assertTrue(groupList.contains(expectedGroup), "Expected group: " +
expectedGroup);
+ }
+ }
+
+ private void assertGroupsEmpty(Iterable<String> groups) {
+ List<String> groupList = new ArrayList<>();
+ groups.forEach(groupList::add);
+ assertTrue(groupList.isEmpty(), "Expected no groups");
+ }
+}