Skip to content

Move the OAuth2AuthorizationEndpointFilter further back in the chain to allow for stateless session authentication #797

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
its-felix opened this issue Jul 2, 2022 · 13 comments
Assignees
Labels
status: declined A suggestion or change that we don't feel we should currently apply

Comments

@its-felix
Copy link

Expected Behavior
When using stateless sessions ("no session") it should still be possible for Authentication Filters to take place before the OAuth2AuthorizationEndpointFilter expects an authentication.

Current Behavior
The OAuth2AuthorizationEndpointFilter is added before AbstractPreAuthenticatedProcessingFilter.

Whenever this filter is executed without already having an authenticated user (which is always the case at this point in the chain for stateless sessions) it will proceed the chain, expecting to be called again from a new http request once the user logged in.

In the case of stateless sessions, the chain will later authenticate the user but the OAuth2AuthorizationEndpointFilter will not be executed again.

Context
I would like to completely get rid of sessions in my authorization server.

I have set the SessionCreationPolicy to STATELESS. Users login using oauth2Login and the oauth2Login.successHandler sends a cookie containing a signed JWT containing the users information.
For all requests after an login I have enabled the oauth2ResourceServer with a custom BearerTokenResolver (to read the JWT from the cookie). This leads to the problem described above when trying to use it together with the authorization server.

Some (WIP) code for context:

Authorization Server Config

    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE + 1)
    public SecurityFilterChain oauth2ServerHttpSecurityFilterChain(HttpSecurity http,
                                                                   Customizer<SecurityContextConfigurer<HttpSecurity>> securityContextCustomizer,
                                                                   Customizer<RequestCacheConfigurer<HttpSecurity>> requestCacheCustomizer,
                                                                   Customizer<OAuth2LoginConfigurer<HttpSecurity>> oauth2LoginCustomizer,
                                                                   Customizer<OAuth2ResourceServerConfigurer<HttpSecurity>> oauth2ResourceServerCustomizer) throws Exception {

        final OAuth2AuthorizationServerConfigurer<HttpSecurity> authorizationServerConfigurer = new OAuth2AuthorizationServerConfigurer<>();
        authorizationServerConfigurer.authorizationEndpoint((authorizationEndpoint) -> {
            authorizationEndpoint
                    .authenticationProvider(CustomOAuth2AuthorizationCodeRequestAuthenticationProvider.create(http))
                    .consentPage(OAUTH2_CONSENT_PAGE);
        });

        final RequestMatcher endpointsMatcher = authorizationServerConfigurer.getEndpointsMatcher();

        http
                .requestMatcher(endpointsMatcher)
                .authorizeRequests((auth) -> auth.anyRequest().authenticated())
                .csrf((csrf) -> csrf.ignoringRequestMatchers(endpointsMatcher))
                .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .securityContext(securityContextCustomizer)
                .requestCache(requestCacheCustomizer)
                .oauth2Login(oauth2LoginCustomizer)
                .oauth2ResourceServer(oauth2ResourceServerCustomizer)
                .apply(authorizationServerConfigurer);

        return http.build();
    }

Other SecurityConfigurations

