Skip to content

Commit

Permalink
Prioritize groups path over groups claim (#767)
Browse files Browse the repository at this point in the history
But when the custom path speficied in `smallrye.jwt.path.groups` is empty and no default groups is set
via `smallrye.jwt.claims.groups` an existing groups claim will still be used.

Signed-off-by: Lukas Ziefle <[email protected]>
  • Loading branch information
oemel09 authored Mar 12, 2024
1 parent 2de45cd commit 1847015
Show file tree
Hide file tree
Showing 3 changed files with 195 additions and 28 deletions.
4 changes: 2 additions & 2 deletions doc/modules/ROOT/pages/configuration.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ SmallRye JWT supports many properties which can be used to customize the token p
|smallrye.jwt.require.named-principal|`false`|If an application relies on `java.security.Principal` returning a name then a token must have a `upn` or `preferred_username` or `sub` claim set. Setting this property will result in SmallRye JWT throwing an exception if none of these claims is available for the application code to reliably deal with a non-null `Principal` name.
|smallrye.jwt.path.sub|none|Path to the claim containing the subject name. It starts from the top level JSON object and can contain multiple segments where each segment represents a JSON object name only, example: `realms/subject`. This property can be used if a token has no 'sub' claim but has the subject set in a different claim. Use double quotes with the namespace qualified claims.
|smallrye.jwt.claims.sub|none| This property can be used to set a default sub claim value when the current token has no standard or custom `sub` claim available. Effectively this property can be used to customize `java.security.Principal` name if no `upn` or `preferred_username` or `sub` claim is set.
|smallrye.jwt.path.groups|none|Path to the claim containing the groups. It starts from the top level JSON object and can contain multiple segments where each segment represents a JSON object name only, example: `realm/groups`. This property can be used if a token has no 'groups' claim but has the groups set in a different claim. Use double quotes with the namespace qualified claims.
|smallrye.jwt.path.groups|none|Path to the claim containing the groups. It starts from the top level JSON object and can contain multiple segments where each segment represents a JSON object name only, example: `realm/groups`. This property can be used if a token has the groups set in a claim different to `groups`. Use double quotes with the namespace qualified claims.
|smallrye.jwt.groups-separator|' '|Separator for splitting a string which may contain multiple group values. It will only be used if the `smallrye.jwt.path.groups` property points to a custom claim whose value is a string. The default value is a single space because a standard OAuth2 `scope` claim may contain a space separated sequence.
|smallrye.jwt.claims.groups|none| This property can be used to set a default groups claim value when the current token has no standard or custom groups claim available.
|smallrye.jwt.claims.groups|none| This property can be used to set a default groups claim value when the current token has no standard groups claim available (or no custom groups claim when `smallrye.jwt.path.groups` is used).
|smallrye.jwt.jwks.refresh-interval|60|JWK cache refresh interval in minutes. It will be ignored unless the `mp.jwt.verify.publickey.location` points to the HTTP or HTTPS URL based JWK set and no HTTP `Cache-Control` response header with a positive `max-age` parameter value is returned from a JWK set endpoint.
|smallrye.jwt.jwks.forced-refresh-interval|30|Forced JWK cache refresh interval in minutes which is used to restrict the frequency of the forced refresh attempts which may happen when the token verification fails due to the cache having no JWK key with a `kid` property matching the current token's `kid` header. It will be ignored unless the `mp.jwt.verify.publickey.location` points to the HTTP or HTTPS URL based JWK set.
|smallrye.jwt.expiration.grace|0|Expiration grace in seconds. By default an expired token will still be accepted if the current time is no more than 1 min after the token expiry time. This property is deprecated. Use `mp.jwt.verify.clock.skew` instead.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,24 @@ public static void setClaims(JwtClaims claimsSet, String token, JWTAuthContextIn
String sub = findSubject(authContextInfo, claimsSet);
claimsSet.setClaim(Claims.sub.name(), sub);
}
Object groupsClaim = claimsSet.getClaimValue(Claims.groups.name());
if (groupsClaim == null || groupsClaim instanceof Map) {
List<String> groups = findGroups(authContextInfo, claimsSet);
claimsSet.setClaim(Claims.groups.name(), groups);
} else if (groupsClaim instanceof String) {
claimsSet.setClaim(Claims.groups.name(),
splitStringClaimValue(groupsClaim.toString(), authContextInfo));

List<String> roles;
if (authContextInfo.getGroupsPath() == null) {
Object groupsClaim = claimsSet.getClaimValue(Claims.groups.name());
if (groupsClaim instanceof String) {
roles = splitStringClaimValue(groupsClaim.toString(), authContextInfo);
} else {
roles = List.class.cast(groupsClaim);
}
} else {
roles = findGroups(authContextInfo, claimsSet);
}

if (roles == null && authContextInfo.getDefaultGroupsClaim() != null) {
roles = Collections.singletonList(authContextInfo.getDefaultGroupsClaim());
}
if (roles != null) {
claimsSet.setClaim(Claims.groups.name(), roles);
}

// Process the rolesMapping claim
Expand All @@ -78,27 +89,22 @@ private static String findSubject(JWTAuthContextInfo authContextInfo, JwtClaims
}

private static List<String> findGroups(JWTAuthContextInfo authContextInfo, JwtClaims claimsSet) {
if (authContextInfo.getGroupsPath() != null) {
final String[] pathSegments = splitClaimPath(authContextInfo.getGroupsPath());
Object claimValue = findClaimValue(authContextInfo.getGroupsPath(), claimsSet.getClaimsMap(), pathSegments, 0);
final String[] pathSegments = splitClaimPath(authContextInfo.getGroupsPath());
Object claimValue = findClaimValue(authContextInfo.getGroupsPath(), claimsSet.getClaimsMap(), pathSegments, 0);

if (claimValue instanceof List) {
@SuppressWarnings("unchecked")
List<String> groups = List.class.cast(claimValue);
// Force a check that a list contains the string values only
try {
return Arrays.asList(groups.toArray(new String[] {}));
} catch (ArrayStoreException ex) {
PrincipalLogging.log.claimAtPathIsNotAnArrayOfStrings(authContextInfo.getGroupsPath());
}
} else if (claimValue instanceof String) {
return splitStringClaimValue(claimValue.toString(), authContextInfo);
} else {
PrincipalLogging.log.claimAtPathIsNeitherAnArrayOfStringsNorString(authContextInfo.getGroupsPath());
if (claimValue instanceof List) {
@SuppressWarnings("unchecked")
List<String> groups = List.class.cast(claimValue);
// Force a check that a list contains the string values only
try {
return Arrays.asList(groups.toArray(new String[] {}));
} catch (ArrayStoreException ex) {
PrincipalLogging.log.claimAtPathIsNotAnArrayOfStrings(authContextInfo.getGroupsPath());
}
}
if (authContextInfo.getDefaultGroupsClaim() != null) {
return Collections.singletonList(authContextInfo.getDefaultGroupsClaim());
} else if (claimValue instanceof String) {
return splitStringClaimValue(claimValue.toString(), authContextInfo);
} else {
PrincipalLogging.log.claimAtPathIsNeitherAnArrayOfStringsNorString(authContextInfo.getGroupsPath());
}

return null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/*
* Copyright 2019 Red Hat, Inc, and individual contributors.
*
* 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 io.smallrye.jwt.auth.principal;

import static org.junit.jupiter.api.Assertions.assertIterableEquals;

import java.util.List;

import org.eclipse.microprofile.jwt.Claims;
import org.jose4j.jwt.JwtClaims;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import org.opentest4j.AssertionFailedError;

@ExtendWith(MockitoExtension.class)
class PrincipalUtilsTest {

private static final List<String> rolesInGroupsClaim = List.of("group1", "group2");
private static final String rolesInGroupsClaimAsString = "group1 group2";
private static final String defaultRole = "default1";
private static final List<String> rolesInDefaultGroup = List.of(defaultRole);
private static final List<String> rolesInCustomGroupsClaim = List.of("custom1", "custom2");
private static final String rolesInCustomGroupsClaimAsString = "custom1 custom2";

private static final List<TestData> tests = List.of(
new TestData("group claim is set, custom groups are not set",
rolesInGroupsClaim,
rolesInGroupsClaim, null, false, null),
new TestData("group claim is set, custom groups are not set, default role is set",
rolesInGroupsClaim,
rolesInGroupsClaim, null, false, defaultRole),

new TestData("group claim is set, custom groups are set",
rolesInCustomGroupsClaim,
rolesInGroupsClaim, rolesInCustomGroupsClaim, true, null),
new TestData("group claim is set, custom groups are set, default role is set",
rolesInCustomGroupsClaim,
rolesInGroupsClaim, rolesInCustomGroupsClaim, true, defaultRole),

new TestData("group claim is set, custom groups are set but empty",
rolesInGroupsClaim,
rolesInGroupsClaim, null, true, null),
new TestData("group claim is set, custom groups are set but empty, default role is set",
rolesInDefaultGroup,
rolesInGroupsClaim, null, true, defaultRole),

new TestData("group claim is empty, custom groups are set",
rolesInCustomGroupsClaim,
null, rolesInCustomGroupsClaim, true, null),
new TestData("group claim is empty, custom groups are set, and default role is set",
rolesInCustomGroupsClaim,
null, rolesInCustomGroupsClaim, true, defaultRole),

new TestData("group claim is empty, custom groups are not set",
null,
null, null, false, null),
new TestData("group claim is empty, custom groups are not set, default role is set",
rolesInDefaultGroup,
null, null, false, defaultRole),

new TestData("group claim is empty, custom groups are set but empty",
null,
null, null, true, null),
new TestData("group claim is empty, custom groups are set but empty, default role is set",
rolesInDefaultGroup,
null, null, true, defaultRole),

new TestData("group claim is set as string, custom groups are not set",
rolesInGroupsClaim,
rolesInGroupsClaimAsString, null, false, null),
new TestData("group claim is empty, custom groups are set as string",
rolesInCustomGroupsClaim,
null, rolesInCustomGroupsClaimAsString, true, null));

@Test
void testGroupsClaimSettings() throws Exception {
for (TestData td : tests) {
JwtClaims claimSet = td.getClaimSet();
PrincipalUtils.setClaims(claimSet, td.getToken(), td.getAuthContextInfo());

@SuppressWarnings("unchecked")
List<String> actualRoles = List.class.cast(claimSet.getClaimValue(Claims.groups.name()));
try {
assertIterableEquals(td.getExpectedRoles(), actualRoles);
} catch (AssertionFailedError e) {
throw new AssertionFailedError(td.getName(), e);
}
}
}

private static class TestData {

private static final String CUSTOM_GROUPS_PATH = "testroles";

private final String name;
private final List<String> expectedRoles;
private final Object rolesInGroupsClaim;
private final Object rolesInCustomClaim;
private final boolean setCustomGroupsPath;
private final String defaultGroupsClaim;

public TestData(String name, List<String> expectedRoles, Object rolesInGroupsClaim, Object rolesInCustomClaim,
boolean setCustomGroupsPath, String defaultGroupsClaim) {
this.name = name;
this.expectedRoles = expectedRoles;
this.rolesInGroupsClaim = rolesInGroupsClaim;
this.rolesInCustomClaim = rolesInCustomClaim;
this.setCustomGroupsPath = setCustomGroupsPath;
this.defaultGroupsClaim = defaultGroupsClaim;
}

public String getName() {
return name;
}

public List<String> getExpectedRoles() {
return expectedRoles;
}

public JwtClaims getClaimSet() {
JwtClaims claimSet = new JwtClaims();
if (rolesInGroupsClaim != null) {
claimSet.setClaim(Claims.groups.name(), rolesInGroupsClaim);
}
if (rolesInCustomClaim != null) {
claimSet.setClaim(CUSTOM_GROUPS_PATH, rolesInCustomClaim);
}
return claimSet;
}

public String getToken() {
return "test.token.signature";
}

public JWTAuthContextInfo getAuthContextInfo() {
JWTAuthContextInfo authContextInfo = new JWTAuthContextInfo();
if (setCustomGroupsPath) {
authContextInfo.setGroupsPath(CUSTOM_GROUPS_PATH);
}
authContextInfo.setDefaultGroupsClaim(defaultGroupsClaim);
return authContextInfo;
}

}

}

0 comments on commit 1847015

Please sign in to comment.