diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..6802dc4 --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1 @@ +60f31cbae173bf1e64aba70a82935c601d9caef2:priv/dev/oauth_example/README.md:curl-auth-header:59 diff --git a/README.md b/README.md index 2282fb0..1df7f91 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ We have build some elixir implementation examples using `plug` based and `phoeni 1. [upcase-server](/priv/dev/upcase/README.md): `plug` based MCP server using streamable_http 2. [echo-elixir](/priv/dev/echo-elixir/README.md): `phoenix` based MCP server using sse 3. [ascii-server](/priv/dev/ascii/README.md): `phoenix_live_view` based MCP server using streamable_http and UI +4. [oauth-example](/priv/dev/oauth_example/README.md): `plug` based MCP server using streamable_http + Oauth authorization (mocked) ## License diff --git a/lib/hermes/server/authorization.ex b/lib/hermes/server/authorization.ex new file mode 100644 index 0000000..446f1fe --- /dev/null +++ b/lib/hermes/server/authorization.ex @@ -0,0 +1,189 @@ +defmodule Hermes.Server.Authorization do + @moduledoc false + + import Peri + + @type token :: String.t() + @type authorization_server :: String.t() + + @type token_info :: %{ + sub: String.t(), + aud: String.t() | list(String.t()) | nil, + scope: String.t() | nil, + exp: integer() | nil, + iat: integer() | nil, + client_id: String.t() | nil, + active: boolean() + } + + @type config :: %{ + authorization_servers: list(authorization_server()), + resource_metadata_url: String.t() | nil, + realm: String.t(), + scopes_supported: list(String.t()) | nil, + validator: module() | nil + } + + @type www_authenticate_params :: %{ + realm: String.t(), + resource_metadata: String.t() | nil, + authorization_servers: list(String.t()) | nil, + scope: String.t() | nil, + error: String.t() | nil, + error_description: String.t() | nil, + error_uri: String.t() | nil + } + + defschema(:config_schema, %{ + authorization_servers: {:required, {:list, :string}}, + resource_metadata_url: {:string, {:default, nil}}, + realm: {:string, {:default, "mcp-server"}}, + scopes_supported: {{:list, :string}, {:default, []}}, + validator: {:atom, {:default, nil}} + }) + + @doc """ + Parses and validates authorization configuration. + """ + @spec parse_config!(keyword() | map()) :: config() + def parse_config!(opts), do: config_schema!(Map.new(opts)) + + @doc """ + Builds a WWW-Authenticate header value for 401 responses per RFC 9728. + + The header format follows: + WWW-Authenticate: Bearer realm="example", + resource_metadata="https://server.example.com/.well-known/oauth-protected-resource", + authorization_servers="https://as.example.com https://as2.example.com" + """ + @spec build_www_authenticate_header(config(), keyword()) :: String.t() + def build_www_authenticate_header(config, opts \\ []) do + params = build_www_authenticate_params(config, opts) + + param_string = + params + |> Enum.map(&format_www_authenticate_param/1) + |> Enum.reject(&is_nil/1) + |> Enum.join(", ") + + "Bearer " <> param_string + end + + @doc """ + Builds Protected Resource Metadata response per RFC 9728. + + This should be served at /.well-known/oauth-protected-resource + """ + @spec build_resource_metadata(config()) :: map() + def build_resource_metadata(config) do + %{ + "resource" => get_canonical_server_uri(), + "authorization_servers" => config.authorization_servers, + "scopes_supported" => config.scopes_supported, + "bearer_methods_supported" => ["header"], + "resource_documentation" => "https://modelcontextprotocol.io", + "resource_signing_alg_values_supported" => ["RS256", "ES256"] + } + |> Enum.reject(fn {_, v} -> is_nil(v) end) + |> Map.new() + end + + @doc """ + Validates that a token is intended for this resource server. + + Checks the audience claim to ensure the token was issued for this specific + MCP server, preventing token confusion attacks. + """ + @spec validate_audience(token_info(), String.t()) :: :ok | {:error, :invalid_audience} + def validate_audience(%{aud: aud}, expected_audience) when is_binary(aud) do + if aud == expected_audience, do: :ok, else: {:error, :invalid_audience} + end + + def validate_audience(%{aud: aud_list}, expected_audience) when is_list(aud_list) do + if expected_audience in aud_list, do: :ok, else: {:error, :invalid_audience} + end + + def validate_audience(_, _), do: {:error, :invalid_audience} + + @doc """ + Checks if a token has expired based on the exp claim. + """ + @spec validate_expiry(token_info()) :: :ok | {:error, :expired_token} + def validate_expiry(%{exp: exp}) when is_integer(exp) do + now = System.system_time(:second) + if now < exp, do: :ok, else: {:error, :expired_token} + end + + def validate_expiry(_), do: :ok + + @doc """ + Validates token has required scopes. + """ + @spec validate_scopes(token_info(), list(String.t())) :: :ok | {:error, :insufficient_scope} + def validate_scopes(%{scope: token_scope}, required_scopes) when is_binary(token_scope) do + token_scopes = String.split(token_scope, " ", trim: true) + + if Enum.all?(required_scopes, &(&1 in token_scopes)) do + :ok + else + {:error, :insufficient_scope} + end + end + + def validate_scopes(_, []), do: :ok + def validate_scopes(_, _), do: {:error, :insufficient_scope} + + # Private functions + + defp build_www_authenticate_params(config, opts) do + %{ + realm: config.realm, + resource_metadata: config.resource_metadata_url, + authorization_servers: Enum.join(config.authorization_servers, " "), + scope: Keyword.get(opts, :scope), + error: Keyword.get(opts, :error), + error_description: Keyword.get(opts, :error_description), + error_uri: Keyword.get(opts, :error_uri) + } + end + + defp format_www_authenticate_param({:realm, value}) when is_binary(value) do + ~s(realm="#{escape_quotes(value)}") + end + + defp format_www_authenticate_param({:resource_metadata, value}) when is_binary(value) do + ~s(resource_metadata="#{escape_quotes(value)}") + end + + defp format_www_authenticate_param({:authorization_servers, value}) when is_binary(value) and value != "" do + ~s(authorization_servers="#{escape_quotes(value)}") + end + + defp format_www_authenticate_param({:scope, value}) when is_binary(value) do + ~s(scope="#{escape_quotes(value)}") + end + + defp format_www_authenticate_param({:error, value}) when is_binary(value) do + ~s(error="#{escape_quotes(value)}") + end + + defp format_www_authenticate_param({:error_description, value}) when is_binary(value) do + ~s(error_description="#{escape_quotes(value)}") + end + + defp format_www_authenticate_param({:error_uri, value}) when is_binary(value) do + ~s(error_uri="#{escape_quotes(value)}") + end + + defp format_www_authenticate_param(_), do: nil + + defp escape_quotes(string) do + String.replace(string, ~s("), ~s(\\")) + end + + defp get_canonical_server_uri do + # This should be configured based on the actual server URL + # For now, return a placeholder that should be overridden + System.get_env("MCP_SERVER_URI", "https://mcp.example.com") + end +end diff --git a/lib/hermes/server/authorization/introspection_validator.ex b/lib/hermes/server/authorization/introspection_validator.ex new file mode 100644 index 0000000..214d42f --- /dev/null +++ b/lib/hermes/server/authorization/introspection_validator.ex @@ -0,0 +1,140 @@ +defmodule Hermes.Server.Authorization.IntrospectionValidator do + @moduledoc """ + OAuth 2.0 Token Introspection validator for authorization. + + Validates tokens by calling an OAuth 2.0 Token Introspection endpoint (RFC 7662). + This is useful when tokens are opaque (not JWTs) or when you need server-side + token validation with real-time revocation checking. + + ## Configuration + + auth_config = [ + authorization_servers: ["https://auth.example.com"], + realm: "my-app-realm", + validator: Hermes.Server.Authorization.IntrospectionValidator, + + # Required + token_introspection_endpoint: "https://auth.example.com/oauth/introspect", + + # Optional - for authenticating to the introspection endpoint + introspection_client_id: "my-mcp-server", + introspection_client_secret: "client-secret" + ] + + ## Features + + - Real-time token validation + - Support for opaque tokens + - Immediate revocation detection + - Client authentication support (Basic Auth) + + ## Token Info + + Returns token information based on the introspection response: + + %{ + sub: "user123", # Subject from introspection + aud: "https://api.example", # Audience claim + scope: "read write", # OAuth scopes + exp: 1234567890, # Expiration timestamp + iat: 1234567800, # Issued at timestamp + client_id: "app123", # OAuth client ID + active: true # From introspection response + } + + ## Security Note + + The introspection endpoint should be protected and only accessible by + authorized resource servers. Use client credentials when required by + your authorization server. + """ + + @behaviour Hermes.Server.Authorization.Validator + + @impl true + def validate_token(token, config) do + with {:ok, response} <- introspect_token(token, config) do + parse_introspection_response(response) + end + end + + @doc """ + Introspects a token using the OAuth 2.0 Token Introspection endpoint. + """ + def introspect_token(token, %{token_introspection_endpoint: endpoint} = config) when is_binary(endpoint) do + body = + URI.encode_query(%{ + "token" => token, + "token_type_hint" => "access_token" + }) + + headers = build_introspection_headers(config) + + request = Hermes.HTTP.build(:post, endpoint, headers, body) + + with {:ok, %Finch.Response{status: 200, body: resp_body}} <- Finch.request(request, Hermes.Finch), + {:ok, json} <- JSON.decode(resp_body) do + {:ok, json} + else + {:ok, %Finch.Response{status: status}} -> + {:error, {:introspection_failed, status}} + + {:error, reason} -> + {:error, reason} + end + end + + def introspect_token(_, _), do: {:error, :no_introspection_endpoint} + + defp build_introspection_headers(config) do + base_headers = %{ + "content-type" => "application/x-www-form-urlencoded", + "accept" => "application/json" + } + + # Add client authentication if configured + case config do + %{introspection_client_id: client_id, introspection_client_secret: secret} -> + auth = Base.encode64("#{client_id}:#{secret}") + Map.put(base_headers, "authorization", "Basic #{auth}") + + _ -> + base_headers + end + end + + defp parse_introspection_response(%{"active" => false}) do + {:error, :invalid_token} + end + + defp parse_introspection_response(%{"active" => true} = response) do + token_info = %{ + sub: response["sub"], + aud: response["aud"], + scope: response["scope"], + exp: response["exp"], + iat: response["iat"], + client_id: response["client_id"], + active: true + } + + # Validate expiration if present + case token_info do + %{exp: exp} when is_integer(exp) -> + now = System.system_time(:second) + + if now < exp do + {:ok, token_info} + else + {:error, :expired_token} + end + + _ -> + {:ok, token_info} + end + end + + defp parse_introspection_response(_) do + {:error, :invalid_introspection_response} + end +end diff --git a/lib/hermes/server/authorization/jwt_validator.ex b/lib/hermes/server/authorization/jwt_validator.ex new file mode 100644 index 0000000..23f3496 --- /dev/null +++ b/lib/hermes/server/authorization/jwt_validator.ex @@ -0,0 +1,243 @@ +defmodule Hermes.Server.Authorization.JWTValidator do + @moduledoc """ + JWT token validator for OAuth 2.1 authorization. + + Validates JWT tokens by fetching public keys from a JWKS (JSON Web Key Set) endpoint + and verifying the token signature and claims. Supports RSA (RS256/RS384/RS512) and + ECDSA (ES256/ES384/ES512) algorithms. + + ## Configuration + + auth_config = [ + authorization_servers: ["https://auth.example.com"], + realm: "my-app-realm", + validator: Hermes.Server.Authorization.JWTValidator, + + # Required + jwks_uri: "https://auth.example.com/.well-known/jwks.json", + + # Optional + issuer: "https://auth.example.com", # Validates iss claim + audience: "https://api.example.com" # Validates aud claim + ] + + ## Features + + - Automatic JWKS caching (5 minutes TTL) + - Key selection by kid (key ID) claim + - Standard claims validation (exp, iss, aud) + - Support for both RSA and EC keys + + ## Token Info + + Returns token information in the format expected by the authorization system: + + %{ + sub: "user123", # Subject (user ID) + aud: "https://api.example", # Audience + scope: "read write", # OAuth scopes + exp: 1234567890, # Expiration timestamp + iat: 1234567800, # Issued at timestamp + client_id: "app123", # OAuth client ID + active: true # Always true for valid tokens + } + """ + + @behaviour Hermes.Server.Authorization.Validator + + require Logger + + @jwks_cache_ttl to_timeout(minute: 5) + + @impl true + def validate_token(token, config) do + with {:ok, jwks} <- fetch_jwks(config), + {:ok, header} <- peek_jwt_header(token), + {:ok, key} <- find_signing_key(header, jwks), + {:ok, claims} <- verify_and_decode(token, key, config) do + {:ok, build_token_info(claims)} + end + end + + @doc """ + Fetches JWKS from the configured endpoint with caching. + """ + def fetch_jwks(%{jwks_uri: jwks_uri}) when is_binary(jwks_uri) do + cache_key = {:jwks, jwks_uri} + now = System.monotonic_time(:millisecond) + + case Process.get(cache_key) do + {jwks, expires_at} when expires_at > now -> + {:ok, jwks} + + _ -> + with {:ok, jwks} <- fetch_jwks_from_uri(jwks_uri) do + expires_at = System.monotonic_time(:millisecond) + @jwks_cache_ttl + Process.put(cache_key, {jwks, expires_at}) + {:ok, jwks} + end + end + end + + def fetch_jwks(_), do: {:error, :no_jwks_uri} + + defp fetch_jwks_from_uri(uri) do + request = Hermes.HTTP.build(:get, uri) + + with {:ok, %Finch.Response{status: 200, body: body}} <- Finch.request(request, Hermes.Finch), + {:ok, %{"keys" => keys}} <- JSON.decode(body) do + {:ok, keys} + else + {:ok, %Finch.Response{status: status}} -> + {:error, {:jwks_fetch_failed, status}} + + {:ok, json} when is_map(json) -> + {:error, :invalid_jwks} + + {:error, reason} -> + {:error, reason} + end + end + + defp peek_jwt_header(token) do + with [header_b64 | _] <- String.split(token, "."), + {:ok, header_json} <- Base.url_decode64(header_b64, padding: false), + {:ok, header} <- JSON.decode(header_json) do + {:ok, header} + else + _ -> {:error, :invalid_jwt_format} + end + end + + defp find_signing_key(%{"kid" => kid, "alg" => alg}, jwks) do + case Enum.find(jwks, &(&1["kid"] == kid && &1["alg"] == alg)) do + nil -> {:error, :key_not_found} + key -> {:ok, key} + end + end + + defp find_signing_key(%{"alg" => alg}, jwks) do + # No kid specified, try to find by algorithm + case Enum.find(jwks, &(&1["alg"] == alg)) do + nil -> {:error, :key_not_found} + key -> {:ok, key} + end + end + + defp find_signing_key(_, _), do: {:error, :no_algorithm} + + defp verify_and_decode(token, jwk, config) do + with {:ok, signer} <- build_signer(jwk) do + token_config = build_token_config(config) + + case Joken.verify_and_validate(token_config, token, signer) do + {:ok, claims} -> {:ok, claims} + {:error, :signature_error} -> {:error, :invalid_signature} + {:error, _} = error -> error + end + end + end + + defp build_token_config(config) do + %{} + |> add_issuer_validation(config) + |> add_audience_validation(config) + |> add_expiration_validation() + end + + defp add_issuer_validation(token_config, %{issuer: expected_iss}) when is_binary(expected_iss) do + Map.put(token_config, "iss", &validate_issuer_claim(&1, expected_iss)) + end + + defp add_issuer_validation(token_config, _), do: token_config + + defp add_audience_validation(token_config, %{audience: expected_aud}) when is_binary(expected_aud) do + Map.put(token_config, "aud", &validate_audience_claim(&1, expected_aud)) + end + + defp add_audience_validation(token_config, _), do: token_config + + defp add_expiration_validation(token_config) do + Map.put(token_config, "exp", &validate_expiration_claim/1) + end + + defp validate_issuer_claim(%{"iss" => iss}, expected_iss) when iss == expected_iss, do: :ok + defp validate_issuer_claim(_, _), do: {:error, :invalid_issuer} + + defp validate_audience_claim(%{"aud" => aud}, expected_aud) when aud == expected_aud, do: :ok + + defp validate_audience_claim(%{"aud" => auds}, expected_aud) when is_list(auds) do + if expected_aud in auds, do: :ok, else: {:error, :invalid_audience} + end + + defp validate_audience_claim(_, _), do: {:error, :invalid_audience} + + defp validate_expiration_claim(%{"exp" => exp}) when is_integer(exp) do + if System.system_time(:second) < exp, do: :ok, else: {:error, :expired_token} + end + + defp validate_expiration_claim(_), do: :ok + + defp build_signer(%{"kty" => "RSA"} = jwk) do + with {:ok, key} <- jwk_to_rsa_key(jwk) do + alg = jwk["alg"] || "RS256" + {:ok, Joken.Signer.create(alg, %{"pem" => key})} + end + end + + defp build_signer(%{"kty" => "EC"} = jwk) do + with {:ok, key} <- jwk_to_ec_key(jwk) do + alg = jwk["alg"] || "ES256" + {:ok, Joken.Signer.create(alg, %{"pem" => key})} + end + end + + defp build_signer(_), do: {:error, :unsupported_key_type} + + defp jwk_to_rsa_key(%{"n" => n, "e" => e}) do + # Convert JWK to RSA public key + with {:ok, n_bin} <- Base.url_decode64(n, padding: false), + {:ok, e_bin} <- Base.url_decode64(e, padding: false) do + modulus = :crypto.bytes_to_integer(n_bin) + exponent = :crypto.bytes_to_integer(e_bin) + + public_key = {:RSAPublicKey, modulus, exponent} + pem_entry = :public_key.pem_entry_encode(:SubjectPublicKeyInfo, public_key) + pem = :public_key.pem_encode([pem_entry]) + + {:ok, pem} + end + end + + defp jwk_to_ec_key(%{"x" => x, "y" => y, "crv" => crv}) do + # Convert JWK to EC public key + with {:ok, x_bin} <- Base.url_decode64(x, padding: false), + {:ok, y_bin} <- Base.url_decode64(y, padding: false), + {:ok, curve} <- curve_from_crv(crv) do + point = <<0x04, x_bin::binary, y_bin::binary>> + public_key = {{:ECPoint, point}, {:namedCurve, curve}} + + pem_entry = :public_key.pem_entry_encode(:SubjectPublicKeyInfo, public_key) + pem = :public_key.pem_encode([pem_entry]) + + {:ok, pem} + end + end + + defp curve_from_crv("P-256"), do: {:ok, :secp256r1} + defp curve_from_crv("P-384"), do: {:ok, :secp384r1} + defp curve_from_crv("P-521"), do: {:ok, :secp521r1} + defp curve_from_crv(_), do: {:error, :unsupported_curve} + + defp build_token_info(claims) do + %{ + sub: claims["sub"], + aud: claims["aud"], + scope: claims["scope"], + exp: claims["exp"], + iat: claims["iat"], + client_id: claims["client_id"] || claims["azp"], + active: true + } + end +end diff --git a/lib/hermes/server/authorization/plug.ex b/lib/hermes/server/authorization/plug.ex new file mode 100644 index 0000000..043b714 --- /dev/null +++ b/lib/hermes/server/authorization/plug.ex @@ -0,0 +1,134 @@ +defmodule Hermes.Server.Authorization.Plug do + @moduledoc false + + @behaviour Plug + + import Plug.Conn + + alias Hermes.MCP.Error + alias Hermes.Server.Authorization + + @well_known_path "/.well-known/oauth-protected-resource" + + @impl Plug + def init(opts) do + config = Authorization.parse_config!(opts[:authorization] || opts) + + validator = + case config[:validator] do + nil -> Authorization.JWTValidator + module when is_atom(module) -> module + _ -> raise ArgumentError, "Invalid validator configuration" + end + + %{ + config: config, + validator: validator, + skip_paths: opts[:skip_paths] || [@well_known_path] + } + end + + @impl Plug + def call(%{request_path: path} = conn, %{skip_paths: skip_paths, config: config} = opts) do + cond do + path == @well_known_path -> + send_metadata_response(conn, config) + + path in skip_paths -> + conn + + true -> + authenticate(conn, opts) + end + end + + defp authenticate(conn, %{config: config, validator: validator}) do + with {:ok, token} <- extract_bearer_token(conn), + {:ok, token_info} <- validator.validate_token(token, config), + :ok <- Authorization.validate_audience(token_info, get_server_uri(conn)), + :ok <- Authorization.validate_expiry(token_info) do + conn + |> assign(:mcp_auth, token_info) + |> assign(:authenticated, true) + else + {:error, :no_token} -> + send_unauthorized(conn, config, error: "invalid_request") + + {:error, :invalid_token} -> + send_unauthorized(conn, config, error: "invalid_token") + + {:error, :expired_token} -> + send_unauthorized(conn, config, + error: "invalid_token", + error_description: "The access token expired" + ) + + {:error, :invalid_audience} -> + send_unauthorized(conn, config, + error: "invalid_token", + error_description: "Token not intended for this resource" + ) + + {:error, _} -> + send_unauthorized(conn, config, error: "invalid_token") + end + end + + @doc """ + Extracts bearer token from Authorization header. + """ + def extract_bearer_token(conn) do + case get_req_header(conn, "authorization") do + ["Bearer " <> token] when byte_size(token) > 0 -> + {:ok, String.trim(token)} + + ["bearer " <> token] when byte_size(token) > 0 -> + # Handle lowercase for compatibility + {:ok, String.trim(token)} + + _ -> + {:error, :no_token} + end + end + + defp send_unauthorized(conn, config, opts) do + www_authenticate = Authorization.build_www_authenticate_header(config, opts) + + error = + Error.protocol(:parse_error, %{ + error: opts[:error] || "unauthorized", + http_status: 401 + }) + + {:ok, body} = Error.to_json_rpc(error) + + conn + |> put_resp_header("www-authenticate", www_authenticate) + |> put_resp_content_type("application/json") + |> send_resp(401, body) + |> halt() + end + + defp send_metadata_response(conn, config) do + metadata = Authorization.build_resource_metadata(config) + + conn + |> put_resp_content_type("application/json") + |> put_resp_header("cache-control", "max-age=3600") + |> send_resp(200, JSON.encode!(metadata)) + |> halt() + end + + defp get_server_uri(conn) do + scheme = if conn.scheme == :https, do: "https", else: "http" + + port_part = + case {conn.scheme, conn.port} do + {:https, 443} -> "" + {:http, 80} -> "" + {_, port} -> ":#{port}" + end + + "#{scheme}://#{conn.host}#{port_part}#{conn.request_path}" + end +end diff --git a/lib/hermes/server/authorization/validator.ex b/lib/hermes/server/authorization/validator.ex new file mode 100644 index 0000000..4f828ef --- /dev/null +++ b/lib/hermes/server/authorization/validator.ex @@ -0,0 +1,55 @@ +defmodule Hermes.Server.Authorization.Validator do + @moduledoc """ + Behaviour for implementing OAuth 2.1 token validators. + + Token validators are responsible for verifying access tokens and extracting + token information such as subject, audience, scopes, and expiration. + + ## Implementing a Custom Validator + + defmodule MyApp.CustomValidator do + @behaviour Hermes.Server.Authorization.Validator + + @impl true + def validate_token(token, config) do + # Custom validation logic + {:ok, %{ + sub: "user123", + aud: "https://mcp.example.com", + scope: "read write", + exp: 1234567890, + active: true + }} + end + end + """ + + alias Hermes.Server.Authorization + + @doc """ + Validates an access token and returns token information. + + The validator should verify the token's signature, expiration, and other + claims according to the chosen validation method (JWT, introspection, etc.). + + ## Return Values + + - `{:ok, token_info}` - Token is valid with extracted information + - `{:error, :invalid_token}` - Token is invalid or malformed + - `{:error, :expired_token}` - Token has expired + - `{:error, reason}` - Other validation errors + """ + @callback validate_token(token :: String.t(), config :: Authorization.config()) :: + {:ok, Authorization.token_info()} | {:error, atom() | String.t()} + + @doc """ + Optional callback for refreshing tokens. + + If the validator supports token refresh, implement this callback to + exchange a refresh token for a new access token. + """ + @callback refresh_token(refresh_token :: String.t(), config :: Authorization.config()) :: + {:ok, %{access_token: String.t(), expires_in: integer()}} | {:error, term()} + + @optional_callbacks refresh_token: 2 +end diff --git a/lib/hermes/server/component/resource.ex b/lib/hermes/server/component/resource.ex index a7f17ed..07519f4 100644 --- a/lib/hermes/server/component/resource.ex +++ b/lib/hermes/server/component/resource.ex @@ -65,7 +65,7 @@ defmodule Hermes.Server.Component.Resource do timestamp: DateTime.utc_now() } - {:ok, Jason.encode!(status), frame} + {:ok, JSON.encode!(status), frame} end end """ diff --git a/lib/hermes/server/frame.ex b/lib/hermes/server/frame.ex index 7fde45c..e2dc0c3 100644 --- a/lib/hermes/server/frame.ex +++ b/lib/hermes/server/frame.ex @@ -254,6 +254,103 @@ defmodule Hermes.Server.Frame do %{frame | request: request} end + @doc """ + Gets the authentication context from the frame. + + Returns the token information if the request is authenticated via OAuth 2.1, + or nil if the request is not authenticated. + + ## Examples + + # For authenticated requests + auth = Frame.get_auth(frame) + # => %{sub: "user123", scope: "read write", exp: 1234567890, ...} + + # For unauthenticated requests + auth = Frame.get_auth(frame) + # => nil + """ + @spec get_auth(t) :: map() | nil + def get_auth(%__MODULE__{transport: %{auth: auth}}), do: auth + def get_auth(%__MODULE__{}), do: nil + + @doc """ + Checks if the current request is authenticated. + + Returns true if the request includes valid OAuth 2.1 authentication, + false otherwise. + + ## Examples + + if Frame.authenticated?(frame) do + # Handle authenticated request + else + # Handle unauthenticated request + end + """ + @spec authenticated?(t) :: boolean() + def authenticated?(frame), do: not is_nil(get_auth(frame)) + + @doc """ + Gets the authenticated subject (user/client ID). + + Returns the subject claim from the OAuth token if authenticated, + or nil if not authenticated. + + ## Examples + + subject = Frame.get_auth_subject(frame) + # => "user123" or nil + """ + @spec get_auth_subject(t) :: String.t() | nil + def get_auth_subject(frame) do + case get_auth(frame) do + %{sub: subject} -> subject + _ -> nil + end + end + + @doc """ + Gets the OAuth scopes for the authenticated request. + + Returns the scope string from the OAuth token if authenticated, + or nil if not authenticated. + + ## Examples + + scopes = Frame.get_auth_scopes(frame) + # => "read write admin" or nil + """ + @spec get_auth_scopes(t) :: String.t() | nil + def get_auth_scopes(frame) do + case get_auth(frame) do + %{scope: scope} -> scope + _ -> nil + end + end + + @doc """ + Checks if the authenticated request has a specific scope. + + Returns true if the token includes the specified scope, + false if not authenticated or scope not present. + + ## Examples + + if Frame.has_scope?(frame, "write") do + # Allow write operation + else + # Deny write operation + end + """ + @spec has_scope?(t, String.t()) :: boolean() + def has_scope?(frame, required_scope) when is_binary(required_scope) do + case get_auth_scopes(frame) do + nil -> false + scopes -> required_scope in String.split(scopes, " ", trim: true) + end + end + @doc """ Sets the pagination limit for listing operations. diff --git a/lib/hermes/server/transport/streamable_http/plug.ex b/lib/hermes/server/transport/streamable_http/plug.ex index 056f7ba..8ed10ff 100644 --- a/lib/hermes/server/transport/streamable_http/plug.ex +++ b/lib/hermes/server/transport/streamable_http/plug.ex @@ -63,6 +63,7 @@ if Code.ensure_loaded?(Plug) do alias Hermes.MCP.Error alias Hermes.MCP.ID alias Hermes.MCP.Message + alias Hermes.Server.Authorization alias Hermes.Server.Transport.StreamableHTTP alias Hermes.SSE.Streaming alias Plug.Conn.Unfetched @@ -82,10 +83,28 @@ if Code.ensure_loaded?(Plug) do session_header = Keyword.get(opts, :session_header, @default_session_header) timeout = Keyword.get(opts, :timeout, @default_timeout) - %{transport: transport, session_header: session_header, timeout: timeout} + base_opts = %{ + transport: transport, + session_header: session_header, + timeout: timeout + } + + if auth_config = Keyword.get(opts, :authorization) do + auth_opts = Authorization.Plug.init(authorization: auth_config) + Map.put(base_opts, :authorization, auth_opts) + else + base_opts + end end @impl Plug + def call(conn, %{authorization: auth_opts} = opts) when not is_nil(auth_opts) do + case Authorization.Plug.call(conn, auth_opts) do + %{halted: true} = conn -> conn + conn -> call(conn, Map.delete(opts, :authorization)) + end + end + def call(conn, opts) do case conn.method do "GET" -> handle_get(conn, opts) @@ -466,7 +485,7 @@ if Code.ensure_loaded?(Plug) do defp extract_request_id(_), do: nil defp build_request_context(conn) do - %{ + context = %{ assigns: conn.assigns, type: :http, req_headers: conn.req_headers, @@ -477,6 +496,11 @@ if Code.ensure_loaded?(Plug) do port: conn.port, request_path: conn.request_path } + + case conn.assigns[:mcp_auth] do + nil -> context + auth_info -> Map.put(context, :auth, auth_info) + end end defp fetch_query_params_safe(conn) do diff --git a/mix.exs b/mix.exs index e83607b..cdf2e67 100644 --- a/mix.exs +++ b/mix.exs @@ -49,6 +49,7 @@ defmodule Hermes.MixProject do {:finch, "~> 0.19"}, {:peri, "~> 0.4"}, {:telemetry, "~> 1.2"}, + {:joken, "~> 2.6"}, {:gun, "~> 2.2", optional: true}, {:burrito, "~> 1.0", optional: true}, {:plug, "~> 1.18", optional: true}, diff --git a/mix.lock b/mix.lock index c271a9d..8c75e5e 100644 --- a/mix.lock +++ b/mix.lock @@ -16,6 +16,8 @@ "ham": {:hex, :ham, "0.3.0", "7cd031b4a55fba219c11553e7b13ba73bd86eab4034518445eff1e038cb9a44d", [:mix], [], "hexpm", "7d6c6b73d7a6a83233876cc1b06a4d9b5de05562b228effda4532f9a49852bf6"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"}, + "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, diff --git a/pages/building-a-server.md b/pages/building-a-server.md index eed3f94..b27136f 100644 --- a/pages/building-a-server.md +++ b/pages/building-a-server.md @@ -476,6 +476,174 @@ Any assign key matching these patterns will be redacted as `[REDACTED]` in logs: This helps prevent accidental exposure of sensitive data in error logs or crash reports while maintaining debugging capabilities. +### OAuth 2.1 Authorization + +Hermes provides built-in OAuth 2.1 authorization for HTTP transports, implementing RFC 9728 (OAuth 2.0 Protected Resource Metadata). This allows you to secure your MCP server with standard OAuth bearer tokens. + +#### Quick Start + +Most OAuth setups fall into two categories: JWT tokens or opaque tokens. Hermes has built-in validators for both. + +**For JWT tokens** (most common): + +```elixir +# In your application.ex +auth_config = [ + authorization_servers: ["https://auth.example.com"], + realm: "my-app-realm", + scopes_supported: ["read", "write", "admin"], + validator: Hermes.Server.Authorization.JWTValidator, + jwks_uri: "https://auth.example.com/.well-known/jwks.json" +] + +children = [ + Hermes.Server.Registry, + {MyApp.Server, transport: {:streamable_http, authorization: auth_config}}, + {Bandit, plug: MyApp.Router, port: 4000} +] +``` + +**For opaque tokens** (with introspection): + +```elixir +auth_config = [ + authorization_servers: ["https://auth.example.com"], + realm: "my-app-realm", + scopes_supported: ["read", "write", "admin"], + validator: Hermes.Server.Authorization.IntrospectionValidator, + token_introspection_endpoint: "https://auth.example.com/oauth/introspect", + introspection_client_id: "my-mcp-server", + introspection_client_secret: System.fetch_env!("OAUTH_CLIENT_SECRET") +] +``` + +#### Built-in Validators + +Hermes includes two production-ready validators that handle most OAuth scenarios: + +**JWTValidator** - Best for: +- Auth0, Okta, Keycloak, AWS Cognito +- Self-signed JWT tokens +- When you want offline token validation +- Supports RS256/384/512 and ES256/384/512 + +**IntrospectionValidator** - Best for: +- Opaque (non-JWT) tokens +- When you need real-time revocation +- Legacy OAuth systems +- Extra security through server-side validation + +#### Using Authorization in Your Components + +Once configured, all your tools, resources, and prompts have access to authentication info: + +```elixir +defmodule MyApp.SecureOperation do + use Hermes.Server.Component, type: :tool + + schema do + %{operation: {:required, {:enum, ["read_data", "write_data"]}}} + end + + def execute(%{operation: operation}, frame) do + # Check if user is authenticated + unless Hermes.Server.Frame.authenticated?(frame) do + error = Hermes.MCP.Error.execution("unauthorized", %{ + message: "Authentication required" + }) + return {:error, error, frame} + end + + # Get user info + user = Hermes.Server.Frame.get_auth_subject(frame) + scopes = Hermes.Server.Frame.get_auth_scopes(frame) + + # Check specific scope + if operation == "write_data" and not Hermes.Server.Frame.has_scope?(frame, "write") do + error = Hermes.MCP.Error.execution("unauthorized", %{ + message: "This operation requires 'write' scope" + }) + {:error, error, frame} + else + {:ok, "Operation completed by #{user}"} + end + end +end +``` + +#### Custom Validators (Advanced) + +Only create a custom validator if the built-in ones don't meet your needs (rare). Examples include: + +- Proprietary token formats +- Local token validation with custom logic +- Integration with non-standard auth systems + +```elixir +defmodule MyApp.CustomValidator do + @behaviour Hermes.Server.Authorization.Validator + + @impl true + def validate_token(token, config) do + case MyApp.Auth.verify_token(token) do + {:ok, claims} -> + {:ok, %{ + sub: claims["sub"], + aud: claims["aud"], + scope: claims["scope"], + exp: claims["exp"], + active: true + }} + + {:error, _reason} -> + {:error, :invalid_token} + end + end +end +``` + +#### OAuth Metadata Discovery + +Hermes automatically serves OAuth 2.0 Protected Resource Metadata at `/.well-known/oauth-protected-resource`: + +```bash +curl http://localhost:4000/.well-known/oauth-protected-resource + +{ + "resource": "http://localhost:4000", + "authorization_servers": ["https://auth.example.com"], + "bearer_methods_supported": ["header"], + "scopes_supported": ["read", "write", "admin"] +} +``` + +This helps clients discover your server's OAuth requirements automatically. + +#### Testing Your Protected Server + +```bash +# Get a token from your auth server first +TOKEN="your-access-token" + +# Initialize MCP session +curl -X POST http://localhost:4000/mcp \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": {"name": "test-client", "version": "1.0.0"} + } + }' +``` + +The authorization is transparent to your MCP implementation - tokens are validated at the transport layer, and auth info flows through the frame context. + ## What's Next? You've seen how to expose your Elixir application's capabilities to AI assistants. What patterns interest you most? diff --git a/pages/recipes.md b/pages/recipes.md index 45ec1f6..f4323d4 100644 --- a/pages/recipes.md +++ b/pages/recipes.md @@ -65,13 +65,13 @@ defmodule MyApp.OAuthResource do def read(_params, frame) do case frame.assigns[:oauth_token] do nil -> - {:ok, Jason.encode!(%{ + {:ok, JSON.encode!(%{ authenticated: false, login_url: generate_oauth_url(frame.private.session_id) })} token -> - {:ok, Jason.encode!(%{ + {:ok, JSON.encode!(%{ authenticated: true, user: fetch_user_info(token) })} diff --git a/priv/dev/oauth_example/.formatter.exs b/priv/dev/oauth_example/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/priv/dev/oauth_example/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/priv/dev/oauth_example/.gitignore b/priv/dev/oauth_example/.gitignore new file mode 100644 index 0000000..4f1657d --- /dev/null +++ b/priv/dev/oauth_example/.gitignore @@ -0,0 +1,23 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +oauth_example-*.tar + +# Temporary files, for example, from tests. +/tmp/ diff --git a/priv/dev/oauth_example/README.md b/priv/dev/oauth_example/README.md new file mode 100644 index 0000000..4c8226c --- /dev/null +++ b/priv/dev/oauth_example/README.md @@ -0,0 +1,123 @@ +# OAuth Example MCP Server + +This example demonstrates how to implement OAuth 2.1 authorization in a Hermes MCP server. + +## Features + +- OAuth 2.1 Bearer token authentication +- Scope-based authorization for tools and resources +- Mock token validator for testing +- Example tools, resources, and prompts that require authentication + +## Running the Server + +```bash +cd priv/dev/oauth_example +mix deps.get +mix run --no-halt +``` + +The server will start on port 4001 with the following endpoints: +- `/mcp` - MCP server endpoint (requires authentication) +- `/health` - Health check endpoint (no auth required) +- `/.well-known/oauth-protected-resource` - OAuth metadata endpoint + +## Testing with Demo Tokens + +The mock validator accepts these demo tokens: + +- `demo_read_token` - Read-only access (scope: "read") +- `demo_write_token` - Read and write access (scope: "read write") +- `demo_admin_token` - Full admin access (scope: "read write admin") + +### Example: Testing without authentication + +```bash +# This will return a 401 Unauthorized +curl -X POST http://localhost:4001/mcp \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": { + "name": "test-client", + "version": "1.0.0" + } + } + }' +``` + +### Example: Testing with authentication + +```bash +# Initialize with read-only token +curl -X POST http://localhost:4001/mcp \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "Authorization: Bearer demo_read_token" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": { + "name": "test-client", + "version": "1.0.0" + } + } + }' +``` + +### Example: OAuth metadata discovery + +```bash +# Get OAuth protected resource metadata +curl http://localhost:4001/.well-known/oauth-protected-resource +``` + +## Components + +### Tools + +- **secure_operation** - Demonstrates scope-based authorization + - `read_data` - Requires authentication + - `write_data` - Requires "write" scope + - `admin_action` - Requires "admin" scope + +### Resources + +- **oauth://profile** - Returns authenticated user's profile (requires authentication) + +### Prompts + +- **auth_info** - Provides information about authentication status + +## Real-World Implementation + +To use this in a production environment: + +1. Replace `MockValidator` with a real validator: + - Use `Hermes.Server.Authorization.JWTValidator` for JWT tokens + - Use `Hermes.Server.Authorization.IntrospectionValidator` for token introspection + - Implement a custom validator for your auth system + +2. Configure real authorization servers: + ```elixir + auth_config = [ + authorization_servers: ["https://your-auth-server.com"], + jwks_uri: "https://your-auth-server.com/.well-known/jwks.json", + audience: "https://your-api.com" + ] + ``` + +3. Implement proper error handling and logging + +4. Add rate limiting and other security measures + diff --git a/priv/dev/oauth_example/lib/oauth_example.ex b/priv/dev/oauth_example/lib/oauth_example.ex new file mode 100644 index 0000000..2078097 --- /dev/null +++ b/priv/dev/oauth_example/lib/oauth_example.ex @@ -0,0 +1,18 @@ +defmodule OauthExample do + @moduledoc """ + Documentation for `OauthExample`. + """ + + @doc """ + Hello world. + + ## Examples + + iex> OauthExample.hello() + :world + + """ + def hello do + :world + end +end diff --git a/priv/dev/oauth_example/lib/oauth_example/application.ex b/priv/dev/oauth_example/lib/oauth_example/application.ex new file mode 100644 index 0000000..e14448b --- /dev/null +++ b/priv/dev/oauth_example/lib/oauth_example/application.ex @@ -0,0 +1,25 @@ +defmodule OauthExample.Application do + @moduledoc false + + use Application + + @impl true + def start(_type, _args) do + # OAuth configuration + auth_config = [ + authorization_servers: ["https://auth.example.com"], + realm: "oauth-example-realm", + scopes_supported: ["read", "write", "admin"], + validator: OauthExample.MockValidator + ] + + children = [ + Hermes.Server.Registry, + {OauthExample.Server, transport: {:streamable_http, authorization: auth_config}}, + {Bandit, plug: OauthExample.Router, port: 4001} + ] + + opts = [strategy: :one_for_one, name: OauthExample.Supervisor] + Supervisor.start_link(children, opts) + end +end diff --git a/priv/dev/oauth_example/lib/oauth_example/mock_validator.ex b/priv/dev/oauth_example/lib/oauth_example/mock_validator.ex new file mode 100644 index 0000000..327ffb3 --- /dev/null +++ b/priv/dev/oauth_example/lib/oauth_example/mock_validator.ex @@ -0,0 +1,55 @@ +defmodule OauthExample.MockValidator do + @moduledoc """ + Mock token validator for demonstration purposes. + + In a real application, this would validate JWT tokens against a real + authorization server or validate tokens using introspection. + """ + + @behaviour Hermes.Server.Authorization.Validator + + @impl true + def validate_token(token, _config) do + case token do + # Demo tokens for testing + "demo_read_token" -> + {:ok, + %{ + sub: "user_read_only", + aud: "https://localhost:4001", + scope: "read", + exp: System.system_time(:second) + 3600, + client_id: "demo_client", + active: true + }} + + "demo_write_token" -> + {:ok, + %{ + sub: "user_read_write", + aud: "https://localhost:4001", + scope: "read write", + exp: System.system_time(:second) + 3600, + client_id: "demo_client", + active: true + }} + + "demo_admin_token" -> + {:ok, + %{ + sub: "user_admin", + aud: "https://localhost:4001", + scope: "read write admin", + exp: System.system_time(:second) + 3600, + client_id: "admin_client", + active: true + }} + + "expired_token" -> + {:error, :expired_token} + + _ -> + {:error, :invalid_token} + end + end +end diff --git a/priv/dev/oauth_example/lib/oauth_example/prompts/auth_info.ex b/priv/dev/oauth_example/lib/oauth_example/prompts/auth_info.ex new file mode 100644 index 0000000..3a1c170 --- /dev/null +++ b/priv/dev/oauth_example/lib/oauth_example/prompts/auth_info.ex @@ -0,0 +1,89 @@ +defmodule OauthExample.Prompts.AuthInfo do + @moduledoc "Generate prompts about OAuth authentication status and requirements" + + use Hermes.Server.Component, type: :prompt + + alias Hermes.Server.Frame + alias Hermes.Server.Response + + schema do + %{ + detail_level: {{:enum, ["basic", "detailed", "technical"]}, {:default, "basic"}} + } + end + + @impl true + def get_messages(%{detail_level: level}, frame) do + auth_status = if Frame.authenticated?(frame), do: "authenticated", else: "not authenticated" + + message = + case {auth_status, level} do + {"authenticated", "basic"} -> + """ + You are currently authenticated as: #{Frame.get_auth_subject(frame)} + Available scopes: #{Frame.get_auth_scopes(frame) || "none"} + """ + + {"authenticated", "detailed"} -> + auth = Frame.get_auth(frame) + + """ + Authentication Status: ✓ Authenticated + + User ID: #{auth.sub} + Client ID: #{auth[:client_id] || "N/A"} + Scopes: #{auth.scope || "none"} + Token Active: #{auth.active} + + You have access to: + - Read operations: #{if auth.scope && String.contains?(auth.scope, "read"), do: "✓", else: "✗"} + - Write operations: #{if auth.scope && String.contains?(auth.scope, "write"), do: "✓", else: "✗"} + - Admin operations: #{if auth.scope && String.contains?(auth.scope, "admin"), do: "✓", else: "✗"} + """ + + {"authenticated", "technical"} -> + auth = Frame.get_auth(frame) + + """ + OAuth 2.1 Authentication Details: + + Token Claims: + - Subject (sub): #{auth.sub} + - Audience (aud): #{inspect(auth.aud)} + - Scopes: #{auth.scope || "none"} + - Expiration: #{format_unix_time(auth[:exp])} + - Issued At: #{format_unix_time(auth[:iat])} + - Client ID: #{auth[:client_id] || "N/A"} + - Active: #{auth.active} + + Authorization Server: https://auth.example.com + Resource Server: oauth://profile + """ + + {"not authenticated", _} -> + """ + You are not currently authenticated. + + To access protected resources and tools, you need to authenticate with a valid OAuth token. + + Available demo tokens for testing: + - "demo_read_token" - Read-only access + - "demo_write_token" - Read and write access + - "demo_admin_token" - Full admin access + + Include the token in your Authorization header: + Authorization: Bearer + """ + end + + {:reply, Response.user_message(Response.prompt(), message), frame} + end + + defp format_unix_time(nil), do: "N/A" + + defp format_unix_time(timestamp) when is_integer(timestamp) do + timestamp + |> DateTime.from_unix!() + |> DateTime.to_iso8601() + end +end diff --git a/priv/dev/oauth_example/lib/oauth_example/resources/user_profile.ex b/priv/dev/oauth_example/lib/oauth_example/resources/user_profile.ex new file mode 100644 index 0000000..37de23d --- /dev/null +++ b/priv/dev/oauth_example/lib/oauth_example/resources/user_profile.ex @@ -0,0 +1,46 @@ +defmodule OauthExample.Resources.UserProfile do + @moduledoc "Resource that returns authenticated user's profile information" + + use Hermes.Server.Component, + type: :resource, + uri: "oauth://profile", + mime_type: "application/json" + + alias Hermes.Server.Frame + alias Hermes.Server.Response + + @impl true + def read(_params, frame) do + case Frame.get_auth(frame) do + nil -> + error = + Hermes.MCP.Error.execution("unauthorized", %{ + message: "Authentication required to view profile" + }) + + {:error, error, frame} + + auth_info -> + profile = %{ + user_id: auth_info.sub, + authenticated: true, + scopes: String.split(auth_info.scope || "", " ", trim: true), + client_id: auth_info[:client_id], + token_expires_at: format_expiry(auth_info[:exp]), + metadata: %{ + retrieved_at: DateTime.utc_now() |> DateTime.to_iso8601() + } + } + + {:reply, Response.json(Response.resource(), profile), frame} + end + end + + defp format_expiry(nil), do: nil + + defp format_expiry(exp) when is_integer(exp) do + exp + |> DateTime.from_unix!() + |> DateTime.to_iso8601() + end +end diff --git a/priv/dev/oauth_example/lib/oauth_example/router.ex b/priv/dev/oauth_example/lib/oauth_example/router.ex new file mode 100644 index 0000000..a576fef --- /dev/null +++ b/priv/dev/oauth_example/lib/oauth_example/router.ex @@ -0,0 +1,28 @@ +defmodule OauthExample.Router do + use Plug.Router + + alias Hermes.Server.Transport.StreamableHTTP + + plug(Plug.Logger) + + plug(Plug.Parsers, + parsers: [:json], + pass: ["application/json"], + json_decoder: JSON + ) + + plug(:match) + plug(:dispatch) + + # Forward MCP requests to the StreamableHTTP plug with authorization + forward("/mcp", to: StreamableHTTP.Plug, init_opts: [server: OauthExample.Server]) + + # Health check endpoint (no auth required) + get "/health" do + send_resp(conn, 200, JSON.encode!(%{status: "ok"})) + end + + match _ do + send_resp(conn, 404, "not found") + end +end diff --git a/priv/dev/oauth_example/lib/oauth_example/server.ex b/priv/dev/oauth_example/lib/oauth_example/server.ex new file mode 100644 index 0000000..f396fcd --- /dev/null +++ b/priv/dev/oauth_example/lib/oauth_example/server.ex @@ -0,0 +1,36 @@ +defmodule OauthExample.Server do + use Hermes.Server, + name: "oauth-example", + version: "1.0.0", + capabilities: [:tools, :resources, :prompts] + + component(OauthExample.Tools.SecureOperation) + component(OauthExample.Resources.UserProfile) + component(OauthExample.Prompts.AuthInfo) + + alias Hermes.Server.Frame + + @impl true + def init(_client_info, frame) do + if Frame.authenticated?(frame) do + IO.puts("🔐 Authenticated client connected!") + IO.puts(" Subject: #{Frame.get_auth_subject(frame)}") + IO.puts(" Scopes: #{Frame.get_auth_scopes(frame)}") + else + IO.puts("🔓 Client connected without authentication") + end + + {:ok, frame} + end + + @impl true + def handle_info(:demo_notification, frame) do + if Frame.authenticated?(frame) do + send_log_message(frame, :info, "Hello authenticated user: #{Frame.get_auth_subject(frame)}") + else + send_log_message(frame, :warning, "Authentication required for full features") + end + + {:noreply, frame} + end +end diff --git a/priv/dev/oauth_example/lib/oauth_example/tools/secure_operation.ex b/priv/dev/oauth_example/lib/oauth_example/tools/secure_operation.ex new file mode 100644 index 0000000..acdd767 --- /dev/null +++ b/priv/dev/oauth_example/lib/oauth_example/tools/secure_operation.ex @@ -0,0 +1,91 @@ +defmodule OauthExample.Tools.SecureOperation do + @moduledoc "A tool that demonstrates OAuth scope-based authorization" + + use Hermes.Server.Component, type: :tool + + alias Hermes.Server.Frame + alias Hermes.Server.Response + + schema do + %{ + operation: {:required, {:enum, ["read_data", "write_data", "admin_action"]}}, + data: :string + } + end + + @impl true + def execute(%{operation: operation} = params, frame) do + case {operation, check_authorization(operation, frame)} do + {_, {:error, reason}} -> + {:error, build_error(reason), frame} + + {"read_data", :ok} -> + result = %{ + result: "Successfully read data", + user: Frame.get_auth_subject(frame), + data: params[:data] || "default data" + } + + {:reply, Response.json(Response.tool(), result), frame} + + {"write_data", :ok} -> + result = %{ + result: "Successfully wrote data", + user: Frame.get_auth_subject(frame), + data: params[:data] || "new data" + } + + {:reply, Response.json(Response.tool(), result), frame} + + {"admin_action", :ok} -> + result = %{ + result: "Admin action completed", + user: Frame.get_auth_subject(frame), + admin_info: %{ + all_scopes: Frame.get_auth_scopes(frame), + client_id: Frame.get_auth(frame)[:client_id] + } + } + + {:reply, Response.json(Response.tool(), result), frame} + end + end + + defp check_authorization("read_data", frame) do + if Frame.authenticated?(frame) do + :ok + else + {:error, "Authentication required"} + end + end + + defp check_authorization("write_data", frame) do + cond do + not Frame.authenticated?(frame) -> + {:error, "Authentication required"} + + not Frame.has_scope?(frame, "write") -> + {:error, "This operation requires 'write' scope"} + + true -> + :ok + end + end + + defp check_authorization("admin_action", frame) do + cond do + not Frame.authenticated?(frame) -> + {:error, "Authentication required"} + + not Frame.has_scope?(frame, "admin") -> + {:error, "This operation requires 'admin' scope"} + + true -> + :ok + end + end + + defp build_error(message) do + Hermes.MCP.Error.execution("unauthorized", %{message: message}) + end +end diff --git a/priv/dev/oauth_example/mix.exs b/priv/dev/oauth_example/mix.exs new file mode 100644 index 0000000..b58c06f --- /dev/null +++ b/priv/dev/oauth_example/mix.exs @@ -0,0 +1,30 @@ +defmodule OauthExample.MixProject do + use Mix.Project + + def project do + [ + app: :oauth_example, + version: "0.1.0", + elixir: "~> 1.18", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger], + mod: {OauthExample.Application, []} + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:plug, "~> 1.18"}, + {:bandit, "~> 1.6"}, + {:hermes_mcp, path: "../../../"} + ] + end +end diff --git a/priv/dev/oauth_example/mix.lock b/priv/dev/oauth_example/mix.lock new file mode 100644 index 0000000..9d3254e --- /dev/null +++ b/priv/dev/oauth_example/mix.lock @@ -0,0 +1,17 @@ +%{ + "bandit": {:hex, :bandit, "1.7.0", "d1564f30553c97d3e25f9623144bb8df11f3787a26733f00b21699a128105c0c", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "3e2f7a98c7a11f48d9d8c037f7177cd39778e74d55c7af06fe6227c742a8168a"}, + "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"}, + "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "peri": {:hex, :peri, "0.6.0", "0758aa037f862f7a3aa0823cb82195916f61a8071f6eaabcff02103558e61a70", [:mix], [{:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.1", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "b27f118f3317fbc357c4a04b3f3c98561efdd8865edd4ec0e24fd936c7ff36c8"}, + "plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "thousand_island": {:hex, :thousand_island, "1.3.14", "ad45ebed2577b5437582bcc79c5eccd1e2a8c326abf6a3464ab6c06e2055a34a", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d0d24a929d31cdd1d7903a4fe7f2409afeedff092d277be604966cd6aa4307ef"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, +} diff --git a/priv/dev/oauth_example/test/oauth_example_test.exs b/priv/dev/oauth_example/test/oauth_example_test.exs new file mode 100644 index 0000000..abc87ce --- /dev/null +++ b/priv/dev/oauth_example/test/oauth_example_test.exs @@ -0,0 +1,8 @@ +defmodule OauthExampleTest do + use ExUnit.Case + doctest OauthExample + + test "greets the world" do + assert OauthExample.hello() == :world + end +end diff --git a/priv/dev/oauth_example/test/test_helper.exs b/priv/dev/oauth_example/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/priv/dev/oauth_example/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start() diff --git a/test/hermes/mcp/error_test.exs b/test/hermes/mcp/error_test.exs index 50692a4..09215cb 100644 --- a/test/hermes/mcp/error_test.exs +++ b/test/hermes/mcp/error_test.exs @@ -139,7 +139,7 @@ defmodule Hermes.MCP.ErrorTest do error = Error.protocol(:parse_error) {:ok, encoded} = Error.to_json_rpc(error, "req-123") - decoded = Jason.decode!(encoded) + decoded = JSON.decode!(encoded) assert decoded["jsonrpc"] == "2.0" assert decoded["id"] == "req-123" assert decoded["error"]["code"] == -32_700 @@ -150,7 +150,7 @@ defmodule Hermes.MCP.ErrorTest do error = Error.protocol(:invalid_params, %{field: "name"}) {:ok, encoded} = Error.to_json_rpc(error, 1) - decoded = Jason.decode!(encoded) + decoded = JSON.decode!(encoded) assert decoded["error"]["data"]["field"] == "name" end @@ -158,7 +158,7 @@ defmodule Hermes.MCP.ErrorTest do error = Error.execution("Custom error message") {:ok, encoded} = Error.to_json_rpc(error, 1) - decoded = Jason.decode!(encoded) + decoded = JSON.decode!(encoded) assert decoded["error"]["message"] == "Custom error message" end end diff --git a/test/hermes/mcp/message_test.exs b/test/hermes/mcp/message_test.exs index 52e9fb6..a2cca04 100644 --- a/test/hermes/mcp/message_test.exs +++ b/test/hermes/mcp/message_test.exs @@ -307,7 +307,7 @@ defmodule Hermes.MCP.MessageTest do "total" => 100 }) - decoded = Jason.decode!(encoded) + decoded = JSON.decode!(encoded) assert decoded["jsonrpc"] == "2.0" assert decoded["method"] == "notifications/progress" @@ -323,7 +323,7 @@ defmodule Hermes.MCP.MessageTest do "progress" => 50 }) - decoded = Jason.decode!(encoded) + decoded = JSON.decode!(encoded) assert decoded["jsonrpc"] == "2.0" assert decoded["method"] == "notifications/progress" @@ -338,7 +338,7 @@ defmodule Hermes.MCP.MessageTest do {:ok, encoded} = Message.encode_log_message("info", "Test log message", "test-logger") - decoded = Jason.decode!(encoded) + decoded = JSON.decode!(encoded) assert decoded["jsonrpc"] == "2.0" assert decoded["method"] == "notifications/message" @@ -351,7 +351,7 @@ defmodule Hermes.MCP.MessageTest do {:ok, encoded} = Message.encode_log_message("error", %{error: "Something went wrong"}) - decoded = Jason.decode!(encoded) + decoded = JSON.decode!(encoded) assert decoded["jsonrpc"] == "2.0" assert decoded["method"] == "notifications/message" diff --git a/test/hermes/server/authorization/plug_test.exs b/test/hermes/server/authorization/plug_test.exs new file mode 100644 index 0000000..1ce8e9d --- /dev/null +++ b/test/hermes/server/authorization/plug_test.exs @@ -0,0 +1,232 @@ +defmodule Hermes.Server.Authorization.PlugTest do + use ExUnit.Case, async: true + + import Plug.Conn + import Plug.Test + + alias Hermes.Server.Authorization.Plug, as: AuthPlug + + defmodule MockValidator do + @moduledoc false + @behaviour Hermes.Server.Authorization.Validator + + @impl true + def validate_token("valid_token", _config) do + {:ok, + %{ + sub: "user123", + aud: "http://www.example.com/api/test", + scope: "read write", + exp: System.system_time(:second) + 3600, + active: true + }} + end + + def validate_token("expired_token", _config) do + {:error, :expired_token} + end + + def validate_token("wrong_audience", _config) do + {:ok, + %{ + sub: "user123", + aud: "https://wrong.com", + scope: "read", + active: true + }} + end + + def validate_token(_, _config) do + {:error, :invalid_token} + end + end + + setup do + config = [ + authorization_servers: ["https://auth.example.com"], + realm: "test-realm", + validator: MockValidator + ] + + {:ok, config: config, plug_opts: AuthPlug.init(authorization: config)} + end + + describe "init/1" do + test "initializes with valid config" do + opts = + AuthPlug.init( + authorization: [ + authorization_servers: ["https://auth.example.com"], + validator: MockValidator + ] + ) + + assert opts.config.authorization_servers == ["https://auth.example.com"] + assert opts.validator == MockValidator + assert opts.skip_paths == ["/.well-known/oauth-protected-resource"] + end + + test "uses JWT validator by default" do + opts = AuthPlug.init(authorization: [authorization_servers: ["https://auth.example.com"]]) + + assert opts.validator == Hermes.Server.Authorization.JWTValidator + end + + test "allows custom skip paths" do + opts = + AuthPlug.init( + authorization: [authorization_servers: ["https://auth.example.com"]], + skip_paths: ["/health", "/metrics"] + ) + + assert opts.skip_paths == ["/health", "/metrics"] + end + end + + describe "call/2 - metadata endpoint" do + test "serves resource metadata at well-known path", %{plug_opts: opts} do + conn = + :get + |> conn("/.well-known/oauth-protected-resource") + |> AuthPlug.call(opts) + + assert conn.status == 200 + + assert Enum.any?(conn.resp_headers, fn {k, v} -> + k == "content-type" && String.contains?(v, "application/json") + end) + + body = JSON.decode!(conn.resp_body) + assert body["authorization_servers"] == ["https://auth.example.com"] + assert body["bearer_methods_supported"] == ["header"] + end + + test "includes cache control header for metadata", %{plug_opts: opts} do + conn = + :get + |> conn("/.well-known/oauth-protected-resource") + |> AuthPlug.call(opts) + + assert get_resp_header(conn, "cache-control") == ["max-age=3600"] + end + end + + describe "call/2 - authentication" do + test "authenticates valid bearer token", %{plug_opts: opts} do + conn = + :get + |> conn("/api/test") + |> put_req_header("authorization", "Bearer valid_token") + |> AuthPlug.call(opts) + + assert conn.assigns.mcp_auth.sub == "user123" + assert conn.assigns.mcp_auth.scope == "read write" + assert conn.assigns.authenticated == true + refute conn.halted + end + + test "handles lowercase bearer prefix", %{plug_opts: opts} do + conn = + :get + |> conn("/api/test") + |> put_req_header("authorization", "bearer valid_token") + |> AuthPlug.call(opts) + + assert conn.assigns.authenticated == true + end + + test "returns 401 for missing token", %{plug_opts: opts} do + conn = + :get + |> conn("/api/test") + |> AuthPlug.call(opts) + + assert conn.status == 401 + assert conn.halted + + www_auth = conn |> get_resp_header("www-authenticate") |> List.first() + assert www_auth =~ "Bearer" + assert www_auth =~ ~s(realm="test-realm") + assert www_auth =~ ~s(error="invalid_request") + end + + test "returns 401 for invalid token", %{plug_opts: opts} do + conn = + :get + |> conn("/api/test") + |> put_req_header("authorization", "Bearer invalid_token") + |> AuthPlug.call(opts) + + assert conn.status == 401 + assert conn.halted + + www_auth = conn |> get_resp_header("www-authenticate") |> List.first() + assert www_auth =~ ~s(error="invalid_token") + end + + test "returns 401 for expired token", %{plug_opts: opts} do + conn = + :get + |> conn("/api/test") + |> put_req_header("authorization", "Bearer expired_token") + |> AuthPlug.call(opts) + + assert conn.status == 401 + assert conn.halted + + www_auth = conn |> get_resp_header("www-authenticate") |> List.first() + assert www_auth =~ ~s(error="invalid_token") + assert www_auth =~ ~s(error_description="The access token expired") + end + + test "returns 401 for wrong audience", %{plug_opts: opts} do + conn = + :get + |> conn("/api/test") + |> put_req_header("authorization", "Bearer wrong_audience") + |> Plug.Conn.put_private(:test_host, "test.example.com") + |> AuthPlug.call(opts) + + assert conn.status == 401 + assert conn.halted + + www_auth = conn |> get_resp_header("www-authenticate") |> List.first() + assert www_auth =~ ~s(error="invalid_token") + assert www_auth =~ ~s(error_description="Token not intended for this resource") + end + + test "returns JSON-RPC error format", %{plug_opts: opts} do + conn = + :get + |> conn("/api/test") + |> AuthPlug.call(opts) + + body = JSON.decode!(conn.resp_body) + assert body["jsonrpc"] == "2.0" + assert body["error"]["code"] == -32_700 + assert body["id"] + end + end + + describe "extract_bearer_token/1" do + test "extracts valid bearer token" do + conn = :get |> conn("/") |> put_req_header("authorization", "Bearer abc123") + assert {:ok, "abc123"} = AuthPlug.extract_bearer_token(conn) + end + + test "trims whitespace from token" do + conn = :get |> conn("/") |> put_req_header("authorization", "Bearer abc123 ") + assert {:ok, "abc123"} = AuthPlug.extract_bearer_token(conn) + end + + test "rejects empty bearer token" do + conn = :get |> conn("/") |> put_req_header("authorization", "Bearer ") + assert {:error, :no_token} = AuthPlug.extract_bearer_token(conn) + end + + test "rejects non-bearer auth" do + conn = :get |> conn("/") |> put_req_header("authorization", "Basic abc123") + assert {:error, :no_token} = AuthPlug.extract_bearer_token(conn) + end + end +end diff --git a/test/hermes/server/authorization_test.exs b/test/hermes/server/authorization_test.exs new file mode 100644 index 0000000..945a462 --- /dev/null +++ b/test/hermes/server/authorization_test.exs @@ -0,0 +1,176 @@ +defmodule Hermes.Server.AuthorizationTest do + use ExUnit.Case, async: true + + alias Hermes.Server.Authorization + + describe "parse_config!/1" do + test "parses valid configuration" do + config = + Authorization.parse_config!( + authorization_servers: ["https://auth.example.com"], + realm: "test-realm", + scopes_supported: ["read", "write"] + ) + + assert config.authorization_servers == ["https://auth.example.com"] + assert config.realm == "test-realm" + assert config.scopes_supported == ["read", "write"] + end + + test "requires authorization_servers" do + assert_raise Peri.InvalidSchema, fn -> + Authorization.parse_config!(realm: "test") + end + end + + test "uses default realm if not provided" do + config = Authorization.parse_config!(authorization_servers: ["https://auth.example.com"]) + + assert config.realm == "mcp-server" + end + end + + describe "build_www_authenticate_header/2" do + setup do + config = %{ + authorization_servers: ["https://auth1.com", "https://auth2.com"], + realm: "test-realm", + resource_metadata_url: "https://api.example.com/.well-known/oauth-protected-resource" + } + + {:ok, config: config} + end + + test "builds basic header", %{config: config} do + header = Authorization.build_www_authenticate_header(config) + + assert header =~ ~s(realm="test-realm") + assert header =~ ~s(authorization_servers="https://auth1.com https://auth2.com") + + assert header =~ + ~s(resource_metadata="https://api.example.com/.well-known/oauth-protected-resource") + end + + test "includes error information when provided", %{config: config} do + header = + Authorization.build_www_authenticate_header(config, + error: "invalid_token", + error_description: "Token expired" + ) + + assert header =~ ~s(error="invalid_token") + assert header =~ ~s(error_description="Token expired") + end + + test "escapes quotes in values", %{config: config} do + config = %{config | realm: ~s(test "quoted" realm)} + header = Authorization.build_www_authenticate_header(config) + + assert header =~ ~s(realm="test \\"quoted\\" realm") + end + end + + describe "build_resource_metadata/1" do + test "builds complete metadata" do + config = %{ + authorization_servers: ["https://auth.example.com"], + scopes_supported: ["read", "write", "admin"] + } + + metadata = Authorization.build_resource_metadata(config) + + assert metadata["authorization_servers"] == ["https://auth.example.com"] + assert metadata["scopes_supported"] == ["read", "write", "admin"] + assert metadata["bearer_methods_supported"] == ["header"] + assert metadata["resource_documentation"] == "https://modelcontextprotocol.io" + assert "RS256" in metadata["resource_signing_alg_values_supported"] + assert "ES256" in metadata["resource_signing_alg_values_supported"] + end + + test "excludes nil values" do + config = %{ + authorization_servers: ["https://auth.example.com"], + scopes_supported: nil + } + + metadata = Authorization.build_resource_metadata(config) + + refute Map.has_key?(metadata, "scopes_supported") + end + end + + describe "validate_audience/2" do + test "validates string audience match" do + token_info = %{aud: "https://api.example.com"} + assert :ok = Authorization.validate_audience(token_info, "https://api.example.com") + end + + test "validates audience in list" do + token_info = %{aud: ["https://api.example.com", "https://other.com"]} + assert :ok = Authorization.validate_audience(token_info, "https://api.example.com") + end + + test "rejects mismatched audience" do + token_info = %{aud: "https://wrong.com"} + + assert {:error, :invalid_audience} = + Authorization.validate_audience(token_info, "https://api.example.com") + end + + test "rejects when expected audience not in list" do + token_info = %{aud: ["https://wrong.com", "https://other.com"]} + + assert {:error, :invalid_audience} = + Authorization.validate_audience(token_info, "https://api.example.com") + end + + test "rejects missing audience" do + assert {:error, :invalid_audience} = + Authorization.validate_audience(%{}, "https://api.example.com") + end + end + + describe "validate_expiry/1" do + test "validates non-expired token" do + future_exp = System.system_time(:second) + 3600 + token_info = %{exp: future_exp} + + assert :ok = Authorization.validate_expiry(token_info) + end + + test "rejects expired token" do + past_exp = System.system_time(:second) - 3600 + token_info = %{exp: past_exp} + + assert {:error, :expired_token} = Authorization.validate_expiry(token_info) + end + + test "passes when no expiration" do + assert :ok = Authorization.validate_expiry(%{}) + end + end + + describe "validate_scopes/2" do + test "validates when token has all required scopes" do + token_info = %{scope: "read write admin"} + assert :ok = Authorization.validate_scopes(token_info, ["read", "write"]) + end + + test "validates with empty required scopes" do + token_info = %{scope: "read"} + assert :ok = Authorization.validate_scopes(token_info, []) + end + + test "rejects when missing required scope" do + token_info = %{scope: "read"} + + assert {:error, :insufficient_scope} = + Authorization.validate_scopes(token_info, ["read", "write"]) + end + + test "rejects when token has no scope" do + assert {:error, :insufficient_scope} = + Authorization.validate_scopes(%{}, ["read"]) + end + end +end diff --git a/test/hermes/server/transport/streamable_http/plug_test.exs b/test/hermes/server/transport/streamable_http/plug_test.exs index 3f8dd11..7e2c714 100644 --- a/test/hermes/server/transport/streamable_http/plug_test.exs +++ b/test/hermes/server/transport/streamable_http/plug_test.exs @@ -105,7 +105,7 @@ defmodule Hermes.Server.Transport.StreamableHTTP.PlugTest do |> StreamableHTTPPlug.call(opts) assert conn.status == 406 - {:ok, body} = Jason.decode(conn.resp_body) + {:ok, body} = JSON.decode(conn.resp_body) assert body["error"]["message"] == "Invalid Request" end end @@ -178,7 +178,7 @@ defmodule Hermes.Server.Transport.StreamableHTTP.PlugTest do |> StreamableHTTPPlug.call(opts) assert conn.status == 200 - {:ok, response} = Jason.decode(conn.resp_body) + {:ok, response} = JSON.decode(conn.resp_body) assert response["result"] == %{} end @@ -191,7 +191,7 @@ defmodule Hermes.Server.Transport.StreamableHTTP.PlugTest do |> StreamableHTTPPlug.call(opts) assert conn.status == 400 - {:ok, body} = Jason.decode(conn.resp_body) + {:ok, body} = JSON.decode(conn.resp_body) assert body["error"]["code"] == -32_700 end end @@ -227,7 +227,7 @@ defmodule Hermes.Server.Transport.StreamableHTTP.PlugTest do |> StreamableHTTPPlug.call(opts) assert conn.status == 400 - {:ok, body} = Jason.decode(conn.resp_body) + {:ok, body} = JSON.decode(conn.resp_body) assert body["error"]["message"] == "Internal error" end end @@ -252,7 +252,7 @@ defmodule Hermes.Server.Transport.StreamableHTTP.PlugTest do |> StreamableHTTPPlug.call(opts) assert conn.status == 405 - {:ok, body} = Jason.decode(conn.resp_body) + {:ok, body} = JSON.decode(conn.resp_body) assert body["error"]["message"] == "Method not found" end end