@Bean
    public RequestCache requestCache() {
        return new CookieRequestCache();
    }

    @Bean
    public Customizer<SecurityContextConfigurer<HttpSecurity>> securityContextCustomizer() {
        return (sc) -> sc.securityContextRepository(new RequestAttributeSecurityContextRepository());
    }

    @Bean
    public Customizer<RequestCacheConfigurer<HttpSecurity>> requestCacheCustomizer(RequestCache requestCache) {
        return (rc) -> rc.requestCache(requestCache);
    }

    @Bean
    public Customizer<OAuth2LoginConfigurer<HttpSecurity>> oauth2LoginCustomizer(@Qualifier("oauth2-authorization-s3-client") AmazonS3 s3,
                                                                                 @Value("${com.gw2auth.oauth2.client.s3.bucket}") String bucket,
                                                                                 @Value("${com.gw2auth.oauth2.client.s3.prefix}") String prefix,
                                                                                 Gw2AuthInternalJwtConverter authenticationSerializer,
                                                                                 RequestCache requestCache) {

        final AuthorizationRequestRepository<OAuth2AuthorizationRequest> authorizationRequestRepository = new S3AuthorizationRequestRepository(s3, bucket, prefix);
        final SavedRequestAwareAuthenticationSuccessHandler delegate = new SavedRequestAwareAuthenticationSuccessHandler();
        delegate.setRequestCache(requestCache);
        delegate.setDefaultTargetUrl("/account");

        return (oauth2) -> {
            oauth2
                    .loginPage("/login")
                    .authorizationEndpoint(authEndpoint -> authEndpoint.authorizationRequestRepository(authorizationRequestRepository))
                    .successHandler((request, response, authentication) -> {
                        final Object principal = authentication.getPrincipal();
                        if (principal instanceof Gw2AuthLoginUser user) {
                            final Jwt jwt = authenticationSerializer.writeJWT(user.issuer(), user.idAtIssuer());
                            CookieHelper.addCookie(request, response, Constants.ACCESS_TOKEN_COOKIE_NAME, jwt.getTokenValue(), jwt.getExpiresAt());
                        }

                        delegate.onAuthenticationSuccess(request, response, authentication);
                    });
        };
    }

    @Bean
    public Customizer<OAuth2ResourceServerConfigurer<HttpSecurity>> oauth2ResourceServerCustomizer(Gw2AuthAuthenticationManagerResolver gw2AuthAuthenticationManagerResolver) {
        return (oauth2) -> {
            oauth2
                    .bearerTokenResolver(new CookieBearerTokenResolver(Constants.ACCESS_TOKEN_COOKIE_NAME))
                    .authenticationManagerResolver(gw2AuthAuthenticationManagerResolver);
        };
    }

    @Bean
    public Customizer<CsrfConfigurer<HttpSecurity>> csrfCustomizer() {
        return (csrf) -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
    }

    @Bean
    @Order(2)
    public SecurityFilterChain frontendHttpSecurityFilterChain(HttpSecurity http,
                                                               Customizer<SecurityContextConfigurer<HttpSecurity>> securityContextCustomizer,
                                                               Customizer<RequestCacheConfigurer<HttpSecurity>> requestCacheCustomizer,
                                                               Customizer<OAuth2LoginConfigurer<HttpSecurity>> oauth2LoginCustomizer,
                                                               Customizer<OAuth2ResourceServerConfigurer<HttpSecurity>> oauth2ResourceServerCustomizer,
                                                               Customizer<CsrfConfigurer<HttpSecurity>> csrfCustomizer) throws Exception {
        http
                .authorizeRequests((auth) -> auth.antMatchers("/", "/login", "/privacy-policy", "/legal", "/faq", "/**/*.css", "/**/*.js", "/assets/**", "/favicon.ico", "/robots.txt").permitAll().anyRequest().authenticated())
                .csrf(csrfCustomizer)
                .headers((headers) -> {
                    headers
                            .frameOptions().deny()
                            .contentSecurityPolicy((csp) -> csp.policyDirectives(String.join("; ",
                                    "default-src 'self'",
                                    "connect-src 'self' https://api.guildwars2.com",
                                    "script-src 'self' 'unsafe-inline'",
                                    "style-src 'self' 'unsafe-inline'",
                                    "img-src 'self' https://icons-gw2.darthmaim-cdn.com/ data:",
                                    "frame-src https://www.youtube.com/embed/"
                            )));
                })
                .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .securityContext(securityContextCustomizer)
                .requestCache(requestCacheCustomizer)
                .oauth2Login(oauth2LoginCustomizer)
                .oauth2ResourceServer(oauth2ResourceServerCustomizer)
                .logout((logout) -> {
                    logout
                            .deleteCookies(Constants.ACCESS_TOKEN_COOKIE_NAME)
                            .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler());
                });

        return http.build();
    }

    @Bean
    @Order(1)
    public SecurityFilterChain apiHttpSecurityFilterChain(HttpSecurity http,
                                                          Customizer<CsrfConfigurer<HttpSecurity>> csrfCustomizer,
                                                          Customizer<SecurityContextConfigurer<HttpSecurity>> securityContextCustomizer,
                                                          Customizer<OAuth2ResourceServerConfigurer<HttpSecurity>> oauth2ResourceServerCustomizer) throws Exception {
        http
                .antMatcher("/api/**")
                .authorizeRequests((auth) -> {
                    auth
                            .antMatchers("/api/authinfo", "/api/application/summary").permitAll()
                            .anyRequest().authenticated();
                })
                .csrf(csrfCustomizer)
                .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .securityContext(securityContextCustomizer)
                .oauth2ResourceServer(oauth2ResourceServerCustomizer);

        return http.build();
    }
