Skip to content

Instantly share code, notes, and snippets.

@sandipchitale
Last active June 27, 2023 17:04
Show Gist options
  • Save sandipchitale/0e6ddb7c5d6432d63dc81b588d90e462 to your computer and use it in GitHub Desktop.
Save sandipchitale/0e6ddb7c5d6432d63dc81b588d90e462 to your computer and use it in GitHub Desktop.
Notes on Spring Security 5 OAuth2 #Spring_Security5_OAuth2_Notes

Introduction

These are notes for OAuth2 Login in Spring Security 5 OAuth2 support for OAuth2 Login.

Auto configuration for OAuth2 Login

  • @OAuth2ClientAutoConfiguration which in turn imports
    • OAuth2ClientRegistrationRepositoryConfiguration
    • OAuth2WebSecurityConfiguration

is conditioned on spring.security.oauth2.client.* (OAuth2ClientProperties) properties being set.

@Configuration OAuth2ClientRegistrationRepositoryConfiguration

  • conditioned on presence of at least one spring.security.oauth2.client.registration
  • By default creates in memory instance of ClientRegistrationRepository if one is not already defined based

@Configuration OAuth2WebSecurityConfiguration

  • conditioned on presence of ClientRegistrationRepository
  • By default creates
    • in memory instance of OAuth2AuthorizedClientService if one is not already defined based
    • instance of AuthenticatedPrincipalOAuth2AuthorizedClientRepository if one OAuth2AuthorizedClientRepository is not already defined based. AuthenticatedPrincipalOAuth2AuthorizedClientRepository wraps the OAuth2AuthorizedClientService instance.
    • Actiavtes OAuth2SecurityFilterChainConfiguration if default web security is in place and creates a oauth2SecurityFilterChain with following defaults:
@Bean
SecurityFilterChain oauth2SecurityFilterChain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests((requests) -> requests.anyRequest().authenticated());
    http.oauth2Login(withDefaults()); <--- This configures `OAuth2LoginConfigurer`
    http.oauth2Client(withDefaults());
    return http.build();
}

Notes: If only one ClientRegistration is present, then the oauth2Login configuration will directly redirect to OAuth2 provider. If more than one ClientRegistration is present, then the oauth2Login configuration will redirect to a page with links to each ClientRegistration's authorization endpoints. But if other authentication mechanisms are configured such as formLogin() but the following really controls what happens:

// File: spring-security-config-6.1.1-sources.jar!/org/springframework/security/config/annotation/web/configurers/ExceptionHandlingConfigurer.java
  240: 		if (this.defaultEntryPointMappings.size() == 1) {
  241: 			return this.defaultEntryPointMappings.values().iterator().next();
  242: 		}

DelegatingAuthenticationEntryPoint -> LoginUrlAuthenticationEntryPoint (This was configured by OAuth2LoginConfigurer as sshown below) -> RedirectStrategy are used to redirect to the login page.

This decision is made here: OAuth2LoginConfigurer's init method.

Map<String, String> loginUrlToClientName = this.getLoginLinks();
if (loginUrlToClientName.size() == 1) {
    // Setup auto-redirect to provider login page
    // when only 1 client is configured
    this.updateAuthenticationDefaults();
    this.updateAccessDefaults(http);
    String providerLoginPage = loginUrlToClientName.keySet().iterator().next();
    this.registerAuthenticationEntryPoint(http, this.getLoginEntryPoint(http, providerLoginPage));
}
else {
    super.init(http); // This will cause the default login page to be rendered with links to each client's authorization endpoint
}

Pattern for OAuth2 Login internal redirect

If there are multiple clients then first redirect is to:

http://localhost:8080/login

And with a links to each client's authorization endpoint:

/oauth2/authorization/<client registration id>

e.g.

http://localhost:8080/oauth2/authorization/github

The page is generated by DefaultLoginPageGeneratingFilter which handles:

if (this.formLoginEnabled) {
}
if (this.oauth2LoginEnabled) {
    // Loop over oauth2AuthenticationUrlToClientName
}
if (this.saml2LoginEnabled) {
}

If there is only one client the first redirect is to:

/oauth2/authorization/<client registration id>

This in turn redirects to provider's authorization endpoint.

https://github.com/login/oauth/authorize

with sample payload like:

response_type: code
client_id: 0191ce83c982a47ff19a
scope: read:user
state: nroExruTvZQWGQdKSZDQn_YNshs1rk9Q_QfLZvDTaSc=
redirect_uri: http://localhost:8080/login/oauth2/code/github

