//// //// import gleam/dict import gleam/dynamic import gleam/dynamic/decode.{type Decoder, type Dynamic} import gleam/float import gleam/json.{type Json} import gleam/list import gleam/order import gleam/time/duration.{type Duration} import gleam/time/timestamp.{type Timestamp} import ywt/internal/core.{type ParseError} /// A Claim is a validation rule on the JWT. /// You can for example validate that the issuer has a value you expect, /// or that the JWT is not expired yet. pub opaque type Claim { HeaderClaim( name: String, is_required: Bool, verify: fn(Dynamic) -> Result(Nil, ParseError), value: fn() -> Json, ) PayloadClaim( name: String, is_required: Bool, verify: fn(Dynamic) -> Result(Nil, ParseError), value: fn() -> Json, ) } /// Validates that the JWT header has a specific `typ` (type) field, and includes /// it in generated tokens. /// /// The `typ` header parameter is used to declare the media type of the JWT. /// While RFC 7519 makes this field optional, many systems expect `"JWT"` as the /// standard value for JSON Web Tokens. Use this claim when integrating with /// systems that require explicit type declaration in the JWT header. /// /// ## Example /// ```gleam /// // Standard JWT type validation /// let claims = [ /// claim.typ("JWT"), /// claim.expires_at(max_age: duration.hours(1), leeway: duration.minutes(5)) /// ] /// ``` pub fn typ(expected: String) -> Claim { let name = "typ" let value = fn() { json.string(expected) } let on_error = fn(found) { core.InvalidHeaderJson( json.UnableToDecode([decode.DecodeError(expected:, found:, path: [name])]), ) } let verify = validate(name, decode.string, on_error, fn(v) { expected == v }) HeaderClaim(name:, is_required: True, value:, verify:) } /// Adds the current timestamp as the "issued at" (iat) claim. /// /// Include this in JWTs to track when tokens were issued, useful for debugging /// and audit trails. To reject tokens that were created in the future, use `not_before`. /// /// ## Example /// ```gleam /// let claims = [ /// claim.expires_at(max_age: duration.hours(1), leeway: duration.minutes(5)), /// ... /// ] /// ``` pub fn issued_at() -> Claim { let value = fn() { encode_numeric_date(timestamp.system_time()) } let verify = fn(_) { Ok(Nil) } PayloadClaim(name: "iat", is_required: False, value:, verify:) } /// Validates that the JWT comes from a trusted issuer, and includes that issuer /// in generated tokens. /// /// Use this to ensure JWTs are only accepted from authorized authentication services, /// preventing token misuse across different services. It is common practice to /// set the issuer to a URL with a `.well-known/jwks.json` endpoint, from which /// public verification keys can be fetched. /// /// ## Example /// ```gleam /// // Only accept tokens from your auth service /// let claims = [ /// claim.issuer("https://auth.example.com", []) /// ] /// ``` pub fn issuer(issuer: String, others: List(String)) -> Claim { string_claim("iss", issuer, others, core.InvalidIssuer) } /// Validates that the JWT is intended for your service, and includes the primary /// audience in generated tokens. /// /// Include this to ensure JWTs are only used by their intended recipients, /// preventing tokens meant for other services from being accepted. /// /// ## Example /// ```gleam /// // For an API service /// let claims = [ /// claim.audience("api.myapp.com", ["admin-api.myapp.com"]), /// ] /// ``` /// /// 💡 **Note:** Even without this claim, tokens containing an `aud` field are /// rejected — a token scoped to a specific audience should not be accepted by a /// service that isn't checking audiences. Add this claim to accept tokens with a /// matching audience. pub fn audience(primary: String, others: List(String)) -> Claim { string_claim("aud", primary, others, core.InvalidAudience) } /// Adds a "not before" (nbf) claim that prevents the JWT from being valid until the current time. /// /// Use this for JWTs that should only become valid at a specific time, such as /// scheduled operations or delayed activations. /// /// ## Example /// ```gleam /// // Token becomes valid immediately, allowing 1 minute clock skew /// let claims = [ /// claim.not_before(timestamp.system_time(), leeway: duration.minutes(1)), /// claim.expires_at(max_age: duration.hours(2), leeway: duration.minutes(1)) /// ] /// ``` /// /// 💡 **Note:** Even without this claim, tokens containing an `nbf` field are /// still checked against the current time with zero leeway. Add this claim /// explicitly to control the `leeway`. pub fn not_before(time time: Timestamp, leeway leeway: Duration) -> Claim { let name = "nbf" let value = fn() { encode_numeric_date(time) } let verify = validate( name, numeric_date_decoder(), core.TokenNotYetValid, fn(not_before) { let now = timestamp.system_time() case timestamp.compare(not_before, timestamp.add(now, leeway)) { order.Gt -> False _ -> True } }, ) PayloadClaim(name:, is_required: True, value:, verify:) } /// Sets an expiration time for the JWT, automatically rejecting expired tokens. /// /// **ALWAYS include this** - it's your primary defense against token replay attacks /// and limits the damage from compromised tokens. /// /// ## Security Considerations /// - Make expiration times as short as reasonably possible /// - Include reasonable leeway for clock differences /// /// ## Example /// ```gleam /// // Short-lived API access token /// let api_claims = [ /// claim.expires_at(max_age: duration.minutes(30), leeway: duration.minutes(5)) /// ] /// /// // Longer-lived refresh token /// let session_claims = [ /// claim.expires_at(max_age: duration.hours(24 * 7), leeway: duration.minutes(5)) /// ] /// ``` /// /// 🚨 **Critical:** Never create or parse JWTs without expiration times - /// they would be valid forever if compromised. /// /// 💡 **Note:** Even without this claim, tokens containing an `exp` field are /// still checked for expiration with zero leeway. Add this claim explicitly to /// control the `max_age` and `leeway`. pub fn expires_at(max_age max_age: Duration, leeway leeway: Duration) -> Claim { let name = "exp" let value = fn() { timestamp.system_time() |> timestamp.add(max_age) |> encode_numeric_date } let verify = validate(name, numeric_date_decoder(), core.TokenExpired, fn(expires_at) { let now = timestamp.system_time() case timestamp.compare(timestamp.add(expires_at, leeway), now) { order.Gt -> True _ -> False } }) PayloadClaim(name:, is_required: True, value:, verify:) } /// Adds a unique identifier (jti) to the JWT for tracking and revocation. /// /// Use this when you need to track specific tokens or implement token revocation /// mechanisms. pub fn id(id: String, others: List(String)) -> Claim { string_claim("jti", id, others, core.InvalidId) } /// Identifies the subject (typically a user) that the JWT represents. /// /// Use this to bind JWTs to specific users or entities, enabling proper /// authorization decisions. This could be an internal user id. /// /// ## Security Considerations /// - Use stable, unique identifiers (user IDs, not usernames) /// - Don't include personally identifiable information /// /// ## Example /// ```gleam /// // Bind token to a specific user /// let claims = [ /// claim.subject("user_12345", []), /// claim.expires_at(max_age: duration.hours(1), leeway: duration.minutes(5)), /// claim.audience("api.myapp.com", []) /// ] /// ``` pub fn subject(subject: String, others: List(String)) -> Claim { string_claim("sub", subject, others, core.InvalidSubject) } /// Creates a custom claim with a specific name and value that must match exactly during validation. /// /// Use this for application-specific claims like roles, permissions, or custom /// business logic requirements. /// /// ## Example /// ```gleam /// // Add role-based authorization /// let claims = [ /// claim.custom( /// name: "role", /// value: "admin", /// encode: json.string, /// decoder: decode.string /// ), /// claim.custom( /// name: "permissions", /// value: ["read", "write", "delete"], /// encode: json.array(_, json.string), /// decoder: decode.list(decode.string) /// ), /// claim.expires_at(max_age: duration.hours(1), leeway: duration.minutes(5)) /// ] /// ``` pub fn custom( name name: String, value value: a, encode encode: fn(a) -> Json, decoder decoder: Decoder(a), ) -> Claim { let on_error = fn(_) { core.InvalidCustomClaim(name) } let check = fn(decoded) { decoded == value } let value = fn() { encode(value) } let verify = validate(name, decoder, on_error, check) PayloadClaim(name:, is_required: True, value:, verify:) } fn validate( name: String, decoder: Decoder(a), on_error: fn(a) -> ParseError, validate: fn(a) -> Bool, ) -> fn(Dynamic) -> Result(Nil, ParseError) { fn(data) { case decode.run(data, decoder) { Ok(value) -> case validate(value) { True -> Ok(Nil) False -> Error(on_error(value)) } Error(error) -> Error(core.ClaimDecodingError(name, error)) } } } fn string_claim( name: String, primary: String, others: List(String), error: fn(List(String), String) -> ParseError, ) { let expected = [primary, ..others] let value = fn() { json.string(primary) } let verify = validate(name, decode.string, error(expected, _), list.contains(expected, _)) PayloadClaim(name:, is_required: True, value:, verify:) } /// Decodes JWT numeric date values (seconds since Unix epoch) into timestamp objects. /// /// Use this when manually processing JWT claims that contain timestamp values. /// /// ## Example /// ```gleam /// // Manually decode an expiration claim /// let exp_decoder = decode.field("exp", numeric_date_decoder()) /// ``` pub fn numeric_date_decoder() -> Decoder(Timestamp) { decode.map(decode.int, timestamp.from_unix_seconds) } /// Encodes a timestamp into the JWT numeric date format (seconds since Unix epoch). /// /// Use this when manually creating JWT payloads with timestamp values. /// /// ## Example /// ```gleam /// // Add custom timestamp to payload /// let payload = [ /// #("custom_date", encode_numeric_date(my_timestamp)) /// ] /// ``` pub fn encode_numeric_date(timestamp: Timestamp) { json.int(float.truncate(timestamp.to_unix_seconds(timestamp))) } /// Marks a claim as optional, meaning the JWT will still be considered valid /// even if this claim is missing from the token. By default, all claims are /// required — a missing required claim causes validation to fail with a /// `MissingClaim` error. /// /// If the claim is already optional, it is returned unchanged. /// /// ## Example /// ```gleam /// // Accept tokens with or without a "jti" (token ID) /// let claims = [ /// claim.id("abc-123", []) |> claim.optional, /// claim.expires_at(max_age: duration.hours(1), leeway: duration.minutes(5)) /// ] /// ``` pub fn optional(claim: Claim) -> Claim { case claim { HeaderClaim(is_required: True, ..) -> HeaderClaim(..claim, is_required: False) PayloadClaim(is_required: True, ..) -> PayloadClaim(..claim, is_required: False) HeaderClaim(is_required: False, ..) | PayloadClaim(is_required: False, ..) -> claim } } // -- CLAIM ENCODE/DECODE ------------------------------------------------------ /// Validates all claims against a JWT payload. /// /// This function checks each claim in the provided list against the JWT payload, /// returning the first validation error encountered or success if all claims pass. /// /// 🚨 This is a low-level function used internally by ywt. /// /// ## Default Claim Validation /// /// Even if you don't explicitly include `exp`, `nbf`, or `aud` claims, they /// will still be validated when present in the token: /// /// - **`exp`** (expiration): If present, the token must not be expired (zero leeway). /// - **`nbf`** (not before): If present, the token must be valid at the current /// time (zero leeway). /// - **`aud`** (audience): If present, the token is rejected. /// /// This prevents accepting tokens that carry security constraints you forgot to /// enforce. To customise validation behaviour (e.g. allowing leeway), add the /// corresponding claim explicitly — your claims always take precedence over the /// defaults. /// /// ## Example /// ```gleam /// let payload = // ... decoded JWT payload /// /// let security_claims = [ /// expires_at(max_age: duration.hours(1), leeway: duration.minutes(5)), /// issuer("https://auth.myapp.com", []), /// audience("https://api.myapp.com", []), /// custom("role", "admin", json.string, decode.string) /// ] /// /// case verify(payload, security_claims) { /// Ok(Nil) -> { /// // All claims validated successfully /// process_authenticated_request(payload) /// } /// Error(TokenExpired(expired_at)) -> { /// log.info("Token expired at: " <> timestamp.to_string(expired_at)) /// return_authentication_error() /// } /// Error(InvalidIssuer(expected, actual)) -> { /// log.warning("Invalid issuer - expected: " <> expected <> ", got: " <> actual) /// return_forbidden_error() /// } /// Error(error) -> { /// log.error("Claim validation failed: " <> string.inspect(error)) /// return_bad_request_error() /// } /// } /// ``` /// /// ⚠️ **Important:** This function only validates claims - it does not verify /// cryptographic signatures. Use the full `decode()` function for complete JWT validation. pub fn verify(payload: Dynamic, claims: List(Claim)) -> Result(Nil, ParseError) { verify_with_header(dynamic.nil(), payload, claims) } /// Validates all claims against both the JWT header and payload. /// /// In addition to checking user-provided claims, this function automatically /// adds optional default validation for security-critical claims (`exp`, `nbf`, /// and `aud`) when the user has not explicitly included them. These defaults are /// added as optional claims — they are only enforced when the corresponding field /// is present in the token: /// /// - **`exp`** (expiration): If present, the token must not be expired (zero leeway). /// - **`nbf`** (not before): If present, the token must be valid at the current time (zero leeway). /// - **`aud`** (audience): If present, the token is rejected — a token scoped to /// a specific audience should not be accepted by a service that isn't checking /// audiences. /// /// This prevents accepting tokens that carry security constraints the caller /// forgot to enforce. To customise validation behaviour (e.g. allowing leeway), /// add the corresponding claim explicitly — user-provided claims always take /// precedence over these defaults. /// /// 🚨 This is a low-level function used internally by ywt. @internal pub fn verify_with_header( header: Dynamic, payload: Dynamic, claims: List(Claim), ) -> Result(Nil, ParseError) { claims |> add_default_claim(expires_at(duration.seconds(0), duration.seconds(0))) |> add_default_claim(not_before(timestamp.system_time(), duration.seconds(0))) |> add_default_claim(audience("", [])) |> list.try_each(verify_single_claim(header, payload, _)) } fn add_default_claim(claims, claim: Claim) { // TODO: @Speed case has_payload_claim(claims, claim.name) { True -> claims False -> [optional(claim), ..claims] } } fn has_payload_claim(claims, name) { use claim <- list.any(claims) case claim { PayloadClaim(name: claim_name, ..) -> name == claim_name HeaderClaim(..) -> False } } /// Verify a single claim, handling required vs optional claims. fn verify_single_claim( header: Dynamic, payload: Dynamic, claim: Claim, ) -> Result(Nil, ParseError) { let decoder = decode.at([claim.name], decode.dynamic) let result = case claim { HeaderClaim(..) -> decode.run(header, decoder) PayloadClaim(..) -> decode.run(payload, decoder) } // TODO: MissingClaim and ClaimDecodeError are 2 different cases case result { Ok(claim_value) -> claim.verify(claim_value) // Optional claim missing is OK Error(_) if !claim.is_required -> Ok(Nil) Error(_) -> Error(core.MissingClaim(claim.name)) } } /// Encode claims and application data into JSON. /// /// This function merges application data with JWT claims into a single JSON object /// for token encoding. When the same field appears in both lists, claims take /// precedence to ensure security-critical values cannot be overridden. /// /// 🚨 This is a low-level function used internally by ywt. /// /// ## Example /// ```gleam /// let user_data = [ /// #("name", json.string("Alice")), /// #("role", json.string("user")), /// #("exp", json.int(1234567890)) // This will be overridden /// ] /// /// let security_claims = [ /// expires_at(max_age: duration.hours(1), leeway: duration.minutes(5)), /// custom("role", "admin", json.string, decode.string) // This takes precedence /// ] /// /// let payload = encode(security_claims, user_data) /// // Result: {"name": "Alice", "role": "admin", "exp": } /// ``` /// /// 💡 **Best Practice:** Place security-sensitive fields in claims rather than /// data to validate them and ensure they cannot be accidentally overridden. pub fn encode(claims: List(Claim), data: List(#(String, Json))) -> Json { // we roundtrip using a dict here to ensure consistent behaviour when a key // appears in both lists or mulitple times. // We want claims to take precedence, so we add them second. dict.new() |> list.fold(from: _, over: data, with: fn(dict, pair) { dict.insert(dict, pair.0, pair.1) }) |> list.fold(from: _, over: claims, with: fn(dict, claim) { case claim { PayloadClaim(..) -> dict.insert(dict, claim.name, claim.value()) _ -> dict } }) |> dict.to_list |> json.object } @internal pub fn encode_header(claims: List(Claim), data: List(#(String, Json))) -> Json { dict.new() |> list.fold(from: _, over: data, with: fn(dict, pair) { dict.insert(dict, pair.0, pair.1) }) |> list.fold(from: _, over: claims, with: fn(dict, claim) { case claim { HeaderClaim(..) -> dict.insert(dict, claim.name, claim.value()) _ -> dict } }) |> dict.to_list |> json.object }