Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SOLR-16230: JWT nested roles support #890

Merged
merged 11 commits into from
Sep 14, 2022
2 changes: 2 additions & 0 deletions solr/CHANGES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ Improvements

* SOLR-16362: Logs: Truncate field values in logs if a doc fails to index. (Nazerke Seidan, David Smiley)

* SOLR-16230: JWT nested roles support (Marco Descher, janhoy)

Optimizations
---------------------
* SOLR-16120: Optimise hl.fl expansion. (Christine Poerschke, David Smiley, Mike Drob)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
Expand Down Expand Up @@ -599,11 +600,45 @@ protected JWTAuthenticationResponse authenticate(String authorizationHeader) {
// Pull roles from separate claim, either as whitespace separated list or as JSON
// array
Object rolesObj = jwtClaims.getClaimValue(rolesClaim);
if (rolesObj == null && rolesClaim.indexOf('.') > 0) {
// support map resolution of nested values
String[] nestedKeys = rolesClaim.split("\\.");
col-panic marked this conversation as resolved.
Show resolved Hide resolved
rolesObj = jwtClaims.getClaimValue(nestedKeys[0]);
for (int i = 1; i < nestedKeys.length; i++) {
if (rolesObj instanceof Map) {
String key = nestedKeys[i];
rolesObj = ((Map<?, ?>) rolesObj).get(key);
}
}
}

if (rolesObj != null) {
if (rolesObj instanceof String) {
finalRoles.addAll(Arrays.asList(((String) rolesObj).split("\\s+")));
} else if (rolesObj instanceof List) {
finalRoles.addAll(jwtClaims.getStringListClaimValue(rolesClaim));
((List<?>) rolesObj)
.forEach(
entry -> {
if (entry instanceof String) {
finalRoles.add((String) entry);
} else {
throw new SolrException(
SolrException.ErrorCode.BAD_REQUEST,
String.format(
Locale.ROOT,
"Could not parse roles from JWT claim %s; expected array of strings, got array with a value of type %s",
rolesClaim,
entry.getClass().getSimpleName()));
}
});
} else {
throw new SolrException(
SolrException.ErrorCode.BAD_REQUEST,
String.format(
Locale.ROOT,
"Could not parse roles from JWT claim %s; got %s",
rolesClaim,
rolesObj.getClass().getSimpleName()));
}
col-panic marked this conversation as resolved.
Show resolved Hide resolved
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,17 @@ protected static JwtClaims generateClaims() {
List<String> roles = Arrays.asList("group-one", "other-group", "group-three");
claims.setStringListClaim(
"roles", roles); // multi-valued claims work too and will end up as a JSON array

// Keycloak Style resource_access roles
HashMap<String, Object> solrMap = new HashMap<>();
solrMap.put("roles", Arrays.asList("user", "admin"));
HashMap<String, Object> resourceAccess = new HashMap<>();
resourceAccess.put("solr", solrMap);
claims.setClaim("resource_access", resourceAccess);

// Special claim with dots in key, should still be addressable non-nested
claims.setClaim("roles.with.dot", Arrays.asList("user", "admin"));

return claims;
}

Expand Down Expand Up @@ -375,7 +386,7 @@ public void roles() {
JWTAuthPlugin.JWTAuthenticationResponse resp = plugin.authenticate(testHeader);
assertTrue(resp.getErrorMessage(), resp.isAuthenticated());

// When 'rolesClaim' is defined in config, then roles from that claim are used instead of claims
// When 'rolesClaim' is defined in config, then roles from that claim are used instead of scopes
Principal principal = resp.getPrincipal();
assertTrue(principal instanceof VerifiedUserRoles);
Set<String> roles = ((VerifiedUserRoles) principal).getVerifiedRoles();
Expand All @@ -385,6 +396,37 @@ public void roles() {
assertTrue(roles.contains("group-three"));
}

@Test
public void rolesWithDotInKey() {
// Special case where a claim key contains dots without being nested
testConfig.put("rolesClaim", "roles.with.dot");
plugin.init(testConfig);
JWTAuthPlugin.JWTAuthenticationResponse resp = plugin.authenticate(testHeader);
assertTrue(resp.getErrorMessage(), resp.isAuthenticated());
Principal principal = resp.getPrincipal();
assertTrue(principal instanceof VerifiedUserRoles);
Set<String> roles = ((VerifiedUserRoles) principal).getVerifiedRoles();
assertEquals(2, roles.size());
assertTrue(roles.contains("user"));
assertTrue(roles.contains("admin"));
}

@Test
public void nestedRoles() {
testConfig.put("rolesClaim", "resource_access.solr.roles");
plugin.init(testConfig);
JWTAuthPlugin.JWTAuthenticationResponse resp = plugin.authenticate(testHeader);
assertTrue(resp.getErrorMessage(), resp.isAuthenticated());

// When 'rolesClaim' is defined in config, then roles from that claim are used instead of claims
Principal principal = resp.getPrincipal();
assertTrue(principal instanceof VerifiedUserRoles);
Set<String> roles = ((VerifiedUserRoles) principal).getVerifiedRoles();
assertEquals(2, roles.size());
assertTrue(roles.contains("user"));
assertTrue(roles.contains("admin"));
}

@Test
public void wrongScope() {
testConfig.put("scope", "wrong");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ requireExp ; Fails requests that lacks an `exp` (expiry time) claim
algAllowlist ; JSON array with algorithms to accept: `HS256`, `HS384`, `HS512`, `RS256`, `RS384`, `RS512`, `ES256`, `ES384`, `ES512`, `PS256`, `PS384`, `PS512`, `none ; Default is to allow all algorithms
jwkCacheDur ; Duration of JWK cache in seconds ; `3600` (1 hour)
principalClaim ; What claim id to pull principal from ; `sub`
rolesClaim ; What claim id to pull user roles from. The claim must then either contain a space separated list of roles or a JSON array. The roles can then be used to define fine-grained access in an Authorization plugin ; By default the scopes from `scope` claim are passed on as user roles
rolesClaim ; What claim id to pull user roles from. Both top-level claim and nested claim is supported. Use `someClaim.child` syntax to address a claim `child` nested within the `someClaim` object. The claim must then either contain a space separated list of roles or a JSON array. The roles can then be used to define fine-grained access in an Authorization plugin ; By default the scopes from `scope` claim are passed on as user roles
claimsMatch ; JSON object of claims (key) that must match a regular expression (value). Example: `{ "foo" : "A|B" }` will require the `foo` claim to be either "A" or "B". ;
adminUiScope ; Define what scope is requested when logging in from Admin UI ; If not defined, the first scope from `scope` parameter is used
redirectUris ; Valid location(s) for redirect after external authentication. Takes a string or array of strings. Must be the base URL of Solr, e.g., https://solr1.example.com:8983/solr/ and must match the list of redirect URIs registered with the Identity Provider beforehand. ; Defaults to empty list, i.e., any node is assumed to be a valid redirect target.
Expand Down