Skip to content

Commit 0e0992e

Browse files
committed
Auto-configure JwtDecoder via OpenId Configuration
Adding JwtDecoders#fromOidcIssuerLocation which takes an issuer and derives from it the jwk set uri via a call to .well-known/openid-configuration Fixes: spring-projectsgh-5523
1 parent 06df562 commit 0e0992e

File tree

4 files changed

+236
-7
lines changed

4 files changed

+236
-7
lines changed

config/src/main/resources/org/springframework/security/config/spring-security-5.1.xsd

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2467,17 +2467,19 @@
24672467
</xs:attributeGroup>
24682468
<xs:element name="feature-policy">
24692469
<xs:annotation>
2470-
<xs:documentation>Adds support for Feature Policy</xs:documentation>
2470+
<xs:documentation>Adds support for Feature Policy
2471+
</xs:documentation>
24712472
</xs:annotation>
24722473
<xs:complexType>
2473-
<xs:attributeGroup ref="security:feature-options.attlist"/>
2474+
<xs:attributeGroup ref="security:feature-options.attlist"/>
24742475
</xs:complexType>
2475-
</xs:element>
2476+
</xs:element>
24762477
<xs:attributeGroup name="feature-options.attlist">
24772478
<xs:attribute name="policy-directives" type="xs:token">
2478-
<xs:annotation>
2479-
<xs:documentation>The security policy directive(s) for the Feature-Policy header.</xs:documentation>
2480-
</xs:annotation>
2479+
<xs:annotation>
2480+
<xs:documentation>The security policy directive(s) for the Feature-Policy header.
2481+
</xs:documentation>
2482+
</xs:annotation>
24812483
</xs:attribute>
24822484
</xs:attributeGroup>
24832485
<xs:element name="cache-control">
@@ -2735,4 +2737,4 @@
27352737
<xs:enumeration value="LAST"/>
27362738
</xs:restriction>
27372739
</xs:simpleType>
2738-
</xs:schema>
2740+
</xs:schema>

