KEYCLOAK-3399 Ignore user session expiration on OIDC logout

This commit is contained in:
Hynek Mlnarik 2017-01-06 11:23:18 +01:00
parent fb6a8da863
commit 9fb3201c8b
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) { 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()); AccessToken token = initToken(realm, client, user, userSession, clientSession, session.getContext().getUri());
for (RoleModel role : requestedRoles) { for (RoleModel role : requestedRoles) {

View file

@ -113,18 +113,11 @@ public class LogoutEndpoint {
} }
UserSessionModel userSession = null; UserSessionModel userSession = null;
boolean error = false;
if (encodedIdToken != null) { if (encodedIdToken != null) {
try { try {
IDToken idToken = tokenManager.verifyIDToken(session, realm, encodedIdToken); IDToken idToken = tokenManager.verifyIDTokenSignature(session, realm, encodedIdToken);
userSession = session.sessions().getUserSession(realm, idToken.getSessionState()); userSession = session.sessions().getUserSession(realm, idToken.getSessionState());
if (userSession == null) {
error = true;
}
} catch (OAuthErrorException e) { } catch (OAuthErrorException e) {
error = true;
}
if (error) {
event.event(EventType.LOGOUT); event.event(EventType.LOGOUT);
event.error(Errors.INVALID_TOKEN); event.error(Errors.INVALID_TOKEN);
return ErrorPage.error(session, Messages.SESSION_NOT_ACTIVE); return ErrorPage.error(session, Messages.SESSION_NOT_ACTIVE);

View file

@ -111,6 +111,42 @@ public class OAuthClient {
private Map<String, PublicKey> publicKeys = new HashMap<>(); 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) { public void init(Keycloak adminClient, WebDriver driver) {
this.adminClient = adminClient; this.adminClient = adminClient;
this.driver = driver; 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(); CloseableHttpClient client = new DefaultHttpClient();
try { try {
HttpPost post = new HttpPost(getLogoutUrl(null, null)); HttpPost post = new HttpPost(getLogoutUrl().build());
List<NameValuePair> parameters = new LinkedList<NameValuePair>(); List<NameValuePair> parameters = new LinkedList<NameValuePair>();
if (refreshToken != null) { if (refreshToken != null) {
@ -558,15 +594,8 @@ public class OAuthClient {
return b.build(realm).toString(); return b.build(realm).toString();
} }
public String getLogoutUrl(String redirectUri, String sessionState) { public LogoutUrlBuilder getLogoutUrl() {
UriBuilder b = OIDCLoginProtocolService.logoutUrl(UriBuilder.fromUri(baseUrl)); return new LogoutUrlBuilder();
if (redirectUri != null) {
b.queryParam(OAuth2Constants.REDIRECT_URI, redirectUri);
}
if (sessionState != null) {
b.queryParam("session_state", sessionState);
}
return b.build(realm).toString();
} }
public String getResourceOwnerPasswordCredentialGrantUrl() { public String getResourceOwnerPasswordCredentialGrantUrl() {

View file

@ -66,7 +66,7 @@ public class LogoutTest extends AbstractTestRealmKeycloakTest {
String redirectUri = AppPage.baseUrl + "?logout"; String redirectUri = AppPage.baseUrl + "?logout";
String logoutUrl = oauth.getLogoutUrl(redirectUri, null); String logoutUrl = oauth.getLogoutUrl().redirectUri(redirectUri).build();
driver.navigate().to(logoutUrl); driver.navigate().to(logoutUrl);
events.expectLogout(sessionId).detail(Details.REDIRECT_URI, redirectUri).assertEvent(); 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 sessionId = events.expectLogin().assertEvent().getSessionId();
String logoutUrl = oauth.getLogoutUrl(null, sessionId); String logoutUrl = oauth.getLogoutUrl().sessionState(sessionId).build();
driver.navigate().to(logoutUrl); driver.navigate().to(logoutUrl);
events.expectLogout(sessionId).removeDetail(Details.REDIRECT_URI).assertEvent(); 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(); events.expectLogin().session(sessionId).removeDetail(Details.USERNAME).assertEvent();
// Logout session 1 by redirect // 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(); events.expectLogout(sessionId).detail(Details.REDIRECT_URI, AppPage.baseUrl).assertEvent();
// Check session 1 not logged-in // Check session 1 not logged-in
@ -176,4 +176,28 @@ public class LogoutTest extends AbstractTestRealmKeycloakTest {
rep.setRememberMe(enabled); rep.setRememberMe(enabled);
adminClient.realm("test").update(rep); 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_TYPE, OAuth2Constants.CLIENT_ASSERTION_TYPE_JWT));
parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ASSERTION, signedJwt)); 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 { private OAuthClient.AccessTokenResponse doClientCredentialsGrantRequest(String signedJwt) throws Exception {

View file

@ -17,23 +17,28 @@
package org.keycloak.testsuite.oauth; package org.keycloak.testsuite.oauth;
import org.apache.http.HttpResponse;
import org.junit.Before; import org.junit.Before;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import org.keycloak.OAuth2Constants; import org.keycloak.OAuth2Constants;
import org.keycloak.common.util.Time; import org.keycloak.common.util.Time;
import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.testsuite.AbstractKeycloakTest; import org.keycloak.testsuite.AbstractKeycloakTest;
import org.keycloak.testsuite.AssertEvents; import org.keycloak.testsuite.AssertEvents;
import org.keycloak.testsuite.util.ClientManager; import org.keycloak.testsuite.pages.AppPage;
import org.keycloak.testsuite.util.OAuthClient; import org.keycloak.testsuite.util.*;
import org.keycloak.testsuite.util.RealmBuilder;
import java.util.List; 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.hamcrest.Matchers.*;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.*;
import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson; import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson;
/** /**
@ -72,11 +77,12 @@ public class LogoutTest extends AbstractKeycloakTest {
OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "password"); OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "password");
String refreshTokenString = tokenResponse.getRefreshToken(); String refreshTokenString = tokenResponse.getRefreshToken();
HttpResponse response = oauth.doLogout(refreshTokenString, "password"); try (CloseableHttpResponse response = oauth.doLogout(refreshTokenString, "password")) {
assertEquals(204, response.getStatusLine().getStatusCode()); assertThat(response, Matchers.statusCodeIsHC(Status.NO_CONTENT));
assertNotNull(testingClient.testApp().getAdminLogoutAction()); assertNotNull(testingClient.testApp().getAdminLogoutAction());
} }
}
@Test @Test
public void postLogoutExpiredRefreshToken() throws Exception { public void postLogoutExpiredRefreshToken() throws Exception {
@ -91,10 +97,83 @@ public class LogoutTest extends AbstractKeycloakTest {
adminClient.realm("test").update(RealmBuilder.create().notBefore(Time.currentTime() + 1).build()); adminClient.realm("test").update(RealmBuilder.create().notBefore(Time.currentTime() + 1).build());
// Logout should succeed with expired refresh token, see KEYCLOAK-3302 // Logout should succeed with expired refresh token, see KEYCLOAK-3302
HttpResponse response = oauth.doLogout(refreshTokenString, "password"); try (CloseableHttpResponse response = oauth.doLogout(refreshTokenString, "password")) {
assertEquals(204, response.getStatusLine().getStatusCode()); 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));
}
}
} }