Merge pull request #3721 from hmlnarik/KEYCLOAK-3399-End-session-endpoint-returns-error-when-keycloak-session-is-expired
KEYCLOAK-3399 Ignore user session expiration on OIDC logout
This commit is contained in:
commit
e364680792
6 changed files with 175 additions and 35 deletions
|
@ -316,6 +316,21 @@ public class TokenManager {
|
|||
}
|
||||
}
|
||||
|
||||
public IDToken verifyIDTokenSignature(KeycloakSession session, RealmModel realm, String encodedIDToken) throws OAuthErrorException {
|
||||
try {
|
||||
JWSInput jws = new JWSInput(encodedIDToken);
|
||||
IDToken idToken;
|
||||
if (!RSAProvider.verify(jws, session.keys().getRsaPublicKey(realm, jws.getHeader().getKeyId()))) {
|
||||
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid IDToken");
|
||||
}
|
||||
idToken = jws.readJsonContent(IDToken.class);
|
||||
|
||||
return idToken;
|
||||
} catch (JWSInputException e) {
|
||||
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid IDToken", e);
|
||||
}
|
||||
}
|
||||
|
||||
public AccessToken createClientAccessToken(KeycloakSession session, Set<RoleModel> requestedRoles, RealmModel realm, ClientModel client, UserModel user, UserSessionModel userSession, ClientSessionModel clientSession) {
|
||||
AccessToken token = initToken(realm, client, user, userSession, clientSession, session.getContext().getUri());
|
||||
for (RoleModel role : requestedRoles) {
|
||||
|
|
|
@ -113,18 +113,11 @@ public class LogoutEndpoint {
|
|||
}
|
||||
|
||||
UserSessionModel userSession = null;
|
||||
boolean error = false;
|
||||
if (encodedIdToken != null) {
|
||||
try {
|
||||
IDToken idToken = tokenManager.verifyIDToken(session, realm, encodedIdToken);
|
||||
IDToken idToken = tokenManager.verifyIDTokenSignature(session, realm, encodedIdToken);
|
||||
userSession = session.sessions().getUserSession(realm, idToken.getSessionState());
|
||||
if (userSession == null) {
|
||||
error = true;
|
||||
}
|
||||
} catch (OAuthErrorException e) {
|
||||
error = true;
|
||||
}
|
||||
if (error) {
|
||||
event.event(EventType.LOGOUT);
|
||||
event.error(Errors.INVALID_TOKEN);
|
||||
return ErrorPage.error(session, Messages.SESSION_NOT_ACTIVE);
|
||||
|
|
|
@ -111,6 +111,42 @@ public class OAuthClient {
|
|||
|
||||
private Map<String, PublicKey> publicKeys = new HashMap<>();
|
||||
|
||||
public class LogoutUrlBuilder {
|
||||
private final UriBuilder b = OIDCLoginProtocolService.logoutUrl(UriBuilder.fromUri(baseUrl));
|
||||
|
||||
public LogoutUrlBuilder idTokenHint(String idTokenHint) {
|
||||
if (idTokenHint != null) {
|
||||
b.queryParam("id_token_hint", idTokenHint);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public LogoutUrlBuilder postLogoutRedirectUri(String redirectUri) {
|
||||
if (redirectUri != null) {
|
||||
b.queryParam("post_logout_redirect_uri", redirectUri);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public LogoutUrlBuilder redirectUri(String redirectUri) {
|
||||
if (redirectUri != null) {
|
||||
b.queryParam(OAuth2Constants.REDIRECT_URI, redirectUri);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public LogoutUrlBuilder sessionState(String sessionState) {
|
||||
if (sessionState != null) {
|
||||
b.queryParam("session_state", sessionState);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public String build() {
|
||||
return b.build(realm).toString();
|
||||
}
|
||||
}
|
||||
|
||||
public void init(Keycloak adminClient, WebDriver driver) {
|
||||
this.adminClient = adminClient;
|
||||
this.driver = driver;
|
||||
|
@ -341,10 +377,10 @@ public class OAuthClient {
|
|||
}
|
||||
|
||||
|
||||
public HttpResponse doLogout(String refreshToken, String clientSecret) throws IOException {
|
||||
public CloseableHttpResponse doLogout(String refreshToken, String clientSecret) throws IOException {
|
||||
CloseableHttpClient client = new DefaultHttpClient();
|
||||
try {
|
||||
HttpPost post = new HttpPost(getLogoutUrl(null, null));
|
||||
HttpPost post = new HttpPost(getLogoutUrl().build());
|
||||
|
||||
List<NameValuePair> parameters = new LinkedList<NameValuePair>();
|
||||
if (refreshToken != null) {
|
||||
|
@ -558,15 +594,8 @@ public class OAuthClient {
|
|||
return b.build(realm).toString();
|
||||
}
|
||||
|
||||
public String getLogoutUrl(String redirectUri, String sessionState) {
|
||||
UriBuilder b = OIDCLoginProtocolService.logoutUrl(UriBuilder.fromUri(baseUrl));
|
||||
if (redirectUri != null) {
|
||||
b.queryParam(OAuth2Constants.REDIRECT_URI, redirectUri);
|
||||
}
|
||||
if (sessionState != null) {
|
||||
b.queryParam("session_state", sessionState);
|
||||
}
|
||||
return b.build(realm).toString();
|
||||
public LogoutUrlBuilder getLogoutUrl() {
|
||||
return new LogoutUrlBuilder();
|
||||
}
|
||||
|
||||
public String getResourceOwnerPasswordCredentialGrantUrl() {
|
||||
|
|
|
@ -66,7 +66,7 @@ public class LogoutTest extends AbstractTestRealmKeycloakTest {
|
|||
|
||||
String redirectUri = AppPage.baseUrl + "?logout";
|
||||
|
||||
String logoutUrl = oauth.getLogoutUrl(redirectUri, null);
|
||||
String logoutUrl = oauth.getLogoutUrl().redirectUri(redirectUri).build();
|
||||
driver.navigate().to(logoutUrl);
|
||||
|
||||
events.expectLogout(sessionId).detail(Details.REDIRECT_URI, redirectUri).assertEvent();
|
||||
|
@ -89,7 +89,7 @@ public class LogoutTest extends AbstractTestRealmKeycloakTest {
|
|||
|
||||
String sessionId = events.expectLogin().assertEvent().getSessionId();
|
||||
|
||||
String logoutUrl = oauth.getLogoutUrl(null, sessionId);
|
||||
String logoutUrl = oauth.getLogoutUrl().sessionState(sessionId).build();
|
||||
driver.navigate().to(logoutUrl);
|
||||
|
||||
events.expectLogout(sessionId).removeDetail(Details.REDIRECT_URI).assertEvent();
|
||||
|
@ -118,7 +118,7 @@ public class LogoutTest extends AbstractTestRealmKeycloakTest {
|
|||
events.expectLogin().session(sessionId).removeDetail(Details.USERNAME).assertEvent();
|
||||
|
||||
// Logout session 1 by redirect
|
||||
driver.navigate().to(oauth.getLogoutUrl(AppPage.baseUrl, null));
|
||||
driver.navigate().to(oauth.getLogoutUrl().redirectUri(AppPage.baseUrl).build());
|
||||
events.expectLogout(sessionId).detail(Details.REDIRECT_URI, AppPage.baseUrl).assertEvent();
|
||||
|
||||
// Check session 1 not logged-in
|
||||
|
@ -176,4 +176,28 @@ public class LogoutTest extends AbstractTestRealmKeycloakTest {
|
|||
rep.setRememberMe(enabled);
|
||||
adminClient.realm("test").update(rep);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void logoutSessionWhenLoggedOutByAdmin() {
|
||||
loginPage.open();
|
||||
loginPage.login("test-user@localhost", "password");
|
||||
assertTrue(appPage.isCurrent());
|
||||
|
||||
String sessionId = events.expectLogin().assertEvent().getSessionId();
|
||||
|
||||
adminClient.realm("test").logoutAll();
|
||||
|
||||
String logoutUrl = oauth.getLogoutUrl().sessionState(sessionId).build();
|
||||
driver.navigate().to(logoutUrl);
|
||||
|
||||
assertEquals(logoutUrl, driver.getCurrentUrl());
|
||||
|
||||
loginPage.open();
|
||||
loginPage.login("test-user@localhost", "password");
|
||||
assertTrue(appPage.isCurrent());
|
||||
|
||||
String sessionId2 = events.expectLogin().assertEvent().getSessionId();
|
||||
assertNotEquals(sessionId, sessionId2);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -755,7 +755,7 @@ public class ClientAuthSignedJWTTest extends AbstractKeycloakTest {
|
|||
parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ASSERTION_TYPE, OAuth2Constants.CLIENT_ASSERTION_TYPE_JWT));
|
||||
parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ASSERTION, signedJwt));
|
||||
|
||||
return sendRequest(oauth.getLogoutUrl(null, null), parameters);
|
||||
return sendRequest(oauth.getLogoutUrl().build(), parameters);
|
||||
}
|
||||
|
||||
private OAuthClient.AccessTokenResponse doClientCredentialsGrantRequest(String signedJwt) throws Exception {
|
||||
|
|
|
@ -17,23 +17,28 @@
|
|||
|
||||
package org.keycloak.testsuite.oauth;
|
||||
|
||||
import org.apache.http.HttpResponse;
|
||||
import org.junit.Before;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
|
||||
import org.keycloak.OAuth2Constants;
|
||||
import org.keycloak.common.util.Time;
|
||||
import org.keycloak.representations.idm.RealmRepresentation;
|
||||
import org.keycloak.testsuite.AbstractKeycloakTest;
|
||||
import org.keycloak.testsuite.AssertEvents;
|
||||
import org.keycloak.testsuite.util.ClientManager;
|
||||
import org.keycloak.testsuite.util.OAuthClient;
|
||||
import org.keycloak.testsuite.util.RealmBuilder;
|
||||
import org.keycloak.testsuite.pages.AppPage;
|
||||
import org.keycloak.testsuite.util.*;
|
||||
|
||||
import java.util.List;
|
||||
import javax.ws.rs.core.HttpHeaders;
|
||||
import javax.ws.rs.core.Response.Status;
|
||||
import org.apache.http.client.methods.CloseableHttpResponse;
|
||||
import org.apache.http.client.methods.HttpGet;
|
||||
import org.apache.http.impl.client.CloseableHttpClient;
|
||||
import org.apache.http.impl.client.HttpClientBuilder;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.hamcrest.Matchers.*;
|
||||
import static org.junit.Assert.*;
|
||||
import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson;
|
||||
|
||||
/**
|
||||
|
@ -72,10 +77,11 @@ public class LogoutTest extends AbstractKeycloakTest {
|
|||
OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "password");
|
||||
String refreshTokenString = tokenResponse.getRefreshToken();
|
||||
|
||||
HttpResponse response = oauth.doLogout(refreshTokenString, "password");
|
||||
assertEquals(204, response.getStatusLine().getStatusCode());
|
||||
try (CloseableHttpResponse response = oauth.doLogout(refreshTokenString, "password")) {
|
||||
assertThat(response, Matchers.statusCodeIsHC(Status.NO_CONTENT));
|
||||
|
||||
assertNotNull(testingClient.testApp().getAdminLogoutAction());
|
||||
assertNotNull(testingClient.testApp().getAdminLogoutAction());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -91,10 +97,83 @@ public class LogoutTest extends AbstractKeycloakTest {
|
|||
adminClient.realm("test").update(RealmBuilder.create().notBefore(Time.currentTime() + 1).build());
|
||||
|
||||
// Logout should succeed with expired refresh token, see KEYCLOAK-3302
|
||||
HttpResponse response = oauth.doLogout(refreshTokenString, "password");
|
||||
assertEquals(204, response.getStatusLine().getStatusCode());
|
||||
try (CloseableHttpResponse response = oauth.doLogout(refreshTokenString, "password")) {
|
||||
assertThat(response, Matchers.statusCodeIsHC(Status.NO_CONTENT));
|
||||
|
||||
assertNotNull(testingClient.testApp().getAdminLogoutAction());
|
||||
assertNotNull(testingClient.testApp().getAdminLogoutAction());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void postLogoutWithValidIdToken() throws Exception {
|
||||
oauth.doLogin("test-user@localhost", "password");
|
||||
|
||||
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
|
||||
|
||||
oauth.clientSessionState("client-session");
|
||||
OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "password");
|
||||
String idTokenString = tokenResponse.getIdToken();
|
||||
|
||||
String logoutUrl = oauth.getLogoutUrl()
|
||||
.idTokenHint(idTokenString)
|
||||
.postLogoutRedirectUri(AppPage.baseUrl)
|
||||
.build();
|
||||
|
||||
try (CloseableHttpClient c = HttpClientBuilder.create().disableRedirectHandling().build();
|
||||
CloseableHttpResponse response = c.execute(new HttpGet(logoutUrl))) {
|
||||
assertThat(response, Matchers.statusCodeIsHC(Status.FOUND));
|
||||
assertThat(response.getFirstHeader(HttpHeaders.LOCATION).getValue(), is(AppPage.baseUrl));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void postLogoutWithExpiredIdToken() throws Exception {
|
||||
oauth.doLogin("test-user@localhost", "password");
|
||||
|
||||
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
|
||||
|
||||
oauth.clientSessionState("client-session");
|
||||
OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "password");
|
||||
String idTokenString = tokenResponse.getIdToken();
|
||||
|
||||
// Logout should succeed with expired ID token, see KEYCLOAK-3399
|
||||
setTimeOffset(60 * 60 * 24);
|
||||
|
||||
String logoutUrl = oauth.getLogoutUrl()
|
||||
.idTokenHint(idTokenString)
|
||||
.postLogoutRedirectUri(AppPage.baseUrl)
|
||||
.build();
|
||||
|
||||
try (CloseableHttpClient c = HttpClientBuilder.create().disableRedirectHandling().build();
|
||||
CloseableHttpResponse response = c.execute(new HttpGet(logoutUrl))) {
|
||||
assertThat(response, Matchers.statusCodeIsHC(Status.FOUND));
|
||||
assertThat(response.getFirstHeader(HttpHeaders.LOCATION).getValue(), is(AppPage.baseUrl));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void postLogoutWithValidIdTokenWhenLoggedOutByAdmin() throws Exception {
|
||||
oauth.doLogin("test-user@localhost", "password");
|
||||
|
||||
String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
|
||||
|
||||
oauth.clientSessionState("client-session");
|
||||
OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "password");
|
||||
String idTokenString = tokenResponse.getIdToken();
|
||||
|
||||
adminClient.realm("test").logoutAll();
|
||||
|
||||
// Logout should succeed with user already logged out, see KEYCLOAK-3399
|
||||
String logoutUrl = oauth.getLogoutUrl()
|
||||
.idTokenHint(idTokenString)
|
||||
.postLogoutRedirectUri(AppPage.baseUrl)
|
||||
.build();
|
||||
|
||||
try (CloseableHttpClient c = HttpClientBuilder.create().disableRedirectHandling().build();
|
||||
CloseableHttpResponse response = c.execute(new HttpGet(logoutUrl))) {
|
||||
assertThat(response, Matchers.statusCodeIsHC(Status.FOUND));
|
||||
assertThat(response.getFirstHeader(HttpHeaders.LOCATION).getValue(), is(AppPage.baseUrl));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue