Search code examples
javakeycloakopenid-connectspring-oauth2

Refresh token not used by Spring OAuth2 when access token expires


Hello fellow developers! I'm struggling for a couple of days now to find out why Spring OAuth2 is not using the refresh token returned for the authorization_code grant when the access token expires.

Setup

Basically, I was following this tutorial to setup everything needed. My Keycloak is running on localhost:8090 and I've configured it like this:

package com.my.project.config.security;

import com.my.project.security.authentication.CustomOidcClientInitiatedLogoutSuccessHandler;
import jakarta.servlet.http.Cookie;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProvider;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder;
import org.springframework.security.oauth2.client.endpoint.DefaultRefreshTokenTokenResponseClient;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.*;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
import org.springframework.security.web.server.authentication.RedirectServerAuthenticationEntryPoint;

@Configuration
public class WebSecurityConfig {

    public static String[] PUBLIC_PATHS = {
            "/api/csrf",
            "/oauth2/login",
            "/oauth2/logout",
            "/oauth2/error",
            "/api/contact-form"
    };

    @Bean
    public SecurityFilterChain securityFilterChain(final HttpSecurity http,
                                                   @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
                                                   final ClientRegistrationRepository clientRegistrationRepository) throws Exception {

        final CustomOidcClientInitiatedLogoutSuccessHandler clientInitiatedLogoutSuccessHandler =
                new CustomOidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository);
        clientInitiatedLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}/oauth2/login?redirect=true");

        final DefaultOAuth2AuthorizationRequestResolver oAuth2AuthorizationRequestResolver =
                new DefaultOAuth2AuthorizationRequestResolver(clientRegistrationRepository,
                        OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI);
        oAuth2AuthorizationRequestResolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce());


        return http
                .csrf(Customizer.withDefaults())
                .cors(Customizer.withDefaults())
                .authorizeHttpRequests((customizer) -> customizer
                                .requestMatchers(PUBLIC_PATHS).permitAll()
                                .anyRequest().authenticated())

                .oauth2ResourceServer((oauth2) -> oauth2
                    .jwt(Customizer.withDefaults()))
                .oauth2Client(configurer -> {
                    //override behaviour of authentication: don't redirect but change status and add location header.
                    //it's a bit hacky, but otherwise we get CORS errors on client side, because through the redirect we're running into cross-origin issues
                    //and keycloak is just not setting correct CORS headers :/

                    //delete this hack when bug https://github.com/keycloak/keycloak/pull/27334 is fixed
                

                    configurer.authorizationCodeGrant(customizer -> {
                        customizer.authorizationRedirectStrategy((request, response, url) -> {
                            response.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, HttpHeaders.LOCATION);
                            response.setHeader(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, HttpHeaders.LOCATION);
                            response.addCookie(new Cookie("test", System.currentTimeMillis() + ""));
                            response.setHeader(HttpHeaders.LOCATION, url);
                            if ("true".equals(request.getParameter("redirect"))) {
                                response.setStatus(302);
                            } else {
                                response.setStatus(201);
                            }


                        });

                        customizer.authorizationRequestResolver(oAuth2AuthorizationRequestResolver);
                    });
                })

                .oauth2Login(configurer -> {

                    configurer.userInfoEndpoint(customizer -> customizer.userAuthoritiesMapper(customGrantedAuthoritiesMapper()));
                    configurer.loginPage("/oauth2/login");
                    configurer.defaultSuccessUrl("/oauth2/success");
                    configurer.failureUrl("/oauth2/error");
                })

                .logout(configurer -> {
                    configurer.invalidateHttpSession(true);
                    configurer.clearAuthentication(true);
                    configurer.deleteCookies("JSESSIONID");
                    configurer.logoutUrl("/oauth2/logout");
                    configurer.logoutSuccessHandler(clientInitiatedLogoutSuccessHandler);
                })

                //.addFilterBefore(new TokenExpiredFilter(), AnonymousAuthenticationFilter.class)
                .build();
    }

    @Bean
    public GrantedAuthoritiesMapper customGrantedAuthoritiesMapper() {
        return new CustomGrantedAuthoritiesMapper();
    }

}

I needed a couple of customizations, because I had a hard time with Keycloak not setting any ACCESS-CONTROL-ALLOW_ORIGIN headers. So I followed a suggestion to change the default redirect (HTTP 302) to a 2xx code while setting the location header. In the frontend code (React) I then do a window.location to the value of that location header. For the custom logout handler I'm doing the same. Otherwise it's a copy of SimpleUrlLogoutSuccessHandler.

My application.yml looks like this:

service:
  mock: false

keycloak:
  realm-id: my-realm-id
  base-uri: http://localhost:8090
  base-rest-uri: ${keycloak.base-uri}/admin/realms/${keycloak.realm-id}
  token-uri: ${keycloak.base-uri}/realms/${keycloak.realm-id}/protocol/openid-connect/token

logging:
  level:
    root: INFO


server:
  port: 8080
  servlet:
    session:
      timeout: 15s
      cookie:
        same-site: none
        http-only: true
        secure: true

  error:
    include-message: never
    include-binding-errors: never
    include-stacktrace: never
    include-exception: false

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/db-name
    driverClassName: org.postgresql.Driver
    username: my-user
    password: my-password
  jpa:
    open-in-view: false
    hibernate:
      ddl-auto: none
    properties:
      database-platform: org.hibernate.dialect.PostgreSQL10Dialect
    show-sql: false

  security:
    oauth2:
      client:
        registration:
          keycloak:
            client_id: my-client-id
            client_secret: my-client-secret
            authorization-grant-type: authorization_code
            scope: openid
        provider:
          keycloak:
            issuerUri: ${keycloak.base-uri}/realms/${keycloak.realm-id}
            user-name-attribute: preferred_username
      resourceserver:
        jwt:
          issuer-uri: ${keycloak.base-uri}/realms/${keycloak.realm-id}

  flyway:
    url: ${spring.datasource.url}
    user: ${spring.datasource.username}
    password: ${spring.datasource.password}
    locations: classpath:/db/migration/ddl 

