Current section
Files
Jump to
Current section
Files
src/ywt@claim.erl
-module(ywt@claim).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/ywt/claim.gleam").
-export([typ/1, custom/4, issuer/2, audience/2, id/2, subject/2, numeric_date_decoder/0, encode_numeric_date/1, issued_at/0, not_before/2, expires_at/2, optional/1, verify_with_header/3, verify/2, encode/2, encode_header/2]).
-export_type([claim/0]).
-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.
?MODULEDOC(
" <style>\n"
" .goto-top {\n"
" display: block;\n"
" font-size: 0.85em;\n"
" text-align: right;\n"
" }\n"
" </style>\n"
" <script>\n"
" (callback => document.readyState !== 'loading' ? callback() : document.addEventListener('DOMContentLoaded', callback, { once: true }))(() => {\n"
" const goToTop = document.createElement('a')\n"
" goToTop.classList.add('goto-top')\n"
" goToTop.setAttribute('href', '#')\n"
" goToTop.textContent = 'Back to top ↑'\n"
" for (const member of document.querySelectorAll('.member')) {\n"
" member.insertBefore(goToTop.cloneNode(true), null)\n"
" }\n"
" })\n"
" </script>\n"
).
-opaque claim() :: {header_claim,
binary(),
boolean(),
fun((gleam@dynamic:dynamic_()) -> {ok, nil} |
{error, ywt@internal@core:parse_error()}),
fun(() -> gleam@json:json())} |
{payload_claim,
binary(),
boolean(),
fun((gleam@dynamic:dynamic_()) -> {ok, nil} |
{error, ywt@internal@core:parse_error()}),
fun(() -> gleam@json:json())}.
-file("src/ywt/claim.gleam", 295).
-spec validate(
binary(),
gleam@dynamic@decode:decoder(EKG),
fun((EKG) -> ywt@internal@core:parse_error()),
fun((EKG) -> boolean())
) -> fun((gleam@dynamic:dynamic_()) -> {ok, nil} |
{error, ywt@internal@core:parse_error()}).
validate(Name, Decoder, On_error, Validate) ->
fun(Data) -> case gleam@dynamic@decode:run(Data, Decoder) of
{ok, Value} ->
case Validate(Value) of
true ->
{ok, nil};
false ->
{error, On_error(Value)}
end;
{error, Error} ->
{error, {claim_decoding_error, Name, Error}}
end end.
-file("src/ywt/claim.gleam", 65).
?DOC(
" Validates that the JWT header has a specific `typ` (type) field, and includes\n"
" it in generated tokens.\n"
"\n"
" The `typ` header parameter is used to declare the media type of the JWT.\n"
" While RFC 7519 makes this field optional, many systems expect `\"JWT\"` as the\n"
" standard value for JSON Web Tokens. Use this claim when integrating with\n"
" systems that require explicit type declaration in the JWT header.\n"
"\n"
" ## Example\n"
" ```gleam\n"
" // Standard JWT type validation\n"
" let claims = [\n"
" claim.typ(\"JWT\"),\n"
" claim.expires_at(max_age: duration.hours(1), leeway: duration.minutes(5))\n"
" ]\n"
" ```\n"
).
-spec typ(binary()) -> claim().
typ(Expected) ->
Name = <<"typ"/utf8>>,
Value = fun() -> gleam@json:string(Expected) end,
On_error = fun(Found) ->
{invalid_header_json,
{unable_to_decode, [{decode_error, Expected, Found, [Name]}]}}
end,
Verify = validate(
Name,
{decoder, fun gleam@dynamic@decode:decode_string/1},
On_error,
fun(V) -> Expected =:= V end
),
{header_claim, Name, true, Verify, Value}.
-file("src/ywt/claim.gleam", 281).
?DOC(
" Creates a custom claim with a specific name and value that must match exactly during validation.\n"
"\n"
" Use this for application-specific claims like roles, permissions, or custom\n"
" business logic requirements.\n"
"\n"
" ## Example\n"
" ```gleam\n"
" // Add role-based authorization\n"
" let claims = [\n"
" claim.custom(\n"
" name: \"role\",\n"
" value: \"admin\",\n"
" encode: json.string,\n"
" decoder: decode.string\n"
" ),\n"
" claim.custom(\n"
" name: \"permissions\",\n"
" value: [\"read\", \"write\", \"delete\"],\n"
" encode: json.array(_, json.string),\n"
" decoder: decode.list(decode.string)\n"
" ),\n"
" claim.expires_at(max_age: duration.hours(1), leeway: duration.minutes(5))\n"
" ]\n"
" ```\n"
).
-spec custom(
binary(),
EKE,
fun((EKE) -> gleam@json:json()),
gleam@dynamic@decode:decoder(EKE)
) -> claim().
custom(Name, Value, Encode, Decoder) ->
On_error = fun(_) -> {invalid_custom_claim, Name} end,
Check = fun(Decoded) -> Decoded =:= Value end,
Value@1 = fun() -> Encode(Value) end,
Verify = validate(Name, Decoder, On_error, Check),
{payload_claim, Name, true, Verify, Value@1}.
-file("src/ywt/claim.gleam", 313).
-spec string_claim(
binary(),
binary(),
list(binary()),
fun((list(binary()), binary()) -> ywt@internal@core:parse_error())
) -> claim().
string_claim(Name, Primary, Others, Error) ->
Expected = [Primary | Others],
Value = fun() -> gleam@json:string(Primary) end,
Verify = validate(
Name,
{decoder, fun gleam@dynamic@decode:decode_string/1},
fun(_capture) -> Error(Expected, _capture) end,
fun(_capture@1) -> gleam@list:contains(Expected, _capture@1) end
),
{payload_claim, Name, true, Verify, Value}.
-file("src/ywt/claim.gleam", 114).
?DOC(
" Validates that the JWT comes from a trusted issuer, and includes that issuer\n"
" in generated tokens.\n"
"\n"
" Use this to ensure JWTs are only accepted from authorized authentication services,\n"
" preventing token misuse across different services. It is common practice to\n"
" set the issuer to a URL with a `.well-known/jwks.json` endpoint, from which\n"
" public verification keys can be fetched.\n"
"\n"
" ## Example\n"
" ```gleam\n"
" // Only accept tokens from your auth service\n"
" let claims = [\n"
" claim.issuer(\"https://auth.example.com\", [])\n"
" ]\n"
" ```\n"
).
-spec issuer(binary(), list(binary())) -> claim().
issuer(Issuer, Others) ->
string_claim(
<<"iss"/utf8>>,
Issuer,
Others,
fun(Field@0, Field@1) -> {invalid_issuer, Field@0, Field@1} end
).
-file("src/ywt/claim.gleam", 136).
?DOC(
" Validates that the JWT is intended for your service, and includes the primary\n"
" audience in generated tokens.\n"
"\n"
" Include this to ensure JWTs are only used by their intended recipients,\n"
" preventing tokens meant for other services from being accepted.\n"
"\n"
" ## Example\n"
" ```gleam\n"
" // For an API service\n"
" let claims = [\n"
" claim.audience(\"api.myapp.com\", [\"admin-api.myapp.com\"]),\n"
" ]\n"
" ```\n"
"\n"
" 💡 **Note:** Even without this claim, tokens containing an `aud` field are\n"
" rejected — a token scoped to a specific audience should not be accepted by a\n"
" service that isn't checking audiences. Add this claim to accept tokens with a\n"
" matching audience.\n"
).
-spec audience(binary(), list(binary())) -> claim().
audience(Primary, Others) ->
string_claim(
<<"aud"/utf8>>,
Primary,
Others,
fun(Field@0, Field@1) -> {invalid_audience, Field@0, Field@1} end
).
-file("src/ywt/claim.gleam", 231).
?DOC(
" Adds a unique identifier (jti) to the JWT for tracking and revocation.\n"
"\n"
" Use this when you need to track specific tokens or implement token revocation\n"
" mechanisms.\n"
).
-spec id(binary(), list(binary())) -> claim().
id(Id, Others) ->
string_claim(
<<"jti"/utf8>>,
Id,
Others,
fun(Field@0, Field@1) -> {invalid_id, Field@0, Field@1} end
).
-file("src/ywt/claim.gleam", 253).
?DOC(
" Identifies the subject (typically a user) that the JWT represents.\n"
"\n"
" Use this to bind JWTs to specific users or entities, enabling proper\n"
" authorization decisions. This could be an internal user id.\n"
"\n"
" ## Security Considerations\n"
" - Use stable, unique identifiers (user IDs, not usernames)\n"
" - Don't include personally identifiable information\n"
"\n"
" ## Example\n"
" ```gleam\n"
" // Bind token to a specific user\n"
" let claims = [\n"
" claim.subject(\"user_12345\", []),\n"
" claim.expires_at(max_age: duration.hours(1), leeway: duration.minutes(5)),\n"
" claim.audience(\"api.myapp.com\", [])\n"
" ]\n"
" ```\n"
).
-spec subject(binary(), list(binary())) -> claim().
subject(Subject, Others) ->
string_claim(
<<"sub"/utf8>>,
Subject,
Others,
fun(Field@0, Field@1) -> {invalid_subject, Field@0, Field@1} end
).
-file("src/ywt/claim.gleam", 336).
?DOC(
" Decodes JWT numeric date values (seconds since Unix epoch) into timestamp objects.\n"
"\n"
" Use this when manually processing JWT claims that contain timestamp values.\n"
"\n"
" ## Example\n"
" ```gleam\n"
" // Manually decode an expiration claim\n"
" let exp_decoder = decode.field(\"exp\", numeric_date_decoder())\n"
" ```\n"
).
-spec numeric_date_decoder() -> gleam@dynamic@decode:decoder(gleam@time@timestamp:timestamp()).
numeric_date_decoder() ->
gleam@dynamic@decode:map(
{decoder, fun gleam@dynamic@decode:decode_int/1},
fun gleam@time@timestamp:from_unix_seconds/1
).
-file("src/ywt/claim.gleam", 351).
?DOC(
" Encodes a timestamp into the JWT numeric date format (seconds since Unix epoch).\n"
"\n"
" Use this when manually creating JWT payloads with timestamp values.\n"
"\n"
" ## Example\n"
" ```gleam\n"
" // Add custom timestamp to payload\n"
" let payload = [\n"
" #(\"custom_date\", encode_numeric_date(my_timestamp))\n"
" ]\n"
" ```\n"
).
-spec encode_numeric_date(gleam@time@timestamp:timestamp()) -> gleam@json:json().
encode_numeric_date(Timestamp) ->
gleam@json:int(
erlang:trunc(gleam@time@timestamp:to_unix_seconds(Timestamp))
).
-file("src/ywt/claim.gleam", 92).
?DOC(
" Adds the current timestamp as the \"issued at\" (iat) claim.\n"
"\n"
" Include this in JWTs to track when tokens were issued, useful for debugging\n"
" and audit trails. To reject tokens that were created in the future, use `not_before`.\n"
"\n"
" ## Example\n"
" ```gleam\n"
" let claims = [\n"
" claim.expires_at(max_age: duration.hours(1), leeway: duration.minutes(5)),\n"
" ...\n"
" ]\n"
" ```\n"
).
-spec issued_at() -> claim().
issued_at() ->
Value = fun() -> encode_numeric_date(gleam@time@timestamp:system_time()) end,
Verify = fun(_) -> {ok, nil} end,
{payload_claim, <<"iat"/utf8>>, false, Verify, Value}.
-file("src/ywt/claim.gleam", 157).
?DOC(
" Adds a \"not before\" (nbf) claim that prevents the JWT from being valid until the current time.\n"
"\n"
" Use this for JWTs that should only become valid at a specific time, such as\n"
" scheduled operations or delayed activations.\n"
"\n"
" ## Example\n"
" ```gleam\n"
" // Token becomes valid immediately, allowing 1 minute clock skew\n"
" let claims = [\n"
" claim.not_before(timestamp.system_time(), leeway: duration.minutes(1)),\n"
" claim.expires_at(max_age: duration.hours(2), leeway: duration.minutes(1))\n"
" ]\n"
" ```\n"
"\n"
" 💡 **Note:** Even without this claim, tokens containing an `nbf` field are\n"
" still checked against the current time with zero leeway. Add this claim\n"
" explicitly to control the `leeway`.\n"
).
-spec not_before(
gleam@time@timestamp:timestamp(),
gleam@time@duration:duration()
) -> claim().
not_before(Time, Leeway) ->
Name = <<"nbf"/utf8>>,
Value = fun() -> encode_numeric_date(Time) end,
Verify = validate(
Name,
numeric_date_decoder(),
fun(Field@0) -> {token_not_yet_valid, Field@0} end,
fun(Not_before) ->
Now = gleam@time@timestamp:system_time(),
case gleam@time@timestamp:compare(
Not_before,
gleam@time@timestamp:add(Now, Leeway)
) of
gt ->
false;
_ ->
true
end
end
),
{payload_claim, Name, true, Verify, Value}.
-file("src/ywt/claim.gleam", 206).
?DOC(
" Sets an expiration time for the JWT, automatically rejecting expired tokens.\n"
"\n"
" **ALWAYS include this** - it's your primary defense against token replay attacks\n"
" and limits the damage from compromised tokens.\n"
"\n"
" ## Security Considerations\n"
" - Make expiration times as short as reasonably possible\n"
" - Include reasonable leeway for clock differences\n"
"\n"
" ## Example\n"
" ```gleam\n"
" // Short-lived API access token\n"
" let api_claims = [\n"
" claim.expires_at(max_age: duration.minutes(30), leeway: duration.minutes(5))\n"
" ]\n"
"\n"
" // Longer-lived refresh token\n"
" let session_claims = [\n"
" claim.expires_at(max_age: duration.hours(24 * 7), leeway: duration.minutes(5))\n"
" ]\n"
" ```\n"
"\n"
" 🚨 **Critical:** Never create or parse JWTs without expiration times -\n"
" they would be valid forever if compromised.\n"
"\n"
" 💡 **Note:** Even without this claim, tokens containing an `exp` field are\n"
" still checked for expiration with zero leeway. Add this claim explicitly to\n"
" control the `max_age` and `leeway`.\n"
).
-spec expires_at(gleam@time@duration:duration(), gleam@time@duration:duration()) -> claim().
expires_at(Max_age, Leeway) ->
Name = <<"exp"/utf8>>,
Value = fun() -> _pipe = gleam@time@timestamp:system_time(),
_pipe@1 = gleam@time@timestamp:add(_pipe, Max_age),
encode_numeric_date(_pipe@1) end,
Verify = validate(
Name,
numeric_date_decoder(),
fun(Field@0) -> {token_expired, Field@0} end,
fun(Expires_at) ->
Now = gleam@time@timestamp:system_time(),
case gleam@time@timestamp:compare(
gleam@time@timestamp:add(Expires_at, Leeway),
Now
) of
gt ->
true;
_ ->
false
end
end
),
{payload_claim, Name, true, Verify, Value}.
-file("src/ywt/claim.gleam", 370).
?DOC(
" Marks a claim as optional, meaning the JWT will still be considered valid\n"
" even if this claim is missing from the token. By default, all claims are\n"
" required — a missing required claim causes validation to fail with a\n"
" `MissingClaim` error.\n"
"\n"
" If the claim is already optional, it is returned unchanged.\n"
"\n"
" ## Example\n"
" ```gleam\n"
" // Accept tokens with or without a \"jti\" (token ID)\n"
" let claims = [\n"
" claim.id(\"abc-123\", []) |> claim.optional,\n"
" claim.expires_at(max_age: duration.hours(1), leeway: duration.minutes(5))\n"
" ]\n"
" ```\n"
).
-spec optional(claim()) -> claim().
optional(Claim) ->
case Claim of
{header_claim, _, true, _, _} ->
{header_claim,
erlang:element(2, Claim),
false,
erlang:element(4, Claim),
erlang:element(5, Claim)};
{payload_claim, _, true, _, _} ->
{payload_claim,
erlang:element(2, Claim),
false,
erlang:element(4, Claim),
erlang:element(5, Claim)};
{header_claim, _, false, _, _} ->
Claim;
{payload_claim, _, false, _, _} ->
Claim
end.
-file("src/ywt/claim.gleam", 485).
-spec has_payload_claim(list(claim()), binary()) -> boolean().
has_payload_claim(Claims, Name) ->
gleam@list:any(Claims, fun(Claim) -> case Claim of
{payload_claim, Claim_name, _, _, _} ->
Name =:= Claim_name;
{header_claim, _, _, _, _} ->
false
end end).
-file("src/ywt/claim.gleam", 477).
-spec add_default_claim(list(claim()), claim()) -> list(claim()).
add_default_claim(Claims, Claim) ->
case has_payload_claim(Claims, erlang:element(2, Claim)) of
true ->
Claims;
false ->
[optional(Claim) | Claims]
end.
-file("src/ywt/claim.gleam", 494).
?DOC(" Verify a single claim, handling required vs optional claims.\n").
-spec verify_single_claim(
gleam@dynamic:dynamic_(),
gleam@dynamic:dynamic_(),
claim()
) -> {ok, nil} | {error, ywt@internal@core:parse_error()}.
verify_single_claim(Header, Payload, Claim) ->
Decoder = gleam@dynamic@decode:at(
[erlang:element(2, Claim)],
{decoder, fun gleam@dynamic@decode:decode_dynamic/1}
),
Result = case Claim of
{header_claim, _, _, _, _} ->
gleam@dynamic@decode:run(Header, Decoder);
{payload_claim, _, _, _, _} ->
gleam@dynamic@decode:run(Payload, Decoder)
end,
case Result of
{ok, Claim_value} ->
(erlang:element(4, Claim))(Claim_value);
{error, _} when not erlang:element(3, Claim) ->
{ok, nil};
{error, _} ->
{error, {missing_claim, erlang:element(2, Claim)}}
end.
-file("src/ywt/claim.gleam", 465).
?DOC(false).
-spec verify_with_header(
gleam@dynamic:dynamic_(),
gleam@dynamic:dynamic_(),
list(claim())
) -> {ok, nil} | {error, ywt@internal@core:parse_error()}.
verify_with_header(Header, Payload, Claims) ->
_pipe = Claims,
_pipe@1 = add_default_claim(
_pipe,
expires_at(
gleam@time@duration:seconds(0),
gleam@time@duration:seconds(0)
)
),
_pipe@2 = add_default_claim(
_pipe@1,
not_before(
gleam@time@timestamp:system_time(),
gleam@time@duration:seconds(0)
)
),
_pipe@3 = add_default_claim(_pipe@2, audience(<<""/utf8>>, [])),
gleam@list:try_each(
_pipe@3,
fun(_capture) -> verify_single_claim(Header, Payload, _capture) end
).
-file("src/ywt/claim.gleam", 440).
?DOC(
" Validates all claims against a JWT payload.\n"
"\n"
" This function checks each claim in the provided list against the JWT payload,\n"
" returning the first validation error encountered or success if all claims pass.\n"
"\n"
" 🚨 This is a low-level function used internally by ywt.\n"
"\n"
" ## Default Claim Validation\n"
"\n"
" Even if you don't explicitly include `exp`, `nbf`, or `aud` claims, they\n"
" will still be validated when present in the token:\n"
"\n"
" - **`exp`** (expiration): If present, the token must not be expired (zero leeway).\n"
" - **`nbf`** (not before): If present, the token must be valid at the current\n"
" time (zero leeway).\n"
" - **`aud`** (audience): If present, the token is rejected.\n"
"\n"
" This prevents accepting tokens that carry security constraints you forgot to\n"
" enforce. To customise validation behaviour (e.g. allowing leeway), add the\n"
" corresponding claim explicitly — your claims always take precedence over the\n"
" defaults.\n"
"\n"
" ## Example\n"
" ```gleam\n"
" let payload = // ... decoded JWT payload\n"
"\n"
" let security_claims = [\n"
" expires_at(max_age: duration.hours(1), leeway: duration.minutes(5)),\n"
" issuer(\"https://auth.myapp.com\", []),\n"
" audience(\"https://api.myapp.com\", []),\n"
" custom(\"role\", \"admin\", json.string, decode.string)\n"
" ]\n"
"\n"
" case verify(payload, security_claims) {\n"
" Ok(Nil) -> {\n"
" // All claims validated successfully\n"
" process_authenticated_request(payload)\n"
" }\n"
" Error(TokenExpired(expired_at)) -> {\n"
" log.info(\"Token expired at: \" <> timestamp.to_string(expired_at))\n"
" return_authentication_error()\n"
" }\n"
" Error(InvalidIssuer(expected, actual)) -> {\n"
" log.warning(\"Invalid issuer - expected: \" <> expected <> \", got: \" <> actual)\n"
" return_forbidden_error()\n"
" }\n"
" Error(error) -> {\n"
" log.error(\"Claim validation failed: \" <> string.inspect(error))\n"
" return_bad_request_error()\n"
" }\n"
" }\n"
" ```\n"
"\n"
" ⚠️ **Important:** This function only validates claims - it does not verify\n"
" cryptographic signatures. Use the full `decode()` function for complete JWT validation.\n"
).
-spec verify(gleam@dynamic:dynamic_(), list(claim())) -> {ok, nil} |
{error, ywt@internal@core:parse_error()}.
verify(Payload, Claims) ->
verify_with_header(gleam@dynamic:nil(), Payload, Claims).
-file("src/ywt/claim.gleam", 542).
?DOC(
" Encode claims and application data into JSON.\n"
"\n"
" This function merges application data with JWT claims into a single JSON object\n"
" for token encoding. When the same field appears in both lists, claims take\n"
" precedence to ensure security-critical values cannot be overridden.\n"
"\n"
" 🚨 This is a low-level function used internally by ywt.\n"
"\n"
" ## Example\n"
" ```gleam\n"
" let user_data = [\n"
" #(\"name\", json.string(\"Alice\")),\n"
" #(\"role\", json.string(\"user\")),\n"
" #(\"exp\", json.int(1234567890)) // This will be overridden\n"
" ]\n"
"\n"
" let security_claims = [\n"
" expires_at(max_age: duration.hours(1), leeway: duration.minutes(5)),\n"
" custom(\"role\", \"admin\", json.string, decode.string) // This takes precedence\n"
" ]\n"
"\n"
" let payload = encode(security_claims, user_data)\n"
" // Result: {\"name\": \"Alice\", \"role\": \"admin\", \"exp\": <actual_expiry>}\n"
" ```\n"
"\n"
" 💡 **Best Practice:** Place security-sensitive fields in claims rather than\n"
" data to validate them and ensure they cannot be accidentally overridden.\n"
).
-spec encode(list(claim()), list({binary(), gleam@json:json()})) -> gleam@json:json().
encode(Claims, Data) ->
_pipe = maps:new(),
_pipe@1 = gleam@list:fold(
Data,
_pipe,
fun(Dict, Pair) ->
gleam@dict:insert(
Dict,
erlang:element(1, Pair),
erlang:element(2, Pair)
)
end
),
_pipe@2 = gleam@list:fold(
Claims,
_pipe@1,
fun(Dict@1, Claim) -> case Claim of
{payload_claim, _, _, _, _} ->
gleam@dict:insert(
Dict@1,
erlang:element(2, Claim),
(erlang:element(5, Claim))()
);
_ ->
Dict@1
end end
),
_pipe@3 = maps:to_list(_pipe@2),
gleam@json:object(_pipe@3).
-file("src/ywt/claim.gleam", 561).
?DOC(false).
-spec encode_header(list(claim()), list({binary(), gleam@json:json()})) -> gleam@json:json().
encode_header(Claims, Data) ->
_pipe = maps:new(),
_pipe@1 = gleam@list:fold(
Data,
_pipe,
fun(Dict, Pair) ->
gleam@dict:insert(
Dict,
erlang:element(1, Pair),
erlang:element(2, Pair)
)
end
),
_pipe@2 = gleam@list:fold(
Claims,
_pipe@1,
fun(Dict@1, Claim) -> case Claim of
{header_claim, _, _, _, _} ->
gleam@dict:insert(
Dict@1,
erlang:element(2, Claim),
(erlang:element(5, Claim))()
);
_ ->
Dict@1
end end
),
_pipe@3 = maps:to_list(_pipe@2),
gleam@json:object(_pipe@3).