Search code examples
springspring-bootoauth-2.0

Reuse/forward OAuth2 authorization code grant from one resource server to another


I need to implement a kinda "bff" but with oauth2 authorisation code grant. Like, client requests a rest endpoint from "bff", "bff" acts like a resource server; then bff queries rest endpoints from multiple services with feign. These services act as a resource servers for this "bff". Then, "bff" should process the responses, making its own response, and send it to the client then. Something like "Authorization" header forwarding, I suppose. But have no idea how to implement it. Maybe something like spring cloud gateway but a bit more intelligent. Or, maybe it is possible to implement a filter that will just relay this header?

Any documentation or better small but complete code sample would be just great. Thank you!

enter image description here


Solution

  • RequestInterceptor

    What you are looking for is probably a RequestInterceptor. In a Spring boot app, exposing one as a @Component or @Bean (it is a functional interface you can implement with a lambda) should be enough.

    This could be implemented:

    • on an OAuth2 resource server:
    @Bean
    RequestInterceptor resourceServerBearerRequestInterceptor() {
        return (RequestTemplate template) -> {
            final var auth = SecurityContextHolder.getContext().getAuthentication();
            if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes servletRequestAttributes) {
                if (auth instanceof AbstractOAuth2TokenAuthenticationToken oauth) {
                    template.header(HttpHeaders.AUTHORIZATION, "Bearer %s".formatted(oauth.getToken().getTokenValue()));
                }
            }
        };
    }
    
    • on an OAuth2 client with oauth2Login
    @Component
    @RequiredArgsConstructor
    public class ClientBearerRequestInterceptor implements RequestInterceptor {
        private final OAuth2AuthorizedClientRepository authorizedClientRepo;
        
        public void apply(RequestTemplate template) {
            final var auth = SecurityContextHolder.getContext().getAuthentication();
            if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes servletRequestAttributes) {
                if (auth instanceof OAuth2AuthenticationToken oauth) {
                    final var authorizedClient = authorizedClientRepo.loadAuthorizedClient(oauth.getAuthorizedClientRegistrationId(), auth, servletRequestAttributes.getRequest());
                    if (authorizedClient != null) {
                        template.header(HttpHeaders.AUTHORIZATION, "Bearer %s".formatted(authorizedClient.getAccessToken().getTokenValue()));
                    }
                }
            }
        }
    }
    

    If your app is not a Boot app (or if you define your RequestInterceptor out of @ComponentScan), you could have to provide the configuration class as @FeignClient(configuration = ...)

    About the BFF pattern

    The pattern in the question drawing is now discouraged for security reasons:

    • requires to configure the frontends as "public" OAuth2 clients
    • exposes tokens (all of it) to the internet and Javascript / mobile apps code

    As defined in an ITEF document linked by the author of the question in the comments a "Full BFF" is an acronym for backends configured as OAuth2 clients bridging between session authorization (for the frontends) and Bearer header authorization for the requests it forwards to resource servers. However, the pattern exposed in this document diverges from the Full BFF: the OAuth2 client (what retrieves OAuth2 tokens from the authorization server) is on the backend but the BFF exposes access tokens to the frontend so that it can call resource servers directly and session authorization is used only to retrieve access tokens (motivation for this quite unusual pattern is performance, apparently).

    "Full BFF" pattern

    To my knowledge, Spring Security team recommendations is to hide tokens from mobile or Javascript based web apps, and to have it query OAuth2 clients using sessions. Also, all major web apps I know (Google, Facebook, Github, Stackoverflow, Linkedin, and so many more) apply this pattern: inspect those web apps with your browser debugging tools, you won't find a single access token (just session cookies).

    spring-cloud-gateway is a perfect fit to implement a "Full BFF" when configured with spring-boot-starter-oauth2-client (with oauth2Login and CSRF protection), and TokenRelay= filter (this filter replaces the session cookie with the access token in session before forwarding a request). Also, it scales very well when used with Spring Session, which removes the limitation motivating the usage of a "non Full" BFF as exposed in the ITEF document linked above.

    I have written a Full BFF tutorial for Baeldung, but be aware that it is using an additional starter of mine to ease security configuration and that you should probably read pro and cons before using such a lib (this latest link also contains references to instructions for writing security conf without my starter).

    ITEF document BFF

    To implement the pattern described in the ITEF document linked by the question author, where the BFF fetches and stores tokens but exposes access tokens to frontends so that it can call resource servers directly, the BFF app configured as both:

    • an OAuth2 client with oauth2Login to expose an endpoint retrieving the access token value from the OAuth2AuthorizedClientRepository, using the OAuth2AuthenticationToken in the security context
    • an OAuth2 resource server to answer REST requests authorized with an access token the frontend got from the endpoint just above

    For that, you'll need two SecurityFilterChain beans: one with OAuth2 client conf for login and the access-token endpoint, and another one with OAuth2 resource server conf for REST endpoints. This filter-chains beans will need to have

    • different names
    • distinct @Order
    • the first in @Order must define a securityMatcher to define which requests it should process

    For a servlet app and using a starter of mine to ease security config, this would give something like:

    @Component
    public class ResourceServerBearerRequestInterceptor implements RequestInterceptor {
        public void apply(RequestTemplate template) {
            final var auth = SecurityContextHolder.getContext().getAuthentication();
            if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes servletRequestAttributes) {
                if (auth instanceof AbstractOAuth2TokenAuthenticationToken oauth) {
                    template.header(HttpHeaders.AUTHORIZATION, "Bearer %s".formatted(oauth.getToken().getTokenValue()));
                }
            }
        }
    }
    
    @FeignClient(name = "quizzes", url = "${spring.cloud.openfeign.client.quizzes.url}")
    public interface QuizClient {
        @RequestMapping(method = RequestMethod.GET, value = "/quizzes")
        List<QuizDto> getAllQuizzes();
    }
    
    @RestController
    @RequiredArgsConstructor
    @Validated
    @Tag(name = "QuizProxy")
    public class QuizProxyController {
        
        private final QuizClient quizzes;
    
        @GetMapping("/proxy/quizzes")
        public Integer getAccessToken() {
            return quizzes.getAllQuizzes().size();
        }
    }
    
    @RestController
    @RequiredArgsConstructor
    @Validated
    @Tag(name = "AccessToken")
    public class AccessTokenController {
        private final OAuth2AuthorizedClientRepository authorizedClientRepo;
    
        @GetMapping("/access-token")
        @PreAuthorize("isAuthenticated()")
        public AccessTokenDto getAccessToken(OAuth2AuthenticationToken auth, HttpServletRequest request) {
            final var authorizedClient = authorizedClientRepo.loadAuthorizedClient(auth.getAuthorizedClientRegistrationId(), auth, request);
            return authorizedClient == null
                    ? null
                    : new AccessTokenDto(authorizedClient.getAccessToken().getTokenValue(), authorizedClient.getAccessToken().getExpiresAt().getEpochSecond());
        }
    
        public static record AccessTokenDto(String accessToken, long expiry) {
        }
    }
    
    @Configuration
    @EnableMethodSecurity
    public class SecurityConfig {
    }
    
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-openfeign</artifactId>
    </dependency>
    
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-oauth2-client</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
    </dependency>
    <dependency>
        <groupId>com.c4-soft.springaddons</groupId>
        <artifactId>spring-addons-starter-oidc</artifactId>
        <version>7.1.7</version>
    </dependency>
    
    scheme: http
    oauth2-issuer: https://oidc.c4-soft.com/auth/realms/quiz
    oauth2-client-id: quiz-bff
    oauth2-client-secret: change-me
    frontend: ${scheme}://localhost:4200
    quizzes-resources: ${scheme}://localhost:7084
    
    server:
      port: 8080
      error:
        include-message: always
      shutdown: graceful
      ssl:
        enabled: false
        
    
    spring:
      config:
        import:
        - optional:configtree:/workspace/config/
        - optional:configtree:/workspace/secret/
      lifecycle:
        timeout-per-shutdown-phase: 30s
      cloud:
        openfeign:
          client:
            quizzes:
              url: ${quizzes-resources}
      security:
        oauth2:
          client:
            provider:
              c4:
                issuer-uri: ${oauth2-issuer}
            registration:
              c4:
                provider: c4
                authorization-grant-type: authoriztion_code
                client-id: ${OAuth2-client-ID} 
                client-secret: ${OAuth2-client-secret} 
                scope:
                - openid
                - profile
                - email
                - offline_access
              
    com:
      c4-soft:
        springaddons:
          oidc:
            ops:
            - iss: ${oauth2-issuer}
              username-claim: preferred_username
              authorities:
              - path: $.resource_access.${oauth2-client-id}.roles
            client:
              security-matchers:
              - /login/**
              - /oauth2/**
              - /access-token
              permit-all:
              - /login/**
              - /oauth2/**
              cors:
              - path: /**
                allowed-origin-patterns:
                - ${frontend}
              post-login-redirect-host: ${frontend}
              post-login-redirect-path: /
              post-logout-redirect-host: ${frontend}
              post-logout-redirect-path: /
            resourceserver:
              permit-all:
              - "/proxy/**"
              - "/actuator/health/readiness"
              - "/actuator/health/liveness"
              - "/v3/api-docs/**"
              cors:
              - path: /**
                allowed-origin-patterns:
                - ${frontend}
            
    management:
      endpoint:
        health:
          probes:
            enabled: true
      endpoints:
        web:
          exposure:
            include: '*'
      health:
        livenessstate:
          enabled: true
        readinessstate:
          enabled: true
    
    logging:
      level:
        org:
          springframework:
            security: TRACE
    
    ---
    server:
      ssl:
        enabled: true
    
    spring:
      config:
        activate:
          on-profile: ssl
    

    Please note that in addition to adding quite some complexity to both front and back ends (compared to Full BFF), such a solution solves only some of known vulnerabilities for frontends configured as "public" clients:

    • a "confidential" client can be used on the backend
    • refresh tokens (which are the most sensitive ones) can remain on the server

    But access tokens are still exposed to Javascript or mobile apps code.

    Considering how well spring-cloud-gateway scales with Spring session, I'm personally more inclined to implement "Full BFF".