From c5481c2cd36ab3a6491c68bc160c59c0992479de Mon Sep 17 00:00:00 2001 From: graham Date: Mon, 16 Mar 2026 15:23:07 -0500 Subject: [PATCH] add mobile token auth to GraphQL endpoint (#48) Extend the GraphQL API and socket to accept mobile session tokens alongside existing API tokens. Add OrganizationScope middleware that extracts organization_id from query args for mobile users. Add my_organizations query for mobile app org listing. Reviewed-on: https://git.mcintire.me/graham/towerops-web/pulls/48 --- .../graphql/middleware/organization_scope.ex | 37 +++++ .../graphql/resolvers/organization.ex | 20 +++ lib/towerops_web/graphql/schema.ex | 113 +++++++++++++-- .../graphql/types/organization.ex | 5 + lib/towerops_web/graphql_socket.ex | 35 ++++- lib/towerops_web/plugs/graphql_auth.ex | 75 ++++++++++ lib/towerops_web/router.ex | 9 +- .../middleware/organization_scope_test.exs | 72 +++++++++ test/towerops_web/graphql_socket_test.exs | 74 +++++++++- test/towerops_web/plugs/graphql_auth_test.exs | 137 ++++++++++++++++++ 10 files changed, 555 insertions(+), 22 deletions(-) create mode 100644 lib/towerops_web/graphql/middleware/organization_scope.ex create mode 100644 lib/towerops_web/plugs/graphql_auth.ex create mode 100644 test/towerops_web/graphql/middleware/organization_scope_test.exs create mode 100644 test/towerops_web/plugs/graphql_auth_test.exs diff --git a/lib/towerops_web/graphql/middleware/organization_scope.ex b/lib/towerops_web/graphql/middleware/organization_scope.ex new file mode 100644 index 00000000..f18bf843 --- /dev/null +++ b/lib/towerops_web/graphql/middleware/organization_scope.ex @@ -0,0 +1,37 @@ +defmodule ToweropsWeb.GraphQL.Middleware.OrganizationScope do + @moduledoc """ + Absinthe middleware that ensures organization_id is present in context. + + For API token users: organization_id is already in context — passes through. + For mobile users: extracts organization_id from args, validates access, sets in context. + """ + + @behaviour Absinthe.Middleware + + alias Towerops.Organizations + + @impl true + def call(%{context: %{organization_id: _org_id}} = resolution, _config) do + resolution + end + + def call(%{context: %{user: user}, arguments: %{organization_id: org_id}} = resolution, _config) + when is_binary(org_id) do + if Organizations.user_has_access?(user.id, org_id) do + %{resolution | context: Map.put(resolution.context, :organization_id, org_id)} + else + Absinthe.Resolution.put_result(resolution, {:error, "Access denied to this organization"}) + end + end + + def call(%{context: %{user: _user}} = resolution, _config) do + Absinthe.Resolution.put_result( + resolution, + {:error, "organization_id argument is required for mobile authentication"} + ) + end + + def call(resolution, _config) do + Absinthe.Resolution.put_result(resolution, {:error, "Authentication required"}) + end +end diff --git a/lib/towerops_web/graphql/resolvers/organization.ex b/lib/towerops_web/graphql/resolvers/organization.ex index 8119b2fc..048c7046 100644 --- a/lib/towerops_web/graphql/resolvers/organization.ex +++ b/lib/towerops_web/graphql/resolvers/organization.ex @@ -1,9 +1,29 @@ defmodule ToweropsWeb.GraphQL.Resolvers.Organization do @moduledoc "GraphQL resolvers for organization queries and mutations." + alias Towerops.Alerts + alias Towerops.Devices alias Towerops.Organizations + alias Towerops.Sites alias ToweropsWeb.GraphQL.Resolvers.Helpers + def list_mine(_parent, _args, %{context: %{user: user}}) do + orgs = + user.id + |> Organizations.list_user_organizations() + |> Enum.map(fn org -> + Map.merge(org, %{ + sites_count: Sites.count_organization_sites(org.id), + device_count: Devices.count_organization_devices(org.id), + active_alerts_count: Alerts.count_active_alerts(org.id) + }) + end) + + {:ok, orgs} + end + + def list_mine(_parent, _args, _resolution), do: Helpers.authentication_error() + def get(_parent, _args, %{context: %{organization_id: org_id}}) do org = Organizations.get_organization!(org_id) {:ok, org} diff --git a/lib/towerops_web/graphql/schema.ex b/lib/towerops_web/graphql/schema.ex index 5178fc18..6219ba8f 100644 --- a/lib/towerops_web/graphql/schema.ex +++ b/lib/towerops_web/graphql/schema.ex @@ -4,9 +4,14 @@ defmodule ToweropsWeb.GraphQL.Schema do Provides a complete GraphQL interface for managing devices, sites, alerts, agents, organization settings, members, integrations, and activity feeds. + + Queries that require an organization scope accept an optional `organization_id` + argument for mobile authentication. API token users can omit it (org comes from token). """ use Absinthe.Schema + alias ToweropsWeb.GraphQL.Middleware.OrganizationScope + import_types(ToweropsWeb.GraphQL.Types.Common) import_types(ToweropsWeb.GraphQL.Types.Device) import_types(ToweropsWeb.GraphQL.Types.Site) @@ -22,145 +27,200 @@ defmodule ToweropsWeb.GraphQL.Schema do import_types(ToweropsWeb.GraphQL.Types.TimeSeries) query do + # User's organizations (no org scope needed — for mobile org picker) + field :my_organizations, list_of(:organization) do + resolve(&ToweropsWeb.GraphQL.Resolvers.Organization.list_mine/3) + end + # Devices field :devices, list_of(:device) do + arg(:organization_id, :id) arg(:site_id, :id) arg(:status, :string) arg(:limit, :integer, default_value: 100) + middleware(OrganizationScope) resolve(&ToweropsWeb.GraphQL.Resolvers.Device.list/3) end field :device, :device do + arg(:organization_id, :id) arg(:id, non_null(:id)) + middleware(OrganizationScope) resolve(&ToweropsWeb.GraphQL.Resolvers.Device.get/3) end # Sites field :sites, list_of(:site) do + arg(:organization_id, :id) + middleware(OrganizationScope) resolve(&ToweropsWeb.GraphQL.Resolvers.Site.list/3) end field :site, :site do + arg(:organization_id, :id) arg(:id, non_null(:id)) + middleware(OrganizationScope) resolve(&ToweropsWeb.GraphQL.Resolvers.Site.get/3) end # Alerts field :alerts, list_of(:alert) do + arg(:organization_id, :id) arg(:status, :string) arg(:device_id, :id) arg(:limit, :integer, default_value: 100) + middleware(OrganizationScope) resolve(&ToweropsWeb.GraphQL.Resolvers.Alert.list/3) end field :alert, :alert do + arg(:organization_id, :id) arg(:id, non_null(:id)) + middleware(OrganizationScope) resolve(&ToweropsWeb.GraphQL.Resolvers.Alert.get/3) end # Agents field :agents, list_of(:agent) do + arg(:organization_id, :id) + middleware(OrganizationScope) resolve(&ToweropsWeb.GraphQL.Resolvers.Agent.list/3) end field :agent, :agent do + arg(:organization_id, :id) arg(:id, non_null(:id)) + middleware(OrganizationScope) resolve(&ToweropsWeb.GraphQL.Resolvers.Agent.get/3) end # Organization field :organization, :organization do + arg(:organization_id, :id) + middleware(OrganizationScope) resolve(&ToweropsWeb.GraphQL.Resolvers.Organization.get/3) end # Members field :members, list_of(:member) do + arg(:organization_id, :id) + middleware(OrganizationScope) resolve(&ToweropsWeb.GraphQL.Resolvers.Member.list/3) end # Integrations field :integrations, list_of(:integration) do + arg(:organization_id, :id) + middleware(OrganizationScope) resolve(&ToweropsWeb.GraphQL.Resolvers.Integration.list/3) end # Activity feed field :activity, list_of(:activity_item) do + arg(:organization_id, :id) arg(:limit, :integer, default_value: 50) arg(:type, :string) + middleware(OrganizationScope) resolve(&ToweropsWeb.GraphQL.Resolvers.Activity.list/3) end # Device metrics field :device_metrics, list_of(:metric_point) do + arg(:organization_id, :id) arg(:device_id, non_null(:id)) arg(:sensor_type, :string) arg(:time_range, :string, default_value: "24h") + middleware(OrganizationScope) resolve(&ToweropsWeb.GraphQL.Resolvers.Device.metrics/3) end # Device interfaces field :device_interfaces, list_of(:interface) do + arg(:organization_id, :id) arg(:device_id, non_null(:id)) + middleware(OrganizationScope) resolve(&ToweropsWeb.GraphQL.Resolvers.Device.interfaces/3) end # Time-series queries field :device_sensors, list_of(:sensor) do + arg(:organization_id, :id) arg(:device_id, non_null(:id)) + middleware(OrganizationScope) resolve(&ToweropsWeb.GraphQL.Resolvers.TimeSeries.device_sensors/3) end field :sensor_readings, list_of(:sensor_reading) do + arg(:organization_id, :id) arg(:sensor_id, non_null(:id)) arg(:time_range, :string, default_value: "24h") + middleware(OrganizationScope) resolve(&ToweropsWeb.GraphQL.Resolvers.TimeSeries.sensor_readings/3) end field :interface_traffic, list_of(:interface_traffic_point) do + arg(:organization_id, :id) arg(:interface_id, non_null(:id)) arg(:time_range, :string, default_value: "24h") + middleware(OrganizationScope) resolve(&ToweropsWeb.GraphQL.Resolvers.TimeSeries.interface_traffic/3) end field :check_results, list_of(:check_result_point) do + arg(:organization_id, :id) arg(:check_id, non_null(:id)) arg(:time_range, :string, default_value: "1h") + middleware(OrganizationScope) resolve(&ToweropsWeb.GraphQL.Resolvers.TimeSeries.check_results/3) end # Schedules field :schedules, list_of(:schedule) do + arg(:organization_id, :id) + middleware(OrganizationScope) resolve(&ToweropsWeb.GraphQL.Resolvers.Schedule.list/3) end field :schedule, :schedule do + arg(:organization_id, :id) arg(:id, non_null(:id)) + middleware(OrganizationScope) resolve(&ToweropsWeb.GraphQL.Resolvers.Schedule.get/3) end field :on_call, :on_call_result do + arg(:organization_id, :id) arg(:schedule_id, non_null(:id)) + middleware(OrganizationScope) resolve(&ToweropsWeb.GraphQL.Resolvers.Schedule.on_call/3) end # Escalation Policies field :escalation_policies, list_of(:escalation_policy) do + arg(:organization_id, :id) + middleware(OrganizationScope) resolve(&ToweropsWeb.GraphQL.Resolvers.EscalationPolicy.list/3) end field :escalation_policy, :escalation_policy do + arg(:organization_id, :id) arg(:id, non_null(:id)) + middleware(OrganizationScope) resolve(&ToweropsWeb.GraphQL.Resolvers.EscalationPolicy.get/3) end # Maintenance Windows field :maintenance_windows, list_of(:maintenance_window) do + arg(:organization_id, :id) arg(:filter, :string) + middleware(OrganizationScope) resolve(&ToweropsWeb.GraphQL.Resolvers.MaintenanceWindow.list/3) end field :maintenance_window, :maintenance_window do + arg(:organization_id, :id) arg(:id, non_null(:id)) + middleware(OrganizationScope) resolve(&ToweropsWeb.GraphQL.Resolvers.MaintenanceWindow.get/3) end end @@ -400,30 +460,63 @@ defmodule ToweropsWeb.GraphQL.Schema do subscription do field :device_status_changed, :device_status_event do + arg(:organization_id, :id) arg(:device_id, :id) - config(fn args, %{context: %{organization_id: org_id}} -> - topic = - if args[:device_id], - do: "gql:device_status:#{org_id}:#{args.device_id}", - else: "gql:device_status:#{org_id}" + config(fn args, resolution -> + org_id = resolve_subscription_org_id(args, resolution) - {:ok, topic: topic} + if org_id do + topic = + if args[:device_id], + do: "gql:device_status:#{org_id}:#{args.device_id}", + else: "gql:device_status:#{org_id}" + + {:ok, topic: topic} + else + {:error, "organization_id is required"} + end end) end field :alert_event, :alert_event do - config(fn _args, %{context: %{organization_id: org_id}} -> - {:ok, topic: "gql:alerts:#{org_id}"} + arg(:organization_id, :id) + + config(fn args, resolution -> + org_id = resolve_subscription_org_id(args, resolution) + + if org_id do + {:ok, topic: "gql:alerts:#{org_id}"} + else + {:error, "organization_id is required"} + end end) end field :sensor_readings_updated, :sensor_readings_event do + arg(:organization_id, :id) arg(:device_id, non_null(:id)) - config(fn %{device_id: device_id}, %{context: %{organization_id: org_id}} -> - {:ok, topic: "gql:sensor_readings:#{org_id}:#{device_id}"} + config(fn args, resolution -> + org_id = resolve_subscription_org_id(args, resolution) + + if org_id do + {:ok, topic: "gql:sensor_readings:#{org_id}:#{args.device_id}"} + else + {:error, "organization_id is required"} + end end) end end + + # Resolves org_id from context (API token) or args (mobile token) + defp resolve_subscription_org_id(args, %{context: %{organization_id: org_id}}) do + args[:organization_id] || org_id + end + + defp resolve_subscription_org_id(%{organization_id: org_id}, %{context: %{user: user}}) when is_binary(org_id) do + if Towerops.Organizations.user_has_access?(user.id, org_id), do: org_id + end + + defp resolve_subscription_org_id(_args, _resolution), do: nil end diff --git a/lib/towerops_web/graphql/types/organization.ex b/lib/towerops_web/graphql/types/organization.ex index 29eed548..bfa49146 100644 --- a/lib/towerops_web/graphql/types/organization.ex +++ b/lib/towerops_web/graphql/types/organization.ex @@ -31,6 +31,11 @@ defmodule ToweropsWeb.GraphQL.Types.Organization do field :default_agent_token_id, :id field :inserted_at, :string field :updated_at, :string + + # Computed fields (populated by my_organizations resolver) + field :sites_count, :integer + field :device_count, :integer + field :active_alerts_count, :integer end input_object :organization_input do diff --git a/lib/towerops_web/graphql_socket.ex b/lib/towerops_web/graphql_socket.ex index af979b1b..2167b477 100644 --- a/lib/towerops_web/graphql_socket.ex +++ b/lib/towerops_web/graphql_socket.ex @@ -2,21 +2,28 @@ defmodule ToweropsWeb.GraphQLSocket do @moduledoc """ WebSocket endpoint for GraphQL subscriptions. - Authenticates via API token passed as a connection parameter. + Authenticates via either: + - API token (prefixed with "towerops_") → sets organization_id + user in context + - Mobile session token → sets user only (no organization_id) """ use Phoenix.Socket use Absinthe.Phoenix.Socket, schema: ToweropsWeb.GraphQL.Schema + alias Absinthe.Phoenix.Socket + alias Towerops.Accounts + alias Towerops.ApiTokens + alias Towerops.MobileSessions + @impl true - def connect(%{"token" => token}, socket, _connect_info) when is_binary(token) do - case Towerops.ApiTokens.verify_token(token) do + def connect(%{"token" => "towerops_" <> _ = token}, socket, _connect_info) do + case ApiTokens.verify_token(token) do {:ok, org_id, user} -> socket = socket |> assign(:organization_id, org_id) |> assign(:user, user) - |> Absinthe.Phoenix.Socket.put_options(context: %{organization_id: org_id, user: user}) + |> Socket.put_options(context: %{organization_id: org_id, user: user}) {:ok, socket} @@ -25,8 +32,26 @@ defmodule ToweropsWeb.GraphQLSocket do end end + def connect(%{"token" => token}, socket, _connect_info) when is_binary(token) do + with session when not is_nil(session) <- MobileSessions.get_session_by_token(token), + user when not is_nil(user) <- Accounts.get_user(session.user_id) do + _ = Task.start(fn -> MobileSessions.touch_session(session) end) + + socket = + socket + |> assign(:user, user) + |> Socket.put_options(context: %{user: user}) + + {:ok, socket} + else + _ -> :error + end + end + def connect(_params, _socket, _connect_info), do: :error @impl true - def id(socket), do: "graphql_socket:#{socket.assigns.organization_id}" + def id(%{assigns: %{organization_id: org_id}}), do: "graphql_socket:#{org_id}" + def id(%{assigns: %{user: user}}), do: "graphql_socket:user:#{user.id}" + def id(_socket), do: nil end diff --git a/lib/towerops_web/plugs/graphql_auth.ex b/lib/towerops_web/plugs/graphql_auth.ex new file mode 100644 index 00000000..df707506 --- /dev/null +++ b/lib/towerops_web/plugs/graphql_auth.ex @@ -0,0 +1,75 @@ +defmodule ToweropsWeb.Plugs.GraphQLAuth do + @moduledoc """ + Plug for authenticating GraphQL requests using either API tokens or mobile session tokens. + + Accepts two token types via Authorization: Bearer : + - API tokens (prefixed with "towerops_") → sets current_organization_id + current_user + - Mobile session tokens → sets current_user only (no org_id) + + Returns 401 Unauthorized if neither token type is valid. + """ + + import Phoenix.Controller, only: [json: 2] + import Plug.Conn + + alias Towerops.Accounts + alias Towerops.ApiTokens + alias Towerops.MobileSessions + + def init(opts), do: opts + + def call(conn, _opts) do + case get_req_header(conn, "authorization") do + ["Bearer " <> token] -> + authenticate(conn, token) + + _ -> + unauthorized(conn, "Missing or invalid Authorization header") + end + end + + defp authenticate(conn, "towerops_" <> _ = token) do + case ApiTokens.verify_token(token) do + {:ok, organization_id, user} -> + conn + |> assign(:current_organization_id, organization_id) + |> assign(:current_user, user) + + {:error, :invalid_token} -> + unauthorized(conn, "Invalid or expired API token") + end + end + + defp authenticate(conn, token) do + with {:ok, session} <- validate_mobile_session(token), + {:ok, user} <- get_user(session.user_id) do + _ = Task.start(fn -> MobileSessions.touch_session(session) end) + + assign(conn, :current_user, user) + else + {:error, _reason} -> + unauthorized(conn, "Invalid or expired authentication token") + end + end + + defp validate_mobile_session(token) do + case MobileSessions.get_session_by_token(token) do + nil -> {:error, :invalid_token} + session -> {:ok, session} + end + end + + defp get_user(user_id) do + case Accounts.get_user(user_id) do + nil -> {:error, :user_not_found} + user -> {:ok, user} + end + end + + defp unauthorized(conn, message) do + conn + |> put_status(:unauthorized) + |> json(%{error: message}) + |> halt() + end +end diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index bd2ab88c..c1cb4f6b 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -65,6 +65,11 @@ defmodule ToweropsWeb.Router do plug RateLimit, type: :api end + pipeline :graphql_api do + plug :accepts, ["json"] + plug ToweropsWeb.Plugs.GraphQLAuth + end + pipeline :graphql_context do plug ToweropsWeb.Plugs.GraphQLIntrospection plug ToweropsWeb.GraphQL.Context @@ -133,9 +138,9 @@ defmodule ToweropsWeb.Router do post "/webhooks/stripe", StripeWebhookController, :create end - # GraphQL API endpoint (requires API token authentication) + # GraphQL API endpoint (accepts API tokens or mobile session tokens) scope "/api/graphql" do - pipe_through [:api_v1, :rate_limit_api, :graphql_context] + pipe_through [:graphql_api, :rate_limit_api, :graphql_context] forward "/", Absinthe.Plug, schema: ToweropsWeb.GraphQL.Schema, diff --git a/test/towerops_web/graphql/middleware/organization_scope_test.exs b/test/towerops_web/graphql/middleware/organization_scope_test.exs new file mode 100644 index 00000000..b720f27b --- /dev/null +++ b/test/towerops_web/graphql/middleware/organization_scope_test.exs @@ -0,0 +1,72 @@ +defmodule ToweropsWeb.GraphQL.Middleware.OrganizationScopeTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias ToweropsWeb.GraphQL.Middleware.OrganizationScope + + describe "call/2" do + test "passes through when context already has organization_id" do + org_id = Ecto.UUID.generate() + + resolution = %Absinthe.Resolution{ + context: %{organization_id: org_id, user: %{id: Ecto.UUID.generate()}}, + arguments: %{} + } + + result = OrganizationScope.call(resolution, []) + assert result.context.organization_id == org_id + assert result.errors == [] + end + + test "promotes organization_id from args for mobile user with access" do + user = user_fixture() + organization = organization_fixture(user.id) + + resolution = %Absinthe.Resolution{ + context: %{user: user}, + arguments: %{organization_id: organization.id} + } + + result = OrganizationScope.call(resolution, []) + assert result.context.organization_id == organization.id + assert result.errors == [] + end + + test "errors when mobile user lacks access to organization" do + user = user_fixture() + other_org_id = Ecto.UUID.generate() + + resolution = %Absinthe.Resolution{ + context: %{user: user}, + arguments: %{organization_id: other_org_id} + } + + result = OrganizationScope.call(resolution, []) + assert result.errors != [] + end + + test "errors when mobile user omits organization_id" do + user = user_fixture() + + resolution = %Absinthe.Resolution{ + context: %{user: user}, + arguments: %{} + } + + result = OrganizationScope.call(resolution, []) + assert result.errors != [] + end + + test "errors when no authentication at all" do + resolution = %Absinthe.Resolution{ + context: %{}, + arguments: %{} + } + + result = OrganizationScope.call(resolution, []) + assert result.errors != [] + end + end +end diff --git a/test/towerops_web/graphql_socket_test.exs b/test/towerops_web/graphql_socket_test.exs index fafb7568..e6f0a3c7 100644 --- a/test/towerops_web/graphql_socket_test.exs +++ b/test/towerops_web/graphql_socket_test.exs @@ -5,9 +5,10 @@ defmodule ToweropsWeb.GraphQLSocketTest do import Towerops.OrganizationsFixtures alias Towerops.ApiTokens + alias Towerops.MobileSessions alias ToweropsWeb.GraphQLSocket - describe "connect/3" do + describe "connect/3 with API token" do setup do user = user_fixture() organization = organization_fixture(user.id) @@ -35,15 +36,61 @@ defmodule ToweropsWeb.GraphQLSocketTest do assert socket.assigns.organization_id == organization.id end - test "rejects invalid token" do + test "rejects invalid API token" do socket = %Phoenix.Socket{ assigns: %{}, transport: :websocket } - assert :error = GraphQLSocket.connect(%{"token" => "invalid_token"}, socket, %{}) + assert :error = GraphQLSocket.connect(%{"token" => "towerops_invalid_xxx"}, socket, %{}) + end + end + + describe "connect/3 with mobile session token" do + setup do + user = user_fixture() + + {:ok, session} = + MobileSessions.create_mobile_session(%{ + user_id: user.id, + device_name: "Test iPhone" + }) + + %{user: user, session: session} end + test "connects with valid mobile token", %{user: user, session: session} do + socket = %Phoenix.Socket{ + assigns: %{}, + transport: :websocket + } + + assert {:ok, socket} = GraphQLSocket.connect(%{"token" => session.raw_token}, socket, %{}) + assert socket.assigns.user.id == user.id + refute Map.has_key?(socket.assigns, :organization_id) + end + + test "rejects expired mobile token" do + user = user_fixture() + expires_at = DateTime.add(DateTime.utc_now(), -1, :day) + + {:ok, session} = + MobileSessions.create_mobile_session(%{ + user_id: user.id, + device_name: "Expired iPhone", + expires_at: expires_at + }) + + socket = %Phoenix.Socket{ + assigns: %{}, + transport: :websocket + } + + assert :error = GraphQLSocket.connect(%{"token" => session.raw_token}, socket, %{}) + end + end + + describe "connect/3 with invalid params" do test "rejects missing token" do socket = %Phoenix.Socket{ assigns: %{}, @@ -52,15 +99,32 @@ defmodule ToweropsWeb.GraphQLSocketTest do assert :error = GraphQLSocket.connect(%{}, socket, %{}) end + + test "rejects completely invalid token" do + socket = %Phoenix.Socket{ + assigns: %{}, + transport: :websocket + } + + assert :error = GraphQLSocket.connect(%{"token" => "totally_bogus"}, socket, %{}) + end end describe "id/1" do - test "returns socket ID based on organization_id" do + test "returns socket ID based on organization_id for API token connections" do socket = %Phoenix.Socket{ - assigns: %{organization_id: "some-org-id"} + assigns: %{organization_id: "some-org-id", user: %{id: "some-user-id"}} } assert GraphQLSocket.id(socket) == "graphql_socket:some-org-id" end + + test "returns socket ID based on user_id for mobile connections" do + socket = %Phoenix.Socket{ + assigns: %{user: %{id: "some-user-id"}} + } + + assert GraphQLSocket.id(socket) == "graphql_socket:user:some-user-id" + end end end diff --git a/test/towerops_web/plugs/graphql_auth_test.exs b/test/towerops_web/plugs/graphql_auth_test.exs new file mode 100644 index 00000000..d5115ae8 --- /dev/null +++ b/test/towerops_web/plugs/graphql_auth_test.exs @@ -0,0 +1,137 @@ +defmodule ToweropsWeb.Plugs.GraphQLAuthTest do + use ToweropsWeb.ConnCase, async: true + + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.ApiTokens + alias Towerops.MobileSessions + alias ToweropsWeb.Plugs.GraphQLAuth + + describe "init/1" do + test "returns options unchanged" do + assert GraphQLAuth.init([]) == [] + end + end + + describe "call/2 with API token (towerops_ prefix)" do + setup do + user = user_fixture() + organization = organization_fixture(user.id) + + {:ok, {_token, raw_token}} = + ApiTokens.create_api_token(%{ + organization_id: organization.id, + user_id: user.id, + name: "GraphQL Auth Test Token" + }) + + %{user: user, organization: organization, raw_token: raw_token} + end + + test "authenticates and sets org_id + user", %{ + conn: conn, + raw_token: raw_token, + organization: organization, + user: user + } do + conn = + conn + |> put_req_header("authorization", "Bearer #{raw_token}") + |> GraphQLAuth.call([]) + + refute conn.halted + assert conn.assigns.current_organization_id == organization.id + assert conn.assigns.current_user.id == user.id + end + + test "returns 401 for expired API token", %{conn: conn, organization: organization, user: user} do + expires_at = DateTime.add(DateTime.utc_now(), -1, :day) + + {:ok, {_token, raw_token}} = + ApiTokens.create_api_token(%{ + organization_id: organization.id, + user_id: user.id, + name: "Expired Token", + expires_at: expires_at + }) + + conn = + conn + |> put_req_header("authorization", "Bearer #{raw_token}") + |> GraphQLAuth.call([]) + + assert conn.halted + assert conn.status == 401 + end + end + + describe "call/2 with mobile session token" do + setup do + user = user_fixture() + + {:ok, session} = + MobileSessions.create_mobile_session(%{ + user_id: user.id, + device_name: "Test iPhone" + }) + + %{user: user, session: session} + end + + test "authenticates and sets user but not org_id", %{ + conn: conn, + user: user, + session: session + } do + conn = + conn + |> put_req_header("authorization", "Bearer #{session.raw_token}") + |> GraphQLAuth.call([]) + + refute conn.halted + assert conn.assigns.current_user.id == user.id + refute Map.has_key?(conn.assigns, :current_organization_id) + end + + test "returns 401 for expired mobile session", %{conn: conn} do + user = user_fixture() + expires_at = DateTime.add(DateTime.utc_now(), -1, :day) + + {:ok, session} = + MobileSessions.create_mobile_session(%{ + user_id: user.id, + device_name: "Expired iPhone", + expires_at: expires_at + }) + + conn = + conn + |> put_req_header("authorization", "Bearer #{session.raw_token}") + |> GraphQLAuth.call([]) + + assert conn.halted + assert conn.status == 401 + end + end + + describe "call/2 with missing or invalid auth" do + test "returns 401 when Authorization header is missing", %{conn: conn} do + conn = GraphQLAuth.call(conn, []) + + assert conn.halted + assert conn.status == 401 + assert json_response(conn, 401) == %{"error" => "Missing or invalid Authorization header"} + end + + test "returns 401 when token is completely invalid", %{conn: conn} do + conn = + conn + |> put_req_header("authorization", "Bearer totally_invalid_token_xyz") + |> GraphQLAuth.call([]) + + assert conn.halted + assert conn.status == 401 + end + end +end