oauth2/oauth2-jose/spring-security-oauth2-jose.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ dependencies {
66
compile springCoreDependency
77
compile 'com.nimbusds:nimbus-jose-jwt'
88

9+
optional 'com.nimbusds:oauth2-oidc-sdk'
910
optional 'io.projectreactor:reactor-core'
1011
optional 'org.springframework:spring-webflux'
1112

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/*
2+
* Copyright 2002-2018 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.security.oauth2.jwt;
17+
18+
import com.nimbusds.oauth2.sdk.ParseException;
19+
import com.nimbusds.openid.connect.sdk.op.OIDCProviderMetadata;
20+
21+
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
22+
import org.springframework.web.client.RestTemplate;
23+
24+
/**
25+
* Allows creating a {@link JwtDecoder} from an
26+
* <a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig">OpenID Provider Configuration</a>.
27+
*
28+
* @author Josh Cummings
29+
* @since 5.1
30+
*/
31+
public final class JwtDecoders {
32+
33+
/**
34+
* Creates a {@link JwtDecoder} using the provided
35+
* <a href="http://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a> by making an
36+
* <a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest">OpenID Provider
37+
* Configuration Request</a> and using the values in the
38+
* <a href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse">OpenID
39+
* Provider Configuration Response</a> to initialize the {@link JwtDecoder}.
40+
*
41+
* @param oidcIssuerLocation the <a href="http://openid.net/specs/openid-connect-core-1_0.html#IssuerIdentifier">Issuer</a>
42+
* @return a {@link JwtDecoder} that was initialized by the OpenID Provider Configuration.
43+
*/
44+
public static JwtDecoder fromOidcIssuerLocation(String oidcIssuerLocation) {
45+
String openidConfiguration = getOpenidConfiguration(oidcIssuerLocation);
46+
OIDCProviderMetadata metadata = parse(openidConfiguration);
47+
String metadataIssuer = metadata.getIssuer().getValue();
48+
if (!oidcIssuerLocation.equals(metadataIssuer)) {
49+
throw new IllegalStateException("The Issuer \"" + metadataIssuer + "\" provided in the OpenID Configuration " +
50+
"did not match the requested issuer \"" + oidcIssuerLocation + "\"");
51+
}
52+
53+
OAuth2TokenValidator<Jwt> jwtValidator =
54+
JwtValidators.createDefaultWithIssuer(oidcIssuerLocation);
55+
56+
NimbusJwtDecoderJwkSupport jwtDecoder =
57+
new NimbusJwtDecoderJwkSupport(metadata.getJWKSetURI().toASCIIString());
58+
jwtDecoder.setJwtValidator(jwtValidator);
59+
60+
return jwtDecoder;
61+
}
62+
63+
private static String getOpenidConfiguration(String issuer) {
64+
RestTemplate rest = new RestTemplate();
65+
try {
66+
return rest.getForObject(issuer + "/.well-known/openid-configuration", String.class);
67+
} catch(RuntimeException e) {
68+
throw new IllegalArgumentException("Unable to resolve the OpenID Configuration with the provided Issuer of " +
69+
"\"" + issuer + "\"", e);
70+
}
71+
}
72+
73+
private static OIDCProviderMetadata parse(String body) {
74+
try {
75+
return OIDCProviderMetadata.parse(body);
76+
}
77+
catch (ParseException e) {
78+
throw new RuntimeException(e);
79+
}
80+
}
81+
82+
private JwtDecoders() {}
83+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/*
2+
* Copyright 2002-2018 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.security.oauth2.jwt;
17+
18+
import okhttp3.mockwebserver.MockResponse;
19+
import okhttp3.mockwebserver.MockWebServer;
20+
import org.junit.After;
21+
import org.junit.Before;
22+
import org.junit.Test;
23+
24+
import org.springframework.http.HttpHeaders;
25+
import org.springframework.http.MediaType;
26+
27+
import static org.assertj.core.api.Assertions.assertThatCode;
28+
29+
/**
30+
* Tests for {@link JwtDecoders}
31+
*
32+
* @author Josh Cummings
33+
*/
34+
public class JwtDecodersTests {
35+
/**
36+
* Contains those parameters required to construct a JwtDecoder as well as any required parameters
37+
*/
38+
private static final String DEFAULT_RESPONSE_TEMPLATE =
39+
"{\n"
40+
+ " \"authorization_endpoint\": \"https://example.com/o/oauth2/v2/auth\", \n"
41+
+ " \"id_token_signing_alg_values_supported\": [\n"
42+
+ " \"RS256\"\n"
43+
+ " ], \n"
44+
+ " \"issuer\": \"%s\", \n"
45+
+ " \"jwks_uri\": \"%s/.well-known/jwks.json\", \n"
46+
+ " \"response_types_supported\": [\n"
47+
+ " \"code\", \n"
48+
+ " \"token\", \n"
49+
+ " \"id_token\", \n"
50+
+ " \"code token\", \n"
51+
+ " \"code id_token\", \n"
52+
+ " \"token id_token\", \n"
53+
+ " \"code token id_token\", \n"
54+
+ " \"none\"\n"
55+
+ " ], \n"
56+
+ " \"subject_types_supported\": [\n"
57+
+ " \"public\"\n"
58+
+ " ], \n"
59+
+ " \"token_endpoint\": \"https://example.com/oauth2/v4/token\"\n"
60+
+ "}";
61+
62+
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\"}]}";
63+
private static final String ISSUER_MISMATCH = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczpcL1wvd3Jvbmdpc3N1ZXIiLCJleHAiOjQ2ODcyNTYwNDl9.Ax8LMI6rhB9Pv_CE3kFi1JPuLj9gZycifWrLeDpkObWEEVAsIls9zAhNFyJlG-Oo7up6_mDhZgeRfyKnpSF5GhKJtXJDCzwg0ZDVUE6rS0QadSxsMMGbl7c4y0lG_7TfLX2iWeNJukJj_oSW9KzW4FsBp1BoocWjrreesqQU3fZHbikH-c_Fs2TsAIpHnxflyEzfOFWpJ8D4DtzHXqfvieMwpy42xsPZK3LR84zlasf0Ne1tC_hLHvyHRdAXwn0CMoKxc7-8j0r9Mq8kAzUsPn9If7bMLqGkxUcTPdk5x7opAUajDZx95SXHLmtztNtBa2S6EfPJXuPKG6tM5Wq5Ug";
64+
65+
private MockWebServer server;
66+
private String issuer;
67+
private String jwkSetUri;
68+
69+
@Before
70+
public void setup() throws Exception {
71+
this.server = new MockWebServer();
72+
this.server.start();
73+
this.issuer = createIssuerFromServer();
74+
this.jwkSetUri = this.issuer + "/.well-known/jwks.json";
75+
}
76+
77+
@After
78+
public void cleanup() throws Exception {
79+
this.server.shutdown();
80+
}
81+
82+
@Test
83+
public void issuerWhenResponseIsTypicalThenReturnedDecoderValidatesIssuer() {
84+
prepareOpenIdConfigurationResponse();
85+
this.server.enqueue(new MockResponse().setBody(JWK_SET));
86+
87+
JwtDecoder decoder = JwtDecoders.fromOidcIssuerLocation(this.issuer);
88+
89+
assertThatCode(() -> decoder.decode(ISSUER_MISMATCH))
90+
.isInstanceOf(JwtValidationException.class)
91+
.hasMessageContaining("This iss claim is not equal to the configured issuer");
92+
}
93+
94+
@Test
95+
public void issuerWhenResponseIsNonCompliantThenThrowsRuntimeException() {
96+
prepareOpenIdConfigurationResponse("{ \"missing_required_keys\" : \"and_values\" }");
97+
98+
assertThatCode(() -> JwtDecoders.fromOidcIssuerLocation(this.issuer))
99+
.isInstanceOf(RuntimeException.class);
100+
}
101+
102+
@Test
103+
public void issuerWhenResponseIsMalformedThenThrowsRuntimeException() {
104+
prepareOpenIdConfigurationResponse("malformed");
105+
106+
assertThatCode(() -> JwtDecoders.fromOidcIssuerLocation(this.issuer))
107+
.isInstanceOf(RuntimeException.class);
108+
}
109+
110+
@Test
111+
public void issuerWhenRespondingIssuerMismatchesRequestedIssuerThenThrowsIllegalStateException() {
112+
prepareOpenIdConfigurationResponse();
113+
114+
assertThatCode(() -> JwtDecoders.fromOidcIssuerLocation(this.issuer + "/wrong"))
115+
.isInstanceOf(IllegalStateException.class);
116+
}
117+
118+
@Test
119+
public void issuerWhenRequestedIssuerIsUnresponsiveThenThrowsIllegalArgumentException()
120+
throws Exception {
121+
122+
this.server.shutdown();
123+
124+
assertThatCode(() -> JwtDecoders.fromOidcIssuerLocation("https://issuer"))
125+
.isInstanceOf(IllegalArgumentException.class);
126+
}
127+
128+
private void prepareOpenIdConfigurationResponse() {
129+
String body = String.format(DEFAULT_RESPONSE_TEMPLATE, this.issuer, this.issuer);
130+
prepareOpenIdConfigurationResponse(body);
131+
}
132+
133+
private void prepareOpenIdConfigurationResponse(String body) {
134+
MockResponse mockResponse = new MockResponse()
135+
.setBody(body)
136+
.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
137+
this.server.enqueue(mockResponse);
138+
}
139+
140+
private String createIssuerFromServer() {
141+
return this.server.url("").toString();
142+
}
143+
}

0 commit comments

Comments
 (0)