Behaviour / Problem description

When I try to request a protected resource in my Spring backend, I get a redirect to /oauth2/login which redirects to /oauth2/authorization/keycloak which finally "redirect" to Keycloak's login page. This works nicely, I never get in touch with any user credentials and the frontend knows only a JSESSIONID. Also logout works fine, the session in the Keycloak realm is destroyed successfully and the Spring session gets destroyed too.

My struggles start when it comes to an expired access token. For testing purposes I've set the access token lifespan to 10 seconds in Keycloak and Spring's session lifespan to 15 seconds. Whenever I request something from my protected backend and the session is expired, the login process starts again, redirecting to /oauth2/login, which redirects to /oauth2/authorization/keycloak and finally to the oauth2 login success url configured, without seeing a login form again. In the Keycloak's user events I see another user login and code to token event with a new access token as the result.

When I debug Spring's code, I see that the authorization_code grant is returning both access token and refresh token correctly, but it seems that the refresh token is never used again. Unfortunately, I cannot find out what is wrong, but I've read that Spring should handle the renewal of the access token using a refresh token out of the box. But

org.springframework.security.oauth2.client.RefreshTokenOAuth2AuthorizedClientProvider#authorize

is never called, nor is

org.springframework.security.oauth2.client.endpoint.DefaultRefreshTokenTokenResponseClient#getTokenResponse

I can see that

org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter#attemptAuthentication

is calling

this.authorizedClientRepository.saveAuthorizedClient(authorizedClient, oauth2Authentication, request, response)

This seems to be the last trail of the refresh token, as it does not exist in the OAuth2AuthenticationToken returned from that method.

Unfortunately, no breakpoint in any implementation of

org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository#loadAuthorizedClient

is ever called to retrieve the refresh token again.

Any help is appreciated, as I'm really stuck here. Thank you very much for any response, questions and help!


Solution

  • So I'm going to share all my findings here, because I truly believe that many other people have, had or will have the same problems I've experienced.

    The main problem was that having oauth2Login() and oauth2ResourceServer() inside the same filter chain is just not doing what I've expected. Let me explain: I wanted to protect my REST api (resource server) with oauth2. So I followed and tried a couple of tutorials out there where they are indeed using both inside the same filter chain. At first glance, everything worked fine. I was able to log in using OAuth2 (Keycloak) and to access the REST api accordingly. But Spring was only inspecting the token during log in and never again after that point. So after login with a valid token, Spring starts a session and as long as the session does not expire, a user stays logged in. No matter if the access token already had expired or not, Spring is never ever again checking the access token. Also, because of that, Spring is not refreshing the access token by using the refresh token that have been correctly provided by Keycloak. If the session expires, Spring starts a new login workflow and Keycloak returns a new access token (and refresh token). But that leads to a refresh on client side, because of the redirects happening during the authorization code grant.

    Thanks to ch4mp and his great tutorial about the BFF pattern, I got it running in the end. He's basically separating functionality into three different applications:

    1. an oauth2 resource server (REST api)
    2. an oauth2 client (BFF) and
    3. a reverse proxy

    The resource server is a completely stateless REST api that gets an OAuth2 access token passed to each and every request. There is no login functionality involved in this part of this application.

    The BFF/Oauth2 client application handles the login part and calls the resource server via REST. It also handles the requests from the client application (React in my case) using sessions (stateful). No access token is ever passed to the client. After login, Spring creates the session and sets a cookie on the client side accordingly. Because of the TokenRelay functionality used by the BFF application, Spring exchanges this session with an access token when calling the resource server. That is the point, when Spring checks the access token for validity and refreshes it using the refresh token if necessary.

    The reverse proxy basically hides this complexity from the client by forwarding requests to the correct endpoints. So the single point of contact for the client is the reverse proxy (see the diagram in the linked tutorial). I've tried to have the BFF and reverse proxy inside the same application first, but that didn't work, because Spring is contacting Keycloak on startup to fetch the endpoints needed to e.g. validate tokens. But that happens before the reverse proxy has been started, ending in exceptions because of 404 errors (those URLs don't exist before the reverse proxy has been started). It's a better idea to have the applications separated anyway, especially because of separations of concerns and scaling.

    Another advantage of the reverse proxy is that it eliminates the problems with Keycloak not setting CORS headers correctly. Thus, CORS isn't an issue anymore, because from the perspective of the client, Keycloak, BFF and the REST api are on the same host.

    Maybe it would be possible somehow to get the same things done with multiple security filter chains in one application instead of separating it to three applications. But I didn't get it running and from an architectural point of view it's the better idea to separate them anyway.

    I hope that this answer helps someone that is struggling with the same problems. Unfortunately, documentation of Spring is not very helpful in that case, especially because of major changes in Spring Security 6.2, rendering many code samples useless. Also, most tutorials I've found about this topic did it worng, probably not realizing the "hidden" problems described above.

    Thanks again ch4mp for your help and the great tutorial! (see the discussions here and also in the SO chat).