Skip to content

Commit c2587b8

Browse files
committed
Apply the required claims restriction to OIDC introspections
1 parent 4b6211c commit c2587b8

File tree

5 files changed

+115
-2
lines changed

5 files changed

+115
-2
lines changed

extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProvider.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,27 @@ public TokenIntrospection apply(TokenIntrospection introspectionResult, Throwabl
378378
throw new AuthenticationFailedException(ex);
379379
}
380380

381+
if (requiredClaims != null && !requiredClaims.isEmpty()) {
382+
for (Map.Entry<String, String> requiredClaim : requiredClaims.entrySet()) {
383+
String introspectionClaimValue = null;
384+
try {
385+
introspectionClaimValue = introspectionResult.getString(requiredClaim.getKey());
386+
} catch (ClassCastException ex) {
387+
LOG.debugf("Introspection claim %s is not String", requiredClaim.getKey());
388+
throw new AuthenticationFailedException();
389+
}
390+
if (introspectionClaimValue == null) {
391+
LOG.debugf("Introspection claim %s is missing", requiredClaim.getKey());
392+
throw new AuthenticationFailedException();
393+
}
394+
if (!introspectionClaimValue.equals(requiredClaim.getValue())) {
395+
LOG.debugf("Value of the introspection claim %s does not match required value of %s",
396+
requiredClaim.getKey(), requiredClaim.getValue());
397+
throw new AuthenticationFailedException();
398+
}
399+
}
400+
}
401+
381402
return introspectionResult;
382403
}
383404

integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/CustomTenantConfigResolver.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package io.quarkus.it.keycloak;
22

33
import java.time.Duration;
4+
import java.util.Map;
45
import java.util.function.Supplier;
56

