Packages

The ywt package for JWT targeting javascript.

Current section

Files

Jump to
ywt_webcrypto src ywt.gleam
Raw

src/ywt.gleam

//// <style>
//// .goto-top {
//// display: block;
//// font-size: 0.85em;
//// text-align: right;
//// }
//// </style>
//// <script>
//// (callback => document.readyState !== 'loading' ? callback() : document.addEventListener('DOMContentLoaded', callback, { once: true }))(() => {
//// const goToTop = document.createElement('a')
//// goToTop.classList.add('goto-top')
//// goToTop.setAttribute('href', '#')
//// goToTop.textContent = 'Back to top ↑'
//// for (const member of document.querySelectorAll('.member')) {
//// member.insertBefore(goToTop.cloneNode(true), null)
//// }
//// })
//// </script>
import gleam/bit_array
import gleam/dynamic/decode.{type Decoder}
import gleam/javascript/promise.{type Promise, await, resolve}
import gleam/json.{type Json}
import gleam/time/timestamp
import ywt/algorithm.{type Algorithm}
import ywt/claim.{type Claim}
import ywt/internal/core
import ywt/internal/jwt
import ywt/sign_key.{type SignKey}
import ywt/verify_key.{type VerifyKey}
/// Detailed error information for JWT parsing failures.
///
/// This provides specific information about why JWT validation failed, enabling
/// better error handling, logging, and debugging. Different error types allow
/// you to respond appropriately - for example, expired tokens might trigger
/// token refresh, while signature failures indicate potential attacks.
///
/// ## Error Categories
/// - **Format Errors**: Malformed JWT structure or encoding issues
/// - **Signature Errors**: Cryptographic validation failures
/// - **Claim Errors**: Business logic validation failures
/// - **Key Errors**: Issues with cryptographic keys
///
/// ## Usage Example
/// ```gleam
/// use decoded <- promise.await(ywt.decode(jwt, using: decoder, claims: claims, keys: keys))
/// case decoded {
/// Ok(payload) -> {
/// // Success - use the payload
/// next(payload)
/// }
/// Error(TokenExpired(expired_at)) -> {
/// // Handle expired token - maybe refresh
/// log.info("Token expired at: " <> timestamp.to_string(expired_at))
/// redirect_to_refresh()
/// }
/// Error(InvalidSignature) -> {
/// // Potential security issue - log and reject
/// log.warning("Invalid JWT signature detected")
/// unauthorized()
/// }
/// Error(InvalidIssuer(expected, actual)) -> {
/// // Wrong issuer - reject but log for debugging
/// log.error("Wrong issuer - expected: " <> expected <> ", got: " <> actual)
/// forbidden()
/// }
/// Error(_) -> {
/// // Generic handling for other errors
/// bad_request()
/// }
/// }
/// ```
pub type ParseError {
// 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 or decoding failed for header
InvalidHeaderJson(json.DecodeError)
/// 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: String)
/// Wrong audience
InvalidSubject(expected: List(String), actual: String)
// Wroing 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))
///
InvalidCustomClaim(claim_name: String)
// Payload decoding errors
/// Payload structure doesn't match expected decoder
PayloadDecodingError(List(decode.DecodeError))
}
fn from_core_error(error: core.ParseError) -> ParseError {
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.InvalidHeaderEncoding -> InvalidHeaderEncoding
core.InvalidHeaderJson(error) -> InvalidHeaderJson(error)
core.InvalidId(expected:, actual:) -> InvalidId(expected:, actual:)
core.InvalidIssuer(expected:, actual:) -> InvalidIssuer(expected:, actual:)
core.InvalidPayloadEncoding -> InvalidPayloadEncoding
core.InvalidPayloadJson(error) -> InvalidPayloadJson(error)
core.InvalidSignature -> InvalidSignature
core.InvalidSignatureEncoding -> InvalidSignatureEncoding
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 new cryptographically secure signing key for the specified algorithm.
///
/// This function creates fresh signing keys with appropriate parameters for each
/// algorithm type. All keys are generated using cryptographically secure random
/// number generators and follow current security best practices.
///
/// The generated keys will have a random id set that can be used to identify the key
/// during key rotation.
///
/// ## Usage
/// Use this for key generation in development, testing, or when implementing
/// key rotation systems. For production use, consider generating keys offline
/// and storing them in secure key management systems.
///
/// ## Key Parameters
/// - **HMAC**: Full-entropy random secrets of appropriate length (at least 32/48/64 bytes)
/// - **RSA**: 4096-bit modulus with secure random prime generation
/// - **ECDSA**: Secure random private scalars on specified curves. The digest type matches the selected curve.
pub fn generate_key(algorithm: Algorithm) -> Promise(SignKey) {
use key <- await(algorithm.generate_key(
algorithm,
generate_hmac,
generate_ecdsa,
generate_rsa,
))
resolve(sign_key.with_random_id(key))
}
@external(javascript, "./ywt_ffi.mjs", "generate_ecdsa")
fn generate_ecdsa(
curve: core.NamedCurve,
digest: core.DigestType,
) -> Promise(SignKey)
@external(javascript, "./ywt_ffi.mjs", "generate_hmac")
fn generate_hmac(digest: core.DigestType) -> Promise(SignKey)
@external(javascript, "./ywt_ffi.mjs", "generate_rsa")
fn generate_rsa(
digest: core.DigestType,
key_length: Int,
padding: core.Padding,
) -> Promise(SignKey)
/// Creates a cryptographic signature for the given message using the specified signing key.
///
/// This is a low-level function that performs the actual cryptographic signing operation.
///
/// ## Usage
/// This function is primarily used internally by higher-level JWT functions. Use this
/// directly only when you need raw signature operations outside of JWT context.
///
/// ## Security Considerations
/// - Never sign untrusted or unvalidated input data
/// - Ensure signing keys are stored securely and accessed only by authorized code
///
/// ## Example
/// ```gleam
/// let message = <<"Hello, world!":utf8>>
/// let signing_key = load_secure_signing_key()
///
/// use signature <- promise.await(ywt.sign_bits(message, signing_key))
/// // signature is raw bytes that can be verified with corresponding verify key
/// ```
///
/// ⚠️ **Warning:** This function performs raw cryptographic operations. Most applications
/// should use the higher-level JWT functions instead.
@external(javascript, "./ywt_ffi.mjs", "sign")
pub fn sign_bits(message: BitArray, key: SignKey) -> Promise(BitArray)
/// Creates a base64url-encoded signature for a UTF-8 string message.
///
/// This is a convenience wrapper around `sign_bits` that handles string encoding
/// and base64url encoding of the signature, commonly used for signing JWT components.
///
/// ## Usage
/// Use this when you need to sign string data and want the signature in base64url
/// format for use in web contexts like JWTs or HTTP headers.
///
/// ## Security Considerations
/// - Same security considerations as `sign_bits` apply
/// - The string is encoded as UTF-8 before signing
/// - Verify signatures using the corresponding `verify_string` function
///
/// ## Example
/// ```gleam
/// let jwt_payload = "eyJzdWIiOiIxMjM0NTY3ODkwIn0"
/// let signing_key = load_jwt_signing_key()
///
/// use signature <- promise.await(sign_string(jwt_payload, signing_key)))
/// // signature is base64url-encoded string ready for JWT use
/// ```
///
/// 💡 **Note:** The returned signature is base64url-encoded (URL-safe, no padding)
/// as required by JWT and other web standards.
pub fn sign_string(message: String, key: SignKey) -> Promise(String) {
use bits <- await(sign_bits(<<message:utf8>>, key))
resolve(bit_array.base64_url_encode(bits, False))
}
/// Verifies a cryptographic signature against a message using the specified verification key.
///
/// This is a low-level function that performs the actual cryptographic verification.
/// Returns `True` if the signature is valid for the given message and key, `False` otherwise.
///
/// ## Usage
/// Use this for raw signature verification operations. The verification algorithm
/// is determined by the key type and must match the algorithm used for signing.
///
/// ## Example
/// ```gleam
/// let message = <<"Hello, world!":utf8>>
/// let signature = // ... received signature bytes
/// let verify_key = load_verification_key()
///
/// use is_valid <- promise.await(verify_bits(message, signature, verify_key))
/// case is_valid {
/// True -> process_verified_message(message)
/// False -> reject_invalid_signature()
/// }
/// ```
///
/// 🔒 **Critical:** Never trust data with invalid signatures. A `False` result
/// indicates potential tampering or use of wrong keys.
@external(javascript, "./ywt_ffi.mjs", "verify")
pub fn verify_bits(
message: BitArray,
signature: BitArray,
key: VerifyKey,
) -> Promise(Bool)
/// Verifies a base64url-encoded signature against a UTF-8 string message.
///
/// This is a convenience wrapper around `verify_bits` that handles string encoding
/// and base64url decoding of the signature. Commonly used for verifying JWT signatures.
///
/// ## Usage
/// Use this when verifying signatures that are base64url-encoded, such as those
/// from JWTs or other web-based cryptographic protocols.
///
/// ## Security Considerations
/// - Same security considerations as `verify_bits` apply
/// - Invalid base64url encoding in the signature automatically returns `False`
///
/// ## Example
/// ```gleam
/// let payload = "eyJzdWIiOiIxMjM0NTY3ODkwIn0"
/// let signature = "base64url-encoded-signature-string"
/// let verify_key = load_jwt_verification_key()
///
/// use is_valid <- promise.await(verify_string(payload, signature, verify_key))
/// case is_valid {
/// True -> {
/// // Signature is valid, payload can be trusted
/// process_authenticated_request(payload)
/// }
/// False -> {
/// // Signature invalid - reject the request
/// return_authentication_error()
/// }
/// }
/// ```
///
/// ⚠️ **Important:** Malformed base64url signatures return `False` rather than
/// causing errors, ensuring consistent handling of invalid input.
pub fn verify_string(
message: String,
signature: String,
key: VerifyKey,
) -> Promise(Bool) {
case bit_array.base64_url_decode(signature) {
Ok(signature) -> verify_bits(<<message:utf8>>, signature, key)
Error(_) -> resolve(False)
}
}
/// Extracts payload from a JWT without verifying its signature or claims.
///
/// 🚨 **USE WITH EXTREME CAUTION** Only use this when you need to inspect tokens
/// from trusted sources where signature verification is handled elsewhere.
///
/// ## Security Considerations
/// - **NEVER use this in production for authentication**
/// - Tokens could be forged or tampered with
/// - No expiration or claim validation is performed
/// - Only use when signature validation happens at a different layer
/// - Consider this as dangerous as accepting any user input
pub fn decode_unsafely_without_validation(
jwt: String,
payload_decoder: Decoder(payload),
) -> Result(payload, Nil) {
jwt.decode_unsafely_without_validation(jwt, payload_decoder)
}
/// Verifies a JWT signature and validates all claims, returning the decoded payload if successful.
///
/// Use this to validate incoming JWTs from clients, ensuring they're authentic and
/// haven't been tampered with.
///
/// ## Security Considerations
/// - Implement appropriate claims validation (expiration, issuer, audience)
/// - Handle verification failures securely (don't leak information)
/// - Consider rate limiting to prevent brute force attacks
///
/// ## Example
/// ```gleam
/// // Define expected claims for validation
/// let claims = [
/// claim.expires_at(max_age: duration.hours(1), leeway: duration.minutes(5)),
/// claim.issuer("my-app", []),
/// ]
///
/// // Parse and validate the JWT
/// use decoded <- promise.await(
/// ywt.decode(jwt_token, using: payload_decoder, claims:, keys: [verify_key])
/// )
/// case decoded {
/// Ok(payload) -> {
/// // JWT is valid, use the payload
/// let user_id = // extract user ID from payload
/// authorize_request(user_id)
/// }
/// Error(_) -> {
/// // JWT is invalid - reject the request
/// unauthorized_response()
/// }
/// }
/// ```
///
/// 💡 **Best Practice:** Always validate JWTs on every request and never trust
/// client-provided tokens without verification.
pub fn decode(
jwt jwt: String,
using decoder: Decoder(payload),
claims claims: List(Claim),
keys keys: List(VerifyKey),
) -> Promise(Result(payload, ParseError)) {
let verify = fn(message, signature, key, next) {
use result <- await(verify_bits(message, signature, key))
next(result)
}
let resolve = fn(result) {
case result {
Ok(result) -> resolve(Ok(result))
Error(error) -> resolve(Error(from_core_error(error)))
}
}
jwt.decode(jwt:, using: decoder, claims:, keys:, verify:, resolve:)
}
/// Creates a signed JWT containing the specified payload and claims.
///
/// Signed JWTs prevent actors without access to the SignKey from modifying it.
/// This can be verified using the corresponding VerifyKey.
///
/// ## Security Considerations
/// - JWTs are signed, not encrypted - all data is publicly readable
/// - Never include sensitive data like passwords or personal information
/// - Keep payloads small to avoid large tokens
/// - Include appropriate expiration times in your claims
/// - Use strong signing keys and rotate them regularly
///
/// If a field is present in claims as well as the payload, the payload takes
/// precedence.
///
/// ## Example
/// ```gleam
/// // Create a user session token
/// let payload = [
/// #("sub", json.string("user_12345")),
/// #("role", json.string("developer")),
/// #("permissions", json.array([json.string("read"), json.string("write")]))
/// ]
///
/// let claims = [
/// claim.issued_at(),
/// claim.expires_at(max_age: duration.hours(1), leeway: duration.minutes(5)),
/// claim.issuer("my-app", []),
/// ]
///
/// use jwt <- promise.await(ywt.encode(payload: payload, claims: claims, key: sign_key))
/// // Returns: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ..."
/// ```
///
/// ⚠️ **Remember:** Anyone can decode and read JWT contents. Only include data
/// you're comfortable being public.
pub fn encode(
payload payload: List(#(String, Json)),
claims claims: List(Claim),
key key: SignKey,
) -> Promise(String) {
let sign = fn(message, key, next) {
use signature <- await(sign_bits(message, key))
resolve(next(signature))
}
jwt.encode(payload:, claims:, key:, sign:)
}