From 869a6ddeefeb2499a21056e4e184611cc3cce56c Mon Sep 17 00:00:00 2001 From: Justin Richer Date: Wed, 21 Dec 2011 15:57:34 -0500 Subject: [PATCH] added signing and some unit tests, HMAC signing still doesn't quite work --- .../java/org/mitre/jwt/AbstractJwtSigner.java | 47 +++++++++++ .../java/org/mitre/jwt/Hmac256Signer.java | 81 +++++++++++++++++++ src/main/java/org/mitre/jwt/Jwt.java | 62 ++++++++++---- src/main/java/org/mitre/jwt/JwtClaims.java | 66 +++++++++++++-- src/main/java/org/mitre/jwt/JwtHeader.java | 43 +++++++++- .../java/org/mitre/jwt/PlaintextSigner.java | 17 ++++ src/test/java/org/mitre/jwt/JwtTest.java | 76 +++++++++++++++++ 7 files changed, 370 insertions(+), 22 deletions(-) create mode 100644 src/main/java/org/mitre/jwt/AbstractJwtSigner.java create mode 100644 src/main/java/org/mitre/jwt/Hmac256Signer.java create mode 100644 src/main/java/org/mitre/jwt/PlaintextSigner.java create mode 100644 src/test/java/org/mitre/jwt/JwtTest.java diff --git a/src/main/java/org/mitre/jwt/AbstractJwtSigner.java b/src/main/java/org/mitre/jwt/AbstractJwtSigner.java new file mode 100644 index 000000000..aa17755ef --- /dev/null +++ b/src/main/java/org/mitre/jwt/AbstractJwtSigner.java @@ -0,0 +1,47 @@ +package org.mitre.jwt; + +import com.google.common.base.Objects; + +public class AbstractJwtSigner implements JwtSigner { + + public static final String PLAINTEXT = "none"; + public static final String HS256 = "HS256"; + public static final String HS384 = "HS384"; + public static final String HS512 = "HS512"; + + private String algorithm; + + public AbstractJwtSigner(String algorithm) { + this.algorithm = algorithm; + } + + /** + * @return the algorithm + */ + public String getAlgorithm() { + return algorithm; + } + + /** + * @param algorithm the algorithm to set + */ + public void setAlgorithm(String algorithm) { + this.algorithm = algorithm; + } + + /** + * Ensures that the 'alg' of the given JWT matches the {@link #algorithm} of this signer + */ + @Override + public void sign(Jwt jwt) { + if (!Objects.equal(algorithm, jwt.getHeader().getAlgorithm())) { + // algorithm type doesn't match + // TODO: should this be an error or should we just fix it in the incoming jwt? + // for now, we fix the Jwt + jwt.getHeader().setAlgorithm(algorithm); + } + + } + + +} \ No newline at end of file diff --git a/src/main/java/org/mitre/jwt/Hmac256Signer.java b/src/main/java/org/mitre/jwt/Hmac256Signer.java new file mode 100644 index 000000000..ea4338bbc --- /dev/null +++ b/src/main/java/org/mitre/jwt/Hmac256Signer.java @@ -0,0 +1,81 @@ +package org.mitre.jwt; + +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import org.apache.commons.codec.binary.Base64; + +public class Hmac256Signer extends AbstractJwtSigner { + + private Mac mac; + + private byte[] passphrase; + + public Hmac256Signer() { + this(null); + } + + public Hmac256Signer(byte[] passphrase) { + super(HS256); + setPassphrase(passphrase); + + try { + mac = Mac.getInstance("HMACSHA256"); + } catch (NoSuchAlgorithmException e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } + + + } + + /* (non-Javadoc) + * @see org.mitre.jwt.AbstractJwtSigner#sign(org.mitre.jwt.Jwt) + */ + @Override + public void sign(Jwt jwt) { + super.sign(jwt); + + if (passphrase == null) { + return; // TODO: probably throw some kind of exception + } + + try { + mac.init(new SecretKeySpec(getPassphrase(), mac.getAlgorithm())); + } catch (InvalidKeyException e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } + + mac.update(jwt.getSignatureBase().getBytes()); + + byte[] sigBytes = mac.doFinal(); + + String sig = new String(Base64.encodeBase64URLSafe(sigBytes)); + + // strip off any padding + sig = sig.replace("=", ""); + + jwt.setSignature(sig); + } + + /** + * @return the passphrase + */ + public byte[] getPassphrase() { + return passphrase; + } + + /** + * @param passphrase the passphrase to set + */ + public void setPassphrase(byte[] passphrase) { + this.passphrase = passphrase; + } + + + +} diff --git a/src/main/java/org/mitre/jwt/Jwt.java b/src/main/java/org/mitre/jwt/Jwt.java index fa47a2605..84a1bf44f 100644 --- a/src/main/java/org/mitre/jwt/Jwt.java +++ b/src/main/java/org/mitre/jwt/Jwt.java @@ -1,26 +1,54 @@ package org.mitre.jwt; +import java.io.ByteArrayInputStream; +import java.io.InputStreamReader; import java.util.List; import org.apache.commons.codec.binary.Base64; -import com.google.common.base.Objects; import com.google.common.base.Splitter; import com.google.common.base.Strings; import com.google.common.collect.Lists; +import com.google.gson.GsonBuilder; import com.google.gson.JsonObject; import com.google.gson.JsonParser; public class Jwt { - private JwtHeader header = new JwtHeader(); - - private JwtClaims claims = new JwtClaims(); + private JwtHeader header; + private JwtClaims claims; + + /** + * Base64Url encoded signature string + */ private String signature; + + public Jwt() { + this.header = new JwtHeader(); + this.claims = new JwtClaims(); + this.signature = null; // unsigned by default + } + + + + /** + * Create a Jwt from existing components + * @param header + * @param claims + * @param signature + */ + public Jwt(JwtHeader header, JwtClaims claims, String signature) { + super(); + this.header = header; + this.claims = claims; + this.signature = signature; + } + + /** * @return the header @@ -78,15 +106,19 @@ public class Jwt { * Return the canonical encoded string of this JWT */ public String toString() { - JsonObject h = header.getAsJsonObject(); - JsonObject o = claims.getAsJsonObject(); - - String h64 = new String(Base64.encodeBase64URLSafe(h.toString().getBytes())); - String o64 = new String(Base64.encodeBase64(o.toString().getBytes())); - - return h64 + "." + o64 + "." + Strings.nullToEmpty(this.signature); + return getSignatureBase() + Strings.nullToEmpty(this.signature); } + public String getSignatureBase() { + JsonObject h = header.getAsJsonObject(); + JsonObject c = claims.getAsJsonObject(); + + String h64 = new String(Base64.encodeBase64URLSafe(h.toString().getBytes())); + String c64 = new String(Base64.encodeBase64URLSafe(c.toString().getBytes())); + + return h64 + "." + c64 + "."; + } + /** * Parse a wire-encoded JWT @@ -101,15 +133,15 @@ public class Jwt { } String h64 = parts.get(0); - String o64 = parts.get(1); + String c64 = parts.get(1); String s64 = parts.get(2); JsonParser parser = new JsonParser(); - - + JsonObject hjo = parser.parse(new InputStreamReader(new ByteArrayInputStream(Base64.decodeBase64(h64)))).getAsJsonObject(); + JsonObject cjo = parser.parse(new InputStreamReader(new ByteArrayInputStream(Base64.decodeBase64(c64)))).getAsJsonObject(); // shuttle for return value - Jwt jwt = new Jwt(); + Jwt jwt = new Jwt(new JwtHeader(hjo), new JwtClaims(cjo), s64); return jwt; diff --git a/src/main/java/org/mitre/jwt/JwtClaims.java b/src/main/java/org/mitre/jwt/JwtClaims.java index dd4124db4..0f5e6e182 100644 --- a/src/main/java/org/mitre/jwt/JwtClaims.java +++ b/src/main/java/org/mitre/jwt/JwtClaims.java @@ -1,20 +1,28 @@ package org.mitre.jwt; import java.text.DateFormat; +import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; import java.util.HashMap; import java.util.Map; +import java.util.Map.Entry; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; public class JwtClaims { /** * ISO8601 / RFC3339 Date Format */ - public static DateFormat dateFormat = new SimpleDateFormat("YYYY-MM-DD'T'HH:mm:ssz"); + public static DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssz"); + /* + * TODO: Should we instead be using a generic claims map with well-named accessor methods? + */ + private Date expiration; private Date notBefore; @@ -37,6 +45,42 @@ public class JwtClaims { } + public JwtClaims(JsonObject json) { + for (Entry element : json.entrySet()) { + if (element.getKey().equals("exp")) { + expiration = new Date(element.getValue().getAsLong() * 1000L); + } else if (element.getKey().equals("nbf")) { + notBefore = new Date(element.getValue().getAsLong() * 1000L); + } else if (element.getKey().equals("iat")) { + issuedAt = new Date(element.getValue().getAsLong() * 1000L); + } else if (element.getKey().equals("iss")) { + issuer = element.getValue().getAsString(); + } else if (element.getKey().equals("aud")) { + audience = element.getValue().getAsString(); + } else if (element.getKey().equals("prn")) { + principal = element.getValue().getAsString(); + } else if (element.getKey().equals("jti")) { + jwtId = element.getValue().getAsString(); + } else if (element.getKey().equals("typ")) { + type = element.getValue().getAsString(); + } else if (element.getValue().isJsonPrimitive()){ + // we handle all primitives in here + JsonPrimitive prim = element.getValue().getAsJsonPrimitive(); + + if (prim.isBoolean()) { + claims.put(element.getKey(), prim.getAsBoolean()); + } else if (prim.isNumber()) { + claims.put(element.getKey(), prim.getAsNumber()); + } else if (prim.isString()) { + claims.put(element.getKey(), prim.getAsString()); + } + } else { + // everything else gets handled as a raw JsonElement + claims.put(element.getKey(), element.getValue()); + } + } + } + /** * @return the expiration */ @@ -179,15 +223,15 @@ public class JwtClaims { JsonObject o = new JsonObject(); if (this.expiration != null) { - o.addProperty("exp", dateFormat.format(this.expiration)); + o.addProperty("exp", this.expiration.getTime() / 1000L); } if (this.notBefore != null) { - o.addProperty("nbf", dateFormat.format(this.notBefore)); + o.addProperty("nbf", this.notBefore.getTime() / 1000L); } if (this.issuedAt != null) { - o.addProperty("iat", dateFormat.format(this.issuedAt)); + o.addProperty("iat", this.issuedAt.getTime() / 1000L); } if (this.issuer != null) { @@ -212,7 +256,9 @@ public class JwtClaims { if (this.claims != null) { for (Map.Entry claim : this.claims.entrySet()) { - if (claim.getValue() instanceof String) { + if (claim.getValue() instanceof JsonElement) { + o.add(claim.getKey(), (JsonElement)claim.getValue()); + } else if (claim.getValue() instanceof String) { o.addProperty(claim.getKey(), (String)claim.getValue()); } else if (claim.getValue() instanceof Number) { o.addProperty(claim.getKey(), (Number)claim.getValue()); @@ -233,4 +279,14 @@ public class JwtClaims { return o; } + /* (non-Javadoc) + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + return "JwtClaims [expiration=" + expiration + ", notBefore=" + notBefore + ", issuedAt=" + issuedAt + ", issuer=" + issuer + ", audience=" + audience + ", principal=" + principal + ", jwtId=" + jwtId + ", type=" + type + ", claims=" + claims + "]"; + } + + + } diff --git a/src/main/java/org/mitre/jwt/JwtHeader.java b/src/main/java/org/mitre/jwt/JwtHeader.java index fd7560769..0c002103f 100644 --- a/src/main/java/org/mitre/jwt/JwtHeader.java +++ b/src/main/java/org/mitre/jwt/JwtHeader.java @@ -2,11 +2,17 @@ package org.mitre.jwt; import java.util.HashMap; import java.util.Map; +import java.util.Map.Entry; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; public class JwtHeader { + /* + * TODO: Should we instead be using a generic claims map with well-named accessor methods? + */ + private String type; private String algorithm; @@ -16,10 +22,32 @@ public class JwtHeader { private Map claims = new HashMap(); + /** + * Make an empty header + */ public JwtHeader() { } - + + /** + * Build a header from a JSON object + * @param json + */ + public JwtHeader(JsonObject json) { + + for (Entry element : json.entrySet()) { + if (element.getKey().equals("typ")) { + this.type = json.get("typ").getAsString(); + } else if (element.getKey().equals("alg")) { + this.algorithm = json.get("alg").getAsString(); + } else if (element.getKey().equals("enc")) { + this.encryptionMethod = json.get("enc").getAsString(); + } else { + // TODO: this assumes string encoding for extensions, probably not quite correct + claims.put(element.getKey(), element.getValue().getAsString()); + } + } + } /** * @return the type @@ -97,7 +125,9 @@ public class JwtHeader { public JsonObject getAsJsonObject() { JsonObject o = new JsonObject(); - o.addProperty("typ", this.type); + if (this.type != null) { + o.addProperty("typ", this.type); + } if (this.algorithm != null) { o.addProperty("alg", this.algorithm); } @@ -129,5 +159,14 @@ public class JwtHeader { return o; } + /* (non-Javadoc) + * @see java.lang.Object#toString() + */ + @Override + public String toString() { + return "JwtHeader [type=" + type + ", algorithm=" + algorithm + ", encryptionMethod=" + encryptionMethod + ", claims=" + claims + "]"; + } + + } diff --git a/src/main/java/org/mitre/jwt/PlaintextSigner.java b/src/main/java/org/mitre/jwt/PlaintextSigner.java new file mode 100644 index 000000000..8c1058b1a --- /dev/null +++ b/src/main/java/org/mitre/jwt/PlaintextSigner.java @@ -0,0 +1,17 @@ +package org.mitre.jwt; + +public class PlaintextSigner extends AbstractJwtSigner { + + public PlaintextSigner() { + super(PLAINTEXT); + } + + @Override + public void sign(Jwt jwt) { + super.sign(jwt); + + jwt.setSignature(""); + + } + +} diff --git a/src/test/java/org/mitre/jwt/JwtTest.java b/src/test/java/org/mitre/jwt/JwtTest.java new file mode 100644 index 000000000..0b18d6455 --- /dev/null +++ b/src/test/java/org/mitre/jwt/JwtTest.java @@ -0,0 +1,76 @@ +package org.mitre.jwt; + +import static org.junit.Assert.*; +import static org.hamcrest.CoreMatchers.*; + +import java.util.Date; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +public class JwtTest { + + @Test + public void testToStringPlaintext() { + Jwt jwt = new Jwt(); + jwt.getHeader().setAlgorithm("none"); + jwt.getClaims().setIssuer("joe"); + jwt.getClaims().setExpiration(new Date(1300819380L * 1000L)); + jwt.getClaims().setClaim("http://example.com/is_root", Boolean.TRUE); + + // sign it with a blank signature + JwtSigner signer = new PlaintextSigner(); + signer.sign(jwt); + + /* + * Expected string based on the following structures, serialized exactly as folows and base64 encoded: + * + * header: {"alg":"none"} + * claims: {"exp":1300819380,"iss":"joe","http://example.com/is_root":true} + */ + String expected = "eyJhbGciOiJub25lIn0.eyJleHAiOjEzMDA4MTkzODAsImlzcyI6ImpvZSIsImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ."; + + String actual = jwt.toString(); + + assertThat(actual, equalTo(expected)); + + } + + @Test + public void testHmacSignature() { + Jwt jwt = new Jwt(); + jwt.getHeader().setType("JWT"); + jwt.getHeader().setAlgorithm("HS256"); + jwt.getClaims().setIssuer("joe"); + jwt.getClaims().setExpiration(new Date(1300819380L * 1000L)); + jwt.getClaims().setClaim("http://example.com/is_root", Boolean.TRUE); + + // sign it + byte[] key = "secret".getBytes(); + + JwtSigner signer = new Hmac256Signer(key); + + signer.sign(jwt); + + String expected = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjEzMDA4MTkzODAsImlzcyI6ImpvZSIsImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ.vQqHHhblAtGiFs7q7nPt9Q"; + + String actual = jwt.toString(); + + } + + @Test + public void testParse() { + String source = "eyJhbGciOiJub25lIn0.eyJleHAiOjEzMDA4MTkzODAsImlzcyI6ImpvZSIsImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ."; + + + Jwt jwt = Jwt.parse(source); + + assertThat(jwt.getHeader().getAlgorithm(), equalTo(AbstractJwtSigner.PLAINTEXT)); + assertThat(jwt.getClaims().getIssuer(), equalTo("joe")); + assertThat(jwt.getClaims().getExpiration(), equalTo(new Date(1300819380L * 1000L))); + assertThat((Boolean)jwt.getClaims().getClaim("http://example.com/is_root"), equalTo(Boolean.TRUE)); + + } + +}