Skip to content

Commit 9d605b5

Browse files
authored
JWT realm - add support for required claims (#92314)
This PR adds a new required_claims group setting that can be used to specify additional mandatory claim checks for either ID tokens or access tokens. A required claim must have either string or string list value.
1 parent 1c06ed8 commit 9d605b5

File tree

9 files changed

+302
-40
lines changed

9 files changed

+302
-40
lines changed

docs/changelog/92314.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
pr: 92314
2+
summary: JWT realm - add support for required claims
3+
area: Authentication
4+
type: enhancement
5+
issues: []

x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authc/jwt/JwtRealmSettings.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import org.elasticsearch.common.settings.SecureString;
1010
import org.elasticsearch.common.settings.Setting;
11+
import org.elasticsearch.common.settings.Settings;
1112
import org.elasticsearch.core.Strings;
1213
import org.elasticsearch.core.TimeValue;
1314
import org.elasticsearch.xpack.core.security.authc.RealmSettings;
@@ -160,6 +161,7 @@ private static Set<Setting.AffixSetting<?>> getNonSecureSettings() {
160161
ALLOWED_SUBJECTS,
161162
FALLBACK_SUB_CLAIM,
162163
FALLBACK_AUD_CLAIM,
164+
REQUIRED_CLAIMS,
163165
CLAIMS_PRINCIPAL.getClaim(),
164166
CLAIMS_PRINCIPAL.getPattern(),
165167
CLAIMS_GROUPS.getClaim(),
@@ -302,6 +304,26 @@ public Iterator<Setting<?>> settings() {
302304
}, Setting.Property.NodeScope)
303305
);
304306

307+
public static final Setting.AffixSetting<Settings> REQUIRED_CLAIMS = Setting.affixKeySetting(
308+
RealmSettings.realmSettingPrefix(TYPE),
309+
"required_claims",
310+
key -> Setting.groupSetting(key + ".", settings -> {
311+
final List<String> invalidRequiredClaims = List.of("iss", "sub", "aud", "exp", "nbf", "iat");
312+
for (String name : settings.names()) {
313+
final String fullName = key + "." + name;
314+
if (invalidRequiredClaims.contains(name)) {
315+
throw new IllegalArgumentException(
316+
Strings.format("required claim [%s] cannot be one of [%s]", fullName, String.join(",", invalidRequiredClaims))
317+
);
318+
}
319+
final List<String> values = settings.getAsList(name);
320+
if (values.isEmpty()) {
321+
throw new IllegalArgumentException(Strings.format("required claim [%s] cannot be empty", fullName));
322+
}
323+
}
324+
}, Setting.Property.NodeScope)
325+
);
326+
305327
// Note: ClaimSetting is a wrapper for two individual settings: getClaim(), getPattern()
306328
public static final ClaimSetting CLAIMS_PRINCIPAL = new ClaimSetting(TYPE, "principal");
307329
public static final ClaimSetting CLAIMS_GROUPS = new ClaimSetting(TYPE, "groups");

