Merge pull request #7086 from sberyozkin/oidc_auth_mechanism

Add a composite OidcAuthenticationMechanism
This commit is contained in:
sberyozkin
2020-02-18 09:15:56 +00:00
committed by GitHub
22 changed files with 286 additions and 130 deletions

View File

@@ -173,6 +173,7 @@
<spring-security.version>5.2.0.RELEASE</spring-security.version>
<spring-boot.version>2.1.10.RELEASE</spring-boot.version>
<mvel2.version>2.4.4.Final</mvel2.version>
<htmlunit.version>2.36.0</htmlunit.version>
<mockito.version>3.2.4</mockito.version>
<jna.version>5.3.1</jna.version>
<antlr.version>4.7.2</antlr.version>
@@ -2111,6 +2112,21 @@
<artifactId>mockito-core</artifactId>
<version>${mockito.version}</version>
</dependency>
<dependency>
<groupId>net.sourceforge.htmlunit</groupId>
<artifactId>htmlunit</artifactId>
<version>${htmlunit.version}</version>
<exclusions>
<exclusion>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpmime</artifactId>
</exclusion>
<exclusion>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.smallrye.reactive</groupId>
<artifactId>smallrye-axle-generator</artifactId>

View File

@@ -12,7 +12,6 @@ import io.quarkus.deployment.builditem.FeatureBuildItem;
import io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerAuthorizer;
import io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerConfig;
import io.quarkus.keycloak.pep.runtime.KeycloakPolicyEnforcerRecorder;
import io.quarkus.oidc.OIDCException;
import io.quarkus.oidc.runtime.OidcBuildTimeConfig;
import io.quarkus.oidc.runtime.OidcConfig;
import io.quarkus.vertx.http.deployment.RequireBodyHandlerBuildItem;
@@ -70,13 +69,10 @@ public class KeycloakPolicyEnforcerBuildStep {
@Record(ExecutionTime.RUNTIME_INIT)
@BuildStep
public void setup(OidcBuildTimeConfig buildTimeConfig, KeycloakPolicyEnforcerConfig keycloakConfig,
OidcConfig runTimeConfig, KeycloakPolicyEnforcerRecorder recorder, BeanContainerBuildItem bc) {
if (!buildTimeConfig.applicationType.equals(OidcBuildTimeConfig.ApplicationType.SERVICE)) {
throw new OIDCException("Application type [" + buildTimeConfig.applicationType + "] not supported");
}
if (keycloakConfig.policyEnforcer.enable) {
recorder.setup(runTimeConfig, keycloakConfig, bc.getValue());
public void setup(OidcBuildTimeConfig oidcBuildTimeConfig, OidcConfig oidcRunTimeConfig,
KeycloakPolicyEnforcerConfig keycloakConfig, KeycloakPolicyEnforcerRecorder recorder, BeanContainerBuildItem bc) {
if (oidcBuildTimeConfig.enabled && keycloakConfig.policyEnforcer.enable) {
recorder.setup(oidcRunTimeConfig, keycloakConfig, bc.getValue());
}
}
}

View File

@@ -1,13 +1,18 @@
package io.quarkus.keycloak.pep.runtime;
import io.quarkus.arc.runtime.BeanContainer;
import io.quarkus.oidc.OIDCException;
import io.quarkus.oidc.runtime.OidcConfig;
import io.quarkus.oidc.runtime.OidcTenantConfig;
import io.quarkus.runtime.annotations.Recorder;
@Recorder
public class KeycloakPolicyEnforcerRecorder {
public void setup(OidcConfig oidcConfig, KeycloakPolicyEnforcerConfig config, BeanContainer beanContainer) {
if (oidcConfig.defaultTenant.applicationType == OidcTenantConfig.ApplicationType.WEB_APP) {
throw new OIDCException("Application type [" + oidcConfig.defaultTenant.applicationType + "] is not supported");
}
beanContainer.instance(KeycloakPolicyEnforcerAuthorizer.class).init(oidcConfig, config);
}
}

View File

@@ -12,9 +12,8 @@ import io.quarkus.deployment.annotations.ExecutionTime;
import io.quarkus.deployment.annotations.Record;
import io.quarkus.deployment.builditem.EnableAllSecurityServicesBuildItem;
import io.quarkus.deployment.builditem.FeatureBuildItem;
import io.quarkus.oidc.runtime.BearerAuthenticationMechanism;
import io.quarkus.oidc.runtime.CodeAuthenticationMechanism;
import io.quarkus.oidc.runtime.DefaultTenantConfigResolver;
import io.quarkus.oidc.runtime.OidcAuthenticationMechanism;
import io.quarkus.oidc.runtime.OidcBuildTimeConfig;
import io.quarkus.oidc.runtime.OidcConfig;
import io.quarkus.oidc.runtime.OidcIdentityProvider;
@@ -53,12 +52,8 @@ public class OidcBuildStep {
public AdditionalBeanBuildItem beans() {
AdditionalBeanBuildItem.Builder beans = AdditionalBeanBuildItem.builder().setUnremovable();
if (OidcBuildTimeConfig.ApplicationType.SERVICE.equals(buildTimeConfig.applicationType)) {
beans.addBeanClass(BearerAuthenticationMechanism.class);
} else if (OidcBuildTimeConfig.ApplicationType.WEB_APP.equals(buildTimeConfig.applicationType)) {
beans.addBeanClass(CodeAuthenticationMechanism.class);
}
return beans.addBeanClass(OidcJsonWebTokenProducer.class)
return beans.addBeanClass(OidcAuthenticationMechanism.class)
.addBeanClass(OidcJsonWebTokenProducer.class)
.addBeanClass(OidcTokenCredentialProducer.class)
.addBeanClass(OidcIdentityProvider.class)
.addBeanClass(DefaultTenantConfigResolver.class).build();

View File

@@ -2,21 +2,12 @@ package io.quarkus.oidc.runtime;
import java.util.concurrent.CompletionStage;
import javax.inject.Inject;
import io.quarkus.security.credential.TokenCredential;
import io.quarkus.security.identity.IdentityProviderManager;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.security.identity.request.TokenAuthenticationRequest;
import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism;
abstract class AbstractOidcAuthenticationMechanism implements HttpAuthenticationMechanism {
protected static final String BEARER = "Bearer";
@Inject
DefaultTenantConfigResolver tenantConfigResolver;
abstract class AbstractOidcAuthenticationMechanism {
protected CompletionStage<SecurityIdentity> authenticate(IdentityProviderManager identityProviderManager,
TokenCredential token) {
return identityProviderManager.authenticate(new TokenAuthenticationRequest(token));

View File

@@ -3,8 +3,6 @@ package io.quarkus.oidc.runtime;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import javax.enterprise.context.ApplicationScoped;
import io.netty.handler.codec.http.HttpResponseStatus;
import io.quarkus.oidc.AccessTokenCredential;
import io.quarkus.security.identity.IdentityProviderManager;
@@ -14,12 +12,13 @@ import io.vertx.core.http.HttpHeaders;
import io.vertx.core.http.HttpServerRequest;
import io.vertx.ext.web.RoutingContext;
@ApplicationScoped
public class BearerAuthenticationMechanism extends AbstractOidcAuthenticationMechanism {
@Override
private static final String BEARER = "Bearer";
public CompletionStage<SecurityIdentity> authenticate(RoutingContext context,
IdentityProviderManager identityProviderManager) {
IdentityProviderManager identityProviderManager,
DefaultTenantConfigResolver resolver) {
String token = extractBearerToken(context);
// if a bearer token is provided try to authenticate
@@ -30,8 +29,7 @@ public class BearerAuthenticationMechanism extends AbstractOidcAuthenticationMec
return CompletableFuture.completedFuture(null);
}
@Override
public CompletionStage<ChallengeData> getChallenge(RoutingContext context) {
public CompletionStage<ChallengeData> getChallenge(RoutingContext context, DefaultTenantConfigResolver resolver) {
String bearerToken = extractBearerToken(context);
if (bearerToken == null) {

View File

@@ -10,8 +10,6 @@ import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.function.Function;
import javax.enterprise.context.ApplicationScoped;
import org.jboss.logging.Logger;
import io.netty.handler.codec.http.HttpResponseStatus;
@@ -33,7 +31,6 @@ import io.vertx.ext.auth.oauth2.AccessToken;
import io.vertx.ext.web.RoutingContext;
import io.vertx.ext.web.impl.CookieImpl;
@ApplicationScoped
public class CodeAuthenticationMechanism extends AbstractOidcAuthenticationMechanism {
private static final Logger LOG = Logger.getLogger(CodeAuthenticationMechanism.class);
@@ -63,9 +60,9 @@ public class CodeAuthenticationMechanism extends AbstractOidcAuthenticationMecha
.build();
}
@Override
public CompletionStage<SecurityIdentity> authenticate(RoutingContext context,
IdentityProviderManager identityProviderManager) {
IdentityProviderManager identityProviderManager,
DefaultTenantConfigResolver resolver) {
Cookie sessionCookie = context.request().getCookie(SESSION_COOKIE_NAME);
// if session already established, try to re-authenticate
@@ -82,18 +79,17 @@ public class CodeAuthenticationMechanism extends AbstractOidcAuthenticationMecha
}
// start a new session by starting the code flow dance
return performCodeFlow(identityProviderManager, context);
return performCodeFlow(identityProviderManager, context, resolver);
}
@Override
public CompletionStage<ChallengeData> getChallenge(RoutingContext context) {
public CompletionStage<ChallengeData> getChallenge(RoutingContext context, DefaultTenantConfigResolver resolver) {
removeCookie(context, SESSION_COOKIE_NAME);
TenantConfigContext configContext = resolver.resolve(context, false);
ChallengeData challenge;
JsonObject params = new JsonObject();
TenantConfigContext configContext = tenantConfigResolver.resolve(context);
// scope
List<Object> scopes = new ArrayList<>();
scopes.add("openid");
@@ -102,13 +98,13 @@ public class CodeAuthenticationMechanism extends AbstractOidcAuthenticationMecha
// redirect_uri
URI absoluteUri = URI.create(context.request().absoluteURI());
String redirectPath = getRedirectPath(context, absoluteUri);
String redirectPath = getRedirectPath(configContext, absoluteUri);
String redirectUriParam = buildRedirectUri(context, absoluteUri, redirectPath);
LOG.debugf("Authentication request redirect_uri parameter: %s", redirectUriParam);
params.put("redirect_uri", redirectUriParam);
// state
params.put("state", generateState(context, absoluteUri, redirectPath));
params.put("state", generateState(context, configContext, absoluteUri, redirectPath));
// extra redirect parameters, see https://openid.net/specs/openid-connect-core-1_0.html#AuthRequests
if (configContext.oidcConfig.authentication.getExtraParams() != null) {
@@ -124,7 +120,9 @@ public class CodeAuthenticationMechanism extends AbstractOidcAuthenticationMecha
}
private CompletionStage<SecurityIdentity> performCodeFlow(IdentityProviderManager identityProviderManager,
RoutingContext context) {
RoutingContext context, DefaultTenantConfigResolver resolver) {
TenantConfigContext configContext = resolver.resolve(context, true);
JsonObject params = new JsonObject();
String code = context.request().getParam("code");
@@ -177,12 +175,12 @@ public class CodeAuthenticationMechanism extends AbstractOidcAuthenticationMecha
}
params.put("code", code);
String redirectPath = getRedirectPath(context, absoluteUri);
String redirectPath = getRedirectPath(configContext, absoluteUri);
String redirectUriParam = buildRedirectUri(context, absoluteUri, redirectPath);
LOG.debugf("Token request redirect_uri parameter: %s", redirectUriParam);
params.put("redirect_uri", redirectUriParam);
tenantConfigResolver.resolve(context).auth.authenticate(params, userAsyncResult -> {
configContext.auth.authenticate(params, userAsyncResult -> {
if (userAsyncResult.failed()) {
cf.completeExceptionally(new AuthenticationFailedException());
} else {
@@ -221,16 +219,17 @@ public class CodeAuthenticationMechanism extends AbstractOidcAuthenticationMecha
result.opaqueRefreshToken(), context));
}
private String getRedirectPath(RoutingContext context, URI absoluteUri) {
Authentication auth = tenantConfigResolver.resolve(context).oidcConfig.getAuthentication();
private String getRedirectPath(TenantConfigContext configContext, URI absoluteUri) {
Authentication auth = configContext.oidcConfig.getAuthentication();
return auth.getRedirectPath().isPresent() ? auth.getRedirectPath().get() : absoluteUri.getRawPath();
}
private String generateState(RoutingContext context, URI absoluteUri, String redirectPath) {
private String generateState(RoutingContext context, TenantConfigContext configContext, URI absoluteUri,
String redirectPath) {
String uuid = UUID.randomUUID().toString();
String cookieValue = uuid;
Authentication auth = tenantConfigResolver.resolve(context).oidcConfig.getAuthentication();
Authentication auth = configContext.oidcConfig.getAuthentication();
if (auth.isRestorePathAfterRedirect() && !redirectPath.equals(absoluteUri.getRawPath())) {
cookieValue += (COOKIE_DELIM + absoluteUri.getRawPath());
}

View File

@@ -3,6 +3,7 @@ package io.quarkus.oidc.runtime;
import java.util.Map;
import java.util.function.Function;
import javax.annotation.PostConstruct;
import javax.enterprise.context.ApplicationScoped;
import javax.enterprise.inject.Instance;
import javax.inject.Inject;
@@ -26,28 +27,33 @@ public class DefaultTenantConfigResolver {
private volatile TenantConfigContext defaultTenant;
private volatile Function<OidcTenantConfig, TenantConfigContext> tenantConfigContextFactory;
TenantConfigContext resolve(RoutingContext context) {
@PostConstruct
public void verifyResolvers() {
if (tenantConfigResolver.isAmbiguous()) {
throw new IllegalStateException("Multiple " + TenantConfigResolver.class + " beans registered");
}
TenantConfigContext config = getTenantConfigFromConfigResolver(context, true);
if (config != null) {
return config;
}
String tenant = null;
if (tenantResolver.isAmbiguous()) {
throw new IllegalStateException("Multiple " + TenantResolver.class + " beans registered");
}
}
if (tenantResolver.isResolvable()) {
tenant = tenantResolver.get().resolve(context);
/**
* Resolve {@linkplain TenantConfigContext} which contains the tenant configuration and
* the active OIDC connection instance which may be null.
*
* @param context the current request context
* @param create if true then the OIDC connection must be available or established
* for the resolution to be successful
* @return
*/
TenantConfigContext resolve(RoutingContext context, boolean create) {
TenantConfigContext config = getTenantConfigFromConfigResolver(context, create);
if (config == null) {
config = getTenantConfigFromTenantResolver(context);
}
return tenantsConfig.getOrDefault(tenant, defaultTenant);
return config;
}
void setTenantsConfig(Map<String, TenantConfigContext> tenantsConfig) {
@@ -62,6 +68,16 @@ public class DefaultTenantConfigResolver {
this.tenantConfigContextFactory = tenantConfigContextFactory;
}
private TenantConfigContext getTenantConfigFromTenantResolver(RoutingContext context) {
String tenant = null;
if (tenantResolver.isResolvable()) {
tenant = tenantResolver.get().resolve(context);
}
return tenantsConfig.getOrDefault(tenant, defaultTenant);
}
boolean isBlocking(RoutingContext context) {
return getTenantConfigFromConfigResolver(context, false) == null;
}
@@ -78,8 +94,8 @@ public class DefaultTenantConfigResolver {
}
if (tenantConfig != null) {
String tenantId = tenantConfig.getClientId()
.orElseThrow(() -> new IllegalStateException("You must provide a client_id"));
String tenantId = tenantConfig.getTenantId()
.orElseThrow(() -> new IllegalStateException("You must provide a tenant id"));
TenantConfigContext tenantContext = tenantsConfig.get(tenantId);
if (tenantContext == null && create) {

View File

@@ -0,0 +1,39 @@
package io.quarkus.oidc.runtime;
import java.util.concurrent.CompletionStage;
import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import io.quarkus.security.identity.IdentityProviderManager;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.vertx.http.runtime.security.ChallengeData;
import io.quarkus.vertx.http.runtime.security.HttpAuthenticationMechanism;
import io.vertx.ext.web.RoutingContext;
@ApplicationScoped
public class OidcAuthenticationMechanism implements HttpAuthenticationMechanism {
@Inject
DefaultTenantConfigResolver resolver;
private BearerAuthenticationMechanism bearerAuth = new BearerAuthenticationMechanism();
private CodeAuthenticationMechanism codeAuth = new CodeAuthenticationMechanism();
@Override
public CompletionStage<SecurityIdentity> authenticate(RoutingContext context,
IdentityProviderManager identityProviderManager) {
return isWebApp(context) ? codeAuth.authenticate(context, identityProviderManager, resolver)
: bearerAuth.authenticate(context, identityProviderManager, resolver);
}
@Override
public CompletionStage<ChallengeData> getChallenge(RoutingContext context) {
return isWebApp(context) ? codeAuth.getChallenge(context, resolver)
: bearerAuth.getChallenge(context, resolver);
}
private boolean isWebApp(RoutingContext context) {
return OidcTenantConfig.ApplicationType.WEB_APP == resolver.resolve(context, false).oidcConfig.applicationType;
}
}

View File

@@ -13,26 +13,4 @@ public class OidcBuildTimeConfig {
*/
@ConfigItem(defaultValue = "true")
public boolean enabled;
/**
* The application type, which can be one of the following values from enum {@link ApplicationType}.
*/
@ConfigItem(defaultValue = "service")
public ApplicationType applicationType;
public enum ApplicationType {
/**
* A {@code WEB_APP} is a client that server pages, usually a frontend application. For this type of client the
* Authorization Code Flow is
* defined as the preferred method for authenticating users.
*/
WEB_APP,
/**
* A {@code SERVICE} is a client that has a set of protected HTTP resources, usually a backend application following the
* RESTful Architectural Design. For this type of client, the Bearer Authorization method is defined as the preferred
* method for authenticating and authorizing users.
*/
SERVICE
}
}

View File

@@ -27,7 +27,6 @@ import io.vertx.ext.web.RoutingContext;
@ApplicationScoped
public class OidcIdentityProvider implements IdentityProvider<TokenAuthenticationRequest> {
@Inject
DefaultTenantConfigResolver tenantResolver;
@@ -36,7 +35,6 @@ public class OidcIdentityProvider implements IdentityProvider<TokenAuthenticatio
return TokenAuthenticationRequest.class;
}
@SuppressWarnings("deprecation")
@Override
public CompletionStage<SecurityIdentity> authenticate(TokenAuthenticationRequest request,
AuthenticationRequestContext context) {
@@ -55,10 +53,11 @@ public class OidcIdentityProvider implements IdentityProvider<TokenAuthenticatio
return authenticate(request, vertxContext);
}
@SuppressWarnings("deprecation")
private CompletableFuture<SecurityIdentity> authenticate(TokenAuthenticationRequest request,
RoutingContext vertxContext) {
CompletableFuture<SecurityIdentity> result = new CompletableFuture<>();
TenantConfigContext resolvedContext = tenantResolver.resolve(vertxContext);
TenantConfigContext resolvedContext = tenantResolver.resolve(vertxContext, true);
OidcTenantConfig config = resolvedContext.oidcConfig;
resolvedContext.auth.decodeToken(request.getToken().getToken(),
@@ -66,7 +65,7 @@ public class OidcIdentityProvider implements IdentityProvider<TokenAuthenticatio
@Override
public void handle(AsyncResult<AccessToken> event) {
if (event.failed()) {
result.completeExceptionally(new AuthenticationFailedException());
result.completeExceptionally(new AuthenticationFailedException(event.cause()));
return;
}
AccessToken token = event.result();

View File

@@ -15,6 +15,7 @@ import org.jose4j.jwt.consumer.JwtConsumerBuilder;
import io.quarkus.oidc.AccessTokenCredential;
import io.quarkus.oidc.IdToken;
import io.quarkus.oidc.IdTokenCredential;
import io.quarkus.oidc.OIDCException;
import io.quarkus.security.credential.TokenCredential;
import io.quarkus.security.identity.SecurityIdentity;
import io.smallrye.jwt.auth.cdi.NullJsonWebToken;
@@ -67,11 +68,12 @@ public class OidcJsonWebTokenProducer {
.setSkipAllValidators()
.build().processToClaims(credential.getToken());
} catch (InvalidJwtException e) {
throw new RuntimeException(e);
throw new OIDCException(e);
}
jwtClaims.setClaim(Claims.raw_token.name(), credential.getToken());
return new OidcJwtCallerPrincipal(jwtClaims, credential);
}
throw new IllegalStateException("Current identity not associated with an access token");
String tokenType = type == AccessTokenCredential.class ? "access" : "ID";
throw new OIDCException("Current identity is not associated with an " + tokenType + " token");
}
}

View File

@@ -30,6 +30,14 @@ public class OidcRecorder {
Map<String, TenantConfigContext> tenantsConfig = new HashMap<>();
for (Map.Entry<String, OidcTenantConfig> tenant : config.namedTenants.entrySet()) {
if (config.defaultTenant.getTenantId().isPresent()
&& tenant.getKey().equals(config.defaultTenant.getTenantId().get())) {
throw new OIDCException("tenant-id '" + tenant.getKey() + "' duplicates the default tenant-id");
}
if (tenant.getValue().getTenantId().isPresent() && !tenant.getKey().equals(tenant.getValue().getTenantId().get())) {
throw new OIDCException("Configuration has 2 different tenant-id values: '"
+ tenant.getKey() + "' and '" + tenant.getValue().getTenantId().get() + "'");
}
tenantsConfig.put(tenant.getKey(), createTenantContext(vertxValue, tenant.getValue()));
}

View File

@@ -12,6 +12,19 @@ import io.quarkus.runtime.annotations.ConfigItem;
@ConfigGroup
public class OidcTenantConfig {
/**
* A unique tenant identifier. It must be set by {@code TenantConfigResolver} providers which
* resolve the tenant configuration dynamically and is optional in all other cases.
*/
@ConfigItem
Optional<String> tenantId = Optional.empty();
/**
* The application type, which can be one of the following values from enum {@link ApplicationType}.
*/
@ConfigItem(defaultValue = "service")
public ApplicationType applicationType;
/**
* The maximum amount of time the adapter will try connecting to the currently unavailable OIDC server for.
* For example, setting it to '20S' will let the adapter keep requesting the connection for up to 20 seconds.
@@ -147,6 +160,14 @@ public class OidcTenantConfig {
this.authentication = authentication;
}
public Optional<String> getTenantId() {
return tenantId;
}
public void setTenantId(String tenantId) {
this.tenantId = Optional.of(tenantId);
}
@ConfigGroup
public static class Credentials {
@@ -356,4 +377,20 @@ public class OidcTenantConfig {
this.principalClaim = Optional.of(principalClaim);
}
}
public static enum ApplicationType {
/**
* A {@code WEB_APP} is a client that server pages, usually a frontend application. For this type of client the
* Authorization Code Flow is
* defined as the preferred method for authenticating users.
*/
WEB_APP,
/**
* A {@code SERVICE} is a client that has a set of protected HTTP resources, usually a backend application following the
* RESTful Architectural Design. For this type of client, the Bearer Authorization method is defined as the preferred
* method for authenticating and authorizing users.
*/
SERVICE
}
}

View File

@@ -16,7 +16,6 @@
<properties>
<keycloak.url>http://localhost:8180/auth</keycloak.url>
<htmlunit.version>2.36.0</htmlunit.version>
</properties>
<dependencies>
@@ -53,18 +52,7 @@
<dependency>
<groupId>net.sourceforge.htmlunit</groupId>
<artifactId>htmlunit</artifactId>
<version>${htmlunit.version}</version>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpmime</artifactId>
</exclusion>
<exclusion>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>

View File

@@ -16,6 +16,7 @@ quarkus.oidc.tenant-1.credentials.secret=secret
quarkus.oidc.tenant-1.token.issuer=${keycloak.url}/realms/quarkus
quarkus.oidc.tenant-1.authentication.redirect-path=/web-app/callback-after-redirect
quarkus.oidc.tenant-1.authentication.restore-path-after-redirect=false
quarkus.oidc.tenant-1.application-type=web-app
quarkus.http.auth.permission.roles1.paths=/index.html
quarkus.http.auth.permission.roles1.policy=authenticated

View File

@@ -47,6 +47,11 @@
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>net.sourceforge.htmlunit</groupId>
<artifactId>htmlunit</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>

View File

@@ -12,7 +12,8 @@ public class CustomTenantConfigResolver implements TenantConfigResolver {
public OidcTenantConfig resolve(RoutingContext context) {
if ("tenant-d".equals(context.request().path().split("/")[2])) {
OidcTenantConfig config = new OidcTenantConfig();
config.setTenantId("tenant-id");
;
config.setAuthServerUrl(getIssuerUrl() + "/realms/quarkus-d");
config.setClientId("quarkus-d");
OidcTenantConfig.Credentials credentials = new OidcTenantConfig.Credentials();

View File

@@ -1,38 +1,67 @@
package io.quarkus.it.keycloak;
import java.util.HashMap;
import java.util.Map;
import javax.annotation.security.RolesAllowed;
import javax.inject.Inject;
import javax.json.JsonString;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
import org.eclipse.microprofile.jwt.JsonWebToken;
import io.quarkus.oidc.IdToken;
import io.quarkus.oidc.OIDCException;
@Path("/tenant/{tenant}/api/user")
public class TenantResource {
@Inject
JsonWebToken jwt;
JsonWebToken accessToken;
@Inject
@IdToken
JsonWebToken idToken;
@GET
@RolesAllowed("user")
@Produces(MediaType.APPLICATION_JSON)
public Map<String, Object> permissions(@PathParam("tenant") String tenant) {
Map<String, Object> claims = new HashMap<>();
for (String i : jwt.getClaimNames()) {
claims.put(i, fromJsonWebObject(jwt.getClaim(i)));
}
return claims;
public String userName(@PathParam("tenant") String tenant) {
return tenant + ":" + ("tenant-web-app".equals(tenant) ? getNameWebAppType() : getNameServiceType());
}
private Object fromJsonWebObject(Object claim) {
return claim instanceof JsonString ? ((JsonString) claim).getString() : claim != null ? claim.toString() : null;
private String getNameWebAppType() {
if (!"ID".equals(idToken.getClaim("typ"))) {
throw new OIDCException("Wrong ID token type");
}
String name = idToken.getName();
// The test is set up to use 'upn' for the 'web-app' application type
if (!name.equals(idToken.getClaim("upn"))) {
throw new OIDCException("upn claim is missing");
}
// Access token must be available too
if (!"Bearer".equals(accessToken.getClaim("typ"))) {
throw new OIDCException("Wrong access token type");
}
return name;
}
private String getNameServiceType() {
if (!"Bearer".equals(accessToken.getClaim("typ"))) {
throw new OIDCException("Wrong access token type");
}
String name = null;
try {
name = idToken.getName();
} catch (OIDCException ex) {
// expected because an ID token must not be available
}
if (name != null) {
throw new OIDCException("Only the access token can be availabe with the 'service' application type");
}
name = accessToken.getName();
// The test is set up to use 'upn' for the 'web-app' application type
if (!name.equals(accessToken.getClaim("preferred_username"))) {
throw new OIDCException("preferred_username claim is missing");
}
return name;
}
}

View File

@@ -4,15 +4,24 @@ quarkus.http.cors=true
quarkus.oidc.auth-server-url=${keycloak.url}/realms/quarkus-a
quarkus.oidc.client-id=quarkus-app-a
quarkus.oidc.credentials.secret=secret
quarkus.oidc.tenant-web-app.application-type=service
# Tenant B
quarkus.oidc.tenant-b.auth-server-url=${keycloak.url}/realms/quarkus-b
quarkus.oidc.tenant-b.client-id=quarkus-app-b
quarkus.oidc.tenant-b.credentials.secret=secret
quarkus.oidc.tenant-b.token.issuer=${keycloak.url}/realms/quarkus-b
quarkus.oidc.tenant-web-app.application-type=service
# Tenant C
quarkus.oidc.tenant-c.auth-server-url=${keycloak.url}/realms/quarkus-c
quarkus.oidc.tenant-c.client-id=quarkus-app-c
quarkus.oidc.tenant-c.credentials.secret=secret
quarkus.oidc.tenant-c.token.audience=${keycloak.url}/realms/quarkus-c
quarkus.oidc.tenant-web-app.application-type=service
# Tenant Web App
quarkus.oidc.tenant-web-app.auth-server-url=${keycloak.url}/realms/quarkus-webapp
quarkus.oidc.tenant-web-app.client-id=quarkus-app-webapp
quarkus.oidc.tenant-web-app.credentials.secret=secret
quarkus.oidc.tenant-web-app.application-type=web-app

View File

@@ -1,10 +1,20 @@
package io.quarkus.it.keycloak;
import static org.hamcrest.Matchers.equalTo;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import java.io.IOException;
import org.junit.jupiter.api.Test;
import org.keycloak.representations.AccessTokenResponse;
import com.gargoylesoftware.htmlunit.SilentCssErrorHandler;
import com.gargoylesoftware.htmlunit.WebClient;
import com.gargoylesoftware.htmlunit.html.HtmlForm;
import com.gargoylesoftware.htmlunit.html.HtmlPage;
import com.gargoylesoftware.htmlunit.util.Cookie;
import io.quarkus.test.common.QuarkusTestResource;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
@@ -19,13 +29,29 @@ public class BearerTokenAuthorizationTest {
private static final String KEYCLOAK_SERVER_URL = System.getProperty("keycloak.url", "http://localhost:8180/auth");
private static final String KEYCLOAK_REALM = "quarkus-";
@Test
public void testResolveTenantIdentifierWebApp() throws IOException {
try (final WebClient webClient = createWebClient()) {
HtmlPage page = webClient.getPage("http://localhost:8081/tenant/tenant-web-app/api/user");
// State cookie is available but there must be no saved path parameter
// as the tenant-web-app configuration does not set a redirect-path property
assertNull(getStateCookieSavedPath(webClient));
assertEquals("Log in to quarkus-webapp", page.getTitleText());
HtmlForm loginForm = page.getForms().get(0);
loginForm.getInputByName("username").setValueAttribute("alice");
loginForm.getInputByName("password").setValueAttribute("alice");
page = loginForm.getInputByName("login").click();
assertEquals("tenant-web-app:alice", page.getBody().asText());
}
}
@Test
public void testResolveTenantIdentifier() {
RestAssured.given().auth().oauth2(getAccessToken("alice", "b"))
.when().get("/tenant/tenant-b/api/user")
.then()
.statusCode(200)
.body("preferred_username", equalTo("alice"));
.body(equalTo("tenant-b:alice"));
// should give a 403 given that access token from issuer b can not access tenant c
RestAssured.given().auth().oauth2(getAccessToken("alice", "b"))
@@ -40,7 +66,7 @@ public class BearerTokenAuthorizationTest {
.when().get("/tenant/tenant-d/api/user")
.then()
.statusCode(200)
.body("preferred_username", equalTo("alice"));
.body(equalTo("tenant-d:alice"));
// should give a 403 given that access token from issuer b can not access tenant c
RestAssured.given().auth().oauth2(getAccessToken("alice", "b"))
@@ -56,7 +82,7 @@ public class BearerTokenAuthorizationTest {
.when().get("/tenant/tenant-any/api/user")
.then()
.statusCode(200)
.body("preferred_username", equalTo("alice"));
.body(equalTo("tenant-any:alice"));
}
private String getAccessToken(String userName, String clientId) {
@@ -71,4 +97,19 @@ public class BearerTokenAuthorizationTest {
.post(KEYCLOAK_SERVER_URL + "/realms/" + KEYCLOAK_REALM + clientId + "/protocol/openid-connect/token")
.as(AccessTokenResponse.class).getToken();
}
private WebClient createWebClient() {
WebClient webClient = new WebClient();
webClient.setCssErrorHandler(new SilentCssErrorHandler());
return webClient;
}
private Cookie getStateCookie(WebClient webClient) {
return webClient.getCookieManager().getCookie("q_auth");
}
private String getStateCookieSavedPath(WebClient webClient) {
String[] parts = getStateCookie(webClient).getValue().split("___");
return parts.length == 2 ? parts[1] : null;
}
}

View File

@@ -26,7 +26,7 @@ public class KeycloakRealmResourceManager implements QuarkusTestResourceLifecycl
@Override
public Map<String, String> start() {
for (String realmId : Arrays.asList("a", "b", "c", "d")) {
for (String realmId : Arrays.asList("a", "b", "c", "d", "webapp")) {
RealmRepresentation realm = createRealm(KEYCLOAK_REALM + realmId);
realm.getClients().add(createClient("quarkus-app-" + realmId));
@@ -92,7 +92,10 @@ public class KeycloakRealmResourceManager implements QuarkusTestResourceLifecycl
client.setDirectAccessGrantsEnabled(true);
client.setEnabled(true);
client.setDefaultRoles(new String[] { "role-" + clientId });
if ("quarkus-app-webapp".equals(clientId)) {
client.setRedirectUris(Arrays.asList("*"));
client.setDefaultClientScopes(Arrays.asList("microprofile-jwt"));
}
return client;
}
@@ -117,7 +120,7 @@ public class KeycloakRealmResourceManager implements QuarkusTestResourceLifecycl
@Override
public void stop() {
for (String realmId : Arrays.asList("a", "b", "c", "d")) {
for (String realmId : Arrays.asList("a", "b", "c", "d", "webapp")) {
RestAssured
.given()
.auth().oauth2(getAdminAccessToken())