//// //// import gleam/bit_array import gleam/dynamic/decode.{type Decoder} import gleam/json.{type Json} import gleam/result import gleam/time/timestamp import ywt/algorithm.{type Algorithm} import ywt/claim.{type Claim} import ywt/internal/base64url import ywt/internal/core import ywt/internal/jwt import ywt/sign_key.{type SignKey} import ywt/verify_key.{type VerifyKey} /// An error returned while decoding and verifying a JWT. /// /// Treat all errors as authentication failures at the boundary. The variants are /// useful for logging and for flows such as token refresh, but avoid leaking /// detailed verification failures to clients. pub type Error { // Format and structure errors /// JWT doesn't contain exactly 3 parts separated by dots MalformedToken /// Base64 decoding failed for header InvalidHeaderEncoding /// Base64 decoding failed for payload InvalidPayloadEncoding /// Base64 decoding failed for signature InvalidSignatureEncoding /// JSON parsing failed for header InvalidHeaderJson(json.DecodeError) /// Header structure doesn't match the JWT header decoder HeaderDecodingError(List(decode.DecodeError)) /// JWT uses critical JOSE header extensions that ywt does not support UnsupportedCriticalHeader /// JWT asks for an unencoded JWS payload, which ywt does not support UnsupportedUnencodedPayload /// JSON parsing failed for payload - note that this does not include /// failures for your payload decoder. See `PayloadDecodingError`. InvalidPayloadJson(json.DecodeError) // Cryptographic errors /// No suitable key found to verify the signature NoMatchingKey /// Signature verification failed (potential tampering) InvalidSignature // Key has invalid parameters or format //InvalidKey(details: String) // // Claim validation errors /// Token has expired TokenExpired(expired_at: timestamp.Timestamp) /// Token is not yet valid (nbf claim) TokenNotYetValid(not_before: timestamp.Timestamp) /// Wrong issuer InvalidIssuer(expected: List(String), actual: String) /// Wrong audience InvalidAudience(expected: List(String), actual: List(String)) /// Wrong typ header InvalidType(expected: String, actual: String) /// Wrong subject InvalidSubject(expected: List(String), actual: String) /// Wrong JWT ID InvalidId(expected: List(String), actual: String) /// Required claim is missing MissingClaim(claim_name: String) /// Claim Decoding Error ClaimDecodingError(claim_name: String, error: List(decode.DecodeError)) /// Custom claim did not match InvalidCustomClaim(claim_name: String) // Payload decoding errors /// Payload structure doesn't match expected decoder PayloadDecodingError(List(decode.DecodeError)) } fn from_core_error(error: core.Error) -> Error { case error { core.ClaimDecodingError(claim_name:, error:) -> ClaimDecodingError(claim_name:, error:) core.InvalidAudience(expected:, actual:) -> InvalidAudience(expected:, actual:) core.InvalidCustomClaim(claim_name:) -> InvalidCustomClaim(claim_name:) core.HeaderDecodingError(error) -> HeaderDecodingError(error) core.InvalidHeaderEncoding -> InvalidHeaderEncoding core.InvalidHeaderJson(error) -> InvalidHeaderJson(error) core.InvalidId(expected:, actual:) -> InvalidId(expected:, actual:) core.InvalidIssuer(expected:, actual:) -> InvalidIssuer(expected:, actual:) core.InvalidType(expected:, actual:) -> InvalidType(expected:, actual:) core.InvalidPayloadEncoding -> InvalidPayloadEncoding core.InvalidPayloadJson(error) -> InvalidPayloadJson(error) core.InvalidSignature -> InvalidSignature core.InvalidSignatureEncoding -> InvalidSignatureEncoding core.UnsupportedCriticalHeader -> UnsupportedCriticalHeader core.UnsupportedUnencodedPayload -> UnsupportedUnencodedPayload core.InvalidSubject(expected:, actual:) -> InvalidSubject(expected:, actual:) core.MalformedToken -> MalformedToken core.MissingClaim(claim_name:) -> MissingClaim(claim_name:) core.NoMatchingKey -> NoMatchingKey core.PayloadDecodingError(error) -> PayloadDecodingError(error) core.TokenExpired(expired_at:) -> TokenExpired(expired_at:) core.TokenNotYetValid(not_before:) -> TokenNotYetValid(not_before:) } } /// Generates a signing key for an algorithm. /// /// Generated keys include a random `kid` for key rotation. Store signing keys as /// secrets; anyone with one can mint trusted tokens. /// /// ```gleam /// let signing_key = ywt.generate_key(algorithm.es384) /// let verify_key = verify_key.derived(signing_key) /// ``` pub fn generate_key(algorithm: Algorithm) -> SignKey { let key = algorithm.generate_key( algorithm, generate_hmac, generate_ecdsa, generate_rsa, ) sign_key.with_random_id(key) } @external(erlang, "ywt_ffi", "generate_ecdsa") fn generate_ecdsa(curve: core.NamedCurve, digest: core.DigestType) -> SignKey @external(erlang, "ywt_ffi", "generate_hmac") fn generate_hmac(digest: core.DigestType) -> SignKey @external(erlang, "ywt_ffi", "generate_rsa") fn generate_rsa( digest: core.DigestType, key_length: Int, padding: core.Padding, ) -> SignKey /// Signs bytes with a signing key. /// /// This is a low-level function used internally by ywt. /// /// This is a low-level primitive. Most applications should use `encode` so the /// JWT header, payload, and claims are constructed consistently. Raw signatures /// do not carry claim or audience validation. @external(erlang, "ywt_ffi", "sign") pub fn sign_bits(message: BitArray, key: SignKey) -> BitArray /// Signs a UTF-8 string and returns an unpadded base64url signature. /// /// This is a low-level helper for JWT-style signing input. Prefer `encode` for /// complete JWTs. /// /// ```gleam /// ywt.sign_string("header.payload", signing_key) /// ``` pub fn sign_string(message: String, key: SignKey) -> String { let bits = sign_bits(<>, key) bit_array.base64_url_encode(bits, False) } /// Verifies a byte signature with a verification key. /// /// This is a low-level function used internally by ywt. /// /// This only verifies the signature over the exact bytes you pass. It does not /// parse JWTs or validate claims. /// /// RSA JWKs decoded without `alg` cannot be used for raw verification and return /// `False`; use `decode` when the signed JWT header should choose the algorithm. @external(erlang, "ywt_ffi", "verify") pub fn verify_bits( message: BitArray, signature: BitArray, key: VerifyKey, ) -> Bool /// Verifies an unpadded base64url signature for a UTF-8 string. /// /// Invalid base64url returns `False`. This only verifies the signature over the /// exact string you pass; use `decode` for complete JWT verification. pub fn verify_string( message: String, signature: String, key: VerifyKey, ) -> Bool { case base64url.decode(signature) { Ok(signature) -> verify_bits(<>, signature, key) Error(_) -> False } } /// Decodes a JWT payload without verifying the signature or claims. /// /// Do not use this for authentication or authorization. The token may be forged, /// expired, or intended for another audience. No `exp`, `nbf`, or `aud` checks /// are performed. pub fn decode_unsafely_without_validation( jwt: String, payload_decoder: Decoder(payload), ) -> Result(payload, Error) { jwt.decode_unsafely_without_validation(jwt, payload_decoder) |> result.map_error(from_core_error) } /// Verifies a JWT and decodes its payload. /// /// Tokens with `exp` and `nbf` are checked by default with zero leeway. Tokens /// with `aud` are rejected by default unless you pass an `audience` claim. /// Audience validation accepts string or array values. /// /// Unknown non-critical JWT header fields are ignored by ywt. Tokens with a /// `crit` header parameter or `b64: false` are rejected. Unknown payload fields /// are accepted or rejected by your payload decoder. /// /// ```gleam /// let claims = [ /// claim.expires_at(max_age: duration.minutes(15), leeway: duration.minutes(1)), /// claim.issuer("https://auth.example.com", []), /// claim.audience("https://api.example.com", []), /// ] /// /// let decoder = decode.field("sub", decode.string) /// let result = ywt.decode(jwt, using: decoder, claims:, keys: [verify_key]) /// ``` pub fn decode( jwt jwt: String, using decoder: Decoder(payload), claims claims: List(Claim), keys keys: List(VerifyKey), ) -> Result(payload, Error) { let verify = fn(message, signature, key, next) { next(verify_bits(message, signature, key)) } let resolve = result.map_error(_, from_core_error) jwt.decode(jwt:, using: decoder, claims:, keys:, verify:, resolve:) } /// Creates a signed JWT from payload data and claims. /// /// JWTs are signed, not encrypted, so anyone with the token can read the /// payload. Claims take precedence when a field appears in both `payload` and /// `claims`; put security fields such as `exp`, `iss`, and `aud` in claims. /// /// ```gleam /// let payload = [ /// #("sub", json.string("user_123")), /// #("role", json.string("admin")), /// ] /// /// let claims = [ /// claim.expires_at(max_age: duration.minutes(15), leeway: duration.minutes(1)), /// claim.issuer("https://auth.example.com", []), /// claim.audience("https://api.example.com", []), /// ] /// /// let jwt = ywt.encode(payload, claims, signing_key) /// ``` pub fn encode( payload payload: List(#(String, Json)), claims claims: List(Claim), key key: SignKey, ) -> String { let sign = fn(message, key, next) { next(sign_bits(message, key)) } jwt.encode(payload:, claims:, key:, sign:) }