diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..036b92c --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +.dub +docs.json +__dummy.html +docs/ +/jwt4d +jwt4d.so +jwt4d.dylib +jwt4d.dll +jwt4d.a +jwt4d.lib +jwt4d-test-* +*.exe +*.pdb +*.o +*.obj +*.lst +*.a diff --git a/dub.json b/dub.json new file mode 100644 index 0000000..a2a9bd2 --- /dev/null +++ b/dub.json @@ -0,0 +1,13 @@ +{ + "authors": [ + "Andrew Lalis" + ], + "copyright": "Copyright © 2025, Andrew Lalis", + "dependencies": { + "secured": "~>3.0.0" + }, + "description": "JSON Web Token library to generate, parse, sign, and verify JWTs.", + "license": "CC0", + "name": "jwt4d", + "targetType": "library" +} \ No newline at end of file diff --git a/dub.selections.json b/dub.selections.json new file mode 100644 index 0000000..3081f41 --- /dev/null +++ b/dub.selections.json @@ -0,0 +1,7 @@ +{ + "fileVersion": 1, + "versions": { + "openssl": "3.3.4", + "secured": "3.0.0" + } +} diff --git a/source/jwt4d/model.d b/source/jwt4d/model.d new file mode 100644 index 0000000..d63a62d --- /dev/null +++ b/source/jwt4d/model.d @@ -0,0 +1,193 @@ +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. + */ +enum JwtClaim { + Issuer = "iss", + Subject = "sub", + Audience = "aud", + Expiration = "exp", + NotBefore = "nbf", + IssuedAt = "iat", + JwtId = "jti" +} + +/** + * Data structure containing a JWT's claims. + */ +struct JwtClaims { + private JSONValue obj = JSONValue.emptyObject; + + ref issuer(string issuer) { + if (issuer is null) { + this.obj.object.remove(JwtClaim.Issuer); + } else { + this.obj.object[JwtClaim.Issuer] = issuer; + } + return this; + } + + string issuer() const { + if (JwtClaim.IssuedAt !in this.obj.object) return null; + return this.obj.object[JwtClaim.Issuer].str; + } + + ref subject(string subject) { + if (subject is null) { + this.obj.object.remove(JwtClaim.Subject); + } else { + this.obj.object[JwtClaim.Subject] = subject; + } + return this; + } + + string subject() const { + if (JwtClaim.Subject !in this.obj.object) return null; + return this.obj.object[JwtClaim.Subject].str; + } + + ref audience(string audience) { + if (audience is null) { + this.obj.object.remove(JwtClaim.Audience); + } else { + this.obj.object[JwtClaim.Audience] = audience; + } + return this; + } + + ref audience(string[] audiences) { + if (audiences is null || audiences.length == 0) { + this.obj.object.remove(JwtClaim.Audience); + } else { + this.obj.object[JwtClaim.Audience] = audiences; + } + return this; + } + + string audience() const { + if ( + JwtClaim.Audience !in this.obj.object || + this.obj.object[JwtClaim.Audience].type != JSONType.STRING + ) return null; + return this.obj.object[JwtClaim.Audience].str; + } + + string[] audiences() const { + import std.algorithm : map; + import std.array : array; + if ( + JwtClaim.Audience !in this.obj.object || + this.obj.object[JwtClaim.Audience].type != JSONType.ARRAY + ) return []; + return this.obj.object[JwtClaim.Audience].array.map!(v => v.str).array; + } + + ref expiration(long expiration) { + if (expiration < 0) { + this.obj.object.remove(JwtClaim.Expiration); + } else { + this.obj.object[JwtClaim.Expiration] = expiration; + } + return this; + } + + ref notBefore(long notBefore) { + if (notBefore < 0) { + this.obj.object.remove(JwtClaim.NotBefore); + } else { + this.obj.object[JwtClaim.NotBefore] = notBefore; + } + return this; + } + + ref issuedAt(long issuedAt) { + if (issuedAt < 0) { + this.obj.object.remove(JwtClaim.IssuedAt); + } else { + this.obj.object[JwtClaim.IssuedAt] = issuedAt; + } + return this; + } + + ref jwtId(string jwtId) { + if (jwtId is null) { + this.obj.object.remove(JwtClaim.JwtId); + } else { + this.obj.object[JwtClaim.JwtId] = jwtId; + } + return this; + } + + string toJson() const { + 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 new file mode 100644 index 0000000..569b836 --- /dev/null +++ b/source/jwt4d/package.d @@ -0,0 +1,2 @@ +module jwt4d; +