@its-felix its-felix added the type: enhancement A general enhancement label Jul 2, 2022
@its-felix
Copy link
Author

This is related to #551 / https://stackoverflow.com/questions/69484979/spring-authorization-server-how-to-use-login-form-hosted-on-a-separate-applicat/69577014#69577014

however, in the case of stateless session it doesn't work like that because this solution requires the /oauth2/authorize request to be replayed in a separate HTTP-request (where the user would again not be authenticated because the filter that authenticates the user would be executed after the OAuth2AuthorizationEndpointFilter

@its-felix
Copy link
Author

I tested locally with a one-line change to the OAuth2AuthorizationEndpointConfigurer:

builder.addFilterBefore(postProcess(authorizationEndpointFilter), AnonymousAuthenticationFilter.class);
// instead of
// builder.addFilterBefore(postProcess(authorizationEndpointFilter), AbstractPreAuthenticatedProcessingFilter.class);

This change makes it work with the sessionless authentication and the existing tests do not fail (gradle build succeeds)

@sjohnr
Copy link
Member

sjohnr commented Jul 15, 2022

@its-felix, thanks for reaching out! I believe there are a number of good reasons why the filter is placed where it is, but I can't enumerate them all confidently without more research.

I'm curious, have you tried configuring your own instance of OAuth2AuthorizationEndpointFilter and adding it after the AnonymousAuthenticationFilter? I recognize that would not be convenient, but as you can imagine, customizing the core of the framework for every use case would be impossible. We would however like to make every valid use case possible through customization in the application.

@sjohnr sjohnr added the status: waiting-for-feedback We need additional information before we can continue label Jul 15, 2022
@its-felix
Copy link
Author

its-felix commented Jul 15, 2022

@sjohnr I understand this would be a critical change. I understand this would not be required for most usecases. Making it customizable would be great though.

Yes, I currently run https://github.com/gw2auth/oauth2-server (https://gw2auth.com/) with a custom build of this project (https://github.com/its-felix/spring-authorization-server/tree/maven-publish)