File: spring-security-oauth2-client-6.1.1-sources.jar!/org/springframework/security/oauth2/client/web/OAuth2LoginAuthenticationFilter.java 99: /** 100: * The default {@code URI} where this {@code Filter} processes authentication 101: * requests. 102: / 103: public static final String DEFAULT_FILTER_PROCESSES_URI = "/login/oauth2/code/";

The common providers are configured in CommonOAuth2Provider enum e.g.

@Override
public Builder getBuilder(String registrationId) {
    ClientRegistration.Builder builder = getBuilder(registrationId,
            ClientAuthenticationMethod.CLIENT_SECRET_BASIC, DEFAULT_REDIRECT_URL);
    builder.scope("read:user");
    builder.authorizationUri("https://github.com/login/oauth/authorize");
    builder.tokenUri("https://github.com/login/oauth/access_token");
    builder.userInfoUri("https://api.github.com/user");
    builder.userNameAttributeName("id");
    builder.clientName("GitHub");
    return builder;
}

DefaultOAuth2AuthorizationRequestResolver basically creates a OAuth2AuthorizationRequest for the given URL /oauth2/authorization/github for a given client github.

// File: spring-security-oauth2-client-6.1.1-sources.jar!/org/springframework/security/oauth2/client/web/DefaultOAuth2AuthorizationRequestResolver.java
  154: 		OAuth2AuthorizationRequest.Builder builder = getBuilder(clientRegistration);
  155:
  156: 		String redirectUriStr = expandRedirectUri(request, clientRegistration, redirectUriAction);
  157:
  158: 		// @formatter:off
  159: 		builder.clientId(clientRegistration.getClientId())
  160: 				.authorizationUri(clientRegistration.getProviderDetails().getAuthorizationUri())
  161: 				.redirectUri(redirectUriStr)
  162: 				.scopes(clientRegistration.getScopes())
  163: 				.state(DEFAULT_STATE_GENERATOR.generateKey());
  164: 		// @formatter:on

Basically builds the redirect URI to provider.

A customizer can set on DefaultOAuth2AuthorizationRequestResolver that can customize OAuth2AuthorizationRequest.Builder before the request is sent to the provider. This is where state parameter can be customized. We will use a stragegy such that any instance of Gateway can verify that the state parameter is valid.

This is where the request is saved in a repository (this is where session comes into picture as the default implementation uses HttpSessionOAuth2AuthorizationRequestRepository). We will have to use a non session based implementation which can use HTTP-only, secure cookie to store the serialized, encrypted OAuth2AuthorizationRequest.

// File: spring-security-oauth2-client-6.1.1-sources.jar!/org/springframework/security/oauth2/client/web/OAuth2AuthorizationRequestRedirectFilter.java
  218: 		if (AuthorizationGrantType.AUTHORIZATION_CODE.equals(authorizationRequest.getGrantType())) {
  219: 			this.authorizationRequestRepository.saveAuthorizationRequest(authorizationRequest, request, response);
  220: 		}

It is retrieved back in OAuth2LoginAuthenticationFilter to finish the code -> access token exchange flow.

File: spring-security-oauth2-client-6.1.1-sources.jar!/org/springframework/security/oauth2/client/web/OAuth2LoginAuthenticationFilter.java
  169: 		OAuth2AuthorizationRequest authorizationRequest = this.authorizationRequestRepository
  170: 				.removeAuthorizationRequest(request, response);

OAuth2LoginAuthenticationFilter uses the client to generate the URL for code -> access token exchange.

http://localhost:8080/login/oauth2/code/github


If this is different than the default then you must override e.g.

```properties
spring.security.oauth2.client.registration.github.redirect-uri=http://localhost:8080/login/github

has to match:

			oAuth2LoginConfigurer.redirectionEndpoint(redirectionEndpointConfig -> {
				redirectionEndpointConfig.baseUri("/login/github");
			});

This is where code -> access token exchange happens.

// File: spring-security-oauth2-client-6.1.1-sources.jar!/org/springframework/security/oauth2/client/web/OAuth2LoginAuthenticationFilter.java
  194: 		OAuth2LoginAuthenticationToken authenticationResult = (OAuth2LoginAuthenticationToken) this
  195: 				.getAuthenticationManager().authenticate(authenticationRequest);

The following can convert the OAuth2LoginAuthenticationToken token to OAuth2AuthenticationToken which is the final result.

// File: spring-security-oauth2-client-6.1.1-sources.jar!/org/springframework/security/oauth2/client/web/OAuth2LoginAuthenticationFilter.java
  115: 	private Converter<OAuth2LoginAuthenticationToken, OAuth2AuthenticationToken> authenticationResultConverter = this::createAuthenticationResult;

we will need to override this so that the token has both access token and refresh token.

File: spring-security-oauth2-client-6.1.1-sources.jar!/org/springframework/security/oauth2/client/web/OAuth2LoginAuthenticationFilter.java
  227: 	public final void setAuthenticationResultConverter(

Override OAuth2UserService so as to not call userInfo endpoint

    oauth2Login.userInfoEndpoint(userInfoEndpointConfig -> {
        userInfoEndpointConfig.userService(new OverridingOAuth2UserService());
    });

    public static class OverridingOAuth2UserService extends DefaultOAuth2UserService {
        private static final String MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE = "missing_user_name_attribute";

        public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
            String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint()
                    .getUserNameAttributeName();
            if (!StringUtils.hasText(userNameAttributeName)) {
                OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE,
                        "Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: "
                                + userRequest.getClientRegistration().getRegistrationId(),
                        null);
                throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
            }

            JwtDecoder jwtDecoder = JwtDecoders.fromIssuerLocation(userRequest.getClientRegistration().getProviderDetails().getIssuerUri());
            OAuth2AccessToken accessToken = userRequest.getAccessToken();
            Jwt jwt = jwtDecoder.decode(accessToken.getTokenValue());

            Assert.notNull(userRequest, "userRequest cannot be null");
            OAuth2AccessToken token = userRequest.getAccessToken();
            Set<GrantedAuthority> authorities = new LinkedHashSet<>();
//            for (String authority : token.getScopes()) {
//                authorities.add(new SimpleGrantedAuthority("SCOPE_" + authority));
//            }
            Object authoritiesObject = jwt.getClaims().get("authorities");
            if (authoritiesObject instanceof List<?>) {
                List<String> authoritiesList = (List<String>) authoritiesObject;
                for (String authority : authoritiesList) {
                    authorities.add(new SimpleGrantedAuthority(authority));
                }
            }

            Map<String, Object> userAttributes = new LinkedHashMap<>();
            userAttributes.put(userNameAttributeName, jwt.getClaims().get(userNameAttributeName));
            userAttributes.put("sub", "user");
            return new DefaultOAuth2User(authorities, userAttributes, "sub");
        }
    }

Override AuthorizationRequestRepository to make it stateless and use cookie to store the serialized, encrypted OAuth2AuthorizationRequest

    config.authorizationEndpoint(subconfig -> {
        subconfig.authorizationRequestRepository(this.customStatelessAuthorizationRequestRepository);
    });


@Component
public class CustomStatelessAuthorizationRequestRepository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {

    @Override
    public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
        return this.retrieveCookie(request);
    }

    @Override
    public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {
        if (authorizationRequest == null) {
            this.removeCookie(response);
            return;
        }
        this.attachCookie(response, authorizationRequest);
    }

    @Override
    @Deprecated
    public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request) {
        return this.retrieveCookie(request);
    }

    //
    // There are also some private helper methods in this class for:
    //   - attaching the cookie to the outgoing response
    //   - retrieving the cookie from an incoming request
    //
    // Plus a bit of functionality to encrypt the cookie payload,
    // ensuring that the cookie doesn't get tampered with
    //

}

Customize Oauth2LoginProcessingFilter converter to pick up access token and refresh token

    oauth2Login.withObjectPostProcessor(postProcessor);

    private static OAuth2AuthenticationToken createAuthenticationResult(OAuth2LoginAuthenticationToken authenticationResult) {
        OAuth2AuthenticationToken oAuth2AuthenticationToken = new OAuth2AuthenticationToken(authenticationResult.getPrincipal(), authenticationResult.getAuthorities(),
                authenticationResult.getClientRegistration().getRegistrationId());

        RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
        if (requestAttributes instanceof ServletRequestAttributes servletRequestAttributes) {
            HttpServletRequest httpServletRequest = servletRequestAttributes.getRequest();
            httpServletRequest.setAttribute("access_token", authenticationResult.getAccessToken().getTokenValue());
            if (authenticationResult.getRefreshToken() != null) {
                httpServletRequest.setAttribute("refresh_token", authenticationResult.getRefreshToken().getTokenValue());
            }
        }

        return oAuth2AuthenticationToken;
    }

    private static final Converter<OAuth2LoginAuthenticationToken, OAuth2AuthenticationToken> authenticationResultConverter = AuthConfig::createAuthenticationResult;

    private static final ObjectPostProcessor<OAuth2LoginAuthenticationFilter> postProcessor = new ObjectPostProcessor<>() {
        @Override
        public <O extends OAuth2LoginAuthenticationFilter> O postProcess(O object) {
            object.setAuthenticationResultConverter(authenticationResultConverter);
            return object;
        }
    };

Pass to a controller after login that will set split token via javascript and also the cookie in the response.

    oauth2Login.successHandler(successHandler());

    @Bean
    AuthenticationSuccessHandler successHandler() {
        return new ForwardAuthenticationSuccessHandler("/index");
    }


        @RestController
    public static class GreetController {

        @GetMapping("/index")
        public String index(HttpServletRequest httpServletRequest) {

            return
"""
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>InfoArchive Redirect</title>
</head>
<body>
    <h1>Access Token</h1>
    <pre>
"""
+
httpServletRequest.getAttribute("access_token")
+
    """
    <h1>Refresh Token</h1>
    <pre>
"""
+
httpServletRequest.getAttribute("refresh_token")
+
"""
    </pre>
    <script>
        window.addEventListener("load", () => {
            setTimeout(() => {
                window.location.replace("http://127.0.0.1:8082/");
            }, 10000);
        });
    </script>
</body>
</html>
""";
        }

DefaultAuthorizationCodeTokenResponseClient handles the token response from the authorization server, which contains the access token and optionally a refresh token. The response is converted to an OAuth2AccessTokenResponse and returned to the OAuth2LoginAuthenticationProvider.

OAuth2LoginAuthenticationProvider

OAuth2LoginAuthenticationToken is the result of the authentication process. It contains the OAuth2User and the OAuth2AccessToken that were obtained during the authorization code grant flow.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment