Skip to content

Commit

Permalink
Merge pull request quarkusio#8278 from sberyozkin/client_secret_jwt
Browse files Browse the repository at this point in the history
Support OIDC client_secret_jwt
  • Loading branch information
stuartwdouglas authored Apr 14, 2020
2 parents 084571e + 0aab27a commit 3143018
Show file tree
Hide file tree
Showing 11 changed files with 250 additions and 43 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@
import io.quarkus.arc.deployment.AdditionalBeanBuildItem;
import io.quarkus.arc.deployment.SyntheticBeanBuildItem;
import io.quarkus.deployment.Capabilities;
import io.quarkus.deployment.annotations.BuildProducer;
import io.quarkus.deployment.annotations.BuildStep;
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.deployment.builditem.nativeimage.ReflectiveClassBuildItem;
import io.quarkus.oidc.runtime.DefaultTenantConfigResolver;
import io.quarkus.oidc.runtime.OidcAuthenticationMechanism;
import io.quarkus.oidc.runtime.OidcBuildTimeConfig;
Expand All @@ -27,8 +29,8 @@
import io.smallrye.jwt.auth.cdi.CommonJwtProducer;
import io.smallrye.jwt.auth.cdi.JsonValueProducer;
import io.smallrye.jwt.auth.cdi.RawClaimTypeProducer;
import io.smallrye.jwt.build.impl.JwtProviderImpl;

