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