How can I require consent for each unique anonymous user with Spring Security OAuth2?

965 views Asked by At

My app has a singular endpoint. It triggers an OAuth2 authorization grant flow. It is meant to be called only by anonymous users. Each anonymous user represents a different person with different authorizations in the resource server. Consent (i.e., distinct authorization grant) is required from each anonymous user.

What is configuration in Spring Boot OAuth2 to require a consent for each anonymous user?

I'm using Spring Boot oath2-client 2.6.4 and Spring Security 5.6.2.

Currently, I have oauth2client configuration. It does not satisfy requirement. In this configuration, consent is requested only once and applied to all following anonymous callers. All callers share the same grant and access token.

I sense oauth2login may be the appropriate configuration, but I have needful customizations which I have to overcome before I try oauth2login. I have to disable the generated login page which prompts the user to select a provider, and I have to add custom fields to the authorization request. I have not had any success with these customizations in outh2login. So, this approach feels right, but is seemingly unavailable.

For information about this endpoint's caller, see: HL7 FHIR SMART-APP-LAUNCH

1

There are 1 answers

2
Steve Riesenberg On BEST ANSWER

There are a number of challenges to this, related to:

My app has a singular endpoint. [...] It is meant to be called only by anonymous users.

This requirement makes it difficult for Spring Security to be of much help. This is because anonymous users typically don't have sessions, and the authorization_code grant is a flow which requires state and therefore a session. As a side note, I am not sure I fully understand how or why the specification you linked to (which is built on OAuth 2.0, as far as I can see) makes sense in the context of a client that allows an anonymous user.

Having said that, this seems possible using only the .oauth2Client() support in Spring Security if you create a custom filter for managing anonymous users. Note: The following assumes that the authorization server does not ignore the launch parameter even if a session exists in the browser.

The following configuration defines and configures this filter, as well as customizing the oauth2Client() to pass the launch parameter to the authorization server. It essentially creates a temporary authentication for the launch parameter to be saved as the principalName in the session for the duration of the flow.

@EnableWebSecurity
public class SecurityConfig {

    private static final String PARAMETER_NAME = "launch";

    private static final String ROLE_NAME = "LAUNCH_USER";

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http, ClientRegistrationRepository clientRegistrationRepository) throws Exception {
        http
            .authorizeHttpRequests((authorize) -> authorize
                .anyRequest().hasRole(ROLE_NAME)
            )
            .addFilterAfter(authenticationFilter(), AnonymousAuthenticationFilter.class)
            .oauth2Client((oauth2) -> oauth2
                .authorizationCodeGrant((authorizationCode) -> authorizationCode
                    .authorizationRequestResolver(authorizationRequestResolver(clientRegistrationRepository))
                )
            );
        return http.build();
    }

    private OAuth2AuthorizationRequestResolver authorizationRequestResolver(ClientRegistrationRepository clientRegistrationRepository) {
        DefaultOAuth2AuthorizationRequestResolver authorizationRequestResolver =
                new DefaultOAuth2AuthorizationRequestResolver(clientRegistrationRepository, OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI);

        // Configure a request customizer for the OAuth2AuthorizationRequestRedirectFilter
        authorizationRequestResolver.setAuthorizationRequestCustomizer((authorizationRequest) -> {
            Authentication currentAuthentication = SecurityContextHolder.getContext().getAuthentication();

            // Customize request with principal name originally obtained from request parameter
            if (currentAuthentication instanceof RequestParameterAuthenticationToken) {
                Map<String, Object> additionalParameters = Map.of(PARAMETER_NAME, currentAuthentication.getName());
                authorizationRequest.additionalParameters(additionalParameters);
            }
        });

        return authorizationRequestResolver;
    }

    private RequestParameterAuthenticationFilter authenticationFilter() {
        return new RequestParameterAuthenticationFilter(PARAMETER_NAME, AuthorityUtils.createAuthorityList("ROLE_" + ROLE_NAME));
    }

    /**
     * Authentication filter that authenticates an anonymous request using a request parameter.
     */
    public static final class RequestParameterAuthenticationFilter extends OncePerRequestFilter {

        private final String parameterName;

        private final List<GrantedAuthority> authorities;

        public RequestParameterAuthenticationFilter(String parameterName, List<GrantedAuthority> authorities) {
            this.parameterName = parameterName;
            this.authorities = authorities;
        }

        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
            SecurityContext existingSecurityContext = SecurityContextHolder.getContext();
            if (existingSecurityContext != null && !(existingSecurityContext.getAuthentication() instanceof AnonymousAuthenticationToken)) {
                filterChain.doFilter(request, response);
                return;
            }

            String principalName = request.getParameter(parameterName);
            if (principalName != null) {
                Authentication authenticationResult = new RequestParameterAuthenticationToken(principalName, authorities);
                authenticationResult.setAuthenticated(true);

                SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
                securityContext.setAuthentication(authenticationResult);
                SecurityContextHolder.setContext(securityContext);
            }
            filterChain.doFilter(request, response);
        }

    }

    /**
     * Custom authentication token that can be persisted between requests, but is otherwise very similar to
     * {@link AnonymousAuthenticationToken}.
     */
    public static final class RequestParameterAuthenticationToken extends AbstractAuthenticationToken implements Serializable {

        private static final long serialVersionUID = 1L;

        private final String principalName;

        public RequestParameterAuthenticationToken(String principalName, Collection<? extends GrantedAuthority> authorities) {
            super(authorities);
            this.principalName = principalName;
        }

        @Override
        public Object getPrincipal() {
            return this.principalName;
        }

        @Override
        public Object getCredentials() {
            return this.principalName;
        }

    }

}

You can use this in a controller endpoint, as in the following example:

@RestController
public class LaunchController {

    @GetMapping("/app/launch")
    public void launch(
            @RegisteredOAuth2AuthorizedClient("fhir-client")
                    OAuth2AuthorizedClient authorizedClient) {
        String launchParameter = authorizedClient.getPrincipalName();
        String accessToken = authorizedClient.getAccessToken().getTokenValue();
        // Use authorizedClient.getAccessToken() to make a request (WebClient)...

        // Clear the SecurityContext after the request, to force the next request
        // to start the flow over again
        SecurityContextHolder.clearContext();
    }

}

See related issue #11069 for additional context on this answer.