diff --git a/README.md b/README.md index 0711d42..16838ed 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ -# jwt4d +# JWT4D -JSON Web Token library to generate, parse, sign, and verify JWTs. \ No newline at end of file +A JSON Web Token library to generate, parse, sign, and verify JWTs. + +Currently, only the "HS256" algorithm is supported. diff --git a/source/jwt4d/model.d b/source/jwt4d/model.d index d63a62d..0797027 100644 --- a/source/jwt4d/model.d +++ b/source/jwt4d/model.d @@ -1,9 +1,7 @@ module jwt4d.model; import std.json; -import std.typecons : Nullable; import std.base64; -import secured; /** * Enum defining the names of registered JWT claims, as per RFC 7519. @@ -97,6 +95,11 @@ struct JwtClaims { return this; } + long expiration() const { + if (JwtClaim.Expiration !in this.obj.object) return -1; + return this.obj.object[JwtClaim.Expiration].integer; + } + ref notBefore(long notBefore) { if (notBefore < 0) { this.obj.object.remove(JwtClaim.NotBefore); @@ -106,6 +109,11 @@ struct JwtClaims { return this; } + long notBefore() const { + if (JwtClaim.NotBefore !in this.obj.object) return -1; + return this.obj.object[JwtClaim.NotBefore].integer; + } + ref issuedAt(long issuedAt) { if (issuedAt < 0) { this.obj.object.remove(JwtClaim.IssuedAt); @@ -128,66 +136,3 @@ struct JwtClaims { return this.obj.toJSON(); } } - -string writeJwt(in JwtClaims claims, string secret) { - JSONValue headerObj = JSONValue.emptyObject; - headerObj.object["typ"] = "JWT"; - headerObj.object["alg"] = "HS256"; - string headerBase64 = Base64URLNoPadding.encode(cast(ubyte[]) headerObj.toJSON()); - - string claimsBase64 = Base64URLNoPadding.encode(cast(ubyte[]) claims.toJson()); - - string prefix = headerBase64 ~ "." ~ claimsBase64; - - ubyte[] signatureBytes = hmac_ex( - cast(ubyte[]) secret, - cast(ubyte[]) (prefix), - HashAlgorithm.SHA2_256 - ); - string signatureBase64 = Base64URLNoPadding.encode(signatureBytes); - - return prefix ~ "." ~ signatureBase64; -} - -unittest { - JwtClaims claims; - claims.issuer = "example.com"; - claims.subject = "user123"; - claims.expiration = 123; - - string token = writeJwt(claims, "test"); - import std.stdio; - writeln(token); -} - -JwtClaims readJwt(string token, string secret) { - import std.algorithm : splitter; - import std.array : array; - - auto parts = token.splitter('.').array; - JSONValue headerObj = parseJSON(cast(string) Base64URLNoPadding.decode(parts[0])); - // TODO: Verify header object and algorithm. - JSONValue claimsObj = parseJSON(cast(string) Base64URLNoPadding.decode(parts[1])); - // TODO: Verify claims object structure. - ubyte[] signatureBytes = Base64URLNoPadding.decode(parts[2]); - bool verified = hmac_verify_ex( - signatureBytes, - cast(ubyte[]) secret, - cast(ubyte[]) (parts[0] ~ "." ~ parts[1]), - HashAlgorithm.SHA2_256 - ); - if (!verified) { - throw new Exception("Verification failed!"); - // TODO: Custom error handling. - } - return JwtClaims(claimsObj); -} - -unittest { - JwtClaims claims; - claims.issuer = "example.com"; - string token = writeJwt(claims, "test"); - JwtClaims readClaims = readJwt(token, "test"); - import std.stdio; - writeln(readClaims.toJson()); -} diff --git a/source/jwt4d/package.d b/source/jwt4d/package.d index 569b836..d83f996 100644 --- a/source/jwt4d/package.d +++ b/source/jwt4d/package.d @@ -1,2 +1,5 @@ module jwt4d; +public import jwt4d.model; +public import jwt4d.reader; +public import jwt4d.writer; diff --git a/source/jwt4d/reader.d b/source/jwt4d/reader.d new file mode 100644 index 0000000..4d77e54 --- /dev/null +++ b/source/jwt4d/reader.d @@ -0,0 +1,119 @@ +module jwt4d.reader; + +import std.json; +import std.base64; +import std.datetime; +import secured; + +import jwt4d.model; + +class JwtException : Exception { + this(string message) { + super(message); + } +} + +class JwtFormatException : JwtException { + this(string message) { + super(message); + } +} + +class JwtVerificationException : JwtException { + this() { + super("JWT could not be verified."); + } +} + +class JwtExpiredException : JwtException { + this() { + super("JWT has expired."); + } +} + +class JwtNotBeforeException : JwtException { + this() { + super("JWT is not valid yet (not before time)."); + } +} + +/** + * Reads a token and parses the claims. If the token is valid and its signature + * is verified, this method will also evaluate the "exp" (expiration) and "nbf" + * (not before) claims, and throw an exception if the token is not valid. + * Params: + * token = The token to read. + * secret = The secret used to sign the token, to verify it. + * Returns: The set of claims. + */ +JwtClaims readJwt(string token, string secret) { + import std.algorithm : splitter; + import std.array : array; + + auto parts = token.splitter('.').array; + JSONValue headerObj = parseJSON(cast(string) Base64URLNoPadding.decode(parts[0])); + verifyHeaderFormat(headerObj); + string algorithm = headerObj.object["alg"].str; + if (algorithm != "HS256") { + throw new JwtFormatException("Unsupported algorithm: " ~ algorithm); + } + + JSONValue claimsObj = parseJSON(cast(string) Base64URLNoPadding.decode(parts[1])); + verifyClaimsFormat(claimsObj); + + ubyte[] signatureBytes = Base64URLNoPadding.decode(parts[2]); + bool verified = hmac_verify_ex( + signatureBytes, + cast(ubyte[]) secret, + cast(ubyte[]) (parts[0] ~ "." ~ parts[1]), + HashAlgorithm.SHA2_256 + ); + if (!verified) { + throw new JwtVerificationException(); + } + + JwtClaims claims = JwtClaims(claimsObj); + verifyStandardTimeClaims(claims); + return claims; +} + +private void verifyHeaderFormat(in JSONValue j) { + if (j.type != JSONType.OBJECT) { + throw new JwtFormatException("Header must be a JSON object."); + } + if ("typ" !in j.object || j.object["typ"].type != JSONType.STRING) { + throw new JwtFormatException("Header is missing the required 'typ' string property."); + } + if (j.object["typ"].str != "JWT") { + throw new JwtFormatException("Header 'typ' property must be 'JWT'."); + } + if ("alg" !in j.object || j.object["alg"].type != JSONType.STRING) { + throw new JwtFormatException("Header is missing the required 'alg' string property."); + } +} + +private void verifyClaimsFormat(in JSONValue j) { + if (j.type != JSONType.OBJECT) { + throw new JwtFormatException("Claims must be a JSON object."); + } +} + +private void verifyStandardTimeClaims(in JwtClaims claims) { + long currentTimestamp = Clock.currTime(UTC()).toUnixTime!long; + if (claims.expiration > 0 && claims.expiration <= currentTimestamp) { + throw new JwtExpiredException(); + } + if (claims.notBefore > 0 && claims.notBefore > currentTimestamp) { + throw new JwtNotBeforeException(); + } +} + +unittest { + import jwt4d.writer; + JwtClaims claims; + claims.issuer = "example.com"; + string token = writeJwt(claims, "test"); + JwtClaims readClaims = readJwt(token, "test"); + import std.stdio; + writeln(readClaims.toJson()); +} \ No newline at end of file diff --git a/source/jwt4d/writer.d b/source/jwt4d/writer.d new file mode 100644 index 0000000..3e83c77 --- /dev/null +++ b/source/jwt4d/writer.d @@ -0,0 +1,45 @@ +module jwt4d.writer; + +import std.json; +import std.base64; +import secured; + +import jwt4d.model; + +/** + * Writes the given claims to a JWT, signed with the given secret. + * Params: + * claims = The claims to encode. + * secret = The secret to use when signing the JWT. + * Returns: The token. + */ +string writeJwt(in JwtClaims claims, string secret) { + JSONValue headerObj = JSONValue.emptyObject; + headerObj.object["typ"] = "JWT"; + headerObj.object["alg"] = "HS256"; + string headerBase64 = Base64URLNoPadding.encode(cast(ubyte[]) headerObj.toJSON()); + + string claimsBase64 = Base64URLNoPadding.encode(cast(ubyte[]) claims.toJson()); + + string prefix = headerBase64 ~ "." ~ claimsBase64; + + ubyte[] signatureBytes = hmac_ex( + cast(ubyte[]) secret, + cast(ubyte[]) (prefix), + HashAlgorithm.SHA2_256 + ); + string signatureBase64 = Base64URLNoPadding.encode(signatureBytes); + + return prefix ~ "." ~ signatureBase64; +} + +unittest { + JwtClaims claims; + claims.issuer = "example.com"; + claims.subject = "user123"; + claims.expiration = 123; + + string token = writeJwt(claims, "test"); + import std.stdio; + writeln(token); +} \ No newline at end of file