diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5f9b599 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Visual Studio Code +#.vscode/ +.suo + +# Compiled Object files +*.o +*.obj + +# Compiled Dynamic libraries +*.so +*.dylib +*.dll + +# Compiled Static libraries +*.a +*.lib + +# Executables +*.exe + +# DUB +.dub +dub.*.json +docs.json +__dummy.html +*-test-* +mixin_output.d + + +# Code coverage +*.lst + +# Examples +examples/simple/simple + +# others +.DS_Store +*.zip diff --git a/README.md b/README.md new file mode 100644 index 0000000..110bce5 --- /dev/null +++ b/README.md @@ -0,0 +1,156 @@ +Dub version + +# JWT + +A Simple D implementation of JSON Web Tokens. It's forked from https://github.com/zolamk/jwt. + +# Supported Algorithms +- none +- HS256 +- HS384 +- HS512 + +#### This library uses [semantic versioning 2.0.0][3] + +# What's New +- added support for `arrays` and `objects` in claims +- removed `verify` function that doesn't take algorithm type, see why [here][4] +- changed `verify` function to take an array of algorithms to support multiple algorithms +- renamed `InvalidSignature` to `InvalidSignatureException` + +# How To Use +## Encoding + + import jwt.jwt; + import jwt.algorithms; + import std.json; + + void main() { + + JSONValue user = ["id": JSONValue(60119), "uri": JSONValue("https://api.we.are/60119")]; + + Token token = new Token(JWTAlgorithm.HS512); + + token.claims.exp = Clock.currTime.toUnixTime(); + + token.claims.set("user", user); + + token.claims.set("data", [JSONValue("zm"), JSONValue(58718)]); + + string encodedToken = token.encode("supersecret"); + + // work with the encoded token + + } + +## Verifying + + import jwt.jwt; + import jwt.exceptions; + import jwt.algorithms; + + void main() { + + // get encoded token from header or ... + + try { + + Token token = verify(encodedToken, "supersecret", [JWTAlgorithm.HS512, JWTAlgorithm.HS256]); + + writeln(token.claims.getInt("id")); + + JSONValue user = token.claims.getObject("user"); + + JSONValue[] a = token.claims.getArray("data"); + + long userID = user["id"].integer(); + + string uri = user["uri"].str(); + + writeln(userID); + + writeln(uri); + + writeln(a[0].str()); + + writeln(a[1].integer()); + + } catch (InvalidAlgorithmException e) { + + writeln("token has an invalid algorithm"); + + } catch (InvalidSignatureException e) { + + writeln("This token has been tampered with"); + + } catch (NotBeforeException e) { + + writeln("Token is not valid yet"); + + } catch (ExpiredException e) { + + writeln("Token has expired"); + + } + + } + +## Encoding without signature + + + import jwt.jwt; + import jwt.algorithms; + + void main() { + + Token token = new Token(JWTAlgorithm.NONE); + + token.claims.exp = Clock.currTime.toUnixTime(); + + token.claims.set("id", 60119); + + string encodedToken = token.encode(); + + // work with the encoded token + + } + +## Verifying without signature + + import jwt.jwt; + import jwt.exceptions; + import jwt.algorithms; + + void main() { + + // get encoded token from header or ... + + try { + + Token token = verify(encodedToken); + + writeln(token.claims.getInt("id")); + + } catch (NotBeforeException e) { + + writeln("Token is not valid yet"); + + } catch (ExpiredException e) { + + writeln("Token has expired"); + + } + + } + +# Limitations + +- ##### Since Phobos doesn't(hopefully yet) support RSA algorithms this library only provides HMAC signing. + +# Note +this library uses code and ideas from [jwtd][1] and [jwt-go][2] + +[1]: https://github.com/olehlong/jwtd +[2]: https://github.com/dgrijalva/jwt-go +[3]: http://semver.org +[4]: https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/ diff --git a/dub.json b/dub.json new file mode 100644 index 0000000..85d2972 --- /dev/null +++ b/dub.json @@ -0,0 +1,9 @@ +{ + "name": "hunt-jwt", + "authors": [ + "Zelalem Mekonen" + ], + "description": "A Simple D implementation of JSON Web Tokens", + "copyright": "Copyright © 2016, Zelalem Mekonen", + "license": "MIT" +} \ No newline at end of file diff --git a/examples/simple/dub.json b/examples/simple/dub.json new file mode 100644 index 0000000..54849c2 --- /dev/null +++ b/examples/simple/dub.json @@ -0,0 +1,11 @@ +{ + "authors": [ + "Zhang" + ], + "description": "A minimal D application.", + "license": "proprietary", + "name": "simple", + "dependencies": { + "hunt-jwt" : {"path": "../../"} + } +} \ No newline at end of file diff --git a/examples/simple/source/app.d b/examples/simple/source/app.d new file mode 100644 index 0000000..46f935f --- /dev/null +++ b/examples/simple/source/app.d @@ -0,0 +1,17 @@ +import std.stdio; + +import jwt; +import std.datetime; +import std.exception; + +void main() +{ + string tokenString = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEiLCJuYW1lIjoiYWxpY2UiLCJlbWFpbCI6ImFsaWNlQGdtYWlsLmNvbSIsInBob25lX251bWJlciI6IjE4ODAwMDAwMDAxIiwibmJmIjoxNTA5NDY0MzQwLCJleHAiOjE1MTAwNjkxNDAsImlhdCI6MTUwOTQ2NDM0MH0.nV7duR2gWHA3TB9xPhP1WWhDpXRn1GA_k8_zBBirT6g"; + + Token tk = decode(tokenString); + + writeln(tk.header.json()); + writeln(tk.claims.json()); + + tk = verify(tokenString, "secret", []); +} diff --git a/source/jwt/algorithms.d b/source/jwt/algorithms.d new file mode 100644 index 0000000..f603c0c --- /dev/null +++ b/source/jwt/algorithms.d @@ -0,0 +1,72 @@ +module jwt.algorithms; + +import std.digest.hmac; +import std.digest.sha; +import std.string : representation; +import std.base64; +import std.stdio; + +import jwt.exceptions; + +/** +* string literal used to represent signing algorithm type +*/ +enum JWTAlgorithm : string { + NONE = "none", // string representation of the none algorithm + HS256 = "HS256", // string representation of hmac algorithm with sha256 + HS384 = "HS384", // string representation of hmac algorithm with sha348 + HS512 = "HS512" //string representation of hmac algorithm with sha512 +} + +/** +* an alias for base64 encoding that is url safe and removes the '=' padding character +*/ +alias URLSafeBase64 = Base64Impl!('-', '_', Base64.NoPadding); + +/** +* signs the given data with the secret using the given algorithm +* Params: +* secret = the secret used to sign the data +* data = the data that is to be signed +* alg = the algorithm to be used to sign the data +* Returns: signature of the data +*/ +string sign(string secret, string data, JWTAlgorithm alg) { + switch(alg) { + case JWTAlgorithm.HS256: + auto signature = HMAC!SHA256(secret.representation); + signature.put(data.representation); + return URLSafeBase64.encode(signature.finish()); + + case JWTAlgorithm.HS384: + auto signature = HMAC!SHA384(secret.representation); + signature.put(data.representation); + return URLSafeBase64.encode(signature.finish()); + + case JWTAlgorithm.HS512: + auto signature = HMAC!SHA512(secret.representation); + signature.put(data.representation); + return URLSafeBase64.encode(signature.finish()); + + case JWTAlgorithm.NONE: + return ""; + + default: + throw new UnsupportedAlgorithmException(alg ~ " algorithm is not supported!"); + + } +} +/// +unittest { + string secret = "supersecret"; + + string data = "an unstoppable force crashes into an unmovable body"; + + string signature = sign(secret, data, JWTAlgorithm.HS512); + + assert(signature.length > 0); + + signature = sign(secret, data, JWTAlgorithm.NONE); + + assert(signature.length == 0); +} \ No newline at end of file diff --git a/source/jwt/exceptions.d b/source/jwt/exceptions.d new file mode 100644 index 0000000..8e4a1d4 --- /dev/null +++ b/source/jwt/exceptions.d @@ -0,0 +1,84 @@ +module jwt.exceptions; + +/** +* thrown when there are issues with token verification +*/ +class VerifyException : Exception { + this(string s) { + super(s); + } +} + +/** +* thrown when attempting to encode or decode a token with an unsupported algorithm +*/ +class UnsupportedAlgorithmException : Exception { + this(string s) { + super(s); + } +} + +/** +* thrown when there are issues with the token +*/ +class InvalidTokenException : VerifyException { + this(string s) { + super(s); + } +} + +/** +* thrown when the tokens signature doesn't match the data signature +*/ +class InvalidSignatureException : VerifyException { + this(string s) { + super(s); + } +} + +/** +* thrown when the algorithm used to sign the token is invalid +*/ +class InvalidAlgorithmException : VerifyException { + this(string s) { + super(s); + } +} + +/** +* thrown when the tokens is expired +*/ +class ExpiredException : VerifyException { + this(string s) { + super(s); + } +} + +/** +* thrown when the token is not valid yet +* or in other words when the nbf claim time is before the current time +*/ +class NotBeforeException : VerifyException { + this(string s) { + super(s); + } +} + +/** +* thrown when the token has an incorrect format +*/ +class MalformedToken : InvalidTokenException { + this(string s) { + super(s); + } +} + +/** +* thrown when the tokens will expire before it becomes valid +* usually when the nbf claim is greater than the exp claim +*/ +class ExpiresBeforeValidException : Exception { + this(string s) { + super(s); + } +} \ No newline at end of file diff --git a/source/jwt/jwt.d b/source/jwt/jwt.d new file mode 100644 index 0000000..83680ac --- /dev/null +++ b/source/jwt/jwt.d @@ -0,0 +1,567 @@ +module jwt.jwt; + +import std.json; +import std.base64; +import std.stdio; +import std.conv; +import std.string; +import std.datetime; +import std.exception; +import std.array : split; +import std.algorithm : count; + +import jwt.algorithms; +import jwt.exceptions; + +private class Component { + abstract @property string json(); + + @property string base64() { + ubyte[] data = cast(ubyte[])this.json; + + return URLSafeBase64.encode(data); + + } +} + +private class Header : Component { + +public: + JWTAlgorithm alg; + string typ; + + this(in JWTAlgorithm alg, in string typ) { + this.alg = alg; + this.typ = typ; + } + + this(in JSONValue headers) { + try { + this.alg = to!(JWTAlgorithm)(toUpper(headers["alg"].str())); + + } catch (Exception e) { + throw new UnsupportedAlgorithmException(alg ~ " algorithm is not supported!"); + + } + + this.typ = headers["typ"].str(); + + } + + @property override string json() { + JSONValue headers = ["alg": cast(string)this.alg, "typ": this.typ]; + + return headers.toString(); + + } +} + +/** +* represents the claims component of a JWT +*/ +private class Claims : Component { +private: + JSONValue data; + + this(in JSONValue claims) { + this.data = claims; + + } + +public: + + this() { + this.data = JSONValue(["iat": JSONValue(Clock.currTime.toUnixTime())]); + + } + + void set(T)(string name, T data) { + this.data.object[name] = JSONValue(data); + } + + /** + * Params: + * name = the name of the claim + * Returns: returns a string representation of the claim if it exists and is a string or an empty string if doesn't exist or is not a string + */ + string get(string name) { + try { + return this.data[name].str(); + + } catch (JSONException e) { + return string.init; + + } + + } + + /** + * Params: + * name = the name of the claim + * Returns: an array of JSONValue + */ + JSONValue[] getArray(string name) { + try { + return this.data[name].array(); + + } catch (JSONException e) { + return JSONValue.Store.array.init; + + } + + } + + + /** + * Params: + * name = the name of the claim + * Returns: a JSONValue + */ + JSONValue[string] getObject(string name) { + try { + return this.data[name].object(); + + } catch (JSONException e) { + return JSONValue.Store.object.init; + + } + + } + + /** + * Params: + * name = the name of the claim + * Returns: returns a long representation of the claim if it exists and is an + * integer or the initial value for long if doesn't exist or is not an integer + */ + long getInt(string name) { + try { + return this.data[name].integer(); + + } catch (JSONException e) { + return long.init; + + } + + } + + /** + * Params: + * name = the name of the claim + * Returns: returns a double representation of the claim if it exists and is a + * double or the initial value for double if doesn't exist or is not a double + */ + double getDouble(string name) { + try { + return this.data[name].floating(); + + } catch (JSONException e) { + return double.init; + + } + + } + + /** + * Params: + * name = the name of the claim + * Returns: returns a boolean representation of the claim if it exists and is a + * boolean or the initial value for bool if doesn't exist or is not a boolean + */ + bool getBool(string name) { + try { + return this.data[name].type == JSONType.true_; + + } catch (JSONException e) { + return bool.init; + + } + + } + + /** + * Params: + * name = the name of the claim + * Returns: returns a boolean value if the claim exists and is null or + * the initial value for bool it it doesn't exist or is not null + */ + bool isNull(string name) { + try { + return this.data[name].isNull(); + + } catch (JSONException) { + return bool.init; + + } + + } + + @property void iss(string s) { + this.data.object["iss"] = s; + } + + + @property string iss() { + try { + return this.data["iss"].str(); + + } catch (JSONException e) { + return ""; + + } + + } + + @property void sub(string s) { + this.data.object["sub"] = s; + } + + @property string sub() { + try { + return this.data["sub"].str(); + + } catch (JSONException e) { + return ""; + + } + + } + + @property void aud(string s) { + this.data.object["aud"] = s; + } + + @property string aud() { + try { + return this.data["aud"].str(); + + } catch (JSONException e) { + return ""; + + } + + } + + @property void exp(long n) { + this.data.object["exp"] = n; + } + + @property long exp() { + try { + return this.data["exp"].integer; + + } catch (JSONException) { + return 0; + + } + + } + + @property void nbf(long n) { + this.data.object["nbf"] = n; + } + + @property long nbf() { + try { + return this.data["nbf"].integer; + + } catch (JSONException) { + return 0; + + } + + } + + @property void iat(long n) { + this.data.object["iat"] = n; + } + + @property long iat() { + try { + return this.data["iat"].integer; + + } catch (JSONException) { + return 0; + + } + + } + + @property void jit(string s) { + this.data.object["jit"] = s; + } + + @property string jit() { + try { + return this.data["jit"].str(); + + } catch(JSONException e) { + return ""; + + } + + } + + /** + * gives json encoded claims + * Returns: json encoded claims + */ + @property override string json() { + return this.data.toString(); + + } +} + +/** +* represents a token +*/ +class Token { + +private: + Claims _claims; + Header _header; + + this(Claims claims, Header header) { + this._claims = claims; + this._header = header; + } + + @property string data() { + return this.header.base64 ~ "." ~ this.claims.base64; + } + + +public: + + this(in JWTAlgorithm alg, in string typ = "JWT") { + this._claims = new Claims(); + + this._header = new Header(alg, typ); + + } + + @property Claims claims() { + return this._claims; + } + + @property Header header() { + return this._header; + } + + /** + * used to get the signature of the token + * Parmas: + * secret = the secret key used to sign the token + * Returns: the signature of the token + */ + string signature(string secret) { + return sign(secret, this.data, this.header.alg); + + } + + /** + * encodes the token + * Params: + * secret = the secret key used to sign the token + *Returns: base64 representation of the token including signature + */ + string encode(string secret) { + if ((this.claims.exp != ulong.init && this.claims.iat != ulong.init) && this.claims.exp < this.claims.iat) { + throw new ExpiredException("Token has already expired"); + } + + if ((this.claims.exp != ulong.init && this.claims.nbf != ulong.init) && this.claims.exp < this.claims.nbf) { + throw new ExpiresBeforeValidException("Token will expire before it becomes valid"); + } + + return this.data ~ "." ~ this.signature(secret); + + } + /// + unittest { + Token token = new Token(JWTAlgorithm.HS512); + + long now = Clock.currTime.toUnixTime(); + + string secret = "super_secret"; + + token.claims.exp = now - 3600; + + assertThrown!ExpiredException(token.encode(secret)); + + token.claims.exp = now + 3600; + + token.claims.nbf = now + 7200; + + assertThrown!ExpiresBeforeValidException(token.encode(secret)); + + } + + /** + * overload of the encode(string secret) function to simplify encoding of token without algorithm none + * Returns: base64 representation of the token + */ + string encode() { + assert(this.header.alg == JWTAlgorithm.NONE); + return this.encode(""); + } +} + +Token decode(string encodedToken) { + string[] tokenParts = split(encodedToken, "."); + + if(tokenParts.length != 3) { + throw new MalformedToken("Malformed Token"); + } + + string component = tokenParts[0]; + + string jsonComponent = cast(string)URLSafeBase64.decode(component); + + JSONValue parsedComponent = parseJSON(jsonComponent); + + Header header = new Header(parsedComponent); + + component = tokenParts[1]; + + jsonComponent = cast(string)URLSafeBase64.decode(component); + + parsedComponent = parseJSON(jsonComponent); + + Claims claims = new Claims(parsedComponent); + + return new Token(claims, header); +} + +/** +* verifies the tokens is valid, using the algorithm given instead of the alg field in the claims +* Params: +* encodedToken = the encoded token +* secret = the secret key used to sign the token +* alg = the algorithm to be used to verify the token +* Returns: a decoded Token +*/ +Token verify(string encodedToken, string secret, JWTAlgorithm[] algs) { + Token token = decode(encodedToken); + + long now = Clock.currTime.toUnixTime(); + + bool algorithmAllowed = false; + + foreach(index, alg; algs) { + if(token.header.alg == alg) { + algorithmAllowed = true; + } + + } + + if (!algorithmAllowed) { + throw new InvalidAlgorithmException("Algorithm " ~ token.header.alg ~ " is not in the allowed algorithms field"); + } + + string signature = split(encodedToken, ".")[2]; + + if (signature != token.signature(secret)) { + throw new InvalidSignatureException("Signature Match Failed"); + } + + if (token.header.alg == JWTAlgorithm.NONE) { + throw new VerifyException("Algorithm set to none while secret is provided"); + } + + if (token.claims.exp != ulong.init && token.claims.exp < now) { + throw new ExpiredException("Token has expired"); + } + + if (token.claims.nbf != ulong.init && token.claims.nbf > now) { + throw new NotBeforeException("Token is not valid yet"); + } + + return token; +} + +unittest { + string secret = "super_secret"; + + long now = Clock.currTime.toUnixTime(); + + Token token = new Token(JWTAlgorithm.HS512); + + token.claims.nbf = now + (60 * 60); + + string encodedToken = token.encode(secret); + + assertThrown!NotBeforeException(verify(encodedToken, secret, [JWTAlgorithm.HS512])); + + token = new Token(JWTAlgorithm.HS512); + + token.claims.iat = now - 3600; + + token.claims.exp = now - 60; + + encodedToken = token.encode(secret); + + assertThrown!ExpiredException(verify(encodedToken, secret, [JWTAlgorithm.HS512])); + + token = new Token(JWTAlgorithm.NONE); + + encodedToken = token.encode(secret); + + assertThrown!VerifyException(verify(encodedToken, secret, [JWTAlgorithm.HS512])); + + token = new Token(JWTAlgorithm.HS512); + + encodedToken = token.encode(secret) ~ "we_are"; + + assertThrown!InvalidSignatureException(verify(encodedToken, secret, [JWTAlgorithm.HS512])); + + token = new Token(JWTAlgorithm.HS512); + + encodedToken = token.encode(secret); + + assertThrown!InvalidAlgorithmException(verify(encodedToken, secret, [JWTAlgorithm.HS256, JWTAlgorithm.HS384])); +} + +/** +* verifies the tokens is valid, used in case the token was signed with "none" as algorithm +* Params: +* encodedToken = the encoded token +* Returns: a decoded Token +*/ +Token verify(string encodedToken) { + Token token = decode(encodedToken); + + long now = Clock.currTime.toUnixTime(); + + if (token.claims.exp != ulong.init && token.claims.exp < now) { + throw new ExpiredException("Token has expired"); + } + + if (token.claims.nbf != ulong.init && token.claims.nbf > now) { + throw new NotBeforeException("Token is not valid yet"); + } + + return token; +} +/// +unittest { + long now = Clock.currTime.toUnixTime(); + + Token token = new Token(JWTAlgorithm.NONE); + + token.claims.nbf = now + (60 * 60); + + string encodedToken = token.encode(); + + assertThrown!NotBeforeException(verify(encodedToken)); + + token = new Token(JWTAlgorithm.NONE); + + token.claims.iat = now - 3600; + + token.claims.exp = now - 60; + + encodedToken = token.encode(); + + assertThrown!ExpiredException(token = verify(encodedToken)); +} \ No newline at end of file diff --git a/source/jwt/package.d b/source/jwt/package.d new file mode 100644 index 0000000..cf18d74 --- /dev/null +++ b/source/jwt/package.d @@ -0,0 +1,5 @@ +module jwt; + +public import jwt.algorithms; +public import jwt.exceptions; +public import jwt.jwt; \ No newline at end of file