Validate JWT 'typ' header. (#62)

* Validate JWT 'typ' header.

Signed-off-by: Max Lambrecht <maxlambrecht@gmail.com>
This commit is contained in:
Max Lambrecht 2021-02-08 16:05:36 -03:00 committed by GitHub
parent e33417b10b
commit 0ee9ae28fa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 192 additions and 59 deletions

View File

@ -1,6 +1,7 @@
package io.spiffe.svid.jwtsvid;
import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.JOSEObjectType;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.JWSVerifier;
@ -8,12 +9,12 @@ import com.nimbusds.jose.crypto.ECDSAVerifier;
import com.nimbusds.jose.crypto.RSASSAVerifier;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import io.spiffe.internal.JwtSignatureAlgorithm;
import io.spiffe.bundle.BundleSource;
import io.spiffe.bundle.jwtbundle.JwtBundle;
import io.spiffe.exception.AuthorityNotFoundException;
import io.spiffe.exception.BundleNotFoundException;
import io.spiffe.exception.JwtSvidException;
import io.spiffe.internal.JwtSignatureAlgorithm;
import io.spiffe.spiffeid.SpiffeId;
import lombok.NonNull;
import lombok.Value;
@ -62,6 +63,9 @@ public class JwtSvid {
*/
String token;
public static final String HEADER_TYP_JWT = "JWT";
public static final String HEADER_TYP_JOSE = "JOSE";
private JwtSvid(final SpiffeId spiffeId,
final Set<String> audience,
final Date expiry,
@ -88,13 +92,13 @@ public class JwtSvid {
* @throws JwtSvidException when the token expired or the expiration claim is missing,
* when the algorithm is not supported (See {@link JwtSignatureAlgorithm}),
* when the header 'kid' is missing,
* when the signature cannot be verified, or
* when the header 'typ' is present and is not 'JWT' or 'JOSE'
* when the signature cannot be verified,
* when the 'aud' claim has an audience that is not in the audience list
* provided as parameter
* @throws IllegalArgumentException when the token is blank or cannot be parsed
* @throws BundleNotFoundException if the bundle for the trust domain of the spiffe id from the 'sub'
* cannot be found
* in the JwtBundleSource
* cannot be found in the JwtBundleSource
* @throws AuthorityNotFoundException if the authority cannot be found in the bundle using the value from
* the 'kid' header
*/
@ -108,6 +112,8 @@ public class JwtSvid {
}
val signedJwt = getSignedJWT(token);
validateTypeHeader(signedJwt.getHeader());
JwtSignatureAlgorithm algorithm = parseAlgorithm(signedJwt.getHeader().getAlgorithm());
val claimsSet = getJwtClaimsSet(signedJwt);
@ -137,9 +143,10 @@ public class JwtSvid {
* @param audience audience as a list of strings used to validate the 'aud' claim
* @return an instance of a {@link JwtSvid} with a SPIFFE ID parsed from the 'sub', audience from 'aud', and expiry
* from 'exp' claim.
* @throws JwtSvidException when the token expired or the expiration claim is missing, or when
* the 'aud' has an audience that is not in the audience provided as parameter,
* or when the 'alg' is not supported (See {@link JwtSignatureAlgorithm}).
* @throws JwtSvidException when the token expired or the expiration claim is missing,
* when the 'aud' has an audience that is not in the audience provided as parameter,
* when the 'alg' is not supported (See {@link JwtSignatureAlgorithm}),
* when the header 'typ' is present and is not 'JWT' or 'JOSE'.
* @throws IllegalArgumentException when the token cannot be parsed
*/
public static JwtSvid parseInsecure(@NonNull final String token, @NonNull final Set<String> audience) throws JwtSvidException {
@ -148,6 +155,8 @@ public class JwtSvid {
}
val signedJwt = getSignedJWT(token);
validateTypeHeader(signedJwt.getHeader());
parseAlgorithm(signedJwt.getHeader().getAlgorithm());
val claimsSet = getJwtClaimsSet(signedJwt);
@ -290,7 +299,7 @@ public class JwtSvid {
private static JwtSignatureAlgorithm parseAlgorithm(JWSAlgorithm algorithm) throws JwtSvidException {
if (algorithm == null) {
throw new JwtSvidException("jwt header 'alg' is required");
throw new JwtSvidException("JWT header 'alg' is required");
}
try {
@ -299,4 +308,16 @@ public class JwtSvid {
throw new JwtSvidException(e.getMessage(), e);
}
}
private static void validateTypeHeader(JWSHeader headers) throws JwtSvidException {
final JOSEObjectType type = headers.getType();
// if it's not present -> OK
if (type == null || StringUtils.isBlank(type.toString())) {
return;
}
final String typValue = type.toString();
if (!HEADER_TYP_JWT.equals(typValue) && !HEADER_TYP_JOSE.equals(typValue)) {
throw new JwtSvidException(String.format("If JWT header 'typ' is present, it must be either 'JWT' or 'JOSE'. Got: '%s'.", type.toString()));
}
}
}

View File

@ -26,6 +26,7 @@ import java.util.stream.Stream;
import static io.spiffe.svid.jwtsvid.JwtSvidParseInsecureTest.newJwtSvidInstance;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.fail;
class JwtSvidParseAndValidateTest {
@ -34,9 +35,8 @@ class JwtSvidParseAndValidateTest {
"dCI6MTUxNjIzOTAyMiwiYXVkIjoiYXVkaWVuY2UifQ.wNm5pQGSLCw5N9ddgSF2hkgmQpGnG9le_gpiFmyBhao";
@ParameterizedTest
@MethodSource("provideJwtScenarios")
void parseAndValidateJwt(TestCase testCase) {
@MethodSource("provideSuccessScenarios")
void parseAndValidateValidJwt(TestCase testCase) {
try {
String token = testCase.generateToken.get();
JwtSvid jwtSvid = JwtSvid.parseAndValidate(token, testCase.jwtBundle, testCase.audience);
@ -46,6 +46,18 @@ class JwtSvidParseAndValidateTest {
assertEquals(testCase.expectedJwtSvid.getExpiry().toInstant().getEpochSecond(), jwtSvid.getExpiry().toInstant().getEpochSecond());
assertEquals(token, jwtSvid.getToken());
assertEquals(token, jwtSvid.marshal());
} catch (Exception e) {
fail(e);
}
}
@ParameterizedTest
@MethodSource("provideFailureScenarios")
void parseAndValidateInvalidJwt(TestCase testCase) {
try {
String token = testCase.generateToken.get();
JwtSvid.parseAndValidate(token, testCase.jwtBundle, testCase.audience);
fail("expected error: " + testCase.expectedException.getMessage());
} catch (Exception e) {
assertEquals(testCase.expectedException.getClass(), e.getClass());
assertEquals(testCase.expectedException.getMessage(), e.getMessage());
@ -100,7 +112,7 @@ class JwtSvidParseAndValidateTest {
}
}
static Stream<Arguments> provideJwtScenarios() {
static Stream<Arguments> provideSuccessScenarios() {
KeyPair key1 = TestUtils.generateECKeyPair(Curve.P_521);
KeyPair key2 = TestUtils.generateECKeyPair(Curve.P_521);
KeyPair key3 = TestUtils.generateRSAKeyPair(2048);
@ -112,126 +124,166 @@ class JwtSvidParseAndValidateTest {
jwtBundle.putJwtAuthority("authority3", key3.getPublic());
SpiffeId spiffeId = trustDomain.newSpiffeId("host");
Date expiration = new Date(System.currentTimeMillis() + 3600000);
Date expiration = new Date(System.currentTimeMillis() + (60 * 60 * 1000));
Set<String> audience = new HashSet<String>() {{add("audience1"); add("audience2");}};
JWTClaimsSet claims = TestUtils.buildJWTClaimSet(audience, spiffeId.toString(), expiration);
return Stream.of(
Arguments.of(TestCase.builder()
.name("1. success using EC signature")
.name("using EC signature")
.jwtBundle(jwtBundle)
.expectedAudience(Collections.singleton("audience1"))
.generateToken(() -> TestUtils.generateToken(claims, key1, "authority1"))
.generateToken(() -> TestUtils.generateToken(claims, key1, "authority1", JwtSvid.HEADER_TYP_JOSE))
.expectedException(null)
.expectedJwtSvid(newJwtSvidInstance(
trustDomain.newSpiffeId("host"),
audience,
expiration,
claims.getClaims(), TestUtils.generateToken(claims, key1, "authority1") ))
claims.getClaims(), TestUtils.generateToken(claims, key1, "authority1", JwtSvid.HEADER_TYP_JOSE) ))
.build()),
Arguments.of(TestCase.builder()
.name("2. success using RSA signature")
.name("using RSA signature")
.jwtBundle(jwtBundle)
.expectedAudience(audience)
.generateToken(() -> TestUtils.generateToken(claims, key3, "authority3"))
.generateToken(() -> TestUtils.generateToken(claims, key3, "authority3", JwtSvid.HEADER_TYP_JWT))
.expectedException(null)
.expectedJwtSvid(newJwtSvidInstance(
trustDomain.newSpiffeId("host"),
audience,
expiration,
claims.getClaims(), TestUtils.generateToken(claims, key3, "authority3", JwtSvid.HEADER_TYP_JWT)))
.build()),
Arguments.of(TestCase.builder()
.name("using empty typ")
.jwtBundle(jwtBundle)
.expectedAudience(audience)
.generateToken(() -> TestUtils.generateToken(claims, key3, "authority3", ""))
.expectedException(null)
.expectedJwtSvid(newJwtSvidInstance(
trustDomain.newSpiffeId("host"),
audience,
expiration,
claims.getClaims(), TestUtils.generateToken(claims, key3, "authority3")))
.build()),
.build())
);
}
static Stream<Arguments> provideFailureScenarios() {
KeyPair key1 = TestUtils.generateECKeyPair(Curve.P_521);
KeyPair key2 = TestUtils.generateECKeyPair(Curve.P_521);
KeyPair key3 = TestUtils.generateRSAKeyPair(2048);
TrustDomain trustDomain = TrustDomain.of("test.domain");
JwtBundle jwtBundle = new JwtBundle(trustDomain);
jwtBundle.putJwtAuthority("authority1", key1.getPublic());
jwtBundle.putJwtAuthority("authority2", key2.getPublic());
jwtBundle.putJwtAuthority("authority3", key3.getPublic());
SpiffeId spiffeId = trustDomain.newSpiffeId("host");
Date expiration = new Date(System.currentTimeMillis() + (60 * 60 * 1000));
Set<String> audience = new HashSet<String>() {{add("audience1"); add("audience2");}};
JWTClaimsSet claims = TestUtils.buildJWTClaimSet(audience, spiffeId.toString(), expiration);
return Stream.of(
Arguments.of(TestCase.builder()
.name("3. malformed")
.name("malformed")
.jwtBundle(jwtBundle)
.expectedAudience(audience)
.generateToken(() -> "invalid token")
.expectedException(new IllegalArgumentException("Unable to parse JWT token"))
.build()),
Arguments.of(TestCase.builder()
.name("4. unsupported algorithm")
.name("unsupported algorithm")
.jwtBundle(jwtBundle)
.expectedAudience(Collections.singleton("audience"))
.generateToken(() -> HS256TOKEN)
.expectedException(new JwtSvidException("Unsupported JWT algorithm: HS256"))
.build()),
Arguments.of(TestCase.builder()
.name("5. missing subject")
.name("missing subject")
.jwtBundle(jwtBundle)
.expectedAudience(audience)
.generateToken(() -> TestUtils.generateToken(TestUtils.buildJWTClaimSet(audience, "", expiration), key1, "authority1"))
.expectedException(new JwtSvidException("Token missing subject claim"))
.build()),
Arguments.of(TestCase.builder()
.name("6. missing expiration")
.name("missing expiration")
.jwtBundle(jwtBundle)
.expectedAudience(audience)
.generateToken(() -> TestUtils.generateToken(TestUtils.buildJWTClaimSet(audience, spiffeId.toString(), null), key1, "authority1"))
.expectedException(new JwtSvidException("Token missing expiration claim"))
.build()),
Arguments.of(TestCase.builder()
.name("7. token has expired")
.name("token has expired")
.jwtBundle(jwtBundle)
.expectedAudience(audience)
.generateToken(() -> TestUtils.generateToken(TestUtils.buildJWTClaimSet(audience, spiffeId.toString(), new Date()), key1, "authority1"))
.expectedException(new JwtSvidException("Token has expired"))
.build()),
Arguments.of(TestCase.builder()
.name("8. unexpected audience")
.name("unexpected audience")
.jwtBundle(jwtBundle)
.expectedAudience(Collections.singleton("another"))
.generateToken(() -> TestUtils.generateToken(claims, key1, "authority1"))
.expectedException(new JwtSvidException("expected audience in [another] (audience=[audience2, audience1])"))
.build()),
Arguments.of(TestCase.builder()
.name("9. invalid subject claim")
.name("invalid subject claim")
.jwtBundle(jwtBundle)
.expectedAudience(audience)
.generateToken(() -> TestUtils.generateToken(TestUtils.buildJWTClaimSet(audience, "non-spiffe-subject", expiration), key1, "authority1"))
.expectedException(new JwtSvidException("Subject non-spiffe-subject cannot be parsed as a SPIFFE ID"))
.build()),
Arguments.of(TestCase.builder()
.name("10. missing key id")
.name("missing key id")
.jwtBundle(jwtBundle)
.expectedAudience(audience)
.generateToken(() -> TestUtils.generateToken(claims, key1, null))
.expectedException(new JwtSvidException("Token header missing key id"))
.build()),
Arguments.of(TestCase.builder()
.name("11. key id contains an empty value")
.name("key id contains an empty value")
.jwtBundle(jwtBundle)
.expectedAudience(audience)
.generateToken(() -> TestUtils.generateToken(claims, key1, " "))
.expectedException(new JwtSvidException("Token header key id contains an empty value"))
.build()),
Arguments.of(TestCase.builder()
.name("12. no bundle for trust domain")
.name("no bundle for trust domain")
.jwtBundle(new JwtBundle(TrustDomain.of("other.domain")))
.expectedAudience(audience)
.generateToken(() -> TestUtils.generateToken(claims, key1, "authority1"))
.expectedException(new BundleNotFoundException("No JWT bundle found for trust domain test.domain"))
.build()),
Arguments.of(TestCase.builder()
.name("13. no authority found for key id")
.name("no authority found for key id")
.jwtBundle(new JwtBundle(TrustDomain.of("test.domain")))
.expectedAudience(audience)
.generateToken(() -> TestUtils.generateToken(claims, key1, "authority1"))
.expectedException(new AuthorityNotFoundException("No authority found for the trust domain test.domain and key id authority1"))
.build()),
Arguments.of(TestCase.builder()
.name("14. signature cannot be verified with authority")
.name("signature cannot be verified with authority")
.jwtBundle(jwtBundle)
.expectedAudience(audience)
.generateToken(() -> TestUtils.generateToken(claims, key2, "authority1"))
.expectedException(new JwtSvidException("Signature invalid: cannot be verified with the authority with keyId=authority1"))
.build()),
Arguments.of(TestCase.builder()
.name("15. authority algorithm mismatch")
.name("authority algorithm mismatch")
.jwtBundle(jwtBundle)
.expectedAudience(audience)
.generateToken(() -> TestUtils.generateToken(claims, key3, "authority1"))
.expectedException(new JwtSvidException("Error verifying signature with the authority with keyId=authority1"))
.build()),
Arguments.of(TestCase.builder()
.name("not valid header 'typ'")
.jwtBundle(jwtBundle)
.expectedAudience(audience)
.generateToken(() -> TestUtils.generateToken(claims, key1, "authority1", "OTHER"))
.expectedException(new JwtSvidException("If JWT header 'typ' is present, it must be either 'JWT' or 'JOSE'. Got: 'OTHER'."))
.build())
);
}

View File

@ -25,6 +25,7 @@ import java.util.function.Supplier;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.fail;
class JwtSvidParseInsecureTest {
@ -33,9 +34,8 @@ class JwtSvidParseInsecureTest {
"dCI6MTUxNjIzOTAyMiwiYXVkIjoiYXVkaWVuY2UifQ.wNm5pQGSLCw5N9ddgSF2hkgmQpGnG9le_gpiFmyBhao";
@ParameterizedTest
@MethodSource("provideJwtScenarios")
void parseJwt(TestCase testCase) {
@MethodSource("provideSuccessScenarios")
void parseValidJwt(TestCase testCase) {
try {
String token = testCase.generateToken.get();
JwtSvid jwtSvid = JwtSvid.parseInsecure(token, testCase.audience);
@ -44,6 +44,18 @@ class JwtSvidParseInsecureTest {
assertEquals(testCase.expectedJwtSvid.getAudience(), jwtSvid.getAudience());
assertEquals(testCase.expectedJwtSvid.getExpiry().toInstant().getEpochSecond(), jwtSvid.getExpiry().toInstant().getEpochSecond());
assertEquals(token, jwtSvid.getToken());
} catch (Exception e) {
fail(e);
}
}
@ParameterizedTest
@MethodSource("provideFailureScenarios")
void parseInvalidJwt(TestCase testCase) {
try {
String token = testCase.generateToken.get();
JwtSvid.parseInsecure(token, testCase.audience);
fail("expected error: " + testCase.expectedException.getMessage());
} catch (Exception e) {
assertEquals(testCase.expectedException.getClass(), e.getClass());
assertEquals(testCase.expectedException.getMessage(), e.getMessage());
@ -89,7 +101,7 @@ class JwtSvidParseInsecureTest {
}
}
static Stream<Arguments> provideJwtScenarios() {
static Stream<Arguments> provideSuccessScenarios() {
KeyPair key1 = TestUtils.generateECKeyPair(Curve.P_521);
KeyPair key2 = TestUtils.generateECKeyPair(Curve.P_521);
@ -106,16 +118,56 @@ class JwtSvidParseInsecureTest {
return Stream.of(
Arguments.of(TestCase.builder()
.name("success")
.name("using typ as JWT")
.expectedAudience(audience)
.generateToken(() -> TestUtils.generateToken(claims, key1, "authority1"))
.generateToken(() -> TestUtils.generateToken(claims, key1, "authority1", JwtSvid.HEADER_TYP_JWT))
.expectedException(null)
.expectedJwtSvid(newJwtSvidInstance(
trustDomain.newSpiffeId("host"),
audience,
expiration,
claims.getClaims(), TestUtils.generateToken(claims, key1, "authority1")))
claims.getClaims(), TestUtils.generateToken(claims, key1, "authority1", JwtSvid.HEADER_TYP_JWT)))
.build()),
Arguments.of(TestCase.builder()
.name("using typ as JOSE")
.expectedAudience(audience)
.generateToken(() -> TestUtils.generateToken(claims, key1, "authority1", JwtSvid.HEADER_TYP_JOSE))
.expectedException(null)
.expectedJwtSvid(newJwtSvidInstance(
trustDomain.newSpiffeId("host"),
audience,
expiration,
claims.getClaims(), TestUtils.generateToken(claims, key1, "authority1", JwtSvid.HEADER_TYP_JWT)))
.build()),
Arguments.of(TestCase.builder()
.name("using empty typ")
.expectedAudience(audience)
.generateToken(() -> TestUtils.generateToken(claims, key1, "authority1", ""))
.expectedException(null)
.expectedJwtSvid(newJwtSvidInstance(
trustDomain.newSpiffeId("host"),
audience,
expiration,
claims.getClaims(), TestUtils.generateToken(claims, key1, "authority1", "")))
.build()));
}
static Stream<Arguments> provideFailureScenarios() {
KeyPair key1 = TestUtils.generateECKeyPair(Curve.P_521);
KeyPair key2 = TestUtils.generateECKeyPair(Curve.P_521);
TrustDomain trustDomain = TrustDomain.of("test.domain");
JwtBundle jwtBundle = new JwtBundle(trustDomain);
jwtBundle.putJwtAuthority("authority1", key1.getPublic());
jwtBundle.putJwtAuthority("authority2", key2.getPublic());
SpiffeId spiffeId = trustDomain.newSpiffeId("host");
Date expiration = new Date(System.currentTimeMillis() + 3600000);
Set<String> audience = Collections.singleton("audience");
JWTClaimsSet claims = TestUtils.buildJWTClaimSet(audience, spiffeId.toString(), expiration);
return Stream.of(
Arguments.of(TestCase.builder()
.name("malformed")
.expectedAudience(audience)
@ -153,10 +205,10 @@ class JwtSvidParseInsecureTest {
.expectedException(new JwtSvidException("Subject non-spiffe-subject cannot be parsed as a SPIFFE ID"))
.build()),
Arguments.of(TestCase.builder()
.name("unsupported algorithm")
.expectedAudience(Collections.singleton("audience"))
.generateToken(() -> HS256TOKEN)
.expectedException(new JwtSvidException("Unsupported JWT algorithm: HS256"))
.name("not valid header 'typ'")
.expectedAudience(audience)
.generateToken(() -> TestUtils.generateToken(claims, key1, "authority1", "OTHER"))
.expectedException(new JwtSvidException("If JWT header 'typ' is present, it must be either 'JWT' or 'JOSE'. Got: 'OTHER'."))
.build())
);
}

View File

@ -1,6 +1,7 @@
package io.spiffe.utils;
import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.JOSEObjectType;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.JWSSigner;
@ -9,6 +10,7 @@ import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jose.jwk.Curve;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import io.spiffe.svid.jwtsvid.JwtSvid;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
@ -57,10 +59,14 @@ public class TestUtils {
public static String generateToken(Map<String, Object> claims, KeyPair keyPair, String keyId) {
JWTClaimsSet jwtClaimsSet = buildJWTClaimSetFromClaimsMap(claims);
return generateToken(jwtClaimsSet, keyPair, keyId);
return generateToken(jwtClaimsSet, keyPair, keyId, JwtSvid.HEADER_TYP_JWT);
}
public static String generateToken(JWTClaimsSet claims, KeyPair keyPair, String keyId) {
return generateToken(claims, keyPair, keyId, JwtSvid.HEADER_TYP_JWT);
}
public static String generateToken(JWTClaimsSet claims, KeyPair keyPair, String keyId, String typ) {
try {
JWSAlgorithm algorithm;
JWSSigner signer;
@ -74,7 +80,9 @@ public class TestUtils {
throw new IllegalArgumentException("Algorithm not supported");
}
SignedJWT signedJWT = new SignedJWT(new JWSHeader.Builder(algorithm).keyID(keyId).build(), claims);
final JOSEObjectType joseTyp = new JOSEObjectType(typ);
final JWSHeader header = new JWSHeader.Builder(algorithm).keyID(keyId).type(joseTyp).build();
SignedJWT signedJWT = new SignedJWT(header, claims);
signedJWT.sign(signer);
return signedJWT.serialize();
} catch (JOSEException e) {