KEYCLOAK-3399 Ignore user session expiration on OIDC logout
This commit is contained in:
parent
fb6a8da863
commit
9fb3201c8b
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) {
|
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) {
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue