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, numeric_date_decoder/0, encode_numeric_date/1, issued_at/0, issuer/2, audience/2, not_before/2, expires_at/2, id/2, subject/2, custom/4, 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:error()}),
fun(() -> gleam@json:json())} |
{payload_claim,
binary(),
boolean(),
fun((gleam@dynamic:dynamic_()) -> {ok, nil} |
{error, ywt@internal@core:error()}),
fun(() -> gleam@json:json())}.
-file("src/ywt/claim.gleam", 284).
-spec validate(
binary(),
gleam@dynamic@decode:decoder(EKY),
fun((EKY) -> ywt@internal@core:error()),
fun((EKY) -> boolean())
) -> fun((gleam@dynamic:dynamic_()) -> {ok, nil} |
{error, ywt@internal@core: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", 66).
?DOC(
" The `typ` header describes what kind of token this is.\n"
"\n"
" The field is optional in the JWT specification, but some systems require\n"
" `\"JWT\"` or another explicit type. It is not a security boundary; still\n"
" validate issuer, audience, expiration, and signature.\n"
"\n"
" ```gleam\n"
" let claims = [\n"
" claim.typ(\"JWT\"),\n"
" claim.expires_at(max_age: duration.minutes(15), leeway: duration.minutes(1)),\n"
" claim.issuer(\"https://auth.example.com\", []),\n"
" claim.audience(\"https://api.example.com\", []),\n"
" ]\n"
" ```\n"
).
-spec typ(binary()) -> claim().
typ(Expected) ->
Name = <<"typ"/utf8>>,
Value = fun() -> gleam@json:string(Expected) end,
Verify = validate(
Name,
{decoder, fun gleam@dynamic@decode:decode_string/1},
fun(Found) -> {invalid_type, Expected, Found} end,
fun(V) -> Expected =:= V end
),
{header_claim, Name, true, Verify, Value}.
-file("src/ywt/claim.gleam", 331).
-spec timestamp_from_numeric_date_float(float()) -> gleam@time@timestamp:timestamp().
timestamp_from_numeric_date_float(Seconds) ->
Whole_seconds = begin
_pipe = Seconds,
_pipe@1 = math:floor(_pipe),
erlang:trunc(_pipe@1)
end,
Fractional_seconds = Seconds - erlang:float(Whole_seconds),
Nanoseconds = erlang:round(Fractional_seconds * 1000000000.0),
gleam@time@timestamp:from_unix_seconds_and_nanoseconds(
Whole_seconds,
Nanoseconds
).
-file("src/ywt/claim.gleam", 349).
?DOC(
" Decodes a JWT numeric date into a timestamp.\n"
"\n"
" JWT numeric dates are seconds since the Unix epoch.\n"
"\n"
" ```gleam\n"
" decode.field(\"exp\", claim.numeric_date_decoder())\n"
" ```\n"
).
-spec numeric_date_decoder() -> gleam@dynamic@decode:decoder(gleam@time@timestamp:timestamp()).
numeric_date_decoder() ->
gleam@dynamic@decode:one_of(
gleam@dynamic@decode:map(
{decoder, fun gleam@dynamic@decode:decode_int/1},
fun gleam@time@timestamp:from_unix_seconds/1
),
[gleam@dynamic@decode:map(
{decoder, fun gleam@dynamic@decode:decode_float/1},
fun timestamp_from_numeric_date_float/1
)]
).
-file("src/ywt/claim.gleam", 362).
?DOC(
" Encodes a timestamp as a JWT numeric date.\n"
"\n"
" Fractional seconds are truncated to keep encoded JWTs compact.\n"
"\n"
" ```gleam\n"
" #(\"checked_at\", claim.encode_numeric_date(timestamp.system_time()))\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", 94).
?DOC(
" The `iat` claim records when the token was issued.\n"
"\n"
" This writes the current time when creating a token, but it does not reject\n"
" future-dated tokens when verifying. Use `not_before` for that.\n"
"\n"
" ```gleam\n"
" let claims = [\n"
" claim.issued_at(),\n"
" claim.expires_at(max_age: duration.minutes(15), leeway: duration.minutes(1)),\n"
" claim.issuer(\"https://auth.example.com\", []),\n"
" claim.audience(\"https://api.example.com\", []),\n"
" ]\n"
" ```\n"
).
-spec issued_at() -> claim().
issued_at() ->
Name = <<"iat"/utf8>>,
Value = fun() -> encode_numeric_date(gleam@time@timestamp:system_time()) end,
Verify = fun(Data) ->
case gleam@dynamic@decode:run(Data, numeric_date_decoder()) of
{ok, _} ->
{ok, nil};
{error, Error} ->
{error, {claim_decoding_error, Name, Error}}
end
end,
{payload_claim, Name, false, Verify, Value}.
-file("src/ywt/claim.gleam", 302).
-spec string_claim(
binary(),
binary(),
list(binary()),
fun((list(binary()), binary()) -> ywt@internal@core: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", 120).
?DOC(
" The `iss` claim identifies who issued the token.\n"
"\n"
" Use this to reject tokens from the wrong issuer. Issuer strings often point\n"
" at the service that also publishes verification keys, but ywt does not fetch\n"
" or trust those keys automatically.\n"
"\n"
" ```gleam\n"
" let claims = [\n"
" claim.expires_at(max_age: duration.minutes(15), leeway: duration.minutes(1)),\n"
" claim.issuer(\"https://auth.example.com\", []),\n"
" claim.audience(\"https://api.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", 322).
-spec any_audience_matches(list(binary()), list(binary())) -> boolean().
any_audience_matches(Found, Expected) ->
gleam@list:any(
Found,
fun(Audience) -> gleam@list:contains(Expected, Audience) end
).
-file("src/ywt/claim.gleam", 327).
-spec invalid_audience(list(binary()), list(binary())) -> ywt@internal@core:error().
invalid_audience(Expected, Found) ->
{invalid_audience, Expected, Found}.
-file("src/ywt/claim.gleam", 316).
-spec audience_decoder() -> gleam@dynamic@decode:decoder(list(binary())).
audience_decoder() ->
gleam@dynamic@decode:one_of(
gleam@dynamic@decode:map(
{decoder, fun gleam@dynamic@decode:decode_string/1},
fun gleam@list:wrap/1
),
[gleam@dynamic@decode:list(
{decoder, fun gleam@dynamic@decode:decode_string/1}
)]
).
-file("src/ywt/claim.gleam", 137).
?DOC(
" The `aud` claim identifies who the token is meant for.\n"
"\n"
" Tokens with an `aud` field are rejected by default unless you add this claim\n"
" and the value matches one of the accepted audiences. ywt validates both\n"
" string and array-valued `aud` fields.\n"
"\n"
" ```gleam\n"
" let claims = [\n"
" claim.expires_at(max_age: duration.minutes(15), leeway: duration.minutes(1)),\n"
" claim.issuer(\"https://auth.example.com\", []),\n"
" claim.audience(\"https://api.example.com\", []),\n"
" ]\n"
" ```\n"
).
-spec audience(binary(), list(binary())) -> claim().
audience(Primary, Others) ->
Name = <<"aud"/utf8>>,
Expected = [Primary | Others],
Value = fun() -> gleam@json:string(Primary) end,
Verify = validate(
Name,
audience_decoder(),
fun(_capture) -> invalid_audience(Expected, _capture) end,
fun(_capture@1) -> any_audience_matches(_capture@1, Expected) end
),
{payload_claim, Name, true, Verify, Value}.
-file("src/ywt/claim.gleam", 167).
?DOC(
" The `nbf` claim says the token must not be accepted before a time.\n"
"\n"
" Tokens with `nbf` are checked by default with zero leeway. Add this claim\n"
" when you want to set the value while signing or allow clock skew while\n"
" verifying. Keep leeway small.\n"
"\n"
" ```gleam\n"
" let claims = [\n"
" claim.not_before(timestamp.system_time(), leeway: duration.minutes(1)),\n"
" claim.expires_at(max_age: duration.minutes(15), leeway: duration.minutes(1)),\n"
" claim.issuer(\"https://auth.example.com\", []),\n"
" claim.audience(\"https://api.example.com\", []),\n"
" ]\n"
" ```\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", 202).
?DOC(
" The `exp` claim says when the token expires.\n"
"\n"
" Prefer short lifetimes. Tokens with `exp` are checked by default with zero\n"
" leeway, but ywt will not add an expiration unless you include this claim.\n"
" `max_age` is used when writing the token; verification checks the token's\n"
" `exp` value plus `leeway`.\n"
"\n"
" ```gleam\n"
" let claims = [\n"
" claim.expires_at(max_age: duration.minutes(15), leeway: duration.minutes(1)),\n"
" claim.issuer(\"https://auth.example.com\", []),\n"
" claim.audience(\"https://api.example.com\", []),\n"
" ]\n"
" ```\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", 236).
?DOC(
" The `jti` claim gives the token a unique id.\n"
"\n"
" ywt does not store token ids. Use this with your own denylist or session\n"
" store if you need revocation.\n"
"\n"
" ```gleam\n"
" let claims = [\n"
" claim.id(\"session-123\", []),\n"
" claim.expires_at(max_age: duration.minutes(15), leeway: duration.minutes(1)),\n"
" claim.issuer(\"https://auth.example.com\", []),\n"
" claim.audience(\"https://api.example.com\", []),\n"
" ]\n"
" ```\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(
" The `sub` claim identifies the user or entity the token is about.\n"
"\n"
" Prefer stable internal ids. Avoid putting personal data in the subject,\n"
" because JWT payloads are readable by whoever has the token.\n"
"\n"
" ```gleam\n"
" let claims = [\n"
" claim.subject(\"user_123\", []),\n"
" claim.expires_at(max_age: duration.minutes(15), leeway: duration.minutes(1)),\n"
" claim.issuer(\"https://auth.example.com\", []),\n"
" claim.audience(\"https://api.example.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", 270).
?DOC(
" A custom claim stores an application-specific value.\n"
"\n"
" The decoded token value must exactly match `value`. Use this for simple\n"
" fixed requirements such as role, tenant, or token class checks.\n"
"\n"
" ```gleam\n"
" let claims = [\n"
" claim.custom(\"role\", \"admin\", json.string, decode.string),\n"
" claim.expires_at(max_age: duration.minutes(15), leeway: duration.minutes(1)),\n"
" claim.issuer(\"https://auth.example.com\", []),\n"
" claim.audience(\"https://api.example.com\", []),\n"
" ]\n"
" ```\n"
).
-spec custom(
binary(),
EKW,
fun((EKW) -> gleam@json:json()),
gleam@dynamic@decode:decoder(EKW)
) -> 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", 379).
?DOC(
" Allows a claim to be absent during verification.\n"
"\n"
" When the field is present it is still validated. If the claim is already\n"
" optional, it is returned unchanged.\n"
"\n"
" ```gleam\n"
" let claims = [\n"
" claim.id(\"session-123\", []) |> claim.optional,\n"
" claim.expires_at(max_age: duration.minutes(15), leeway: duration.minutes(1)),\n"
" claim.issuer(\"https://auth.example.com\", []),\n"
" claim.audience(\"https://api.example.com\", []),\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", 453).
?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:error()}.
verify_single_claim(Header, Payload, Claim) ->
Decoder = begin
gleam@dynamic@decode:optional_field(
erlang:element(2, Claim),
none,
gleam@dynamic@decode:map(
{decoder, fun gleam@dynamic@decode:decode_dynamic/1},
fun(Field@0) -> {some, Field@0} end
),
fun(Value) -> gleam@dynamic@decode:success(Value) end
)
end,
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, {some, Claim_value}} ->
(erlang:element(4, Claim))(Claim_value);
{ok, none} when not erlang:element(3, Claim) ->
{ok, nil};
{ok, none} ->
{error, {missing_claim, erlang:element(2, Claim)}};
{error, Error} ->
{error, {claim_decoding_error, erlang:element(2, Claim), Error}}
end.
-file("src/ywt/claim.gleam", 426).
-spec reject_present_audience() -> claim().
reject_present_audience() ->
Name = <<"aud"/utf8>>,
Value = fun() -> gleam@json:string(<<""/utf8>>) end,
Verify = validate(
Name,
audience_decoder(),
fun(_capture) -> invalid_audience([], _capture) end,
fun(_) -> false end
),
{payload_claim, Name, false, Verify, Value}.
-file("src/ywt/claim.gleam", 444).
-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", 436).
-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", 414).
?DOC(false).
-spec verify_with_header(
gleam@dynamic:dynamic_(),
gleam@dynamic:dynamic_(),
list(claim())
) -> {ok, nil} | {error, ywt@internal@core: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, reject_present_audience()),
gleam@list:try_each(
_pipe@3,
fun(_capture) -> verify_single_claim(Header, Payload, _capture) end
).
-file("src/ywt/claim.gleam", 404).
?DOC(
" Validates claims against a JWT payload.\n"
"\n"
" This is a low-level function used internally by ywt.\n"
"\n"
" This does not verify the token signature. Use the platform `ywt.decode`\n"
" function for normal JWT verification.\n"
"\n"
" Tokens with `exp` and `nbf` are checked by default with zero leeway. Tokens\n"
" with `aud` are rejected by default unless you pass an `audience` claim.\n"
" Audience validation accepts string or array values.\n"
).
-spec verify(gleam@dynamic:dynamic_(), list(claim())) -> {ok, nil} |
{error, ywt@internal@core:error()}.
verify(Payload, Claims) ->
verify_with_header(gleam@dynamic:nil(), Payload, Claims).
-file("src/ywt/claim.gleam", 499).
?DOC(
" Encodes claims and application data into a JSON object.\n"
"\n"
" This is a low-level function used internally by ywt.\n"
"\n"
" Claims take precedence when a field appears in both lists. Put security\n"
" fields such as `exp`, `iss`, `aud`, and `sub` in claims instead of payload\n"
" data.\n"
"\n"
" ```gleam\n"
" let claims = [\n"
" claim.subject(\"user_123\", []),\n"
" claim.expires_at(max_age: duration.minutes(15), leeway: duration.minutes(1)),\n"
" ]\n"
"\n"
" claim.encode(claims, [\n"
" #(\"sub\", json.string(\"ignored\")),\n"
" #(\"role\", json.string(\"admin\")),\n"
" ])\n"
" ```\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", 518).
?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).