67
import jakarta.enterprise.context.ApplicationScoped;
@@ -35,6 +36,7 @@ public OidcTenantConfig get() {
3536

3637
String path = context.request().path();
3738
String tenantId = path.split("/")[2];
39+
3840
if ("tenant-d".equals(tenantId)) {
3941
OidcTenantConfig config = new OidcTenantConfig();
4042
config.setTenantId("tenant-c");
@@ -70,7 +72,20 @@ public OidcTenantConfig get() {
7072
config.setJwksPath("jwks");
7173
// try the absolute URI
7274
config.setIntrospectionPath(authServerUri + "/introspect");
75+
return config;
76+
} else if ("tenant-introspection-required-claims".equals(tenantId)) {
7377

78+
OidcTenantConfig config = new OidcTenantConfig();
79+
config.setTenantId("tenant-introspection-required-claims");
80+
config.token.setRequiredClaims(Map.of("required_claim", "1"));
81+
String uri = context.request().absoluteURI();
82+
String authServerUri = uri.replace("/tenant-introspection/tenant-introspection-required-claims",
83+
"/oidc");
84+
config.setAuthServerUrl(authServerUri);
85+
config.setDiscoveryEnabled(false);
86+
config.setClientId("client");
87+
config.setIntrospectionPath(authServerUri + "/introspect");
88+
config.setAllowTokenIntrospectionCache(false);
7489
return config;
7590
} else if ("tenant-oidc-no-discovery".equals(tenantId)) {
7691
OidcTenantConfig config = new OidcTenantConfig();

integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/OidcResource.java

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ public class OidcResource {
4040
private volatile boolean rotate;
4141
private volatile int jwkEndpointCallCount;
4242
private volatile int introspectionEndpointCallCount;
43+
private volatile int opaqueToken2UsageCount;
4344
private volatile int revokeEndpointCallCount;
4445
private volatile int userInfoEndpointCallCount;
4546
private volatile boolean enableDiscovery = true;
@@ -112,6 +113,13 @@ public int resetIntrospectionEndpointCallCount() {
112113
return introspectionEndpointCallCount;
113114
}
114115

116+
@POST
117+
@Path("opaque-token-call-count")
118+
public int resetOpaqueTokenCallCount() {
119+
opaqueToken2UsageCount = 0;
120+
return opaqueToken2UsageCount;
121+
}
122+
115123
@POST
116124
@Produces("application/json")
117125
@Path("introspect")
@@ -120,7 +128,12 @@ public String introspect(@FormParam("client_id") String clientId, @FormParam("cl
120128
introspectionEndpointCallCount++;
121129

122130
boolean activeStatus = introspection && !token.endsWith("-invalid");
123-
131+
boolean requiredClaim = true;
132+
if (token.endsWith("_2") && ++opaqueToken2UsageCount == 2) {
133+
// This is to confirm that the same opaque token_2 works well when its introspection response
134+
// includes `required_claim` with value "1" but fails when the required claim is not included
135+
requiredClaim = false;
136+
}
124137
String introspectionClientId = "none";
125138
String introspectionClientSecret = "none";
126139
if (clientSecret != null) {
@@ -146,6 +159,7 @@ public String introspect(@FormParam("client_id") String clientId, @FormParam("cl
146159
" \"scope\": \"user\"," +
147160
" \"email\": \"[email protected]\"," +
148161
" \"username\": \"alice\"," +
162+
(requiredClaim ? "\"required_claim\": \"1\"," : "") +
149163
" \"introspection_client_id\": \"" + introspectionClientId + "\"," +
150164
" \"introspection_client_secret\": \"" + introspectionClientSecret + "\"," +
151165
" \"client_id\": \"" + clientId + "\"" +
@@ -251,13 +265,23 @@ public String testAccessTokenWithEmptyScope(@QueryParam("kid") String kid, @Quer
251265
@POST
252266
@Path("opaque-token")
253267
@Produces("application/json")
254-
public String testOpaqueToken(@QueryParam("kid") String kid) {
268+
public String testOpaqueToken() {
255269
return "{\"access_token\": \"987654321\"," +
256270
" \"token_type\": \"Bearer\"," +
257271
" \"refresh_token\": \"123456789\"," +
258272
" \"expires_in\": 300 }";
259273
}
260274

275+
@POST
276+
@Path("opaque-token2")
277+
@Produces("application/json")
278+
public String testOpaqueToken2() {
279+
return "{\"access_token\": \"987654321_2\"," +
280+
" \"token_type\": \"Bearer\"," +
281+
" \"refresh_token\": \"123456789\"," +
282+
" \"expires_in\": 300 }";
283+
}
284+
261285
@POST
262286
@Path("enable-introspection")
263287
public boolean setIntrospection() {
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package io.quarkus.it.keycloak;
2+
3+
import jakarta.inject.Inject;
4+
import jakarta.ws.rs.GET;
5+
import jakarta.ws.rs.Path;
6+
7+
import io.quarkus.oidc.TokenIntrospection;
8+
import io.quarkus.security.Authenticated;
9+
10+
@Path("/tenant-introspection")
11+
@Authenticated
12+
public class TenantIntrospectionRequiredClaimsResource {
13+
14+
@Inject
15+
TokenIntrospection token;
16+
17+
@GET
18+
@Path("tenant-introspection-required-claims")
19+
public String userPermission() {
20+
return token.getUsername() + ", required_claim:" + token.getString("required_claim");
21+
}
22+
}

integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -725,6 +725,28 @@ public void testOpaqueTokenScopePermission() {
725725
.statusCode(403);
726726
}
727727

728+
@Test
729+
public void testTokenIntrospectionRequiredClaims() {
730+
RestAssured.when().post("/oidc/enable-introspection").then().body(equalTo("true"));
731+
RestAssured.when().post("/oidc/opaque-token-call-count").then().body(equalTo("0"));
732+
733+
// Successful request with opaque token 2
734+
String opaqueToken2 = getOpaqueAccessToken2FromSimpleOidc();
735+
RestAssured.given().auth().oauth2(opaqueToken2)
736+
.when().get("/tenant-introspection/tenant-introspection-required-claims")
737+
.then()
738+
.statusCode(200)
739+
.body(equalTo("alice, required_claim:1"));
740+
741+
// Expected to fail now because its introspection does not include the expected required claim
742+
RestAssured.given().auth().oauth2(opaqueToken2)
743+
.when().get("/tenant-introspection/tenant-introspection-required-claims")
744+
.then()
745+
.statusCode(401);
746+
747+
RestAssured.when().post("/oidc/opaque-token-call-count").then().body(equalTo("0"));
748+
}
749+
728750
@Test
729751
public void testResolveStaticTenantsByPathPatterns() {
730752
// default tenant path pattern is more specific, therefore it wins over tenant-b pattern that is also matched
@@ -900,6 +922,15 @@ private String getOpaqueAccessTokenFromSimpleOidc() {
900922
return object.getString("access_token");
901923
}
902924

925+
private String getOpaqueAccessToken2FromSimpleOidc() {
926+
String json = RestAssured
927+
.when()
928+
.post("/oidc/opaque-token2")
929+
.body().asString();
930+
JsonObject object = new JsonObject(json);
931+
return object.getString("access_token");
932+
}
933+
903934
static WebClient createWebClient() {
904935
WebClient webClient = new WebClient();
905936
webClient.setCssErrorHandler(new SilentCssErrorHandler());

0 commit comments

Comments
 (0)