I am developing an application in Spring Boot 3.1.0 using the Spring Authorization Server to implement an OAuth 2.1 server for Auth Code Flow with PKCE.
The OAuth works perfectly, but as soon as I continued to work on the service API part and secured it, my application refused to authorize incoming http requests to API endpoints with the Bearer token passed in the header.
How to secure my REST API endpoints with Bearer token authentication in this single-module web-server?
Is it possible? What I should to do?
Spring application logs:
# I try to exchange OAuth code for an access token
23:16:44.991 [nio-8080-exec-4] o.s.security.web.FilterChainProxy : Securing POST /oauth2/token
23:16:45.059 [nio-8080-exec-4] o.s.a.w.OAuth2ClientAuthenticationFilter : Set SecurityContextHolder authentication to OAuth2ClientAuthenticationToken
# And then I try to use this token for default OAuth provided endpoints (result: 200 OK)
23:17:16.212 [nio-8080-exec-6] o.s.security.web.FilterChainProxy : Securing GET /userinfo
23:17:16.217 [nio-8080-exec-6] o.s.web.client.RestTemplate : HTTP POST http://localhost:8080/oauth2/introspect
23:17:16.221 [nio-8080-exec-6] o.s.web.client.RestTemplate : Accept=[application/json, application/*+json]
23:17:16.222 [nio-8080-exec-6] o.s.web.client.RestTemplate : Writing [{token=[2Jd2M-Pq3Cx8We9gKVpfosvAnNGjprCJoyA6-gHOH3t2_cbpVaGsmGkgJ1n9wzam_kvvL4cthCUwSCNRWrfm_uGZJUtFWJjL_jaaKla0p37MDwkPbrGGhoJOGLeGDSrC]}] with org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter
# Also for opaque token introspection (result: 200 OK)
23:17:16.227 [nio-8080-exec-5] o.s.security.web.FilterChainProxy : Securing POST /oauth2/introspect
23:17:16.294 [nio-8080-exec-5] o.s.a.w.OAuth2ClientAuthenticationFilter : Set SecurityContextHolder authentication to OAuth2ClientAuthenticationToken
23:17:16.307 [nio-8080-exec-6] o.s.web.client.RestTemplate : Response 200 OK
23:17:16.308 [nio-8080-exec-6] o.s.web.client.RestTemplate : Reading to [java.util.Map<java.lang.String, java.lang.Object>]
23:17:16.320 [nio-8080-exec-6] .s.r.a.OpaqueTokenAuthenticationProvider : Authenticated token
23:17:16.320 [nio-8080-exec-6] .s.r.w.a.BearerTokenAuthenticationFilter : Set SecurityContextHolder to BearerTokenAuthentication [Principal=org.springframework.security.oauth2.server.resource.introspection.OAuth2IntrospectionAuthenticatedPrincipal@49bc387c, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[SCOPE_openid, SCOPE_user:read, SCOPE_user:write]]
# After that I try to send request to my REST API controller (result: 401 + redirect)
23:17:43.504 [nio-8080-exec-8] o.s.security.web.FilterChainProxy : Securing GET /api/user/get
23:17:43.504 [nio-8080-exec-8] o.s.s.w.a.AnonymousAuthenticationFilter : Set SecurityContextHolder to anonymous SecurityContext
23:17:43.504 [nio-8080-exec-8] o.s.s.w.session.SessionManagementFilter : Request requested invalid session id 9FC445DE02E5AA86CF6C7D898290112F
23:17:43.505 [nio-8080-exec-8] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to team.flow.server.api.controller.UserApiController#getUser(Long, Authentication)
23:17:43.505 [nio-8080-exec-8] o.s.s.w.s.HttpSessionRequestCache : Saved request http://localhost:8080/api/user/get?continue to session
23:17:43.505 [nio-8080-exec-8] o.s.s.web.DefaultRedirectStrategy : Redirecting to http://localhost:8080/auth/sign-in
23:17:43.512 [nio-8080-exec-9] o.s.security.web.FilterChainProxy : Securing GET /auth/sign-in
23:17:43.512 [nio-8080-exec-9] o.s.s.w.a.AnonymousAuthenticationFilter : Set SecurityContextHolder to anonymous SecurityContext
23:17:43.512 [nio-8080-exec-9] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to team.flow.server.auth.controller.AuthFrontController#handleDefaultRequest()
23:17:43.512 [nio-8080-exec-9] o.s.security.web.FilterChainProxy : Secured GET /auth/sign-in
23:17:43.512 [nio-8080-exec-9] o.s.web.servlet.DispatcherServlet : GET "/auth/sign-in", parameters={}
23:17:43.512 [nio-8080-exec-9] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to team.flow.server.auth.controller.AuthFrontController#handleDefaultRequest()
23:17:43.513 [nio-8080-exec-9] o.s.web.servlet.DispatcherServlet : Completed 200 OK
Postman console screenshot for the last request:
Security configuration:
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private static final List<String> FLOW_OAUTH2_SCOPES = List.of(
"openid",
"user:read", "user:write"
);
private final UserRepository userRepository;
private final FlowAuthenticationHandler authenticationHandler;
@Bean
@Order(1)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.oidc(Customizer.withDefaults());
return http
.exceptionHandling((exceptions) -> exceptions
.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/auth"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
))
.oauth2ResourceServer((resourceServer) -> resourceServer
.opaqueToken(Customizer.withDefaults()))
.build();
}
@Bean
@Order(2)
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(authorizeRequests -> authorizeRequests
.requestMatchers("/error", "/flow-web/**", "/favicon.ico").permitAll()
.requestMatchers("/auth/complete").authenticated()
.requestMatchers("/auth/**", "/logout").permitAll()
.requestMatchers("/oauth2/code").permitAll()
.requestMatchers(HttpMethod.GET, "/api/user/**").hasAuthority("SCOPE_user:read")
.requestMatchers(HttpMethod.POST, "/api/user/**").hasAuthority("SCOPE_user:write")
.anyRequest().authenticated())
.sessionManagement(sessionManagement -> sessionManagement
.maximumSessions(1))
.formLogin(formLogin -> formLogin
.loginPage("/auth/sign-in")
.loginProcessingUrl("/auth/sign-in")
.successHandler(authenticationHandler)
.failureHandler(authenticationHandler)
.usernameParameter("email")
.passwordParameter("password"))
.logout(logout -> logout
.deleteCookies("JSESSIONID")
.logoutUrl("/logout")
.logoutSuccessUrl("/auth"))
.build();
}
@Bean
public UserDetailsService userDetailsService() {
return new FlowUserDetailsService(userRepository);
}
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient flowClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("")
.clientName("")
.clientSecret("")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("http://localhost:8080/oauth2/code")
.postLogoutRedirectUri("http://localhost:8080/auth")
.scopes(scopes -> scopes.addAll(FLOW_OAUTH2_SCOPES))
.clientSettings(ClientSettings.builder()
.requireAuthorizationConsent(false)
.requireProofKey(true)
.build())
.tokenSettings(TokenSettings.builder()
.accessTokenFormat(OAuth2TokenFormat.REFERENCE) // yes, I use opaque tokens here
.authorizationCodeTimeToLive(Duration.ofSeconds(30))
.accessTokenTimeToLive(Duration.ofDays(3))
.refreshTokenTimeToLive(Duration.ofDays(14))
.reuseRefreshTokens(false)
.build())
.build();
return new InMemoryRegisteredClientRepository(flowClient);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityContextRepository securityContextRepository() {
return new HttpSessionSecurityContextRepository();
}
REST API Controller:
@RestController
@RequestMapping("/api/user")
@RequiredArgsConstructor
public class UserApiController {
private final UserApiService userApiService;
@GetMapping("/get")
public ResponseEntity<?> getUser(@RequestParam(name = "id", required = false) Long userId) throws ApiException {
if (userId < 1) {
return ResponseEntity.badRequest().build();
}
User user = userApiService.getUser(userId);
return ResponseEntity.ok(UserModel.constructFrom(user));
}
@GetMapping("/meta/get")
public ResponseEntity<?> getUserMeta(@RequestParam(name = "id", required = false) Long userId) throws ApiException {
if (userId < 1) {
return ResponseEntity.badRequest().build();
}
UserMeta userMeta = userApiService.getUserMeta(userId);
return ResponseEntity.ok(UserMetaModel.constructFrom(userMeta));
}
}
Application config:
spring:
datasource:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://127.0.0.1/${DATABASE}
username: ${USERNAME}
password: ${PASSWORD}
jpa:
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
hibernate:
ddl-auto: update
open-in-view: false
security:
oauth2:
resourceserver:
opaquetoken:
introspection-uri: http://localhost:8080/oauth2/introspect
client-id: my_client_id
client-secret: my_client_secret
logging:
level:
root: INFO
'[org.springframework.web]': DEBUG
'[org.springframework.security]': DEBUG
'[org.springframework.security.oauth2]': DEBUG
org.springframework.security.web.FilterChainProxy: DEBUG
server:
servlet:
session:
cookie:
same-site: lax
error:
whitelabel:
enabled: false
path: /error
Part of maven project configuration:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.0</version>
<relativePath/>
</parent>
<dependencies>
<!-- Spring Boot Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- Thymeleaf Extras: Spring Security 5 -->
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
<version>3.1.1.RELEASE</version>
<scope>compile</scope>
</dependency>
<!-- PostgreSQL JDBC Driver -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>
With resource server configuration
Both the authorization server endpoints and the ones exposing Thymeleaf pages require OAuth2 client configuration in which requests are secured with sessions (and require CSRF protection).
To have REST API endpoints secured with access tokens (and without session, CSRF protection, login or logout), define a third security filter-chain dedicated to resource server endpoints.
To keep your defaultSecurityFilterChain
with OAuth2 client config as default, change its order to @Order(3)
, and insert a resourceServerFilterChain
with @Order(2)
and a security-matcher like http.securityMatcher("/api/**")
so that it only matches REST API routes and lets the defaultSecurityFilterChain
process all requests that were intercepted by none of the security filter-chains with lower @Order
.
You might refer to my tutorials for resource server configuration and applications with both OAuth2 client and OAuth2 resource server configuration (but nothing there about Spring's authorization-server, as I'm using other solutions as OpenID Providers).