DEV Community

Josh
Josh

Posted on • Updated on

Taming Github OAuth integrations in Phoenix with the help of Ueberauth and Guardian (but then actually no just Ueberauth)

  1. The problem comes into focus
  2. Caturday afternoon accomplishments
  3. Creating your app in Github: an intermission
  4. This Tutorial: The Code Parts
    1. Package dependencies
    2. YourAppWeb.Router
    3. Controllers, Views, and Templates
      1. In which Ueberauth plays gatekeeper in more ways than one
      2. Not done quite yet…
  5. 🎵 Going auth the rails on a crazy train 🎵
    1. UserAuth
      1. .current_user/1
      2. .log_in/2
      3. .log_out/2
      4. .require_signin/2
      5. .fetch_current_user/2
    2. YourAppWeb.Endpoint
    3. config/*.exs
  6. You made it!

You're working on your super-slick Phoenix app, and early on, determine that it wouldn't make any sense for you to build the thing without integrating with GitHub. After all, your app is for developers, and developers do use GitHub, unless they're using GitLab or BitBucket, in which case they still use something, and you're gonna get around to building out those OAuth use cases too, it's just first let's focus on the biggest piece of the pie, because you have no money, and pie sounds way more appetizing than malnourishment right now.

So you go online and check around for phoenix github oauth integration and oh, look, it's a blog post from thoughtbot on that very subject. You remember your interview with thoughtbot in late 2017 fondly, and though your nagging worry that candidly sharing your plans for that weekend when they asked was possibly the reason they passed on making you an offer,1 you hold yourself above grudges, and definitely harbor nothing of the sort against their dumb stupid faces.2

As you read through the post you discover, to your annoyance, that not all of the information you'll need in order to build what you're trying to build is given to you in one solid block of practical information and code examples. You also glean from the words "Part 4" in the title that the practical information you crave might not even be contained in one solid blog post and that you are about to scavenge, ratlike, through no fewer than three (3) other posts in order to have the full working mechanism that is user authentication in Phoenix using Ueberauth + Guardian.

You acquiesce, and trudge through the four-post walkthrough, acquainting yourself with Ueberauth and Guardian along the way. It bugs you that a number of the settings you end up configuring for Guardian seem both esoteric ("I need to implement resource_for_token, which takes a resource and throws away _claims, but... then I retrieve the resource from claims later on? In resource_from_claims? Why is it in the map under the key "sub"?")3, yet somehow redundant ("I have to create YourApp.Authentication.Pipeline module that uses Guardian.Plug.Pipeline, then give it references to the otp_app it should already be part of, a custom ErrorHandler, and module it's already namespaced in?"), but it's the most popular Hex package that promises OAuth 2.0 support, and it even uses those hot new JWTs that're all the rage these days. Once everything's in place, you feel more or less like you've successfully implemented an OAuth registration workflow, with all of the "bells and whistles" of session persistence, logging out, and even logging back in again, all painlessly slotted into your middleware workflow through the magic of Plug's pipelines and plugs.

The problem comes into focus

Until, of course, you try to configure the session's expiration time – which received no (0) attention in any of the four (4) posts you were following along with – and you discover that not only do Guardian's configuration settings resemble the esoteric scratchings of druid runes, Guardian's documentation gives you about as much insight into their proper usage, as you personally have into the proper usage of esoteric druid runes, and the task of implementing the session expiration gets derailed almost as soon as you begin flipping through Guardian's hexdocs pages.

During a brief out-of-body experience, you chuckle about the schadenfreude in which you find yourself currently steeped, and once back in your body you commence spelunking through Guardian's code, hoping in vain that it offers some better insights when how it works is laid out in front of you. But you stop when your subconscious rings a small bell in the middle side of your brain, and the sudden joy of epiphany informs you that parts of your /lib folder now bear a striking resemblance to the code blocks you saw reading through the section of Chris McCord, José Valim, and Bruce Tate's book, Programming Phoenix ≥ 1.4 about building an authentication and authorization pipeline for your app. In fact, you're pretty sure that just a few small adjustments would be all you'd need before you could mercilessly tear Guardian out of your package dependencies completely and still see your code run perfectly fine.4

You get the coffee brewing, put on your Getting Stuff Done music, and hammer out code like a cat in a GIF.

A looping gif of three cats sitting upright at computers, slapping the keys on the keyboard. One of them is wearing pants and a striped short sleeve shirt.

Literally like any/all of these fine feline folx

Caturday afternoon accomplishments

After an hour or two5 6, you lean back at your desk, sip your last bit of coffee, and admire the mature tone of your expertly-crafted commit message, signaling your triumph over user session expiration:

commit 144a2a5ca5104b914c8ffc264b2cef79bd38e781 (HEAD -> github_oauth_workflow)
Author: You <you@you.you>
Date:   Sat Apr 17 15:03:04 2021 +0000

    Suck it, backstabbing Guardian jerk. DELETED!!

    Also session expirationisimplemented or something
Enter fullscreen mode Exit fullscreen mode

…but especially signaling your triumph over Guardian.

One git push, pull request, and rebase-merge later, and your code is there on your repo's main branch, proud and ready to guide users through an absurdly streamlined authorization workflow from start to finish. And all in all, it's not really as complex as that thoughtbot blog had you worried it'd be.

Of course, the prelude to all of this was setting up your app in Github, which you did once for the dev environment, and once again for staging. You'll do it one more time once you've made your first push to prod, but as to when that may come to fruition, qui peut dire? 7 ¯\_(ツ)_/¯

Creating your app listing in Github (An Intermission)
  1. On github.com, go to your "Settings" page
  2. Find the "Developer Settings" link in the sidebar
  3. Click "Github Apps", then "New Github App"
  4. Fill out a good name for your app. Something descriptive, but also very opaque or even random, if you're still operating in stealth mode, and then also denoting (to yourself, mainly) that this is the dev version of the Github listing; something against which you can prototype and test new features with fuzzed data, or data from your own personal projects. Something like… your_app-dev.
  5. Run brew install --cask ngrok (or your preferred local-tunneling daemon)8 and pretend you did this step ages ago!
  6. Now run ngrok http --bind-tls true 4000 and copy the ngrok.io URL from the "Forwarding" row of the dashboard now displaying in your Terminal
  7. Paste that URL into the "Homepage URL" field in github's New App form
  8. Paste it into the "Callback URL" field below that, and add /auth/github/callback to the end of it in that field
  9. Check the box for "Request user authorization (OAuth) during installation"
  10. (This doesn't pertain to OAuth, directly, but will come up for you later while you're building something else, so just) paste your ngrok.io URL into the "Webhook URL" field
  11. run mix phx.gen.secret | tee .scratchpaper | pbcopy in your Terminal. This generates a strong, random 32-character long string, saves it to a file at the base of your project called .scratchpaper, and then immediately pushes it to your clipboard (Linux users, you'll want to use xclip -selection clipboard [which you'll want to install { unless you're not running the X window system, then idk what to tell you ¯\_(ツ)_/¯ } from your package manager] in place of pbcopy)
  12. Paste that secret into the "Webhook Secret" field on Github's New App page
  13. Select the permissions you'll need in order to support your app's features beyond authorization
    1. Make sure you're subscribed to their webhook events
    2. And that you add that webhook_secret to your config/dev.secret.exs or .envrc (If you copied something else to your clipboard before getting to this step, running pbcopy < .scratchpaper will put it back there for you)!

Oof. That was a fun detour. But it was necessary!



This tutorial: The Code Parts

Dependencies

You begin with the dependencies in your mixfile:

# mix.exs

defp deps do
  # ...
  {:ueberauth, "~> 0.6.3"},
  {:ueberauth_github, "~> 0.8.0"}
  # ...
end
Enter fullscreen mode Exit fullscreen mode

Router

Then you define the pipelines and routes you'll use for authentication, and for authorization-restricted access.

# lib/your_app_web/router.ex

import YourAppWeb.UserAuth, only [fetch_current_user: 2, require_signin: 2]

pipeline :browser do
  plug :accepts, ["html"]
  plug :fetch_session
  plug :fetch_live_flash
  plug :protect_from_forgery
  plug :put_secure_browser_headers
  plug :fetch_current_user
end

pipeline :authentication_required do
  plug :require_signin
end

scope "/", YourAppWeb do
  pipe_through :browser

  get "/register", AuthenticationController, :register
  get "/login", AuthenticationController, :login
  get "/logout", AuthenticationController, :logout      # not strictly RESTful, but also won't require your logout link to be a    
  delete "/logout", AuthenticationController, :logout

  scope "/auth" do
    get "/:provider", OAuthController, :request, as: :oauth
    get "/:provider/callback", OAuthController, :callback, as: :oauth
  end

  scope "/", do
    pipe_through :authentication_required

    get "/dashboard", DashboardController, :show
  end
end

# We'll cover this next week
scope "/hook", YourAppWeb.Hook do
  pipe_through :api

  post "/github", GithubController, :received, as: :github_webhook
end
Enter fullscreen mode Exit fullscreen mode

Since you've decided to rely purely on OAuth for your registration and login workflows, instead of a UserController and SessionController for managing your users and logged in sessions, you're making an AuthenticationController for the front-facing register and login pages (as well as the logout functionality) and an OAuthController for handling the backend handoff to retrieve your users' credentials from various OAuth providers, as well as logging them in once everything's copacetic. You've also got an :authentication_required pipeline now, which allows you to softly kick users who aren't logged in, out of the parts of your app that they shouldn't access.

Now, obviously, we need to talk about the YourApp.UserAuth module you're importing the :fetch_current_user and :require_signin plugs from, but there's extra functionality in UserAuth whose usages we haven't seen in the workflow quite yet, and since I want to avoid hopping into and out of each module's code multiple times as I describe how they all fit together, it makes more sense to go through the workflow layer by layer9 instead of drilling down into deeper modules as soon as we encounter the first usage of a novel function.

Controllers, Views, and templates

So with that in mind, AuthenticationController is the next step on our journey!

defmodule YourAppWeb.AuthenticationController do
  use YourAppWeb, :controller
  alias YourAppWeb.UserAuth

  def register(conn, _) do
    render(conn, "register.html")
  end

  def login(conn, _) do
    render(conn, "login.html")
  end

  def logout(conn, _params) do
    conn
     |> UserAuth.log_out
     |> redirect(to: Routes.root_path(conn, :index))
  end
end
Enter fullscreen mode Exit fullscreen mode

The templates for register.html and login.html are almost identical. Of course, as you're using the Slime template engine, the differences are much easier to spot:

/- lib/your_app_web/templates/authentication/register.html.slime

h1 Sign Up

h3 Choose your repo host

.services
  = link to: app_installation_page(), class: "button github" do
      img.icon src=Routes.static_path(@conn, "/images/octoutline.svg") alt="Github Logo"
      ' Connect through Github

p Already registered? #{link "Log in instead", to: Routes.authentication_path(@conn, :login)}.

Enter fullscreen mode Exit fullscreen mode
/- lib/your_app_web/templates/authentication/login.html.slime

h1 Log In

h3 using the host you signed up with

.services
  = link to: Routes.oauth_path(@conn, :request, :github), class: "button github" do
    img.icon src=Routes.static_path(@conn, "/images/octoutline.svg") alt="Github Logo"
    ' Github

p Not a user yet? #{link "Sign Up instead", to: Routes.authentication_path(@conn, :register)}.
Enter fullscreen mode Exit fullscreen mode

Minor differences in copy aside, the thing to pay special attention to is the URL your app uses to send users to Github's site. The login page uses the normal link to: Routes.some_derived_path(...), ... do function that you'll use regularly in your templates to allow users to reach various parts of your app. In this case, it's to the oauth_path for the request action for the provider github, which (you'll soon find) provides you literally no direct insight into how your users get from there to Github's site. In contrast, the register page uses app_installation_page/0, which you've defined as a helper within YourAppWeb.AuthenticationView:

defmodule YourAppWeb.AuthenticationView do
  use YourAppWeb, :view

  @app_installation_page "https://github.com/apps/"
    <> Application.get_env(:your_app, :github)[:app_name]
    <> "/installations/new"

  def app_installation_page, do: @app_installation_page
end
Enter fullscreen mode Exit fullscreen mode

A note on module attributes
This pattern may seem familiar to people coming from a background working with Ruby and Rails, where a declaration like @app_installation_page would signify that you were creating an instance variable. And in a few ways, module attributes can behave somewhat similarly to instance variables... just, in a very "immutable functional programming" kind of way. Also, not at all.

The important thing to note in this usage is that module attributes are finalized at compile time, meaning the value of @app_installation_page will be inlined at its call sites during your app's build phase, and even if the result of Application.get_env(:your_app, :github)[:app_name] changes later on, the return of app_installation_page/0 will stay the value that was set according to your environment config.

# config/dev.exs

config :your_app, :github,
  app_name: "something"

# ---

# config/{runtime,releases}.exs

config :your_app, :github,
  app_name: "or_another"
Enter fullscreen mode Exit fullscreen mode
$ iex -S mix

iex> Application.get_env(:your_app, :github)[:app_name]
"or_another"

iex> YourAppWeb.AuthenticationView.app_installation_page
"https://github.com/apps/something/installations/new"
Enter fullscreen mode Exit fullscreen mode

As indicated by the /installations/new portion of the URL, this link will take new users to Github's "Install Github App for the logged in Github User/Organization" page, which is the one that enables all of those permissions you figured out your app needed, whereas the Routes.oauth_path/3 link before the app is installed will take your new users to Github's "Authorize OAuth App for the logged in Github User". The difference is subtle in appearance, but it determines whether your app has the fine-grained permissions it needs to function properly (Github App), or the side-of-a-barn broad category permissions it needs in order to log the user in, and then... do literally nothing else involving an integration with GitHub (OAuth App).

Speaking of OAuth, that's exactly the Controller that makes up the next part of our journey! And, uh... this is a bigger one.

defmodule YourAppWeb.OAuthController do
  use YourAppWeb, :controller

  alias YourApp.Accounts
  alias Accounts.User
  alias YourAppWeb.UserAuth

  plug :set_unique_state when action == :request
  plug :verify_unique_state when action == :callback
  plug Ueberauth

  def callback(%{assigns: %{ueberauth_auth: auth}} = conn, _params) do
    case Accounts.get_or_create_user_with_credentials(auth) do
      user = %User{} ->
        some_success_path = nil # you'll want to fill this in with
                                # something, likely from `Routes`
        conn
         |> UserAuth.log_in(user)
         |> redirect(to: some_success_path)
      %Ecto.Changeset{} ->
        conn
         |> put_flash(
              :error, 
              "Inserting the user and/or some of its associated #{
              }records failed. Check to make sure the schema #{
              }constraints and incoming data all conform."
            )
         |> redirect(to: Routes.authentication_path(conn, :register))
      nil ->
        conn
         |> put_flash(
              :error, 
              "Something went really wrong."
            )                   # and you'll definitely want to debug this.
         |> redirect(to: Routes.authentication_path(conn, :register))
    end
  end
  def callback(conn, %{"error" => error, "error_description" => message}) do
    require Logger
    Logger.error(%{"error" => error, "message" => message})
    conn
     |> put_flash(:error, "OAuth request failed in transit. This has been logged and will be looked into.")
     |> redirect(to: Routes.authentication_path(conn, :register))
  end

  defp set_unique_state(%{query_params: %{"state" => _}} = conn, _), do: conn
  defp set_unique_state(%{path_params: %{"provider" => provider}} = conn, _) do
    state = :crypto.strong_rand_bytes(24) |> Base.url_encode64()

    conn
     |> put_session("oauth_state", state)
     |> redirect(to: Routes.oauth_path(conn, :request, provider, state: state))
     |> halt
  end

  defp verify_unique_state(conn, _) do
    state = fetch_query_params(conn).query_params["state"]
    unique_state = get_session(conn, "oauth_state")

    conn
     |> delete_session("oauth_state")
     |> verify_unique_state(unique_state, state)
  end
  defp verify_unique_state(conn, state, state), do: conn
  defp verify_unique_state(conn, _expected, _tampered) do
    %{path_info: ["auth", provider | _]} = conn
    conn
     |> put_flash(
          :error, 
          "Something occurred while trying to authenticate with #{provider}. Please try again"
        )
     |> redirect(to: Routes.authentication_path(conn, :register))
     |> halt()
  end
end
Enter fullscreen mode Exit fullscreen mode

The plugs :set_unique_state and :verify_unique_state are safeguards for preventing MitM attacks. MitM stands for Malcolm in the Middle, a sitcom which follows a kid genius growing up in a wholly dysfunctional family, and which aired on the Fox network from January 2000 until May 2006 to both critical and popular acclaim. It also stands for Meddler in the Middle, which is a case of internet attack where someone intercepts the data over your connection, and then either steals the sensitive details therein, sends malicious data back to you, or does both of these. These functions provide a safeguard. Details in this footnote ==> 10

In which Ueberauth plays gatekeeper in more ways than one

There's probably a burning question on your mind: "Where the h*ck is request implemented?" First of all, don't cuss; and second, it turns out that plug Ueberauth handles it. However, as I am neither the author of Ueberauth, nor an eldritch sorcerer, I cannot explain the how, what, or why of any of that plug call's inner workings, try though I have, multiple times. For hours.11

Let's move on; the callback action receives a conn that's already had either an Ueberauth.Auth or Ueberauth.Failure struct added to its assigns under either the :ueberauth_auth or :ueberauth_failure key, respectively12. The Ueberauth.Auth struct contains everything you need in order to create a User and their corresponding authorization tokens within Accounts.get_or_create_user_with_credentials/1.

Explaining how to create Ecto records out of data received from a controller is outside of the scope of this tutorial.13 The way I've structured my app, get_or_create_user_with_credentials/1 persists records to three distinct tables in the database: one for the user, one for their OAuth profile, and one for their OAuth tokens (by default, Github uses a combination of short-lived access tokens and longer-lived [but one-time use] refresh tokens).

The call to Accounts.get_or_create_user_with_credentials/1 occurs in a case clause so that we can delegate what to do based on its success or failure. In this three-split path, receiving a nil value from it means something went wrong that we had no idea could happen, an %Ecto.Changeset{} struct implies that there was an issue trying to persist one or more of the records to the database, and being able to match user = %User{} in the topmost case means we successfully came back out of the function with the user and their credentials all persisted to the database. In the former two cases, we present an error to the user (which should be ourselves until we've worked most of the kinks out of this function). In the latter case, we invoke UserAuth.log_in/2 and then redirect to some path the user would logically be directed to next.

At this point, your user has successfully logged in through OAuth, and has a persistent session. The confetti cannons are overjoyed 🎉

You're not done quite yet though

Of course, those all just compose the outer boundary of YourApp; the parts your users will directly engage for access to the things they're trying to do. That is to say, your user can theoretically go through the complete sign-up/sign-in workflow, but you still have a little more business logic to implement deeper down in YourApp.14

🎵 Going auth the rails phoenix on a crazy cookie train 🎵

Let's look at YourAppWeb.UserAuthnext:

UserAuth

defmodule YourAppWeb.UserAuth do
  import Plug.Conn
  alias Phoenix.Controller
  alias YourAppWeb.Router.Helpers, as: Routes
  alias YourApp.Accounts
  alias Accounts.{User, OauthLogin}

  @doc """
  Retrieves `conn.assigns.current_user`, setting it first if necessary.
  """
  def current_user(%{assigns: %{current_user: user?}}), do: user?
  def current_user(conn), do: conn |> fetch_current_user |> current_user

  @doc """
  Store a user's OAuthLogin.id alongside an expiration time within
  the encrypted session cookie.
  """
  @spec log_in(Plug.Conn.t, %User{oauth_logins: [OauthLogin.t]}) :: Plug.Conn.t
  def log_in(conn, %User{oauth_logins: [login]} = _user) do
    conn
     |> put_session("login_token", login.id)
     |> configure_session(renew: true)
  end
  def log_in(conn, _non_user), do: conn

  @doc """
  Clears the session.
  """
  @spec log_out(Plug.Conn.t) :: Plug.Conn.t
  def log_out(conn), do: configure_session(conn, drop: true)

  @doc """
  Redirects to the login page if `conn.assigns[:current_user]` is `nil`.

  Runs `fetch_current_user` if `:current_user` is unassigned.
  """
  def require_signin(conn, opts \\ [])
  def require_signin(%{assigns: %{current_user: %User{}}} = conn, _),
    do: conn
  def require_signin(%{assigns: %{current_user: nil}} = conn, _),
    do: conn
     |> Controller.put_flash(:error, "You need to be logged in to use that")
     |> Controller.redirect(to: Routes.authentication_path(conn, :login))
     |> halt
  def require_signin(conn, _) when not is_map_key(conn.assigns, :current_user),
    do: conn
     |> fetch_current_user
     |> require_signin

  @doc """
  Loads the user record from the token stored in the session and adds it to the
  `conn` assigns.

  If a user record can't be loaded from the token, say, because
  the token is expired or no corresponding record could be found,
  then the session is dropped and the client will be asked to log in
  again the next time they access a path requiring authorization.

  This plug is memoized; re-running it during a request will
  retrieve the previously-returned value, and not further
  alter the `conn` in any way. To re-run the fetch, delete
  the `:current_user` key from the `conn.assigns` map.
  """
  def fetch_current_user(conn, _opts), do: fetch_current_user(conn)
  def fetch_current_user(%{assigns: %{current_user: _}} = conn), do: conn
  def fetch_current_user(conn) do
    login_token = get_session(conn)["login_token"]
    {conn, user?} = case load_user_from_session_token(login_token) do
      {:ok, user} ->
        {conn, user}
      {:no_session_token, nil} ->
        {conn, nil}
      {:token_refresh_denied, user} ->
        message = "There was a problem refreshing your provider's access token, repos may not display the latest data"
        {Controller.put_flash(conn, :error, message), user}
      {_error, nil} ->
        {log_out(conn), nil}
    end

    assign(conn, :current_user, user?)
  end

  defp load_user_from_session_token(login_id) when is_binary(login_id) do
    with %User{} = user <- Accounts.get_user_with_login(login_id),
         {:ok, user} <- maybe_refresh_user_tokens(user) do
      {:ok, user}
    else
      nil -> {:user_not_found, nil}
      {:token_refresh_denied, _user} = result -> result
    end
  end
  defp load_user_from_session_token(nil), do: {:no_session_token, nil}
  defp load_user_from_session_token(_), do: {:user_not_found, nil}

  @hour 60 * 60
  defp maybe_refresh_user_tokens(%User{oauth_tokens: [bearer_token]} = user) do
    hour_from_now = DateTime.add(DateTime.utc_now(), @hour)
    comparison = DateTime.compare(bearer_token.expires_at, hour_from_now)
    with comp when comp in [:lt, :eq] <- comparison,
         {:ok, tokens} <- Accounts.refresh_bearer_token(bearer_token) do
      {:ok, %{ user | oauth_tokens: tokens }}
    else
      :gt = _comparison -> {:ok, user}
      {_error, _old_token} -> {:token_refresh_denied, user}
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

I've taken care to describe how each public function works within its @doc tag, so enough of this should be straightforward, but a quick rundown of the functions shouldn't hurt anybody.15

.current_user/1

As with most lazy television, we draw in the audience by starting our episode in the middle of the action: UserAuth.current_user will retrieve the already-assigned :current_user from the connection's assigns, or it will invoke fetch_current_user/1 to populate the key and then retrieve its value. But why does it call fetch_current_user/1 at all, and how does fetch_current_user/1 work?

*record scratch* *freeze frame*16 Yeah, that's (a function to retrieve an app's database representation of) me. You're probably wondering, how'd it get into this situation? Well...

*The Who's "Baba O'Riley" seems to begin playing deep within the very fabric of reality.*

.log_in/2

*record scratch* THERE'S NO TIME TO EXPLAIN, we have to flashback to the inciting incident, UserAuth.log_in/2! This function is called with a conn and a YourApp.Accounts.User struct, which the function head pattern-matches on when its oauth_logins relation contains exactly one YourApp.Accounts.OauthLogin struct.17 This login's id will be our session token.18 Finally, log_in/2 is going to configure_session(conn, renew: true) before passing the conn back to the function that called log_in. The :renew option makes Plug generate a new session ID to give back to the client within our server's response, to protect from session fixation attacks.

.log_out/2

log_out/2 is the most straightforward of all the functions in UserAuth. It prevents the session cookie from being sent back to the client at all on this request. The next action the client takes (which, in the case of our AuthenticationController.logout/2 function, is to load the app's root index page), the app will treat them as a visitor until they log back in as a User. And with that exposition out of the way...

.require_signin/2

We still haven't learned anything about how fetch_current_user/1 works, but we've got bigger problems to deal with, because require_signin/2 is creating some serious thematic tension in its presentation of a conflicting philosophy to the one set forward by our established narrative (gasp!)! Like some kind of twisted19 version of current_user/2 from that mirror dimension in Star Trek where everyone has facial hair that makes them hotter but also, evil, require_signin/2 has many similarities to the latter function, but instead of always letting the conn continue through the Plug pipeline, in a very evil way, it doesn't do that necessarily in every circumstance (bigger gasp!)!

THERE'S NO TIME TO EXPLAIN, but let me try anyway. current_user/2 has a "live and let live" approach to things: If it can't find assigns[:current_user], it'll make a call to fetch it, and it's okay with whatever the fetch finds, whether that's an actual %User{} struct, or a nil value. If current_user/2 sees a nil, it's just like "Hey, client, I know how it is. We all feel a little nil from time to time, but it doesn't mean we're bad protocols." Then it lights a joint and listens to Grateful Dead for the rest of the request while the rest of the pipeline is at work. Meanwhile, require_signin/2's philosophy feels closer to the one embraced by the fictional nation setting of the Bleakpunk computer game Papers, Please. But its workflow is only applied to the parts of your app that go through YourAppWeb.Router's :authentication_required pipeline, so rather than being an unwitting stooge of totalitarianism who's just trying to keep his family fed, require_signin/2 is really more like a hyper-vigilant, omnipresent, standing-slightly-too-close-to-you bouncer. It makes sure every client's name is "on the list" every time an action has them come through its pipeline. When a client even so much as refreshes the page they're on, require_signin/2 swings by and double-checks the list to make sure they're still on it, gently heaving them back out to the lobby the instant it can't verify their listworthiness, telling "VIPs only, buddy", before it goes back to powerwalking the perimeter of the club, incessantly checking the IDs of every user either in or approaching the VIP room.

Actually, require_signin/2's not so bad. Imagine if current_user/2 were the bouncer in front of your VIP room; nobody would ever be turned away! Sure, your clients may be a little annoyed if their session ever expires and they have to sign in again, in a circumstance they find "unexpected", but hey, that session expiration is for their benefit. Like I mentioned when we went over configure_session(conn, renew: true); doing what you can to keep attackers from impersonating your users is crucial. If you didn't have safeguards like renewing the session ID and setting expiration times, and an imposter ever managed to get a copy of a user's token, they could impersonate that user for as long as they wanted, and you wouldn't be able to stop them without taking drastic measures, assuming you found out there was an imposter at all. And if I've learned one thing from watching weird TikTok clips of people doing things that are allegedly relevant to the team-based, route-out-imposters video game sensation of 2020, Among Us!20, it's that imposters can outsmart your teammates and destroy your spaceship. And they will. Every. Single. Match.

An animation of astronauts from the computer game _Among Us_. One is walking down a corridor, past a room where he witnesses an imposter space alien, disguised as another astronaut, slip into one of the air vents. Panicked, the astronaut runs back to go hit the emergency meeting button, but the imposter has beaten the astronaut there, and they press the button first. In the meeting, the other astronauts vote their belief that the astronaut is actually the imposter, resulting in them being thrown out of an airlock into space


Imposters wreak havoc on things, both in outer space, and in cyber-space.

That honestly doesn't have much of anything to do with user authentication workflows or stale session attacks. I just thought putting a colorful, funny gif here would break the monotony of this giant wall of text, a bit.

OKAY, now that I've covered current_user/2, log_in/2, log_out/2, require_signin/2, and teased it almost as long as George R.R. Martin teased Daenerys' dragons, we finally arrive at… The moment we flashed back from!

.fetch_current_user/2

*record scratch* THERE'S NO TIME TO EXPLAIN; I need to finish explaining all of this module's functions! fetch_current_user/2 is at the crux of your Phoenix app's session persistence capabilities. It gets the value stored in the session map under the key "login_token" and conditionally operates on conn based on the result of load_user_from_session_token/1. If the result is {:ok, user}, there doesn't have to be any further processing on either the conn or the user in this function, aside from adding the user to conn.assigns. Conversely, with {:no_session_token, nil}, we aren't getting any user to assign to the conn, but we know that since it's because no session token was loaded to begin with, we don't have to log the user out or display any kind of error message about what happened. {:token_refresh_denied, user} means that while the integration didn't allow YourApp to refresh the user's access token and the user will be unable to perform any actions against Github's API, they can still interact with the data cached with YourApp, although YourApp will show a notification that their data may not be in sync with what Github has. Any other sort of response from load_user_from_session_token/1 essentially means that the user couldn't or shouldn't be loaded; right now, fetch_current_user/2 regards those cases as instances where the user should be logged out from the session, but you can change that if your use cases call for different behavior.

Now, a funny thing about teaching: It can help reinforce your memory regarding what you know, but it can also lead you to make new discoveries and learn even more. This tutorial was originally written with a version of load_user_from_session_token/1 that involved verifying that the login_token hadn't expired. This was because neither the series of blog posts, nor the section of Programming Phoenix ≥ 1.4 on session persistence, mentioned any sort of built-in mechanism that the server might use to expire session cookies. Indeed, a random dive into Plug.Session's documentation while writing this tutorial lead me to discover a max_age option, which would have been helpful in the time between writing the comparison logic for both expiring the login token and refreshing the bearer token, and realizing that the API was being called to refresh the bearer token for every action while a user was logged in. Mine is apparently the first Phoenix tutorial to recognize the sublime importance of having your users verify who they are every once in a while. It's fine. The information being helpful to me is why I now pass it on to you 💖

The version of load_user_from_session_token/1 presented here is pretty straightforward. I don't particularly feel like I need to walk you through this one.

maybe_refresh_user_tokens/1 matches on the user struct, like log_in/2, but it's interested in the user's :oauth_tokens as opposed to their :oauth_logins. The single entry here is going to be the bearer token — the token they'll use to interact with Github's API. They also have a refresh token, which Accounts.refresh_bearer_token/1 retrieves and uses to request a new value for the bearer token from the Github integration.21 Of course, it only needs to do this if the access token is going to expire soon. If it is, it calls to refresh the token, updates the User with the new value, and then sends the {:ok, updated_user} envelope back to its caller with the new token on a successful refresh. If the access token isn't expiring soon, it passes the user back to the caller. unchanged, in the {:ok, user} envelope. Any error results in it sending {:token_refresh_denied, user} back, so that the user can still use YourApp's integral features.

Although it's only 110 lines of code, it's easy to marvel at the little behemoth UserAuth is within YourApp's workflow. But even if it was daunting to consider when you began, you've learned that Phoenix and Plug lay down enough of the infrastructure necessary to assemble it relying on the help (or hindrance) of a larger third-party package (especially one that takes a token standard designed to allow web apps to confidently send messages to other web apps and tries to squeeze it into the already-solved problem space of session management).22

We aren't out of the woods quite yet, though. YourApp's endpoint and its config still have a few things to go over.

YourAppWeb.Endpoint

defmodule YourAppWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :your_app
  @endpoint_config Application.get_env(:your_app, YourAppWeb.Endpoint)

  # The session will be stored in the cookie and signed,
  # this means its contents can be read but not tampered with.
  @session_options [
    store: :cookie,
    key: "_your_app_key",
    max_age: @endpoint_config[:session_max_age] || 604800,   
    signing_salt: "f+tfIyAdTnKXiTMc",
    encryption_salt: "4/zKvGZ0eXRikpgT+INdEtLw"
  ]

  socket "/socket", YourAppWeb.UserSocket, websocket: true, longpoll: false

  socket "/live", Phoenix.LiveView.Socket, websocket: [
    connect_info: [session: @session_options]
  ]

  # …

  plug Plug.MethodOverride
  plug Plug.Head
  plug Plug.Session, @session_options
  plug YourAppWeb.Router
end
Enter fullscreen mode Exit fullscreen mode

You might be worried about storing these salts in a file that's checked-in to version control. I'm not a security expert, but the common philosophy in that regard among security experts: Don't panic. It is safe to have a salt be a known value. The important thing to keep from leaking is your secret_key_base. Even if an attacker knows your salts, they still won't be able to forge your signature on a cookie, or decrypt the contents of one that they've gotten hold of without YourApp's secret_key_base. Though, if the paranoia overwhelms you, you can always keep your secret values in your shell environment and then retrieve them in your config files, or put them in files that aren't checked-in to source control.23

# <your_app_root>/.gitignore
# …
/config/secret/

# or possibly
*.secret.* # extension in the middle of a chain
*.secret   # extension at the end of the chain 
# …
Enter fullscreen mode Exit fullscreen mode

config/*.exs

Speaking of config files, that brings us to the last piece of this workflow. You'll need to configure Ueberauth by informing it to use the Github strategy when Github is the OAuth provider like so:

# config/config.exs

config :ueberauth, Ueberauth,
  providers: [
    github: {
      Ueberauth.Strategy.Github, [
        allow_private_emails: true
      ]
    }
  ]
Enter fullscreen mode Exit fullscreen mode

And you'll additionally need to provide Ueberauth's Github strategy with the Client ID Github lists for your app, as well as a Client secret, which you can generate for your app by clicking the button marked "Generate a new client secret" within the header of the Client Secrets section of YourApp's developer settings. It'll look something like this:

An example of a defunct client secret from github.com. The screenshot is annotated with the message "lol the app this key is for is already deleted"


(kids, don't try this at home) (by which I mean, don't post screenshots of your client secrets)

# config/{dev,staging,prod,releases,runtime}.exs

config :ueberauth, Ueberauth.Strategy.Github.OAuth,
  client_id: "add in secrets",
  client_secret: "add in secrets"
Enter fullscreen mode Exit fullscreen mode

And you'll want to keep at least the client_secret a secret, using whichever secret-storing strategy sublimely suits your style.

---

You made it!24

...FWAHHH, hello, End of the Post! 24 footnotes, 578 lines, 7660 words, and 47823 characters!25 But we got through it together, all of the code examples are contiguous, working pieces of code (most of them... at least...), nobody had to dig through multiple other blog posts in order to get all of the parts put together, and even I learned a few things in the process. I'd say that just about the only thing that'd do better to prove this was a successful tutorial, would be for you to buy me a Ko-fi help keep my phone line connected!

No, seriously. If you appreciated/enjoyed/learned something from/shamelessly ripped code wholesale from this tutorial, and you want to keep seeing more tutorials like them, it's infinitely easier for me to write them when I can pay my electric bill, my internet bill, and my rent! Donating to my Ko-Fi or directly to me on Square Cash not only helps me to be able to keep this blog going, it also helps me do things like make good on the payment schedule I told my phone provider I'd adhere to for paying off my overdue fees!

A link to my Ko-Fi account, styled like a button which reads, "Help me keep my phone line!"

Also, let me know down in the comments what aspects of these tutorials I could improve in future posts; better reference materials for languages and frameworks lead to better tools built in those languages and frameworks.

Meanwhile, keep a lookout for the next tutorial in this series: Taming Webhooks, while failing to tame the single mutable component you might ever encounter in all of Elixir


  1. No, I didn't go during Pride, and I have no reason to really suspect this was why I wasn't offered a job. It could've been anything; they probably had more than a dozen strong candidates, and I wasn't among the ones who stood out. I consider myself lucky to have even been considered for a role at thoughtbot, and I hope they're going strong through this pandemic. 

  2. Again, I have the utmost respsect for the people at thoughtbot and the work they do. I was kind of annoyed at the structure of these blog posts, but their commitment to readable, well-documented, and efficient test-driven code is an admirable rarity in software. If you can't guess just yet, I like my jokes how I like my summers: Dry, abrasive, and long enough to make you wonder if we're doing enough as a species to handle our carbon footprint. (We're not.

  3. "Wait, does Guardian know I went to Palm Springs?" 

  4. There are benefits to having middleware like Guardian handle your incoming and outgoing JWT credentials, if you need to support JWTs. If you do, then go with Guardian, and godspeed. If you're only using them for session management, though, you really don't need, and definitely shouldn't use JWTs 

  5. having worked on everything around user authentication for a good solid few days' worth of work 

  6. which was spread over a few months as you went through what you are sure a psychiatrist would classify as "just a harmless bit of mild depression" 

  7. "who can say?" en Francais. 

  8. If you're not a fan of opening a tunnel from the public internet into port 4000 (or 4001) (or whichever crazy port number you Docker kids use) of your dev box, the other option you have here is to only test your integrations on staging and other remote environments. github.com just plain cannot interpret localhost or 127.0.0.1 to mean your local machine. You may be able to configure your router to serve your dev website, if you're especially savvy, but those really are your really limited options for this particular quandary. 

  9. breadth-first code analysis! 

  10. The :set_unique_state plug creates a random token that it safely adds to the encrypted session and will send within the request to the authentication provider — Github's server, in this case — and which the authentication provider will pass back to us so that we can verify it prior to doing the work of the callback action, in the :verify_unique_state plug. In that function, we retrieve the copy of the state we'd stored in the session, and compare it to the value we get back in the provider's query parameters to us. If there's a discrepancy, we redirect the client and display an error to them; if they match, we continue through to our callback. There's zero potential for an attacker to build rainbow tables against these tokens, so we can compare them using pattern-matching in the function signatures def verify_unique_state(conn, state, state) and def verify_unique_state(conn, _expected, _tampered). To wit, we're telling the Erlang VM (and Elixir compiler), "This function expects to be called with either the same value passed in at two different spots, or with two different values (whose actual data we don't care about aside from the fact that they're not identical)." In security critical parts of your app, you will want to use a constant-time comparison technique, like  

  11. If anyone reading this has a better understanding than I do of how Ueberauth conjures this action from the æther, I would love to know. 

  12. I hope it's at least a little obvious where these are coming from. 

  13. I already have my topic planned for the next part of this series, but if there's enough interest in it, I can write about creating Ecto records after that! 

  14. Yes, I'm counting user authorization and session retention as important business logic. Some web apps incorporate neither of these things, yet still provide a revenue stream to their developers. Your app authenticates users and persists a session for them as part of what it helps them do; if that isn't the case, have you read this far into the tutorial strictly for entertainment value?
     

  15. If anyone is left still feeling confused after reading this (entire post), don't hesitate to ask questions in the comments. Clarifying the problem points will only make this a better tutorial, and above everything else (besides keeping a roof over my head. roofs are above literally everything in software), I want this tutorial series to leave as few loose ends as possible. 

  16. This is the longest it has taken in any media, ever, to get to the *record scratch* *freeze frame* flashback. 

  17. You may have noticed that the User schema is using an oauth_logins association, plural, yet our pattern match is expecting exactly one login in that list. This is for two reasons. First, our app anticipates supporting various different OAuth providers, and even with several different OAuth providers, one user will still be one User, which will be an important architecture decision when users want to interact with several different OAuth provider APIs at the same time. Second, although we will eventually have multiple logins per user, right now we only support Github, which only provides one login per user, and so if we found any more than one during any particular query, it'd be a pretty big clue that something wasn't quite right with our database. 

  18. Normally, it's dangerous to keep a user identifier in a cookie, because cookies are sent to the client in every request, and all of the JavaScript on the page can read the cookie string. We can be confident that nobody will be able to steal the user's login ID thanks to the minimal amount of configuration Phoenix and Plug.Session need in order provide strong encryption for our app's session data. 

  19. At least... "twisted" in the context of lazy, reductive tropes we endure constantly in our TV shows. 

  20. it's that I do not understand the kids today and am officially an Old. 

  21. While it feels natural to just load your refresh token as part of fetch_current_user/2, there really isn't a compelling reason to. Its sole purpose is to update the bearer token, which only needs to happen about once every 8 hours, and loading it within such a common part of your app is unnecessary runaround for the database in the best case, and exposing it to an unnecessary attack surface in the worst. That's why all of the queries in the Accounts context concerning a user's workflow-relevant data are scoped specifically to just the user's bearer token(s), with the exceptions of the aforementioned refresh_bearer_token/1 and functions related to removing a user's records when they request for their account to be deleted, or when they revoke access to one of their OAuth providers. 

  22. If you are curious about how JWTs should typically be used, keep an eye out for the next tutorial in this series. We may have built out letting YourApp's users log in through OAuth with their Github profile today, but next is the fun part: Integrating with Github's developer API 😏 

  23. The benefit of going this route is that if you're deploying using Elixir releases, your app won't need those secret files once it's through its build (compile) phase. The values will be baked into YourApp's optimized BEAM bytecode, and you can configure it to never print them to standard out or a log file. Depending on your security hygiene, this can be much safer than setting environment variables on your server and loading them inside of config/runtime.exs

  24. unless you ripped all of the code from this tutorial and renamed a few modules and/or functions; then I made it 

  25. footnote and wc counts are representative of the statistics of this post up to (and excluding) the link to this footnote 

Discussion (0)