Skip to content

Commit

Permalink
Allow to customize OIDC verification
Browse files Browse the repository at this point in the history
  • Loading branch information
sberyozkin committed May 22, 2023
1 parent 0929f70 commit 41dff77
Show file tree
Hide file tree
Showing 19 changed files with 371 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
import io.quarkus.oidc.runtime.OidcSessionImpl;
import io.quarkus.oidc.runtime.OidcTokenCredentialProducer;
import io.quarkus.oidc.runtime.TenantConfigBean;
import io.quarkus.oidc.runtime.providers.AzureAccessTokenCustomizer;
import io.quarkus.runtime.TlsConfig;
import io.quarkus.vertx.core.deployment.CoreVertxBuildItem;
import io.quarkus.vertx.http.deployment.SecurityInformationBuildItem;
Expand Down Expand Up @@ -85,7 +86,8 @@ public void additionalBeans(BuildProducer<AdditionalBeanBuildItem> additionalBea
.addBeanClass(DefaultTenantConfigResolver.class)
.addBeanClass(DefaultTokenStateManager.class)
.addBeanClass(OidcSessionImpl.class)
.addBeanClass(BackChannelLogoutHandler.class);
.addBeanClass(BackChannelLogoutHandler.class)
.addBeanClass(AzureAccessTokenCustomizer.class);
additionalBeans.produce(builder.build());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1293,6 +1293,16 @@ public static Token fromAudience(String... audience) {
@ConfigItem(defaultValue = "true")
public boolean allowOpaqueTokenIntrospection = true;

/**
* Token customizer name.
*
* Allows to select a tenant specific token customizer as a named bean.
* Prefer using {@link Tenant} qualifier when registering custom {@link TokenCustomizer}.
* Use this property only to refer to `TokenCustomizer` implementations provided by this extension.
*/
@ConfigItem
public Optional<String> customizerName = Optional.empty();

/**
* Indirectly verify that the opaque (binary) access token is valid by using it to request UserInfo.
* Opaque access token is considered valid if the provider accepted this token and returned a valid UserInfo.
Expand Down Expand Up @@ -1437,6 +1447,14 @@ public Optional<SignatureAlgorithm> getSignatureAlgorithm() {

public void setSignatureAlgorithm(SignatureAlgorithm signatureAlgorithm) {
this.signatureAlgorithm = Optional.of(signatureAlgorithm);
}

public Optional<String> getCustomizerName() {
return customizerName;
}

public void setCustomizerName(String customizerName) {
this.customizerName = Optional.of(customizerName);
}
}

Expand Down
19 changes: 19 additions & 0 deletions extensions/oidc/runtime/src/main/java/io/quarkus/oidc/Tenant.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package io.quarkus.oidc;

import static java.lang.annotation.ElementType.TYPE;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
* Qualifier which can be used to associate one or more OIDC features with a named tenant.
*/
@Target({ TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface Tenant {
/**
* Identifies an OIDC tenant to which a given feature applies.
*/
String value();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package io.quarkus.oidc;

import jakarta.json.JsonObject;

/**
* TokenCustomizer can be used to change token headers to their original value for the token verification to succeed.
*
* Use it only if OIDC provider has changed some of the header values after the token signature has been created
* for security reasons. Changing the headers in all other cases will lead to the token signature verification failure.
*
* Please note that JSON canonicalization is not performed as part of JWT token signing process.
* It means that if OIDC provider adds ignorable characters such as spaces or newline characters to JSON
* which represents token headers then these characters will also be included as an additional input to
* the token signing process. In this case recreating exactly the same JSON token headers sequence after the headers
* have been modified by this customizer will not be possible and the signature verification will fail.
*
* Custom token customizers should be registered and discoverable as CDI beans.
* They should be bound to specific OIDC tenants with a {@link Tenant} qualifier.
* with the exception of named customizers provided by this extension which have to be selected with
* a `quarkus.oidc.token.customizer-name` property.
*
* Custom token customizers without a {@link Tenant} qualifier will be bound to all OIDC tenants.
*/
public interface TokenCustomizer {
/**
* Customize token headers
*
* @param headers the token headers
* @return modified headers, null can be returned to indicate no modification has taken place
*/
JsonObject customizeHeaders(JsonObject headers);
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ protected String getNonNullJsonString() {
return jsonString == null ? json.toString() : jsonString;
}

private static JsonObject toJsonObject(String userInfoJson) {
try (JsonReader jsonReader = Json.createReader(new StringReader(userInfoJson))) {
static JsonObject toJsonObject(String json) {
try (JsonReader jsonReader = Json.createReader(new StringReader(json))) {
return jsonReader.readObject();
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package io.quarkus.oidc.runtime;

import java.io.Closeable;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.time.Duration;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.function.BiFunction;
import java.util.function.Function;

import jakarta.json.JsonObject;

import org.eclipse.microprofile.jwt.Claims;
import org.jboss.logging.Logger;
import org.jose4j.jwa.AlgorithmConstraints;
Expand All @@ -29,6 +33,7 @@
import io.quarkus.oidc.OIDCException;
import io.quarkus.oidc.OidcConfigurationMetadata;
import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.TokenCustomizer;
import io.quarkus.oidc.TokenIntrospection;
import io.quarkus.oidc.UserInfo;
import io.quarkus.oidc.common.runtime.OidcConstants;
Expand Down Expand Up @@ -60,15 +65,22 @@ public class OidcProvider implements Closeable {
final OidcProviderClient client;
final RefreshableVerificationKeyResolver asymmetricKeyResolver;
final OidcTenantConfig oidcConfig;
final TokenCustomizer tokenCustomizer;
final String issuer;
final String[] audience;
final Map<String, String> requiredClaims;
final Key tokenDecryptionKey;
final AlgorithmConstraints requiredAlgorithmConstraints;

public OidcProvider(OidcProviderClient client, OidcTenantConfig oidcConfig, JsonWebKeySet jwks, Key tokenDecryptionKey) {
this(client, oidcConfig, jwks, TokenCustomizerFinder.find(oidcConfig), tokenDecryptionKey);
}

public OidcProvider(OidcProviderClient client, OidcTenantConfig oidcConfig, JsonWebKeySet jwks,
TokenCustomizer tokenCustomizer, Key tokenDecryptionKey) {
this.client = client;
this.oidcConfig = oidcConfig;
this.tokenCustomizer = tokenCustomizer;
this.asymmetricKeyResolver = jwks == null ? null
: new JsonWebKeyResolver(jwks, oidcConfig.token.forcedJwkRefreshInterval);

Expand All @@ -82,6 +94,7 @@ public OidcProvider(OidcProviderClient client, OidcTenantConfig oidcConfig, Json
public OidcProvider(String publicKeyEnc, OidcTenantConfig oidcConfig, Key tokenDecryptionKey) {
this.client = null;
this.oidcConfig = oidcConfig;
this.tokenCustomizer = TokenCustomizerFinder.find(oidcConfig);
this.asymmetricKeyResolver = new LocalPublicKeyResolver(publicKeyEnc);
this.issuer = checkIssuerProp();
this.audience = checkAudienceProp();
Expand Down Expand Up @@ -125,8 +138,8 @@ public TokenVerificationResult verifySelfSignedJwtToken(String token) throws Inv

public TokenVerificationResult verifyJwtToken(String token, boolean enforceAudienceVerification)
throws InvalidJwtException {
return verifyJwtTokenInternal(token, enforceAudienceVerification,
requiredAlgorithmConstraints != null ? requiredAlgorithmConstraints : ASYMMETRIC_ALGORITHM_CONSTRAINTS,
return verifyJwtTokenInternal(customizeJwtToken(token), enforceAudienceVerification,
(requiredAlgorithmConstraints != null ? requiredAlgorithmConstraints : ASYMMETRIC_ALGORITHM_CONSTRAINTS),
asymmetricKeyResolver, true);
}

Expand Down Expand Up @@ -208,6 +221,23 @@ private TokenVerificationResult verifyJwtTokenInternal(String token, boolean enf
return result;
}

private String customizeJwtToken(String token) {
if (tokenCustomizer != null) {
JsonObject headers = AbstractJsonObjectResponse.toJsonObject(
OidcUtils.decodeJwtHeadersAsString(token));
headers = tokenCustomizer.customizeHeaders(headers);
if (headers != null) {
String newHeaders = new String(
Base64.getUrlEncoder().withoutPadding().encode(headers.toString().getBytes()),
StandardCharsets.UTF_8);
int dotIndex = token.indexOf('.');
String newToken = newHeaders + token.substring(dotIndex);
return newToken;
}
}
return token;
}

private void verifyTokenAge(Long iat) throws InvalidJwtException {
if (oidcConfig.token.age.isPresent() && iat != null) {
final long now = now() / 1000;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,11 @@ public static JsonObject decodeJwtHeaders(String jwt) {
return decodeAsJsonObject(tokens.nextToken());
}

public static String decodeJwtHeadersAsString(String jwt) {
StringTokenizer tokens = new StringTokenizer(jwt, ".");
return base64UrlDecode(tokens.nextToken());
}

public static List<String> findRoles(String clientId, OidcTenantConfig.Roles rolesConfig, JsonObject json) {
// If the user configured specific paths - check and enforce the claims at these paths exist
if (rolesConfig.getRoleClaimPath().isPresent()) {
Expand Down Expand Up @@ -444,6 +449,9 @@ static OidcTenantConfig mergeTenantConfig(OidcTenantConfig tenant, OidcTenantCon
if (tenant.token.verifyAccessTokenWithUserInfo.isEmpty()) {
tenant.token.verifyAccessTokenWithUserInfo = provider.token.verifyAccessTokenWithUserInfo;
}
if (tenant.token.customizerName.isEmpty()) {
tenant.token.customizerName = provider.token.customizerName;
}

return tenant;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package io.quarkus.oidc.runtime;

import io.quarkus.arc.Arc;
import io.quarkus.arc.ArcContainer;
import io.quarkus.arc.InstanceHandle;
import io.quarkus.oidc.OIDCException;
import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.Tenant;
import io.quarkus.oidc.TokenCustomizer;

public class TokenCustomizerFinder {

public static TokenCustomizer find(OidcTenantConfig oidcConfig) {
if (oidcConfig == null) {
return null;
}
ArcContainer container = Arc.container();
if (container != null) {
String customizerName = oidcConfig.token.customizerName.orElse(null);
if (customizerName != null && !customizerName.isEmpty()) {
InstanceHandle<TokenCustomizer> tokenCustomizer = container.instance(customizerName);
if (tokenCustomizer.isAvailable()) {
return tokenCustomizer.get();
} else {
throw new OIDCException("Unable to find TokenCustomizer " + customizerName);
}
} else {
for (InstanceHandle<TokenCustomizer> tokenCustomizer : container.listAll(TokenCustomizer.class)) {
Tenant tenantAnn = tokenCustomizer.get().getClass().getAnnotation(Tenant.class);
if (tenantAnn != null && oidcConfig.tenantId.get().equals(tenantAnn.value())) {
return tokenCustomizer.get();
}
}
}
}
return null;
}

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

import java.nio.charset.StandardCharsets;
import java.util.Base64;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Named;
import jakarta.json.Json;
import jakarta.json.JsonObject;

import io.quarkus.oidc.OIDCException;
import io.quarkus.oidc.TokenCustomizer;
import io.quarkus.oidc.runtime.OidcUtils;

@Named("azure-access-token-customizer")
@ApplicationScoped
public class AzureAccessTokenCustomizer implements TokenCustomizer {
private static final String NONCE = "nonce";

@Override
public JsonObject customizeHeaders(JsonObject headers) {
try {
String nonce = headers.getString(NONCE);
if (nonce != null) {
byte[] nonceSha256 = OidcUtils.getSha256Digest(nonce.getBytes(StandardCharsets.UTF_8));
byte[] newNonceBytes = Base64.getUrlEncoder().withoutPadding().encode(nonceSha256);
return Json.createObjectBuilder(headers)
.add(NONCE, new String(newNonceBytes, StandardCharsets.UTF_8)).build();
}
return null;
} catch (Exception ex) {
throw new OIDCException(ex);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ private static OidcTenantConfig microsoft() {
ret.setAuthServerUrl("https://login.microsoftonline.com/common/v2.0");
ret.setApplicationType(OidcTenantConfig.ApplicationType.WEB_APP);
ret.getToken().setIssuer("any");
ret.getToken().setCustomizerName("azure-access-token-customizer");
ret.getAuthentication().setScopes(List.of("openid", "email", "profile"));
return ret;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package io.quarkus.oidc.runtime;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.fail;

import java.nio.charset.StandardCharsets;
import java.util.Base64;

import jakarta.json.Json;
import jakarta.json.JsonObject;

import org.jose4j.jwk.RsaJsonWebKey;
import org.jose4j.jwk.RsaJwkGenerator;
import org.jose4j.jwt.consumer.InvalidJwtException;
import org.junit.jupiter.api.Test;

import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.TokenCustomizer;
import io.smallrye.jwt.build.Jwt;

public class OidcProviderTest {

@SuppressWarnings("resource")
@Test
public void testAlgorithmCustomizer() throws Exception {

RsaJsonWebKey rsaJsonWebKey = RsaJwkGenerator.generateJwk(2048);
rsaJsonWebKey.setKeyId("k1");

final String token = Jwt.issuer("http://keycloak/ream").jws().keyId("k1").sign(rsaJsonWebKey.getPrivateKey());
final String newToken = replaceAlgorithm(token, "ES256");
JsonWebKeySet jwkSet = new JsonWebKeySet("{\"keys\": [" + rsaJsonWebKey.toJson() + "]}");
OidcTenantConfig oidcConfig = new OidcTenantConfig();

OidcProvider provider = new OidcProvider(null, oidcConfig, jwkSet, null, null);
try {
provider.verifyJwtToken(newToken, false);
fail("InvalidJwtException expected");
} catch (InvalidJwtException ex) {
// continue
}

provider = new OidcProvider(null, oidcConfig, jwkSet, new TokenCustomizer() {

@Override
public JsonObject customizeHeaders(JsonObject headers) {
return Json.createObjectBuilder(headers).add("alg", "RS256").build();
}

}, null);
TokenVerificationResult result = provider.verifyJwtToken(newToken, false);
assertEquals("http://keycloak/ream", result.localVerificationResult.getString("iss"));
}

private static String replaceAlgorithm(String token, String algorithm) {
io.vertx.core.json.JsonObject headers = OidcUtils.decodeJwtHeaders(token);
headers.put("alg", algorithm);
String newHeaders = new String(
Base64.getUrlEncoder().withoutPadding().encode(headers.toString().getBytes()),
StandardCharsets.UTF_8);
int dotIndex = token.indexOf('.');
return newHeaders + token.substring(dotIndex);
}

}
Loading

0 comments on commit 41dff77

Please sign in to comment.