Add more docs, more cleanup.

This commit is contained in:
Andrew Lalis 2025-07-30 21:18:08 -04:00
parent 4585845c95
commit 9cf62e4d36
4 changed files with 156 additions and 24 deletions

View File

@ -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());
```

View File

@ -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();
}

View File

@ -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());
}
}
// 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());
}

View File

@ -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);
}