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: graham/towerops-web#48
This commit is contained in:
parent
0fa1fa800b
commit
c5481c2cd3
10 changed files with 555 additions and 22 deletions
37
lib/towerops_web/graphql/middleware/organization_scope.ex
Normal file
37
lib/towerops_web/graphql/middleware/organization_scope.ex
Normal file
|
|
@ -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
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
75
lib/towerops_web/plugs/graphql_auth.ex
Normal file
75
lib/towerops_web/plugs/graphql_auth.ex
Normal file
|
|
@ -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 <token>:
|
||||
- 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
137
test/towerops_web/plugs/graphql_auth_test.exs
Normal file
137
test/towerops_web/plugs/graphql_auth_test.exs
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue