Packages
fnord
0.9.36
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/client.ex
defmodule MCP.OAuth2.Client do
@moduledoc """
Pure OAuth2 + PKCE client implementation for MCP servers.
Unlike OIDC libraries (like oidcc), this works with OAuth2 Authorization Server
discovery (RFC 8414) at `/.well-known/oauth-authorization-server`, not just
OpenID Connect discovery at `/.well-known/openid-configuration`.
Implements:
- Authorization Code flow with PKCE (RFC 7636)
- Token refresh (RFC 6749)
- OAuth2 server metadata discovery (RFC 8414)
- Resource Indicators (RFC 8707) via the optional `:resource` config key
The MCP authorization spec requires clients to send the canonical MCP
server URI as the `resource` parameter on both the authorization and
token requests so the server can bind the grant to that resource.
Servers that advertise a `resource` in their metadata (e.g. Linear)
hard-fail flows that omit it. Callers pass the server's `base_url` as
`:resource`; when absent the parameter is omitted for compatibility
with servers that predate RFC 8707.
Security:
- PKCE is always required (S256 challenge method)
- Tokens are never logged
- Uses secure random generation for state and verifier
"""
@type config :: %{
required(:discovery_url) => String.t(),
required(:client_id) => String.t(),
optional(:client_secret) => String.t(),
required(:redirect_uri) => String.t(),
required(:scopes) => [String.t()],
optional(:resource) => String.t()
}
@type tokens :: %{
access_token: String.t(),
token_type: String.t(),
expires_at: non_neg_integer(),
refresh_token: String.t() | nil,
scope: String.t() | nil
}
@doc """
Start OAuth2 authorization flow with PKCE.
Fetches server metadata, generates PKCE parameters, and builds authorization URL.
Returns: `{:ok, %{auth_url: String.t(), state: String.t(), code_verifier: String.t()}}`
"""
@spec start_flow(config) ::
{:ok, %{auth_url: String.t(), state: String.t(), code_verifier: String.t()}}
| {:error, term()}
def start_flow(cfg) do
with {:ok, metadata} <- fetch_metadata(cfg.discovery_url),
{:ok, state} <- generate_state(),
{:ok, verifier, challenge} <- generate_pkce(),
{:ok, auth_url} <- build_authorization_url(metadata, cfg, state, challenge) do
{:ok, %{auth_url: auth_url, state: state, code_verifier: verifier}}
end
end
@doc """
Handle OAuth2 callback and exchange authorization code for tokens.
Validates state, extracts code, exchanges for tokens with PKCE verifier.
Returns: `{:ok, tokens}` with normalized token map
"""
@spec handle_callback(config, map(), String.t(), String.t()) ::
{:ok, tokens} | {:error, term()}
def handle_callback(cfg, params, expected_state, code_verifier) do
with {:ok, metadata} <- fetch_metadata(cfg.discovery_url),
:ok <- verify_state(params, expected_state),
{:ok, code} <- extract_code(params),
{:ok, tokens} <- exchange_code(metadata, cfg, code, code_verifier) do
{:ok, normalize_tokens(tokens)}
end
end
@doc """
Refresh an expired access token using the refresh token.
Returns: `{:ok, tokens}` with new access token and possibly new refresh token
"""
@spec refresh_token(config, String.t()) :: {:ok, tokens} | {:error, term()}
def refresh_token(cfg, refresh_token) do
with {:ok, metadata} <- fetch_metadata(cfg.discovery_url),
{:ok, tokens} <- refresh_with_server(metadata, cfg, refresh_token) do
{:ok, normalize_tokens(tokens)}
end
end
# -- Metadata Discovery --
defp fetch_metadata(discovery_url) do
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) ->
validate_metadata(metadata)
{:ok, _} ->
{:error, {:invalid_metadata, "Discovery response is not a JSON object"}}
{:error, {:invalid_json, reason}} ->
{:error, {:invalid_json, reason}}
end
{:ok, %{status_code: code}} ->
{:error, {:http_error, code}}
{:error, reason} ->
{:error, {:network_error, reason}}
end
end
defp validate_metadata(metadata) do
required = ["authorization_endpoint", "token_endpoint"]
case Enum.filter(required, &(!Map.has_key?(metadata, &1))) do
[] -> {:ok, metadata}
missing -> {:error, {:incomplete_metadata, "Missing: #{Enum.join(missing, ", ")}"}}
end
end
# -- PKCE Generation --
defp generate_pkce do
# Generate 32-byte random verifier, base64url encode
verifier =
:crypto.strong_rand_bytes(32)
|> Base.url_encode64(padding: false)
# SHA256 hash the verifier, base64url encode for challenge
challenge =
:crypto.hash(:sha256, verifier)
|> Base.url_encode64(padding: false)
{:ok, verifier, challenge}
end
defp generate_state do
state =
:crypto.strong_rand_bytes(16)
|> Base.url_encode64(padding: false)
{:ok, state}
end
# -- Authorization URL Building --
defp build_authorization_url(metadata, cfg, state, challenge) do
auth_endpoint = metadata["authorization_endpoint"]
params =
%{
"response_type" => "code",
"client_id" => cfg.client_id,
"redirect_uri" => cfg.redirect_uri,
"scope" => Enum.join(cfg.scopes, " "),
"state" => state,
"code_challenge" => challenge,
"code_challenge_method" => "S256"
}
|> Map.merge(resource_param(cfg))
query = URI.encode_query(params)
{:ok, "#{auth_endpoint}?#{query}"}
end
# RFC 8707 resource indicator. Present on the authorization request, the
# code exchange, AND the refresh request - the MCP spec requires it on all
# three so each issued token stays bound to the same resource. Omitted
# entirely when the caller didn't configure one (pre-RFC 8707 servers).
defp resource_param(cfg) do
case Map.get(cfg, :resource) do
resource when is_binary(resource) and resource != "" -> %{"resource" => resource}
_ -> %{}
end
end
# -- State Verification --
defp verify_state(params, expected_state) do
case Map.get(params, "state") do
^expected_state -> :ok
nil -> {:error, :missing_state}
_ -> {:error, :state_mismatch}
end
end
defp extract_code(params) do
case Map.get(params, "code") do
nil -> {:error, :missing_code}
code when is_binary(code) -> {:ok, code}
_ -> {:error, :invalid_code}
end
end
# -- Token Exchange --
defp exchange_code(metadata, cfg, code, code_verifier) do
token_endpoint = metadata["token_endpoint"]
body_params =
%{
"grant_type" => "authorization_code",
"code" => code,
"redirect_uri" => cfg.redirect_uri,
"client_id" => cfg.client_id,
"code_verifier" => code_verifier
}
|> Map.merge(resource_param(cfg))
# Add client_secret if provided (confidential client)
body_params =
if cfg[:client_secret] do
Map.put(body_params, "client_secret", cfg.client_secret)
else
body_params
end
make_token_request(token_endpoint, body_params)
end
# -- Token Refresh --
defp refresh_with_server(metadata, cfg, refresh_token) do
token_endpoint = metadata["token_endpoint"]
body_params =
%{
"grant_type" => "refresh_token",
"refresh_token" => refresh_token,
"client_id" => cfg.client_id
}
|> Map.merge(resource_param(cfg))
# Add client_secret if provided
body_params =
if cfg[:client_secret] do
Map.put(body_params, "client_secret", cfg.client_secret)
else
body_params
end
make_token_request(token_endpoint, body_params)
end
# -- HTTP Request Helper --
defp make_token_request(token_endpoint, body_params) do
headers = [
{"Content-Type", "application/x-www-form-urlencoded"},
{"Accept", "application/json"}
]
body = URI.encode_query(body_params)
case Http.Client.impl().post(token_endpoint, body, headers,
recv_timeout: 15_000,
timeout: 15_000
) do
{:ok, %{status_code: 200, body: response_body}} ->
case SafeJson.decode(response_body) do
{:ok, tokens} when is_map(tokens) ->
{:ok, tokens}
{:ok, _} ->
{:error, {:invalid_response, "Token response is not a JSON object"}}
{:error, {:invalid_json, reason}} ->
{:error, {:invalid_json, reason}}
end
{:ok, %{status_code: code, body: _error_body}} ->
{:error, {:http_error, code}}
{:error, reason} ->
{:error, {:network_error, reason}}
end
end
# -- Token Normalization --
defp normalize_tokens(tokens) do
# Calculate expires_at from provider token response.
# Prefer a provider-supplied "expires_at" where present; otherwise use "expires_in".
expires_at =
cond do
is_integer(Map.get(tokens, "expires_at")) ->
Map.get(tokens, "expires_at")
true ->
case Map.get(tokens, "expires_in") do
secs when is_integer(secs) and secs > 0 ->
System.os_time(:second) + secs
_ ->
# Treat 0 or nil as "unknown" (no expiry information). Don't set expires_at.
nil
end
end
# Debug: show raw fields used to compute expiry when debug enabled (redact access_token)
if Util.Env.mcp_debug_enabled?() do
raw = %{
"raw_expires_in" => Map.get(tokens, "expires_in"),
"raw_expires_at" => Map.get(tokens, "expires_at"),
"computed_expires_at" => expires_at
}
# Redact access token before printing
dbg =
Map.put(
raw,
"access_token",
if(Map.has_key?(tokens, "access_token"), do: "<redacted>", else: nil)
)
UI.debug("[MCP Debug] token fields used for expiry calculation")
UI.printf_debug(dbg)
end
%{
access_token: Map.fetch!(tokens, "access_token"),
token_type: Map.get(tokens, "token_type", "Bearer"),
expires_at: expires_at,
refresh_token: Map.get(tokens, "refresh_token"),
scope: Map.get(tokens, "scope")
}
end
end