parent
9ee8202625
commit
76eba9bd0c
|
@ -327,6 +327,7 @@ This is handy when you need deeper configuration, such as <<webflux-oauth2resour
|
|||
==== Exposing a `ReactiveJwtDecoder` `@Bean`
|
||||
|
||||
Alternately, exposing a `ReactiveJwtDecoder` `@Bean` has the same effect as `decoder()`:
|
||||
You can construct one with a `jwkSetUri` like so:
|
||||
|
||||
====
|
||||
.Java
|
||||
|
@ -343,7 +344,51 @@ public ReactiveJwtDecoder jwtDecoder() {
|
|||
----
|
||||
@Bean
|
||||
fun jwtDecoder(): ReactiveJwtDecoder {
|
||||
return ReactiveJwtDecoders.fromIssuerLocation(issuerUri)
|
||||
return NimbusReactiveJwtDecoder.withJwkSetUri(jwkSetUri).build()
|
||||
}
|
||||
----
|
||||
====
|
||||
|
||||
or you can use the issuer and have `NimbusReactiveJwtDecoder` look up the `jwkSetUri` when `build()` is invoked, like the following:
|
||||
|
||||
====
|
||||
.Java
|
||||
[source,java,role="primary"]
|
||||
----
|
||||
@Bean
|
||||
public ReactiveJwtDecoder jwtDecoder() {
|
||||
return NimbusReactiveJwtDecoder.withIssuerLocation(issuer).build();
|
||||
}
|
||||
----
|
||||
|
||||
.Kotlin
|
||||
[source,kotlin,role="secondary"]
|
||||
----
|
||||
@Bean
|
||||
fun jwtDecoder(): ReactiveJwtDecoder {
|
||||
return NimbusReactiveJwtDecoder.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 ReactiveJwtDecoder jwtDecoder() {
|
||||
return ReactiveJwtDecoders.fromIssuerLocation(issuer);
|
||||
}
|
||||
----
|
||||
|
||||
.Kotlin
|
||||
[source,kotlin,role="secondary"]
|
||||
----
|
||||
@Bean
|
||||
fun jwtDecoder(): ReactiveJwtDecoder {
|
||||
return ReactiveJwtDecoders.fromIssuerLocation(issuer)
|
||||
}
|
||||
----
|
||||
====
|
||||
|
@ -384,7 +429,7 @@ For greater power, though, we can use a builder that ships with `NimbusReactiveJ
|
|||
----
|
||||
@Bean
|
||||
ReactiveJwtDecoder jwtDecoder() {
|
||||
return NimbusReactiveJwtDecoder.withJwkSetUri(this.jwkSetUri)
|
||||
return NimbusReactiveJwtDecoder.withIssuerLocation(this.issuer)
|
||||
.jwsAlgorithm(RS512).build();
|
||||
}
|
||||
----
|
||||
|
@ -394,7 +439,7 @@ ReactiveJwtDecoder jwtDecoder() {
|
|||
----
|
||||
@Bean
|
||||
fun jwtDecoder(): ReactiveJwtDecoder {
|
||||
return NimbusReactiveJwtDecoder.withJwkSetUri(this.jwkSetUri)
|
||||
return NimbusReactiveJwtDecoder.withIssuerLocation(this.issuer)
|
||||
.jwsAlgorithm(RS512).build()
|
||||
}
|
||||
----
|
||||
|
@ -408,7 +453,7 @@ Calling `jwsAlgorithm` more than once configures `NimbusReactiveJwtDecoder` to t
|
|||
----
|
||||
@Bean
|
||||
ReactiveJwtDecoder jwtDecoder() {
|
||||
return NimbusReactiveJwtDecoder.withJwkSetUri(this.jwkSetUri)
|
||||
return NimbusReactiveJwtDecoder.withIssuerLocation(this.issuer)
|
||||
.jwsAlgorithm(RS512).jwsAlgorithm(ES512).build();
|
||||
}
|
||||
----
|
||||
|
@ -418,7 +463,7 @@ ReactiveJwtDecoder jwtDecoder() {
|
|||
----
|
||||
@Bean
|
||||
fun jwtDecoder(): ReactiveJwtDecoder {
|
||||
return NimbusReactiveJwtDecoder.withJwkSetUri(this.jwkSetUri)
|
||||
return NimbusReactiveJwtDecoder.withIssuerLocation(this.issuer)
|
||||
.jwsAlgorithm(RS512).jwsAlgorithm(ES512).build()
|
||||
}
|
||||
----
|
||||
|
@ -432,7 +477,7 @@ Alternately, you can call `jwsAlgorithms`:
|
|||
----
|
||||
@Bean
|
||||
ReactiveJwtDecoder jwtDecoder() {
|
||||
return NimbusReactiveJwtDecoder.withJwkSetUri(this.jwkSetUri)
|
||||
return NimbusReactiveJwtDecoder.withIssuerLocation(this.jwkSetUri)
|
||||
.jwsAlgorithms(algorithms -> {
|
||||
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)
|
||||
|
|
|
@ -430,7 +430,8 @@ This is handy when deeper configuration, like <<oauth2resourceserver-jwt-validat
|
|||
[[oauth2resourceserver-jwt-decoder-bean]]
|
||||
=== Exposing a `JwtDecoder` `@Bean`
|
||||
|
||||
Or, exposing a <<oauth2resourceserver-jwt-architecture-jwtdecoder,`JwtDecoder`>> `@Bean` has the same effect as `decoder()`:
|
||||
Or, exposing a <<oauth2resourceserver-jwt-architecture-jwtdecoder,`JwtDecoder`>> `@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()
|
||||
}
|
||||
|
|
|
@ -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<String, Object> getConfigurationForOidcIssuerLocation(String oidcIssuerLocation) {
|
||||
return getConfiguration(oidcIssuerLocation, oidc(URI.create(oidcIssuerLocation)));
|
||||
return getConfiguration(oidcIssuerLocation, rest, oidc(URI.create(oidcIssuerLocation)));
|
||||
}
|
||||
|
||||
static Map<String, Object> getConfigurationForIssuerLocation(String issuer, RestOperations rest) {
|
||||
URI uri = URI.create(issuer);
|
||||
return getConfiguration(issuer, rest, oidc(uri), oidcRfc8414(uri), oauth(uri));
|
||||
}
|
||||
|
||||
static Map<String, Object> 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<String, Object> configuration, String issuer) {
|
||||
|
@ -142,7 +147,7 @@ final class JwtDecoderProviderConfigurationUtils {
|
|||
return "(unavailable)";
|
||||
}
|
||||
|
||||
private static Map<String, Object> getConfiguration(String issuer, URI... uris) {
|
||||
private static Map<String, Object> 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 {
|
||||
|
|
|
@ -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 extends JwtDecoder> T fromIssuerLocation(String issuer) {
|
||||
Assert.hasText(issuer, "issuer cannot be empty");
|
||||
Map<String, Object> configuration = JwtDecoderProviderConfigurationUtils
|
||||
.getConfigurationForIssuerLocation(issuer);
|
||||
return (T) withProviderConfiguration(configuration, issuer);
|
||||
NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withIssuerLocation(issuer).build();
|
||||
OAuth2TokenValidator<Jwt> jwtValidator = JwtValidators.createDefaultWithIssuer(issuer);
|
||||
jwtDecoder.setJwtValidator(jwtValidator);
|
||||
return (T) jwtDecoder;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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 <a href=
|
||||
* "https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a>
|
||||
* by making an <a href=
|
||||
* "https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest">OpenID
|
||||
* Provider Configuration Request</a> and using the values in the <a href=
|
||||
* "https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse">OpenID
|
||||
* Provider Configuration Response</a> to derive the needed
|
||||
* <a href="https://tools.ietf.org/html/rfc7517#section-5">JWK Set</a> uri.
|
||||
* @param issuer the <a href=
|
||||
* "https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a>
|
||||
* @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<String, Object> configuration = JwtDecoderProviderConfigurationUtils
|
||||
.getConfigurationForIssuerLocation(issuer, rest);
|
||||
JwtDecoderProviderConfigurationUtils.validateIssuer(configuration, issuer);
|
||||
return configuration.get("jwks_uri").toString();
|
||||
}, JwtDecoderProviderConfigurationUtils::getJWSAlgorithms);
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the given <a href="https://tools.ietf.org/html/rfc7517#section-5">JWK Set</a>
|
||||
* uri.
|
||||
|
@ -235,7 +261,10 @@ public final class NimbusJwtDecoder implements JwtDecoder {
|
|||
*/
|
||||
public static final class JwkSetUriJwtDecoderBuilder {
|
||||
|
||||
private String jwkSetUri;
|
||||
private Function<RestOperations, String> jwkSetUri;
|
||||
|
||||
private Function<JWKSource<SecurityContext>, Set<JWSAlgorithm>> defaultAlgorithms = (source) -> Set
|
||||
.of(JWSAlgorithm.RS256);
|
||||
|
||||
private Set<SignatureAlgorithm> 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<RestOperations, String> jwkSetUri,
|
||||
Function<JWKSource<SecurityContext>, Set<JWSAlgorithm>> 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<SecurityContext> jwsKeySelector(JWKSource<SecurityContext> jwkSource) {
|
||||
if (this.signatureAlgorithms.isEmpty()) {
|
||||
return new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, jwkSource);
|
||||
return new JWSVerificationKeySelector<>(this.defaultAlgorithms.apply(jwkSource), jwkSource);
|
||||
}
|
||||
Set<JWSAlgorithm> jwsAlgorithms = new HashSet<>();
|
||||
for (SignatureAlgorithm signatureAlgorithm : this.signatureAlgorithms) {
|
||||
|
@ -334,17 +373,18 @@ public final class NimbusJwtDecoder implements JwtDecoder {
|
|||
return new JWSVerificationKeySelector<>(jwsAlgorithms, jwkSource);
|
||||
}
|
||||
|
||||
JWKSource<SecurityContext> jwkSource(ResourceRetriever jwkSetRetriever) {
|
||||
JWKSource<SecurityContext> 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<SecurityContext> processor() {
|
||||
ResourceRetriever jwkSetRetriever = new RestOperationsResourceRetriever(this.restOperations);
|
||||
JWKSource<SecurityContext> jwkSource = jwkSource(jwkSetRetriever);
|
||||
String jwkSetUri = this.jwkSetUri.apply(this.restOperations);
|
||||
JWKSource<SecurityContext> jwkSource = jwkSource(jwkSetRetriever, jwkSetUri);
|
||||
ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>();
|
||||
jwtProcessor.setJWSKeySelector(jwsKeySelector(jwkSource));
|
||||
// Spring Security validates the claim set independent from Nimbus
|
||||
|
|
|
@ -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 <a href=
|
||||
* "https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a>
|
||||
* by making an <a href=
|
||||
* "https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest">OpenID
|
||||
* Provider Configuration Request</a> and using the values in the <a href=
|
||||
* "https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse">OpenID
|
||||
* Provider Configuration Response</a> to derive the needed
|
||||
* <a href="https://tools.ietf.org/html/rfc7517#section-5">JWK Set</a> uri.
|
||||
* @param issuer the <a href=
|
||||
* "https://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a>
|
||||
* @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 <a href="https://tools.ietf.org/html/rfc7517#section-5">JWK Set</a>
|
||||
* 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<WebClient, Mono<String>> jwkSetUri;
|
||||
|
||||
private Function<ReactiveRemoteJWKSource, Mono<Set<JWSAlgorithm>>> defaultAlgorithms = (source) -> Mono
|
||||
.just(Set.of(JWSAlgorithm.RS256));
|
||||
|
||||
private Set<SignatureAlgorithm> 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<WebClient, Mono<String>> jwkSetUri,
|
||||
Function<ReactiveRemoteJWKSource, Mono<Set<JWSAlgorithm>>> 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<JWKSecurityContext> jwsKeySelector(JWKSource<JWKSecurityContext> jwkSource) {
|
||||
Mono<JWSKeySelector<JWKSecurityContext>> 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<JWSAlgorithm> 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<JWT, Mono<JWTClaimsSet>> processor() {
|
||||
JWKSecurityContextJWKSet jwkSource = new JWKSecurityContextJWKSet();
|
||||
DefaultJWTProcessor<JWKSecurityContext> jwtProcessor = new DefaultJWTProcessor<>();
|
||||
JWSKeySelector<JWKSecurityContext> 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<Tuple2<ConfigurableJWTProcessor<JWKSecurityContext>, Function<JWSAlgorithm, Boolean>>> jwtProcessorMono = this.jwtProcessorCustomizer
|
||||
.apply(source, jwtProcessor)
|
||||
.map((processor) -> Tuples.of(processor, getExpectedJwsAlgorithms(processor.getJWSKeySelector())))
|
||||
Mono<JWSKeySelector<JWKSecurityContext>> jwsKeySelector = jwsKeySelector(source);
|
||||
Mono<Tuple2<ConfigurableJWTProcessor<JWKSecurityContext>, Function<JWSAlgorithm, Boolean>>> 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) -> {
|
||||
|
|
|
@ -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<Map<String, Object>> STRING_OBJECT_MAP = new ParameterizedTypeReference<Map<String, Object>>() {
|
||||
};
|
||||
|
||||
static <C extends SecurityContext> Mono<ConfigurableJWTProcessor<C>> addJWSAlgorithms(
|
||||
ReactiveRemoteJWKSource jwkSource, ConfigurableJWTProcessor<C> jwtProcessor) {
|
||||
JWSKeySelector<C> selector = jwtProcessor.getJWSKeySelector();
|
||||
|
@ -75,6 +90,56 @@ final class ReactiveJwtDecoderProviderConfigurationUtils {
|
|||
}).onErrorMap(KeySourceException.class, (ex) -> new IllegalStateException(ex));
|
||||
}
|
||||
|
||||
static Mono<Map<String, Object>> 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<Map<String, Object>> 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() {
|
||||
}
|
||||
|
||||
|
|
|
@ -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<String> jwkSetURL;
|
||||
|
||||
ReactiveRemoteJWKSource(String jwkSetURL) {
|
||||
Assert.hasText(jwkSetURL, "jwkSetURL cannot be empty");
|
||||
this.jwkSetURL = jwkSetURL;
|
||||
this.jwkSetURL = Mono.just(jwkSetURL);
|
||||
}
|
||||
|
||||
ReactiveRemoteJWKSource(Mono<String> jwkSetURL) {
|
||||
Assert.notNull(jwkSetURL, "jwkSetURL cannot be null");
|
||||
this.jwkSetURL = jwkSetURL.cache();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -95,10 +100,10 @@ class ReactiveRemoteJWKSource implements ReactiveJWKSource {
|
|||
*/
|
||||
private Mono<JWKSet> 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))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<JWKSecurityContext> jwkSource = mock(JWKSource.class);
|
||||
ReactiveRemoteJWKSource jwkSource = mock(ReactiveRemoteJWKSource.class);
|
||||
JWSKeySelector<JWKSecurityContext> jwsKeySelector = NimbusReactiveJwtDecoder.withJwkSetUri(this.jwkSetUri)
|
||||
.jwsKeySelector(jwkSource);
|
||||
.jwsKeySelector(jwkSource).block();
|
||||
assertThat(jwsKeySelector instanceof JWSVerificationKeySelector);
|
||||
JWSVerificationKeySelector<JWKSecurityContext> jwsVerificationKeySelector = (JWSVerificationKeySelector<JWKSecurityContext>) jwsKeySelector;
|
||||
assertThat(jwsVerificationKeySelector.isAllowed(JWSAlgorithm.RS256)).isTrue();
|
||||
|
@ -609,9 +627,9 @@ public class NimbusReactiveJwtDecoderTests {
|
|||
|
||||
@Test
|
||||
public void jwsKeySelectorWhenOneAlgorithmThenReturnsSingleSelector() {
|
||||
JWKSource<JWKSecurityContext> jwkSource = mock(JWKSource.class);
|
||||
ReactiveRemoteJWKSource jwkSource = mock(ReactiveRemoteJWKSource.class);
|
||||
JWSKeySelector<JWKSecurityContext> jwsKeySelector = NimbusReactiveJwtDecoder.withJwkSetUri(this.jwkSetUri)
|
||||
.jwsAlgorithm(SignatureAlgorithm.RS512).jwsKeySelector(jwkSource);
|
||||
.jwsAlgorithm(SignatureAlgorithm.RS512).jwsKeySelector(jwkSource).block();
|
||||
assertThat(jwsKeySelector instanceof JWSVerificationKeySelector);
|
||||
JWSVerificationKeySelector<JWKSecurityContext> jwsVerificationKeySelector = (JWSVerificationKeySelector<JWKSecurityContext>) jwsKeySelector;
|
||||
assertThat(jwsVerificationKeySelector.isAllowed(JWSAlgorithm.RS512)).isTrue();
|
||||
|
@ -619,12 +637,12 @@ public class NimbusReactiveJwtDecoderTests {
|
|||
|
||||
@Test
|
||||
public void jwsKeySelectorWhenMultipleAlgorithmThenReturnsCompositeSelector() {
|
||||
JWKSource<JWKSecurityContext> jwkSource = mock(JWKSource.class);
|
||||
ReactiveRemoteJWKSource jwkSource = mock(ReactiveRemoteJWKSource.class);
|
||||
// @formatter:off
|
||||
JWSKeySelector<JWKSecurityContext> 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;
|
||||
|
|
|
@ -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<String, Object> configuration = ReactiveJwtDecoderProviderConfigurationUtils
|
||||
.getConfigurationForIssuerLocation(this.issuer, this.web).block();
|
||||
assertThat(configuration).containsKey("jwks_uri");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void issuerWhenOidcFallbackResponseIsTypicalThenReturnedConfigurationContainsJwksUri() {
|
||||
prepareConfigurationResponseOidc();
|
||||
Map<String, Object> configuration = ReactiveJwtDecoderProviderConfigurationUtils
|
||||
.getConfigurationForIssuerLocation(this.issuer, this.web).block();
|
||||
assertThat(configuration).containsKey("jwks_uri");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void issuerWhenOAuth2ResponseIsTypicalThenReturnedConfigurationContainsJwksUri() {
|
||||
prepareConfigurationResponseOAuth2();
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, MockResponse> 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<String, MockResponse> responses = new HashMap<>();
|
||||
responses.put(oauth(), response(body));
|
||||
responses.put(jwks(), response(JWK_SET));
|
||||
prepareConfigurationResponses(responses);
|
||||
}
|
||||
|
||||
private void prepareConfigurationResponses(Map<String, MockResponse> 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<String, Object> response = mapper.readValue(DEFAULT_RESPONSE_TEMPLATE,
|
||||
new TypeReference<Map<String, Object>>() {
|
||||
});
|
||||
response.remove("jwks_uri");
|
||||
return mapper.writeValueAsString(response);
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue