diff --git a/docs/modules/ROOT/pages/reactive/oauth2/resource-server/jwt.adoc b/docs/modules/ROOT/pages/reactive/oauth2/resource-server/jwt.adoc index ed511ed047..2a00fbcc3f 100644 --- a/docs/modules/ROOT/pages/reactive/oauth2/resource-server/jwt.adoc +++ b/docs/modules/ROOT/pages/reactive/oauth2/resource-server/jwt.adoc @@ -327,6 +327,7 @@ This is handy when you need deeper configuration, such as < { algorithms.add(RS512); algorithms.add(ES512); @@ -445,7 +490,7 @@ ReactiveJwtDecoder jwtDecoder() { ---- @Bean fun jwtDecoder(): ReactiveJwtDecoder { - return NimbusReactiveJwtDecoder.withJwkSetUri(this.jwkSetUri) + return NimbusReactiveJwtDecoder.withIssuerLocation(this.jwkSetUri) .jwsAlgorithms { it.add(RS512) it.add(ES512) diff --git a/docs/modules/ROOT/pages/servlet/oauth2/resource-server/jwt.adoc b/docs/modules/ROOT/pages/servlet/oauth2/resource-server/jwt.adoc index 1145e53915..0b288753d5 100644 --- a/docs/modules/ROOT/pages/servlet/oauth2/resource-server/jwt.adoc +++ b/docs/modules/ROOT/pages/servlet/oauth2/resource-server/jwt.adoc @@ -430,7 +430,8 @@ This is handy when deeper configuration, like <> `@Bean` has the same effect as `decoder()`: +Or, exposing a <> `@Bean` has the same effect as `decoder()`. +You can construct one with a `jwkSetUri` like so: ==== .Java @@ -452,6 +453,50 @@ fun jwtDecoder(): JwtDecoder { ---- ==== +or you can use the issuer and have `NimbusJwtDecoder` look up the `jwkSetUri` when `build()` is invoked, like the following: + +==== +.Java +[source,java,role="primary"] +---- +@Bean +public JwtDecoder jwtDecoder() { + return NimbusJwtDecoder.withIssuerLocation(issuer).build(); +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Bean +fun jwtDecoder(): JwtDecoder { + return NimbusJwtDecoder.withIssuerLocation(issuer).build() +} +---- +==== + +Or, if the defaults work for you, you can also use `JwtDecoders`, which does the above in addition to configuring the decoder's validator: + +==== +.Java +[source,java,role="primary"] +---- +@Bean +public JwtDecoders jwtDecoder() { + return JwtDecoders.fromIssuerLocation(issuer); +} +---- + +.Kotlin +[source,kotlin,role="secondary"] +---- +@Bean +fun jwtDecoder(): JwtDecoders { + return JwtDecoders.fromIssuerLocation(issuer) +} +---- +==== + [[oauth2resourceserver-jwt-decoder-algorithm]] == Configuring Trusted Algorithms @@ -486,7 +531,7 @@ For greater power, though, we can use a builder that ships with `NimbusJwtDecode ---- @Bean JwtDecoder jwtDecoder() { - return NimbusJwtDecoder.withJwkSetUri(this.jwkSetUri) + return NimbusJwtDecoder.withIssuerLocation(this.issuer) .jwsAlgorithm(RS512).build(); } ---- @@ -496,7 +541,7 @@ JwtDecoder jwtDecoder() { ---- @Bean fun jwtDecoder(): JwtDecoder { - return NimbusJwtDecoder.withJwkSetUri(this.jwkSetUri) + return NimbusJwtDecoder.withIssuerLocation(this.issuer) .jwsAlgorithm(RS512).build() } ---- @@ -510,7 +555,7 @@ Calling `jwsAlgorithm` more than once will configure `NimbusJwtDecoder` to trust ---- @Bean JwtDecoder jwtDecoder() { - return NimbusJwtDecoder.withJwkSetUri(this.jwkSetUri) + return NimbusJwtDecoder.withIssuerLocation(this.issuer) .jwsAlgorithm(RS512).jwsAlgorithm(ES512).build(); } ---- @@ -520,7 +565,7 @@ JwtDecoder jwtDecoder() { ---- @Bean fun jwtDecoder(): JwtDecoder { - return NimbusJwtDecoder.withJwkSetUri(this.jwkSetUri) + return NimbusJwtDecoder.withIssuerLocation(this.issuer) .jwsAlgorithm(RS512).jwsAlgorithm(ES512).build() } ---- @@ -534,7 +579,7 @@ Or, you can call `jwsAlgorithms`: ---- @Bean JwtDecoder jwtDecoder() { - return NimbusJwtDecoder.withJwkSetUri(this.jwkSetUri) + return NimbusJwtDecoder.withIssuerLocation(this.issuer) .jwsAlgorithms(algorithms -> { algorithms.add(RS512); algorithms.add(ES512); @@ -547,7 +592,7 @@ JwtDecoder jwtDecoder() { ---- @Bean fun jwtDecoder(): JwtDecoder { - return NimbusJwtDecoder.withJwkSetUri(this.jwkSetUri) + return NimbusJwtDecoder.withIssuerLocation(this.issuer) .jwsAlgorithms { it.add(RS512) it.add(ES512) @@ -1207,7 +1252,7 @@ An individual claim's conversion strategy can be configured using `MappedJwtClai ---- @Bean JwtDecoder jwtDecoder() { - NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build(); + NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer).build(); MappedJwtClaimSetConverter converter = MappedJwtClaimSetConverter .withDefaults(Collections.singletonMap("sub", this::lookupUserIdBySub)); @@ -1222,7 +1267,7 @@ JwtDecoder jwtDecoder() { ---- @Bean fun jwtDecoder(): JwtDecoder { - val jwtDecoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build() + val jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer).build() val converter = MappedJwtClaimSetConverter .withDefaults(mapOf("sub" to this::lookupUserIdBySub)) @@ -1319,7 +1364,7 @@ And then, the instance can be supplied like normal: ---- @Bean JwtDecoder jwtDecoder() { - NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build(); + NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer).build(); jwtDecoder.setClaimSetConverter(new UsernameSubClaimAdapter()); return jwtDecoder; } @@ -1330,7 +1375,7 @@ JwtDecoder jwtDecoder() { ---- @Bean fun jwtDecoder(): JwtDecoder { - val jwtDecoder: NimbusJwtDecoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build() + val jwtDecoder: NimbusJwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer).build() jwtDecoder.setClaimSetConverter(UsernameSubClaimAdapter()) return jwtDecoder } @@ -1358,7 +1403,7 @@ public JwtDecoder jwtDecoder(RestTemplateBuilder builder) { .setReadTimeout(Duration.ofSeconds(60)) .build(); - NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).restOperations(rest).build(); + NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer).restOperations(rest).build(); return jwtDecoder; } ---- @@ -1372,7 +1417,7 @@ fun jwtDecoder(builder: RestTemplateBuilder): JwtDecoder { .setConnectTimeout(Duration.ofSeconds(60)) .setReadTimeout(Duration.ofSeconds(60)) .build() - return NimbusJwtDecoder.withJwkSetUri(jwkSetUri).restOperations(rest).build() + return NimbusJwtDecoder.withIssuerLocation(issuer).restOperations(rest).build() } ---- ==== @@ -1388,7 +1433,7 @@ To adjust the way in which Resource Server caches the JWK set, `NimbusJwtDecoder ---- @Bean public JwtDecoder jwtDecoder(CacheManager cacheManager) { - return NimbusJwtDecoder.withJwkSetUri(jwkSetUri) + return NimbusJwtDecoder.withIssuerLocation(issuer) .cache(cacheManager.getCache("jwks")) .build(); } @@ -1399,7 +1444,7 @@ public JwtDecoder jwtDecoder(CacheManager cacheManager) { ---- @Bean fun jwtDecoder(cacheManager: CacheManager): JwtDecoder { - return NimbusJwtDecoder.withJwkSetUri(jwkSetUri) + return NimbusJwtDecoder.withIssuerLocation(issuer) .cache(cacheManager.getCache("jwks")) .build() } diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtDecoderProviderConfigurationUtils.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtDecoderProviderConfigurationUtils.java index 03e88e1f2d..fb465811a0 100644 --- a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtDecoderProviderConfigurationUtils.java +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtDecoderProviderConfigurationUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -42,6 +42,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; import org.springframework.util.Assert; import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestOperations; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; @@ -71,12 +72,16 @@ final class JwtDecoderProviderConfigurationUtils { } static Map getConfigurationForOidcIssuerLocation(String oidcIssuerLocation) { - return getConfiguration(oidcIssuerLocation, oidc(URI.create(oidcIssuerLocation))); + return getConfiguration(oidcIssuerLocation, rest, oidc(URI.create(oidcIssuerLocation))); + } + + static Map getConfigurationForIssuerLocation(String issuer, RestOperations rest) { + URI uri = URI.create(issuer); + return getConfiguration(issuer, rest, oidc(uri), oidcRfc8414(uri), oauth(uri)); } static Map getConfigurationForIssuerLocation(String issuer) { - URI uri = URI.create(issuer); - return getConfiguration(issuer, oidc(uri), oidcRfc8414(uri), oauth(uri)); + return getConfigurationForIssuerLocation(issuer, rest); } static void validateIssuer(Map configuration, String issuer) { @@ -142,7 +147,7 @@ final class JwtDecoderProviderConfigurationUtils { return "(unavailable)"; } - private static Map getConfiguration(String issuer, URI... uris) { + private static Map getConfiguration(String issuer, RestOperations rest, URI... uris) { String errorMessage = "Unable to resolve the Configuration with the provided Issuer of " + "\"" + issuer + "\""; for (URI uri : uris) { try { diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtDecoders.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtDecoders.java index 7132ca22bb..635e0a177d 100644 --- a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtDecoders.java +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/JwtDecoders.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -89,9 +89,10 @@ public final class JwtDecoders { @SuppressWarnings("unchecked") public static T fromIssuerLocation(String issuer) { Assert.hasText(issuer, "issuer cannot be empty"); - Map configuration = JwtDecoderProviderConfigurationUtils - .getConfigurationForIssuerLocation(issuer); - return (T) withProviderConfiguration(configuration, issuer); + NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer).build(); + OAuth2TokenValidator jwtValidator = JwtValidators.createDefaultWithIssuer(issuer); + jwtDecoder.setJwtValidator(jwtValidator); + return (T) jwtDecoder; } /** diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoder.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoder.java index 33c3999562..604cfec6dc 100644 --- a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoder.java +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoder.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -29,6 +29,7 @@ import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; import java.util.function.Consumer; +import java.util.function.Function; import javax.crypto.SecretKey; @@ -200,6 +201,31 @@ public final class NimbusJwtDecoder implements JwtDecoder { return "Unable to validate Jwt"; } + /** + * Use the given Issuer + * by making an OpenID + * Provider Configuration Request and using the values in the OpenID + * Provider Configuration Response to derive the needed + * JWK Set uri. + * @param issuer the Issuer + * @return a {@link JwkSetUriJwtDecoderBuilder} that will derive the JWK Set uri when + * {@link JwkSetUriJwtDecoderBuilder#build} is called + * @since 6.1 + * @see JwtDecoders + */ + public static JwkSetUriJwtDecoderBuilder withIssuerLocation(String issuer) { + return new JwkSetUriJwtDecoderBuilder((rest) -> { + Map configuration = JwtDecoderProviderConfigurationUtils + .getConfigurationForIssuerLocation(issuer, rest); + JwtDecoderProviderConfigurationUtils.validateIssuer(configuration, issuer); + return configuration.get("jwks_uri").toString(); + }, JwtDecoderProviderConfigurationUtils::getJWSAlgorithms); + } + /** * Use the given JWK Set * uri. @@ -235,7 +261,10 @@ public final class NimbusJwtDecoder implements JwtDecoder { */ public static final class JwkSetUriJwtDecoderBuilder { - private String jwkSetUri; + private Function jwkSetUri; + + private Function, Set> defaultAlgorithms = (source) -> Set + .of(JWSAlgorithm.RS256); private Set signatureAlgorithms = new HashSet<>(); @@ -247,7 +276,17 @@ public final class NimbusJwtDecoder implements JwtDecoder { private JwkSetUriJwtDecoderBuilder(String jwkSetUri) { Assert.hasText(jwkSetUri, "jwkSetUri cannot be empty"); + this.jwkSetUri = (rest) -> jwkSetUri; + this.jwtProcessorCustomizer = (processor) -> { + }; + } + + private JwkSetUriJwtDecoderBuilder(Function jwkSetUri, + Function, Set> defaultAlgorithms) { + Assert.notNull(jwkSetUri, "jwkSetUri function cannot be null"); + Assert.notNull(defaultAlgorithms, "defaultAlgorithms function cannot be null"); this.jwkSetUri = jwkSetUri; + this.defaultAlgorithms = defaultAlgorithms; this.jwtProcessorCustomizer = (processor) -> { }; } @@ -324,7 +363,7 @@ public final class NimbusJwtDecoder implements JwtDecoder { JWSKeySelector jwsKeySelector(JWKSource jwkSource) { if (this.signatureAlgorithms.isEmpty()) { - return new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, jwkSource); + return new JWSVerificationKeySelector<>(this.defaultAlgorithms.apply(jwkSource), jwkSource); } Set jwsAlgorithms = new HashSet<>(); for (SignatureAlgorithm signatureAlgorithm : this.signatureAlgorithms) { @@ -334,17 +373,18 @@ public final class NimbusJwtDecoder implements JwtDecoder { return new JWSVerificationKeySelector<>(jwsAlgorithms, jwkSource); } - JWKSource jwkSource(ResourceRetriever jwkSetRetriever) { + JWKSource jwkSource(ResourceRetriever jwkSetRetriever, String jwkSetUri) { if (this.cache == null) { - return new RemoteJWKSet<>(toURL(this.jwkSetUri), jwkSetRetriever); + return new RemoteJWKSet<>(toURL(jwkSetUri), jwkSetRetriever); } - JWKSetCache jwkSetCache = new SpringJWKSetCache(this.jwkSetUri, this.cache); - return new RemoteJWKSet<>(toURL(this.jwkSetUri), jwkSetRetriever, jwkSetCache); + JWKSetCache jwkSetCache = new SpringJWKSetCache(jwkSetUri, this.cache); + return new RemoteJWKSet<>(toURL(jwkSetUri), jwkSetRetriever, jwkSetCache); } JWTProcessor processor() { ResourceRetriever jwkSetRetriever = new RestOperationsResourceRetriever(this.restOperations); - JWKSource jwkSource = jwkSource(jwkSetRetriever); + String jwkSetUri = this.jwkSetUri.apply(this.restOperations); + JWKSource jwkSource = jwkSource(jwkSetRetriever, jwkSetUri); ConfigurableJWTProcessor jwtProcessor = new DefaultJWTProcessor<>(); jwtProcessor.setJWSKeySelector(jwsKeySelector(jwkSource)); // Spring Security validates the claim set independent from Nimbus diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusReactiveJwtDecoder.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusReactiveJwtDecoder.java index 79ed261b2d..0e833ae4de 100644 --- a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusReactiveJwtDecoder.java +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/NimbusReactiveJwtDecoder.java @@ -38,7 +38,6 @@ import com.nimbusds.jose.jwk.JWK; import com.nimbusds.jose.jwk.JWKMatcher; import com.nimbusds.jose.jwk.JWKSelector; import com.nimbusds.jose.jwk.source.JWKSecurityContextJWKSet; -import com.nimbusds.jose.jwk.source.JWKSource; import com.nimbusds.jose.proc.BadJOSEException; import com.nimbusds.jose.proc.JWKSecurityContext; import com.nimbusds.jose.proc.JWSKeySelector; @@ -211,6 +210,36 @@ public final class NimbusReactiveJwtDecoder implements ReactiveJwtDecoder { return "Unable to validate Jwt"; } + /** + * Use the given Issuer + * by making an OpenID + * Provider Configuration Request and using the values in the OpenID + * Provider Configuration Response to derive the needed + * JWK Set uri. + * @param issuer the Issuer + * @return a {@link NimbusJwtDecoder.JwkSetUriJwtDecoderBuilder} that will derive the + * JWK Set uri when {@link NimbusJwtDecoder.JwkSetUriJwtDecoderBuilder#build} is + * called + * @since 6.1 + * @see JwtDecoders + */ + public static JwkSetUriReactiveJwtDecoderBuilder withIssuerLocation(String issuer) { + return new JwkSetUriReactiveJwtDecoderBuilder((web) -> ReactiveJwtDecoderProviderConfigurationUtils + .getConfigurationForIssuerLocation(issuer, web).flatMap((configuration) -> { + try { + JwtDecoderProviderConfigurationUtils.validateIssuer(configuration, issuer); + } + catch (IllegalStateException ex) { + return Mono.error(ex); + } + return Mono.just(configuration.get("jwks_uri").toString()); + }), ReactiveJwtDecoderProviderConfigurationUtils::getJWSAlgorithms); + } + /** * Use the given JWK Set * uri to validate JWTs. @@ -280,7 +309,10 @@ public final class NimbusReactiveJwtDecoder implements ReactiveJwtDecoder { private static final Duration FOREVER = Duration.ofMillis(Long.MAX_VALUE); - private final String jwkSetUri; + private Function> jwkSetUri; + + private Function>> defaultAlgorithms = (source) -> Mono + .just(Set.of(JWSAlgorithm.RS256)); private Set signatureAlgorithms = new HashSet<>(); @@ -290,7 +322,16 @@ public final class NimbusReactiveJwtDecoder implements ReactiveJwtDecoder { private JwkSetUriReactiveJwtDecoderBuilder(String jwkSetUri) { Assert.hasText(jwkSetUri, "jwkSetUri cannot be empty"); + this.jwkSetUri = (web) -> Mono.just(jwkSetUri); + this.jwtProcessorCustomizer = (source, processor) -> Mono.just(processor); + } + + private JwkSetUriReactiveJwtDecoderBuilder(Function> jwkSetUri, + Function>> defaultAlgorithms) { + Assert.notNull(jwkSetUri, "jwkSetUri cannot be null"); + Assert.notNull(defaultAlgorithms, "defaultAlgorithms cannot be null"); this.jwkSetUri = jwkSetUri; + this.defaultAlgorithms = defaultAlgorithms; this.jwtProcessorCustomizer = (source, processor) -> Mono.just(processor); } @@ -369,30 +410,32 @@ public final class NimbusReactiveJwtDecoder implements ReactiveJwtDecoder { return new NimbusReactiveJwtDecoder(processor()); } - JWSKeySelector jwsKeySelector(JWKSource jwkSource) { + Mono> jwsKeySelector(ReactiveRemoteJWKSource source) { + JWKSecurityContextJWKSet jwkSource = new JWKSecurityContextJWKSet(); if (this.signatureAlgorithms.isEmpty()) { - return new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, jwkSource); + return this.defaultAlgorithms.apply(source) + .map((algorithms) -> new JWSVerificationKeySelector<>(algorithms, jwkSource)); } Set jwsAlgorithms = new HashSet<>(); for (SignatureAlgorithm signatureAlgorithm : this.signatureAlgorithms) { JWSAlgorithm jwsAlgorithm = JWSAlgorithm.parse(signatureAlgorithm.getName()); jwsAlgorithms.add(jwsAlgorithm); } - return new JWSVerificationKeySelector<>(jwsAlgorithms, jwkSource); + return Mono.just(new JWSVerificationKeySelector<>(jwsAlgorithms, jwkSource)); } Converter> processor() { - JWKSecurityContextJWKSet jwkSource = new JWKSecurityContextJWKSet(); DefaultJWTProcessor jwtProcessor = new DefaultJWTProcessor<>(); - JWSKeySelector jwsKeySelector = jwsKeySelector(jwkSource); - jwtProcessor.setJWSKeySelector(jwsKeySelector); jwtProcessor.setJWTClaimsSetVerifier((claims, context) -> { }); - ReactiveRemoteJWKSource source = new ReactiveRemoteJWKSource(this.jwkSetUri); + ReactiveRemoteJWKSource source = new ReactiveRemoteJWKSource(this.jwkSetUri.apply(this.webClient)); source.setWebClient(this.webClient); - Mono, Function>> jwtProcessorMono = this.jwtProcessorCustomizer - .apply(source, jwtProcessor) - .map((processor) -> Tuples.of(processor, getExpectedJwsAlgorithms(processor.getJWSKeySelector()))) + Mono> jwsKeySelector = jwsKeySelector(source); + Mono, Function>> jwtProcessorMono = jwsKeySelector + .flatMap((selector) -> { + jwtProcessor.setJWSKeySelector(selector); + return this.jwtProcessorCustomizer.apply(source, jwtProcessor); + }).map((processor) -> Tuples.of(processor, getExpectedJwsAlgorithms(processor.getJWSKeySelector()))) .cache((processor) -> FOREVER, (ex) -> Duration.ZERO, () -> Duration.ZERO); return (jwt) -> { return jwtProcessorMono.flatMap((tuple) -> { diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/ReactiveJwtDecoderProviderConfigurationUtils.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/ReactiveJwtDecoderProviderConfigurationUtils.java index 94a8e918d9..d4ab2d56f1 100644 --- a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/ReactiveJwtDecoderProviderConfigurationUtils.java +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/ReactiveJwtDecoderProviderConfigurationUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,7 +16,10 @@ package org.springframework.security.oauth2.jwt; +import java.net.URI; +import java.util.Collections; import java.util.HashSet; +import java.util.Map; import java.util.Set; import com.nimbusds.jose.JWSAlgorithm; @@ -31,12 +34,24 @@ import com.nimbusds.jose.proc.JWSKeySelector; import com.nimbusds.jose.proc.JWSVerificationKeySelector; import com.nimbusds.jose.proc.SecurityContext; import com.nimbusds.jwt.proc.ConfigurableJWTProcessor; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.util.Assert; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import org.springframework.web.util.UriComponentsBuilder; final class ReactiveJwtDecoderProviderConfigurationUtils { + private static final String OIDC_METADATA_PATH = "/.well-known/openid-configuration"; + + private static final String OAUTH_METADATA_PATH = "/.well-known/oauth-authorization-server"; + + private static final ParameterizedTypeReference> STRING_OBJECT_MAP = new ParameterizedTypeReference>() { + }; + static Mono> addJWSAlgorithms( ReactiveRemoteJWKSource jwkSource, ConfigurableJWTProcessor jwtProcessor) { JWSKeySelector selector = jwtProcessor.getJWSKeySelector(); @@ -75,6 +90,56 @@ final class ReactiveJwtDecoderProviderConfigurationUtils { }).onErrorMap(KeySourceException.class, (ex) -> new IllegalStateException(ex)); } + static Mono> getConfigurationForIssuerLocation(String issuer, WebClient web) { + URI uri = URI.create(issuer); + return getConfiguration(issuer, web, oidc(uri), oidcRfc8414(uri), oauth(uri)); + } + + private static URI oidc(URI issuer) { + // @formatter:off + return UriComponentsBuilder.fromUri(issuer) + .replacePath(issuer.getPath() + OIDC_METADATA_PATH) + .build(Collections.emptyMap()); + // @formatter:on + } + + private static URI oidcRfc8414(URI issuer) { + // @formatter:off + return UriComponentsBuilder.fromUri(issuer) + .replacePath(OIDC_METADATA_PATH + issuer.getPath()) + .build(Collections.emptyMap()); + // @formatter:on + } + + private static URI oauth(URI issuer) { + // @formatter:off + return UriComponentsBuilder.fromUri(issuer) + .replacePath(OAUTH_METADATA_PATH + issuer.getPath()) + .build(Collections.emptyMap()); + // @formatter:on + } + + private static Mono> getConfiguration(String issuer, WebClient web, URI... uris) { + String errorMessage = "Unable to resolve the Configuration with the provided Issuer of " + "\"" + issuer + "\""; + return Flux.just(uris).concatMap((uri) -> web.get().uri(uri).retrieve().bodyToMono(STRING_OBJECT_MAP)) + .flatMap((configuration) -> { + if (configuration.get("jwks_uri") == null) { + return Mono + .error(() -> new IllegalArgumentException("The public JWK set URI must not be null")); + } + return Mono.just(configuration); + }) + .onErrorContinue( + (ex) -> ex instanceof WebClientResponseException + && ((WebClientResponseException) ex).getStatusCode().is4xxClientError(), + (ex, object) -> { + }) + .onErrorMap(RuntimeException.class, + (ex) -> (ex instanceof IllegalArgumentException) ? ex + : new IllegalArgumentException(errorMessage, ex)) + .next().switchIfEmpty(Mono.error(() -> new IllegalArgumentException(errorMessage))); + } + private ReactiveJwtDecoderProviderConfigurationUtils() { } diff --git a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/ReactiveRemoteJWKSource.java b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/ReactiveRemoteJWKSource.java index 498fa81a57..153414e1b4 100644 --- a/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/ReactiveRemoteJWKSource.java +++ b/oauth2/oauth2-jose/src/main/java/org/springframework/security/oauth2/jwt/ReactiveRemoteJWKSource.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2019 the original author or authors. + * Copyright 2002-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -45,11 +45,16 @@ class ReactiveRemoteJWKSource implements ReactiveJWKSource { private WebClient webClient = WebClient.create(); - private final String jwkSetURL; + private final Mono jwkSetURL; ReactiveRemoteJWKSource(String jwkSetURL) { Assert.hasText(jwkSetURL, "jwkSetURL cannot be empty"); - this.jwkSetURL = jwkSetURL; + this.jwkSetURL = Mono.just(jwkSetURL); + } + + ReactiveRemoteJWKSource(Mono jwkSetURL) { + Assert.notNull(jwkSetURL, "jwkSetURL cannot be null"); + this.jwkSetURL = jwkSetURL.cache(); } @Override @@ -95,10 +100,10 @@ class ReactiveRemoteJWKSource implements ReactiveJWKSource { */ private Mono getJWKSet() { // @formatter:off - return this.webClient.get() - .uri(this.jwkSetURL) + return this.jwkSetURL.flatMap((jwkSetURL) -> this.webClient.get() + .uri(jwkSetURL) .retrieve() - .bodyToMono(String.class) + .bodyToMono(String.class)) .map(this::parse) .doOnNext((jwkSet) -> this.cachedJWKSet .set(Mono.just(jwkSet)) diff --git a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoderTests.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoderTests.java index 0dec2b97a3..00e9efa832 100644 --- a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoderTests.java +++ b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusJwtDecoderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2023 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -61,6 +61,7 @@ import org.mockito.ArgumentCaptor; import org.springframework.cache.Cache; import org.springframework.cache.concurrent.ConcurrentMapCache; import org.springframework.cache.support.SimpleValueWrapper; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.convert.converter.Converter; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -97,7 +98,7 @@ import static org.mockito.Mockito.verifyNoMoreInteractions; */ public class NimbusJwtDecoderTests { - private static final String JWK_SET = "{\"keys\":[{\"p\":\"49neceJFs8R6n7WamRGy45F5Tv0YM-R2ODK3eSBUSLOSH2tAqjEVKOkLE5fiNA3ygqq15NcKRadB2pTVf-Yb5ZIBuKzko8bzYIkIqYhSh_FAdEEr0vHF5fq_yWSvc6swsOJGqvBEtuqtJY027u-G2gAQasCQdhyejer68zsTn8M\",\"kty\":\"RSA\",\"q\":\"tWR-ysspjZ73B6p2vVRVyHwP3KQWL5KEQcdgcmMOE_P_cPs98vZJfLhxobXVmvzuEWBpRSiqiuyKlQnpstKt94Cy77iO8m8ISfF3C9VyLWXi9HUGAJb99irWABFl3sNDff5K2ODQ8CmuXLYM25OwN3ikbrhEJozlXg_NJFSGD4E\",\"d\":\"FkZHYZlw5KSoqQ1i2RA2kCUygSUOf1OqMt3uomtXuUmqKBm_bY7PCOhmwbvbn4xZYEeHuTR8Xix-0KpHe3NKyWrtRjkq1T_un49_1LLVUhJ0dL-9_x0xRquVjhl_XrsRXaGMEHs8G9pLTvXQ1uST585gxIfmCe0sxPZLvwoic-bXf64UZ9BGRV3lFexWJQqCZp2S21HfoU7wiz6kfLRNi-K4xiVNB1gswm_8o5lRuY7zB9bRARQ3TS2G4eW7p5sxT3CgsGiQD3_wPugU8iDplqAjgJ5ofNJXZezoj0t6JMB_qOpbrmAM1EnomIPebSLW7Ky9SugEd6KMdL5lW6AuAQ\",\"e\":\"AQAB\",\"use\":\"sig\",\"kid\":\"one\",\"qi\":\"wdkFu_tV2V1l_PWUUimG516Zvhqk2SWDw1F7uNDD-Lvrv_WNRIJVzuffZ8WYiPy8VvYQPJUrT2EXL8P0ocqwlaSTuXctrORcbjwgxDQDLsiZE0C23HYzgi0cofbScsJdhcBg7d07LAf7cdJWG0YVl1FkMCsxUlZ2wTwHfKWf-v4\",\"dp\":\"uwnPxqC-IxG4r33-SIT02kZC1IqC4aY7PWq0nePiDEQMQWpjjNH50rlq9EyLzbtdRdIouo-jyQXB01K15-XXJJ60dwrGLYNVqfsTd0eGqD1scYJGHUWG9IDgCsxyEnuG3s0AwbW2UolWVSsU2xMZGb9PurIUZECeD1XDZwMp2s0\",\"dq\":\"hra786AunB8TF35h8PpROzPoE9VJJMuLrc6Esm8eZXMwopf0yhxfN2FEAvUoTpLJu93-UH6DKenCgi16gnQ0_zt1qNNIVoRfg4rw_rjmsxCYHTVL3-RDeC8X_7TsEySxW0EgFTHh-nr6I6CQrAJjPM88T35KHtdFATZ7BCBB8AE\",\"n\":\"oXJ8OyOv_eRnce4akdanR4KYRfnC2zLV4uYNQpcFn6oHL0dj7D6kxQmsXoYgJV8ZVDn71KGmuLvolxsDncc2UrhyMBY6DVQVgMSVYaPCTgW76iYEKGgzTEw5IBRQL9w3SRJWd3VJTZZQjkXef48Ocz06PGF3lhbz4t5UEZtdF4rIe7u-977QwHuh7yRPBQ3sII-cVoOUMgaXB9SHcGF2iZCtPzL_IffDUcfhLQteGebhW8A6eUHgpD5A1PQ-JCw_G7UOzZAjjDjtNM2eqm8j-Ms_gqnm4MiCZ4E-9pDN77CAAPVN7kuX6ejs9KBXpk01z48i9fORYk9u7rAkh1HuQw\"}]}"; + private static final String JWK_SET = "{\"keys\":[{\"kty\":\"RSA\",\"e\":\"AQAB\",\"use\":\"sig\",\"kid\":\"one\",\"n\":\"oXJ8OyOv_eRnce4akdanR4KYRfnC2zLV4uYNQpcFn6oHL0dj7D6kxQmsXoYgJV8ZVDn71KGmuLvolxsDncc2UrhyMBY6DVQVgMSVYaPCTgW76iYEKGgzTEw5IBRQL9w3SRJWd3VJTZZQjkXef48Ocz06PGF3lhbz4t5UEZtdF4rIe7u-977QwHuh7yRPBQ3sII-cVoOUMgaXB9SHcGF2iZCtPzL_IffDUcfhLQteGebhW8A6eUHgpD5A1PQ-JCw_G7UOzZAjjDjtNM2eqm8j-Ms_gqnm4MiCZ4E-9pDN77CAAPVN7kuX6ejs9KBXpk01z48i9fORYk9u7rAkh1HuQw\"}]}"; private static final String NEW_KID_JWK_SET = "{\"keys\":[{\"kty\":\"RSA\",\"e\":\"AQAB\",\"kid\":\"two\",\"n\":\"ra9UJw4I0fCHuOqr1xWJsh-qcVeZWtKEU3uoqq1sAg5fG67dujNCm_Q16yuO0ZdDiU0vlJkbc_MXFAvm4ZxdJ_qR7PAneV-BOGNtLpSaiPclscCy3m7zjRWkaqwt9ZZEsdK5UqXyPlBpcYhNKsmnQGjnX4sYb7d8b2jSCM_qto48-6451rbyEhXXywtFy_JqtTpbsw_IIdQHMr1O-MdSjsQxX9kkvZwPU8LsC-CcqlcsZ7mnpOhmIXaf4tbRwAaluXwYft0yykFsp8e5C4t9mMs9Vu8AB5gT8o-D_ovXd2qh4k3ejzVpYLtzD4nbfvPJA_TXmjhn-9GOPAqkzfON2Q\"}]}"; @@ -312,6 +313,19 @@ public class NimbusJwtDecoderTests { } } + @Test + public void decodeWhenIssuerLocationThenOk() { + String issuer = "https://example.org/issuer"; + RestOperations restOperations = mock(RestOperations.class); + given(restOperations.exchange(any(RequestEntity.class), any(ParameterizedTypeReference.class))).willReturn( + new ResponseEntity<>(Map.of("issuer", issuer, "jwks_uri", issuer + "/jwks"), HttpStatus.OK)); + given(restOperations.exchange(any(RequestEntity.class), eq(String.class))) + .willReturn(new ResponseEntity<>(JWK_SET, HttpStatus.OK)); + JwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer).restOperations(restOperations).build(); + Jwt jwt = jwtDecoder.decode(SIGNED_JWT); + assertThat(jwt.hasClaim(JwtClaimNames.EXP)).isNotNull(); + } + @Test public void withJwkSetUriWhenNullOrEmptyThenThrowsException() { // @formatter:off diff --git a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusReactiveJwtDecoderTests.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusReactiveJwtDecoderTests.java index 58f096ec3b..c65ae6ddd9 100644 --- a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusReactiveJwtDecoderTests.java +++ b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/NimbusReactiveJwtDecoderTests.java @@ -41,7 +41,6 @@ import com.nimbusds.jose.crypto.MACSigner; import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.RSAKey; import com.nimbusds.jose.jwk.source.JWKSecurityContextJWKSet; -import com.nimbusds.jose.jwk.source.JWKSource; import com.nimbusds.jose.proc.DefaultJOSEObjectTypeVerifier; import com.nimbusds.jose.proc.JWKSecurityContext; import com.nimbusds.jose.proc.JWSKeySelector; @@ -58,6 +57,7 @@ import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.convert.converter.Converter; import org.springframework.security.oauth2.core.OAuth2Error; import org.springframework.security.oauth2.core.OAuth2TokenValidator; @@ -597,11 +597,29 @@ public class NimbusReactiveJwtDecoderTests { // @formatter:on } + @Test + public void decodeWhenIssuerLocationThenOk() { + String issuer = "https://example.org/issuer"; + WebClient real = WebClient.builder().build(); + WebClient.RequestHeadersUriSpec spec = spy(real.get()); + WebClient webClient = spy(WebClient.class); + given(webClient.get()).willReturn(spec); + WebClient.ResponseSpec responseSpec = mock(WebClient.ResponseSpec.class); + given(responseSpec.bodyToMono(String.class)).willReturn(Mono.just(this.jwkSet)); + given(responseSpec.bodyToMono(any(ParameterizedTypeReference.class))) + .willReturn(Mono.just(Map.of("issuer", issuer, "jwks_uri", issuer + "/jwks"))); + given(spec.retrieve()).willReturn(responseSpec); + ReactiveJwtDecoder jwtDecoder = NimbusReactiveJwtDecoder.withIssuerLocation(issuer).webClient(webClient) + .build(); + Jwt jwt = jwtDecoder.decode(this.messageReadToken).block(); + assertThat(jwt.hasClaim(JwtClaimNames.EXP)).isNotNull(); + } + @Test public void jwsKeySelectorWhenNoAlgorithmThenReturnsRS256Selector() { - JWKSource jwkSource = mock(JWKSource.class); + ReactiveRemoteJWKSource jwkSource = mock(ReactiveRemoteJWKSource.class); JWSKeySelector jwsKeySelector = NimbusReactiveJwtDecoder.withJwkSetUri(this.jwkSetUri) - .jwsKeySelector(jwkSource); + .jwsKeySelector(jwkSource).block(); assertThat(jwsKeySelector instanceof JWSVerificationKeySelector); JWSVerificationKeySelector jwsVerificationKeySelector = (JWSVerificationKeySelector) jwsKeySelector; assertThat(jwsVerificationKeySelector.isAllowed(JWSAlgorithm.RS256)).isTrue(); @@ -609,9 +627,9 @@ public class NimbusReactiveJwtDecoderTests { @Test public void jwsKeySelectorWhenOneAlgorithmThenReturnsSingleSelector() { - JWKSource jwkSource = mock(JWKSource.class); + ReactiveRemoteJWKSource jwkSource = mock(ReactiveRemoteJWKSource.class); JWSKeySelector jwsKeySelector = NimbusReactiveJwtDecoder.withJwkSetUri(this.jwkSetUri) - .jwsAlgorithm(SignatureAlgorithm.RS512).jwsKeySelector(jwkSource); + .jwsAlgorithm(SignatureAlgorithm.RS512).jwsKeySelector(jwkSource).block(); assertThat(jwsKeySelector instanceof JWSVerificationKeySelector); JWSVerificationKeySelector jwsVerificationKeySelector = (JWSVerificationKeySelector) jwsKeySelector; assertThat(jwsVerificationKeySelector.isAllowed(JWSAlgorithm.RS512)).isTrue(); @@ -619,12 +637,12 @@ public class NimbusReactiveJwtDecoderTests { @Test public void jwsKeySelectorWhenMultipleAlgorithmThenReturnsCompositeSelector() { - JWKSource jwkSource = mock(JWKSource.class); + ReactiveRemoteJWKSource jwkSource = mock(ReactiveRemoteJWKSource.class); // @formatter:off JWSKeySelector jwsKeySelector = NimbusReactiveJwtDecoder.withJwkSetUri(this.jwkSetUri) .jwsAlgorithm(SignatureAlgorithm.RS256) .jwsAlgorithm(SignatureAlgorithm.RS512) - .jwsKeySelector(jwkSource); + .jwsKeySelector(jwkSource).block(); // @formatter:on assertThat(jwsKeySelector instanceof JWSVerificationKeySelector); JWSVerificationKeySelector jwsAlgorithmMapKeySelector = (JWSVerificationKeySelector) jwsKeySelector; diff --git a/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/ReactiveJwtDecoderProviderConfigurationUtilsTests.java b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/ReactiveJwtDecoderProviderConfigurationUtilsTests.java new file mode 100644 index 0000000000..4fc5ff46f0 --- /dev/null +++ b/oauth2/oauth2-jose/src/test/java/org/springframework/security/oauth2/jwt/ReactiveJwtDecoderProviderConfigurationUtilsTests.java @@ -0,0 +1,319 @@ +/* + * Copyright 2002-2023 the original author or 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 org.springframework.security.oauth2.jwt; + +import java.net.URI; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import okhttp3.HttpUrl; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.util.UriComponentsBuilder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Tests for {@link ReactiveJwtDecoderProviderConfigurationUtils} + * + * @author Josh Cummings + */ +public class ReactiveJwtDecoderProviderConfigurationUtilsTests { + + /** + * Contains those parameters required to construct a ReactiveJwtDecoder as well as any + * required parameters + */ + // @formatter:off + private static final String DEFAULT_RESPONSE_TEMPLATE = "{\n" + + " \"authorization_endpoint\": \"https://example.com/o/oauth2/v2/auth\", \n" + + " \"id_token_signing_alg_values_supported\": [\n" + + " \"RS256\"\n" + + " ], \n" + + " \"issuer\": \"%s\", \n" + + " \"jwks_uri\": \"%s/.well-known/jwks.json\", \n" + + " \"response_types_supported\": [\n" + + " \"code\", \n" + + " \"token\", \n" + + " \"id_token\", \n" + + " \"code token\", \n" + + " \"code id_token\", \n" + + " \"token id_token\", \n" + + " \"code token id_token\", \n" + + " \"none\"\n" + + " ], \n" + + " \"subject_types_supported\": [\n" + + " \"public\"\n" + + " ], \n" + + " \"token_endpoint\": \"https://example.com/oauth2/v4/token\"\n" + + "}"; + // @formatter:on + + private static final String JWK_SET = "{\"keys\":[{\"kty\":\"RSA\",\"e\":\"AQAB\",\"use\":\"sig\",\"kid\":\"one\",\"n\":\"oXJ8OyOv_eRnce4akdanR4KYRfnC2zLV4uYNQpcFn6oHL0dj7D6kxQmsXoYgJV8ZVDn71KGmuLvolxsDncc2UrhyMBY6DVQVgMSVYaPCTgW76iYEKGgzTEw5IBRQL9w3SRJWd3VJTZZQjkXef48Ocz06PGF3lhbz4t5UEZtdF4rIe7u-977QwHuh7yRPBQ3sII-cVoOUMgaXB9SHcGF2iZCtPzL_IffDUcfhLQteGebhW8A6eUHgpD5A1PQ-JCw_G7UOzZAjjDjtNM2eqm8j-Ms_gqnm4MiCZ4E-9pDN77CAAPVN7kuX6ejs9KBXpk01z48i9fORYk9u7rAkh1HuQw\"}]}"; + + private static final String OIDC_METADATA_PATH = "/.well-known/openid-configuration"; + + private static final String OAUTH_METADATA_PATH = "/.well-known/oauth-authorization-server"; + + private final WebClient web = WebClient.builder().build(); + + private MockWebServer server; + + private String issuer; + + @BeforeEach + public void setup() throws Exception { + this.server = new MockWebServer(); + this.server.start(); + this.issuer = createIssuerFromServer(); + this.issuer += "path"; + } + + @AfterEach + public void cleanup() throws Exception { + this.server.shutdown(); + } + + @Test + public void issuerWhenResponseIsTypicalThenReturnedConfigurationContainsJwksUri() { + prepareConfigurationResponse(); + Map configuration = ReactiveJwtDecoderProviderConfigurationUtils + .getConfigurationForIssuerLocation(this.issuer, this.web).block(); + assertThat(configuration).containsKey("jwks_uri"); + } + + @Test + public void issuerWhenOidcFallbackResponseIsTypicalThenReturnedConfigurationContainsJwksUri() { + prepareConfigurationResponseOidc(); + Map configuration = ReactiveJwtDecoderProviderConfigurationUtils + .getConfigurationForIssuerLocation(this.issuer, this.web).block(); + assertThat(configuration).containsKey("jwks_uri"); + } + + @Test + public void issuerWhenOAuth2ResponseIsTypicalThenReturnedConfigurationContainsJwksUri() { + prepareConfigurationResponseOAuth2(); + Map configuration = ReactiveJwtDecoderProviderConfigurationUtils + .getConfigurationForIssuerLocation(this.issuer, this.web).block(); + assertThat(configuration).containsKey("jwks_uri"); + } + + @Test + public void issuerWhenOidcFallbackResponseIsNonCompliantThenThrowsRuntimeException() { + prepareConfigurationResponseOidc("{ \"missing_required_keys\" : \"and_values\" }"); + // @formatter:off + assertThatExceptionOfType(RuntimeException.class) + .isThrownBy(() -> ReactiveJwtDecoderProviderConfigurationUtils.getConfigurationForIssuerLocation(this.issuer, this.web).block()); + // @formatter:on + } + + @Test + public void issuerWhenOAuth2ResponseIsNonCompliantThenThrowsRuntimeException() { + prepareConfigurationResponseOAuth2("{ \"missing_required_keys\" : \"and_values\" }"); + // @formatter:off + assertThatExceptionOfType(RuntimeException.class) + .isThrownBy(() -> ReactiveJwtDecoderProviderConfigurationUtils.getConfigurationForIssuerLocation(this.issuer, this.web).block()); + // @formatter:on + } + + // gh-7512 + @Test + public void issuerWhenOidcFallbackResponseDoesNotContainJwksUriThenThrowsIllegalArgumentException() + throws JsonMappingException, JsonProcessingException { + prepareConfigurationResponseOidc(this.buildResponseWithMissingJwksUri()); + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> ReactiveJwtDecoderProviderConfigurationUtils.getConfigurationForIssuerLocation(this.issuer, this.web).block()) + .withMessage("The public JWK set URI must not be null"); + // @formatter:on + } + + // gh-7512 + @Test + public void issuerWhenOAuth2ResponseDoesNotContainJwksUriThenThrowsIllegalArgumentException() + throws JsonMappingException, JsonProcessingException { + prepareConfigurationResponseOAuth2(this.buildResponseWithMissingJwksUri()); + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> ReactiveJwtDecoderProviderConfigurationUtils.getConfigurationForIssuerLocation(this.issuer, this.web).block()) + .withMessage("The public JWK set URI must not be null"); + // @formatter:on + } + + @Test + public void issuerWhenOidcFallbackResponseIsMalformedThenThrowsRuntimeException() { + prepareConfigurationResponseOidc("malformed"); + // @formatter:off + assertThatExceptionOfType(RuntimeException.class) + .isThrownBy(() -> ReactiveJwtDecoderProviderConfigurationUtils.getConfigurationForIssuerLocation(this.issuer, this.web).block()); + // @formatter:on + } + + @Test + public void issuerWhenOAuth2ResponseIsMalformedThenThrowsRuntimeException() { + prepareConfigurationResponseOAuth2("malformed"); + // @formatter:off + assertThatExceptionOfType(RuntimeException.class) + .isThrownBy(() -> ReactiveJwtDecoderProviderConfigurationUtils.getConfigurationForIssuerLocation(this.issuer, this.web).block()); + // @formatter:on + } + + @Test + public void issuerWhenOidcFallbackRespondingIssuerMismatchesRequestedIssuerThenThrowsIllegalStateException() { + prepareConfigurationResponseOidc(String.format(DEFAULT_RESPONSE_TEMPLATE, this.issuer + "/wrong", this.issuer)); + // @formatter:off + assertThatIllegalStateException() + .isThrownBy(() -> { + Map configuration = ReactiveJwtDecoderProviderConfigurationUtils.getConfigurationForIssuerLocation(this.issuer, this.web).block(); + JwtDecoderProviderConfigurationUtils.validateIssuer(configuration, this.issuer); + }); + // @formatter:on + } + + @Test + public void issuerWhenOAuth2RespondingIssuerMismatchesRequestedIssuerThenThrowsIllegalStateException() { + prepareConfigurationResponseOAuth2( + String.format(DEFAULT_RESPONSE_TEMPLATE, this.issuer + "/wrong", this.issuer)); + // @formatter:off + assertThatIllegalStateException() + .isThrownBy(() -> { + Map configuration = ReactiveJwtDecoderProviderConfigurationUtils.getConfigurationForIssuerLocation(this.issuer, this.web).block(); + JwtDecoderProviderConfigurationUtils.validateIssuer(configuration, this.issuer); + }); + // @formatter:on + } + + @Test + public void issuerWhenOidcFallbackRequestedIssuerIsUnresponsiveThenThrowsIllegalArgumentException() + throws Exception { + this.server.shutdown(); + // @formatter:off + assertThatIllegalArgumentException() + .isThrownBy(() -> ReactiveJwtDecoderProviderConfigurationUtils.getConfigurationForIssuerLocation("https://issuer", this.web).block()); + // @formatter:on + } + + private void prepareConfigurationResponse() { + String body = String.format(DEFAULT_RESPONSE_TEMPLATE, this.issuer, this.issuer); + prepareConfigurationResponse(body); + } + + private void prepareConfigurationResponse(String body) { + this.server.enqueue(response(body)); + this.server.enqueue(response(JWK_SET)); + } + + private void prepareConfigurationResponseOidc() { + String body = String.format(DEFAULT_RESPONSE_TEMPLATE, this.issuer, this.issuer); + prepareConfigurationResponseOidc(body); + } + + private void prepareConfigurationResponseOidc(String body) { + Map responses = new HashMap<>(); + responses.put(oidc(), response(body)); + responses.put(jwks(), response(JWK_SET)); + prepareConfigurationResponses(responses); + } + + private void prepareConfigurationResponseOAuth2() { + String body = String.format(DEFAULT_RESPONSE_TEMPLATE, this.issuer, this.issuer); + prepareConfigurationResponseOAuth2(body); + } + + private void prepareConfigurationResponseOAuth2(String body) { + Map responses = new HashMap<>(); + responses.put(oauth(), response(body)); + responses.put(jwks(), response(JWK_SET)); + prepareConfigurationResponses(responses); + } + + private void prepareConfigurationResponses(Map responses) { + Dispatcher dispatcher = new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + // @formatter:off + return Optional.of(request) + .map(RecordedRequest::getRequestUrl) + .map(HttpUrl::toString) + .map(responses::get) + .orElse(new MockResponse().setResponseCode(404)); + // @formatter:on + } + }; + this.server.setDispatcher(dispatcher); + } + + private String createIssuerFromServer() { + return this.server.url("").toString(); + } + + private String oidc() { + URI uri = URI.create(this.issuer); + // @formatter:off + return UriComponentsBuilder.fromUri(uri) + .replacePath(uri.getPath() + OIDC_METADATA_PATH) + .toUriString(); + // @formatter:on + } + + private String oauth() { + URI uri = URI.create(this.issuer); + // @formatter:off + return UriComponentsBuilder.fromUri(uri) + .replacePath(OAUTH_METADATA_PATH + uri.getPath()) + .toUriString(); + // @formatter:on + } + + private String jwks() { + return this.issuer + "/.well-known/jwks.json"; + } + + private MockResponse response(String body) { + // @formatter:off + return new MockResponse().setBody(body) + .setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); + // @formatter:on + } + + public String buildResponseWithMissingJwksUri() throws JsonMappingException, JsonProcessingException { + ObjectMapper mapper = new ObjectMapper(); + Map response = mapper.readValue(DEFAULT_RESPONSE_TEMPLATE, + new TypeReference>() { + }); + response.remove("jwks_uri"); + return mapper.writeValueAsString(response); + } + +}