Packages

A Model Context Protocol (MCP) server library for Elixir, implementing the 2025-11-25 specification over the Streamable HTTP transport.

Current section

Files

Jump to
urchin lib urchin auth claims.ex
Raw

lib/urchin/auth/claims.ex

defmodule Urchin.Auth.Claims do
@moduledoc """
Normalized claims for a validated OAuth 2.1 access token.
An `Urchin.Auth.TokenValidator` returns one of these from `validate/2`. The transport
surfaces it to handlers as `ctx.auth` (see `Urchin.Context`), so a handler can make
per-tool authorization decisions:
def call_tool("delete", _args, ctx) do
if Urchin.Auth.Claims.has_scope?(ctx.auth, "files:write") do
# ...
else
{:error, "files:write scope required"}
end
end
`from_map/1` converts a decoded JWT payload or an RFC 7662 introspection response into
this struct, normalizing the OAuth/JWT field names (`sub`, `aud`, `scope`, `exp`, ...).
"""
defstruct subject: nil,
client_id: nil,
expires_at: nil,
scopes: [],
audience: [],
claims: %{}
@type t :: %__MODULE__{
subject: String.t() | nil,
client_id: String.t() | nil,
expires_at: integer() | nil,
scopes: [String.t()],
audience: [String.t()],
claims: map()
}
@doc """
Builds a `Claims` struct from a decoded token payload (string-keyed map).
Recognized fields: `sub`, `client_id`/`azp`, `exp`, `aud` (string or list),
`scope` (space-delimited string) and/or `scp`/`scopes` (string or list). The full
payload is preserved under `:claims` for custom checks.
"""
@spec from_map(map()) :: t()
def from_map(payload) when is_map(payload) do
%__MODULE__{
subject: payload["sub"],
client_id: payload["client_id"] || payload["azp"],
expires_at: normalize_exp(payload["exp"]),
scopes: extract_scopes(payload),
audience: normalize_list(payload["aud"]),
claims: payload
}
end
@doc "Returns true when the claims grant the given scope."
@spec has_scope?(t() | nil, String.t()) :: boolean()
def has_scope?(%__MODULE__{scopes: scopes}, scope) when is_binary(scope) do
scope in scopes
end
def has_scope?(nil, _scope), do: false
@doc "Returns true when the claims grant every scope in `required`."
@spec has_scopes?(t() | nil, [String.t()]) :: boolean()
def has_scopes?(%__MODULE__{scopes: scopes}, required) when is_list(required) do
Enum.all?(required, &(&1 in scopes))
end
def has_scopes?(nil, required), do: required == []
# OAuth and JWT carry scopes under several names: a space-delimited "scope" string
# (RFC 6749 / 7662), or "scp"/"scopes" as either a list or a string.
defp extract_scopes(payload) do
[payload["scope"], payload["scp"], payload["scopes"]]
|> Enum.flat_map(&split_scope/1)
|> Enum.uniq()
end
defp split_scope(nil), do: []
defp split_scope(list) when is_list(list), do: Enum.filter(list, &is_binary/1)
defp split_scope(value) when is_binary(value), do: String.split(value, " ", trim: true)
defp split_scope(_), do: []
defp normalize_list(nil), do: []
defp normalize_list(value) when is_binary(value), do: [value]
defp normalize_list(list) when is_list(list), do: Enum.filter(list, &is_binary/1)
defp normalize_list(_), do: []
defp normalize_exp(exp) when is_integer(exp), do: exp
defp normalize_exp(exp) when is_float(exp), do: trunc(exp)
defp normalize_exp(_), do: nil
end