Phoenix auth in 2024: provider auth

marcin 22nd May 2024 at 8:25am

This is part 2 of Phoenix authentication guide (part 1 is Phoenix auth in 2024: password auth). In part 1 we read the user information from users table, and this time we will use user information stored by an external website, such as Github or Google (a provider).

If you are following example code, check out example_3 tag.

In this scenario, user gives their secret (password) to such external website, which similarly generates a user token, which is then passed (by a HTTP request) to your phoenix app. The token can either contain user information, or be used to fetch it over the API. How this works in details is specified by protocols: OAuth 1.0, OAuth 2.0, OpenID Connect.

We will use the library assent, which handles these protocols for us.

Start off by adding {:assent, "~> 0.2.9"} and configure it in config.exs:

config :assent, http_adapter: {Assent.HTTPAdapter.Finch, supervisor: Auth2024.Finch} 
# supervisor: Auth2024.Finch is the name defined in `application.ex` for Finch component

To setup the secret (token) passing mechanism, usually we need these pieces of data setup on the auth provider. Please consult documentation on their website.

  • client_id, client_secret - credentials authenticating our app to authentication provider
  • callback url - an url in our app, where user token will be passed (usually using a GET request)
  • sometimes other variables, depending on authentication provider.

Lets follow assent documentation example and create a login by Github.

In Github I register my application here.

Fill out the form and use "http://localhost:4000/auth/github/callback" as callback url. Double check if the callback is configured everywhere the same. Very often OAuth failures just result from making a typo in the callback url. Write down client id and secret, and add them to your dev.exs:

config :assent,
  github: [
    client_id: "XXXXXXXXXXXXX",
    client_secret: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
    redirect_uri: "http://localhost:4000/auth/github/callback",
  ]

(Caution: in production you would pass these secret as env vars read in runtime.exs)

The flow is as follows:

  1. Mr Murky opens your app's url, which does not show anything, but redirects to Github. Assent calls this request phase.
  2. The Github link contains query string parameters with your app's identification (signed with cryptography). client_id and client_secret were used for this.
  3. Github asks if you want to authorized the app.
  4. After you approve, the Github issues a user token (in a similar way our controller did in part 1) and redirects you back to callback_url. Assent calls this callback phase.

Now we need to add a module to handle this authentication, similar to UserAuth. Lets call it Auth2024Web.GithubAuth:

defmodule Auth2024Web.GithubAuth do
  import Plug.Conn

  alias Assent.{Config, Strategy.Github}
  alias Auth2024.Accounts.User


  # http://localhost:4000/auth/github
  def request(conn) do
    Application.get_env(:assent, :github)
    |> Github.authorize_url()
    |> IO.inspect(label: "authorize_url")
    |> case do
      {:ok, %{url: url, session_params: session_params}} ->
        # Session params (used for OAuth 2.0 and OIDC strategies) will be
        # retrieved when user returns for the callback phase
        conn = put_session(conn, :session_params, session_params)

        # Redirect end-user to Github to authorize access to their account
        conn
        |> put_resp_header("location", url)
        |> send_resp(302, "")

      {:error, error} ->
        conn
        |> put_resp_content_type("text/plain")
        |> send_resp(500, "Something went wrong generating the request authorization url: #{inspect(error)}")
    end
  end

  # http://localhost:4000/auth/github/callback
  def callback(conn) do
    # End-user will return to the callback URL with params attached to the
    # request. These must be passed on to the strategy. In this example we only
    # expect GET query params, but the provider could also return the user with
    # a POST request where the params is in the POST body.
    %{params: params} = fetch_query_params(conn)

    # The session params (used for OAuth 2.0 and OIDC strategies) stored in the
    # request phase will be used in the callback phase
    session_params = get_session(conn, :session_params)

    Application.get_env(:assent, :github)
    # Session params should be added to the config so the strategy can use them
    |> Config.put(:session_params, session_params)
    |> Github.callback(params)
    |> IO.inspect(label: "callback params")
    |> case do
      {:ok, %{user: user, token: token}} ->
        # Authorization succesful
        IO.inspect({user, token}, label: "user and token")

        conn
        |> put_session(:github_user, user)
        |> put_session(:github_user_token, token)
        |> Phoenix.Controller.redirect(to: "/")

      {:error, error} ->
        # Authorizaiton failed
        IO.inspect(error, label: "error")
        conn
        |> put_resp_content_type("text/plain")
        |> send_resp(500, inspect(error, pretty: true))
    end
  end
end

This module contains one function which passes app secrets to authentication provider (redirecting the browser to pass these secrets), and another that receives the user info and token from auth provider. Lets log them into console and store in the session. On error (which is most probably a misconfiguration error), display it.

The GithubAuth module is not exactly a controller, it does not implement the Phoenix.Controller behaviour, so lets add a simple controller so we can add these functions to our router.

