commit 941652e0f47829173f62f01d40eedc554b51c364 Author: Evert Prants Date: Mon Jun 1 17:41:37 2020 +0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f210199 --- /dev/null +++ b/.gitignore @@ -0,0 +1,59 @@ + +# Created by https://www.gitignore.io/api/elixir,osx,vim + +### Elixir ### +/_build +/cover +/deps +erl_crash.dump +*.ez +*.beam + +### Elixir Patch ### +/doc +### OSX ### +*.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### Vim ### +# swap +[._]*.s[a-v][a-z] +[._]*.sw[a-p] +[._]s[a-v][a-z] +[._]sw[a-p] +# session +Session.vim +# temporary +.netrwhist +*~ +# auto-generated tag files +tags + +# End of https://www.gitignore.io/api/elixir,osx,vim + + +### VS Code ### +.elixir_ls \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..e237b6f --- /dev/null +++ b/.travis.yml @@ -0,0 +1,22 @@ +language: elixir + +elixir: + - 1.5 + - 1.4 + +otp_release: + - 20.0 + - 19.3 + +env: + global: + - MIX_ENV=test + +notifications: + email: false + +sudo: false + +script: + - mix test + - mix credo diff --git a/README.md b/README.md new file mode 100644 index 0000000..dca31bf --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Überauth IcyNet + +> Icy Network OAuth2 strategy for Überauth. diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..b8368cd --- /dev/null +++ b/config/config.exs @@ -0,0 +1,30 @@ +# This file is responsible for configuring your application +# and its dependencies with the aid of the Mix.Config module. +use Mix.Config + +# 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, +# if you want to provide default values for your application for +# 3rd-party users, it should be done in your "mix.exs" file. + +# You can configure for your application as: +# +# config :ueberauth_icynet, key: :value +# +# And access this configuration in your application as: +# +# Application.get_env(:ueberauth_icynet, :key) +# +# Or configure a 3rd-party app: +# +# config :logger, level: :info +# + +# It is also possible to import configuration files, relative to this +# directory. For example, you can emulate configuration per environment +# 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" diff --git a/lib/ueber_icynet.ex b/lib/ueber_icynet.ex new file mode 100644 index 0000000..af1e5c9 --- /dev/null +++ b/lib/ueber_icynet.ex @@ -0,0 +1,3 @@ +defmodule UeberauthIcyNet do + @moduledoc false +end diff --git a/lib/ueberauth/strategy/icynet.ex b/lib/ueberauth/strategy/icynet.ex new file mode 100644 index 0000000..8e8918b --- /dev/null +++ b/lib/ueberauth/strategy/icynet.ex @@ -0,0 +1,202 @@ +defmodule Ueberauth.Strategy.IcyNet do + @moduledoc """ + Provides an Ueberauth strategy for authenticating with Icy Network. + + ### Setup + + Obtain a Client ID and Secret from Icy Network. + + Include the provider in your configuration for Ueberauth + + config :ueberauth, Ueberauth, + providers: [ + icynet: { Ueberauth.Strategy.IcyNet, [] } + ] + + Then include the configuration for icynet. + + config :ueberauth, Ueberauth.Strategy.IcyNet.OAuth, + client_id: System.get_env("ICYNET_CLIENT_ID"), + client_secret: System.get_env("ICYNET_CLIENT_SECRET") + + If you haven't already, create a pipeline and setup routes for your callback handler + + pipeline :auth do + Ueberauth.plug "/auth" + end + + scope "/auth" do + pipe_through [:browser, :auth] + + get "/:provider/callback", AuthController, :callback + end + + + Create an endpoint for the callback where you will handle the `Ueberauth.Auth` struct + + defmodule MyApp.AuthController do + use MyApp.Web, :controller + + def callback_phase(%{ assigns: %{ ueberauth_failure: fails } } = conn, _params) do + # do things with the failure + end + + def callback_phase(%{ assigns: %{ ueberauth_auth: auth } } = conn, params) do + # do things with the auth + end + end + + You can edit the behaviour of the Strategy by including some options when you register your provider. + + To set the `uid_field` + + config :ueberauth, Ueberauth, + providers: [ + icynet: { Ueberauth.Strategy.IcyNet, [uid_field: :email] } + ] + + Default is `:id` + + To set the default 'scopes' (permissions): + + config :ueberauth, Ueberauth, + providers: [ + icynet: { Ueberauth.Strategy.IcyNet, [default_scope: "email,image"] } + ] + + Default is empty ("") which "Grants read-only access to public information (includes public user profile info, public repository info, and gists)" + """ + use Ueberauth.Strategy, + uid_field: :id, + default_scope: "", + oauth2_module: Ueberauth.Strategy.IcyNet.OAuth + + alias Ueberauth.Auth.Info + alias Ueberauth.Auth.Credentials + alias Ueberauth.Auth.Extra + + @doc """ + Handles the initial redirect to the icynet authentication page. + + To customize the scope (permissions) that are requested by icynet include them as part of your url: + + "/auth/icynet?scope=email,image,privilege" + + You can also include a `state` param that icynet will return to you. + """ + def handle_request!(conn) do + scopes = conn.params["scope"] || option(conn, :default_scope) + send_redirect_uri = Keyword.get(options(conn), :send_redirect_uri, true) + + opts = + if send_redirect_uri do + [redirect_uri: callback_url(conn), scope: scopes] + else + [scope: scopes] + end + + opts = + if conn.params["state"], do: Keyword.put(opts, :state, conn.params["state"]), else: opts + + module = option(conn, :oauth2_module) + redirect!(conn, apply(module, :authorize_url!, [opts])) + end + + @doc """ + Handles the callback from IcyNet. When there is a failure from IcyNet the failure is included in the + `ueberauth_failure` struct. Otherwise the information returned from IcyNet is returned in the `Ueberauth.Auth` struct. + """ + def handle_callback!(%Plug.Conn{params: %{"code" => code}} = conn) do + module = option(conn, :oauth2_module) + token = apply(module, :get_token!, [[code: code]]) + + if token.access_token == nil do + set_errors!(conn, [ + error(token.other_params["error"], token.other_params["error_description"]) + ]) + else + fetch_user(conn, token) + end + end + + @doc false + def handle_callback!(conn) do + set_errors!(conn, [error("missing_code", "No code received")]) + end + + @doc """ + Cleans up the private area of the connection used for passing the raw IcyNet response around during the callback. + """ + def handle_cleanup!(conn) do + conn + |> put_private(:icynet_user, nil) + |> put_private(:icynet_token, nil) + end + + @doc """ + Fetches the uid field from the IcyNet response. This defaults to the option `uid_field` which in-turn defaults to `id` + """ + def uid(conn) do + conn |> option(:uid_field) |> to_string() + end + + @doc """ + Includes the credentials from the IcyNet response. + """ + def credentials(conn) do + token = conn.private.icynet_token + + %Credentials{ + token: token.access_token, + refresh_token: token.refresh_token, + expires_at: token.expires_at, + token_type: token.token_type, + expires: !!token.expires_in + } + end + + @doc """ + Fetches the fields to populate the info section of the `Ueberauth.Auth` struct. + """ + def info(conn) do + user = conn.private.icynet_user + + %Info{ + name: user["username"], + nickname: user["display_name"], + email: user["email"], + image: user["image"], + } + end + + @doc """ + Stores the raw information (including the token) obtained from the IcyNet callback. + """ + def extra(conn) do + %Extra{ + raw_info: %{ + token: conn.private.icynet_token, + user: conn.private.icynet_user + } + } + end + + defp fetch_user(conn, token) do + conn = put_private(conn, :icynet_token, token) + case Ueberauth.Strategy.IcyNet.OAuth.get(token, "/oauth2/user") do + {:ok, %OAuth2.Response{status_code: 401, body: _body}} -> + set_errors!(conn, [error("token", "unauthorized")]) + + {:ok, %OAuth2.Response{status_code: status_code, body: user}} + when status_code in 200..399 -> + put_private(conn, :icynet_user, user) + + {:error, %OAuth2.Error{reason: reason}} -> + set_errors!(conn, [error("OAuth2", reason)]) + end + end + + defp option(conn, key) do + Keyword.get(options(conn), key, Keyword.get(default_options(), key)) + end +end diff --git a/lib/ueberauth/strategy/icynet/oauth.ex b/lib/ueberauth/strategy/icynet/oauth.ex new file mode 100644 index 0000000..5b41cf2 --- /dev/null +++ b/lib/ueberauth/strategy/icynet/oauth.ex @@ -0,0 +1,117 @@ +defmodule Ueberauth.Strategy.IcyNet.OAuth do + @moduledoc """ + An implementation of OAuth2 for icynet. + + To add your `client_id` and `client_secret` include these values in your configuration. + + config :ueberauth, Ueberauth.Strategy.IcyNet.OAuth, + client_id: System.get_env("ICYNET_CLIENT_ID"), + client_secret: System.get_env("ICYNET_CLIENT_SECRET") + """ + use OAuth2.Strategy + + @defaults [ + strategy: __MODULE__, + site: "https://icynet.eu", + authorize_url: "https://icynet.eu/oauth2/authorize", + token_url: "https://icynet.eu/oauth2/token", + token_method: :post + ] + + @doc """ + Construct a client for requests to IcyNet. + + Optionally include any OAuth2 options here to be merged with the defaults. + + Ueberauth.Strategy.IcyNet.OAuth.client(redirect_uri: "http://localhost:4000/auth/icynet/callback") + + This will be setup automatically for you in `Ueberauth.Strategy.IcyNet`. + These options are only useful for usage outside the normal callback phase of Ueberauth. + """ + def client(opts \\ []) do + config = + :ueberauth + |> Application.fetch_env!(Ueberauth.Strategy.IcyNet.OAuth) + |> check_credential(:client_id) + |> check_credential(:client_secret) + + client_opts = + @defaults + |> Keyword.merge(config) + |> Keyword.merge(opts) + + json_library = Ueberauth.json_library() + + OAuth2.Client.new(client_opts) + |> OAuth2.Client.put_serializer("application/json", json_library) + end + + @doc """ + Provides the authorize url for the request phase of Ueberauth. No need to call this usually. + """ + def authorize_url!(params \\ [], opts \\ []) do + opts + |> client + |> OAuth2.Client.authorize_url!(params) + end + + def get(token, url, headers \\ [], opts \\ []) do + [token: token] + |> client + |> put_param("access_token", token) + |> OAuth2.Client.get(url, headers, opts) + end + + def get_token!(params \\ [], options \\ []) do + headers = Keyword.get(options, :headers, []) + options = Keyword.get(options, :options, []) + client_options = Keyword.get(options, :client_options, []) + client = OAuth2.Client.get_token!(client(client_options), params, headers, options) + client.token + end + + # Strategy Callbacks + + def authorize_url(client, params) do + client + |> put_param("response_type", "code") + |> put_param("redirect_uri", client().redirect_uri) + OAuth2.Strategy.AuthCode.authorize_url(client, params) + end + + def get_token(client, params, headers) do + client + |> put_param("client_id", client.client_id) + |> put_param("client_secret", client.client_secret) + |> put_param("grant_type", "authorization_code") + |> put_param("redirect_uri", client().redirect_uri) + |> put_header("Accept", "application/json") + |> OAuth2.Strategy.AuthCode.get_token(params, headers) + end + + defp check_credential(config, key) do + check_config_key_exists(config, key) + + case Keyword.get(config, key) do + value when is_binary(value) -> + config + {:system, env_key} -> + case System.get_env(env_key) do + nil -> + raise "#{inspect (env_key)} missing from environment, expected in config :ueberauth, Ueberauth.Strategy.IcyNet" + value -> + Keyword.put(config, key, value) + end + end + end + + defp check_config_key_exists(config, key) when is_list(config) do + unless Keyword.has_key?(config, key) do + raise "#{inspect (key)} missing from config :ueberauth, Ueberauth.Strategy.IcyNet" + end + config + end + defp check_config_key_exists(_, _) do + raise "Config :ueberauth, Ueberauth.Strategy.IcyNet is not a keyword list, as expected" + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..57d078e --- /dev/null +++ b/mix.exs @@ -0,0 +1,52 @@ +defmodule Ueberauth.IcyNet.Mixfile do + use Mix.Project + + @version "0.0.1" + + def project do + [app: :ueberauth_icynet, + version: @version, + name: "Ueberauth IcyNet", + package: package(), + elixir: "~> 1.3", + build_embedded: Mix.env == :prod, + start_permanent: Mix.env == :prod, + source_url: "https://gitlab.icynet.eu/IcyNetwork/ueberauth_icynet", + homepage_url: "https://gitlab.icynet.eu/IcyNetwork/ueberauth_icynet", + description: description(), + deps: deps(), + docs: docs()] + end + + def application do + [applications: [:logger, :ueberauth, :oauth2]] + end + + defp deps do + [ + {:oauth2, "~> 1.0 or ~> 2.0"}, + {:ueberauth, "~> 0.6.0"}, + + # dev/test only dependencies + {:credo, "~> 0.8", only: [:dev, :test]}, + + # docs dependencies + {:ex_doc, ">= 0.0.0", only: :dev} + ] + end + + defp docs do + [extras: ["README.md"]] + end + + defp description do + "An Ueberauth strategy for using IcyNet to authenticate your users." + end + + defp package do + [files: ["lib", "mix.exs", "README.md"], + maintainers: ["Evert Prants"], + licenses: ["CC0"], + links: %{"Gitlab": "https://gitlab.icynet.eu/IcyNetwork/ueberauth_icynet"}] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..374d2cd --- /dev/null +++ b/mix.lock @@ -0,0 +1,23 @@ +%{ + "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, + "certifi": {:hex, :certifi, "2.5.2", "b7cfeae9d2ed395695dd8201c57a2d019c0c43ecaf8b8bcb9320b40d6662f340", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "3b3b5f36493004ac3455966991eaf6e768ce9884693d9968055aeeeb1e575040"}, + "credo": {:hex, :credo, "0.10.2", "03ad3a1eff79a16664ed42fc2975b5e5d0ce243d69318060c626c34720a49512", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "539596b6774069260d5938aa73042a2f5157e1c0215aa35f5a53d83889546d14"}, + "earmark": {:hex, :earmark, "1.4.4", "4821b8d05cda507189d51f2caeef370cf1e18ca5d7dfb7d31e9cafe6688106a4", [:mix], [], "hexpm", "1f93aba7340574847c0f609da787f0d79efcab51b044bb6e242cae5aca9d264d"}, + "ex_doc": {:hex, :ex_doc, "0.22.1", "9bb6d51508778193a4ea90fa16eac47f8b67934f33f8271d5e1edec2dc0eee4c", [:mix], [{:earmark, "~> 1.4.0", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "d957de1b75cb9f78d3ee17820733dc4460114d8b1e11f7ee4fd6546e69b1db60"}, + "hackney": {:hex, :hackney, "1.16.0", "5096ac8e823e3a441477b2d187e30dd3fff1a82991a806b2003845ce72ce2d84", [:rebar3], [{:certifi, "2.5.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.1", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.0", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.6", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "3bf0bebbd5d3092a3543b783bf065165fa5d3ad4b899b836810e513064134e18"}, + "idna": {:hex, :idna, "6.0.1", "1d038fb2e7668ce41fbf681d2c45902e52b3cb9e9c77b55334353b222c2ee50c", [:rebar3], [{:unicode_util_compat, "0.5.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a02c8a1c4fd601215bb0b0324c8a6986749f807ce35f25449ec9e69758708122"}, + "jason": {:hex, :jason, "1.2.1", "12b22825e22f468c02eb3e4b9985f3d0cb8dc40b9bd704730efa11abd2708c44", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b659b8571deedf60f79c5a608e15414085fa141344e2716fbd6988a084b5f993"}, + "makeup": {:hex, :makeup, "1.0.2", "0b9f7bfb7a88bed961341b359bc2cc1b233517af891ba4890ec5a580ffe738b4", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "43833299231c6a6983afc75a34e43eeba638521d5527ff89809fa6372424fd7e"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.14.1", "4f0e96847c63c17841d42c08107405a005a2680eb9c7ccadfd757bd31dabccfb", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f2438b1a80eaec9ede832b5c41cd4f373b38fd7aa33e3b22d9db79e640cbde11"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, + "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm", "6cbe761d6a0ca5a31a0931bf4c63204bceb64538e664a8ecf784a9a6f3b875f1"}, + "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, + "nimble_parsec": {:hex, :nimble_parsec, "0.6.0", "32111b3bf39137144abd7ba1cce0914533b2d16ef35e8abc5ec8be6122944263", [:mix], [], "hexpm", "27eac315a94909d4dc68bc07a4a83e06c8379237c5ea528a9acff4ca1c873c52"}, + "oauth2": {:hex, :oauth2, "2.0.0", "338382079fe16c514420fa218b0903f8ad2d4bfc0ad0c9f988867dfa246731b0", [:mix], [{:hackney, "~> 1.13", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "881b8364ac7385f9fddc7949379cbe3f7081da37233a1aa7aab844670a91e7e7"}, + "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, + "plug": {:hex, :plug, "1.10.1", "c56a6d9da7042d581159bcbaef873ba9d87f15dce85420b0d287bca19f40f9bd", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "b5cd52259817eb8a31f2454912ba1cff4990bca7811918878091cb2ab9e52cb8"}, + "plug_crypto": {:hex, :plug_crypto, "1.1.2", "bdd187572cc26dbd95b87136290425f2b580a116d3fb1f564216918c9730d227", [:mix], [], "hexpm", "6b8b608f895b6ffcfad49c37c7883e8df98ae19c6a28113b02aa1e9c5b22d6b5"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, + "ueberauth": {:hex, :ueberauth, "0.6.3", "d42ace28b870e8072cf30e32e385579c57b9cc96ec74fa1f30f30da9c14f3cc0", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "afc293d8a1140d6591b53e3eaf415ca92842cb1d32fad3c450c6f045f7f91b60"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.5.0", "8516502659002cec19e244ebd90d312183064be95025a319a6c7e89f4bccd65b", [:rebar3], [], "hexpm", "d48d002e15f5cc105a696cf2f1bbb3fc72b4b770a184d8420c8db20da2674b38"}, +} diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start() diff --git a/test/ueber_icynet_test.exs b/test/ueber_icynet_test.exs new file mode 100644 index 0000000..6fecd6c --- /dev/null +++ b/test/ueber_icynet_test.exs @@ -0,0 +1,4 @@ +defmodule UeberauthIcyNetTest do + use ExUnit.Case + doctest UeberauthIcyNet +end