defmodule MicrowavepropWeb.Api.Auth do @moduledoc """ Bearer-token authentication for `/api/v1`. Looks up `Authorization: Bearer ` headers, resolves them via `Microwaveprop.Accounts.get_user_by_api_token/1`, and stashes the user + token on `conn.assigns`. Two plugs: * `optional_auth/2` — never halts, sets `:current_api_user` when a valid token is present and falls through unauthenticated otherwise. Use for endpoints with a public read fallback. * `require_auth/2` — halts with `401 Unauthorized` problem+json when no valid token is present. Use for any endpoint that mutates state or returns user-private data. """ @behaviour Plug import Plug.Conn alias Microwaveprop.Accounts alias MicrowavepropWeb.Api.ErrorJSON @impl true def init(opts), do: Keyword.put_new(opts, :mode, :require) @impl true def call(conn, opts) do case Keyword.fetch!(opts, :mode) do :require -> require_auth(conn, opts) :optional -> optional_auth(conn, opts) end end @doc "Plug that halts with 401 when no valid bearer token is supplied." @spec require_auth(Plug.Conn.t(), keyword()) :: Plug.Conn.t() def require_auth(conn, _opts \\ []) do case authenticate(conn) do {:ok, conn} -> conn {:error, reason} -> conn |> put_resp_header("www-authenticate", ~s(Bearer realm="api")) |> ErrorJSON.send_problem(401, "unauthorized", reason_message(reason)) end end @doc "Plug that lets unauthenticated requests through but assigns the user when a token is valid." @spec optional_auth(Plug.Conn.t(), keyword()) :: Plug.Conn.t() def optional_auth(conn, _opts \\ []) do case authenticate(conn) do {:ok, conn} -> conn {:error, :missing_token} -> assign_anonymous(conn) {:error, reason} -> halt_with_invalid(conn, reason) end end defp authenticate(conn) do with {:ok, plaintext} <- extract_bearer(conn), {:ok, user, token} <- Accounts.get_user_by_api_token(plaintext) do {:ok, conn |> assign(:current_api_user, user) |> assign(:current_api_token, token)} end end @doc false @spec extract_bearer(Plug.Conn.t()) :: {:ok, String.t()} | {:error, :missing_token | :invalid_authorization_header} def extract_bearer(conn) do case get_req_header(conn, "authorization") do ["Bearer " <> token] when byte_size(token) > 0 -> {:ok, token} ["bearer " <> token] when byte_size(token) > 0 -> {:ok, token} [] -> {:error, :missing_token} _ -> {:error, :invalid_authorization_header} end end defp assign_anonymous(conn) do conn |> assign(:current_api_user, nil) |> assign(:current_api_token, nil) end defp halt_with_invalid(conn, reason) do conn |> put_resp_header("www-authenticate", ~s(Bearer realm="api")) |> ErrorJSON.send_problem(401, "unauthorized", reason_message(reason)) end defp reason_message(:missing_token), do: "Missing bearer token in Authorization header." defp reason_message(:invalid_authorization_header), do: "Authorization header must be `Bearer `." defp reason_message(:invalid_token), do: "Bearer token is invalid, expired, or revoked." end