diff --git a/extensions/oidc/deployment/pom.xml b/extensions/oidc/deployment/pom.xml index 9e30fa1a60198..2aa0c2fa9a4a1 100644 --- a/extensions/oidc/deployment/pom.xml +++ b/extensions/oidc/deployment/pom.xml @@ -102,7 +102,12 @@ maven-surefire-plugin - true + + + + + ServicePublicKeyTestCase.java + @@ -121,6 +126,9 @@ maven-surefire-plugin false + + ServicePublicKeyTestCase.java + ${keycloak.url} diff --git a/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/KeycloakDevModeRealmResourceManager.java b/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/KeycloakDevModeRealmResourceManager.java index de09e7640f5a0..c11e58f7b4bfb 100644 --- a/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/KeycloakDevModeRealmResourceManager.java +++ b/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/KeycloakDevModeRealmResourceManager.java @@ -26,24 +26,25 @@ public class KeycloakDevModeRealmResourceManager implements QuarkusTestResourceL @Override public Map start() { - - try { - - RealmRepresentation realm = createRealm(KEYCLOAK_REALM); - - realm.getClients().add(createClient("client-dev-mode")); - realm.getUsers().add(createUser("alice-dev-mode", "user")); - - RestAssured - .given() - .auth().oauth2(getAdminAccessToken()) - .contentType("application/json") - .body(JsonSerialization.writeValueAsBytes(realm)) - .when() - .post(KEYCLOAK_SERVER_URL + "/admin/realms").then() - .statusCode(201); - } catch (IOException e) { - throw new RuntimeException(e); + if (System.getProperty("keycloak.not.required") == null) { + try { + + RealmRepresentation realm = createRealm(KEYCLOAK_REALM); + + realm.getClients().add(createClient("client-dev-mode")); + realm.getUsers().add(createUser("alice-dev-mode", "user")); + + RestAssured + .given() + .auth().oauth2(getAdminAccessToken()) + .contentType("application/json") + .body(JsonSerialization.writeValueAsBytes(realm)) + .when() + .post(KEYCLOAK_SERVER_URL + "/admin/realms").then() + .statusCode(201); + } catch (IOException e) { + throw new RuntimeException(e); + } } return Collections.emptyMap(); } @@ -113,11 +114,12 @@ private static UserRepresentation createUser(String username, String... realmRol @Override public void stop() { - - RestAssured - .given() - .auth().oauth2(getAdminAccessToken()) - .when() - .delete(KEYCLOAK_SERVER_URL + "/admin/realms/" + KEYCLOAK_REALM).thenReturn().prettyPrint(); + if (System.getProperty("keycloak.not.required") == null) { + RestAssured + .given() + .auth().oauth2(getAdminAccessToken()) + .when() + .delete(KEYCLOAK_SERVER_URL + "/admin/realms/" + KEYCLOAK_REALM).thenReturn().prettyPrint(); + } } } diff --git a/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/ServiceProtectedResource.java b/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/ServiceProtectedResource.java new file mode 100644 index 0000000000000..815973fe7bd9a --- /dev/null +++ b/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/ServiceProtectedResource.java @@ -0,0 +1,22 @@ +package io.quarkus.oidc.test; + +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.Path; + +import org.eclipse.microprofile.jwt.JsonWebToken; + +import io.quarkus.security.Authenticated; + +@Path("/service") +@Authenticated +public class ServiceProtectedResource { + + @Inject + JsonWebToken accessToken; + + @GET + public String getName() { + return accessToken.getName(); + } +} diff --git a/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/ServicePublicKeyTestCase.java b/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/ServicePublicKeyTestCase.java new file mode 100644 index 0000000000000..084e9c7c0c477 --- /dev/null +++ b/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/ServicePublicKeyTestCase.java @@ -0,0 +1,46 @@ +package io.quarkus.oidc.test; + +import java.io.IOException; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; +import io.restassured.RestAssured; +import io.restassured.response.Response; +import io.smallrye.jwt.build.Jwt; + +public class ServicePublicKeyTestCase { + + private static Class[] testClasses = { + ServiceProtectedResource.class + }; + + @RegisterExtension + static final QuarkusUnitTest test = new QuarkusUnitTest() + .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) + .addClasses(testClasses) + .addAsResource("privateKey.pem") + .addAsResource("application-service-public-key.properties", "application.properties")); + + @Test + public void testAccessTokenInjection() throws IOException, InterruptedException { + String jwt = Jwt.claims().preferredUserName("alice").sign(); + Assertions.assertEquals("alice", RestAssured.given().auth() + .oauth2(jwt) + .get("/service").getBody().asString()); + } + + @Test + public void testModifiedSignature() throws IOException, InterruptedException { + String jwt = Jwt.claims().preferredUserName("alice").sign(); + // the last section of the jwt token is a signature + Response r = RestAssured.given().auth() + .oauth2(jwt + "1") + .get("/service"); + Assertions.assertEquals(403, r.getStatusCode()); + } +} diff --git a/extensions/oidc/deployment/src/test/resources/application-service-public-key.properties b/extensions/oidc/deployment/src/test/resources/application-service-public-key.properties new file mode 100644 index 0000000000000..58a1d19270db4 --- /dev/null +++ b/extensions/oidc/deployment/src/test/resources/application-service-public-key.properties @@ -0,0 +1,3 @@ +quarkus.oidc.client-id=test +quarkus.oidc.public-key=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlivFI8qB4D0y2jy0CfEqFyy46R0o7S8TKpsx5xbHKoU1VWg6QkQm+ntyIv1p4kE1sPEQO73+HY8+Bzs75XwRTYL1BmR1w8J5hmjVWjc6R2BTBGAYRPFRhor3kpM6ni2SPmNNhurEAHw7TaqszP5eUF/F9+KEBWkwVta+PZ37bwqSE4sCb1soZFrVz/UT/LF4tYpuVYt3YbqToZ3pZOZ9AX2o1GCG3xwOjkc4x0W7ezbQZdC9iftPxVHR8irOijJRRjcPDtA6vPKpzLl6CyYnsIYPd99ltwxTHjr3npfv/3Lw50bAkbT4HeLFxTx4flEoZLKO/g0bAoV2uqBhkA9xnQIDAQAB +smallrye.jwt.sign.key-location=/privateKey.pem \ No newline at end of file diff --git a/extensions/oidc/deployment/src/test/resources/privateKey.pem b/extensions/oidc/deployment/src/test/resources/privateKey.pem new file mode 100644 index 0000000000000..27543a434a1eb --- /dev/null +++ b/extensions/oidc/deployment/src/test/resources/privateKey.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCWK8UjyoHgPTLa +PLQJ8SoXLLjpHSjtLxMqmzHnFscqhTVVaDpCRCb6e3Ii/WniQTWw8RA7vf4djz4H +OzvlfBFNgvUGZHXDwnmGaNVaNzpHYFMEYBhE8VGGiveSkzqeLZI+Y02G6sQAfDtN +qqzM/l5QX8X34oQFaTBW1r49nftvCpITiwJvWyhkWtXP9RP8sXi1im5Vi3dhupOh +nelk5n0BfajUYIbfHA6ORzjHRbt7NtBl0L2J+0/FUdHyKs6KMlFGNw8O0Dq88qnM +uXoLJiewhg9332W3DFMeOveel+//cvDnRsCRtPgd4sXFPHh+UShkso7+DRsChXa6 +oGGQD3GdAgMBAAECggEAAjfTSZwMHwvIXIDZB+yP+pemg4ryt84iMlbofclQV8hv +6TsI4UGwcbKxFOM5VSYxbNOisb80qasb929gixsyBjsQ8284bhPJR7r0q8h1C+jY +URA6S4pk8d/LmFakXwG9Tz6YPo3pJziuh48lzkFTk0xW2Dp4SLwtAptZY/+ZXyJ6 +96QXDrZKSSM99Jh9s7a0ST66WoxSS0UC51ak+Keb0KJ1jz4bIJ2C3r4rYlSu4hHB +Y73GfkWORtQuyUDa9yDOem0/z0nr6pp+pBSXPLHADsqvZiIhxD/O0Xk5I6/zVHB3 +zuoQqLERk0WvA8FXz2o8AYwcQRY2g30eX9kU4uDQAQKBgQDmf7KGImUGitsEPepF +KH5yLWYWqghHx6wfV+fdbBxoqn9WlwcQ7JbynIiVx8MX8/1lLCCe8v41ypu/eLtP +iY1ev2IKdrUStvYRSsFigRkuPHUo1ajsGHQd+ucTDf58mn7kRLW1JGMeGxo/t32B +m96Af6AiPWPEJuVfgGV0iwg+HQKBgQCmyPzL9M2rhYZn1AozRUguvlpmJHU2DpqS +34Q+7x2Ghf7MgBUhqE0t3FAOxEC7IYBwHmeYOvFR8ZkVRKNF4gbnF9RtLdz0DMEG +5qsMnvJUSQbNB1yVjUCnDAtElqiFRlQ/k0LgYkjKDY7LfciZl9uJRl0OSYeX/qG2 +tRW09tOpgQKBgBSGkpM3RN/MRayfBtmZvYjVWh3yjkI2GbHA1jj1g6IebLB9SnfL +WbXJErCj1U+wvoPf5hfBc7m+jRgD3Eo86YXibQyZfY5pFIh9q7Ll5CQl5hj4zc4Y +b16sFR+xQ1Q9Pcd+BuBWmSz5JOE/qcF869dthgkGhnfVLt/OQzqZluZRAoGAXQ09 +nT0TkmKIvlza5Af/YbTqEpq8mlBDhTYXPlWCD4+qvMWpBII1rSSBtftgcgca9XLB +MXmRMbqtQeRtg4u7dishZVh1MeP7vbHsNLppUQT9Ol6lFPsd2xUpJDc6BkFat62d +Xjr3iWNPC9E9nhPPdCNBv7reX7q81obpeXFMXgECgYEAmk2Qlus3OV0tfoNRqNpe +Mb0teduf2+h3xaI1XDIzPVtZF35ELY/RkAHlmWRT4PCdR0zXDidE67L6XdJyecSt +FdOUH8z5qUraVVebRFvJqf/oGsXc4+ex1ZKUTbY0wqY1y9E39yvB3MaTmZFuuqk8 +f3cg+fr8aou7pr9SHhJlZCU= +-----END PRIVATE KEY----- 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 837fd50edeb64..5a088f5ac7689 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 @@ -22,7 +22,10 @@ import io.quarkus.security.runtime.QuarkusSecurityIdentity; import io.vertx.core.AsyncResult; import io.vertx.core.Handler; +import io.vertx.core.json.JsonObject; import io.vertx.ext.auth.oauth2.AccessToken; +import io.vertx.ext.auth.oauth2.impl.OAuth2AuthProviderImpl; +import io.vertx.ext.jwt.JWT; import io.vertx.ext.web.RoutingContext; @ApplicationScoped @@ -53,13 +56,22 @@ public SecurityIdentity get() { return authenticate(request, vertxContext); } - @SuppressWarnings("deprecation") private CompletableFuture authenticate(TokenAuthenticationRequest request, RoutingContext vertxContext) { - CompletableFuture result = new CompletableFuture<>(); TenantConfigContext resolvedContext = tenantResolver.resolve(vertxContext, true); - OidcTenantConfig config = resolvedContext.oidcConfig; + if (resolvedContext.oidcConfig.publicKey.isPresent()) { + return validateTokenWithoutOidcServer(request, resolvedContext); + } else { + return validateTokenWithOidcServer(request, resolvedContext); + } + } + + @SuppressWarnings("deprecation") + private CompletableFuture validateTokenWithOidcServer(TokenAuthenticationRequest request, + TenantConfigContext resolvedContext) { + + CompletableFuture result = new CompletableFuture<>(); resolvedContext.auth.decodeToken(request.getToken().getToken(), new Handler>() { @Override @@ -68,42 +80,73 @@ public void handle(AsyncResult event) { result.completeExceptionally(new AuthenticationFailedException(event.cause())); return; } - AccessToken token = event.result(); - try { - OidcUtils.validateClaims(config.getToken(), token.accessToken()); - } catch (OIDCException e) { - result.completeExceptionally(new AuthenticationFailedException(e)); - return; - } - - QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder(); - builder.addCredential(request.getToken()); - - JsonWebToken jwtPrincipal; - try { - JwtClaims jwtClaims = JwtClaims.parse(token.accessToken().encode()); - jwtClaims.setClaim(Claims.raw_token.name(), request.getToken().getToken()); - jwtPrincipal = new OidcJwtCallerPrincipal(jwtClaims, request.getToken(), - config.token.principalClaim.isPresent() ? config.token.principalClaim.get() : null); - } catch (InvalidJwtException e) { - result.completeExceptionally(new AuthenticationFailedException(e)); - return; - } - builder.setPrincipal(jwtPrincipal); + JsonObject tokenJson = event.result().accessToken(); try { - String clientId = config.getClientId().isPresent() ? config.getClientId().get() : null; - for (String role : OidcUtils.findRoles(clientId, config.getRoles(), token.accessToken())) { - builder.addRole(role); - } - } catch (Exception e) { - result.completeExceptionally(new ForbiddenException(e)); - return; + result.complete(validateAndCreateIdentity(request, resolvedContext.oidcConfig, tokenJson)); + } catch (Throwable ex) { + result.completeExceptionally(ex); } - - result.complete(builder.build()); } }); return result; } + + private CompletableFuture validateTokenWithoutOidcServer(TokenAuthenticationRequest request, + TenantConfigContext resolvedContext) { + CompletableFuture result = new CompletableFuture<>(); + + OAuth2AuthProviderImpl auth = ((OAuth2AuthProviderImpl) resolvedContext.auth); + JWT jwt = auth.getJWT(); + JsonObject tokenJson = null; + try { + tokenJson = jwt.decode(request.getToken().getToken()); + } catch (Throwable ex) { + result.completeExceptionally(new AuthenticationFailedException(ex)); + return result; + } + if (jwt.isExpired(tokenJson, auth.getConfig().getJWTOptions())) { + result.completeExceptionally(new AuthenticationFailedException()); + } else { + try { + result.complete(validateAndCreateIdentity(request, resolvedContext.oidcConfig, tokenJson)); + } catch (Throwable ex) { + result.completeExceptionally(ex); + } + } + return result; + } + + private QuarkusSecurityIdentity validateAndCreateIdentity(TokenAuthenticationRequest request, + OidcTenantConfig config, JsonObject tokenJson) + throws Exception { + try { + OidcUtils.validateClaims(config.getToken(), tokenJson); + } catch (OIDCException e) { + throw new AuthenticationFailedException(e); + } + + QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder(); + builder.addCredential(request.getToken()); + + JsonWebToken jwtPrincipal; + try { + JwtClaims jwtClaims = JwtClaims.parse(tokenJson.encode()); + jwtClaims.setClaim(Claims.raw_token.name(), request.getToken().getToken()); + jwtPrincipal = new OidcJwtCallerPrincipal(jwtClaims, request.getToken(), + config.token.principalClaim.isPresent() ? config.token.principalClaim.get() : null); + } catch (InvalidJwtException e) { + throw new AuthenticationFailedException(e); + } + builder.setPrincipal(jwtPrincipal); + try { + String clientId = config.getClientId().isPresent() ? config.getClientId().get() : null; + for (String role : OidcUtils.findRoles(clientId, config.getRoles(), tokenJson)) { + builder.addRole(role); + } + } catch (Exception e) { + throw new ForbiddenException(e); + } + return builder.build(); + } } 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 ed68de52aef70..5f356e7de5682 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 @@ -11,6 +11,7 @@ import io.quarkus.arc.runtime.BeanContainer; import io.quarkus.oidc.OIDCException; +import io.quarkus.oidc.runtime.OidcTenantConfig.ApplicationType; import io.quarkus.oidc.runtime.OidcTenantConfig.Credentials; import io.quarkus.oidc.runtime.OidcTenantConfig.Credentials.Secret; import io.quarkus.runtime.annotations.Recorder; @@ -21,6 +22,7 @@ import io.vertx.ext.auth.PubSecKeyOptions; import io.vertx.ext.auth.oauth2.OAuth2Auth; import io.vertx.ext.auth.oauth2.OAuth2ClientOptions; +import io.vertx.ext.auth.oauth2.impl.OAuth2AuthProviderImpl; import io.vertx.ext.auth.oauth2.providers.KeycloakAuth; import io.vertx.ext.jwt.JWTOptions; @@ -65,13 +67,41 @@ private TenantConfigContext createTenantContext(Vertx vertx, OidcTenantConfig oi return null; } + OAuth2ClientOptions options = new OAuth2ClientOptions(); + + if (oidcConfig.getClientId().isPresent()) { + options.setClientID(oidcConfig.getClientId().get()); + } + + if (oidcConfig.getToken().issuer.isPresent()) { + options.setValidateIssuer(false); + } + + if (oidcConfig.getToken().getExpirationGrace().isPresent()) { + JWTOptions jwtOptions = new JWTOptions(); + jwtOptions.setLeeway(oidcConfig.getToken().getExpirationGrace().get()); + options.setJWTOptions(jwtOptions); + } + + if (oidcConfig.getPublicKey().isPresent()) { + if (oidcConfig.applicationType == ApplicationType.WEB_APP) { + throw new ConfigurationException("'public-key' property can only be used with the 'service' applications"); + } + LOG.info("'public-key' property for the local token verification is set," + + " no connection to the OIDC server will be created"); + options.addPubSecKey(new PubSecKeyOptions() + .setAlgorithm("RS256") + .setPublicKey(oidcConfig.getPublicKey().get())); + + return new TenantConfigContext(new OAuth2AuthProviderImpl(vertx, options), oidcConfig); + } + if (!oidcConfig.getAuthServerUrl().isPresent() || !oidcConfig.getClientId().isPresent()) { throw new ConfigurationException( - "auth-server-url and client-id must be configured when the quarkus-oidc extension is enabled"); + "Both 'auth-server-url' and 'client-id' or alterntively 'public-key' must be configured" + + " when the quarkus-oidc extension is enabled"); } - OAuth2ClientOptions options = new OAuth2ClientOptions(); - // Base IDP server URL options.setSite(oidcConfig.getAuthServerUrl().get()); // RFC7662 introspection service address @@ -84,10 +114,6 @@ private TenantConfigContext createTenantContext(Vertx vertx, OidcTenantConfig oi options.setJwkPath(oidcConfig.getJwksPath().get()); } - if (oidcConfig.getClientId().isPresent()) { - options.setClientID(oidcConfig.getClientId().get()); - } - Credentials creds = oidcConfig.getCredentials(); if (creds.secret.isPresent() && (creds.clientSecret.value.isPresent() || creds.clientSecret.method.isPresent())) { throw new ConfigurationException( @@ -95,7 +121,6 @@ private TenantConfigContext createTenantContext(Vertx vertx, OidcTenantConfig oi } // TODO: The workaround to support client_secret_post is added below and have to be removed once // it is supported again in VertX OAuth2. - if (creds.secret.isPresent() || creds.clientSecret.value.isPresent() && creds.clientSecret.method.orElseGet(() -> Secret.Method.BASIC) == Secret.Method.BASIC) { @@ -107,21 +132,6 @@ private TenantConfigContext createTenantContext(Vertx vertx, OidcTenantConfig oi options.setClientSecretParameterName(null); } - if (oidcConfig.getPublicKey().isPresent()) { - options.addPubSecKey(new PubSecKeyOptions() - .setAlgorithm("RS256") - .setPublicKey(oidcConfig.getPublicKey().get())); - } - if (oidcConfig.getToken().issuer.isPresent()) { - options.setValidateIssuer(false); - } - - if (oidcConfig.getToken().getExpirationGrace().isPresent()) { - JWTOptions jwtOptions = new JWTOptions(); - jwtOptions.setLeeway(oidcConfig.getToken().getExpirationGrace().get()); - options.setJWTOptions(jwtOptions); - } - final long connectionDelayInSecs = oidcConfig.getConnectionDelay().isPresent() ? oidcConfig.getConnectionDelay().get().toMillis() / 1000 : 0;