@SuppressWarnings("deprecation")
public class OidcBuildStep {

OidcBuildTimeConfig buildTimeConfig;
Expand All @@ -52,14 +54,18 @@ AdditionalBeanBuildItem jwtClaimIntegration(Capabilities capabilities) {
}

@BuildStep(onlyIf = IsEnabled.class)
public AdditionalBeanBuildItem beans() {
AdditionalBeanBuildItem.Builder beans = AdditionalBeanBuildItem.builder().setUnremovable();
public void additionalBeans(BuildProducer<AdditionalBeanBuildItem> additionalBeans,
BuildProducer<ReflectiveClassBuildItem> reflectiveClasses) {
AdditionalBeanBuildItem.Builder builder = AdditionalBeanBuildItem.builder().setUnremovable();

return beans.addBeanClass(OidcAuthenticationMechanism.class)
builder.addBeanClass(OidcAuthenticationMechanism.class)
.addBeanClass(OidcJsonWebTokenProducer.class)
.addBeanClass(OidcTokenCredentialProducer.class)
.addBeanClass(OidcIdentityProvider.class)
.addBeanClass(DefaultTenantConfigResolver.class).build();
.addBeanClass(DefaultTenantConfigResolver.class);
additionalBeans.produce(builder.build());

reflectiveClasses.produce(new ReflectiveClassBuildItem(true, true, JwtProviderImpl.class));
}

@BuildStep(onlyIf = IsEnabled.class)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.quarkus.oidc.runtime;

import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.Permission;
import java.util.ArrayList;
import java.util.List;
Expand All @@ -10,6 +11,9 @@
import java.util.function.Consumer;
import java.util.function.Function;

import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;

import org.jboss.logging.Logger;

import io.netty.handler.codec.http.HttpResponseStatus;
Expand All @@ -25,6 +29,7 @@
import io.quarkus.vertx.http.runtime.security.AuthenticationCompletionException;
import io.quarkus.vertx.http.runtime.security.AuthenticationRedirectException;
import io.quarkus.vertx.http.runtime.security.ChallengeData;
import io.smallrye.jwt.build.Jwt;
import io.smallrye.mutiny.Uni;
import io.smallrye.mutiny.subscription.UniEmitter;
import io.vertx.core.http.Cookie;
Expand Down Expand Up @@ -188,9 +193,11 @@ private Uni<SecurityIdentity> performCodeFlow(IdentityProviderManager identityPr

// Client secret has to be posted as a form parameter if OIDC requires the client_secret_post authentication
Credentials creds = configContext.oidcConfig.getCredentials();
if (creds.clientSecret.value.isPresent() && creds.clientSecret.method.isPresent()
&& Secret.Method.POST == creds.clientSecret.method.get()) {
if (creds.clientSecret.value.isPresent() && Secret.Method.POST == creds.clientSecret.method.orElse(null)) {
params.put("client_secret", creds.clientSecret.value.get());
} else if (creds.jwt.secret.isPresent()) {
params.put("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
params.put("client_assertion", signJwtWithClientSecret(configContext.oidcConfig));
}

return Uni.createFrom().emitter(new Consumer<UniEmitter<? super SecurityIdentity>>() {
Expand Down Expand Up @@ -228,6 +235,23 @@ public void accept(Throwable throwable) {
});
}

private String signJwtWithClientSecret(OidcTenantConfig cfg) {
final byte[] keyBytes = cfg.credentials.jwt.secret.get().getBytes(StandardCharsets.UTF_8);
SecretKey key = new SecretKeySpec(keyBytes, 0, keyBytes.length, "HMACSHA256");

// 'jti' claim is created by default.
final long iat = (System.currentTimeMillis() / 1000);
final long exp = iat + cfg.credentials.jwt.lifespan;

return Jwt.claims()
.issuer(cfg.clientId.get())
.subject(cfg.clientId.get())
.audience(cfg.authServerUrl.get())
.issuedAt(iat)
.expiresAt(exp)
.sign(key);
}

private void processSuccessfulAuthentication(RoutingContext context, TenantConfigContext configContext,
UniEmitter<? super SecurityIdentity> cf,
AccessToken result, SecurityIdentity securityIdentity) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,16 +89,7 @@ private TenantConfigContext createTenantContext(Vertx vertx, OidcTenantConfig oi
}

if (oidcConfig.getPublicKey().isPresent()) {
if (oidcConfig.applicationType == ApplicationType.WEB_APP) {
throw new ConfigurationException("'public-key' property can only be used with the 'service' applications");
}
LOG.info("'public-key' property for the local token verification is set,"
+ " no connection to the OIDC server will be created");
options.addPubSecKey(new PubSecKeyOptions()
.setAlgorithm("RS256")
.setPublicKey(oidcConfig.getPublicKey().get()));

return new TenantConfigContext(new OAuth2AuthProviderImpl(vertx, options), oidcConfig);
return createdTenantContextFromPublicKey(options, oidcConfig);
}

if (!oidcConfig.getAuthServerUrl().isPresent() || !oidcConfig.getClientId().isPresent()) {
Expand All @@ -120,20 +111,23 @@ private TenantConfigContext createTenantContext(Vertx vertx, OidcTenantConfig oi
}

Credentials creds = oidcConfig.getCredentials();
if (creds.secret.isPresent() && (creds.clientSecret.value.isPresent() || creds.clientSecret.method.isPresent())) {
if (creds.secret.isPresent() && creds.clientSecret.value.isPresent()) {
throw new ConfigurationException(
"'credentials.secret' and 'credentials.client-secret' properties are mutually exclusive");
}
if ((creds.secret.isPresent() || creds.clientSecret.value.isPresent()) && creds.jwt.secret.isPresent()) {
throw new ConfigurationException(
"Use only 'credentials.secret' or 'credentials.client-secret' or 'credentials.jwt.secret' property");
}

// TODO: The workaround to support client_secret_post is added below and have to be removed once
// it is supported again in VertX OAuth2.
if (creds.secret.isPresent()
|| creds.clientSecret.value.isPresent()
&& creds.clientSecret.method.orElseGet(() -> Secret.Method.BASIC) == Secret.Method.BASIC) {
if (creds.secret.isPresent() || creds.clientSecret.value.isPresent()
&& creds.clientSecret.method.orElseGet(() -> Secret.Method.BASIC) == Secret.Method.BASIC) {
// If it is set for client_secret_post as well then VertX OAuth2 will only use client_secret_basic
options.setClientSecret(creds.secret.orElseGet(() -> creds.clientSecret.value.get()));
} else {
// Avoid the client_secret set in CodeAuthenticationMechanism when client_secret_post is enabled
// from being reset to null in VertX OAuth2
// Avoid VertX OAuth2 setting a null client_secret form parameter if it is client_secret_post or client_secret_jwt
options.setClientSecretParameterName(null);
}

Expand Down Expand Up @@ -195,6 +189,20 @@ public void handle(AsyncResult<OAuth2Auth> event) {
return new TenantConfigContext(auth, oidcConfig);
}

@SuppressWarnings("deprecation")
private TenantConfigContext createdTenantContextFromPublicKey(OAuth2ClientOptions options, OidcTenantConfig oidcConfig) {
if (oidcConfig.applicationType == ApplicationType.WEB_APP) {
throw new ConfigurationException("'public-key' property can only be used with the 'service' applications");
}
LOG.debug("'public-key' property for the local token verification is set,"
+ " no connection to the OIDC server will be created");
options.addPubSecKey(new PubSecKeyOptions()
.setAlgorithm("RS256")
.setPublicKey(oidcConfig.getPublicKey().get()));

return new TenantConfigContext(new OAuth2AuthProviderImpl(null, options), oidcConfig);
}

protected static OIDCException toOidcException(Throwable cause) {
final String message = "OIDC server is not available at the 'quarkus.oidc.auth-server-url' URL. "
+ "Please make sure it is correct. Note it has to end with a realm value if you work with Keycloak, for example:"
Expand All @@ -218,5 +226,4 @@ protected static Optional<ProxyOptions> toProxyOptions(OidcTenantConfig.Proxy pr
}
return Optional.of(new ProxyOptions(jsonOptions));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,14 @@ public enum Verification {
@ConfigItem(defaultValue = "REQUIRED")
Verification verification;

public Verification getVerification() {
return verification;
}

public void setVerification(Verification verification) {
this.verification = verification;
}

}

public Optional<Duration> getConnectionDelay() {
Expand Down Expand Up @@ -220,20 +228,26 @@ public static class Credentials {

/**
* Client secret which is used for a 'client_secret_basic' authentication method.
* Note that a 'client-secret' can be used instead but both properties are mutually exclusive.
* Note that a 'client-secret.value' can be used instead but both properties are mutually exclusive.
*/
@ConfigItem
Optional<String> secret = Optional.empty();

/**
* Client secret credentials which can be used for the 'client_secret_basic' (default)
* and 'client_secret_post' authentication methods.
* Client secret which can be used for the 'client_secret_basic' (default) and 'client_secret_post'
* and 'client_secret_jwt' authentication methods.
* Note that a 'secret.value' property can be used instead to support the 'client_secret_basic' method
* but both properties are mutually exclusive.
*/
@ConfigItem
Secret clientSecret = new Secret();

/**
* Client JWT authentication methods
*/
@ConfigItem
Jwt jwt = new Jwt();

public Optional<String> getSecret() {
return secret;
}
Expand All @@ -250,23 +264,24 @@ public void setClientSecret(Secret clientSecret) {
this.clientSecret = clientSecret;
}

/**
* Supports the client authentication methods which involve sending a client secret.
*
* @see <a href=
* "https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication">https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication</a>
*/
@ConfigGroup
public static class Secret {

/**
* Client secret authentication methods which specify how a client id and client secret
* have to be used to authenticate a client.
*
* @see <a href=
* "https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication">https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication</a>
*/
public static enum Method {
/**
* client_secret_basic (default)
* client_secret_basic (default): client id and secret are submitted with the HTTP Authorization Basic scheme
*/
BASIC,

/**
* client_secret_post
* client_secret_post: client id and secret are submitted as the 'client_id' and 'client_secret' form
* parameters.
*/
POST
}
Expand Down Expand Up @@ -299,6 +314,46 @@ public void setMethod(Method method) {
this.method = Optional.of(method);
}
}

/**
* Supports the client authentication methods which involve sending a signed JWT token.
* Currently only 'client_secret_jwt' is supported
*
* @see <a href=
* "https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication">https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication</a>
*/
@ConfigGroup
public static class Jwt {
/**
* client_secret_jwt: JWT which includes client id as one of its claims is signed by the client secret and is
* submitted as a 'client_assertion' form parameter, while 'client_assertion_type' parameter is set to
* "urn:ietf:params:oauth:client-assertion-type:jwt-bearer".
*/
@ConfigItem
Optional<String> secret = Optional.empty();

/**
* JWT life-span in seconds. It will be added to the time it was issued at to calculate the expiration time.
*/
@ConfigItem(defaultValue = "10")
int lifespan = 10;

public Optional<String> getSecret() {
return secret;
}

public void setSecret(String secret) {
this.secret = Optional.of(secret);
}

public int getLifespan() {
return lifespan;
}

public void setLifespan(int lifespan) {
this.lifespan = lifespan;
}
}
}

@ConfigGroup
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,18 @@

class TenantConfigContext {

/**
* Discovered OIDC
*/
final OAuth2Auth auth;
/**
* Tenant configuration
*/
final OidcTenantConfig oidcConfig;

TenantConfigContext(OAuth2Auth auth, OidcTenantConfig config) {
this.auth = auth;
oidcConfig = config;
this.oidcConfig = config;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import io.smallrye.jwt.auth.cdi.CommonJwtProducer;
import io.smallrye.jwt.auth.cdi.JsonValueProducer;
import io.smallrye.jwt.auth.cdi.RawClaimTypeProducer;
import io.smallrye.jwt.build.impl.JwtProviderImpl;
import io.smallrye.jwt.config.JWTAuthContextInfoProvider;

/**
Expand Down Expand Up @@ -83,6 +84,7 @@ void registerAdditionalBeans(BuildProducer<AdditionalBeanBuildItem> additionalBe
additionalBeans.produce(removable.build());

reflectiveClasses.produce(new ReflectiveClassBuildItem(true, true, SignatureAlgorithm.class));
reflectiveClasses.produce(new ReflectiveClassBuildItem(true, true, JwtProviderImpl.class));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ public String resolve(RoutingContext context) {
}

String path = context.request().path();
return path.contains("callback-") ? "tenant-1" : path.contains("/web-app2") ? "tenant-2" : null;
return path.contains("callback-after-redirect") || path.contains("callback-before-redirect") ? "tenant-1"
: path.contains("callback-jwt-after-redirect") || path.contains("callback-jwt-before-redirect") ? "tenant-jwt"
: path.contains("callback-jwt-not-used-after-redirect")
|| path.contains("callback-jwt-not-used-before-redirect")
? "tenant-jwt-not-used"
: path.contains("/web-app2") ? "tenant-2" : null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,30 @@ public String getNameCallbackAfterRedirect() {
return "callback:" + getName();
}

@GET
@Path("callback-jwt-before-redirect")
public String getNameCallbackJwtBeforeRedirect() {
throw new InternalServerErrorException("This method must not be invoked");
}

@GET
@Path("callback-jwt-after-redirect")
public String getNameCallbackJwtAfterRedirect() {
return "callback-jwt:" + getName();
}

@GET
@Path("callback-jwt-not-used-before-redirect")
public String getNameCallbackJwtNotUsedBeforeRedirect() {
throw new InternalServerErrorException("This method must not be invoked");
}

@GET
@Path("callback-jwt-not-used-after-redirect")
public String getNameCallbackJwtNotUsedAfterRedirect() {
throw new InternalServerErrorException("This method must not be invoked");
}

@GET
@Path("access")
public String getAccessToken() {
Expand Down
Loading

0 comments on commit 3143018

Please sign in to comment.