Skip to content

Commit 7b887e6

Browse files
committed
feat: add decoder() for JWK and JWK Set types
Expose Decoder values so JWK and JWK Set parsing can compose with gleam/dynamic/decode pipelines. JWK Set provides both a lenient decoder (skips invalid keys) and a strict_decoder (fails on any invalid key).
1 parent 037a5fd commit 7b887e6

4 files changed

Lines changed: 234 additions & 1 deletion

File tree

src/gose/jwk.gleam

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1741,6 +1741,40 @@ pub fn from_json_bits(json_bits: BitArray) -> Result(Jwk, gose.GoseError) {
17411741
from_dynamic(dyn)
17421742
}
17431743

1744+
/// Return a decoder for JWK values.
1745+
///
1746+
/// This lets you compose JWK decoding inside larger decode pipelines, for
1747+
/// example with `decode.field`, `decode.list`, or `json.parse`.
1748+
///
1749+
/// ## Returns
1750+
///
1751+
/// A `Decoder(Jwk)` that succeeds on valid JWK JSON objects and fails on
1752+
/// anything else.
1753+
///
1754+
/// ## Example
1755+
///
1756+
/// ```gleam
1757+
/// // Parse a JWK directly from a JSON string
1758+
/// let assert Ok(key) = json.parse(json_string, jwk.decoder())
1759+
///
1760+
/// // Use inside a larger decoder
1761+
/// use key <- decode.field("signing_key", jwk.decoder())
1762+
/// ```
1763+
pub fn decoder() -> decode.Decoder(Jwk) {
1764+
let placeholder =
1765+
Jwk(
1766+
material: OctetKey(secret: <<>>),
1767+
kid: None,
1768+
key_use: None,
1769+
key_ops: None,
1770+
alg: None,
1771+
)
1772+
decode.new_primitive_decoder("Jwk", fn(dyn) {
1773+
from_dynamic(dyn)
1774+
|> result.replace_error(placeholder)
1775+
})
1776+
}
1777+
17441778
fn key_op_from_string(s: String) -> Result(KeyOp, gose.GoseError) {
17451779
case s {
17461780
"sign" -> Ok(Sign)

src/gose/jwk_set.gleam

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,10 @@ pub fn from_json_bits(json_bits: BitArray) -> Result(JwkSet, gose.GoseError) {
126126
/// returns an error if any key in the array fails to parse. The error
127127
/// message includes the index of the invalid key.
128128
///
129-
/// Use this for strict validation where all keys must be valid.
129+
/// Note that RFC 7517 Section 5 says implementations SHOULD ignore JWKs
130+
/// with unrecognised key types, missing required members, or unsupported
131+
/// parameter values. Prefer `from_json` unless you need to guarantee
132+
/// every key in the set is valid.
130133
///
131134
/// ## Parameters
132135
///
@@ -148,6 +151,11 @@ pub fn from_json_strict(json_str: String) -> Result(JwkSet, gose.GoseError) {
148151
/// returns an error if any key in the array fails to parse. The error
149152
/// message includes the index of the invalid key.
150153
///
154+
/// Note that RFC 7517 Section 5 says implementations SHOULD ignore JWKs
155+
/// with unrecognised key types, missing required members, or unsupported
156+
/// parameter values. Prefer `from_json_bits` unless you need to guarantee
157+
/// every key in the set is valid.
158+
///
151159
/// ## Parameters
152160
///
153161
/// - `json_bits` - The JSON BitArray containing a JWK Set object.
@@ -164,6 +172,49 @@ pub fn from_json_strict_bits(
164172
|> result.map(JwkSet)
165173
}
166174

175+
/// Return a lenient decoder for JWK Set values.
176+
///
177+
/// Invalid keys are silently skipped, matching `from_json` behavior.
178+
///
179+
/// ## Returns
180+
///
181+
/// A `Decoder(JwkSet)` that parses the `keys` array, skipping any
182+
/// individual key that fails to parse.
183+
///
184+
/// ## Example
185+
///
186+
/// ```gleam
187+
/// let assert Ok(set) = json.parse(json_string, jwk_set.decoder())
188+
/// ```
189+
pub fn decoder() -> decode.Decoder(JwkSet) {
190+
use keys_dyn <- decode.field("keys", decode.list(decode.dynamic))
191+
decode.success(parse_keys_lenient(keys_dyn))
192+
}
193+
194+
/// Return a strict decoder for JWK Set values.
195+
///
196+
/// Unlike `decoder()`, this fails if any key in the set is invalid.
197+
///
198+
/// Note that RFC 7517 Section 5 says implementations SHOULD ignore JWKs
199+
/// with unrecognised key types, missing required members, or unsupported
200+
/// parameter values. Prefer `decoder()` unless you need to guarantee
201+
/// every key in the set is valid.
202+
///
203+
/// ## Returns
204+
///
205+
/// A `Decoder(JwkSet)` that parses the `keys` array and fails if any
206+
/// individual key is invalid.
207+
///
208+
/// ## Example
209+
///
210+
/// ```gleam
211+
/// let assert Ok(set) = json.parse(json_string, jwk_set.strict_decoder())
212+
/// ```
213+
pub fn strict_decoder() -> decode.Decoder(JwkSet) {
214+
use keys <- decode.field("keys", decode.list(jwk.decoder()))
215+
decode.success(JwkSet(keys:))
216+
}
217+
167218
fn parse_keys_array(
168219
parse: fn(decode.Decoder(List(decode.Dynamic))) ->
169220
Result(List(decode.Dynamic), json.DecodeError),

test/gose/jwk_set_test.gleam

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import birdie
22
import gleam/bit_array
3+
import gleam/dynamic/decode
34
import gleam/json
45
import gleam/list
56
import gose
@@ -622,3 +623,100 @@ pub fn from_json_strict_bits_roundtrip_test() {
622623
},
623624
)
624625
}
626+
627+
pub fn decoder_skips_invalid_keys_test() {
628+
let json_str =
629+
json.object([
630+
#(
631+
"keys",
632+
json.preprocessed_array([
633+
json.object([
634+
#("kty", json.string("oct")),
635+
#("k", json.string("dGVzdA")),
636+
]),
637+
json.object([#("kty", json.string("invalid"))]),
638+
]),
639+
),
640+
])
641+
|> json.to_string
642+
let assert Ok(dyn) = json.parse(json_str, decode.dynamic)
643+
let assert Ok(set) = decode.run(dyn, jwk_set.decoder())
644+
assert list.length(jwk_set.to_list(set)) == 1
645+
}
646+
647+
pub fn decoder_all_valid_test() {
648+
let key1 = jwk.generate_hmac_key(jwa.HmacSha256)
649+
let key2 = jwk.generate_ec(ec.P256)
650+
let set = jwk_set.from_list([key1, key2])
651+
let json_str = jwk_set.to_json(set) |> json.to_string
652+
let assert Ok(parsed) = json.parse(json_str, jwk_set.decoder())
653+
assert list.length(jwk_set.to_list(parsed)) == 2
654+
}
655+
656+
pub fn strict_decoder_test() {
657+
let key1 = jwk.generate_hmac_key(jwa.HmacSha256)
658+
let key2 = jwk.generate_ec(ec.P256)
659+
let set = jwk_set.from_list([key1, key2])
660+
let json_str = jwk_set.to_json(set) |> json.to_string
661+
let assert Ok(parsed) = json.parse(json_str, jwk_set.strict_decoder())
662+
assert list.length(jwk_set.to_list(parsed)) == 2
663+
}
664+
665+
pub fn strict_decoder_invalid_key_test() {
666+
let json_str =
667+
json.object([
668+
#(
669+
"keys",
670+
json.preprocessed_array([
671+
json.object([
672+
#("kty", json.string("oct")),
673+
#("k", json.string("dGVzdA")),
674+
]),
675+
json.object([#("kty", json.string("invalid"))]),
676+
]),
677+
),
678+
])
679+
|> json.to_string
680+
let assert Error(json.UnableToDecode([
681+
decode.DecodeError(expected: "Jwk", found: _, path: ["keys", "1"]),
682+
])) = json.parse(json_str, jwk_set.strict_decoder())
683+
}
684+
685+
pub fn decoder_missing_keys_field_test() {
686+
let json_str = json.object([]) |> json.to_string
687+
let assert Error(json.UnableToDecode([
688+
decode.DecodeError(expected: "Field", found: "Nothing", path: ["keys"]),
689+
])) = json.parse(json_str, jwk_set.decoder())
690+
}
691+
692+
pub fn decoder_keys_not_array_test() {
693+
let json_str =
694+
json.object([#("keys", json.string("not-an-array"))]) |> json.to_string
695+
let assert Error(json.UnableToDecode([
696+
decode.DecodeError(expected: "List", found: _, path: ["keys"]),
697+
])) = json.parse(json_str, jwk_set.decoder())
698+
}
699+
700+
pub fn strict_decoder_missing_keys_field_test() {
701+
let json_str = json.object([]) |> json.to_string
702+
let assert Error(json.UnableToDecode([
703+
decode.DecodeError(expected: "Field", found: "Nothing", path: ["keys"]),
704+
])) = json.parse(json_str, jwk_set.strict_decoder())
705+
}
706+
707+
pub fn strict_decoder_keys_not_array_test() {
708+
let json_str =
709+
json.object([#("keys", json.string("not-an-array"))]) |> json.to_string
710+
let assert Error(json.UnableToDecode([
711+
decode.DecodeError(expected: "List", found: _, path: ["keys"]),
712+
])) = json.parse(json_str, jwk_set.strict_decoder())
713+
}
714+
715+
pub fn decoder_with_json_parse_test() {
716+
let key = jwk.generate_hmac_key(jwa.HmacSha256) |> jwk.with_kid("test-key")
717+
let set = jwk_set.from_list([key])
718+
let json_str = jwk_set.to_json(set) |> json.to_string
719+
let assert Ok(parsed) = json.parse(json_str, jwk_set.decoder())
720+
let assert Ok(found) = jwk_set.first(parsed)
721+
assert jwk.kid(found) == Ok("test-key")
722+
}

test/gose/jwk_test.gleam

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1315,3 +1315,53 @@ pub fn from_dynamic_test() {
13151315
let assert Ok(parsed) = jwk.from_dynamic(dyn)
13161316
assert jwk.key_type(parsed) == jwk.OctKeyType
13171317
}
1318+
1319+
pub fn decoder_with_decode_run_test() {
1320+
let key = jwk.generate_hmac_key(jwa.HmacSha256)
1321+
let json_str = jwk.to_json(key) |> json.to_string
1322+
let assert Ok(dyn) = json.parse(json_str, decode.dynamic)
1323+
let assert Ok(parsed) = decode.run(dyn, jwk.decoder())
1324+
assert jwk.key_type(parsed) == jwk.OctKeyType
1325+
}
1326+
1327+
pub fn decoder_with_json_parse_test() {
1328+
let key =
1329+
jwk.generate_ec(ec.P256)
1330+
|> jwk.with_kid("ec-key")
1331+
let json_str = jwk.to_json(key) |> json.to_string
1332+
let assert Ok(parsed) = json.parse(json_str, jwk.decoder())
1333+
assert jwk.key_type(parsed) == jwk.EcKeyType
1334+
assert jwk.kid(parsed) == Ok("ec-key")
1335+
}
1336+
1337+
pub fn decoder_in_nested_field_test() {
1338+
let key = jwk.generate_hmac_key(jwa.HmacSha256)
1339+
let inner_json = jwk.to_json(key)
1340+
let wrapper =
1341+
json.object([#("signing_key", inner_json)])
1342+
|> json.to_string
1343+
let wrapper_decoder = {
1344+
use k <- decode.field("signing_key", jwk.decoder())
1345+
decode.success(k)
1346+
}
1347+
let assert Ok(dyn) = json.parse(wrapper, decode.dynamic)
1348+
let assert Ok(parsed) = decode.run(dyn, wrapper_decoder)
1349+
assert jwk.key_type(parsed) == jwk.OctKeyType
1350+
}
1351+
1352+
pub fn decoder_list_test() {
1353+
let key1 = jwk.generate_hmac_key(jwa.HmacSha256)
1354+
let key2 = jwk.generate_ec(ec.P256)
1355+
let json_str =
1356+
json.preprocessed_array([jwk.to_json(key1), jwk.to_json(key2)])
1357+
|> json.to_string
1358+
let assert Ok(keys) = json.parse(json_str, decode.list(jwk.decoder()))
1359+
assert list.length(keys) == 2
1360+
}
1361+
1362+
pub fn decoder_invalid_input_test() {
1363+
let json_str =
1364+
json.object([#("not", json.string("a-jwk"))])
1365+
|> json.to_string
1366+
let assert Error(_) = json.parse(json_str, jwk.decoder())
1367+
}

0 commit comments

Comments
 (0)