In the previous posts in this series we walked through:
- creating a basic Phoenix application
- providing pages for registration and log in
- adding the ability to register for an account using an email address and password
- adding the ability to log in and out of one’s account
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:
- establishing the identity of our users by receiving and storing emails and passwords upon account registration; and
- 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.