Packages

Model Context Protocol (MCP) implementation in Elixir with Phoenix integration

Retired package: Release invalid - Accidental version. Use 0.3.0 instead.

Current section

Files

Jump to
backplane_mcp_protocol lib backplane mcp_protocol server authorization.ex
Raw

lib/backplane/mcp_protocol/server/authorization.ex

defmodule Backplane.McpProtocol.Server.Authorization do
@moduledoc """
OAuth 2.1 resource server authorization support.
Provides configuration, metadata building, and token validation primitives
for securing MCP servers with bearer token authorization.
## Standards Implemented
* RFC 6750 — Bearer Token Usage
* RFC 9728 — Protected Resource Metadata
* RFC 8707 — Resource Indicators (audience validation)
* RFC 7662 — Token Introspection
* RFC 7519 — JSON Web Token (JWT)
## Configuration
use MyServer,
authorization: [
authorization_servers: ["https://auth.example.com"],
resource: "https://api.example.com",
realm: "mcp",
scopes_supported: ["tools:read", "tools:write"],
validator: {Backplane.McpProtocol.Server.Authorization.JWTValidator,
jwks_uri: "https://auth.example.com/.well-known/jwks.json"}
]
## Claims map
After successful validation, a normalized claims map is stored in `Context.auth`:
%{
sub: "user-id",
aud: "https://api.example.com",
scope: "tools:read tools:write",
scopes: ["tools:read", "tools:write"],
exp: 1_234_567_890,
iat: 1_234_567_800,
client_id: "client-abc",
raw_claims: %{}
}
"""
import Peri
@type config :: %{
authorization_servers: [String.t()],
resource: String.t(),
realm: String.t(),
scopes_supported: [String.t()],
validator: {module(), keyword()}
}
@type claims :: %{
sub: String.t() | nil,
aud: String.t() | [String.t()] | nil,
scope: String.t() | nil,
scopes: [String.t()],
exp: integer() | nil,
iat: integer() | nil,
client_id: String.t() | nil,
raw_claims: map()
}
defschema(:parse_config_schema, [
{:authorization_servers, {:required, {:list, :string}}},
{:resource, {:required, :string}},
{:realm, {:string, {:default, "mcp"}}},
{:scopes_supported, {{:list, :string}, {:default, []}}},
{:validator, {:required, {:tuple, [:atom, {:list, :any}]}}}
])
@doc """
Parses and validates the authorization configuration keyword list.
Raises `ArgumentError` if required fields are missing or invalid.
## Examples
config = Authorization.parse_config!(
authorization_servers: ["https://auth.example.com"],
resource: "https://api.example.com",
validator: {MyValidator, []}
)
"""
@spec parse_config!(keyword()) :: config()
def parse_config!(opts) when is_list(opts) do
config = parse_config_schema!(opts)
Map.new(config)
end
@doc """
Builds the RFC 9728 protected resource metadata map.
## Examples
Authorization.build_resource_metadata(config)
# => %{
# "resource" => "https://api.example.com",
# "authorization_servers" => ["https://auth.example.com"],
# "scopes_supported" => ["tools:read"],
# "bearer_methods_supported" => ["header"]
# }
"""
@spec build_resource_metadata(config()) :: map()
def build_resource_metadata(config) do
%{
"resource" => config.resource,
"authorization_servers" => config.authorization_servers,
"scopes_supported" => config.scopes_supported,
"bearer_methods_supported" => ["header"]
}
end
@doc """
Builds the `WWW-Authenticate` header value for a 401 unauthorized response.
Includes `resource_metadata` URL per RFC 9728.
## Examples
Authorization.build_www_authenticate(config, :unauthorized)
# => ~s(Bearer realm="mcp", resource_metadata="https://api.example.com/.well-known/oauth-protected-resource")
"""
@spec build_www_authenticate(config(), :unauthorized | {:insufficient_scope, String.t()}) :: String.t()
def build_www_authenticate(config, :unauthorized) do
metadata_url = well_known_url(config.resource)
~s(Bearer realm="#{config.realm}", resource_metadata="#{metadata_url}")
end
def build_www_authenticate(config, {:insufficient_scope, required_scope}) do
~s(Bearer realm="#{config.realm}", error="insufficient_scope", scope="#{required_scope}")
end
@doc """
Validates that the token `aud` claim matches the server's canonical resource URI.
Returns `:ok` when the audience matches, `{:error, :invalid_audience}` otherwise.
## Examples
Authorization.validate_audience(%{aud: "https://api.example.com"}, config)
# => :ok
Authorization.validate_audience(%{aud: "https://other.example.com"}, config)
# => {:error, :invalid_audience}
"""
@spec validate_audience(claims(), config()) :: :ok | {:error, :invalid_audience}
def validate_audience(%{aud: aud}, config) when is_binary(aud) do
if aud == config.resource, do: :ok, else: {:error, :invalid_audience}
end
def validate_audience(%{aud: aud_list}, config) when is_list(aud_list) do
if config.resource in aud_list, do: :ok, else: {:error, :invalid_audience}
end
def validate_audience(_, _), do: {:error, :invalid_audience}
@doc """
Validates that the token has not expired.
Compares `exp` against the current Unix timestamp.
Returns `:ok` if not expired, `{:error, :token_expired}` otherwise.
Tokens without `exp` are treated as non-expiring.
## Examples
Authorization.validate_expiry(%{exp: future_timestamp})
# => :ok
"""
@spec validate_expiry(claims()) :: :ok | {:error, :token_expired | :invalid_expiry}
def validate_expiry(%{exp: nil}), do: :ok
def validate_expiry(%{exp: exp}) when is_integer(exp) and exp >= 0 do
now = System.os_time(:second)
if exp > now, do: :ok, else: {:error, :token_expired}
end
def validate_expiry(%{exp: _}), do: {:error, :invalid_expiry}
def validate_expiry(_), do: :ok
@doc """
Validates that the claims contain all required scopes.
Returns `:ok` when all `required` scopes are present in the claims,
`{:error, {:insufficient_scope, required_scopes}}` otherwise.
## Examples
Authorization.validate_scopes(%{scopes: ["tools:read", "tools:write"]}, ["tools:read"])
# => :ok
Authorization.validate_scopes(%{scopes: ["tools:read"]}, ["tools:write"])
# => {:error, {:insufficient_scope, ["tools:write"]}}
"""
@spec validate_scopes(claims(), [String.t()]) ::
:ok | {:error, {:insufficient_scope, [String.t()]}}
def validate_scopes(_claims, []), do: :ok
def validate_scopes(%{scopes: granted}, required) when is_list(granted) do
missing = Enum.reject(required, &(&1 in granted))
if missing == [], do: :ok, else: {:error, {:insufficient_scope, missing}}
end
def validate_scopes(_, required), do: {:error, {:insufficient_scope, required}}
@doc """
Returns the canonical `/.well-known/oauth-protected-resource` URL for a resource URI.
## Examples
Authorization.well_known_url("https://api.example.com")
# => "https://api.example.com/.well-known/oauth-protected-resource"
"""
@spec well_known_url(String.t()) :: String.t()
def well_known_url(resource) when is_binary(resource) do
uri = URI.parse(resource)
base = URI.to_string(%{uri | path: nil, query: nil, fragment: nil})
"#{base}/.well-known/oauth-protected-resource"
end
@doc """
Normalizes raw claims (string-keyed map) into the canonical claims shape.
Parses the `scope` string into a `scopes` list for convenient membership checks.
If the raw claims already contain a `scopes` list (string- or atom-keyed), it is
preserved as-is so custom validators that emit pre-normalized data are honored.
"""
@spec normalize_claims(map()) :: claims()
def normalize_claims(raw) when is_map(raw) do
scope = raw["scope"] || raw[:scope]
%{
sub: raw["sub"] || raw[:sub],
aud: raw["aud"] || raw[:aud],
scope: scope,
scopes: extract_scopes(raw, scope),
exp: raw["exp"] || raw[:exp],
iat: raw["iat"] || raw[:iat],
client_id: raw["client_id"] || raw[:client_id],
raw_claims: raw
}
end
defp extract_scopes(raw, scope) do
cond do
is_list(raw["scopes"]) -> raw["scopes"]
is_list(raw[:scopes]) -> raw[:scopes]
is_binary(scope) -> String.split(scope, " ", trim: true)
true -> []
end
end
end