'Logout user via Keycloak REST API doesn't work

I have issue while calling Keycloak's logout endpoint from an (mobile) application.

This scenario is supported as stated in its documentation:

/realms/{realm-name}/protocol/openid-connect/logout

The logout endpoint logs out the authenticated user.

The user agent can be redirected to the endpoint, in which case the active user session is logged out. Afterward the user agent is redirected back to the application.

The endpoint can also be invoked directly by the application. To invoke this endpoint directly the refresh token needs to be included as well as the credentials required to authenticate the client.

My request has following format:

POST http://localhost:8080/auth/realms/<my_realm>/protocol/openid-connect/logout
Authorization: Bearer <access_token>
Content-Type: application/x-www-form-urlencoded

refresh_token=<refresh_token>

but this error always occurs:

HTTP/1.1 400 Bad Request
Connection: keep-alive
X-Powered-By: Undertow/1
Server: WildFly/10
Content-Type: application/json
Content-Length: 123
Date: Wed, 11 Oct 2017 12:47:08 GMT

{
  "error": "unauthorized_client",
  "error_description": "UNKNOWN_CLIENT: Client was not identified by any client authenticator"
}

It seems that Keycloak is unable to detect the current client's identity event if I've provided access_token. I've the used same access_token to access other Keycloak's APIs without any problems, like userinfo (/auth/realms//protocol/openid-connect/userinfo).

My request was based on this Keycloak's issue. The author of the issue got it worked but it is not my case.

I'm using Keycloak 3.2.1.Final.

Do you have that same problem? Have you got any idea how to solve it?



Solution 1:[1]

Finally, I've found the solution by looking at the Keycloak's source code: https://github.com/keycloak/keycloak/blob/9cbc335b68718443704854b1e758f8335b06c242/services/src/main/java/org/keycloak/protocol/oidc/endpoints/LogoutEndpoint.java#L169. It says:

If the client is a public client, then you must include a "client_id" form parameter.

So what I was missing is the client_id form parameter. My request should have been:

POST http://localhost:8080/auth/realms/<my_realm>/protocol/openid-connect/logout
Authorization: Bearer <access_token>
Content-Type: application/x-www-form-urlencoded

client_id=<my_client_id>&refresh_token=<refresh_token>

The session should be destroyed correctly.

Solution 2:[2]

Works with Keycloak 6.0.

Just for clarity: we do expire refreshToken, but accessToken IS STILL VALID while "Access Token Lifespan" time. Next time user tries to renew access token passing refresh token, Keycloak returns 400 Bad request, what should be catch and send as 401 Unauthorised response.

public void logout(String refreshToken) {
    try {
        MultiValueMap<String, String> requestParams = new LinkedMultiValueMap<>();
        requestParams.add("client_id", "my-client-id");
        requestParams.add("client_secret", "my-client-id-secret");
        requestParams.add("refresh_token", refreshToken);

        logoutUserSession(requestParams);

    } catch (Exception e) {
        log.info(e.getMessage(), e);
        throw e;
    }
}

private void logoutUserSession(MultiValueMap<String, String> requestParams) {
    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

    HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(requestParams, headers);
    String realmName = "your-realm-name";
    String url = "/auth/realms/" + realmName + "/protocol/openid-connect/logout";

    restTemplate.postForEntity(url, request, Object.class);
    // got response 204, no content
}

Solution 3:[3]

Finally. It worked for me. I made a REST call as shown below:

Headers:

{
 "Authorization" : "Bearer <access_token>",
 "Content-Type" : "application/x-www-form-urlencoded"
}

Request Body:

{
    "client_id" : "<client_id>",
    "client_secret" : "<client_secret>",
    "refresh_token" : "<refresh_token>"
}

Method:

POST

URL:

<scheme>://<host>:<port>/auth/realms/<realmName>/protocol/openid-connect/logout

I received 200 as a response... If you do anything wrong you will get 401 or 400 errors. It's very tough to debug this issue. BTW my keycloak version is 12.0.4

Let me know if the post is not clear or if you need more information.

Solution 4:[4]

in version 3.4 you need as x-www-form-urlencoded body key client_id, client_secret and refresh_token.

Solution 5:[5]

FYI: OIDC spec and Google's implementation has a token revocation endpoint

It was implemented in Keycloak 10. See Keycloak JIRA for details

Solution 6:[6]

This approach does not require any manual endpoint triggers. It relies on LogoutSuccessHandler and particularly on OidcClientInitiatedLogoutSuccessHandler that checks if end_session_endpoint is present on ClientRegistration bean.

By some circumstances end_session_endpoint is not used by default on most auth providers (except Okta) when paired with Spring Security, and we are left to inject it into ClientRegistration manually. The easiest way was to put it before InMemoryClientRegistrationRepository initialization, right after application.properties or application.yaml loading.

package com.tb.ws.cscommon.config;

import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties;
import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientPropertiesRegistrationAdapter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Configuration
public class ClientRegistrationConfig {

  @Bean
  @ConditionalOnMissingBean({ClientRegistrationRepository.class})
  InMemoryClientRegistrationRepository clientRegistrationRepository(
      OAuth2ClientProperties properties) {
    List<ClientRegistration> registrations =
        OAuth2ClientPropertiesRegistrationAdapter.getClientRegistrations(properties)
            .values()
            .stream()
            .map(
                o ->
                    ClientRegistration.withClientRegistration(o)
                        .providerConfigurationMetadata(
                            Map.of(
                                "end_session_endpoint",
                                "http://127.0.0.1:8080/auth/realms/OAuth2/protocol/openid-connect/logout"))
                        .build())
            .collect(Collectors.toList());

    return new InMemoryClientRegistrationRepository(registrations);
  }
}

And in WebSecurity:

package com.tb.ws.cscommon.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler;
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;

@Slf4j
@EnableWebSecurity
public class WebSecurity extends WebSecurityConfigurerAdapter {
  private final InMemoryClientRegistrationRepository registrationRepository;

  public WebSecurity(InMemoryClientRegistrationRepository registrationRepository) {
    this.registrationRepository = registrationRepository;
  }

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    String[] permitAccess = new String[] {"/", "/styles/**"};

    http.authorizeRequests()
        .antMatchers(permitAccess)
        .permitAll()
        .anyRequest()
        .authenticated()
        .and()
        .oauth2Login()
        .and()
        .logout(
            logout -> {
              logout.logoutSuccessHandler(logoutSuccessHandler());
              logout.invalidateHttpSession(true);
              logout.clearAuthentication(true);
              logout.deleteCookies("JSESSIONID");
            });
  }

  private LogoutSuccessHandler logoutSuccessHandler() {
    OidcClientInitiatedLogoutSuccessHandler handler =
        new OidcClientInitiatedLogoutSuccessHandler(registrationRepository);
    handler.setPostLogoutRedirectUri("http://127.0.0.1:8005/");

    return handler;
  }
}

