///|
/// base64url encoding (RFC 4648 §5, no padding): standard base64 with `+`/`/`
/// remapped to `-`/`_` and trailing `=` dropped — the alphabet JWT uses for its
/// header, payload, and signature segments.
pub fn base64url_encode(data : Bytes) -> String {
let std = @base64.encode(data[:], padding=false)
let sb = StringBuilder::new()
for i = 0; i < std.length(); i = i + 1 {
let c = std[i]
if c == '+' {
sb.write_char('-')
} else if c == '/' {
sb.write_char('_')
} else {
sb.write_char(c.unsafe_to_char())
}
}
sb.to_string()
}
///|
/// Decode a base64url string (padding optional) back to bytes, remapping
/// `-`/`_` to `+`/`/` before decoding. Lenient about missing padding, the way
/// JWT segments are written.
pub fn base64url_decode(s : String) -> Bytes {
let sb = StringBuilder::new()
for i = 0; i < s.length(); i = i + 1 {
let c = s[i]
if c == '-' {
sb.write_char('+')
} else if c == '_' {
sb.write_char('/')
} else {
sb.write_char(c.unsafe_to_char())
}
}
@base64.decode_lossy(sb.to_string())
}
///|
/// A JWT verification failure (← go-zero's `handler.Authorize` rejection cases).
/// Every path a bad token can fail on is reported distinctly so callers (and the
/// `auth` middleware) can log or branch precisely.
pub suberror JwtError {
/// Not three `.`-separated segments, an un-decodable segment, or a payload
/// that is not a JSON object.
MalformedToken(String)
/// The header's `alg` is missing or not `HS256` — including the `alg: "none"`
/// downgrade attack, which is always rejected.
UnsupportedAlg(String)
/// The recomputed HMAC signature does not match the token's signature.
BadSignature
/// The `exp` claim is at or before the verification time.
Expired
/// The `nbf` (not-before) claim is after the verification time.
NotYetValid
}
///|
/// Split a string on a single ASCII delimiter into its parts. Core ships no
/// `String::split`, so it is hand-written; used to break a `header.payload.sig`
/// token into three segments.
fn split_char(s : String, delim : Char) -> Array[String] {
let out : Array[String] = []
let sb = StringBuilder::new()
let d = delim.to_int()
for i = 0; i < s.length(); i = i + 1 {
if s[i].to_int() == d {
out.push(sb.to_string())
sb.reset()
} else {
sb.write_char(s[i].unsafe_to_char())
}
}
out.push(sb.to_string())
out
}
///|
/// The signing input `base64url(header).base64url(payload)` as bytes: the exact
/// octets HMAC-SHA256 runs over, shared by signing and verification.
fn jwt_signing_input(header_seg : String, payload_seg : String) -> Bytes {
@utf8.encode(header_seg + "." + payload_seg)
}
///|
/// Sign a claims set as a compact JWT using HS256 (← go-zero's
/// `jwt.NewWithClaims(SigningMethodHS256, ...)`). The header is fixed to
/// `{"alg":"HS256","typ":"JWT"}`; `claims` is serialised as the JSON payload
/// (include `exp`/`iat`/`nbf`/`sub`/… as ordinary entries); `secret` is the
/// shared HS256 key. Returns `header.payload.signature`, each segment
/// base64url-encoded.
pub fn jwt_sign(claims : Map[String, Json], secret : String) -> String {
let header_json =
#|{"alg":"HS256","typ":"JWT"}
let header_seg = base64url_encode(@utf8.encode(header_json))
let payload_seg = base64url_encode(
@utf8.encode(Json::object(claims).stringify()),
)
let sig = hmac_sha256(
@utf8.encode(secret),
jwt_signing_input(header_seg, payload_seg),
)
header_seg + "." + payload_seg + "." + base64url_encode(sig)
}
///|
/// Read the header segment's `alg`, raising `UnsupportedAlg` for anything but
/// `HS256` — this is where the `alg: "none"` downgrade is refused.
fn check_alg(header_seg : String) -> Unit raise JwtError {
let text = @utf8.decode_lossy(base64url_decode(header_seg)[:])
let json = @json.parse(text) catch {
_ => raise MalformedToken("header is not valid JSON")
}
match json {
Object(m) =>
match m.get("alg") {
Some(String("HS256")) => ()
Some(String(other)) => raise UnsupportedAlg(other)
_ => raise UnsupportedAlg("missing alg")
}
_ => raise MalformedToken("header is not a JSON object")
}
}
///|
/// Verify a compact HS256 JWT and return its claims (← go-zero's
/// `handler.Authorize`). Checks, in order: three segments; header `alg` is
/// `HS256`; the HMAC-SHA256 signature matches (compared in constant time);
/// `exp` (if present) is strictly after `now_secs`; `nbf` (if present) is at or
/// before `now_secs`. `now_secs` is the verification time as a Unix timestamp in
/// **seconds** (JWT `NumericDate`). Raises the matching `JwtError` on any
/// failure; a tampered payload or signature fails at `BadSignature`.
pub fn jwt_verify(
token : String,
secret : String,
now_secs : Int64,
) -> Map[String, Json] raise JwtError {
let parts = split_char(token, '.')
if parts.length() != 3 {
raise MalformedToken("expected three segments")
}
let header_seg = parts[0]
let payload_seg = parts[1]
let sig_seg = parts[2]
check_alg(header_seg)
let expected = hmac_sha256(
@utf8.encode(secret),
jwt_signing_input(header_seg, payload_seg),
)
let given = base64url_decode(sig_seg)
if !constant_time_eq(expected, given) {
raise BadSignature
}
let payload_text = @utf8.decode_lossy(base64url_decode(payload_seg)[:])
let payload = @json.parse(payload_text) catch {
_ => raise MalformedToken("payload is not valid JSON")
}
let claims = match payload {
Object(m) => m
_ => raise MalformedToken("payload is not a JSON object")
}
match claims.get("exp") {
Some(Number(exp, ..)) => if now_secs.to_double() >= exp { raise Expired }
_ => ()
}
match claims.get("nbf") {
Some(Number(nbf, ..)) => if now_secs.to_double() < nbf { raise NotYetValid }
_ => ()
}
claims
}