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