defmodule Auth2024Web.GithubAuthController do
  use Auth2024Web, :controller
  alias Auth2024Web.GithubAuth

  def request(conn, _params) do
    GithubAuth.request(conn)
  end

  def callback(conn, _params) do
    GithubAuth.callback(conn)
  end
end

and now add it to router.ex:

  scope "/auth/github", Auth2024Web do
    pipe_through [:browser]
    get "/", GithubAuthController, :request
    get "/callback", GithubAuthController, :callback
  end

To login via Github, go to http://localhost/auth/github (best add it to top menu in root.html.heex). You should be taken to Github, where you can authorize your app, and then you should be taken back to your app. Look at the console – there should see this information received from Github:

user and token: {%{
   "email" => "github@cahoots.pl",
   "email_verified" => true,
   "name" => "Marcin Koziej",
   "picture" => "https://avatars.githubusercontent.com/u/156725?v=4",
   "preferred_username" => "marcinkoziej",
   "profile" => "https://github.com/marcinkoziej",
   "sub" => 156725
 },
 %{
   "access_token" => "gho_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
   "scope" => "read:user,user:email",
   "token_type" => "bearer"
 }}

First element of the tuple is map with personal information of the logged in user - the thing we wanted to lookup in the auth process. Second element is an access token, which allows us to call Github APIs on behalf of this user.

We cannot yet see a "logged in" page header, nor our live view page at /hi recognises us. This is because we need to put the :current_user into assigns of conn (for requests) and socket (for web socket).

Lets add these two functions to GithubAuth. They re-use existing Accounts.User schema, however, we are not really storing this information in the DB. We just use the User struct because our frontend uses it already, even though it doesn't contain all the fields for user data returned by Github (like full name, profile picture, etc).

  def fetch_github_user(conn, _opts) do
    with user when is_map(user) <- get_session(conn, :github_user) do
      assign(conn, :current_user, %User{email: user["email"]})
    else
      _ -> conn
    end
  end

  def on_mount(:mount_current_user, _params, session, socket) do
    {:cont, mount_current_user(socket, session)}
  end

  defp mount_current_user(socket, session) do
    Phoenix.Component.assign_new(socket, :current_user, fn ->
      if user = session["github_user"] do
        %User{email: user["email"]}
      end
    end)
  end

and enable them by:

  1. adding to router:
   pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {Auth2024Web.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug :fetch_current_user
    plug :fetch_github_user # <-- add this
  end
  1. replacing on_mount in HiWidgetLive:
  on_mount {Auth2024Web.GithubAuth, :mount_current_user}

Why not both? Allowing provider and password authentication

So far we did not use the functionality provided by phx.gen.auth, when authenticating with Github. We just stored the user information in session and (controller, live view) process memory. We did not register the user in app's database.

Lets try to integrate both, so our users can use both password and Github auth. Check out the example_4 tag to see the changes.

First of all, our User schema contains only email and password, but we receive full name, profile pic, username from Github. Some of these we might want to use in our app. In that case they should be added to the User schema, and the registration and settings page need to be updated with these inputs; when users register using email and password (and not Github), they will have to provide this data by themselves. I am leaving this as an exercise to the reader.

Second, we need to integrate GithubAuth with UserAuth. Signing with Github should result in a user being registered, if it does not yet exist. Lets use UserAuth and Accounts functions to achieve this.

First, lets define a function in Accounts:

  def get_user_by_email_or_register(email) when is_binary(email) do
    case Repo.get_by(User, email: email) do
      nil ->
        # user needs some password, lets generate it and not tell them.
        pw = :crypto.strong_rand_bytes(30) |> Base.encode64(padding: false)
        {:ok, user} = register_user(%{email: email, password: pw})
        user
      user -> user
    end
  end

Because we cannot create a User record with empty password, we generate it. We could send the password to the user by email, telling them that thay can also log in like this. We could also not relieve it, requiring the user to reset the password, if they ever wanted to use this authentication mechanism.

Then, lets change the GithubAuth.callback/1 to get/register the user and log them in:

      {:ok, %{user: user, token: token}} ->
        # Authorization succesful
        IO.inspect({user, token}, label: "user and token")

        user_record = Auth2024.Accounts.get_user_by_email_or_register(user["email"]) # <-- add this

        conn
        |> Auth2024Web.UserAuth.log_in_user(user_record)  # <-- and this
        |> put_session(:github_user, user)
        |> put_session(:github_user_token, token)
        |> Phoenix.Controller.redirect(to: "/")

And that's almost it! We now can remove fetch_github_user from router.ex, because fetch_current_user (provided by phx.gen.auth suffices). We should also change the HiWidgetLive to use the previous on_mount call provided by UserAuth:

  on_mount {Auth2024Web.UserAuth, :mount_current_user}