Packages

fnord

0.9.39

AI code archaeology

Current section

Files

Jump to
fnord lib mcp oauth2 discovery.ex
Raw

lib/mcp/oauth2/discovery.ex

defmodule MCP.OAuth2.Discovery do
@moduledoc """
OAuth2 server discovery and automatic configuration.
Implements RFC 8414 Authorization Server Metadata discovery.
"""
alias MCP.OAuth2.Registration
@doc """
Discover OAuth2 configuration and set up authentication automatically.
## Parameters
- base_url: The MCP server's base URL
- opts: Configuration options
- :client_id - Use an existing client_id and skip dynamic registration
- :client_secret - Client secret (optional)
- :scope - List of scopes to request; if omitted, fnord prefers `mcp:access` when the server advertises it and otherwise falls back to the server's supported scopes
- :redirect_port - Port to use for the loopback redirect URI; if present, the discovered config carries it forward and dynamic registration uses it for exact callback matching
## Returns
- `{:ok, oauth_config}` - Ready-to-use OAuth configuration map
- `{:error, reason}` - Discovery or setup failed
## OAuth Config Structure
%{
"discovery_url" => String.t(),
"client_id" => String.t(),
"client_secret" => String.t() | nil,
"scopes" => [String.t()]
}
"""
@spec discover_and_setup(String.t(), keyword()) :: {:ok, map()} | {:error, term()}
def discover_and_setup(base_url, opts \\ []) do
with {:ok, discovery_url} <- build_discovery_url(base_url),
{:ok, metadata} <- fetch_metadata(discovery_url),
:ok <- validate_metadata(metadata),
{:ok, client_id, client_secret} <- ensure_client_credentials(metadata, opts),
{:ok, scopes} <- determine_scopes(metadata, opts) do
oauth_config = %{
"discovery_url" => discovery_url,
"client_id" => client_id,
"client_secret" => client_secret,
"scopes" => scopes
}
# Include redirect_port if it was provided (for exact URI matching)
oauth_with_port =
case opts[:redirect_port] do
port when is_integer(port) ->
Map.put(oauth_config, "redirect_port", port)
_ ->
oauth_config
end
log_setup_success(client_id, scopes)
{:ok, oauth_with_port}
end
end
# Build the well-known OAuth discovery URL
defp build_discovery_url(base_url) do
url =
base_url
|> String.trim_trailing("/")
|> Kernel.<>("/.well-known/oauth-authorization-server")
{:ok, url}
end
# Fetch OAuth authorization server metadata
defp fetch_metadata(discovery_url) do
MCP.Util.debug("MCP OAuth", "Fetching metadata from #{discovery_url}")
case Http.Client.impl().get(discovery_url, [], recv_timeout: 10_000, timeout: 10_000) do
{:ok, %{status_code: 200, body: body}} ->
case SafeJson.decode(body) do
{:ok, metadata} when is_map(metadata) ->
{:ok, metadata}
{:ok, _} ->
{:error, {:invalid_metadata, "Discovery response is not a JSON object"}}
{:error, {:invalid_json, reason}} ->
{:error, {:invalid_json, reason}}
end
{:ok, %{status_code: 404}} ->
{:error, :discovery_not_found}
{:ok, %{status_code: code}} ->
{:error, {:http_error, code}}
{:error, reason} ->
{:error, {:network_error, reason}}
end
end
# Validate required OAuth metadata fields
defp validate_metadata(metadata) do
required = ["issuer", "authorization_endpoint", "token_endpoint"]
missing =
Enum.filter(required, fn field ->
!Map.has_key?(metadata, field) || is_nil(metadata[field])
end)
case missing do
[] ->
:ok
fields ->
{:error, {:incomplete_metadata, "Missing required fields: #{Enum.join(fields, ", ")}"}}
end
end
# Ensure we have client credentials (either provided or via registration)
defp ensure_client_credentials(metadata, opts) do
case opts[:client_id] do
nil ->
# No client_id provided, attempt dynamic registration
register_client(metadata, opts)
client_id ->
# Use provided client_id
MCP.Util.debug("MCP OAuth", "Using provided client_id: #{client_id}")
{:ok, client_id, opts[:client_secret]}
end
end
# Attempt dynamic client registration
defp register_client(%{"registration_endpoint" => endpoint}, opts) when is_binary(endpoint) do
MCP.Util.debug("MCP OAuth", "Attempting dynamic registration at #{endpoint}")
# If a redirect port is provided, use it for registration
registration_opts =
case opts[:redirect_port] do
port when is_integer(port) ->
[redirect_uris: ["http://localhost:#{port}/callback"]]
_ ->
[]
end
case Registration.register(endpoint, registration_opts) do
{:ok, %{client_id: client_id, client_secret: client_secret}} ->
{:ok, client_id, client_secret}
{:error, reason} ->
{:error, {:registration_failed, reason}}
end
end
defp register_client(_metadata, _opts) do
{:error, :no_registration_endpoint}
end
# Determine which scopes to use
defp determine_scopes(metadata, opts) do
scopes =
cond do
# User explicitly provided scopes
opts[:scope] && length(opts[:scope]) > 0 ->
MCP.Util.debug("MCP OAuth", "Using user-provided scopes: #{inspect(opts[:scope])}")
opts[:scope]
# Server supports mcp:access (recommended minimal scope)
has_scope?(metadata, "mcp:access") ->
MCP.Util.debug("MCP OAuth", "Using recommended scope: mcp:access")
["mcp:access"]
# Fall back to all supported scopes
true ->
supported = Map.get(metadata, "scopes_supported", [])
MCP.Util.debug("MCP OAuth", "Using all supported scopes: #{inspect(supported)}")
supported
end
{:ok, scopes}
end
defp has_scope?(%{"scopes_supported" => scopes}, scope) when is_list(scopes) do
scope in scopes
end
defp has_scope?(_, _), do: false
defp log_setup_success(client_id, scopes) do
# Only log in non-quiet mode (tests typically run with quiet mode)
unless UI.quiet?() do
UI.puts("✓ OAuth configured successfully")
UI.puts(" Client ID: #{client_id}")
UI.puts(" Scopes: #{Enum.join(scopes, ", ")}")
UI.puts("")
end
end
end