-
Notifications
You must be signed in to change notification settings - Fork 24.9k
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
Map only specific type of OIDC Claims #58524
Changes from 1 commit
e93302d
4274675
f1b3589
1c02a7d
fd030f0
cb9e3f2
ee0b57b
2499dc2
41aeec6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -210,13 +210,9 @@ private void buildUserFromClaims(JWTClaimsSet claims, ActionListener<Authenticat | |
|
||
final Map<String, Object> userMetadata; | ||
if (populateUserMetadata) { | ||
userMetadata = claims.getClaims().entrySet().stream().filter(entry -> { | ||
/* | ||
* We whitelist the Types that we want to parse as metadata from the Claims, explicitly filtering out {@link Date}s | ||
*/ | ||
Object v = entry.getValue(); | ||
return (v instanceof String || v instanceof Boolean || v instanceof Number || v instanceof Collection); | ||
}).collect(Collectors.toUnmodifiableMap(entry -> "oidc(" + entry.getKey() + ")", Map.Entry::getValue)); | ||
userMetadata = claims.getClaims().entrySet().stream() | ||
.filter(entry -> isAllowedTypeForClaim(entry.getValue())) | ||
.collect(Collectors.toUnmodifiableMap(entry -> "oidc(" + entry.getKey() + ")", Map.Entry::getValue)); | ||
} else { | ||
userMetadata = Map.of(); | ||
} | ||
|
@@ -385,6 +381,15 @@ public void close() { | |
openIdConnectAuthenticator.close(); | ||
} | ||
|
||
/* | ||
* We only map claims that are of Type String, Boolean, or Number, or arrays that contain only these types | ||
*/ | ||
private static boolean isAllowedTypeForClaim(Object o) { | ||
return (o instanceof String || o instanceof Boolean || o instanceof Number | ||
|| (o instanceof Collection && ((Collection) o).stream() | ||
.allMatch(c -> c instanceof String || c instanceof Boolean || c instanceof Number))); | ||
} | ||
|
||
static final class ClaimParser { | ||
private final String name; | ||
private final Function<JWTClaimsSet, List<String>> parser; | ||
|
@@ -412,6 +417,15 @@ public String toString() { | |
return name; | ||
} | ||
|
||
private static void validateClaimType(Object claimValueObject, String settingKey) { | ||
if (claimValueObject instanceof String == false && | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd prefer a more descriptive method name if possible. The current feels like it will validate the claim value is a certain specified type. But it really just asserts the value is either:
Now I am not sure if this is what we really want? Is it OK if the value is a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hey can you elaborate on what you mean be "descriptive" here? The method does what it intends to do as described in the PR description:
Doing so, seems to me to be "Validating the type of the claim value" and as such There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What I mean is the name gave me the first impression that the signature would be something like: void validateCliamType(Object value, Class<?> type) But instead the type is not specified but should always be String or collection of it. So I'd personally prefer something like There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fair enough, I don't necessarily agree that validateClaimType is off :) but I don't dislike There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks Ioannis. The more important piece of my comment here is about the behaviour, i.e. the claimValueObject instanceof String == false &&
claimValueObject instanceof Collection &&
((Collection) claimValueObject).stream().allMatch(c -> c instanceof String) == false If the value is a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Aha! Now I saw it , thanks, will address |
||
claimValueObject instanceof Collection && ((Collection) claimValueObject).stream().allMatch(c -> c instanceof String) == false) { | ||
throw new SettingsException("Setting [ " + settingKey | ||
+ " expects a claim with String or a String Array value but found a " | ||
+ claimValueObject.getClass().getName()); | ||
} | ||
} | ||
|
||
static ClaimParser forSetting(Logger logger, OpenIdConnectRealmSettings.ClaimSetting setting, RealmConfig realmConfig, | ||
boolean required) { | ||
|
||
|
@@ -424,12 +438,14 @@ static ClaimParser forSetting(Logger logger, OpenIdConnectRealmSettings.ClaimSet | |
+ setting.name(realmConfig) + "]", | ||
claims -> { | ||
Object claimValueObject = claims.getClaim(claimName); | ||
validateClaimType(claimValueObject, RealmSettings.getFullSettingKey(realmConfig, setting.getClaim())); | ||
List<String> values; | ||
if (claimValueObject == null) { | ||
values = List.of(); | ||
} else if (claimValueObject instanceof String) { | ||
values = List.of((String) claimValueObject); | ||
} else if (claimValueObject instanceof List) { | ||
} else if (claimValueObject instanceof Collection) { | ||
validateClaimType(claimValueObject, RealmSettings.getFullSettingKey(realmConfig, setting.getClaim())); | ||
values = (List<String>) claimValueObject; | ||
} else { | ||
throw new SettingsException("Setting [" + RealmSettings.getFullSettingKey(realmConfig, setting.getClaim()) | ||
|
@@ -461,18 +477,15 @@ static ClaimParser forSetting(Logger logger, OpenIdConnectRealmSettings.ClaimSet | |
"OpenID Connect Claim [" + claimName + "] for [" + setting.name(realmConfig) + "]", | ||
claims -> { | ||
Object claimValueObject = claims.getClaim(claimName); | ||
validateClaimType(claimValueObject, RealmSettings.getFullSettingKey(realmConfig, setting.getClaim())); | ||
if (claimValueObject == null) { | ||
return List.of(); | ||
} else if (claimValueObject instanceof String) { | ||
return List.of((String) claimValueObject); | ||
} else if (claimValueObject instanceof List == false) { | ||
throw new SettingsException("Setting [" + RealmSettings.getFullSettingKey(realmConfig, setting.getClaim()) | ||
+ " expects a claim with String or a String Array value but found a " | ||
+ claimValueObject.getClass().getName()); | ||
} | ||
return ((List<String>) claimValueObject).stream() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
.filter(Objects::nonNull) | ||
.collect(Collectors.toUnmodifiableList()); | ||
.filter(Objects::nonNull) | ||
.collect(Collectors.toUnmodifiableList()); | ||
}); | ||
} | ||
} else if (required) { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -11,7 +11,9 @@ | |
import com.nimbusds.openid.connect.sdk.Nonce; | ||
import org.elasticsearch.action.ActionListener; | ||
import org.elasticsearch.action.support.PlainActionFuture; | ||
import org.elasticsearch.common.Nullable; | ||
import org.elasticsearch.common.settings.Settings; | ||
import org.elasticsearch.common.settings.SettingsException; | ||
import org.elasticsearch.common.util.concurrent.ThreadContext; | ||
import org.elasticsearch.env.Environment; | ||
import org.elasticsearch.env.TestEnvironment; | ||
|
@@ -26,6 +28,7 @@ | |
import org.elasticsearch.xpack.core.security.authc.RealmSettings; | ||
import org.elasticsearch.xpack.core.security.authc.oidc.OpenIdConnectRealmSettings; | ||
import org.elasticsearch.xpack.core.security.authc.support.DelegatedAuthorizationSettings; | ||
import org.elasticsearch.xpack.core.security.support.Exceptions; | ||
import org.elasticsearch.xpack.core.security.user.User; | ||
import org.elasticsearch.xpack.security.authc.support.MockLookupRealm; | ||
import org.elasticsearch.xpack.core.security.authc.support.UserRoleMapper; | ||
|
@@ -85,7 +88,7 @@ public void testAuthentication() throws Exception { | |
|
||
final boolean notPopulateMetadata = randomBoolean(); | ||
final String authenticatingRealm = randomBoolean() ? REALM_NAME : null; | ||
AuthenticationResult result = authenticateWithOidc(principal, roleMapper, notPopulateMetadata, false, authenticatingRealm); | ||
AuthenticationResult result = authenticateWithOidc(principal, roleMapper, notPopulateMetadata, false, authenticatingRealm, null); | ||
assertThat(result, notNullValue()); | ||
assertThat(result.getStatus(), equalTo(AuthenticationResult.Status.SUCCESS)); | ||
assertThat(result.getUser().principal(), equalTo(principal)); | ||
|
@@ -104,6 +107,63 @@ public void testAuthentication() throws Exception { | |
} | ||
} | ||
|
||
public void testClaimPropertyMapping() throws Exception { | ||
final UserRoleMapper roleMapper = mock(UserRoleMapper.class); | ||
final String principal = randomAlphaOfLength(12); | ||
AtomicReference<UserRoleMapper.UserData> userData = new AtomicReference<>(); | ||
doAnswer(invocation -> { | ||
assert invocation.getArguments().length == 2; | ||
userData.set((UserRoleMapper.UserData) invocation.getArguments()[0]); | ||
ActionListener<Set<String>> listener = (ActionListener<Set<String>>) invocation.getArguments()[1]; | ||
listener.onResponse(new HashSet<>(Arrays.asList("kibana_user", "role1"))); | ||
return null; | ||
}).when(roleMapper).resolveRoles(any(UserRoleMapper.UserData.class), any(ActionListener.class)); | ||
Map<String, Object> claims = Map.of( | ||
"groups", List.of(Map.of("key1", List.of("value1", "value2")), Map.of("key2", List.of("value1", "value2"))) | ||
); | ||
Exception e = expectThrows(Exception.class, () -> authenticateWithOidc(principal, roleMapper, false, false, | ||
REALM_NAME, claims)); | ||
assertThat(e.getCause().getMessage(), containsString("expects a claim with String or a String Array value")); | ||
} | ||
|
||
public void testClaimMetadataMapping() throws Exception { | ||
final UserRoleMapper roleMapper = mock(UserRoleMapper.class); | ||
final String principal = randomAlphaOfLength(12); | ||
AtomicReference<UserRoleMapper.UserData> userData = new AtomicReference<>(); | ||
doAnswer(invocation -> { | ||
assert invocation.getArguments().length == 2; | ||
userData.set((UserRoleMapper.UserData) invocation.getArguments()[0]); | ||
ActionListener<Set<String>> listener = (ActionListener<Set<String>>) invocation.getArguments()[1]; | ||
listener.onResponse(new HashSet<>(Arrays.asList("kibana_user", "role1"))); | ||
return null; | ||
}).when(roleMapper).resolveRoles(any(UserRoleMapper.UserData.class), any(ActionListener.class)); | ||
Map<String, Object> claims = Map.of( | ||
"string", "String", | ||
"number", 232, | ||
"boolean", true, | ||
"string_array", List.of("one", "two", "three"), | ||
"number_array", List.of(1, 2, 3), | ||
"boolean_array", List.of(true, false, true), | ||
"object", Map.of("key", List.of("value1", "value2")), | ||
"object_array", List.of(Map.of("key1", List.of("value1", "value2")), Map.of("key2", List.of("value1", "value2"))) | ||
); | ||
AuthenticationResult result = authenticateWithOidc(principal, roleMapper, false, false, REALM_NAME, claims); | ||
assertThat(result, notNullValue()); | ||
assertThat(result.getStatus(), equalTo(AuthenticationResult.Status.SUCCESS)); | ||
assertThat(result.getUser().principal(), equalTo(principal)); | ||
assertThat(result.getUser().email(), equalTo("[email protected]")); | ||
assertThat(result.getUser().fullName(), equalTo("Clinton Barton")); | ||
assertThat(result.getUser().roles(), arrayContainingInAnyOrder("kibana_user", "role1")); | ||
assertTrue(result.getUser().metadata().containsKey("oidc(string)")); | ||
assertTrue(result.getUser().metadata().containsKey("oidc(number)")); | ||
assertTrue(result.getUser().metadata().containsKey("oidc(boolean)")); | ||
assertTrue(result.getUser().metadata().containsKey("oidc(string_array)")); | ||
assertTrue(result.getUser().metadata().containsKey("oidc(boolean_array)")); | ||
assertTrue(result.getUser().metadata().containsKey("oidc(number_array)")); | ||
assertFalse(result.getUser().metadata().containsKey("oidc(object_array)")); | ||
assertFalse(result.getUser().metadata().containsKey("oidc(object)")); | ||
} | ||
|
||
public void testWithAuthorizingRealm() throws Exception { | ||
final UserRoleMapper roleMapper = mock(UserRoleMapper.class); | ||
final String principal = randomAlphaOfLength(12); | ||
|
@@ -114,7 +174,7 @@ public void testWithAuthorizingRealm() throws Exception { | |
return null; | ||
}).when(roleMapper).resolveRoles(any(UserRoleMapper.UserData.class), any(ActionListener.class)); | ||
final String authenticatingRealm = randomBoolean() ? REALM_NAME : null; | ||
AuthenticationResult result = authenticateWithOidc(principal, roleMapper, randomBoolean(), true, authenticatingRealm); | ||
AuthenticationResult result = authenticateWithOidc(principal, roleMapper, randomBoolean(), true, authenticatingRealm, null); | ||
assertThat(result, notNullValue()); | ||
assertThat(result.getStatus(), equalTo(AuthenticationResult.Status.SUCCESS)); | ||
assertThat(result.getUser().principal(), equalTo(principal)); | ||
|
@@ -132,7 +192,7 @@ public void testWithAuthorizingRealm() throws Exception { | |
public void testAuthenticationWithWrongRealm() throws Exception{ | ||
final String principal = randomAlphaOfLength(12); | ||
AuthenticationResult result = authenticateWithOidc(principal, mock(UserRoleMapper.class), randomBoolean(), true, | ||
REALM_NAME+randomAlphaOfLength(8)); | ||
REALM_NAME + randomAlphaOfLength(8), null); | ||
assertThat(result, notNullValue()); | ||
assertThat(result.getStatus(), equalTo(AuthenticationResult.Status.CONTINUE)); | ||
} | ||
|
@@ -334,8 +394,8 @@ public void testBuildingAuthenticationRequestWithLoginHint() { | |
} | ||
|
||
private AuthenticationResult authenticateWithOidc(String principal, UserRoleMapper roleMapper, boolean notPopulateMetadata, | ||
boolean useAuthorizingRealm | ||
,String authenticatingRealm) | ||
boolean useAuthorizingRealm, String authenticatingRealm, | ||
@Nullable Map<String, Object> additionalClaims) | ||
throws Exception { | ||
RealmConfig.RealmIdentifier realmIdentifier = new RealmConfig.RealmIdentifier("mock", "mock_lookup"); | ||
final MockLookupRealm lookupRealm = new MockLookupRealm( | ||
|
@@ -360,7 +420,7 @@ private AuthenticationResult authenticateWithOidc(String principal, UserRoleMapp | |
final OpenIdConnectRealm realm = new OpenIdConnectRealm(config, authenticator, roleMapper); | ||
initializeRealms(realm, lookupRealm); | ||
final OpenIdConnectToken token = new OpenIdConnectToken("", new State(), new Nonce(), authenticatingRealm); | ||
final JWTClaimsSet claims = new JWTClaimsSet.Builder() | ||
final JWTClaimsSet.Builder claimsBuilder = new JWTClaimsSet.Builder() | ||
.subject(principal) | ||
.audience("https://rp.elastic.co/cb") | ||
.expirationTime(Date.from(now().plusSeconds(3600))) | ||
|
@@ -370,9 +430,13 @@ private AuthenticationResult authenticateWithOidc(String principal, UserRoleMapp | |
.claim("groups", Arrays.asList("group1", "group2", "groups3")) | ||
.claim("mail", "[email protected]") | ||
.claim("name", "Clinton Barton") | ||
.claim("id_token_hint", "thisis.aserialized.jwt") | ||
.build(); | ||
|
||
.claim("id_token_hint", "thisis.aserialized.jwt"); | ||
if (additionalClaims != null) { | ||
for (Map.Entry<String, Object> entry : additionalClaims.entrySet()) { | ||
claimsBuilder.claim(entry.getKey(), entry.getValue()); | ||
} | ||
} | ||
final JWTClaimsSet claims = claimsBuilder.build(); | ||
doAnswer((i) -> { | ||
ActionListener<JWTClaimsSet> listener = (ActionListener<JWTClaimsSet>) i.getArguments()[1]; | ||
listener.onResponse(claims); | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we probably want to ensure that these are homogenous collections. I don't know how we would make sense of
[ "1", true, 1.0 ]
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You used Greek so I get to be pedantic 🎉 s/heterogeneous/homogeneous/ :)
In all seriousness, I took a lenient stance here on purpose for metadata ( as opposed to user properties where I allowed only strings ).
happy to reconsider if you think this would cause an issue somewhere else or that it is too lenient for the sake of being lenient but I don't see any specific problem with it as is
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My comment clearly says
homogeneous
😈Fair enough - as long as it's a considered position, I'm not worried.