By default, Spring Security appends query parameters id_token_hint and post_logout_redirect_uri onto end_session_endpoint. This can be changed with OidcClientInitiatedLogoutSuccessHandler handler. This can be used with social providers. Just have a relevant end_session_endpoint for each provider.

Properties file application.yaml used in this example:

spring:
  application:
    name: cs-common
  main:
    banner-mode: off
  security:
    oauth2:
      client:
        registration:
          cs-common-1:
            client_id: cs-common
            client-secret: 03e2f8e1-f150-449c-853d-4d8f51f66a29
            scope: openid, profile, roles
            authorization-grant-type: authorization_code
            redirect_uri: http://127.0.0.1:8005/login/oauth2/code/cs-common-1
        provider:
          cs-common-1:
            authorization-uri: http://127.0.0.1:8080/auth/realms/OAuth2/protocol/openid-connect/auth
            token-uri: http://127.0.0.1:8080/auth/realms/OAuth2/protocol/openid-connect/token
            jwk-set-uri: http://127.0.0.1:8080/auth/realms/OAuth2/protocol/openid-connect/certs
            user-info-uri: http://127.0.0.1:8080/auth/realms/OAuth2/protocol/openid-connect/userinfo
            user-name-attribute: preferred_username
server:
  port: 8005
eureka:
  client:
    service-url:
      defaultZone: http://127.0.0.1:8004/eureka
  instance:
    instance-id: ${spring.application.name}:${instanceId:${random.value}}

To test we just kick Spring Security's default GET /logout endpoint from the UI.

Misc:

  • Spring Boot 2.5
  • Spring Cloud 2020.0.3
  • Java 11
  • Keycloak Server 13.0.1

Client settings:

  • Standard Flow Enabled
  • Implicit Flow Disabled
  • Direct Access Grants Enabled

Someone, somewhere may find it helpful.

P.S. The app and its properties file are for learning

Solution 7:[7]

I tried this with Keycloak 4.4.0.Final and 4.6.0.Final. I checked the keycloak server log and I saw the following warning messages in the console output.

10:33:22,882 WARN  [org.keycloak.events] (default task-1) type=REFRESH_TOKEN_ERROR, realmId=master, clientId=security-admin-console, userId=null, ipAddress=127.0.0.1, error=invalid_token, grant_type=refresh_token, client_auth_method=client-secret
10:40:41,376 WARN  [org.keycloak.events] (default task-5) type=LOGOUT_ERROR, realmId=demo, clientId=eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJqYTBjX18xMHJXZi1KTEpYSGNqNEdSNWViczRmQlpGS3NpSHItbDlud2F3In0.eyJqdGkiOiI1ZTdhYzQ4Zi1mYjkyLTRkZTYtYjcxNC01MTRlMTZiMmJiNDYiLCJleHAiOjE1NDM0MDE2MDksIm5iZiI6MCwiaWF0IjoxNTQzNDAxMzA5LCJpc3MiOiJodHRwOi8vMTI3Lj, userId=null, ipAddress=127.0.0.1, error=invalid_client_credentials

