A JWT library for Gleam
0

Configure Feed

Select the types of activity you want to include in your feed.

:sparkles: support array "aud" values, support float timestamps

rebecca (May 28, 2026, 10:30 PM +0200) d1ca6f1d a1613511

+204 -16
+1 -1
README.md
··· 51 51 and `aud` when they are present even if you did not configure matching claims: 52 52 expired tokens are rejected, not-yet-valid tokens are rejected, and tokens with 53 53 an `aud` claim are rejected unless you explicitly accept that audience. Audience 54 - validation currently expects string values. 54 + validation accepts string and array values. 55 55 56 56 JWTs have no built-in revocation. If you need logout, account disablement, or 57 57 emergency key compromise handling, keep server-side state such as short token
+4 -1
TODO.md
··· 6 6 7 7 - [x] Introduce separate error for HeaderDecodeFailed similar to Payload 8 8 - [x] Replace typ HeaderDecodeError with custom error variant. 9 - - Make claims a non-empty list 9 + - [x] Support array-valued `aud` claims 10 + - [x] Use a dedicated default `aud` validator that rejects any present audience 11 + unless the caller explicitly accepts it 12 + - [x] Accept fractional `NumericDate` values for spec compatibility 10 13 - Come up with a custom decode api to be able to error on onknown data fields
+66 -10
core/src/ywt/claim.gleam
··· 21 21 import gleam/dynamic 22 22 import gleam/dynamic/decode.{type Decoder, type Dynamic} 23 23 import gleam/float 24 + import gleam/int 24 25 import gleam/json.{type Json} 25 26 import gleam/list 26 27 import gleam/option.{None, Some} 27 28 import gleam/order 29 + import gleam/string 28 30 import gleam/time/duration.{type Duration} 29 31 import gleam/time/timestamp.{type Timestamp} 30 32 import ywt/internal/core.{type ParseError} ··· 117 119 /// The `aud` claim identifies who the token is meant for. 118 120 /// 119 121 /// Tokens with an `aud` field are rejected by default unless you add this claim 120 - /// and the value matches one of the accepted audiences. ywt currently validates 121 - /// string audiences; array-valued `aud` fields are rejected. 122 + /// and the value matches one of the accepted audiences. ywt validates both 123 + /// string and array-valued `aud` fields. 122 124 /// 123 125 /// ```gleam 124 126 /// let claims = [ ··· 128 130 /// ] 129 131 /// ``` 130 132 pub fn audience(primary: String, others: List(String)) -> Claim { 131 - string_claim("aud", primary, others, core.InvalidAudience) 133 + let name = "aud" 134 + let expected = [primary, ..others] 135 + let value = fn() { json.string(primary) } 136 + 137 + let verify = 138 + validate( 139 + name, 140 + audience_decoder(), 141 + invalid_audience(expected, _), 142 + any_audience_matches(_, expected), 143 + ) 144 + 145 + PayloadClaim(name:, is_required: True, value:, verify:) 132 146 } 133 147 134 148 /// The `nbf` claim says the token must not be accepted before a time. ··· 294 308 PayloadClaim(name:, is_required: True, value:, verify:) 295 309 } 296 310 311 + fn audience_decoder() -> Decoder(List(String)) { 312 + decode.one_of(decode.map(decode.string, list.wrap), or: [ 313 + decode.list(decode.string), 314 + ]) 315 + } 316 + 317 + fn any_audience_matches(found: List(String), expected: List(String)) -> Bool { 318 + use audience <- list.any(found) 319 + list.contains(expected, audience) 320 + } 321 + 322 + fn invalid_audience(expected: List(String), found: List(String)) -> ParseError { 323 + let actual = case found { 324 + [] -> "[]" 325 + [_, ..] -> string.join(found, with: ", ") 326 + } 327 + 328 + core.InvalidAudience(expected, actual) 329 + } 330 + 331 + fn timestamp_from_numeric_date_float(seconds: Float) -> Timestamp { 332 + let whole_seconds = seconds |> float.floor |> float.truncate 333 + let fractional_seconds = seconds -. int.to_float(whole_seconds) 334 + let nanoseconds = float.round(fractional_seconds *. 1_000_000_000.0) 335 + 336 + timestamp.from_unix_seconds_and_nanoseconds( 337 + seconds: whole_seconds, 338 + nanoseconds: nanoseconds, 339 + ) 340 + } 341 + 297 342 /// Decodes a JWT numeric date into a timestamp. 298 343 /// 299 - /// JWT numeric dates are integer seconds since the Unix epoch. 344 + /// JWT numeric dates are seconds since the Unix epoch. 300 345 /// 301 346 /// ```gleam 302 347 /// decode.field("exp", claim.numeric_date_decoder()) 303 348 /// ``` 304 349 pub fn numeric_date_decoder() -> Decoder(Timestamp) { 305 - decode.map(decode.int, timestamp.from_unix_seconds) 350 + decode.one_of(decode.map(decode.int, timestamp.from_unix_seconds), or: [ 351 + decode.map(decode.float, timestamp_from_numeric_date_float), 352 + ]) 306 353 } 307 354 308 355 /// Encodes a timestamp as a JWT numeric date. 309 356 /// 310 - /// Fractional seconds are truncated because JWT numeric dates use integer Unix 311 - /// seconds. 357 + /// Fractional seconds are truncated to keep encoded JWTs compact. 312 358 /// 313 359 /// ```gleam 314 360 /// #("checked_at", claim.encode_numeric_date(timestamp.system_time())) ··· 354 400 /// 355 401 /// Tokens with `exp` and `nbf` are checked by default with zero leeway. Tokens 356 402 /// with `aud` are rejected by default unless you pass an `audience` claim. 357 - /// Audience validation expects a string value. 403 + /// Audience validation accepts string or array values. 358 404 pub fn verify( 359 405 payload: Dynamic, 360 406 claims: List(Claim), ··· 366 412 /// 367 413 /// This is a low-level helper used by ywt. It adds default checks for `exp`, 368 414 /// `nbf`, and `aud` when you have not configured those claims yourself. 369 - /// Audience validation expects a string value. 415 + /// Audience validation accepts string or array values. 370 416 @internal 371 417 pub fn verify_with_header( 372 418 header: Dynamic, ··· 376 422 claims 377 423 |> add_default_claim(expires_at(duration.seconds(0), duration.seconds(0))) 378 424 |> add_default_claim(not_before(timestamp.system_time(), duration.seconds(0))) 379 - |> add_default_claim(audience("", [])) 425 + |> add_default_claim(reject_present_audience()) 380 426 |> list.try_each(verify_single_claim(header, payload, _)) 427 + } 428 + 429 + fn reject_present_audience() -> Claim { 430 + let name = "aud" 431 + let value = fn() { json.string("") } 432 + 433 + let verify = 434 + validate(name, audience_decoder(), invalid_audience([], _), fn(_) { False }) 435 + 436 + PayloadClaim(name:, is_required: False, value:, verify:) 381 437 } 382 438 383 439 fn add_default_claim(claims, claim: Claim) {
+1 -1
erlang/README.md
··· 50 50 and `aud` when they are present even if you did not configure matching claims: 51 51 expired tokens are rejected, not-yet-valid tokens are rejected, and tokens with 52 52 an `aud` claim are rejected unless you explicitly accept that audience. Audience 53 - validation currently expects string values. 53 + validation accepts string and array values. 54 54 55 55 JWTs have no built-in revocation. If you need logout, account disablement, or 56 56 emergency key compromise handling, keep server-side state such as short token
+1 -1
erlang/src/ywt.gleam
··· 220 220 /// 221 221 /// Tokens with `exp` and `nbf` are checked by default with zero leeway. Tokens 222 222 /// with `aud` are rejected by default unless you pass an `audience` claim. 223 - /// Audience validation expects a string value. 223 + /// Audience validation accepts string or array values. 224 224 /// 225 225 /// Unknown JWT header fields are ignored by ywt. Unknown payload fields are 226 226 /// accepted or rejected by your payload decoder.
+129
test/tests.gleam
··· 49 49 } 50 50 } 51 51 52 + fn signed_jwt(payload: json.Json, key: SignKey) { 53 + let verify_key = verify_key.derived(key) 54 + let assert Ok(alg) = verify_key.algorithm(verify_key) 55 + 56 + let header = 57 + json.object([ 58 + #("alg", algorithm.to_json(alg)), 59 + ]) 60 + 61 + let header = 62 + bit_array.base64_url_encode(<<json.to_string(header):utf8>>, False) 63 + let payload = 64 + bit_array.base64_url_encode(<<json.to_string(payload):utf8>>, False) 65 + let message = header <> "." <> payload 66 + 67 + use signature <- await(sign_bits(<<message:utf8>>, key)) 68 + 69 + resolve(message <> "." <> bit_array.base64_url_encode(signature, False)) 70 + } 71 + 52 72 // ================================================================================================ 53 73 // KEY GENERATION AND LOADING HELPERS 54 74 // ================================================================================================ ··· 442 462 parse(jwt_with_audience, decode.dynamic, wrong_audience_claims, [verify_key]), 443 463 ) 444 464 let assert Error(InvalidAudience(_, _)) = result 465 + 466 + resolve(Nil) 467 + } 468 + 469 + pub fn audience_array_validation_test() { 470 + use sign_key <- loop(sign_keys()) 471 + let verify_key = verify_key.derived(sign_key) 472 + 473 + let payload = 474 + json.object([ 475 + #("sub", json.string("user123")), 476 + #("exp", json.int(9_876_543_210)), 477 + #( 478 + "aud", 479 + json.array( 480 + ["https://other.example.com", "https://api.example.com"], 481 + json.string, 482 + ), 483 + ), 484 + ]) 485 + use jwt_with_audience <- await(signed_jwt(payload, sign_key)) 486 + 487 + let claims = [ 488 + expires_at(duration.hours(1), duration.minutes(5)), 489 + audience("https://api.example.com", []), 490 + ] 491 + use result <- await( 492 + parse(jwt_with_audience, decode.dynamic, claims, [ 493 + verify_key, 494 + ]), 495 + ) 496 + let assert Ok(_) = result 497 + 498 + let wrong_claims = [ 499 + expires_at(duration.hours(1), duration.minutes(5)), 500 + audience("https://different-api.com", []), 501 + ] 502 + use result <- await( 503 + parse(jwt_with_audience, decode.dynamic, wrong_claims, [ 504 + verify_key, 505 + ]), 506 + ) 507 + let assert Error(InvalidAudience( 508 + _, 509 + "https://other.example.com, https://api.example.com", 510 + )) = result 445 511 446 512 resolve(Nil) 447 513 } ··· 1084 1150 parse(jwt_with_aud, decode.dynamic, parse_claims, [verify_key]), 1085 1151 ) 1086 1152 let assert Error(InvalidAudience(_, _)) = result 1153 + 1154 + resolve(Nil) 1155 + } 1156 + 1157 + pub fn rejects_array_audience_token_without_explicit_aud_claim_test() { 1158 + use sign_key <- loop(sign_keys()) 1159 + let verify_key = verify_key.derived(sign_key) 1160 + 1161 + let payload = 1162 + json.object([ 1163 + #("sub", json.string("user123")), 1164 + #("exp", json.int(9_876_543_210)), 1165 + #( 1166 + "aud", 1167 + json.array( 1168 + ["https://api.example.com", "https://other.example.com"], 1169 + json.string, 1170 + ), 1171 + ), 1172 + ]) 1173 + use jwt_with_aud <- await(signed_jwt(payload, sign_key)) 1174 + 1175 + use result <- await(parse(jwt_with_aud, decode.dynamic, [], [verify_key])) 1176 + let assert Error(InvalidAudience(_, _)) = result 1177 + 1178 + resolve(Nil) 1179 + } 1180 + 1181 + pub fn accepts_fractional_numeric_date_claims_test() { 1182 + use sign_key <- loop(sign_keys()) 1183 + let verify_key = verify_key.derived(sign_key) 1184 + 1185 + let now = timestamp.to_unix_seconds(timestamp.system_time()) 1186 + let payload = 1187 + json.object([ 1188 + #("sub", json.string("user123")), 1189 + #("exp", json.float(now +. 3600.5)), 1190 + #("nbf", json.float(now -. 1.5)), 1191 + ]) 1192 + use jwt <- await(signed_jwt(payload, sign_key)) 1193 + 1194 + use result <- await(parse(jwt, decode.dynamic, [], [verify_key])) 1195 + let assert Ok(_) = result 1196 + 1197 + resolve(Nil) 1198 + } 1199 + 1200 + pub fn rejects_expired_fractional_numeric_date_claim_test() { 1201 + use sign_key <- loop(sign_keys()) 1202 + let verify_key = verify_key.derived(sign_key) 1203 + 1204 + let payload = 1205 + json.object([ 1206 + #("sub", json.string("user123")), 1207 + #( 1208 + "exp", 1209 + json.float(timestamp.to_unix_seconds(timestamp.system_time()) -. 3600.5), 1210 + ), 1211 + ]) 1212 + use jwt <- await(signed_jwt(payload, sign_key)) 1213 + 1214 + use result <- await(parse(jwt, decode.dynamic, [], [verify_key])) 1215 + let assert Error(TokenExpired(_)) = result 1087 1216 1088 1217 resolve(Nil) 1089 1218 }
+1 -1
webcrypto/README.md
··· 50 50 and `aud` when they are present even if you did not configure matching claims: 51 51 expired tokens are rejected, not-yet-valid tokens are rejected, and tokens with 52 52 an `aud` claim are rejected unless you explicitly accept that audience. Audience 53 - validation currently expects string values. 53 + validation accepts string and array values. 54 54 55 55 JWTs have no built-in revocation. If you need logout, account disablement, or 56 56 emergency key compromise handling, keep server-side state such as short token
+1 -1
webcrypto/src/ywt.gleam
··· 222 222 /// 223 223 /// Tokens with `exp` and `nbf` are checked by default with zero leeway. Tokens 224 224 /// with `aud` are rejected by default unless you pass an `audience` claim. 225 - /// Audience validation expects a string value. 225 + /// Audience validation accepts string or array values. 226 226 /// 227 227 /// Unknown JWT header fields are ignored by ywt. Unknown payload fields are 228 228 /// accepted or rejected by your payload decoder.