Authentication in Elixir Web Applications with Ueberauth and Guardian: Part 4

In the previous posts in this series we walked through:

In this post we are going to expand upon that base to allow users to create an account or log in to an existing account using OAuth providers such as Google or Twitter.

OAuth providers

A detailed explanation of OAuth is outside the scope of this blog post. If you’re not familiar with OAuth, this video is a good brief introduction. It will be helpful, however, to explore how OAuth works in the context of using it to log in to accounts in our application. In the case of using an email address and password, our application, Yauth, assumed two responsibilities:

  1. establishing the identity of our users by receiving and storing emails and passwords upon account registration; and
  2. verifying the identity of users by challenging them to produce the correct email and password combination on subsequent attempts to log in to an account.

Our application can, however, offload one or both of those responsibilities to OAuth providers with which we can integrate.

Our users may have already established their identity and the credentials needed to prove that identity with OAuth providers like Google, Twitter, Facebook, etc. Yauth can register with those providers as an application that would like to allow users access via the identity and credentials they manage. When we register Yauth with the provider, the provider gives us a client id and client secret and we give them a callback URL to our application. Yauth can use that information to create a link on Yauth pages. When our user clicks that link, they are redirected to the OAuth provider’s login page (i.e. their identity challenge). When the user completes the identity challenge (e.g. providing their user name and password for that provider), that information is submitted to the OAuth provider’s server. The OAuth server then redirects the user to the callback URL that Yauth provided with the result of the identity challenge. When Yauth receives that data, it creates an account and logs in upon success or displays an error message if the challenge failed.

By using OAuth providers, Yauth can accept the identity information for the user from those providers and allow them to manage user authentication. This provides flexibility for our users, some of whom may not want another password to manage, and could free us from managing passwords, password resets, etc. if we wanted to use OAuth exclusively.

Implementing OAuth log in

We will use Google as our example OAuth provider for this post. The first step will be to create a project with Google and set up credentials for an OAuth client. Walking through that process step-by-step will make this post too long but following “Step 1” in these instructions should get you where you need to be to follow along.

Once we have registered with Google as an OAuth client, we need to implement registration and log in to Yauth using Google. In an earlier post, we described Ueberauth and used the Ueberauth Identity strategy to help us with email/password registration and log in. Here we’ll use the Ueberauth Google strategy.

To get started, we need to add the Ueberauth Google Strategy package to our project.

# mix.exs
defmodule Yauth.MixProject do
  # ...
  defp deps do
    [
      # ...
      {:ueberauth, "~> 0.6"},
      {:ueberauth_google, "~> 0.8"},
      # ...
    ]
  end

  # ...
end
mix deps.get

In our previous post we configured Ueberauth for the Identity strategy. Now we need to provide some configuration for the Google strategy.

# config/config.exs
config :ueberauth, Ueberauth,
  providers: [
    google: {Ueberauth.Strategy.Google, []},
    # ...
  ]

config :ueberauth, Ueberauth.Strategy.Google.OAuth,
  client_id: System.get_env("GOOGLE_CLIENT_ID"),
  client_secret: System.get_env("GOOGLE_CLIENT_SECRET")

The first configuration tells ueberauth which providers we intend to use in the application and the module that implements the strategy behavior for that provider. The second configuration provides the client id and secret for the Google strategy we intend to use in the form of environment variables. These values are available from the Google Developer Console where you set up your application. Remember to export those values in your shell before starting your Phoenix server.

Routes

As you may recall from our previous discussion, Ueberauth uses a “two-phase” approach to authentication. The first phase presents an authentication challenge to the user. The second phase handles the data provided in that challenge. To accomplish this with OAuth providers, we need to provide routes by which users access this functionality.

# lib/yauth_web/router.ex
defmodule YauthWeb.Router do
  # ...

  scope "/auth", YauthWeb do
    pipe_through [:browser, :guardian]

    get "/:provider", AuthController, :request
    get "/:provider/callback", AuthController, :callback
  end
end

