diff --git a/lib/a2a/plug/jwt_verifier.ex b/lib/a2a/plug/jwt_verifier.ex new file mode 100644 index 0000000..a0be804 --- /dev/null +++ b/lib/a2a/plug/jwt_verifier.ex @@ -0,0 +1,218 @@ +if Code.ensure_loaded?(Plug) and Code.ensure_loaded?(Joken) do + defmodule A2A.Plug.JWTVerifier do + @moduledoc """ + JWT verification utilities for A2A principal authentication. + + Provides JWT verification for authenticating principals (users, agents, or + services) accessing A2A endpoints. Signature verification is delegated to + [Joken](https://hex.pm/packages/joken)/JOSE rather than hand-rolled crypto. + + ## Features + + - JWT signature verification via Joken (HS256) + - Expiration (`exp`) and not-before (`nbf`) validation with clock-skew tolerance + - Issuer and audience verification + - Configurable required claims (default: `["sub"]`) + + ## Usage with HMAC (HS256) + + # Configure HMAC-based JWT verification + verifier = A2A.Plug.JWTVerifier.new( + secret: "your-secret-key", + algorithm: "HS256", + issuer: "https://auth.example.com", + audience: "a2a-api" + ) + + # Use with A2A.Plug.Auth + plug A2A.Plug.Auth, + schemes: %{ + "jwt_auth" => %A2A.SecurityScheme.HTTPAuth{scheme: "bearer"} + }, + verify: fn _name, token, _conn -> + A2A.Plug.JWTVerifier.verify(verifier, token) + end + + ## Configuration Options + + - `:secret` — HMAC secret key (for HS256) + - `:algorithm` — Signature algorithm: "HS256" (default: "HS256") + - `:issuer` — Expected issuer claim (optional) + - `:audience` — Expected audience claim (optional) + - `:required_claims` — List of claim names that must be present (default: `["sub"]`) + - `:clock_skew` — Allowed clock skew in seconds (default: 60) + + This module is only compiled when both `:plug` and `:joken` are available. + """ + + @type verifier :: %{ + secret: String.t() | nil, + algorithm: String.t(), + issuer: String.t() | nil, + audience: String.t() | nil, + required_claims: [String.t()], + clock_skew: integer() + } + + @type claim_map :: %{String.t() => any()} + + @doc """ + Creates a new JWT verifier configuration. + """ + @spec new(keyword()) :: verifier() + def new(opts) do + %{ + secret: Keyword.get(opts, :secret), + algorithm: Keyword.get(opts, :algorithm, "HS256"), + issuer: Keyword.get(opts, :issuer), + audience: Keyword.get(opts, :audience), + required_claims: Keyword.get(opts, :required_claims, ["sub"]), + clock_skew: Keyword.get(opts, :clock_skew, 60) + } + end + + @doc """ + Verifies a JWT token and returns the claims. + + Signature verification is performed by Joken. Header/algorithm and claim + validation are performed here so error reasons stay descriptive. + """ + @spec verify(verifier(), String.t()) :: {:ok, claim_map()} | {:error, String.t()} + def verify(config, token) when is_binary(token) do + with {:ok, header} <- peek_header(token), + :ok <- verify_algorithm(header, config), + {:ok, signer} <- build_signer(config), + {:ok, claims} <- verify_signature(token, signer), + :ok <- validate_claims(claims, config) do + {:ok, claims} + end + end + + # -- Signature (delegated to Joken) ----------------------------------------- + + defp peek_header(token) do + case Joken.peek_header(token) do + {:ok, header} -> {:ok, header} + {:error, _} -> {:error, "invalid JWT format"} + end + rescue + _ -> {:error, "invalid JWT format"} + end + + defp verify_algorithm(header, config) do + case Map.get(header, "alg") do + nil -> + {:error, "invalid JWT format"} + + alg when alg == config.algorithm -> + :ok + + alg -> + {:error, "algorithm mismatch: expected #{config.algorithm}, got #{alg}"} + end + end + + defp build_signer(%{secret: nil}), + do: {:error, "no secret key configured for HMAC verification"} + + defp build_signer(%{algorithm: "HS256", secret: secret}), + do: {:ok, Joken.Signer.create("HS256", secret)} + + defp build_signer(%{algorithm: algorithm}), + do: {:error, "unsupported algorithm: #{algorithm}"} + + defp verify_signature(token, signer) do + case Joken.verify(token, signer) do + {:ok, claims} -> {:ok, claims} + {:error, _reason} -> {:error, "signature verification failed"} + end + end + + # -- Claims (not security-sensitive crypto) --------------------------------- + + defp validate_claims(claims, config) do + with :ok <- validate_required_claims(claims, config.required_claims), + :ok <- validate_issuer(claims, config.issuer), + :ok <- validate_audience(claims, config.audience), + :ok <- validate_expiration(claims, config.clock_skew), + :ok <- validate_not_before(claims, config.clock_skew) do + :ok + end + end + + defp validate_required_claims(claims, required) do + missing = Enum.reject(required, &Map.has_key?(claims, &1)) + + case missing do + [] -> :ok + missing -> {:error, "missing required claims: #{Enum.join(missing, ", ")}"} + end + end + + defp validate_issuer(_claims, nil), do: :ok + + defp validate_issuer(claims, expected_issuer) do + case Map.get(claims, "iss") do + ^expected_issuer -> :ok + actual -> {:error, "issuer mismatch: expected #{expected_issuer}, got #{inspect(actual)}"} + end + end + + defp validate_audience(_claims, nil), do: :ok + + defp validate_audience(claims, expected_audience) do + case Map.get(claims, "aud") do + ^expected_audience -> + :ok + + audiences when is_list(audiences) -> + if expected_audience in audiences do + :ok + else + {:error, "audience mismatch: #{expected_audience} not in #{inspect(audiences)}"} + end + + actual -> + {:error, "audience mismatch: expected #{expected_audience}, got #{inspect(actual)}"} + end + end + + defp validate_expiration(claims, clock_skew) do + case Map.get(claims, "exp") do + nil -> + :ok + + exp when is_number(exp) -> + now = System.system_time(:second) + + if exp + clock_skew >= now do + :ok + else + {:error, "token expired"} + end + + _ -> + {:error, "invalid exp claim"} + end + end + + defp validate_not_before(claims, clock_skew) do + case Map.get(claims, "nbf") do + nil -> + :ok + + nbf when is_number(nbf) -> + now = System.system_time(:second) + + if nbf - clock_skew <= now do + :ok + else + {:error, "token not yet valid"} + end + + _ -> + {:error, "invalid nbf claim"} + end + end + end +end diff --git a/lib/agentmsg_elixir_web/controllers/a2a_controller.ex b/lib/agentmsg_elixir_web/controllers/a2a_controller.ex new file mode 100644 index 0000000..e07498e --- /dev/null +++ b/lib/agentmsg_elixir_web/controllers/a2a_controller.ex @@ -0,0 +1,376 @@ +if Code.ensure_loaded?(Plug) and Code.ensure_loaded?(Phoenix.Controller) and + Code.ensure_loaded?(Joken) do + defmodule AgentmsgElixirWeb.A2AController do + @moduledoc """ + Phoenix controller for A2A agent-to-agent communication with JWT authentication. + + This controller implements the A2A protocol endpoints with Principal authentication + using JWT bearer tokens (HS256). It bridges the gap between simple test + authentication and shared-secret JWT validation. + + ## Principal Authentication + + Supports JWT bearer tokens with: + - HS256 signature verification (via `A2A.Plug.JWTVerifier`) + - Standard claims validation (exp, nbf) + - Issuer and audience verification + - Configurable claim requirements + + ## Endpoints + + - `POST /a2a/message` — Send message to agent + - `GET /a2a/stream/:task_id` — Stream agent responses (SSE) + - `GET /.well-known/agent-card.json` — Agent card (no auth required) + + ## Configuration + + Configure in your endpoint or router: + + pipeline :a2a_auth do + plug A2A.Plug.Auth, + schemes: %{ + "jwt_auth" => %A2A.SecurityScheme.HTTPAuth{scheme: "bearer"} + }, + verify: &AgentmsgElixirWeb.A2AController.verify_jwt_token/3, + exempt_paths: [ + [".well-known", "agent-card.json"] + ] + end + + scope "/", AgentmsgElixirWeb do + pipe_through [:a2a_auth] + + post "/a2a/message", A2AController, :send_message + get "/a2a/stream/:task_id", A2AController, :stream_response + get "/.well-known/agent-card.json", A2AController, :agent_card + end + + ## Environment Configuration + + config :agentmsg_elixir, AgentmsgElixirWeb.A2AController, + jwt_verifier: %{ + secret: System.get_env("JWT_SECRET"), + algorithm: "HS256", + issuer: "https://auth.example.com", + audience: "a2a-api", + required_claims: ["sub", "principal_type"] + }, + agent_module: MyApp.Agent, + base_url: "https://myagent.example.com" + """ + + use Phoenix.Controller, formats: [:json] + + alias A2A.Plug.{Auth, JWTVerifier, SSE} + alias A2A.{Agent, Task, Message} + + # Default configuration - override in your app config + @default_config %{ + jwt_verifier: %{ + secret: System.get_env("JWT_SECRET"), + algorithm: "HS256", + issuer: System.get_env("JWT_ISSUER", "https://auth.example.com"), + audience: System.get_env("JWT_AUDIENCE", "a2a-api"), + required_claims: ["sub", "principal_type"], + clock_skew: 60 + }, + # Must be configured + agent_module: nil, + base_url: System.get_env("A2A_BASE_URL", "http://localhost:4000") + } + + # -- Public API -------------------------------------------------------------- + + @doc """ + JWT verification callback for A2A.Plug.Auth. + + This function is called by the auth plug to verify JWT bearer tokens. + It implements the Principal authentication flow with HS256 validation. + + ## Parameters + + - `scheme_name` — The authentication scheme name (typically "jwt_auth") + - `token` — The JWT token extracted from the Authorization header + - `conn` — The Plug connection + + ## Returns + + - `{:ok, identity}` — Authentication successful, contains principal claims + - `{:error, reason}` — Authentication failed + + ## Principal Identity + + On successful authentication, the identity contains: + + %{ + "principal_id" => "user:alice@example.com", + "principal_type" => "user", # or "agent", "service" + "sub" => "alice@example.com", + "iss" => "https://auth.example.com", + "aud" => "a2a-api", + "exp" => 1234567890, + # ... other JWT claims + } + + ## Usage + + # In your router configuration: + plug A2A.Plug.Auth, + schemes: %{ + "jwt_auth" => %A2A.SecurityScheme.HTTPAuth{scheme: "bearer"} + }, + verify: &AgentmsgElixirWeb.A2AController.verify_jwt_token/3 + """ + @spec verify_jwt_token(String.t(), String.t(), Plug.Conn.t()) :: + {:ok, map()} | {:error, String.t()} + def verify_jwt_token(_scheme_name, token, _conn) do + config = get_config() + verifier = JWTVerifier.new(config.jwt_verifier) + + case JWTVerifier.verify(verifier, token) do + {:ok, claims} -> + identity = build_principal_identity(claims) + {:ok, identity} + + {:error, reason} -> + {:error, "JWT verification failed: #{reason}"} + end + end + + @doc """ + Send a message to the agent. + + Requires JWT authentication. The principal identity from the JWT token + is passed to the agent as context metadata. + + ## Request Body + + { + "jsonrpc": "2.0", + "id": 1, + "method": "message/send", + "params": { + "message": { + "messageId": "msg-123", + "role": "user", + "parts": [ + {"kind": "text", "text": "Hello, agent!"} + ] + } + } + } + + ## Response + + { + "jsonrpc": "2.0", + "id": 1, + "result": { + "task": { + "taskId": "task-456", + "status": "running", + "metadata": { + "a2a.auth": { + "scheme": "jwt_auth", + "identity": { + "principal_id": "user:alice@example.com", + "principal_type": "user", + "sub": "alice@example.com" + } + } + } + } + } + } + """ + def send_message(conn, _params) do + config = get_config() + + unless config.agent_module do + send_error(conn, 500, "Agent module not configured") + else + # Use A2A.Plug for standard JSON-RPC handling with auth + plug_opts = + A2A.Plug.init( + agent: config.agent_module, + base_url: config.base_url + ) + + A2A.Plug.call(conn, plug_opts) + end + end + + @doc """ + Stream agent responses via Server-Sent Events. + + Requires JWT authentication. Streams real-time updates for the specified task. + + ## Parameters + + - `task_id` — The task ID to stream updates for + + ## Response + + Server-Sent Events stream with task status updates: + + event: task_update + data: {"taskId": "task-456", "status": "running"} + + event: task_update + data: {"taskId": "task-456", "status": "completed", "result": {...}} + """ + def stream_response(conn, %{"task_id" => task_id}) do + config = get_config() + + unless config.agent_module do + send_error(conn, 500, "Agent module not configured") + else + # Verify the task exists and user has access + case verify_task_access(task_id, conn) do + :ok -> + # Use A2A.Plug.SSE for streaming + sse_opts = + SSE.init( + task_id: task_id, + base_url: config.base_url + ) + + SSE.call(conn, sse_opts) + + {:error, reason} -> + send_error(conn, 403, "Access denied: #{reason}") + end + end + end + + @doc """ + Return the agent card. + + This endpoint is exempt from authentication and returns the agent's + capabilities and metadata. + + ## Response + + { + "name": "Example Agent", + "version": "1.0.0", + "description": "An example A2A agent", + "author": "Example Corp", + "security": [ + { + "jwt_auth": [] + } + ], + "extensions": [...], + "metadata": {...} + } + """ + def agent_card(conn, _params) do + config = get_config() + + unless config.agent_module do + send_error(conn, 500, "Agent module not configured") + else + case Agent.card(config.agent_module) do + {:ok, card} -> + # Add security schemes to advertise JWT auth + card_with_security = add_security_schemes(card) + + conn + |> put_resp_content_type("application/json") + |> json(card_with_security) + + {:error, reason} -> + send_error(conn, 500, "Failed to get agent card: #{inspect(reason)}") + end + end + end + + # -- Private helpers --------------------------------------------------------- + + defp get_config do + app_config = Application.get_env(:agentmsg_elixir, __MODULE__, %{}) + Map.merge(@default_config, app_config) + end + + defp build_principal_identity(claims) do + principal_type = Map.get(claims, "principal_type", "user") + sub = Map.get(claims, "sub") + + principal_id = + case {principal_type, sub} do + {type, sub} when is_binary(sub) -> "#{type}:#{sub}" + {_, _} -> "unknown:#{System.unique_integer([:positive])}" + end + + %{ + "principal_id" => principal_id, + "principal_type" => principal_type, + "sub" => sub, + "iss" => Map.get(claims, "iss"), + "aud" => Map.get(claims, "aud"), + "exp" => Map.get(claims, "exp"), + "iat" => Map.get(claims, "iat"), + "roles" => Map.get(claims, "roles", []), + "permissions" => Map.get(claims, "permissions", []) + } + end + + defp verify_task_access(task_id, conn) do + # Get the authenticated identity + case Auth.get_identity(conn) do + nil -> + {:error, "not authenticated"} + + _identity -> + # In a real implementation, you would check if the principal + # has access to the specific task. For now, just verify the task exists. + case Task.get(task_id) do + {:ok, _task} -> :ok + {:error, :not_found} -> {:error, "task not found"} + {:error, reason} -> {:error, reason} + end + end + end + + defp add_security_schemes(card) do + jwt_security = %{ + "jwt_auth" => [] + } + + existing_security = Map.get(card, "security", []) + new_security = [jwt_security | existing_security] + + Map.put(card, "security", new_security) + end + + defp send_error(conn, status, message) do + conn + |> put_status(status) + |> put_resp_content_type("application/json") + |> json(%{"error" => message}) + |> halt() + end + end +else + defmodule AgentmsgElixirWeb.A2AController do + @moduledoc """ + A2A Controller - requires Phoenix, Plug, and Joken to be loaded. + + Add `{:phoenix, "~> 1.7"}` and `{:joken, "~> 2.6"}` to your dependencies + to use this module. + """ + + def __using__(_opts) do + raise """ + AgentmsgElixirWeb.A2AController requires Phoenix, Plug, and Joken. + + Add to your mix.exs dependencies: + + {:phoenix, "~> 1.7"}, + {:plug, "~> 1.16"}, + {:joken, "~> 2.6"} + """ + end + end +end diff --git a/mix.exs b/mix.exs index c9988ce..c2af7ef 100644 --- a/mix.exs +++ b/mix.exs @@ -43,6 +43,10 @@ defmodule A2A.MixProject do {:plug, "~> 1.16", optional: true}, {:req, "~> 0.5", optional: true}, {:bandit, "~> 1.5", optional: true}, + {:joken, "~> 2.6", optional: true}, + # jose is pulled in by joken; pin to 1.11.10 because 1.11.11+ uses the + # OTP 26 `dynamic()` type and fails to compile on the OTP 25 CI target. + {:jose, "~> 1.11.10 and < 1.11.11", optional: true}, # Dev/test {:ex_doc, "~> 0.34", only: :dev, runtime: false}, diff --git a/mix.lock b/mix.lock index 99d54dd..db3c092 100644 --- a/mix.lock +++ b/mix.lock @@ -10,6 +10,8 @@ "finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [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", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, + "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.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, diff --git a/test/a2a/plug/jwt_verifier_test.exs b/test/a2a/plug/jwt_verifier_test.exs new file mode 100644 index 0000000..cb05a23 --- /dev/null +++ b/test/a2a/plug/jwt_verifier_test.exs @@ -0,0 +1,473 @@ +defmodule A2A.Plug.JWTVerifierTest do + use ExUnit.Case, async: true + + @moduletag :plug + + alias A2A.Plug.JWTVerifier + + # Test secret for HMAC signing + @test_secret "test-secret-key-that-is-long-enough" + + # -- Helpers ----------------------------------------------------------------- + + defp create_jwt_token(payload, secret \\ @test_secret) do + header = %{"alg" => "HS256", "typ" => "JWT"} + header_json = Jason.encode!(header) + payload_json = Jason.encode!(payload) + + header_b64 = Base.url_encode64(header_json, padding: false) + payload_b64 = Base.url_encode64(payload_json, padding: false) + message = "#{header_b64}.#{payload_b64}" + + signature = :crypto.mac(:hmac, :sha256, secret, message) + signature_b64 = Base.url_encode64(signature, padding: false) + + "#{message}.#{signature_b64}" + end + + defp future_timestamp, do: System.system_time(:second) + 3600 + defp past_timestamp, do: System.system_time(:second) - 3600 + defp current_timestamp, do: System.system_time(:second) + + # -- Configuration ----------------------------------------------------------- + + describe "new/1" do + test "creates verifier with default options" do + verifier = JWTVerifier.new(secret: @test_secret) + + assert verifier.secret == @test_secret + assert verifier.algorithm == "HS256" + assert verifier.issuer == nil + assert verifier.audience == nil + assert verifier.required_claims == ["sub"] + assert verifier.clock_skew == 60 + end + + test "creates verifier with custom options" do + verifier = + JWTVerifier.new( + secret: "custom-secret", + algorithm: "HS256", + issuer: "https://auth.example.com", + audience: "test-api", + required_claims: ["sub", "role"], + clock_skew: 120 + ) + + assert verifier.secret == "custom-secret" + assert verifier.algorithm == "HS256" + assert verifier.issuer == "https://auth.example.com" + assert verifier.audience == "test-api" + assert verifier.required_claims == ["sub", "role"] + assert verifier.clock_skew == 120 + end + end + + # -- Token verification ----------------------------------------------------- + + describe "verify/2" do + test "verifies valid JWT with required claims" do + verifier = JWTVerifier.new(secret: @test_secret) + + payload = %{ + "sub" => "user123", + "iss" => "test-issuer", + "aud" => "test-audience", + "exp" => future_timestamp(), + "iat" => current_timestamp() + } + + token = create_jwt_token(payload) + + assert {:ok, claims} = JWTVerifier.verify(verifier, token) + assert claims["sub"] == "user123" + assert claims["iss"] == "test-issuer" + end + + test "verifies valid JWT with principal claims" do + verifier = + JWTVerifier.new( + secret: @test_secret, + required_claims: ["sub", "principal_type"] + ) + + payload = %{ + "sub" => "alice@example.com", + "principal_type" => "user", + "iss" => "https://auth.example.com", + "aud" => "a2a-api", + "exp" => future_timestamp(), + "iat" => current_timestamp(), + "roles" => ["user", "api-access"] + } + + token = create_jwt_token(payload) + + assert {:ok, claims} = JWTVerifier.verify(verifier, token) + assert claims["sub"] == "alice@example.com" + assert claims["principal_type"] == "user" + assert claims["roles"] == ["user", "api-access"] + end + + test "rejects token with invalid signature" do + verifier = JWTVerifier.new(secret: @test_secret) + + payload = %{ + "sub" => "user123", + "exp" => future_timestamp() + } + + # Create token with wrong secret + token = create_jwt_token(payload, "wrong-secret") + + assert {:error, reason} = JWTVerifier.verify(verifier, token) + assert reason =~ "signature verification failed" + end + + test "rejects expired token" do + verifier = JWTVerifier.new(secret: @test_secret) + + payload = %{ + "sub" => "user123", + "exp" => past_timestamp() + } + + token = create_jwt_token(payload) + + assert {:error, "token expired"} = JWTVerifier.verify(verifier, token) + end + + test "accepts token within clock skew" do + verifier = JWTVerifier.new(secret: @test_secret, clock_skew: 300) + + # Token expired 2 minutes ago, but within 5-minute clock skew + payload = %{ + "sub" => "user123", + "exp" => current_timestamp() - 120 + } + + token = create_jwt_token(payload) + + assert {:ok, _claims} = JWTVerifier.verify(verifier, token) + end + + test "rejects token not yet valid (nbf)" do + verifier = JWTVerifier.new(secret: @test_secret) + + payload = %{ + "sub" => "user123", + "exp" => future_timestamp(), + "nbf" => future_timestamp() + } + + token = create_jwt_token(payload) + + assert {:error, "token not yet valid"} = JWTVerifier.verify(verifier, token) + end + + test "rejects token missing required claims" do + verifier = + JWTVerifier.new( + secret: @test_secret, + required_claims: ["sub", "role"] + ) + + payload = %{ + "sub" => "user123", + "exp" => future_timestamp() + # Missing "role" claim + } + + token = create_jwt_token(payload) + + assert {:error, reason} = JWTVerifier.verify(verifier, token) + assert reason =~ "missing required claims: role" + end + + test "validates issuer when configured" do + verifier = + JWTVerifier.new( + secret: @test_secret, + issuer: "https://auth.example.com" + ) + + payload = %{ + "sub" => "user123", + "iss" => "https://wrong-issuer.com", + "exp" => future_timestamp() + } + + token = create_jwt_token(payload) + + assert {:error, reason} = JWTVerifier.verify(verifier, token) + assert reason =~ "issuer mismatch" + end + + test "validates audience when configured" do + verifier = + JWTVerifier.new( + secret: @test_secret, + audience: "a2a-api" + ) + + payload = %{ + "sub" => "user123", + "aud" => "wrong-audience", + "exp" => future_timestamp() + } + + token = create_jwt_token(payload) + + assert {:error, reason} = JWTVerifier.verify(verifier, token) + assert reason =~ "audience mismatch" + end + + test "validates audience from list when configured" do + verifier = + JWTVerifier.new( + secret: @test_secret, + audience: "a2a-api" + ) + + payload = %{ + "sub" => "user123", + "aud" => ["web-api", "a2a-api", "mobile-api"], + "exp" => future_timestamp() + } + + token = create_jwt_token(payload) + + assert {:ok, _claims} = JWTVerifier.verify(verifier, token) + end + + test "rejects malformed JWT" do + verifier = JWTVerifier.new(secret: @test_secret) + + assert {:error, reason} = JWTVerifier.verify(verifier, "invalid.jwt") + assert reason =~ "invalid JWT format" + end + + test "rejects JWT with invalid base64" do + verifier = JWTVerifier.new(secret: @test_secret) + + # Invalid base64 in header + invalid_token = "invalid-base64!!!.eyJzdWIiOiJ1c2VyMTIzIn0.signature" + + assert {:error, reason} = JWTVerifier.verify(verifier, invalid_token) + assert reason =~ "invalid" + end + + test "rejects JWT with invalid JSON" do + verifier = JWTVerifier.new(secret: @test_secret) + + # Valid base64 but invalid JSON + header_b64 = Base.url_encode64("not-json", padding: false) + payload_b64 = Base.url_encode64("{\"sub\":\"test\"}", padding: false) + token = "#{header_b64}.#{payload_b64}.signature" + + assert {:error, reason} = JWTVerifier.verify(verifier, token) + assert reason =~ "invalid JWT format" + end + + test "rejects JWT with algorithm mismatch" do + verifier = JWTVerifier.new(secret: @test_secret, algorithm: "HS256") + + # Create token with RS256 in header + header = %{"alg" => "RS256", "typ" => "JWT"} + payload = %{"sub" => "user123", "exp" => future_timestamp()} + + header_json = Jason.encode!(header) + payload_json = Jason.encode!(payload) + + header_b64 = Base.url_encode64(header_json, padding: false) + payload_b64 = Base.url_encode64(payload_json, padding: false) + + # Use dummy signature since verification will fail on algorithm check + token = "#{header_b64}.#{payload_b64}.dummy-signature" + + assert {:error, reason} = JWTVerifier.verify(verifier, token) + assert reason =~ "algorithm mismatch" + end + end + + # -- Integration scenarios -------------------------------------------------- + + describe "integration scenarios" do + test "principal authentication flow" do + verifier = + JWTVerifier.new( + secret: @test_secret, + issuer: "https://auth.example.com", + audience: "a2a-api", + required_claims: ["sub", "principal_type"] + ) + + # User principal + user_payload = %{ + "sub" => "alice@example.com", + "principal_type" => "user", + "iss" => "https://auth.example.com", + "aud" => "a2a-api", + "exp" => future_timestamp(), + "iat" => current_timestamp(), + "roles" => ["user", "api-access"], + "permissions" => ["message:send", "task:read"] + } + + user_token = create_jwt_token(user_payload) + + assert {:ok, claims} = JWTVerifier.verify(verifier, user_token) + assert claims["sub"] == "alice@example.com" + assert claims["principal_type"] == "user" + assert claims["roles"] == ["user", "api-access"] + + # Agent principal + agent_payload = %{ + "sub" => "agent-123", + "principal_type" => "agent", + "iss" => "https://auth.example.com", + "aud" => "a2a-api", + "exp" => future_timestamp(), + "iat" => current_timestamp(), + "agent_id" => "agent-123", + "capabilities" => ["message:process", "task:execute"] + } + + agent_token = create_jwt_token(agent_payload) + + assert {:ok, claims} = JWTVerifier.verify(verifier, agent_token) + assert claims["sub"] == "agent-123" + assert claims["principal_type"] == "agent" + assert claims["agent_id"] == "agent-123" + end + + test "default-to-test gap closure" do + # Test mode: simple secret-based validation + test_verifier = + JWTVerifier.new( + secret: "test-secret", + algorithm: "HS256" + ) + + # Production mode: with full validation + prod_verifier = + JWTVerifier.new( + secret: "production-secret", + algorithm: "HS256", + issuer: "https://prod-auth.example.com", + audience: "a2a-production", + required_claims: ["sub", "principal_type", "roles"] + ) + + # Both should work with appropriately configured tokens + test_payload = %{ + "sub" => "test-user", + "exp" => future_timestamp() + } + + prod_payload = %{ + "sub" => "prod-user", + "principal_type" => "user", + "iss" => "https://prod-auth.example.com", + "aud" => "a2a-production", + "exp" => future_timestamp(), + "roles" => ["admin"] + } + + test_token = create_jwt_token(test_payload, "test-secret") + prod_token = create_jwt_token(prod_payload, "production-secret") + + assert {:ok, _} = JWTVerifier.verify(test_verifier, test_token) + assert {:ok, _} = JWTVerifier.verify(prod_verifier, prod_token) + + # Cross-verification should fail + assert {:error, _} = JWTVerifier.verify(test_verifier, prod_token) + assert {:error, _} = JWTVerifier.verify(prod_verifier, test_token) + end + end + + # -- Security: forged/malformed tokens --------------------------------------- + + describe "verify/2 security" do + # Builds a token with an arbitrary header and a raw (possibly empty) signature, + # used to exercise attack vectors that create_jwt_token/2 cannot produce. + defp forge(header, payload, signature) do + header_b64 = Base.url_encode64(Jason.encode!(header), padding: false) + payload_b64 = Base.url_encode64(Jason.encode!(payload), padding: false) + "#{header_b64}.#{payload_b64}.#{signature}" + end + + test "rejects alg:none token (no signature)" do + verifier = JWTVerifier.new(secret: @test_secret) + token = forge(%{"alg" => "none", "typ" => "JWT"}, %{"sub" => "attacker"}, "") + + assert {:error, reason} = JWTVerifier.verify(verifier, token) + assert reason =~ "algorithm mismatch" + end + + test "rejects alg:None case-variant token" do + verifier = JWTVerifier.new(secret: @test_secret) + token = forge(%{"alg" => "None", "typ" => "JWT"}, %{"sub" => "attacker"}, "") + + assert {:error, reason} = JWTVerifier.verify(verifier, token) + assert reason =~ "algorithm mismatch" + end + + test "rejects HS256 token with an empty signature" do + verifier = JWTVerifier.new(secret: @test_secret) + token = forge(%{"alg" => "HS256", "typ" => "JWT"}, %{"sub" => "attacker"}, "") + + assert {:error, "signature verification failed"} = JWTVerifier.verify(verifier, token) + end + + test "rejects token with the signature segment stripped" do + verifier = JWTVerifier.new(secret: @test_secret) + header_b64 = Base.url_encode64(Jason.encode!(%{"alg" => "HS256"}), padding: false) + payload_b64 = Base.url_encode64(Jason.encode!(%{"sub" => "x"}), padding: false) + token = "#{header_b64}.#{payload_b64}" + + assert {:error, "invalid JWT format"} = JWTVerifier.verify(verifier, token) + end + + test "rejects token signed with a different secret" do + verifier = JWTVerifier.new(secret: @test_secret) + token = create_jwt_token(%{"sub" => "admin"}, "attacker-secret") + + assert {:error, "signature verification failed"} = JWTVerifier.verify(verifier, token) + end + end + + # -- Claims: RFC 7519 NumericDate edge cases --------------------------------- + + describe "verify/2 numeric date claims" do + test "accepts a fractional (float) exp per RFC 7519 NumericDate" do + verifier = JWTVerifier.new(secret: @test_secret) + token = create_jwt_token(%{"sub" => "user", "exp" => future_timestamp() + 0.5}) + + assert {:ok, claims} = JWTVerifier.verify(verifier, token) + assert claims["sub"] == "user" + end + + test "rejects a non-numeric exp claim" do + verifier = JWTVerifier.new(secret: @test_secret) + token = create_jwt_token(%{"sub" => "user", "exp" => "#{future_timestamp()}"}) + + assert {:error, "invalid exp claim"} = JWTVerifier.verify(verifier, token) + end + + test "accepts audience supplied as a list containing the expected value" do + verifier = JWTVerifier.new(secret: @test_secret, audience: "a2a-api") + token = create_jwt_token(%{"sub" => "user", "aud" => ["other", "a2a-api"]}) + + assert {:ok, _claims} = JWTVerifier.verify(verifier, token) + end + + test "rejects audience list that does not contain the expected value" do + verifier = JWTVerifier.new(secret: @test_secret, audience: "a2a-api") + token = create_jwt_token(%{"sub" => "user", "aud" => ["x", "y"]}) + + assert {:error, reason} = JWTVerifier.verify(verifier, token) + assert reason =~ "audience mismatch" + end + end +end