Skip to content

Commit

Permalink
[ELY-2574] Add the ability to configure scopes with elytron-oidc-client
Browse files Browse the repository at this point in the history
  • Loading branch information
PrarthonaPaul committed Mar 18, 2024
1 parent bf5bcf0 commit f42da1e
Show file tree
Hide file tree
Showing 6 changed files with 157 additions and 8 deletions.
Expand Up @@ -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());
Expand Down
Expand Up @@ -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 {

Expand Down Expand Up @@ -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}.
*/
Expand Down Expand Up @@ -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;
}
}

Expand Up @@ -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;
Expand Down Expand Up @@ -166,10 +168,13 @@ protected String getRedirectUri(String state) {

List<String> forwardableQueryParams = Arrays.asList(LOGIN_HINT, DOMAIN_HINT, KC_IDP_HINT, PROMPT, MAX_AGE, UI_LOCALES, SCOPE);
List<NameValuePair> forwardedQueryParams = new ArrayList<>(forwardableQueryParams.size());
Set<String> allScopes = new HashSet<>();
addScope(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));
Expand All @@ -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())
Expand Down Expand Up @@ -416,4 +422,26 @@ private static boolean hasScope(String scopeParam, String targetScope) {
}
return false;
}

private String combineAndReorderScopes(Set<String> allScopes, String paramValue) {
StringBuilder combinedScopes = new StringBuilder();
addScope(paramValue, allScopes);

if (allScopes.contains(OIDC_SCOPE)) { //some OpenID providers require openid scope to be added in the beginning
allScopes.remove(OIDC_SCOPE);
}
combinedScopes.append(OIDC_SCOPE);
for (String scope : allScopes) {
if (scope != null && !scope.isEmpty()) {
combinedScopes.append(" ").append(scope);
}
}
return combinedScopes.toString();
}

private void addScope(String scopes, Set<String> allScopes) {
if (scopes != null && !scopes.isEmpty()) {
allScopes.addAll(Arrays.asList(scopes.split("\\s+")));
}
}
}
Expand Up @@ -47,6 +47,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:
Expand Down Expand Up @@ -178,6 +179,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);
Expand Down
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -117,8 +123,11 @@ protected static boolean isDockerAvailable() {
return false;
}
}

protected CallbackHandler getCallbackHandler() {
return getCallbackHandler(false);
}

protected CallbackHandler getCallbackHandler(boolean checkScope) {
return callbacks -> {
for(Callback callback : callbacks) {
if (callback instanceof EvidenceVerifyCallback) {
Expand All @@ -127,7 +136,13 @@ protected CallbackHandler getCallbackHandler() {
} else if (callback instanceof AuthenticationCompleteCallback) {
// NO-OP
} else if (callback instanceof IdentityCredentialCallback) {
// NO-OP
if (checkScope) {
try {
checkForScopeClaims(callback);
} catch (InvalidJwtException e) {
throw new RuntimeException(e);
}
}
} else if (callback instanceof AuthorizeCallback) {
((AuthorizeCallback) callback).setAuthorized(true);
} else if (callback instanceof SecurityIdentityCallback) {
Expand Down Expand Up @@ -181,6 +196,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);
Expand Down Expand Up @@ -215,4 +231,18 @@ protected String getCookieString(HttpServerCookie cookie) {
return header.toString();
}

protected void checkForScopeClaims(Callback callback) throws InvalidJwtException {
Credential credential = ((IdentityCredentialCallback)callback).getCredential();
String token = ((BearerTokenCredential) credential).getToken();
JwtClaims jwtClaims = new JwtConsumerBuilder().setSkipSignatureVerification().setSkipAllValidators().build().processToClaims(token);
String scopes = jwtClaims.getClaimValueAsString("scope");
if (scopes != null) {
if (scopes.contains("email")) {
assertTrue(jwtClaims.getClaimValueAsString("email_verified").contains(String.valueOf(KeycloakConfiguration.EMAIL_VERIFIED)));
}
if (scopes.contains("profile")) {
assertTrue(jwtClaims.getClaimValueAsString("preferred_username").contains(KeycloakConfiguration.ALICE));
}
}
}
}
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -161,31 +163,91 @@ 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(CLIENT_SECRET, "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(CLIENT_SECRET, ""), 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(CLIENT_SECRET, "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(CLIENT_SECRET, "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(CLIENT_SECRET, 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(CLIENT_SECRET, "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<String, Object> 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());
HttpServerAuthenticationMechanism mechanism;
if (expectedScope == null) {
mechanism = oidcFactory.createAuthenticationMechanism(OIDC_NAME, props, getCallbackHandler());
} else {
mechanism = oidcFactory.createAuthenticationMechanism(OIDC_NAME, props, getCallbackHandler(true));
}

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 (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());
Expand Down Expand Up @@ -291,4 +353,18 @@ private InputStream getOidcConfigurationInputStreamWithTokenSignatureAlgorithm()
"}";
return new ByteArrayInputStream(oidcConfig.getBytes(StandardCharsets.UTF_8));
}

private InputStream getOidcConfigurationInputStreamWithScope(String clientSecret, 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));
}
}

0 comments on commit f42da1e

Please sign in to comment.