From 58c5e856438c07d79f346b02cbdb68499407bec8 Mon Sep 17 00:00:00 2001 From: Aaron Coburn Date: Wed, 8 Jul 2020 16:52:46 -0400 Subject: [PATCH 1/3] Initial implementation of a DPoP proofing mechanism Resolves #165 This adds the initial structure for handling proof of possession semantics at the application layer. --- .../jwt/auth/AbstractDpopTokenValidator.java | 214 ++++++++++++++++++ .../io/smallrye/jwt/auth/AuthLogging.java | 18 +- .../io/smallrye/jwt/auth/AuthMessages.java | 22 ++ 3 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 implementation/src/main/java/io/smallrye/jwt/auth/AbstractDpopTokenValidator.java create mode 100644 implementation/src/main/java/io/smallrye/jwt/auth/AuthMessages.java diff --git a/implementation/src/main/java/io/smallrye/jwt/auth/AbstractDpopTokenValidator.java b/implementation/src/main/java/io/smallrye/jwt/auth/AbstractDpopTokenValidator.java new file mode 100644 index 00000000..d2a2b9ab --- /dev/null +++ b/implementation/src/main/java/io/smallrye/jwt/auth/AbstractDpopTokenValidator.java @@ -0,0 +1,214 @@ +/* + * Copyright 2020 Red Hat, Inc, and individual contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not + * use this file except in compliance with the License. You may obtain a copy of + * the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under + * the License. + */ +package io.smallrye.jwt.auth; + +import static org.jose4j.jwa.AlgorithmConstraints.ConstraintType.PERMIT; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Map; + +import org.eclipse.microprofile.jwt.Claims; +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.jose4j.jwa.AlgorithmConstraints; +import org.jose4j.jwt.JwtClaims; +import org.jose4j.jwt.consumer.InvalidJwtException; +import org.jose4j.jwt.consumer.JwtConsumer; +import org.jose4j.jwt.consumer.JwtConsumerBuilder; +import org.jose4j.jwt.consumer.Validator; +import org.jose4j.jwx.JsonWebStructure; +import org.jose4j.keys.resolvers.EmbeddedJwkVerificationKeyResolver; +import org.jose4j.lang.HashUtil; +import org.jose4j.lang.JoseException; + +import io.smallrye.jwt.auth.principal.JWTAuthContextInfo; +import io.smallrye.jwt.auth.principal.ParseException; + +/** + * Common functionality for classes implementing DPoP token proofing using + * HTTP request headers and a valid DPoP-bound {@link JsonWebToken}. + * + * @author Aaron Coburn {@literal } + */ +public abstract class AbstractDpopTokenValidator { + + protected static final String DPOP_HEADER = "DPoP"; + protected static final String DPOP_HTTP_URI_CLAIM = "htu"; + protected static final String DPOP_HTTP_METHOD_CLAIM = "htm"; + protected static final String DPOP_JWT_TYPE = "dpop+jwt"; + protected static final String JSON_WEB_KEY_THUMBPRINT = "jkt"; + + private final JWTAuthContextInfo authContextInfo; + + protected AbstractDpopTokenValidator(JWTAuthContextInfo authContextInfo) { + this.authContextInfo = authContextInfo; + } + + public void verify(JsonWebToken accessToken) throws ParseException { + final String dpop = getDpopHeaderValue(); + final String thumbprint = getDpopKeyThumbprint(accessToken); + // DPoP validation is only relevant for access tokens with a bound confirmation key. + if (thumbprint != null) { + if (dpop != null) { + final JwtConsumer parser = new JwtConsumerBuilder() + .setRequireJwtId() + .setExpectedType(true, DPOP_JWT_TYPE) + .setJwsAlgorithmConstraints(new AlgorithmConstraints(PERMIT, + authContextInfo.getKeyEncryptionAlgorithm().getAlgorithm())) + .setVerificationKeyResolver(new EmbeddedJwkVerificationKeyResolver()) + .setRequireIssuedAt().setAllowedClockSkewInSeconds(authContextInfo.getExpGracePeriodSecs()) + .setExpectedIssuer(false, null) + .registerValidator(htuValidator(getRequestUri())) + .registerValidator(htmValidator(getRequestMethod())) + .registerValidator(thumbprintValidator(thumbprint)) + .build(); + try { + parser.process(dpop); + } catch (InvalidJwtException e) { + AuthLogging.log.invalidDpopToken(); + throw AuthMessages.msg.failedToVerifyDpopToken(e); + } + } else { + AuthLogging.log.missingDpopToken(); + throw AuthMessages.msg.missingDpopProof(); + } + } else { + AuthLogging.log.missingDpopKeyBinding(); + throw AuthMessages.msg.missingDpopKeyBinding(); + } + } + + /** + * Retrieve the DPoP-bound key thumbprint from the access token's confirmation claim. + * + * @param accessToken the access token + * @return the thumbprint of the DPoP-bound key, if one exists + */ + protected String getDpopKeyThumbprint(JsonWebToken accessToken) { + final Object cnf = accessToken.getClaim(Claims.cnf.name()); + if (cnf instanceof Map) { + final Object jkt = ((Map) cnf).get(JSON_WEB_KEY_THUMBPRINT); + if (jkt instanceof String) { + return (String) jkt; + } + } + return null; + } + + /** + * Retrieve an HTTP DPoP header value. + * + * @return value of the header + */ + protected abstract String getDpopHeaderValue(); + + /** + * Retrieve the HTTP request URI. + * + * @return the HTTP request URI + */ + protected abstract String getRequestUri(); + + /** + * Retrieve the HTTP method. + * + * @return the HTTP method + */ + protected abstract String getRequestMethod(); + + /** + * Validate the htu (HTTP URI) claim in the DPoP token. + * + * @param uri the HTTP request URI + */ + static Validator htuValidator(String uri) { + return ctx -> { + final JwtClaims claims = ctx.getJwtClaims(); + if (!claims.hasClaim(DPOP_HTTP_URI_CLAIM)) { + return "Missing required htu claim in DPoP token"; + } + if (!compareUrls(uri, claims.getClaimValueAsString(DPOP_HTTP_URI_CLAIM))) { + return "Incorrect htu claim"; + } + return null; + }; + } + + /** + * Validate the htm (HTTP Method) claim in the DPoP token. + * + * @param method the HTTP method + */ + static Validator htmValidator(String method) { + return ctx -> { + final JwtClaims claims = ctx.getJwtClaims(); + if (!claims.hasClaim(DPOP_HTTP_METHOD_CLAIM)) { + return "Missing required htm claim in DPoP token"; + } + + if (!method.equalsIgnoreCase(claims.getClaimValueAsString(DPOP_HTTP_METHOD_CLAIM))) { + return "Incorrect htm claim"; + } + return null; + }; + } + + /** + * Validate that the embedded public key matches the provided thumbprint. + * + * @param thumbprint the thumbprint of the expected public key + */ + static Validator thumbprintValidator(String thumbprint) { + return ctx -> { + try { + for (JsonWebStructure jose : ctx.getJoseObjects()) { + if (thumbprint.equals(jose.getJwkHeader().calculateBase64urlEncodedThumbprint(HashUtil.SHA_256))) { + return null; + } + } + } catch (JoseException ex) { + return "Could not calculate SHA-256 thumbprint of embedded DPoP key: " + ex.getMessage(); + } + return "Mismatched public key thumbprint: " + thumbprint; + }; + } + + /** + * Compare two URLs, ignoring case and any query parameters. + * + * @param url1 the first URL + * @param url2 the second URL + * @return true if the two URLs are equivalent; otherwise, return false + */ + static boolean compareUrls(String url1, String url2) { + if (url1 != null && url2 != null) { + if (url1.equalsIgnoreCase(url2)) { + return true; + } + // If a simple comparison was inconclusive, parse both into URI objects + try { + final URI uri1 = new URI(url1); + final URI uri2 = new URI(url2); + return uri1.getScheme().equalsIgnoreCase(uri2.getScheme()) + && uri1.getAuthority().equalsIgnoreCase(uri2.getAuthority()) + && uri1.getPath().equalsIgnoreCase(uri2.getPath()); + } catch (URISyntaxException ex) { + AuthLogging.log.invalidRequestUrl(ex.getMessage()); + } + } + return false; + } +} diff --git a/implementation/src/main/java/io/smallrye/jwt/auth/AuthLogging.java b/implementation/src/main/java/io/smallrye/jwt/auth/AuthLogging.java index 44677b70..f0376c7f 100644 --- a/implementation/src/main/java/io/smallrye/jwt/auth/AuthLogging.java +++ b/implementation/src/main/java/io/smallrye/jwt/auth/AuthLogging.java @@ -33,4 +33,20 @@ interface AuthLogging extends BasicLogger { @LogMessage(level = Logger.Level.DEBUG) @Message(id = 6005, value = "Authorization header was null") void authHeaderIsNull(); -} \ No newline at end of file + + @LogMessage(level = Logger.Level.DEBUG) + @Message(id = 6006, value = "Invalid DPoP token") + void invalidDpopToken(); + + @LogMessage(level = Logger.Level.DEBUG) + @Message(id = 6007, value = "Missing DPoP token") + void missingDpopToken(); + + @LogMessage(level = Logger.Level.DEBUG) + @Message(id = 6008, value = "Missing DPoP key binding in access token") + void missingDpopKeyBinding(); + + @LogMessage(level = Logger.Level.DEBUG) + @Message(id = 6009, value = "Invalid request URL: %s") + void invalidRequestUrl(String invalidUrlMsg); +} diff --git a/implementation/src/main/java/io/smallrye/jwt/auth/AuthMessages.java b/implementation/src/main/java/io/smallrye/jwt/auth/AuthMessages.java new file mode 100644 index 00000000..9207542b --- /dev/null +++ b/implementation/src/main/java/io/smallrye/jwt/auth/AuthMessages.java @@ -0,0 +1,22 @@ +package io.smallrye.jwt.auth; + +import org.jboss.logging.Messages; +import org.jboss.logging.annotations.Cause; +import org.jboss.logging.annotations.Message; +import org.jboss.logging.annotations.MessageBundle; + +import io.smallrye.jwt.auth.principal.ParseException; + +@MessageBundle(projectCode = "SRJWT", length = 5) +interface AuthMessages { + AuthMessages msg = Messages.getBundle(AuthMessages.class); + + @Message(id = 14000, value = "Failed to verify DPoP token") + ParseException failedToVerifyDpopToken(@Cause Throwable e); + + @Message(id = 14001, value = "Missing DPoP proofing value") + ParseException missingDpopProof(); + + @Message(id = 14002, value = "Missing DPoP key binding") + ParseException missingDpopKeyBinding(); +} From cb1a8f0cabe595dafcb26cf1a4959df2e404952a Mon Sep 17 00:00:00 2001 From: Aaron Coburn Date: Thu, 9 Jul 2020 08:24:23 -0400 Subject: [PATCH 2/3] Use auth.dpop package --- .../io/smallrye/jwt/auth/AuthLogging.java | 18 +----------- .../AbstractDpopTokenValidator.java | 16 +++++------ .../smallrye/jwt/auth/dpop/DpopLogging.java | 28 +++++++++++++++++++ .../DpopMessages.java} | 6 ++-- 4 files changed, 40 insertions(+), 28 deletions(-) rename implementation/src/main/java/io/smallrye/jwt/auth/{ => dpop}/AbstractDpopTokenValidator.java (94%) create mode 100644 implementation/src/main/java/io/smallrye/jwt/auth/dpop/DpopLogging.java rename implementation/src/main/java/io/smallrye/jwt/auth/{AuthMessages.java => dpop/DpopMessages.java} (83%) diff --git a/implementation/src/main/java/io/smallrye/jwt/auth/AuthLogging.java b/implementation/src/main/java/io/smallrye/jwt/auth/AuthLogging.java index f0376c7f..44677b70 100644 --- a/implementation/src/main/java/io/smallrye/jwt/auth/AuthLogging.java +++ b/implementation/src/main/java/io/smallrye/jwt/auth/AuthLogging.java @@ -33,20 +33,4 @@ interface AuthLogging extends BasicLogger { @LogMessage(level = Logger.Level.DEBUG) @Message(id = 6005, value = "Authorization header was null") void authHeaderIsNull(); - - @LogMessage(level = Logger.Level.DEBUG) - @Message(id = 6006, value = "Invalid DPoP token") - void invalidDpopToken(); - - @LogMessage(level = Logger.Level.DEBUG) - @Message(id = 6007, value = "Missing DPoP token") - void missingDpopToken(); - - @LogMessage(level = Logger.Level.DEBUG) - @Message(id = 6008, value = "Missing DPoP key binding in access token") - void missingDpopKeyBinding(); - - @LogMessage(level = Logger.Level.DEBUG) - @Message(id = 6009, value = "Invalid request URL: %s") - void invalidRequestUrl(String invalidUrlMsg); -} +} \ No newline at end of file diff --git a/implementation/src/main/java/io/smallrye/jwt/auth/AbstractDpopTokenValidator.java b/implementation/src/main/java/io/smallrye/jwt/auth/dpop/AbstractDpopTokenValidator.java similarity index 94% rename from implementation/src/main/java/io/smallrye/jwt/auth/AbstractDpopTokenValidator.java rename to implementation/src/main/java/io/smallrye/jwt/auth/dpop/AbstractDpopTokenValidator.java index d2a2b9ab..edc7cf1f 100644 --- a/implementation/src/main/java/io/smallrye/jwt/auth/AbstractDpopTokenValidator.java +++ b/implementation/src/main/java/io/smallrye/jwt/auth/dpop/AbstractDpopTokenValidator.java @@ -13,7 +13,7 @@ * License for the specific language governing permissions and limitations under * the License. */ -package io.smallrye.jwt.auth; +package io.smallrye.jwt.auth.dpop; import static org.jose4j.jwa.AlgorithmConstraints.ConstraintType.PERMIT; @@ -78,16 +78,16 @@ public void verify(JsonWebToken accessToken) throws ParseException { try { parser.process(dpop); } catch (InvalidJwtException e) { - AuthLogging.log.invalidDpopToken(); - throw AuthMessages.msg.failedToVerifyDpopToken(e); + DpopLogging.log.invalidDpopToken(); + throw DpopMessages.msg.failedToVerifyDpopToken(e); } } else { - AuthLogging.log.missingDpopToken(); - throw AuthMessages.msg.missingDpopProof(); + DpopLogging.log.missingDpopToken(); + throw DpopMessages.msg.missingDpopProof(); } } else { - AuthLogging.log.missingDpopKeyBinding(); - throw AuthMessages.msg.missingDpopKeyBinding(); + DpopLogging.log.missingDpopKeyBinding(); + throw DpopMessages.msg.missingDpopKeyBinding(); } } @@ -206,7 +206,7 @@ static boolean compareUrls(String url1, String url2) { && uri1.getAuthority().equalsIgnoreCase(uri2.getAuthority()) && uri1.getPath().equalsIgnoreCase(uri2.getPath()); } catch (URISyntaxException ex) { - AuthLogging.log.invalidRequestUrl(ex.getMessage()); + DpopLogging.log.invalidRequestUrl(ex.getMessage()); } } return false; diff --git a/implementation/src/main/java/io/smallrye/jwt/auth/dpop/DpopLogging.java b/implementation/src/main/java/io/smallrye/jwt/auth/dpop/DpopLogging.java new file mode 100644 index 00000000..bff80343 --- /dev/null +++ b/implementation/src/main/java/io/smallrye/jwt/auth/dpop/DpopLogging.java @@ -0,0 +1,28 @@ +package io.smallrye.jwt.auth.dpop; + +import org.jboss.logging.BasicLogger; +import org.jboss.logging.Logger; +import org.jboss.logging.annotations.LogMessage; +import org.jboss.logging.annotations.Message; +import org.jboss.logging.annotations.MessageLogger; + +@MessageLogger(projectCode = "SRJWT", length = 5) +interface DpopLogging extends BasicLogger { + DpopLogging log = Logger.getMessageLogger(DpopLogging.class, DpopLogging.class.getPackage().getName()); + + @LogMessage(level = Logger.Level.DEBUG) + @Message(id = 15000, value = "Invalid DPoP token") + void invalidDpopToken(); + + @LogMessage(level = Logger.Level.DEBUG) + @Message(id = 15001, value = "Missing DPoP token") + void missingDpopToken(); + + @LogMessage(level = Logger.Level.DEBUG) + @Message(id = 15002, value = "Missing DPoP key binding in access token") + void missingDpopKeyBinding(); + + @LogMessage(level = Logger.Level.DEBUG) + @Message(id = 15003, value = "Invalid request URL: %s") + void invalidRequestUrl(String invalidUrlMsg); +} diff --git a/implementation/src/main/java/io/smallrye/jwt/auth/AuthMessages.java b/implementation/src/main/java/io/smallrye/jwt/auth/dpop/DpopMessages.java similarity index 83% rename from implementation/src/main/java/io/smallrye/jwt/auth/AuthMessages.java rename to implementation/src/main/java/io/smallrye/jwt/auth/dpop/DpopMessages.java index 9207542b..cf925f78 100644 --- a/implementation/src/main/java/io/smallrye/jwt/auth/AuthMessages.java +++ b/implementation/src/main/java/io/smallrye/jwt/auth/dpop/DpopMessages.java @@ -1,4 +1,4 @@ -package io.smallrye.jwt.auth; +package io.smallrye.jwt.auth.dpop; import org.jboss.logging.Messages; import org.jboss.logging.annotations.Cause; @@ -8,8 +8,8 @@ import io.smallrye.jwt.auth.principal.ParseException; @MessageBundle(projectCode = "SRJWT", length = 5) -interface AuthMessages { - AuthMessages msg = Messages.getBundle(AuthMessages.class); +interface DpopMessages { + DpopMessages msg = Messages.getBundle(DpopMessages.class); @Message(id = 14000, value = "Failed to verify DPoP token") ParseException failedToVerifyDpopToken(@Cause Throwable e); From 076fe8b099c74595636f71d4cbbefe3524bb6488 Mon Sep 17 00:00:00 2001 From: Aaron Coburn Date: Thu, 9 Jul 2020 08:27:20 -0400 Subject: [PATCH 3/3] Use getSignatureAlgorithm --- .../io/smallrye/jwt/auth/dpop/AbstractDpopTokenValidator.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/implementation/src/main/java/io/smallrye/jwt/auth/dpop/AbstractDpopTokenValidator.java b/implementation/src/main/java/io/smallrye/jwt/auth/dpop/AbstractDpopTokenValidator.java index edc7cf1f..4caa3f34 100644 --- a/implementation/src/main/java/io/smallrye/jwt/auth/dpop/AbstractDpopTokenValidator.java +++ b/implementation/src/main/java/io/smallrye/jwt/auth/dpop/AbstractDpopTokenValidator.java @@ -67,7 +67,7 @@ public void verify(JsonWebToken accessToken) throws ParseException { .setRequireJwtId() .setExpectedType(true, DPOP_JWT_TYPE) .setJwsAlgorithmConstraints(new AlgorithmConstraints(PERMIT, - authContextInfo.getKeyEncryptionAlgorithm().getAlgorithm())) + authContextInfo.getSignatureAlgorithm().getAlgorithm())) .setVerificationKeyResolver(new EmbeddedJwkVerificationKeyResolver()) .setRequireIssuedAt().setAllowedClockSkewInSeconds(authContextInfo.getExpGracePeriodSecs()) .setExpectedIssuer(false, null)