I am trying to implement single sign-on and single sign-out features in my application which runs on Java 21 and Spring Boot 3.4.5, and uses Keycloak for access management.
Here is my application.properties:
spring.security.oauth2.client.provider.oidcclient.issuer-uri=${AUTH_SERVER:http://localhost:8180/auth}/realms/${REALM:realm}
spring.security.oauth2.client.provider.oidcclient.user-name-attribute=preferred_username
spring.security.oauth2.client.registration.oidcclient.client-id=${RESOURCE:resource}
spring.security.oauth2.client.registration.oidcclient.client-secret=${KEYCLOAK_SECRET:99b22503-44a7-4579-ba86-373c9ce56270}
spring.security.oauth2.client.registration.oidcclient.client-name=OIDC-Client
spring.security.oauth2.client.registration.oidcclient.provider=oidcclient
spring.security.oauth2.client.registration.oidcclient.scope=openid,profile,email
Here is pom.xml dependencies:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
Here is my security config:
@Bean
public SecurityFilterChain webOAuth2FilterChain(HttpSecurity httpSecurity,
ClientRegistrationRepository clientRegistrationRepository)
throws Exception {
return httpSecurity.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(
authorize -> authorize.anyRequest().authenticated())
.headers(headers
-> headers.frameOptions(
HeadersConfigurer.FrameOptionsConfig::sameOrigin))
.oauth2Login(
login -> { login.loginPage("/oauth2/authorization/oidcclient"); })
.oauth2Client(Customizer.withDefaults())
.logout(logout
-> logout.logoutSuccessHandler(
oidcLogoutSuccessHandler(clientRegistrationRepository)))
.oidcLogout((logout) -> logout.backChannel(Customizer.withDefaults()))
.build();
}
@Bean
public LogoutSuccessHandler oidcLogoutSuccessHandler(
ClientRegistrationRepository clientRegistrationRepository) {
OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler =
new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository);
oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}");
return oidcLogoutSuccessHandler;
}
Here is the Keycloak configuration:
{
"clientId": "b22-emcs-business",
"rootUrl": "https://view-business-b22-emcs-new.dev.openshift.local",
"adminUrl": "https://view-business-b22-emcs.dev.openshift.local",
"baseUrl": "/",
"surrogateAuthRequired": false,
"enabled": true,
"alwaysDisplayInConsole": false,
"clientAuthenticatorType": "client-secret",
"redirectUris": [
"https://view-business-b22-emcs-new.dev.openshift.local/*",
"https://view-business-b22-emcs.dev.openshift.local/*"
],
"webOrigins": [
"+"
],
"notBefore": 0,
"bearerOnly": false,
"consentRequired": false,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": true,
"serviceAccountsEnabled": true,
"publicClient": false,
"frontchannelLogout": false,
"protocol": "openid-connect",
"attributes": {
"saml.multivalued.roles": "false",
"saml.force.post.binding": "false",
"frontchannel.logout.session.required": "false",
"oauth2.device.authorization.grant.enabled": "false",
"backchannel.logout.revoke.offline.tokens": "false",
"saml.server.signature.keyinfo.ext": "false",
"use.refresh.tokens": "true",
"oidc.ciba.grant.enabled": "false",
"backchannel.logout.session.required": "true",
"client_credentials.use_refresh_token": "false",
"saml.client.signature": "false",
"require.pushed.authorization.requests": "false",
"saml.allow.ecp.flow": "false",
"saml.assertion.signature": "false",
"id.token.as.detached.signature": "false",
"client.secret.creation.time": "1696319788",
"saml.encrypt": "false",
"saml.server.signature": "false",
"exclude.session.state.from.auth.response": "false",
"saml.artifact.binding": "false",
"saml_force_name_id_format": "false",
"tls.client.certificate.bound.access.tokens": "false",
"acr.loa.map": "{}",
"saml.authnstatement": "false",
"display.on.consent.screen": "false",
"token.response.type.bearer.lower-case": "false",
"saml.onetimeuse.condition": "false",
"backchannel.logout.url": "https://view-business-b22-emcs-new.dev.openshift.local/logout/connect/back-channel/oidcclient"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": false,
"nodeReRegistrationTimeout": -1,
"defaultClientScopes": [
"web-origins",
"roles",
"profile",
"client_roles_to_userinfo",
"email"
],
"optionalClientScopes": [
"address",
"phone",
"offline_access",
"microprofile-jwt"
],
"access": {
"view": true,
"configure": true,
"manage": true
}
}
Single sign-on works, but single sign-out works only if it is triggered from my application. When the user in the other application signs out, this user is not signed out from my application.
Am I missing some config in order to make this work?
First thing, with oauth2Login
, request are authorized with sessions (not Bearer
tokens), which makes your system vulnerable to CSRF attacks => never disable protection against CSRF in a filter-chain with oauth2Login
(or formLogin
). You can do it safely only in filter-chains using oauth2ResourceServer
or basic
auth (and you should not mix oauth2Login
and oauth2ResourceServer
in the same filter-chain).
Single sign out is composed of two things: Back-Channel Logout and RP-Initiated Logout.. The 1st relies for 1/2 on the OP configuration and the 2nd was missing before your edit.
The pupose of the Back-Channel Logout is to notify your app with oauth2Login
that another app closed the user session on the OpenID Provider. It is a message sent by the OP to the oauth2Client
on an endpoint that you must register in the OP conf. In your Keycloak conf, there is no backchannel.logout.url
=> Back-Channel Logout configuration is missing in Keycloak => Keycloak won't call your Spring app with oauth2Login
when another Relying Party (RP) ends the user session.
But for such notifications to be relayed by the OP, RPs should use RP-Initiated Logout. This will be cascaded by the OP to other RPs for which the Back-Channel Logout is configured. To enable RP-Initiated Logout in your app, configure an OidcClientInitiatedLogoutSuccessHandler
:
http.logout(logout -> logout.logoutSuccessHandler(oidcLogoutSuccessHandler(clientRegistrationRepository)));
private LogoutSuccessHandler oidcLogoutSuccessHandler(ClientRegistrationRepository clientRegistrationRepository) {
OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler =
new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository);
oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}");
return oidcLogoutSuccessHandler;
}
As reminders
oauth2Login
) logout endpointYou may consider using this starter of mine which helps configuring all of the above with just application properties: both RP and Back-Channel logouts, as well as CSRF protection (cookie-based if your frontend is a SPA)