I also have a good number of integration test for the authorization server (see https://github.com/gw2auth/oauth2-server/blob/main/src/test/java/com/gw2auth/oauth2/server/oauth2/OAuth2ServerTest.java).

I introduced stateless sessions and this change with this commit: gw2auth/oauth2-server@49cfaca

@spring-projects-issues spring-projects-issues added status: feedback-provided Feedback has been provided and removed status: waiting-for-feedback We need additional information before we can continue labels Jul 15, 2022
@jgrandja
Copy link
Collaborator

@its-felix I'd like to understand your use case better.

I would like to completely get rid of sessions in my authorization server.

I don't see how this would work? The authorization_code grant requires an authenticated user (Resource Owner) to authorize the client before it can obtain an access token. Even in the cases where authorization consent is not required (auto-consent) there is still the requirement of the Resource Owner being authenticated since the authorized scope(s) (and/or claims) need to be saved and associated with the authenticated Resource Owner.

Users login using oauth2Login and the oauth2Login.successHandler sends a cookie containing a signed JWT containing the users information.

Where is the user logging into? I'm assuming the IdP integrated with the Authorization Server?

@its-felix
Copy link
Author

its-felix commented Jul 19, 2022

Hi @jgrandja ,

the user (resource owner) will be authenticated later in the chain. They are authenticated by either OAuth2LoginAuthenticationFilter or BearerTokenAuthenticationFilter. But since I don't have sessions enabled in my application, each request must be re-authenticated by said filters again. This doesn't work together with the OAuth2AuthorizationEndpointFilter because it is located prior to all other authenticating filters in the filterchain.

This is the authentication flow for unauthenticated users (oauth2Login):

  • Unauthenticated user accesses protected URL
  • My application redirects to /login
  • User clicks one of the 3 buttons to login with an external IdP
  • My application stores OAuth2AuthorizationRequest in S3 (see)
  • If successfully authenticated at IdP, IdP redirects back to my application (with state and code params)
  • My application creates a signed JWT containing an internal session-id claim (not a spring / tomcat session) and sends this JWT as a cookie to the browser (see)

This is the authentication flow for authenticated users (oauth2ResourceServer):

  • The signed JWT mentioned above is automatically sent by the browser because it's a Cookie and is resolved using a custom BearerTokenResolver
  • The JWT is validated using a RSA KeyPair and the session-id claim is used to resolve the corresponding account (again, this is not a spring / tomcat managed session)

In summary:

  • The first flow (oauth2Login) authenticates users that don't show a valid login cookie and will create such a cookie on success
  • Subsequent requests are authenticated using the second flow (oauth2ResourceServer).
  • Authentications are not kept in application state. Every HTTP request will go through either of the two authentication processes again
  • Everything in this authentication process is not related to the functions as authorization-server of this application.

As I said, I currently actively use this approach. https://gw2auth.com uses exactly this including the change in the linked PR to make this work.
I run 2 nodes of my application, with sessions enabled this required me to sync session state between those nodes. I used hazelcast for this. With the change to stateless sessions I was able to remove the dependency on hazelcast completely and both nodes can operate without knowing each other.

@jgrandja
Copy link
Collaborator

@its-felix Thanks for the detailed explanation. I now understand your flow and the issue you are having. I'll check to see if we can move the OAuth2AuthorizationEndpointFilter further back in the chain without any issues.

I understand you don't want the underlying container to manage sessions but have you considered delegating this to Spring Session?

Also, Spring Security provides session management protection out-of-the-box that you won't be able to take advantage of with the custom solution you have implemented. I'm assuming you're aware of this but wanted to point it out either way.

@jgrandja jgrandja removed the status: feedback-provided Feedback has been provided label Jul 20, 2022
@jgrandja jgrandja assigned jgrandja and unassigned sjohnr Jul 20, 2022
@its-felix
Copy link
Author

Hi @jgrandja ,

thanks for getting back quickly :)
I did not look into these things since I didn’t know about them, I will have a look for sure (mostly for future projects).

I decided to implement this on my own because I want to migrate some of the non oauth2 Server related endpoints of this application to AWS Lambda which will have to validate sessions on its own.
I don’t want to route all requests through the spring application, that’s why I don’t want to rely on spring managed sessions here.

In the future my infrastructure will look roughly like this:

