Current section
Files
Jump to
Current section
Files
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/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(<<message:utf8>>, 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(<<message:utf8>>, 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:)
}