Skip to content

Commit

Permalink
Merge pull request #29 from mmrobins/auth
Browse files Browse the repository at this point in the history
Add authorization via Session ID
  • Loading branch information
jeffweiss authored Mar 14, 2018
2 parents b5024fd + 72ec41a commit a857395
Show file tree
Hide file tree
Showing 17 changed files with 417 additions and 77 deletions.
3 changes: 2 additions & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ elixir:
notifications:
recipients:
- [email protected]
- [email protected]
otp_release:
- 20.0
env:
- MIX_ENV=test
script:
- "mix do deps.get, compile"
- "mix do deps.get, compile, test"
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,42 @@ Forcex.Client.default_config
|> Forcex.Client.login(%Forcex.Client{endpoint: "https://test.salesforce.com", api_version: "34.0"})
```

## Testing

Make sure dependencies are installed

mix deps.get

Tests can be run with

mix test

Tests can be run automatically when files change with

mix test.watch --stale

Tests mock the api calls to the Salesforce API using Mox to set expectations on
`Forcex.Api.MockHttp.raw_request`. To know what to put in a mock response just
run the client in `iex` and look for the debug logging response from http.ex.
Make sure not to scrub any responses for sensitive data before including them
in a commit.

Example assuming environment variables are in place with login info

% iex -S mix
iex(1)> client = Forcex.Client.login |> Forcex.Client.locate_services
14:40:27.858 file=forcex/lib/forcex/api/http.ex line=19 [debug] Elixir.Forcex.Api.Http.raw_request response=%{access_token: "redacted",...
14:40:28.222 file=forcex/lib/forcex/api/http.ex line=19 [debug] Elixir.Forcex.Api.Http.raw_request response=%{process: "/services/data/v41.0/process", search...
iex(2)> Forcex.query("select Id, Name from Account order by CreatedDate desc", client)
14:43:05.896 file=forcex/lib/forcex/api/http.ex line=19 [debug] Elixir.Forcex.Api.Http.raw_request response=%{done: false, nextRecordsUrl: "/services/data/v4

Just take the data after `response=` and throw it in a Mox expectation. See
existing tests for full examples

response = %{access_token: "redacted"}
Forcex.Api.MockHttp
|> expect(:raw_request, fn(:get, ^expected_url, _, ^auth_header, _) -> response end)


## Current State

Expand Down
9 changes: 7 additions & 2 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
# and its dependencies with the aid of the Mix.Config module.
use Mix.Config

config :forcex, :api, Forcex.Api.Http
config :logger,
:console,
format: "\n$time $metadata[$level] $levelpad$message\n",
metadata: [:file, :line]

# This configuration is loaded before any dependency and is restricted
# to this project. If another project depends on this project, this
# file won't be loaded nor affect the parent project. For this reason,
Expand All @@ -20,5 +26,4 @@ use Mix.Config
# by uncommenting the line below and defining dev.exs, test.exs and such.
# Configuration from the imported file will override the ones defined
# here (which is why it is important to import them last).
#
# import_config "#{Mix.env}.exs"
import_config "#{Mix.env}.exs"
3 changes: 3 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
use Mix.Config

config :forcex, :api, Forcex.Api.MockHttp
62 changes: 10 additions & 52 deletions lib/forcex.ex
Original file line number Diff line number Diff line change
@@ -1,79 +1,43 @@
defmodule Forcex do
use HTTPoison.Base
require Logger
@moduledoc """
The main module for interacting with Salesforce
"""

@user_agent [{"User-agent", "forcex"}]
@accept [{"Accept", "application/json"}]
@accept_encoding [{"Accept-Encoding", "gzip,deflate"}]
require Logger

@type client :: map
@type response :: map | {number, any}
@type method :: :get | :put | :post | :patch | :delete

@spec process_request_headers(list({String.t, String.t})) :: list({String.t, String.t})
def process_request_headers(headers), do: headers ++ @user_agent ++ @accept ++ @accept_encoding

@spec process_headers(list({String.t, String.t})) :: map
def process_headers(headers), do: Map.new(headers)

@spec process_response(HTTPoison.Response.t) :: response
def process_response(%HTTPoison.Response{body: body, headers: %{"Content-Encoding" => "gzip"} = headers } = resp) do
%{resp | body: :zlib.gunzip(body), headers: Map.drop(headers, ["Content-Encoding"])}
|> process_response
end
def process_response(%HTTPoison.Response{body: body, headers: %{"Content-Encoding" => "deflate"} = headers } = resp) do
zstream = :zlib.open
:ok = :zlib.inflateInit(zstream, -15)
uncompressed_data = :zlib.inflate(zstream, body) |> Enum.join
:zlib.inflateEnd(zstream)
:zlib.close(zstream)
%{resp | body: uncompressed_data, headers: Map.drop(headers, ["Content-Encoding"])}
|> process_response
end
def process_response(%HTTPoison.Response{body: body, headers: %{"Content-Type" => "application/json" <> _} = headers} = resp) do
%{resp | body: Poison.decode!(body, keys: :atoms), headers: Map.drop(headers, ["Content-Type"])}
|> process_response
end
def process_response(%HTTPoison.Response{body: body, status_code: 200}), do: body
def process_response(%HTTPoison.Response{body: body, status_code: status}), do: {status, body}

@spec extra_options :: list
defp extra_options() do
Application.get_env(:forcex, :request_options, [])
end
@api Application.get_env(:forcex, :api) || Forcex.Api.Http

@spec json_request(method, String.t, map | String.t, list, list) :: response
def json_request(method, url, body, headers, options) do
raw_request(method, url, Poison.encode!(body), headers, options)
end

@spec raw_request(method, String.t, map | String.t, list, list) :: response
def raw_request(method, url, body, headers, options) do
request!(method, url, body, headers, extra_options() ++ options) |> process_response
@api.raw_request(method, url, Poison.encode!(body), headers, options)
end

@spec post(String.t, map | String.t, client) :: response
def post(path, body \\ "", client) do
url = client.endpoint <> path
json_request(:post, url, body, authorization_header(client), [])
json_request(:post, url, body, client.authorization_header, [])
end

@spec patch(String.t, String.t, client) :: response
def patch(path, body \\ "", client) do
url = client.endpoint <> path
json_request(:patch, url, body, authorization_header(client), [])
json_request(:patch, url, body, client.authorization_header, [])
end

@spec delete(String.t, client) :: response
def delete(path, client) do
url = client.endpoint <> path
raw_request(:delete, url, "", authorization_header(client), [])
@api.raw_request(:delete, url, "", client.authorization_header, [])
end

@spec get(String.t, map | String.t, list, client) :: response
def get(path, body \\ "", headers \\ [], client) do
url = client.endpoint <> path
json_request(:get, url, body, headers ++ authorization_header(client), [])
json_request(:get, url, body, headers ++ client.authorization_header, [])
end

@spec versions(client) :: response
Expand Down Expand Up @@ -149,10 +113,4 @@ defmodule Forcex do
defp service_endpoint(%Forcex.Client{services: services}, service) do
Map.get(services, service)
end

@spec authorization_header(client) :: list
defp authorization_header(%{access_token: nil}), do: []
defp authorization_header(%{access_token: token, token_type: type}) do
[{"Authorization", type <> " " <> token}]
end
end
10 changes: 10 additions & 0 deletions lib/forcex/api.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
defmodule Forcex.Api do
@moduledoc """
Behavior for requests to Salesforce API
"""

@type method :: :get | :put | :post | :patch | :delete
@type response :: map | {number, any}

@callback raw_request(method, String.t, map | String.t, list, list) :: response
end
54 changes: 54 additions & 0 deletions lib/forcex/api/http.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
defmodule Forcex.Api.Http do
@moduledoc """
HTTP communication with Salesforce API
"""

@behaviour Forcex.Api
require Logger
use HTTPoison.Base

@user_agent [{"User-agent", "forcex"}]
@accept [{"Accept", "application/json"}]
@accept_encoding [{"Accept-Encoding", "gzip,deflate"}]

@type method :: :get | :put | :post | :patch | :delete
@type response :: map | {number, any}

def raw_request(method, url, body, headers, options) do
response = method |> request!(url, body, headers, extra_options() ++ options) |> process_response
Logger.debug("#{__ENV__.module}.#{elem(__ENV__.function, 0)} response=" <> inspect(response))
response
end

@spec extra_options :: list
defp extra_options() do
Application.get_env(:forcex, :request_options, [])
end

@spec process_response(HTTPoison.Response.t) :: response
defp process_response(%HTTPoison.Response{body: body, headers: %{"Content-Encoding" => "gzip"} = headers} = resp) do
%{resp | body: :zlib.gunzip(body), headers: Map.drop(headers, ["Content-Encoding"])}
|> process_response
end
defp process_response(%HTTPoison.Response{body: body, headers: %{"Content-Encoding" => "deflate"} = headers} = resp) do
zstream = :zlib.open
:ok = :zlib.inflateInit(zstream, -15)
uncompressed_data = zstream |> :zlib.inflate(body) |> Enum.join
:zlib.inflateEnd(zstream)
:zlib.close(zstream)
%{resp | body: uncompressed_data, headers: Map.drop(headers, ["Content-Encoding"])}
|> process_response
end
defp process_response(%HTTPoison.Response{body: body, headers: %{"Content-Type" => "application/json" <> _} = headers} = resp) do
%{resp | body: Poison.decode!(body, keys: :atoms), headers: Map.drop(headers, ["Content-Type"])}
|> process_response
end
defp process_response(%HTTPoison.Response{body: body, status_code: 200}), do: body
defp process_response(%HTTPoison.Response{body: body, status_code: status}), do: {status, body}

@spec process_request_headers(list({String.t, String.t})) :: list({String.t, String.t})
defp process_request_headers(headers), do: headers ++ @user_agent ++ @accept ++ @accept_encoding

@spec process_headers(list({String.t, String.t})) :: map
defp process_headers(headers), do: Map.new(headers)
end
7 changes: 7 additions & 0 deletions lib/forcex/auth.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
defmodule Forcex.Auth do
@moduledoc """
Auth behavior
"""

@callback login(config :: Map.t(), struct) :: Map.t()
end
46 changes: 46 additions & 0 deletions lib/forcex/auth/oauth.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
defmodule Forcex.Auth.OAuth do
@moduledoc """
Auth via OAuth
"""
require Logger
@behaviour Forcex.Auth

def login(conf, starting_struct) do
login_payload =
conf
|> Map.put(:password, "#{conf.password}#{conf.security_token}")
|> Map.put(:grant_type, "password")

"/services/oauth2/token?#{URI.encode_query(login_payload)}"
|> Forcex.post(starting_struct)
|> handle_login_response
end

defp handle_login_response(%{
access_token: token,
token_type: token_type,
instance_url: endpoint
}) do
%{
authorization_header: authorization_header(token, token_type),
endpoint: endpoint
}
end

defp handle_login_response({status_code, error_message}) do
Logger.warn(
"Cannot log into SFDC API. Please ensure you have Forcex properly configured. Got error code #{
status_code
} and message #{inspect(error_message)}"
)

%{}
end

@spec authorization_header(token :: String.t(), type :: String.t()) :: list
defp authorization_header(nil, _), do: []

defp authorization_header(token, type) do
[{"Authorization", type <> " " <> token}]
end
end
72 changes: 72 additions & 0 deletions lib/forcex/auth/session_id.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
defmodule Forcex.Auth.SessionId do
@moduledoc """
Auth via a session id
"""

require Logger
@behaviour Forcex.Auth
@api Application.get_env(:forcex, :api) || Forcex.Api.Http

def login(conf, starting_struct) do
schema = "http://www.w3.org/2001/XMLSchema"
schema_instance = "http://www.w3.org/2001/XMLSchema-instance"
env = "http://schemas.xmlsoap.org/soap/envelope/"

body = """
<?xml version="1.0" encoding="utf-8" ?>
<env:Envelope xmlns:xsd="#{schema}" xmlns:xsi="#{schema_instance}" xmlns:env="#{env}">
<env:Body>
<n1:login xmlns:n1="urn:partner.soap.sforce.com">
<n1:username>#{conf.username}</n1:username>
<n1:password>#{conf.password}#{conf.security_token}</n1:password>
</n1:login>
</env:Body>
</env:Envelope>
"""

headers = [
{"Content-Type", "text/xml; charset=UTF-8"},
{"SOAPAction", "login"}
]

url = "https://login.salesforce.com/services/Soap/u/#{starting_struct.api_version}"

Logger.debug("api=#{@api}")
@api.raw_request(:post, url, body, headers, [])
|> handle_login_response
end

defp handle_login_response(body) do
{:ok,
{'{http://schemas.xmlsoap.org/soap/envelope/}Envelope', _,
[
{'{http://schemas.xmlsoap.org/soap/envelope/}Body', _,
[
{'{urn:partner.soap.sforce.com}loginResponse', _,
[
{'{urn:partner.soap.sforce.com}result', _, login_parameters}
]}
]}
]}, _} = :erlsom.simple_form(body)

server_url = extract_from_parameters(login_parameters, :serverUrl)
session_id = extract_from_parameters(login_parameters, :sessionId)
host = server_url |> URI.parse() |> Map.get(:host)
endpoint = "https://#{host}/"

%{authorization_header: authorization_header(session_id), endpoint: endpoint}
end

defp extract_from_parameters(params, key) do
compound_key = "{urn:partner.soap.sforce.com}#{key}" |> to_charlist
{^compound_key, _, [value]} = :lists.keyfind(compound_key, 1, params)
value |> to_string
end

@spec authorization_header(session_id :: String.t()) :: list
def authorization_header(nil), do: []

def authorization_header(session_id) do
[{"Authorization", "Bearer #{session_id}"}]
end
end
Loading

0 comments on commit a857395

Please sign in to comment.