Packages
fnord
0.9.14
0.9.40
0.9.39
0.9.38
0.9.37
0.9.36
0.9.35
0.9.34
0.9.33
0.9.32
0.9.31
0.9.30
0.9.29
0.9.28
0.9.27
0.9.26
0.9.25
0.9.24
0.9.23
0.9.22
0.9.21
0.9.20
0.9.19
0.9.18
0.9.17
0.9.16
0.9.15
0.9.14
0.9.13
0.9.12
0.9.11
0.9.10
0.9.9
0.9.8
0.9.7
0.9.6
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.99
0.8.98
0.8.97
0.8.96
0.8.95
0.8.94
0.8.93
0.8.92
0.8.91
0.8.90
0.8.89
0.8.88
0.8.87
0.8.86
0.8.85
0.8.84
0.8.83
0.8.82
0.8.81
0.8.80
0.8.79
0.8.78
0.8.77
0.8.76
0.8.75
0.8.74
0.8.73
0.8.72
0.8.71
0.8.70
0.8.69
0.8.68
0.8.67
0.8.66
0.8.65
0.8.64
0.8.63
0.8.62
0.8.61
0.8.60
0.8.59
0.8.58
0.8.57
0.8.56
0.8.55
0.8.54
0.8.53
0.8.52
0.8.51
0.8.50
0.8.49
0.8.48
0.8.47
0.8.46
0.8.45
0.8.44
0.8.43
0.8.42
0.8.41
0.8.40
0.8.39
0.8.38
0.8.37
0.8.36
0.8.35
0.8.34
0.8.33
0.8.32
0.8.31
0.8.30
0.8.29
0.8.27
0.8.26
0.8.25
0.8.24
0.8.23
0.8.22
0.8.21
0.8.20
0.8.19
0.8.18
0.8.17
0.8.16
0.8.15
0.8.14
0.8.13
0.8.12
0.8.11
0.8.1
0.8.0
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.3
0.7.2
0.7.1
0.7.0
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.1
0.6.0
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.44
0.4.43
0.4.42
0.4.41
0.4.40
0.4.39
0.4.38
0.4.37
0.4.36
0.4.35
0.4.34
0.4.33
0.4.32
0.4.30
0.4.29
0.4.28
0.4.27
0.4.26
0.4.25
0.4.24
0.4.23
0.4.22
0.4.21
0.4.20
0.4.19
0.4.18
0.4.17
0.4.16
0.4.15
0.4.14
0.4.13
0.4.12
0.4.11
0.4.10
0.4.9
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.0
0.2.0
0.1.0
AI code archaeology
Current section
Files
Jump to
Current section
Files
lib/mcp/oauth2/loopback.ex
defmodule MCP.OAuth2.Loopback do
@moduledoc """
Minimal loopback HTTP server for OAuth2 Authorization Code callback.
- Binds to 127.0.0.1 on an ephemeral port
- Exposes GET /callback to capture `code` and `state`
- Delegates token exchange to `MCP.OAuth2.Client.handle_callback/4`
- Persists tokens via `MCP.OAuth2.CredentialsStore`
- Returns a tiny HTML page and stops itself
"""
use GenServer
require Logger
@type t :: %{
server_ref: pid(),
port: non_neg_integer(),
state: String.t(),
code_verifier: String.t(),
cfg: map(),
server_key: String.t()
}
@doc """
Start the loopback server on 127.0.0.1:0, returning bound port.
"""
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts) do
GenServer.start_link(__MODULE__, opts)
end
@doc """
Start the server and return `{pid, port}` so the caller can construct the redirect_uri.
"""
@spec start(map(), String.t(), String.t(), String.t(), non_neg_integer()) ::
{:ok, pid(), non_neg_integer()} | {:error, term()}
def start(cfg, server_key, expected_state, code_verifier, port \\ 0) do
with {:ok, pid} <-
start_link(
cfg: cfg,
server_key: server_key,
state: expected_state,
code_verifier: code_verifier,
port: port
),
{:ok, port} <- GenServer.call(pid, :get_port) do
{:ok, pid, port}
end
end
@doc """
Run the loopback flow until one callback is handled or timeout.
Returns the token map on success.
"""
@spec run(
map(),
String.t(),
String.t(),
String.t(),
String.t(),
non_neg_integer(),
non_neg_integer()
) ::
{:ok, map()} | {:error, term()}
def run(
cfg,
base_url,
server_key,
expected_state,
code_verifier,
port \\ 0,
timeout_ms \\ 120_000
) do
{:ok, pid} =
start_link(
cfg: cfg,
base_url: base_url,
server_key: server_key,
state: expected_state,
code_verifier: code_verifier,
port: port
)
GenServer.call(pid, {:await, timeout_ms}, timeout_ms + 5_000)
end
# GenServer
@impl true
def init(opts) do
cfg = Keyword.fetch!(opts, :cfg)
base_url = Keyword.fetch!(opts, :base_url)
server_key = Keyword.fetch!(opts, :server_key)
expected_state = Keyword.fetch!(opts, :state)
code_verifier = Keyword.fetch!(opts, :code_verifier)
port = Keyword.get(opts, :port, 0)
# Build Plug router with captured state
{:ok, plug} = build_router(cfg, base_url, server_key, expected_state, code_verifier)
ref = :"mcp_oauth_loopback_#{System.unique_integer([:monotonic, :positive])}"
{:ok, _pid} = Plug.Cowboy.http(plug, [], ip: {127, 0, 0, 1}, port: port, ref: ref)
actual_port =
case cowboy_port(ref) do
{:ok, p} -> p
_ -> 0
end
state = %{
server_ref: ref,
port: actual_port,
state: expected_state,
code_verifier: code_verifier,
cfg: cfg,
server_key: server_key,
result: nil
}
{:ok, state}
end
@impl true
def handle_call({:await, timeout_ms}, from, st) do
# Save caller; will reply on first callback
{:noreply, Map.put(st, :await, {from, timeout_ms}), timeout_ms}
end
@impl true
def handle_call(:get_port, _from, st) do
{:reply, {:ok, st.port}, st}
end
@impl true
def handle_info({:callback_result, result}, %{await: {from, _}} = st) do
reply =
case result do
{:ok, token_map} -> {:ok, token_map}
{:error, e} -> {:error, e}
end
GenServer.reply(from, reply)
# Keep GenServer alive - escript will exit and clean up naturally
# This ensures HTTP response has time to be fully sent to browser
{:noreply, Map.put(st, :result, result)}
end
@impl true
def handle_info(:timeout, st) do
if st[:await] do
GenServer.reply(elem(st.await, 0), {:error, :timeout})
end
# Don't explicitly shut down - let the escript process exit handle cleanup
{:stop, :timeout, st}
end
defp cowboy_port(ref) do
case :ranch.get_addr(ref) do
{_, port} when is_integer(port) ->
{:ok, port}
{:local, _socket} ->
{:error, :local_socket}
other ->
{:error, other}
end
end
defp build_router(cfg, base_url, server_key, expected_state, code_verifier) do
parent = self()
mod = Module.concat(__MODULE__, :"Router_#{System.unique_integer([:monotonic, :positive])}")
# Define the router module using defmodule at runtime
contents =
quote do
use Plug.Router
plug(:match)
plug(:fetch_query_params)
plug(:dispatch)
get "/callback" do
params = var!(conn).params
case MCP.OAuth2.Client.handle_callback(
unquote(Macro.escape(cfg)),
params,
unquote(expected_state),
unquote(code_verifier)
) do
{:ok, token_map} ->
case Services.Approvals.Gate.require(
{:mcp, unquote(server_key), :auth_finalize},
[]
) do
:approved ->
:ok =
MCP.OAuth2.CredentialsStore.write(unquote(server_key), %{
"access_token" => token_map.access_token,
"refresh_token" => Map.get(token_map, :refresh_token),
"token_type" => token_map.token_type,
"expires_at" => token_map.expires_at,
"scope" => Map.get(token_map, :scope)
})
send(unquote(parent), {:callback_result, {:ok, token_map}})
send_resp(var!(conn), 200, success_html(unquote(server_key), unquote(base_url)))
{:pending, ref} ->
send(unquote(parent), {:callback_result, {:error, :approval_pending}})
send_resp(var!(conn), 202, "Approval pending; ref=" <> ref)
end
{:error, e} ->
send(unquote(parent), {:callback_result, {:error, e}})
send_resp(var!(conn), 400, failure_html())
end
end
match _ do
send_resp(var!(conn), 404, "Not Found")
end
defp success_html(server_name, base_url) do
"""
<html>
<head>
<meta charset="UTF-8">
<title>Authentication Complete</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
max-width: 600px;
margin: 100px auto;
padding: 20px;
text-align: center;
}
h3 { color: #2d3748; }
.details {
color: #4a5568;
margin: 20px 0;
font-size: 0.95em;
}
</style>
</head>
<body>
<h3>Authentication complete</h3>
<div class="details">
<p><strong>fnord</strong> successfully authenticated</p>
<p><strong>#{server_name}</strong> at #{base_url}</p>
</div>
<p>You can close this tab.</p>
</body>
</html>
"""
end
defp failure_html do
"<html><body><h3>Authentication failed</h3><p>Please return to the app.</p></body></html>"
end
end
Module.create(mod, contents, Macro.Env.location(__ENV__))
{:ok, mod}
end
end