-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( " \n" " \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).