Skip to content

Commit 47e76b1

Browse files
authored
Principal extraction from nested username claim was broken (#194)
* Principal extraction from nested username claim was broken Signed-off-by: Marko Strukelj <[email protected]> * Add proper support for nested claims Signed-off-by: Marko Strukelj <[email protected]> * Address PR comments and suggestions Signed-off-by: Marko Strukelj <[email protected]> * Remove testsuite run using Kafka 3.3.2 from Travis build Signed-off-by: Marko Strukelj <[email protected]> * Replace custom parsing with JsonPath already used for groups extraction Signed-off-by: Marko Strukelj <[email protected]> * Fix javadoc issue Signed-off-by: Marko Strukelj <[email protected]> --------- Signed-off-by: Marko Strukelj <[email protected]>
1 parent a0c4c34 commit 47e76b1

File tree

7 files changed

+283
-29
lines changed

7 files changed

+283
-29
lines changed

.travis/build.sh

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,14 @@ elif [[ "$arch" != 'ppc64le' ]]; then
6969
EXIT=$?
7070
exitIfError
7171

72-
clearDockerEnv
73-
mvn -e -V -B clean install -f testsuite -Pkafka-3_3_2
74-
EXIT=$?
75-
exitIfError
76-
7772
# Excluded by default to not exceed Travis job timeout
7873
if [ "$SKIP_DISABLED" == "false" ]; then
7974

75+
clearDockerEnv
76+
mvn -e -V -B clean install -f testsuite -Pkafka-3_3_2
77+
EXIT=$?
78+
exitIfError
79+
8080
clearDockerEnv
8181
mvn -e -V -B clean install -f testsuite -Pkafka-3_2_3 -DfailIfNoTests=false -Dtest=\!KeycloakKRaftAuthorizationTests
8282
EXIT=$?

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -316,12 +316,12 @@ If the configured `oauth.client.id` is `kafka`, the following are valid examples
316316

317317
JWT tokens contain unique user identification in `sub` claim. However, this is often a long number or a UUID, but we usually prefer to use human-readable usernames, which may also be present in JWT token.
318318
Use `oauth.username.claim` to map the claim (attribute) where the value you want to use as user id is stored:
319-
- `oauth.username.claim` (e.g.: "preferred_username")
319+
- `oauth.username.claim` (e.g.: "preferred_username", for nested attributes use `[topAttrKey].[subAttrKey]`. Claim names can also be single quoted: `['topAttrKey'].['subAttrKey']`)
320320

321321
If `oauth.username.claim` is specified the value of that claim is used instead, but if not set, the automatic fallback claim is the `sub` claim.
322322

323323
You can specify the secondary claim to fall back to, which allows you to map multiple account types into the same principal namespace:
324-
- `oauth.fallback.username.claim` (e.g.: "client_id")
324+
- `oauth.fallback.username.claim` (e.g.: "client_id", for nested attributes use `[topAttrKey].[subAttrKey]`. Claim names can also be single quoted: `['topAttrKey'].['subAttrKey']`)
325325
- `oauth.fallback.username.prefix` (e.g.: "client-account-")
326326

327327
If `oauth.username.claim` is specified but value does not exist in the token, then `oauth.fallback.username.claim` is used. If value for that doesn't exist either, the exception is thrown.
@@ -400,10 +400,10 @@ Introspection Endpoint may or may not return identifying information which we co
400400
If the information is available we attempt to extract the user id from Introspection Endpoint response.
401401

402402
Use `oauth.username.claim` to map the attribute where the user id is stored:
403-
- `oauth.username.claim` (e.g.: "preferred_username")
403+
- `oauth.username.claim` (e.g.: "preferred_username", for nested attributes use `[topAttrKey].[subAttrKey]`. Claim names can also be single quoted: `['topAttrKey'].['subAttrKey']`)
404404

405405
You can fall back to a secondary attribute, which allows you to map multiple account types into the same user id namespace:
406-
- `oauth.fallback.username.claim` (e.g.: "client_id")
406+
- `oauth.fallback.username.claim` (e.g.: "client_id", for nested attributes use `[topAttrKey].[subAttrKey]`. Claim names can also be single quoted: `['topAttrKey'].['subAttrKey']`)
407407
- `oauth.fallback.username.prefix` (e.g.: "client-account-")
408408

409409
If `oauth.username.claim` is specified but value does not exist in the Introspection Endpoint response, then `oauth.fallback.username.claim` is used. If value for that doesn't exist either, the exception is thrown.
@@ -985,7 +985,7 @@ Audience is sent to the Token Endpoint when obtaining the access token.
985985

986986
For debug purposes you may want to properly configure which JWT token attribute contains the user id of the account used to obtain the access token:
987987

988-
- `oauth.username.claim` (e.g.: "preferred_username")
988+
- `oauth.username.claim` (e.g.: "preferred_username", for nested attributes use `[topAttrKey].[subAttrKey]`. Claim names can also be single quoted: `['topAttrKey'].['subAttrKey']`)
989989

990990
This does not affect how Kafka client is presented to the Kafka Broker.
991991
The broker performs user id extraction from the token once again or it uses the Introspection Endpoint or the User Info Endpoint to get the user id.

oauth-common/src/main/java/io/strimzi/kafka/oauth/common/JSONUtil.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,9 @@ public static String getClaimFromJWT(String claim, Object token) {
112112
* @return Value of the specific claim as String or null if claim not present
113113
*/
114114
public static String getClaimFromJWT(JsonNode node, String... path) {
115+
if (path.length == 0) {
116+
return null;
117+
}
115118
for (String p: path) {
116119
node = node.get(p);
117120
if (node == null) {

oauth-common/src/main/java/io/strimzi/kafka/oauth/common/PrincipalExtractor.java

Lines changed: 121 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,37 +5,67 @@
55
package io.strimzi.kafka.oauth.common;
66

77
import com.fasterxml.jackson.databind.JsonNode;
8+
import io.strimzi.kafka.oauth.jsonpath.JsonPathQuery;
89

910
import static io.strimzi.kafka.oauth.common.JSONUtil.getClaimFromJWT;
1011

1112
/**
1213
* An object with logic for extracting a principal name (i.e. a user id) from a JWT token.
13-
*
14+
* <p>
1415
* First a claim configured as <code>usernameClaim</code> is looked up.
1516
* If not found the claim configured as <code>fallbackUsernameClaim</code> is looked up. If that one is found and if
1617
* the <code>fallbackUsernamePrefix</code> is configured prefix the found value with the prefix, otherwise not.
18+
* <p>
19+
* The claim specification uses the following rules:
20+
* <ul>
21+
* <li>If the claim specification starts with an opening square bracket '[', it is interpreted as a JsonPath query, and allows
22+
* targeting a nested attribute. </li>
23+
* <li>Otherwise, it is interpreted as a top level attribute name.</li>
24+
* </ul>
25+
* <p>
26+
* A JsonPath query is resolved relative to JSON object containing info to identify user
27+
* (a JWT payload, a response from Introspection Endpoint or a response from User Info Endpoint).
28+
* <p>
29+
* For more on JsonPath syntax see https://github.com/json-path/JsonPath.
30+
* <p>
31+
* Examples of claim specification:
32+
* <pre>
33+
* userId ... use top level attribute named 'userId'
34+
* user.id ... use top level attribute named 'user.id'
35+
* $userid ... use top level attribute named '$userid'
36+
* ['userInfo']['id'] ... use nested attribute 'id' under 'userInfo' top level attribute
37+
* ['userInfo'].id ... use nested attribute 'id' under 'userInfo' top level attribute (second segment not using brackets)
38+
* ['user.info']['user.id'] ... use nested attribute 'user.id' under 'user.info' top level attribute
39+
* ['user.info'].['user.id'] ... use nested attribute 'user.id' under 'user.info' top level attribute (optional dot)
40+
* </pre>
41+
*
42+
* See PrincipalExtractorTest.java for more working and non-working examples of claim specification.
1743
*/
1844
public class PrincipalExtractor {
1945

20-
private String usernameClaim;
21-
private String fallbackUsernameClaim;
22-
private String fallbackUsernamePrefix;
46+
private final Extractor usernameExtractor;
47+
private final Extractor fallbackUsernameExtractor;
48+
private final String fallbackUsernamePrefix;
2349

2450
/**
2551
* Create a new instance
2652
*/
27-
public PrincipalExtractor() {}
53+
public PrincipalExtractor() {
54+
usernameExtractor = null;
55+
fallbackUsernameExtractor = null;
56+
fallbackUsernamePrefix = null;
57+
}
2858

2959
/**
3060
* Create a new instance
3161
*
32-
* @param usernameClaim Attribute name for an attribute containing the user id to lookup first
62+
* @param usernameClaim Attribute name for an attribute containing the user id to lookup first.
3363
* @param fallbackUsernameClaim Attribute name for an attribute containg the user id to lookup as a fallback
3464
* @param fallbackUsernamePrefix A prefix to prepend to the value of the fallback attribute value if set
3565
*/
3666
public PrincipalExtractor(String usernameClaim, String fallbackUsernameClaim, String fallbackUsernamePrefix) {
37-
this.usernameClaim = usernameClaim;
38-
this.fallbackUsernameClaim = fallbackUsernameClaim;
67+
this.usernameExtractor = parseClaimSpec(usernameClaim);
68+
this.fallbackUsernameExtractor = parseClaimSpec(fallbackUsernameClaim);
3969
this.fallbackUsernamePrefix = fallbackUsernamePrefix;
4070
}
4171

@@ -48,23 +78,38 @@ public PrincipalExtractor(String usernameClaim, String fallbackUsernameClaim, St
4878
public String getPrincipal(JsonNode json) {
4979
String result;
5080

51-
if (usernameClaim != null) {
52-
result = getClaimFromJWT(json, usernameClaim);
81+
if (usernameExtractor != null) {
82+
result = extractUsername(usernameExtractor, json);
5383
if (result != null) {
5484
return result;
5585
}
56-
57-
if (fallbackUsernameClaim != null) {
58-
result = getClaimFromJWT(json, fallbackUsernameClaim);
86+
if (fallbackUsernameExtractor != null) {
87+
result = extractUsername(fallbackUsernameExtractor, json);
5988
if (result != null) {
60-
return fallbackUsernamePrefix == null ? result : fallbackUsernamePrefix + result;
89+
return result;
6190
}
6291
}
6392
}
6493

6594
return null;
6695
}
6796

97+
private String extractUsername(Extractor extractor, JsonNode json) {
98+
if (extractor.getAttributeName() != null) {
99+
String result = getClaimFromJWT(json, extractor.getAttributeName());
100+
if (result != null && !result.isEmpty()) {
101+
return result;
102+
}
103+
} else {
104+
JsonNode queryResult = extractor.getJSONPathQuery().apply(json);
105+
String result = queryResult == null ? null : queryResult.asText().trim();
106+
if (result != null && !result.isEmpty()) {
107+
return result;
108+
}
109+
}
110+
return null;
111+
}
112+
68113
/**
69114
* Get the value of <code>sub</code> claim
70115
*
@@ -77,7 +122,7 @@ public String getSub(JsonNode json) {
77122

78123
@Override
79124
public String toString() {
80-
return "PrincipalExtractor {usernameClaim: " + usernameClaim + ", fallbackUsernameClaim: " + fallbackUsernameClaim + ", fallbackUsernamePrefix: " + fallbackUsernamePrefix + "}";
125+
return "PrincipalExtractor {usernameClaim: " + usernameExtractor + ", fallbackUsernameClaim: " + fallbackUsernameExtractor + ", fallbackUsernamePrefix: " + fallbackUsernamePrefix + "}";
81126
}
82127

83128
/**
@@ -86,6 +131,66 @@ public String toString() {
86131
* @return True if any of the constructor parameters is set
87132
*/
88133
public boolean isConfigured() {
89-
return usernameClaim != null || fallbackUsernameClaim != null || fallbackUsernamePrefix != null;
134+
return usernameExtractor != null || fallbackUsernameExtractor != null || fallbackUsernamePrefix != null;
135+
}
136+
137+
/**
138+
* The claim specification uses the following rules:
139+
* <ul>
140+
* <li>If the claim specification starts with an opening square bracket '[', it is interpreted as a JsonPath query, and allows
141+
* targeting a nested attribute. </li>
142+
* <li>Otherwise, it is interpreted as a top level attribute name.</li>
143+
* </ul>
144+
* For more on JsonPath syntax see https://github.com/json-path/JsonPath.
145+
* <p>
146+
* Examples of claim specification:
147+
* <pre>
148+
* userId ... use top level attribute named 'userId'
149+
* user.id ... use top level attribute named 'user.id'
150+
* $userid ... use top level attribute named '$userid'
151+
* ['userInfo']['id'] ... use nested attribute 'id' under 'userInfo' top level attribute
152+
* ['userInfo'].id ... use nested attribute 'id' under 'userInfo' top level attribute (second segment not using brackets)
153+
* ['user.info']['user.id'] ... use nested attribute 'user.id' under 'user.info' top level attribute
154+
* ['user.info'].['user.id'] ... use nested attribute 'user.id' under 'user.info' top level attribute (optional dot)
155+
* </pre>
156+
*
157+
* @param spec Claim specification
158+
* @return Result containing either a claim with top level attribute name or a JsonPathQuery object
159+
*/
160+
private static Extractor parseClaimSpec(String spec) {
161+
spec = spec == null ? null : spec.trim();
162+
if (spec == null || spec.isEmpty()) {
163+
return null;
164+
}
165+
166+
if (!spec.startsWith("[")) {
167+
return new Extractor(spec);
168+
}
169+
170+
return new Extractor(JsonPathQuery.parse(spec));
171+
}
172+
173+
static class Extractor {
174+
175+
private final String attributeName;
176+
private final JsonPathQuery query;
177+
178+
private Extractor(JsonPathQuery query) {
179+
this.query = query;
180+
this.attributeName = null;
181+
}
182+
183+
private Extractor(String attributeName) {
184+
this.attributeName = attributeName;
185+
this.query = null;
186+
}
187+
188+
String getAttributeName() {
189+
return attributeName;
190+
}
191+
192+
JsonPathQuery getJSONPathQuery() {
193+
return query;
194+
}
90195
}
91196
}

oauth-common/src/main/java/io/strimzi/kafka/oauth/jsonpath/JsonPathQuery.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ private JsonPathQuery(String query) {
9090
try {
9191
this.matcher = new Matcher(ctx, query, false);
9292
} catch (JsonPathException e) {
93-
throw new JsonPathQueryException("Failed to parse filter query: \"" + query + "\"", e);
93+
throw new JsonPathQueryException("Failed to parse JsonPath query: \"" + query + "\"", e);
9494
}
9595
}
9696

0 commit comments

Comments
 (0)