Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow to customize OIDC verification #33319

Merged
merged 1 commit into from
May 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
sberyozkin marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -1438,6 +1448,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);
}
}

public static enum ApplicationType {
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