diff --git a/auth/realm/token/src/main/java/org/wildfly/security/auth/realm/token/_private/ElytronMessages.java b/auth/realm/token/src/main/java/org/wildfly/security/auth/realm/token/_private/ElytronMessages.java index 3d23a845fe4..229776e39a1 100644 --- a/auth/realm/token/src/main/java/org/wildfly/security/auth/realm/token/_private/ElytronMessages.java +++ b/auth/realm/token/src/main/java/org/wildfly/security/auth/realm/token/_private/ElytronMessages.java @@ -98,5 +98,9 @@ public interface ElytronMessages extends BasicLogger { @LogMessage(level = WARN) @Message(id = 1181, value = "Not sending new request to jwks url \"%s\". Last request time was %d.") void avoidingFetchJwks(URL url, long timestamp); + + @LogMessage(level = WARN) + @Message(id = 1182, value = "Allowed jku values haven't been configured for the JWT validator. Token validation will fail if the token contains a 'jku' header parameter.") + void allowedJkuValuesNotConfigured(); } diff --git a/auth/realm/token/src/main/java/org/wildfly/security/auth/realm/token/validator/JwkManager.java b/auth/realm/token/src/main/java/org/wildfly/security/auth/realm/token/validator/JwkManager.java index cf4a8a80ef7..71b889c0659 100644 --- a/auth/realm/token/src/main/java/org/wildfly/security/auth/realm/token/validator/JwkManager.java +++ b/auth/realm/token/src/main/java/org/wildfly/security/auth/realm/token/validator/JwkManager.java @@ -41,6 +41,7 @@ import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; +import java.util.Set; import static org.wildfly.security.auth.realm.token._private.ElytronMessages.log; @@ -54,6 +55,7 @@ class JwkManager { private final Map keys = new LinkedHashMap<>(); private final SSLContext sslContext; private final HostnameVerifier hostnameVerifier; + private final Set allowedJkuValues; private final long updateTimeout; private final int minTimeBetweenRequests; @@ -61,13 +63,14 @@ class JwkManager { private final int connectionTimeout; private final int readTimeout; - JwkManager(SSLContext sslContext, HostnameVerifier hostnameVerifier, long updateTimeout, int connectionTimeout, int readTimeout, int minTimeBetweenRequests) { + JwkManager(SSLContext sslContext, HostnameVerifier hostnameVerifier, long updateTimeout, int connectionTimeout, int readTimeout, int minTimeBetweenRequests, Set allowedJkuValues) { this.sslContext = sslContext; this.hostnameVerifier = hostnameVerifier; this.updateTimeout = updateTimeout; this.connectionTimeout = connectionTimeout; this.readTimeout = readTimeout; this.minTimeBetweenRequests = minTimeBetweenRequests; + this.allowedJkuValues = allowedJkuValues; } /** diff --git a/auth/realm/token/src/main/java/org/wildfly/security/auth/realm/token/validator/JwtValidator.java b/auth/realm/token/src/main/java/org/wildfly/security/auth/realm/token/validator/JwtValidator.java index adcea12fb47..d6cde8c6bb7 100644 --- a/auth/realm/token/src/main/java/org/wildfly/security/auth/realm/token/validator/JwtValidator.java +++ b/auth/realm/token/src/main/java/org/wildfly/security/auth/realm/token/validator/JwtValidator.java @@ -77,6 +77,7 @@ public static Builder builder() { private final Set issuers; private final Set audiences; + private final Set allowedJkuValues; private final JwkManager jwkManager; private final Map namedKeys; @@ -85,12 +86,14 @@ public static Builder builder() { JwtValidator(Builder configuration) { this.issuers = checkNotNullParam("issuers", configuration.issuers); this.audiences = checkNotNullParam("audience", configuration.audience); + this.allowedJkuValues = checkNotNullParam("allowedJkuValues", configuration.allowedJkuValues); this.defaultPublicKey = configuration.publicKey; this.namedKeys = configuration.namedKeys; if (configuration.sslContext != null) { this.jwkManager = new JwkManager(configuration.sslContext, configuration.hostnameVerifier != null ? configuration.hostnameVerifier : HttpsURLConnection.getDefaultHostnameVerifier(), - configuration.updateTimeout, configuration.connectionTimeout, configuration.readTimeout, configuration.minTimeBetweenRequests); + configuration.updateTimeout, configuration.connectionTimeout, configuration.readTimeout, configuration.minTimeBetweenRequests, + configuration.allowedJkuValues); } else { log.tokenRealmJwtNoSSLIgnoringJku(); @@ -106,6 +109,9 @@ public static Builder builder() { if (audiences.isEmpty()) { log.tokenRealmJwtWarnNoAudienceIgnoringAudienceCheck(); } + if (allowedJkuValues.isEmpty()) { + log.allowedJkuValuesNotConfigured(); + } } @@ -311,6 +317,10 @@ private PublicKey resolvePublicKey(JsonObject headers) { log.debugf("Cannot validate token with jku [%s]. SSL is not configured and jku claim is not supported.", jku); return null; } + if (! allowedJkuValues.contains(jku.getString())) { + log.debug("Cannot validate token, jku value is not allowed"); + return null; + } try { return jwkManager.getPublicKey(kid.getString(), new URL(jku.getString())); } catch (MalformedURLException e) { @@ -340,6 +350,7 @@ public static class Builder { private Set issuers = new LinkedHashSet<>(); private Set audience = new LinkedHashSet<>(); + private Set allowedJkuValues = new LinkedHashSet<>(); private PublicKey publicKey; private Map namedKeys = new LinkedHashMap<>(); private HostnameVerifier hostnameVerifier; @@ -495,6 +506,19 @@ public Builder setJkuMinTimeBetweenRequests(int minTimeBetweenRequests) { return this; } + /** + * One or more string values representing the jku values that are supported by this configuration. + * During JWT validation, if the jku header parameter is present in a token, it must exactly match + * one of the strings defined here or token validation will fail. + * + * @param allowedJkuValues the allowed values for the jku header parameter + * @return this instance + */ + public Builder setAllowedJkuValues(String... allowedJkuValues) { + this.allowedJkuValues.addAll(asList(allowedJkuValues)); + return this; + } + /** * Returns a {@link JwtValidator} instance based on all the configuration provided with this builder. * diff --git a/http/oidc/src/main/java/org/wildfly/security/http/oidc/Oidc.java b/http/oidc/src/main/java/org/wildfly/security/http/oidc/Oidc.java index 8d0170fa75a..2052af1a0c1 100644 --- a/http/oidc/src/main/java/org/wildfly/security/http/oidc/Oidc.java +++ b/http/oidc/src/main/java/org/wildfly/security/http/oidc/Oidc.java @@ -352,4 +352,18 @@ public static void logToken(String name, String token) { } } + protected static boolean checkCachedAccountMatchesRequest(OidcAccount account, OidcClientConfiguration deployment) { + if (deployment.getRealm() != null + && ! deployment.getRealm().equals(account.getOidcSecurityContext().getRealm())) { + log.debug("Account in session belongs to a different realm than for this request."); + return false; + } + if (deployment.getProviderUrl() != null + && ! deployment.getProviderUrl().equals(account.getOidcSecurityContext().getOidcClientConfiguration().getProviderUrl())) { + log.debug("Account in session belongs to a different provider than for this request."); + return false; + } + return true; + } + } diff --git a/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcCookieTokenStore.java b/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcCookieTokenStore.java index 3039192f0a7..f24fe9008f8 100644 --- a/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcCookieTokenStore.java +++ b/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcCookieTokenStore.java @@ -20,6 +20,7 @@ import static org.wildfly.security.http.oidc.ElytronMessages.log; import static org.wildfly.security.http.oidc.Oidc.OIDC_STATE_COOKIE; +import static org.wildfly.security.http.oidc.Oidc.checkCachedAccountMatchesRequest; import java.net.URISyntaxException; import java.util.List; @@ -72,8 +73,7 @@ public boolean isCached(RequestAuthenticator authenticator) { return false; } OidcAccount account = new OidcAccount(principal); - if (deployment.getRealm() != null && ! deployment.getRealm().equals(account.getOidcSecurityContext().getRealm())) { - log.debug("Account in session belongs to a different realm than for this request."); + if (! checkCachedAccountMatchesRequest(account, deployment)) { return false; } diff --git a/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcSessionTokenStore.java b/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcSessionTokenStore.java index c2e6838f7a7..cb6206177cd 100644 --- a/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcSessionTokenStore.java +++ b/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcSessionTokenStore.java @@ -19,6 +19,7 @@ package org.wildfly.security.http.oidc; import static org.wildfly.security.http.oidc.ElytronMessages.log; +import static org.wildfly.security.http.oidc.Oidc.checkCachedAccountMatchesRequest; import java.util.ArrayList; import java.util.Collection; @@ -88,9 +89,7 @@ public boolean isCached(RequestAuthenticator authenticator) { } OidcClientConfiguration deployment = httpFacade.getOidcClientConfiguration(); - - if (deployment.getRealm() != null && ! deployment.getRealm().equals(account.getOidcSecurityContext().getRealm())) { - log.debug("Account in session belongs to a different realm than for this request."); + if (! checkCachedAccountMatchesRequest(account, deployment)) { return false; } 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 bbe6e091e5e..ce35ea86008 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 @@ -18,6 +18,9 @@ package org.wildfly.security.http.oidc; +import static org.wildfly.security.http.oidc.OidcBaseTest.TENANT1_REALM; +import static org.wildfly.security.http.oidc.OidcBaseTest.TENANT2_REALM; + import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -51,6 +54,16 @@ public class KeycloakConfiguration { public static final String ALLOWED_ORIGIN = "http://somehost"; public static final boolean EMAIL_VERIFIED = false; + // the users below are for multi-tenancy tests specifically + public static final String TENANT1_USER = "tenant1_user"; + public static final String TENANT1_PASSWORD = "tenant1_password"; + public static final String TENANT2_USER = "tenant2_user"; + public static final String TENANT2_PASSWORD = "tenant2_password"; + public static final String CHARLIE = "charlie"; + public static final String CHARLIE_PASSWORD =" charlie123+"; + public static final String DAN = "dan"; + public static final String DAN_PASSWORD =" dan123+"; + /** * Configure RealmRepresentation as follows: *
    @@ -67,6 +80,12 @@ public static RealmRepresentation getRealmRepresentation(final String realmName, return createRealm(realmName, clientId, clientSecret, clientHostName, clientPort, clientApp, configureClientScopes); } + public static RealmRepresentation getRealmRepresentation(final String realmName, String clientId, String clientSecret, + String clientHostName, int clientPort, String clientApp, int accessTokenLifespan, + int ssoSessionMaxLifespan, boolean configureClientScopes, boolean multiTenancyApp) { + return createRealm(realmName, clientId, clientSecret, clientHostName, clientPort, clientApp, accessTokenLifespan, ssoSessionMaxLifespan, configureClientScopes, multiTenancyApp); + } + public static RealmRepresentation getRealmRepresentation(final String realmName, String clientId, String clientSecret, String clientHostName, int clientPort, String clientApp, boolean directAccessGrantEnabled, String bearerOnlyClientId, @@ -116,18 +135,31 @@ private static RealmRepresentation createRealm(String name, String clientId, Str return createRealm(name, clientId, clientSecret, clientHostName, clientPort, clientApp, false, null, null, configureClientScopes); } + private static RealmRepresentation createRealm(String name, String clientId, String clientSecret, + String clientHostName, int clientPort, String clientApp, int accessTokenLifeSpan, int ssoSessionMaxLifespan, + boolean configureClientScopes, boolean multiTenancyApp) { + return createRealm(name, clientId, clientSecret, clientHostName, clientPort, clientApp, false, null, null, accessTokenLifeSpan, ssoSessionMaxLifespan, configureClientScopes, multiTenancyApp); + } + private static RealmRepresentation createRealm(String name, String clientId, String clientSecret, String clientHostName, int clientPort, String clientApp, boolean directAccessGrantEnabled, String bearerOnlyClientId, String corsClientId, boolean configureClientScopes) { - RealmRepresentation realm = new RealmRepresentation(); + return createRealm(name, clientId, clientSecret, clientHostName, clientPort, clientApp, directAccessGrantEnabled, bearerOnlyClientId, corsClientId, 3, 3, configureClientScopes, false); + } + private static RealmRepresentation createRealm(String name, String clientId, String clientSecret, + String clientHostName, int clientPort, String clientApp, + boolean directAccessGrantEnabled, String bearerOnlyClientId, + String corsClientId, int accessTokenLifespan, int ssoSessionMaxLifespan, + boolean configureClientScopes, boolean multiTenancyApp) { + RealmRepresentation realm = new RealmRepresentation(); realm.setRealm(name); realm.setEnabled(true); realm.setUsers(new ArrayList<>()); realm.setClients(new ArrayList<>()); - realm.setAccessTokenLifespan(3); - realm.setSsoSessionMaxLifespan(3); + realm.setAccessTokenLifespan(accessTokenLifespan); + realm.setSsoSessionMaxLifespan(ssoSessionMaxLifespan); RolesRepresentation roles = new RolesRepresentation(); List realmRoles = new ArrayList<>(); @@ -137,7 +169,8 @@ 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)); - ClientRepresentation webAppClient = createWebAppClient(clientId, clientSecret, clientHostName, clientPort, clientApp, directAccessGrantEnabled); + + ClientRepresentation webAppClient = createWebAppClient(clientId, clientSecret, clientHostName, clientPort, clientApp, directAccessGrantEnabled, multiTenancyApp); if (configureClientScopes) { webAppClient.setDefaultClientScopes(Collections.singletonList(OIDC_SCOPE)); webAppClient.setOptionalClientScopes(Arrays.asList("phone", "email", "profile")); @@ -149,26 +182,46 @@ private static RealmRepresentation createRealm(String name, String clientId, Str } if (corsClientId != null) { - realm.getClients().add(createWebAppClient(corsClientId, clientSecret, clientHostName, clientPort, clientApp, directAccessGrantEnabled, ALLOWED_ORIGIN)); + realm.getClients().add(createWebAppClient(corsClientId, clientSecret, clientHostName, clientPort, clientApp, directAccessGrantEnabled, ALLOWED_ORIGIN, multiTenancyApp)); } - realm.getUsers().add(createUser(ALICE, ALICE_PASSWORD, Arrays.asList(USER_ROLE, ADMIN_ROLE))); - realm.getUsers().add(createUser(BOB, BOB_PASSWORD, Arrays.asList(USER_ROLE))); + if (name.equals(TENANT1_REALM)) { + realm.getUsers().add(createUser(TENANT1_USER, TENANT1_PASSWORD, Arrays.asList(USER_ROLE, ADMIN_ROLE))); + realm.getUsers().add(createUser(CHARLIE, CHARLIE_PASSWORD, Arrays.asList(USER_ROLE, ADMIN_ROLE))); + realm.getUsers().add(createUser(DAN, DAN_PASSWORD, Arrays.asList(USER_ROLE, ADMIN_ROLE))); + } else if (name.equals(TENANT2_REALM)) { + realm.getUsers().add(createUser(TENANT2_USER, TENANT2_PASSWORD, Arrays.asList(USER_ROLE, ADMIN_ROLE))); + realm.getUsers().add(createUser(CHARLIE, CHARLIE_PASSWORD, Arrays.asList(USER_ROLE, ADMIN_ROLE))); + realm.getUsers().add(createUser(DAN, DAN_PASSWORD, Arrays.asList(USER_ROLE, ADMIN_ROLE))); + } else { + 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, 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, boolean multiTenancyApp) { + return createWebAppClient(clientId, clientSecret, clientHostName, clientPort, clientApp, directAccessGrantEnabled, null, multiTenancyApp); } private static ClientRepresentation createWebAppClient(String clientId, String clientSecret, String clientHostName, int clientPort, String clientApp, boolean directAccessGrantEnabled, String allowedOrigin) { + return createWebAppClient(clientId, clientSecret, clientHostName, clientPort, clientApp, directAccessGrantEnabled, allowedOrigin, false); + } + + private static ClientRepresentation createWebAppClient(String clientId, String clientSecret, String clientHostName, int clientPort, + String clientApp, boolean directAccessGrantEnabled, String allowedOrigin, boolean multiTenancyApp) { ClientRepresentation client = new ClientRepresentation(); client.setClientId(clientId); client.setPublicClient(false); client.setSecret(clientSecret); //client.setRedirectUris(Arrays.asList("*")); - client.setRedirectUris(Arrays.asList("http://" + clientHostName + ":" + clientPort + "/" + clientApp)); + if (multiTenancyApp) { + client.setRedirectUris(Arrays.asList("http://" + clientHostName + ":" + clientPort + "/" + clientApp + "/*")); + } else { + client.setRedirectUris(Arrays.asList("http://" + clientHostName + ":" + clientPort + "/" + clientApp)); + } client.setEnabled(true); client.setDirectAccessGrantsEnabled(directAccessGrantEnabled); if (allowedOrigin != null) { diff --git a/http/oidc/src/test/java/org/wildfly/security/http/oidc/MultiTenantResolver.java b/http/oidc/src/test/java/org/wildfly/security/http/oidc/MultiTenantResolver.java new file mode 100644 index 00000000000..e8fad971837 --- /dev/null +++ b/http/oidc/src/test/java/org/wildfly/security/http/oidc/MultiTenantResolver.java @@ -0,0 +1,67 @@ +/* + * JBoss, Home of Professional Open Source. + * Copyright 2024 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.wildfly.security.http.oidc.OidcBaseTest.CLIENT_APP; + +import java.io.InputStream; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Multi-tenant resolver. + * + * @author Farah Juma + */ +public class MultiTenantResolver implements OidcClientConfigurationResolver { + private final boolean useAuthServerUrl; + + public MultiTenantResolver(boolean useAuthServerUrl) { + this.useAuthServerUrl = useAuthServerUrl; + } + + private final Map cache = new ConcurrentHashMap<>(); + + @Override + public OidcClientConfiguration resolve(OidcHttpFacade.Request request) { + String path = request.getURI(); + int multitenantIndex = path.indexOf(CLIENT_APP + "/"); + if (multitenantIndex == -1) { + throw new IllegalStateException("Cannot resolve the configuration to use from the request"); + } + + String tenant = path.substring(multitenantIndex).split("/")[1]; + if (tenant.contains("?")) { + tenant = tenant.split("\\?")[0]; + } + + OidcClientConfiguration clientConfiguration = cache.get(tenant); + if (clientConfiguration == null) { + // not found in the simple cache, try to load it instead + InputStream is = useAuthServerUrl ? OidcTest.getTenantConfigWithAuthServerUrl(tenant) : OidcTest.getTenantConfigWithProviderUrl(tenant); + if (is == null) { + throw new IllegalStateException("Cannot find tenant configuration"); + } + clientConfiguration = OidcClientConfigurationBuilder.build(is); + cache.put(tenant, clientConfiguration); + } + return clientConfiguration; + } + +} + 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 index 65d0da04ba6..68d4712547c 100644 --- 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 @@ -19,17 +19,20 @@ package org.wildfly.security.http.oidc; import static org.junit.Assert.assertEquals; -import static org.wildfly.common.Assert.assertTrue; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; import java.io.IOException; import java.net.URI; 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.jose4j.jwt.JwtClaims; import org.jose4j.jwt.consumer.InvalidJwtException; import org.jose4j.jwt.consumer.JwtConsumerBuilder; @@ -74,6 +77,8 @@ public class OidcBaseTest extends AbstractBaseHttpTest { public static final String CLIENT_SECRET = "secret"; public static KeycloakContainer KEYCLOAK_CONTAINER; public static final String TEST_REALM = "WildFly"; + public static final String TENANT1_REALM = "tenant1"; + public static final String TENANT2_REALM = "tenant2"; public static final String KEYCLOAK_USERNAME = "username"; public static final String KEYCLOAK_PASSWORD = "password"; public static final String KEYCLOAK_LOGIN = "login"; @@ -83,7 +88,8 @@ public class OidcBaseTest extends AbstractBaseHttpTest { public static final String CLIENT_HOST_NAME = "localhost"; public static MockWebServer client; // to simulate the application being secured public static final Boolean CONFIGURE_CLIENT_SCOPES = true; // to simulate the application being secured - + public static final String TENANT1_ENDPOINT = "tenant1"; + public static final String TENANT2_ENDPOINT = "tenant2"; protected HttpServerAuthenticationMechanismFactory oidcFactory; @AfterClass @@ -179,7 +185,71 @@ public MockResponse dispatch(RecordedRequest recordedRequest) throws Interrupted }; } - protected WebClient getWebClient() { + protected static Dispatcher createAppResponse(HttpServerAuthenticationMechanism mechanism, int expectedStatusCode, String expectedLocation, String clientPageText, + Map sessionScopeAttachments) { + 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()); + for (String key : request.getSessionScopeAttachments().keySet()) { + sessionScopeAttachments.put(key, request.getSessionScopeAttachments().get(key)); + } + return new MockResponse().setBody(clientPageText); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + return new MockResponse() + .setBody(""); + } + }; + } + + protected static Dispatcher createAppResponse(HttpServerAuthenticationMechanism mechanism, String clientPageText, + Map sessionScopeAttachments, String tenant, boolean sameTenant) { + return new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest recordedRequest) throws InterruptedException { + String path = recordedRequest.getPath(); + if (path.contains("/" + CLIENT_APP + "/" + tenant)) { + try { + TestingHttpServerRequest request = new TestingHttpServerRequest(new String[0], + new URI(recordedRequest.getRequestUrl().toString()), sessionScopeAttachments); + mechanism.evaluateRequest(request); + TestingHttpServerResponse response = request.getResponse(); + if (sameTenant) { + // should be able to access the same tenant without logging in again + assertEquals(Status.COMPLETE, request.getResult()); + return new MockResponse().setBody(clientPageText); + } else { + // should be redirected to Keycloak to access the other tenant + assertEquals(Status.NO_AUTH, request.getResult()); + assertEquals(HttpStatus.SC_MOVED_TEMPORARILY, response.getStatusCode()); + assertTrue(response.getLocation().contains(KEYCLOAK_CONTAINER.getAuthServerUrl())); + HtmlPage keycloakLoginPage = getWebClient().getPage(response.getLocation()); + HtmlForm loginForm = keycloakLoginPage.getForms().get(0); + assertNotNull(loginForm.getInputByName(KEYCLOAK_USERNAME)); + assertNotNull(loginForm.getInputByName(KEYCLOAK_PASSWORD)); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } + return new MockResponse() + .setBody(""); + } + }; + } + + static WebClient getWebClient() { WebClient webClient = new WebClient(); webClient.setCssErrorHandler(new SilentCssErrorHandler()); webClient.setJavaScriptErrorListener(new SilentJavaScriptErrorListener()); @@ -190,6 +260,10 @@ protected static String getClientUrl() { return "http://" + CLIENT_HOST_NAME + ":" + CLIENT_PORT + "/" + CLIENT_APP; } + protected static String getClientUrlForTenant(String tenant) { + return "http://" + CLIENT_HOST_NAME + ":" + CLIENT_PORT + "/" + CLIENT_APP + "/" + tenant; + } + protected HtmlInput loginToKeycloak(String username, String password, URI requestUri, String location, List cookies) throws IOException { WebClient webClient = getWebClient(); if (cookies != null) { @@ -246,4 +320,4 @@ protected void checkForScopeClaims(Callback callback, String expectedScopes) thr } } } -} \ 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 bb41ffe97bd..3e6057a77ce 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 @@ -19,8 +19,17 @@ 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.Assume.assumeTrue; +import static org.wildfly.security.http.oidc.KeycloakConfiguration.CHARLIE; +import static org.wildfly.security.http.oidc.KeycloakConfiguration.CHARLIE_PASSWORD; +import static org.wildfly.security.http.oidc.KeycloakConfiguration.DAN; +import static org.wildfly.security.http.oidc.KeycloakConfiguration.DAN_PASSWORD; +import static org.wildfly.security.http.oidc.KeycloakConfiguration.TENANT1_PASSWORD; +import static org.wildfly.security.http.oidc.KeycloakConfiguration.TENANT1_USER; +import static org.wildfly.security.http.oidc.KeycloakConfiguration.TENANT2_PASSWORD; +import static org.wildfly.security.http.oidc.KeycloakConfiguration.TENANT2_USER; import static org.wildfly.security.http.oidc.Oidc.OIDC_NAME; import static org.wildfly.security.http.oidc.Oidc.OIDC_SCOPE; @@ -31,7 +40,6 @@ import java.util.HashMap; import java.util.Map; -import com.gargoylesoftware.htmlunit.WebClient; import org.apache.http.HttpStatus; import org.junit.AfterClass; import org.junit.BeforeClass; @@ -39,6 +47,7 @@ import org.wildfly.security.http.HttpServerAuthenticationMechanism; import com.gargoylesoftware.htmlunit.TextPage; +import com.gargoylesoftware.htmlunit.WebClient; import com.gargoylesoftware.htmlunit.html.HtmlPage; import io.restassured.RestAssured; @@ -52,12 +61,18 @@ */ public class OidcTest extends OidcBaseTest { + // setting a high number for the token lifespan so we can test that a valid token from tenant1 can't be used for tenant2 + private static final int ACCESS_TOKEN_LIFESPAN = 120; + private static final int SESSION_MAX_LIFESPAN = 120; + @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, CONFIGURE_CLIENT_SCOPES)); + sendRealmCreationRequest(KeycloakConfiguration.getRealmRepresentation(TENANT1_REALM, CLIENT_ID, CLIENT_SECRET, CLIENT_HOST_NAME, CLIENT_PORT, CLIENT_APP, ACCESS_TOKEN_LIFESPAN, SESSION_MAX_LIFESPAN, false, true)); + sendRealmCreationRequest(KeycloakConfiguration.getRealmRepresentation(TENANT2_REALM, CLIENT_ID, CLIENT_SECRET, CLIENT_HOST_NAME, CLIENT_PORT, CLIENT_APP, ACCESS_TOKEN_LIFESPAN, SESSION_MAX_LIFESPAN, false, true)); client = new MockWebServer(); client.start(CLIENT_PORT); } @@ -70,6 +85,16 @@ public static void generalCleanup() throws Exception { .auth().oauth2(KeycloakConfiguration.getAdminAccessToken(KEYCLOAK_CONTAINER.getAuthServerUrl())) .when() .delete(KEYCLOAK_CONTAINER.getAuthServerUrl() + "/admin/realms/" + TEST_REALM).then().statusCode(204); + RestAssured + .given() + .auth().oauth2(KeycloakConfiguration.getAdminAccessToken(KEYCLOAK_CONTAINER.getAuthServerUrl())) + .when() + .delete(KEYCLOAK_CONTAINER.getAuthServerUrl() + "/admin/realms/" + TENANT1_REALM).then().statusCode(204); + RestAssured + .given() + .auth().oauth2(KeycloakConfiguration.getAdminAccessToken(KEYCLOAK_CONTAINER.getAuthServerUrl())) + .when() + .delete(KEYCLOAK_CONTAINER.getAuthServerUrl() + "/admin/realms/" + TENANT2_REALM).then().statusCode(204); KEYCLOAK_CONTAINER.stop(); } if (client != null) { @@ -210,6 +235,264 @@ private void performAuthentication(InputStream oidcConfig, String username, Stri performAuthentication(oidcConfig, username, password, loginToKeycloak, expectedDispatcherStatusCode, expectedLocation, clientPageText, null, false); } + /***************************************************************************************************************************************** + * Tests for multi-tenancy. + * + * The tests below involve two tenants: + * Tenant1: http://localhost:5002/clientApp/tenant1 + * Tenant2: http://localhost:5002/clientApp/tenant2 + * + * Tenant1 is secured using the tenant1 Keycloak Realm which contains the following users: + * tenant1_user + * charlie + * dan + * + * Tenant2 is secured using the tenant2 Keycloak Realm which contains the following users: + * tenant2_user + * charlie + * dan + * + * The first set of tests will make use of Keycloak-specific OIDC configuration. + * The second set of tests will make use of a provider-url in the OIDC configuration. + *****************************************************************************************************************************************/ + + /********************************************************** + * 1. Tests using Keycloak-specific OIDC configuration + **********************************************************/ + + /** + * Test that logging into each tenant with a non-existing user fails. + */ + @Test + public void testNonExistingUserWithAuthServerUrl() throws Exception { + testNonExistingUserWithAuthServerUrl(TENANT2_USER, TENANT2_PASSWORD, TENANT1_ENDPOINT); + testNonExistingUserWithAuthServerUrl(TENANT1_USER, TENANT1_PASSWORD, TENANT2_ENDPOINT); + testNonExistingUserWithAuthServerUrl(KeycloakConfiguration.ALICE, KeycloakConfiguration.ALICE_PASSWORD, TENANT1_ENDPOINT); + } + + /** + * Test successfully logging into /tenant1 with the tenant1_user and successfully logging into /tenant2 with the tenant2_user. + */ + @Test + public void testSuccessfulAuthenticationWithAuthServerUrl() throws Exception { + performTenantRequestWithAuthServerUrl(TENANT1_USER, TENANT1_PASSWORD, TENANT1_ENDPOINT, null); + performTenantRequestWithAuthServerUrl(TENANT2_USER, TENANT2_PASSWORD, TENANT2_ENDPOINT, null); + } + + /** + * Test successfully logging into /tenant1 with the tenant1_user and then attempt to access /tenant1 again. + * We should be able to access /tenant1 again without needing to log in again. + * + * Then test successfully logging into /tenant2 with the tenant2_user and then attempt to access /tenant2 again. + * We should be able to access /tenant2 again without needing to log in again. + */ + @Test + public void testLoggedInUserWithAuthServerUrl() throws Exception { + performTenantRequestWithAuthServerUrl(TENANT1_USER, TENANT1_PASSWORD, TENANT1_ENDPOINT, TENANT1_ENDPOINT); + performTenantRequestWithAuthServerUrl(TENANT2_USER, TENANT2_PASSWORD, TENANT2_ENDPOINT, TENANT2_ENDPOINT); + } + + /** + * Test logging into /tenant1 with the tenant1_user and then attempt to access /tenant2. + * We should be redirected to Keycloak to log in since the user's cached token isn't valid for + * /tenant2. + * + * Then test logging into /tenant2 with the tenant2_user and then attempt to access /tenant1. + * We should be redirected to Keycloak to log in since the user's cached token isn't valid for + * /tenant1. + */ + @Test + public void testUnauthorizedAccessWithAuthServerUrl() throws Exception { + performTenantRequestWithAuthServerUrl(TENANT1_USER, TENANT1_PASSWORD, TENANT1_ENDPOINT, TENANT2_ENDPOINT); + performTenantRequestWithAuthServerUrl(TENANT2_USER, TENANT2_PASSWORD, TENANT2_ENDPOINT, TENANT1_ENDPOINT); + } + + /** + * Test logging into /tenant1 with a username that exists in both tenant realms and then attempt to access /tenant2. + * We should be redirected to Keycloak to log in since the user's cached token isn't valid for /tenant2. + * + * Test logging into /tenant2 with a username that exists in both tenant realms and then attempt to access /tenant1. + * We should be redirected to Keycloak to log in since the user's cached token isn't valid for /tenant1. + */ + @Test + public void testUnauthorizedAccessWithAuthServerUrlValidUser() throws Exception { + performTenantRequestWithAuthServerUrl(CHARLIE, CHARLIE_PASSWORD, TENANT1_ENDPOINT, TENANT2_ENDPOINT); + performTenantRequestWithAuthServerUrl(DAN, DAN_PASSWORD, TENANT2_ENDPOINT, TENANT1_ENDPOINT); + } + + /********************************************************** + * 2. Tests using a provider-url in the OIDC configuration + **********************************************************/ + + /** + * Test that logging into each tenant with a non-existing user fails. + */ + @Test + public void testNonExistingUserWithProviderUrl() throws Exception { + testNonExistingUserWithProviderUrl(TENANT2_USER, TENANT2_PASSWORD, TENANT1_ENDPOINT); + testNonExistingUserWithProviderUrl(TENANT1_USER, TENANT1_PASSWORD, TENANT2_ENDPOINT); + testNonExistingUserWithProviderUrl(KeycloakConfiguration.ALICE, KeycloakConfiguration.ALICE_PASSWORD, TENANT1_ENDPOINT); + } + + /** + * Test successfully logging into /tenant1 with the tenant1_user and successfully logging into /tenant2 with the tenant2_user. + */ + @Test + public void testSuccessfulAuthenticationWithProviderUrl() throws Exception { + performTenantRequestWithProviderUrl(TENANT1_USER, TENANT1_PASSWORD, TENANT1_ENDPOINT, null); + performTenantRequestWithProviderUrl(TENANT2_USER, TENANT2_PASSWORD, TENANT2_ENDPOINT, null); + } + + /** + * Test successfully logging into /tenant1 with the tenant1_user and then attempt to access /tenant1 again. + * We should be able to access /tenant1 again without needing to log in again. + * + * Then test successfully logging into /tenant2 with the tenant2_user and then attempt to access /tenant2 again. + * We should be able to access /tenant2 again without needing to log in again. + */ + @Test + public void testLoggedInUserWithProviderUrl() throws Exception { + performTenantRequestWithProviderUrl(TENANT1_USER, TENANT1_PASSWORD, TENANT1_ENDPOINT, TENANT1_ENDPOINT); + performTenantRequestWithProviderUrl(TENANT2_USER, TENANT2_PASSWORD, TENANT2_ENDPOINT, TENANT2_ENDPOINT); + } + + /** + * Test logging into /tenant1 with the tenant1_user and then attempt to access /tenant2. + * We should be redirected to Keycloak to log in since the user's cached token isn't valid for + * /tenant2. + * + * Then test logging into /tenant2 with the tenant2_user and then attempt to access /tenant1. + * We should be redirected to Keycloak to log in since the user's cached token isn't valid for + * /tenant1. + */ + @Test + public void testUnauthorizedAccessWithProviderUrl() throws Exception { + performTenantRequestWithProviderUrl(TENANT1_USER, TENANT1_PASSWORD, TENANT1_ENDPOINT, TENANT2_ENDPOINT); + performTenantRequestWithProviderUrl(TENANT2_USER, TENANT2_PASSWORD, TENANT2_ENDPOINT, TENANT1_ENDPOINT); + } + + /** + * Test logging into /tenant1 with a username that exists in both tenant realms and then attempt to access /tenant2. + * We should be redirected to Keycloak to log in since the user's cached token isn't valid for /tenant2. + * + * Test logging into /tenant2 with a username that exists in both tenant realms and then attempt to access /tenant1. + * We should be redirected to Keycloak to log in since the user's cached token isn't valid for /tenant1. + */ + @Test + public void testUnauthorizedAccessWithProviderUrlValidUser() throws Exception { + performTenantRequestWithProviderUrl(CHARLIE, CHARLIE_PASSWORD, TENANT1_ENDPOINT, TENANT2_ENDPOINT); + performTenantRequestWithProviderUrl(DAN, DAN_PASSWORD, TENANT2_ENDPOINT, TENANT1_ENDPOINT); + } + + private void testNonExistingUserWithAuthServerUrl(String username, String password, String tenant) throws Exception { + testNonExistingUser(username, password, tenant, true); + } + + private void testNonExistingUserWithProviderUrl(String username, String password, String tenant) throws Exception { + testNonExistingUser(username, password, tenant, false); + } + + private void testNonExistingUser(String username, String password, String tenant, boolean useAuthServerUrl) throws Exception { + Map props = new HashMap<>(); + MultiTenantResolver multiTenantResolver = new MultiTenantResolver(useAuthServerUrl); + OidcClientContext oidcClientContext = new OidcClientContext(multiTenantResolver); + oidcFactory = new OidcMechanismFactory(oidcClientContext); + HttpServerAuthenticationMechanism mechanism = oidcFactory.createAuthenticationMechanism(OIDC_NAME, props, getCallbackHandler()); + + URI requestUri = new URI(getClientUrlForTenant(tenant)); + TestingHttpServerRequest request = new TestingHttpServerRequest(null, requestUri); + mechanism.evaluateRequest(request); + TestingHttpServerResponse response = request.getResponse(); + assertEquals(HttpStatus.SC_MOVED_TEMPORARILY, response.getStatusCode()); + assertEquals(Status.NO_AUTH, request.getResult()); + + HtmlPage page = loginToKeycloak(username, password, requestUri, response.getLocation(), response.getCookies()).click(); + assertTrue(page.getBody().asText().contains("Invalid username or password")); + } + + private void loginToAppMultiTenancy(InputStream oidcConfig, String username, String password, boolean loginToKeycloak, + int expectedDispatcherStatusCode, String expectedLocation, String clientPageText) 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; + mechanism = oidcFactory.createAuthenticationMechanism(OIDC_NAME, props, getCallbackHandler()); + + URI requestUri = new URI(getClientUrl()); + TestingHttpServerRequest request = new TestingHttpServerRequest(null, requestUri); + mechanism.evaluateRequest(request); + TestingHttpServerResponse response = request.getResponse(); + assertEquals(loginToKeycloak ? HttpStatus.SC_MOVED_TEMPORARILY : HttpStatus.SC_FORBIDDEN, response.getStatusCode()); + assertEquals(Status.NO_AUTH, request.getResult()); + + if (loginToKeycloak) { + client.setDispatcher(createAppResponse(mechanism, expectedDispatcherStatusCode, expectedLocation, clientPageText)); + TextPage page = loginToKeycloak(username, password, requestUri, response.getLocation(), + response.getCookies()).click(); + assertTrue(page.getContent().contains(clientPageText)); + } + } finally { + client.setDispatcher(new QueueDispatcher()); + } + } + + private void performTenantRequestWithAuthServerUrl(String username, String password, String tenant, String otherTenant) throws Exception { + performTenantRequest(username, password, tenant, otherTenant, true); + } + + private void performTenantRequestWithProviderUrl(String username, String password, String tenant, String otherTenant) throws Exception { + performTenantRequest(username, password, tenant, otherTenant, false); + } + + private void performTenantRequest(String username, String password, String tenant, String otherTenant, boolean useAuthServerUrl) throws Exception { + try { + Map props = new HashMap<>(); + Map sessionScopeAttachments = new HashMap<>(); + String clientPageText = getClientPageTestForTenant(tenant); + String expectedLocation = getClientUrlForTenant(tenant); + + // the resolver will be used to obtain the OIDC configuration + MultiTenantResolver multiTenantResolver = new MultiTenantResolver(useAuthServerUrl); + OidcClientContext oidcClientContext = new OidcClientContext(multiTenantResolver); + + oidcFactory = new OidcMechanismFactory(oidcClientContext); + HttpServerAuthenticationMechanism mechanism = oidcFactory.createAuthenticationMechanism(OIDC_NAME, props, getCallbackHandler()); + + // attempt to access the specified tenant, we should be redirected to Keycloak to login + URI requestUri = new URI(getClientUrlForTenant(tenant)); + TestingHttpServerRequest request = new TestingHttpServerRequest(null, requestUri); + mechanism.evaluateRequest(request); + TestingHttpServerResponse response = request.getResponse(); + assertEquals(HttpStatus.SC_MOVED_TEMPORARILY, response.getStatusCode()); + assertEquals(Status.NO_AUTH, request.getResult()); + + // log into Keycloak, we should then be redirected back to the tenant upon successful authentication + client.setDispatcher(createAppResponse(mechanism, HttpStatus.SC_MOVED_TEMPORARILY, expectedLocation, clientPageText, sessionScopeAttachments)); + TextPage page = loginToKeycloak(username, password, requestUri, response.getLocation(), + response.getCookies()).click(); + assertTrue(page.getContent().contains(clientPageText)); + + if (otherTenant != null) { + // attempt to access the other tenant + client.setDispatcher(createAppResponse(mechanism, clientPageText, sessionScopeAttachments, otherTenant, tenant.equals(otherTenant))); + WebClient webClient = getWebClient(); + page = webClient.getPage(getClientUrlForTenant(otherTenant)); + if (otherTenant.equals(tenant)) { + // accessing the same tenant as above, already logged in + assertTrue(page.getContent().contains(clientPageText)); + } else { + assertFalse(page.getContent().contains(clientPageText)); + } + } + } finally { + client.setDispatcher(new QueueDispatcher()); + } + } + private void performAuthentication(InputStream oidcConfig, String username, String password, boolean loginToKeycloak, int expectedDispatcherStatusCode, String expectedLocation, String clientPageText, String expectedScope, boolean checkInvalidScopeError) throws Exception { try { @@ -367,4 +650,35 @@ private InputStream getOidcConfigurationInputStreamWithScope(String scopeValue){ "}"; return new ByteArrayInputStream(oidcConfig.getBytes(StandardCharsets.UTF_8)); } + + static InputStream getTenantConfigWithAuthServerUrl(String tenant) { + String oidcConfig = "{\n" + + " \"realm\" : \"" + tenant + "\",\n" + + " \"resource\" : \"" + CLIENT_ID + "\",\n" + + " \"public-client\" : \"false\",\n" + + " \"auth-server-url\" : \"" + KEYCLOAK_CONTAINER.getAuthServerUrl() + "\",\n" + + " \"ssl-required\" : \"EXTERNAL\",\n" + + " \"credentials\" : {\n" + + " \"secret\" : \"" + CLIENT_SECRET + "\"\n" + + " }\n" + + "}"; + return new ByteArrayInputStream(oidcConfig.getBytes(StandardCharsets.UTF_8)); + } + + static InputStream getTenantConfigWithProviderUrl(String tenant) { + String oidcConfig = "{\n" + + " \"provider-url\" : \"" + KEYCLOAK_CONTAINER.getAuthServerUrl() + "/realms/" + tenant + "\",\n" + + " \"client-id\" : \"" + CLIENT_ID + "\",\n" + + " \"public-client\" : \"false\",\n" + + " \"ssl-required\" : \"EXTERNAL\",\n" + + " \"credentials\" : {\n" + + " \"secret\" : \"" + CLIENT_SECRET + "\"\n" + + " }\n" + + "}"; + return new ByteArrayInputStream(oidcConfig.getBytes(StandardCharsets.UTF_8)); + } + + private static final String getClientPageTestForTenant(String tenant) { + return tenant.equals(TENANT1_ENDPOINT) ? TENANT1_ENDPOINT : TENANT2_ENDPOINT + ":" + CLIENT_PAGE_TEXT; + } } diff --git a/tests/base/src/test/java/org/wildfly/security/auth/realm/token/JwtSecurityRealmTest.java b/tests/base/src/test/java/org/wildfly/security/auth/realm/token/JwtSecurityRealmTest.java index 18b4e107c30..917fb30cc47 100644 --- a/tests/base/src/test/java/org/wildfly/security/auth/realm/token/JwtSecurityRealmTest.java +++ b/tests/base/src/test/java/org/wildfly/security/auth/realm/token/JwtSecurityRealmTest.java @@ -18,38 +18,25 @@ package org.wildfly.security.auth.realm.token; -import com.nimbusds.jose.JOSEObjectType; -import com.nimbusds.jose.JWSAlgorithm; -import com.nimbusds.jose.JWSHeader; -import com.nimbusds.jose.JWSObject; -import com.nimbusds.jose.JWSSigner; import com.nimbusds.jose.Payload; import com.nimbusds.jose.PlainHeader; import com.nimbusds.jose.PlainObject; -import com.nimbusds.jose.crypto.RSASSASigner; import jakarta.json.Json; -import jakarta.json.JsonArrayBuilder; import jakarta.json.JsonObject; import jakarta.json.JsonObjectBuilder; -import jakarta.json.JsonValue; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; -import java.math.BigInteger; import java.net.URI; import java.security.GeneralSecurityException; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.KeyStore; -import java.security.PrivateKey; import java.security.PublicKey; import java.security.cert.X509Certificate; -import java.security.interfaces.RSAPublicKey; -import java.util.Arrays; -import java.util.Base64; import java.util.LinkedHashMap; import java.util.Map; import javax.net.ssl.HttpsURLConnection; @@ -83,6 +70,7 @@ import org.wildfly.security.evidence.BearerTokenEvidence; import org.wildfly.security.evidence.Evidence; import org.wildfly.security.pem.Pem; +import org.wildfly.security.realm.token.test.util.RsaJwk; import org.wildfly.security.ssl.SSLContextBuilder; import org.wildfly.security.x500.cert.SelfSignedX509CertificateAndSigningKey; @@ -90,6 +78,11 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; +import static org.wildfly.security.realm.token.test.util.JwtTestUtil.createClaims; +import static org.wildfly.security.realm.token.test.util.JwtTestUtil.createJwt; +import static org.wildfly.security.realm.token.test.util.JwtTestUtil.createRsaJwk; +import static org.wildfly.security.realm.token.test.util.JwtTestUtil.createTokenDispatcher; +import static org.wildfly.security.realm.token.test.util.JwtTestUtil.jwksToJson; /** * @author Pedro Igor @@ -117,50 +110,18 @@ public class JwtSecurityRealmTest { private static String jwksResponse; - // rfc7518 dictates the use of Base64urlUInt for "n" and "e" and it explicitly mentions that the - // minimum number of octets should be used and the 0 leading sign byte should not be included - private static byte[] toBase64urlUInt(final BigInteger bigInt) { - byte[] bytes = bigInt.toByteArray(); - int i = 0; - while (i < bytes.length && bytes[i] == 0) { - i++; - } - if (i > 0 && i < bytes.length) { - return Arrays.copyOfRange(bytes, i, bytes.length); - } else { - return bytes; - } - } - @BeforeClass public static void setup() throws GeneralSecurityException, IOException { System.setProperty("wildfly.config.url", JwtSecurityRealmTest.class.getResource("wildfly-jwt-test-config.xml").toExternalForm()); keyPair1 = KeyPairGenerator.getInstance("RSA").generateKeyPair(); - keyPair2 = KeyPairGenerator.getInstance("RSA").generateKeyPair(); - keyPair3 = KeyPairGenerator.getInstance("RSA").generateKeyPair(); - - RSAPublicKey pk1 = (RSAPublicKey) keyPair1.getPublic(); - RSAPublicKey pk2 = (RSAPublicKey) keyPair2.getPublic(); - RSAPublicKey pk3 = (RSAPublicKey) keyPair3.getPublic(); + jwk1 = createRsaJwk(keyPair1, "1"); - jwk1.setAlg("RS256"); - jwk1.setKid("1"); - jwk1.setKty("RSA"); - jwk1.setE(Base64.getUrlEncoder().withoutPadding().encodeToString(toBase64urlUInt(pk1.getPublicExponent()))); - jwk1.setN(Base64.getUrlEncoder().withoutPadding().encodeToString(toBase64urlUInt(pk1.getModulus()))); - - jwk2.setAlg("RS256"); - jwk2.setKid("2"); - jwk2.setKty("RSA"); - jwk2.setE(Base64.getUrlEncoder().withoutPadding().encodeToString(toBase64urlUInt(pk2.getPublicExponent()))); - jwk2.setN(Base64.getUrlEncoder().withoutPadding().encodeToString(toBase64urlUInt(pk2.getModulus()))); + keyPair2 = KeyPairGenerator.getInstance("RSA").generateKeyPair(); + jwk2 = createRsaJwk(keyPair2, "2"); - jwk3.setAlg("RS256"); - jwk3.setKid("3"); - jwk3.setKty("RSA"); - jwk3.setE(Base64.getUrlEncoder().withoutPadding().encodeToString(toBase64urlUInt(pk3.getPublicExponent()))); - jwk3.setN(Base64.getUrlEncoder().withoutPadding().encodeToString(toBase64urlUInt(pk3.getModulus()))); + keyPair3 = KeyPairGenerator.getInstance("RSA").generateKeyPair(); + jwk3 = createRsaJwk(keyPair3, "3"); JsonObject jwks = jwksToJson(jwk1, jwk2); @@ -245,6 +206,7 @@ public void testChangedKeys() throws Exception { .validator(JwtValidator.builder() .issuer("elytron-oauth2-realm") .audience("my-app-valid") + .setAllowedJkuValues("https://localhost:50831") .setJkuTimeout(0) //refresh jwks every time .setJkuMinTimeBetweenRequests(0) .useSslContext(sslContext) @@ -276,6 +238,7 @@ public void testNewRotationKeys() throws Exception { .validator(JwtValidator.builder() .issuer("elytron-oauth2-realm") .audience("my-app-valid") + .setAllowedJkuValues("https://localhost:50831") .setJkuTimeout(60000L) // 60s of cache .setJkuMinTimeBetweenRequests(0) // no time betweeen requests .useSslContext(sslContext) @@ -309,6 +272,7 @@ public void testNewRotationKeysTimeBetweenRequests() throws Exception { .validator(JwtValidator.builder() .issuer("elytron-oauth2-realm") .audience("my-app-valid") + .setAllowedJkuValues("https://localhost:50831") .setJkuTimeout(60000L) // 60s of cache .setJkuMinTimeBetweenRequests(10000) // 10s between calls .useSslContext(sslContext) @@ -352,6 +316,7 @@ public void testMultipleTokenTypes() throws Exception { .validator(JwtValidator.builder() .issuer("elytron-oauth2-realm") .audience("my-app-valid") + .setAllowedJkuValues("https://localhost:50831") .publicKeys(namedKeys) .publicKey(keyPair3.getPublic()) .useSslContext(sslContext) @@ -373,6 +338,23 @@ public void testMultipleTokenTypes() throws Exception { @Test public void testUnsecuredJkuEndpoint() throws Exception { checkIdentityDoesNotExist("1", 50832); + BearerTokenEvidence evidence = new BearerTokenEvidence(createJwt(keyPair1, 60, -1, "1", new URI("https://localhost:50832"))); + + X509TrustManager tm = getTrustManager(); + SSLContext sslContext = new SSLContextBuilder().setTrustManager(tm).setClientMode(true).setSessionTimeout(10).build().create(); + + TokenSecurityRealm securityRealm = TokenSecurityRealm.builder() + .principalClaimName("sub") + .validator(JwtValidator.builder() + .issuer("elytron-oauth2-realm") + .audience("my-app-valid") + .setAllowedJkuValues("https://localhost:50832") + .useSslContext(sslContext) + .useSslHostnameVerifier((a,b) -> true).build()) + .build(); + + assertIdentityNotExist(securityRealm, evidence); + } @Test @@ -414,6 +396,7 @@ public void testStoppedJkuEndpoint() throws Exception { .validator(JwtValidator.builder() .issuer("elytron-oauth2-realm") .audience("my-app-valid") + .setAllowedJkuValues("https://localhost:50831") .setJkuTimeout(0) //Keys will be downloaded on every request .setJkuMinTimeBetweenRequests(0) .useSslContext(sslContext) @@ -442,6 +425,7 @@ public void testJkuMultipleKeys() throws Exception { .validator(JwtValidator.builder() .issuer("elytron-oauth2-realm") .audience("my-app-valid") + .setAllowedJkuValues("https://localhost:50831") .useSslContext(sslContext) .useSslHostnameVerifier((a,b) -> true).build()) .build(); @@ -453,11 +437,44 @@ public void testJkuMultipleKeys() throws Exception { @Test public void testInvalidJku() throws Exception { checkIdentityDoesNotExist("1", 80); + BearerTokenEvidence evidence = new BearerTokenEvidence(createJwt(keyPair1, 60, -1, "1", new URI("https://localhost:80"))); + + X509TrustManager tm = getTrustManager(); + SSLContext sslContext = new SSLContextBuilder().setTrustManager(tm).setClientMode(true).setSessionTimeout(10).build().create(); + + TokenSecurityRealm securityRealm = TokenSecurityRealm.builder() + .principalClaimName("sub") + .validator(JwtValidator.builder() + .issuer("elytron-oauth2-realm") + .audience("my-app-valid") + .setAllowedJkuValues("https://localhost:80") + .useSslContext(sslContext).useSslHostnameVerifier((a,b) -> true).build()) + .build(); + + assertIdentityNotExist(securityRealm, evidence); + } @Test public void testInvalidKid() throws Exception { checkIdentityDoesNotExist("badkid", 50831); + BearerTokenEvidence evidence = new BearerTokenEvidence(createJwt(keyPair1, 60, -1, "badkid", new URI("https://localhost:50831"))); + + X509TrustManager tm = getTrustManager(); + SSLContext sslContext = new SSLContextBuilder().setTrustManager(tm).setClientMode(true).setSessionTimeout(10).build().create(); + + TokenSecurityRealm securityRealm = TokenSecurityRealm.builder() + .principalClaimName("sub") + .validator(JwtValidator.builder() + .issuer("elytron-oauth2-realm") + .audience("my-app-valid") + .setAllowedJkuValues("https://localhost:50831") + .useSslContext(sslContext) + .useSslHostnameVerifier((a,b) -> true).build()) + .build(); + + assertIdentityNotExist(securityRealm, evidence); + } @Test @@ -678,74 +695,104 @@ public void testAltPrincipaNamesSubFallback() throws Exception { assertEquals("elytron@jboss.org", realmIdentity.getRealmIdentityPrincipal().getName()); } - private void assertIdentityNotExist(SecurityRealm realm, Evidence evidence) throws RealmUnavailableException { - RealmIdentity identity = realm.getRealmIdentity(evidence); - assertNotNull(identity); - assertFalse(identity.exists()); - } + @Test + public void testTokenWithJkuValueAllowed() throws Exception { + BearerTokenEvidence evidence = new BearerTokenEvidence( + createJwt(keyPair1, 60, -1, "1", new URI("https://localhost:50831"))); - private void assertIdentityExist(SecurityRealm realm, Evidence evidence) throws RealmUnavailableException { - RealmIdentity identity = realm.getRealmIdentity(evidence); - assertNotNull(identity); - assertTrue(identity.exists()); - } + SSLContext sslContext = getSSLContext(); - private String createJwt(KeyPair keyPair, int expirationOffset, int notBeforeOffset) throws Exception { - return createJwt(keyPair, expirationOffset, notBeforeOffset, null, null); - } + TokenSecurityRealm securityRealm = TokenSecurityRealm.builder() + .principalClaimName("sub") + .validator(JwtValidator.builder() + .issuer("elytron-oauth2-realm") + .audience("my-app-valid") + .setAllowedJkuValues("https://localhost:50832", "https://localhost:50831") + .useSslContext(sslContext) + .useSslHostnameVerifier((a,b) -> true).build()) + .build(); - private String createJwt(KeyPair keyPair, int expirationOffset) throws Exception { - return createJwt(keyPair, expirationOffset, -1); + // token validation should succeed + assertIdentityExist(securityRealm, evidence); } - private String createJwt(KeyPair keyPair, int expirationOffset, int notBeforeOffset, String kid, URI jku) throws Exception { - PrivateKey privateKey = keyPair.getPrivate(); - JWSSigner signer = new RSASSASigner(privateKey); - JsonObjectBuilder claimsBuilder = createClaims(expirationOffset, notBeforeOffset); + @Test + public void testTokenWithJkuValueNotAllowed() throws Exception { + BearerTokenEvidence evidence = new BearerTokenEvidence( + createJwt(keyPair1, 60, -1, "1", new URI("https://localhost:50834"))); - JWSHeader.Builder headerBuilder = new JWSHeader.Builder(JWSAlgorithm.RS256) - .type(new JOSEObjectType("jwt")); + SSLContext sslContext = getSSLContext(); - if (jku != null) { - headerBuilder.jwkURL(jku); - } - if (kid != null) { - headerBuilder.keyID(kid); - } + TokenSecurityRealm securityRealm = TokenSecurityRealm.builder() + .principalClaimName("sub") + .validator(JwtValidator.builder() + .issuer("elytron-oauth2-realm") + .audience("my-app-valid") + .setAllowedJkuValues("https://localhost:50832", "https://localhost:50831") + .useSslContext(sslContext) + .useSslHostnameVerifier((a,b) -> true).build()) + .build(); - JWSObject jwsObject = new JWSObject(headerBuilder.build(), new Payload(claimsBuilder.build().toString())); + // token validation should fail + assertIdentityNotExist(securityRealm, evidence); + } - jwsObject.sign(signer); + @Test + public void testAllowedJkuValuesNotConfigured() throws Exception { + BearerTokenEvidence evidence = new BearerTokenEvidence( + createJwt(keyPair1, 60, -1, "1", new URI("https://localhost:50831"))); - return jwsObject.serialize(); - } + SSLContext sslContext = getSSLContext(); - private String createJwt(KeyPair keyPair) throws Exception { - return createJwt(keyPair, 60); + TokenSecurityRealm securityRealm = TokenSecurityRealm.builder() + .principalClaimName("sub") + .validator(JwtValidator.builder() + .issuer("elytron-oauth2-realm") + .audience("my-app-valid") + .useSslContext(sslContext) + .useSslHostnameVerifier((a,b) -> true).build()) + .build(); + + // token validation should fail + assertIdentityNotExist(securityRealm, evidence); } - private JsonObjectBuilder createClaims(int expirationOffset, int notBeforeOffset) { - return createClaims(expirationOffset, notBeforeOffset, null); + @Test + public void testTokenWithoutJkuValue() throws Exception { + BearerTokenEvidence evidence1 = new BearerTokenEvidence( + createJwt(keyPair1, 60, -1, "1", null)); + BearerTokenEvidence evidence2 = new BearerTokenEvidence( + createJwt(keyPair2, 60, -1, "2", null)); + + Map namedKeys = new LinkedHashMap<>(); + namedKeys.put("1", keyPair1.getPublic()); + namedKeys.put("2", keyPair2.getPublic()); + + TokenSecurityRealm securityRealm = TokenSecurityRealm.builder() + .principalClaimName("sub") + .validator(JwtValidator.builder() + .issuer("elytron-oauth2-realm") + .audience("my-app-valid") + .setAllowedJkuValues("https://localhost:50832", "https://localhost:50831") + .publicKeys(namedKeys) + .build()) + .build(); + + // token validation should succeed + assertIdentityExist(securityRealm, evidence1); + assertIdentityExist(securityRealm, evidence2); } - private JsonObjectBuilder createClaims(int expirationOffset, int notBeforeOffset, JsonObject additionalClaims) { - JsonObjectBuilder claimsBuilder = Json.createObjectBuilder() - .add("active", true) - .add("sub", "elytron@jboss.org") - .add("iss", "elytron-oauth2-realm") - .add("aud", Json.createArrayBuilder().add("my-app-valid").add("third-app-valid").add("another-app-valid").build()) - .add("exp", (System.currentTimeMillis() / 1000) + expirationOffset); - if (additionalClaims != null) { - for(String name : additionalClaims.keySet()) { - JsonValue value = additionalClaims.get(name); - claimsBuilder.add(name, value); - } - } - if (notBeforeOffset > 0) { - claimsBuilder.add("nbf", (System.currentTimeMillis() / 1000) + notBeforeOffset); - } + private void assertIdentityNotExist(SecurityRealm realm, Evidence evidence) throws RealmUnavailableException { + RealmIdentity identity = realm.getRealmIdentity(evidence); + assertNotNull(identity); + assertFalse(identity.exists()); + } - return claimsBuilder; + private void assertIdentityExist(SecurityRealm realm, Evidence evidence) throws RealmUnavailableException { + RealmIdentity identity = realm.getRealmIdentity(evidence); + assertNotNull(identity); + assertTrue(identity.exists()); } private X509TrustManager getTrustManager() throws Exception { @@ -764,18 +811,14 @@ private X509TrustManager getTrustManager() throws Exception { return tm; } - private static JsonObject jwksToJson(RsaJwk... jwks) { - JsonArrayBuilder jab = Json.createArrayBuilder(); - for (int i = 0; i < jwks.length; i++){ - JsonObjectBuilder jwk = Json.createObjectBuilder() - .add("kty", jwks[i].getKty()) - .add("alg", jwks[i].getAlg()) - .add("kid", jwks[i].getKid()) - .add("n", jwks[i].getN()) - .add("e", jwks[i].getE()); - jab.add(jwk); - } - return Json.createObjectBuilder().add("keys", jab).build(); + private SSLContext getSSLContext() throws Exception { + X509TrustManager trustManager = getTrustManager(); + return new SSLContextBuilder() + .setTrustManager(trustManager) + .setClientMode(true) + .setSessionTimeout(10) + .build() + .create(); } private static Dispatcher createOneTimeDispatcher(String response) { @@ -793,15 +836,6 @@ public MockResponse dispatch(RecordedRequest recordedRequest) { }; } - private static Dispatcher createTokenDispatcher(String response) { - return new Dispatcher() { - @Override - public MockResponse dispatch(RecordedRequest recordedRequest) { - return new MockResponse().setBody(response); - } - }; - } - private void checkIdentityDoesNotExist(String kid, int port) throws Exception { BearerTokenEvidence evidence = new BearerTokenEvidence(createJwt(keyPair1, 60, -1, kid, new URI("https://localhost:" + port))); 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 fc65b959925..fd4513e18b1 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 @@ -152,6 +152,7 @@ protected static class TestingHttpServerRequest implements HttpServerRequest { private String requestMethod = "GET"; private Map> requestHeaders = new HashMap<>(); private X500Principal testPrincipal = null; + private Map sessionScopeAttachments = new HashMap<>(); public TestingHttpServerRequest(String[] authorization) { if (authorization != null) { @@ -179,6 +180,16 @@ public TestingHttpServerRequest(String[] authorization, URI requestURI) { this.cookies = new ArrayList<>(); } + public TestingHttpServerRequest(String[] authorization, URI requestURI, Map sessionScopeAttachments) { + if (authorization != null) { + requestHeaders.put(AUTHORIZATION, Arrays.asList(authorization)); + } + this.remoteUser = null; + this.requestURI = requestURI; + this.cookies = new ArrayList<>(); + this.sessionScopeAttachments = sessionScopeAttachments; + } + public TestingHttpServerRequest(String[] authorization, URI requestURI, List cookies) { if (authorization != null) { requestHeaders.put(AUTHORIZATION, Arrays.asList(authorization)); @@ -348,14 +359,19 @@ public boolean supportsInvalidation() { @Override public void setAttachment(String key, Object value) { - // no-op + if (scope.equals(Scope.SESSION)) { + sessionScopeAttachments.put(key, value); + } } @Override public Object getAttachment(String key) { - return null; + if (scope.equals(Scope.SESSION)) { + return sessionScopeAttachments.get(key); + } else { + return null; + } } - }; } } @@ -376,6 +392,10 @@ public void setRemoteUser(String remoteUser) { public String getRemoteUser() { return remoteUser; } + + public Map getSessionScopeAttachments() { + return sessionScopeAttachments; + } } protected static class TestingHttpServerResponse implements HttpServerResponse { diff --git a/tests/common/pom.xml b/tests/common/pom.xml index 3fa90f00d1a..eb10d1ab665 100644 --- a/tests/common/pom.xml +++ b/tests/common/pom.xml @@ -47,6 +47,22 @@ + + net.minidev + json-smart + test + + + com.nimbusds + nimbus-jose-jwt + test + + + net.minidev + json-smart + + + org.wildfly.security wildfly-elytron-client @@ -57,11 +73,21 @@ junit test + + org.glassfish + jakarta.json + test + org.jmockit jmockit test + + com.squareup.okhttp3 + mockwebserver + test + diff --git a/tests/common/src/test/java/org/wildfly/security/realm/token/test/util/JwtTestUtil.java b/tests/common/src/test/java/org/wildfly/security/realm/token/test/util/JwtTestUtil.java new file mode 100644 index 00000000000..67781984931 --- /dev/null +++ b/tests/common/src/test/java/org/wildfly/security/realm/token/test/util/JwtTestUtil.java @@ -0,0 +1,149 @@ +/* + * Copyright The WildFly Authors + * SPDX-License-Identifier: Apache-2.0 + */ +package org.wildfly.security.realm.token.test.util; + +import com.nimbusds.jose.JOSEObjectType; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.JWSObject; +import com.nimbusds.jose.JWSSigner; +import com.nimbusds.jose.Payload; +import com.nimbusds.jose.crypto.RSASSASigner; + +import jakarta.json.Json; +import jakarta.json.JsonArrayBuilder; +import jakarta.json.JsonObject; +import jakarta.json.JsonObjectBuilder; +import jakarta.json.JsonValue; + +import java.math.BigInteger; +import java.net.URI; +import java.security.KeyPair; +import java.security.PrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.util.Arrays; +import java.util.Base64; + +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.RecordedRequest; + +/** + * A utility class containing common methods for working with token realms. + * + * @author Farah Juma + */ +public final class JwtTestUtil { + + public static JsonObject jwksToJson(RsaJwk... jwks) { + JsonArrayBuilder jab = Json.createArrayBuilder(); + for (int i = 0; i < jwks.length; i++){ + JsonObjectBuilder jwk = Json.createObjectBuilder() + .add("kty", jwks[i].getKty()) + .add("alg", jwks[i].getAlg()) + .add("kid", jwks[i].getKid()) + .add("n", jwks[i].getN()) + .add("e", jwks[i].getE()); + jab.add(jwk); + } + return Json.createObjectBuilder().add("keys", jab).build(); + } + + public static String createJwt(KeyPair keyPair, int expirationOffset, int notBeforeOffset) throws Exception { + return createJwt(keyPair, expirationOffset, notBeforeOffset, null, null); + } + + public static String createJwt(KeyPair keyPair, int expirationOffset) throws Exception { + return createJwt(keyPair, expirationOffset, -1); + } + + public static String createJwt(KeyPair keyPair, int expirationOffset, int notBeforeOffset, String kid, URI jku) throws Exception { + PrivateKey privateKey = keyPair.getPrivate(); + JWSSigner signer = new RSASSASigner(privateKey); + JsonObjectBuilder claimsBuilder = createClaims(expirationOffset, notBeforeOffset); + + JWSHeader.Builder headerBuilder = new JWSHeader.Builder(JWSAlgorithm.RS256) + .type(new JOSEObjectType("jwt")); + + if (jku != null) { + headerBuilder.jwkURL(jku); + } + if (kid != null) { + headerBuilder.keyID(kid); + } + + JWSObject jwsObject = new JWSObject(headerBuilder.build(), new Payload(claimsBuilder.build().toString())); + + jwsObject.sign(signer); + + return jwsObject.serialize(); + } + + public static String createJwt(KeyPair keyPair) throws Exception { + return createJwt(keyPair, 60); + } + + public static JsonObjectBuilder createClaims(int expirationOffset, int notBeforeOffset) { + return createClaims(expirationOffset, notBeforeOffset, null); + } + public static JsonObjectBuilder createClaims(int expirationOffset, int notBeforeOffset, JsonObject additionalClaims) { + JsonObjectBuilder claimsBuilder = Json.createObjectBuilder() + .add("active", true) + .add("sub", "elytron@jboss.org") + .add("iss", "elytron-oauth2-realm") + .add("aud", Json.createArrayBuilder().add("my-app-valid").add("third-app-valid").add("another-app-valid").build()) + .add("exp", (System.currentTimeMillis() / 1000) + expirationOffset); + + if (additionalClaims != null) { + for(String name : additionalClaims.keySet()) { + JsonValue value = additionalClaims.get(name); + claimsBuilder.add(name, value); + } + } + if (notBeforeOffset > 0) { + claimsBuilder.add("nbf", (System.currentTimeMillis() / 1000) + notBeforeOffset); + } + + return claimsBuilder; + } + + public static RsaJwk createRsaJwk(KeyPair keyPair, String kid) { + RSAPublicKey pk = (RSAPublicKey) keyPair.getPublic(); + RsaJwk jwk = new RsaJwk(); + + jwk.setAlg("RS256"); + jwk.setKid(kid); + jwk.setKty("RSA"); + jwk.setE(Base64.getUrlEncoder().withoutPadding().encodeToString(toBase64urlUInt(pk.getPublicExponent()))); + jwk.setN(Base64.getUrlEncoder().withoutPadding().encodeToString(toBase64urlUInt(pk.getModulus()))); + + return jwk; + } + + public static Dispatcher createTokenDispatcher(String response) { + return new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest recordedRequest) { + return new MockResponse().setBody(response); + } + }; + } + + // rfc7518 dictates the use of Base64urlUInt for "n" and "e" and it explicitly mentions that the + // minimum number of octets should be used and the 0 leading sign byte should not be included + private static byte[] toBase64urlUInt(final BigInteger bigInt) { + byte[] bytes = bigInt.toByteArray(); + int i = 0; + while (i < bytes.length && bytes[i] == 0) { + i++; + } + if (i > 0 && i < bytes.length) { + return Arrays.copyOfRange(bytes, i, bytes.length); + } else { + return bytes; + } + } + +} diff --git a/tests/base/src/test/java/org/wildfly/security/auth/realm/token/RsaJwk.java b/tests/common/src/test/java/org/wildfly/security/realm/token/test/util/RsaJwk.java similarity index 96% rename from tests/base/src/test/java/org/wildfly/security/auth/realm/token/RsaJwk.java rename to tests/common/src/test/java/org/wildfly/security/realm/token/test/util/RsaJwk.java index 4047eb29c2e..f7a7a69f948 100644 --- a/tests/base/src/test/java/org/wildfly/security/auth/realm/token/RsaJwk.java +++ b/tests/common/src/test/java/org/wildfly/security/realm/token/test/util/RsaJwk.java @@ -15,7 +15,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.wildfly.security.auth.realm.token; +package org.wildfly.security.realm.token.test.util; public class RsaJwk { private String kty;