So how did build the HTTP request? First, I retrieved the user principal from the HttpSession and cast to the internal Keycloak instance types:

KeycloakAuthenticationToken keycloakAuthenticationToken = (KeycloakAuthenticationToken) request.getUserPrincipal();
final KeycloakPrincipal keycloakPrincipal = (KeycloakPrincipal)keycloakAuthenticationToken.getPrincipal();
final RefreshableKeycloakSecurityContext context = (RefreshableKeycloakSecurityContext) keycloakPrincipal.getKeycloakSecurityContext();
final AccessToken accessToken = context.getToken();
final IDToken idToken = context.getIdToken();

Second, I created the logout URL as in the top stack overflow answer (see above):

final String logoutURI = idToken.getIssuer() +"/protocol/openid-connect/logout?"+
            "redirect_uri="+response.encodeRedirectURL(url.toString());

And now I then build the rest of the HTTP request like so:

KeycloakRestTemplate keycloakRestTemplate = new KeycloakRestTemplate(keycloakClientRequestFactory);
HttpHeaders headers = new HttpHeaders();
headers.put("Authorization", Collections.singletonList("Bearer "+idToken.getId()));
headers.put("Content-Type", Collections.singletonList("application/x-www-form-urlencoded"));

And also build the body content string:

StringBuilder bodyContent = new StringBuilder();
bodyContent.append("client_id=").append(context.getTokenString())
            .append("&")
            .append("client_secret=").append(keycloakCredentialsSecret)
            .append("&")
            .append("user_name=").append(keycloakPrincipal.getName())
            .append("&")
            .append("user_id=").append(idToken.getId())
            .append("&")
            .append("refresh_token=").append(context.getRefreshToken())
            .append("&")
            .append("token=").append(accessToken.getId());
HttpEntity<String> entity = new HttpEntity<>(bodyContent.toString(), headers);
//   ...
ResponseEntity<String> forEntity = keycloakRestTemplate.exchange(logoutURI, HttpMethod.POST, entity, String.class); // *FAILURE*

As you can observed, I attempted many variations of theme, but I kept getting invalid user authentication. Oh yeah. I injected the keycloak credentials secret from the application.properties into object instance field with @Value

@Value("${keycloak.credentials.secret}")
private String keycloakCredentialsSecret;

Any ideas from Java Spring Security experienced engineers?

ADDENDUM I created a realm in KC called 'demo' and a client called 'web-portal' with the following parameters:

Client Protocol: openid-connect
Access Type: public
Standard Flow Enabled: On
Implicit Flow Enabled: Off
Direct Access Grants Enabled: On
Authorization Enabled: Off

Here is the code that rebuilds the redirect URI, I forgot to include it here.

final String scheme = request.getScheme();             // http
final String serverName = request.getServerName();     // hostname.com
final int serverPort = request.getServerPort();        // 80
final String contextPath = request.getContextPath();   // /mywebapp

// Reconstruct original requesting URL
StringBuilder url = new StringBuilder();
url.append(scheme).append("://").append(serverName);

if (serverPort != 80 && serverPort != 443) {
    url.append(":").append(serverPort);
}

url.append(contextPath).append("/offline-page.html");

That's all

Solution 8:[8]

According to the code: https://github.com/keycloak/keycloak/blob/master/services/src/main/java/org/keycloak/protocol/oidc/endpoints/LogoutEndpoint.java#L106

This is how it worked for my SpringBoot FX app

GET http://loccalhost:8080/auth/realms/<my_realm>/protocol/openid-connect/logout?post_redirect_uri=your_encodedRedirectUri&id_token_hint=id_token

Solution 9:[9]

In the JWT you have "session_state"

{
    "exp": 1616268254,
    "iat": 1616267954,
     ....
    "session_state": "c0e2cd7a-11ed-4537-b6a5-182db68eb00f",
    ...
}

After

public void testDeconnexion() {
        
        String serverUrl = "http://localhost:8080/auth";
        String realm = "master";
        String clientId = "admin-cli";
        String clientSecret = "1d911233-bfb3-452b-8186-ebb7cceb426c";
        
        String sessionState = "c0e2cd7a-11ed-4537-b6a5-182db68eb00f";

        Keycloak keycloak = KeycloakBuilder.builder()
                .serverUrl(serverUrl)
                .realm(realm)
                .grantType(OAuth2Constants.CLIENT_CREDENTIALS)
                .clientId(clientId)
                .clientSecret(clientSecret) 
                .build();

        String realmApp = "MeineSuperApp";      

        RealmResource realmResource = keycloak.realm(realmApp);
        realmResource.deleteSession(sessionState);      
        
    }

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Aliaksandr Sushkevich
Solution 2
Solution 3 SANDEEP MACHIRAJU
Solution 4 Subodh Joshi
Solution 5
Solution 6
Solution 7
Solution 8 Yaw
Solution 9 Olivier Duchâteau