import gleam/bit_array import gleam/bool import gleam/dynamic/decode.{type Decoder, type Dynamic} import gleam/json.{type Json} import gleam/list import gleam/option.{type Option, None, Some} import gleam/result import gleam/string import ywt/algorithm.{type Algorithm} import ywt/claim.{type Claim} import ywt/internal/core.{type ParseError} import ywt/sign_key.{type SignKey} import ywt/verify_key.{type VerifyKey} // -- HEADER ------------------------------------------------------------------- /// Header represents the values we are interested in in a JOSE (JWT) header. type Header { Header(alg: Algorithm, kid: Option(String)) } fn header_decoder() -> Decoder(Header) { use alg <- decode.field("alg", algorithm.decoder()) use kid <- decode.optional_field("kid", None, decode.map(decode.string, Some)) decode.success(Header(alg:, kid:)) } fn encode_header( alg: Algorithm, kid: Option(String), claims: List(Claim), ) -> Json { let fields = [ #("alg", algorithm.to_json(alg)), ] let fields = case kid { Some(kid) -> [#("kid", json.string(kid)), ..fields] None -> fields } claim.encode_header(claims, fields) } // -- PARSE--------------------------------------------------------------------- /// Extracts payload from a JWT without verifying its signature or claims. pub fn decode_unsafely_without_validation( jwt: String, payload_decoder: Decoder(payload), ) -> Result(payload, Nil) { let result = { use #(_, raw_payload, _) <- result.try(split(jwt)) use dynamic_payload <- result.try(part( raw_payload, core.InvalidPayloadEncoding, core.InvalidPayloadJson, )) decode.run(dynamic_payload, payload_decoder) |> result.map_error(core.PayloadDecodingError) } result.replace_error(result, Nil) } /// Verifies a JWT signature and validates all claims, returning the decoded payload if successful. pub fn decode( jwt jwt: String, using decoder: Decoder(payload), claims claims: List(Claim), keys keys: List(VerifyKey), verify verify: fn(BitArray, BitArray, VerifyKey, fn(Bool) -> result) -> result, resolve resolve: fn(Result(payload, ParseError)) -> result, ) -> result { let continuation_or_error = { // 1. Verify that the JWT contains at least one period ('.') character. // 6. Determine whether the JWT is a JWS or a JWE using any of the // methods described in Section 9 of [JWE]. use #(raw_header, raw_payload, raw_signature) <- result.try(split(jwt)) // 2. Let the Encoded JOSE Header be the portion of the JWT before the first // period ('.') character. use dynamic_header <- result.try(part( raw_header, core.InvalidHeaderEncoding, core.InvalidHeaderJson, )) use parsed_header <- result.try( decode.run(dynamic_header, header_decoder()) |> result.map_error(fn(error) { core.InvalidHeaderJson(json.UnableToDecode(error)) }), ) // 6. Base64url-decode the encoded representation of the JWS Payload, // following the restriction that no line breaks, whitespace, or // other additional characters have been used. use dynamic_payload <- result.try(part( raw_payload, core.InvalidPayloadEncoding, core.InvalidPayloadJson, )) // 10. Verify that the resulting octet sequence is a UTF-8-encoded // representation of a completely valid JSON object conforming to // RFC 7159 [RFC7159]; let the JWT Claims Set be this JSON object. use Nil <- result.try(claim.verify_with_header( dynamic_header, dynamic_payload, claims, )) // Decode the payload using the provided decoder use parsed_payload <- result.try( decode.run(dynamic_payload, decoder) |> result.map_error(core.PayloadDecodingError), ) // 7. Base64url-decode the encoded representation of the JWS Signature, // following the restriction that no line breaks, whitespace, or // other additional characters have been used. use signature <- result.try( bit_array.base64_url_decode(raw_signature) |> result.replace_error(core.InvalidSignatureEncoding), ) let matching_keys = list.filter(keys, key_matches_header(_, parsed_header)) use <- bool.guard( when: list.is_empty(matching_keys), return: Error(core.NoMatchingKey), ) // 8. Validate the JWS Signature against the JWS Signing Input // ASCII(BASE64URL(UTF8(JWS Protected Header)) || '.' || // BASE64URL(JWS Payload)) in the manner defined for the algorithm // being used, which MUST be accurately represented by the value of // the "alg" (algorithm) Header Parameter, which MUST be present. // See Section 10.6 for security considerations on algorithm // validation. Record whether the validation succeeded or not. let verify_message = <> let verify = fn(key: VerifyKey, next: fn(Bool) -> result) -> result { verify(verify_message, signature, key, next) } let on_result = fn(result: Bool) -> result { case result { True -> resolve(Ok(parsed_payload)) False -> resolve(Error(core.InvalidSignature)) } } Ok(fn() { verify_loop(matching_keys, False, verify, on_result) }) } case continuation_or_error { Ok(continuation) -> continuation() Error(error) -> resolve(Error(error)) } } fn verify_loop( keys: List(VerifyKey), result: Bool, verify verify: fn(VerifyKey, fn(Bool) -> result) -> result, then on_result: fn(Bool) -> result, ) -> result { case keys { [] -> on_result(result) [key, ..keys] -> { use is_valid <- verify(key) let result = result || is_valid verify_loop(keys, result, verify, on_result) } } } /// Split a JWT/JWS into its 3 parts - header, payload, and signature. fn split(jwt: String) -> Result(#(String, String, String), ParseError) { case string.split(jwt, on: ".") { [header, payload, signature] -> Ok(#(header, payload, signature)) _ -> Error(core.MalformedToken) } } /// Decode a raw base64-encoded part into a Gleam value with specific error types. fn part( part: String, encoding_error: ParseError, json_error: fn(json.DecodeError) -> ParseError, ) -> Result(Dynamic, ParseError) { // 3. Base64url decode the Encoded JOSE Header following the // restriction that no line breaks, whitespace, or other additional // characters have been used. use decoded <- result.try( bit_array.base64_url_decode(part) |> result.replace_error(encoding_error), ) // 4. Verify that the resulting octet sequence is a UTF-8-encoded // representation of a completely valid JSON object conforming to // RFC 7159 [RFC7159]; let the JOSE Header be this JSON object. json.parse_bits(decoded, decode.dynamic) |> result.map_error(json_error) } /// Pairs up a key candidate with the data given in the JWT header fn key_matches_header(key: VerifyKey, header: Header) -> Bool { // 5. Verify that the resulting JOSE Header includes only parameters // and values whose syntax and semantics are both understood and // supported or that are specified as being ignored when not // understood. // // The producer SHOULD include sufficient information in the Header // Parameters to identify the key used, unless the application uses // another means or convention to determine the key used. Validation of // the signature or MAC fails when the algorithm used requires a key // (which is true of all algorithms except for "none") and the key used // cannot be determined. // let key_alg = verify_key.algorithm(key) case header.kid, verify_key.id(key) { // if the header and the key have an id, they have to match. Some(header_kid), Ok(key_id) -> header_kid == key_id && key_alg == header.alg // a header with a key id can only match keys with an id. Some(_), Error(_) -> False // if the header has no id set, only match based on the algorithm _, _ -> key_alg == header.alg } } // -- SIGN --------------------------------------------------------------------- /// Creates a signed JWT containing the specified payload and claims. pub fn encode( payload payload: List(#(String, Json)), claims claims: List(Claim), key key: SignKey, sign sign: fn(BitArray, SignKey, fn(BitArray) -> String) -> result, ) -> result { let alg = sign_key.algorithm(key) let header = encode_json(encode_header(alg, option.from_result(sign_key.id(key)), claims)) let payload = encode_json(claim.encode(claims, payload)) let verify_message = header <> "." <> payload use signed <- sign(<>, key) let signature = bit_array.base64_url_encode(signed, False) verify_message <> "." <> signature } fn encode_json(part: Json) -> String { bit_array.base64_url_encode(<>, False) }