defmodule JwtLite do @moduledoc """ Lightweight JWT decoder for Elixir. Decodes and inspects JWTs without performing signature verification. Useful for inspecting token claims in development, logging, and debugging. ## Usage iex> token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" iex> {:ok, %{header: h, payload: p}} = JwtLite.decode(token) iex> h["alg"] "HS256" iex> p["name"] "John Doe" ## Tip Jar If this library saves you time, tips are welcome: ETH/Polygon/Base: `0x7463903430D5294DcfBa09Abb9B8B30A42BdFe36` """ @doc """ Decodes a JWT string into its constituent parts. Returns `{:ok, %{header: map, payload: map, signature: binary}}` on success, or `{:error, reason}` if the token is malformed. ## Options * `:validate_exp` - When `true`, returns `{:error, :expired}` if the `exp` claim is in the past. Defaults to `false`. ## Examples iex> JwtLite.decode("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abc") {:ok, %{header: %{"alg" => "HS256", "typ" => "JWT"}, payload: %{"sub" => "1234567890"}, signature: "abc"}} """ @spec decode(String.t(), keyword()) :: {:ok, map()} | {:error, atom()} def decode(token, opts \\\\ []) when is_binary(token) do case String.split(token, ".") do [header_enc, payload_enc, signature] -> with {:ok, header} <- decode_part(header_enc), {:ok, payload} <- decode_part(payload_enc) do result = %{header: header, payload: payload, signature: signature} if Keyword.get(opts, :validate_exp, false) do validate_expiry(result) else {:ok, result} end end _ -> {:error, :invalid_format} end end @doc """ Like `decode/2` but raises on error. """ @spec decode!(String.t(), keyword()) :: map() def decode!(token, opts \\\\ []) do case decode(token, opts) do {:ok, result} -> result {:error, reason} -> raise ArgumentError, "Failed to decode JWT: #{reason}" end end @doc """ Returns `true` if the token is expired (has an `exp` claim in the past). Returns `false` if valid or if there is no `exp` claim. """ @spec expired?(String.t()) :: boolean() def expired?(token) when is_binary(token) do case decode(token, validate_exp: true) do {:error, :expired} -> true _ -> false end end @doc """ Returns the remaining seconds until the token expires, or `nil` if no `exp` claim. Returns a negative number if already expired. """ @spec ttl(String.t()) :: integer() | nil def ttl(token) when is_binary(token) do case decode(token) do {:ok, %{payload: %{"exp" => exp}}} -> exp - System.system_time(:second) _ -> nil end end # Private defp decode_part(encoded) do padded = pad_base64(encoded) case Base.decode64(padded, padding: true) do {:ok, json} -> case Jason.decode(json) do {:ok, map} -> {:ok, map} _ -> # Try without Jason (manual JSON would be needed, fallback to raw) {:ok, %{"_raw" => json}} end :error -> {:error, :invalid_base64} end rescue _ -> {:error, :decode_failed} end defp pad_base64(str) do str = String.replace(str, "-", "+") |> String.replace("_", "/") case rem(String.length(str), 4) do 0 -> str 2 -> str <> "==" 3 -> str <> "=" _ -> str end end defp validate_expiry({:ok, %{payload: payload} = result}) do now = System.system_time(:second) case Map.get(payload, "exp") do nil -> {:ok, result} exp when is_integer(exp) and exp < now -> {:error, :expired} _ -> {:ok, result} end end defp validate_expiry(error), do: error end