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 13, 2023
1 parent ba59762 commit e0bff19
Show file tree
Hide file tree
Showing 9 changed files with 230 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -1249,6 +1249,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.
* If this property is not set then a default `io.quarkus.oidc.TokenCustomizer` bean
* will be used if registered.
*/
@ConfigItem
public Optional<String> tokenCustomizer = 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 @@ -1386,6 +1396,14 @@ public boolean isRequireJwtIntrospectionOnly() {
public void setRequireJwtIntrospectionOnly(boolean requireJwtIntrospectionOnly) {
this.requireJwtIntrospectionOnly = requireJwtIntrospectionOnly;
}

public Optional<String> getTokenCustomizer() {
return tokenCustomizer;
}

public void setTokenCustomizer(String tokenCustomizer) {
this.tokenCustomizer = Optional.of(tokenCustomizer);
}
}

public static enum ApplicationType {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package io.quarkus.oidc;

import jakarta.json.JsonObject;

/**
* TokenCustomizer can be used to change token headers for the token verification to succeed
*/
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 All @@ -55,14 +60,21 @@ 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;

public OidcProvider(OidcProviderClient client, OidcTenantConfig oidcConfig, JsonWebKeySet jwks, Key tokenDecryptionKey) {
this(client, oidcConfig, jwks, findTokenCustomizer(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 @@ -75,13 +87,19 @@ 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.token.getTokenCustomizer().orElse(null));
this.asymmetricKeyResolver = new LocalPublicKeyResolver(publicKeyEnc);
this.issuer = checkIssuerProp();
this.audience = checkAudienceProp();
this.requiredClaims = checkRequiredClaimsProp();
this.tokenDecryptionKey = tokenDecryptionKey;
}

private static TokenCustomizer findTokenCustomizer(OidcTenantConfig config) {

return config == null ? null : TokenCustomizerFinder.find(config.token.getTokenCustomizer().orElse(null));
}

private String checkIssuerProp() {
String issuerProp = null;
if (oidcConfig != null) {
Expand All @@ -107,7 +125,7 @@ public TokenVerificationResult verifySelfSignedJwtToken(String token) throws Inv
}

public TokenVerificationResult verifyJwtToken(String token) throws InvalidJwtException {
return verifyJwtTokenInternal(token, ASYMMETRIC_ALGORITHM_CONSTRAINTS, asymmetricKeyResolver, true);
return verifyJwtTokenInternal(customizeJwtToken(token), ASYMMETRIC_ALGORITHM_CONSTRAINTS, asymmetricKeyResolver, true);
}

public TokenVerificationResult verifyLogoutJwtToken(String token) throws InvalidJwtException {
Expand Down Expand Up @@ -180,6 +198,22 @@ private TokenVerificationResult verifyJwtTokenInternal(String token, AlgorithmCo
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('.');
return newHeaders + token.substring(dotIndex);
}
}
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 @@ -149,6 +149,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
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package io.quarkus.oidc.runtime;

import io.quarkus.arc.Arc;
import io.quarkus.arc.ArcContainer;
import io.quarkus.oidc.OIDCException;
import io.quarkus.oidc.TokenCustomizer;

public class TokenCustomizerFinder {

public static TokenCustomizer find(String name) {
ArcContainer container = Arc.container();
TokenCustomizer tokenCustomizer = null;
if (container != null) {
tokenCustomizer = name != null
? (TokenCustomizer) container.instance(name).get()
: container.instance(TokenCustomizer.class).get();
}
if (tokenCustomizer == null && name != null) {
throw new OIDCException("Unable to find TokenCustomizer " + name);
}

return tokenCustomizer;
}

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

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

import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;

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.deployment.util.FileUtil;
import io.quarkus.oidc.OidcTenantConfig;
import io.quarkus.oidc.TokenCustomizer;
import io.smallrye.jwt.build.Jwt;
import jakarta.json.Json;
import jakarta.json.JsonObject;

public class OidcProviderTest {

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

final String token = readFile("token.txt");
final String jwkSetString = readFile("jwks.json");
JsonWebKeySet jwkSet = new JsonWebKeySet(jwkSetString);
OidcTenantConfig oidcConfig = new OidcTenantConfig();
oidcConfig.token.setLifespanGrace(Integer.MAX_VALUE);

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

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

@Override
public JsonObject customizeHeaders(JsonObject headers) {
try {
String nonce = headers.getString("nonce");
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();
} catch (Exception ex) {
throw new RuntimeException(ex);
}
}

}, null);

TokenVerificationResult result = provider.verifyJwtToken(token);
assertEquals("https://sts.windows.net/e7861267-92c5-4a03-bdb2-2d3e491e7831/",
result.localVerificationResult.getString("iss"));
}

private String readFile(String filePath) throws Exception {
try (InputStream is = Thread.currentThread().getContextClassLoader().getResourceAsStream(filePath)) {
byte[] content = FileUtil.readFileContents(is);
return new String(content, StandardCharsets.UTF_8);
}
}

@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);
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);
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);
}

}
14 changes: 14 additions & 0 deletions extensions/oidc/runtime/src/test/resources/jwks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"keys": [
{
"kty":"RSA",
"use":"sig",
"kid":"-KI3Q9nNR7bRofxmeZoXqbHZGew",
"x5t":"-KI3Q9nNR7bRofxmeZoXqbHZGew",
"n":"tJL6Wr2JUsxLyNezPQh1J6zn6wSoDAhgRYSDkaMuEHy75VikiB8wg25WuR96gdMpookdlRvh7SnRvtjQN9b5m4zJCMpSRcJ5DuXl4mcd7Cg3Zp1C5-JmMq8J7m7OS9HpUQbA1yhtCHqP7XA4UnQI28J-TnGiAa3viPLlq0663Cq6hQw7jYo5yNjdJcV5-FS-xNV7UHR4zAMRruMUHxte1IZJzbJmxjKoEjJwDTtcd6DkI3yrkmYt8GdQmu0YBHTJSZiz-M10CY3LbvLzf-tbBNKQ_gfnGGKF7MvRCmPA_YF_APynrIG7p4vPDRXhpG3_CIt317NyvGoIwiv0At83kQ",
"e":"AQAB",
"x5c":["MIIDBTCCAe2gAwIBAgIQGQ6YG6NleJxJGDRAwAd/ZTANBgkqhkiG9w0BAQsFADAtMSswKQYDVQQDEyJhY2NvdW50cy5hY2Nlc3Njb250cm9sLndpbmRvd3MubmV0MB4XDTIyMTAwMjE4MDY0OVoXDTI3MTAwMjE4MDY0OVowLTErMCkGA1UEAxMiYWNjb3VudHMuYWNjZXNzY29udHJvbC53aW5kb3dzLm5ldDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALSS+lq9iVLMS8jXsz0IdSes5+sEqAwIYEWEg5GjLhB8u+VYpIgfMINuVrkfeoHTKaKJHZUb4e0p0b7Y0DfW+ZuMyQjKUkXCeQ7l5eJnHewoN2adQufiZjKvCe5uzkvR6VEGwNcobQh6j+1wOFJ0CNvCfk5xogGt74jy5atOutwquoUMO42KOcjY3SXFefhUvsTVe1B0eMwDEa7jFB8bXtSGSc2yZsYyqBIycA07XHeg5CN8q5JmLfBnUJrtGAR0yUmYs/jNdAmNy27y83/rWwTSkP4H5xhihezL0QpjwP2BfwD8p6yBu6eLzw0V4aRt/wiLd9ezcrxqCMIr9ALfN5ECAwEAAaMhMB8wHQYDVR0OBBYEFJcSH+6Eaqucndn9DDu7Pym7OA8rMA0GCSqGSIb3DQEBCwUAA4IBAQADKkY0PIyslgWGmRDKpp/5PqzzM9+TNDhXzk6pw8aESWoLPJo90RgTJVf8uIj3YSic89m4ftZdmGFXwHcFC91aFe3PiDgCiteDkeH8KrrpZSve1pcM4SNjxwwmIKlJdrbcaJfWRsSoGFjzbFgOecISiVaJ9ZWpb89/+BeAz1Zpmu8DSyY22dG/K6ZDx5qNFg8pehdOUYY24oMamd4J2u2lUgkCKGBZMQgBZFwk+q7H86B/byGuTDEizLjGPTY/sMms1FAX55xBydxrADAer/pKrOF1v7Dq9C1Z9QVcm5D9G4DcenyWUdMyK43NXbVQLPxLOng51KO9icp2j4U7pwHP"],
"issuer":"https://login.microsoftonline.com/e7861267-92c5-4a03-bdb2-2d3e491e7831/v2.0"
}
]
}
1 change: 1 addition & 0 deletions extensions/oidc/runtime/src/test/resources/token.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
eyJ0eXAiOiJKV1QiLCJub25jZSI6ImM0LXg2c0RzQ2FXeVV2a25leEozekpOVXdfRU56U3FmSjg2dnJrcUs5NTQiLCJhbGciOiJSUzI1NiIsIng1dCI6Ii1LSTNROW5OUjdiUm9meG1lWm9YcWJIWkdldyIsImtpZCI6Ii1LSTNROW5OUjdiUm9meG1lWm9YcWJIWkdldyJ9.eyJhdWQiOiIwMDAwMDAwMy0wMDAwLTAwMDAtYzAwMC0wMDAwMDAwMDAwMDAiLCJpc3MiOiJodHRwczovL3N0cy53aW5kb3dzLm5ldC9lNzg2MTI2Ny05MmM1LTRhMDMtYmRiMi0yZDNlNDkxZTc4MzEvIiwiaWF0IjoxNjgzOTE2MTY1LCJuYmYiOjE2ODM5MTYxNjUsImV4cCI6MTY4MzkyMDgyNywiYWNjdCI6MCwiYWNyIjoiMSIsImFpbyI6IkFUUUF5LzhUQUFBQXJmc0hnb0NoL2o3Rkw0cGdMRGk0QktwTUNFRWZrWlBnQStGR3ltZUNqazIvbnV0clg3NGVuSVdQekRBbmw1ankiLCJhbXIiOlsicHdkIl0sImFwcF9kaXNwbGF5bmFtZSI6InRlc3QiLCJhcHBpZCI6IjRmYmEzODc5LWI1ZmItNDYzMy05ZGUzLTEyZGQ4ZTI2ZmE2NyIsImFwcGlkYWNyIjoiMSIsImlkdHlwIjoidXNlciIsImlwYWRkciI6IjI0MDY6NzQwMDo1MToyODExOjFjN2U6NGRjMjo4MjZiOmZhMzMiLCJuYW1lIjoidGVzdCIsIm9pZCI6ImM2MDMwM2FjLTEwYTQtNGM3OC05NTY0LTUwYTc2MTA0MzBlNSIsInBsYXRmIjoiNSIsInB1aWQiOiIxMDAzMjAwMkExMkQwMjFBIiwicmgiOiIwLkFWQUFaeEtHNThXU0EwcTlzaTAtU1I1NE1RTUFBQUFBQUFBQXdBQUFBQUFBQUFCX0FHay4iLCJzY3AiOiJvcGVuaWQgcHJvZmlsZSBlbWFpbCIsInN1YiI6InA3V1RyMnFqSFdwbUZScWc3NHJVQVhtcHlEVGJjemM2RjY3RVhkd2dDcnciLCJ0ZW5hbnRfcmVnaW9uX3Njb3BlIjoiTkEiLCJ0aWQiOiJlNzg2MTI2Ny05MmM1LTRhMDMtYmRiMi0yZDNlNDkxZTc4MzEiLCJ1bmlxdWVfbmFtZSI6ImpvbmRvZUBxdWFya3Vzb2lkY3Rlc3Qub25taWNyb3NvZnQuY29tIiwidXBuIjoiam9uZG9lQHF1YXJrdXNvaWRjdGVzdC5vbm1pY3Jvc29mdC5jb20iLCJ1dGkiOiJjQ1R1NmtmVlJFR3VBckJnLWpVWEFBIiwidmVyIjoiMS4wIiwid2lkcyI6WyJiNzlmYmY0ZC0zZWY5LTQ2ODktODE0My03NmIxOTRlODU1MDkiXSwieG1zX3N0Ijp7InN1YiI6IkNlSmk2bElucFRQSUtNWkdQRGFsT3NFUkhSZlZjLXdEZDNFZERsOHBEU0EifSwieG1zX3RjZHQiOjE2ODM5MTUyMTR9.pjAwut-ko5xjM-4i-AXG_5fenhRc-Q0QpeboWEEi2Yo8IFEIL-Qc3tcsO0PwmVguayklH9yO6tdIMoKMjSC7wAqKQooNaPxT62UJsBdjGBdTsNUhnJxr8EAn09BILp-tmHk_5P8t3n9t9PWrCRZ--NBpGg_q473OfGM8pfRU5lGDeEgZJ8T0ZgDPJ45qDU5FFZzZE6TougYtcr2ABD95woK6-rHqdXbanHEjp4ZWYpEw2hBm5cFhGQIU3fqEANt2DRJrGu3bv7VYvcn4U-LfOKyIFu6yCZOniREawzFmBwzztjgWw_v7Cs2vJdl8CpQ6edWplinJgybJIE8HGBojWA

0 comments on commit e0bff19

Please sign in to comment.