From d1ca6f1d115d3d7296fa2b6641fb723b45d2f0bd Mon Sep 17 00:00:00 2001 From: rebecca <802632-arkandos@users.noreply.gitlab.com> Date: Thu, 28 May 2026 22:30:57 +0200 Subject: [PATCH] :sparkles: support array "aud" values, support float timestamps --- README.md | 2 +- TODO.md | 5 +- core/src/ywt/claim.gleam | 76 ++++++++++++++++++++--- erlang/README.md | 2 +- erlang/src/ywt.gleam | 2 +- test/tests.gleam | 129 +++++++++++++++++++++++++++++++++++++++ webcrypto/README.md | 2 +- webcrypto/src/ywt.gleam | 2 +- 8 files changed, 204 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 2bb4cc5..ebe0013 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Always add an expiration claim with `claim.expires_at`. ywt checks `exp`, `nbf`, and `aud` when they are present even if you did not configure matching claims: expired tokens are rejected, not-yet-valid tokens are rejected, and tokens with an `aud` claim are rejected unless you explicitly accept that audience. Audience -validation currently expects string values. +validation accepts string and array values. JWTs have no built-in revocation. If you need logout, account disablement, or emergency key compromise handling, keep server-side state such as short token diff --git a/TODO.md b/TODO.md index 87a4617..bcff195 100644 --- a/TODO.md +++ b/TODO.md @@ -6,5 +6,8 @@ - [x] Introduce separate error for HeaderDecodeFailed similar to Payload - [x] Replace typ HeaderDecodeError with custom error variant. -- Make claims a non-empty list +- [x] Support array-valued `aud` claims +- [x] Use a dedicated default `aud` validator that rejects any present audience + unless the caller explicitly accepts it +- [x] Accept fractional `NumericDate` values for spec compatibility - Come up with a custom decode api to be able to error on onknown data fields diff --git a/core/src/ywt/claim.gleam b/core/src/ywt/claim.gleam index ba67061..5985d37 100644 --- a/core/src/ywt/claim.gleam +++ b/core/src/ywt/claim.gleam @@ -21,10 +21,12 @@ import gleam/dict import gleam/dynamic import gleam/dynamic/decode.{type Decoder, type Dynamic} import gleam/float +import gleam/int import gleam/json.{type Json} import gleam/list import gleam/option.{None, Some} import gleam/order +import gleam/string import gleam/time/duration.{type Duration} import gleam/time/timestamp.{type Timestamp} import ywt/internal/core.{type ParseError} @@ -117,8 +119,8 @@ pub fn issuer(issuer: String, others: List(String)) -> Claim { /// The `aud` claim identifies who the token is meant for. /// /// Tokens with an `aud` field are rejected by default unless you add this claim -/// and the value matches one of the accepted audiences. ywt currently validates -/// string audiences; array-valued `aud` fields are rejected. +/// and the value matches one of the accepted audiences. ywt validates both +/// string and array-valued `aud` fields. /// /// ```gleam /// let claims = [ @@ -128,7 +130,19 @@ pub fn issuer(issuer: String, others: List(String)) -> Claim { /// ] /// ``` pub fn audience(primary: String, others: List(String)) -> Claim { - string_claim("aud", primary, others, core.InvalidAudience) + let name = "aud" + let expected = [primary, ..others] + let value = fn() { json.string(primary) } + + let verify = + validate( + name, + audience_decoder(), + invalid_audience(expected, _), + any_audience_matches(_, expected), + ) + + PayloadClaim(name:, is_required: True, value:, verify:) } /// The `nbf` claim says the token must not be accepted before a time. @@ -294,21 +308,53 @@ fn string_claim( PayloadClaim(name:, is_required: True, value:, verify:) } +fn audience_decoder() -> Decoder(List(String)) { + decode.one_of(decode.map(decode.string, list.wrap), or: [ + decode.list(decode.string), + ]) +} + +fn any_audience_matches(found: List(String), expected: List(String)) -> Bool { + use audience <- list.any(found) + list.contains(expected, audience) +} + +fn invalid_audience(expected: List(String), found: List(String)) -> ParseError { + let actual = case found { + [] -> "[]" + [_, ..] -> string.join(found, with: ", ") + } + + core.InvalidAudience(expected, actual) +} + +fn timestamp_from_numeric_date_float(seconds: Float) -> Timestamp { + let whole_seconds = seconds |> float.floor |> float.truncate + let fractional_seconds = seconds -. int.to_float(whole_seconds) + let nanoseconds = float.round(fractional_seconds *. 1_000_000_000.0) + + timestamp.from_unix_seconds_and_nanoseconds( + seconds: whole_seconds, + nanoseconds: nanoseconds, + ) +} + /// Decodes a JWT numeric date into a timestamp. /// -/// JWT numeric dates are integer seconds since the Unix epoch. +/// JWT numeric dates are seconds since the Unix epoch. /// /// ```gleam /// decode.field("exp", claim.numeric_date_decoder()) /// ``` pub fn numeric_date_decoder() -> Decoder(Timestamp) { - decode.map(decode.int, timestamp.from_unix_seconds) + decode.one_of(decode.map(decode.int, timestamp.from_unix_seconds), or: [ + decode.map(decode.float, timestamp_from_numeric_date_float), + ]) } /// Encodes a timestamp as a JWT numeric date. /// -/// Fractional seconds are truncated because JWT numeric dates use integer Unix -/// seconds. +/// Fractional seconds are truncated to keep encoded JWTs compact. /// /// ```gleam /// #("checked_at", claim.encode_numeric_date(timestamp.system_time())) @@ -354,7 +400,7 @@ pub fn optional(claim: Claim) -> Claim { /// /// Tokens with `exp` and `nbf` are checked by default with zero leeway. Tokens /// with `aud` are rejected by default unless you pass an `audience` claim. -/// Audience validation expects a string value. +/// Audience validation accepts string or array values. pub fn verify( payload: Dynamic, claims: List(Claim), @@ -366,7 +412,7 @@ pub fn verify( /// /// This is a low-level helper used by ywt. It adds default checks for `exp`, /// `nbf`, and `aud` when you have not configured those claims yourself. -/// Audience validation expects a string value. +/// Audience validation accepts string or array values. @internal pub fn verify_with_header( header: Dynamic, @@ -376,10 +422,20 @@ pub fn verify_with_header( claims |> add_default_claim(expires_at(duration.seconds(0), duration.seconds(0))) |> add_default_claim(not_before(timestamp.system_time(), duration.seconds(0))) - |> add_default_claim(audience("", [])) + |> add_default_claim(reject_present_audience()) |> list.try_each(verify_single_claim(header, payload, _)) } +fn reject_present_audience() -> Claim { + let name = "aud" + let value = fn() { json.string("") } + + let verify = + validate(name, audience_decoder(), invalid_audience([], _), fn(_) { False }) + + PayloadClaim(name:, is_required: False, value:, verify:) +} + fn add_default_claim(claims, claim: Claim) { // TODO: @Speed case has_payload_claim(claims, claim.name) { diff --git a/erlang/README.md b/erlang/README.md index d6fecc1..ba9752a 100644 --- a/erlang/README.md +++ b/erlang/README.md @@ -50,7 +50,7 @@ Always add an expiration claim with `claim.expires_at`. ywt checks `exp`, `nbf`, and `aud` when they are present even if you did not configure matching claims: expired tokens are rejected, not-yet-valid tokens are rejected, and tokens with an `aud` claim are rejected unless you explicitly accept that audience. Audience -validation currently expects string values. +validation accepts string and array values. JWTs have no built-in revocation. If you need logout, account disablement, or emergency key compromise handling, keep server-side state such as short token diff --git a/erlang/src/ywt.gleam b/erlang/src/ywt.gleam index 9fd8066..d9a1730 100644 --- a/erlang/src/ywt.gleam +++ b/erlang/src/ywt.gleam @@ -220,7 +220,7 @@ pub fn decode_unsafely_without_validation( /// /// Tokens with `exp` and `nbf` are checked by default with zero leeway. Tokens /// with `aud` are rejected by default unless you pass an `audience` claim. -/// Audience validation expects a string value. +/// Audience validation accepts string or array values. /// /// Unknown JWT header fields are ignored by ywt. Unknown payload fields are /// accepted or rejected by your payload decoder. diff --git a/test/tests.gleam b/test/tests.gleam index 4e91639..17e2767 100644 --- a/test/tests.gleam +++ b/test/tests.gleam @@ -49,6 +49,26 @@ fn loop(list, f) { } } +fn signed_jwt(payload: json.Json, key: SignKey) { + let verify_key = verify_key.derived(key) + let assert Ok(alg) = verify_key.algorithm(verify_key) + + let header = + json.object([ + #("alg", algorithm.to_json(alg)), + ]) + + let header = + bit_array.base64_url_encode(<>, False) + let payload = + bit_array.base64_url_encode(<>, False) + let message = header <> "." <> payload + + use signature <- await(sign_bits(<>, key)) + + resolve(message <> "." <> bit_array.base64_url_encode(signature, False)) +} + // ================================================================================================ // KEY GENERATION AND LOADING HELPERS // ================================================================================================ @@ -446,6 +466,52 @@ pub fn issuer_and_audience_validation_test() { resolve(Nil) } +pub fn audience_array_validation_test() { + use sign_key <- loop(sign_keys()) + let verify_key = verify_key.derived(sign_key) + + let payload = + json.object([ + #("sub", json.string("user123")), + #("exp", json.int(9_876_543_210)), + #( + "aud", + json.array( + ["https://other.example.com", "https://api.example.com"], + json.string, + ), + ), + ]) + use jwt_with_audience <- await(signed_jwt(payload, sign_key)) + + let claims = [ + expires_at(duration.hours(1), duration.minutes(5)), + audience("https://api.example.com", []), + ] + use result <- await( + parse(jwt_with_audience, decode.dynamic, claims, [ + verify_key, + ]), + ) + let assert Ok(_) = result + + let wrong_claims = [ + expires_at(duration.hours(1), duration.minutes(5)), + audience("https://different-api.com", []), + ] + use result <- await( + parse(jwt_with_audience, decode.dynamic, wrong_claims, [ + verify_key, + ]), + ) + let assert Error(InvalidAudience( + _, + "https://other.example.com, https://api.example.com", + )) = result + + resolve(Nil) +} + pub fn custom_claim_validation_test() { use sign_key <- loop(sign_keys()) let verify_key = verify_key.derived(sign_key) @@ -1088,6 +1154,69 @@ pub fn rejects_audience_token_without_explicit_aud_claim_test() { resolve(Nil) } +pub fn rejects_array_audience_token_without_explicit_aud_claim_test() { + use sign_key <- loop(sign_keys()) + let verify_key = verify_key.derived(sign_key) + + let payload = + json.object([ + #("sub", json.string("user123")), + #("exp", json.int(9_876_543_210)), + #( + "aud", + json.array( + ["https://api.example.com", "https://other.example.com"], + json.string, + ), + ), + ]) + use jwt_with_aud <- await(signed_jwt(payload, sign_key)) + + use result <- await(parse(jwt_with_aud, decode.dynamic, [], [verify_key])) + let assert Error(InvalidAudience(_, _)) = result + + resolve(Nil) +} + +pub fn accepts_fractional_numeric_date_claims_test() { + use sign_key <- loop(sign_keys()) + let verify_key = verify_key.derived(sign_key) + + let now = timestamp.to_unix_seconds(timestamp.system_time()) + let payload = + json.object([ + #("sub", json.string("user123")), + #("exp", json.float(now +. 3600.5)), + #("nbf", json.float(now -. 1.5)), + ]) + use jwt <- await(signed_jwt(payload, sign_key)) + + use result <- await(parse(jwt, decode.dynamic, [], [verify_key])) + let assert Ok(_) = result + + resolve(Nil) +} + +pub fn rejects_expired_fractional_numeric_date_claim_test() { + use sign_key <- loop(sign_keys()) + let verify_key = verify_key.derived(sign_key) + + let payload = + json.object([ + #("sub", json.string("user123")), + #( + "exp", + json.float(timestamp.to_unix_seconds(timestamp.system_time()) -. 3600.5), + ), + ]) + use jwt <- await(signed_jwt(payload, sign_key)) + + use result <- await(parse(jwt, decode.dynamic, [], [verify_key])) + let assert Error(TokenExpired(_)) = result + + resolve(Nil) +} + pub fn accepts_token_without_exp_nbf_aud_fields_and_no_explicit_claims_test() { use sign_key <- loop(sign_keys()) let verify_key = verify_key.derived(sign_key) diff --git a/webcrypto/README.md b/webcrypto/README.md index a979eb4..475edf6 100644 --- a/webcrypto/README.md +++ b/webcrypto/README.md @@ -50,7 +50,7 @@ Always add an expiration claim with `claim.expires_at`. ywt checks `exp`, `nbf`, and `aud` when they are present even if you did not configure matching claims: expired tokens are rejected, not-yet-valid tokens are rejected, and tokens with an `aud` claim are rejected unless you explicitly accept that audience. Audience -validation currently expects string values. +validation accepts string and array values. JWTs have no built-in revocation. If you need logout, account disablement, or emergency key compromise handling, keep server-side state such as short token diff --git a/webcrypto/src/ywt.gleam b/webcrypto/src/ywt.gleam index b70dc76..80a691f 100644 --- a/webcrypto/src/ywt.gleam +++ b/webcrypto/src/ywt.gleam @@ -222,7 +222,7 @@ pub fn decode_unsafely_without_validation( /// /// Tokens with `exp` and `nbf` are checked by default with zero leeway. Tokens /// with `aud` are rejected by default unless you pass an `audience` claim. -/// Audience validation expects a string value. +/// Audience validation accepts string or array values. /// /// Unknown JWT header fields are ignored by ywt. Unknown payload fields are /// accepted or rejected by your payload decoder. -- 2.51.2