import gleam/dict 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 { Claim( name: String, is_required: Bool, verify: fn(Dynamic) -> Result(Nil, ParseError), value: fn() -> Json, ) } /// 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)), /// ... /// ] /// ``` /// ///
/// /// Back to top ↑ /// ///
pub fn issued_at() -> Claim { let value = fn() { encode_numeric_date(timestamp.system_time()) } let verify = fn(_) { Ok(Nil) } Claim(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", []) /// ] /// ``` /// ///
/// /// Back to top ↑ /// ///
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"]), /// ] /// ``` /// ///
/// /// Back to top ↑ /// ///
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)) /// ] /// ``` /// ///
/// /// Back to top ↑ /// ///
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 } }, ) Claim(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. /// ///
/// /// Back to top ↑ /// ///
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 } }) Claim(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. /// ///
/// /// Back to top ↑ /// ///
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", []) /// ] /// ``` /// ///
/// /// Back to top ↑ /// ///
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)) /// ] /// ``` /// ///
/// /// Back to top ↑ /// ///
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) Claim(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, _)) Claim(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()) /// ``` /// ///
/// /// Back to top ↑ /// ///
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)) /// ] /// ``` /// ///
/// /// Back to top ↑ /// ///
pub fn encode_numeric_date(timestamp: Timestamp) { json.int(float.truncate(timestamp.to_unix_seconds(timestamp))) } // -- 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. /// /// ## 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. /// ///
/// /// Back to top ↑ /// ///
pub fn verify(payload: Dynamic, claims: List(Claim)) -> Result(Nil, ParseError) { list.try_each(claims, verify_single_claim(payload, _)) } /// Verify a single claim, handling required vs optional claims. fn verify_single_claim( payload: Dynamic, claim: Claim, ) -> Result(Nil, ParseError) { let decoder = decode.at([claim.name], decode.dynamic) // TODO: MissingClaim and CalimDecodeError are 2 different cases case decode.run(payload, decoder) { 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. /// ///
/// /// Back to top ↑ /// ///
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) { dict.insert(dict, claim.name, claim.value()) }) |> dict.to_list |> json.object }