···5151and `aud` when they are present even if you did not configure matching claims:
5252expired tokens are rejected, not-yet-valid tokens are rejected, and tokens with
5353an `aud` claim are rejected unless you explicitly accept that audience. Audience
5454-validation currently expects string values.
5454+validation accepts string and array values.
55555656JWTs have no built-in revocation. If you need logout, account disablement, or
5757emergency key compromise handling, keep server-side state such as short token
+4-1
TODO.md
···6677- [x] Introduce separate error for HeaderDecodeFailed similar to Payload
88- [x] Replace typ HeaderDecodeError with custom error variant.
99-- Make claims a non-empty list
99+- [x] Support array-valued `aud` claims
1010+- [x] Use a dedicated default `aud` validator that rejects any present audience
1111+ unless the caller explicitly accepts it
1212+- [x] Accept fractional `NumericDate` values for spec compatibility
1013- Come up with a custom decode api to be able to error on onknown data fields
+66-10
core/src/ywt/claim.gleam
···2121import gleam/dynamic
2222import gleam/dynamic/decode.{type Decoder, type Dynamic}
2323import gleam/float
2424+import gleam/int
2425import gleam/json.{type Json}
2526import gleam/list
2627import gleam/option.{None, Some}
2728import gleam/order
2929+import gleam/string
2830import gleam/time/duration.{type Duration}
2931import gleam/time/timestamp.{type Timestamp}
3032import ywt/internal/core.{type ParseError}
···117119/// The `aud` claim identifies who the token is meant for.
118120///
119121/// Tokens with an `aud` field are rejected by default unless you add this claim
120120-/// and the value matches one of the accepted audiences. ywt currently validates
121121-/// string audiences; array-valued `aud` fields are rejected.
122122+/// and the value matches one of the accepted audiences. ywt validates both
123123+/// string and array-valued `aud` fields.
122124///
123125/// ```gleam
124126/// let claims = [
···128130/// ]
129131/// ```
130132pub fn audience(primary: String, others: List(String)) -> Claim {
131131- string_claim("aud", primary, others, core.InvalidAudience)
133133+ let name = "aud"
134134+ let expected = [primary, ..others]
135135+ let value = fn() { json.string(primary) }
136136+137137+ let verify =
138138+ validate(
139139+ name,
140140+ audience_decoder(),
141141+ invalid_audience(expected, _),
142142+ any_audience_matches(_, expected),
143143+ )
144144+145145+ PayloadClaim(name:, is_required: True, value:, verify:)
132146}
133147134148/// The `nbf` claim says the token must not be accepted before a time.
···294308 PayloadClaim(name:, is_required: True, value:, verify:)
295309}
296310311311+fn audience_decoder() -> Decoder(List(String)) {
312312+ decode.one_of(decode.map(decode.string, list.wrap), or: [
313313+ decode.list(decode.string),
314314+ ])
315315+}
316316+317317+fn any_audience_matches(found: List(String), expected: List(String)) -> Bool {
318318+ use audience <- list.any(found)
319319+ list.contains(expected, audience)
320320+}
321321+322322+fn invalid_audience(expected: List(String), found: List(String)) -> ParseError {
323323+ let actual = case found {
324324+ [] -> "[]"
325325+ [_, ..] -> string.join(found, with: ", ")
326326+ }
327327+328328+ core.InvalidAudience(expected, actual)
329329+}
330330+331331+fn timestamp_from_numeric_date_float(seconds: Float) -> Timestamp {
332332+ let whole_seconds = seconds |> float.floor |> float.truncate
333333+ let fractional_seconds = seconds -. int.to_float(whole_seconds)
334334+ let nanoseconds = float.round(fractional_seconds *. 1_000_000_000.0)
335335+336336+ timestamp.from_unix_seconds_and_nanoseconds(
337337+ seconds: whole_seconds,
338338+ nanoseconds: nanoseconds,
339339+ )
340340+}
341341+297342/// Decodes a JWT numeric date into a timestamp.
298343///
299299-/// JWT numeric dates are integer seconds since the Unix epoch.
344344+/// JWT numeric dates are seconds since the Unix epoch.
300345///
301346/// ```gleam
302347/// decode.field("exp", claim.numeric_date_decoder())
303348/// ```
304349pub fn numeric_date_decoder() -> Decoder(Timestamp) {
305305- decode.map(decode.int, timestamp.from_unix_seconds)
350350+ decode.one_of(decode.map(decode.int, timestamp.from_unix_seconds), or: [
351351+ decode.map(decode.float, timestamp_from_numeric_date_float),
352352+ ])
306353}
307354308355/// Encodes a timestamp as a JWT numeric date.
309356///
310310-/// Fractional seconds are truncated because JWT numeric dates use integer Unix
311311-/// seconds.
357357+/// Fractional seconds are truncated to keep encoded JWTs compact.
312358///
313359/// ```gleam
314360/// #("checked_at", claim.encode_numeric_date(timestamp.system_time()))
···354400///
355401/// Tokens with `exp` and `nbf` are checked by default with zero leeway. Tokens
356402/// with `aud` are rejected by default unless you pass an `audience` claim.
357357-/// Audience validation expects a string value.
403403+/// Audience validation accepts string or array values.
358404pub fn verify(
359405 payload: Dynamic,
360406 claims: List(Claim),
···366412///
367413/// This is a low-level helper used by ywt. It adds default checks for `exp`,
368414/// `nbf`, and `aud` when you have not configured those claims yourself.
369369-/// Audience validation expects a string value.
415415+/// Audience validation accepts string or array values.
370416@internal
371417pub fn verify_with_header(
372418 header: Dynamic,
···376422 claims
377423 |> add_default_claim(expires_at(duration.seconds(0), duration.seconds(0)))
378424 |> add_default_claim(not_before(timestamp.system_time(), duration.seconds(0)))
379379- |> add_default_claim(audience("", []))
425425+ |> add_default_claim(reject_present_audience())
380426 |> list.try_each(verify_single_claim(header, payload, _))
427427+}
428428+429429+fn reject_present_audience() -> Claim {
430430+ let name = "aud"
431431+ let value = fn() { json.string("") }
432432+433433+ let verify =
434434+ validate(name, audience_decoder(), invalid_audience([], _), fn(_) { False })
435435+436436+ PayloadClaim(name:, is_required: False, value:, verify:)
381437}
382438383439fn add_default_claim(claims, claim: Claim) {
+1-1
erlang/README.md
···5050and `aud` when they are present even if you did not configure matching claims:
5151expired tokens are rejected, not-yet-valid tokens are rejected, and tokens with
5252an `aud` claim are rejected unless you explicitly accept that audience. Audience
5353-validation currently expects string values.
5353+validation accepts string and array values.
54545555JWTs have no built-in revocation. If you need logout, account disablement, or
5656emergency key compromise handling, keep server-side state such as short token
+1-1
erlang/src/ywt.gleam
···220220///
221221/// Tokens with `exp` and `nbf` are checked by default with zero leeway. Tokens
222222/// with `aud` are rejected by default unless you pass an `audience` claim.
223223-/// Audience validation expects a string value.
223223+/// Audience validation accepts string or array values.
224224///
225225/// Unknown JWT header fields are ignored by ywt. Unknown payload fields are
226226/// accepted or rejected by your payload decoder.
+129
test/tests.gleam
···4949 }
5050}
51515252+fn signed_jwt(payload: json.Json, key: SignKey) {
5353+ let verify_key = verify_key.derived(key)
5454+ let assert Ok(alg) = verify_key.algorithm(verify_key)
5555+5656+ let header =
5757+ json.object([
5858+ #("alg", algorithm.to_json(alg)),
5959+ ])
6060+6161+ let header =
6262+ bit_array.base64_url_encode(<<json.to_string(header):utf8>>, False)
6363+ let payload =
6464+ bit_array.base64_url_encode(<<json.to_string(payload):utf8>>, False)
6565+ let message = header <> "." <> payload
6666+6767+ use signature <- await(sign_bits(<<message:utf8>>, key))
6868+6969+ resolve(message <> "." <> bit_array.base64_url_encode(signature, False))
7070+}
7171+5272// ================================================================================================
5373// KEY GENERATION AND LOADING HELPERS
5474// ================================================================================================
···442462 parse(jwt_with_audience, decode.dynamic, wrong_audience_claims, [verify_key]),
443463 )
444464 let assert Error(InvalidAudience(_, _)) = result
465465+466466+ resolve(Nil)
467467+}
468468+469469+pub fn audience_array_validation_test() {
470470+ use sign_key <- loop(sign_keys())
471471+ let verify_key = verify_key.derived(sign_key)
472472+473473+ let payload =
474474+ json.object([
475475+ #("sub", json.string("user123")),
476476+ #("exp", json.int(9_876_543_210)),
477477+ #(
478478+ "aud",
479479+ json.array(
480480+ ["https://other.example.com", "https://api.example.com"],
481481+ json.string,
482482+ ),
483483+ ),
484484+ ])
485485+ use jwt_with_audience <- await(signed_jwt(payload, sign_key))
486486+487487+ let claims = [
488488+ expires_at(duration.hours(1), duration.minutes(5)),
489489+ audience("https://api.example.com", []),
490490+ ]
491491+ use result <- await(
492492+ parse(jwt_with_audience, decode.dynamic, claims, [
493493+ verify_key,
494494+ ]),
495495+ )
496496+ let assert Ok(_) = result
497497+498498+ let wrong_claims = [
499499+ expires_at(duration.hours(1), duration.minutes(5)),
500500+ audience("https://different-api.com", []),
501501+ ]
502502+ use result <- await(
503503+ parse(jwt_with_audience, decode.dynamic, wrong_claims, [
504504+ verify_key,
505505+ ]),
506506+ )
507507+ let assert Error(InvalidAudience(
508508+ _,
509509+ "https://other.example.com, https://api.example.com",
510510+ )) = result
445511446512 resolve(Nil)
447513}
···10841150 parse(jwt_with_aud, decode.dynamic, parse_claims, [verify_key]),
10851151 )
10861152 let assert Error(InvalidAudience(_, _)) = result
11531153+11541154+ resolve(Nil)
11551155+}
11561156+11571157+pub fn rejects_array_audience_token_without_explicit_aud_claim_test() {
11581158+ use sign_key <- loop(sign_keys())
11591159+ let verify_key = verify_key.derived(sign_key)
11601160+11611161+ let payload =
11621162+ json.object([
11631163+ #("sub", json.string("user123")),
11641164+ #("exp", json.int(9_876_543_210)),
11651165+ #(
11661166+ "aud",
11671167+ json.array(
11681168+ ["https://api.example.com", "https://other.example.com"],
11691169+ json.string,
11701170+ ),
11711171+ ),
11721172+ ])
11731173+ use jwt_with_aud <- await(signed_jwt(payload, sign_key))
11741174+11751175+ use result <- await(parse(jwt_with_aud, decode.dynamic, [], [verify_key]))
11761176+ let assert Error(InvalidAudience(_, _)) = result
11771177+11781178+ resolve(Nil)
11791179+}
11801180+11811181+pub fn accepts_fractional_numeric_date_claims_test() {
11821182+ use sign_key <- loop(sign_keys())
11831183+ let verify_key = verify_key.derived(sign_key)
11841184+11851185+ let now = timestamp.to_unix_seconds(timestamp.system_time())
11861186+ let payload =
11871187+ json.object([
11881188+ #("sub", json.string("user123")),
11891189+ #("exp", json.float(now +. 3600.5)),
11901190+ #("nbf", json.float(now -. 1.5)),
11911191+ ])
11921192+ use jwt <- await(signed_jwt(payload, sign_key))
11931193+11941194+ use result <- await(parse(jwt, decode.dynamic, [], [verify_key]))
11951195+ let assert Ok(_) = result
11961196+11971197+ resolve(Nil)
11981198+}
11991199+12001200+pub fn rejects_expired_fractional_numeric_date_claim_test() {
12011201+ use sign_key <- loop(sign_keys())
12021202+ let verify_key = verify_key.derived(sign_key)
12031203+12041204+ let payload =
12051205+ json.object([
12061206+ #("sub", json.string("user123")),
12071207+ #(
12081208+ "exp",
12091209+ json.float(timestamp.to_unix_seconds(timestamp.system_time()) -. 3600.5),
12101210+ ),
12111211+ ])
12121212+ use jwt <- await(signed_jwt(payload, sign_key))
12131213+12141214+ use result <- await(parse(jwt, decode.dynamic, [], [verify_key]))
12151215+ let assert Error(TokenExpired(_)) = result
1087121610881217 resolve(Nil)
10891218}
+1-1
webcrypto/README.md
···5050and `aud` when they are present even if you did not configure matching claims:
5151expired tokens are rejected, not-yet-valid tokens are rejected, and tokens with
5252an `aud` claim are rejected unless you explicitly accept that audience. Audience
5353-validation currently expects string values.
5353+validation accepts string and array values.
54545555JWTs have no built-in revocation. If you need logout, account disablement, or
5656emergency key compromise handling, keep server-side state such as short token
+1-1
webcrypto/src/ywt.gleam
···222222///
223223/// Tokens with `exp` and `nbf` are checked by default with zero leeway. Tokens
224224/// with `aud` are rejected by default unless you pass an `audience` claim.
225225-/// Audience validation expects a string value.
225225+/// Audience validation accepts string or array values.
226226///
227227/// Unknown JWT header fields are ignored by ywt. Unknown payload fields are
228228/// accepted or rejected by your payload decoder.