diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/AccessTokenCredential.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/AccessTokenCredential.java index 461e991e0734a..417512b0e5ff5 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/AccessTokenCredential.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/AccessTokenCredential.java @@ -1,11 +1,13 @@ package io.quarkus.oidc; import io.quarkus.oidc.runtime.ContextAwareTokenCredential; +import io.quarkus.oidc.runtime.OidcUtils; import io.vertx.ext.web.RoutingContext; public class AccessTokenCredential extends ContextAwareTokenCredential { private RefreshToken refreshToken; + private boolean opaque; public AccessTokenCredential() { this(null, null); @@ -17,7 +19,7 @@ public AccessTokenCredential() { * @param accessToken - access token */ public AccessTokenCredential(String accessToken, RoutingContext context) { - super(accessToken, "bearer", context); + this(accessToken, null, context); } /** @@ -27,11 +29,18 @@ public AccessTokenCredential(String accessToken, RoutingContext context) { * @param refreshToken - refresh token which can be used to refresh this access token, may be null */ public AccessTokenCredential(String accessToken, RefreshToken refreshToken, RoutingContext context) { - this(accessToken, context); + super(accessToken, "bearer", context); this.refreshToken = refreshToken; + if (accessToken != null) { + this.opaque = OidcUtils.isOpaqueToken(accessToken); + } } public RefreshToken getRefreshToken() { return refreshToken; } + + public boolean isOpaque() { + return opaque; + } } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java index d6a3db400063c..055cf833a2875 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java @@ -638,6 +638,12 @@ public static Token fromAudience(String... audience) { @ConfigItem public boolean refreshExpired; + /** + * Forced JWK set refresh interval in minutes. + */ + @ConfigItem(defaultValue = "10M") + public Duration forcedJwkRefreshInterval = Duration.ofMinutes(10); + public Optional getIssuer() { return issuer; } @@ -677,6 +683,14 @@ public boolean isRefreshExpired() { public void setRefreshExpired(boolean refreshExpired) { this.refreshExpired = refreshExpired; } + + public Duration getForcedJwkRefreshInterval() { + return forcedJwkRefreshInterval; + } + + public void setForcedJwkRefreshInterval(Duration forcedJwkRefreshInterval) { + this.forcedJwkRefreshInterval = forcedJwkRefreshInterval; + } } @ConfigGroup diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java index 5c2331a3d4860..2a49897af3e71 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java @@ -193,7 +193,7 @@ private Uni performCodeFlow(IdentityProviderManager identityPr LOG.debug("State parameter can not be empty or multi-valued"); return Uni.createFrom().failure(new AuthenticationCompletionException()); } else if (!stateCookie.getValue().startsWith(values.get(0))) { - LOG.debug("State cookie does not match the state parameter"); + LOG.debug("State cookie value does not match the state query parameter value"); return Uni.createFrom().failure(new AuthenticationCompletionException()); } else if (context.queryParam("pathChecked").isEmpty()) { // This is an original redirect from IDP, check if the request path needs to be updated diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/JwkSetRefreshHandler.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/JwkSetRefreshHandler.java new file mode 100644 index 0000000000000..b317338f233b7 --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/JwkSetRefreshHandler.java @@ -0,0 +1,35 @@ +package io.quarkus.oidc.runtime; + +import java.time.Duration; + +import org.jboss.logging.Logger; + +import io.vertx.core.Handler; +import io.vertx.ext.auth.oauth2.OAuth2Auth; + +public class JwkSetRefreshHandler implements Handler { + private static final Logger LOG = Logger.getLogger(JwkSetRefreshHandler.class); + private OAuth2Auth auth; + private volatile long lastForcedRefreshTime; + private long forcedJwksRefreshIntervalMilliSecs; + + public JwkSetRefreshHandler(OAuth2Auth auth, Duration forcedJwksRefreshInterval) { + this.auth = auth; + this.forcedJwksRefreshIntervalMilliSecs = forcedJwksRefreshInterval.toMillis(); + } + + @SuppressWarnings("deprecation") + @Override + public void handle(String kid) { + final long now = System.currentTimeMillis(); + if (now > lastForcedRefreshTime + forcedJwksRefreshIntervalMilliSecs) { + lastForcedRefreshTime = now; + LOG.debugf("No JWK with %s key id is available, trying to refresh the JWK set", kid); + auth.loadJWK(res -> { + if (res.failed()) { + LOG.debugf("Failed to refresh the JWK set: %s", res.cause()); + } + }); + } + } +} diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java index 01ce0d4506319..2ce3deebd2c94 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java @@ -2,17 +2,22 @@ import static io.quarkus.oidc.runtime.OidcUtils.validateAndCreateIdentity; +import java.security.Principal; import java.util.function.Consumer; import java.util.function.Supplier; import javax.enterprise.context.ApplicationScoped; import javax.inject.Inject; +import io.quarkus.oidc.AccessTokenCredential; +import io.quarkus.oidc.IdTokenCredential; import io.quarkus.security.AuthenticationFailedException; +import io.quarkus.security.credential.TokenCredential; import io.quarkus.security.identity.AuthenticationRequestContext; import io.quarkus.security.identity.IdentityProvider; import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.security.identity.request.TokenAuthenticationRequest; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.subscription.UniEmitter; import io.vertx.core.AsyncResult; @@ -82,13 +87,42 @@ public void handle(AsyncResult event) { uniEmitter.fail(new AuthenticationFailedException(event.cause())); return; } + // Token has been verified, as a JWT or an opaque token, possibly involving + // an introspection request. + final TokenCredential tokenCred = request.getToken(); JsonObject tokenJson = event.result().accessToken(); - try { - uniEmitter.complete( - validateAndCreateIdentity(request.getToken(), resolvedContext.oidcConfig, - tokenJson)); - } catch (Throwable ex) { - uniEmitter.fail(ex); + if (tokenJson == null) { + // JSON token representation may be null not only if it is an opaque access token + // but also if it is JWT and no JWK with a matching kid is available, asynchronous + // JWK refresh has not finished yet, but the fallback introspection request has succeeded. + tokenJson = OidcUtils.decodeJwtContent(tokenCred.getToken()); + } + if (tokenJson != null) { + try { + uniEmitter.complete( + validateAndCreateIdentity(tokenCred, resolvedContext.oidcConfig, tokenJson)); + } catch (Throwable ex) { + uniEmitter.fail(ex); + } + } else if (tokenCred instanceof IdTokenCredential + || tokenCred instanceof AccessTokenCredential + && !((AccessTokenCredential) tokenCred).isOpaque()) { + uniEmitter + .fail(new AuthenticationFailedException("JWT token can not be converted to JSON")); + } else { + // Opaque access token + QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder(); + builder.addCredential(tokenCred); + if (event.result().principal().containsKey("username")) { + final String userName = event.result().principal().getString("username"); + builder.setPrincipal(new Principal() { + @Override + public String getName() { + return userName; + } + }); + } + uniEmitter.complete(builder.build()); } } }); diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcJsonWebTokenProducer.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcJsonWebTokenProducer.java index 0f491838ad2a4..6df9def69bb99 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcJsonWebTokenProducer.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcJsonWebTokenProducer.java @@ -61,6 +61,9 @@ private JsonWebToken getTokenCredential(Class type) { } TokenCredential credential = identity.getCredential(type); if (credential != null) { + if (credential instanceof AccessTokenCredential && ((AccessTokenCredential) credential).isOpaque()) { + throw new OIDCException("Opaque access token can not be converted to JsonWebToken"); + } JwtClaims jwtClaims; try { jwtClaims = new JwtConsumerBuilder() diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java index d3ec881c8f513..9a085c431daba 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java @@ -213,7 +213,7 @@ public void handle(AsyncResult event) { } } } - + auth.missingKeyHandler(new JwkSetRefreshHandler(auth, oidcConfig.token.forcedJwkRefreshInterval)); return new TenantConfigContext(auth, oidcConfig); } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java index 322eadb98377f..514dd8c6f03ee 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java @@ -1,10 +1,13 @@ package io.quarkus.oidc.runtime; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; +import java.util.Base64; import java.util.Collections; import java.util.LinkedList; import java.util.List; +import java.util.StringTokenizer; import java.util.regex.Pattern; import org.eclipse.microprofile.jwt.Claims; @@ -32,6 +35,31 @@ private OidcUtils() { } + public static boolean isOpaqueToken(String token) { + return new StringTokenizer(token, ".").countTokens() != 3; + } + + public static JsonObject decodeJwtContent(String jwt) { + StringTokenizer tokens = new StringTokenizer(jwt, "."); + // part 1: skip the token headers + tokens.nextToken(); + if (!tokens.hasMoreTokens()) { + return null; + } + // part 2: token content + String encodedContent = tokens.nextToken(); + + // lets check only 1 more signature part is available + if (tokens.countTokens() != 1) { + return null; + } + try { + return new JsonObject(new String(Base64.getUrlDecoder().decode(encodedContent), StandardCharsets.UTF_8)); + } catch (IllegalArgumentException ex) { + return null; + } + } + public static boolean validateClaims(OidcTenantConfig.Token tokenConfig, JsonObject json) { if (tokenConfig.issuer.isPresent()) { String issuer = json.getString(Claims.iss.name()); diff --git a/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcUtilsTest.java b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcUtilsTest.java index 6822d09b953f8..c7a4706dfd81f 100644 --- a/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcUtilsTest.java +++ b/extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcUtilsTest.java @@ -1,6 +1,8 @@ package io.quarkus.oidc.runtime; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -12,10 +14,14 @@ import java.util.List; import java.util.stream.Collectors; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + import org.junit.jupiter.api.Test; import io.quarkus.oidc.OIDCException; import io.quarkus.oidc.OidcTenantConfig; +import io.smallrye.jwt.build.Jwt; import io.vertx.core.json.JsonObject; public class OidcUtilsTest { @@ -176,6 +182,33 @@ public void testTokenWithCustomRolesWrongPath() throws Exception { } } + @Test + public void testTokenIsOpaque() throws Exception { + assertTrue(OidcUtils.isOpaqueToken("123")); + assertTrue(OidcUtils.isOpaqueToken("1.23")); + assertFalse(OidcUtils.isOpaqueToken("1.2.3")); + } + + @Test + public void testDecodeOpaqueTokenAsJwt() throws Exception { + assertNull(OidcUtils.decodeJwtContent("123")); + assertNull(OidcUtils.decodeJwtContent("1.23")); + assertNull(OidcUtils.decodeJwtContent("1.2.3")); + } + + @Test + public void testDecodeJwt() throws Exception { + final byte[] keyBytes = "AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow" + .getBytes(StandardCharsets.UTF_8); + SecretKey key = new SecretKeySpec(keyBytes, 0, keyBytes.length, "HMACSHA256"); + String jwt = Jwt.claims().sign(key); + assertNull(OidcUtils.decodeJwtContent(jwt + ".4")); + JsonObject json = OidcUtils.decodeJwtContent(jwt); + assertTrue(json.containsKey("iat")); + assertTrue(json.containsKey("exp")); + assertTrue(json.containsKey("jti")); + } + public static JsonObject read(InputStream input) throws IOException { try (BufferedReader buffer = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) { return new JsonObject(buffer.lines().collect(Collectors.joining("\n"))); diff --git a/integration-tests/oidc-tenancy/pom.xml b/integration-tests/oidc-tenancy/pom.xml index c874d9da56bd3..a859543268865 100644 --- a/integration-tests/oidc-tenancy/pom.xml +++ b/integration-tests/oidc-tenancy/pom.xml @@ -52,6 +52,11 @@ htmlunit test + + org.awaitility + awaitility + test + diff --git a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/CustomTenantConfigResolver.java b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/CustomTenantConfigResolver.java index 19de759b304dc..1bec138b1a55e 100644 --- a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/CustomTenantConfigResolver.java +++ b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/CustomTenantConfigResolver.java @@ -10,23 +10,26 @@ public class CustomTenantConfigResolver implements TenantConfigResolver { @Override public OidcTenantConfig resolve(RoutingContext context) { - if ("tenant-d".equals(context.request().path().split("/")[2])) { + String path = context.request().path(); + String tenantId = path.split("/")[2]; + if ("tenant-d".equals(tenantId)) { OidcTenantConfig config = new OidcTenantConfig(); - config.setTenantId("tenant-id"); + config.setTenantId("tenant-d"); config.setAuthServerUrl(getIssuerUrl() + "/realms/quarkus-d"); config.setClientId("quarkus-d"); - OidcTenantConfig.Credentials credentials = new OidcTenantConfig.Credentials(); - - credentials.setSecret("secret"); - - config.setCredentials(credentials); - - OidcTenantConfig.Token token = new OidcTenantConfig.Token(); - - token.setIssuer(getIssuerUrl() + "/realms/quarkus-d"); - - config.setToken(token); - + config.getCredentials().setSecret("secret"); + config.getToken().setIssuer(getIssuerUrl() + "/realms/quarkus-d"); + return config; + } else if ("tenant-oidc".equals(tenantId)) { + OidcTenantConfig config = new OidcTenantConfig(); + config.setTenantId("tenant-oidc"); + String uri = context.request().absoluteURI(); + String keycloakUri = path.contains("tenant-opaque") + ? uri.replace("/tenant-opaque/tenant-oidc/api/user", "/oidc") + : uri.replace("/tenant/tenant-oidc/api/user", "/oidc"); + config.setAuthServerUrl(keycloakUri); + config.setClientId("client"); + config.getCredentials().setSecret("secret"); return config; } return null; diff --git a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/OidcResource.java b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/OidcResource.java new file mode 100644 index 0000000000000..f4f3cc841ce76 --- /dev/null +++ b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/OidcResource.java @@ -0,0 +1,136 @@ +package io.quarkus.it.keycloak; + +import javax.annotation.PostConstruct; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.UriInfo; + +import org.jose4j.jwk.JsonWebKeySet; +import org.jose4j.jwk.RsaJsonWebKey; +import org.jose4j.jwk.RsaJwkGenerator; + +import io.smallrye.jwt.build.Jwt; + +@Path("oidc") +public class OidcResource { + + @Context + UriInfo ui; + RsaJsonWebKey key; + private volatile boolean introspection; + private volatile boolean rotate; + private volatile int jwkEndpointCallCount; + + @PostConstruct + public void init() throws Exception { + key = RsaJwkGenerator.generateJwk(2048); + key.setUse("sig"); + key.setKeyId("1"); + key.setAlgorithm("RS256"); + } + + @GET + @Produces("application/json") + @Path(".well-known/openid-configuration") + public String discovery() { + final String baseUri = ui.getBaseUriBuilder().path("oidc").build().toString(); + return "{" + + " \"token_endpoint\":" + "\"" + baseUri + "/token\"," + + " \"token_introspection_endpoint\":" + "\"" + baseUri + "/introspect\"," + + " \"jwks_uri\":" + "\"" + baseUri + "/jwks\"" + + " }"; + } + + @GET + @Produces("application/json") + @Path("jwks") + public String jwks() { + jwkEndpointCallCount++; + if (introspection) { + return "{\"keys\":[]}"; + } + String json = new JsonWebKeySet(key).toJson(); + if (rotate) { + json = json.replace("\"1\"", "\"2\""); + } + return json; + } + + @GET + @Path("jwk-endpoint-call-count") + public int jwkEndpointCallCount() { + return jwkEndpointCallCount; + } + + @POST + @Produces("application/json") + @Path("introspect") + public String introspect() { + // Introspect call will return an active token status only when the introspection is disallowed. + // This is done to test that an asynchronous JWK refresh call done by Vertx Auth is effective. + return "{" + + " \"active\": " + introspection + "," + + " \"username\": \"alice\"" + + " }"; + } + + @POST + @Path("token") + @Produces("application/json") + public String token(@QueryParam("kid") String kid) { + return "{\"access_token\": \"" + jwt(kid) + "\"," + + " \"token_type\": \"Bearer\"," + + " \"refresh_token\": \"123456789\"," + + " \"expires_in\": 300 }"; + } + + @POST + @Path("opaque-token") + @Produces("application/json") + public String opaqueToken(@QueryParam("kid") String kid) { + return "{\"access_token\": \"987654321\"," + + " \"token_type\": \"Bearer\"," + + " \"refresh_token\": \"123456789\"," + + " \"expires_in\": 300 }"; + } + + @POST + @Path("introspection") + public boolean setIntrospection() { + introspection = true; + return introspection; + } + + @GET + @Path("introspection-status") + public boolean introspectionStatus() { + return introspection; + } + + @POST + @Path("rotate") + public boolean setRotate() { + rotate = true; + return rotate; + } + + @GET + @Path("rotate-status") + public boolean rotateStatus() { + return rotate; + } + + private String jwt(String kid) { + return Jwt.claims() + .claim("typ", "Bearer") + .upn("alice") + .preferredUserName("alice") + .groups("user") + .jws().signatureKeyId(kid) + .sign(key.getPrivateKey()); + } +} diff --git a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantOpaqueResource.java b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantOpaqueResource.java new file mode 100644 index 0000000000000..523053328e3f3 --- /dev/null +++ b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantOpaqueResource.java @@ -0,0 +1,29 @@ +package io.quarkus.it.keycloak; + +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; + +import io.quarkus.oidc.AccessTokenCredential; +import io.quarkus.oidc.OIDCException; +import io.quarkus.security.Authenticated; +import io.quarkus.security.identity.SecurityIdentity; + +@Path("/tenant-opaque/tenant-oidc/api/user") +@Authenticated +public class TenantOpaqueResource { + + @Inject + SecurityIdentity identity; + @Inject + AccessTokenCredential accessToken; + + @GET + public String userName(@PathParam("tenant") String tenant) { + if (!identity.getCredential(AccessTokenCredential.class).isOpaque()) { + throw new OIDCException("Opaque token is expected"); + } + return "tenant-oidc-opaque:" + identity.getPrincipal().getName(); + } +} diff --git a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantResource.java b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantResource.java index 280fddc1473b6..e1d03fd085344 100644 --- a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantResource.java +++ b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantResource.java @@ -8,6 +8,7 @@ import org.eclipse.microprofile.jwt.JsonWebToken; +import io.quarkus.oidc.AccessTokenCredential; import io.quarkus.oidc.IdToken; import io.quarkus.oidc.OIDCException; @@ -17,6 +18,9 @@ public class TenantResource { @Inject JsonWebToken accessToken; + @Inject + AccessTokenCredential accessTokenCred; + @Inject @IdToken JsonWebToken idToken; @@ -40,10 +44,16 @@ private String getNameWebAppType() { if (!"Bearer".equals(accessToken.getClaim("typ"))) { throw new OIDCException("Wrong access token type"); } + if (accessTokenCred.isOpaque()) { + throw new OIDCException("JWT token is expected"); + } return name; } private String getNameServiceType() { + if (accessTokenCred.isOpaque()) { + throw new OIDCException("JWT token is expected"); + } if (!"Bearer".equals(accessToken.getClaim("typ"))) { throw new OIDCException("Wrong access token type"); } diff --git a/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java b/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java index e7b8f56ed201b..59819d97a1cc2 100644 --- a/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java +++ b/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java @@ -1,10 +1,14 @@ package io.quarkus.it.keycloak; +import static org.awaitility.Awaitility.await; import static org.hamcrest.Matchers.equalTo; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import java.io.IOException; +import java.time.Duration; +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.Test; import org.keycloak.representations.AccessTokenResponse; @@ -18,6 +22,8 @@ import io.quarkus.test.common.QuarkusTestResource; import io.quarkus.test.junit.QuarkusTest; import io.restassured.RestAssured; +import io.restassured.response.Response; +import io.vertx.core.json.JsonObject; /** * @author Pedro Igor @@ -129,6 +135,62 @@ public void testDefaultTenant() { .body(equalTo("tenant-any:alice")); } + @Test + public void testSimpleOidcJwtWithJwkRefresh() { + RestAssured.when().get("/oidc/introspection-status").then().body(equalTo("false")); + RestAssured.when().get("/oidc/rotate-status").then().body(equalTo("false")); + // Quarkus OIDC is initialized with JWK set with kid '1' as part of the discovery process + // Now enable the rotation + RestAssured.when().post("/oidc/rotate").then().body(equalTo("true")); + + // OIDC server will have a refreshed JWK set with kid '2', 200 is expected even though the introspection fallback is disabled. + await().atMost(5, TimeUnit.SECONDS) + .pollInterval(Duration.ofSeconds(1)) + .until(new Callable() { + @Override + public Boolean call() throws Exception { + Response r = RestAssured.given().auth().oauth2(getAccessTokenFromSimpleOidc("2")) + .when().get("/tenant/tenant-oidc/api/user"); + return r.getStatusCode() == 200; + } + }); + + // JWK is available now in Quarkus OIDC, confirm that no timeout is needed + RestAssured.given().auth().oauth2(getAccessTokenFromSimpleOidc("2")) + .when().get("/tenant/tenant-oidc/api/user") + .then() + .statusCode(200) + .body(equalTo("tenant-oidc:alice")); + + // Get a token with kid '3' - it can only be verified via the introspection fallback since OIDC returns JWK set with kid '2' + // 403 since the introspection is not enabled + RestAssured.given().auth().oauth2(getAccessTokenFromSimpleOidc("3")) + .when().get("/tenant/tenant-oidc/api/user") + .then() + .statusCode(403); + + // Enable introspection + RestAssured.when().post("/oidc/introspection").then().body(equalTo("true")); + // No timeout is required + RestAssured.given().auth().oauth2(getAccessTokenFromSimpleOidc("3")) + .when().get("/tenant/tenant-oidc/api/user") + .then() + .statusCode(200) + .body(equalTo("tenant-oidc:alice")); + + // Finally try the opaque token + RestAssured.given().auth().oauth2(getOpaqueAccessTokenFromSimpleOidc()) + .when().get("/tenant-opaque/tenant-oidc/api/user") + .then() + .statusCode(200) + .body(equalTo("tenant-oidc-opaque:alice")); + + // OIDC JWK endpoint must've been called only twice, once as part of the Quarkus OIDC/Vertx Auth initialization + // and once during the 1st request with a token kid '2', follow up requests must've been blocked due to the interval + // restrictions + RestAssured.when().get("/oidc/jwk-endpoint-call-count").then().body(equalTo("2")); + } + private String getAccessToken(String userName, String clientId) { return RestAssured .given() @@ -142,6 +204,26 @@ private String getAccessToken(String userName, String clientId) { .as(AccessTokenResponse.class).getToken(); } + private String getAccessTokenFromSimpleOidc(String kid) { + String json = RestAssured + .given() + .queryParam("kid", kid) + .when() + .post("/oidc/token") + .body().asString(); + JsonObject object = new JsonObject(json); + return object.getString("access_token"); + } + + private String getOpaqueAccessTokenFromSimpleOidc() { + String json = RestAssured + .when() + .post("/oidc/opaque-token") + .body().asString(); + JsonObject object = new JsonObject(json); + return object.getString("access_token"); + } + private WebClient createWebClient() { WebClient webClient = new WebClient(); webClient.setCssErrorHandler(new SilentCssErrorHandler());