Skip to content

Commit

Permalink
Merge pull request quarkusio#4880 from sberyozkin/oidc_role_claims
Browse files Browse the repository at this point in the history
Support for groups, KC and custom role claims
  • Loading branch information
stuartwdouglas authored Oct 28, 2019
2 parents 64eeeb6 + 61ebeb9 commit 4c90448
Show file tree
Hide file tree
Showing 13 changed files with 372 additions and 31 deletions.
10 changes: 5 additions & 5 deletions extensions/oidc/runtime/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,11 @@
<groupId>io.vertx</groupId>
<artifactId>vertx-auth-oauth2</artifactId>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5-internal</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ public class OidcConfig {
@ConfigItem
Optional<String> clientId;

/**
* Configuration to find and parse a custom claim containing the roles information.
*/
@ConfigItem
Roles roles;

/**
* Credentials which the OIDC adapter will use to authenticate to the OIDC server.
*/
Expand All @@ -67,6 +73,10 @@ public Credentials getCredentials() {
return credentials;
}

public Roles getRoles() {
return roles;
}

@ConfigGroup
public static class Credentials {

Expand All @@ -81,4 +91,43 @@ public Optional<String> getSecret() {
}
}

@ConfigGroup
public static class Roles {

/**
* Path to the claim containing an array of groups. It starts from the top level JWT JSON object and
* can contain multiple segments where each segment represents a JSON object name only, example: "realm/groups".
* This property can be used if a token has no 'groups' claim but has the groups set in a different claim.
*/
@ConfigItem
Optional<String> roleClaimPath;

/**
* Separator for splitting a string which may contain multiple group values.
* It will only be used if the "role-claim-path" property points to a custom claim whose value is a string.
* A single space will be used by default because the standard 'scope' claim may contain a space separated sequence.
*/
@ConfigItem
Optional<String> roleClaimSeparator;

public Optional<String> getRoleClaimPath() {
return roleClaimPath;
}

public Optional<String> getRoleClaimSeparator() {
return roleClaimSeparator;
}

public static Roles fromClaimPath(String path) {
return fromClaimPathAndSeparator(path, null);
}

public static Roles fromClaimPathAndSeparator(String path, String sep) {
Roles roles = new Roles();
roles.roleClaimPath = Optional.ofNullable(path);
roles.roleClaimSeparator = Optional.ofNullable(sep);
return roles;
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,13 @@
*/
public class VertxJwtCallerPrincipal extends DefaultJWTCallerPrincipal {
private JwtClaims claims;
private String customPrincipalName;

public VertxJwtCallerPrincipal(final String customPrincipalName, final JwtClaims claims) {
public VertxJwtCallerPrincipal(final JwtClaims claims) {
super(claims);
this.claims = claims;
this.customPrincipalName = customPrincipalName;
}

public JwtClaims getClaims() {
return claims;
}

@Override
public String getName() {
return customPrincipalName != null ? customPrincipalName : super.getName();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ public void handle(AsyncResult<OAuth2Auth> event) {
});

OAuth2Auth auth = cf.join();
beanContainer.instance(VertxOAuth2IdentityProvider.class).setAuth(auth);
VertxOAuth2IdentityProvider identityProvider = beanContainer.instance(VertxOAuth2IdentityProvider.class);
identityProvider.setAuth(auth);
identityProvider.setConfig(config);
VertxOAuth2AuthenticationMechanism mechanism = beanContainer.instance(VertxOAuth2AuthenticationMechanism.class);
mechanism.setAuth(auth);
mechanism.setAuthServerURI(config.authServerUrl);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,26 @@

import javax.enterprise.context.ApplicationScoped;

import org.eclipse.microprofile.jwt.JsonWebToken;
import org.jose4j.jwt.JwtClaims;
import org.jose4j.jwt.consumer.InvalidJwtException;

import io.quarkus.oidc.runtime.OidcUtils;
import io.quarkus.security.identity.AuthenticationRequestContext;
import io.quarkus.security.identity.IdentityProvider;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.security.identity.request.TokenAuthenticationRequest;
import io.quarkus.security.runtime.QuarkusSecurityIdentity;
import io.vertx.core.AsyncResult;
import io.vertx.core.Handler;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.auth.oauth2.AccessToken;
import io.vertx.ext.auth.oauth2.OAuth2Auth;

@ApplicationScoped
public class VertxOAuth2IdentityProvider implements IdentityProvider<TokenAuthenticationRequest> {

private volatile OAuth2Auth auth;
private volatile OidcConfig config;

public OAuth2Auth getAuth() {
return auth;
Expand All @@ -34,11 +35,17 @@ public VertxOAuth2IdentityProvider setAuth(OAuth2Auth auth) {
return this;
}

public VertxOAuth2IdentityProvider setConfig(OidcConfig config) {
this.config = config;
return this;
}

@Override
public Class<TokenAuthenticationRequest> getRequestType() {
return TokenAuthenticationRequest.class;
}

@SuppressWarnings("deprecation")
@Override
public CompletionStage<SecurityIdentity> authenticate(TokenAuthenticationRequest request,
AuthenticationRequestContext context) {
Expand All @@ -53,32 +60,30 @@ public void handle(AsyncResult<AccessToken> event) {
AccessToken token = event.result();
QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder();

//this is not great, we are re-parsing
//this needs some love from someone who knows smallrye JWT better to avoid re-parsing
JsonWebToken jwtPrincipal = null;
try {
JwtClaims jwtClaims = JwtClaims.parse(token.accessToken().encode());

String username = token.principal().getString("username");
builder.setPrincipal(new VertxJwtCallerPrincipal(username, jwtClaims));
jwtPrincipal = new VertxJwtCallerPrincipal(JwtClaims.parse(token.accessToken().encode()));
} catch (InvalidJwtException e) {
result.completeExceptionally(e);
return;
}

JsonObject realmAccess = token.accessToken().getJsonObject("realm_access");
if (realmAccess != null) {
JsonArray roles = realmAccess.getJsonArray("roles");
if (roles != null) {
for (Object authority : roles) {
builder.addRole(authority.toString());
}
builder.setPrincipal(jwtPrincipal);
try {
String clientId = config.getClientId().isPresent() ? config.getClientId().get() : null;
for (String role : OidcUtils.findRoles(clientId, config.getRoles(), token.accessToken())) {
builder.addRole(role);
}
} catch (Exception e) {
result.completeExceptionally(e);
return;
}

builder.addCredential(request.getToken());
result.complete(builder.build());
}
});

return result;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package io.quarkus.oidc.runtime;

import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.stream.Collectors;

import io.quarkus.oidc.OIDCException;
import io.quarkus.oidc.OidcConfig;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;

public final class OidcUtils {
private OidcUtils() {

}

public static List<String> findRoles(String clientId, OidcConfig.Roles rolesConfig, JsonObject json) throws Exception {
// If the user configured a specific path - check and enforce a claim at this path exists
if (rolesConfig.getRoleClaimPath().isPresent()) {
return findClaimWithRoles(rolesConfig, rolesConfig.getRoleClaimPath().get(), json, true);
}

// Check 'groups' next
List<String> groups = findClaimWithRoles(rolesConfig, "groups", json, false);
if (!groups.isEmpty()) {
return groups.stream().map(v -> v.toString()).collect(Collectors.toList());
} else {
// Finally, check if this token has been issued by Keycloak.
// Return an empty or populated list of realm and resource access roles
List<String> allRoles = new LinkedList<>();
allRoles.addAll(findClaimWithRoles(rolesConfig, "realm_access/roles", json, false));
if (clientId != null) {
allRoles.addAll(findClaimWithRoles(rolesConfig, "resource_access/" + clientId + "/roles", json, false));
}

return allRoles;
}

}

private static List<String> findClaimWithRoles(OidcConfig.Roles rolesConfig, String claimPath,
JsonObject json, boolean mustExist) {
Object claimValue = findClaimValue(claimPath, json, claimPath.split("/"), 0, mustExist);

if (claimValue instanceof JsonArray) {
return ((JsonArray) claimValue).stream().map(v -> v.toString()).collect(Collectors.toList());
} else if (claimValue != null) {
String sep = rolesConfig.getRoleClaimSeparator().isPresent() ? rolesConfig.getRoleClaimSeparator().get() : " ";
return Arrays.asList(claimValue.toString().split(sep));
} else {
return Collections.emptyList();
}
}

private static Object findClaimValue(String claimPath, JsonObject json, String[] pathArray, int step, boolean mustExist) {
Object claimValue = json.getValue(pathArray[step]);
if (claimValue == null) {
if (mustExist) {
throw new OIDCException(
"No claim exists at the path " + claimPath + " at the path segment " + pathArray[step]);
}
} else if (step + 1 < pathArray.length) {
if (claimValue instanceof JsonObject) {
int nextStep = step + 1;
return findClaimValue(claimPath, (JsonObject) claimValue, pathArray, nextStep, mustExist);
} else {
throw new OIDCException("Claim value at the path " + claimPath + " is not a json object");
}
}

return claimValue;
}
}
Loading

0 comments on commit 4c90448

Please sign in to comment.