diff --git a/README.md b/README.md index 16838ed..d8ce197 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,35 @@ A JSON Web Token library to generate, parse, sign, and verify JWTs. Currently, only the "HS256" algorithm is supported. + +## Example: Create a JWT +```d +import jwt4d; +import std.datetime; // To set expiration in duration. +import std.json; // To add a custom claim value. +import std.stdio; + +const string MY_SECRET = "this is a secret!"; + +JwtClaims claims = JwtClaims() + .issuer("my.webpage.com") + .subject("user123") + .issuedAtNow() + .expiresIn(minutes(30)) + .customClaim("role", JSONValue("admin")); +string token = writeJwt(claims, MY_SECRET); +writeln(token); +``` + +## Example: Read a JWT +```d +import jwt4d; +import std.stdio; + +const string MY_SECRET = "this is a secret!"; + +string token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NTM5MjYzMjAsImlhdCI6MTc1MzkyNDUyMCwiaXNzIjoibXkud2VicGFnZS5jb20iLCJyb2xlIjoiYWRtaW4iLCJzdWIiOiJ1c2VyMTIzIn0.n5X2giJ3S5T3wrW4C0qlZrShr2ZwPiWIu6FxUzQ3K9s"; + +JwtClaims claims = readJwt(token, MY_SECRET); +writeln(claims.toJson()); +``` diff --git a/source/jwt4d/model.d b/source/jwt4d/model.d index 0797027..14fe196 100644 --- a/source/jwt4d/model.d +++ b/source/jwt4d/model.d @@ -1,7 +1,7 @@ module jwt4d.model; import std.json; -import std.base64; +import std.datetime; /** * Enum defining the names of registered JWT claims, as per RFC 7519. @@ -95,6 +95,12 @@ struct JwtClaims { return this; } + ref expiresIn(Duration dur) { + long expirationUnixTime = (Clock.currTime() + dur).toUnixTime!long; + this.obj.object[JwtClaim.Expiration] = expirationUnixTime; + return this; + } + long expiration() const { if (JwtClaim.Expiration !in this.obj.object) return -1; return this.obj.object[JwtClaim.Expiration].integer; @@ -123,6 +129,17 @@ struct JwtClaims { return this; } + ref issuedAtNow() { + long now = Clock.currTime().toUnixTime!long(); + this.obj.object[JwtClaim.IssuedAt] = now; + return this; + } + + long issuedAt() const { + if (JwtClaim.IssuedAt !in this.obj.object) return -1; + return this.obj.object[JwtClaim.IssuedAt].integer; + } + ref jwtId(string jwtId) { if (jwtId is null) { this.obj.object.remove(JwtClaim.JwtId); @@ -132,6 +149,21 @@ struct JwtClaims { return this; } + string jwtId() const { + if (JwtClaim.JwtId !in this.obj.object) return null; + return this.obj.object[JwtClaim.JwtId].str; + } + + ref customClaim(string name, JSONValue value) { + this.obj.object[name] = value; + return this; + } + + JSONValue customClaim(string name) const { + if (name !in this.obj.object) return JSONValue(null); + return this.obj.object[name]; + } + string toJson() const { return this.obj.toJSON(); } diff --git a/source/jwt4d/reader.d b/source/jwt4d/reader.d index 4d77e54..9208f44 100644 --- a/source/jwt4d/reader.d +++ b/source/jwt4d/reader.d @@ -1,3 +1,7 @@ +/** + * Defines the `readJwt` function for reading a JWT from a string token, and + * associated exceptions which may be thrown for invalid tokens. + */ module jwt4d.reader; import std.json; @@ -7,36 +11,50 @@ import secured; import jwt4d.model; +/// Base type for any exception thrown while attempting to read a JWT. class JwtException : Exception { this(string message) { super(message); } } +/// Thrown if a token is malformed and cannot be read. class JwtFormatException : JwtException { this(string message) { super(message); } } +/// Thrown if a token cannot be verified. class JwtVerificationException : JwtException { this() { super("JWT could not be verified."); } } +/// Thrown if a token's "exp" claim is present and not in the future. class JwtExpiredException : JwtException { this() { super("JWT has expired."); } } +/// Thrown if a token's "nbf" claim is present and not in the past. class JwtNotBeforeException : JwtException { this() { super("JWT is not valid yet (not before time)."); } } +/// Internal struct for passing around JWT data after parsing it. +private struct JwtComponents { + JSONValue headerObj; + JSONValue claimsObj; + string payloadForSigning; + string algorithm; + ubyte[] signature; +} + /** * 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" @@ -47,36 +65,54 @@ class JwtNotBeforeException : JwtException { * 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]); + JwtComponents components = extractComponents(token); bool verified = hmac_verify_ex( - signatureBytes, + components.signature, cast(ubyte[]) secret, - cast(ubyte[]) (parts[0] ~ "." ~ parts[1]), + cast(ubyte[]) components.payloadForSigning, HashAlgorithm.SHA2_256 ); if (!verified) { throw new JwtVerificationException(); } - JwtClaims claims = JwtClaims(claimsObj); + JwtClaims claims = JwtClaims(components.claimsObj); verifyStandardTimeClaims(claims); return claims; } +private JwtComponents extractComponents(string token) { + import std.algorithm : splitter; + import std.array : array; + + auto parts = token.splitter('.').array; + if (parts.length != 3 || parts[0].length == 0 || parts[1].length == 0 || parts[2].length == 0) { + throw new JwtFormatException("Invalid token format. Couldn't parse header, payload, and signature parts."); + } + + JSONValue headerObj; + JSONValue claimsObj; + ubyte[] signature; + try { + headerObj = parseJSON(cast(string) Base64URLNoPadding.decode(parts[0])); + claimsObj = parseJSON(cast(string) Base64URLNoPadding.decode(parts[1])); + signature = Base64URLNoPadding.decode(parts[2]); + } catch (JSONException e) { + throw new JwtFormatException("Invalid JSON format."); + } catch (Base64Exception e) { + throw new JwtFormatException("Invalid Base64 encoding."); + } + + verifyHeaderFormat(headerObj); + string algorithm = headerObj.object["alg"].str; + if (algorithm != "HS256") { + throw new JwtFormatException("Unsupported algorithm: " ~ algorithm); + } + verifyClaimsFormat(claimsObj); + + return JwtComponents(headerObj, claimsObj, parts[0] ~ "." ~ parts[1], algorithm, signature); +} + private void verifyHeaderFormat(in JSONValue j) { if (j.type != JSONType.OBJECT) { throw new JwtFormatException("Header must be a JSON object."); @@ -116,4 +152,17 @@ unittest { JwtClaims readClaims = readJwt(token, "test"); import std.stdio; writeln(readClaims.toJson()); -} \ No newline at end of file +} + +// README example. +unittest { + import jwt4d; + import std.stdio; + + const string MY_SECRET = "this is a secret!"; + + string token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3NTM5MjYzMjAsImlhdCI6MTc1MzkyNDUyMCwiaXNzIjoibXkud2VicGFnZS5jb20iLCJyb2xlIjoiYWRtaW4iLCJzdWIiOiJ1c2VyMTIzIn0.n5X2giJ3S5T3wrW4C0qlZrShr2ZwPiWIu6FxUzQ3K9s"; + + JwtClaims claims = readJwt(token, MY_SECRET); + writeln(claims.toJson()); +} diff --git a/source/jwt4d/writer.d b/source/jwt4d/writer.d index 3e83c77..233c093 100644 --- a/source/jwt4d/writer.d +++ b/source/jwt4d/writer.d @@ -34,12 +34,31 @@ string writeJwt(in JwtClaims claims, string secret) { } unittest { - JwtClaims claims; - claims.issuer = "example.com"; - claims.subject = "user123"; - claims.expiration = 123; + JwtClaims claims = JwtClaims() + .issuer("example.com") + .subject("user123") + .expiration(123); string token = writeJwt(claims, "test"); import std.stdio; writeln(token); +} + +// README example. +unittest { + import jwt4d; + import std.datetime; // To set expiration in duration. + import std.json; // To add a custom claim value. + import std.stdio; + + const string MY_SECRET = "this is a secret!"; + + JwtClaims claims = JwtClaims() + .issuer("my.webpage.com") + .subject("user123") + .issuedAtNow() + .expiresIn(minutes(30)) + .customClaim("role", JSONValue("admin")); + string token = writeJwt(claims, MY_SECRET); + writeln(token); } \ No newline at end of file