gw2auth.com ->
AWS CloudFront ->
/oauth2 paths -> LB -> spring application
/api paths (API for the UI) -> AWS Lambda
/* (fallthrough) -> S3 Objects (html etc)

@jgrandja
Copy link
Collaborator

@its-felix According to Section 4.1.1 Authorization Request:

The authorization server validates the request to ensure that all
required parameters are present and valid.
...
If the request is valid, the authorization server authenticates the
resource owner and obtains an authorization decision...

The current flow has been implemented to spec. IMO, this makes sense because there is no point in authenticating the resource owner if the authorization request is invalid. Instead, the validation should occur first to enable a fail-fast approach and short-circuit the request if it's invalid. Furthermore, when the authorization request is valid but the resource owner is not authenticated, the valid authorization request can be saved in the RequestCache and later replayed after the resource owner successfully authenticates (this is how the flow is implemented today).

This is the main reason the OAuth2AuthorizationEndpointFilter is placed before the authentication filters. If we moved it after the authentication filters, then authentication would take place first and then an invalid authorization request would fail with no chance of replaying it. This would degrade the user experience.

I'm curious on...

Subsequent requests are authenticated using the second flow (oauth2ResourceServer).

Can you provide more info on the type of requests? Are these requests to your API endpoints? If so, can you provide details on the type of API it is.

FYI, another customization you can apply to move you ahead with your issue is providing a custom AuthenticationConverter, for example:

OAuth2AuthorizationServerConfigurer<HttpSecurity> authorizationServerConfigurer =
		new OAuth2AuthorizationServerConfigurer<>();
authorizationServerConfigurer
		.authorizationEndpoint(authorizationEndpoint ->
				authorizationEndpoint
						.authorizationRequestConverter(customAuthorizationRequestConverter)

The default AuthenticationConverter is OAuth2AuthorizationCodeRequestAuthenticationConverter, which initializes the principal using SecurityContextHolder.getContext().getAuthentication(). Instead of using the default AuthenticationConverter, you can configure a custom one similar to OAuth2AuthorizationCodeRequestAuthenticationConverter that initializes the principal from the Jwt Cookie. This customization should move you ahead.

@jgrandja jgrandja added the status: waiting-for-feedback We need additional information before we can continue label Jul 25, 2022
@jgrandja
Copy link
Collaborator

@its-felix In addition to the suggestion above:

FYI, another customization you can apply to move you ahead with your issue is providing a custom AuthenticationConverter ...

You can also configure a custom SecurityContextRepository that reads the JWT Cookie and returns a SecurityContext representation of it when loadContext() is called. See the reference documentation on how to configure it.

The custom SecurityContextRepository is the recommended solution out of the two provided.

I'm going to close this issue and associated PR as the 2 solutions provided will move you forward.

@jgrandja jgrandja added status: declined A suggestion or change that we don't feel we should currently apply and removed status: waiting-for-feedback We need additional information before we can continue type: enhancement A general enhancement labels Jul 26, 2022
@its-felix
Copy link
Author

@jgrandja

Can you provide more info on the type of requests? Are these requests to your API endpoints? If so, can you provide details on the type of API it is.

This is for all subsequent requests (including "UI" requests returning HTML pages). Since the oauth2ResourceServer-Configuration uses the CookieBearerTokenResolver this works in my case for every requests after a successful login through oauth2Login (the Cookie used in the BearerTokenResolver is created in the successHandler of oauth2Login).

My application doesn't provide any API endpoints which are meant for "external" use (beyond the oauth2-authorization-server endpoints). All API endpoints are exclusively to be used by the applications UI code from Javascript.

Thank you for the hints! I will go forward with the suggested approach using a custom SecurityContextRepository

@its-felix
Copy link
Author

its-felix commented Jul 28, 2022

Just FYI and for future readers, I was able to implement the expected behavior using the proposed solution (custom SecurityContextRepository with this change: gw2auth/oauth2-server@v1.28.0...v1.29.1

I'm now using the latest release (0.3.1) again instead of the temporary custom build.

@jgrandja
Copy link
Collaborator

Excellent! Great to hear it's all working out for you @its-felix 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
status: declined A suggestion or change that we don't feel we should currently apply
Projects
None yet
Development

No branches or pull requests

4 participants