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:
Stian Thorgersen 2017-01-18 08:38:53 +01:00 committed by GitHub
commit e364680792
6 changed files with 175 additions and 35 deletions

View file

@ -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) {

View file

@ -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);

View file

@ -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() {

View file

@ -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);
}
}

View file

@ -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 {

View file

@ -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));
}
}
}