spring-bootspring-securityspring-authorization-serveroauth-2.1

Spring Auth Server doesn't authorize HTTP requests to secured endpoints by the passed Bearer token


The key point

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.

The main question

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?

The test case

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:

The source code

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>

Solution

  • How to secure my REST API endpoints with Bearer token authentication?

    With resource server configuration

    How to do it in an application already containing client conf?

    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).