defmodule MastercardSimulator.AuthPlug do @moduledoc """ Authentication middleware for the MPGS simulator. Supports two authentication schemes: 1. HTTP Basic Auth — legacy support, credentials compared in constant time. 2. OAuth 1.0a with RSA-SHA256 — matches exactly what the device middlelayer's OAuthClient sends. Verification steps: a. Parse the Authorization: OAuth ... header into key/value pairs b. Validate oauth_consumer_key against configured value (constant-time) c. Verify oauth_body_hash against the cached raw request body d. Reconstruct the signature base string using the same algorithm as OAuthClient.build_signature_base_string/3 in the device middlelayer e. Verify the RSA-SHA256 signature using the configured public key PEM Paths in @public_paths bypass authentication entirely. """ import Plug.Conn require Logger @behaviour Plug @public_paths ["/health"] @impl Plug def init(opts), do: opts @impl Plug def call(%{request_path: path} = conn, _opts) when path in @public_paths, do: conn def call(conn, _opts) do case get_req_header(conn, "authorization") do ["Basic " <> encoded] -> validate_basic(conn, encoded) ["OAuth " <> oauth_str] -> validate_oauth(conn, oauth_str) _ -> unauthorized(conn) end end # ── HTTP Basic Auth ─────────────────────────────────────────────────────────── defp validate_basic(conn, encoded) do with {:ok, decoded} <- Base.decode64(encoded), [username, password | _] <- String.split(decoded, ":", parts: 2) do expected_user = Application.get_env(:mastercard_simulator, :api_username, "merchant.TEST_MERCHANT") expected_pass = Application.get_env(:mastercard_simulator, :api_password, "test_password_123") user_ok = Plug.Crypto.secure_compare(username, expected_user) pass_ok = Plug.Crypto.secure_compare(password, expected_pass) if user_ok and pass_ok do conn else Logger.warning("[AuthPlug] Failed Basic auth attempt for user: #{username}") unauthorized(conn) end else _ -> unauthorized(conn) end end # ── OAuth 1.0a ──────────────────────────────────────────────────────────────── defp validate_oauth(conn, oauth_str) do with {:ok, oauth_params} <- parse_oauth_header(oauth_str), :ok <- check_consumer_key(oauth_params["oauth_consumer_key"]), :ok <- check_body_hash(conn, oauth_params["oauth_body_hash"]), {:ok, base_string} <- build_signature_base_string(conn, oauth_params), {:ok, signature_binary} <- decode_signature(oauth_params["oauth_signature"]), {:ok, public_key} <- load_public_key(), :ok <- verify_rsa_signature(base_string, signature_binary, public_key) do conn else {:error, reason} -> Logger.warning("[AuthPlug] OAuth validation failed: #{inspect(reason)}") unauthorized(conn) end end # Parse "key=\"percent-encoded-value\", ..." into a plain string→string map. # Values in the Authorization header are percent-encoded by OAuthClient.build_authorization_header/2, # so each value is decoded here to restore the original string before verification. defp parse_oauth_header(oauth_str) do pairs = Regex.scan(~r/(\w+)="([^"]*)"/, oauth_str) if pairs == [] do {:error, :invalid_oauth_header_format} else params = Map.new(pairs, fn [_, key, value] -> {key, URI.decode(value)} end) {:ok, params} end end # Validate consumer key using constant-time comparison to prevent timing attacks. defp check_consumer_key(nil), do: {:error, :missing_consumer_key} defp check_consumer_key(key) do expected = Application.get_env(:mastercard_simulator, :oauth_consumer_key, "") if Plug.Crypto.secure_compare(key, expected) do :ok else {:error, :invalid_consumer_key} end end # Verify oauth_body_hash = Base64(SHA256(raw_request_body)). # The raw body was cached in conn.private[:raw_body] by BodyReader before Plug.Parsers ran. # For requests with no body (e.g. GET) the cache is nil and we treat it as "". defp check_body_hash(_conn, nil), do: {:error, :missing_body_hash} defp check_body_hash(conn, expected_hash) do raw_body = conn.private[:raw_body] || "" computed = raw_body |> :crypto.hash(:sha256) |> Base.encode64() if Plug.Crypto.secure_compare(computed, expected_hash) do :ok else {:error, :body_hash_mismatch} end end # Reconstruct the OAuth 1.0a signature base string, mirroring # OAuthClient.build_signature_base_string/3 in the device middlelayer exactly: # # METHOD & percent_encode(normalized_base_url) & percent_encode(sorted_normalized_params) # # where: # - normalized_base_url strips default ports (80 for http, 443 for https) # - sorted_normalized_params = all OAuth params EXCEPT oauth_signature, merged with # any URL query params, with both key and value individually percent-encoded per # RFC 3986, sorted lexicographically by key, joined as "key=value&key=value" defp build_signature_base_string(conn, oauth_params) do scheme = Atom.to_string(conn.scheme) host = conn.host port = conn.port path = conn.request_path query = conn.query_string normalized_port = normalize_port(scheme, port) base_url = if normalized_port do "#{scheme}://#{host}:#{normalized_port}#{path}" else "#{scheme}://#{host}#{path}" end # Remove oauth_signature — it must NOT be included in the base string signing_params = Map.delete(oauth_params, "oauth_signature") # Merge URL query parameters (same as OAuthClient.parse_query_params/1) query_params = if query && query != "" do URI.decode_query(query) else %{} end normalized = signing_params |> Map.merge(query_params) |> normalize_parameters() base_string = [String.upcase(conn.method), percent_encode(base_url), percent_encode(normalized)] |> Enum.join("&") {:ok, base_string} end # Mirror OAuthClient.normalize_parameters/1 exactly: # percent-encode both key and value, sort by key, join as "k=v&k=v" defp normalize_parameters(params) do params |> Enum.map(fn {k, v} -> {percent_encode(k), percent_encode(v)} end) |> Enum.sort_by(fn {k, _} -> k end) |> Enum.map(fn {k, v} -> "#{k}=#{v}" end) |> Enum.join("&") end # Mirror OAuthClient.percent_encode/1 — RFC 3986 unreserved characters only. defp percent_encode(str) when is_binary(str) do URI.encode(str, &unreserved_char?/1) end # RFC 3986 unreserved set: ALPHA / DIGIT / "-" / "." / "_" / "~" defp unreserved_char?(c) do (c >= ?A and c <= ?Z) or (c >= ?a and c <= ?z) or (c >= ?0 and c <= ?9) or c in [?-, ?., ?_, ?~] end # Mirror OAuthClient.normalize_port/2 — strip default ports from the base URL. defp normalize_port("http", 80), do: nil defp normalize_port("https", 443), do: nil defp normalize_port(_scheme, port), do: port # Base64-decode the oauth_signature value (which was decoded from percent-encoding # by parse_oauth_header, restoring the original Base64 string). defp decode_signature(nil), do: {:error, :missing_oauth_signature} defp decode_signature(sig) do case Base.decode64(sig) do {:ok, binary} -> {:ok, binary} :error -> {:error, :invalid_signature_base64} end end # Load the RSA public key PEM from the configured file. # The result is cached in :persistent_term on first load so the file is only # read once per VM lifetime. defp load_public_key do case :persistent_term.get({__MODULE__, :public_key}, :not_loaded) do :not_loaded -> key_file = Application.get_env( :mastercard_simulator, :oauth_public_key_file, "priv/credentials/mpgs_sandbox_public_key.pem" ) with {:ok, pem_data} <- read_key_file(key_file), {:ok, public_key} <- decode_public_key_pem(pem_data) do :persistent_term.put({__MODULE__, :public_key}, public_key) {:ok, public_key} end public_key -> {:ok, public_key} end end defp read_key_file(path) do case File.read(path) do {:ok, data} -> {:ok, data} {:error, reason} -> {:error, {:public_key_file_unreadable, path, reason}} end end defp decode_public_key_pem(pem_data) do case :public_key.pem_decode(pem_data) do [entry | _] -> {:ok, :public_key.pem_entry_decode(entry)} [] -> {:error, :no_public_key_entry_in_pem} end end # Verify RSA-SHA256 signature. # :public_key.verify/4 returns true on success, false on mismatch. defp verify_rsa_signature(base_string, signature_binary, public_key) do if :public_key.verify(base_string, :sha256, signature_binary, public_key) do :ok else {:error, :rsa_signature_mismatch} end end # ── 401 response ───────────────────────────────────────────────────────────── defp unauthorized(conn) do body = Jason.encode!(%{ "error" => %{ "cause" => "INVALID_CREDENTIALS", "explanation" => "Invalid API credentials. Provide OAuth 1.0a RSA-SHA256 or HTTP Basic Auth credentials." }, "result" => "ERROR", "version" => "77" }) conn |> put_resp_header("content-type", "application/json") |> put_resp_header("www-authenticate", ~s(OAuth realm="Mastercard Gateway Simulator")) |> send_resp(401, body) |> halt() end end