- The problem comes into focus
- Caturday afternoon accomplishments
- Creating your app in Github: an intermission
- This Tutorial: The Code Parts
- 🎵 Going auth the rails on a crazy train 🎵
- 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 use
s 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 pipeline
s and plug
s.
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.
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
…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 ¯\_(ツ)_/¯
Oof. That was a fun detour. But it was necessary!Creating your app listing in Github (An Intermission)
your_app-dev
.brew install --cask ngrok
(or your preferred local-tunneling daemon)8 and pretend you did this step ages ago!ngrok http --bind-tls true 4000
and copy the ngrok.io
URL from the "Forwarding" row of the dashboard now displaying in your Terminal/auth/github/callback
to the end of it in that fieldngrok.io
URL into the "Webhook URL" fieldmix 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
)
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)!
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
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
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
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)}.
/- 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)}.
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
The important thing to note in this usage is that module attributes are finalized at compile time, meaning the value of 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.
@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"
$ 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"
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
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.UserAuth
next:
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
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.
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
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
# …
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
]
}
]
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:
# config/{dev,staging,prod,releases,runtime}.exs
config :ueberauth, Ueberauth.Strategy.Github.OAuth,
client_id: "add in secrets",
client_secret: "add in secrets"
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!
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
-
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. ↩
-
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.) ↩
-
"Wait, does Guardian know I went to Palm Springs?" ↩
-
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 ↩
-
having worked on everything around user authentication for a good solid few days' worth of work ↩
-
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" ↩
-
"who can say?" en Francais. ↩
-
If you're not a fan of opening a tunnel from the public internet into port
4000
(or4001
) (orwhichever crazy port number you Docker kids use
) of your dev box, the other option you have here is to only test your integrations onstaging
and other remote environments. github.com just plain cannot interpretlocalhost
or127.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. ↩ -
breadth-first code analysis! ↩
-
The
:set_unique_state
plug creates a random token that it safely adds to the encrypted session and will send within therequest
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 thecallback
action, in the:verify_unique_state
plug. In that function, we retrieve the copy of thestate
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 ourcallback
. 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 signaturesdef verify_unique_state(conn, state, state)
anddef 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 ↩ -
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. ↩
-
I hope it's at least a little obvious where these are coming from. ↩
-
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! ↩
-
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?
↩ -
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. ↩
-
This is the longest it has taken in any media, ever, to get to the *record scratch* *freeze frame* flashback. ↩
-
You may have noticed that the
User
schema is using anoauth_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 oneUser
, 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. ↩ -
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. ↩ -
At least... "twisted" in the context of lazy, reductive tropes we endure constantly in our TV shows. ↩
-
it's that I do not understand the kids today and am officially an Old. ↩
-
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 theAccounts
context concerning a user's workflow-relevant data are scoped specifically to just the user's bearer token(s), with the exceptions of the aforementionedrefresh_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. ↩ -
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 😏 ↩ -
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 ofconfig/runtime.exs
. ↩ -
unless you ripped all of the code from this tutorial and renamed a few modules and/or functions; then I made it ↩
-
footnote and
wc
counts are representative of the statistics of this post up to (and excluding) the link to this footnote ↩
Top comments (0)