Skip to content

Commit

Permalink
SOLR-16230: JWT nested roles support (#890)
Browse files Browse the repository at this point in the history
Co-authored-by: Jan Høydahl <janhoy@users.noreply.github.com>
  • Loading branch information
col-panic and janhoy committed Sep 14, 2022
1 parent a926926 commit dd3b35f
Show file tree
Hide file tree
Showing 4 changed files with 82 additions and 3 deletions.
2 changes: 2 additions & 0 deletions solr/CHANGES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,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("\\.");
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()));
}
}
}
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

0 comments on commit dd3b35f

Please sign in to comment.