diff --git a/.gitignore b/.gitignore index 9607671..621a9da 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /deps erl_crash.dump *.ez +.DS_Store diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9989a09 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,14 @@ +# Contributing to Ueberauth Twitter + +## Pull Requests Welcome +1. Fork ueberauth_twitter +2. Create a topic branch +3. Make logically-grouped commits with clear commit messages +4. Push commits to your fork +5. Open a pull request against ueberauth_twitter/master + +## Issues + +If you believe there to be a bug, please provide the maintainers with enough +detail to reproduce or a link to an app exhibiting unexpected behavior. For +help, please start with Stack Overflow. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4f1532b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Sean Callan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 97785cb..3e52734 100644 --- a/README.md +++ b/README.md @@ -2,35 +2,78 @@ > Twitter strategy for Überauth. -### Setup - -Include the provider in your configuration for Überauth: - -```elixir -config :ueberauth, Ueberauth, - providers: [ - twitter: [ { Ueberauth.Strategy.Twitter, [] } ] - ] -``` - -Then configure your provider: - -```elixir -config :ueberauth, Ueberauth.Strategy.Twitter.OAuth, - client_id: System.get_env("TWITTER_API_KEY"), - client_secret: System.get_env("TWITTER_API_SECRET") -``` - -For an example implementation see the [Überauth Example](https://github.com/doomspork/ueberauth_example) application. +_Note_: Sessions are required for this strategy. ## Installation -If [available in Hex](https://hex.pm/docs/publish), the package can be installed as: +1. Setup your application at [Twitter Developers](https://dev.twitter.com/). 1. Add `:ueberauth_twitter` to your list of dependencies in `mix.exs`: ```elixir def deps do - [{:ueberauth_twitter, "~> 0.1.0"}] + [{:ueberauth_twitter, "~> 0.1"}, + {:oauth, github: "tim/erlang-oauth"}] end ``` + +1. Add the strategy to your applications: + + ```elixir + def application do + [applications: [:ueberauth_twitter]] + end + ``` + +1. Add Twitter to your Überauth configuration: + + ```elixir + config :ueberauth, Ueberauth, + providers: [ + twitter: [{Ueberauth.Strategy.Twitter, []}] + ] + ``` + +1. Update your provider configuration: + + ```elixir + config :ueberauth, Ueberauth.Strategy.Twitter.OAuth, + consumer_key: System.get_env("TWITTER_CONSUMER_KEY"), + consumer_secret: System.get_env("TWITTER_CONSUMER_SECRET") + ``` + +1. Include the Überauth plug in your controller: + + ```elixir + defmodule MyApp.AuthController do + use MyApp.Web, :controller + plug Ueberauth + ... + end + ``` + +1. Create the request and callback routes if you haven't already: + + ```elixir + scope "/auth", MyApp do + pipe_through :browser + + get "/:provider", AuthController, :request + get "/:provider/callback", AuthController, :callback + end + ``` + +1. You controller needs to implement callbacks to deal with `Ueberauth.Auth` and `Ueberauth.Failure` responses. + +For an example implementation see the [Überauth Example](https://github.com/ueberauth/ueberauth_example) application. + +## Calling + +Depending on the configured url you can initial the request through: + + /auth/twitter + +## License + +Please see [LICENSE](https://github.com/ueberauth/ueberauth_twitter/blob/master/LICENSE) for licensing details. + diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..d2d855e --- /dev/null +++ b/config/config.exs @@ -0,0 +1 @@ +use Mix.Config diff --git a/lib/ueberauth/strategy/twitter.ex b/lib/ueberauth/strategy/twitter.ex new file mode 100644 index 0000000..643bb77 --- /dev/null +++ b/lib/ueberauth/strategy/twitter.ex @@ -0,0 +1,123 @@ +defmodule Ueberauth.Strategy.Twitter do + @moduledoc """ + Twitter Strategy for Überauth. + """ + + use Ueberauth.Strategy, uid_field: :id_str + + alias Ueberauth.Auth.Info + alias Ueberauth.Auth.Credentials + alias Ueberauth.Auth.Extra + alias Ueberauth.Strategy.Twitter + + @doc """ + Handles initial request for Twitter authentication. + """ + def handle_request!(conn) do + token = Twitter.OAuth.request_token!([], [redirect_uri: callback_url(conn)]) + + conn + |> put_session(:twitter_token, token) + |> redirect!(Twitter.OAuth.authorize_url!(token)) + end + + @doc """ + Handles the callback from Twitter. + """ + def handle_callback!(%Plug.Conn{params: %{"oauth_verifier" => oauth_verifier}} = conn) do + token = get_session(conn, :twitter_token) + case Twitter.OAuth.access_token(token, oauth_verifier) do + {:ok, access_token} -> fetch_user(conn, access_token) + {:error, error} -> set_errors!(conn, [error(error.code, error.reason)]) + end + end + + @doc false + def handle_callback!(conn) do + set_errors!(conn, [error("missing_code", "No code received")]) + end + + @doc false + def handle_cleanup!(conn) do + conn + |> put_private(:twitter_user, nil) + |> put_session(:twitter_token, nil) + end + + @doc """ + Fetches the uid field from the response. + """ + def uid(conn) do + uid_field = + conn + |> option(:uid_field) + |> to_string + + conn.private.twitter_user[uid_field] + end + + @doc """ + Includes the credentials from the twitter response. + """ + def credentials(conn) do + {token, _secret} = get_session(conn, :twitter_token + + %Credentials{token: token} + end + + @doc """ + Fetches the fields to populate the info section of the `Ueberauth.Auth` struct. + """ + def info(conn) do + user = conn.private.twitter_user + + %Info{ + email: user["email"], + image: user["profile_image_url"], + name: user["name"], + nickname: user["screen_name"], + description: user["description"], + urls: %{ + Twitter: "https://twitter.com/#{user["screen_name"]}", + Website: user["url"] + } + } + end + + @doc """ + Stores the raw information (including the token) obtained from the twitter callback. + """ + def extra(conn) do + {token, _secret} = get_session(conn, :twitter_token) + + %Extra{ + raw_info: %{ + token: token, + user: conn.private.twitter_user + } + } + end + + defp fetch_user(conn, token) do + params = [include_entities: false, skip_status: true, include_email: true] + case Twitter.OAuth.get("/1.1/account/verify_credentials.json", params, token) do + {:ok, {{_, 401, _}, _, _}} -> + set_errors!(conn, [error("token", "unauthorized")]) + {:ok, {{_, status_code, _}, _, body}} when status_code in 200..399 -> + body = body |> List.to_string |> Poison.decode! + + conn + |> put_private(:twitter_token, token) + |> put_private(:twitter_user, body) + {:ok, {_, _, body}} -> + body = body |> List.to_string |> Poison.decode! + + error = List.first(body["errors"]) + set_errors!(conn, [error("token", error["message"])]) + end + end + + defp option(conn, key) do + Dict.get(options(conn), key, Dict.get(default_options, key)) + end +end diff --git a/lib/ueberauth/strategy/twitter/oauth.ex b/lib/ueberauth/strategy/twitter/oauth.ex new file mode 100644 index 0000000..a26444a --- /dev/null +++ b/lib/ueberauth/strategy/twitter/oauth.ex @@ -0,0 +1,110 @@ +defmodule Ueberauth.Strategy.Twitter.OAuth do + @moduledoc """ + OAuth1 for Twitter. + + Add `consumer_key` and `consumer_secret` to your configuration: + + config :ueberauth, Ueberauth.Strategy.Twitter.OAuth, + consumer_key: System.get_env("TWITTER_CONSUMER_KEY"), + consumer_secret: System.get_env("TWITTER_CONSUMER_SECRET"), + redirect_uri: System.get_env("TWITTER_REDIRECT_URI") + """ + + @defaults [access_token: "/oauth/access_token", + authorize_url: "/oauth/authorize", + request_token: "/oauth/request_token", + site: "https://api.twitter.com"] + + def access_token({token, token_secret}, verifier, opts \\ []) do + opts + |> client + |> to_url(:access_token) + |> String.to_char_list + |> :oauth.get([oauth_verifier: verifier], consumer(client), token, token_secret) + |> decode_access_response + end + + def access_token!(access_token, verifier, opts \\ []) do + case access_token(access_token, verifier, opts) do + {:ok, token} -> token + {:error, error} -> raise error + end + end + + def authorize_url!({token, _token_secret}, opts \\ []) do + opts + |> client + |> to_url(:authorize_url, %{"oauth_token" => List.to_string(token)}) + end + + def client(opts \\ []) do + config = Application.get_env(:ueberauth, __MODULE__) + + @defaults + |> Keyword.merge(config) + |> Keyword.merge(opts) + |> Enum.into(%{}) + end + + def get(url, access_token), do: get(url, [], access_token) + def get(url, params \\ [], {token, token_secret}) do + client + |> to_url(url) + |> String.to_char_list + |> :oauth.get(params, consumer(client), token, token_secret) + end + + def request_token(params \\ [], opts \\ []) do + client = client(opts) + params = Keyword.put_new(params, :oauth_callback, client.redirect_uri) + + client + |> to_url(:request_token) + |> String.to_char_list + |> :oauth.get(params, consumer(client)) + |> decode_request_response + end + + def request_token!(params \\ [], opts \\ []) do + case request_token(params, opts) do + {:ok, token} -> token + {:error, error} -> raise error + end + end + + defp consumer(client), do: {client.consumer_key, client.consumer_secret, :hmac_sha1} + + defp decode_access_response({:ok, {{_, 200, _}, _, _} = resp}) do + params = :oauth.params_decode(resp) + token = :oauth.token(params) + token_secret = :oauth.token_secret(params) + + {:ok, {token, token_secret}} + end + defp decode_access_response(error), do: {:error, error} + + defp decode_request_response({:ok, {{_, 200, _}, _, _} = resp}) do + params = :oauth.params_decode(resp) + token = :oauth.token(params) + token_secret = :oauth.token_secret(params) + + {:ok, {token, token_secret}} + end + defp decode_request_response(error), do: {:error, error} + + defp endpoint("/" <> _path = endpoint, client), do: client.site <> endpoint + defp endpoint(endpoint, _client), do: endpoint + + defp to_url(client, endpoint, params \\ nil) do + endpoint = + client + |> Map.get(endpoint, endpoint) + |> endpoint(client) + + unless params == nil do + endpoint = endpoint <> "?" <> URI.encode_query(params) + end + + endpoint + end +end diff --git a/lib/ueberauth_twitter.ex b/lib/ueberauth_twitter.ex new file mode 100644 index 0000000..e9b4e11 --- /dev/null +++ b/lib/ueberauth_twitter.ex @@ -0,0 +1,2 @@ +defmodule UeberauthTwitter do +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..5bb6596 --- /dev/null +++ b/mix.exs @@ -0,0 +1,52 @@ +defmodule UeberauthTwitter.Mixfile do + use Mix.Project + + @version "0.1.0" + @url "https://github.com/doomspork/ueberauth_twitter" + + def project do + [app: :ueberauth_twitter, + version: @version, + name: "Ueberauth Twitter Strategy", + package: package, + elixir: "~> 1.1", + build_embedded: Mix.env == :prod, + start_permanent: Mix.env == :prod, + source_url: @url, + homepage_url: @url, + description: description, + deps: deps, + docs: docs] + end + + def application do + [applications: [:logger, :httpoison, :oauth, :ueberauth]] + end + + defp deps do + [{:ueberauth, "~> 0.1"}, + {:oauth, github: "tim/erlang-oauth"}, + {:httpoison, "~> 0.7"}, + {:ex_doc, "~> 0.1", only: :dev}, + {:earmark, ">= 0.0.0", only: :dev}] + end + + defp docs do + [extras: docs_extras, main: "extra-readme"] + end + + defp docs_extras do + ["README.md"] + end + + defp description do + "An Uberauth strategy for Twitter authentication." + end + + defp package do + [files: ["lib", "mix.exs", "README.md", "LICENSE"], + maintainers: ["Sean Callan"], + licenses: ["MIT"], + links: %{"GitHub": @url}] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..c9a7f1e --- /dev/null +++ b/mix.lock @@ -0,0 +1,15 @@ +%{"certifi": {:hex, :certifi, "0.1.1"}, + "earmark": {:hex, :earmark, "0.1.19"}, + "ex_doc": {:hex, :ex_doc, "0.10.0"}, + "hackney": {:hex, :hackney, "1.4.4"}, + "httpoison": {:hex, :httpoison, "0.8.0"}, + "idna": {:hex, :idna, "1.0.2"}, + "mimerl": {:hex, :mimerl, "1.0.0"}, + "mimetype_parser": {:hex, :mimetype_parser, "0.1.0"}, + "oauth": {:git, "https://github.com/tim/erlang-oauth.git", "cd31addc828179983564fd57738f1680a4a4d1ff", []}, + "oauth2": {:hex, :oauth2, "0.5.0"}, + "oauther": {:hex, :oauther, "1.0.2"}, + "plug": {:hex, :plug, "1.0.2"}, + "poison": {:hex, :poison, "1.5.0"}, + "ssl_verify_hostname": {:hex, :ssl_verify_hostname, "1.0.5"}, + "ueberauth": {:hex, :ueberauth, "0.1.0"}} 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_twitter_test.exs b/test/ueber_twitter_test.exs new file mode 100644 index 0000000..3027950 --- /dev/null +++ b/test/ueber_twitter_test.exs @@ -0,0 +1,4 @@ +defmodule UeberauthTwitterTest do + use ExUnit.Case + doctest UeberauthTwitter +end