x-pack/plugin/security/qa/jwt-realm/build.gradle

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ testClusters.matching { it.name == 'javaRestTest' }.configureEach {
6262
setting 'xpack.security.authc.realms.jwt.jwt1.claims.dn', 'dn'
6363
setting 'xpack.security.authc.realms.jwt.jwt1.claims.name', 'name'
6464
setting 'xpack.security.authc.realms.jwt.jwt1.claims.mail', 'mail'
65+
setting 'xpack.security.authc.realms.jwt.jwt1.required_claims.token_use', 'id'
66+
setting 'xpack.security.authc.realms.jwt.jwt1.required_claims.version', '2.0'
6567
setting 'xpack.security.authc.realms.jwt.jwt1.client_authentication.type', 'NONE'
6668
// Use default value (RS256) for signature algorithm
6769
setting 'xpack.security.authc.realms.jwt.jwt1.pkc_jwkset_path', 'rsa.jwkset'
@@ -84,12 +86,13 @@ testClusters.matching { it.name == 'javaRestTest' }.configureEach {
8486
setting 'xpack.security.authc.realms.jwt.jwt2.claims.principal', 'sub'
8587
}
8688
setting 'xpack.security.authc.realms.jwt.jwt2.claim_patterns.principal', '^(.*)@[^.]*[.]example[.]com$'
89+
setting 'xpack.security.authc.realms.jwt.jwt2.required_claims.token_use', 'access'
8790
setting 'xpack.security.authc.realms.jwt.jwt2.authorization_realms', 'lookup_native'
8891
setting 'xpack.security.authc.realms.jwt.jwt2.client_authentication.type', 'shared_secret'
8992
keystore 'xpack.security.authc.realms.jwt.jwt2.client_authentication.shared_secret', 'test-secret'
9093
keystore 'xpack.security.authc.realms.jwt.jwt2.hmac_key', 'test-HMAC/secret passphrase-value'
9194

92-
// Place PKI realm after JWT realm to verify realm chain fall-throug
95+
// Place PKI realm after JWT realm to verify realm chain fall-through
9396
setting 'xpack.security.authc.realms.pki.pki_realm.order', '4'
9497

9598
setting 'xpack.security.authc.realms.jwt.jwt3.order', '5'

x-pack/plugin/security/qa/jwt-realm/src/javaRestTest/java/org/elasticsearch/xpack/security/authc/jwt/JwtRestIT.java

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,28 @@ public void testFailureOnInvalidHMACSignature() throws Exception {
338338

339339
}
340340

341+
public void testFailureOnRequiredClaims() throws JOSEException, IOException {
342+
final String principal = System.getProperty("jwt2.service_subject");
343+
final String username = getUsernameFromPrincipal(principal);
344+
final List<String> roles = randomRoles();
345+
createUser(username, roles, Map.of());
346+
try {
347+
final String audience = "es0" + randomIntBetween(1, 3);
348+
final Map<String, Object> data = new HashMap<>(Map.of("iss", "my-issuer", "aud", audience, "email", principal));
349+
// The required claim is either missing or mismatching
350+
if (randomBoolean()) {
351+
data.put("token_use", randomValueOtherThan("access", () -> randomAlphaOfLengthBetween(3, 10)));
352+
}
353+
final JWTClaimsSet claimsSet = buildJwt(data, Instant.now(), false);
354+
final SignedJWT jwt = signHmacJwt(claimsSet, "test-HMAC/secret passphrase-value");
355+
final TestSecurityClient client = getSecurityClient(jwt, VALID_SHARED_SECRET);
356+
final ResponseException exception = expectThrows(ResponseException.class, client::authenticate);
357+
assertThat(exception.getResponse(), hasStatusCode(RestStatus.UNAUTHORIZED));
358+
} finally {
359+
deleteUser(username);
360+
}
361+
}
362+
341363
public void testAuthenticationFailureIfDelegatedAuthorizationFails() throws Exception {
342364
final String principal = System.getProperty("jwt2.service_subject");
343365
final String username = getUsernameFromPrincipal(principal);
@@ -486,7 +508,9 @@ private SignedJWT buildAndSignJwtForRealm1(
486508
Map.entry("dn", dn),
487509
Map.entry("name", name),
488510
Map.entry("mail", mail),
489-
Map.entry("roles", groups) // Realm realm config has `claim.groups: "roles"`
511+
Map.entry("roles", groups), // Realm realm config has `claim.groups: "roles"`
512+
Map.entry("token_use", "id"),
513+
Map.entry("version", "2.0")
490514
),
491515
issueTime
492516
);
@@ -505,7 +529,9 @@ private SignedJWT buildAndSignJwtForRealm2(String principal, Instant issueTime)
505529
private JWTClaimsSet buildJwtForRealm2(String principal, Instant issueTime) {
506530
// The "jwt2" realm, supports 3 audiences (es01/02/03)
507531
final String audience = "es0" + randomIntBetween(1, 3);
508-
final Map<String, Object> data = new HashMap<>(Map.of("iss", "my-issuer", "aud", audience, "email", principal));
532+
final Map<String, Object> data = new HashMap<>(
533+
Map.of("iss", "my-issuer", "aud", audience, "email", principal, "token_use", "access")
534+
);
509535
// scope (fallback audience) is ignored since aud exists
510536
if (randomBoolean()) {
511537
data.put("scope", randomAlphaOfLength(20));

x-pack/plugin/security/src/main/java/org/elasticsearch/xpack/security/authc/jwt/JwtAuthenticator.java

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import org.apache.logging.log4j.Logger;
1616
import org.elasticsearch.action.ActionListener;
1717
import org.elasticsearch.common.settings.SecureString;
18+
import org.elasticsearch.common.settings.Settings;
1819
import org.elasticsearch.core.Releasable;
1920
import org.elasticsearch.core.TimeValue;
2021
import org.elasticsearch.xpack.core.security.authc.RealmConfig;
@@ -23,6 +24,7 @@
2324

2425
import java.text.ParseException;
2526
import java.time.Clock;
27+
import java.util.ArrayList;
2628
import java.util.List;
2729
import java.util.Map;
2830

@@ -47,16 +49,19 @@ public JwtAuthenticator(
4749
) {
4850
this.realmConfig = realmConfig;
4951
this.tokenType = realmConfig.getSetting(JwtRealmSettings.TOKEN_TYPE);
52+
final List<JwtFieldValidator> jwtFieldValidators = new ArrayList<>();
5053
if (tokenType == JwtRealmSettings.TokenType.ID_TOKEN) {
5154
this.fallbackClaimNames = Map.of();
52-
this.jwtFieldValidators = configureFieldValidatorsForIdToken(realmConfig);
55+
jwtFieldValidators.addAll(configureFieldValidatorsForIdToken(realmConfig));
5356
} else {
5457
this.fallbackClaimNames = Map.ofEntries(
5558
Map.entry("sub", realmConfig.getSetting(JwtRealmSettings.FALLBACK_SUB_CLAIM)),
5659
Map.entry("aud", realmConfig.getSetting(JwtRealmSettings.FALLBACK_AUD_CLAIM))
5760
);
58-
this.jwtFieldValidators = configureFieldValidatorsForAccessToken(realmConfig, fallbackClaimNames);
61+
jwtFieldValidators.addAll(configureFieldValidatorsForAccessToken(realmConfig, fallbackClaimNames));
5962
}
63+
jwtFieldValidators.addAll(getRequireClaimsValidators());
64+
this.jwtFieldValidators = List.copyOf(jwtFieldValidators);
6065
this.jwtSignatureValidator = new JwtSignatureValidator.DelegatingJwtSignatureValidator(realmConfig, sslService, reloadNotifier);
6166
}
6267

@@ -104,12 +109,17 @@ public void authenticate(JwtAuthenticationToken jwtAuthenticationToken, ActionLi
104109
}
105110

106111
try {
107-
jwtSignatureValidator.validate(tokenPrincipal, signedJWT, listener.map(ignored -> jwtClaimsSet));
112+
validateSignature(tokenPrincipal, signedJWT, listener.map(ignored -> jwtClaimsSet));
108113
} catch (Exception e) {
109114
listener.onFailure(e);
110115
}
111116
}
112117

118+
// Package private for testing
119+
void validateSignature(String tokenPrincipal, SignedJWT signedJWT, ActionListener<Void> listener) {
120+
jwtSignatureValidator.validate(tokenPrincipal, signedJWT, listener);
121+
}
122+
113123
@Override
114124
public void close() {
115125
jwtSignatureValidator.close();
@@ -172,6 +182,13 @@ private static List<JwtFieldValidator> configureFieldValidatorsForAccessToken(
172182
new JwtDateClaimValidator(clock, "iat", allowedClockSkew, JwtDateClaimValidator.Relationship.BEFORE_NOW, false),
173183
new JwtDateClaimValidator(clock, "exp", allowedClockSkew, JwtDateClaimValidator.Relationship.AFTER_NOW, false)
174184
);
185+
}
175186

187+
private List<JwtStringClaimValidator> getRequireClaimsValidators() {
188+
final Settings requiredClaims = realmConfig.getSetting(JwtRealmSettings.REQUIRED_CLAIMS);
189+
return requiredClaims.names().stream().map(name -> {
190+
final List<String> allowedValues = requiredClaims.getAsList(name);
191+
return new JwtStringClaimValidator(name, allowedValues, false);
192+
}).toList();
176193
}
177194
}

x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/jwt/JwtAuthenticatorAccessTokenTypeTests.java

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,42 +9,28 @@
99

1010
import org.elasticsearch.xpack.core.security.authc.RealmSettings;
1111
import org.elasticsearch.xpack.core.security.authc.jwt.JwtRealmSettings;
12-
import org.junit.Before;
1312

1413
import java.text.ParseException;
1514

1615
import static org.hamcrest.Matchers.containsString;
1716

1817
public class JwtAuthenticatorAccessTokenTypeTests extends JwtAuthenticatorTests {
1918

20-
private String fallbackSub;
21-
private String fallbackAud;
22-
23-
@Before
24-
public void beforeTest() {
25-
doBeforeTest();
26-
fallbackSub = randomBoolean() ? "_" + randomAlphaOfLength(5) : null;
27-
fallbackAud = randomBoolean() ? "_" + randomAlphaOfLength(8) : null;
28-
}
29-
3019
@Override
3120
protected JwtRealmSettings.TokenType getTokenType() {
3221
return JwtRealmSettings.TokenType.ACCESS_TOKEN;
3322
}
3423

3524
public void testSubjectIsRequired() throws ParseException {
36-
final IllegalArgumentException e = doTestSubjectIsRequired(buildJwtAuthenticator(fallbackSub, fallbackAud));
25+
final IllegalArgumentException e = doTestSubjectIsRequired(buildJwtAuthenticator());
3726
if (fallbackSub != null) {
3827
assertThat(e.getMessage(), containsString("missing required string claim [" + fallbackSub + " (fallback of sub)]"));
3928
}
4029
}
4130

4231
public void testAccessTokenTypeMandatesAllowedSubjects() {
4332
allowedSubject = null;
44-
final IllegalArgumentException e = expectThrows(
45-
IllegalArgumentException.class,
46-
() -> buildJwtAuthenticator(fallbackSub, fallbackAud)
47-
);
33+
final IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> buildJwtAuthenticator());
4834

4935
assertThat(
5036
e.getMessage(),
@@ -53,6 +39,6 @@ public void testAccessTokenTypeMandatesAllowedSubjects() {
5339
}
5440

5541
public void testInvalidIssuerIsCheckedBeforeAlgorithm() throws ParseException {
56-
doTestInvalidIssuerIsCheckedBeforeAlgorithm(buildJwtAuthenticator(fallbackSub, fallbackAud));
42+
doTestInvalidIssuerIsCheckedBeforeAlgorithm(buildJwtAuthenticator());
5743
}
5844
}

x-pack/plugin/security/src/test/java/org/elasticsearch/xpack/security/authc/jwt/JwtAuthenticatorIdTokenTypeTests.java

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,35 +8,24 @@
88
package org.elasticsearch.xpack.security.authc.jwt;
99

1010
import org.elasticsearch.xpack.core.security.authc.jwt.JwtRealmSettings;
11-
import org.junit.Before;
1211

1312
import java.text.ParseException;
1413

1514
import static org.hamcrest.Matchers.containsString;
1615

1716
public class JwtAuthenticatorIdTokenTypeTests extends JwtAuthenticatorTests {
1817

19-
private String fallbackSub;
20-
private String fallbackAud;
21-
22-
@Before
23-
public void beforeTest() {
24-
doBeforeTest();
25-
fallbackSub = null;
26-
fallbackAud = null;
27-
}
28-
2918
@Override
3019
protected JwtRealmSettings.TokenType getTokenType() {
3120
return JwtRealmSettings.TokenType.ID_TOKEN;
3221
}
3322

3423
public void testSubjectIsRequired() throws ParseException {
35-
final IllegalArgumentException e = doTestSubjectIsRequired(buildJwtAuthenticator(fallbackSub, fallbackAud));
24+
final IllegalArgumentException e = doTestSubjectIsRequired(buildJwtAuthenticator());
3625
assertThat(e.getMessage(), containsString("missing required string claim [sub]"));
3726
}
3827

3928
public void testInvalidIssuerIsCheckedBeforeAlgorithm() throws ParseException {
40-
doTestInvalidIssuerIsCheckedBeforeAlgorithm(buildJwtAuthenticator(fallbackSub, fallbackAud));
29+
doTestInvalidIssuerIsCheckedBeforeAlgorithm(buildJwtAuthenticator());
4130
}
4231
}

0 commit comments

Comments
 (0)