Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .DS_Store
Binary file not shown.
2 changes: 2 additions & 0 deletions gleam.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ description = "A JWT library written in Gleam"
licences = ["Apache-2.0"]
repository = { type = "github", user = "brettkolodny", repo = "gwt" }


[dependencies]
gleam_stdlib = ">= 0.51.0 and < 2.0.0"
gleam_json = ">= 1.0.0 and < 4.0.0"
gleam_crypto = ">= 1.3.0 and < 2.0.0"
birl = ">= 1.7.0 and < 2.0.0"
gleam_javascript = ">= 1.0.0 and < 2.0.0"

[dev-dependencies]
gleeunit = ">= 1.0.0 and < 2.0.0"
2 changes: 2 additions & 0 deletions manifest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
packages = [
{ name = "birl", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_regexp", "gleam_stdlib", "ranger"], otp_app = "birl", source = "hex", outer_checksum = "2AC7BA26F998E3DFADDB657148BD5DDFE966958AD4D6D6957DD0D22E5B56C400" },
{ name = "gleam_crypto", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "917BC8B87DBD584830E3B389CBCAB140FFE7CB27866D27C6D0FB87A9ECF35602" },
{ name = "gleam_javascript", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "EF6C77A506F026C6FB37941889477CD5E4234FCD4337FF0E9384E297CB8F97EB" },
{ name = "gleam_json", version = "3.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "5BA154440B22D9800955B1AB854282FA37B97F30F409D76B0824D0A60C934188" },
{ name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" },
{ name = "gleam_stdlib", version = "0.61.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "3DC407D6EDA98FCE089150C11F3AD892B6F4C3CA77C87A97BAE8D5AB5E41F331" },
Expand All @@ -15,6 +16,7 @@ packages = [
[requirements]
birl = { version = ">= 1.7.0 and < 2.0.0" }
gleam_crypto = { version = ">= 1.3.0 and < 2.0.0" }
gleam_javascript = { version = ">= 1.0.0 and < 2.0.0" }
gleam_json = { version = ">= 1.0.0 and < 4.0.0" }
gleam_stdlib = { version = ">= 0.51.0 and < 2.0.0" }
gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
223 changes: 70 additions & 153 deletions src/gwt.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import birl
import gleam/bit_array
import gleam/crypto
import gleam/dict.{type Dict}
import gleam/dynamic
import gleam/dynamic/decode.{type DecodeError, type Decoder, type Dynamic}
Expand All @@ -12,6 +11,8 @@ import gleam/option.{type Option, None, Some}
import gleam/result
import gleam/string

import gwt/utils

// TYPES -----------------------------------------------------------------------

/// A phantom type representing a Jwt successfully decoded from a signed string.
Expand All @@ -31,6 +32,7 @@ pub opaque type JwtBuilder {
/// A decoded JWT that can be read. The phantom type `status` indicated if it's
/// signature was verified or not.
///
@internal
pub opaque type Jwt(status) {
Jwt(header: Dict(String, Dynamic), payload: Dict(String, Dynamic))
}
Expand All @@ -39,6 +41,9 @@ pub opaque type Jwt(status) {
/// from a successfully decoded JWT string.
///
pub type JwtDecodeError {
///
MalformedJwt
///
MissingHeader
///
MissingPayload
Expand All @@ -65,22 +70,13 @@ pub type JwtDecodeError {
///
UnsupportedSigningAlgorithm
///
UnexpectedAlgorithm
///
MissingClaim
///
InvalidClaim(List(DecodeError))
}

/// Available [JSON Web Algorithms](https://datatracker.ietf.org/doc/html/rfc7518#section-3.2) used for encoding and decdoing signatures in [from_signed_string](#from_signed_string) and [to_signed_string](#to_signed_string).
///
/// If JWT calls for a different algorithm than the ones listed here [from_signed_string](#from_signed_string) will fail
/// with the [JwtDecodeError](#JwtDecodeError) `UnsupportedSigningAlgorithm`.
///
pub type Algorithm {
HS256
HS384
HS512
}

// CONSTRUCTORS ----------------------------------------------------------------

/// Creates a JwtBuilder with an empty payload and a header that only
Expand Down Expand Up @@ -123,62 +119,6 @@ pub fn from_string(
Ok(Jwt(header, payload))
}

/// Decode a signed JWT string into a verified [Jwt](#Jwt).
///
/// Returns `Ok(JwtBuilder)` if it is a valid JWT and the JWT's signature is successfully verified,
/// and `Error(JwtDecodeError)` otherwise.
///
/// At the moment this library only supports `HS256`, `HS384`, and `HS512` hashing algorithms.
/// if a JWT's alg claim calls for any other this function will return `Error(UnsupportedSigningAlgorithm)`.
///
/// ```gleam
/// import gwt.{type Jwt, type Verified, type JwtDecodeError}
///
/// fn example(jwt_string: String) -> Result(Jwt(Verified), JwtDecodeError) {
/// gwt.from_signed_string(jwt_string, "some secret")
/// }
/// ```
///
pub fn from_signed_string(
jwt_string: String,
secret: String,
) -> Result(Jwt(Verified), JwtDecodeError) {
use #(header, payload, signature) <- result.try(parts(jwt_string))
use signature <- result.try(option.to_result(signature, MissingSignature))

use _ <- result.try(ensure_valid_expiration(payload))
use _ <- result.try(ensure_valid_not_before(payload))
use alg <- result.try(ensure_valid_alg(header))

let assert [encoded_header, encoded_payload, ..] =
string.split(jwt_string, ".")
case alg {
"HS256" | "HS384" | "HS512" -> {
let alg = case alg {
"HS256" -> HS256
"HS384" -> HS384
"HS512" -> HS512
_ -> panic as "Should not be reachable"
}

let sig =
get_signature(encoded_header <> "." <> encoded_payload, alg, secret)
case
crypto.secure_compare(
bit_array.from_string(sig),
bit_array.from_string(signature),
)
{
True -> {
Ok(Jwt(header: header, payload: payload))
}
False -> Error(InvalidSignature)
}
}
_ -> Error(UnsupportedSigningAlgorithm)
}
}

// PAYLOAD ---------------------------------------------------------------------

/// Retrieve the iss from the JWT's payload.
Expand Down Expand Up @@ -636,106 +576,52 @@ pub fn to_string(jwt: JwtBuilder) -> String {

let header_string =
header
|> dict_to_json_object()
|> utils.dict_to_json_object()
|> json.to_string()
|> bit_array.from_string()
|> bit_array.base64_url_encode(False)

let payload_string =
payload
|> dict_to_json_object()
|> utils.dict_to_json_object()
|> json.to_string()
|> bit_array.from_string()
|> bit_array.base64_url_encode(False)

header_string <> "." <> payload_string
}

/// Encode a [Jwt](#Jwt) to a signed String using the given [Algorithm](#Algorithm) and secret.
///
/// ```gleam
/// import gwt
///
/// fn example() {
/// gwt.new()
/// |> gwt.set_issuer("gleam")
/// |> gwt.to_signed_string(gwt.HS256, "lucy")
/// }
/// ```
///
pub fn to_signed_string(
jwt: JwtBuilder,
alg: Algorithm,
secret: String,
) -> String {
let JwtBuilder(header:, payload:) = jwt

case alg {
HS256 | HS384 | HS512 -> {
let #(alg_string, hash_alg) = case alg {
HS256 -> #("HS256", crypto.Sha256)
HS384 -> #("HS384", crypto.Sha384)
HS512 -> #("HS512", crypto.Sha512)
}

let header = dict.insert(header, "alg", json.string(alg_string))

let header_string =
header
|> dict_to_json_object()
|> json.to_string()
|> bit_array.from_string()
|> bit_array.base64_url_encode(False)

let payload_string =
payload
|> dict_to_json_object()
|> json.to_string()
|> bit_array.from_string()
|> bit_array.base64_url_encode(False)

let jwt_body = header_string <> "." <> payload_string

let jwt_signature =
jwt_body
|> bit_array.from_string()
|> crypto.hmac(hash_alg, bit_array.from_string(secret))
|> bit_array.base64_url_encode(False)

jwt_body <> "." <> jwt_signature
}
}
}

// UTILITIES -------------------------------------------------------------------

fn dict_to_json_object(d: Dict(String, Json)) -> Json {
let key_value_list = {
use acc, key, value <- dict.fold(d, [])
[#(key, value), ..acc]
}

json.object(key_value_list)
}

fn get_signature(data: String, algorithm: Algorithm, secret: String) -> String {
case algorithm {
HS256 | HS384 | HS512 -> {
let hash_alg = case algorithm {
HS256 -> crypto.Sha256
HS384 -> crypto.Sha384
HS512 -> crypto.Sha512
}

data
|> bit_array.from_string()
|> crypto.hmac(hash_alg, bit_array.from_string(secret))
|> bit_array.base64_url_encode(False)
}
@internal
pub fn string_parts(
jwt_string: String,
) -> Result(#(String, String, Option(String)), JwtDecodeError) {
case string.split(jwt_string, ".") {
[header, payload] -> Ok(#(header, payload, None))
[header, payload, signature] -> Ok(#(header, payload, Some(signature)))
_ -> Error(MalformedJwt)
}
}

fn parts(
@internal
pub fn part_to_dict(
part: String,
error: JwtDecodeError,
) -> Result(Dict(String, Dynamic), JwtDecodeError) {
part
|> bit_array.base64_url_decode()
|> result.try(bit_array.to_string)
|> result.replace_error(error)
|> result.try(fn(str) {
str
|> json.parse(decode.dict(decode.string, decode.dynamic))
|> result.replace_error(error)
})
}

@internal
pub fn parts(
jwt_string: String,
) -> Result(
#(Dict(String, Dynamic), Dict(String, Dynamic), Option(String)),
Expand Down Expand Up @@ -781,7 +667,8 @@ fn parts(
Ok(#(header, payload, signature))
}

fn ensure_valid_expiration(
@internal
pub fn ensure_valid_expiration(
payload: Dict(String, Dynamic),
) -> Result(Nil, JwtDecodeError) {
let exp = {
Expand Down Expand Up @@ -813,7 +700,8 @@ fn ensure_valid_expiration(
}
}

fn ensure_valid_not_before(
@internal
pub fn ensure_valid_not_before(
payload: Dict(String, Dynamic),
) -> Result(Nil, JwtDecodeError) {
let nbf = {
Expand Down Expand Up @@ -845,7 +733,8 @@ fn ensure_valid_not_before(
}
}

fn ensure_valid_alg(
@internal
pub fn ensure_valid_alg(
header: Dict(String, Dynamic),
) -> Result(String, JwtDecodeError) {
use alg <- result.try(
Expand All @@ -857,3 +746,31 @@ fn ensure_valid_alg(
|> decode.run(decode.string)
|> result.replace_error(InvalidAlg)
}

@internal
pub fn get_header_string(jwt: JwtBuilder, alg_string: String) -> String {
let header = dict.insert(jwt.header, "alg", json.string(alg_string))

header
|> utils.dict_to_json_object()
|> json.to_string()
|> bit_array.from_string()
|> bit_array.base64_url_encode(False)
}

@internal
pub fn get_payload_string(jwt: JwtBuilder) -> String {
jwt.payload
|> utils.dict_to_json_object()
|> json.to_string()
|> bit_array.from_string()
|> bit_array.base64_url_encode(False)
}

@internal
pub fn dangerously_set_jwt_from_header_and_payload(
header: Dict(String, Dynamic),
payload: Dict(String, Dynamic),
) -> Jwt(a) {
Jwt(header:, payload:)
}
Loading