angularoauth-2.0oauthapi-gatewayspring-cloud-gateway

Angular CORS Error When Following Backend Redirect for OAuth2 Logout


I'm working on a web application that uses Angular for the frontend and a Spring Boot backend. We have implemented OAuth2 login via Microsoft's identity platform. Logging in works fine, but I've run into an issue when trying to implement the logout.

Here's what happens

  1. The user clicks 'Logout' in the Angular frontend.
  2. An HTTP request issent to a /logout endpoint in my Spring Cloud Gateway.
  3. The backend returns a 3XX redirect to Microsoft's logout URL, something like:
https://login.microsoftonline.com/12345-345-450c-be26-e9df0d55c2fb/oauth2/v2.0/logout?id_token_hint=...&post_logout_redirect_uri=http://localhost:9000

The Angular client tries to follow this URL and then I get a CORS error:

Access to XMLHttpRequest at 'https://login.microsoftonline.com/...' (redirected from 'http://localhost:9000/logout') from origin 'http://localhost:9000' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.

I don't understand why Angular is even trying to follow the redirect like a regular HTTP request. Isn't it supposed to be handled like a typical browser redirect?

Any idea what's going on here? And more importantly, how do I fix this?

Thank you in advance!


Solution

  • RP-Initiated Logout is based on a redirection, not a cross-origin request from your single page or mobile application.

    The reason why you face a cross-origin request is that this redirection is a follow-up of a REST request from your SPA using the HttpClient (the POST to /logout).

    To avoid sending such a cross-origin request, I observe the response of the POST request to /logout and set window.location.href with the value of the response Location header. Something like:

    async logout() {
      return lastValueFrom(
        this.http.post(`/logout`, null, { observe: 'response' })
      ).then((response) => {
        const location = response.headers.get('Location');
        if (!!location) {
          window.location.href = location;
        }
      });
    }
    

    Setting the window location to an external URI "exits" your Angular app to "enter" a new website (as opposed to using the HttpClient).

    But, to observe a response, the HTTP status must be in 2xx range. The easiest way for that is probably to proxy an existing ServerLogoutSuccessHandler to change the response HTTP status. If your authorization server is fully compliant with RP-Initiated Logout (I have no sufficient experience with login.microsoftonline.com to be sure, but from what I can see from the logout URI you provide, it seems to be), using Spring's OidcClientInitiatedServerLogoutSuccessHandler as delegate is a good option.

    Do It Yourself

    static class AngularLogoutSucessHandler implements ServerLogoutSuccessHandler {
        private final OidcClientInitiatedServerLogoutSuccessHandler delegate;
            
        public AngularLogoutSucessHandler(ReactiveClientRegistrationRepository clientRegistrationRepository, String postLogoutRedirectUri) {
            this.delegate = new OidcClientInitiatedServerLogoutSuccessHandler(clientRegistrationRepository);
            this.delegate.setPostLogoutRedirectUri(postLogoutRedirectUri);
            }
    
        @Override
        public Mono<Void> onLogoutSuccess(WebFilterExchange exchange, Authentication authentication) {
            return delegate.onLogoutSuccess(exchange, authentication).then(Mono.fromRunnable(() -> {
                exchange.getExchange().getResponse().setStatusCode(HttpStatus.ACCEPTED);
            }));
        }
    
    }
    
    @Bean
    SecurityWebFilterChain clientFilterCHain(
            ServerHttpSecurity http,
            ReactiveClientRegistrationRepository clientRegistrationRepository,
            @Value("${post-logout-redirect-uri}") String postLogoutRedirectUri) {
    
        http.logout(logout -> {
            logout.logoutSuccessHandler(new AngularLogoutSucessHandler(clientRegistrationRepository, postLogoutRedirectUri));
        });
    
        ...
    
        return http.build()
    }
    

    The post-logout-redirect-uri is expected in properties (should point to your Angular app, with a path accessible to anonymous users).

    Use spring-addons-starter-oidc

    I wrote a Spring Boot starter to extend the auto-confioguration features of spring-boot-starter-oauth2-client and spring-boot-starter-oauth2-resource-server.

    With just a few properties (and no Java conf at all), you can configure RP-Initiated Logout with custom redirection status and post-logout URI. This post-logout URI can even be overridden by the frontend using a custom header or logout request param.

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-client</artifactId>
       </dependency>
       <dependency>
            <groupId>com.c4-soft.springaddons</groupId>
            <artifactId>spring-addons-starter-oidc</artifactId>
            <version>7.8.8</version>
        </dependency>
    </dependencies>
    
    spring:
      security:
        oauth2:
          client:
            provider:
              keycloak:
                issuer-uri: ${oauth2-issuer}
                user-name-attribute: preferred_username
            registration:
              quiz-bff:
                provider: keycloak
                client-id: ${oauth2-client-id}
                client-secret: ${oauth2-client-secret}
                authorization-grant-type: authorization_code
                scope: openid, profile, email, offline_access
    
    com:
      c4-soft:
        springaddons:
          oidc:
            ops:
            - iss: ${oauth2-issuer}
              authorities:
              - path: $.realm_access.roles
            client:
              client-uri: ${gateway-uri}
              security-matchers:
              - /login/**
              - /oauth2/**
              - /logout/**
              - /v1/**
              permit-all:
              - /login/**
              - /oauth2/**
              - /v1/**
              csrf: cookie-accessible-from-js
              post-login-redirect-host: ${reverse-proxy-uri}
              post-login-redirect-path: /ui/
              post-logout-redirect-host: ${reverse-proxy-uri}
              post-logout-redirect-path: /ui/
              oauth2-redirections:
                rp-initiated-logout: ACCEPTED