1defmodule OAuth2.Client do
2  @moduledoc ~S"""
3  This module defines the `OAuth2.Client` struct and is responsible for building
4  and establishing a request for an access token.
5
6  ### Notes
7
8  * If a full url is given (e.g. "http://www.example.com/api/resource") then it
9  will use that otherwise you can specify an endpoint (e.g. "/api/resource") and
10  it will append it to the `Client.site`.
11
12  * The headers from the `Client.headers` are appended to the request headers.
13
14  ### Examples
15
16      client = OAuth2.Client.new(token: "abc123")
17
18      case OAuth2.Client.get(client, "/some/resource") do
19        {:ok, %OAuth2.Response{body: body}} ->
20          "Yay!!"
21        {:error, %OAuth2.Response{body: body}} ->
22          "Something bad happen: #{inspect body}"
23        {:error, %OAuth2.Error{reason: reason}} ->
24          reason
25      end
26
27      response = OAuth2.Client.get!(client, "/some/resource")
28
29      response = OAuth2.Client.post!(client, "/some/other/resources", %{foo: "bar"})
30  """
31
32  alias OAuth2.{AccessToken, Client, Error, Request, Response}
33
34  @type authorize_url :: binary
35  @type body :: any
36  @type client_id :: binary
37  @type client_secret :: binary
38  @type headers :: [{binary, binary}]
39  @type param :: binary | %{binary => param} | [param]
40  @type params :: %{binary => param} | Keyword.t()
41  @type redirect_uri :: binary
42  @type ref :: reference | nil
43  @type request_opts :: Keyword.t()
44  @type serializers :: %{binary => module}
45  @type site :: binary
46  @type strategy :: module
47  @type token :: AccessToken.t() | nil
48  @type token_method :: :post | :get | atom
49  @type token_url :: binary
50
51  @type t :: %Client{
52          authorize_url: authorize_url,
53          client_id: client_id,
54          client_secret: client_secret,
55          headers: headers,
56          params: params,
57          redirect_uri: redirect_uri,
58          ref: ref,
59          request_opts: request_opts,
60          serializers: serializers,
61          site: site,
62          strategy: strategy,
63          token: token,
64          token_method: token_method,
65          token_url: token_url
66        }
67
68  defstruct authorize_url: "/oauth/authorize",
69            client_id: "",
70            client_secret: "",
71            headers: [],
72            params: %{},
73            redirect_uri: "",
74            ref: nil,
75            request_opts: [],
76            serializers: %{},
77            site: "",
78            strategy: OAuth2.Strategy.AuthCode,
79            token: nil,
80            token_method: :post,
81            token_url: "/oauth/token"
82
83  @doc """
84  Builds a new `OAuth2.Client` struct using the `opts` provided.
85
86  ## Client struct fields
87
88  * `authorize_url` - absolute or relative URL path to the authorization
89    endpoint. Defaults to `"/oauth/authorize"`
90  * `client_id` - the client_id for the OAuth2 provider
91  * `client_secret` - the client_secret for the OAuth2 provider
92  * `headers` - a list of request headers
93  * `params` - a map of request parameters
94  * `redirect_uri` - the URI the provider should redirect to after authorization
95     or token requests
96  * `request_opts` - a keyword list of request options that will be sent to the
97    `hackney` client. See the [hackney documentation] for a list of available
98    options.
99  * `site` - the OAuth2 provider site host
100  * `strategy` - a module that implements the appropriate OAuth2 strategy,
101    default `OAuth2.Strategy.AuthCode`
102  * `token` - `%OAuth2.AccessToken{}` struct holding the token for requests.
103  * `token_method` - HTTP method to use to request token (`:get` or `:post`).
104    Defaults to `:post`
105  * `token_url` - absolute or relative URL path to the token endpoint.
106    Defaults to `"/oauth/token"`
107
108  ## Example
109
110      iex> OAuth2.Client.new(token: "123")
111      %OAuth2.Client{authorize_url: "/oauth/authorize", client_id: "",
112      client_secret: "", headers: [], params: %{}, redirect_uri: "", site: "",
113      strategy: OAuth2.Strategy.AuthCode,
114      token: %OAuth2.AccessToken{access_token: "123", expires_at: nil,
115      other_params: %{}, refresh_token: nil, token_type: "Bearer"},
116      token_method: :post, token_url: "/oauth/token"}
117
118      iex> token = OAuth2.AccessToken.new("123")
119      iex> OAuth2.Client.new(token: token)
120      %OAuth2.Client{authorize_url: "/oauth/authorize", client_id: "",
121      client_secret: "", headers: [], params: %{}, redirect_uri: "", site: "",
122      strategy: OAuth2.Strategy.AuthCode,
123      token: %OAuth2.AccessToken{access_token: "123", expires_at: nil,
124      other_params: %{}, refresh_token: nil, token_type: "Bearer"},
125      token_method: :post, token_url: "/oauth/token"}
126
127  [hackney documentation]: https://github.com/benoitc/hackney/blob/master/doc/hackney.md#request5
128  """
129  @spec new(t, Keyword.t()) :: t
130  def new(client \\ %Client{}, opts) do
131    {token, opts} = Keyword.pop(opts, :token)
132    {req_opts, opts} = Keyword.pop(opts, :request_opts, [])
133
134    opts =
135      opts
136      |> Keyword.put(:token, process_token(token))
137      |> Keyword.put(:request_opts, Keyword.merge(client.request_opts, req_opts))
138
139    struct(client, opts)
140  end
141
142  defp process_token(nil), do: nil
143  defp process_token(val) when is_binary(val), do: AccessToken.new(val)
144  defp process_token(%AccessToken{} = token), do: token
145
146  @doc """
147  Puts the specified `value` in the params for the given `key`.
148
149  The key can be a `string` or an `atom`. Atoms are automatically
150  convert to strings.
151  """
152  @spec put_param(t, String.t() | atom, any) :: t
153  def put_param(%Client{params: params} = client, key, value) do
154    %{client | params: Map.put(params, "#{key}", value)}
155  end
156
157  @doc """
158  Set multiple params in the client in one call.
159  """
160  @spec merge_params(t, params) :: t
161  def merge_params(client, params) do
162    params =
163      Enum.reduce(params, %{}, fn {k, v}, acc ->
164        Map.put(acc, "#{k}", v)
165      end)
166
167    %{client | params: Map.merge(client.params, params)}
168  end
169
170  @doc """
171  Adds a new header `key` if not present, otherwise replaces the
172  previous value of that header with `value`.
173  """
174  @spec put_header(t, binary, binary) :: t
175  def put_header(%Client{headers: headers} = client, key, value)
176      when is_binary(key) and is_binary(value) do
177    key = String.downcase(key)
178    %{client | headers: List.keystore(headers, key, 0, {key, value})}
179  end
180
181  @doc """
182  Set multiple headers in the client in one call.
183  """
184  @spec put_headers(t, list) :: t
185  def put_headers(%Client{} = client, []), do: client
186
187  def put_headers(%Client{} = client, [{k, v} | rest]) do
188    client
189    |> put_header(k, v)
190    |> put_headers(rest)
191  end
192
193  @doc false
194  @spec authorize_url(t, list) :: {t, binary}
195  def authorize_url(%Client{} = client, params \\ []) do
196    client.strategy.authorize_url(client, params) |> to_url(:authorize_url)
197  end
198
199  @doc """
200  Returns the authorize url based on the client configuration.
201
202  ## Example
203
204      iex> OAuth2.Client.authorize_url!(%OAuth2.Client{})
205      "/oauth/authorize?client_id=&redirect_uri=&response_type=code"
206  """
207  @spec authorize_url!(t, list) :: binary
208  def authorize_url!(%Client{} = client, params \\ []) do
209    {_, url} = authorize_url(client, params)
210    url
211  end
212
213  @doc """
214  Register a serialization module for a given mime type.
215
216  ## Example
217
218      iex> client = OAuth2.Client.put_serializer(%OAuth2.Client{}, "application/json", Jason)
219      %OAuth2.Client{serializers: %{"application/json" => Jason}}
220      iex> OAuth2.Client.get_serializer(client, "application/json")
221      Jason
222  """
223  @spec put_serializer(t, binary, atom) :: t
224  def put_serializer(%Client{serializers: serializers} = client, mime, module)
225      when is_binary(mime) and is_atom(module) do
226    %Client{client | serializers: Map.put(serializers, mime, module)}
227  end
228
229  @doc """
230  Un-register a serialization module for a given mime type.
231
232  ## Example
233
234      iex> client = OAuth2.Client.delete_serializer(%OAuth2.Client{}, "application/json")
235      %OAuth2.Client{}
236      iex> OAuth2.Client.get_serializer(client, "application/json")
237      nil
238  """
239  @spec delete_serializer(t, binary) :: t
240  def delete_serializer(%Client{serializers: serializers} = client, mime) do
241    %Client{client | serializers: Map.delete(serializers, mime)}
242  end
243
244  @doc false
245  @spec get_serializer(t, binary) :: atom
246  def get_serializer(%Client{serializers: serializers}, mime) do
247    Map.get(serializers, mime)
248  end
249
250  @doc """
251  Fetches an `OAuth2.AccessToken` struct by making a request to the token endpoint.
252
253  Returns the `OAuth2.Client` struct loaded with the access token which can then
254  be used to make authenticated requests to an OAuth2 provider's API.
255
256  ## Arguments
257
258  * `client` - a `OAuth2.Client` struct with the strategy to use, defaults to
259    `OAuth2.Strategy.AuthCode`
260  * `params` - a keyword list of request parameters which will be encoded into
261    a query string or request body dependening on the selected strategy
262  * `headers` - a list of request headers
263  * `opts` - a Keyword list of request options which will be merged with
264    `OAuth2.Client.request_opts`
265
266  ## Options
267
268  * `:recv_timeout` - the timeout (in milliseconds) of the request
269  * `:proxy` - a proxy to be used for the request; it can be a regular url or a
270    `{host, proxy}` tuple
271  """
272  @spec get_token(t, params, headers, Keyword.t()) ::
273          {:ok, Client.t()} | {:error, Response.t()} | {:error, Error.t()}
274  def get_token(%{token_method: method} = client, params \\ [], headers \\ [], opts \\ []) do
275    {client, url} = token_url(client, params, headers)
276
277    case Request.request(method, client, url, client.params, client.headers, opts) do
278      {:ok, response} ->
279        token = AccessToken.new(response.body)
280        {:ok, %{client | headers: [], params: %{}, token: token}}
281
282      {:error, error} ->
283        {:error, error}
284    end
285  end
286
287  @doc """
288  Same as `get_token/4` but raises `OAuth2.Error` if an error occurs during the
289  request.
290  """
291  @spec get_token!(t, params, headers, Keyword.t()) :: Client.t() | Error.t()
292  def get_token!(client, params \\ [], headers \\ [], opts \\ []) do
293    case get_token(client, params, headers, opts) do
294      {:ok, client} ->
295        client
296
297      {:error, %Response{status_code: code, headers: headers, body: body}} ->
298        raise %Error{
299          reason: """
300          Server responded with status: #{code}
301
302          Headers:
303
304          #{Enum.reduce(headers, "", fn {k, v}, acc -> acc <> "#{k}: #{v}\n" end)}
305          Body:
306
307          #{inspect(body)}
308          """
309        }
310
311      {:error, error} ->
312        raise error
313    end
314  end
315
316  @doc """
317  Refreshes an existing access token using a refresh token.
318  """
319  @spec refresh_token(t, params, headers, Keyword.t()) ::
320          {:ok, Client.t()} | {:error, Response.t()} | {:error, Error.t()}
321  def refresh_token(token, params \\ [], headers \\ [], opts \\ [])
322
323  def refresh_token(%Client{token: %{refresh_token: nil}}, _params, _headers, _opts) do
324    {:error, %Error{reason: "Refresh token not available."}}
325  end
326
327  def refresh_token(
328        %Client{token: %{refresh_token: refresh_token}} = client,
329        params,
330        headers,
331        opts
332      ) do
333    refresh_client =
334      %{client | strategy: OAuth2.Strategy.Refresh, token: nil}
335      |> Client.put_param(:refresh_token, refresh_token)
336
337    case Client.get_token(refresh_client, params, headers, opts) do
338      {:ok, %Client{} = client} ->
339        if client.token.refresh_token do
340          {:ok, client}
341        else
342          {:ok, put_in(client.token.refresh_token, refresh_token)}
343        end
344
345      {:error, error} ->
346        {:error, error}
347    end
348  end
349
350  @doc """
351  Calls `refresh_token/4` but raises `Error` if there an error occurs.
352  """
353  @spec refresh_token!(t, params, headers, Keyword.t()) :: Client.t() | Error.t()
354  def refresh_token!(%Client{} = client, params \\ [], headers \\ [], opts \\ []) do
355    case refresh_token(client, params, headers, opts) do
356      {:ok, %Client{} = client} -> client
357      {:error, error} -> raise error
358    end
359  end
360
361  @doc """
362  Adds `authorization` header for basic auth.
363  """
364  @spec basic_auth(t) :: t
365  def basic_auth(%OAuth2.Client{client_id: id, client_secret: secret} = client) do
366    put_header(client, "authorization", "Basic " <> Base.encode64(id <> ":" <> secret))
367  end
368
369  @doc """
370  Makes a `GET` request to the given `url` using the `OAuth2.AccessToken`
371  struct.
372  """
373  @spec get(t, binary, headers, Keyword.t()) ::
374          {:ok, Response.t()} | {:error, Response.t()} | {:error, Error.t()}
375  def get(%Client{} = client, url, headers \\ [], opts \\ []),
376    do: Request.request(:get, client, url, "", headers, opts)
377
378  @doc """
379  Same as `get/4` but returns a `OAuth2.Response` or `OAuth2.Error` exception if
380  the request results in an error.
381  """
382  @spec get!(t, binary, headers, Keyword.t()) :: Response.t() | Error.t()
383  def get!(%Client{} = client, url, headers \\ [], opts \\ []),
384    do: Request.request!(:get, client, url, "", headers, opts)
385
386  @doc """
387  Makes a `PUT` request to the given `url` using the `OAuth2.AccessToken`
388  struct.
389  """
390  @spec put(t, binary, body, headers, Keyword.t()) ::
391          {:ok, Response.t()} | {:error, Response.t()} | {:error, Error.t()}
392  def put(%Client{} = client, url, body \\ "", headers \\ [], opts \\ []),
393    do: Request.request(:put, client, url, body, headers, opts)
394
395  @doc """
396  Same as `put/5` but returns a `OAuth2.Response` or `OAuth2.Error` exception if
397  the request results in an error.
398
399  An `OAuth2.Error` exception is raised if the request results in an
400  error tuple (`{:error, reason}`).
401  """
402  @spec put!(t, binary, body, headers, Keyword.t()) :: Response.t() | Error.t()
403  def put!(%Client{} = client, url, body \\ "", headers \\ [], opts \\ []),
404    do: Request.request!(:put, client, url, body, headers, opts)
405
406  @doc """
407  Makes a `PATCH` request to the given `url` using the `OAuth2.AccessToken`
408  struct.
409  """
410  @spec patch(t, binary, body, headers, Keyword.t()) ::
411          {:ok, Response.t()} | {:error, Response.t()} | {:error, Error.t()}
412  def patch(%Client{} = client, url, body \\ "", headers \\ [], opts \\ []),
413    do: Request.request(:patch, client, url, body, headers, opts)
414
415  @doc """
416  Same as `patch/5` but returns a `OAuth2.Response` or `OAuth2.Error` exception if
417  the request results in an error.
418
419  An `OAuth2.Error` exception is raised if the request results in an
420  error tuple (`{:error, reason}`).
421  """
422  @spec patch!(t, binary, body, headers, Keyword.t()) :: Response.t() | Error.t()
423  def patch!(%Client{} = client, url, body \\ "", headers \\ [], opts \\ []),
424    do: Request.request!(:patch, client, url, body, headers, opts)
425
426  @doc """
427  Makes a `POST` request to the given URL using the `OAuth2.AccessToken`.
428  """
429  @spec post(t, binary, body, headers, Keyword.t()) ::
430          {:ok, Response.t()} | {:error, Response.t()} | {:error, Error.t()}
431  def post(%Client{} = client, url, body \\ "", headers \\ [], opts \\ []),
432    do: Request.request(:post, client, url, body, headers, opts)
433
434  @doc """
435  Same as `post/5` but returns a `OAuth2.Response` or `OAuth2.Error` exception
436  if the request results in an error.
437
438  An `OAuth2.Error` exception is raised if the request results in an
439  error tuple (`{:error, reason}`).
440  """
441  @spec post!(t, binary, body, headers, Keyword.t()) :: Response.t() | Error.t()
442  def post!(%Client{} = client, url, body \\ "", headers \\ [], opts \\ []),
443    do: Request.request!(:post, client, url, body, headers, opts)
444
445  @doc """
446  Makes a `DELETE` request to the given URL using the `OAuth2.AccessToken`.
447  """
448  @spec delete(t, binary, body, headers, Keyword.t()) ::
449          {:ok, Response.t()} | {:error, Response.t()} | {:error, Error.t()}
450  def delete(%Client{} = client, url, body \\ "", headers \\ [], opts \\ []),
451    do: Request.request(:delete, client, url, body, headers, opts)
452
453  @doc """
454  Same as `delete/5` but returns a `OAuth2.Response` or `OAuth2.Error` exception
455  if the request results in an error.
456
457  An `OAuth2.Error` exception is raised if the request results in an
458  error tuple (`{:error, reason}`).
459  """
460  @spec delete!(t, binary, body, headers, Keyword.t()) :: Response.t() | Error.t()
461  def delete!(%Client{} = client, url, body \\ "", headers \\ [], opts \\ []),
462    do: Request.request!(:delete, client, url, body, headers, opts)
463
464  defp to_url(%Client{token_method: :post} = client, :token_url) do
465    {client, endpoint(client, client.token_url)}
466  end
467
468  defp to_url(client, endpoint) do
469    endpoint = Map.get(client, endpoint)
470    url = endpoint(client, endpoint) <> "?" <> URI.encode_query(client.params)
471    {client, url}
472  end
473
474  defp token_url(client, params, headers) do
475    client
476    |> token_post_header()
477    |> client.strategy.get_token(params, headers)
478    |> to_url(:token_url)
479  end
480
481  defp token_post_header(%Client{token_method: :post} = client),
482    do: put_header(client, "content-type", "application/x-www-form-urlencoded")
483
484  defp token_post_header(%Client{} = client), do: client
485
486  defp endpoint(client, <<"/"::utf8, _::binary>> = endpoint),
487    do: client.site <> endpoint
488
489  defp endpoint(_client, endpoint), do: endpoint
490end
491