Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,32 @@ config :tailwind,
cd: Path.expand("../assets", __DIR__)
]

config :boruta, Boruta.Oauth,
repo: Store.Repo,
cache_backend: Boruta.Cache,
contexts: [
access_tokens: Boruta.Ecto.AccessTokens,
clients: Boruta.Ecto.Clients,
codes: Boruta.Ecto.Codes,
# mandatory for user flows
resource_owners: Store.Oauth.ResourceOwners,
scopes: Boruta.Ecto.Scopes
],
max_ttl: [
authorization_code: 60,
access_token: 60 * 60 * 24,
refresh_token: 60 * 60 * 24 * 30
],
token_generator: Boruta.TokenGenerator

config :boruta, Boruta.Cache,
primary: [
# => 1 day
gc_interval: :timer.hours(6),
backend: :shards,
partitions: 2
]

# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"
85 changes: 85 additions & 0 deletions lib/store/api.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
defmodule Store.Api do
@moduledoc """
"""
alias Absinthe.Relay.Connection
alias Store.Repo

defmodule Access do
@moduledoc """
Common struct for ensuring API access
"""
defstruct role: :user,
user: nil,
access_type: nil,
access_identifier: nil,
scopes: %{
public: false,
email: false,
password: false,
}
end

@error_not_found "Could not find resource"
@error_access_denied "Access denied"

def error_not_found, do: {:error, @error_not_found}

def error_access_denied, do: {:error, @error_access_denied}

@doc """
Return a Connection.from_query with a count (if last is specified).
"""
def connection_from_query_with_count(query, args, options \\ []) do
options =
if Map.has_key?(args, :last) do
# Only count records if we're starting at the end
Keyword.put(options, :count, Repo.aggregate(query, :count, :id))
else
options
end

# Set a complete max of 1000 records
options = Keyword.put(options, :max, 1000)

# If the user didn't select an order, just give them the first 100
args =
if Map.has_key?(args, :first) == false and Map.has_key?(args, :last) == false do
Map.put(args, :first, 100)
else
args
end

Connection.from_query(query, &Repo.replica().all/1, args, options)
end

@doc """
Parse a list of changeset errors into actionable API errors
"""
def parse_ecto_changeset_errors(%Ecto.Changeset{} = changeset) do
changeset
|> Ecto.Changeset.traverse_errors(fn {msg, opts} ->
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end)
|> Enum.map(fn {key, value} -> "#{key}: #{value}" end)
end

@doc """
If a potentially_local_path is a locally uploaded Waffle file (for example a default chat background),
this function will create a full static URL for us to return to the API. If the potentially_local_path is
instead a full URL, we'll just return it.
"""
def resolve_full_url(potentially_local_path) when is_binary(potentially_local_path) do
if String.starts_with?(potentially_local_path, ["http://", "https://"]) do
potentially_local_path
else
StoreWeb.Router.Helpers.static_url(
StoreWeb.Endpoint,
potentially_local_path
)
end
end

def resolve_full_url(_), do: nil
end
2 changes: 2 additions & 0 deletions lib/store/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ defmodule Store.Application do
Supervisor.start_link(children, opts)
end

StoreWeb.ApiLogger.start_logger()

# Tell Phoenix to update the endpoint configuration
# whenever the application is updated.
@impl true
Expand Down
70 changes: 70 additions & 0 deletions lib/store/oauth.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
defmodule Store.Oauth do
@moduledoc """
Helper functions for resolving oauth actions
"""

require Logger

#
# API Access Resolution
#
def get_api_access_from_token(%Boruta.Oauth.Token{} = token) do
resolve_resource_owner(token.resource_owner, token)
end

def get_unprivileged_api_access_from_client(%Boruta.Oauth.Client{} = client) do
{:ok,
%Store.Api.Access{
role: :user,
user: nil,
access_type: "app",
access_identifier: client.id
}}
end

defp resolve_resource_owner(
%Boruta.Oauth.ResourceOwner{} = resource_owner,
%Boruta.Oauth.Token{} = token
) do
case Store.Oauth.ResourceOwners.get_from(resource_owner) do
%Store.Accounts.User{} = user ->
access_for_user(user, token.scope)

_ ->
Logger.error("Unexpected resource owner for token: #{token.value}")
{:error, "Unexpected resource owner for token: #{token.value}"}
end
end

defp resolve_resource_owner(_, %Boruta.Oauth.Token{} = token) do
owner = Store.Apps.get_app_owner_by_client_id!(token.client.id)

access_for_user(owner, token.scope)
end

def access_for_user(%Store.Accounts.User{} = user, scope) do
{:ok,
Map.merge(
%Store.Api.Access{
role: :admin,
user: user,
access_type: "user",
access_identifier: user.username
},
parse_scope(scope)
)}
end

defp parse_scope(scope) when is_binary(scope) do
%{
scopes:
Enum.reduce(String.split(scope), %{}, fn x, acc ->
Map.put(acc, String.to_atom(x), true)
end)
}
end

defp parse_scope(_) do
%{}
end
end
17 changes: 17 additions & 0 deletions lib/store/oauth/oauth_errors.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
defmodule Store.OauthHandler.OauthError do
@moduledoc false

def add_error({:error, params}, _), do: {:error, params}

def add_error({:ok, params}, {:error, error, status}) do
{:error, Map.merge(params, %{error: error, error_status: status})}
end

