Current section

Files

Jump to
ywt_core src ywt@claim.erl
Raw

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([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, verify/2, encode/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.
-opaque claim() :: {claim,
binary(),
boolean(),
fun((gleam@dynamic:dynamic_()) -> {ok, nil} |
{error, ywt@internal@core:parse_error()}),
fun(() -> gleam@json:json())}.
-file("src/ywt/claim.gleam", 273).
-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", 259).
?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"
"\n"
" <div style=\"text-align: right;\">\n"
" <a href=\"#\">\n"
" <small>Back to top ↑</small>\n"
" </a>\n"
" </div>\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),
{claim, Name, true, Verify, Value@1}.
-file("src/ywt/claim.gleam", 291).
-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
),
{claim, Name, true, Verify, Value}.
-file("src/ywt/claim.gleam", 69).
?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"
"\n"
" <div style=\"text-align: right;\">\n"
" <a href=\"#\">\n"
" <small>Back to top ↑</small>\n"
" </a>\n"
" </div>\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", 92).
?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"
" <div style=\"text-align: right;\">\n"
" <a href=\"#\">\n"
" <small>Back to top ↑</small>\n"
" </a>\n"
" </div>\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", 197).
?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"
"\n"
" <div style=\"text-align: right;\">\n"
" <a href=\"#\">\n"
" <small>Back to top ↑</small>\n"
" </a>\n"
" </div>\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", 225).
?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"
"\n"
" <div style=\"text-align: right;\">\n"
" <a href=\"#\">\n"
" <small>Back to top ↑</small>\n"
" </a>\n"
" </div>\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", 320).
?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"
"\n"
" <div style=\"text-align: right;\">\n"
" <a href=\"#\">\n"
" <small>Back to top ↑</small>\n"
" </a>\n"
" </div>\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", 341).
?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"
"\n"
" <div style=\"text-align: right;\">\n"
" <a href=\"#\">\n"
" <small>Back to top ↑</small>\n"
" </a>\n"
" </div>\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", 41).
?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"
"\n"
" <div style=\"text-align: right;\">\n"
" <a href=\"#\">\n"
" <small>Back to top ↑</small>\n"
" </a>\n"
" </div>\n"
).
-spec issued_at() -> claim().
issued_at() ->
Value = fun() -> encode_numeric_date(gleam@time@timestamp:system_time()) end,
Verify = fun(_) -> {ok, nil} end,
{claim, <<"iat"/utf8>>, false, Verify, Value}.
-file("src/ywt/claim.gleam", 115).
?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"
" <div style=\"text-align: right;\">\n"
" <a href=\"#\">\n"
" <small>Back to top ↑</small>\n"
" </a>\n"
" </div>\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
),
{claim, Name, true, Verify, Value}.
-file("src/ywt/claim.gleam", 166).
?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"
" <div style=\"text-align: right;\">\n"
" <a href=\"#\">\n"
" <small>Back to top ↑</small>\n"
" </a>\n"
" </div>\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
),
{claim, Name, true, Verify, Value}.
-file("src/ywt/claim.gleam", 398).
?DOC(" Verify a single claim, handling required vs optional claims.\n").
-spec verify_single_claim(gleam@dynamic:dynamic_(), claim()) -> {ok, nil} |
{error, ywt@internal@core:parse_error()}.
verify_single_claim(Payload, Claim) ->
Decoder = gleam@dynamic@decode:at(
[erlang:element(2, Claim)],
{decoder, fun gleam@dynamic@decode:decode_dynamic/1}
),
case gleam@dynamic@decode:run(Payload, Decoder) 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", 393).
?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"
" ## 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"
"\n"
" <div style=\"text-align: right;\">\n"
" <a href=\"#\">\n"
" <small>Back to top ↑</small>\n"
" </a>\n"
" </div>\n"
).
-spec verify(gleam@dynamic:dynamic_(), list(claim())) -> {ok, nil} |
{error, ywt@internal@core:parse_error()}.
verify(Payload, Claims) ->
gleam@list:try_each(
Claims,
fun(_capture) -> verify_single_claim(Payload, _capture) end
).
-file("src/ywt/claim.gleam", 446).
?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"
"\n"
" <div style=\"text-align: right;\">\n"
" <a href=\"#\">\n"
" <small>Back to top ↑</small>\n"
" </a>\n"
" </div>\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) ->
gleam@dict:insert(
Dict@1,
erlang:element(2, Claim),
(erlang:element(5, Claim))()
)
end
),
_pipe@3 = maps:to_list(_pipe@2),
gleam@json:object(_pipe@3).