Skip to content

Commit 0938ca0

Browse files
committed
Add support for automatic context-propagation with Micrometer
Closes gh-16665
1 parent 1083813 commit 0938ca0

File tree

15 files changed

+579
-0
lines changed

15 files changed

+579
-0
lines changed

Diff for: core/spring-security-core.gradle

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ dependencies {
1616
api 'io.micrometer:micrometer-observation'
1717

1818
optional 'com.fasterxml.jackson.core:jackson-databind'
19+
optional 'io.micrometer:context-propagation'
1920
optional 'io.projectreactor:reactor-core'
2021
optional 'jakarta.annotation:jakarta.annotation-api'
2122
optional 'org.aspectj:aspectjrt'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
* Copyright 2002-2025 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.core.context;
18+
19+
import io.micrometer.context.ThreadLocalAccessor;
20+
import reactor.core.publisher.Mono;
21+
22+
import org.springframework.util.Assert;
23+
24+
/**
25+
* A {@link ThreadLocalAccessor} for accessing a {@link SecurityContext} with the
26+
* {@link ReactiveSecurityContextHolder}.
27+
* <p>
28+
* This class adapts the {@link ReactiveSecurityContextHolder} to the
29+
* {@link ThreadLocalAccessor} contract to allow Micrometer Context Propagation to
30+
* automatically propagate a {@link SecurityContext} in Reactive applications. It is
31+
* automatically registered with the {@link io.micrometer.context.ContextRegistry} through
32+
* the {@link java.util.ServiceLoader} mechanism when context-propagation is on the
33+
* classpath.
34+
*
35+
* @author Steve Riesenberg
36+
* @since 6.5
37+
* @see io.micrometer.context.ContextRegistry
38+
*/
39+
public final class ReactiveSecurityContextHolderThreadLocalAccessor
40+
implements ThreadLocalAccessor<Mono<SecurityContext>> {
41+
42+
private static final ThreadLocal<Mono<SecurityContext>> threadLocal = new ThreadLocal<>();
43+
44+
@Override
45+
public Object key() {
46+
return SecurityContext.class;
47+
}
48+
49+
@Override
50+
public Mono<SecurityContext> getValue() {
51+
return threadLocal.get();
52+
}
53+
54+
@Override
55+
public void setValue(Mono<SecurityContext> securityContext) {
56+
Assert.notNull(securityContext, "securityContext cannot be null");
57+
threadLocal.set(securityContext);
58+
}
59+
60+
@Override
61+
public void setValue() {
62+
threadLocal.remove();
63+
}
64+
65+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* Copyright 2002-2025 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.core.context;
18+
19+
import io.micrometer.context.ThreadLocalAccessor;
20+
21+
import org.springframework.util.Assert;
22+
23+
/**
24+
* A {@link ThreadLocalAccessor} for accessing a {@link SecurityContext} with the
25+
* {@link SecurityContextHolder}.
26+
* <p>
27+
* This class adapts the {@link SecurityContextHolder} to the {@link ThreadLocalAccessor}
28+
* contract to allow Micrometer Context Propagation to automatically propagate a
29+
* {@link SecurityContext} in Servlet applications. It is automatically registered with
30+
* the {@link io.micrometer.context.ContextRegistry} through the
31+
* {@link java.util.ServiceLoader} mechanism when context-propagation is on the classpath.
32+
*
33+
* @author Steve Riesenberg
34+
* @since 6.5
35+
* @see io.micrometer.context.ContextRegistry
36+
*/
37+
public final class SecurityContextHolderThreadLocalAccessor implements ThreadLocalAccessor<SecurityContext> {
38+
39+
@Override
40+
public Object key() {
41+
return SecurityContext.class.getName();
42+
}
43+
44+
@Override
45+
public SecurityContext getValue() {
46+
SecurityContext securityContext = SecurityContextHolder.getContext();
47+
SecurityContext emptyContext = SecurityContextHolder.createEmptyContext();
48+
49+
return !securityContext.equals(emptyContext) ? securityContext : null;
50+
}
51+
52+
@Override
53+
public void setValue(SecurityContext securityContext) {
54+
Assert.notNull(securityContext, "securityContext cannot be null");
55+
SecurityContextHolder.setContext(securityContext);
56+
}
57+
58+
@Override
59+
public void setValue() {
60+
SecurityContextHolder.clearContext();
61+
}
62+
63+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
org.springframework.security.core.context.ReactiveSecurityContextHolderThreadLocalAccessor
2+
org.springframework.security.core.context.SecurityContextHolderThreadLocalAccessor
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/*
2+
* Copyright 2002-2025 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.core.context;
18+
19+
import java.util.concurrent.CountDownLatch;
20+
21+
import org.junit.jupiter.api.AfterEach;
22+
import org.junit.jupiter.api.BeforeEach;
23+
import org.junit.jupiter.api.Test;
24+
import reactor.core.publisher.Mono;
25+
26+
import org.springframework.core.task.SimpleAsyncTaskExecutor;
27+
import org.springframework.security.authentication.TestingAuthenticationToken;
28+
29+
import static org.assertj.core.api.Assertions.assertThat;
30+
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
31+
32+
/**
33+
* Tests for {@link ReactiveSecurityContextHolderThreadLocalAccessor}.
34+
*
35+
* @author Steve Riesenberg
36+
*/
37+
public class ReactiveSecurityContextHolderThreadLocalAccessorTests {
38+
39+
private ReactiveSecurityContextHolderThreadLocalAccessor threadLocalAccessor;
40+
41+
@BeforeEach
42+
public void setUp() {
43+
this.threadLocalAccessor = new ReactiveSecurityContextHolderThreadLocalAccessor();
44+
}
45+
46+
@AfterEach
47+
public void tearDown() {
48+
this.threadLocalAccessor.setValue();
49+
}
50+
51+
@Test
52+
public void keyAlwaysReturnsSecurityContextClass() {
53+
assertThat(this.threadLocalAccessor.key()).isEqualTo(SecurityContext.class);
54+
}
55+
56+
@Test
57+
public void getValueWhenThreadLocalNotSetThenReturnsNull() {
58+
assertThat(this.threadLocalAccessor.getValue()).isNull();
59+
}
60+
61+
@Test
62+
public void getValueWhenThreadLocalSetThenReturnsSecurityContextMono() {
63+
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
64+
securityContext.setAuthentication(new TestingAuthenticationToken("user", "password"));
65+
Mono<SecurityContext> mono = Mono.just(securityContext);
66+
this.threadLocalAccessor.setValue(mono);
67+
68+
assertThat(this.threadLocalAccessor.getValue()).isSameAs(mono);
69+
}
70+
71+
@Test
72+
public void getValueWhenThreadLocalSetOnAnotherThreadThenReturnsNull() throws InterruptedException {
73+
CountDownLatch threadLocalSet = new CountDownLatch(1);
74+
CountDownLatch threadLocalRead = new CountDownLatch(1);
75+
CountDownLatch threadLocalCleared = new CountDownLatch(1);
76+
77+
Runnable task = () -> {
78+
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
79+
securityContext.setAuthentication(new TestingAuthenticationToken("user", "password"));
80+
Mono<SecurityContext> mono = Mono.just(securityContext);
81+
this.threadLocalAccessor.setValue(mono);
82+
threadLocalSet.countDown();
83+
try {
84+
threadLocalRead.await();
85+
}
86+
catch (InterruptedException ignored) {
87+
}
88+
finally {
89+
this.threadLocalAccessor.setValue();
90+
threadLocalCleared.countDown();
91+
}
92+
};
93+
try (SimpleAsyncTaskExecutor taskExecutor = new SimpleAsyncTaskExecutor()) {
94+
taskExecutor.execute(task);
95+
threadLocalSet.await();
96+
assertThat(this.threadLocalAccessor.getValue()).isNull();
97+
threadLocalRead.countDown();
98+
threadLocalCleared.await();
99+
}
100+
}
101+
102+
@Test
103+
public void setValueWhenNullThenThrowsIllegalArgumentException() {
104+
// @formatter:off
105+
assertThatIllegalArgumentException()
106+
.isThrownBy(() -> this.threadLocalAccessor.setValue(null))
107+
.withMessage("securityContext cannot be null");
108+
// @formatter:on
109+
}
110+
111+
@Test
112+
public void setValueWhenThreadLocalSetThenClearsThreadLocal() {
113+
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
114+
securityContext.setAuthentication(new TestingAuthenticationToken("user", "password"));
115+
Mono<SecurityContext> mono = Mono.just(securityContext);
116+
this.threadLocalAccessor.setValue(mono);
117+
assertThat(this.threadLocalAccessor.getValue()).isSameAs(mono);
118+
119+
this.threadLocalAccessor.setValue();
120+
assertThat(this.threadLocalAccessor.getValue()).isNull();
121+
}
122+
123+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/*
2+
* Copyright 2002-2025 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.core.context;
18+
19+
import org.junit.jupiter.api.AfterEach;
20+
import org.junit.jupiter.api.BeforeEach;
21+
import org.junit.jupiter.api.Test;
22+
23+
import org.springframework.security.authentication.TestingAuthenticationToken;
24+
25+
import static org.assertj.core.api.Assertions.assertThat;
26+
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
27+
28+
/**
29+
* Tests for {@link SecurityContextHolderThreadLocalAccessor}.
30+
*
31+
* @author Steve Riesenberg
32+
*/
33+
public class SecurityContextHolderThreadLocalAccessorTests {
34+
35+
private SecurityContextHolderThreadLocalAccessor threadLocalAccessor;
36+
37+
@BeforeEach
38+
public void setUp() {
39+
this.threadLocalAccessor = new SecurityContextHolderThreadLocalAccessor();
40+
}
41+
42+
@AfterEach
43+
public void tearDown() {
44+
this.threadLocalAccessor.setValue();
45+
}
46+
47+
@Test
48+
public void keyAlwaysReturnsSecurityContextClassName() {
49+
assertThat(this.threadLocalAccessor.key()).isEqualTo(SecurityContext.class.getName());
50+
}
51+
52+
@Test
53+
public void getValueWhenSecurityContextHolderNotSetThenReturnsNull() {
54+
assertThat(this.threadLocalAccessor.getValue()).isNull();
55+
}
56+
57+
@Test
58+
public void getValueWhenSecurityContextHolderSetThenReturnsSecurityContext() {
59+
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
60+
securityContext.setAuthentication(new TestingAuthenticationToken("user", "password"));
61+
SecurityContextHolder.setContext(securityContext);
62+
assertThat(this.threadLocalAccessor.getValue()).isSameAs(securityContext);
63+
}
64+
65+
@Test
66+
public void setValueWhenSecurityContextThenSetsSecurityContextHolder() {
67+
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
68+
securityContext.setAuthentication(new TestingAuthenticationToken("user", "password"));
69+
this.threadLocalAccessor.setValue(securityContext);
70+
assertThat(SecurityContextHolder.getContext()).isSameAs(securityContext);
71+
}
72+
73+
@Test
74+
public void setValueWhenNullThenThrowsIllegalArgumentException() {
75+
// @formatter:off
76+
assertThatIllegalArgumentException()
77+
.isThrownBy(() -> this.threadLocalAccessor.setValue(null))
78+
.withMessage("securityContext cannot be null");
79+
// @formatter:on
80+
}
81+
82+
@Test
83+
public void setValueWhenSecurityContextSetThenClearsSecurityContextHolder() {
84+
SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
85+
securityContext.setAuthentication(new TestingAuthenticationToken("user", "password"));
86+
SecurityContextHolder.setContext(securityContext);
87+
this.threadLocalAccessor.setValue();
88+
89+
SecurityContext emptyContext = SecurityContextHolder.createEmptyContext();
90+
assertThat(SecurityContextHolder.getContext()).isEqualTo(emptyContext);
91+
}
92+
93+
}

Diff for: dependencies/spring-security-dependencies.gradle

+1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ dependencies {
3535
api libs.com.unboundid.unboundid.ldapsdk
3636
api libs.commons.collections
3737
api libs.io.mockk
38+
api libs.io.micrometer.context.propagation
3839
api libs.io.micrometer.micrometer.observation
3940
api libs.jakarta.annotation.jakarta.annotation.api
4041
api libs.jakarta.inject.jakarta.inject.api

Diff for: docs/modules/ROOT/pages/whats-new.adoc

+4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
Spring Security 6.5 provides a number of new features.
55
Below are the highlights of the release, or you can view https://github.com/spring-projects/spring-security/releases[the release notes] for a detailed listing of each feature and bug fix.
66

7+
== New Features
8+
9+
* Support for automatic context-propagation with Micrometer (https://github.com/spring-projects/spring-security/issues/16665[gh-16665])
10+
711
== Breaking Changes
812

913
=== Observability

Diff for: gradle/libs.versions.toml

+1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ com-squareup-okhttp3-okhttp = { module = "com.squareup.okhttp3:okhttp", version.
2828
com-unboundid-unboundid-ldapsdk = "com.unboundid:unboundid-ldapsdk:6.0.11"
2929
com-unboundid-unboundid-ldapsdk7 = "com.unboundid:unboundid-ldapsdk:7.0.1"
3030
commons-collections = "commons-collections:commons-collections:3.2.2"
31+
io-micrometer-context-propagation = "io.micrometer:context-propagation:1.1.2"
3132
io-micrometer-micrometer-observation = "io.micrometer:micrometer-observation:1.14.5"
3233
io-mockk = "io.mockk:mockk:1.13.17"
3334
io-projectreactor-reactor-bom = "io.projectreactor:reactor-bom:2023.0.16"

Diff for: oauth2/oauth2-client/spring-security-oauth2-client.gradle

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ dependencies {
1919
testImplementation project(path: ':spring-security-oauth2-core', configuration: 'tests')
2020
testImplementation project(path: ':spring-security-oauth2-jose', configuration: 'tests')
2121
testImplementation 'com.squareup.okhttp3:mockwebserver'
22+
testImplementation 'io.micrometer:context-propagation'
2223
testImplementation 'io.projectreactor.netty:reactor-netty'
2324
testImplementation 'io.projectreactor:reactor-test'
2425
testImplementation 'org.skyscreamer:jsonassert'

0 commit comments

Comments
 (0)