diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 56410c55a8..493c723609 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,7 @@ groovy = "4.0.13" geb = "7.0" selenium = "4.15.0" +system-stubs-core = "2.1.4" testcontainers = "1.19.2" unboundid-ldapsdk = "6.0.10" bouncycastle = "1.70" @@ -50,6 +51,8 @@ bcpkix-jdk15on = { module = "org.bouncycastle:bcpkix-jdk15on", version.ref = "bo bcprov-jdk15on = { module = "org.bouncycastle:bcprov-jdk15on", version.ref = "bouncycastle" } kotlin-stdlib-jdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } bcpkix = { module = "org.bouncycastle:bcpkix-jdk15on", version.ref = "bcpkix" } +system-stubs-core = { module = "uk.org.webcompere:system-stubs-core", version.ref = "system-stubs-core" } + testcontainers-bom = { module = "org.testcontainers:testcontainers-bom", version.ref = "testcontainers" } testcontainers = { module = "org.testcontainers:testcontainers" } diff --git a/security-jwt/build.gradle b/security-jwt/build.gradle index 365143caab..6acc735b11 100644 --- a/security-jwt/build.gradle +++ b/security-jwt/build.gradle @@ -8,12 +8,12 @@ dependencies { annotationProcessor(mnValidation.micronaut.validation.processor) api(mnValidation.micronaut.validation) api(projects.micronautSecurity) - implementation(mn.micronaut.http.client.core) api(libs.managed.nimbus.jose.jwt) implementation(mnReactor.micronaut.reactor) testImplementation(libs.bcpkix.jdk15on) testImplementation(libs.bcprov.jdk15on) + compileOnly(mn.micronaut.http.client.core) compileOnly(mn.micronaut.http.server) compileOnly(mn.micronaut.json.core) @@ -35,6 +35,8 @@ dependencies { testImplementation(platform(libs.testcontainers.bom)) testImplementation(libs.testcontainers) + + testImplementation(libs.system.stubs.core) } test { diff --git a/security-jwt/src/main/java/io/micronaut/security/token/jwt/signature/jwks/DefaultJwkSetFetcher.java b/security-jwt/src/main/java/io/micronaut/security/token/jwt/signature/jwks/DefaultJwkSetFetcher.java index 5b190bead6..ef2c91a422 100644 --- a/security-jwt/src/main/java/io/micronaut/security/token/jwt/signature/jwks/DefaultJwkSetFetcher.java +++ b/security-jwt/src/main/java/io/micronaut/security/token/jwt/signature/jwks/DefaultJwkSetFetcher.java @@ -16,30 +16,18 @@ package io.micronaut.security.token.jwt.signature.jwks; import com.nimbusds.jose.jwk.JWKSet; -import io.micronaut.context.BeanContext; import io.micronaut.core.annotation.Blocking; import io.micronaut.core.annotation.NonNull; import io.micronaut.core.annotation.Nullable; import io.micronaut.core.optim.StaticOptimizations; -import io.micronaut.core.util.SupplierUtil; -import io.micronaut.http.client.HttpClient; -import io.micronaut.http.client.HttpClientConfiguration; -import io.micronaut.http.client.LoadBalancer; -import io.micronaut.http.client.exceptions.HttpClientException; -import io.micronaut.inject.qualifiers.Qualifiers; import jakarta.inject.Singleton; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import reactor.core.publisher.Mono; -import java.io.IOException; -import java.net.URL; import java.text.ParseException; import java.util.Collections; import java.util.Map; -import java.util.Objects; import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; import java.util.function.Supplier; /** @@ -53,14 +41,10 @@ public class DefaultJwkSetFetcher implements JwkSetFetcher { private static final Logger LOG = LoggerFactory.getLogger(DefaultJwkSetFetcher.class); - private final BeanContext beanContext; - private final Supplier defaultJwkSetClient; - private final ConcurrentHashMap jwkSetClients = new ConcurrentHashMap<>(); + private final JwksClient jwksClient; - public DefaultJwkSetFetcher(BeanContext beanContext, - HttpClientConfiguration defaultClientConfiguration) { - this.beanContext = beanContext; - this.defaultJwkSetClient = SupplierUtil.memoized(() -> beanContext.createBean(HttpClient.class, LoadBalancer.empty(), defaultClientConfiguration)); + public DefaultJwkSetFetcher(JwksClient jwksClient) { + this.jwksClient = jwksClient; } @Override @@ -72,13 +56,13 @@ public Optional fetch(@Nullable String url) { } return OPTIMIZATIONS.findJwkSet(url) .map(s -> Optional.of(s.get())) - .orElseGet(() -> Optional.ofNullable(load(url))); + .orElseGet(() -> Optional.ofNullable(load(null, url))); } @Override @NonNull @Blocking - public Optional fetch(@NonNull String providerName, @Nullable String url) { + public Optional fetch(@Nullable String providerName, @Nullable String url) { if (url == null) { return Optional.empty(); } @@ -93,44 +77,18 @@ public void clearCache(@NonNull String url) { } @Nullable - private JWKSet load(@NonNull String url) { + private JWKSet load(@Nullable String providerName, @NonNull String url) { try { - return JWKSet.load(new URL(url)); - } catch (IOException | ParseException e) { + String jwkSetContent = jwksClient.load(providerName, url); + return jwkSetContent != null ? JWKSet.parse(jwkSetContent) : null; + } catch (ParseException e) { if (LOG.isErrorEnabled()) { - LOG.error("Exception loading JWK from " + url, e); + LOG.error("Exception parsing JWK Set response from " + url, e); } } return null; } - @Nullable - private JWKSet load(@NonNull String providerName, @NonNull String url) { - try { - String jwkSetContent = Mono.from(getClient(providerName).retrieve(url)).block(); - Objects.requireNonNull(jwkSetContent, "JWK Set must not be null."); - return JWKSet.parse(jwkSetContent); - } catch (HttpClientException | ParseException e) { - if (LOG.isErrorEnabled()) { - LOG.error("Exception loading JWK from " + url, e); - } - } - return null; - } - - /** - * Retrieves a client for the given provider. - * - * @param providerName The provider name - * @return An HTTP client to use to send the request - */ - protected HttpClient getClient(String providerName) { - return jwkSetClients.computeIfAbsent(providerName, provider -> { - Optional client = beanContext.findBean(io.micronaut.http.client.HttpClient.class, Qualifiers.byName(provider)); - return client.orElseGet(defaultJwkSetClient); - }); - } - /** * AOT Optimizations. */ diff --git a/security-jwt/src/main/java/io/micronaut/security/token/jwt/signature/jwks/HttpClientJwksClient.java b/security-jwt/src/main/java/io/micronaut/security/token/jwt/signature/jwks/HttpClientJwksClient.java new file mode 100644 index 0000000000..0b20a495f6 --- /dev/null +++ b/security-jwt/src/main/java/io/micronaut/security/token/jwt/signature/jwks/HttpClientJwksClient.java @@ -0,0 +1,96 @@ +/* + * Copyright 2017-2023 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.token.jwt.signature.jwks; + +import io.micronaut.context.BeanContext; +import io.micronaut.context.annotation.Primary; +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.core.util.SupplierUtil; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.HttpClientConfiguration; +import io.micronaut.http.client.HttpClientRegistry; +import io.micronaut.http.client.HttpVersionSelection; +import io.micronaut.http.client.LoadBalancer; +import io.micronaut.http.client.ServiceHttpClientConfiguration; +import io.micronaut.http.client.exceptions.HttpClientException; +import io.micronaut.inject.qualifiers.Qualifiers; +import jakarta.inject.Singleton; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +/** + * Implementation of {@link JwksClient} that uses the Micronaut {@link HttpClient}. + * + *

+ * If a named service-specific client is configured (i.e. with "micronaut.http.services.foo.*") with a + * name that matches the name used for security configuration (i.e. "micronaut.security.token.jwt.signatures.jwks.foo.*") + * then that client will be used for the request. Otherwise, a default client will be used. + * + * @author Jeremy Grelle + * @since 4.5.0 + */ +@Singleton +@Primary +@Requires(classes = HttpClient.class) +public class HttpClientJwksClient implements JwksClient { + + private static final Logger LOG = LoggerFactory.getLogger(HttpClientJwksClient.class); + + private final BeanContext beanContext; + private final HttpClientRegistry clientRegistry; + private final Supplier defaultJwkSetClient; + private final ConcurrentHashMap jwkSetClients = new ConcurrentHashMap<>(); + + public HttpClientJwksClient(BeanContext beanContext, HttpClientRegistry clientRegistry, HttpClientConfiguration defaultClientConfiguration) { + this.beanContext = beanContext; + this.clientRegistry = clientRegistry; + this.defaultJwkSetClient = SupplierUtil.memoized(() -> beanContext.createBean(HttpClient.class, LoadBalancer.empty(), defaultClientConfiguration)); + } + + @Override + public String load(@Nullable String providerName, @NonNull String url) throws HttpClientException { + try { + return Mono.from(getClient(providerName).retrieve(url)).block(); + } catch (HttpClientException e) { + if (LOG.isErrorEnabled()) { + LOG.error("Exception loading JWK from " + url, e); + } + } + return null; + } + + /** + * Retrieves an HTTP client for the given provider. + * + * @param providerName The provider name + * @return An HTTP client to use to send the JWKS request + */ + protected HttpClient getClient(@Nullable String providerName) { + if (providerName == null) { + return defaultJwkSetClient.get(); + } + return jwkSetClients.computeIfAbsent(providerName, provider -> + beanContext.findBean(ServiceHttpClientConfiguration.class, Qualifiers.byName(provider)) + .map(serviceConfig -> this.clientRegistry.getClient(HttpVersionSelection.forClientConfiguration(serviceConfig), provider, "/")) + .orElseGet(defaultJwkSetClient)); + } +} diff --git a/security-jwt/src/main/java/io/micronaut/security/token/jwt/signature/jwks/JwkSetFetcher.java b/security-jwt/src/main/java/io/micronaut/security/token/jwt/signature/jwks/JwkSetFetcher.java index 1cde7a138d..60ce619a01 100644 --- a/security-jwt/src/main/java/io/micronaut/security/token/jwt/signature/jwks/JwkSetFetcher.java +++ b/security-jwt/src/main/java/io/micronaut/security/token/jwt/signature/jwks/JwkSetFetcher.java @@ -34,9 +34,11 @@ public interface JwkSetFetcher { * * @param url The Jwks uri * @return The Json Web Key Set representation or an empty optional if it could not be loaded + * @deprecated Use {@link #fetch(String, String)} instead. */ @NonNull @Blocking + @Deprecated(since = "4.5.0", forRemoval = true) Optional fetch(@Nullable String url); /** diff --git a/security-jwt/src/main/java/io/micronaut/security/token/jwt/signature/jwks/JwksClient.java b/security-jwt/src/main/java/io/micronaut/security/token/jwt/signature/jwks/JwksClient.java new file mode 100644 index 0000000000..f462d63fcf --- /dev/null +++ b/security-jwt/src/main/java/io/micronaut/security/token/jwt/signature/jwks/JwksClient.java @@ -0,0 +1,38 @@ +/* + * Copyright 2017-2023 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.token.jwt.signature.jwks; + +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; + +/** + * Client for loading Json Web Key Set content over http. + * + * @author Jeremy Grelle + * @since 4.5.0 + */ +public interface JwksClient { + + /** + * Loads remote Json Web Key Set content over http. + * + * @param providerName The jwks provider name + * @param url The URL for loading the remote JWK Set + * @return The JWK Set response body content + */ + @Nullable + String load(@Nullable String providerName, @NonNull String url); +} diff --git a/security-jwt/src/main/java/io/micronaut/security/token/jwt/signature/jwks/JwksSignature.java b/security-jwt/src/main/java/io/micronaut/security/token/jwt/signature/jwks/JwksSignature.java index 75ea19d667..df616cbbd2 100644 --- a/security-jwt/src/main/java/io/micronaut/security/token/jwt/signature/jwks/JwksSignature.java +++ b/security-jwt/src/main/java/io/micronaut/security/token/jwt/signature/jwks/JwksSignature.java @@ -146,6 +146,19 @@ public boolean verify(SignedJWT jwt) throws JOSEException { return JwksSignatureUtils.verify(jwt, computeJWKSet().orElse(null), jwkValidator); } + /** + * Instantiates a JWKSet for a given url. + * @param url JSON Web Key Set Url. + * @return a JWKSet or null if there was an error. + * @deprecated Use {@link #loadJwkSet(String, String)} instead. + */ + @Nullable + @Deprecated(since = "4.5.0", forRemoval = true) + protected JWKSet loadJwkSet(String url) { + return jwkSetFetcher.fetch(null, url) + .orElse(null); + } + /** * Instantiates a JWKSet for a given url. * @param providerName The name of the JWKS configuration. @@ -153,7 +166,7 @@ public boolean verify(SignedJWT jwt) throws JOSEException { * @return a JWKSet or null if there was an error. */ @Nullable - protected JWKSet loadJwkSet(String providerName, String url) { + protected JWKSet loadJwkSet(@Nullable String providerName, String url) { return jwkSetFetcher.fetch(providerName, url) .orElse(null); } diff --git a/security-jwt/src/main/java/io/micronaut/security/token/jwt/signature/jwks/JwksSignatureConfiguration.java b/security-jwt/src/main/java/io/micronaut/security/token/jwt/signature/jwks/JwksSignatureConfiguration.java index 9b91d10d89..4f8467e67b 100644 --- a/security-jwt/src/main/java/io/micronaut/security/token/jwt/signature/jwks/JwksSignatureConfiguration.java +++ b/security-jwt/src/main/java/io/micronaut/security/token/jwt/signature/jwks/JwksSignatureConfiguration.java @@ -18,6 +18,7 @@ import com.nimbusds.jose.jwk.KeyType; import io.micronaut.core.annotation.NonNull; import io.micronaut.core.annotation.Nullable; +import io.micronaut.core.naming.Named; /** * JSON Web Key Set Configuration. @@ -25,10 +26,7 @@ * @author Sergio del Amo * @since 1.1.0 */ -public interface JwksSignatureConfiguration { - - @NonNull - String getName(); +public interface JwksSignatureConfiguration extends Named { /** * Json Web Key Set endpoint url. diff --git a/security-jwt/src/main/java/io/micronaut/security/token/jwt/signature/jwks/JwksSignatureConfigurationProperties.java b/security-jwt/src/main/java/io/micronaut/security/token/jwt/signature/jwks/JwksSignatureConfigurationProperties.java index 82007db9aa..3d201e3ffb 100644 --- a/security-jwt/src/main/java/io/micronaut/security/token/jwt/signature/jwks/JwksSignatureConfigurationProperties.java +++ b/security-jwt/src/main/java/io/micronaut/security/token/jwt/signature/jwks/JwksSignatureConfigurationProperties.java @@ -47,6 +47,7 @@ public class JwksSignatureConfigurationProperties implements JwksSignatureConfig @SuppressWarnings("WeakerAccess") public static final int DEFAULT_CACHE_EXPIRATION = 60; + @Nullable private final String name; @NonNull diff --git a/security-jwt/src/main/java/io/micronaut/security/token/jwt/signature/jwks/ResourceRetrieverJwksClient.java b/security-jwt/src/main/java/io/micronaut/security/token/jwt/signature/jwks/ResourceRetrieverJwksClient.java new file mode 100644 index 0000000000..1f05ea740c --- /dev/null +++ b/security-jwt/src/main/java/io/micronaut/security/token/jwt/signature/jwks/ResourceRetrieverJwksClient.java @@ -0,0 +1,52 @@ +/* + * Copyright 2017-2023 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.micronaut.security.token.jwt.signature.jwks; + +import com.nimbusds.jose.util.DefaultResourceRetriever; +import com.nimbusds.jose.util.Resource; +import jakarta.inject.Singleton; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URL; + +/** + * Implementation of {@link JwksClient} that uses the Nimbus library's built-in {@code com.nimbusds.jose.util.ResourceRetriever} interface. + * + * @author Jeremy Grelle + * @since 4.5.0 + */ +@Singleton +public class ResourceRetrieverJwksClient implements JwksClient { + + private static final Logger LOG = LoggerFactory.getLogger(ResourceRetrieverJwksClient.class); + + private final DefaultResourceRetriever resourceRetriever = new DefaultResourceRetriever(0, 0, 0); + + @Override + public String load(String providerName, String url) { + try { + Resource resource = resourceRetriever.retrieveResource(new URL(url)); + return resource.getContent(); + } catch (IOException e) { + if (LOG.isErrorEnabled()) { + LOG.error("Exception loading JWK from " + url, e); + } + } + return null; + } +} diff --git a/security-jwt/src/test/groovy/io/micronaut/security/token/jwt/signature/jwks/JwksProxySpec.groovy b/security-jwt/src/test/groovy/io/micronaut/security/token/jwt/signature/jwks/JwksProxySpec.groovy new file mode 100644 index 0000000000..65e040f763 --- /dev/null +++ b/security-jwt/src/test/groovy/io/micronaut/security/token/jwt/signature/jwks/JwksProxySpec.groovy @@ -0,0 +1,357 @@ +package io.micronaut.security.token.jwt.signature.jwks + +import com.nimbusds.jose.JOSEException +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.jwk.JWK +import com.nimbusds.jose.jwk.KeyUse +import com.nimbusds.jose.jwk.RSAKey +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator +import io.micronaut.context.ApplicationContext +import io.micronaut.context.annotation.Factory +import io.micronaut.context.annotation.Primary +import io.micronaut.context.annotation.Replaces +import io.micronaut.context.annotation.Requires +import io.micronaut.context.annotation.Value +import io.micronaut.http.HttpHeaders +import io.micronaut.http.HttpMethod +import io.micronaut.http.HttpRequest +import io.micronaut.http.HttpResponse +import io.micronaut.http.HttpStatus +import io.micronaut.http.MediaType +import io.micronaut.http.MutableHttpResponse +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Filter +import io.micronaut.http.annotation.Get +import io.micronaut.http.annotation.Produces +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.ProxyHttpClient +import io.micronaut.http.filter.HttpServerFilter +import io.micronaut.http.filter.ServerFilterChain +import io.micronaut.runtime.server.EmbeddedServer +import io.micronaut.security.annotation.Secured +import io.micronaut.security.authentication.UsernamePasswordCredentials +import io.micronaut.security.rules.SecurityRule +import io.micronaut.security.testutils.authprovider.MockAuthenticationProvider +import io.micronaut.security.testutils.authprovider.SuccessAuthenticationScenario +import io.micronaut.security.token.jwt.endpoints.JwkProvider +import io.micronaut.security.token.jwt.signature.rsa.RSASignatureGeneratorConfiguration +import io.micronaut.security.token.render.BearerAccessRefreshToken +import jakarta.inject.Named +import jakarta.inject.Singleton +import org.reactivestreams.Publisher +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import reactor.core.publisher.Mono +import spock.lang.AutoCleanup +import spock.lang.Shared +import spock.lang.Specification +import uk.org.webcompere.systemstubs.properties.SystemProperties + +import java.security.Principal +import java.security.interfaces.RSAPrivateKey +import java.security.interfaces.RSAPublicKey +import java.util.concurrent.atomic.AtomicBoolean + +class JwksProxySpec extends Specification { + + static final String SPEC_NAME_PROPERTY = 'spec.name' + + @Shared + Map authConfig = [ + (SPEC_NAME_PROPERTY) : 'jwks-proxy.auth', + 'micronaut.security.authentication' : 'bearer' + ] + + @AutoCleanup + @Shared + EmbeddedServer authEmbeddedServer = ApplicationContext.run(EmbeddedServer, authConfig) + + HttpClient authClient = authEmbeddedServer.applicationContext.createBean(HttpClient, authEmbeddedServer.getURL()) + + @Shared + Map proxyConfig = [ + (SPEC_NAME_PROPERTY) : 'jwks-proxy.proxy', + 'forward-proxy-host' : authEmbeddedServer.host, + 'forward-proxy-port' : authEmbeddedServer.port + ] + + @AutoCleanup + @Shared + EmbeddedServer proxyEmbeddedServer = ApplicationContext.run(EmbeddedServer, proxyConfig) + + def cleanup() { + proxyEmbeddedServer.applicationContext.getBean(ProxyFilter.class).keySetProxied.set(false) + } + + def "jwks key set loading uses global http client proxy config"() { + given: + EmbeddedServer globalClientEmbeddedServer = ApplicationContext.run(EmbeddedServer, [ + (SPEC_NAME_PROPERTY) : 'jwks-proxy.books', + 'micronaut.http.client.proxy-type' : 'http', + 'micronaut.http.client.proxy-address' : "localhost:${proxyEmbeddedServer.port}", + 'micronaut.security.token.jwt.signatures.jwks.gateway.url' : "http://localhost:${authEmbeddedServer.port}/keys", + ]) + ApplicationContext clientContext = ApplicationContext.run([ (SPEC_NAME_PROPERTY) : 'jwks-proxy.client' ]) + HttpClient booksClient = clientContext.createBean(HttpClient, globalClientEmbeddedServer.getURL()) + ProxyFilter filter = proxyEmbeddedServer.applicationContext.getBean(ProxyFilter.class) + + when: + UsernamePasswordCredentials creds = new UsernamePasswordCredentials('user', 'password') + HttpResponse rsp = authClient.toBlocking().exchange(HttpRequest.POST('/login', creds), BearerAccessRefreshToken) + + then: + rsp.status() == HttpStatus.OK + rsp.body().accessToken + !rsp.body().refreshToken + + when: + String username = booksClient.toBlocking().retrieve(HttpRequest.GET('/').bearerAuth(rsp.body().accessToken), String) + + then: + username == 'user' + filter.keySetProxied.get() + + cleanup: + globalClientEmbeddedServer.close() + clientContext.close() + } + + def "jwks key set loading uses service level http client proxy config"() { + given: + EmbeddedServer globalClientEmbeddedServer = ApplicationContext.run(EmbeddedServer, [ + (SPEC_NAME_PROPERTY) : 'jwks-proxy.books', + 'micronaut.http.services.gateway.url' : "http://localhost:${authEmbeddedServer.port}", + 'micronaut.http.services.gateway.proxy-type' : 'http', + 'micronaut.http.services.gateway.proxy-address' : "localhost:${proxyEmbeddedServer.port}", + 'micronaut.security.token.jwt.signatures.jwks.gateway.url' : "/keys", + ]) + ApplicationContext clientContext = ApplicationContext.run([ (SPEC_NAME_PROPERTY) : 'jwks-proxy.client' ]) + HttpClient booksClient = clientContext.createBean(HttpClient, globalClientEmbeddedServer.getURL()) + ProxyFilter filter = proxyEmbeddedServer.applicationContext.getBean(ProxyFilter.class) + + when: + UsernamePasswordCredentials creds = new UsernamePasswordCredentials('user', 'password') + HttpResponse rsp = authClient.toBlocking().exchange(HttpRequest.POST('/login', creds), BearerAccessRefreshToken) + + then: + rsp.status() == HttpStatus.OK + rsp.body().accessToken + !rsp.body().refreshToken + + when: + String username = booksClient.toBlocking().retrieve(HttpRequest.GET('/').bearerAuth(rsp.body().accessToken), String) + + then: + username == 'user' + filter.keySetProxied.get() + + cleanup: + globalClientEmbeddedServer.close() + clientContext.close() + } + + def "jwks key set loading with Nimbus library resource retriever client uses system properties proxy config"() { + given: + SystemProperties proxyProps = new SystemProperties() + proxyProps.set("http.proxyHost", "localhost") + proxyProps.set("http.proxyPort", "${proxyEmbeddedServer.port}") + proxyProps.set("http.nonProxyHosts", "") + + EmbeddedServer globalClientEmbeddedServer = ApplicationContext.run(EmbeddedServer, [ + (SPEC_NAME_PROPERTY) : 'jwks-proxy.books', + 'spec.replace-client' : true, + 'micronaut.security.token.jwt.signatures.jwks.gateway.url' : "http://localhost:${authEmbeddedServer.port}/keys", + ]) + EmbeddedServer clientServer = ApplicationContext.run(EmbeddedServer, [ (SPEC_NAME_PROPERTY) : 'jwks-proxy.client' ]) + HttpClient booksClient = clientServer.applicationContext.createBean(HttpClient, globalClientEmbeddedServer.getURL()) + + ProxyFilter filter = proxyEmbeddedServer.applicationContext.getBean(ProxyFilter.class) + + when: + UsernamePasswordCredentials creds = new UsernamePasswordCredentials('user', 'password') + HttpResponse rsp = authClient.toBlocking().exchange(HttpRequest.POST('/login', creds), BearerAccessRefreshToken) + + then: + rsp.status() == HttpStatus.OK + rsp.body().accessToken + !rsp.body().refreshToken + + when: + String username = proxyProps.execute(() -> { + return booksClient.toBlocking().retrieve(HttpRequest.GET('/').bearerAuth(rsp.body().accessToken), String) + }) + + then: + username == 'user' + filter.keySetProxied.get() + + cleanup: + globalClientEmbeddedServer.close() + clientServer.close() + } + + def "jwks key set loading with Nimbus library resource retriever client can be used without proxy config"() { + given: + EmbeddedServer globalClientEmbeddedServer = ApplicationContext.run(EmbeddedServer, [ + (SPEC_NAME_PROPERTY) : 'jwks-proxy.books', + 'spec.replace-client' : true, + 'micronaut.security.token.jwt.signatures.jwks.gateway.url' : "http://localhost:${authEmbeddedServer.port}/keys", + ]) + EmbeddedServer clientServer = ApplicationContext.run(EmbeddedServer, [ (SPEC_NAME_PROPERTY) : 'jwks-proxy.client' ]) + HttpClient booksClient = clientServer.applicationContext.createBean(HttpClient, globalClientEmbeddedServer.getURL()) + + ProxyFilter filter = proxyEmbeddedServer.applicationContext.getBean(ProxyFilter.class) + + when: + UsernamePasswordCredentials creds = new UsernamePasswordCredentials('user', 'password') + HttpResponse rsp = authClient.toBlocking().exchange(HttpRequest.POST('/login', creds), BearerAccessRefreshToken) + + then: + rsp.status() == HttpStatus.OK + rsp.body().accessToken + !rsp.body().refreshToken + + when: + String username = booksClient.toBlocking().retrieve(HttpRequest.GET('/').bearerAuth(rsp.body().accessToken), String) + + then: + username == 'user' + !filter.keySetProxied.get() + + cleanup: + globalClientEmbeddedServer.close() + clientServer.close() + } + + @Factory + @Requires(property = 'spec.name', value = 'jwks-proxy.books') + static class JwksClientFactory { + + @Replaces(HttpClientJwksClient.class) + @Singleton + @Primary + @Requires(property = 'spec.replace-client', value = 'true') + ResourceRetrieverJwksClient jwksClient(@Value('${spec.name}') String specName) { + return new ResourceRetrieverJwksClient() + } + } + + @Singleton + @Requires(property = 'spec.name', value = 'jwks-proxy.books') + @Controller + @Secured(SecurityRule.IS_AUTHENTICATED) + static class HomeController { + + @Produces(MediaType.TEXT_HTML) + @Get + String username(Principal principal) { + principal.name + } + } + + @Filter("/**") + @Requires(property = 'spec.name', value = 'jwks-proxy.proxy') + static class ProxyFilter implements HttpServerFilter { + private final ProxyHttpClient client + private final String targetHost + private final int targetPort + private AtomicBoolean keySetProxied = new AtomicBoolean(false); + + ProxyFilter(ProxyHttpClient client, @Value('${forward-proxy-host}') String targetHost, @Value('${forward-proxy-port}') int targetPort) { + this.client = client + this.targetHost = targetHost + this.targetPort = targetPort + } + + @Override + Publisher> doFilter(HttpRequest request, + ServerFilterChain chain) { + if (request.method == HttpMethod.CONNECT) { + return Mono.just(HttpResponse.ok()) + } + + def forwardRequest = request.mutate() + .uri(b -> b + .scheme("http") + .host(targetHost) + .port(targetPort) + ) + .header(HttpHeaders.VIA, "Micronaut") + + if ("/keys" == forwardRequest.getPath()) { + keySetProxied.set(true) + } + + return client.proxy(forwardRequest) + } + } + + @Singleton + @Requires(property = 'spec.name', value = 'jwks-proxy.auth') + static class AuthenticationProviderUserPassword extends MockAuthenticationProvider { + AuthenticationProviderUserPassword() { + super([new SuccessAuthenticationScenario( 'user')]) + } + } + + @Named("generator") + @Singleton + @Requires(property = 'spec.name', value = 'jwks-proxy.auth') + static class RSAJwkProvider implements JwkProvider, RSASignatureGeneratorConfiguration { + private RSAKey jwk + + private static final Logger LOG = LoggerFactory.getLogger(RSAJwkProvider.class) + + RSAJwkProvider() { + + String keyId = UUID.randomUUID().toString() + try { + this.jwk = new RSAKeyGenerator(2048) + .algorithm(JWSAlgorithm.RS256) + .keyUse(KeyUse.SIGNATURE) // indicate the intended use of the key + .keyID(keyId) // give the key a unique ID + .generate() + + } catch (JOSEException e) { + + } + } + + @Override + List retrieveJsonWebKeys() { + [jwk] + } + + @Override + RSAPrivateKey getPrivateKey() { + try { + return jwk.toRSAPrivateKey() + } catch (JOSEException e) { + if (LOG.isErrorEnabled()) { + LOG.error("JOSEException getting RSA private key", e) + } + } + return null + } + + @Override + JWSAlgorithm getJwsAlgorithm() { + if (jwk.getAlgorithm() instanceof JWSAlgorithm) { + return (JWSAlgorithm) jwk.getAlgorithm() + } + return null + } + + @Override + RSAPublicKey getPublicKey() { + try { + return jwk.toRSAPublicKey() + } catch (JOSEException e) { + if (LOG.isErrorEnabled()) { + LOG.error("JOSEException getting RSA public key", e) + } + } + return null + } + } +} diff --git a/security-oauth2/build.gradle b/security-oauth2/build.gradle index 503a609657..b421428268 100644 --- a/security-oauth2/build.gradle +++ b/security-oauth2/build.gradle @@ -28,4 +28,5 @@ dependencies { testImplementation(projects.testSuiteUtilsSecurity) testImplementation(projects.testSuiteKeycloak16) testImplementation(mnLogging.logback.classic) + testImplementation(libs.system.stubs.core) } diff --git a/security-oauth2/src/main/java/io/micronaut/security/oauth2/endpoint/token/response/validation/DefaultOpenIdTokenResponseValidator.java b/security-oauth2/src/main/java/io/micronaut/security/oauth2/endpoint/token/response/validation/DefaultOpenIdTokenResponseValidator.java index 6b001191b5..0e2cb18589 100644 --- a/security-oauth2/src/main/java/io/micronaut/security/oauth2/endpoint/token/response/validation/DefaultOpenIdTokenResponseValidator.java +++ b/security-oauth2/src/main/java/io/micronaut/security/oauth2/endpoint/token/response/validation/DefaultOpenIdTokenResponseValidator.java @@ -146,6 +146,21 @@ protected Optional validateClaims(@NonNull OauthClientConfiguration clientC return Optional.empty(); } + /** + * @param openIdProviderMetadata The OpenID provider metadata + * @param openIdTokenResponse ID Token Access Token response + * Uses the ID token in the OpenID connect response to extract a JSON Web token and validates its signature + * @return A JWT if the signature validation is successful + * @deprecated Use {@link #parseJwtWithValidSignature} instead. + */ + @NonNull + @Deprecated(since = "4.5.0", forRemoval = true) + protected Optional parseJwtWithValidSignature(@NonNull OpenIdProviderMetadata openIdProviderMetadata, + @NonNull OpenIdTokenResponse openIdTokenResponse) { + + return parseJwtWithValidSignature(null, openIdProviderMetadata, openIdTokenResponse); + } + /** * @param clientConfiguration The OAuth 2.0 client configuration * @param openIdProviderMetadata The OpenID provider metadata @@ -164,16 +179,27 @@ protected Optional parseJwtWithValidSignature(@NonNull OauthClientConfigura .validate(openIdTokenResponse.getIdToken(), null); } + /** + * @param openIdProviderMetadata The OpenID provider metadata + * @return A {@link JwksSignature} for the OpenID provider JWKS uri. + * @deprecated Use {@link #jwksSignatureForOpenIdProviderMetadata(OauthClientConfiguration, OpenIdProviderMetadata)} instead. + */ + @Deprecated(since = "4.5.0", forRemoval = true) + protected JwksSignature jwksSignatureForOpenIdProviderMetadata(@NonNull OpenIdProviderMetadata openIdProviderMetadata) { + return jwksSignatureForOpenIdProviderMetadata(null, openIdProviderMetadata); + } + /** * @param clientConfiguration The OAuth 2.0 client configuration * @param openIdProviderMetadata The OpenID provider metadata * @return A {@link JwksSignature} for the OpenID provider JWKS uri. */ - protected JwksSignature jwksSignatureForOpenIdProviderMetadata(@NonNull OauthClientConfiguration clientConfiguration, + protected JwksSignature jwksSignatureForOpenIdProviderMetadata(@Nullable OauthClientConfiguration clientConfiguration, @NonNull OpenIdProviderMetadata openIdProviderMetadata) { final String jwksUri = openIdProviderMetadata.getJwksUri(); jwksSignatures.computeIfAbsent(jwksUri, k -> { - JwksSignatureConfigurationProperties config = new JwksSignatureConfigurationProperties(clientConfiguration.getName()); + String providerName = clientConfiguration != null ? clientConfiguration.getName() : null; + JwksSignatureConfigurationProperties config = new JwksSignatureConfigurationProperties(providerName); config.setUrl(jwksUri); return new JwksSignature(config, jwkValidator, jwkSetFetcher); }); diff --git a/security-oauth2/src/test/groovy/io/micronaut/security/oauth2/client/JwksUriSignatureProxySpec.groovy b/security-oauth2/src/test/groovy/io/micronaut/security/oauth2/client/JwksUriSignatureProxySpec.groovy new file mode 100644 index 0000000000..16dd8447b1 --- /dev/null +++ b/security-oauth2/src/test/groovy/io/micronaut/security/oauth2/client/JwksUriSignatureProxySpec.groovy @@ -0,0 +1,416 @@ +package io.micronaut.security.oauth2.client + +import com.nimbusds.jose.JOSEException +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.jwk.JWK +import com.nimbusds.jose.jwk.KeyUse +import com.nimbusds.jose.jwk.RSAKey +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator +import com.nimbusds.jwt.JWTClaimsSet +import io.micronaut.context.ApplicationContext +import io.micronaut.context.annotation.Factory +import io.micronaut.context.annotation.Primary +import io.micronaut.context.annotation.Replaces +import io.micronaut.context.annotation.Requires +import io.micronaut.context.annotation.Value +import io.micronaut.core.annotation.Nullable +import io.micronaut.core.io.socket.SocketUtils +import io.micronaut.http.HttpHeaders +import io.micronaut.http.HttpMethod +import io.micronaut.http.HttpRequest +import io.micronaut.http.HttpResponse +import io.micronaut.http.HttpStatus +import io.micronaut.http.MediaType +import io.micronaut.http.MutableHttpResponse +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Filter +import io.micronaut.http.annotation.Get +import io.micronaut.http.annotation.Produces +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.ProxyHttpClient +import io.micronaut.http.filter.HttpServerFilter +import io.micronaut.http.filter.ServerFilterChain +import io.micronaut.runtime.ApplicationConfiguration +import io.micronaut.runtime.server.EmbeddedServer +import io.micronaut.security.annotation.Secured +import io.micronaut.security.authentication.UsernamePasswordCredentials +import io.micronaut.security.rules.SecurityRule +import io.micronaut.security.testutils.authprovider.MockAuthenticationProvider +import io.micronaut.security.testutils.authprovider.SuccessAuthenticationScenario +import io.micronaut.security.token.claims.ClaimsAudienceProvider +import io.micronaut.security.token.claims.JtiGenerator +import io.micronaut.security.token.config.TokenConfiguration +import io.micronaut.security.token.jwt.endpoints.JwkProvider +import io.micronaut.security.token.jwt.generator.claims.JWTClaimsSetGenerator +import io.micronaut.security.token.jwt.signature.jwks.HttpClientJwksClient +import io.micronaut.security.token.jwt.signature.jwks.ResourceRetrieverJwksClient +import io.micronaut.security.token.jwt.signature.rsa.RSASignatureGeneratorConfiguration +import io.micronaut.security.token.render.BearerAccessRefreshToken +import jakarta.inject.Named +import jakarta.inject.Singleton +import org.reactivestreams.Publisher +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import reactor.core.publisher.Mono +import spock.lang.AutoCleanup +import spock.lang.Shared +import spock.lang.Specification +import uk.org.webcompere.systemstubs.properties.SystemProperties + +import java.security.Principal +import java.security.interfaces.RSAPrivateKey +import java.security.interfaces.RSAPublicKey +import java.util.concurrent.atomic.AtomicBoolean + +class JwksUriSignatureProxySpec extends Specification { + + static final String SPEC_NAME_PROPERTY = 'spec.name' + + @Shared + Map authConfig = [ + (SPEC_NAME_PROPERTY) : 'jwks-uri-proxy.auth', + 'micronaut.server.port': SocketUtils.findAvailableTcpPort(), + 'micronaut.security.authentication' : 'bearer' + ] + + @AutoCleanup + @Shared + EmbeddedServer authEmbeddedServer = ApplicationContext.run(EmbeddedServer, authConfig) + + HttpClient authClient = authEmbeddedServer.applicationContext.createBean(HttpClient, authEmbeddedServer.getURL()) + + @Shared + Map proxyConfig = [ + (SPEC_NAME_PROPERTY) : 'jwks-uri-proxy.proxy', + 'forward-proxy-host' : authEmbeddedServer.host, + 'forward-proxy-port' : authEmbeddedServer.port + ] + + @AutoCleanup + @Shared + EmbeddedServer proxyEmbeddedServer = ApplicationContext.run(EmbeddedServer, proxyConfig) + + def cleanup() { + proxyEmbeddedServer.applicationContext.getBean(ProxyFilter.class).keySetProxied.set(false) + } + + def "jwks key set loading uses global http client proxy config"() { + given: + EmbeddedServer globalClientEmbeddedServer = ApplicationContext.run(EmbeddedServer, [ + (SPEC_NAME_PROPERTY) : 'jwks-uri-proxy.books', + 'micronaut.http.client.proxy-type' : 'http', + 'micronaut.http.client.proxy-address' : "localhost:${proxyEmbeddedServer.port}", + 'micronaut.security.authentication': 'idtoken', + 'micronaut.security.oauth2.clients.a.client-id': "XXX", + 'micronaut.security.oauth2.clients.a.openid.issuer' : "http://localhost:${authEmbeddedServer.port}/oauth2/default" + ]) + ApplicationContext clientContext = ApplicationContext.run([ (SPEC_NAME_PROPERTY) : 'jwks-uri-proxy.client' ]) + + HttpClient booksClient = clientContext.createBean(HttpClient, globalClientEmbeddedServer.getURL()) + ProxyFilter filter = proxyEmbeddedServer.applicationContext.getBean(ProxyFilter.class) + + when: + UsernamePasswordCredentials creds = new UsernamePasswordCredentials('user', 'password') + HttpResponse rsp = authClient.toBlocking().exchange(HttpRequest.POST('/login', creds), BearerAccessRefreshToken) + + then: + rsp.status() == HttpStatus.OK + rsp.body().accessToken + !rsp.body().refreshToken + + when: + String username = booksClient.toBlocking().retrieve(HttpRequest.GET('/').bearerAuth(rsp.body().accessToken), String) + + then: + username == 'user' + filter.keySetProxied.get() + + cleanup: + globalClientEmbeddedServer.close() + clientContext.close() + } + + def "jwks key set loading uses service level http client proxy config"() { + given: + EmbeddedServer globalClientEmbeddedServer = ApplicationContext.run(EmbeddedServer, [ + (SPEC_NAME_PROPERTY) : 'jwks-uri-proxy.books', + 'micronaut.http.services.a.url' : "http://localhost:${authEmbeddedServer.port}", + 'micronaut.http.services.a.proxy-type' : 'http', + 'micronaut.http.services.a.proxy-address' : "localhost:${proxyEmbeddedServer.port}", + 'micronaut.security.authentication': 'idtoken', + 'micronaut.security.oauth2.clients.a.client-id': "XXX", + 'micronaut.security.oauth2.clients.a.openid.issuer' : "http://localhost:${authEmbeddedServer.port}/oauth2/default" + ]) + ApplicationContext clientContext = ApplicationContext.run([ (SPEC_NAME_PROPERTY) : 'jwks-uri-proxy.client' ]) + HttpClient booksClient = clientContext.createBean(HttpClient, globalClientEmbeddedServer.getURL()) + ProxyFilter filter = proxyEmbeddedServer.applicationContext.getBean(ProxyFilter.class) + + when: + UsernamePasswordCredentials creds = new UsernamePasswordCredentials('user', 'password') + HttpResponse rsp = authClient.toBlocking().exchange(HttpRequest.POST('/login', creds), BearerAccessRefreshToken) + + then: + rsp.status() == HttpStatus.OK + rsp.body().accessToken + !rsp.body().refreshToken + + when: + String username = booksClient.toBlocking().retrieve(HttpRequest.GET('/').bearerAuth(rsp.body().accessToken), String) + + then: + username == 'user' + filter.keySetProxied.get() + + cleanup: + globalClientEmbeddedServer.close() + clientContext.close() + } + + def "jwks key set loading with Nimbus library resource retriever client uses system properties proxy config"() { + given: + SystemProperties proxyProps = new SystemProperties() + proxyProps.set("http.proxyHost", "localhost") + proxyProps.set("http.proxyPort", "${proxyEmbeddedServer.port}") + proxyProps.set("http.nonProxyHosts", "") + + EmbeddedServer globalClientEmbeddedServer = ApplicationContext.run(EmbeddedServer, [ + (SPEC_NAME_PROPERTY) : 'jwks-uri-proxy.books', + 'spec.replace-client' : true, + 'micronaut.security.authentication': 'idtoken', + 'micronaut.security.oauth2.clients.a.client-id': "XXX", + 'micronaut.security.oauth2.clients.a.openid.issuer' : "http://localhost:${authEmbeddedServer.port}/oauth2/default" + ]) + EmbeddedServer clientServer = ApplicationContext.run(EmbeddedServer, [ (SPEC_NAME_PROPERTY) : 'jwks-uri-proxy.client' ]) + HttpClient booksClient = clientServer.applicationContext.createBean(HttpClient, globalClientEmbeddedServer.getURL()) + + ProxyFilter filter = proxyEmbeddedServer.applicationContext.getBean(ProxyFilter.class) + + when: + UsernamePasswordCredentials creds = new UsernamePasswordCredentials('user', 'password') + HttpResponse rsp = authClient.toBlocking().exchange(HttpRequest.POST('/login', creds), BearerAccessRefreshToken) + + then: + rsp.status() == HttpStatus.OK + rsp.body().accessToken + !rsp.body().refreshToken + + when: + String username = proxyProps.execute(() -> { + return booksClient.toBlocking().retrieve(HttpRequest.GET('/').bearerAuth(rsp.body().accessToken), String) + }) + + then: + username == 'user' + filter.keySetProxied.get() + + cleanup: + globalClientEmbeddedServer.close() + clientServer.close() + } + + def "jwks key set loading with Nimbus library resource retriever client can be used without proxy config"() { + given: + EmbeddedServer globalClientEmbeddedServer = ApplicationContext.run(EmbeddedServer, [ + (SPEC_NAME_PROPERTY) : 'jwks-uri-proxy.books', + 'spec.replace-client' : true, + 'micronaut.security.authentication': 'idtoken', + 'micronaut.security.oauth2.clients.a.client-id': "XXX", + 'micronaut.security.oauth2.clients.a.openid.issuer' : "http://localhost:${authEmbeddedServer.port}/oauth2/default" + ]) + EmbeddedServer clientServer = ApplicationContext.run(EmbeddedServer, [ (SPEC_NAME_PROPERTY) : 'jwks-uri-proxy.client' ]) + HttpClient booksClient = clientServer.applicationContext.createBean(HttpClient, globalClientEmbeddedServer.getURL()) + + ProxyFilter filter = proxyEmbeddedServer.applicationContext.getBean(ProxyFilter.class) + + when: + UsernamePasswordCredentials creds = new UsernamePasswordCredentials('user', 'password') + HttpResponse rsp = authClient.toBlocking().exchange(HttpRequest.POST('/login', creds), BearerAccessRefreshToken) + + then: + rsp.status() == HttpStatus.OK + rsp.body().accessToken + !rsp.body().refreshToken + + when: + String username = booksClient.toBlocking().retrieve(HttpRequest.GET('/').bearerAuth(rsp.body().accessToken), String) + + then: + username == 'user' + !filter.keySetProxied.get() + + cleanup: + globalClientEmbeddedServer.close() + clientServer.close() + } + + @Factory + @Requires(property = 'spec.name', value = 'jwks-uri-proxy.books') + static class JwksClientFactory { + + @Replaces(HttpClientJwksClient.class) + @Singleton + @Primary + @Requires(property = 'spec.replace-client', value = 'true') + ResourceRetrieverJwksClient jwksClient(@Value('${spec.name}') String specName) { + return new ResourceRetrieverJwksClient() + } + } + + @Singleton + @Requires(property = 'spec.name', value = 'jwks-uri-proxy.books') + @Controller + @Secured(SecurityRule.IS_AUTHENTICATED) + static class HomeController { + + @Produces(MediaType.TEXT_HTML) + @Get + String username(Principal principal) { + principal.name + } + } + + @Filter("/**") + @Requires(property = 'spec.name', value = 'jwks-uri-proxy.proxy') + static class ProxyFilter implements HttpServerFilter { + private final ProxyHttpClient client + private final String targetHost + private final int targetPort + private AtomicBoolean keySetProxied = new AtomicBoolean(false); + + ProxyFilter(ProxyHttpClient client, @Value('${forward-proxy-host}') String targetHost, @Value('${forward-proxy-port}') int targetPort) { + this.client = client + this.targetHost = targetHost + this.targetPort = targetPort + } + + @Override + Publisher> doFilter(HttpRequest request, + ServerFilterChain chain) { + if (request.method == HttpMethod.CONNECT) { + return Mono.just(HttpResponse.ok()) + } + + def forwardRequest = request.mutate() + .uri(b -> b + .scheme("http") + .host(targetHost) + .port(targetPort) + ) + .header(HttpHeaders.VIA, "Micronaut") + + if ("/keys" == forwardRequest.getPath()) { + keySetProxied.set(true) + } + + return client.proxy(forwardRequest) + } + } + + @Singleton + @Requires(property = 'spec.name', value = 'jwks-uri-proxy.auth') + static class AuthenticationProviderUserPassword extends MockAuthenticationProvider { + AuthenticationProviderUserPassword() { + super([new SuccessAuthenticationScenario( 'user')]) + } + } + + @Requires(property = 'spec.name', value = 'jwks-uri-proxy.auth') + @Replaces(JWTClaimsSetGenerator) + @Singleton + static class AuthServerACustomJWTClaimsSetGenerator extends JWTClaimsSetGenerator { + Integer port + AuthServerACustomJWTClaimsSetGenerator(TokenConfiguration tokenConfiguration, + @Nullable JtiGenerator jwtIdGenerator, + @Nullable ClaimsAudienceProvider claimsAudienceProvider, + @Nullable ApplicationConfiguration applicationConfiguration, + @Value('${micronaut.server.port}') Integer port) { + super(tokenConfiguration, jwtIdGenerator, claimsAudienceProvider, applicationConfiguration) + this.port = port + } + + @Override + protected void populateIss(JWTClaimsSet.Builder builder) { + builder.issuer("http://localhost:${port}/oauth2/default") + } + + @Override + protected void populateAud(JWTClaimsSet.Builder builder) { + builder.audience("XXX") + } + } + + @Requires(property = 'spec.name', value = 'jwks-uri-proxy.auth') + @Controller("/oauth2/default/.well-known/openid-configuration") + static class AuthServerOpenIdConfigurationController { + Integer port + AuthServerOpenIdConfigurationController(@Value('${micronaut.server.port}') Integer port) { + this.port = port + } + @Secured(SecurityRule.IS_ANONYMOUS) + @Get + String index() { + '{"issuer":"http://localhost:' + port + '/oauth2/default","authorization_endpoint":"https://dev-133320.okta.com/oauth2/default/v1/authorize","token_endpoint":"https://dev-133320.okta.com/oauth2/default/v1/token","userinfo_endpoint":"https://dev-133320.okta.com/oauth2/default/v1/userinfo","registration_endpoint":"https://dev-133320.okta.com/oauth2/v1/clients","jwks_uri":"http://localhost:' + port + '/keys","response_types_supported":["code","id_token","code id_token","code token","id_token token","code id_token token"],"response_modes_supported":["query","fragment","form_post","okta_post_message"],"grant_types_supported":["authorization_code","implicit","refresh_token","password"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256"],"scopes_supported":["openid","profile","email","address","phone","offline_access"],"token_endpoint_auth_methods_supported":["client_secret_basic","client_secret_post","client_secret_jwt","private_key_jwt","none"],"claims_supported":["iss","ver","sub","aud","iat","exp","jti","auth_time","amr","idp","nonce","name","nickname","preferred_username","given_name","middle_name","family_name","email","email_verified","profile","zoneinfo","locale","address","phone_number","picture","website","gender","birthdate","updated_at","at_hash","c_hash"],"code_challenge_methods_supported":["S256"],"introspection_endpoint":"https://dev-133320.okta.com/oauth2/default/v1/introspect","introspection_endpoint_auth_methods_supported":["client_secret_basic","client_secret_post","client_secret_jwt","private_key_jwt","none"],"revocation_endpoint":"https://dev-133320.okta.com/oauth2/default/v1/revoke","revocation_endpoint_auth_methods_supported":["client_secret_basic","client_secret_post","client_secret_jwt","private_key_jwt","none"],"end_session_endpoint":"https://dev-133320.okta.com/oauth2/default/v1/logout","request_parameter_supported":true,"request_object_signing_alg_values_supported":["HS256","HS384","HS512","RS256","RS384","RS512","ES256","ES384","ES512"]}' + } + } + + @Named("generator") + @Singleton + @Requires(property = 'spec.name', value = 'jwks-uri-proxy.auth') + static class RSAJwkProvider implements JwkProvider, RSASignatureGeneratorConfiguration { + private RSAKey jwk + + private static final Logger LOG = LoggerFactory.getLogger(RSAJwkProvider.class) + + RSAJwkProvider() { + + String keyId = UUID.randomUUID().toString() + try { + this.jwk = new RSAKeyGenerator(2048) + .algorithm(JWSAlgorithm.RS256) + .keyUse(KeyUse.SIGNATURE) // indicate the intended use of the key + .keyID(keyId) // give the key a unique ID + .generate() + + } catch (JOSEException e) { + + } + } + + @Override + List retrieveJsonWebKeys() { + [jwk] + } + + @Override + RSAPrivateKey getPrivateKey() { + try { + return jwk.toRSAPrivateKey() + } catch (JOSEException e) { + if (LOG.isErrorEnabled()) { + LOG.error("JOSEException getting RSA private key", e) + } + } + return null + } + + @Override + JWSAlgorithm getJwsAlgorithm() { + if (jwk.getAlgorithm() instanceof JWSAlgorithm) { + return (JWSAlgorithm) jwk.getAlgorithm() + } + return null + } + + @Override + RSAPublicKey getPublicKey() { + try { + return jwk.toRSAPublicKey() + } catch (JOSEException e) { + if (LOG.isErrorEnabled()) { + LOG.error("JOSEException getting RSA public key", e) + } + } + return null + } + } +} diff --git a/security-oauth2/src/test/groovy/io/micronaut/security/oauth2/client/JwksUriSignatureSpec.groovy b/security-oauth2/src/test/groovy/io/micronaut/security/oauth2/client/JwksUriSignatureSpec.groovy index 1155663f4f..e37388a0d6 100644 --- a/security-oauth2/src/test/groovy/io/micronaut/security/oauth2/client/JwksUriSignatureSpec.groovy +++ b/security-oauth2/src/test/groovy/io/micronaut/security/oauth2/client/JwksUriSignatureSpec.groovy @@ -54,7 +54,7 @@ import java.security.interfaces.RSAPublicKey class JwksUriSignatureSpec extends Specification { private static final Logger LOG = LoggerFactory.getLogger(JwksUriSignatureSpec.class) - void "registering an open id client, creates a JwskUriSignature with the jws_uri exposed in the openid-configuration endpoint"() { + void "registering an open id client, creates a JwksUriSignature with the jws_uri exposed in the openid-configuration endpoint"() { given: int authServerAPort = SocketUtils.findAvailableTcpPort() int authServerBPort = SocketUtils.findAvailableTcpPort()