/* * Copyright 2016 Red Hat, Inc. and/or its affiliates * and other contributors as indicated by the @author tags. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ 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.Keycloak; import org.keycloak.admin.client.resource.ClientResource; import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.admin.client.resource.RoleResource; import org.keycloak.admin.client.resource.UserResource; import org.keycloak.common.constants.ServiceAccountConstants; import org.keycloak.events.Details; import org.keycloak.events.Errors; import org.keycloak.models.AdminRoles; import org.keycloak.models.Constants; import org.keycloak.models.utils.KeycloakModelUtils; import org.keycloak.representations.AccessToken; import org.keycloak.representations.RefreshToken; import org.keycloak.representations.idm.ClientRepresentation; import org.keycloak.representations.idm.EventRepresentation; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RoleRepresentation; import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.testsuite.AbstractKeycloakTest; import org.keycloak.testsuite.AssertEvents; import org.keycloak.testsuite.account.AccountTest; import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.arquillian.AuthServerTestEnricher; import org.keycloak.testsuite.auth.page.AuthRealm; import org.keycloak.testsuite.pages.AccountApplicationsPage; import org.keycloak.testsuite.pages.LoginPage; import org.keycloak.testsuite.util.ClientBuilder; import org.keycloak.testsuite.util.ClientManager; import org.keycloak.testsuite.util.OAuthClient; import org.keycloak.testsuite.util.RealmBuilder; import org.keycloak.testsuite.util.RealmManager; import org.keycloak.testsuite.util.RoleBuilder; import org.keycloak.testsuite.util.UserBuilder; import org.keycloak.util.TokenUtil; import java.util.Collections; import java.util.List; import java.util.Map; import javax.ws.rs.NotFoundException; import static org.junit.Assert.assertEquals; import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson; import static org.keycloak.testsuite.admin.ApiUtil.findRealmRoleByName; import static org.keycloak.testsuite.admin.ApiUtil.findUserByUsername; import static org.keycloak.testsuite.admin.ApiUtil.findUserByUsernameId; import static org.keycloak.testsuite.util.OAuthClient.APP_ROOT; /** * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a> */ public class OfflineTokenTest extends AbstractKeycloakTest { private static String userId; private static String offlineClientAppUri; private static String serviceAccountUserId; @Page protected LoginPage loginPage; @Page protected AccountApplicationsPage applicationsPage; @Rule public AssertEvents events = new AssertEvents(this); @Override public void beforeAbstractKeycloakTest() throws Exception { super.beforeAbstractKeycloakTest(); } @Before public void clientConfiguration() { userId = findUserByUsername(adminClient.realm("test"), "test-user@localhost").getId(); oauth.clientId("test-app"); } @Override public void addTestRealms(List<RealmRepresentation> testRealms) { RealmRepresentation realmRepresentation = loadJson(getClass().getResourceAsStream("/testrealm.json"), RealmRepresentation.class); RealmBuilder realm = RealmBuilder.edit(realmRepresentation) .accessTokenLifespan(10) .ssoSessionIdleTimeout(30) .testEventListener(); offlineClientAppUri = APP_ROOT + "/offline-client"; ClientRepresentation app = ClientBuilder.create().clientId("offline-client") .id(KeycloakModelUtils.generateId()) .adminUrl(offlineClientAppUri) .redirectUris(offlineClientAppUri) .directAccessGrants() .serviceAccountsEnabled(true) .secret("secret1").build(); realm.client(app); serviceAccountUserId = KeycloakModelUtils.generateId(); UserRepresentation serviceAccountUser = UserBuilder.create() .id(serviceAccountUserId) .addRoles("user", "offline_access") .role("test-app", "customer-user") .username(ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + app.getClientId()) .serviceAccountId(app.getClientId()).build(); realm.user(serviceAccountUser); testRealms.add(realm.build()); } @Test public void offlineTokenDisabledForClient() throws Exception { ClientManager.realm(adminClient.realm("test")).clientId("offline-client").fullScopeAllowed(false); oauth.scope(OAuth2Constants.OFFLINE_ACCESS); oauth.clientId("offline-client"); oauth.redirectUri(offlineClientAppUri); oauth.doLogin("test-user@localhost", "password"); EventRepresentation loginEvent = events.expectLogin() .client("offline-client") .detail(Details.REDIRECT_URI, offlineClientAppUri) .assertEvent(); String sessionId = loginEvent.getSessionId(); String codeId = loginEvent.getDetails().get(Details.CODE_ID); String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "secret1"); assertEquals(400, tokenResponse.getStatusCode()); assertEquals("not_allowed", tokenResponse.getError()); events.expectCodeToToken(codeId, sessionId) .client("offline-client") .error("not_allowed") .clearDetails() .assertEvent(); ClientManager.realm(adminClient.realm("test")).clientId("offline-client").fullScopeAllowed(true); } @Test public void offlineTokenUserNotAllowed() throws Exception { String userId = findUserByUsername(adminClient.realm("test"), "keycloak-user@localhost").getId(); oauth.scope(OAuth2Constants.OFFLINE_ACCESS); oauth.clientId("offline-client"); oauth.redirectUri(offlineClientAppUri); oauth.doLogin("keycloak-user@localhost", "password"); EventRepresentation loginEvent = events.expectLogin() .client("offline-client") .user(userId) .detail(Details.REDIRECT_URI, offlineClientAppUri) .assertEvent(); String sessionId = loginEvent.getSessionId(); String codeId = loginEvent.getDetails().get(Details.CODE_ID); String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "secret1"); assertEquals(400, tokenResponse.getStatusCode()); assertEquals("not_allowed", tokenResponse.getError()); events.expectCodeToToken(codeId, sessionId) .client("offline-client") .user(userId) .error("not_allowed") .clearDetails() .assertEvent(); } @Test public void offlineTokenBrowserFlow() throws Exception { oauth.scope(OAuth2Constants.OFFLINE_ACCESS); oauth.clientId("offline-client"); oauth.redirectUri(offlineClientAppUri); oauth.doLogin("test-user@localhost", "password"); EventRepresentation loginEvent = events.expectLogin() .client("offline-client") .detail(Details.REDIRECT_URI, offlineClientAppUri) .assertEvent(); final String sessionId = loginEvent.getSessionId(); String codeId = loginEvent.getDetails().get(Details.CODE_ID); String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE); OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "secret1"); AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken()); String offlineTokenString = tokenResponse.getRefreshToken(); RefreshToken offlineToken = oauth.verifyRefreshToken(offlineTokenString); events.expectCodeToToken(codeId, sessionId) .client("offline-client") .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) .assertEvent(); assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType()); assertEquals(0, offlineToken.getExpiration()); String newRefreshTokenString = testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, sessionId, userId); // Change offset to very big value to ensure offline session expires setTimeOffset(3000000); OAuthClient.AccessTokenResponse response = oauth.doRefreshTokenRequest(newRefreshTokenString, "secret1"); Assert.assertEquals(400, response.getStatusCode()); assertEquals("invalid_grant", response.getError()); events.expectRefresh(offlineToken.getId(), sessionId) .client("offline-client") .error(Errors.INVALID_TOKEN) .user(userId) .clearDetails() .assertEvent(); setTimeOffset(0); } private String testRefreshWithOfflineToken(AccessToken oldToken, RefreshToken offlineToken, String offlineTokenString, final String sessionId, String userId) { // Change offset to big value to ensure userSession expired setTimeOffset(99999); Assert.assertFalse(oldToken.isActive()); Assert.assertTrue(offlineToken.isActive()); // Assert userSession expired testingClient.testing().removeExpired("test"); try { testingClient.testing().removeUserSession("test", sessionId); } catch (NotFoundException nfe) { // Ignore } OAuthClient.AccessTokenResponse response = oauth.doRefreshTokenRequest(offlineTokenString, "secret1"); AccessToken refreshedToken = oauth.verifyToken(response.getAccessToken()); Assert.assertEquals(200, response.getStatusCode()); Assert.assertEquals(sessionId, refreshedToken.getSessionState()); // Assert new refreshToken in the response String newRefreshToken = response.getRefreshToken(); Assert.assertNotNull(newRefreshToken); Assert.assertNotEquals(oldToken.getId(), refreshedToken.getId()); Assert.assertEquals(userId, refreshedToken.getSubject()); Assert.assertTrue(refreshedToken.getRealmAccess().isUserInRole("user")); Assert.assertTrue(refreshedToken.getRealmAccess().isUserInRole(Constants.OFFLINE_ACCESS_ROLE)); Assert.assertEquals(1, refreshedToken.getResourceAccess("test-app").getRoles().size()); Assert.assertTrue(refreshedToken.getResourceAccess("test-app").isUserInRole("customer-user")); EventRepresentation refreshEvent = events.expectRefresh(offlineToken.getId(), sessionId) .client("offline-client") .user(userId) .removeDetail(Details.UPDATED_REFRESH_TOKEN_ID) .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) .assertEvent(); Assert.assertNotEquals(oldToken.getId(), refreshEvent.getDetails().get(Details.TOKEN_ID)); setTimeOffset(0); return newRefreshToken; } @Test public void offlineTokenDirectGrantFlow() throws Exception { oauth.scope(OAuth2Constants.OFFLINE_ACCESS); oauth.clientId("offline-client"); OAuthClient.AccessTokenResponse tokenResponse = oauth.doGrantAccessTokenRequest("secret1", "test-user@localhost", "password"); Assert.assertNull(tokenResponse.getErrorDescription()); AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken()); String offlineTokenString = tokenResponse.getRefreshToken(); RefreshToken offlineToken = oauth.verifyRefreshToken(offlineTokenString); events.expectLogin() .client("offline-client") .user(userId) .session(token.getSessionState()) .detail(Details.GRANT_TYPE, OAuth2Constants.PASSWORD) .detail(Details.TOKEN_ID, token.getId()) .detail(Details.REFRESH_TOKEN_ID, offlineToken.getId()) .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) .detail(Details.USERNAME, "test-user@localhost") .removeDetail(Details.CODE_ID) .removeDetail(Details.REDIRECT_URI) .removeDetail(Details.CONSENT) .assertEvent(); Assert.assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType()); Assert.assertEquals(0, offlineToken.getExpiration()); testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionState(), userId); // Assert same token can be refreshed again testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionState(), userId); } @Test public void offlineTokenDirectGrantFlowWithRefreshTokensRevoked() throws Exception { RealmManager.realm(adminClient.realm("test")).revokeRefreshToken(true); oauth.scope(OAuth2Constants.OFFLINE_ACCESS); oauth.clientId("offline-client"); OAuthClient.AccessTokenResponse tokenResponse = oauth.doGrantAccessTokenRequest("secret1", "test-user@localhost", "password"); AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken()); String offlineTokenString = tokenResponse.getRefreshToken(); RefreshToken offlineToken = oauth.verifyRefreshToken(offlineTokenString); events.expectLogin() .client("offline-client") .user(userId) .session(token.getSessionState()) .detail(Details.GRANT_TYPE, OAuth2Constants.PASSWORD) .detail(Details.TOKEN_ID, token.getId()) .detail(Details.REFRESH_TOKEN_ID, offlineToken.getId()) .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) .detail(Details.USERNAME, "test-user@localhost") .removeDetail(Details.CODE_ID) .removeDetail(Details.REDIRECT_URI) .removeDetail(Details.CONSENT) .assertEvent(); Assert.assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType()); Assert.assertEquals(0, offlineToken.getExpiration()); String offlineTokenString2 = testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionState(), userId); RefreshToken offlineToken2 = oauth.verifyRefreshToken(offlineTokenString2); // Assert second refresh with same refresh token will fail OAuthClient.AccessTokenResponse response = oauth.doRefreshTokenRequest(offlineTokenString, "secret1"); Assert.assertEquals(400, response.getStatusCode()); events.expectRefresh(offlineToken.getId(), token.getSessionState()) .client("offline-client") .error(Errors.INVALID_TOKEN) .user(userId) .clearDetails() .assertEvent(); // Refresh with new refreshToken is successful now testRefreshWithOfflineToken(token, offlineToken2, offlineTokenString2, token.getSessionState(), userId); RealmManager.realm(adminClient.realm("test")).revokeRefreshToken(false); } @Test public void offlineTokenServiceAccountFlow() throws Exception { oauth.scope(OAuth2Constants.OFFLINE_ACCESS); oauth.clientId("offline-client"); OAuthClient.AccessTokenResponse tokenResponse = oauth.doClientCredentialsGrantAccessTokenRequest("secret1"); AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken()); String offlineTokenString = tokenResponse.getRefreshToken(); RefreshToken offlineToken = oauth.verifyRefreshToken(offlineTokenString); events.expectClientLogin() .client("offline-client") .user(serviceAccountUserId) .session(token.getSessionState()) .detail(Details.TOKEN_ID, token.getId()) .detail(Details.REFRESH_TOKEN_ID, offlineToken.getId()) .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) .detail(Details.USERNAME, ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + "offline-client") .assertEvent(); Assert.assertEquals(TokenUtil.TOKEN_TYPE_OFFLINE, offlineToken.getType()); Assert.assertEquals(0, offlineToken.getExpiration()); testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionState(), serviceAccountUserId); // Now retrieve another offline token and verify that previous offline token is still valid tokenResponse = oauth.doClientCredentialsGrantAccessTokenRequest("secret1"); AccessToken token2 = oauth.verifyToken(tokenResponse.getAccessToken()); String offlineTokenString2 = tokenResponse.getRefreshToken(); RefreshToken offlineToken2 = oauth.verifyRefreshToken(offlineTokenString2); events.expectClientLogin() .client("offline-client") .user(serviceAccountUserId) .session(token2.getSessionState()) .detail(Details.TOKEN_ID, token2.getId()) .detail(Details.REFRESH_TOKEN_ID, offlineToken2.getId()) .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) .detail(Details.USERNAME, ServiceAccountConstants.SERVICE_ACCOUNT_USER_PREFIX + "offline-client") .assertEvent(); // Refresh with both offline tokens is fine testRefreshWithOfflineToken(token, offlineToken, offlineTokenString, token.getSessionState(), serviceAccountUserId); testRefreshWithOfflineToken(token2, offlineToken2, offlineTokenString2, token2.getSessionState(), serviceAccountUserId); } @Test public void offlineTokenAllowedWithCompositeRole() throws Exception { RealmResource appRealm = adminClient.realm("test"); UserResource testUser = findUserByUsernameId(appRealm, "test-user@localhost"); RoleRepresentation offlineAccess = findRealmRoleByName(adminClient.realm("test"), Constants.OFFLINE_ACCESS_ROLE).toRepresentation(); // Grant offline_access role indirectly through composite role appRealm.roles().create(RoleBuilder.create().name("composite").build()); RoleResource roleResource = appRealm.roles().get("composite"); roleResource.addComposites(Collections.singletonList(offlineAccess)); testUser.roles().realmLevel().remove(Collections.singletonList(offlineAccess)); testUser.roles().realmLevel().add(Collections.singletonList(roleResource.toRepresentation())); // Integration test offlineTokenDirectGrantFlow(); // Revert changes testUser.roles().realmLevel().remove(Collections.singletonList(appRealm.roles().get("composite").toRepresentation())); appRealm.roles().get("composite").remove(); testUser.roles().realmLevel().add(Collections.singletonList(offlineAccess)); } /** * KEYCLOAK-4201 * * @throws Exception */ @Test public void offlineTokenAdminRESTAccess() throws Exception { // Grant "view-realm" role to user RealmResource appRealm = adminClient.realm("test"); ClientResource realmMgmt = ApiUtil.findClientByClientId(appRealm, Constants.REALM_MANAGEMENT_CLIENT_ID); String realmMgmtUuid = realmMgmt.toRepresentation().getId(); RoleRepresentation roleRep = realmMgmt.roles().get(AdminRoles.VIEW_REALM).toRepresentation(); UserResource testUser = findUserByUsernameId(appRealm, "test-user@localhost"); testUser.roles().clientLevel(realmMgmtUuid).add(Collections.singletonList(roleRep)); // Login with offline token now oauth.scope(OAuth2Constants.OFFLINE_ACCESS); oauth.clientId("offline-client"); OAuthClient.AccessTokenResponse tokenResponse = oauth.doGrantAccessTokenRequest("secret1", "test-user@localhost", "password"); events.clear(); // Set the time offset, so that "normal" userSession expires setTimeOffset(86400); // Remove expired sessions. This will remove "normal" userSession testingClient.testing().removeUserSessions(appRealm.toRepresentation().getId()); // Refresh with the offline token tokenResponse = oauth.doRefreshTokenRequest(tokenResponse.getRefreshToken(), "secret1"); // Use accessToken to admin REST request Keycloak offlineTokenAdmin = Keycloak.getInstance(AuthServerTestEnricher.getAuthServerContextRoot() + "/auth", AuthRealm.MASTER, Constants.ADMIN_CLI_CLIENT_ID, tokenResponse.getAccessToken()); RealmRepresentation testRealm = offlineTokenAdmin.realm("test").toRepresentation(); Assert.assertNotNull(testRealm); } // KEYCLOAK-4525 @Test public void offlineTokenRemoveClientWithTokens() throws Exception { // Create new client RealmResource appRealm = adminClient.realm("test"); ClientRepresentation clientRep = ClientBuilder.create().clientId("offline-client-2") .id(KeycloakModelUtils.generateId()) .directAccessGrants() .secret("secret1").build(); appRealm.clients().create(clientRep); // Direct grant login requesting offline token oauth.scope(OAuth2Constants.OFFLINE_ACCESS); oauth.clientId("offline-client-2"); OAuthClient.AccessTokenResponse tokenResponse = oauth.doGrantAccessTokenRequest("secret1", "test-user@localhost", "password"); Assert.assertNull(tokenResponse.getErrorDescription()); AccessToken token = oauth.verifyToken(tokenResponse.getAccessToken()); String offlineTokenString = tokenResponse.getRefreshToken(); RefreshToken offlineToken = oauth.verifyRefreshToken(offlineTokenString); events.expectLogin() .client("offline-client-2") .user(userId) .session(token.getSessionState()) .detail(Details.GRANT_TYPE, OAuth2Constants.PASSWORD) .detail(Details.TOKEN_ID, token.getId()) .detail(Details.REFRESH_TOKEN_ID, offlineToken.getId()) .detail(Details.REFRESH_TOKEN_TYPE, TokenUtil.TOKEN_TYPE_OFFLINE) .detail(Details.USERNAME, "test-user@localhost") .removeDetail(Details.CODE_ID) .removeDetail(Details.REDIRECT_URI) .removeDetail(Details.CONSENT) .assertEvent(); // Go to account mgmt applications page applicationsPage.open(); loginPage.login("test-user@localhost", "password"); events.expectLogin().client("account").detail(Details.REDIRECT_URI, AccountTest.ACCOUNT_REDIRECT + "?path=applications").assertEvent(); Assert.assertTrue(applicationsPage.isCurrent()); Map<String, AccountApplicationsPage.AppEntry> apps = applicationsPage.getApplications(); Assert.assertTrue(apps.containsKey("offline-client-2")); Assert.assertEquals("Offline Token", apps.get("offline-client-2").getAdditionalGrants().get(0)); // Now remove the client ClientResource offlineTokenClient2 = ApiUtil.findClientByClientId(appRealm, "offline-client-2" ); offlineTokenClient2.remove(); // Go to applications page and see offline-client not anymore applicationsPage.open(); apps = applicationsPage.getApplications(); Assert.assertFalse(apps.containsKey("offline-client-2")); // Login as admin and see consents of user UserResource user = ApiUtil.findUserByUsernameId(appRealm, "test-user@localhost"); List<Map<String, Object>> consents = user.getConsents(); Assert.assertTrue(consents.isEmpty()); } }