The Ueberauth package expects routes to be prefixed with a certain path before the OAuth provider name. The default option is “/auth”, which works for our application, but another path could be used by adding the :base_path option in the ueberauth configuration in your config/config.exs file. We accomplish that by placing these routes in the scope "/auth" ... block.

With the routes in place, we need to provide the controller. Here are the module and function signatures for that controller that we’ll walk through together.

# lib/yauth_web/controllers/auth_controller.ex
defmodule YauthWeb.AuthController do
  use YauthWeb, :controller
  plug Ueberauth

  def request(conn, _params) do
    # Present an authentication challenge to the user
  end

  def callback(%{assigns: %{ueberauth_auth: auth_data}} = conn, _params) do
    # Find the account if it exists or create it if it doesn't
  end

  def callback(%{assigns: %{ueberauth_failure: _}} = conn, _params) do
    # Tell the user something went wrong
  end
end

Authentication challenge

The function AuthController.request/2 exists to provide the authentication challenge to the user. As we are not presenting an authentication challenge (i.e. a form for the user to complete) to the user at this route, we don’t actually need to implement or even define this function. Why not? At the top of the controller we call plug Ueberauth. In the event that the request is for a configured OAuth provider, this plug redirects the request to that OAuth provider’s authentication challenge page in place of any function call within the Yauth controller.

In other words, our Google button on our login and registration pages has the route /auth/google that our Router directs to AuthController.request/2. When the user clicks on that button, the request is directed to the AuthController that first passes it through the Ueberauth plug. That plug sees the request is for a configured OAuth provider and redirects the request to the provider’s URL, circumventing a call to the request/2 function. Our final implementation won’t have this function signature but I wanted to explain why it will be absent as it can be puzzling to see it in the Router but missing in the controller. If we wanted, we could also move all of the handling for the identity strategy out of the RegistrationController and SessionController into the AuthController, which would require implementing request/2 to present the identity authentication challenge, but we’ll leave that as is for now.

Authentication verification

The AuthController.callback/2 is the function registered with our OAuth providers and handles the incoming request from the provider after their authentication challenge has been completed. When the user provides the Google credentials at the Google page, Google sends the requested data to the route handled by this function.

The Ueberauth plug enters the picture in this case as well. That plug calls functions provided by the Ueberauth strategy implementation—Ueberauth.Strategy.Google in our case—that extract the expected data from the incoming request. That extracted data is added to the Plug.Conn assigns where our controller can easily access it. As you can see in the two function heads above, this will assign either the key :ueberauth_auth in the event of success or :ueberauth_failure in the event of failure.

We’re now in a position to fill in the implementation of the callback/2 function. In a successful request we receive the email address of the user and want to do two things. First, we want to see if an account exists with that email address already, either through a previous registration via email address and password or a previous OAuth login. If the account exists, we want to log in the account and redirect them to their profile page. Second, if an account does not exist we want to register and log in an account.1

As we’ve done in the past, let’s write the code we wish we had and fill in the details as we go.

# lib/yauth_web/controllers/auth_controller.ex
defmodule YauthWeb.AuthController do
  # ...
  alias Yauth.Accounts
  alias YauthWeb.Authentication

  def callback(%{assigns: %{ueberauth_auth: auth_data}} = conn, _params) do
    case Accounts.get_or_register(auth_data) do
      {:ok, account} ->
        conn
        |> Authentication.log_in(account)
        |> redirect(to: Routes.profile_path(conn, :show))

      {:error, _error_changeset} ->
        conn
        |> put_flash(:error, "Authentication failed.")
        |> redirect(to: Routes.registration_path(conn, :new))
    end
  end

  # ...
end

When we successfully receive data from the OAuth provider (as indicated by the :ueberauth_auth key), we pass that data to the get_or_register/1 function on the Accounts context. The controller expects an :ok tuple with the account (either loaded from the database or newly created) or an :error tuple. If we receive the :ok tuple, we use the log_in/2 function we introduced in our prior post on registering with an email and password. Finally, we redirect the user to their profile page. If we receive the :error tuple, we redirect to the registration page with a message telling the user their authentication failed.

