This is a workaround for
Keycloak: Adding a custom field for the token
endpoint POST to get Single-Sign-Off to work · Issue #980 · scribejava/scribejava
See that issue and comments in KeycloakLogoutableOAuth20Service.java for more details
This is a workaround for
Keycloak: Adding a custom field for the token
endpoint POST to get Single-Sign-Off to work · Issue #980 · scribejava/scribejava
See that issue and comments in KeycloakLogoutableOAuth20Service.java for more details
package dk.lessor.oauth2; | |
import com.github.scribejava.apis.KeycloakApi; | |
import com.github.scribejava.core.httpclient.HttpClient; | |
import com.github.scribejava.core.httpclient.HttpClientConfig; | |
import com.github.scribejava.core.oauth.OAuth20Service; | |
import java.io.OutputStream; | |
import java.util.concurrent.ConcurrentHashMap; | |
import java.util.concurrent.ConcurrentMap; | |
/** See description of KeycloakLogoutableOAuth20Service as to why this exists */ | |
public class KeycloakLogoutableApi extends KeycloakApi { | |
private static final ConcurrentMap<String, KeycloakLogoutableApi> INSTANCES = new ConcurrentHashMap<>(); | |
protected KeycloakLogoutableApi(String baseUrlWithRealm) { | |
super(baseUrlWithRealm); | |
} | |
public OAuth20Service createService(String apiKey, String apiSecret, String callback, String defaultScope, | |
String responseType, OutputStream debugStream, String userAgent, HttpClientConfig httpClientConfig, | |
HttpClient httpClient) { | |
return new KeycloakLogoutableOAuth20Service(this, apiKey, apiSecret, callback, defaultScope, responseType, debugStream, userAgent, | |
httpClientConfig, httpClient); | |
} | |
public static KeycloakApi instance(String baseUrl, String realm) { | |
final String defaultBaseUrlWithRealm = composeBaseUrlWithRealm(baseUrl, realm); | |
//java8: switch to ConcurrentMap::computeIfAbsent | |
KeycloakLogoutableApi api = INSTANCES.get(defaultBaseUrlWithRealm); | |
if (api == null) { | |
api = new KeycloakLogoutableApi(defaultBaseUrlWithRealm); | |
final KeycloakApi alreadyCreatedApi = INSTANCES.putIfAbsent(defaultBaseUrlWithRealm, api); | |
if (alreadyCreatedApi != null) { | |
return alreadyCreatedApi; | |
} | |
} | |
return api; | |
} | |
} |
package dk.lessor.oauth2; | |
import com.github.scribejava.apis.KeycloakApi; | |
import com.github.scribejava.core.httpclient.HttpClient; | |
import com.github.scribejava.core.httpclient.HttpClientConfig; | |
import com.github.scribejava.core.model.OAuth2AccessToken; | |
import com.github.scribejava.core.model.OAuthRequest; | |
import com.github.scribejava.core.oauth.AccessTokenRequestParams; | |
import com.github.scribejava.core.oauth.OAuth20Service; | |
import java.io.IOException; | |
import java.io.OutputStream; | |
import java.util.concurrent.ExecutionException; | |
/** | |
* We want to be able to have "instant" logout. For this to occur with keycloak, | |
* we need keycloak to call us with a AdminURL webhook. Until keycloak 12, | |
* this is the "old" keycloak-specific way of doing it. | |
* | |
* After the release of Keycloak 12, we'll be able to use an RFC based way of doing it instead. See: | |
* [KEYCLOAK-2940] OpenID Connect Back-Channel Logout | |
* https://issues.redhat.com/browse/KEYCLOAK-2940 | |
* | |
* Unfortunately, for the legacy, pre-version-12 approach we need the token endpoint to be called | |
* with an extra, non-standard "client_session_state" parameter as described in: | |
* [KEYCLOAK-15234] Admin URL k_logout event is not always called when client logs out | |
* https://issues.redhat.com/browse/KEYCLOAK-15234. Beause of that we need to send the "client_session_state" | |
* parameter for Scribejava too, as described in: | |
* Keycloak: Adding a custom field for the `token` endpoint POST to get Single-Sign-Off to work | |
* https://github.com/scribejava/scribejava/issues/980 | |
* | |
* That is what this class, KeycloakLogoutableOAuth20Service and its companion KeycloakLogoutableApi is for. | |
* | |
* To use it, use a KeycloakLogoutableApi instead of a KeycloakApi. So where | |
* https://github.com/scribejava/scribejava/wiki/getting-started says: | |
* | |
* final OAuth10aService service = new ServiceBuilder("your_api_key") | |
* .apiSecret("your_api_secret") | |
* .build(TwitterApi.instance()); | |
* | |
* that would be this, for out-of-the-box KeycloakApi: | |
* | |
* final OAuth20Service service = new ServiceBuilder("your_api_key") | |
* .apiSecret("your_api_secret") | |
* .build(KeycloakApi.instance(baseURL, realm)); | |
* | |
* Instead of KeycloakApi, use the KeycloakLogoutableApi. | |
* | |
* final OAuth20Service service = new ServiceBuilder("your_api_key") | |
* .apiSecret("your_api_secret") | |
* .build(KeycloakLogoutableApi.instance(baseURL, realm)); | |
* | |
* Now, the OAuth20Service service returned by build() is really a KeycloakLogoutableOAuth20Service instance, | |
* so we can cast it as such and do: | |
* | |
* OAuth2AccessToken token = ((KeycloakLogoutableOAuth20Service) service).getAccessToken(code, sessionId); | |
* | |
* Done this way, Keycloak's AdminURL will be called when any client in the session logs out, | |
* achieving Single-Sign-Out | |
* | |
* This is not the most elegant approach, but it is the most elegant one I can think of that | |
* doesn't require *any* changes to ScribeJava | |
* (as decribed in https://github.com/scribejava/scribejava/issues/980) | |
* | |
* */ | |
public class KeycloakLogoutableOAuth20Service extends OAuth20Service { | |
KeycloakLogoutableOAuth20Service( | |
KeycloakApi api, | |
String apiKey, | |
String apiSecret, | |
String callback, | |
String defaultScope, | |
String responseType, | |
OutputStream debugStream, | |
String userAgent, | |
HttpClientConfig httpClientConfig, | |
HttpClient httpClient) { | |
super(api, apiKey, apiSecret, callback, defaultScope, responseType, debugStream, userAgent, | |
httpClientConfig, httpClient); | |
} | |
OAuth2AccessToken getAccessToken(String code, String sessionId) throws IOException, InterruptedException, ExecutionException { | |
OAuthRequest request = createAccessTokenRequest(AccessTokenRequestParams.create(code)); | |
request.addParameter("client_session_state", sessionId); | |
return sendAccessTokenRequestSync(request); | |
} | |
} |