Move Snippets to Compiled Code

Issue gh-18745

Signed-off-by: Josh Cummings <3627351+jzheaux@users.noreply.github.com>
This commit is contained in:
Josh Cummings 2026-03-02 14:53:54 -07:00
parent 498b0cb59c
commit 587ac2cbad
6 changed files with 373 additions and 73 deletions

View File

@ -533,7 +533,7 @@ Or, exposing a <<oauth2resourceserver-opaque-architecture-introspector,`OpaqueTo
----
@Bean
public OpaqueTokenIntrospector introspector() {
return return SpringOpaqueTokenIntrospector.withIntrospectionUri(introspectionUri)
return SpringOpaqueTokenIntrospector.withIntrospectionUri(introspectionUri)
.clientId(clientId).clientSecret(clientSecret).build();
}
----
@ -754,82 +754,28 @@ By default, Resource Server uses connection and socket timeouts of 30 seconds ea
This may be too short in some scenarios.
Further, it doesn't take into account more sophisticated patterns like back-off and discovery.
To adjust the way in which Resource Server connects to the authorization server, `SpringOpaqueTokenIntrospector` accepts an instance of `RestOperations`:
[tabs]
======
Java::
+
[source,java,role="primary"]
----
@Bean
public OpaqueTokenIntrospector introspector(RestTemplateBuilder builder, OAuth2ResourceServerProperties properties) {
RestOperations rest = builder
.basicAuthentication(properties.getOpaquetoken().getClientId(), properties.getOpaquetoken().getClientSecret())
.setConnectTimeout(Duration.ofSeconds(60))
.setReadTimeout(Duration.ofSeconds(60))
.build();
return new SpringOpaqueTokenIntrospector(introspectionUri, rest);
}
----
Kotlin::
+
[source,kotlin,role="secondary"]
----
@Bean
fun introspector(builder: RestTemplateBuilder, properties: OAuth2ResourceServerProperties): OpaqueTokenIntrospector? {
val rest: RestOperations = builder
.basicAuthentication(properties.opaquetoken.clientId, properties.opaquetoken.clientSecret)
.setConnectTimeout(Duration.ofSeconds(60))
.setReadTimeout(Duration.ofSeconds(60))
.build()
return SpringOpaqueTokenIntrospector(introspectionUri, rest)
}
----
======
[[oauth2resourceserver-opaque-restclient]]
[[opaque-token-timeouts-rest-client]]
=== Using `RestClientOpaqueTokenIntrospector`
Alternatively, you can use `RestClientOpaqueTokenIntrospector`, which uses `RestClient` instead of `RestTemplate`.
This allows using the same configuration while benefiting from the newer `RestClient` API.
You can use `RestClientOpaqueTokenIntrospector`, which uses `RestClient` to communicate with the introspection endpoint.
[tabs]
======
Java::
+
[source,java,role="primary"]
----
@Bean
public OpaqueTokenIntrospector introspector(RestTemplateBuilder builder, OAuth2ResourceServerProperties properties) {
RestTemplate restTemplate = builder
.basicAuthentication(properties.getOpaquetoken().getClientId(), properties.getOpaquetoken().getClientSecret())
.connectTimeout(Duration.ofSeconds(60))
.readTimeout(Duration.ofSeconds(60))
.build();
RestClient restClient = RestClient.create(restTemplate);
return new RestClientOpaqueTokenIntrospector(introspectionUri, restClient);
}
----
[TIP]
====
When using Spring Boot, you can inject `OAuth2ResourceServerProperties` to obtain the introspection URI and client credentials.
====
Kotlin::
+
[source,kotlin,role="secondary"]
----
@Bean
fun introspector(builder: RestTemplateBuilder, properties: OAuth2ResourceServerProperties): OpaqueTokenIntrospector {
val restTemplate = builder
.basicAuthentication(properties.opaquetoken.clientId, properties.opaquetoken.clientSecret)
.connectTimeout(Duration.ofSeconds(60))
.readTimeout(Duration.ofSeconds(60))
.build()
val restClient = RestClient.create(restTemplate)
return RestClientOpaqueTokenIntrospector(introspectionUri, restClient)
}
----
======
.Minimal configuration using the builder
include-code::./RestClientOpaqueTokenIntrospectorConfiguration[tag=restclient-simple,indent=0]
To customize timeouts, build a `RestClient` with a custom `RequestFactory` and pass it to the introspector:
.Custom timeouts
include-code::./RestClientOpaqueTokenIntrospectorConfiguration[tag=restclient-timeouts,indent=0]
[TIP]
====
If you prefer to use `RestTemplate`, you can use `SpringOpaqueTokenIntrospector` instead, which accepts an instance of `RestOperations`.
====
[[oauth2resourceserver-opaque-jwt-introspector]]
== Using Introspection with JWTs

View File

@ -42,6 +42,7 @@ dependencies {
testImplementation project(path : ':spring-security-config', configuration : 'tests')
testImplementation project(':spring-security-test')
testImplementation project(':spring-security-oauth2-client')
testImplementation project(':spring-security-oauth2-resource-server')
testImplementation 'com.squareup.okhttp3:mockwebserver'
testImplementation libs.com.password4j.password4j
testImplementation 'com.unboundid:unboundid-ldapsdk'

View File

@ -0,0 +1,56 @@
/*
* Copyright 2004-present 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.docs.servlet.oauth2.resourceserver.opaquetokentimeoutsrestclient;
import java.time.Duration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector;
import org.springframework.security.oauth2.server.resource.introspection.RestClientOpaqueTokenIntrospector;
import org.springframework.web.client.RestClient;
@Configuration
public class RestClientOpaqueTokenIntrospectorConfiguration {
// tag::restclient-simple[]
@Bean
public OpaqueTokenIntrospector introspector(String introspectionUri, String clientId, String clientSecret) {
return RestClientOpaqueTokenIntrospector.withIntrospectionUri(introspectionUri)
.clientId(clientId)
.clientSecret(clientSecret)
.build();
}
// end::restclient-simple[]
// tag::restclient-timeouts[]
@Bean
public OpaqueTokenIntrospector introspectorWithTimeouts(String introspectionUri, String clientId,
String clientSecret) {
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(Duration.ofSeconds(60));
requestFactory.setReadTimeout(Duration.ofSeconds(60));
RestClient restClient = RestClient.builder()
.requestFactory(requestFactory)
.defaultHeaders((headers) -> headers.setBasicAuth(clientId, clientSecret))
.build();
return new RestClientOpaqueTokenIntrospector(introspectionUri, restClient);
}
// end::restclient-timeouts[]
}

View File

@ -0,0 +1,122 @@
/*
* Copyright 2004-present 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.docs.servlet.oauth2.resourceserver.opaquetokentimeoutsrestclient;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
import okhttp3.mockwebserver.Dispatcher;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal;
import org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimNames;
import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector;
import org.springframework.security.oauth2.server.resource.introspection.RestClientOpaqueTokenIntrospector;
import org.springframework.web.client.RestClient;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link RestClientOpaqueTokenIntrospectorConfiguration} sample snippets.
*/
class RestClientOpaqueTokenIntrospectorConfigurationTests {
private static final String CLIENT_ID = "client";
private static final String CLIENT_SECRET = "secret";
private static final String ACTIVE_RESPONSE = """
{
"active": true,
"sub": "Z5O3upPC88QrAjx00dis",
"scope": "read write",
"exp": 1419356238,
"iat": 1419350238
}
""";
@Test
void introspectorWhenBuilderThenIntrospectsSuccessfully() throws Exception {
try (MockWebServer server = new MockWebServer()) {
server.setDispatcher(requiresAuth(CLIENT_ID, CLIENT_SECRET, ACTIVE_RESPONSE));
String introspectionUri = server.url("/introspect").toString();
OpaqueTokenIntrospector introspector = RestClientOpaqueTokenIntrospector
.withIntrospectionUri(introspectionUri)
.clientId(CLIENT_ID)
.clientSecret(CLIENT_SECRET)
.build();
OAuth2AuthenticatedPrincipal principal = introspector.introspect("token");
assertThat(principal.getAttributes()).isNotNull()
.containsEntry(OAuth2TokenIntrospectionClaimNames.ACTIVE, true)
.containsEntry(OAuth2TokenIntrospectionClaimNames.SUB, "Z5O3upPC88QrAjx00dis")
.containsEntry(OAuth2TokenIntrospectionClaimNames.EXP, Instant.ofEpochSecond(1419356238));
assertThat((List<String>) principal.getAttribute(OAuth2TokenIntrospectionClaimNames.SCOPE))
.isEqualTo(Arrays.asList("read", "write"));
}
}
@Test
void introspectorWithTimeoutsWhenCustomRestClientThenIntrospectsSuccessfully() throws Exception {
try (MockWebServer server = new MockWebServer()) {
server.setDispatcher(requiresAuth(CLIENT_ID, CLIENT_SECRET, ACTIVE_RESPONSE));
String introspectionUri = server.url("/introspect").toString();
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(Duration.ofSeconds(60));
requestFactory.setReadTimeout(Duration.ofSeconds(60));
RestClient restClient = RestClient.builder()
.requestFactory(requestFactory)
.defaultHeaders((headers) -> headers.setBasicAuth(CLIENT_ID, CLIENT_SECRET))
.build();
OpaqueTokenIntrospector introspector = new RestClientOpaqueTokenIntrospector(introspectionUri, restClient);
OAuth2AuthenticatedPrincipal principal = introspector.introspect("token");
assertThat(principal.getAttributes()).isNotNull()
.containsEntry(OAuth2TokenIntrospectionClaimNames.ACTIVE, true)
.containsEntry(OAuth2TokenIntrospectionClaimNames.SUB, "Z5O3upPC88QrAjx00dis");
}
}
private static Dispatcher requiresAuth(String username, String password, String response) {
return new Dispatcher() {
@Override
public MockResponse dispatch(RecordedRequest request) {
String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
return Optional.ofNullable(authorization)
.filter((a) -> isAuthorized(authorization, username, password))
.map((a) -> new MockResponse().setBody(response)
.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE))
.orElse(new MockResponse().setResponseCode(401));
}
};
}
private static boolean isAuthorized(String authorization, String username, String password) {
String decoded = new String(Base64.getDecoder().decode(authorization.substring(6)));
String[] values = decoded.split(":", 2);
return values.length == 2 && username.equals(values[0]) && password.equals(values[1]);
}
}

View File

@ -0,0 +1,57 @@
/*
* Copyright 2004-present 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.kt.docs.servlet.oauth2.resourceserver.opaquetokentimeoutsrestclient
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.client.SimpleClientHttpRequestFactory
import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector
import org.springframework.security.oauth2.server.resource.introspection.RestClientOpaqueTokenIntrospector
import org.springframework.web.client.RestClient
import java.time.Duration
@Configuration
open class RestClientOpaqueTokenIntrospectorConfiguration {
// tag::restclient-simple[]
@Bean
open fun introspector(introspectionUri: String, clientId: String, clientSecret: String): OpaqueTokenIntrospector {
return RestClientOpaqueTokenIntrospector.withIntrospectionUri(introspectionUri)
.clientId(clientId)
.clientSecret(clientSecret)
.build()
}
// end::restclient-simple[]
// tag::restclient-timeouts[]
@Bean
open fun introspectorWithTimeouts(
introspectionUri: String,
clientId: String,
clientSecret: String
): OpaqueTokenIntrospector {
val requestFactory = SimpleClientHttpRequestFactory()
requestFactory.setConnectTimeout(Duration.ofSeconds(60))
requestFactory.setReadTimeout(Duration.ofSeconds(60))
val restClient = RestClient.builder()
.requestFactory(requestFactory)
.defaultHeaders { headers -> headers.setBasicAuth(clientId, clientSecret) }
.build()
return RestClientOpaqueTokenIntrospector(introspectionUri, restClient)
}
// end::restclient-timeouts[]
}

View File

@ -0,0 +1,118 @@
/*
* Copyright 2004-present 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.kt.docs.servlet.oauth2.resourceserver.opaquetokentimeoutsrestclient
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.jupiter.api.Test
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.http.client.SimpleClientHttpRequestFactory
import org.springframework.security.oauth2.core.OAuth2AuthenticatedPrincipal
import org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimNames
import org.springframework.security.oauth2.server.resource.introspection.OpaqueTokenIntrospector
import org.springframework.security.oauth2.server.resource.introspection.RestClientOpaqueTokenIntrospector
import org.springframework.web.client.RestClient
import org.assertj.core.api.Assertions.assertThat
import java.time.Duration
import java.time.Instant
import java.util.Base64
/**
* Tests for [RestClientOpaqueTokenIntrospectorConfiguration] sample snippets.
*/
class RestClientOpaqueTokenIntrospectorConfigurationTests {
companion object {
private const val CLIENT_ID = "client"
private const val CLIENT_SECRET = "secret"
private const val ACTIVE_RESPONSE = """
{
"active": true,
"sub": "Z5O3upPC88QrAjx00dis",
"scope": "read write",
"exp": 1419356238,
"iat": 1419350238
}
"""
}
@Test
fun introspectorWhenBuilderThenIntrospectsSuccessfully() {
MockWebServer().use { server ->
server.dispatcher = requiresAuth(CLIENT_ID, CLIENT_SECRET, ACTIVE_RESPONSE)
server.start()
val introspectionUri = server.url("/introspect").toString()
val introspector: OpaqueTokenIntrospector = RestClientOpaqueTokenIntrospector
.withIntrospectionUri(introspectionUri)
.clientId(CLIENT_ID)
.clientSecret(CLIENT_SECRET)
.build()
val principal: OAuth2AuthenticatedPrincipal = introspector.introspect("token")
assertThat(principal.attributes).isNotNull
assertThat(principal.attributes).containsEntry(OAuth2TokenIntrospectionClaimNames.ACTIVE, true)
assertThat(principal.attributes).containsEntry(OAuth2TokenIntrospectionClaimNames.SUB, "Z5O3upPC88QrAjx00dis")
assertThat(principal.attributes).containsEntry(OAuth2TokenIntrospectionClaimNames.EXP, Instant.ofEpochSecond(1419356238))
assertThat(principal.getAttribute<Any>(OAuth2TokenIntrospectionClaimNames.SCOPE))
.isEqualTo(listOf("read", "write"))
}
}
@Test
fun introspectorWithTimeoutsWhenCustomRestClientThenIntrospectsSuccessfully() {
MockWebServer().use { server ->
server.dispatcher = requiresAuth(CLIENT_ID, CLIENT_SECRET, ACTIVE_RESPONSE)
server.start()
val introspectionUri = server.url("/introspect").toString()
val requestFactory = SimpleClientHttpRequestFactory()
requestFactory.setConnectTimeout(Duration.ofSeconds(60))
requestFactory.setReadTimeout(Duration.ofSeconds(60))
val restClient = RestClient.builder()
.requestFactory(requestFactory)
.defaultHeaders { headers -> headers.setBasicAuth(CLIENT_ID, CLIENT_SECRET) }
.build()
val introspector = RestClientOpaqueTokenIntrospector(introspectionUri, restClient)
val principal: OAuth2AuthenticatedPrincipal = introspector.introspect("token")
assertThat(principal.attributes).isNotNull
assertThat(principal.attributes).containsEntry(OAuth2TokenIntrospectionClaimNames.ACTIVE, true)
assertThat(principal.attributes).containsEntry(OAuth2TokenIntrospectionClaimNames.SUB, "Z5O3upPC88QrAjx00dis")
}
}
private fun requiresAuth(username: String, password: String, response: String): Dispatcher {
return object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val authorization = request.getHeader(HttpHeaders.AUTHORIZATION)
return if (authorization != null && isAuthorized(authorization, username, password)) {
MockResponse()
.setBody(response)
.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
} else {
MockResponse().setResponseCode(401)
}
}
}
}
private fun isAuthorized(authorization: String, username: String, password: String): Boolean {
val decoded = String(Base64.getDecoder().decode(authorization.substring(6)))
val values = decoded.split(":", limit = 2)
return values.size == 2 && username == values[0] && password == values[1]
}
}