diff --git a/http/oidc/src/test/java/org/wildfly/security/http/oidc/BearerTest.java b/http/oidc/src/test/java/org/wildfly/security/http/oidc/BearerTest.java new file mode 100644 index 00000000000..1aacbe3239d --- /dev/null +++ b/http/oidc/src/test/java/org/wildfly/security/http/oidc/BearerTest.java @@ -0,0 +1,545 @@ +/* + * JBoss, Home of Professional Open Source. + * Copyright 2022 Red Hat, Inc., and individual 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.wildfly.security.http.oidc; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.junit.Assume.assumeTrue; +import static org.wildfly.security.http.oidc.KeycloakConfiguration.ALLOWED_ORIGIN; +import static org.wildfly.security.http.oidc.Oidc.OIDC_NAME; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.http.HttpStatus; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; +import org.wildfly.common.iteration.CodePointIterator; +import org.wildfly.security.http.HttpServerAuthenticationMechanism; +import org.wildfly.security.http.HttpServerAuthenticationMechanismFactory; + +import com.gargoylesoftware.htmlunit.FailingHttpStatusCodeException; +import com.gargoylesoftware.htmlunit.HttpMethod; +import com.gargoylesoftware.htmlunit.TextPage; +import com.gargoylesoftware.htmlunit.WebClient; + +import io.restassured.RestAssured; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.QueueDispatcher; +import okhttp3.mockwebserver.RecordedRequest; + +/** + * Tests for bearer only auth. + * + * @author Farah Juma + */ +public class BearerTest extends OidcBaseTest { + + private static boolean DIRECT_ACCESS_GRANT_ENABLED = true; + private static final String BEARER_ONLY_CLIENT_ID = "bearer-client"; + private static final String CORS_CLIENT_ID = "cors-client"; + private static final String SECURED_ENDPOINT = "/service/secured"; + private static final String SECURED_PAGE_TEXT = "Welcome to the secured page!"; + private static final String WRONG_PASSWORD = "WRONG_PASSWORD"; + + protected HttpServerAuthenticationMechanismFactory oidcFactory; + + private enum BearerAuthType { + BEARER, + QUERY_PARAM, + BASIC + } + + @BeforeClass + public static void startTestContainers() throws Exception { + assumeTrue("Docker isn't available, OIDC tests will be skipped", isDockerAvailable()); + KEYCLOAK_CONTAINER = new KeycloakContainer(); + KEYCLOAK_CONTAINER.start(); + sendRealmCreationRequest(KeycloakConfiguration.getRealmRepresentation(TEST_REALM, CLIENT_ID, CLIENT_SECRET, + CLIENT_HOST_NAME, CLIENT_PORT, CLIENT_APP, DIRECT_ACCESS_GRANT_ENABLED, BEARER_ONLY_CLIENT_ID, + CORS_CLIENT_ID)); + client = new MockWebServer(); + client.start(CLIENT_PORT); + } + + private static Dispatcher createAppBearerResponse(HttpServerAuthenticationMechanism mechanism, String clientPageText, + String expectedError, String originHeader) { + return new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest recordedRequest) throws InterruptedException { + String path = recordedRequest.getPath(); + if (path.contains("/" + CLIENT_APP + SECURED_ENDPOINT)) { + try { + String authorizationHeader = recordedRequest.getHeader("Authorization"); + TestingHttpServerRequest request; + if (originHeader != null) { + Map> requestHeaders = new HashMap<>(); + if (authorizationHeader != null) { + requestHeaders.put("Authorization", Collections.singletonList(authorizationHeader)); + } + requestHeaders.put(CorsHeaders.ORIGIN, Collections.singletonList(originHeader)); + request = new TestingHttpServerRequest(requestHeaders, new URI(recordedRequest.getRequestUrl().toString()), recordedRequest.getMethod()); + } else { + request = new TestingHttpServerRequest(authorizationHeader == null ? null : new String[]{authorizationHeader}, + new URI(recordedRequest.getRequestUrl().toString())); + } + mechanism.evaluateRequest(request); + TestingHttpServerResponse response = request.getResponse(); + int statusCode = response.getStatusCode(); + if (expectedError != null) { + assertTrue(response.getAuthenticateHeader().contains(expectedError)); + return new MockResponse().setResponseCode(statusCode); + } else if (statusCode > 300) { + // unexpected error + return new MockResponse().setResponseCode(statusCode); + } + return new MockResponse().setBody(clientPageText); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + return new MockResponse() + .setBody(""); + } + }; + } + + @AfterClass + public static void generalCleanup() throws Exception { + if (KEYCLOAK_CONTAINER != null) { + RestAssured + .given() + .auth().oauth2(KeycloakConfiguration.getAdminAccessToken(KEYCLOAK_CONTAINER.getAuthServerUrl())) + .when() + .delete(KEYCLOAK_CONTAINER.getAuthServerUrl() + "/admin/realms/" + TEST_REALM).then().statusCode(204); + KEYCLOAK_CONTAINER.stop(); + } + if (client != null) { + client.shutdown(); + } + } + + @Test + public void testSucessfulAuthenticationWithAuthServerUrl() throws Exception { + performBearerAuthentication(getOidcConfigurationInputStream(), SECURED_ENDPOINT, KeycloakConfiguration.ALICE, KeycloakConfiguration.ALICE_PASSWORD, + SECURED_PAGE_TEXT); + } + + @Test + public void testSucessfulAuthenticationWithProviderUrl() throws Exception { + performBearerAuthentication(getOidcConfigurationInputStreamWithProviderUrl(), SECURED_ENDPOINT, KeycloakConfiguration.ALICE, KeycloakConfiguration.ALICE_PASSWORD, + SECURED_PAGE_TEXT); + } + + @Test + public void testWrongToken() throws Exception { + String wrongToken = "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJrNmhQYTdHdmdrajdFdlhLeFAtRjFLZkNSUk85Q3kwNC04YzFqTERWOXNrIn0.eyJleHAiOjE2NTc2NjExODksImlhdCI6MTY1NzY2MTEyOSwianRpIjoiZThiZGQ3MWItYTA2OC00Mjc3LTkyY2UtZWJkYmU2MDVkMzBhIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL3JlYWxtcy9tYXN0ZXIiLCJhdWQiOlsibXlyZWFsbS1yZWFsbSIsIm1hc3Rlci1yZWFsbSIsImFjY291bnQiXSwic3ViIjoiZTliOGE2OWItM2RlNy00ZDYzLWFjYmItMmYyNTRhMDM1MjVkIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoidGVzdC13ZWJhcHAiLCJzZXNzaW9uX3N0YXRlIjoiMTQ1OTdhMmUtOGM1Ni00YzkwLWI3NjAtZWFjYzczNWU1Zjc1IiwiYWNyIjoiMSIsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJjcmVhdGUtcmVhbG0iLCJkZWZhdWx0LXJvbGVzLW1hc3RlciIsIm9mZmxpbmVfYWNjZXNzIiwiYWRtaW4iLCJ1bWFfYXV0aG9yaXphdGlvbiIsInVzZXIiXX0sInJlc291cmNlX2FjY2VzcyI6eyJteXJlYWxtLXJlYWxtIjp7InJvbGVzIjpbInZpZXctcmVhbG0iLCJ2aWV3LWlkZW50aXR5LXByb3ZpZGVycyIsIm1hbmFnZS1pZGVudGl0eS1wcm92aWRlcnMiLCJpbXBlcnNvbmF0aW9uIiwiY3JlYXRlLWNsaWVudCIsIm1hbmFnZS11c2VycyIsInF1ZXJ5LXJlYWxtcyIsInZpZXctYXV0aG9yaXphdGlvbiIsInF1ZXJ5LWNsaWVudHMiLCJxdWVyeS11c2VycyIsIm1hbmFnZS1ldmVudHMiLCJtYW5hZ2UtcmVhbG0iLCJ2aWV3LWV2ZW50cyIsInZpZXctdXNlcnMiLCJ2aWV3LWNsaWVudHMiLCJtYW5hZ2UtYXV0aG9yaXphdGlvbiIsIm1hbmFnZS1jbGllbnRzIiwicXVlcnktZ3JvdXBzIl19LCJtYXN0ZXItcmVhbG0iOnsicm9sZXMiOlsidmlldy1yZWFsbSIsInZpZXctaWRlbnRpdHktcHJvdmlkZXJzIiwibWFuYWdlLWlkZW50aXR5LXByb3ZpZGVycyIsImltcGVyc29uYXRpb24iLCJjcmVhdGUtY2xpZW50IiwibWFuYWdlLXVzZXJzIiwicXVlcnktcmVhbG1zIiwidmlldy1hdXRob3JpemF0aW9uIiwicXVlcnktY2xpZW50cyIsInF1ZXJ5LXVzZXJzIiwibWFuYWdlLWV2ZW50cyIsIm1hbmFnZS1yZWFsbSIsInZpZXctZXZlbnRzIiwidmlldy11c2VycyIsInZpZXctY2xpZW50cyIsIm1hbmFnZS1hdXRob3JpemF0aW9uIiwibWFuYWdlLWNsaWVudHMiLCJxdWVyeS1ncm91cHMiXX0sImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoiZW1haWwgcHJvZmlsZSIsInNpZCI6IjE0NTk3YTJlLThjNTYtNGM5MC1iNzYwLWVhY2M3MzVlNWY3NSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwicHJlZmVycmVkX3VzZXJuYW1lIjoiYWxpY2UifQ.hVj6SG-aTcDYhifdljpiBcz4ShCHej3h_4-82rgX0s_oJ-En68Cqt-_DgJLtMdr6dW_gQFFCPYBJfEGvZ8L6b_TwzbdLxyrQrKTOpeG0KJ8VAFlbWum9B1vvES_sav1Gj1sQHlV621EaLISYz7pnknuQEvrB7liJFRRjN9SH30AsAJy6nmKTDHGZ6Eegkveqd_7POaKfsHS3Z0-SGyL5GClXv9yZ1l5Y4VH-rrMUztLPCFH5bJ319-m-7sgizvV-C2EcM37XVAtPRVQbJNRW0wVmLEJKMuLYVnjS1Wn5eU_qnBvVMEaENNG3TzNd6b4YmxMFHFf9tnkb3wkDzdrRTA"; + performBearerAuthentication(getOidcConfigurationInputStreamWithProviderUrl(), SECURED_ENDPOINT, KeycloakConfiguration.ALICE, KeycloakConfiguration.ALICE_PASSWORD, + SECURED_PAGE_TEXT, wrongToken, BearerAuthType.BEARER); + } + + @Test + public void testInvalidToken() throws Exception { + performBearerAuthentication(getOidcConfigurationInputStreamWithProviderUrl(), SECURED_ENDPOINT, KeycloakConfiguration.ALICE, KeycloakConfiguration.ALICE_PASSWORD, + SECURED_PAGE_TEXT, "INVALID_TOKEN", BearerAuthType.BEARER); + } + + @Test + public void testNoTokenProvidedWithAuthServerUrl() throws Exception { + accessAppWithoutToken(SECURED_ENDPOINT, getOidcConfigurationInputStream()); + } + + @Test + public void testNoTokenProvidedWithProviderUrl() throws Exception { + accessAppWithoutToken(SECURED_ENDPOINT, getOidcConfigurationInputStreamWithProviderUrl()); + } + + @Test + public void testTokenProvidedBearerOnlyNotSet() throws Exception { + // ensure we still make use of the bearer token + performBearerAuthentication(getOidcConfigurationInputStreamWithoutBearerOnly(), SECURED_ENDPOINT, KeycloakConfiguration.ALICE, KeycloakConfiguration.ALICE_PASSWORD, + SECURED_PAGE_TEXT); + } + + @Test + public void testTokenNotProvidedBearerOnlyNotSet() throws Exception { + // ensure the regular OIDC flow takes place + accessAppWithoutToken("", getRegularOidcConfigurationInputStream()); + } + + /** + * Tests that pass the bearer token to use via an access_token query param. + */ + + @Test + public void testValidTokenViaQueryParameter() throws Exception { + performBearerAuthentication(getOidcConfigurationInputStreamWithProviderUrl(), SECURED_ENDPOINT, KeycloakConfiguration.ALICE, KeycloakConfiguration.ALICE_PASSWORD, + SECURED_PAGE_TEXT, null, BearerAuthType.QUERY_PARAM); + } + + @Test + public void testWrongTokenViaQueryParameter() throws Exception { + String wrongToken = "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJrNmhQYTdHdmdrajdFdlhLeFAtRjFLZkNSUk85Q3kwNC04YzFqTERWOXNrIn0.eyJleHAiOjE2NTc2NjExODksImlhdCI6MTY1NzY2MTEyOSwianRpIjoiZThiZGQ3MWItYTA2OC00Mjc3LTkyY2UtZWJkYmU2MDVkMzBhIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL3JlYWxtcy9tYXN0ZXIiLCJhdWQiOlsibXlyZWFsbS1yZWFsbSIsIm1hc3Rlci1yZWFsbSIsImFjY291bnQiXSwic3ViIjoiZTliOGE2OWItM2RlNy00ZDYzLWFjYmItMmYyNTRhMDM1MjVkIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoidGVzdC13ZWJhcHAiLCJzZXNzaW9uX3N0YXRlIjoiMTQ1OTdhMmUtOGM1Ni00YzkwLWI3NjAtZWFjYzczNWU1Zjc1IiwiYWNyIjoiMSIsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJjcmVhdGUtcmVhbG0iLCJkZWZhdWx0LXJvbGVzLW1hc3RlciIsIm9mZmxpbmVfYWNjZXNzIiwiYWRtaW4iLCJ1bWFfYXV0aG9yaXphdGlvbiIsInVzZXIiXX0sInJlc291cmNlX2FjY2VzcyI6eyJteXJlYWxtLXJlYWxtIjp7InJvbGVzIjpbInZpZXctcmVhbG0iLCJ2aWV3LWlkZW50aXR5LXByb3ZpZGVycyIsIm1hbmFnZS1pZGVudGl0eS1wcm92aWRlcnMiLCJpbXBlcnNvbmF0aW9uIiwiY3JlYXRlLWNsaWVudCIsIm1hbmFnZS11c2VycyIsInF1ZXJ5LXJlYWxtcyIsInZpZXctYXV0aG9yaXphdGlvbiIsInF1ZXJ5LWNsaWVudHMiLCJxdWVyeS11c2VycyIsIm1hbmFnZS1ldmVudHMiLCJtYW5hZ2UtcmVhbG0iLCJ2aWV3LWV2ZW50cyIsInZpZXctdXNlcnMiLCJ2aWV3LWNsaWVudHMiLCJtYW5hZ2UtYXV0aG9yaXphdGlvbiIsIm1hbmFnZS1jbGllbnRzIiwicXVlcnktZ3JvdXBzIl19LCJtYXN0ZXItcmVhbG0iOnsicm9sZXMiOlsidmlldy1yZWFsbSIsInZpZXctaWRlbnRpdHktcHJvdmlkZXJzIiwibWFuYWdlLWlkZW50aXR5LXByb3ZpZGVycyIsImltcGVyc29uYXRpb24iLCJjcmVhdGUtY2xpZW50IiwibWFuYWdlLXVzZXJzIiwicXVlcnktcmVhbG1zIiwidmlldy1hdXRob3JpemF0aW9uIiwicXVlcnktY2xpZW50cyIsInF1ZXJ5LXVzZXJzIiwibWFuYWdlLWV2ZW50cyIsIm1hbmFnZS1yZWFsbSIsInZpZXctZXZlbnRzIiwidmlldy11c2VycyIsInZpZXctY2xpZW50cyIsIm1hbmFnZS1hdXRob3JpemF0aW9uIiwibWFuYWdlLWNsaWVudHMiLCJxdWVyeS1ncm91cHMiXX0sImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoiZW1haWwgcHJvZmlsZSIsInNpZCI6IjE0NTk3YTJlLThjNTYtNGM5MC1iNzYwLWVhY2M3MzVlNWY3NSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwicHJlZmVycmVkX3VzZXJuYW1lIjoiYWxpY2UifQ.hVj6SG-aTcDYhifdljpiBcz4ShCHej3h_4-82rgX0s_oJ-En68Cqt-_DgJLtMdr6dW_gQFFCPYBJfEGvZ8L6b_TwzbdLxyrQrKTOpeG0KJ8VAFlbWum9B1vvES_sav1Gj1sQHlV621EaLISYz7pnknuQEvrB7liJFRRjN9SH30AsAJy6nmKTDHGZ6Eegkveqd_7POaKfsHS3Z0-SGyL5GClXv9yZ1l5Y4VH-rrMUztLPCFH5bJ319-m-7sgizvV-C2EcM37XVAtPRVQbJNRW0wVmLEJKMuLYVnjS1Wn5eU_qnBvVMEaENNG3TzNd6b4YmxMFHFf9tnkb3wkDzdrRTA"; + performBearerAuthentication(getOidcConfigurationInputStreamWithProviderUrl(), SECURED_ENDPOINT, KeycloakConfiguration.ALICE, KeycloakConfiguration.ALICE_PASSWORD, + SECURED_PAGE_TEXT, wrongToken, BearerAuthType.QUERY_PARAM); + } + + @Test + public void testInvalidTokenViaQueryParameter() throws Exception { + performBearerAuthentication(getOidcConfigurationInputStreamWithProviderUrl(), SECURED_ENDPOINT, KeycloakConfiguration.ALICE, KeycloakConfiguration.ALICE_PASSWORD, + SECURED_PAGE_TEXT, "INVALID_TOKEN", BearerAuthType.QUERY_PARAM); + } + + /** + * Tests that rely on obtaining the bearer token to use from credentials obtained from basic auth. + */ + + @Test + public void testBasicAuthenticationWithoutEnableBasicAuthSet() throws Exception { + accessAppWithoutToken(SECURED_ENDPOINT, getOidcConfigurationInputStream(), BearerAuthType.BASIC, KeycloakConfiguration.ALICE, KeycloakConfiguration.ALICE_PASSWORD); + } + + @Test + public void testBasicAuthenticationWithoutEnableBasicAuthSetAndWithoutBearerOnlySet() throws Exception { + // ensure the regular OIDC flow takes place + accessAppWithoutToken("", getRegularOidcConfigurationInputStream(), BearerAuthType.BASIC, KeycloakConfiguration.ALICE, KeycloakConfiguration.ALICE_PASSWORD); + } + + @Test + public void testValidCredentialsBasicAuthentication() throws Exception { + performBearerAuthentication(getOidcConfigurationInputStreamWithEnableBasicAuth(), SECURED_ENDPOINT, KeycloakConfiguration.ALICE, KeycloakConfiguration.ALICE_PASSWORD, + SECURED_PAGE_TEXT, null, BearerAuthType.BASIC); + } + + @Test + public void testInvalidCredentialsBasicAuthentication() throws Exception { + accessAppWithoutToken(SECURED_ENDPOINT, getOidcConfigurationInputStreamWithEnableBasicAuth(), BearerAuthType.BASIC, KeycloakConfiguration.ALICE, WRONG_PASSWORD); + } + + /** + * Tests that simulate CORS preflight requests. + */ + + @Test + public void testCorsRequestWithEnableCors() throws Exception { + performBearerAuthenticationCorsRequest(getOidcConfigurationInputStreamWithEnableCors(), SECURED_ENDPOINT, KeycloakConfiguration.ALICE, KeycloakConfiguration.ALICE_PASSWORD, + SECURED_PAGE_TEXT, null, ALLOWED_ORIGIN); + } + + @Test + public void testCorsRequestWithEnableCorsWithWrongToken() throws Exception { + String wrongToken = "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJrNmhQYTdHdmdrajdFdlhLeFAtRjFLZkNSUk85Q3kwNC04YzFqTERWOXNrIn0.eyJleHAiOjE2NTc2NjExODksImlhdCI6MTY1NzY2MTEyOSwianRpIjoiZThiZGQ3MWItYTA2OC00Mjc3LTkyY2UtZWJkYmU2MDVkMzBhIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL3JlYWxtcy9tYXN0ZXIiLCJhdWQiOlsibXlyZWFsbS1yZWFsbSIsIm1hc3Rlci1yZWFsbSIsImFjY291bnQiXSwic3ViIjoiZTliOGE2OWItM2RlNy00ZDYzLWFjYmItMmYyNTRhMDM1MjVkIiwidHlwIjoiQmVhcmVyIiwiYXpwIjoidGVzdC13ZWJhcHAiLCJzZXNzaW9uX3N0YXRlIjoiMTQ1OTdhMmUtOGM1Ni00YzkwLWI3NjAtZWFjYzczNWU1Zjc1IiwiYWNyIjoiMSIsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJjcmVhdGUtcmVhbG0iLCJkZWZhdWx0LXJvbGVzLW1hc3RlciIsIm9mZmxpbmVfYWNjZXNzIiwiYWRtaW4iLCJ1bWFfYXV0aG9yaXphdGlvbiIsInVzZXIiXX0sInJlc291cmNlX2FjY2VzcyI6eyJteXJlYWxtLXJlYWxtIjp7InJvbGVzIjpbInZpZXctcmVhbG0iLCJ2aWV3LWlkZW50aXR5LXByb3ZpZGVycyIsIm1hbmFnZS1pZGVudGl0eS1wcm92aWRlcnMiLCJpbXBlcnNvbmF0aW9uIiwiY3JlYXRlLWNsaWVudCIsIm1hbmFnZS11c2VycyIsInF1ZXJ5LXJlYWxtcyIsInZpZXctYXV0aG9yaXphdGlvbiIsInF1ZXJ5LWNsaWVudHMiLCJxdWVyeS11c2VycyIsIm1hbmFnZS1ldmVudHMiLCJtYW5hZ2UtcmVhbG0iLCJ2aWV3LWV2ZW50cyIsInZpZXctdXNlcnMiLCJ2aWV3LWNsaWVudHMiLCJtYW5hZ2UtYXV0aG9yaXphdGlvbiIsIm1hbmFnZS1jbGllbnRzIiwicXVlcnktZ3JvdXBzIl19LCJtYXN0ZXItcmVhbG0iOnsicm9sZXMiOlsidmlldy1yZWFsbSIsInZpZXctaWRlbnRpdHktcHJvdmlkZXJzIiwibWFuYWdlLWlkZW50aXR5LXByb3ZpZGVycyIsImltcGVyc29uYXRpb24iLCJjcmVhdGUtY2xpZW50IiwibWFuYWdlLXVzZXJzIiwicXVlcnktcmVhbG1zIiwidmlldy1hdXRob3JpemF0aW9uIiwicXVlcnktY2xpZW50cyIsInF1ZXJ5LXVzZXJzIiwibWFuYWdlLWV2ZW50cyIsIm1hbmFnZS1yZWFsbSIsInZpZXctZXZlbnRzIiwidmlldy11c2VycyIsInZpZXctY2xpZW50cyIsIm1hbmFnZS1hdXRob3JpemF0aW9uIiwibWFuYWdlLWNsaWVudHMiLCJxdWVyeS1ncm91cHMiXX0sImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoiZW1haWwgcHJvZmlsZSIsInNpZCI6IjE0NTk3YTJlLThjNTYtNGM5MC1iNzYwLWVhY2M3MzVlNWY3NSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwicHJlZmVycmVkX3VzZXJuYW1lIjoiYWxpY2UifQ.hVj6SG-aTcDYhifdljpiBcz4ShCHej3h_4-82rgX0s_oJ-En68Cqt-_DgJLtMdr6dW_gQFFCPYBJfEGvZ8L6b_TwzbdLxyrQrKTOpeG0KJ8VAFlbWum9B1vvES_sav1Gj1sQHlV621EaLISYz7pnknuQEvrB7liJFRRjN9SH30AsAJy6nmKTDHGZ6Eegkveqd_7POaKfsHS3Z0-SGyL5GClXv9yZ1l5Y4VH-rrMUztLPCFH5bJ319-m-7sgizvV-C2EcM37XVAtPRVQbJNRW0wVmLEJKMuLYVnjS1Wn5eU_qnBvVMEaENNG3TzNd6b4YmxMFHFf9tnkb3wkDzdrRTA"; + performBearerAuthenticationCorsRequest(getOidcConfigurationInputStreamWithEnableCors(), SECURED_ENDPOINT, KeycloakConfiguration.ALICE, KeycloakConfiguration.ALICE_PASSWORD, + SECURED_PAGE_TEXT, wrongToken, ALLOWED_ORIGIN); + } + + @Test + public void testCorsRequestWithEnableCorsWithInvalidToken() throws Exception { + performBearerAuthenticationCorsRequest(getOidcConfigurationInputStreamWithEnableCors(), SECURED_ENDPOINT, KeycloakConfiguration.ALICE, KeycloakConfiguration.ALICE_PASSWORD, + SECURED_PAGE_TEXT, "INVALID_TOKEN", ALLOWED_ORIGIN); + } + + @Test + public void testCorsRequestWithEnableCorsInvalidOrigin() throws Exception { + performBearerAuthenticationCorsRequest(getOidcConfigurationInputStreamWithEnableCors(), SECURED_ENDPOINT, KeycloakConfiguration.ALICE, KeycloakConfiguration.ALICE_PASSWORD, + SECURED_PAGE_TEXT, null, "http://invalidorigin"); + } + + @Test + public void testCorsRequestWithoutEnableCors() throws Exception { + performBearerAuthenticationCorsRequest(getOidcConfigurationInputStream(), SECURED_ENDPOINT, KeycloakConfiguration.ALICE, KeycloakConfiguration.ALICE_PASSWORD, + SECURED_PAGE_TEXT, null, ALLOWED_ORIGIN); + } + + private void performBearerAuthentication(InputStream oidcConfig, String endpoint, String username, String password, String clientPageText) throws Exception { + performBearerAuthentication(oidcConfig, endpoint, username, password, clientPageText, null, BearerAuthType.BEARER); + } + + private void performBearerAuthentication(InputStream oidcConfig, String endpoint, String username, String password, + String clientPageText, String bearerToken, BearerAuthType bearerAuthType) throws Exception { + try { + Map props = new HashMap<>(); + OidcClientConfiguration oidcClientConfiguration = OidcClientConfigurationBuilder.build(oidcConfig); + assertEquals(OidcClientConfiguration.RelativeUrlsUsed.NEVER, oidcClientConfiguration.getRelativeUrls()); + + OidcClientContext oidcClientContext = new OidcClientContext(oidcClientConfiguration); + oidcFactory = new OidcMechanismFactory(oidcClientContext); + HttpServerAuthenticationMechanism mechanism = oidcFactory.createAuthenticationMechanism(OIDC_NAME, props, getCallbackHandler()); + + if (bearerToken != null) { // going to pass an invalid token + client.setDispatcher(createAppBearerResponse(mechanism, clientPageText, "invalid_token", null)); + } else { + client.setDispatcher(createAppBearerResponse(mechanism, clientPageText, null, null)); + } + + URI requestUri; + WebClient webClient = getWebClient(); + switch (bearerAuthType) { + case QUERY_PARAM: + if (bearerToken == null) { + // obtain a bearer token and then try accessing the endpoint with a query param specified + requestUri = new URI(getClientUrl() + endpoint + "?access_token=" + + KeycloakConfiguration.getAccessToken(KEYCLOAK_CONTAINER.getAuthServerUrl(), TEST_REALM, username, + password, CLIENT_ID, CLIENT_SECRET)); + } else { + // try accessing the endpoint with the given bearer token specified using a query param + requestUri = new URI(getClientUrl() + endpoint + "?access_token=" + bearerToken); + } + break; + case BASIC: + webClient.addRequestHeader("Authorization", + "Basic " + CodePointIterator.ofString(username + ":" + password).asUtf8().base64Encode().drainToString()); + requestUri = new URI(getClientUrl() + endpoint); + break; + default: + if (bearerToken == null) { + // obtain a bearer token and then try accessing the endpoint with the Authorization header specified + webClient.addRequestHeader("Authorization", "Bearer " + KeycloakConfiguration.getAccessToken(KEYCLOAK_CONTAINER.getAuthServerUrl(), TEST_REALM, username, + password, CLIENT_ID, CLIENT_SECRET)); + } else { + // try accessing the endpoint with the given bearer token specified using the Authorization header + webClient.addRequestHeader("Authorization", "Bearer " + bearerToken); + } + requestUri = new URI(getClientUrl() + endpoint); + } + + if (bearerToken == null) { + TextPage page = webClient.getPage(requestUri.toURL()); + assertEquals(HttpStatus.SC_OK, page.getWebResponse().getStatusCode()); + assertTrue(page.getContent().contains(clientPageText)); + } else { + try { + webClient.getPage(requestUri.toURL()); + fail("Expected exception not thrown"); + } catch (FailingHttpStatusCodeException e) { + assertEquals(HttpStatus.SC_UNAUTHORIZED, e.getStatusCode()); + } + } + } finally { + client.setDispatcher(new QueueDispatcher()); + } + } + + private void performBearerAuthenticationCorsRequest(InputStream oidcConfig, String endpoint, String username, String password, + String clientPageText, String bearerToken, String originHeader) throws Exception { + try { + Map props = new HashMap<>(); + OidcClientConfiguration oidcClientConfiguration = OidcClientConfigurationBuilder.build(oidcConfig); + assertEquals(OidcClientConfiguration.RelativeUrlsUsed.NEVER, oidcClientConfiguration.getRelativeUrls()); + + OidcClientContext oidcClientContext = new OidcClientContext(oidcClientConfiguration); + oidcFactory = new OidcMechanismFactory(oidcClientContext); + HttpServerAuthenticationMechanism mechanism = oidcFactory.createAuthenticationMechanism(OIDC_NAME, props, getCallbackHandler()); + + URI requestUri = new URI(getClientUrl() + endpoint); + + // simulate preflight request + Map> requestHeaders = new HashMap<>(); + requestHeaders.put(CorsHeaders.ORIGIN, Collections.singletonList(originHeader)); + requestHeaders.put(CorsHeaders.ACCESS_CONTROL_REQUEST_HEADERS, Collections.singletonList("authorization")); + requestHeaders.put(CorsHeaders.ACCESS_CONTROL_REQUEST_METHOD, Collections.singletonList(HttpMethod.GET.name())); + TestingHttpServerRequest request = new TestingHttpServerRequest(requestHeaders, requestUri, HttpMethod.OPTIONS.name()); + mechanism.evaluateRequest(request); + TestingHttpServerResponse response = request.getResponse(); + + if (oidcClientConfiguration.isCors()) { + assertTrue(Boolean.valueOf(response.getFirstResponseHeaderValue(CorsHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS))); + assertEquals("authorization", response.getFirstResponseHeaderValue(CorsHeaders.ACCESS_CONTROL_ALLOW_HEADERS)); + assertEquals(HttpMethod.GET.name(), response.getFirstResponseHeaderValue(CorsHeaders.ACCESS_CONTROL_ALLOW_METHODS)); + assertEquals(originHeader, response.getFirstResponseHeaderValue(CorsHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)); + + if (bearerToken != null) { // going to pass an invalid token + client.setDispatcher(createAppBearerResponse(mechanism, clientPageText, "invalid_token", originHeader)); + } else { + client.setDispatcher(createAppBearerResponse(mechanism, clientPageText, null, originHeader)); + } + + WebClient webClient = getWebClient(); + webClient.addRequestHeader(CorsHeaders.ORIGIN, originHeader); + if (bearerToken == null) { + webClient.addRequestHeader("Authorization", "Bearer " + KeycloakConfiguration.getAccessToken(KEYCLOAK_CONTAINER.getAuthServerUrl(), TEST_REALM, username, + password, CORS_CLIENT_ID, CLIENT_SECRET)); + } else { + webClient.addRequestHeader("Authorization", "Bearer " + bearerToken); + } + if (bearerToken == null) { + try { + TextPage page = webClient.getPage(requestUri.toURL()); + assertEquals(HttpStatus.SC_OK, page.getWebResponse().getStatusCode()); + assertTrue(page.getContent().contains(clientPageText)); + } catch (FailingHttpStatusCodeException e) { + assertFalse(originHeader.equals(ALLOWED_ORIGIN)); + assertEquals(HttpStatus.SC_FORBIDDEN, e.getStatusCode()); + } + } else { + try { + webClient.getPage(requestUri.toURL()); + fail("Expected exception not thrown"); + } catch (FailingHttpStatusCodeException e) { + assertEquals(HttpStatus.SC_UNAUTHORIZED, e.getStatusCode()); + } + } + } else { + assertEquals(HttpStatus.SC_UNAUTHORIZED, response.getStatusCode()); + if (oidcClientConfiguration.getRealm() != null) { + // if we have a keycloak realm configured, its name should appear in the challenge + assertEquals("Bearer realm=\"" + TEST_REALM + "\"", response.getAuthenticateHeader()); + } else { + assertEquals("Bearer", response.getAuthenticateHeader()); + } + } + } finally { + client.setDispatcher(new QueueDispatcher()); + } + } + + private void accessAppWithoutToken(String endpoint, InputStream oidcConfigInputStream) throws Exception { + accessAppWithoutToken(endpoint, oidcConfigInputStream, null, null, null); + } + + private void accessAppWithoutToken(String endpoint, InputStream oidcConfigInputStream, BearerAuthType bearerAuthType, String username, String password) throws Exception { + Map props = new HashMap<>(); + OidcClientConfiguration oidcClientConfiguration = OidcClientConfigurationBuilder.build(oidcConfigInputStream); + assertEquals(OidcClientConfiguration.RelativeUrlsUsed.NEVER, oidcClientConfiguration.getRelativeUrls()); + + OidcClientContext oidcClientContext = new OidcClientContext(oidcClientConfiguration); + oidcFactory = new OidcMechanismFactory(oidcClientContext); + HttpServerAuthenticationMechanism mechanism = oidcFactory.createAuthenticationMechanism(OIDC_NAME, props, getCallbackHandler()); + + URI requestUri = new URI(getClientUrl() + endpoint); + TestingHttpServerRequest request; + if (bearerAuthType == BearerAuthType.BASIC) { + request = new TestingHttpServerRequest(new String[] {"Basic " + + CodePointIterator.ofString(username + ":" + password).asUtf8().base64Encode().drainToString()}, requestUri); + } else { + request = new TestingHttpServerRequest(null, requestUri); // no bearer token specified + } + mechanism.evaluateRequest(request); + TestingHttpServerResponse response = request.getResponse(); + + if (oidcClientConfiguration.isBearerOnly() || oidcClientConfiguration.isEnableBasicAuth()) { + assertEquals(HttpStatus.SC_UNAUTHORIZED, response.getStatusCode()); + String authenticateHeader = response.getAuthenticateHeader(); + if ((bearerAuthType == BearerAuthType.BASIC) && password.equals(WRONG_PASSWORD)) { + assertTrue(authenticateHeader.startsWith("Bearer error=\"" + "no_token" + "\"")); + assertTrue(authenticateHeader.contains("error_description")); + assertTrue(authenticateHeader.contains(String.valueOf(HttpStatus.SC_UNAUTHORIZED))); + } else if (oidcClientConfiguration.getRealm() != null) { + // if we have a keycloak realm configured, its name should appear in the challenge + assertEquals("Bearer realm=\"" + TEST_REALM + "\"", authenticateHeader); + } else { + assertEquals("Bearer", authenticateHeader); + } + } else { + // no token provided and bearer-only is not configured, should end up in the OIDC flow + assertEquals(HttpStatus.SC_MOVED_TEMPORARILY, response.getStatusCode()); + assertEquals(Status.NO_AUTH, request.getResult()); + try { + // browser login should succeed + client.setDispatcher(createAppResponse(mechanism, HttpStatus.SC_MOVED_TEMPORARILY, getClientUrl(), CLIENT_PAGE_TEXT)); + TextPage page = loginToKeycloak(KeycloakConfiguration.ALICE, KeycloakConfiguration.ALICE_PASSWORD, requestUri, response.getLocation(), + response.getCookies()).click(); + assertTrue(page.getContent().contains(CLIENT_PAGE_TEXT)); + } finally { + client.setDispatcher(new QueueDispatcher()); + } + } + } + + private InputStream getOidcConfigurationInputStream() { + return getOidcConfigurationInputStream(KEYCLOAK_CONTAINER.getAuthServerUrl()); + } + + private InputStream getOidcConfigurationInputStream(String authServerUrl) { + String oidcConfig = "{\n" + + " \"realm\" : \"" + TEST_REALM + "\",\n" + + " \"resource\" : \"" + BEARER_ONLY_CLIENT_ID + "\",\n" + + " \"auth-server-url\" : \"" + authServerUrl + "\",\n" + + " \"ssl-required\" : \"EXTERNAL\",\n" + + " \"bearer-only\" : \"true\"\n" + + "}"; + return new ByteArrayInputStream(oidcConfig.getBytes(StandardCharsets.UTF_8)); + } + + private InputStream getOidcConfigurationInputStreamWithProviderUrl() { + String oidcConfig = "{\n" + + " \"client-id\" : \"" + BEARER_ONLY_CLIENT_ID + "\",\n" + + " \"provider-url\" : \"" + KEYCLOAK_CONTAINER.getAuthServerUrl() + "/realms/" + TEST_REALM + "\",\n" + + " \"ssl-required\" : \"EXTERNAL\",\n" + + " \"bearer-only\" : \"true\"\n" + + "}"; + return new ByteArrayInputStream(oidcConfig.getBytes(StandardCharsets.UTF_8)); + } + + private InputStream getOidcConfigurationInputStreamWithoutBearerOnly() { + String oidcConfig = "{\n" + + " \"client-id\" : \"" + BEARER_ONLY_CLIENT_ID + "\",\n" + + " \"provider-url\" : \"" + KEYCLOAK_CONTAINER.getAuthServerUrl() + "/realms/" + TEST_REALM + "\",\n" + + " \"ssl-required\" : \"EXTERNAL\"\n" + + "}"; + return new ByteArrayInputStream(oidcConfig.getBytes(StandardCharsets.UTF_8)); + } + + private InputStream getRegularOidcConfigurationInputStream() { + String oidcConfig = "{\n" + + " \"client-id\" : \"" + CLIENT_ID + "\",\n" + + " \"provider-url\" : \"" + KEYCLOAK_CONTAINER.getAuthServerUrl() + "/realms/" + TEST_REALM + "\",\n" + + " \"ssl-required\" : \"EXTERNAL\",\n" + + " \"credentials\" : {\n" + + " \"secret\" : \"" + CLIENT_SECRET + "\"\n" + + " }\n" + + "}"; + return new ByteArrayInputStream(oidcConfig.getBytes(StandardCharsets.UTF_8)); + } + + private InputStream getOidcConfigurationInputStreamWithEnableBasicAuth() { + String oidcConfig = "{\n" + + " \"client-id\" : \"" + CLIENT_ID + "\",\n" + + " \"provider-url\" : \"" + KEYCLOAK_CONTAINER.getAuthServerUrl() + "/realms/" + TEST_REALM + "\",\n" + + " \"ssl-required\" : \"EXTERNAL\",\n" + + " \"enable-basic-auth\" : \"true\",\n" + + " \"credentials\" : {\n" + + " \"secret\" : \"" + CLIENT_SECRET + "\"\n" + + " }\n" + + "}"; + return new ByteArrayInputStream(oidcConfig.getBytes(StandardCharsets.UTF_8)); + } + + private InputStream getOidcConfigurationInputStreamWithEnableCors() { + String oidcConfig = "{\n" + + " \"client-id\" : \"" + BEARER_ONLY_CLIENT_ID + "\",\n" + + " \"provider-url\" : \"" + KEYCLOAK_CONTAINER.getAuthServerUrl() + "/realms/" + TEST_REALM + "\",\n" + + " \"ssl-required\" : \"EXTERNAL\",\n" + + " \"enable-cors\" : \"true\",\n" + + " \"bearer-only\" : \"true\"\n" + + "}"; + return new ByteArrayInputStream(oidcConfig.getBytes(StandardCharsets.UTF_8)); + } +} \ No newline at end of file diff --git a/http/oidc/src/test/java/org/wildfly/security/http/oidc/KeycloakConfiguration.java b/http/oidc/src/test/java/org/wildfly/security/http/oidc/KeycloakConfiguration.java index 0e80a70cf59..5dfa052ed28 100644 --- a/http/oidc/src/test/java/org/wildfly/security/http/oidc/KeycloakConfiguration.java +++ b/http/oidc/src/test/java/org/wildfly/security/http/oidc/KeycloakConfiguration.java @@ -20,6 +20,7 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; import org.keycloak.representations.AccessTokenResponse; @@ -45,6 +46,7 @@ public class KeycloakConfiguration { public static final String ALICE_PASSWORD = "alice123+"; private static final String BOB = "bob"; private static final String BOB_PASSWORD = "bob123+"; + public static final String ALLOWED_ORIGIN = "http://somehost"; /** * Configure RealmRepresentation as follows: @@ -62,20 +64,52 @@ public static RealmRepresentation getRealmRepresentation(final String realmName, return createRealm(realmName, clientId, clientSecret, clientHostName, clientPort, clientApp); } + public static RealmRepresentation getRealmRepresentation(final String realmName, String clientId, String clientSecret, + String clientHostName, int clientPort, String clientApp, + boolean directAccessGrantEnabled, String bearerOnlyClientId, + String corsClientId) { + return createRealm(realmName, clientId, clientSecret, clientHostName, clientPort, clientApp, directAccessGrantEnabled, bearerOnlyClientId, corsClientId); + } + public static String getAdminAccessToken(String authServerUrl) { + return getAdminAccessToken(authServerUrl, "master", KeycloakContainer.KEYCLOAK_ADMIN_USER, + KeycloakContainer.KEYCLOAK_ADMIN_PASSWORD, "admin-cli"); + } + + public static String getAdminAccessToken(String authServerUrl, String realmName, String username, String password, String clientId) { return RestAssured .given() .param("grant_type", "password") - .param("username", KeycloakContainer.KEYCLOAK_ADMIN_USER) - .param("password", KeycloakContainer.KEYCLOAK_ADMIN_PASSWORD) - .param("client_id", "admin-cli") + .param("username", username) + .param("password", password) + .param("client_id", clientId) .when() - .post(authServerUrl + "/realms/master/protocol/openid-connect/token") + .post(authServerUrl + "/realms/" + realmName + "/protocol/openid-connect/token") + .as(AccessTokenResponse.class).getToken(); + } + + public static String getAccessToken(String authServerUrl, String realmName, String username, String password, String clientId, String clientSecret) { + return RestAssured + .given() + .param("grant_type", "password") + .param("username", username) + .param("password", password) + .param("client_id", clientId) + .param("client_secret", clientSecret) + .when() + .post(authServerUrl + "/realms/" + realmName + "/protocol/openid-connect/token") .as(AccessTokenResponse.class).getToken(); } private static RealmRepresentation createRealm(String name, String clientId, String clientSecret, String clientHostName, int clientPort, String clientApp) { + return createRealm(name, clientId, clientSecret, clientHostName, clientPort, clientApp, false, null, null); + } + + private static RealmRepresentation createRealm(String name, String clientId, String clientSecret, + String clientHostName, int clientPort, String clientApp, + boolean directAccessGrantEnabled, String bearerOnlyClientId, + String corsClientId) { RealmRepresentation realm = new RealmRepresentation(); realm.setRealm(name); @@ -94,14 +128,27 @@ private static RealmRepresentation createRealm(String name, String clientId, Str realm.getRoles().getRealm().add(new RoleRepresentation("user", null, false)); realm.getRoles().getRealm().add(new RoleRepresentation("admin", null, false)); - realm.getClients().add(createWebAppClient(clientId, clientSecret, clientHostName, clientPort, clientApp)); + realm.getClients().add(createWebAppClient(clientId, clientSecret, clientHostName, clientPort, clientApp, directAccessGrantEnabled)); + + if (bearerOnlyClientId != null) { + realm.getClients().add(createBearerOnlyClient(bearerOnlyClientId)); + } + + if (corsClientId != null) { + realm.getClients().add(createWebAppClient(corsClientId, clientSecret, clientHostName, clientPort, clientApp, directAccessGrantEnabled, ALLOWED_ORIGIN)); + } realm.getUsers().add(createUser(ALICE, ALICE_PASSWORD, Arrays.asList(USER_ROLE, ADMIN_ROLE))); realm.getUsers().add(createUser(BOB, BOB_PASSWORD, Arrays.asList(USER_ROLE))); return realm; } - private static ClientRepresentation createWebAppClient(String clientId, String clientSecret, String clientHostName, int clientPort, String clientApp) { + private static ClientRepresentation createWebAppClient(String clientId, String clientSecret, String clientHostName, int clientPort, String clientApp, boolean directAccessGrantEnabled) { + return createWebAppClient(clientId, clientSecret, clientHostName, clientPort, clientApp, directAccessGrantEnabled, null); + } + + private static ClientRepresentation createWebAppClient(String clientId, String clientSecret, String clientHostName, int clientPort, + String clientApp, boolean directAccessGrantEnabled, String allowedOrigin) { ClientRepresentation client = new ClientRepresentation(); client.setClientId(clientId); client.setPublicClient(false); @@ -109,6 +156,18 @@ private static ClientRepresentation createWebAppClient(String clientId, String c //client.setRedirectUris(Arrays.asList("*")); client.setRedirectUris(Arrays.asList("http://" + clientHostName + ":" + clientPort + "/" + clientApp)); client.setEnabled(true); + client.setDirectAccessGrantsEnabled(directAccessGrantEnabled); + if (allowedOrigin != null) { + client.setWebOrigins(Collections.singletonList(allowedOrigin)); + } + return client; + } + + private static ClientRepresentation createBearerOnlyClient(String clientId) { + ClientRepresentation client = new ClientRepresentation(); + client.setClientId(clientId); + client.setBearerOnly(true); + client.setEnabled(true); return client; } diff --git a/http/oidc/src/test/java/org/wildfly/security/http/oidc/OidcBaseTest.java b/http/oidc/src/test/java/org/wildfly/security/http/oidc/OidcBaseTest.java new file mode 100644 index 00000000000..b1fb8ea2d2e --- /dev/null +++ b/http/oidc/src/test/java/org/wildfly/security/http/oidc/OidcBaseTest.java @@ -0,0 +1,218 @@ +/* + * JBoss, Home of Professional Open Source. + * Copyright 2022 Red Hat, Inc., and individual 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.wildfly.security.http.oidc; + +import static org.junit.Assert.assertEquals; + +import java.io.IOException; +import java.net.URI; +import java.util.List; + +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.CallbackHandler; +import javax.security.auth.callback.UnsupportedCallbackException; +import javax.security.sasl.AuthorizeCallback; + +import org.junit.AfterClass; +import org.keycloak.representations.idm.RealmRepresentation; +import org.testcontainers.DockerClientFactory; +import org.wildfly.security.auth.callback.AuthenticationCompleteCallback; +import org.wildfly.security.auth.callback.EvidenceVerifyCallback; +import org.wildfly.security.auth.callback.IdentityCredentialCallback; +import org.wildfly.security.auth.callback.SecurityIdentityCallback; +import org.wildfly.security.auth.server.SecurityDomain; +import org.wildfly.security.evidence.Evidence; +import org.wildfly.security.http.HttpServerAuthenticationMechanism; +import org.wildfly.security.http.HttpServerAuthenticationMechanismFactory; +import org.wildfly.security.http.HttpServerCookie; +import org.wildfly.security.http.impl.AbstractBaseHttpTest; +import org.wildfly.security.jose.util.JsonSerialization; + +import com.gargoylesoftware.htmlunit.SilentCssErrorHandler; +import com.gargoylesoftware.htmlunit.WebClient; +import com.gargoylesoftware.htmlunit.html.HtmlForm; +import com.gargoylesoftware.htmlunit.html.HtmlInput; +import com.gargoylesoftware.htmlunit.html.HtmlPage; +import com.gargoylesoftware.htmlunit.javascript.SilentJavaScriptErrorListener; + +import io.restassured.RestAssured; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; + +/** + * Tests for the OpenID Connect authentication mechanism. + * + * @author Farah Juma + */ +public class OidcBaseTest extends AbstractBaseHttpTest { + + public static final String CLIENT_ID = "test-webapp"; + public static final String CLIENT_SECRET = "secret"; + public static KeycloakContainer KEYCLOAK_CONTAINER; + public static final String TEST_REALM = "WildFly"; + public static final String KEYCLOAK_USERNAME = "username"; + public static final String KEYCLOAK_PASSWORD = "password"; + public static final String KEYCLOAK_LOGIN = "login"; + public static final int CLIENT_PORT = 5002; + public static final String CLIENT_APP = "clientApp"; + public static final String CLIENT_PAGE_TEXT = "Welcome page!"; + public static final String CLIENT_HOST_NAME = "localhost"; + public static MockWebServer client; // to simulate the application being secured + + protected HttpServerAuthenticationMechanismFactory oidcFactory; + + @AfterClass + public static void generalCleanup() throws Exception { + if (KEYCLOAK_CONTAINER != null) { + RestAssured + .given() + .auth().oauth2(KeycloakConfiguration.getAdminAccessToken(KEYCLOAK_CONTAINER.getAuthServerUrl())) + .when() + .delete(KEYCLOAK_CONTAINER.getAuthServerUrl() + "/admin/realms/" + TEST_REALM).then().statusCode(204); + KEYCLOAK_CONTAINER.stop(); + } + if (client != null) { + client.shutdown(); + } + } + + protected static void sendRealmCreationRequest(RealmRepresentation realm) { + try { + RestAssured + .given() + .auth().oauth2(KeycloakConfiguration.getAdminAccessToken(KEYCLOAK_CONTAINER.getAuthServerUrl())) + .contentType("application/json") + .body(JsonSerialization.writeValueAsBytes(realm)) + .when() + .post(KEYCLOAK_CONTAINER.getAuthServerUrl() + "/admin/realms").then() + .statusCode(201); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + protected static boolean isDockerAvailable() { + try { + DockerClientFactory.instance().client(); + return true; + } catch (Throwable ex) { + return false; + } + } + + protected CallbackHandler getCallbackHandler() { + return callbacks -> { + for(Callback callback : callbacks) { + if (callback instanceof EvidenceVerifyCallback) { + Evidence evidence = ((EvidenceVerifyCallback) callback).getEvidence(); + ((EvidenceVerifyCallback) callback).setVerified(evidence.getDecodedPrincipal() != null); + } else if (callback instanceof AuthenticationCompleteCallback) { + // NO-OP + } else if (callback instanceof IdentityCredentialCallback) { + // NO-OP + } else if (callback instanceof AuthorizeCallback) { + ((AuthorizeCallback) callback).setAuthorized(true); + } else if (callback instanceof SecurityIdentityCallback) { + ((SecurityIdentityCallback) callback).setSecurityIdentity(SecurityDomain.builder().build().getCurrentSecurityIdentity()); + } else { + throw new UnsupportedCallbackException(callback); + } + } + }; + } + + protected static Dispatcher createAppResponse(HttpServerAuthenticationMechanism mechanism, int expectedStatusCode, String expectedLocation, String clientPageText) { + return new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest recordedRequest) throws InterruptedException { + String path = recordedRequest.getPath(); + if (path.contains("/" + CLIENT_APP) && path.contains("&code=")) { + try { + TestingHttpServerRequest request = new TestingHttpServerRequest(new String[0], + new URI(recordedRequest.getRequestUrl().toString()), recordedRequest.getHeader("Cookie")); + mechanism.evaluateRequest(request); + TestingHttpServerResponse response = request.getResponse(); + assertEquals(expectedStatusCode, response.getStatusCode()); + assertEquals(expectedLocation, response.getLocation()); + return new MockResponse().setBody(clientPageText); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + return new MockResponse() + .setBody(""); + } + }; + } + + protected WebClient getWebClient() { + WebClient webClient = new WebClient(); + webClient.setCssErrorHandler(new SilentCssErrorHandler()); + webClient.setJavaScriptErrorListener(new SilentJavaScriptErrorListener()); + return webClient; + } + + protected static String getClientUrl() { + return "http://" + CLIENT_HOST_NAME + ":" + CLIENT_PORT + "/" + CLIENT_APP; + } + + protected HtmlInput loginToKeycloak(String username, String password, URI requestUri, String location, List cookies) throws IOException { + WebClient webClient = getWebClient(); + if (cookies != null) { + for (HttpServerCookie cookie : cookies) { + webClient.addCookie(getCookieString(cookie), requestUri.toURL(), null); + } + } + HtmlPage keycloakLoginPage = webClient.getPage(location); + HtmlForm loginForm = keycloakLoginPage.getForms().get(0); + loginForm.getInputByName(KEYCLOAK_USERNAME).setValueAttribute(username); + loginForm.getInputByName(KEYCLOAK_PASSWORD).setValueAttribute(password); + return loginForm.getInputByName(KEYCLOAK_LOGIN); + } + + protected String getCookieString(HttpServerCookie cookie) { + final StringBuilder header = new StringBuilder(cookie.getName()); + header.append("="); + if(cookie.getValue() != null) { + header.append(cookie.getValue()); + } + if (cookie.getPath() != null) { + header.append("; Path="); + header.append(cookie.getPath()); + } + if (cookie.getDomain() != null) { + header.append("; Domain="); + header.append(cookie.getDomain()); + } + if (cookie.isSecure()) { + header.append("; Secure"); + } + if (cookie.isHttpOnly()) { + header.append("; HttpOnly"); + } + if (cookie.getMaxAge() >= 0) { + header.append("; Max-Age="); + header.append(cookie.getMaxAge()); + } + return header.toString(); + } + +} \ No newline at end of file diff --git a/http/oidc/src/test/java/org/wildfly/security/http/oidc/OidcTest.java b/http/oidc/src/test/java/org/wildfly/security/http/oidc/OidcTest.java index 710e82c99d8..3ae28fc1e05 100644 --- a/http/oidc/src/test/java/org/wildfly/security/http/oidc/OidcTest.java +++ b/http/oidc/src/test/java/org/wildfly/security/http/oidc/OidcTest.java @@ -24,73 +24,31 @@ import static org.wildfly.security.http.oidc.Oidc.OIDC_NAME; import java.io.ByteArrayInputStream; -import java.io.IOException; import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.HashMap; -import java.util.List; import java.util.Map; -import javax.security.auth.callback.Callback; -import javax.security.auth.callback.CallbackHandler; -import javax.security.auth.callback.UnsupportedCallbackException; -import javax.security.sasl.AuthorizeCallback; - import org.apache.http.HttpStatus; import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Test; -import org.keycloak.representations.idm.RealmRepresentation; -import org.testcontainers.DockerClientFactory; -import org.wildfly.security.auth.callback.AuthenticationCompleteCallback; -import org.wildfly.security.auth.callback.EvidenceVerifyCallback; -import org.wildfly.security.auth.callback.IdentityCredentialCallback; -import org.wildfly.security.auth.callback.SecurityIdentityCallback; -import org.wildfly.security.auth.server.SecurityDomain; -import org.wildfly.security.evidence.Evidence; import org.wildfly.security.http.HttpServerAuthenticationMechanism; -import org.wildfly.security.http.HttpServerAuthenticationMechanismFactory; -import org.wildfly.security.http.HttpServerCookie; -import org.wildfly.security.http.impl.AbstractBaseHttpTest; -import org.wildfly.security.jose.util.JsonSerialization; -import com.gargoylesoftware.htmlunit.SilentCssErrorHandler; import com.gargoylesoftware.htmlunit.TextPage; -import com.gargoylesoftware.htmlunit.WebClient; -import com.gargoylesoftware.htmlunit.html.HtmlForm; -import com.gargoylesoftware.htmlunit.html.HtmlInput; import com.gargoylesoftware.htmlunit.html.HtmlPage; -import com.gargoylesoftware.htmlunit.javascript.SilentJavaScriptErrorListener; import io.restassured.RestAssured; -import okhttp3.mockwebserver.Dispatcher; -import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.QueueDispatcher; -import okhttp3.mockwebserver.RecordedRequest; /** * Tests for the OpenID Connect authentication mechanism. * * @author Farah Juma */ -public class OidcTest extends AbstractBaseHttpTest { - - public static final String CLIENT_ID = "test-webapp"; - public static final String CLIENT_SECRET = "secret"; - private static KeycloakContainer KEYCLOAK_CONTAINER; - private static final String TEST_REALM = "WildFly"; - private static final String KEYCLOAK_USERNAME = "username"; - private static final String KEYCLOAK_PASSWORD = "password"; - private static final String KEYCLOAK_LOGIN = "login"; - private static final int CLIENT_PORT = 5002; - private static final String CLIENT_APP = "clientApp"; - private static final String CLIENT_PAGE_TEXT = "Welcome page!"; - private static final String CLIENT_HOST_NAME = "localhost"; - private static MockWebServer client; // to simulate the application being secured - - protected HttpServerAuthenticationMechanismFactory oidcFactory; +public class OidcTest extends OidcBaseTest { @BeforeClass public static void startTestContainers() throws Exception { @@ -102,30 +60,6 @@ public static void startTestContainers() throws Exception { client.start(CLIENT_PORT); } - private static Dispatcher createAppResponse(HttpServerAuthenticationMechanism mechanism, int expectedStatusCode, String expectedLocation, String clientPageText) { - return new Dispatcher() { - @Override - public MockResponse dispatch(RecordedRequest recordedRequest) throws InterruptedException { - String path = recordedRequest.getPath(); - if (path.contains("/" + CLIENT_APP) && path.contains("&code=")) { - try { - TestingHttpServerRequest request = new TestingHttpServerRequest(null, - new URI(recordedRequest.getRequestUrl().toString()), recordedRequest.getHeader("Cookie")); - mechanism.evaluateRequest(request); - TestingHttpServerResponse response = request.getResponse(); - assertEquals(expectedStatusCode, response.getStatusCode()); - assertEquals(expectedLocation, response.getLocation()); - return new MockResponse().setBody(clientPageText); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - return new MockResponse() - .setBody(""); - } - }; - } - @AfterClass public static void generalCleanup() throws Exception { if (KEYCLOAK_CONTAINER != null) { @@ -141,21 +75,6 @@ public static void generalCleanup() throws Exception { } } - private static void sendRealmCreationRequest(RealmRepresentation realm) { - try { - RestAssured - .given() - .auth().oauth2(KeycloakConfiguration.getAdminAccessToken(KEYCLOAK_CONTAINER.getAuthServerUrl())) - .contentType("application/json") - .body(JsonSerialization.writeValueAsBytes(realm)) - .when() - .post(KEYCLOAK_CONTAINER.getAuthServerUrl() + "/admin/realms").then() - .statusCode(201); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - @Test public void testWrongPassword() throws Exception { Map props = new HashMap<>(); @@ -240,27 +159,6 @@ private void performAuthentication(InputStream oidcConfig, String username, Stri } } - private WebClient getWebClient() { - WebClient webClient = new WebClient(); - webClient.setCssErrorHandler(new SilentCssErrorHandler()); - webClient.setJavaScriptErrorListener(new SilentJavaScriptErrorListener()); - return webClient; - } - - private HtmlInput loginToKeycloak(String username, String password, URI requestUri, String location, List cookies) throws IOException { - WebClient webClient = getWebClient(); - if (cookies != null) { - for (HttpServerCookie cookie : cookies) { - webClient.addCookie(getCookieString(cookie), requestUri.toURL(), null); - } - } - HtmlPage keycloakLoginPage = webClient.getPage(location); - HtmlForm loginForm = keycloakLoginPage.getForms().get(0); - loginForm.getInputByName(KEYCLOAK_USERNAME).setValueAttribute(username); - loginForm.getInputByName(KEYCLOAK_PASSWORD).setValueAttribute(password); - return loginForm.getInputByName(KEYCLOAK_LOGIN); - } - private InputStream getOidcConfigurationInputStream() { return getOidcConfigurationInputStream(CLIENT_SECRET); } @@ -321,65 +219,4 @@ private InputStream getOidcConfigurationInputStreamWithTokenSignatureAlgorithm() "}"; return new ByteArrayInputStream(oidcConfig.getBytes(StandardCharsets.UTF_8)); } - - private CallbackHandler getCallbackHandler() { - return callbacks -> { - for(Callback callback : callbacks) { - if (callback instanceof EvidenceVerifyCallback) { - Evidence evidence = ((EvidenceVerifyCallback) callback).getEvidence(); - ((EvidenceVerifyCallback) callback).setVerified(evidence.getDecodedPrincipal() != null); - } else if (callback instanceof AuthenticationCompleteCallback) { - // NO-OP - } else if (callback instanceof IdentityCredentialCallback) { - // NO-OP - } else if (callback instanceof AuthorizeCallback) { - ((AuthorizeCallback) callback).setAuthorized(true); - } else if (callback instanceof SecurityIdentityCallback) { - ((SecurityIdentityCallback) callback).setSecurityIdentity(SecurityDomain.builder().build().getCurrentSecurityIdentity()); - } else { - throw new UnsupportedCallbackException(callback); - } - } - }; - } - - private static boolean isDockerAvailable() { - try { - DockerClientFactory.instance().client(); - return true; - } catch (Throwable ex) { - return false; - } - } - - private String getCookieString(HttpServerCookie cookie) { - final StringBuilder header = new StringBuilder(cookie.getName()); - header.append("="); - if(cookie.getValue() != null) { - header.append(cookie.getValue()); - } - if (cookie.getPath() != null) { - header.append("; Path="); - header.append(cookie.getPath()); - } - if (cookie.getDomain() != null) { - header.append("; Domain="); - header.append(cookie.getDomain()); - } - if (cookie.isSecure()) { - header.append("; Secure"); - } - if (cookie.isHttpOnly()) { - header.append("; HttpOnly"); - } - if (cookie.getMaxAge() >= 0) { - header.append("; Max-Age="); - header.append(cookie.getMaxAge()); - } - return header.toString(); - } - - private static String getClientUrl() { - return "http://" + CLIENT_HOST_NAME + ":" + CLIENT_PORT + "/" + CLIENT_APP; - } } \ No newline at end of file diff --git a/tests/base/src/test/java/org/wildfly/security/http/impl/AbstractBaseHttpTest.java b/tests/base/src/test/java/org/wildfly/security/http/impl/AbstractBaseHttpTest.java index a86f86d1443..73171b4fb48 100644 --- a/tests/base/src/test/java/org/wildfly/security/http/impl/AbstractBaseHttpTest.java +++ b/tests/base/src/test/java/org/wildfly/security/http/impl/AbstractBaseHttpTest.java @@ -36,6 +36,7 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; @@ -124,35 +125,52 @@ protected enum Status { protected static class TestingHttpServerRequest implements HttpServerRequest { - private String[] authorization; private Status result; private HttpServerMechanismsResponder responder; private String remoteUser; private URI requestURI; private List cookies; + private String requestMethod = "GET"; + private Map> requestHeaders = new HashMap<>(); public TestingHttpServerRequest(String[] authorization) { - this.authorization = authorization; + if (authorization != null) { + requestHeaders.put(AUTHORIZATION, Arrays.asList(authorization)); + } this.remoteUser = null; this.cookies = new ArrayList<>(); } public TestingHttpServerRequest(String[] authorization, URI requestURI) { - this.authorization = authorization; + if (authorization != null) { + requestHeaders.put(AUTHORIZATION, Arrays.asList(authorization)); + } this.remoteUser = null; this.requestURI = requestURI; this.cookies = new ArrayList<>(); } public TestingHttpServerRequest(String[] authorization, URI requestURI, List cookies) { - this.authorization = authorization; + if (authorization != null) { + requestHeaders.put(AUTHORIZATION, Arrays.asList(authorization)); + } this.remoteUser = null; this.requestURI = requestURI; this.cookies = cookies; } + public TestingHttpServerRequest(Map> requestHeaders, URI requestURI, String requestMethod) { + this.requestHeaders = requestHeaders; + this.remoteUser = null; + this.requestURI = requestURI; + this.cookies = new ArrayList<>(); + this.requestMethod = requestMethod; + } + public TestingHttpServerRequest(String[] authorization, URI requestURI, String cookie) { - this.authorization = authorization; + if (authorization != null) { + requestHeaders.put(AUTHORIZATION, Arrays.asList(authorization)); + } this.remoteUser = null; this.requestURI = requestURI; this.cookies = new ArrayList<>(); @@ -215,14 +233,12 @@ public TestingHttpServerResponse getResponse() throws HttpAuthenticationExceptio } public List getRequestHeaderValues(String headerName) { - if (AUTHORIZATION.equals(headerName)) { - return authorization == null ? null : Arrays.asList(authorization); - } - return null; + return requestHeaders.get(headerName); } public String getFirstRequestHeaderValue(String headerName) { - throw new IllegalStateException(); + List headerValues = requestHeaders.get(headerName); + return headerValues != null ? headerValues.get(0) : null; } public SSLSession getSSLSession() { @@ -263,7 +279,7 @@ public void badRequest(HttpAuthenticationException failure, HttpServerMechanisms } public String getRequestMethod() { - return "GET"; + return requestMethod; } public URI getRequestURI() { @@ -367,9 +383,8 @@ public String getRemoteUser() { protected static class TestingHttpServerResponse implements HttpServerResponse { private int statusCode; - private String authenticate; - private String location; private List cookies; + private Map> responseHeaders = new HashMap<>(); public void setStatusCode(int statusCode) { this.statusCode = statusCode; @@ -380,19 +395,22 @@ public int getStatusCode() { } public void addResponseHeader(String headerName, String headerValue) { - if (WWW_AUTHENTICATE.equals(headerName)) { - authenticate = headerValue; - } else if (LOCATION.equals(headerName)) { - location = headerValue; + if (headerValue != null) { + responseHeaders.put(headerName, Collections.singletonList(headerValue)); } } public String getAuthenticateHeader() { - return authenticate; + return getFirstResponseHeaderValue(WWW_AUTHENTICATE); } public String getLocation() { - return location; + return getFirstResponseHeaderValue(LOCATION); + } + + public String getFirstResponseHeaderValue(String headerName) { + List headerValue = responseHeaders.get(headerName); + return headerValue == null ? null : headerValue.get(0); } public List getCookies() { @@ -473,11 +491,12 @@ protected CallbackHandler getCallbackHandler(String username, String realm, Stri public class TestingHttpExchangeSpi implements HttpExchangeSpi { - private List requestAuthorizationHeaders = Collections.emptyList(); + private Map> requestHeaders = new HashMap<>(); private List responseAuthenticateHeaders = new LinkedList<>(); private List responseAuthenticationInfoHeaders = new LinkedList<>(); private int statusCode; private Status result; + private String requestMethod = "GET"; public int getStatusCode() { return statusCode; @@ -496,17 +515,27 @@ public List getResponseAuthenticationInfoHeaders() { } public void setRequestAuthorizationHeaders(List requestAuthorizationHeaders) { - this.requestAuthorizationHeaders = requestAuthorizationHeaders; + requestHeaders.put(AUTHORIZATION, requestAuthorizationHeaders); + } + + public void setHeader(String headerName, String headerValue) { + if (headerValue != null) { + setHeader(headerName, Collections.singletonList(headerValue)); + } + } + + public void setHeader(String headerName, List headerValue) { + requestHeaders.put(headerName, headerValue); + } + + public void setRequestMethod(String requestMethod) { + this.requestMethod = requestMethod; } // ------ public List getRequestHeaderValues(String headerName) { - if (AUTHORIZATION.equals(headerName)) { - return requestAuthorizationHeaders; - } else { - throw new IllegalStateException(); - } + return requestHeaders.get(headerName); } public void addResponseHeader(String headerName, String headerValue) { @@ -536,7 +565,7 @@ public void badRequest(HttpAuthenticationException error, String mechanismName) } public String getRequestMethod() { - return "GET"; + return requestMethod; } public URI getRequestURI() {