Skip to content

Commit 6370906

Browse files
committed
Add SpringOpaqueTokenIntrospector
Closes gh-9354
1 parent d1dfb2b commit 6370906

File tree

6 files changed

+1071
-4
lines changed

6 files changed

+1071
-4
lines changed

config/src/main/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurer.java

+3-3
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@
4242
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
4343
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider;
4444
import org.springframework.security.oauth2.server.resource.authentication.OpaqueTokenAuthenticationProvider;
45-
import org.springframework.security.oauth2.server.resource.introspection.NimbusOpaqueTokenIntrospector;
4645
import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector;
46+
import org.springframework.security.oauth2.server.resource.introspection.SpringOpaqueTokenIntrospector;
4747
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
4848
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationFilter;
4949
import org.springframework.security.oauth2.server.resource.web.BearerTokenResolver;
@@ -454,7 +454,7 @@ public OpaqueTokenConfigurer authenticationManager(AuthenticationManager authent
454454
public OpaqueTokenConfigurer introspectionUri(String introspectionUri) {
455455
Assert.notNull(introspectionUri, "introspectionUri cannot be null");
456456
this.introspectionUri = introspectionUri;
457-
this.introspector = () -> new NimbusOpaqueTokenIntrospector(this.introspectionUri, this.clientId,
457+
this.introspector = () -> new SpringOpaqueTokenIntrospector(this.introspectionUri, this.clientId,
458458
this.clientSecret);
459459
return this;
460460
}
@@ -464,7 +464,7 @@ public OpaqueTokenConfigurer introspectionClientCredentials(String clientId, Str
464464
Assert.notNull(clientSecret, "clientSecret cannot be null");
465465
this.clientId = clientId;
466466
this.clientSecret = clientSecret;
467-
this.introspector = () -> new NimbusOpaqueTokenIntrospector(this.introspectionUri, this.clientId,
467+
this.introspector = () -> new SpringOpaqueTokenIntrospector(this.introspectionUri, this.clientId,
468468
this.clientSecret);
469469
return this;
470470
}

config/src/test/java/org/springframework/security/config/annotation/web/configurers/oauth2/server/resource/OAuth2ResourceServerConfigurerTests.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -1124,7 +1124,7 @@ public void getIntrospectionClientWhenConfiguredWithClientAndIntrospectionUriThe
11241124
opaqueTokenConfigurer.introspector(client);
11251125
opaqueTokenConfigurer.introspectionUri(INTROSPECTION_URI);
11261126
opaqueTokenConfigurer.introspectionClientCredentials(CLIENT_ID, CLIENT_SECRET);
1127-
assertThat(opaqueTokenConfigurer.getIntrospector()).isInstanceOf(NimbusOpaqueTokenIntrospector.class);
1127+
assertThat(opaqueTokenConfigurer.getIntrospector()).isNotSameAs(client);
11281128
}
11291129

11301130
@Test
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
/*
2+
* Copyright 2002-2021 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+
* https://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+
17+
package org.springframework.security.oauth2.server.resource.introspection;
18+
19+
import java.net.URI;
20+
import java.net.URL;
21+
import java.time.Instant;
22+
import java.util.ArrayList;
23+
import java.util.Arrays;
24+
import java.util.Collection;
25+
import java.util.Collections;
26+
import java.util.Map;
27+
28+
import org.apache.commons.logging.Log;
29+
import org.apache.commons.logging.LogFactory;
30+
31+
import org.springframework.core.ParameterizedTypeReference;
32+
import org.springframework.core.convert.converter.Converter;
33+
import org.springframework.http.HttpHeaders;
34+
import org.springframework.http.HttpMethod;
35+
import org.springframework.http.HttpStatus;
36+
import org.springframework.http.MediaType;
37+
import org.springframework.http.RequestEntity;
38+
import org.springframework.http.ResponseEntity;
39+
import org.springframework.http.client.support.BasicAuthenticationInterceptor;
40+
import org.springframework.security.core.GrantedAuthority;
41+
import org.springframework.security.core.authority.SimpleGrantedAuthority;
42+
import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal;
43+
import org.springframework.util.Assert;
44+
import org.springframework.util.LinkedMultiValueMap;
45+
import org.springframework.util.MultiValueMap;
46+
import org.springframework.web.client.RestOperations;
47+
import org.springframework.web.client.RestTemplate;
48+
49+
/**
50+
* A Spring implementation of {@link OpaqueTokenIntrospector} that verifies and
51+
* introspects a token using the configured
52+
* <a href="https://tools.ietf.org/html/rfc7662" target="_blank">OAuth 2.0 Introspection
53+
* Endpoint</a>.
54+
*
55+
* @author Josh Cummings
56+
* @since 5.6
57+
*/
58+
public class SpringOpaqueTokenIntrospector implements OpaqueTokenIntrospector {
59+
60+
private final Log logger = LogFactory.getLog(getClass());
61+
62+
private static final ParameterizedTypeReference<Map<String, Object>> STRING_OBJECT_MAP = new ParameterizedTypeReference<Map<String, Object>>() {
63+
};
64+
65+
private Converter<String, RequestEntity<?>> requestEntityConverter;
66+
67+
private RestOperations restOperations;
68+
69+
private final String authorityPrefix = "SCOPE_";
70+
71+
/**
72+
* Creates a {@code OpaqueTokenAuthenticationProvider} with the provided parameters
73+
* @param introspectionUri The introspection endpoint uri
74+
* @param clientId The client id authorized to introspect
75+
* @param clientSecret The client's secret
76+
*/
77+
public SpringOpaqueTokenIntrospector(String introspectionUri, String clientId, String clientSecret) {
78+
Assert.notNull(introspectionUri, "introspectionUri cannot be null");
79+
Assert.notNull(clientId, "clientId cannot be null");
80+
Assert.notNull(clientSecret, "clientSecret cannot be null");
81+
this.requestEntityConverter = this.defaultRequestEntityConverter(URI.create(introspectionUri));
82+
RestTemplate restTemplate = new RestTemplate();
83+
restTemplate.getInterceptors().add(new BasicAuthenticationInterceptor(clientId, clientSecret));
84+
this.restOperations = restTemplate;
85+
}
86+
87+
/**
88+
* Creates a {@code OpaqueTokenAuthenticationProvider} with the provided parameters
89+
*
90+
* The given {@link RestOperations} should perform its own client authentication
91+
* against the introspection endpoint.
92+
* @param introspectionUri The introspection endpoint uri
93+
* @param restOperations The client for performing the introspection request
94+
*/
95+
public SpringOpaqueTokenIntrospector(String introspectionUri, RestOperations restOperations) {
96+
Assert.notNull(introspectionUri, "introspectionUri cannot be null");
97+
Assert.notNull(restOperations, "restOperations cannot be null");
98+
this.requestEntityConverter = this.defaultRequestEntityConverter(URI.create(introspectionUri));
99+
this.restOperations = restOperations;
100+
}
101+
102+
private Converter<String, RequestEntity<?>> defaultRequestEntityConverter(URI introspectionUri) {
103+
return (token) -> {
104+
HttpHeaders headers = requestHeaders();
105+
MultiValueMap<String, String> body = requestBody(token);
106+
return new RequestEntity<>(body, headers, HttpMethod.POST, introspectionUri);
107+
};
108+
}
109+
110+
private HttpHeaders requestHeaders() {
111+
HttpHeaders headers = new HttpHeaders();
112+
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
113+
return headers;
114+
}
115+
116+
private MultiValueMap<String, String> requestBody(String token) {
117+
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
118+
body.add("token", token);
119+
return body;
120+
}
121+
122+
@Override
123+
public OAuth2AuthenticatedPrincipal introspect(String token) {
124+
RequestEntity<?> requestEntity = this.requestEntityConverter.convert(token);
125+
if (requestEntity == null) {
126+
throw new OAuth2IntrospectionException("requestEntityConverter returned a null entity");
127+
}
128+
ResponseEntity<Map<String, Object>> responseEntity = makeRequest(requestEntity);
129+
Map<String, Object> claims = adaptToNimbusResponse(responseEntity);
130+
return convertClaimsSet(claims);
131+
}
132+
133+
/**
134+
* Sets the {@link Converter} used for converting the OAuth 2.0 access token to a
135+
* {@link RequestEntity} representation of the OAuth 2.0 token introspection request.
136+
* @param requestEntityConverter the {@link Converter} used for converting to a
137+
* {@link RequestEntity} representation of the token introspection request
138+
*/
139+
public void setRequestEntityConverter(Converter<String, RequestEntity<?>> requestEntityConverter) {
140+
Assert.notNull(requestEntityConverter, "requestEntityConverter cannot be null");
141+
this.requestEntityConverter = requestEntityConverter;
142+
}
143+
144+
private ResponseEntity<Map<String, Object>> makeRequest(RequestEntity<?> requestEntity) {
145+
try {
146+
return this.restOperations.exchange(requestEntity, STRING_OBJECT_MAP);
147+
}
148+
catch (Exception ex) {
149+
throw new OAuth2IntrospectionException(ex.getMessage(), ex);
150+
}
151+
}
152+
153+
private Map<String, Object> adaptToNimbusResponse(ResponseEntity<Map<String, Object>> responseEntity) {
154+
if (responseEntity.getStatusCode() != HttpStatus.OK) {
155+
throw new OAuth2IntrospectionException(
156+
"Introspection endpoint responded with " + responseEntity.getStatusCode());
157+
}
158+
Map<String, Object> claims = responseEntity.getBody();
159+
// relying solely on the authorization server to validate this token (not checking
160+
// 'exp', for example)
161+
boolean active = (boolean) claims.compute(OAuth2IntrospectionClaimNames.ACTIVE, (k, v) -> {
162+
if (v instanceof String) {
163+
return Boolean.parseBoolean((String) v);
164+
}
165+
if (v instanceof Boolean) {
166+
return v;
167+
}
168+
return false;
169+
});
170+
if (!active) {
171+
this.logger.trace("Did not validate token since it is inactive");
172+
throw new BadOpaqueTokenException("Provided token isn't active");
173+
}
174+
return claims;
175+
}
176+
177+
private OAuth2AuthenticatedPrincipal convertClaimsSet(Map<String, Object> claims) {
178+
claims.computeIfPresent(OAuth2IntrospectionClaimNames.AUDIENCE, (k, v) -> {
179+
if (v instanceof String) {
180+
return Collections.singletonList(v);
181+
}
182+
return v;
183+
});
184+
claims.computeIfPresent(OAuth2IntrospectionClaimNames.CLIENT_ID, (k, v) -> v.toString());
185+
claims.computeIfPresent(OAuth2IntrospectionClaimNames.EXPIRES_AT,
186+
(k, v) -> Instant.ofEpochSecond(((Number) v).longValue()));
187+
claims.computeIfPresent(OAuth2IntrospectionClaimNames.ISSUED_AT,
188+
(k, v) -> Instant.ofEpochSecond(((Number) v).longValue()));
189+
claims.computeIfPresent(OAuth2IntrospectionClaimNames.ISSUER, (k, v) -> issuer(v.toString()));
190+
claims.computeIfPresent(OAuth2IntrospectionClaimNames.NOT_BEFORE,
191+
(k, v) -> Instant.ofEpochSecond(((Number) v).longValue()));
192+
Collection<GrantedAuthority> authorities = new ArrayList<>();
193+
claims.computeIfPresent(OAuth2IntrospectionClaimNames.SCOPE, (k, v) -> {
194+
if (v instanceof String) {
195+
Collection<String> scopes = Arrays.asList(((String) v).split(" "));
196+
for (String scope : scopes) {
197+
authorities.add(new SimpleGrantedAuthority(this.authorityPrefix + scope));
198+
}
199+
return scopes;
200+
}
201+
return v;
202+
});
203+
return new OAuth2IntrospectionAuthenticatedPrincipal(claims, authorities);
204+
}
205+
206+
private URL issuer(String uri) {
207+
try {
208+
return new URL(uri);
209+
}
210+
catch (Exception ex) {
211+
throw new OAuth2IntrospectionException(
212+
"Invalid " + OAuth2IntrospectionClaimNames.ISSUER + " value: " + uri);
213+
}
214+
}
215+
216+
}

0 commit comments

Comments
 (0)