def not_accessable do
{:error, %{error: :not_accessable}, :not_accessable}
end

def invalid_ownership do
{:error, %{error: :invalid_ownership}, :invalid_ownership}
end
end
51 changes: 51 additions & 0 deletions lib/store/oauth/resource_owners.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
defmodule Store.Oauth.ResourceOwners do
@moduledoc false

@behaviour Boruta.Oauth.ResourceOwners
alias Boruta.Oauth.ResourceOwner
alias Store.Accounts.User
alias Store.Repo

@impl Boruta.Oauth.ResourceOwners
def get_by(email: email) do
case Repo.replica().get_by(User, email: email) do
%User{id: id, email: email} ->
{:ok, %ResourceOwner{sub: Integer.to_string(id)}}

_ ->
{:error, "User not found."}
end
end

def get_by(sub: sub) do
case Repo.replica().get_by(User, id: String.to_integer(sub)) do
%User{id: id} ->
{:ok, %ResourceOwner{sub: Integer.to_string(id)}}

_ ->
{:error, "User not found."}
end
end

def get_from(%Boruta.Oauth.ResourceOwner{} = resource_owner) do
case resource_owner do
%{email: email} -> Repo.replica().get_by(User, email: email)
%{sub: sub} -> Repo.replica().get_by(User, id: String.to_integer(sub))
_ -> nil
end
end

@impl Boruta.Oauth.ResourceOwners
def check_password(resource_owner, password) do
user = Store.Accounts.get_by_email(resource_owner.email)

if User.valid_password?(user, password) do
:ok
else
{:error, "Invalid password or email."}
end
end

@impl Boruta.Oauth.ResourceOwners
def authorized_scopes(%ResourceOwner{}), do: []
end
13 changes: 13 additions & 0 deletions lib/store/oauth/scopes.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
defmodule Store.Oauth.Scopes do
@moduledoc false

import StoreWeb.Gettext

def scope_gettext(scope) do
case scope do
"public" -> gettext("scopepublic")
"email" -> gettext("scopeemail")
"password" -> gettext("password")
end
end
end
34 changes: 34 additions & 0 deletions lib/store_web/api_logger.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
defmodule StoreWeb.ApiLogger do
@moduledoc """
Store API Logger
"""
require Logger

def start_logger do
:telemetry.attach(
"absinthe-query-logger",
[:absinthe, :execute, :operation, :start],
&StoreWeb.ApiLogger.log_query/4,
nil
)
end

def log_query(_event, _time, %{blueprint: %{input: raw_query}, options: options}, _)
when is_binary(raw_query) do
case options do
%{context: context} ->
access =
Keyword.get(context, :access, %{
access_type: "Unknown",
access_identifier: "Unknown"
})

Logger.info(
"[Absinthe Operation] Access Type: #{access.access_type} Access Identifier: #{access.access_identifier} Query: #{inspect(raw_query)} "
)

_ ->
nil
end
end
end
65 changes: 65 additions & 0 deletions lib/store_web/channels/api_socket.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
defmodule StoreWeb.GraphApiSocket do
@moduledoc """
Allow for connections to the API socket with either an API token or a client id.

Client ID is for read API access only.
"""
use Phoenix.Socket

use Absinthe.Phoenix.Socket,
schema: Store.Api.Schema

@impl true
def connect(%{"client_id" => client_id}, socket, _connect_info) do
with %Boruta.Oauth.Client{} = client <- Boruta.Ecto.Clients.get_client(client_id),
{:ok, %Store.Api.Access{} = access} <-
Store.Oauth.get_unprivileged_api_access_from_client(client) do
{:ok,
socket
|> assign(:user_id, nil)
|> assign(:socket_id, client.id)
|> Absinthe.Phoenix.Socket.put_options(
context: %{
access: access
}
)}
else
_ -> :error
end
end

@impl true
def connect(%{"token" => access_token}, socket, _connect_info) do
with {:ok, %Boruta.Oauth.Token{} = token} <-
Boruta.Oauth.Authorization.AccessToken.authorize(value: access_token),
{:ok, %Store.Api.Access{} = access} <-
Store.Oauth.get_api_access_from_token(token) do
{:ok,
socket
|> assign(:user_id, access.user.id)
|> assign(:socket_id, token.value)
|> Absinthe.Phoenix.Socket.put_options(
context: %{
access: access
}
)}
else
_ -> :error
end
end

def connect(_, _, _) do
:error
end

# Socket id's are topics that allow you to identify all sockets for a given user:
#
# def id(socket), do: "graph_api_socket:#{socket.assigns.user_id}"
#
# Would allow you to broadcast a "disconnect" event and terminate
# all active sockets and channels for a given user:
#
# Returning `nil` makes this socket anonymous.
@impl true
def id(socket), do: "graph_api_socket:#{socket.assigns.socket_id}"
end
9 changes: 9 additions & 0 deletions lib/store_web/endpoint.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ defmodule StoreWeb.Endpoint do

socket("/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]])



socket "/api/graph", StoreWeb.GraphApiSocket,
# We can check_origin: false here because the only method of using this connection
# is by having an existing API key you are authorized to use. This allows for devs
# to run third party apps on their own client websites.
websocket: [check_origin: false],
longpoll: false

# Serve at "/" the static files from "priv/static" directory.
#
# You should set gzip to true if you are running phx.digest
Expand Down
Loading