///|
/// # mjwt — JSON Web Token library for MoonBit
///
/// A pure-MoonBit JWT library with extensible trait-based signer architecture.
/// Supports HMAC, RSA, and ECDSA signing algorithms. Passes **32 unit tests**
/// and is cross-validated against a Python reference implementation — HMAC
/// tokens are verified to interoperate in both directions.
///
/// > Requires MoonBit **v0.10.4+** (uses `extend` syntax for explicit trait
/// > method mounting).
///
/// ## Supported algorithms
///
/// | Algorithm | Type | Hash | Curve | Test |
/// |-----------|-----------------|------------|-------|------|
/// | **HS256** | HMAC symmetric | SHA-256 | — | ✅ |
/// | **HS384** | HMAC symmetric | SHA-384† | — | ✅ |
/// | **HS512** | HMAC symmetric | SHA-512† | — | ✅ |
/// | **RS256** | RSA PKCS#1 v1.5 | SHA-256 | — | ✅ |
/// | **ES256** | ECDSA | SHA-256 | P-256 | ✅ |
///
/// † SHA-384 / SHA-512 are self-implemented per FIPS 180-4 and verified with
/// NIST known-answer tests (empty, short, multi-block) and RFC 4231 HMAC test
/// vectors.
///
/// ## Quick start (HS256)
///
/// ```moonbit
/// let claims = JwtClaims::new()
/// claims.set_subject("user123")
///
/// let token = @mjwt.encode(claims, "my-secret")
/// let decoded = @mjwt.decode(token, "my-secret")
/// @mjwt.verify(token, "my-secret") // true
///
/// // Inspect without verification (debugging only)
/// let raw = @mjwt.decode_without_verify(token)
/// raw.claims.get_subject() // Some("user123")
/// ```
///
/// ## Trait-based API (extensible)
///
/// ```moonbit
/// let signer = HmacSigner::new("HS256", "my-secret")
/// let verifier = HmacVerifier::new("HS256", "my-secret")
/// let token = @mjwt.encode_with(signer, claims)
/// let decoded = @mjwt.decode_with(verifier, token)
/// ```
///
/// ## Expiration check
///
/// ```moonbit
/// claims.set_expiration(1_800_000_000L)
/// claims.is_expired() // false (year 2027 is still in the future)
/// claims.is_now_valid() // true (no nbf constraint)
/// ```
///
/// ## Architecture
///
/// ```
/// ┌──────────────────────┐
/// │ JwtSigner trait │
/// │ JwtVerifier trait │
/// └──────────┬───────────┘
/// │ implements
/// ┌─────────────────────┼──────────────────────┐
/// ▼ ▼ ▼
/// HmacSigner RsaSigner EcSigner
/// HmacVerifier RsaVerifier EcVerifier
/// (HS256/384/512) (RS256) (ES256 P-256)
/// ```
///
/// | File | Responsibility |
/// |------|---------------|
/// | `mjwt.mbt` | Core: errors, traits, `JwtHeader`, `JwtClaims`, `JwtToken`, Base64URL, public API |
/// | `mjwt_hash_sha512.mbt` | SHA-384 / SHA-512 (FIPS 180-4, `@crypto.CryptoHasher`) |
/// | `mjwt_signer_hmac.mbt` | `HmacSigner` / `HmacVerifier` |
/// | `mjwt_signer_rsa.mbt` | `RsaSigner` / `RsaVerifier` |
/// | `mjwt_signer_ecdsa.mbt` | `EcSigner` / `EcVerifier` (P-256) |
/// | `examples/example_usage.mbt` | Runnable usage examples (12 test cases) |
/// | `examples/py_compare.py` | Python cross-validation script |
///
/// ## Extending
///
/// Implement `JwtSigner` / `JwtVerifier` on any type, and use `extend` to
/// expose trait methods (required since MoonBit v0.10.4):
///
/// ```moonbit nocheck
/// struct MySigner { key : Bytes }
/// impl JwtSigner for MySigner with fn alg_name(_) -> String { "HS256" }
/// impl JwtSigner for MySigner with fn sign(self, msg) -> .. { .. }
/// pub extend MySigner with JwtSigner::{alg_name, sign}
/// let token = @mjwt.encode_with(MySigner { key }, claims)
/// ```
// =============================================================================
// Errors
// =============================================================================
///|
/// Errors that can occur during JWT encoding, decoding, or verification.
pub suberror JwtError {
/// Malformed compact serialization (not 3 dot-separated parts)
InvalidFormat(String)
/// Invalid Base64URL character encountered
InvalidBase64(String)
/// JSON parsing failure on header or claims
InvalidJson(String)
/// UTF-8 decoding failure
InvalidUtf8(String)
/// Invalid JWT header structure
InvalidHeader(String)
/// Signature does not match
SignatureMismatch
/// Requested algorithm is not supported
UnsupportedAlgorithm(String)
/// Cryptographic operation failed (e.g. key too short)
CryptoError(String)
} derive(Debug, Eq)
///|
pub extend JwtError with @moonbitlang/core/debug.Debug::{to_repr}
///|
pub extend JwtError with Eq::{not_equal, equal}
///|
pub fn JwtError::to_string(self : JwtError) -> String {
match self {
InvalidFormat(msg) => "invalid JWT format: \{msg}"
InvalidBase64(msg) => "invalid base64url: \{msg}"
InvalidJson(msg) => "invalid JSON: \{msg}"
InvalidUtf8(msg) => "invalid UTF-8: \{msg}"
InvalidHeader(msg) => "invalid header: \{msg}"
SignatureMismatch => "signature verification failed"
UnsupportedAlgorithm(s) => "unsupported algorithm: \{s}"
CryptoError(msg) => "crypto error: \{msg}"
}
}
// =============================================================================
// Traits — pluggable signing & verification
// =============================================================================
///|
/// Pluggable JWT signer.
///
/// Implement this on any type that holds key material.
///
/// ```moonbit nocheck
/// struct RsaSigner { n: BigUint, d: BigUint }
/// impl JwtSigner for RsaSigner with fn alg_name(_) -> String { "RS256" }
/// impl JwtSigner for RsaSigner with fn sign(self, msg) -> .. { .. }
/// ```
pub(open) trait JwtSigner {
/// Algorithm name placed in the JWT header, e.g. `"HS256"`.
fn alg_name(Self) -> String
/// Produce a raw signature over `message`.
fn sign(Self, BytesView) -> FixedArray[Byte] raise JwtError
}
///|
/// Pluggable JWT verifier.
///
/// ```moonbit nocheck
/// struct RsaVerifier { n: BigUint, e: BigUint }
/// impl JwtVerifier for RsaVerifier with fn alg_name(_) -> String { "RS256" }
/// impl JwtVerifier for RsaVerifier with fn verify(self, msg, sig) -> .. { .. }
/// ```
pub(open) trait JwtVerifier {
/// Algorithm name, must match the signer's output.
fn alg_name(Self) -> String
/// Return `true` iff `signature` is valid for `message`.
fn verify(Self, BytesView, BytesView) -> Bool
}
// =============================================================================
// JWT Header
// =============================================================================
///|
pub(all) struct JwtHeader {
/// Algorithm name, e.g. `"HS256"`, `"RS256"`, `"ES256"`
alg : String
/// Token type, always `"JWT"`
typ : String
/// Optional key hint
kid : String?
}
///|
pub fn JwtHeader::new(alg : String, kid? : String = "") -> JwtHeader {
{ alg, typ: "JWT", kid: if kid == "" { None } else { Some(kid) } }
}
///|
fn JwtHeader::to_json(self : JwtHeader) -> Json {
let map = Map([], capacity=3)
map.set("alg", Json::string(self.alg))
map.set("typ", Json::string(self.typ))
match self.kid {
Some(kid) => map.set("kid", Json::string(kid))
None => ()
}
Json::object(map)
}
///|
fn JwtHeader::from_json(json : Json) -> JwtHeader raise JwtError {
let obj = match json {
Object(o) => o
_ => raise InvalidHeader("expected object")
}
let alg_str = match obj.get("alg") {
Some(String(s)) => s
Some(_) => raise InvalidHeader("alg must be a string")
None => raise InvalidHeader("missing alg")
}
let typ = match obj.get("typ") {
Some(String(s)) => s
Some(_) => raise InvalidHeader("typ must be a string")
None => "JWT"
}
let kid = match obj.get("kid") {
Some(String(s)) => Some(s)
_ => None
}
{ alg: alg_str, typ, kid }
}
// =============================================================================
// JWT Claims
// =============================================================================
///|
pub(all) struct JwtClaims {
data : Map[String, Json]
}
///|
pub fn JwtClaims::new() -> JwtClaims {
{ data: Map([], capacity=10) }
}
///|
/// Store an arbitrary key-value pair in the claims payload.
/// - `key` – claim name, e.g. `"custom-claim"`
/// - `value` – any JSON value (`Json::string(...)`, `Json::number(...)`, etc.)
pub fn JwtClaims::set(self : JwtClaims, key : String, value : Json) -> Unit {
self.data.set(key, value)
}
///|
/// Retrieve a previously stored claim by name.
/// Returns `None` when the key does not exist.
pub fn JwtClaims::get(self : JwtClaims, key : String) -> Json? {
self.data.get(key)
}
///|
/// Registered claim **iss** (issuer) — identifies the principal that issued the JWT.
/// Per RFC 7519 §4.1.1.
pub fn JwtClaims::set_issuer(self : JwtClaims, iss : String) -> Unit {
self.data.set("iss", Json::string(iss))
}
///|
/// Returns the **iss** (issuer) value, or `None` if absent.
pub fn JwtClaims::get_issuer(self : JwtClaims) -> String? {
match self.data.get("iss") {
Some(String(s)) => Some(s)
_ => None
}
}
///|
/// Registered claim **sub** (subject) — identifies the principal that is the
/// subject of the JWT. Per RFC 7519 §4.1.2.
pub fn JwtClaims::set_subject(self : JwtClaims, sub : String) -> Unit {
self.data.set("sub", Json::string(sub))
}
///|
/// Returns the **sub** (subject) value, or `None` if absent.
pub fn JwtClaims::get_subject(self : JwtClaims) -> String? {
match self.data.get("sub") {
Some(String(s)) => Some(s)
_ => None
}
}
///|
/// Registered claim **aud** (audience) — identifies the recipients that the JWT
/// is intended for. Per RFC 7519 §4.1.3.
pub fn JwtClaims::set_audience(self : JwtClaims, aud : String) -> Unit {
self.data.set("aud", Json::string(aud))
}
///|
/// Returns the **aud** (audience) value, or `None` if absent.
pub fn JwtClaims::get_audience(self : JwtClaims) -> String? {
match self.data.get("aud") {
Some(String(s)) => Some(s)
_ => None
}
}
///|
/// Registered claim **exp** (expiration time) — Unix timestamp after which the
/// JWT MUST NOT be accepted. Per RFC 7519 §4.1.4.
pub fn JwtClaims::set_expiration(self : JwtClaims, exp : Int64) -> Unit {
self.data.set("exp", Json::number(exp.to_double()))
}
///|
/// Returns the **exp** (expiration time) value, or `None` if absent.
pub fn JwtClaims::get_expiration(self : JwtClaims) -> Int64? {
match self.data.get("exp") {
Some(Number(n, ..)) => Some(n.to_int64())
_ => None
}
}
///|
/// Returns `true` when the current time (UTC) is **past** the **exp** claim.
/// Returns `false` if **exp** is not set, so it is **safe** to call on any claims.
pub fn JwtClaims::is_expired(self : JwtClaims) -> Bool {
match self.get_expiration() {
Some(exp) => (@env.now() / 1000).reinterpret_as_int64() >= exp
None => false
}
}
///|
/// Checks both **nbf** (not-before) and **exp** (expiration) against the
/// current UTC time.
///
/// Returns `true` iff:
/// - **nbf** is absent OR `now >= nbf`
/// - **exp** is absent OR `now < exp`
pub fn JwtClaims::is_now_valid(self : JwtClaims) -> Bool {
let now_s = (@env.now() / 1000).reinterpret_as_int64()
match self.get_not_before() {
Some(nbf) => if now_s < nbf { return false }
None => ()
}
match self.get_expiration() {
Some(exp) => if now_s >= exp { return false }
None => ()
}
true
}
///|
/// Registered claim **nbf** (not before) — Unix timestamp before which the JWT
/// MUST NOT be accepted. Per RFC 7519 §4.1.5.
pub fn JwtClaims::set_not_before(self : JwtClaims, nbf : Int64) -> Unit {
self.data.set("nbf", Json::number(nbf.to_double()))
}
///|
/// Returns the **nbf** (not before) value, or `None` if absent.
pub fn JwtClaims::get_not_before(self : JwtClaims) -> Int64? {
match self.data.get("nbf") {
Some(Number(n, ..)) => Some(n.to_int64())
_ => None
}
}
///|
/// Registered claim **iat** (issued at) — Unix timestamp when the JWT was
/// created. Per RFC 7519 §4.1.6.
pub fn JwtClaims::set_issued_at(self : JwtClaims, iat : Int64) -> Unit {
self.data.set("iat", Json::number(iat.to_double()))
}
///|
/// Returns the **iat** (issued at) value, or `None` if absent.
pub fn JwtClaims::get_issued_at(self : JwtClaims) -> Int64? {
match self.data.get("iat") {
Some(Number(n, ..)) => Some(n.to_int64())
_ => None
}
}
///|
/// Registered claim **jti** (JWT ID) — a unique identifier for the JWT.
/// Per RFC 7519 §4.1.7.
pub fn JwtClaims::set_jwt_id(self : JwtClaims, jti : String) -> Unit {
self.data.set("jti", Json::string(jti))
}
///|
/// Returns the **jti** (JWT ID) value, or `None` if absent.
pub fn JwtClaims::get_jwt_id(self : JwtClaims) -> String? {
match self.data.get("jti") {
Some(String(s)) => Some(s)
_ => None
}
}
///|
fn JwtClaims::to_json(self : JwtClaims) -> Json {
Json::object(self.data)
}
///|
fn JwtClaims::from_json(json : Json) -> JwtClaims {
let data = match json {
Object(o) => o
_ => Map([], capacity=0)
}
{ data, }
}
// =============================================================================
// JWT Token (parsed representation)
// =============================================================================
///|
pub(all) struct JwtToken {
header : JwtHeader
claims : JwtClaims
signature : FixedArray[Byte]
}
// =============================================================================
// Internal helpers
// =============================================================================
///|
fn split_by_char(s : String, delim : Char) -> Array[String] {
let result : Array[String] = []
let mut current = StringBuilder::new()
for ch in s {
if ch == delim {
result.push(current.to_string())
current = StringBuilder::new()
} else {
current.write_char(ch)
}
}
result.push(current.to_string())
result
}
///|
fn base64url_encode(input : BytesView) -> String {
let raw = @base64.encode(input, url_safe=true)
let chars = raw.to_array()
let mut n = chars.length()
for i = chars.length() - 1; i >= 0; i = i - 1 {
if chars[i] == '=' {
n = i
} else {
break
}
}
let out = StringBuilder::new()
for i = 0; i < n; i = i + 1 {
out.write_char(chars[i])
}
out.to_string()
}
///|
fn base64url_decode(input : StringView) -> Bytes raise JwtError {
@base64.decode(input, url_safe=true) catch {
@base64.InvalidChar(ch) => raise InvalidBase64("invalid character: \{ch}")
}
}
///|
fn string_to_bytes(s : String) -> Bytes {
@encoding.encode(UTF8, s)
}
///|
fn split_token(sv : StringView) -> (String, String, String) raise JwtError {
let parts = split_by_char(sv.to_owned(), '.')
guard parts.length() == 3 else { raise InvalidFormat("expected 3 parts") }
(parts[0], parts[1], parts[2])
}
///|
fn decode_json_str(data : Bytes) -> (String, Json) raise JwtError {
let s = @encoding.decoder(UTF8).decode(data.view(), stream=false) catch {
_ => raise InvalidUtf8("invalid UTF-8")
}
let j = @json5.parse(s) catch { _ => raise InvalidJson("parse error") }
(s, j)
}
// =============================================================================
// Public API — trait-based
// =============================================================================
///|
/// Encode `claims` into a compact JWT string signed by `signer`.
///
/// ```moonbit nocheck
/// let s = HmacSigner::new("HS256", "my-secret")?
/// let t = mjwt::encode_with(s, claims)?
/// ```
pub fn[S : JwtSigner] encode_with(
signer : S,
claims : JwtClaims,
) -> String raise JwtError {
let hdr = JwtHeader::new(signer.alg_name())
let hdr_b64 = base64url_encode(
string_to_bytes(hdr.to_json().stringify()).view(),
)
let pay_b64 = base64url_encode(
string_to_bytes(claims.to_json().stringify()).view(),
)
let input = hdr_b64 + "." + pay_b64
let sig = signer.sign(string_to_bytes(input).view())
input + "." + base64url_encode(sig.unsafe_reinterpret_as_bytes().view())
}
///|
/// Verify and decode a compact JWT string using `verifier`.
///
/// ```moonbit nocheck
/// let v = HmacVerifier::new("HS256", "my-secret")?
/// let tok = mjwt::decode_with(v, token)?
/// ```
pub fn[V : JwtVerifier] decode_with(
verifier : V,
sv : StringView,
) -> JwtToken raise JwtError {
let (hdr_b64, pay_b64, sig_b64) = split_token(sv)
let hdr = JwtHeader::from_json(decode_json_str(base64url_decode(hdr_b64)).1)
let claims = JwtClaims::from_json(
decode_json_str(base64url_decode(pay_b64)).1,
)
let input = string_to_bytes(hdr_b64 + "." + pay_b64)
let sig = base64url_decode(sig_b64)
if !verifier.verify(input.view(), sig.view()) {
raise SignatureMismatch
}
{ header: hdr, claims, signature: sig.to_fixedarray() }
}
// =============================================================================
// Convenience wrappers (defaults to HS256)
// =============================================================================
///|
/// Encode using HMAC-SHA256.
pub fn encode(claims : JwtClaims, secret : String) -> String raise JwtError {
encode_with(HmacSigner::new("HS256", secret) catch { e => raise e }, claims)
}
///|
/// Decode and verify HMAC-SHA256.
pub fn decode(sv : StringView, secret : String) -> JwtToken raise JwtError {
decode_with(HmacVerifier::new("HS256", secret) catch { e => raise e }, sv)
}
///|
/// Decode **without** signature verification. Use for inspecting tokens only.
pub fn decode_without_verify(sv : StringView) -> JwtToken raise JwtError {
let (hdr_b64, pay_b64, sig_b64) = split_token(sv)
let hdr = JwtHeader::from_json(decode_json_str(base64url_decode(hdr_b64)).1)
let claims = JwtClaims::from_json(
decode_json_str(base64url_decode(pay_b64)).1,
)
{ header: hdr, claims, signature: base64url_decode(sig_b64).to_fixedarray() }
}
///|
/// Verify HMAC-SHA256 signature. Returns `true` / `false` (no panic).
pub fn verify(sv : StringView, secret : String) -> Bool {
let v = HmacVerifier::new("HS256", secret) catch { _ => return false }
let (hdr_b64, pay_b64, sig_b64) = split_token(sv) catch { _ => return false }
let input = string_to_bytes(hdr_b64 + "." + pay_b64)
let sig = base64url_decode(sig_b64) catch { _ => return false }
v.verify(input.view(), sig.view())
}