Skip to content

Commit 683f1f4

Browse files
committed
Set PublicKeyCredentialCreationOptionsRepository by DSL or Bean
Closes gh-16396
2 parents 4dc1dcb + 718c90d commit 683f1f4

File tree

7 files changed

+218
-0
lines changed

7 files changed

+218
-0
lines changed

config/src/main/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurer.java

+27
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
import org.springframework.security.web.webauthn.management.Webauthn4JRelyingPartyOperations;
4545
import org.springframework.security.web.webauthn.registration.DefaultWebAuthnRegistrationPageGeneratingFilter;
4646
import org.springframework.security.web.webauthn.registration.PublicKeyCredentialCreationOptionsFilter;
47+
import org.springframework.security.web.webauthn.registration.PublicKeyCredentialCreationOptionsRepository;
4748
import org.springframework.security.web.webauthn.registration.WebAuthnRegistrationFilter;
4849

4950
/**
@@ -64,6 +65,8 @@ public class WebAuthnConfigurer<H extends HttpSecurityBuilder<H>>
6465

6566
private boolean disableDefaultRegistrationPage = false;
6667

68+
private PublicKeyCredentialCreationOptionsRepository creationOptionsRepository;
69+
6770
private HttpMessageConverter<Object> converter;
6871

6972
/**
@@ -130,6 +133,17 @@ public WebAuthnConfigurer<H> messageConverter(HttpMessageConverter<Object> conve
130133
return this;
131134
}
132135

136+
/**
137+
* Sets PublicKeyCredentialCreationOptionsRepository
138+
* @param creationOptionsRepository the creationOptionsRepository
139+
* @return the {@link WebAuthnConfigurer} for further customization
140+
*/
141+
public WebAuthnConfigurer<H> creationOptionsRepository(
142+
PublicKeyCredentialCreationOptionsRepository creationOptionsRepository) {
143+
this.creationOptionsRepository = creationOptionsRepository;
144+
return this;
145+
}
146+
133147
@Override
134148
public void configure(H http) throws Exception {
135149
UserDetailsService userDetailsService = getSharedOrBean(http, UserDetailsService.class).orElseGet(() -> {
@@ -141,13 +155,18 @@ public void configure(H http) throws Exception {
141155
UserCredentialRepository userCredentials = getSharedOrBean(http, UserCredentialRepository.class)
142156
.orElse(userCredentialRepository());
143157
WebAuthnRelyingPartyOperations rpOperations = webAuthnRelyingPartyOperations(userEntities, userCredentials);
158+
PublicKeyCredentialCreationOptionsRepository creationOptionsRepository = creationOptionsRepository();
144159
WebAuthnAuthenticationFilter webAuthnAuthnFilter = new WebAuthnAuthenticationFilter();
145160
webAuthnAuthnFilter.setAuthenticationManager(
146161
new ProviderManager(new WebAuthnAuthenticationProvider(rpOperations, userDetailsService)));
147162
WebAuthnRegistrationFilter webAuthnRegistrationFilter = new WebAuthnRegistrationFilter(userCredentials,
148163
rpOperations);
149164
PublicKeyCredentialCreationOptionsFilter creationOptionsFilter = new PublicKeyCredentialCreationOptionsFilter(
150165
rpOperations);
166+
if (creationOptionsRepository != null) {
167+
webAuthnRegistrationFilter.setCreationOptionsRepository(creationOptionsRepository);
168+
creationOptionsFilter.setCreationOptionsRepository(creationOptionsRepository);
169+
}
151170
if (this.converter != null) {
152171
webAuthnRegistrationFilter.setConverter(this.converter);
153172
creationOptionsFilter.setConverter(this.converter);
@@ -181,6 +200,14 @@ public void configure(H http) throws Exception {
181200
}
182201
}
183202

203+
private PublicKeyCredentialCreationOptionsRepository creationOptionsRepository() {
204+
if (this.creationOptionsRepository != null) {
205+
return this.creationOptionsRepository;
206+
}
207+
ApplicationContext context = getBuilder().getSharedObject(ApplicationContext.class);
208+
return context.getBeanProvider(PublicKeyCredentialCreationOptionsRepository.class).getIfUnique();
209+
}
210+
184211
private <C> Optional<C> getSharedOrBean(H http, Class<C> type) {
185212
C shared = http.getSharedObject(type);
186213
return Optional.ofNullable(shared).or(() -> getBeanOrNull(type));

config/src/main/kotlin/org/springframework/security/config/annotation/web/WebAuthnDsl.kt

+3
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package org.springframework.security.config.annotation.web
1818

1919
import org.springframework.security.config.annotation.web.builders.HttpSecurity
2020
import org.springframework.security.config.annotation.web.configurers.WebAuthnConfigurer
21+
import org.springframework.security.web.webauthn.registration.PublicKeyCredentialCreationOptionsRepository
2122

2223
/**
2324
* A Kotlin DSL to configure [HttpSecurity] webauthn using idiomatic Kotlin code.
@@ -35,13 +36,15 @@ class WebAuthnDsl {
3536
var rpId: String? = null
3637
var allowedOrigins: Set<String>? = null
3738
var disableDefaultRegistrationPage: Boolean? = false
39+
var creationOptionsRepository: PublicKeyCredentialCreationOptionsRepository? = null
3840

3941
internal fun get(): (WebAuthnConfigurer<HttpSecurity>) -> Unit {
4042
return { webAuthn ->
4143
rpName?.also { webAuthn.rpName(rpName) }
4244
rpId?.also { webAuthn.rpId(rpId) }
4345
allowedOrigins?.also { webAuthn.allowedOrigins(allowedOrigins) }
4446
disableDefaultRegistrationPage?.also { webAuthn.disableDefaultRegistrationPage(disableDefaultRegistrationPage!!) }
47+
creationOptionsRepository?.also { webAuthn.creationOptionsRepository(creationOptionsRepository) }
4548
}
4649
}
4750
}

config/src/test/java/org/springframework/security/config/annotation/web/configurers/WebAuthnConfigurerTests.java

+99
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import org.springframework.security.web.webauthn.api.PublicKeyCredentialCreationOptions;
4444
import org.springframework.security.web.webauthn.api.TestPublicKeyCredentialCreationOptions;
4545
import org.springframework.security.web.webauthn.management.WebAuthnRelyingPartyOperations;
46+
import org.springframework.security.web.webauthn.registration.HttpSessionPublicKeyCredentialCreationOptionsRepository;
4647
import org.springframework.test.web.servlet.MockMvc;
4748

4849
import static org.assertj.core.api.Assertions.assertThat;
@@ -55,6 +56,7 @@
5556
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
5657
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
5758
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
59+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
5860
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
5961

6062
/**
@@ -140,6 +142,46 @@ public void webauthnWhenConfiguredAndNoDefaultRegistrationPageThenDoesNotServeJa
140142
this.mvc.perform(get("/login/webauthn.js")).andExpect(status().isNotFound());
141143
}
142144

145+
@Test
146+
public void webauthnWhenConfiguredPublicKeyCredentialCreationOptionsRepository() throws Exception {
147+
TestingAuthenticationToken user = new TestingAuthenticationToken("user", "password", "ROLE_USER");
148+
SecurityContextHolder.setContext(new SecurityContextImpl(user));
149+
PublicKeyCredentialCreationOptions options = TestPublicKeyCredentialCreationOptions
150+
.createPublicKeyCredentialCreationOptions()
151+
.build();
152+
WebAuthnRelyingPartyOperations rpOperations = mock(WebAuthnRelyingPartyOperations.class);
153+
ConfigCredentialCreationOptionsRepository.rpOperations = rpOperations;
154+
given(rpOperations.createPublicKeyCredentialCreationOptions(any())).willReturn(options);
155+
String attrName = "attrName";
156+
HttpSessionPublicKeyCredentialCreationOptionsRepository creationOptionsRepository = new HttpSessionPublicKeyCredentialCreationOptionsRepository();
157+
creationOptionsRepository.setAttrName(attrName);
158+
ConfigCredentialCreationOptionsRepository.creationOptionsRepository = creationOptionsRepository;
159+
this.spring.register(ConfigCredentialCreationOptionsRepository.class).autowire();
160+
this.mvc.perform(post("/webauthn/register/options"))
161+
.andExpect(status().isOk())
162+
.andExpect(request().sessionAttribute(attrName, options));
163+
}
164+
165+
@Test
166+
public void webauthnWhenConfiguredPublicKeyCredentialCreationOptionsRepositoryBeanPresent() throws Exception {
167+
TestingAuthenticationToken user = new TestingAuthenticationToken("user", "password", "ROLE_USER");
168+
SecurityContextHolder.setContext(new SecurityContextImpl(user));
169+
PublicKeyCredentialCreationOptions options = TestPublicKeyCredentialCreationOptions
170+
.createPublicKeyCredentialCreationOptions()
171+
.build();
172+
WebAuthnRelyingPartyOperations rpOperations = mock(WebAuthnRelyingPartyOperations.class);
173+
ConfigCredentialCreationOptionsRepositoryFromBean.rpOperations = rpOperations;
174+
given(rpOperations.createPublicKeyCredentialCreationOptions(any())).willReturn(options);
175+
String attrName = "attrName";
176+
HttpSessionPublicKeyCredentialCreationOptionsRepository creationOptionsRepository = new HttpSessionPublicKeyCredentialCreationOptionsRepository();
177+
creationOptionsRepository.setAttrName(attrName);
178+
ConfigCredentialCreationOptionsRepositoryFromBean.creationOptionsRepository = creationOptionsRepository;
179+
this.spring.register(ConfigCredentialCreationOptionsRepositoryFromBean.class).autowire();
180+
this.mvc.perform(post("/webauthn/register/options"))
181+
.andExpect(status().isOk())
182+
.andExpect(request().sessionAttribute(attrName, options));
183+
}
184+
143185
@Test
144186
public void webauthnWhenConfiguredMessageConverter() throws Exception {
145187
TestingAuthenticationToken user = new TestingAuthenticationToken("user", "password", "ROLE_USER");
@@ -165,6 +207,63 @@ public void webauthnWhenConfiguredMessageConverter() throws Exception {
165207
.andExpect(content().string(expectedBody));
166208
}
167209

210+
@Configuration
211+
@EnableWebSecurity
212+
static class ConfigCredentialCreationOptionsRepository {
213+
214+
private static HttpSessionPublicKeyCredentialCreationOptionsRepository creationOptionsRepository;
215+
216+
private static WebAuthnRelyingPartyOperations rpOperations;
217+
218+
@Bean
219+
WebAuthnRelyingPartyOperations webAuthnRelyingPartyOperations() {
220+
return ConfigCredentialCreationOptionsRepository.rpOperations;
221+
}
222+
223+
@Bean
224+
UserDetailsService userDetailsService() {
225+
return new InMemoryUserDetailsManager();
226+
}
227+
228+
@Bean
229+
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
230+
return http.csrf(AbstractHttpConfigurer::disable)
231+
.webAuthn((c) -> c.creationOptionsRepository(creationOptionsRepository))
232+
.build();
233+
}
234+
235+
}
236+
237+
@Configuration
238+
@EnableWebSecurity
239+
static class ConfigCredentialCreationOptionsRepositoryFromBean {
240+
241+
private static HttpSessionPublicKeyCredentialCreationOptionsRepository creationOptionsRepository;
242+
243+
private static WebAuthnRelyingPartyOperations rpOperations;
244+
245+
@Bean
246+
WebAuthnRelyingPartyOperations webAuthnRelyingPartyOperations() {
247+
return ConfigCredentialCreationOptionsRepositoryFromBean.rpOperations;
248+
}
249+
250+
@Bean
251+
UserDetailsService userDetailsService() {
252+
return new InMemoryUserDetailsManager();
253+
}
254+
255+
@Bean
256+
HttpSessionPublicKeyCredentialCreationOptionsRepository creationOptionsRepository() {
257+
return ConfigCredentialCreationOptionsRepositoryFromBean.creationOptionsRepository;
258+
}
259+
260+
@Bean
261+
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
262+
return http.csrf(AbstractHttpConfigurer::disable).webAuthn(Customizer.withDefaults()).build();
263+
}
264+
265+
}
266+
168267
@Configuration
169268
@EnableWebSecurity
170269
static class ConfigMessageConverter {

config/src/test/kotlin/org/springframework/security/config/annotation/web/WebAuthnDslTests.kt

+38
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import org.springframework.security.core.userdetails.User
3030
import org.springframework.security.core.userdetails.UserDetailsService
3131
import org.springframework.security.provisioning.InMemoryUserDetailsManager
3232
import org.springframework.security.web.SecurityFilterChain
33+
import org.springframework.security.web.webauthn.registration.HttpSessionPublicKeyCredentialCreationOptionsRepository
3334
import org.springframework.test.web.servlet.MockMvc
3435
import org.springframework.test.web.servlet.get
3536
import org.springframework.test.web.servlet.post
@@ -58,6 +59,16 @@ class WebAuthnDslTests {
5859
}
5960
}
6061

62+
@Test
63+
fun `explicit PublicKeyCredentialCreationOptionsRepository`() {
64+
this.spring.register(ExplicitPublicKeyCredentialCreationOptionsRepositoryConfig::class.java).autowire()
65+
66+
this.mockMvc.post("/test1")
67+
.andExpect {
68+
status { isForbidden() }
69+
}
70+
}
71+
6172
@Test
6273
fun `webauthn and formLogin configured with default registration page`() {
6374
spring.register(DefaultWebauthnConfig::class.java).autowire()
@@ -128,6 +139,33 @@ class WebAuthnDslTests {
128139
}
129140
}
130141

142+
@Configuration
143+
@EnableWebSecurity
144+
open class ExplicitPublicKeyCredentialCreationOptionsRepositoryConfig {
145+
@Bean
146+
open fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
147+
http {
148+
webAuthn {
149+
rpName = "Spring Security Relying Party"
150+
rpId = "example.com"
151+
allowedOrigins = setOf("https://example.com")
152+
creationOptionsRepository = HttpSessionPublicKeyCredentialCreationOptionsRepository()
153+
}
154+
}
155+
return http.build()
156+
}
157+
158+
@Bean
159+
open fun userDetailsService(): UserDetailsService {
160+
val userDetails = User.withDefaultPasswordEncoder()
161+
.username("rod")
162+
.password("password")
163+
.roles("USER")
164+
.build()
165+
return InMemoryUserDetailsManager(userDetails)
166+
}
167+
}
168+
131169
@Configuration
132170
@EnableWebSecurity
133171
open class WebauthnConfig {

docs/modules/ROOT/pages/servlet/authentication/passkeys.adoc

+36
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,16 @@ Java::
6060
----
6161
@Bean
6262
SecurityFilterChain filterChain(HttpSecurity http) {
63+
// ...
6364
http
6465
// ...
6566
.formLogin(withDefaults())
6667
.webAuthn((webAuthn) -> webAuthn
6768
.rpName("Spring Security Relying Party")
6869
.rpId("example.com")
6970
.allowedOrigins("https://example.com")
71+
// optional properties
72+
.creationOptionsRepository(new CustomPublicKeyCredentialCreationOptionsRepository())
7073
);
7174
return http.build();
7275
}
@@ -89,11 +92,14 @@ Kotlin::
8992
----
9093
@Bean
9194
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
95+
// ...
9296
http {
9397
webAuthn {
9498
rpName = "Spring Security Relying Party"
9599
rpId = "example.com"
96100
allowedOrigins = setOf("https://example.com")
101+
// optional properties
102+
creationOptionsRepository = CustomPublicKeyCredentialCreationOptionsRepository()
97103
}
98104
}
99105
}
@@ -110,6 +116,36 @@ open fun userDetailsService(): UserDetailsService {
110116
----
111117
======
112118

119+
[[passkeys-configuration-pkccor]]
120+
=== Custom PublicKeyCredentialCreationOptionsRepository
121+
122+
The `PublicKeyCredentialCreationOptionsRepository` is used to persist the `PublicKeyCredentialCreationOptions` between requests.
123+
The default is to persist it the `HttpSession`, but at times users may need to customize this behavior.
124+
This can be done by setting the optional property `creationOptionsRepository` demonstrated in xref:./passkeys.adoc#passkeys-configuration[Configuration] or by exposing a `PublicKeyCredentialCreationOptionsRepository` Bean:
125+
126+
[tabs]
127+
======
128+
Java::
129+
+
130+
[source,java,role="primary"]
131+
----
132+
@Bean
133+
CustomPublicKeyCredentialCreationOptionsRepository creationOptionsRepository() {
134+
return new CustomPublicKeyCredentialCreationOptionsRepository();
135+
}
136+
----
137+
138+
Kotlin::
139+
+
140+
[source,kotlin,role="secondary"]
141+
----
142+
@Bean
143+
open fun creationOptionsRepository(): CustomPublicKeyCredentialCreationOptionsRepository {
144+
return CustomPublicKeyCredentialCreationOptionsRepository()
145+
}
146+
----
147+
======
148+
113149
[[passkeys-register]]
114150
== Register a New Credential
115151

docs/modules/ROOT/pages/whats-new.adoc

+4
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,7 @@ Note that this may affect reports that operate on this key name.
1414
== OAuth
1515

1616
* https://github.com/spring-projects/spring-security/pull/16386[gh-16386] - Enable PKCE for confidential clients using `ClientRegistration.clientSettings.requireProofKey=true` for xref:servlet/oauth2/client/core.adoc#oauth2Client-client-registration-requireProofKey[servlet] and xref:reactive/oauth2/client/core.adoc#oauth2Client-client-registration-requireProofKey[reactive] applications
17+
18+
== WebAuthn
19+
20+
* https://github.com/spring-projects/spring-security/pull/16396[gh-16396] - Added the ability to configure a custom xref:servlet/authentication/passkeys.adoc#passkeys-configuration-pkccor[`PublicKeyCredentialCreationOptionsRepository`]

web/src/main/java/org/springframework/security/web/webauthn/registration/PublicKeyCredentialCreationOptionsFilter.java

+11
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,17 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
105105
this.converter.write(options, MediaType.APPLICATION_JSON, new ServletServerHttpResponse(response));
106106
}
107107

108+
/**
109+
* Sets the {@link PublicKeyCredentialCreationOptionsRepository} to use. The default
110+
* is {@link HttpSessionPublicKeyCredentialCreationOptionsRepository}.
111+
* @param creationOptionsRepository the
112+
* {@link PublicKeyCredentialCreationOptionsRepository} to use. Cannot be null.
113+
*/
114+
public void setCreationOptionsRepository(PublicKeyCredentialCreationOptionsRepository creationOptionsRepository) {
115+
Assert.notNull(creationOptionsRepository, "creationOptionsRepository cannot be null");
116+
this.repository = creationOptionsRepository;
117+
}
118+
108119
/**
109120
* Set the {@link HttpMessageConverter} to read the
110121
* {@link WebAuthnRegistrationFilter.WebAuthnRegistrationRequest} and write the

0 commit comments

Comments
 (0)