We also need to handle a failure from the OAuth provider (as indicated by the :ueberauth_failure key). We can handle that situation with a second head of the callback/2 function.

# lib/yauth_web/controllers/auth_controller.ex
defmodule YauthWeb.AuthController do
  # ...
  def callback(%{assigns: %{ueberauth_failure: _}} = conn, _params) do
    conn
    |> put_flash(:error, "Authentication failed.")
    |> redirect(to: Routes.registration_path(conn, :new))
  end
end

This is a simple function that adds a flash message and redirects to the registration page.

With those in place we need to add the functions to our Accounts context that are called by our controller.

# lib/yauth/accounts.ex
defmodule Yauth.Accounts do
  # ...
  def get_or_register(%Ueberauth.Auth{info: %{email: email}} = params) do
    if account = get_by_email(email) do
      {:ok, account}
    else
      register(params)
    end
  end
  # ...

This function accepts the Ueberauth struct containing our user’s information from the OAuth provider. We pattern match on that struct to get the email address of the user and pass that to our existing get_by_email/1 function. That function returns either an account or nil. If an account is returned we wrap it in an {:ok, account} tuple. Otherwise we call through to the existing register/1 function with the Ueberauth struct. Our register/1 function, however, will need to handle the Google data as well as the identity data we established in our previous post. Ueberauth adds the provider to its data struct so we can pattern match on that value to handle our different use cases. Update the existing register/1 function to handle the identity provider.

# lib/yauth/accounts.ex
defmodule Yauth.Accounts do
  # ...
  def register(%Ueberauth.Auth{provider: :identity} = params) do
    %Account{}
    |> Account.changeset(extract_account_params(params))
    |> Yauth.Repo.insert()
  end
  # ...
end

Now we can add a function head to handle our OAuth-provided data.

# lib/yauth/accounts.ex
defmodule Yauth.Accounts do
  # ...
  def register(%Ueberauth.Auth{} = params) do
    %Account{}
    |> Account.oauth_changeset(extract_account_params(params))
    |> Yauth.Repo.insert()
  end
  # ...
end

This function head calls out to a new changeset function on the Account module.

# lib/yauth/accounts/account.ex
defmodule Yauth.Accounts.Account do
  # ...
  def oauth_changeset(struct, params) do
    struct
    |> cast(params, [:email])
    |> validate_required([:email])
    |> unique_constraint(:email)
  end
  # ...
end

For OAuth data we don’t manage passwords at all but we still want to require an email address and enforce the uniqueness of that email within our application.

Views

Up to this point we’ve added our Ueberauth dependencies, provided the necessary configuration, added routes for social login, and added the controller to handle those requests. All that remains is for us to update our social sign in links to point to the appropriate routes. Update your template with the following:

<!-- lib/yauth_web/templates/session/social_links.html.eex -->
<!-- ... -->
<div class="social-log-in">
  <p>Or log in with</p>
  <%= link(
    "Google",
    to: Routes.auth_path(@conn, :request, "google"),
    class: "button button-outline"
  ) %>
</div>
<!-- ... -->

At this point our users should be able to register for or log in to an account using their Google credentials.

Recap

In this post we expanded our authentication options by letting users use their OAuth service accounts to authenticate with Yauth. We used Google as our example service. We registered our application with Google, added the Ueberauth Google strategy to our application, and provided the controllers, views, and supporting functions to register and log in users who authenticated with Google. To expand our options to Twitter or GitHub or other OAuth providers, we can easily repeat this process with those services.


[1] As an aside, we should note there are more options for handling existing accounts when integrating with OAuth providers. For the sake of simplicity, we allow users with existing identity accounts to use the Google strategy as well. In other words, if you have a Yauth account with a Gmail address and then later use the Google OAuth option with that same Gmail address, you will access the same Yauth account. However, you could not do the same in the opposite direction. The merits and limitations of this approach should be compared with other options when setting up OAuth for production systems. This post chose the simplest path for illustration; using it here is not an endorsement of it as the best way to handle that situation.