diff --git a/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcClientConfigurationBuilder.java b/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcClientConfigurationBuilder.java index 99f9b185a5..f2d757e493 100644 --- a/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcClientConfigurationBuilder.java +++ b/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcClientConfigurationBuilder.java @@ -100,6 +100,9 @@ protected OidcClientConfiguration internalBuild(final OidcJsonConfiguration oidc if (oidcJsonConfiguration.getTokenCookiePath() != null) { oidcClientConfiguration.setOidcStateCookiePath(oidcJsonConfiguration.getTokenCookiePath()); } + if (oidcJsonConfiguration.getScope() != null) { + oidcClientConfiguration.setScope(oidcJsonConfiguration.getScope()); + } if (oidcJsonConfiguration.getPrincipalAttribute() != null) oidcClientConfiguration.setPrincipalAttribute(oidcJsonConfiguration.getPrincipalAttribute()); oidcClientConfiguration.setResourceCredentials(oidcJsonConfiguration.getCredentials()); diff --git a/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcJsonConfiguration.java b/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcJsonConfiguration.java index 5e65d60fe0..f835cc4fbc 100644 --- a/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcJsonConfiguration.java +++ b/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcJsonConfiguration.java @@ -46,7 +46,7 @@ "register-node-at-startup", "register-node-period", "token-store", "adapter-state-cookie-path", "principal-attribute", "proxy-url", "turn-off-change-session-id-on-login", "token-minimum-time-to-live", "min-time-between-jwks-requests", "public-key-cache-ttl", - "ignore-oauth-query-parameter", "verify-token-audience", "token-signature-algorithm" + "ignore-oauth-query-parameter", "verify-token-audience", "token-signature-algorithm", "scope" }) public class OidcJsonConfiguration { @@ -140,6 +140,9 @@ public class OidcJsonConfiguration { @JsonProperty("token-signature-algorithm") protected String tokenSignatureAlgorithm = DEFAULT_TOKEN_SIGNATURE_ALGORITHM; + @JsonProperty("scope") + protected String scope; + /** * The Proxy url to use for requests to the auth-server, configurable via the adapter config property {@code proxy-url}. */ @@ -511,5 +514,12 @@ public void setTokenSignatureAlgorithm(String tokenSignatureAlgorithm) { this.tokenSignatureAlgorithm = tokenSignatureAlgorithm; } + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } } diff --git a/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcRequestAuthenticator.java b/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcRequestAuthenticator.java index 6b51d980d9..dbb3f05687 100644 --- a/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcRequestAuthenticator.java +++ b/http/oidc/src/main/java/org/wildfly/security/http/oidc/OidcRequestAuthenticator.java @@ -45,8 +45,10 @@ import java.net.URL; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import org.apache.http.HttpStatus; import org.apache.http.NameValuePair; @@ -166,10 +168,13 @@ protected String getRedirectUri(String state) { List forwardableQueryParams = Arrays.asList(LOGIN_HINT, DOMAIN_HINT, KC_IDP_HINT, PROMPT, MAX_AGE, UI_LOCALES, SCOPE); List forwardedQueryParams = new ArrayList<>(forwardableQueryParams.size()); + Set allScopes = new HashSet<>(); + addScopes(deployment.getScope(), allScopes); + for (String paramName : forwardableQueryParams) { String paramValue = getQueryParamValue(facade, paramName); if (SCOPE.equals(paramName)) { - paramValue = addOidcScopeIfNeeded(paramValue); + paramValue = combineAndReorderScopes(allScopes, paramValue); } if (paramValue != null && !paramValue.isEmpty()) { forwardedQueryParams.add(new BasicNameValuePair(paramName, paramValue)); @@ -180,6 +185,7 @@ protected String getRedirectUri(String state) { if (deployment.getAuthUrl() == null) { return null; } + URIBuilder redirectUriBuilder = new URIBuilder(deployment.getAuthUrl()) .addParameter(RESPONSE_TYPE, CODE) .addParameter(CLIENT_ID, deployment.getResourceName()) @@ -416,4 +422,24 @@ private static boolean hasScope(String scopeParam, String targetScope) { } return false; } + + private String combineAndReorderScopes(Set allScopes, String paramValue) { + StringBuilder combinedScopes = new StringBuilder(); + addScopes(paramValue, allScopes); + + //some OpenID providers require openid scope to be added in the beginning + combinedScopes.append(OIDC_SCOPE); + for (String scope : allScopes) { + if (!scope.equals(OIDC_SCOPE)) { + combinedScopes.append(" ").append(scope); + } + } + return combinedScopes.toString(); + } + + private void addScopes(String scopes, Set allScopes) { + if (scopes != null && !scopes.isEmpty()) { + allScopes.addAll(Arrays.asList(scopes.split("\\s+"))); + } + } } 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 5dfa052ed2..bbe6e091e5 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 @@ -33,6 +33,8 @@ import io.restassured.RestAssured; +import static org.wildfly.security.http.oidc.Oidc.OIDC_SCOPE; + /** * Keycloak configuration for testing. * @@ -47,6 +49,7 @@ public class KeycloakConfiguration { private static final String BOB = "bob"; private static final String BOB_PASSWORD = "bob123+"; public static final String ALLOWED_ORIGIN = "http://somehost"; + public static final boolean EMAIL_VERIFIED = false; /** * Configure RealmRepresentation as follows: @@ -60,8 +63,8 @@ public class KeycloakConfiguration { * */ public static RealmRepresentation getRealmRepresentation(final String realmName, String clientId, String clientSecret, - String clientHostName, int clientPort, String clientApp) { - return createRealm(realmName, clientId, clientSecret, clientHostName, clientPort, clientApp); + String clientHostName, int clientPort, String clientApp, boolean configureClientScopes) { + return createRealm(realmName, clientId, clientSecret, clientHostName, clientPort, clientApp, configureClientScopes); } public static RealmRepresentation getRealmRepresentation(final String realmName, String clientId, String clientSecret, @@ -101,15 +104,22 @@ public static String getAccessToken(String authServerUrl, String realmName, Stri .as(AccessTokenResponse.class).getToken(); } + private static RealmRepresentation createRealm(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, false); + } + 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); + String clientHostName, int clientPort, String clientApp, boolean configureClientScopes) { + 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, boolean directAccessGrantEnabled, String bearerOnlyClientId, - String corsClientId) { + String corsClientId, boolean configureClientScopes) { RealmRepresentation realm = new RealmRepresentation(); realm.setRealm(name); @@ -127,8 +137,12 @@ 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, directAccessGrantEnabled)); + ClientRepresentation webAppClient = createWebAppClient(clientId, clientSecret, clientHostName, clientPort, clientApp, directAccessGrantEnabled); + if (configureClientScopes) { + webAppClient.setDefaultClientScopes(Collections.singletonList(OIDC_SCOPE)); + webAppClient.setOptionalClientScopes(Arrays.asList("phone", "email", "profile")); + } + realm.getClients().add(webAppClient); if (bearerOnlyClientId != null) { realm.getClients().add(createBearerOnlyClient(bearerOnlyClientId)); @@ -178,6 +192,7 @@ private static UserRepresentation createUser(String username, String password, L user.setCredentials(new ArrayList<>()); user.setRealmRoles(realmRoles); user.setEmail(username + "@gmail.com"); + user.setEmailVerified(EMAIL_VERIFIED); CredentialRepresentation credential = new CredentialRepresentation(); credential.setType(CredentialRepresentation.PASSWORD); 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 b1fb8ea2d2..65d0da04ba 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,6 +19,7 @@ package org.wildfly.security.http.oidc; import static org.junit.Assert.assertEquals; +import static org.wildfly.common.Assert.assertTrue; import java.io.IOException; import java.net.URI; @@ -29,6 +30,9 @@ import javax.security.auth.callback.UnsupportedCallbackException; import javax.security.sasl.AuthorizeCallback; +import org.jose4j.jwt.JwtClaims; +import org.jose4j.jwt.consumer.InvalidJwtException; +import org.jose4j.jwt.consumer.JwtConsumerBuilder; import org.junit.AfterClass; import org.keycloak.representations.idm.RealmRepresentation; import org.testcontainers.DockerClientFactory; @@ -37,6 +41,8 @@ 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.credential.BearerTokenCredential; +import org.wildfly.security.credential.Credential; import org.wildfly.security.evidence.Evidence; import org.wildfly.security.http.HttpServerAuthenticationMechanism; import org.wildfly.security.http.HttpServerAuthenticationMechanismFactory; @@ -76,6 +82,7 @@ public class OidcBaseTest extends AbstractBaseHttpTest { 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 + public static final Boolean CONFIGURE_CLIENT_SCOPES = true; // to simulate the application being secured protected HttpServerAuthenticationMechanismFactory oidcFactory; @@ -117,8 +124,11 @@ protected static boolean isDockerAvailable() { return false; } } - protected CallbackHandler getCallbackHandler() { + return getCallbackHandler(false, null); + } + + protected CallbackHandler getCallbackHandler(boolean checkScope, String expectedScopes) { return callbacks -> { for(Callback callback : callbacks) { if (callback instanceof EvidenceVerifyCallback) { @@ -127,7 +137,13 @@ protected CallbackHandler getCallbackHandler() { } else if (callback instanceof AuthenticationCompleteCallback) { // NO-OP } else if (callback instanceof IdentityCredentialCallback) { - // NO-OP + if (checkScope) { + try { + checkForScopeClaims(callback, expectedScopes); + } catch (InvalidJwtException e) { + throw new RuntimeException(e); + } + } } else if (callback instanceof AuthorizeCallback) { ((AuthorizeCallback) callback).setAuthorized(true); } else if (callback instanceof SecurityIdentityCallback) { @@ -181,6 +197,7 @@ protected HtmlInput loginToKeycloak(String username, String password, URI reques webClient.addCookie(getCookieString(cookie), requestUri.toURL(), null); } } + HtmlPage keycloakLoginPage = webClient.getPage(location); HtmlForm loginForm = keycloakLoginPage.getForms().get(0); loginForm.getInputByName(KEYCLOAK_USERNAME).setValueAttribute(username); @@ -215,4 +232,18 @@ protected String getCookieString(HttpServerCookie cookie) { return header.toString(); } + protected void checkForScopeClaims(Callback callback, String expectedScopes) throws InvalidJwtException { + Credential credential = ((IdentityCredentialCallback)callback).getCredential(); + String token = ((BearerTokenCredential) credential).getToken(); + JwtClaims jwtClaims = new JwtConsumerBuilder().setSkipSignatureVerification().setSkipAllValidators().build().processToClaims(token); + + if (expectedScopes != null) { + if (expectedScopes.contains("email")) { + assertTrue(jwtClaims.getClaimValueAsString("email_verified").contains(String.valueOf(KeycloakConfiguration.EMAIL_VERIFIED))); + } + if (expectedScopes.contains("profile")) { + assertTrue(jwtClaims.getClaimValueAsString("preferred_username").contains(KeycloakConfiguration.ALICE)); + } + } + } } \ 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 9ce5a55c93..bb41ffe97b 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 @@ -22,6 +22,7 @@ import static org.junit.Assert.assertTrue; import static org.junit.Assume.assumeTrue; import static org.wildfly.security.http.oidc.Oidc.OIDC_NAME; +import static org.wildfly.security.http.oidc.Oidc.OIDC_SCOPE; import java.io.ByteArrayInputStream; import java.io.InputStream; @@ -30,6 +31,7 @@ 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; @@ -55,7 +57,7 @@ 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)); + sendRealmCreationRequest(KeycloakConfiguration.getRealmRepresentation(TEST_REALM, CLIENT_ID, CLIENT_SECRET, CLIENT_HOST_NAME, CLIENT_PORT, CLIENT_APP, CONFIGURE_CLIENT_SCOPES)); client = new MockWebServer(); client.start(CLIENT_PORT); } @@ -161,10 +163,55 @@ public void testTokenSignatureAlgorithm() throws Exception { performAuthentication(getOidcConfigurationInputStreamWithTokenSignatureAlgorithm(), KeycloakConfiguration.ALICE, KeycloakConfiguration.ALICE_PASSWORD, true, HttpStatus.SC_MOVED_TEMPORARILY, getClientUrl(), CLIENT_PAGE_TEXT); } + @Test + public void testInvalidScope() throws Exception { + String expectedScope = OIDC_SCOPE + "+INVALID_SCOPE"; + performAuthentication(getOidcConfigurationInputStreamWithScope("INVALID_SCOPE"), KeycloakConfiguration.ALICE, KeycloakConfiguration.ALICE_PASSWORD, + true, HttpStatus.SC_MOVED_TEMPORARILY, getClientUrl(), "error=invalid_scope", expectedScope, true); + } + + @Test + public void testEmptyScope() throws Exception { + performAuthentication(getOidcConfigurationInputStreamWithScope(""), KeycloakConfiguration.ALICE, KeycloakConfiguration.ALICE_PASSWORD, + true, HttpStatus.SC_MOVED_TEMPORARILY, getClientUrl(), CLIENT_PAGE_TEXT, OIDC_SCOPE, false); + } + + @Test + public void testSingleScopeValue() throws Exception { + String expectedScope = OIDC_SCOPE + "+profile"; + performAuthentication(getOidcConfigurationInputStreamWithScope("profile"), KeycloakConfiguration.ALICE, KeycloakConfiguration.ALICE_PASSWORD, + true, HttpStatus.SC_MOVED_TEMPORARILY, getClientUrl(), CLIENT_PAGE_TEXT, expectedScope, false); + } + + @Test + public void testMultipleScopeValue() throws Exception { + String expectedScope = OIDC_SCOPE + "+phone+profile+email"; + performAuthentication(getOidcConfigurationInputStreamWithScope("email phone profile"), KeycloakConfiguration.ALICE, KeycloakConfiguration.ALICE_PASSWORD, + true, HttpStatus.SC_MOVED_TEMPORARILY, getClientUrl(), CLIENT_PAGE_TEXT, expectedScope, false); + } + + @Test + public void testOpenIDScopeValue() throws Exception { + String expectedScope = OIDC_SCOPE; + performAuthentication(getOidcConfigurationInputStreamWithScope(OIDC_SCOPE), KeycloakConfiguration.ALICE, KeycloakConfiguration.ALICE_PASSWORD, + true, HttpStatus.SC_MOVED_TEMPORARILY, getClientUrl(), CLIENT_PAGE_TEXT, expectedScope, false); + } + + @Test + public void testOpenIDWithMultipleScopeValue() throws Exception { + String expectedScope = OIDC_SCOPE + "+phone+profile+email";//order gets changed when combining with query parameters + performAuthentication(getOidcConfigurationInputStreamWithScope("email phone profile " + OIDC_SCOPE), KeycloakConfiguration.ALICE, KeycloakConfiguration.ALICE_PASSWORD, + true, HttpStatus.SC_MOVED_TEMPORARILY, getClientUrl(), CLIENT_PAGE_TEXT, expectedScope, false); + } // Note: The tests will fail if `localhost` is not listed first in `/etc/hosts` file for the loopback addresses (IPv4 and IPv6). private void performAuthentication(InputStream oidcConfig, String username, String password, boolean loginToKeycloak, int expectedDispatcherStatusCode, String expectedLocation, String clientPageText) throws Exception { + performAuthentication(oidcConfig, username, password, loginToKeycloak, expectedDispatcherStatusCode, expectedLocation, clientPageText, null, false); + } + + private void performAuthentication(InputStream oidcConfig, String username, String password, boolean loginToKeycloak, + int expectedDispatcherStatusCode, String expectedLocation, String clientPageText, String expectedScope, boolean checkInvalidScopeError) throws Exception { try { Map props = new HashMap<>(); OidcClientConfiguration oidcClientConfiguration = OidcClientConfigurationBuilder.build(oidcConfig); @@ -172,7 +219,12 @@ private void performAuthentication(InputStream oidcConfig, String username, Stri OidcClientContext oidcClientContext = new OidcClientContext(oidcClientConfiguration); oidcFactory = new OidcMechanismFactory(oidcClientContext); - HttpServerAuthenticationMechanism mechanism = oidcFactory.createAuthenticationMechanism(OIDC_NAME, props, getCallbackHandler()); + HttpServerAuthenticationMechanism mechanism; + if (expectedScope == null) { + mechanism = oidcFactory.createAuthenticationMechanism(OIDC_NAME, props, getCallbackHandler()); + } else { + mechanism = oidcFactory.createAuthenticationMechanism(OIDC_NAME, props, getCallbackHandler(true, expectedScope)); + } URI requestUri = new URI(getClientUrl()); TestingHttpServerRequest request = new TestingHttpServerRequest(null, requestUri); @@ -180,12 +232,22 @@ private void performAuthentication(InputStream oidcConfig, String username, Stri TestingHttpServerResponse response = request.getResponse(); assertEquals(loginToKeycloak ? HttpStatus.SC_MOVED_TEMPORARILY : HttpStatus.SC_FORBIDDEN, response.getStatusCode()); assertEquals(Status.NO_AUTH, request.getResult()); + if (expectedScope != null) { + assertTrue(response.getFirstResponseHeaderValue("Location").contains("scope=" + expectedScope)); + } 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)); + + if (checkInvalidScopeError) { + WebClient webClient = getWebClient(); + TextPage keycloakLoginPage = webClient.getPage(response.getLocation()); + assertTrue(keycloakLoginPage.getWebResponse().getWebRequest().toString().contains("error_description=Invalid+scopes")); + } else { + TextPage page = loginToKeycloak(username, password, requestUri, response.getLocation(), + response.getCookies()).click(); + assertTrue(page.getContent().contains(clientPageText)); + } } } finally { client.setDispatcher(new QueueDispatcher()); @@ -291,4 +353,18 @@ private InputStream getOidcConfigurationInputStreamWithTokenSignatureAlgorithm() "}"; return new ByteArrayInputStream(oidcConfig.getBytes(StandardCharsets.UTF_8)); } + + private InputStream getOidcConfigurationInputStreamWithScope(String scopeValue){ + String oidcConfig = "{\n" + + " \"client-id\" : \"" + CLIENT_ID + "\",\n" + + " \"provider-url\" : \"" + KEYCLOAK_CONTAINER.getAuthServerUrl() + "/realms/" + TEST_REALM + "/" + "\",\n" + + " \"public-client\" : \"false\",\n" + + " \"scope\" : \"" + scopeValue + "\",\n" + + " \"ssl-required\" : \"EXTERNAL\",\n" + + " \"credentials\" : {\n" + + " \"secret\" : \"" + CLIENT_SECRET + "\"\n" + + " }\n" + + "}"; + return new ByteArrayInputStream(oidcConfig.getBytes(StandardCharsets.UTF_8)); + } }