/*
* 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.adapter.servlet;
import java.io.IOException;
import java.io.InputStream;
import java.util.concurrent.TimeUnit;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriBuilder;
import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.client.HttpClient;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.DefaultHttpClient;
import org.jboss.arquillian.container.test.api.Deployment;
import org.jboss.arquillian.graphene.page.Page;
import org.jboss.shrinkwrap.api.spec.WebArchive;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.keycloak.OAuth2Constants;
import org.keycloak.admin.client.resource.ClientResource;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.common.util.StreamUtil;
import org.keycloak.common.util.Time;
import org.keycloak.constants.AdapterConstants;
import org.keycloak.jose.jws.AlgorithmType;
import org.keycloak.keys.KeyProvider;
import org.keycloak.protocol.oidc.OIDCAdvancedConfigWrapper;
import org.keycloak.protocol.oidc.OIDCLoginProtocolService;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.adapters.action.GlobalRequestResult;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.ComponentRepresentation;
import org.keycloak.representations.idm.KeysMetadataRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.testsuite.adapter.AbstractServletsAdapterTest;
import org.keycloak.testsuite.adapter.filter.AdapterActionsFilter;
import org.keycloak.testsuite.adapter.page.CustomerDb;
import org.keycloak.testsuite.adapter.page.SecurePortal;
import org.keycloak.testsuite.adapter.page.TokenMinTTLPage;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.util.URLAssert;
import org.openqa.selenium.By;
import static org.junit.Assert.*;
import static org.keycloak.testsuite.auth.page.AuthRealm.DEMO;
import static org.keycloak.testsuite.util.URLAssert.assertCurrentUrlEquals;
import static org.keycloak.testsuite.util.URLAssert.assertCurrentUrlStartsWith;
import static org.keycloak.testsuite.util.URLAssert.assertCurrentUrlStartsWithLoginUrlOf;
import static org.keycloak.testsuite.util.WaitUtils.waitUntilElement;
/**
* Tests related to public key rotation for OIDC adapter
*
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public abstract class AbstractOIDCPublicKeyRotationAdapterTest extends AbstractServletsAdapterTest {
@Page
private SecurePortal securePortal;
@Page
private TokenMinTTLPage tokenMinTTLPage;
@Page
private CustomerDb customerDb;
@Deployment(name = SecurePortal.DEPLOYMENT_NAME)
protected static WebArchive securePortal() {
return servletDeployment(SecurePortal.DEPLOYMENT_NAME, CallAuthenticatedServlet.class);
}
@Deployment(name = TokenMinTTLPage.DEPLOYMENT_NAME)
protected static WebArchive tokenMinTTLPage() {
return servletDeployment(TokenMinTTLPage.DEPLOYMENT_NAME, AdapterActionsFilter.class, AbstractShowTokensServlet.class, TokenMinTTLServlet.class, ErrorServlet.class);
}
@Deployment(name = CustomerDb.DEPLOYMENT_NAME)
protected static WebArchive customerDb() {
return servletDeployment(CustomerDb.DEPLOYMENT_NAME, AdapterActionsFilter.class, CustomerDatabaseServlet.class);
}
@Before
public void beforeRotationAdapterTest() {
// Delete all cookies from token-min-ttl page to be sure we are logged out
tokenMinTTLPage.navigateTo();
driver.manage().deleteAllCookies();
}
@Test
public void testRealmKeyRotationWithNewKeyDownload() throws Exception {
// Login success first
loginToTokenMinTtlApp();
// Logout
String logoutUri = OIDCLoginProtocolService.logoutUrl(authServerPage.createUriBuilder())
.queryParam(OAuth2Constants.REDIRECT_URI, tokenMinTTLPage.toString())
.build("demo").toString();
driver.navigate().to(logoutUri);
assertCurrentUrlStartsWithLoginUrlOf(testRealmPage);
// Generate new realm key
generateNewRealmKey();
// Try to login again. It should fail now because not yet allowed to download new keys
tokenMinTTLPage.navigateTo();
testRealmLoginPage.form().waitForUsernameInputPresent();
assertCurrentUrlStartsWithLoginUrlOf(testRealmPage);
testRealmLoginPage.form().login("bburke@redhat.com", "password");
URLAssert.assertCurrentUrlStartsWith(driver, tokenMinTTLPage.getInjectedUrl().toString());
Assert.assertNull(tokenMinTTLPage.getAccessToken());
driver.navigate().to(logoutUri);
assertCurrentUrlStartsWithLoginUrlOf(testRealmPage);
setAdapterAndServerTimeOffset(300, tokenMinTTLPage.toString() + "/unsecured/foo");
// Try to login. Should work now due to realm key change
loginToTokenMinTtlApp();
driver.navigate().to(logoutUri);
// Revert public keys change
resetKeycloakDeploymentForAdapter(tokenMinTTLPage.toString() + "/unsecured/foo");
}
@Test
public void testClientWithJwksUri() throws Exception {
// Set client to bad JWKS URI
ClientResource clientResource = ApiUtil.findClientResourceByClientId(testRealmResource(), "secure-portal");
ClientRepresentation client = clientResource.toRepresentation();
OIDCAdvancedConfigWrapper wrapper = OIDCAdvancedConfigWrapper.fromClientRepresentation(client);
wrapper.setUseJwksUrl(true);
wrapper.setJwksUrl(securePortal + "/bad-jwks-url");
clientResource.update(client);
// Login should fail at the code-to-token
securePortal.navigateTo();
assertCurrentUrlStartsWithLoginUrlOf(testRealmPage);
testRealmLoginPage.form().login("bburke@redhat.com", "password");
String pageSource = driver.getPageSource();
assertCurrentUrlStartsWith(securePortal);
assertFalse(pageSource.contains("Bill Burke") && pageSource.contains("Stian Thorgersen"));
// Set client to correct JWKS URI
client = clientResource.toRepresentation();
wrapper = OIDCAdvancedConfigWrapper.fromClientRepresentation(client);
wrapper.setUseJwksUrl(true);
wrapper.setJwksUrl(securePortal + "/" + AdapterConstants.K_JWKS);
clientResource.update(client);
// Login to secure-portal should be fine now. Client keys downloaded from JWKS URI
securePortal.navigateTo();
assertCurrentUrlEquals(securePortal);
pageSource = driver.getPageSource();
assertTrue(pageSource.contains("Bill Burke") && pageSource.contains("Stian Thorgersen"));
// Logout
String logoutUri = OIDCLoginProtocolService.logoutUrl(authServerPage.createUriBuilder())
.queryParam(OAuth2Constants.REDIRECT_URI, securePortal.toString()).build("demo").toString();
driver.navigate().to(logoutUri);
}
// KEYCLOAK-3824: Test for public-key-cache-ttl
@Test
public void testPublicKeyCacheTtl() {
// increase accessTokenLifespan to 1200
RealmRepresentation demoRealm = adminClient.realm(DEMO).toRepresentation();
demoRealm.setAccessTokenLifespan(1200);
adminClient.realm(DEMO).update(demoRealm);
// authenticate in tokenMinTTL app
loginToTokenMinTtlApp();
String accessTokenString = tokenMinTTLPage.getAccessTokenString();
// Send REST request to customer-db app. I should be successfully authenticated
int status = invokeRESTEndpoint(accessTokenString);
Assert.assertEquals(200, status);
// Re-generate realm public key and remove the old key
String oldActiveKeyProviderId = getActiveKeyProvider();
generateNewRealmKey();
adminClient.realm(DEMO).components().component(oldActiveKeyProviderId).remove();
// Send REST request to the customer-db app. Should be still succcessfully authenticated as the JWKPublicKeyLocator cache is still valid
status = invokeRESTEndpoint(accessTokenString);
Assert.assertEquals(200, status);
// TimeOffset to 900 on the REST app side. Token is still valid (1200) but JWKPublicKeyLocator should try to download new key (public-key-cache-ttl=600)
setAdapterAndServerTimeOffset(900, customerDb.toString() + "/unsecured/foo");
// Send REST request. New request to the publicKey cache should be sent, and key is no longer returned as token contains the old kid
status = invokeRESTEndpoint(accessTokenString);
Assert.assertEquals(401, status);
// Revert public keys change and time offset
resetKeycloakDeploymentForAdapter(customerDb.toString() + "/unsecured/foo");
resetKeycloakDeploymentForAdapter(tokenMinTTLPage.toString() + "/unsecured/foo");
}
// KEYCLOAK-3823: Test that sending notBefore policy invalidates JWKPublicKeyLocator cache
@Test
public void testPublicKeyCacheInvalidatedWhenPushedNotBefore() {
driver.manage().timeouts().pageLoadTimeout(1000, TimeUnit.SECONDS);
// increase accessTokenLifespan to 1200
RealmRepresentation demoRealm = adminClient.realm(DEMO).toRepresentation();
demoRealm.setAccessTokenLifespan(1200);
adminClient.realm(DEMO).update(demoRealm);
// authenticate in tokenMinTTL app
loginToTokenMinTtlApp();
String accessTokenString = tokenMinTTLPage.getAccessTokenString();
// Generate new realm public key
String oldActiveKeyProviderId = getActiveKeyProvider();
generateNewRealmKey();
// Send REST request to customer-db app. It should be successfully authenticated even that token is signed by the old key
int status = invokeRESTEndpoint(accessTokenString);
Assert.assertEquals(200, status);
// Remove the old realm key now
adminClient.realm(DEMO).components().component(oldActiveKeyProviderId).remove();
// Set some offset to ensure pushing notBefore will pass
setAdapterAndServerTimeOffset(130, customerDb.toString() + "/unsecured/foo", tokenMinTTLPage.toString() + "/unsecured/foo");
// Send notBefore policy from the realm
demoRealm.setNotBefore(Time.currentTime() - 1);
adminClient.realm(DEMO).update(demoRealm);
GlobalRequestResult result = adminClient.realm(DEMO).pushRevocation();
Assert.assertTrue(result.getSuccessRequests().contains(customerDb.toString()));
// Send REST request. New request to the publicKey cache should be sent, and key is no longer returned as token contains the old kid
status = invokeRESTEndpoint(accessTokenString);
Assert.assertEquals(401, status);
// Revert public keys change and time offset
resetKeycloakDeploymentForAdapter(customerDb.toString() + "/unsecured/foo");
resetKeycloakDeploymentForAdapter(tokenMinTTLPage.toString() + "/unsecured/foo");
}
// HELPER METHODS
private void loginToTokenMinTtlApp() {
tokenMinTTLPage.navigateTo();
testRealmLoginPage.form().waitForUsernameInputPresent();
assertCurrentUrlStartsWithLoginUrlOf(testRealmPage);
testRealmLoginPage.form().login("bburke@redhat.com", "password");
assertCurrentUrlEquals(tokenMinTTLPage);
AccessToken token = tokenMinTTLPage.getAccessToken();
Assert.assertEquals("bburke@redhat.com", token.getPreferredUsername());
}
private void generateNewRealmKey() {
String realmId = adminClient.realm(DEMO).toRepresentation().getId();
ComponentRepresentation keys = new ComponentRepresentation();
keys.setName("generated");
keys.setProviderType(KeyProvider.class.getName());
keys.setProviderId("rsa-generated");
keys.setParentId(realmId);
keys.setConfig(new MultivaluedHashMap<>());
keys.getConfig().putSingle("priority", "150");
Response response = adminClient.realm(DEMO).components().add(keys);
assertEquals(201, response.getStatus());
response.close();
}
private String getActiveKeyProvider() {
KeysMetadataRepresentation keyMetadata = adminClient.realm(DEMO).keys().getKeyMetadata();
String activeKid = keyMetadata.getActive().get(AlgorithmType.RSA.name());
for (KeysMetadataRepresentation.KeyMetadataRepresentation rep : keyMetadata.getKeys()) {
if (rep.getKid().equals(activeKid)) {
return rep.getProviderId();
}
}
return null;
}
private int invokeRESTEndpoint(String accessTokenString) {
HttpClient client = new DefaultHttpClient();
try {
String restUrl = customerDb.toString();
HttpGet get = new HttpGet(restUrl);
get.addHeader("Authorization", "Bearer " + accessTokenString);
try {
HttpResponse response = client.execute(get);
int status = response.getStatusLine().getStatusCode();
if (status != 200) {
return status;
}
HttpEntity entity = response.getEntity();
InputStream is = entity.getContent();
try {
String body = StreamUtil.readString(is);
Assert.assertTrue(body.contains("Stian Thorgersen") && body.contains("Bill Burke"));
return status;
} finally {
is.close();
}
} catch (IOException e) {
throw new RuntimeException(e);
}
} finally {
client.getConnectionManager().shutdown();
}
}
private void resetKeycloakDeploymentForAdapter(String adapterActionsUrl) {
String timeOffsetUri = UriBuilder.fromUri(adapterActionsUrl)
.queryParam(AdapterActionsFilter.RESET_DEPLOYMENT_PARAM, "true")
.build().toString();
driver.navigate().to(timeOffsetUri);
waitUntilElement(By.tagName("body")).is().visible();
}
}