From 34f0b0566172b15ea9835e0ada11c4733371423b Mon Sep 17 00:00:00 2001 From: Scott Date: Tue, 22 Feb 2022 07:43:08 -0800 Subject: [PATCH 1/4] Adds support for JWT OAuth flow --- lib/ex_force/oauth.ex | 80 +++++++++++++++++++++++++- mix.exs | 1 + mix.lock | 2 + test/ex_force/oauth_test.exs | 106 +++++++++++++++++++++++++++++++++++ 4 files changed, 188 insertions(+), 1 deletion(-) diff --git a/lib/ex_force/oauth.ex b/lib/ex_force/oauth.ex index 5f473f5..821c814 100644 --- a/lib/ex_force/oauth.ex +++ b/lib/ex_force/oauth.ex @@ -96,12 +96,35 @@ defmodule ExForce.OAuth do refresh_token: "refresh_token" ) ``` + + ### `jwt_token` + + ```elixir + ExForce.OAuth.get_token( + "https://login.salesforce.com", + grant_type: "jwt", + username: "username", + client_id: "client_id", + jwt_key: "jwt_key" + ) + ``` + """ @spec get_token(ExForce.Client.t() | String.t(), list) :: {:ok, OAuthResponse.t()} | {:error, :invalid_signature | term} - def get_token(url, payload) when is_binary(url), do: url |> build_client() |> get_token(payload) + def get_token(url, payload) when is_binary(url) do + url + |> build_client + |> then( + &if Keyword.fetch!(payload, :grant_type) == "jwt" do + get_token_jwt(&1, url, payload) + else + get_token(&1, payload) + end + ) + end def get_token(client, payload) do client_secret = Keyword.fetch!(payload, :client_secret) @@ -146,6 +169,40 @@ defmodule ExForce.OAuth do end end + def get_token_jwt(client, url, payload) do + case Client.request(client, %Request{ + method: :post, + url: "/services/oauth2/token", + body: create_jwt_payload(url, payload) + }) do + {:ok, + %Response{ + status: 200, + body: %{ + "token_type" => token_type, + "instance_url" => instance_url, + "id" => id, + "access_token" => access_token, + "scope" => scope + } + }} -> + {:ok, + %OAuthResponse{ + token_type: token_type, + instance_url: instance_url, + id: id, + access_token: access_token, + scope: scope + }} + + {:ok, %Response{body: body}} -> + {:error, body} + + {:error, _} = other -> + other + end + end + defp verify_signature( %OAuthResponse{id: id, issued_at: issued_at, signature: signature} = resp, client_secret @@ -174,4 +231,25 @@ defmodule ExForce.OAuth do else defp hmac_fun(key, data), do: :crypto.hmac(:sha256, key, data) end + + defp create_jwt_payload(url, payload) do + IO.inspect("in create jwt payload") + key = %{"pem" => Keyword.fetch!(payload, :jwt_key)} + signer = Joken.Signer.create("RS256", key) + + claims = %{ + "iss" => Keyword.fetch!(payload, :client_id), + "aud" => url, + "sub" => Keyword.fetch!(payload, :username), + "iat" => System.os_time(:second), + "exp" => System.os_time(:second) + 180 + } + + {:ok, token, _claims} = Joken.encode_and_sign(claims, signer) + + [ + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion: token + ] + end end diff --git a/mix.exs b/mix.exs index cdd84c8..d9d3e5b 100644 --- a/mix.exs +++ b/mix.exs @@ -34,6 +34,7 @@ defmodule ExForce.Mixfile do [ {:tesla, "~> 1.3"}, {:jason, "~> 1.0"}, + {:joken, "~> 2.4"}, {:bypass, "~> 2.1", only: :test}, {:credo, "~> 1.5", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 1.1", only: :dev, runtime: false}, diff --git a/mix.lock b/mix.lock index d71cd99..fc60a63 100644 --- a/mix.lock +++ b/mix.lock @@ -16,6 +16,8 @@ "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "inch_ex": {:hex, :inch_ex, "2.0.0", "24268a9284a1751f2ceda569cd978e1fa394c977c45c331bb52a405de544f4de", [:mix], [{:bunt, "~> 0.2", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "96d0ec5ecac8cf63142d02f16b7ab7152cf0f0f1a185a80161b758383c9399a8"}, "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, + "joken": {:hex, :joken, "2.4.1", "63a6e47aaf735637879f31babfad93c936d63b8b7d01c5ef44c7f37689e71ab4", [:mix], [{:jose, "~> 1.11.2", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "d4fc7c703112b2dedc4f9ec214856c3a07108c4835f0f174a369521f289c98d1"}, + "jose": {:hex, :jose, "1.11.2", "f4c018ccf4fdce22c71e44d471f15f723cb3efab5d909ab2ba202b5bf35557b3", [:mix, :rebar3], [], "hexpm", "98143fbc48d55f3a18daba82d34fe48959d44538e9697c08f34200fa5f0947d2"}, "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, "makeup_elixir": {:hex, :makeup_elixir, "0.15.1", "b5888c880d17d1cc3e598f05cdb5b5a91b7b17ac4eaf5f297cb697663a1094dd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "db68c173234b07ab2a07f645a5acdc117b9f99d69ebf521821d89690ae6c6ec8"}, "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, diff --git a/test/ex_force/oauth_test.exs b/test/ex_force/oauth_test.exs index 2e9d763..dc8720b 100644 --- a/test/ex_force/oauth_test.exs +++ b/test/ex_force/oauth_test.exs @@ -23,6 +23,16 @@ defmodule ExForce.OAuthTest do conn end + defp assert_form_body_jwt(conn) do + ["application/x-www-form-urlencoded" <> _] = Conn.get_req_header(conn, "content-type") + {:ok, raw, conn} = Conn.read_body(conn) + + assert %{"assertion" => _, "grant_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer"} = + URI.decode_query(raw) + + conn + end + defp to_issued_at(string) do {:ok, issued_at, 0} = DateTime.from_iso8601(string) issued_at @@ -288,6 +298,102 @@ defmodule ExForce.OAuthTest do }} end + test "get_token/2 - jwt - success", %{bypass: bypass, client: client} do + Bypass.expect_once(bypass, "POST", "/services/oauth2/token", fn conn -> + conn + |> assert_form_body_jwt() + |> Conn.put_resp_content_type("application/json") + |> Conn.resp(200, """ + { + "access_token": "access_token_foo", + "id": "https://example.com/id/fakeid", + "instance_url": "https://example.com", + "token_type": "Bearer", + "scope":"full" + } + """) + end) + + assert OAuth.get_token_jwt( + client, + "https://example.com/id/fakeid", + grant_type: "jwt", + client_id: "client_id_foo", + username: "u@example.com", + jwt_key: """ + -----BEGIN RSA PRIVATE KEY----- + MIICWwIBAAKBgQCzr8JYWco0zKTH61qBu+5b9cRp2eBuDwLXYvvNqO6GoJUsNZpl + 9cp7NzNZafM2akyH+88ygr4GmSejzGuCatqMskJZXDYnT8WT47a8RpOXUR96xqQa + Q37a22QVb+97sj8yLBgIkGn3I0bysVXjwlrENCosy2Y0Fck5sOIvxmnjKQIDAQAB + AoGAOrvivNpsvCGAY1DM/scdPLXzA96R+6eweBME1862WQ84c4D5/QYAr5H1mO6G + 72yDo5dtvMb7slBxopr5MWIYGYUbrAMn7jEo3l1GUigpEnBecXkkSTMnk/DwnNW+ + 3gal2rFFIo4CR2c2pCFhfJYfxKspyEdah7qChUSDdjZg9LkCQQDYEnbwuct1e3yk + Dym38gWzGqxfQy5mIDrvYHY5Za1LYy+t6RUTccCLI66iI241mLAlIL4ugZ3miNhQ + CjMC/9OLAkEA1OQIxUHhtRA7pcw+LpuDyRPFHIZMQ0XTnOV2RFUhxenthMIV8HKX + eED1VI+4Tx0zvWkjErzHdI94Z/s1UHAqmwJAbH75Em945okXURn8DM2OZxzhqQQG + 7GkKruB0/OU9Wzl225DKcHUSBcvpCKlZ0bfV2w7R8HBNZVEZrTcx3jOveQJATtu5 + M/hXdw5wSdYCIpmQk2czWIGWtkSjQjbtPBqczAb+6HJMVijcWrsVJSGnkAatJ7hO + OZ6b811BqKKw+P7TiQJASGctccNhTmtm9o81RJWPRayBZHKYEVjnOjYlJM63KWQf + MO5oLcaGvZJrOcmOlsRVPby/liZgX+NJ8m5BFaNZgQ== + -----END RSA PRIVATE KEY----- + """ + ) == + {:ok, + %OAuthResponse{ + access_token: "access_token_foo", + id: "https://example.com/id/fakeid", + instance_url: "https://example.com", + issued_at: nil, + refresh_token: nil, + scope: "full", + signature: nil, + token_type: "Bearer" + }} + end + + test "get_token/2 - jwt - failure", %{bypass: bypass, client: client} do + Bypass.expect_once(bypass, "POST", "/services/oauth2/token", fn conn -> + conn + |> Conn.put_resp_content_type("application/json") + |> Conn.resp(400, """ + { + "error": "invalid_grant", + "error_description": "incorrect assertion" + } + """) + end) + + assert OAuth.get_token_jwt( + client, + "https://example.com/id/fakeid", + grant_type: "jwt", + client_id: "client_id_foo", + username: "u@example.com", + jwt_key: """ + -----BEGIN RSA PRIVATE KEY----- + MIICWwIBAAKBgQCzr8JYWco0zKTH61qBu+5b9cRp2eBuDwLXYvvNqO6GoJUsNZpl + 9cp7NzNZafM2akyH+88ygr4GmSejzGuCatqMskJZXDYnT8WT47a8RpOXUR96xqQa + Q37a22QVb+97sj8yLBgIkGn3I0bysVXjwlrENCosy2Y0Fck5sOIvxmnjKQIDAQAB + AoGAOrvivNpsvCGAY1DM/scdPLXzA96R+6eweBME1862WQ84c4D5/QYAr5H1mO6G + 72yDo5dtvMb7slBxopr5MWIYGYUbrAMn7jEo3l1GUigpEnBecXkkSTMnk/DwnNW+ + 3gal2rFFIo4CR2c2pCFhfJYfxKspyEdah7qChUSDdjZg9LkCQQDYEnbwuct1e3yk + Dym38gWzGqxfQy5mIDrvYHY5Za1LYy+t6RUTccCLI66iI241mLAlIL4ugZ3miNhQ + CjMC/9OLAkEA1OQIxUHhtRA7pcw+LpuDyRPFHIZMQ0XTnOV2RFUhxenthMIV8HKX + eED1VI+4Tx0zvWkjErzHdI94Z/s1UHAqmwJAbH75Em945okXURn8DM2OZxzhqQQG + 7GkKruB0/OU9Wzl225DKcHUSBcvpCKlZ0bfV2w7R8HBNZVEZrTcx3jOveQJATtu5 + M/hXdw5wSdYCIpmQk2czWIGWtkSjQjbtPBqczAb+6HJMVijcWrsVJSGnkAatJ7hO + OZ6b811BqKKw+P7TiQJASGctccNhTmtm9o81RJWPRayBZHKYEVjnOjYlJM63KWQf + MO5oLcaGvZJrOcmOlsRVPby/liZgX+NJ8m5BFaNZgQ== + -----END RSA PRIVATE KEY----- + """ + ) == + {:error, + %{ + "error" => "invalid_grant", + "error_description" => "incorrect assertion" + }} + end + test "get_token/2 with url", %{bypass: bypass} do Bypass.expect_once(bypass, "POST", "/services/oauth2/token", fn conn -> conn From 7e54abc8a8d6660c1352e86b60f80867036f96c6 Mon Sep 17 00:00:00 2001 From: Scott Date: Tue, 22 Feb 2022 08:00:52 -0800 Subject: [PATCH 2/4] Removes inspect statement used for debugging --- lib/ex_force/oauth.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/ex_force/oauth.ex b/lib/ex_force/oauth.ex index 821c814..882612b 100644 --- a/lib/ex_force/oauth.ex +++ b/lib/ex_force/oauth.ex @@ -233,7 +233,6 @@ defmodule ExForce.OAuth do end defp create_jwt_payload(url, payload) do - IO.inspect("in create jwt payload") key = %{"pem" => Keyword.fetch!(payload, :jwt_key)} signer = Joken.Signer.create("RS256", key) From 4675d91c76bede64f3281c33d77041ab77075d07 Mon Sep 17 00:00:00 2001 From: Scott Date: Tue, 22 Feb 2022 08:02:40 -0800 Subject: [PATCH 3/4] Cleans up formatting --- test/ex_force/oauth_test.exs | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/ex_force/oauth_test.exs b/test/ex_force/oauth_test.exs index dc8720b..9fa1830 100644 --- a/test/ex_force/oauth_test.exs +++ b/test/ex_force/oauth_test.exs @@ -26,10 +26,8 @@ defmodule ExForce.OAuthTest do defp assert_form_body_jwt(conn) do ["application/x-www-form-urlencoded" <> _] = Conn.get_req_header(conn, "content-type") {:ok, raw, conn} = Conn.read_body(conn) - assert %{"assertion" => _, "grant_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer"} = URI.decode_query(raw) - conn end From 29d76a66a6a06565184e6a8a3dc1dcf340b80dad Mon Sep 17 00:00:00 2001 From: Scott Date: Fri, 11 Mar 2022 13:18:14 -0800 Subject: [PATCH 4/4] Refactors get_token, verify_signature as suggested. --- lib/ex_force/oauth.ex | 136 ++++++++++++++++------------------- test/ex_force/oauth_test.exs | 10 +-- 2 files changed, 68 insertions(+), 78 deletions(-) diff --git a/lib/ex_force/oauth.ex b/lib/ex_force/oauth.ex index 882612b..30a2aa8 100644 --- a/lib/ex_force/oauth.ex +++ b/lib/ex_force/oauth.ex @@ -114,67 +114,23 @@ defmodule ExForce.OAuth do @spec get_token(ExForce.Client.t() | String.t(), list) :: {:ok, OAuthResponse.t()} | {:error, :invalid_signature | term} - def get_token(url, payload) when is_binary(url) do - url - |> build_client - |> then( - &if Keyword.fetch!(payload, :grant_type) == "jwt" do - get_token_jwt(&1, url, payload) - else - get_token(&1, payload) - end - ) + def get_token(url, payload) when is_binary(url), + do: url |> build_client() |> get_token(payload ++ [url: url]) + + def get_token(client, opts) do + grant_type = Keyword.fetch!(opts, :grant_type) + + client + |> Client.request(%Request{ + method: :post, + url: "/services/oauth2/token", + body: build_payload(grant_type, opts) + }) + |> verify_signature(grant_type, opts) end - def get_token(client, payload) do - client_secret = Keyword.fetch!(payload, :client_secret) - - case Client.request(client, %Request{ - method: :post, - url: "/services/oauth2/token", - body: payload - }) do - {:ok, - %Response{ - status: 200, - body: - map = %{ - "token_type" => token_type, - "instance_url" => instance_url, - "id" => id, - "signature" => signature, - "issued_at" => issued_at, - "access_token" => access_token - } - }} -> - verify_signature( - %OAuthResponse{ - token_type: token_type, - instance_url: instance_url, - id: id, - issued_at: issued_at |> String.to_integer() |> DateTime.from_unix!(:millisecond), - signature: signature, - access_token: access_token, - refresh_token: Map.get(map, "refresh_token"), - scope: Map.get(map, "scope") - }, - client_secret - ) - - {:ok, %Response{body: body}} -> - {:error, body} - - {:error, _} = other -> - other - end - end - - def get_token_jwt(client, url, payload) do - case Client.request(client, %Request{ - method: :post, - url: "/services/oauth2/token", - body: create_jwt_payload(url, payload) - }) do + defp verify_signature(response, "jwt", _) do + case response do {:ok, %Response{ status: 200, @@ -203,24 +159,49 @@ defmodule ExForce.OAuth do end end - defp verify_signature( - %OAuthResponse{id: id, issued_at: issued_at, signature: signature} = resp, - client_secret - ) do - if signature == calculate_signature(id, issued_at, client_secret) do - {:ok, resp} - else - {:error, :invalid_signature} + defp verify_signature(response, _, opts) do + client_secret = Keyword.fetch!(opts, :client_secret) + + case response do + {:ok, + %Response{ + status: 200, + body: + map = %{ + "token_type" => token_type, + "instance_url" => instance_url, + "id" => id, + "signature" => signature, + "issued_at" => issued_at, + "access_token" => access_token + } + }} -> + if signature == calculate_signature(id, issued_at, client_secret) do + {:ok, + %OAuthResponse{ + token_type: token_type, + instance_url: instance_url, + id: id, + issued_at: issued_at |> String.to_integer() |> DateTime.from_unix!(:millisecond), + signature: signature, + access_token: access_token, + refresh_token: Map.get(map, "refresh_token"), + scope: Map.get(map, "scope") + }} + else + {:error, :invalid_signature} + end + + {:ok, %Response{body: body}} -> + {:error, body} + + {:error, _} = other -> + other end end defp calculate_signature(id, issued_at, client_secret) do - issued_at_raw = - issued_at - |> DateTime.to_unix(:millisecond) - |> Integer.to_string() - - hmac_fun(client_secret, id <> issued_at_raw) + hmac_fun(client_secret, id <> issued_at) |> Base.encode64() end @@ -232,7 +213,9 @@ defmodule ExForce.OAuth do defp hmac_fun(key, data), do: :crypto.hmac(:sha256, key, data) end - defp create_jwt_payload(url, payload) do + defp build_payload("jwt", opts) do + {url, payload} = Keyword.pop(opts, :url) + key = %{"pem" => Keyword.fetch!(payload, :jwt_key)} signer = Joken.Signer.create("RS256", key) @@ -251,4 +234,9 @@ defmodule ExForce.OAuth do assertion: token ] end + + defp build_payload(_, opts) do + {_, payload} = Keyword.pop(opts, :url) + payload + end end diff --git a/test/ex_force/oauth_test.exs b/test/ex_force/oauth_test.exs index 9fa1830..8c805fc 100644 --- a/test/ex_force/oauth_test.exs +++ b/test/ex_force/oauth_test.exs @@ -26,8 +26,10 @@ defmodule ExForce.OAuthTest do defp assert_form_body_jwt(conn) do ["application/x-www-form-urlencoded" <> _] = Conn.get_req_header(conn, "content-type") {:ok, raw, conn} = Conn.read_body(conn) + assert %{"assertion" => _, "grant_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer"} = URI.decode_query(raw) + conn end @@ -312,9 +314,9 @@ defmodule ExForce.OAuthTest do """) end) - assert OAuth.get_token_jwt( + assert OAuth.get_token( client, - "https://example.com/id/fakeid", + url: "https://example.com/id/fakeid", grant_type: "jwt", client_id: "client_id_foo", username: "u@example.com", @@ -361,9 +363,9 @@ defmodule ExForce.OAuthTest do """) end) - assert OAuth.get_token_jwt( + assert OAuth.get_token( client, - "https://example.com/id/fakeid", + url: "https://example.com/id/fakeid", grant_type: "jwt", client_id: "client_id_foo", username: "u@example.com",