diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/RootAuthenticationSessionAdapter.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/RootAuthenticationSessionAdapter.java index d930c6faf3..d571b29e44 100644 --- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/RootAuthenticationSessionAdapter.java +++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/RootAuthenticationSessionAdapter.java @@ -121,6 +121,19 @@ public class RootAuthenticationSessionAdapter implements RootAuthenticationSessi return new AuthenticationSessionAdapter(session, this, tabId, authSessionEntity); } + @Override + public void removeAuthenticationSessionByTabId(String tabId) { + if (entity.getAuthenticationSessions().remove(tabId) != null) { + if (entity.getAuthenticationSessions().isEmpty()) { + provider.tx.remove(cache, entity.getId()); + } else { + entity.setTimestamp(Time.currentTime()); + + update(); + } + } + } + @Override public void restartSession(RealmModel realm) { entity.getAuthenticationSessions().clear(); diff --git a/server-spi/src/main/java/org/keycloak/sessions/RootAuthenticationSessionModel.java b/server-spi/src/main/java/org/keycloak/sessions/RootAuthenticationSessionModel.java index 5aff0eda8b..5854a9891b 100644 --- a/server-spi/src/main/java/org/keycloak/sessions/RootAuthenticationSessionModel.java +++ b/server-spi/src/main/java/org/keycloak/sessions/RootAuthenticationSessionModel.java @@ -58,6 +58,12 @@ public interface RootAuthenticationSessionModel { */ AuthenticationSessionModel createAuthenticationSession(ClientModel client); + /** + * Removes authentication session from root authentication session. + * If there's no child authentication session left in the root authentication session, it's removed as well. + * @param tabId String + */ + void removeAuthenticationSessionByTabId(String tabId); /** * Will completely restart whole state of authentication session. It will just keep same ID. It will setup it with provided realm. diff --git a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java index c6a1423c22..f25c3e208a 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java @@ -138,6 +138,12 @@ public class TokenManager { throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "User disabled", "User disabled"); } + if (oldToken.getIssuedAt() + 1 < userSession.getStarted()) { + logger.debug("Refresh toked issued before the user session started"); + throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Refresh toked issued before the user session started"); + } + + ClientModel client = session.getContext().getClient(); AuthenticatedClientSessionModel clientSession = userSession.getAuthenticatedClientSessionByClient(client.getId()); @@ -245,6 +251,9 @@ public class TokenManager { if (token.getIssuedAt() < session.users().getNotBeforeOfUser(realm, user)) { return false; } + if (token.getIssuedAt() + 1 < userSession.getStarted()) { + return false; + } return true; } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/LogoutEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/LogoutEndpoint.java index 6ccbad3fb7..473e8261bb 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/LogoutEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/LogoutEndpoint.java @@ -120,6 +120,10 @@ public class LogoutEndpoint { try { IDToken idToken = tokenManager.verifyIDTokenSignature(session, encodedIdToken); userSession = session.sessions().getUserSession(realm, idToken.getSessionState()); + + if (userSession != null) { + checkTokenIssuedAt(idToken, userSession); + } } catch (OAuthErrorException e) { event.event(EventType.LOGOUT); event.error(Errors.INVALID_TOKEN); @@ -198,6 +202,7 @@ public class LogoutEndpoint { } if (userSessionModel != null) { + checkTokenIssuedAt(token, userSessionModel); logout(userSessionModel, offline); } } catch (OAuthErrorException e) { @@ -235,4 +240,9 @@ public class LogoutEndpoint { } } + private void checkTokenIssuedAt(IDToken token, UserSessionModel userSession) throws OAuthErrorException { + if (token.getIssuedAt() + 1 < userSession.getStarted()) { + throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Refresh toked issued before the user session started"); + } + } } diff --git a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/UserInfoEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/UserInfoEndpoint.java index 40c4d767b5..b8eded5eea 100755 --- a/services/src/main/java/org/keycloak/protocol/oidc/endpoints/UserInfoEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc/endpoints/UserInfoEndpoint.java @@ -233,11 +233,13 @@ public class UserInfoEndpoint { UserSessionModel userSession = new UserSessionCrossDCManager(session).getUserSessionWithClient(realm, token.getSessionState(), false, client.getId()); UserSessionModel offlineUserSession = null; if (AuthenticationManager.isSessionValid(realm, userSession)) { + checkTokenIssuedAt(token, userSession, event); event.session(userSession); return userSession; } else { offlineUserSession = new UserSessionCrossDCManager(session).getUserSessionWithClient(realm, token.getSessionState(), true, client.getId()); if (AuthenticationManager.isOfflineSessionValid(realm, offlineUserSession)) { + checkTokenIssuedAt(token, offlineUserSession, event); event.session(offlineUserSession); return offlineUserSession; } @@ -258,4 +260,10 @@ public class UserInfoEndpoint { throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Session expired", Response.Status.UNAUTHORIZED); } + private void checkTokenIssuedAt(AccessToken token, UserSessionModel userSession, EventBuilder event) throws ErrorResponseException { + if (token.getIssuedAt() + 1 < userSession.getStarted()) { + event.error(Errors.INVALID_TOKEN); + throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Stale token", Response.Status.UNAUTHORIZED); + } + } } diff --git a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java index d88a8fead4..1c06ad113b 100755 --- a/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java +++ b/services/src/main/java/org/keycloak/services/managers/AuthenticationManager.java @@ -242,7 +242,8 @@ public class AuthenticationManager { backchannelLogoutAll(session, realm, userSession, logoutAuthSession, uriInfo, headers, logoutBroker); checkUserSessionOnlyHasLoggedOutClients(realm, userSession, logoutAuthSession); } finally { - asm.removeAuthenticationSession(realm, logoutAuthSession, false); + RootAuthenticationSessionModel rootAuthSession = logoutAuthSession.getParentSession(); + rootAuthSession.removeAuthenticationSessionByTabId(logoutAuthSession.getTabId()); } userSession.setState(UserSessionModel.State.LOGGED_OUT); @@ -707,7 +708,7 @@ public class AuthenticationManager { } public static void expireCookie(RealmModel realm, String cookieName, String path, boolean httpOnly, ClientConnection connection) { - logger.debugv("Expiring cookie: {0} path: {1}", cookieName, path); + logger.debugf("Expiring cookie: %s path: %s", cookieName, path); boolean secureOnly = realm.getSslRequired().isRequired(connection);; CookieHelper.addCookie(cookieName, "", path, null, "Expiring cookie", 0, secureOnly, httpOnly); } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/auth/page/login/OIDCLogin.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/auth/page/login/OIDCLogin.java index bd2130179c..4ce8f126f7 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/auth/page/login/OIDCLogin.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/auth/page/login/OIDCLogin.java @@ -17,6 +17,8 @@ package org.keycloak.testsuite.auth.page.login; +import org.keycloak.testsuite.util.DroneUtils; + /** * * @author tkyjovsk @@ -27,4 +29,14 @@ public class OIDCLogin extends Login { setProtocol(OIDC); } + @Override + public boolean isCurrent() { + String realm = "test"; + return isCurrent(realm); + } + + public boolean isCurrent(String realm) { + return DroneUtils.getCurrentDriver().getTitle().equals("Log in to " + realm) || DroneUtils.getCurrentDriver().getTitle().equals("Anmeldung bei " + realm); + } + } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/page/AbstractPage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/page/AbstractPage.java index d452f294b2..9cdb90ddf6 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/page/AbstractPage.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/page/AbstractPage.java @@ -18,7 +18,9 @@ package org.keycloak.testsuite.page; import org.jboss.arquillian.drone.api.annotation.Drone; +import org.jboss.arquillian.test.api.ArquillianResource; import org.jboss.logging.Logger; +import org.keycloak.testsuite.util.DroneUtils; import org.keycloak.testsuite.util.URLUtils; import org.openqa.selenium.WebDriver; @@ -105,7 +107,7 @@ public abstract class AbstractPage { public void assertCurrent() { String name = getClass().getSimpleName(); - Assert.assertTrue("Expected " + name + " but was " + driver.getTitle() + " (" + driver.getCurrentUrl() + ")", + Assert.assertTrue("Expected " + name + " but was " + DroneUtils.getCurrentDriver().getTitle() + " (" + DroneUtils.getCurrentDriver().getCurrentUrl() + ")", isCurrent()); } } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/AbstractPage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/AbstractPage.java index 8642568e9f..9a0eb94204 100755 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/AbstractPage.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/AbstractPage.java @@ -21,6 +21,7 @@ import org.jboss.arquillian.test.api.ArquillianResource; import org.junit.Assert; import org.keycloak.common.util.KeycloakUriBuilder; import org.keycloak.testsuite.arquillian.SuiteContext; +import org.keycloak.testsuite.util.DroneUtils; import org.keycloak.testsuite.util.OAuthClient; import org.openqa.selenium.By; import org.openqa.selenium.WebDriver; @@ -44,7 +45,7 @@ public abstract class AbstractPage { public void assertCurrent() { String name = getClass().getSimpleName(); - Assert.assertTrue("Expected " + name + " but was " + driver.getTitle() + " (" + driver.getCurrentUrl() + ")", + Assert.assertTrue("Expected " + name + " but was " + DroneUtils.getCurrentDriver().getTitle() + " (" + DroneUtils.getCurrentDriver().getCurrentUrl() + ")", isCurrent()); } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LanguageComboboxAwarePage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LanguageComboboxAwarePage.java index e1e75def59..41ff437d6c 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LanguageComboboxAwarePage.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LanguageComboboxAwarePage.java @@ -17,6 +17,7 @@ package org.keycloak.testsuite.pages; +import org.keycloak.testsuite.util.DroneUtils; import org.keycloak.testsuite.util.WaitUtils; import org.openqa.selenium.By; import org.openqa.selenium.WebElement; @@ -40,7 +41,7 @@ public abstract class LanguageComboboxAwarePage extends AbstractPage { public void openLanguage(String language){ WebElement langLink = localeDropdown.findElement(By.xpath("//a[text()='" + language + "']")); String url = langLink.getAttribute("href"); - driver.navigate().to(url); + DroneUtils.getCurrentDriver().navigate().to(url); WaitUtils.waitForPageToLoad(); } } diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginPage.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginPage.java index bd40883797..b83b8ff948 100755 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginPage.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/pages/LoginPage.java @@ -18,6 +18,7 @@ package org.keycloak.testsuite.pages; import org.jboss.arquillian.test.api.ArquillianResource; +import org.keycloak.testsuite.util.DroneUtils; import org.keycloak.testsuite.util.OAuthClient; import org.openqa.selenium.By; import org.openqa.selenium.WebElement; @@ -145,7 +146,7 @@ public class LoginPage extends LanguageComboboxAwarePage { } public boolean isCurrent(String realm) { - return driver.getTitle().equals("Log in to " + realm) || driver.getTitle().equals("Anmeldung bei " + realm); + return DroneUtils.getCurrentDriver().getTitle().equals("Log in to " + realm) || DroneUtils.getCurrentDriver().getTitle().equals("Anmeldung bei " + realm); } public void clickRegister() { @@ -159,7 +160,7 @@ public class LoginPage extends LanguageComboboxAwarePage { public WebElement findSocialButton(String providerId) { String id = "zocial-" + providerId; - return this.driver.findElement(By.id(id)); + return DroneUtils.getCurrentDriver().findElement(By.id(id)); } public void resetPassword() { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LoginTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LoginTest.java index 33afbafed7..a725620f60 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LoginTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/forms/LoginTest.java @@ -16,12 +16,14 @@ */ package org.keycloak.testsuite.forms; +import org.jboss.arquillian.drone.api.annotation.Drone; import org.jboss.arquillian.graphene.page.Page; import org.junit.Assert; import org.junit.Rule; import org.junit.Test; import org.keycloak.OAuth2Constants; import org.keycloak.admin.client.resource.UserResource; +import org.keycloak.common.util.Retry; import org.keycloak.crypto.Algorithm; import org.keycloak.events.Details; import org.keycloak.events.Errors; @@ -40,24 +42,32 @@ import org.keycloak.testsuite.AssertEvents; import org.keycloak.testsuite.AbstractTestRealmKeycloakTest; import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.arquillian.AuthServerTestEnricher; +import org.keycloak.testsuite.console.page.AdminConsole; import org.keycloak.testsuite.pages.AppPage; import org.keycloak.testsuite.pages.AppPage.RequestType; import org.keycloak.testsuite.pages.ErrorPage; import org.keycloak.testsuite.pages.LoginPage; import org.keycloak.testsuite.pages.LoginPasswordUpdatePage; +import org.keycloak.testsuite.updaters.RealmAttributeUpdater; +import org.keycloak.testsuite.util.DroneUtils; +import org.keycloak.testsuite.util.JavascriptBrowser; import org.keycloak.testsuite.util.OAuthClient; import org.keycloak.testsuite.util.Matchers; import org.keycloak.testsuite.util.RealmBuilder; import org.keycloak.testsuite.util.TokenSignatureUtil; import org.keycloak.testsuite.util.UserBuilder; -import org.openqa.selenium.Cookie; import org.openqa.selenium.NoSuchElementException; +import org.openqa.selenium.WebDriver; import javax.ws.rs.client.Client; import javax.ws.rs.client.ClientBuilder; import javax.ws.rs.core.Response; import javax.ws.rs.core.UriBuilder; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; @@ -68,6 +78,7 @@ import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertTrue; import static org.keycloak.testsuite.admin.ApiUtil.findClientByClientId; import static org.keycloak.testsuite.util.OAuthClient.AUTH_SERVER_ROOT; +import static org.keycloak.testsuite.util.URLAssert.assertCurrentUrlStartsWithLoginUrlOf; /** * @author Stian Thorgersen @@ -94,9 +105,19 @@ public class LoginTest extends AbstractTestRealmKeycloakTest { .build(); user2Id = user2.getId(); + UserRepresentation admin = UserBuilder.create() + .username("admin") + .password("admin") + .enabled(true) + .build(); + HashMap> clientRoles = new HashMap<>(); + clientRoles.put("realm-management", Arrays.asList("realm-admin")); + admin.setClientRoles(clientRoles); + RealmBuilder.edit(testRealm) .user(user) - .user(user2); + .user(user2) + .user(admin); } @Rule @@ -105,9 +126,21 @@ public class LoginTest extends AbstractTestRealmKeycloakTest { @Page protected AppPage appPage; + @Page + @JavascriptBrowser + protected AdminConsole jsAdminConsole; + + @Drone + @JavascriptBrowser + protected WebDriver jsDriver; + @Page protected LoginPage loginPage; + @Page + @JavascriptBrowser + protected LoginPage jsLoginPage; + @Page protected ErrorPage errorPage; @@ -696,6 +729,35 @@ public class LoginTest extends AbstractTestRealmKeycloakTest { .assertEvent(); } + @Test + public void loginAfterExpiredTimeout() throws Exception { + try (AutoCloseable c = new RealmAttributeUpdater(adminClient.realm("test")) + .updateWith(r -> { + r.setSsoSessionMaxLifespan(5); + }) + .update()) { + + DroneUtils.addWebDriver(jsDriver); + + jsAdminConsole.setAdminRealm(testRealm().toRepresentation().getRealm()); + + jsAdminConsole.navigateTo(); + assertCurrentUrlStartsWithLoginUrlOf(jsAdminConsole); + + // login for the first time + jsLoginPage.login("admin", "admin"); + + // wait for a timeout + TimeUnit.SECONDS.sleep(5); + Retry.execute(() -> jsLoginPage.assertCurrent(), 20, 500); + + // try to re-login immediately, it should be successful i.e without "You took too long to login. Login process starting from beginning." message + jsLoginPage.login("admin", "admin"); + + assertFalse(jsLoginPage.isCurrent()); + } + } + @Test public void loginExpiredCodeAndExpiredCookies() { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/LogoutTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/LogoutTest.java index a93715b2c2..3cd162a3d1 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/LogoutTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/LogoutTest.java @@ -25,16 +25,19 @@ import org.keycloak.OAuth2Constants; import org.keycloak.common.util.Time; import org.keycloak.jose.jws.JWSHeader; import org.keycloak.jose.jws.JWSInput; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.testsuite.AbstractKeycloakTest; +import org.keycloak.testsuite.Assert; import org.keycloak.testsuite.AssertEvents; import org.keycloak.testsuite.admin.ApiUtil; -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 javax.ws.rs.core.UriBuilder; + import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpGet; import org.apache.http.impl.client.CloseableHttpClient; @@ -107,6 +110,47 @@ public class LogoutTest extends AbstractKeycloakTest { } } + @Test + public void postLogoutWithRefreshTokenAfterUserSessionLogoutAndLoginAgain() throws Exception { + // Login + OAuthClient.AccessTokenResponse accessTokenResponse = loginAndForceNewLoginPage(); + String refreshToken1 = accessTokenResponse.getRefreshToken(); + + oauth.doLogout(refreshToken1, "password"); + + setTimeOffset(2); + + oauth.fillLoginForm("test-user@localhost", "password"); + + Assert.assertFalse(loginPage.isCurrent()); + + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse tokenResponse2 = oauth.doAccessTokenRequest(code, "password"); + + // POST logout with token should fail + try (CloseableHttpResponse response = oauth.doLogout(refreshToken1, "password")) { + assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatusLine().getStatusCode()); + } + + String logoutUrl = oauth.getLogoutUrl() + .idTokenHint(accessTokenResponse.getIdToken()) + .postLogoutRedirectUri(oauth.APP_AUTH_ROOT) + .build(); + + // GET logout with ID token should fail as well + try (CloseableHttpClient c = HttpClientBuilder.create().disableRedirectHandling().build(); + CloseableHttpResponse response = c.execute(new HttpGet(logoutUrl))) { + assertEquals(Status.BAD_REQUEST.getStatusCode(), response.getStatusLine().getStatusCode()); + } + + // finally POST logout with VALID token should succeed + try (CloseableHttpResponse response = oauth.doLogout(tokenResponse2.getRefreshToken(), "password")) { + assertThat(response, Matchers.statusCodeIsHC(Status.NO_CONTENT)); + + assertNotNull(testingClient.testApp().getAdminLogoutAction()); + } + } + @Test public void postLogoutFailWithCredentialsOfDifferentClient() throws Exception { @@ -248,4 +292,23 @@ public class LogoutTest extends AbstractKeycloakTest { } } + private OAuthClient.AccessTokenResponse loginAndForceNewLoginPage() { + oauth.doLogin("test-user@localhost", "password"); + + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + oauth.clientSessionState("client-session"); + + OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "password"); + + setTimeOffset(1); + + String loginFormUri = UriBuilder.fromUri(oauth.getLoginFormUrl()) + .queryParam(OIDCLoginProtocol.PROMPT_PARAM, OIDCLoginProtocol.PROMPT_VALUE_LOGIN) + .build().toString(); + driver.navigate().to(loginFormUri); + + loginPage.assertCurrent(); + + return tokenResponse; + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java index 6a202297cd..20d0663a55 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/RefreshTokenTest.java @@ -16,12 +16,14 @@ */ package org.keycloak.testsuite.oauth; +import org.jboss.arquillian.graphene.page.Page; import org.junit.Assert; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.keycloak.OAuth2Constants; import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.admin.client.resource.UserResource; import org.keycloak.common.enums.SslRequired; import org.keycloak.crypto.Algorithm; import org.keycloak.events.Details; @@ -29,6 +31,7 @@ import org.keycloak.events.Errors; import org.keycloak.jose.jws.JWSHeader; import org.keycloak.jose.jws.JWSInput; import org.keycloak.models.utils.SessionTimeoutHelper; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.protocol.oidc.OIDCLoginProtocolService; import org.keycloak.representations.AccessToken; import org.keycloak.representations.RefreshToken; @@ -37,6 +40,7 @@ import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.testsuite.AbstractKeycloakTest; import org.keycloak.testsuite.AssertEvents; import org.keycloak.testsuite.admin.ApiUtil; +import org.keycloak.testsuite.pages.LoginPage; import org.keycloak.testsuite.util.ClientManager; import org.keycloak.testsuite.util.OAuthClient; import org.keycloak.testsuite.util.RealmBuilder; @@ -63,6 +67,7 @@ import static org.hamcrest.Matchers.greaterThanOrEqualTo; import static org.hamcrest.Matchers.lessThan; import static org.hamcrest.Matchers.lessThanOrEqualTo; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson; @@ -74,6 +79,9 @@ import static org.keycloak.testsuite.util.OAuthClient.AUTH_SERVER_ROOT; */ public class RefreshTokenTest extends AbstractKeycloakTest { + @Page + protected LoginPage loginPage; + @Rule public AssertEvents events = new AssertEvents(this); @@ -470,7 +478,6 @@ public class RefreshTokenTest extends AbstractKeycloakTest { private void processExpectedValidRefresh(String sessionId, RefreshToken requestToken, String refreshToken) { OAuthClient.AccessTokenResponse response2 = oauth.doRefreshTokenRequest(refreshToken, "password"); - RefreshToken refreshToken2 = oauth.parseRefreshToken(response2.getRefreshToken()); assertEquals(200, response2.getStatusCode()); @@ -540,6 +547,93 @@ public class RefreshTokenTest extends AbstractKeycloakTest { events.clear(); } + @Test + public void refreshTokenAfterUserLogoutAndLoginAgain() { + String refreshToken1 = loginAndForceNewLoginPage(); + + oauth.doLogout(refreshToken1, "password"); + events.clear(); + + // Set time offset to 2 (Just to simulate to be more close to real situation) + setTimeOffset(2); + + // Continue with login + oauth.fillLoginForm("test-user@localhost", "password"); + + assertFalse(loginPage.isCurrent()); + + OAuthClient.AccessTokenResponse tokenResponse2 = null; + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + tokenResponse2 = oauth.doAccessTokenRequest(code, "password"); + + // Now try refresh with the original refreshToken1 created in logged-out userSession. It should fail + OAuthClient.AccessTokenResponse responseReuseExceeded = oauth.doRefreshTokenRequest(refreshToken1, "password"); + assertEquals(400, responseReuseExceeded.getStatusCode()); + + // Finally try with valid refresh token + responseReuseExceeded = oauth.doRefreshTokenRequest(tokenResponse2.getRefreshToken(), "password"); + assertEquals(200, responseReuseExceeded.getStatusCode()); + } + + @Test + public void refreshTokenAfterAdminLogoutAllAndLoginAgain() { + String refreshToken1 = loginAndForceNewLoginPage(); + + adminClient.realm("test").logoutAll(); + + events.clear(); + + // Set time offset to 2 (Just to simulate to be more close to real situation) + setTimeOffset(2); + + // Continue with login + oauth.fillLoginForm("test-user@localhost", "password"); + + assertFalse(loginPage.isCurrent()); + + OAuthClient.AccessTokenResponse tokenResponse2 = null; + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + tokenResponse2 = oauth.doAccessTokenRequest(code, "password"); + + // Now try refresh with the original refreshToken1 created in logged-out userSession. It should fail + OAuthClient.AccessTokenResponse responseReuseExceeded = oauth.doRefreshTokenRequest(refreshToken1, "password"); + assertEquals(400, responseReuseExceeded.getStatusCode()); + + // Finally try with valid refresh token + responseReuseExceeded = oauth.doRefreshTokenRequest(tokenResponse2.getRefreshToken(), "password"); + assertEquals(200, responseReuseExceeded.getStatusCode()); + } + + @Test + public void refreshTokenAfterUserAdminLogoutEndpointAndLoginAgain() { + String refreshToken1 = loginAndForceNewLoginPage(); + + RefreshToken refreshTokenParsed1 = oauth.parseRefreshToken(refreshToken1); + String userId = refreshTokenParsed1.getSubject(); + UserResource user = adminClient.realm("test").users().get(userId); + user.logout(); + + // Set time offset to 2 (Just to simulate to be more close to real situation) + setTimeOffset(2); + + // Continue with login + oauth.fillLoginForm("test-user@localhost", "password"); + + assertFalse(loginPage.isCurrent()); + + OAuthClient.AccessTokenResponse tokenResponse2 = null; + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + tokenResponse2 = oauth.doAccessTokenRequest(code, "password"); + + // Now try refresh with the original refreshToken1 created in logged-out userSession. It should fail + OAuthClient.AccessTokenResponse responseReuseExceeded = oauth.doRefreshTokenRequest(refreshToken1, "password"); + assertEquals(400, responseReuseExceeded.getStatusCode()); + + // Finally try with valid refresh token + responseReuseExceeded = oauth.doRefreshTokenRequest(tokenResponse2.getRefreshToken(), "password"); + assertEquals(200, responseReuseExceeded.getStatusCode()); + } + @Test public void testUserSessionRefreshAndIdle() throws Exception { oauth.doLogin("test-user@localhost", "password"); @@ -1009,4 +1103,34 @@ public class RefreshTokenTest extends AbstractKeycloakTest { setTimeOffset(0); } + private String loginAndForceNewLoginPage() { + oauth.doLogin("test-user@localhost", "password"); + + EventRepresentation loginEvent = events.expectLogin().assertEvent(); + + String sessionId = loginEvent.getSessionId(); + + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "password"); + + events.poll(); + + // Assert refresh successful + String refreshToken = tokenResponse.getRefreshToken(); + RefreshToken refreshTokenParsed1 = oauth.parseRefreshToken(tokenResponse.getRefreshToken()); + processExpectedValidRefresh(sessionId, refreshTokenParsed1, refreshToken); + + // Set time offset to 1 (Just to simulate to be more close to real situation) + setTimeOffset(1); + + // Open the tab with prompt=login. AuthenticationSession will be created with same ID like userSession + String loginFormUri = UriBuilder.fromUri(oauth.getLoginFormUrl()) + .queryParam(OIDCLoginProtocol.PROMPT_PARAM, OIDCLoginProtocol.PROMPT_VALUE_LOGIN) + .build().toString(); + driver.navigate().to(loginFormUri); + + loginPage.assertCurrent(); + + return refreshToken; + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenIntrospectionTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenIntrospectionTest.java index 42cd9c1d92..6851e9e0dd 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenIntrospectionTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenIntrospectionTest.java @@ -26,6 +26,8 @@ import org.keycloak.OAuthErrorException; import org.keycloak.crypto.Algorithm; import org.keycloak.events.Errors; import org.keycloak.jose.jws.JWSInput; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; +import org.keycloak.representations.RefreshToken; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.representations.idm.EventRepresentation; @@ -40,10 +42,12 @@ import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.oidc.OIDCScopeTest; import org.keycloak.testsuite.oidc.AbstractOIDCScopeTest; import org.keycloak.testsuite.util.KeycloakModelUtils; +import org.keycloak.testsuite.util.OAuthClient; import org.keycloak.testsuite.util.OAuthClient.AccessTokenResponse; import org.keycloak.testsuite.util.TokenSignatureUtil; import org.keycloak.util.JsonSerialization; +import javax.ws.rs.core.UriBuilder; import java.util.ArrayList; import java.util.List; @@ -179,6 +183,36 @@ public class TokenIntrospectionTest extends AbstractTestRealmKeycloakTest { assertEquals(jsonNode.get("typ").asText(), "Refresh"); } + @Test + public void testIntrospectRefreshTokenAfterUserSessionLogoutAndLoginAgain() throws Exception { + AccessTokenResponse accessTokenResponse = loginAndForceNewLoginPage(); + String refreshToken1 = accessTokenResponse.getRefreshToken(); + + oauth.doLogout(refreshToken1, "password"); + events.clear(); + + setTimeOffset(2); + + oauth.fillLoginForm("test-user@localhost", "password"); + events.expectLogin().assertEvent(); + + Assert.assertFalse(loginPage.isCurrent()); + + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + OAuthClient.AccessTokenResponse tokenResponse2 = oauth.doAccessTokenRequest(code, "password"); + + String introspectResponse = oauth.introspectRefreshTokenWithClientCredential("confidential-cli", "secret1", tokenResponse2.getRefreshToken()); + + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode jsonNode = objectMapper.readTree(introspectResponse); + assertTrue(jsonNode.get("active").asBoolean()); + + introspectResponse = oauth.introspectRefreshTokenWithClientCredential("confidential-cli", "secret1", refreshToken1); + + jsonNode = objectMapper.readTree(introspectResponse); + assertFalse(jsonNode.get("active").asBoolean()); + } + @Test public void testPublicClientCredentialsNotAllowed() throws Exception { oauth.doLogin("test-user@localhost", "password"); @@ -389,4 +423,24 @@ public class TokenIntrospectionTest extends AbstractTestRealmKeycloakTest { assertEquals(Errors.INVALID_CLIENT, rep.getOtherClaims().get("error")); assertNull(rep.getSubject()); } + + private OAuthClient.AccessTokenResponse loginAndForceNewLoginPage() { + oauth.doLogin("test-user@localhost", "password"); + + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + oauth.clientSessionState("client-session"); + + OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "password"); + + setTimeOffset(1); + + String loginFormUri = UriBuilder.fromUri(oauth.getLoginFormUrl()) + .queryParam(OIDCLoginProtocol.PROMPT_PARAM, OIDCLoginProtocol.PROMPT_VALUE_LOGIN) + .build().toString(); + driver.navigate().to(loginFormUri); + + loginPage.assertCurrent(); + + return tokenResponse; + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/UserInfoTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/UserInfoTest.java index 0281196e66..42c2bf0fa4 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/UserInfoTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oidc/UserInfoTest.java @@ -32,6 +32,7 @@ import org.keycloak.events.EventType; import org.keycloak.jose.jws.Algorithm; import org.keycloak.jose.jws.JWSInput; import org.keycloak.jose.jws.crypto.RSAProvider; +import org.keycloak.protocol.oidc.OIDCLoginProtocol; import org.keycloak.representations.AccessToken; import org.keycloak.representations.idm.RoleRepresentation; import org.keycloak.testsuite.util.KeycloakModelUtils; @@ -342,6 +343,44 @@ public class UserInfoTest extends AbstractKeycloakTest { } } + @Test + public void testAccessTokenAfterUserSessionLogoutAndLoginAgain() { + OAuthClient.AccessTokenResponse accessTokenResponse = loginAndForceNewLoginPage(); + String refreshToken1 = accessTokenResponse.getRefreshToken(); + + oauth.doLogout(refreshToken1, "password"); + events.clear(); + + setTimeOffset(2); + + oauth.fillLoginForm("test-user@localhost", "password"); + events.expectLogin().assertEvent(); + + Assert.assertFalse(loginPage.isCurrent()); + + events.clear(); + + Client client = ClientBuilder.newClient(); + + try { + Response response = UserInfoClientUtil.executeUserInfoRequest_getMethod(client, accessTokenResponse.getAccessToken()); + + assertEquals(Status.UNAUTHORIZED.getStatusCode(), response.getStatus()); + + response.close(); + + events.expect(EventType.USER_INFO_REQUEST_ERROR) + .error(Errors.INVALID_TOKEN) + .user(Matchers.nullValue(String.class)) + .session(Matchers.nullValue(String.class)) + .detail(Details.AUTH_METHOD, Details.VALIDATE_ACCESS_TOKEN) + .client("test-app") + .assertEvent(); + } finally { + client.close(); + } + } + @Test public void testSessionExpiredOfflineAccess() throws Exception { Client client = ClientBuilder.newClient(); @@ -518,4 +557,23 @@ public class UserInfoTest extends AbstractKeycloakTest { } } + private OAuthClient.AccessTokenResponse loginAndForceNewLoginPage() { + oauth.doLogin("test-user@localhost", "password"); + + String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); + oauth.clientSessionState("client-session"); + + OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "password"); + + setTimeOffset(1); + + String loginFormUri = UriBuilder.fromUri(oauth.getLoginFormUrl()) + .queryParam(OIDCLoginProtocol.PROMPT_PARAM, OIDCLoginProtocol.PROMPT_VALUE_LOGIN) + .build().toString(); + driver.navigate().to(loginFormUri); + + loginPage.assertCurrent(); + + return tokenResponse; + } }