Describe the bug Given I have: - a Spring OAuth2 client configured with Back-Channel Logout - an authorization server with Back-Channel Logout configured to call the Spring OAuth2 client

When I try to visit a path requiring to be authorized on the Spring client, then I am redirected to login.

Once authenticated, I can access the protected resource.

When I visit the end_session_endpoint of the authorization server, then I can see a TRACE log on the Spring client displaying: Found and removed 1 session(s) from mapping of 1 session(s)

However, if I refresh the tab pointing to the protected resource, I can still access it.

There are two log lines after the one stating that the session was removed:

ExchangeFunctions : [1c75356f] HTTP POST http://host.docker.internal:7080/logout, headers={masked}
ExchangeFunctions : [1c75356f] Cancel signal (to close connection)

Is the client trying to call its own /logout endpoint but using the wrong hostname? host.docker.internal is the hostname used by the authorization server to initiate the Back-Channel Logout (which is running in Docker), but this hostname is unknown from the Spring client.

Expected behavior If the Spring client needs to call itself to actually destroy the user session, shouldn't it call localhost?

Sample

https://github.com/ch4mpy/spring-security-14553

run docker compose up to create a new Keycloak instance (admin / admin to access http://localhost:8080/realms/master)

create a new realm by importing spring-security-realm.json

create a user

start the spring boot app

visit http://localhost:7080/

the end_session_endpoint is at http://localhost:8080/realms/spring-security/protocol/openid-connect/logout

Comment From: jzheaux

@ch4mpy, good observation. I think we can change this:

String logout = UriComponentsBuilder.fromHttpUrl(url)
            .replacePath(this.logoutEndpointName)
            .build()
            .toUriString();

to:

String logout = UriComponentsBuilder.fromHttpUrl(url)
+           .host("localhost")
            .replacePath(this.logoutEndpointName)
            .build()
            .toUriString();

to address the issue.

Comment From: ch4mpy

@jzheaux thank you.

Comment From: jk-tagbangers

In the production environment, the logout endpoint is accessed via https. With the current implementation, requests to https://localhost will fail due to a different default port.

e.g. https://example.com/logout/connect/back-channel/my-registration -> https://localhost/logout

So it seems like you need to change the schema and port to your own values.

Or rewrite hostname to localhost only host.docker.internal.

Comment From: lmorocz

@jzheaux

Rewriting only the host name to localhost is not correct when the app (client) is behind a reverse proxy with TLS termination. In this case schema (HTTPS vs. HTTP) and/or port number is often different for localhost and the original hostname, as @koyama-tagbangers mentioned earlier.

The application context-path is another thing to consider here, see #14181.

One could use the internal host name/address and port number of the client in the server backchannel logout configuration, but only if the IDP (e.g. Keycloak) can access these internal hosts at all, which is often not possible.

Could we replace the POST request for the local logout resource (LogoutFilter) with something else?

Comment From: ch4mpy

One could use the internal host name/address and port number of the client in the server backchannel logout configuration, but only if the IDP (e.g. Keycloak) can access these internal hosts at all

I think this is a wrong assumption: a server should always be able to call itself with its internal scheme, host and port. The idea here was not to modify the hostname to which the OP sends the requests, just to change what the RP uses to call itself after it received the Back-Channel Logout request. Also, the path used for this additional call(s) is not the one of the Back-Channel Logout endpoint, it is the path of the RP "standard" logout.

Note that in the case of a reverse proxy rewriting the path the previous impl could be broken too: if the request goes through the reverse proxy, the logoutEndpointName (set around line 118 of OidcBackChannel(Server)LogoutHandler) can be altered and the request fail.

Sure that if one finds a way to perform the logout without this second network call, that would eliminate the problem, but I think it was discussed in another ticket already (couldn't remember which one).

Updating the scheme, port and context path with the values we see in the logs after startup (Tomcat started on port 8080 (https) with context path '') would improve the situation, but what if the /logout endpoint is configured to something non-standard? Maybe should we just make this other "internal" logout endpoint entirely configurable?

@jzheaux maybe would it be worth to: - rollback the modification I asked in this ticket (my use-case is certainly less frequent in prod than having the OP call the RP through a reverse proxy with a different scheme or port). An alternative being to keep localhost, but to also set scheme, port and context path (this would probably be a better default in the case of path rewriting between a reverse proxy and the OAuth2 client, but is more work). - on the OidcBackChannelServerLogoutHandler, make all of the logout endpoint URI configurable (not only the path) - open a bit the BackChannelLogoutConfigurer to give a hand on the OidcBackChannelServerLogoutHandler (for now, everything in BackChannelLogoutConfigurer is private, which makes it a relatively useless configurer)

Should I propose a PR for that ?

Comment From: kmeyer-mbs

I just ran into the problem with "localhost". I use a let's encrypt SSL certificate directly in the embedded Tomcat in the local development environment. Due to the host name localhost the handshake fails.

2024-02-29T18:09:35.256+01:00 DEBUG 37790 --- [nio-7443-exec-4] c.a.w.c.o.c.OidcBackChannelLogoutHandler : Failed to invalidate session

org.springframework.web.client.ResourceAccessException: I/O error on POST request for "https://localhost:7443/logout": No subject alternative DNS name matching localhost found.
    at org.springframework.web.client.RestTemplate.createResourceAccessException(RestTemplate.java:915) ~[spring-web-6.1.4.jar:6.1.4]
...

I would also welcome a configuration option.

Many thanks and best regards!

Comment From: dalbani

I confirm that the change in https://github.com/spring-projects/spring-security/commit/2702a64be7efcacce112044d7c5f89dbad2f5454 broke the use of back-channel logout in my application.

As mentioned above, in a situation where the application sits behind a reverse proxy, the connection to, in my case, http://localhost/logout [*] fails.

This is really a blocker for us to be able to upgrade to 3.2.3.

Comment From: daliborfilus

I just wanted to implement OIDC back channel logout and got to the issue with "localhost" and found this ticket. In my experience, the app is never run on the same host and port as it is seen from the outside, there's always some proxying going on, be it on local nginx/haproxy or external nginx/haproxy/F5/whatever. Changing just the host to localhost may work in a very small minority of production environments. (Also note that the hostname is used for routing on the reverse proxy even in the case of local nginx; it may be used for other apps and relies on the hostname for routing to the correct one.) In my case, the app is run in a container on port 8080, the https is terminated on the edge (k8s ingress controller) somewhere else in the cluster. Not only it would not work trying to contact "localhost", but it also would require different port, different scheme.

Let it at least be configurable...

But I don't understand why it needs to do such internal requests against itself at all. The backchannel request came to the app via controllers, yes, it's a different session at that point, but we are inside the app and have access to the session registry. We are looking into it in the code to get the session ID for that token anyway, so it's not like it would be required for Spring Gateway or something. Why not just delete it right at that moment, why does it need to do the request? Some of you mentioned it was discussed somewhere, I'd like to read that discussion.

Comment From: dalbani

I'm interested to know more as well. To be honest, I was quite surprised to see this change included in a patch release (3.2.2), pretty much breaking back-channel logout, without any workaround.

Comment From: ch4mpy

The internal logout URI was made configurable back in March:

http.oidcLogout(ol -> {
    ol.backChannel(bc -> {
        bc.logoutUri("http://localhost:8080/logout");
    });
});

This will work even behind a reverse proxy having different scheme and port.

Comment From: dreamstar-enterprises

I maybe barking up the wrong tree here, but this first principles approach worked for me, in a way that I could also understand. I'm sure people could adapt the below to their needs too

``` ** * Configuration properties for server endpoints and authentication settings. * * @property route53Host AWS Route53 host for production routing * @property route53Port AWS Route53 port number * @property reverseProxyHost Reverse proxy host for development routing * @property reverseProxyPort Reverse proxy port number * @property bffServerPrefix BFF (Backend for Frontend) server prefix * @property resourceServerHost Resource server host address * @property resourceServerPort Resource server port number * @property resourceServerPrefix Resource server endpoint prefix * @property auth0AuthRegistrationId Auth0 registration identifier * @property auth0IssuerUri Auth0 issuer URI * @property auth0ApiAudience Auth0 API audience identifier * @property auth0DreamstarVivianaUrl Auth0 Dreamstar Viviana URL * @property inHouseAuthServerPrefix In-house authorization server prefix * @property inHouseAuthRegistrationId In-house authorization registration ID * @property sslProperties SSL configuration properties */ @ConfigurationProperties(prefix = "dse-viviana-servers") internal class ServerProperties( private val springProfileProperties: SpringProfileProperties, private val sslProperties: SslProperties ) {

/*************************/
/* SERVERS               */
/*************************/

/**
 * Route53 Configuration
 */
var route53Host: String? = null
var route53Port: Int? = null

/**
 * Constructs the complete Route53 URI using HTTPS scheme (External Calls)
 */
val route53Uri: String
    get() = "${HttpSchemeTypesEnum.HTTPS}://$route53Host:$route53Port"

/**
 * Reverse Proxy Configuration
 */
var reverseProxyHost: String? = null
var reverseProxyPort: Int? = null

/**
 * Constructs the complete Reverse Proxy URI using HTTP scheme (External Calls)
 */
val reverseProxyUri: String
    get() = "${HttpSchemeTypesEnum.HTTP}://$reverseProxyHost:$reverseProxyPort"

/**
 * BFF Server Configuration
 */
var bffServerHost: String? = null
var bffServerPort: Int? = null
var bffServerPrefix: String? = null

/**
 * Constructs the complete BFF URI using HTTP scheme (Internal Calls)
 */
val bffUri: String
    get() {
        val scheme = if (sslProperties.sslEnabled) HttpSchemeTypesEnum.HTTPS else HttpSchemeTypesEnum.HTTP
        return "$scheme://$bffServerHost:$bffServerPort"
    }

/**
 * Determines the client URI based on active profile (External Calls)
 * @return URI string for client communications
 */
val clientUri: String
    get() = when (springProfileProperties.activeProfile) {
        ProfileTypesEnum.DEVELOPMENT.type -> "$reverseProxyUri$bffServerPrefix"
        ProfileTypesEnum.PRODUCTION.type -> "$route53Uri$bffServerPrefix"
        else -> "$reverseProxyUri$bffServerPrefix"
    }

/**
 * Resource Server Configuration
 */
var resourceServerHost: String? = null
var resourceServerPort: Int? = null
var resourceServerPrefix: String? = null

/**
 * Constructs the complete Resource Server URI using HTTP scheme (Internal Routing)
 */
val resourceServerUri: String
    get() = "${HttpSchemeTypesEnum.HTTP}://$resourceServerHost:$resourceServerPort"

```